0*d0WbbV_8WVz2lFFA.png

Abstract

Java (and the JVM) reaches the next long-term-support version v25 in September 2025 (it will be 30 years old!) and that ilability of newer technologies and languages like Kotlin, Scala, Clojure, and others like Go and Rust, Java still dominates many large codebases and sits 3rd (up from 4th last year!) on the TIOBE index of programming languages. Rumors of the death of Java are unfounded! What better way to discover and explore what’s new while others fight over which newer language is the best, than to over-engineer and overcomplicate the age-old game of tic-tac-toe in Java!


Welcome back to the multi-part series: Road to JDK 25 — Over-Engineering Tic-Tac-Toe. For a discussion of the features previously introduced in JDK 23, visit Road to JDK 25, JDK 23. It’s been a while!

Photo by kimi lee on Unsplash

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.


Introduction to JDK 24

JDK 24 started ramping down in December 2024, with no less than…well, 24 JEP features in tow; Jack Bauer would be proud. While many of those features are still in preview, we’re left with more than in this JDK to work with. So much so that I’ve been even more selective this time around!

In a release with more candidate features than even JDK 11, we are left with some necessary refinements — enhancements to virtual threads which were first delivered in JDK 21 which now avoid unnecessary pinning to platform threads1, better streams support, improved startup times and runtime efficiency for modules2, as well as memory management enhancements3 come to the JDK this time around. The security manager is now permanently disabled4 and 32-bit support has either been removed completely (Windows)5 or is on its way out (Linux)6. Integrity by default and memory safety are also further emphasized and encouraged as warnings are issued when using JNI7 and sun.misc.Unsafe memory access methods8.

Quote

As the dust of JDK 23 settles, our seasoned developer steadies his gaze toward the horizon. There, a new storm brews — JDK 24, its silhouette sharp and purposeful.

“What power does this beast hold?” he whispers, the winds carrying his words forward.

A resonant voice answers: “Behold, JDK 24! Threads woven tighter, its fibers stronger. Gather! For streams, like an oasis, glisten and shine.”

He feels the hum beneath him, the surge of refined connection, the pulse of precision in every line of code.

But amidst the spectacle, a fleeting pang — a feature teased but not delivered. He smirks, weathered and wise. “Not every promise blooms, but what thrives here is more than enough.”

As the phantom of JDK 24 streaks ahead, leaving trails of blazing performance and connectivity, he tightens his grip. “The road is relentless, but so are we. Valhalla awaits.”


Features

Stream Gatherers

Streams are a powerful functional concept in Java that when used wisely can output expressive, performant, and side-effect free code. On the other hand, if overused they can turn code into an unwieldy mess that a simple, procedural, mechanically-sympathetic equivalent could avoid.

With that caveat out of the way — in JDK 24 stream gatherers9 have been added to the mix to support custom intermediate operations that builds upon the existing built-in intermediate, generally language agnostic functional operations like map, distinct, flatMap, etc. introducing Stream::gather(Gatherer) that allows us to process and transform streams using a custom gatherer of our own.

They can be one-to-one, one-to-many, many-to-one, or many-to-many like the new gathers scan, fold, windowFixed/windowSliding (which you leetcoders will love!) and track state like previously visited elements. If a combiner function is included they can even be processed in parallel.

Example

In our game of tic-tac-toe we capture the current GameState history immutably which includes the full state of the board. This allows us to go back in time and see the state of the game as it was after any amount of moves.

We can use that data to capture something simple, e.g. for each move, which were the last moves were made prior or in response by other players. Now easily done via the new built-in Gatherers.windowSliding():

game.history()  
    .stream()  
    .skip(1) // Initial State (No Moves Yet)  
    .gather(Gatherers.windowSliding(game.numberOfPlayers()))  
    .map(  
        window ->  
            window.stream()  
                .map(  
                    state ->  
                        String.format(  
                            "%s->%s",  
                            state.lastPlayer(),  
                            state.lastMove()))  
                .collect(Collectors.joining(",", "[", "]")))  
    .collect(Collectors.joining(" | "));

which we can evaluate easily using jshell with using a pre-built class path:

 jshell --class-path api/build/classes/java/main  
|  Welcome to JShell -- Version 24  
|  For an introduction type: /help intro  
  
jshell> import org.xxdc.oss.example.*;  
  
jshell> var game = Game.ofBots();  
Feb 04, 2025 5:53:43 PM org.xxdc.oss.example.GameBoard withDimension  
WARNING: Unable to use native game board, falling back to local game board: class java.lang.ClassNotFoundException(org.xxdc.oss.example.GameBoardNativeImpl)  
game ==> org.xxdc.oss.example.Game@46f5f779  
  
