0*d0WbbV_8WVz2lFFA.png

Abstract

Java (and the JVM) reaches the next long-term-support version v25 in September 2025 as Java turns 30 years old! Despite the availability of newer technologies and languages like Kotlin, Scala, Clojure, and others like Go, Rust, and Zig, Java still dominates many large codebases and sits comfortably in the top 5 on the TIOBE index of programming languages_. Java Lives! What better way to discover and explore what’s new and improved before our AI coding “assistants” have all the fun, 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 game-changing JDK 24, visit Road to JDK 25, JDK 24. This is our last stop on our road exploring modern Java development. Let’s Go!

Tic-Tac-Toe

Let’s do this one last time!

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. As a Brit, I grew up calling this game “noughts-and-crosses”.

Our task has been to continue to progressively over-engineer tic-tac-toe (GitHub repo: here) focused primarily on finalized features from JEPs (Java Enhancement Proposals), whilst introducing best practices as our code base grows.


Introduction to JDK 25

Java 25 is here, and it feels like the language just got a double shot of caffeine like my brown oat milk shaken espresso! This release helps to make Java sharper, faster, and more fun to write.

We finally get Scoped Values — a smarter, cleaner way to share data across threads without the gotchas of ThreadLocal. Constructors loosen up their restrictions with flexible bodies, letting you validate or prep work before calling super()Compact source files cut down on the cruft for utilities, scripts, rapid protoypting, or language learning.

Under the hood, compact object headers and the new improvements like generational Shenandoah GC mean tighter memory usage and faster apps with improvements JFR profiling adding to our tools for monitoring and observability.

Finally, the removal of the 32-bit x86 port from the JDK feels like the end of a console generation but should mean new features roll-out faster after shedding the extra baggage.

JDK 25 feels like the Java we always wanted — more expressive, more efficient, and ready for pretty much any task we throw at it. If you haven’t already begun — it’s time to get on the road and upgrade!

The horizon shivers, and a new light fractures the sky. JDK 25 emerges, not as a tempest but as a finely honed blade, glinting with purpose.

He steps forward, boots sinking into the soil of change, and feels the weight of Scoped Values, their threads humming like well-tuned strings. Memory sighs in relief beneath the compact headers, and the air itself seems lighter, as if the Great Garbage Collector has swept away the old clutter.

Constructors bend freely, scripts shed their chains, and cryptography beats steady beneath his hands. No previews, no half‑measures — only tools forged and ready.

He breathes deep, eyes alight, heart steady, feeling the road behind him and the endless path ahead. “This is the summit,” he whispers. “The forge where our legacy is wrought. Onward, always onward.”


Features

Scoped Values

Scoped values from JEP 506 enable a method to share immutable data both with its callees within a thread, and with child threads, doing so without the unconstrained mutability, unbounded lifetimes, and expensive inheritance that come with using a ThreadLocal variable.

Example

In our game of tic-tac-toe, every Game has a gameId, which is a simple unique identifier for the game. During the evolution of the project we had added the gameId to the GameState object too, so that processes that only referenced a collection of those states had some additional context that could associate a GameState within the same game.

However, that pollutes the game state with extra information it does not need. This provides a good opportunity to correct that misstep by migrating the game context into a GameContext object, along with some useful additional context that we might want to associate with a game:

public record GameContext(  
    String id,  
    long createdAt,  
    Map<String, String> metadata  
)

In order to use it, it’s then just a case of creating a static ScopedValue context and then binding that context during the playtime of the game using ScopedValue.where().run():

Game.java
private static final ScopedValue<GameContext> gameContext =  
  ScopedValue.newInstance();  
  
// ...  
  
public void playWithAction(Consumer<Game> postMoveAction) {  
    ScopedValue.where(gameContext, newGameContext())  
        .run(  
            () -> {  
              // game running - context is also available to postMoveAction  
            }  
        )  
        // ...  
    }  
}  
  
public static Optional<GameContext> gameContext() {  
    return gameContext.isBound() ?  
        Optional.of(gameContext.get()) :  
        Optional.empty();  
}  
  
public static boolean isGameContextSet() {  
    return gameContext.isBound();  
}

This then enables any thread or child thread within the scope (run) of the context execution to retrieve the correct context e.g.

Game.gameContext().ifPresent(/* use it */)

all without any having to supply that context directly in method parameters or objects, or expose the use of ScopedValue to the callers of Game.


Compact Source Files and Instance Main Methods

Java has often been accused of being pretty verbose, or introducing too many concepts to users who are new to the language, or slow to script with. With this change in JEP 512, its Hello World, minimal program has gone from this:

public class Hello {  
  public static void main(String[] args) {  
    System.out.println("Hello, World!");  
  }  
}

to this:

void main() {  
  IO.println("Hello, World!");  
}

