Road to JDK 25 — Over-Engineering Tic-Tac-Toe (Java 21)!
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 3 of the Multi-Part Series: Road to JDK 25 — Over-Engineering Tic-Tac-Toe. For a discussion of features introduced in JDK 18–20 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.
Introduction to JDK 21
JDK 21. After a few barren releases now we ride! Features galore and long-term support. This is where most of us should be sat in enterprise before we reach JDK 25. Features include Pattern Matching for switch, which allows for more complex and readable data handling; Record Patterns, enabling concise matching against record values; Sequenced collections, and a key encapsulation mechanism API. Additionally, Virtual Threads provide a lightweight and scalable approach to concurrency, making it easier to write concurrent applications.
In the wasteland of the Java apocalypse, a grizzled developer revs up his rusty JDK 19 machine, sputtering across the barren landscape. Suddenly, a chrome-plated behemoth roars past him, leaving him in a cloud of dust.
“What in the null pointer exception is that?” he cries.
A voice booms from the passing juggernaut: “Witness JDK 21, shiny and chrome! We ride eternal to Valhalla, all features and glory!”
The old dev gapes as he watches the JDK 21 war rig disappear over the horizon, trailing streams of virtual threads and pattern matching switch expressions.
He mutters to himself, “So that’s where all the features went. JDK 19 and 20 were just… guzzoline for this beast!”
In honor of hitting a LTS version, we’re removing the previously deprecated
LegacyPlayer
that we introduced for Context-Specific Deserialization Filters (It will remain in the previous branches of the repository).
It’s worth a mention here that Windows 32-bit x86–32 JVM is deprecated from JDK 21 onwards. That typically means if you’re still using JNI and wrapping Windows 32-bit libraries then you’ll have to wrap and expose the functionality to your APIs some other way. JDK also strengthens integrity by disallowing the dynamic loading of agents which could be use to inject code / behavior without an explicit startup javaagent
or agentlib
being explicitly selected.
Sequenced Collections
The Java collections framework had several collections with a defined encounter order (e.g. List, LinkedHashset, etc.), but lacked a unified type to represent them. Therefore, certain operations on these collections could be inconsistent or absent, and processing elements in reverse order is often inconvenient or even impossible. This is where SequencedCollection
(and SequencedSet
, SequencedMap
) comes in.
Example
The game currently maintains a list of players which can be one of HumanPlayer
or BotPlayer
. Typically, in tic-tac-toe they can only be one of O
or X
(noughts or crosses) but this is over-engineered to one day allow more than those. However, as it's implemented with a list, technically I could have two BotPlayer
s with O
and one HumanPlayer
with X
in the player list and that would give the Bot two turns, to the human player's one turn - a more specialized collection is needed to resolve issues like that.
The new class PlayerList
captures the players and their selection and maintains both a List
for iterating through by index and a SequenedSet
for validating players with set semantics in the order they were added. (This could also have been a SequencedMap
)
private final List<Player> players;
private final SequencedSet<String> playerMarkers;
private int playerIndex;
...
public PlayerList() {
this.players = new ArrayList<Player>(2);
this.playerMarkers = new LinkedHashSet<String>(2);
this.playerIndex = 0;
}
public void tryAddPlayer(Player player) {
if (!playerMarkers.contains(player.getPlayerMarker())) {
players.add(player);
playerMarkers.add(player.getPlayerMarker());
} else {
throw new RuntimeException("Unable to add player " + player + " as player with marker '" + player.getPlayerMarker() + "' already exists.");
}
}
The above method tryAddPlayer
can now reject sets of players that have duplicate markers for tic-tac-toe.
Record Patterns and Pattern Matching for Switch
Many languages provide great pattern matching capabilities these days, e.g. Scala, Gleam — it’s generally in vogue and is particularly useful for deconstructing records, avoiding branching and extracting fields we care about. JDK 21 builds upon prior work in JDK 16 to allow nested pattern matching capabilities that lead to cleaner code.
Example
In our tic-tac-toe game we added a close
method on Game
and PlayerList
the latter calling close
if the Player
instance had implemented AutoCloseable
. Since, JDK 16 this can be rewritten more succinctly from:
public void close() throws Exception {
for (Player p : players) {
if (p instanceof AutoCloseable) {
((AutoCloseable) p).close();
}
}
}
to:
public void close() throws Exception {
for (Player p : players) {
if (p instanceof AutoCloseable ac) {
ac.close();
}
}
}
What’s far more interesting than any of that, though, is doing a bit of a refactor getting rid of all of that and turning all the Player
classes we built in our Sealed Classes hierarchy into record
types so that we can both simplify our classes which had mostly final
fields anyway, as well as take advantage of pattern matching. Conveniently, the PlayerPrinter
class has a method ripe for a rewrite here. This allows us to go from:
private String playerToType(Player player) {
return switch (player.getClass().getSimpleName()) {
case "HumanPlayer" -> "Human";
case "BotPlayer" -> "Bot";
case "LegacyPlayer" -> "Legacy";
default -> "Unknown";
};
}
to:
private String playerToType(Player player) {
return switch (player) {
case HumanPlayer h -> "Human";
case BotPlayer b -> "Bot";
};
}
which is both shorter, safer, and exhaustive. With an extra tweak we can go one step further to deconstruct the Player
classes to extract the playerMarker
.
private String playerToType(Player player) {
return switch (player) {
case HumanPlayer(String playerMarker) -> "Human" + " (" + playerMarker + ")";
case BotPlayer(String playerMarker, RandomGenerator r) -> "Bot" + " (" + playerMarker + ")";
};
}
which yields the following to the console:
- TicTacToeClient/1.0 [Human (X)] (IP: 127.0.0.1; Host: www.example.org; Java: 21.0.3; OS: Mac OS X 14.5)
- TicTacToeClient/1.0 [Bot (O)] (IP: 127.0.0.1; Host: www.example.org; Java: 21.0.3; OS: Mac OS X 14.5)
We’re not yet done, though! As the switch pattern matching has become much more advanced we can replace methods with a bunch of if
statements into something more readable. E.g. From our persistence Context-Specific Deserialization Filters:
public Status checkInput(FilterInfo filterInfo) {
if (filterInfo.references() > MAX_REFERENCES) {
return Status.REJECTED;
}
if (null != filterInfo.serialClass()) {
return Status.ALLOWED;
}
return Status.UNDECIDED;
}
becomes:
public Status checkInput(FilterInfo filterInfo) {
return switch (filterInfo) {
case FilterInfo fi when fi.references() > MAX_REFERENCES -> Status.REJECTED;
case FilterInfo fi when fi.serialClass() != null -> Status.ALLOWED;
default -> Status.UNDECIDED;
};
}
Generational ZGC
The ZGC was introduced as far back as JDK11 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.
If high-performance computing environments if you’re not using ZGC it’s likely because you’re still stuck on Java 8, or just using G1, or you’re using an alternative JDK entirely like Azul which has the C4 (Continuously Concurrent Compacting Collector). If you are not then you might get some extra performance and consistency for free by switching.
Until JDK21 the ZGC was single-generational, so all allocated objects were treated the same regardless of age meaning more objects to inspect for liveness ergo potentially longer pauses. Multi-generational garbage collection takes advantage of the common use case: most created objects are short-lived. This means more frequent efficient collection of short-lived objects, less frequent full GCs, all with no need to configure space sizes.
Example
Our small tic-tac-toe game doesn’t have a big reason at present to tune the garbage collector. Starting it up with -XX:+UseZGC -XX:+ZGenerational
is enough to benefit from the ZGC, though.
Virtual Threads
Remember when reactive programming was going to replace the old way of doing things and we went ahead and signed the reactive manifesto for creating applications that were responsive, resilient, elastic and message driven? I do, I signed it 10 years ago! The principles remain relevant until this day but increasingly it’s becoming possible to follow those principles without stepping too far out of your programming comfort zone of sequential, synchronous-style coding.
Virtual threads are lightweight threads managed by the JVM rather than the operating system. They enable highly scalable and efficient concurrency by allowing millions of threads to be created and managed with minimal overhead. They’re designed to be extremely fast to create, start, and context-switch, making them ideal for handling massive numbers of concurrent tasks in Java applications. Similar design goals have benefited go (with goroutines), erlang (with processes) and others.
JEP 444 describes the problem we’re trying to solve well:
The scalability of server applications is governed by Little’s Law, which relates latency, concurrency, and throughput: For a given request-processing duration (i.e., latency), the number of requests an application handles at the same time (i.e., concurrency) must grow in proportion to the rate of arrival (i.e., throughput).
In order to do this Java steps away from binding Java threads 1-to-1 with OS threads and allows these more plentiful virtual threads to be suspended with non-blocking OS calls transparently to the user, enabling high throughput without a change in programming paradigm, or even pooling. They bring benefits when:
- The number of concurrent tasks is high (i.e. more than a few thousand)
- The workload is not CPU-bound — more threads than processor cores cannot improve throughput for this use case
Example
Until now, our game of tic-tac-toe would run on a single machine within a single JVM. In order to utilize a threading model, after a bit of a refactor to configure persistence on/off, we introduce a GameServer
and GameClient
which simply use TCP (Java Sockets) to communicate with the server holding the game state. The server spawns virtual threads for each connecting client to simulate a game and the client spawns virtual threads that connect to the server and respond to the server which prompts it for the next move in the game. A specialized Player
(RemoteBotPlayer
) was added to the hierarchy to support the remote connection.
The virtual threads are spawned in the server by submitting tasks to an ExecutorService
:
GameServer server = new GameServer();
try (
ServerSocket serverSocket = new ServerSocket(args.length > 0 ? Integer.parseInt(args[0]) : 9090, 10000);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
) {
serverSocket.setSoTimeout(CONNECTION_TIMEOUT);
System.out.println("Starting game server at " + serverSocket);
server.listenForPlayers(executor, serverSocket);
executor.shutdown();
executor.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES);
}
...
This allows us to scale with unlimited virtual threads to levels of concurrency that are harder to achieve by setting fixed numbers of platform threads with NIO.
private void listenForPlayers(ExecutorService executor, ServerSocket serverSocket) throws IOException {
while(true) {
Socket socketPlayerOne = serverSocket.accept();
Socket socketPlayerTwo = serverSocket.accept();
executor.submit(() -> {
try (
var playerX = new RemoteBotPlayer("X", socketPlayerOne);
var playerO = new RemoteBotPlayer("O", socketPlayerTwo)
) {
System.out.println(updateStatsAndGetConcurrentGames() + " concurrent games in progress.");
Game game = new Game(3, false, playerX, playerO);
game.play();
} catch (Exception e) {
System.out.println(e);
throw new RuntimeException(e);
} finally {
concurrentGames.decrement();
}
});
}
}
Hopefully when we visit StructuredTaskScope
in future, this code will look less convoluted, and we can handle errors in a cleaner, more structured way.
Key Encapsulation Mechanism API
A key encapsulation mechanism (KEM) is a modern public-key encryption system designed to succeed traditional public-private key encryption systems by providing enhanced security against eavesdropping and interception by malicious actors. Prior to JDK 21, there was no standardized Java API to support KEMs.
In a standard (or pre-quantum) cryptographic public-key encryption system, anyone who has the public key can encrypt a message, yielding a ciphertext. Only those who know the corresponding private key can decrypt the ciphertext to recover the original message. The problem with this is that algorithms and brute-force attacks enabled by quantum technology will compromise these systems. Once compromised, all past encrypted communications that have used the same public-private key pair could also be compromised.
KEMs address this vulnerability by allowing a sender who knows a public key to simultaneously generate a short random secret key (or shared session key) and an encapsulation of the secret key using the KEM’s encapsulation algorithm. The receiver, who knows the private key corresponding to the public key, can recover the same secret key from the encapsulation using the KEM’s decapsulation algorithm. This process can utilize post-quantum cryptographic algorithms, making KEMs resistant to quantum attacks. The securely exchanged session key can then be used for encrypting the actual message with symmetric encryption, which is not susceptible to quantum attacks.
Example
In JDK 21, KEM key providers implement the KEMSpi
interface, which provides the necessary methods for encapsulation and decapsulation processes. This interface is used alongside the KeyPairGenerator
API which facilitates the generation and management of the key pairs needed for KEMs, integrating seamlessly into the existing Java cryptographic architecture.
Nothing says over-engineering more than using post-quantum cryptographic techniques to secure a game of tic-tac-toe. SSL/TLS is simply not strong enough to protect our game of tic-tac-toe in post-quantum computing era.
The journey to integrating this in the game took me on a side quest through Star Wars (Kyber), Star Trek (Dilithium) and a bunch of other videos and papers to understand post-quantum cryptographic techniques. I highly recommend the YouTube series from “Chalk Talk” — the topic probably deserves a post of its own.
We introduce a class SecureMessageHandler
which uses Kyber post-quantum cryptography to securely exchange a SecretKey
which then used with an AES/CBC
cipher for symmetric encryption of messages sent between the client and server.
Note: as of the time of writing there were no third-party implementations of PQC that used the
KEMSpi
from JDK21 so I had to implement it myself using a JDK 18 version of bouncy castle cryptographic APIs in order to implement the code in the style of the API in the JEP.
The main methods of the new KyberKEMSpi
introduced are for the generation, encapsulation and decapsulation of the shared key:
@Override
public Encapsulated engineEncapsulate(int from, int to, String algorithm) {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("Kyber", "BCPQC");
KEMGenerateSpec kemGenerateSpec = new KEMGenerateSpec(publicKey, parameterSpec.getName());
if (random != null) {
keyGenerator.init(kemGenerateSpec);
} else {
keyGenerator.init(kemGenerateSpec, random);
}
SecretKeyWithEncapsulation key = (SecretKeyWithEncapsulation) keyGenerator.generateKey();
return new Encapsulated(key, key.getEncapsulation(), KyberParams.byKyberParameterSpec(parameterSpec).encode());
} catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) {
throw new UnsupportedOperationException(e);
}
}
@Override
public SecretKey engineDecapsulate(byte[] encapsulation, int from, int to, String algorithm)
throws DecapsulateException {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("Kyber", "BCPQC");
KEMExtractSpec kemExtractSpec = new KEMExtractSpec(privateKey, encapsulation, parameterSpec.getName());
keyGenerator.init(kemExtractSpec);
return keyGenerator.generateKey();
} catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) {
throw new DecapsulateException("Failed whilst decapsulating.", e);
}
}
The IO of of the client/server is then refactored to use a MessageHandler
which uses a secure implementation SecureMessageHandler
that handles the public key exchange, shared key generation and exchange, and message encryption/decryption. The main methods related to to the key encapsulation mechanism are the server side, and client side key generation and exchange:
// 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 encapsulatedParams = handler.receiveBytes();
var kem = KEM.getInstance("Kyber", "BCPQC.KEM");
var params = AlgorithmParameters.getInstance("Kyber");
params.init(encapsulatedParams);
var paramSpec = params.getParameterSpec(KyberParameterSpec.class);
var decapsulator = kem.newDecapsulator(keyPair.getPrivate(), paramSpec);
return decapsulator.decapsulate(encapsulated);
}
private KeyPair generateKeyPair() throws NoSuchAlgorithmException, IOException {
var keyPairGen = KeyPairGenerator.getInstance("Kyber");
var keyPair = keyPairGen.generateKeyPair();
return keyPair;
}
// Client
protected SecretKey exchangeSharedKey() throws NoSuchAlgorithmException, NoSuchProviderException, ClassNotFoundException, IOException, InvalidAlgorithmParameterException, InvalidKeyException {
var kem = KEM.getInstance("Kyber", "BCPQC.KEM");
var publicKey = retrieveKey();
var paramSpec = KyberParameterSpec.kyber1024;
var encapsulator = kem.newEncapsulator(publicKey, paramSpec, null);
var encapsulated = encapsulator.encapsulate();
handler.sendBytes(encapsulated.encapsulation());
handler.sendBytes(encapsulated.params());
return encapsulated.key();
}
Phew! That’s a wrap for JDK 21! Next up in this series… Over-Engineering Tic-Tac-Toe in JDK 22 — we’re not slowing down...
NB: Ahead of adding features for the next JDK release I took the extra liberty of applying some code styling to the codebase at this point due to its growth using Spotless and the AOSP (android open source project) styling, which is my personal preference for Java projects.
Also, since we introduced using third-party APIs as implementations for JDK facades and service providers with the KEM Spi, I also introduced JDK 9 Platform Logging (from JEP 264) over an SLF4J to Logback bridge, to avoid all of those unwieldy
System.out.println
calls in a way that still allows us to change the logging implementation at will. I'm of the opinion, though, that theSystem.Logger
API needs to change sincelog.log(Level.INFO,...)
, is just not as simple and clean as simply writinglog.info(...)
and developers shouldn't have to wrap that functionality to get it.
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) for future updates on my engineering exploits and professional / personal development tips.
- Connect with @briancorbinxyz on social media channels.
- Subscribe!