jshell> game.play();  
  
... // snipped 4 brevity  
  
INFO: Winner: Player X!  
Feb 04, 2025 5:54:00 PM org.xxdc.oss.example.Game renderBoard  
INFO:   
O_X  
XXO  
X_O  
  
  
jshell> game.history()  
   ...>     .stream()  
   ...>     .skip(1) // Initial State (No Moves Yet)  
   ...>     .gather(Gatherers.windowSliding(game.numberOfPlayers()))  
   ...>     .map(  
   ...>          states ->  
   ...>            states.stream()  
   ...>                  .map(  
   ...>                       state ->  
   ...>                         String.format(  
   ...>                           "%s->%s",  
   ...>                           state.lastPlayer(),  
   ...>                           state.lastMove()))  
   ...>                  .collect(Collectors.joining(",", "[", "]")))  
   ...>             .collect(Collectors.joining(" | "));  
$4 ==> "[X->4,O->5] | [O->5,X->2] | [X->2,O->8] | [O->8,X->3] | [X->3,O->0] | [O->0,X->6]"

Or, we can take a custom approach and use that data to do something more interesting like analyze strategic turning points in the game and add commentary.

This is achieved by adding a Gatherer of our own which takes an Integrator — a class that takes our upstream inputs and potentially transforms and pushes them downstream, and an optional combiner (for parallelizable algos), and also an optional initializer and finisher which allow us to initialize and perform final actions on state attached to the gatherer, respectively.

An example of this is implemented is below:

public static Gatherer<GameState, GathererState, StrategicTurningPoint> strategicTurningPoints() {  
  return Gatherer.ofSequential(  
      // Initializer<State>: state - track the previous game state, move counter  
      GathererState::new,  
      // Integrator<State, Upstream, Downstream>: discover and emit strategic turning points  
      Integrator.<GathererState, GameState, StrategicTurningPoint>of(  
          (state, currGameState, downstream) -> {  
            if (state.prevGameState != null) {  
              StrategicTurningPoint.from(state.prevGameState, currGameState, state.currMoveNumber)  
                  .ifPresent(downstream::push);  
            }  
  
            return state.add(currGameState);  
          }));  
}
 

This allows us to stream the history, gather and analyze turning points in the game, and add live and/or post-game commentary.

public void run() throws Exception {  
  try (var game = newStandardGame()) {  
    game.playWithAction(this::logLiveCommentary);  
    logPostAnalysisCommentary(game);  
  }  
}  
  
private void logPostAnalysisCommentary(Game game) {  
  log.log(Level.INFO, "Post-Game Analysis:");  
  var commentary = new EsportsPostAnalysisConmmentaryPersona();  
  game.history().stream()  
      .gather(strategicTurningPoints())  
      .map(commentary::comment)  
      .forEach(l -> log.log(Level.INFO, "- \"{0}\"", l));  
}
 
private void logLiveCommentary(Game game) {  
  var commentary = new EsportsLiveCommentaryPersona();  
  game.history().stream()  
      .skip(game.moveNumber() - 1) // latest move state changes only  
      .gather(strategicTurningPoints())  
      .map(commentary::comment)  
      .forEach(l -> log.log(Level.INFO, "\"{0}\"", l));  
}  

Giving us far more exciting games of tic-tac-toe with esports-style commentary and analysis:

...
"O seizes the high ground, taking control of the critical center square - textbook tic-tac-toe strategy!"
 
...
 
- Tie Game!   
-   
OXO  
OXX  
XOX  
   
- Post-Game Analysis:   
- - "After the 1st move X seized the high ground - in textbook tic-tac-toe strategy by taking control of the critical center square."   
- - "With the 4th move of the game O made a critical defensive play that prevented an immediate loss. Clutch!"
 

Generational ZGC

As mentioned in previous posts, the ZGC was introduced in JDK 11 as a “scalable low-latency garbage collector” capable of supporting massive terabyte sized heaps, concurrent class loading, NUMA (non-uniform-memory-access) awareness, GC pause times not exceeding 1ms and not increasing with the size of the heap, minimized impact to application throughput, etc.

In JDK 21 the ZGC became multi-generational taking advantage of the known memory allocation fact that most created objects are short-lived. This means more frequent and efficient collection of short-lived objects in newer generations and therefore less frequent full GCs.

In JDK 23 multi-generational collection became the default and non-generational collection was deprecated. JDK 24 does away with non-generational mode10 and makes multi-generational garbage collection the default with ZGC. Using the -XX:+ZGenerational flag will now give a warning as it’s unused.

