Is OpenJDK a Solution or Platform?
From a business and architectural perspective, __OpenJDK is fundamentally a development and runtime platform (specifically, an open-source implementation of the Java SE Platform and Java Development Kit), rather than a standalone business “solution”.
OpenJDK provides the runtime environment (JRE), compiler (javac), core libraries, and developer tools required to
build and execute Java applications. It is the official open-source reference implementation from which commercial Java
distributions (like Oracle JDK, Eclipse Temurin, Amazon Corretto, and Red Hat Build of OpenJDK) are built
When OpenJDK acts as a “Solution” to Business ProblemsWhile it is technically a platform, businesses adopt and evaluate OpenJDK to solve specific organizational and financial problems:
- Cost Optimization: Enterprises migrate to OpenJDK distributions to eliminate or avoid expensive commercial licensing fees associated with legacy Java runtimes.
- Compliance and Vendor Lock-in: It offers a standards-compliant, open-source ecosystem under the GNU General Public License (GPLv2 with Classpath Exception), giving companies freedom from proprietary vendor restrictions.
- Enterprise Security & Support: Through enterprise-backed distributions (e.g., Red Hat, Azul, Microsoft), businesses procure Long-Term Support (LTS) contracts, turning OpenJDK into a secure, supported enterprise solution for production environments.
Oracle JDK v.s. OpenJDK
While both are Java Development Kits, the most critical differences between them make this post exclusively discuss new features of OpenJDK only, not of Oracle JDK.
Historically, Oracle JDK was seen as the “premium” option, sometimes with a slight performance edge and proprietary features like Java Flight Recorder (JFR) and Java Mission Control (JMC). However, since Java 11, Oracle has open-sourced these features and contributed them back to the OpenJDK project. As a result, the codebases have converged to the point where they are functionally identical for the vast majority of use cases.
A critical point to remember is that Oracle’s builds of the JDK are based on the OpenJDK source code. Think of OpenJDK as the “reference implementation” maintained by a community that includes Oracle engineers, Red Hat, IBM, and others. Oracle then takes that open source codebase, adds its own branding, packaging, and commercial support, and distributes it as Oracle JDK.
Oracle JDK offers professional, 24/7 commercial support and long-term stability with a fixed quarterly update schedule. Enterprise that needs guaranteed, timely patches, and a service level agreement (SLA) would choose Oracle’s paid subscription. OpenJDK, on the other hand, has a community-driven support. While security vulnerabilities and bugs are promptly fixed by the community, there is no official, central support system. However, many vendors (like Red Hat, Azul, and Amazon) offer their own builds of OpenJDK with paid support contracts, providing a flexible alternative to Oracle’s offering at a potentially lower cost.
Hence the content of this post would be centered around the OpenJDK release page while is assumed
What is Java EE
Ever heard of Java EE? How about Java 2EE, J2EE, or now Jakarta EE? Actually, these are all different names for the same thing: a set of enterprise specifications that extend Java SE. In this section, we discuss the evolution of Java EE.
Java EE is a collection of specifications for developing and deploying enterprise applications.
In general, enterprise applications refer to software hosted on servers that provide the applications that support the enterprise.
The specifications (defined by Sun) describe services, application programming interfaces (APIs), and protocols.
The Java EE product provider is typically an application-server, web-server, or database-system vendor who provides classes that implement the interfaces defined in the specifications. These vendors compete on implementations of the Java EE specifications.
When a company requires Java EE experience what are they really asking for is experience using the technologies that make up Java EE. Frequently, a company will only be using a subset of the Java EE technologies.
A reference implementation of Java EE is GlassFish
Evolution of Java EE
In the first version of Java, Java enterprise extensions were simply a part of the core JDK.
Then, as part of Java 2 in 1999, these extensions were broken out of the standard binaries, and J2EE, or Java 2 Platform Enterprise Edition, was born. It would keep that name until 2006.
For Java 5 in 2006, J2EE was renamed to Java EE or Java Platform Enterprise Edition. That name would stick all the way to September 2017, when something major happened.
In September 2017, Oracle decided to give away the rights for Java EE to the Eclipse Foundation (the language is still owned by Oracle).

In Transition
Actually, the Eclipse Foundation legally had to rename Java EE. That’s because Oracle has the rights over the “Java” brand.
So to choose the new name, the community voted and picked: Jakarta EE. In a certain way, it’s still JEE.
This is still an evolving story, though, and the dust hasn’t completely settled. For example, while Oracle open-sourced the source code, they did not open-source all the documentation. There’s still a lot of discussion over this matter because of legal issues that make it tricky to open-source documentation related to, for example, JMS and EJB.
It’s not clear yet if new Eclipse Foundation documentation will be able to refer to the originals.
Also, curiously, the Eclipse Foundation can’t create any new Java packages using the javax namespace, but it can create new classes and subclasses under the existing ones.
The transition also means a new process for adding specifications to Jakarta EE. To understand it better, let’s take a look at what that process was like under Oracle and how it changes under the Eclipse Foundation in the next section.
The Future
Historically, in order for a feature to make it into “EE”, we needed three things:
- a specification,
- a reference implementation, and
- tests
These three things could be provided by anyone in the community, and an Executive Committee would decide when these were ready to add to the language.
To better understand the past process, let’s take a closer look at what JSRs(specification), Glassfish(implementation), and the TCK(test) are and how they embodied new EE features.
The JCP -> EFSP
In the past, the process by which a new EE feature was born was called the Java Community Process (JCP).
Java SE still uses the JCP today. But, since EE has changed its ownership, from Oracle to the Eclipse Foundation, we have a new and separate process for that. It’s the Eclipse Foundation Specification Process (EFSP) and it’s an extension of the Eclipse Development Process.
There are some important differences, though, mostly around “Transparency, Openness, Shared Burden and Vendor Neutrality”. The EFSP organizers, for example, envision collaborative working groups that are vendor-neutral, a certification process that is self-service, and an organization that operates and governs as a meritocracy.
JSRs
In the JCP, the first step to adding a feature to EE was to create a JSR or Java Specification Request. The JSR was a bit like the interface for an EE feature. The JCP Executive Committee reviewed and approved a completed JSR, and then JSR contributors would code it up and make it available to the community.
A good example of this was JSR-339 - or JAX-RS - which was originally proposed in 2011, approved by JCP in 2012 and finally released in 2013.
And while the community could always weigh in while a specification was under discussion, time showed that an
implementation-first approach – like in the case of JSR 310, java.time, and
Joda Time - tended to create more widely-accepted features and APIs.
So, the EFSP reflects this code-first view in its stated goal: “EFSP will be based on hands-on experimenting and coding first, as a way to prove something is worthy of documenting in a specification”.
Glassfish
Then, as part of the JCP, a JSR needed a reference implementation. This is a bit like the class that implements the interface. A reference implementation helps developers of compatible libraries or other organizations that want to create their own implementation of the spec.
For Java EE features, the JCP used Glassfish for its reference implementations.
And while this centralization on Glassfish simplified the discovery process for implementers, that centralization also required more governance and had a tendency to favor one vendor over another.
Hence, the EFSP doesn’t require a reference implementation, but instead only a compatible implementation. Simply put, this subtle change makes so that implementations inside of a central architecture, like Glassfish, won’t be inadvertently preferred by the foundation.
TCK
Finally, the JCP required that EE features be tested through the Technology Compatibility Kit, or TCK.
The TCK was a suite of tests to validate a specific EE JSR. Simply put, in order to comply with Java EE, an application server needs to implement all of its JSRs and pass all the tests on the designated TCK.
Not much changes here. Oracle open-sourced the TCK as well as the EE JSRs. Of course, all future documents and the TCK will be open-source.
Memory
The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine terminates. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread terminates. Those run-time data areas are
- The pc Register
- Java Virtual Machine Stacks
- Heap
- Method Area
- Run-Time Constant Pool
- Native Method Stacks
The pc Register

