2416 words
12 minutes
Houston, We Have a Problem - OpenJDK is on 26 Now!
2025-07-28
2025-10-14

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 fffers 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

Java Resources

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…)

Houston, We Have a Problem - OpenJDK is on 26 Now!
https://blogs.openml.io/posts/java/
Author
OpenML Blogs
Published at
2025-07-28
License
CC BY-NC-SA 4.0