Example

Our JDK-only tic-tac-toe game doesn’t have a big reason at present to tune the garbage collector (that becomes more relevant to application performance as we scale. Starting it up with JVM arg -XX:+UseZGC is enough to benefit from generational ZGC in JDK 24.

Since we’re using Gradle we can make sure it starts using the ZGC with gradle run by including it in the applicationDefaultJvmArgs:

application {
    mainClass = "org.xxdc.oss.example.App"
    applicationDefaultJvmArgs = listOf(
        "-XX:+UseZGC"
    )
}

Class-File API

For many developers, the Class-File API may be a feature they never use — JVM-level language generation and parsing. For the rest of us, we can now natively interact with the stack-based JVM at runtime and using three main concepts: elements, builders, and transforms. Elements are immutable descriptions of class file components. A builder enables the construction of class files. Finally, transforms represent functions that modify elements during the building process.11

In order to grasp the concepts and use the API effectively at that level a good understanding of the JVM spec is required.12 Together, with the Constants API we can over-engineer essentially to the metal of the JVM. If this sounds intimidating, I’d encourage the reader to gradually build up your knowledge by using javap or sites like https://godbolt.org with sample Java programs alongside the JVM spec. We’ll barely scratch the surface of this API but having it part of the JDK can help solve many of the delays associated with 3rd-party tools that generate code in this way.

Example

Over time we have built a tic-tac-toe game with a bot capable of picking a next move at random and evolved it to use intelligent monte-carlo tree search.

We can add another random-like naive strategy that simply picks the first available move from the list of availableMoves submitted to the BotStrategy.

Were we to code that in high-level ‘verbose’ java it could simply look like this that takes the first available move:

import java.util.List;
 
class NaiveFifo implements BotStrategy {
	@Override
    public int bestMove(List<Integer> availableMoves) {
        if (availableMoves.isEmpty()) {
            throw new RuntimeException("No moves available.");
        } else {
            return availableMoves.get(0);
        }
    }
}

This would produce byte instruction output similar to the following:

class NaiveFifo {
  Naive();
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public int bestMove(java.util.List<java.lang.Integer>);
       0: aload_1
       1: invokeinterface #7,  1            // InterfaceMethod java/util/List.isEmpty:()Z
       6: ifeq          19
       9: new           #13                 // class java/lang/RuntimeException
      12: dup
      13: ldc           #15                 // String No moves available.
      15: invokespecial #17                 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
      18: athrow
      19: aload_1
      20: iconst_0
      21: invokeinterface #20,  2           // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
      26: checkcast     #24                 // class java/lang/Integer
      29: invokevirtual #26                 // Method java/lang/Integer.intValue:()I
      32: ireturn
}

For a basic and partial understanding of the instructions above, in bestMove we’re:

  • aload_1: loading the local reference at index 1 — i.e. the list of available moves (since the first 0-index is the class instance itself)
  • invokeinterface #7 1: invoking the isEmpty method from the interface.
  • ifeq 19: jumping to label 19 if isEmpty is true otherwise continuing on
  • … and so on

In our new NaiveFifo bot strategy class the method does the same using the Class-File API, in line with the instructions above, picking the first available move:

// ...
.withMethod(  
    "bestMove",  
    MethodTypeDesc.ofDescriptor("(Ljava/util/List;)I"),  
    ClassFile.ACC_PUBLIC,  
    mb ->  
        mb.withCode(  
            code -> {  
              var nonEmpty = code.newLabel();  
              code.aload(1) // Load `availableMoves`  
                  .invokeinterface(  
                      ClassDesc.of("java.util.List"), "isEmpty",  
                      MethodTypeDesc.ofDescriptor("()Z")) // Call availableMoves.isEmpty()  
                  .ifeq(nonEmpty) // If not empty, jump to 'nonEmpty'  
  
                  // Throw new RuntimeException("No moves available.")                  .new_(ClassDesc.of("java.lang.RuntimeException"))  
                  .dup()  
                  .ldc("No moves available.")  
                  .invokespecial(  
                      ClassDesc.of("java.lang.RuntimeException"),  
                      ConstantDescs.INIT_NAME, //   "<init>"  
                      MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"))  
                  .athrow()  
  
                  // If non-empty, return first element: availableMoves.get(0)  
                  .labelBinding(nonEmpty)  
                  .aload(1) // Load `availableMoves`  
                  .iconst_0() // Load `0`  
                  .invokeinterface(  
                      ClassDesc.of("java.util.List"), "get",  
                      MethodTypeDesc.ofDescriptor(  
                          "(I)Ljava/lang/Object;")) // Call get(0)  
                  .checkcast(ClassDesc.of("java.lang.Integer")) // Cast Object -> Integer  
                  .invokevirtual(
	                  ClassDesc.of("java.lang.Integer"), "intValue",  
                      MethodTypeDesc.ofDescriptor("()I")) // Unbox Integer -> int  
                  .ireturn();  
            })));
// ...

Ahead-of-Time Class Loading & Linking

For all its speed, optimizations, and dynamism, Java rarely gets accused of starting up quickly. This is particularly the case if you’re using an application framework like Spring or Spring Boot. JEP48313 seeks to mitigate this by making the classes of an application instantly available — loaded and linked — when the JVM starts. With the new ahead-of-time cache, the reading, parsing, loading, and linking work that the JVM would normally do just-in-time when at startup is now performed during a cache creation step.

Example

By using this new feature and taking the steps to train and configure AOT, create an AOT cache, and then run the application with the AOT cache we can achieve quantifiable speed-up in the start-up time of our little game of tic-tac-toe.

I created an AppTrainer that simply plays off two bots against each other to train the cache and load most of the relevant classes.

Step 1: AOT Configuration Creation

$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
       -cp %classpath% org.xxdc.oss.example.AppTrainer ...

Step 2: AOT Cache Creation

$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
       -XX:AOTCache=app.aot -cp %classpath%

Step 3: Running the application using the AOT Cache

$ java -XX:AOTCache=app.aot -cp %classpath% org.xxdc.oss.example.App

Benchmarking it with hyperfine shows a 1.16x speed-up over the application without the cache.

$ hyperfine -n standard ./scripts/bench.sh -n aot ./scripts/3b_aot_bench.sh
Benchmark 1: standard
  Time (mean ± σ):     896.9 ms ±  86.0 ms    [User: 1220.8 ms, System: 107.1 ms]
  Range (min  max):   834.9 ms … 1107.8 ms    10 runs
 
Benchmark 2: aot
  Time (mean ± σ):     771.9 ms ±  28.7 ms    [User: 1023.0 ms, System: 96.5 ms]
  Range (min  max):   745.6 ms … 838.5 ms    10 runs
 
Summary
  aot ran
    1.16 ± 0.12 times faster than standard

Quantum-Resistant Module-Lattice-Based Key Encapsulation Mechanism

Have you ever tried to play a game of tic-tac-toe and been anxious about the possibility of someone using advanced quantum computing to hack into your game? Me neither. That’s why we took the opportunity to introduce quantum-resistant cryptography into our over-engineered game anyway in a previous post once an interface was made available in JDK 21.

At that time, however, there was no JDK-native implementation we could use that implemented the new KEMSpi interface. JDK 24 introduces Module-Lattice-Based Key-Encryption Mechanism (ML-KEM) i.e. Kyber to the javax.crypto package14.

KEMs are like exchanging a locked box with a secret inside—one party (the sender) locks it and sends it, and the other party (the receiver) unlocks it with a special key. ML-KEM, designed to be secure against future quantum computing attacks, has been approved and standardized by the United States National Institute of Standards and Technology (NIST) in FIPS 203.

If you want to enhance your mathematical sympathy and understand some of the math behind the algos take a look at the “Learning with Errors” and “Lattice-based cryptography” episodes on chalk talk15.

Example

A KEM like ML-KEM consists of three functions called by a sender and/or receiver:

  • A key pair generation function that returns a key pair containing a public key and a private key.

  • A key encapsulation function, called by the sender, takes the receiver’s public key and an encryption option; it returns a secret key K and a key encapsulation message. The sender sends the key encapsulation message to the receiver.

  • A key decapsulation function, called by the receiver, takes the receiver’s private key and the received key encapsulation message; it returns the secret key K.

We can then use that securely exchanged secret key K to perform symmetric encryption/decryption on any messages we need to keep secure.

We introduce SecureKyberClient and SecureKyberServer which use 1024-bit ML Kyber to handle both sides of the exchange so no longer need to have external dependencies like bouncy castle (or liboqs16) in order to provide post-quantum secure encryption in Java:

Key Pair Generation:

private KeyPair generateKeyPair() throws NoSuchAlgorithmException, IOException {
  var keyPairGen = KeyPairGenerator.getInstance("ML-KEM-1024");
  return keyPairGen.generateKeyPair();
}

Key Exchange (encapsulation/decapsulation):

 // Server
 protected SecretKey exchangeSharedKey()
  throws NoSuchAlgorithmException,
	  IOException,
	  NoSuchProviderException,
	  InvalidParameterSpecException,
	  InvalidAlgorithmParameterException,
	  InvalidKeyException,
	  DecapsulateException {
  var keyPair = generateKeyPair();
  publishKey(keyPair.getPublic());
  // Receiver side
  var encapsulated = handler.receiveBytes();
  var kem = KEM.getInstance("ML-KEM-1024");
  var decapsulator = kem.newDecapsulator(keyPair.getPrivate());
  return decapsulator.decapsulate(encapsulated);
}
 
// Client
protected SecretKey exchangeSharedKey()
  throws NoSuchAlgorithmException,
	  NoSuchProviderException,
	  ClassNotFoundException,
	  IOException,
	  InvalidAlgorithmParameterException,
	  InvalidKeyException {
  var kem = KEM.getInstance("ML-KEM-1024");
  var publicKey = retrieveKey();
  var encapsulator = kem.newEncapsulator(publicKey);
  var encapsulated = encapsulator.encapsulate();
  handler.sendBytes(encapsulated.encapsulation());
  return encapsulated.key();
}

Quantum-Resistant Module-Lattice-Based Digital Signature Algorithm

If KEMs can be compared to exchanging a locked box with a key, digital signature schemes are like signing a document where the receiver can validate that it was signed by the other party and hasn’t been altered.

NIST standardized the Module-Lattice-Based Digital Signature Algorithm (ML-DSA) in FIPS 204 and the ML-DSA (Dilithium) implementation is now available in JDK 24.

Example

We could use this functionality to digitally sign each move made by each player, or create a zero-trust game. Maybe in future we will but since this article is bloated with features enough as it is we’ll let that be an exercise for the reader!


That’s all folks — that’s a wrap (for now) for JDK 24! I’d encourage you to to deep dive into the overengineering-tictactoe codebase, the JDK docs for not just JEPs but other improvements in JDK24 to discover more. Will we finally reach the end of the road with JDK 25?

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 developer? Comment & share your own opinions, favorite learning resources, or book recommendations.
  • Follow my blog (or digital garden www.sympatheticengineering.com) for future updates on my engineering exploits and professional / personal development tips.
  • Connect with @briancorbinxyz on social media channels.
  • Subscribe!

Footnotes

  1. “JEP 491: Synchronize Virtual Threads without Pinning.” Accessed March 17, 2025. https://openjdk.org/jeps/491.

  2. “JEP 493: Linking Run-Time Images without JMODs.” Accessed March 19, 2025. https://openjdk.org/jeps/493.

  3. “JEP 475: Late Barrier Expansion for G1.” Accessed March 19, 2025. https://openjdk.org/jeps/475.

  4. “JEP 486: Permanently Disable the Security Manager.” Accessed February 7, 2025. https://openjdk.org/jeps/486.

  5. “JEP 479: Remove the Windows 32-Bit X86 Port.” Accessed February 12, 2025. https://openjdk.org/jeps/479.

  6. “JEP 501: Deprecate the 32-Bit X86 Port for Removal.” Accessed February 12, 2025. https://openjdk.org/jeps/501.

  7. “JEP 472: Prepare to Restrict the Use of JNI.” Accessed July 9, 2024. https://openjdk.org/jeps/472.

  8. “JEP 498: Warn upon Use of Memory-Access Methods in Sun.Misc.Unsafe.” Accessed February 12, 2025. https://openjdk.org/jeps/498.

  9. “JEP 485: Stream Gatherers.” Accessed February 4, 2025. https://openjdk.org/jeps/485.

  10. “JEP 490: ZGC: Remove the Non-Generational Mode.” Accessed February 7, 2025. https://openjdk.org/jeps/490.

  11. “JEP 484: Class-File API.” Accessed March 15, 2025. https://openjdk.org/jeps/484.

  12. “Java SE Specifications.” Accessed March 15, 2025. https://docs.oracle.com/javase/specs/.

  13. “JEP 483: Ahead-of-Time Class Loading & Linking.” Accessed February 12, 2025. https://openjdk.org/jeps/483.

  14. “JEP 496: Quantum-Resistant Module-Lattice-Based Key Encapsulation Mechanism.” Accessed February 12, 2025. https://openjdk.org/jeps/496.

  15. Learning with Errors: Encrypting with Unsolvable Equations, 2023. https://www.youtube.com/watch?v=K026C5YaB3A.

  16. “Open Quantum Safe.” Accessed February 12, 2025. https://github.com/open-quantum-safe.


Comments

Reply on Bluesky here to join the conversation.


Loading comments...