Road to JDK 25 — Over-Engineering Tic-Tac-Toe (Java 22)!

--

Java (and the JVM) reaches the next long-term-support version v25 in September 2025 (it will be 30 years old!) and that warrants an exploration of its modern features. Despite the availability of newer technologies and languages like Kotlin, Scala, Clojure, and others like Go and Rust, Java still dominates many large codebases and sits 4th on the TIOBE index of programming languages. Rumors of the death of Java may be unfounded. What better way to discover and explore what’s new in a hands-on way than to over-engineer and overcomplicate the age-old game of tic-tac-toe!

This article was originally self-published in “The Life of Brian” the digital garden and is being serialized on Medium. All references and citations can be found on the original article.

This is Part 4 of the Multi-Part Series: Road to JDK 25 — Over-Engineering Tic-Tac-Toe. For a discussion of features introduced in JDK 21 written previously, visit:

Tic-Tac-Toe

Tic-tac-toe is a simple game usually played by two players who take turns marking the spaces in a three-by-three grid with X or O. The player who successfully places three of their marks in either a horizontal, vertical, or diagonal row is the winner.

Our task is to continue to progressively over-engineer tic-tac-toe (repo: here) focused primarily on finalized features from JEPs rather than those still in preview prior to v25 and introduce best practices as the code base grows.

JDK 22 and the Java 22 Runtime

Introduction to JDK 22

JDK 22 keeps up the momentum gained in JDK 21. Unnamed Patterns and Variables, helping to ignore unneeded parts of data structures for cleaner code (Scala, Python had this for ages), the ability to launch multi-file source code programs, and finally delivering an interoperability API with the foreign function and memory API to replace JNI as the defacto way to perform interop, that allow us to interoperate with code and data outside of the runtime and heap.

Launch Multi-File Source-Code Programs

One fair criticism with Java development over the years is that it’s not beginner friendly and slow to get started with, or slow to ideate with, or RAD (referring to rapid-application-development) with. Even when kicking off this app, the first tools I used before calling java were Homebrew, SDKMan, and Gradle.

In previous feature updates, the JDK answered the call with jshell where we have a REPL (read-evaluate-print-loop) that simplifies experimentation with language features, debugging, and learning by providing instant feedback on code execution; the java command being able to directly run .java files without precompiling or in shell scripts with #! shebangs, and other niceties us older heads did not have when starting out.

Those last features are enhanced in JDK 22 by allowing java to run and execute multiple source code programs directly with the java command and it will compile only those necessary for your execution or those explicitly requested. Additionally you can pull in jars by specifying the class path so it's possible to start more significant development without having to reach for a build tool.

Example

e.g. starting our tic-tac-toe app without using gradle or even javac… can be performed with the javacommand.

Before (using JDK 21): java org/example/App.java:

❯ java --version
openjdk 21.0.1 2023-10-17
OpenJDK Runtime Environment (build 21.0.1+12-29)
OpenJDK 64-Bit Server VM (build 21.0.1+12-29, mixed mode, sharing)

❯ pwd
/Users/briancorbin/Documents/GitHub/overengineering-tictactoe/app/src/main/java
❯ java org/example/App.java
org/example/App.java:21: error: cannot find symbol
var game = new Game();
^
symbol: class Game
location: class App
org/example/App.java:33: error: cannot find symbol
var game = Game.from(gameFile);
^
symbol: variable Game
location: class App
2 errors
error: compilation failed

after switching to JDK 22: java org/example/App.java:

Setting java 22.0.1-open as default.
❯ java --version
openjdk 22.0.1 2024-04-16
OpenJDK Runtime Environment (build 22.0.1+8-16)
OpenJDK 64-Bit Server VM (build 22.0.1+8-16, mixed mode, sharing)
❯ pwd
/Users/briancorbin/Documents/GitHub/overengineering-tictactoe/app/src/main/java
❯ java org/example/App.java
/Users/briancorbin/Documents/GitHub/overengineering-tictactoe/app/src/main/java/org/example/SecureMessageHandler.java:25: error: package org.bouncycastle.jce.provider does not exist
import org.bouncycastle.jce.provider.BouncyCastleProvider;
...

It’s still failing. This time because when we introduced KEM API we did so using a 3rd party post-quantum crypto implementation which is a compile-time dependency on bcprov-jdk18on-1.78.1. For it to run we would have to ensure the 3rd-party libraries are in the class path.

