
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:
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 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 heapBecause 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 theDirectByteBuffer
object on the heap becomes unreachable, the garbage collector will eventually process it. This action triggers a cleanup process (using an object called aCleaner
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 theDirectByteBuffer
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.
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
FileChannel
into theByteBuffer
usingfileChannel.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…)