which brings it much closer to the minimal program available in most modern languages. It comes with a new java.lang.IO class with the static methods print()println()readln()readln(String).

Example

This slimmer, simpler approach is what we use in AppLite.java which makes a minimal run of the game look like this:

import org.xxdc.oss.example.Game;  
  
void main() throws Exception {  
  try (var game = new Game()) {  
    game.play();  
  }  
}

rather than this:

package org.xxdc.oss.example;    
    
public class AppLite {     
  public static void main(String[] args) throws Exception {    
    try (var game = new Game()) {  
      game.play();    
    }  
  }  
}

Introducing people to the language or writing scripts has become a lot more simple.


Module Import Declarations

Many of us avoid * package imports in favor of specific on-demand package imports aided by intelligent IDEs. However, in early stages of development and prototyping, or even scenarios like this where being succinct is preferable, simplicity trumps standards — it’s ideal to be able to pull in imports as easily as possible.

Since Java 9, modules have allowed a set of related packages to be grouped together for reuse under a single name. Module import declarations allow these to be imported easily either in jshell or a regular java source file.

E.g. import module java.base has the same effect as over 50 individual on-demand package imports.

Example

We introduce AppLiteHttp which makes a web call to retrieve a message from https://api.github.com/zen to include before running a game of tic-tac-toe, using the JDK web client. In JDK 25, the list of http imports required go from this:

package org.xxdc.oss.example;  
  
import java.net.URI;  
  
import java.net.http.HttpClient;  
import java.net.http.HttpRequest;  
import java.net.http.HttpResponse;  
  
public final class AppLiteHttp {  
  // ... removed for brevity  
}

to this when using import module java.net.http as part of JDK 25:

import org.xxdc.oss.example.Game;  
  
import java.net.URI;  
  
import module java.net.http;  
  
void main() {  
  final String url = "https://api.github.com/zen";  
  try (var client = HttpClient.newHttpClient()) {  
    var res =  
        client.send(  
            HttpRequest.newBuilder(URI.create(url))  
                .header("User-Agent", "overengineering-tictactoe/3.0")  
                .build(),  
            HttpResponse.BodyHandlers.ofString());  
    // ... simplified  
    int status = res.statusCode();  
    String message = status == 200 ? res.body() : ("status=" + status);  
    IO.println("[Lite] " + message);  
    // ...  
  }  
}

As a reminder modules are discoverable with java --list-modules. Similarly, you can discover the exports of a module using a java --describe-module command:

java --describe-module java.net.http  
  
java.net.http@25  
exports java.net.http  
requires java.base mandated  
contains jdk.internal.net.http  
contains jdk.internal.net.http.common  
contains jdk.internal.net.http.frame  
contains jdk.internal.net.http.hpack  
contains jdk.internal.net.http.websocket

Flexible Constructor Bodies

Best practices in OOP and specifically in Java have evolved over time. Inheritance was once King (or Queen), so deeply nested class inheritance hierarchies were fairly common.

However, nowadays excessive inheritance is discouraged and Java developers typically adopt patterns like DecoratorStrategyAdapter (GoF), preferable because inheritance can lead to brittle class hierarchies and complicated behavior changes. Newer languages even do away with inheritance only including traits or functional composition.

That said, 30 years later and in its prime, there are still times when we have to extend behavior through inheritance in Java. Previously, in the constructor, the first line in an extending class had to call the constructor of its super class using super(). If omitted it was assumed. So a constructor body was:

public class Classes {  
  
  class Fizz() {  
    Fizz() {  
      // Init Fizz  
    }  
  }  
  
  class Buzz extends Fizz() {  
    Buzz() {  
      super();  
      // epilogue: init Buzz  
    }  
  }  
}

The class Buzz extends Fizz (which implicitly extends fromObject)and runs the constructor from Fizz before that of Buzz.

With JEP 513’s flexible constructor bodies, alongside reasonable restrictions (e.g. like not referring to uninitialized variables) it’s possible to have a prologue before the call to super(), as well as the standard epilogue we have when initializing the extending class.

Example

Even though this is an over-engineered codebase I draw the line at deeply nested class hierarchies! However, we do have a GameServiceException which extends from RuntimeException since unchecked exceptions are best practice. This provides the opportunity for us to add some validation as part of our constructor prologue that was not possible in the same way before e.g. for this secondary constructor:

/**  
 * Constructs a new {@link GameServiceException} with the  
 * specified error message and cause.  
 *  
 * @param message the error message describing the exception  
 * @param cause the underlying cause of the exception  
 */  
public GameServiceException(String message, Throwable cause) {  
  super(message, cause);  
}

We can now add validation that the cause exists (without having to do so on the same line as the super() call):