This works after switching to JDK 22 and providing the class path of the jar dependency:
java --class-path '/var/tmp/bcprov-jdk18on-1.78.1.jar' org/example/App.java:

❯ java --class-path '/var/tmp/bcprov-jdk18on-1.78.1.jar' org/example/App.java
Jul 26, 2024 11:52:16 PM org.example.App main
INFO: Welcome to Tic-Tac-Toe!
Jul 26, 2024 11:52:16 PM org.example.Players render
INFO: Players: X, O
Jul 26, 2024 11:52:16 PM org.example.Players render
INFO: - TicTacToeClient/1.0 [Human (X)] (IP: 127.0.0.1; Host: Brians-MacBook-Air.local; Java: 22.0.1; OS: Mac OS X 14.5)
Jul 26, 2024 11:52:16 PM org.example.Players render
INFO: - TicTacToeClient/1.0 [Bot (O)] (IP: 127.0.0.1; Host: Brians-MacBook-Air.local; Java: 22.0.1; OS: Mac OS X 14.5)
Jul 26, 2024 11:52:16 PM org.example.Game renderBoard
INFO:
___
___
___

Player 'X' choose an available location between [0-8]:

That feature is pretty great and simplifies the early development feedback loop. The keen-eyed will notice that although it ran fine, it did so without using the Logback implementation and fell back on java.util.logging. Ensuring that's included alongside other dependencies in the class path using a wildcard remedies the issue:
java --class-path '/var/tmp/libs/*' org/example/App.java:

❯ ls --tree /var/tmp/libs
 libs
├──  bcprov-jdk18on-1.78.1.jar
├──  logback-classic-1.5.6.jar
├──  logback-core-1.5.6.jar
├──  slf4j-api-2.0.13.jar
└──  slf4j-jdk-platform-logging-2.0.13.jar

❯ java --class-path '/var/tmp/libs/*' org/example/App.java
00:11:29.562 [main] INFO org.example.App -- Welcome to Tic-Tac-Toe!
00:11:29.868 [main] INFO org.example.Players -- Players: X, O
00:11:29.949 [main] INFO org.example.Players -- - TicTacToeClient/1.0 [Human (X)] (IP: 127.0.0.1; Host: Brians-MacBook-Air.local; Java: 22.0.1; OS: Mac OS X 14.5)
00:11:29.950 [main] INFO org.example.Players -- - TicTacToeClient/1.0 [Bot (O)] (IP: 127.0.0.1; Host: Brians-MacBook-Air.local; Java: 22.0.1; OS: Mac OS X 14.5)
00:11:29.950 [main] INFO org.example.Game --
___
___
___
Player 'X' choose an available location between [0-8]:

Unnamed Variables & Patterns

JDK 22 introduces unnamed variables and nested patterns which can be used when variable declarations or nested patterns are required but never used. They are denoted by the underscore character, _ and their use reduces the verbosity/boilerplate of writing code and reduces the cognitive load whilst reading code since only the relevant details need to be declared.

Example

When we introduced #Record Patterns and Pattern Matching for Switch to the game we implemented a case statement that had to unnecessarily declare the RandomGenerator that we wasn't going to refer to since it was in the record constructor for BotPlayer:

private String playerToType(Player player) {
return switch (player) {
case HumanPlayer(String playerMarker) -> "Human" + " (" + playerMarker + ")";
case BotPlayer(String playerMarker, RandomGenerator r) ->
"Bot" + " (" + playerMarker + ")";
case RemoteBotPlayer p -> "BotClient" + " (" + p.getPlayerMarker() + ")";
};
}

Since JDK 22, however, we no longer have that problem, we can replace anything that we will not use or refer to with an underscore i.e. case BotPlayer(String playerMarker, _):

private String playerToType(Player player) {
return switch (player) {
case HumanPlayer(String playerMarker) -> "Human" + " (" + playerMarker + ")";
case BotPlayer(String playerMarker, _) -> "Bot" + " (" + playerMarker + ")";
case RemoteBotPlayer p -> "BotClient" + " (" + p.getPlayerMarker() + ")";
};
}

Similarly in the same PlayerPrinter class, we have a case where we are using a try/catch block with an exception we do not use, since it's an expected (or handled) exception:

try {
InetAddress localHost = InetAddress.getLocalHost();
...
return String.format(
"TicTacToeClient/1.0 [%s] (IP: %s; Host: %s; Java: %s; OS: %s %s)",
playerToType(player), ipAddress, hostName, javaVersion, osName, osVersion);
} catch (UnknownHostException e) {
return String.format(
"TicTacToeClient/1.0 [%s] (IP: unknown; Host: unknown; Java: %s; OS: %s %s)",
playerToType(player), javaVersion, osName, osVersion);
}

