8993 words
45 minutes
OpenJDK
2025-07-28
2026-10-25

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 Problems

While 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

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

Error loading java-optional-computer.png

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”):

Error loading java-optional-eg.png

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():

@Test
public 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->true

That will be, however, different if we want to combine them using Predicate.or():

@Test
public 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:

AllowedClass.java
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;
}
}
RestrictedClass.java
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 error

Programmatic 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 error

In 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 heap

    Because it lives on the heap, a heap buffer is fully managed by the garbage collector. When the ByteBuffer object 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 DirectByteBuffer object, 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 the DirectByteBuffer object on the heap becomes unreachable, the garbage collector will eventually process it. This action triggers a cleanup process (using an object called a Cleaner in 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 the DirectByteBuffer objects 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.

CAUTION

This 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();
TIP

Notice the buffer.flip() call above. First we read into a Buffer. Then we flip it to read out of it.

When we read from the FileChannel into the ByteBuffer using fileChannel.read(buffer), the buffer is in write mode. The position advances as bytes are written into it. The line buffer.flip() is a necessary transitional step. It changes the buffer’s state from write mode to read mode by:

  1. Setting the limit to the current position. This tells the buffer that the data to be read extends only up to this point.
  2. 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.”

- OpenJDK Records

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 via setFoo(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, and toString methods 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”.”

- OpenJDK Records

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:

  1. 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.
  2. 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. JavaBean

    All 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:

    POJOJavaBean
    ConstructorCan have any configuration of constructors (with or without arguments).Must have a public, no-argument constructor.
    Properties & VisibilityFields can use any access modifier (public, private, etc.).Must have private fields accessed exclusively via public getters and setters.
    SerializationImplementing Serializable is entirely optional.Must implement java.io.Serializable.
    Lifecycle ManagementManually created by the developer using the new keyword.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#

TIP

Whenever 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.

  1. Scenario 1: Spring Boot Configuration Object

    We have a spring book application and we need to store the configurations read from application.properties yaml so that we can access those configs programmatically. This configuration object is best to be modeled using Record over Enum or POJO or Bean

    Spring Boot configuration properties (read from application.properties or application.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 a record, 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.

  2. 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(), and toString(), 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)
TIP

Want 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.

  1. 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.
  2. 2Q (Two-Queue) is a simpler algorithm that also solves the scan-resistance problem.
  3. 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#

:::

OpenJDK
https://blogs.openml.io/posts/java/
Author
OpenML Blogs
Published at
2025-07-28
License
CC BY-NC-SA 4.0