/**  
 * Constructs a new {@link GameServiceException} with the  
 * specified error message and cause.  
 *  
 * @param message the error message describing the exception  
 * @param cause the underlying cause of the exception  
 */  
public GameServiceException(String message, Throwable cause) {  
  Objects.requireNonNull(cause);  
  super(message, cause);  
}
 

Key Derivation Function API

Key Derivation Functions (KDFs) generate secure key material from inputs like initial keys, a salt, and a pseudorandom function. They work in two phases: first, instantiation, where the KDF is initialized with parameters; second, derivation, where it produces derived keys or data based on the input material and specifications.

The new java class javax.crypto.KDF enables this standard capability for the Java platform with JEP 510. This includes the functionality to deriveKey or deriveData.

Two examples of KDFs are HKDF (HMAC-based KDF) and Argon2 which have different use cases:

HKDF, comes with a JDK 25 implementation, and is designed to be computationally efficient. It usually takes already-random or pseudorandom material (like Diffie-Hellman shared secrets) in order to extract entropy and expand into multiple keys quickly.

Argon2 which is a targeted for a future version of the JDK (already available via 3rd party libraries like Bouncy Castle), is deliberately computationally expensive and derives keys from human-memorable passwords. It has the goal of making brute-force attacks prohibitively expensive.

Example

Our over-engineered tic-tac-toe game has been secure from post-quantum cryptographic attacks since JDK 21 (since you never know).

If we wanted to roll our own implementation or integrate an external one into the API from another provider we can implement javax.crypto.KDFSpi much like we implemented javax.crypto.KEMSpi in JDK 21.

In our SecureDuplexMessageHandler we leverage the new KDF API using HKDF HKDF-SHA256. Adding an info enables us to include some versioning/api tagging that might differentiate it from another derived key.

private SecretKey getOrDeriveAesKey()  
      throws NoSuchAlgorithmException,  
             InvalidAlgorithmParameterException {  
  // Instantiate HKDF (SHA-256).  
  KDF hkdf = KDF.getInstance("HKDF-SHA256");  
  byte[] ikm = sharedKey.getEncoded();  
  byte[] info = "oe-ttt:aes-gcm:v1".getBytes();  
  var params = HKDFParameterSpec.ofExtract()  
    .addIKM(ikm)  
    .thenExpand(info, 32);  
  return hkdf.deriveKey("AES", params);  
}

Ahead-of-Time Command-Line Ergonomics & Ahead-of-Time Method Profiling

When using and AOT cache (since JDK 24) the production run of the application starts up faster because its classes don’t need to be discovered, loaded, or linked — they’re ready to go from the cache.

Additionally, normally the JVM spends some time at startup figuring out which methods run most often and compiling them to native code for better performance. With the JDK 25 AOT cache, those method profiles are already stored, so the JVM can skip this early work during the production run. The result: the application not only starts quicker but also reaches its best performance much sooner.

Previously building a cache was a more cumbersome multi-step process, first to create the AOT configuration and then to create the AOT cache. This can now be performed in one shot with AOTCacheOutput.

Example

We created scripts to create the AOT cache using the AppTrainer and then execute the production run of the application using the cache.

Step 1. AOT Cache Creation:

$ java -XX:AOTCacheOutput=app.aot \  
  -cp %classpath% \  
  org.xxdc.oss.example.AppTrainer

Step 2. Running the application using the AOT Cache

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

Well, that’s all folks! Take a deeper dive into the over-engineering tic-tac-toe codebase and the JDK docs to discover more. Remember:

“The epitome of sophistication is utter simplicity.”
— Maya Angelou

As you explore the brave new world of Java, remember this: complexity is easy, clarity is hard. Resist the urge to turn every project into an over-engineered tic-tac-toe extravaganza.

Sympathetic engineering is about gaining and using your understanding wisely — building products with code that’s elegant, efficient, and actually maintainable, while still leaving room for a little fun!

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 student or developer? Comment & share your own opinions, favorite learning and study resources, or book recommendations.
  • Follow my blog (or sympathetic engineering digital garden) for future updates on my sympathetic engineering exploits and professional / personal development tips.
  • Connect with @briancorbinxyz on social media channels.
  • Enjoyed what you read? I like coffee, buy me a coffee so I have an excuse to write more.
  • Subscribe!

But wait…

Beyond the peaks of JDK 25, the horizon shimmers with possibilities yet to be shaped. Vectors surge like lightning through the air, threads bind in perfect harmony, and unnamed innovations whisper promises of speed, clarity, and power.

Amid this electric landscape, a presence stirs — neither fully human nor machine, an AI sentinel woven into the shadows of the storm. Its gaze follows him, silent and inscrutable, friend or foe unknown.

He breathes in the charged air, senses sharpened, and whispers, “The road continues… and we will meet it, one line of code at a time.”