The _pc (program counter) register is one of the most fundamental of memory areas and operates on a strict per-thread basis.
The Java Virtual Machine can support many threads of execution at once. Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method for that thread. When a new thread is created, it is immediately allocated its own pc register. The primary responsibility of this register is to act as a “bookmark”, keeping track of exactly where the thread currently is in its execution sequence.
- If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed.
- If the method currently being executed by the thread is native (meaning code written in another language like C or C++ that interacts directly with the host operating system), the JVM steps back, i.e. the value of the Java Virtual Machine’s pc register is undefined because the underlying native platform’s program counter takes over the tracking responsibility.
Here is a realistic illustration of how the JVM organizes the pc register across different threads as part of the Run-Time Data Areas.
This diagram demonstrates a few key aspects of the JVM specification:
- Isolation: Thread 1 and Thread 2 are entirely separate containers with their own registers and stacks.
- Tracking Java Code: For Thread 1, the PC Register points to a specific bytecode address (
invokevirtualin thecalculateValuemethod).- Handling Native Code: For Thread 2, which is executing code outside the JVM (a native method), the specifications state that the JVM pc register is undefined
Java Virtual Machine Stacks

Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. JVM Stacks are private to each thread and store frames, which are created with every method invocation and destroyed upon method completion. Each frame is the storage location for local variables (like integers or object references), an operand stack (for expressions), and other data required for method execution. Think of the stack as a record of method calls, and the frames as the internal state of each of those active calls.
Heap
The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.
The heap is created on virtual machine start-up. Heap storage for objects is reclaimed by an automatic storage management system (known as a garbage collector); objects are never explicitly deallocated. The Java Virtual Machine assumes no particular type of automatic storage management system, and the storage management technique may be chosen according to the implementor’s system requirements.
Method Area
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and interface initialization and in instance initialization.
Run-Time Constant Pool
A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a
class file. It contains several kinds of constants, ranging from numeric literals known at compile-time to method and
field references that must be resolved at run-time. The run-time constant pool serves a function similar to that of a
symbol table for a conventional programming language, although it contains a wider range of data than a typical symbol
table.
Native Method Stacks
An implementation of the Java Virtual Machine may use conventional stacks, colloquially called “C stacks,” to support native methods (methods written in a language other than the Java programming language). Native method stacks may also be used by the implementation of an interpreter for the Java Virtual Machine’s instruction set in a language such as C. Java Virtual Machine implementations that cannot load native methods and that do not themselves rely on conventional stacks need not supply native method stacks. If supplied, native method stacks are typically allocated per thread when each thread is created.
JDK 8
Optional
A wise man once said we are not a real Java programmer until we’ve dealt with a null pointer exception. Joking aside, the null reference is the source of many problems because it is often used to denote the absence of a value. Java SE 8 introduces a new class called java.util.Optional that can alleviate some of these problems.
Dangers of Null
Let’s start with an example to see the dangers of null by considering a nested object structure for a Computer, as
illustrated in Figure below