In the past typical convention may have been to name the UnknownHostException expectedor UnknownHostException ignored. In JDK 22, however we can simply use the underscore UnknownHostException _ (as long as it's clearly understood why the exception is not being used by the code).

try {
InetAddress localHost = InetAddress.getLocalHost();
...
return String.format(
"TicTacToeClient/1.0 [%s] (IP: %s; Host: %s; Java: %s; OS: %s %s)",
playerToType(player), ipAddress, hostName, javaVersion, osName, osVersion);
} catch (UnknownHostException _) {
return String.format(
"TicTacToeClient/1.0 [%s] (IP: unknown; Host: unknown; Java: %s; OS: %s %s)",
playerToType(player), javaVersion, osName, osVersion);
}

Region Pinning for G1

Java is part of the family of languages recognized as “Memory Safe” since as it uses automatic garbage collection it provides guarantees against certain type of programming errors (and related exploits and security vulnerabilities) related to memory management. These type of issues could lead to undefined behavior, such as accessing memory out of bounds, using uninitialized memory, or freeing memory incorrectly. There are whole category of techniques used in order to avoid these types of issues in non-memory safe languages.

However, even a memory safe language like Java allows some unsafe operations when performance is critical. For a long time JNI (the Java Native Interface) has been the primary means for interoperating with memory non-safe languages like C and C++.

In certain such use-cases, Java objects on the heap may need to be pinned (i.e. forced to ensure their address remains constant) when being referenced by native code which require a stable address to reference them in memory. The G1 garbage collector — the default garbage collector, however, is designed to compact the heap and reduce fragmentation which may mean migrating objects around. This activity would be problematic for pinned objects so previously, the G1 collector would prevent collection whilst objects were pinned which would lead to both performance degradation due to fragmented memory or long thread pauses. The more pinned objects, the more likely there was to be issues and increased latency.

Region pinning in JDK 22 improves efficiency as it isolates pinned regions allowing the garbage collector to better manage the rest of the heap, improving overall memory utilization and reducing the negative impact of pinned objects.

Foreign Function & Memory API

The goal of the foreign function and memory API that was finally delivered in this JDK was to introduce an API that allowed Java programs to interoperate with ‘foreign’ code (i.e. outside the JVM) and ‘foreign’ data (i.e. not managed by the JVM) in a way that is safer, more efficient, and more natural than previous methods, such as JNI, or others introduced later such as JNA and other custom libraries.

It allows us to efficiently invoke foreign functions and to safely access foreign memory on the same machine but outside of the Java runtime by calling native libraries and processes native heap data not subject to the memory management (garbage collection) of the runtime.

As I previously mentioned, even a memory safe language like Java sometimes need to invoke some unsafe operations when performance is critical; this includes high performance computing, trading, scientific, or AI contexts, or perhaps if an important library is only available in another language.

The FFM API lives in the java.lang.foreign package and has key constructs that allow us to:

Example

This one was fun diversion to look at since it was an excuse to take a modern look at interop in memory safe languages by bridging between Java and Rust, since my past integrations for performance were typically either in C or C++.

The FFM API builds upon Java constructs that may be familiar to developers who use reflection or who have written code that programmatically generates other Java code to invoke functions or reference variables. These constructs are MethodHandle and VarHandle, which were used to re-implement reflection way back in JDK 18 and provide direct references to method-like and variable-like entities.

As a related example of using MethodHandle, when we set up a logger we typically did this in the TicTacToe code:

// SecureMessageHandler.java
private static final Logger log =
System.getLogger(SecureMessageHandler.class.getName());

It simply looks up a logger based on the class name. It’s the same type of pattern you would use if you’re logging with JDK platform logging, SLF4J, or directly with Logback or Log4J.

Alternatively, though, you could use a one-liner that works in the same way across source files and is, therefore, copy-paste safe:

// TicTacToeLibrary.java
private static final Logger log =
System.getLogger(MethodHandles.lookup().lookupClass().getName());

MethodHandles.lookup().lookupClass().getName() uses the MethodHandles factory to return the fully qualified name of the class from which it is called by creating a MethodHandles.Lookup object that references the current class and then invoking getName() on that class object.

To introduce a native library to Tic Tac Toe, we first abstracted the area that would benefit the most — the GameBoard which holds the most data. We had previously made it immutable specifically to increase our memory footprint to make garbage collection more impactful but also allow for future undo or game history DVR. This gives us an interface GameBoard which now has two implementations GameBoardDefaultImpl which worked as before and the new GameBoardNativeImpl which uses a native library/java wrapper pair to implement the functions required by the interface.

// GameBoard.java
public interface GameBoard extends JsonSerializable {
boolean isValidMove(int location);
boolean hasChain(String playerMarker);
boolean hasMovesAvailable();
GameBoard withMove(String playerMarker, int location);
int getDimension();
String asJsonString();
String toString();
}

The GameBoardNativeImpl calls the Java wrapper TicTacToeLibrary for the Rust FFI library libtictactoe, and the wrapper TicTacToeGameBoard for the game board. We also use the Cleaner and Cleanable we discussed in #Deprecation of Finalization for Removal to ensure resources are freed appropriately.

The full description of the features used, the gotchas, and surprises are worthy of a single post of their own but in summary (“trust-me-bro”), here’s a sample of a section of the library class, which makes use of most of the features from the FFM API.

// TicTacToeLibrary.java
public final class TicTacToeLibrary {
// ...
static final String LIBRARY_NAME = "tictactoe";
private final Arena arena = Arena.ofAuto();
private final Linker linker = Linker.nativeLinker();
private final Cleaner cleaner = Cleaner.create();
private SymbolLookup libTicTacToe;
private MethodHandle version;
private MethodHandle versionString;
public TicTacToeLibrary() {
initLibrary();
}
public GameBoard newGameBoard(int dimension) {
return new TicTacToeGameBoard(dimension, libTicTacToe, cleaner);
}
private String platformLibraryName() {
return System.mapLibraryName(LIBRARY_NAME);
}
private void initLibrary() {
libTicTacToe = SymbolLookup.libraryLookup(platformLibraryName(), arena);
initLibraryMethods();
try {
logVersion(version);
logVersionString(versionString);
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
private void initLibraryMethods() {
version = libTicTacToe
.find("version")
.map(m -> linker.downcallHandle(
m,
FunctionDescriptor.of(
ValueLayout.JAVA_LONG,
new MemoryLayout[] {
ValueLayout.ADDRESS,
ValueLayout.JAVA_LONG })))
.orElseThrow(
() -> new IllegalStateException("Unable to find method version"));
// ...
}
// ...
private void logVersion(MethodHandle version) throws Throwable {
// First, call with null pointer to get required length
long requiredLength = (long) version.invoke(MemorySegment.NULL, 0L);
if (requiredLength <= 0) {
throw new RuntimeException("Failed to get required buffer length");
}
MemorySegment buffer = arena.allocate(requiredLength);
long written = (long) version.invoke(buffer, requiredLength);
if (written < 0) {
throw new RuntimeException("Buffer too small");
} else if (written != requiredLength) {
throw new RuntimeException("Unexpected number of bytes written");
}
log.log(Level.DEBUG, "Version = {0}", buffer.getString(0));
}
}

When the TicTacToeLibrary object is created, it initializes the library by loading it and setting up method handles for two functions: version and version_string (the former of which is shown above, version_string is removed for brevity). These method handles allow Java to call functions in our native library.

The code goes through several important steps:

  1. It loads the native library using the platform-specific library name.
  2. It sets up method handles for the version and version_string functions from the native library.
  3. It calls these functions to retrieve and log the version information of the native library.
  4. It provides a simple interface for creating native game boards.

For the version function, in the logVersion method it first calls it with a null pointer to determine the required buffer size, then allocates a buffer of that size and calls the function again to get the actual version string. It's a typical FFI "double-call" pattern allocating sufficient memory for variable length buffers (which could be avoided by allocating a certain amount with a "good guess" upfront).

For completeness, in Rust, the function being called for version is simple enough. Describing it in depth is out of scope for this particular blog post since the focus is on Java. In short, though, it publishes an external function with a "C" binary interface to the dynamic library. This allows the caller to retrieve the version of the library when it was built, and the function ensures the foreign caller allocates enough native memory for it to write back a c-style null-terminated string:

// lib.rs
#[no_mangle]
pub extern "C" fn version(buffer: *mut u8, len: usize) -> isize {
let version = env!("CARGO_PKG_VERSION");
let version_bytes = version.as_bytes();
let required_len = version_bytes.len() + 1; // +1 for null terminator
if buffer.is_null() {
return required_len as isize;
}
if len < version_bytes.len() {
return -1;
}
unsafe {
let buffer_slice = slice::from_raw_parts_mut(buffer, required_len);
buffer_slice[..version_bytes.len()].copy_from_slice(version_bytes);
buffer_slice[version_bytes.len()] = 0; // null terminator
}
required_len as isize
}

Dealing with variable length arrays does tend to be non-trivial with FFI in any language since there’s not always a simple way to know the length of a pointer (MemorySegment) at the foreign call-site without passing that information along. So this was also an opportunity to swap out the string representation of player markers at the data layer with integer ones in the native implementation of TicTacToeGameBoard and worry about the string representation at the presentation layer. A sample of this in action is below in the withMove method:

// TicTacToeGameBoard.java
// ...
@Override
public GameBoard withMove(String playerMarker, int location) {
if (!playerMarkerToId.containsKey(playerMarker)) {
int id = nextId.getAndIncrement();
playerMarkerToId.put(playerMarker, id);
idToPlayerMarker.put(id, playerMarker);
}
try {
int playerId = playerMarkerToId.get(playerMarker).intValue();
MemorySegment newBoard = (MemorySegment) withMove.invoke(board, location, playerId);
return new TicTacToeGameBoard(
newBoard, playerMarkerToId, idToPlayerMarker, nextId.get(), libTicTacToe);
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
private void initGameBoardMethods() {
// ...
withMove =
libTicTacToe
.find("get_game_board_with_value_at_index")
.map(m ->
linker.downcallHandle(
m,
FunctionDescriptor.of(
ValueLayout.ADDRESS,
ValueLayout.ADDRESS,
ValueLayout.JAVA_INT,
ValueLayout.JAVA_INT
)
)
)
.orElseThrow(
() -> new IllegalArgumentException(
"Unable to find method 'get_game_board_with_value_at_index'"
)
);
// ...
}

withMove takes two inputs: a playerMarker (the string representing the player making the move e.g. 'X', or 'O') and a location (an integer indicating where the player wants to place their mark).

The method first checks if the playerMarker is new to the game. If it is, it assigns a unique ID to this player and stores this information in two maps: playerMarkerToId and idToPlayerMarker. This allows the game to keep track of different players.

Next, the method makes a move on the game board by calling a native function (represented by the withMove MethodHandle) from libtictactoe. This function takes the current board state, the chosen location, and the player's ID as inputs, and returns a new board state with the move applied.

The supporting method in Rust is below, which uses Rust’s Box to take control of the raw pointer which is passed onto the JVM which will manage its lifecycle as a MemorySegment.

// lib.rs
#[no_mangle]
pub unsafe extern "C" fn get_game_board_with_value_at_index(
game_board: *mut tictactoe::GameBoard,
index: u32,
value: u32,
) -> *mut tictactoe::GameBoard {
Box::into_raw(Box::new((*game_board).with_value_at_index(index, value)))
}

That’s a wrap for JDK 22!

In the aftermath of the JDK 21’s triumphant passage, the desolate expanse still echoes with whispers of innovation. Our weathered developer, now aboard his patched-up JDK buggy, surveys the horizon for signs of the next marvel.

“By the garbage collector’s mercy, what sorcery is this?” he gasps.

A spectral voice resonates from the chariot’s core: “Behold JDK 22, forged in the crucible of progress! It storms ahead, unyielding and unstoppable!”

The veteran dev stands in awe, watching the JDK 22 phantom streak through the skies, leaving a trail of high-performance data processing and seamless native integration.

He murmurs, “The journey to Valhalla continues… JDK 21 was a war rig, but this, this is the streamlined juggernaut of our future.”

Disclaimer: The views and opinions expressed in this blog are based on my personal experiences and knowledge acquired throughout my career. They do not necessarily reflect the views of or experiences at my current or past employers.

Next Steps

  • Are you a Java (or Rust) developer? Comment & share your own opinions, favorite learning resources, or book recommendations.
  • Follow my blog (or digital garden) for future updates on my engineering exploits and professional / personal development tips.
  • Connect with @briancorbinxyz on social media channels.
  • Subscribe!

--

--

Brian Corbin XYZ - TheCodeInfluencer
Brian Corbin XYZ - TheCodeInfluencer

Written by Brian Corbin XYZ - TheCodeInfluencer

“I write because I don’t know what I think until I read what I say.”

No responses yet