What’s possibly problematic with the following code?
String version = computer.getSoundcard().getUSB().getVersion();This code looks pretty reasonable. However, many computers (for example, the Raspberry Pi) don’t actually ship with a
sound card. So what is the result of getSoundcard()?
A common (bad) practice is to return the null reference to indicate the absence of a sound card. Unfortunately, this
means the call to getUSB() will try to return the USB port of a null reference, which will result in a
NullPointerException at runtime and stop our program from running further. Imagine if our program was running on a
customer’s machine; what would our customer say if the program suddenly failed?
To give some historical context, Tony Hoare - one of the giants of computer science - wrote, “I call it my billion-dollar mistake. It was the invention of the null reference in 1965. I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement.”
What can we do to prevent unintended null pointer exceptions? We can be defensive and add checks to prevent null dereferences, as shown below:
String version = "UNKNOWN";if (computer != null) { Soundcard soundcard = computer.getSoundcard(); if(soundcard != null){ USB usb = soundcard.getUSB(); if(usb != null){ version = usb.getVersion(); } }}However, we can see that the code quickly becomes very ugly due to the nested checks. Unfortunately, we need a lot of boilerplate code to make sure we don’t get a NullPointerException. In addition, it’s just annoying that these checks get in the way of the business logic. In fact, they are decreasing the overall readability of our program.
Furthermore, it is an error-prone process; what if we forget to check that one property could be null? I will argue in this post that using null to represent the absence of a value is a wrong approach. What we need is a better way to model the absence and presence of a value.
To give some context, let’s briefly look at what other programming languages have to offer.
What Alternatives to Null Are There
Languages such as Groovy have a safe navigation operator represented by ”?.” to safely navigate through potential null references. (Note that it is soon to be included in C#, too, and it was proposed for Java SE 7 but didn’t make it into that release.) It works as follows:
String version = computer?.getSoundcard()?.getUSB()?.getVersion()In this case, the variable “version” will be assigned to null if computer is null, or getSoundcard() returns null,
or getUSB() returns null. We don’t need to write complex nested conditions to check for null.
In addition, Groovy also includes the Elvis operator ”?:” (if we look at it sideways, we’ll recognize Elvis’ famous hair), which can be used for simple cases when a default value is needed. In the following, if the expression that uses the safe navigation operator returns null, the default value “UNKNOWN” is returned; otherwise, the available version tag is returned.
String version = computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN"Other functional languages, such as Haskell and Scala, take a different view. Haskell includes a Maybe type, which
essentially encapsulates an optional value. A value of type Maybe can contain either a value of a given type or
nothing. There is no concept of a null reference. Scala has a similar construct called Option[T] to encapsulate
the presence or absence of a value of type T. We then have to explicitly check whether a value is present or not
using operations available on the Option type, which enforces the idea of “null checking.” We can no longer
“forget to do it” because it is enforced by the type system.
Java 8 Optional in a Nutshell
Java SE 8 introduces a new class called java.util.Optional<T> that is inspired from the ideas of Haskell and Scala.
It is a class that encapsulates an optional value, as illustrated in figure below. We can view Optional as a
single-value container that either contains a value or doesn’t (it is then said to be “empty”):

We can update our model to make use of Optional, as shown in Listing 2:
public class Computer {
private Optional<Soundcard> soundcard;
public Optional<Soundcard> getSoundcard() { ... } ...}
public class Soundcard {
private Optional<USB> usb;
public Optional<USB> getUSB() { ... }}
public class USB {
public String getVersion(){ ... }}The code above immediately shows that a computer might or might not have a sound card (the sound card is optional). In addition, a sound card can optionally have a USB port. This is an improvement, because this new model can now reflect clearly whether a given value is allowed to be missing.
But what can we actually do with an Optional<Soundcard> object? After all, we want to get to the USB port’s version
number. In a nutshell, the Optional class includes methods to explicitly deal with the cases where a value is present
or absent. However, the advantage compared to null references is that the Optional class forces us to think about the
case when the value is not present. As a consequence, we can prevent unintended null pointer exceptions.
It is important to note that the intention of the Optional class is not to replace every single null reference.
Instead, its purpose is to help design more-comprehensible APIs so that by just reading the signature of a method, we
can tell whether we can expect an optional value. This forces us to actively unwrap an Optional to deal with the
absence of a value.
Using Optional
The Groovy code above can be written with Optional in Java as
String name = computer.flatMap(Computer::getSoundcard) .flatMap(Soundcard::getUSB) .map(USB::getVersion) .orElse("UNKNOWN");Creating Optional Objects
First, how do we create Optional objects? There are several ways:
Here is an empty Optional:
Optional<Soundcard> sc = Optional.empty();And here is an Optional with a non-null value:
Optional<Soundcard> sc = Optional.of(new Soundcard());In the case above, if soundcard were null, a NullPointerException would be immediately thrown (rather than getting a latent error once we try to access properties of the soundcard)
By using ofNullable, however, we can create an Optional object that may hold a null value:
Optional<Soundcard> sc = Optional.ofNullable(soundcard);If soundcard were null, the resulting Optional object would be empty.
Do Something If a Value Is Present
Now that we have an Optional object, we can access the methods available to explicitly deal with the presence or absence of values. Instead of having to remember to do a null check, as follows:
SoundCard soundcard = ...;if(soundcard != null){ System.out.println(soundcard);}We can use the ifPresent() method, as follows:
Optional<Soundcard> soundcard = ...;soundcard.ifPresent(System.out::println);We no longer need to do an explicit null check; it is enforced by the type system. If the Optional object were empty, nothing would be printed.
We can also use the isPresent() method to find out whether a value is present in an Optional object. In addition, there’s a get() method that returns the value contained in the Optional object, if it is present. Otherwise, it throws a NoSuchElementException. The two methods can be combined, as follows, to prevent exceptions:
if(soundcard.isPresent()){ System.out.println(soundcard.get());}However, this is not the recommended use of Optional because it’s not much of an improvement over nested null checks, and there are more idiomatic alternatives, which we explore below.
Default Values and Actions
A typical pattern is to return a default value if we determine that the result of an operation is null. In general, we can use the ternary operator, as follows, to achieve this:
Soundcard soundcard = maybeSoundcard != null ? maybeSoundcard : new Soundcard("basic_sound_card");Using an Optional object, we can rewrite this code by using the orElse() method, which provides a default value if
Optional is empty:
Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("defaut"));Similarly, we can use the orElseThrow() method, which instead of providing a default value if Optional is empty, throws an exception:
Soundcard soundcard = maybeSoundCard.orElseThrow(IllegalStateException::new);Rejecting Certain Values Using the filter Method
Often we need to call a method on an object and check some property. For example, we might need to check whether the
USB port is a particular version. To do this in a safe way, we first need to check whether the reference pointing to a
USB object is null and then call the getVersion() method, as follows:
USB usb = ...;if(usb != null && "3.0".equals(usb.getVersion())){ System.out.println("ok");}This pattern can be rewritten using the filter method on an Optional object, as follows:
Optional<USB> maybeUSB = ...;maybeUSB.filter(usb -> "3.0".equals(usb.getVersion()).ifPresent(() -> System.out.println("ok"));The filter method takes a predicate as an argument. If a value is present in the Optional object and it matches the
predicate, the filter method returns that value; otherwise, it returns an empty Optional object.
Extracting and Transforming Values Using the map Method
Another common pattern is to extract information from an object. For example, from a Soundcard object, we might want
to extract the USB object and then further check whether it is of the correct version. We would typically write the
following code:
if(soundcard != null){ USB usb = soundcard.getUSB(); if(usb != null && "3.0".equals(usb.getVersion()){ System.out.println("ok"); }}We can rewrite this pattern of “checking for null and extracting” (here, the Soundcard object) using the map method.
Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);There’s a direct parallel to the map method used with streams. There, we pass a function to the map method, which
applies this function to each element of a stream. However, nothing happens if the stream is empty. The map method of
the Optional class does exactly the same: the value contained inside Optional is “transformed” by the function
passed as an argument (here, a method reference to extract the USB port), while nothing happens if Optional is empty.
Finally, we can combine the map method with the filter method to reject a USB port whose version is different than
3.0:
maybeSoundcard.map(Soundcard::getUSB) .filter(usb -> "3.0".equals(usb.getVersion()) .ifPresent(() -> System.out.println("ok"));Cascading Optional Objects Using the flatMap Method
We’ve seen a few patterns that can be refactored to use Optional. So how can we write the following code in a safe way?
String version = computer.getSoundcard().getUSB().getVersion();Notice that all this code does is extract one object from another one, which is exactly what the map method is for.
Earlier in the article, we changed our model so a Computer has an Optional<Soundcard> and a Soundcard has an
Optional<USB>, so we should be able to write the following:
String version = computer.map(Computer::getSoundcard) .map(Soundcard::getUSB) .map(USB::getVersion) .orElse("UNKNOWN");Unfortunately, this code doesn’t compile. Why? The variable computer is of type Optional<Computer>, so it is
perfectly correct to call the map method. However, getSoundcard() returns an object of type Optional<Soundcard>.
This means the result of the map operation is an object of type Optional<Optional<Soundcard>>. As a result, the
call to getUSB() is invalid because the outermost Optional contains as its value another Optional, which of course
doesn’t support the getUSB() method
So how can we solve this problem? Again, we can look at a pattern we might have used previously with streams: the
flatMap method. With streams, the flatMap method takes a function as an argument, which returns another stream.
This function is applied to each element of a stream, which would result in a stream of streams. However, flatMap has
the effect of replacing each generated stream by the contents of that stream. In other words, all the separate streams
that are generated by the function get amalgamated or “flattened” into one single stream. What we want here is
something similar, but we want to “flatten” a two-level Optional into one.
Well, here’s good news: Optional also supports a flatMap method. Its purpose is to apply the transformation
function on the value of an Optional and then flatten the resulting two-level Optional into a single one.
So, to make our code correct, we need to rewrite it as follows using flatMap:
String version = computer.flatMap(Computer::getSoundcard) .flatMap(Soundcard::getUSB) .map(USB::getVersion) .orElse("UNKNOWN");The first flatMap ensures that an Optional<Soundcard> is returned instead of an Optional<Optional<Soundcard>>, and
the second flatMap achieves the same purpose to return an Optional<USB>. Note that the third call just needs to be a
map() because getVersion() returns a String rather than an Optional object.
Stream
Combining a Collection of Predicates
We chain a collection of Predicates by reducing them. In the following example, we have a list of predicates that we
combined using Predicate.and():
@Testpublic void whenFilterListWithCollectionOfPredicatesUsingAnd_thenSuccess(){ List<Predicate<String>> allPredicates = new ArrayList<Predicate<String>>(); allPredicates.add(str -> str.startsWith("A")); allPredicates.add(str -> str.contains("d")); allPredicates.add(str -> str.length() > 4);
List<String> result = names.stream() .filter(allPredicates.stream().reduce(x->true, Predicate::and)) .collect(Collectors.toList());
assertEquals(1, result.size()); assertThat(result, contains("Alexander"));}Note that we use our base identity as:
x->trueThat will be, however, different if we want to combine them using Predicate.or():
@Testpublic void whenFilterListWithCollectionOfPredicatesUsingOr_thenSuccess(){ List<String> result = names.stream() .filter(allPredicates.stream().reduce(x->false, Predicate::or)) .collect(Collectors.toList());
assertEquals(2, result.size()); assertThat(result, contains("Adam","Alexander"));}Collecting Stream containing “null” to List
person.stream() .sorted(Comparator.comparingInt(Person::getDriverLicense)) .collect(Collectors.toList());If this stream contains a kid who doesn’t know how to drive, the Collectors.toList() might see a null value, which
causes NullPointerException, because Collectors.toList() gives us a list implementation that doesn’t permit null
elements. ArrayList, however, permits null elements according to its JavaDoc. The solution them would be
person.stream() .sorted(Comparator.comparingInt(Person::getDriverLicense)) .collect(Collectors.toCollection(ArrayList::new));Note that the last line is changed so that the collect procedure returns an uer-specified list implementation, which is
ArrayList
Sorting
public class User {
private final String name; private final int age;}
final List<User> users = Arrays.asList( new User("C", 30), new User("D", 40), new User("A", 10), new User("B", 20), new User("E", 50));
List<User> sortedList = users.stream() .sorted(Comparator.comparingInt(User::getAge)) .collect(Collectors.toList());Remove Duplicates from a List of Objects based on Property
You can get a stream from the List and put in in the TreeSet from which you provide a custom comparator that compares the property uniquely. Then if you really need a list you can put then back this collection into an ArrayList:
import static java.util.Comparator.comparingInt;import static java.util.stream.Collectors.collectingAndThen;import static java.util.stream.Collectors.toCollection;
...List<Employee> unique = employee.stream() .collect( collectingAndThen( toCollection(() -> new TreeSet<>(comparingInt(Employee::getId))), ArrayList::new ) );Given the example:
List<Employee> employee = Arrays.asList(new Employee(1, "John"), new Employee(1, "Bob"), new Employee(2, "Alice"));It will output:
[Employee{id=1, name='John'}, Employee{id=2, name='Alice'}]Convert Iterable to Stream
StreamSupport.stream(iterable.spliterator(), false) .filter(...) .moreStreamOps(...);Convert Two Dimensional Array to List
List<Foo> collection = Arrays.stream(array) //'array' is two-dimensional .flatMap(Arrays::stream) .collect(Collectors.toList());Preserve Order in Stream with collect
Say we would like to process a list such as [“blah”, “blah”, “yep”] and get [“blah (2 times)”, “yep”], we will collect
them to a LinkedHashMap to get the expected result:
return messages.stream() .collect(groupingBy(Function.identity(), LinkedHashMap::new, summingInt(e -> 1))) .entrySet() .stream() .map(e -> e.getKey()+(e.getValue() == 1 ? "" : " (" + e.getValue() +" times)")) .collect(toList());JDK 9
Serialization Filter Configuration
“Allow incoming streams of object-serialization data to be filtered in order to improve both security and robustness.”
JEP 290 introduced serialization filtering to Java, allowing control over which classes can be deserialized from an
ObjectInputStream. As an example, suppose we have 2 serializable classes: AllowedClass and RestrictedClass:
package com.example.allowed;
import java.io.Serial;import java.io.Serializable;
public class AllowedClass implements Serializable { @Serial private static final long serialVersionUID = 1L; public String message;
public AllowedClass(String message) { this.message = message; }
@Override public String toString() { return "AllowedClass: " + message; }}package com.example.restricted;
import java.io.Serial;import java.io.Serializable;
public class RestrictedClass implements Serializable { @Serial private static final long serialVersionUID = 1L; public String secret;
public RestrictedClass(String secret) { this.secret = secret; }
@Override public String toString() { return "RestrictedClass: " + secret; }}To make sure AllowedClass can be deserialized while RestrictedClass cannot, we can use either
pattern-based filters defined via system properties or security
properties, or programmatically using the ObjectInputFilter API.
Pattern-Based Filter (System Property Example)
This example demonstrates how to set a global filter using the jdk.serialFilter system property to allow only classes
within com.example.allowed and reject all others.
If the JDK system property has been set with -Djdk.serialFilter="com.example.allowed.*;!*", the following runtime
should execute successfully without error for AllowedClass and throw exception for RestrictedClass:
import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.io.Serializable;
public static void serialize(Serializable obj, Class<? extends Serializable> clazz) { try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(obj);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bis)) { Serializable result = clazz.cast(ois.readObject()); } } catch (IOException | ClassNotFoundException exception) { throw new IllegalStateException(exception); }}
AllowedClass allowed = new AllowedClass("Hello Allowed!");RestrictedClass restricted = new RestrictedClass("Secret Data!");
serialize(allowed, AllowedClass.class); // ✅serialize(restricted, RestrictedClass.class); // ❌ runtime errorProgrammatic Filter (ObjectInputFilter API)
This example shows how to set a filter directly on an ObjectInputStream using the ObjectInputFilter interface.
import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.io.ObjectInputFilter;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.io.Serializable;
public static void serialize(Serializable obj, Class<? extends Serializable> clazz) { try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(obj);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bis)) {
ois.setObjectInputFilter(info -> { if (info.serialClass() != null && info.serialClass().getName().startsWith("com.example.allowed")) { return ObjectInputFilter.Status.ALLOWED; } return ObjectInputFilter.Status.REJECTED; });
Serializable result = clazz.cast(ois.readObject()); } } catch (IOException | ClassNotFoundException exception) { throw new IllegalStateException(exception); }}
AllowedClass allowed = new AllowedClass("Hello Allowed!");RestrictedClass restricted = new RestrictedClass("Secret Data!");
serialize(allowed, AllowedClass.class); // ✅serialize(restricted, RestrictedClass.class); // ❌ runtime errorIn this example, the setObjectInputFilter method is used on the ObjectInputStream to apply a lambda expression as
the filter. This filter explicitly allows classes starting with com.example.allowed and rejects all others.
Compact Strings
“Adopt a more space-efficient internal representation for strings.”
Before compact strings, Java’s String class stored characters in a char[] array, where each char is 2 bytes
(UTF-16 encoding). This meant that even strings containing only ASCII characters (which could be represented by 1 byte)
would still consume 2 bytes per character.
With JEP 254, the String implementation was changed to use either the old storage
mechanism or a byte[] array plus an encoding flag. If the string contains only Latin-1 characters (characters with
code points from 0 to 255, fitting within 1 byte), it will be stored as a byte[] array, effectively using 1 byte per
character. If it contains characters outside of Latin-1 (requiring more than 1 byte), it will revert to the
2-byte-per-character UTF-16 representation.
The compact string optimization is transparent to the developer. We continue to use the String class as before. The
JVM automatically decides the most memory-efficient internal representation based on the characters present in the
string. This significantly reduces the memory footprint of applications that deal extensively with ASCII or Latin-1
based strings, which is a very common scenario.
Support Bounding the Size of Buffers Cached in the Per-Thread Buffer Caches
JDK-8147468 is a specific enhancement that was included in OpenJDK 9 to address a potential memory management issue in the Java NIO (Non-blocking I/O) package. It allows developers to set a limit on the size of temporary buffers, preventing a form of memory leak.
Java’s NIO framework uses DirectByteBuffers for certain I/O operations. These are buffers allocated
outside the Java heap, in “native memory,” to provide better performance by avoiding an extra copy of data. To optimize
performance and reduce the overhead of constant allocation and deallocation, the Java Virtual Machine (JVM) caches these
temporary DirectByteBuffers in a per-thread cache.
The core issue that JDK-8147468 addresses is that in older JDK versions
(before 8u102 and JDK 9), this cache had no size limit. If an application performed a large, but infrequent, NIO
operation that required a very big buffer, this large buffer would then be stored in the thread-local cache. Over time,
these caches could accumulate a number of large buffers, leading to excessive native memory consumption, which could
ultimately result in a java.lang.OutOfMemoryError even if the Java heap itself had plenty of space. It’s a bit like a
person who, while trying to be efficient, ends up hoarding very large, but rarely-used, items in their pockets.
Eventually, their pockets get so full and heavy that they can’t move freely.
To solve this, JDK-8147468 introduced a new system property:
jdk.nio.maxCachedBufferSize. By setting this property, we can specify a maximum size for any buffer that can be held
in the per-thread cache. For example, if we set jdk.nio.maxCachedBufferSize=1048576 (1 MB), any NIO operation that
requires a buffer larger than 1 MB will not store that buffer in the cache. Instead, the JVM will simply allocate a
new, one-off buffer for the operation and then free it immediately afterward. This prevents the native memory cache
from growing indefinitely and mitigates the risk of an OutOfMemoryError due to a native memory leak. This change gives
developers greater control over their application’s native memory footprint.
Why does NIO matter?
Java NIO provides 2 main types of ByteBuffer: heap buffers and direct buffers. How they are managed by the garbage
collector is fundamentally different.
-
Heap Buffers (
ByteBuffer.allocate)This buffer is essentially a wrapper around a standard Java
byte[]array. Both the buffer object and the underlying byte array reside on the JVM heapBecause it lives on the heap, a heap buffer is fully managed by the garbage collector. When the
ByteBufferobject is no longer referenced by our application, it becomes eligible for garbage collection, and the GC will reclaim its memory just like any other Java object. This process is automatic and predictable within the normal workings of the GC. -
Direct Buffers (
ByteBuffer.allocateDirect)Direct buffers are designed for high-performance I/O and behave very differently. A direct buffer allocates a block of memory outside the normal garbage-collected heap. This is often called “off-heap” or “native” memory. The JVM will try to perform native I/O operations directly on this memory, avoiding the overhead of copying data between the JVM heap and the native I/O layer.
It gets tricky when it comes to garbage collection. The native memory block itself is not directly managed by the GC. However, the
DirectByteBufferobject, which is a small Java object that acts as a reference to this native memory, does live on the JVM heap and is garbage collected. The JVM uses a special mechanism to free the native memory. When theDirectByteBufferobject on the heap becomes unreachable, the garbage collector will eventually process it. This action triggers a cleanup process (using an object called aCleanerin modern Java versions) that deallocates the corresponding block of off-heap, native memory.This indirect management can cause problems. If our application creates many short-lived direct buffers, we might exhaust our available native memory and get an
OutOfMemoryError: Direct buffer memory. This can happen even if our JVM heap has plenty of free space, because the garbage collector may not run frequently enough to clean up theDirectByteBufferobjects that would, in turn, free the native memory. For this reason, direct buffers are best suited for large, long-lived buffers where their performance benefits are most significant.
Understand such nuanced intricacies requires systematic study of the memory structure and allocation of JVM. For example, we need a firm grip in our mind on what’s actually happening in the “overhead of copying data between the JVM heap and the native I/O layer”. In addition, as a serious Java developer, Understanding the JVM’s internal memory management is the key to writing truly high-performance, resilient applications and diagnosing complex production issues. While there are many resources, one book is consistently recommended as the definitive modern guide, which is highly recommended, Java Performance: The Definitive Guide by Scott Oaks:

This book is perfectly suited for a developer who wants a systematic and practical understanding of the JVM. It discusses garbage collection theory, the specifics of different GC algorithms (like G1, ZGC, and Shenandoah), and the tools (like profilers and command-line utilities) we need to observe the JVM in action. It provides a clear path from theory to practice.
This book, however, doesn’t talk too much about JVM memory structure. For example, it just mentions “heap memory” without defining or describing what it is. Those who need another “definitive” walkthrough on JVM internal structure could pick up Inside the Java Virtual Machine by Bill Venners.

While performance guides tell us how to make it run faster, Venners’ book explains what it is and why it is designed that way.
CAUTIONThis book is a classic, with the 2nd edition published in 1999 for Java 2. This means the conceptual explanations of the architecture are timeless and unparalleled, but the implementation details are outdated. We won’t find information on modern garbage collectors like G1/ZGC, the JIT compiler, or the transition from PermGen to Metaspace.
The best approach is to use this book to build our fundamental understanding of the JVM’s blueprint and then supplement it with modern resources for the specifics of the HotSpot JVM, such as Java Virtual Machine Specification, the blueprint from which all JVMs are built. It’s not a tutorial and can be a dense, formal read, but it is the ultimate source of truth.
Java NIO
Understanding Java NIO (New Input/Output) is crucial for building high-performance, scalable applications,
especially those dealing with network communication or large file operations. Introduced in Java 1.4, NIO provides an
alternative and often more efficient way to handle I/O compared to the traditional java.io package. In particular
- Non-blocking IO: Java NIO enables us to do non-blocking IO. For instance, a thread can ask a channel to read data into a buffer. While the channel reads data into the buffer, the thread can do something else. Once data is read into the buffer, the thread can then continue processing it. The same is true for writing data to channels.
- Channels and Buffers: In the standard IO API we work with byte streams and character streams. In NIO we work with channels and buffers. Data is always read from a channel into a buffer, or written from a buffer to a channel.
- Selectors: Java NIO contains the concept of “selectors”. A selector is an object that can monitor multiple channels for events (like: connection opened, data arrived etc.). Thus, a single thread can monitor multiple channels for data.
Java NIO consist of the following core components:
- Channels
- Buffers
- Selectors
Java NIO has more classes and components than these, but the Channel, Buffer and Selector forms the core of the
API. The rest of the components, like Pipe and FileLock are merely utility classes to be used in conjunction with
the 3 core components. Let’s then focus on these 3 components for now. The other components are explained in their own
texts further below
Channels and Buffers
Typically, all IO in NIO starts with a Channel. A Channel is a formal representation of a connection to an
entity capable of performing I/O operations, such as a file, a network socket, or a hardware device. From the Channel
data can be read into a Buffer. Data can also be written from a Buffer into a Channel.
Here is a basic example that uses a FileChannel to read some data into a Buffer:
RandomAccessFile file = new RandomAccessFile("data.txt", "r");FileChannel fileChannel = file.getChannel();ByteBuffer buffer = ByteBuffer.allocate(512);
int bytesRead = fileChannel.read(buffer);while (bytesRead != -1) { buffer.flip();
while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); }
buffer.clear(); bytesRead = fileChannel.read(buffer);}
fileChannel.close();file.close();TIPNotice the
buffer.flip()call above. First we read into aBuffer. Then we flip it to read out of it.When we read from the
FileChannelinto theByteBufferusingfileChannel.read(buffer), the buffer is in write mode. The position advances as bytes are written into it. The linebuffer.flip()is a necessary transitional step. It changes the buffer’s state from write mode to read mode by:
- Setting the limit to the current position. This tells the buffer that the data to be read extends only up to this point.
- Resetting the position back to 0. This ensures that the next read operation starts at the beginning of the data that was just written.
(To be continued…)
JDK 16
Records
Official Documentation on Records
From it’s first glimpse, Records are very similar to Enums. Let’s first unpack what Records are really for. The goal of Records, according to OpenJDK, are
-
“Devise an object-oriented construct that expresses a simple aggregation of values.”
- “Help developers to focus on modeling immutable data rather than extensible behavior.”
- “Automatically implement data-driven methods such as equals and accessors.”
- “Preserve long-standing Java principles such as nominal typing and migration compatibility.”
What are Records NOT for
First, we should be very clear about what Records are NOT for:
Non-Goal 1“While records do offer improved concision when declaring data carrier classes, it is NOT a goal to declare a “war on boilerplate”. In particular, it is not a goal to address the problems of mutable classes which use the JavaBeans naming conventions.”
What are “Classes which use the JavaBeans Naming Conventions”? A traditional JavaBean is a standard Java class designed around a specific set of conventions:
- Private Fields: The internal data is hidden.
- A Public No-Argument Constructor: An empty constructor (
new MyBean()) is required so frameworks can instantiate the object dynamically. - Getters and Setters: Properties are accessed via
getFoo()and modified viasetFoo(value).
Because objects are created empty and populated afterward via setters, JavaBeans are inherently mutable.
What are the “Problems” of Mutable JavaBeans? While the JavaBean pattern became ubiquitous in enterprise Java (used extensively by frameworks, ORMs, and UI libraries), it suffers from several deep design flaws:
- Inconsistent (Half-Baked) States: When we instantiate a JavaBean using a no-arg constructor and call setters one by one, the object exists in a temporary, incomplete, or invalid state until all necessary setters have been invoked. If another thread reads the object midway, it sees corrupted or missing data.
- Loss of Immutability: Because fields are non-final and exposed to setters, any part of the application can modify the object’s state at any time. This makes tracking bugs, debugging race conditions, and writing thread-safe code extremely difficult.
- Verbosity and Boilerplate: Even with modern IDEs, writing or generating getters, setters,
equals,hashCode, andtoStringmethods creates massive amounts of boilerplate code.
Why is this a “Non-Goal” for Records? In a record, all fields are implicitly
final, and values can only be set once - at the exact moment of construction. Records are immutable and do not have
setter methods. JavaBeans, being mutable by design, rely entirely on mutability and setters to change state after
construction. Because records are strictly immutable and require all data to be passed into the constructor upfront,
they cannot act as drop-in replacements for mutable JavaBeans. If our architecture relies on objects being mutable,
having empty default constructors, or being modified dynamically by frameworks, records will not solve those design
patterns.
Non-Goal 2“It is not a goal to add features such as properties or annotation-driven code generation, which are often proposed to streamline the declaration of classes for “Plain Old Java Objects”.”
What are “Properties” and “Annotation-Driven Code Generation” in traditional POJOs? In the traditional enterprise Java ecosystem (outside of the language core), developers grew tired of writing massive amounts of repetitive boilerplate code for standard data classes. To “streamline” this, 2 major mechanisms emerged:
- Framework-defined “Properties”: Unlike languages like C#, Kotlin, or Swift which have first-class language
properties built into their syntax, Java classes rely on naming conventions (
getFoo/setFoo) to simulate properties. Because Java doesn’t have a native property concept, frameworks have to inspect classes via reflection at runtime to figure out what fields exist. - Annotation-Driven Code Generation (e.g., Project Lombok): Tools like Lombok use
annotation processors (such as
@Data,@Getter,@Setter,@ToString) during compilation to automatically inject getters, setters, and constructors into the bytecode so the developer doesn’t have to look at them.
Why is adding these features a “Non-Goal” for Records? When designing Records, the Java language architects deliberately chose a different philosophical path:
-
Transparency over Magic: Records are designed to be “transparent carriers for data.” What we declare in the header is precisely what we get: no hidden fields, no auto-generated setters, and no runtime magic.
-
Immutability over Mutable POJOs: POJOs are typically mutable (with getters and setters allowing state changes after creation). Records are strictly immutable. If the Java team tried to solve the “POJO problem,” they would have to support mutability, custom getters/setters, empty constructors, and flexible property mapping which completely contradicts what a record is (an immutable, nominal tuple).
POJO v.s. JavaBeanAll JavaBeans are POJOs, but not all POJOs are JavaBeans.
POJO (Plain Old Java Object) are standard Java object completely free of framework-specific restrictions. JavaBean is specialized POJO that adheres to strict coding conventions:
POJO JavaBean Constructor Can have any configuration of constructors (with or without arguments). Must have a public, no-argument constructor. Properties & Visibility Fields can use any access modifier ( public,private, etc.).Must have privatefields accessed exclusively via public getters and setters.Serialization Implementing Serializableis entirely optional.Must implement java.io.Serializable.Lifecycle Management Manually created by the developer using the newkeyword.Manually created by the developer or via reflection by visual design tools. -
Language-Level Design vs. Tooling: Features like annotation-driven boilerplate reduction (Lombok) are handled at the compiler-plugin level by third-party libraries. The Java language designers prefer to keep the core language specifications explicit, readable without special IDE plugins, and structurally sound, rather than turning Java into a kitchen-sink language laden with annotations that rewrite class behavior behind the scenes.
Records Can’t Be Entities
Records are immutable and are used to store data. They contain fields, all-args constructor, getters, toString, and equals/hashCode methods. Since they are immutable, they don’t have setters. Because of their concise syntax, they are often used as data transfer objects (DTOs) in Java applications.
Entities in JPA are classes that are mapped to a database table. They are used to represent an entry in a database. Their fields are mapped to columns in the database table.
Java Records, thus, cannot be used directly as JPA or Hibernate entities, because records are immutable, final, and lack a parameterless default constructor required by persistence providers
What are Records for
TIPWhenever we need a simple, transparent, immutable aggregation of values where behavior is secondary to data carriage, Records are vastly superior to mutable beans, generic POJOs, or enums.
-
Scenario 1: Spring Boot Configuration Object
We have a spring book application and we need to store the configurations read from
application.propertiesyaml so that we can access those configs programmatically. This configuration object is best to be modeled using Record over Enum or POJO or BeanSpring Boot configuration properties (read from
application.propertiesorapplication.yml) are meant to be immutable data carriers. Once the application boots up and binds those configuration values, they should never change while the application is running.If we used a traditional JavaBean, we would have to define private fields, a public no-arg constructor, and mutable setter methods (
setHost(),setPort()). This would leave our configuration object in a vulnerable, half-baked state while Spring binds the values, and allow any part of our code to mutate the configuration later. With arecord, Spring’s binder populates the values directly into the constructor, and the resulting config object is fully immutable, thread-safe, and clean of setter boilerplate. -
Scenario 2: Web Service Response Object
We have a Response object that is returned from all the endpoints of a Java webservice for the similar reason.
An API response object (like
ApiResponse<T>) is a classic aggregation of values sent back to a client. It carries data from the service layer to the transport layer without containing complex business logic or extensible behavior.Response payloads need to be transparent, predictable, and immutable so that data integrity is guaranteed as it leaves our system. Records automatically generate
equals(),hashCode(), andtoString(), making them exceptionally easy to log, serialize to JSON, and assert against in unit tests.
Other scenarios where Java Records shine include
- Data Transfer Objects (DTOs) between microservices or layers
- Multi-field composite keys or map keys (a composite map key consisting of multiple distinct values)
- Event messages between publishers and consumers (e.g., Kafka or RabbitMQ)
TIPWant to harness this insight into your Java development? Start coding with OpenML Vertex agent now~
Caching
A Brief Overview of Caching Algorithms
There is no single “all” list, as new and hybrid algorithms are constantly being developed, but they all fall into a few key families. These algorithms, known as cache replacement policies, are rules that decide which piece of data to evict (remove) when the cache is full and new data needs to be added.
The goal is always to maximize the cache hit rate (finding the data in the cache) and minimize the cache miss rate (having to fetch the data from slower, underlying storage).
Here is a breakdown of the most important caching algorithms, from simple to advanced.
Simple Recency & Order-Based Algorithms
These are the most basic algorithms, focusing on the order or time of access.
- Least Recently Used (LRU): This is one of the most popular algorithms. It evicts the item that has not been used for the longest time. It’s based on the idea of temporal locality: if we used something recently, we are likely to use it again soon.
- First-In, First-Out (FIFO): The first item added to the cache is the first one to be evicted, regardless of how often or recently it was used. It’s simple to implement but often inefficient, as it can evict popular items that were just loaded early.
- Most Recently Used (MRU): This algorithm evicts the item that was most recently used. This seems counter-intuitive, but it’s effective in specific cases, such as database full-table scans, where the data is read once and is unlikely to be needed again soon.
- Random Replacement (RR): As the name suggests, it just picks an item at random to evict. It’s very simple and avoids the overhead of tracking access, but its performance is unpredictable.
Frequency-Based Algorithms
These algorithms track how many times an item is accessed.
- Least Frequently Used (LFU): This algorithm evicts the item that has been accessed the fewest times. The idea is to keep the most popular items. LFU, however, struggles with cache pollution. An item that was popular in the past but is no longer needed can “pollute” the cache and never get evicted, while a new, instantly popular item might be evicted before its frequency count can build up.
Advanced & Adaptive Algorithms
These are more complex, high-performance algorithms that solve the problems of the simpler ones. They are often “scan-resistant,” meaning a single pass over a large dataset (like a backup or index) won’t wipe out all the useful, popular data in the cache.
- Adaptive Replacement Cache (ARC) is a high-performance algorithm that constantly balances between LRU (recency) and LFU (frequency). It’s considered one of the best general-purpose algorithms.
- 2Q (Two-Queue) is a simpler algorithm that also solves the scan-resistance problem.
- SIEVE is an algorithm that has gained significant attention for being simpler than LRU but higher-performing than many complex algorithms.
Caching API in OpenJDK
OpenJDK’s standard API does not provide built-in, “out-of-the-box” implementations for all of those algorithms. The Java
standard library provides the building blocks (like HashMap and LinkedList), but it expects us to either build a
cache ourselves or, more commonly, use a dedicated third-party library. The only algorithm we can easily implement with
a built-in class, however, is LRU. The java.util.LinkedHashMap class can be configured to function as a simple LRU
cache by overriding its removeEldestEntry method:
import java.util.LinkedHashMap;import java.util.Map;
/** * A cache class with Least Recently Used (LRU) eviction policy. * * @param <K> The type of keys maintained by this cache * @param <V> The type of cached values */public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int cacheSize;
/** * Constructs an empty {@link LruCache} instance with the provided maximum number of entries hold in the cache. * * @param cacheSize Maximum number of entries in cache */ private LruCache(final int cacheSize) { super(cacheSize * 4 / 3, 0.75f, true); this.cacheSize = cacheSize; }
/** * Creates a new instance of {@link LruCache} with the provided maximum number of entries hold in the cache. * * @param cacheSize Maximum number of entries in cache * * @param <K> The type of keys maintained by this cache * @param <V> The type of cached values * * @return a new initialized {@link LruCache} instance */ public static <K, V> LruCache<K, V> ofSize(final int cacheSize) { return new LruCache<>(cacheSize); }
@Override protected boolean removeEldestEntry(final Map.Entry<K, V> eldest) { return size() > getCacheSize(); }
private int getCacheSize() { return cacheSize; }}OpenJDK includes the javax.cache API, known as JCache (JSR 107). This is an interface specification, not an
implementation. It provides a standard set of methods (CacheManager, Cache, get, put), but we must plug in a
caching library (like Caffeine) that actually implements the logic.
For any serious caching, Java developers should use high-performance libraries. Here’s how they map to the algorithms we just listed:
-
Google Guava (The Predecessor): Guava’s cache uses a size-based LRU-like policy. It evicts the least recently used items when the cache reaches its maximum size.
-
Caffeine (The Modern Standard): This is the high-performance library that replaced Google’s Guava cache. It does not use a simple LRU or LFU. It uses W-TinyLFU, a much more advanced algorithm that provides near-optimal hit rates by combining the best parts of both LFU (frequency) and LRU (recency). It’s highly scan-resistant and generally superior to the simple algorithms.
-
Ehcache (The Enterprise Standard):
- Ehcache 2.x (older) allowed us to explicitly configure LRU, LFU, or FIFO.
- Ehcache 3.x (current) simplified this. It no longer lets us choose and primarily uses a sampling-based LRU for in-memory caching.
Java Learning Resources
-
Effective Java: “Bible” of the language
-
Diagnostic Tools
:::