Abstract

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 index1 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-toe2!

Java: Loved and Hated

Quote

”There are only two kinds of languages: the ones people complain about and the ones nobody uses” - Bjarne Stroustrup (Creator of C++)

For all of the complaints, there’s still a lot to love about Java. It has earned a place on the the Mount Rushmore of programming languages and when its time comes to pass the torch to a truly worthy general purpose language successor it will but that time is not now, nor will it be for some time. If you’re an experienced Java developer who had to code through years of stagnant features and are were enviously at other languages or even a new engineer wondering if there’s life in the old beast yet, come and take a sip as we explore the new features and enhancements that have come and are still coming thick and fast over recent years.


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 tic-tac-toe repository started off with a simple JDK 16-compatible implementation and is available here: https://github.com/briancorbinxyz/overengineering-tictactoe/tree/jdk16 with minimal support for and X by X game of tic-tac-toe using a one-dimensional array with sequential checks for a winner between a human vs. unintelligent (read: it picks a spot at random) bot.

As we update our code we (at least mostly) use the typical OpenJDK reference implementation from jdk.java.net via SDKMan3 though we may dip into some performance implementations like Azul Builds for comparison. We’ll also focus primarily on finalized features rather than those still in preview prior to v25. We will also introduce best practices as the code base grows.


Introduction to JDK 17

JDK 17 introduced a number of interesting features4, including Sealed Classes, kicking off improvements to Switch pattern matching. It also removed/deprecated a few that I can’t imagine anyone will miss like Applet API support5, the Security Manager6 the latter of which was a pain to use and for all the effort failed to provide protection against 80% of the top security threats7, as well as RMI8 (remote-method-invocation) which has effectively been replaced with web-technologies and micro-services.

Features

Sealed Classes

Sealed Classes9 offer more control over the class hierarchy, enabling you to specify which classes or interfaces can extend or implement them. This allows you to maintain a controlled and predictable class hierarchy which is especially useful for exhaustive pattern matching and reflection.

Example

In our game of tic-tac-toe we had a simple interface Player which could be one of HumanPlayer or BotPlayer. We can seal the hierarchy by using the permits clause, and therefore disallowing any new extensions like AlienPlayer, or JoePlayer10 making it possible to reason on our fixed set of known Player types.

public sealed interface Player permits HumanPlayer, BotPlayer {
	String getPlayerMarker();
    int nextMove(GameBoard board);
}

This means that the classes themselves also need to be sealed by making them final:

The class HumanPlayer with a sealed direct superclass or a sealed direct superinterface Player should be declared either final, sealed, or non-sealed

public final class BotPlayer implements Player {
   ...
}
 
public final class HumanPlayer implements Player {
   ...
}
 

Now, if someone tries to extend Player (directly) with a new type not included in our permits clause there will be an error:

The type AlienPlayer that implements a sealed interface Player should be a permitted subtype of Player

Altogether, this method of sealing the hierarchy helps us to abide by the principle “design for extension, or else prohibit it.”11 which, in practice, is more pragmatic than the open-closed principle12, certainly at the class-level.


Enhanced Pseudo-Random Number Generators

Essentially, prior to JDK 17 the random number generators were not easily swappable and didn’t take advantage of newer features and programming models in java like stream processing. Various random number generators are available that provide enhanced statistical levels of random distribution, better quality, scalability, or the ability to hop/skip/jump over generated numbers, all of these now implement the RandomGenerator interface and if we desire we can create our own.

Example

In our game of tic-tac-toe our bot (BotPlayer) currently selects a random available location for its next move. Swapping out the field:

    private final Random random;

for:

    private final RandomGenerator random;

allows us to code to the new interface. It wouldn’t be over-engineered, though, if we didn’t go the lengths of making it cryptographically secure by default and specifiable in its constructor.

public BotPlayer(String playerMarker, RandomGenerator randomGenerator) {
	this.playerMarker = playerMarker;
	this.random = randomGenerator;
}
 
public BotPlayer(String playerMarker) {
	this(playerMarker, new SecureRandom());
}

This is less over-engineered for this case than grabbing a stream of random.ints() upfront and taking the first valid move (int) it spits out (don’t tempt me - I’ll do it!).


AOT Compilation Removal

The experimental AOT (ahead-of-time) compiler GRAAL was removed13 from the standard build because it was a lot of a work to maintain vs. the usage it was getting. We may want to still be able to use this feature to be able to start our applications more quickly or take advantage of other benefits of ahead-of-time compilation.

Example

Per the JEP:

Developers who wish to use the Graal compiler for either AOT or JIT compilation can use GraalVM

This is easily done by setting up our Gradle build to use it, which will give us the task gradlew nativeCompile which will build an executable (which itself leverages the native-image tool from the distribution).

build.gradle.kts:

plugins {
    ...
    id("org.graalvm.buildtools.native") version "0.10.2"
}
 
repositories {
    ...
    gradlePluginPortal()
}
 
// Allow GraalVM native AOT compilation
graalvmNative {
    binaries {
        all {
            javaLauncher = javaToolchains.launcherFor {
                // NB: On MacOS ARM ARCH (Silicon) the native-image implementation is not available
                // for the versions of GRAAL_VM Community edition - selecting Oracle
                languageVersion = JavaLanguageVersion.of(22)
                vendor = JvmVendorSpec.matching("Oracle")
                // languageVersion = JavaLanguageVersion.of(17)
                // vendor = JvmVendorSpec.GRAAL_VM
            }
        }
    }
}

Executed:

 ./gradlew nativeCompile
 
BUILD SUCCESSFUL in 425ms
4 actionable tasks: 4 up-to-date
 
 
 ls ./app/build/native/nativeCompile/app
 
./app/build/native/nativeCompile/app

Context-Specific Deserialization Filters

Now this isn’t the most sexy sounding feature but if you’ve lost vacation dealing with zero-day vulnerabilities and exploits then you will at least understand the motivation which is to provide a level of protection against deserialization vulnerabilities. This feature is used by implementing an ObjectInputFilter

Example

Even writing the line implements Serializable feels like a relic of the past since any sane engineer would be using something like json, avro, protocol-buffers, or some other object serialization flavor of their choice to serialize or persist data, but this is over-engineering java after all so let’s go!

First of all we make all the game objects Serializable and introduce a new class that can load the game state (that is now persisted after each turn) at startup.

public class GamePersistence {
	public void saveTo(File gameFile, Game game) throws IOException {
		try (
			FileOutputStream os = new FileOutputStream(gameFile);
			ObjectOutputStream o = new ObjectOutputStream(os)
		) {
			o.writeObject(game);
		}
		System.out.println("[Saved to " + gameFile + "]");
	}
 
	public Game loadFrom(File gameFile) throws IOException, ClassNotFoundException {
		try (
			FileInputStream is = new FileInputStream(gameFile);
			ObjectInputStream o = new ObjectInputStream(is)
		) {
			o.setObjectInputFilter(new GamePersistenceFilter());
			return Game.class.cast(o.readObject());
		}
	}
 
	private static class GamePersistenceFilter implements ObjectInputFilter {
		// OVER-ENGINEER Reject any loaded classes games > 1000 object references
		private static final long MAX_REFERENCES = 1000;
 
		public Status checkInput(FilterInfo filterInfo) {
			if (filterInfo.references() > MAX_REFERENCES) {
				return Status.REJECTED;
			}
 
			if (null != filterInfo.serialClass()) {
				if (LegacyPlayer.class.equals(filterInfo.serialClass())) {
					return Status.REJECTED;
				}
				return Status.ALLOWED;
			}
			return Status.UNDECIDED;
		}
	}
}

So when we deserialize i.e. convert from a file via an ObjectInputStream into our Game object we can choose to reject deserialization any game objects with a contrived number (1000) of object references or any that use a LegacyPlayer (added for this purpose to our sealed Player hierarchy) which we no longer support.


Introduction to JDK 18

Java 18 introduces several notable features14 aimed at enhancing developer productivity, performance, robustness and modernity. Among these are the introduction of UTF-8 as the default charset, which improves consistency across different environments, a new Simple Web Server was added for testing and development purposes. The deprecation of the finalization mechanism marked a significant step towards improving resource management in Java which had multiple critical flaws15. Additionally, Java 18 included enhancements in the foreign function and memory API, allowing for safer and more efficient interactions with non-Java code and memory.

Also of note are some changes to internals, notably core reflection now using java.lang.invoke method handles, the reflection API remains the same but there are some marginal performance differences16.

Features

UTF-8 by Default

In prior versions of Java the default charset could be different on different operating systems17 and was determined on JVM startup, which could lead to files becoming corrupted on read. There was also a lack of consistency about which character set would be used (the default) or specifically the most common on the world-wide-web: UTF-8. In general, the added benefit is less verbosity with character reading/writing and the ability to use default method references (::) rather than overloads. Python made a similar (more painful) transition when it went from v2 to v3.

The potential issue is that running with JDK 18+ without some flags may lead to files saved with a prior JDK under a different default character set not being loaded properly. In this case the JVM can be run with -Dfile.enconding=COMPAT. To retrieve and use the native encoding of the system the property System.getProperty("native.encoding") can be submitted to Charset::forName.


Simple Web Server

This is a simple command-line tool jwebserver which starts up a minimal web server which serves static files18. If used via the API it can also be enhanced to use custom handlers.

As a long-time engineer this is great - especially when accessing a server/box to retrieve logs/files is a pain in the neck. The Java developer who wanted to do this previously would have to dip into their Python bag of tricks e.g. python -m SimpleHttpServer 9090 (python 2) or python -m http.server 9090 (python 3) to open a file server at port 9090. Now this is possible with just Java tools. (In fact under the hood it does something similar: java -m jdk.httpserver)

Example

Our tic-tac-toe game persists our game state to a number of files in a specific directory, if we wanted to pull them locally for inspection we could simply start a web server on the host in that directory and access it locally or via curl.

E.g. jwebserver -p 9090:

 cd /var/folders/jx/4clcmgkx18775h6rpcs7vnyw0000gn/T/d0ec9336-5be8-4ff3-a567-f8b00b02db3f13158585413888981846/
 ls -l
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:17 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.1.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:17 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.2.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:20 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.3.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:20 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.4.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:23 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.5.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:23 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.6.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:27 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.7.game
.rw-r--r-- briancorbin staff 12 KB Tue Jul 16 20:56:27 2024 d0ec9336-5be8-4ff3-a567-f8b00b02db3f.8.game
 jwebserver -p 9090
Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::".
Serving /private/var/folders/jx/4clcmgkx18775h6rpcs7vnyw0000gn/T/d0ec9336-5be8-4ff3-a567-f8b00b02db3f13158585413888981846 and subdirectories on 127.0.0.1 port 9090
URL http://127.0.0.1:9090/
127.0.0.1 - - [16/Jul/2024:22:29:31 -0400] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [16/Jul/2024:22:29:44 -0400] "GET /d0ec9336-5be8-4ff3-a567-f8b00b02db3f.4.game HTTP/1.1" 200 -

Directory listing at http:///localhost:9090:


Code Snippets in Java API Documentation

Including code examples in API documentation has generally been a bit of a pain in the past, especially so if those are more complex code examples. This introduces the ability to add both inline and external code snippets as examples in Javadocs, as well as highlight specific regions and substrings of those snippets.

Example

Our tic-tac-toe game previously had almost no documentation because I knew this was coming. I kid, it had no documentation because of lazy developer syndrome: Although, in practice and in my defense, IRL I generally tend to at least document interfaces and entry points. Seriously though, this is the type of work that AI is for!

Adding in some documentation including the tag @snippet which is throughly documented in JEP 41319 will give nice snippets in generated java docs - which will please the library creators and their clients.

Player.java:

/**
* Tic-tac-toe player interface for all players
* {@snippet :
* // Create a human player
* Player player = new HumanPlayer("X"); // @highlight region="player" substring="player"
*
* // Choose the next valid move on the game board
* int validBoardLocation = player.nextMove(gameBoard); // @end
* }
*/
public sealed interface Player permits HumanPlayer, BotPlayer, LegacyPlayer {
	String getPlayerMarker();
	int nextMove(GameBoard board);
}

Which looks like the below in the JavaDocs:


Internet-Address Resolution SPI

Previously, if you wanted to do an internet address lookup using the standard InetAddress static functions then you were stuck waiting on a blocking call to the operating system to use the local DNS and internet lookup call. This new feature20 allows us to specify / override the default with our own setup and configuration by using our own service provider and implementation. This is great for testing.

Example

Given we didn’t really have a use for InetAddress in the current implementation I contrived and over-engineered one - printing a user agent-style string at the start of the game identifying both players:

Welcome to Tic-Tac-Toe!
- TicTacToeClient[Human]/1.0 'X' (IP: 127.0.0.1; Host: corbinm1mac.local; Java: 18.0.2.1; OS: Mac OS X 14.5)
- TicTacToeClient[Bot]/1.0 'O' (IP: 127.0.0.1; Host: corbinm1mac.local; Java: 18.0.2.1; OS: Mac OS X 14.5)

This leverages InetAddress functions as follows:

InetAddress localHost = InetAddress.getLocalHost();
String ipAddress = localHost.getHostAddress();
String hostName = localHost.getHostName();
 
return String.format("TicTacToeClient[%s]/1.0 '%s' (IP: %s; Host: %s; Java: %s; OS: %s %s)", playerToType(player), player.getPlayerMarker(), ipAddress, hostName, javaVersion, osName, osVersion);

The SPI (service provider interface) comes in handy to override the default behavior via the implementation class InetAddressResolver and the factory class InetAddressResolverProvider, which is registered in META-INF/services as a java.net.spi.InetAddressResolverProvider. The implementation overrides the default address resolution resolving every ip to 127.0.0.1 and every address to www.example.org masking the true ones:

ExampleOrgInetAddressResolver:

public class ExampleOrgInetAddressResolver implements InetAddressResolver {
	@Override
	public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
		throws UnknownHostException {
		return Stream.of(InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }));
	}
 
	@Override
	public String lookupByAddress(byte[] addr) {
		return "www.example.org";
	}
}

ExampleOrgInetAddressResolverProvider:

public class ExampleOrgInetAddressResolverProvider extends InetAddressResolverProvider {
	@Override
	public InetAddressResolver get(Configuration configuration) {
		return new ExampleOrgInetAddressResolver();
	}
 
	@Override
	public String name() {
		return "Example.org Inet Address Resolver Provider";
	}
}

This results in:

Welcome to Tic-Tac-Toe!
- TicTacToeClient[Human]/1.0 'X' (IP: 127.0.0.1; Host: www.example.org; Java: 18.0.2.1; OS: Mac OS X 14.5)
- TicTacToeClient[Bot]/1.0 'O' (IP: 127.0.0.1; Host: www.example.org; Java: 18.0.2.1; OS: Mac OS X 14.5)

Deprecation of Finalization for Removal

The Java finally clause was never a catch all for ensuring resource cleanup for everything you might be holding onto after you were done with it. If you had multiple resources to clean up there was still a chance it wouldn’t happen properly or in a timely way or there would be some error/exception in between resource closing calls leaving one or more unclosed. So we’d develop a finalize method on an object but unfortunately without any real control over when it would be called.

Per the JEP15 :

Maintainers of libraries and applications that rely upon finalization should consider migrating to other resource management techniques such as the try-with-resources statement and cleaners.

Example

The deprecation of finalization calls for the use of existing features like try-with-resources which will auto close AutoCloseable classes (which we already use in the tic-tac-toe codebase):

public void saveTo(File gameFile, Game game) throws IOException {
	try (
		FileOutputStream os = new FileOutputStream(gameFile);
		ObjectOutputStream o = new ObjectOutputStream(os)
	) {
		o.writeObject(game);
	}
	System.out.println("[Saved to " + gameFile + "]");
}

In the above, the FileOutputStream and ObjectOutputStream are automatically closed when they go out of context without any explicit calls to close().

More, interesting is using Cleaner. This sits atop of java phantom references which allow you to take action just before an object is garbage collected. At present in the HumanPlayer character we’re holding a transient field to accept user input private transient Scanner io = new Scanner(System.in). The IDE doesn’t love that we never close it though:

Resource leak: 'io' is never closed

So we resolved that by tweaking how the app runs making the Game and the HumanPlayer implement AutoCloseable and running the game in a try-with-resources block to deterministically ensure the resources are cleaned up.

public void run() throws Exception {
	try (var game = new Game()) {
		game.play();
	};
}

HumanPlayer.java snippet:

private static final Cleaner cleaner = Cleaner.create();
 
private final transient Cleaner.Cleanable cleanable = cleaner
	.register(this, () -> {
		if (io != null) {
			io.close();
			io = null;
		}
	});
 
@Override
public void close() throws Exception {
	cleanable.clean();
}

So, now we are really sure io gets cleaned up both with the explicit use of try-with-resources and should someone happen to forget to use that or the close() method explicitly then our cleanup will occur for sure just before garbage collection. It would, of course, have more value if we were holding onto a native/foreign resource outside of the JVM memory model which is typically what we would have if we were previously using finalize().


Introduction to JDK 19

Java 19 was not a particularly exciting release21 (unless you include incubating/preview features). So instead I asked an AI to come up with a joke about the lack of new features instead. It was actually pretty good:

Quote

A Java developer walks into a bar and orders a JDK 19. The bartender hands them a glass with just a few drops in it.

”What’s this?” asks the developer. “I ordered a full JDK 19!”

The bartender replies, “Sorry, most of it is still in preview. But don’t worry, it’s a RISC-V you’re willing to take!”

As an enterprise guy, it’s not a “risk” I’m going to take on new features just yet (and I’m already using a RISC-compatible AARCH JDK variant), so stay tuned for JDK 21 where most of the quite exciting preview/incubating features get finalized.

Introduction to JDK 20

Ah, Java 2022… the release full of, um, nothing to see (yet). I’m starting to see now that the primary purpose is AI is not to take our jobs or to write our code. It’s to simply tell a few jokes to keep us sane:

Quote

A Java developer excitedly upgrades to JDK 20 from JDK 19, hoping for groundbreaking features. They call their colleague and say, “Hey, I just installed JDK 20! Ask me anything it can do!”

The colleague asks, “Okay, what can it do?”

The developer replies, “Anything JDK 19 can do!”

The colleague sighs, “So… not much then?”

The developer chuckles, “Hey, at least it’s a short learning curve!”


Introduction to JDK 21

JDK 2123. 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.

Quote

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!”

Warning

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).

Features

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 way24. JDK also strengthens integrity by disallowing the dynamic loading of agents25 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 in26.

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 BotPlayers 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 capabilities2728 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)29. 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 sizes30.

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 driven31? 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.

The JEP32 describes the problem we’re trying to solve well:

Quote

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 KEMs25.

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 (Kyber33), Star Trek (Dilithium34) and a bunch of other videos and papers to understand post-quantum cryptographic techniques. I highly recommend the YouTube series from “Chalk Talk”35 - 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 APIs1 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();
}

That's a wrap for JDK 21!

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 Spotless36 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)37 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 the System.Logger API needs to change since log.log(Level.INFO,...), is just not as simple and clean as simply writing log.info(...) and developers shouldn’t have to wrap that functionality to get it. The other issue is that


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 allows us to interoperate with code and data outside of the runtime and heap.

Features

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 precompiling38 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 command39 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..

before when 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 Key Encapsulation Mechanism 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 patterns40 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 expected41 or 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”42 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.43

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 2244 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 API45 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 package46 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 MethodHandle47 and VarHandle48, which were used to re-implement reflection way back in JDK 1816 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.”


Introduction to JDK 23

JDK 23 makes perhaps more news for what didn’t make the cut as for what did. We get improved documentation with markdown support, ZGC becomes generational by default, and the loved and loathed internal API sun.misc.Unsafe(that we shouldn’t have been using in the first place) heads for the exit.

However, anyone who was hoping for the finalization of String Templates - that combine literal text with embedded expressions to safely, easily, and dynamically construct strings containing values - would be disappointed as it was yoinked from the JDK after two previews49. It’s not even available with --enable-preview in JDK 23. This proves my point about features not being ready for professional production use until they exit preview; by all means test them out to help the community and process but JEP 12 (which announced the preview process)50 always left provision for features in preview to be removed if they didn’t meet community standards and became a permanent fixture in the JDK.

Features

Markdown Documentation Comments

For those who diligently write comments across their Java codebase, it’s never been the best experience doing so with HTML tags to get the right formatting, especially if you’re subsequently generating javadocs. The JEP for markdown comments introduced in JDK 2351 aimw at solving that problem by using a markup format that people actually enjoy using, supporting comments in markdown which are denoted by three forward slashes (///).

Note

Other forms of markdown documentation indicators were tried, such as /**md which extends javadoc comments but were unpopular during prototyping.

The main goal here is to make code documentation both easier to read and write which should be celebrated. Other modern languages including Rust support markdown comments through similar means52 so the consistency is beneficial. The javadoc tags like @snippet, @implSpec, etc. remain unchanged.

Example

Enhancing our Player interface which included @snippet for javadoc, is one area of enhancement where this can be illustrated.

Before:

/**
 * Tic-tac-toe player interface for all players 
 * {@snippet :
 * // Create a human player
 * Player player = new HumanPlayer("X"); // @highlight region="player" substring="player"
 * 
 * // Choose the next valid move on the game board
 * int validBoardLocation = player.nextMove(gameBoard); // @end
 * }
 */
public sealed interface Player permits HumanPlayer, BotPlayer, RemoteBotPlayer {
    String getPlayerMarker();
    int nextMove(GameBoard board);
}

After:

/// Tic-tac-toe player interface for all players
/// {@snippet :
/// // Create a human player
/// Player player = new HumanPlayer("X"); // @highlight region="player" substring="player"
///
/// // Choose the next valid move on the game board
/// int validBoardLocation = player.nextMove(gameBoard); // @end
/// }
public sealed interface Player permits HumanPlayer, BotPlayer, RemoteBotPlayer {
 
    /// Returns the marker (e.g. "X" or "O") used by this player.
    /// @return the player's marker
    String getPlayerMarker();
 
    /// Chooses the next valid move on the game board.
    /// @param board the current state of the game board
    /// @return the index of the next valid move on the board
    int nextMove(GameBoard board);
}

which correctly outputs Javadoc as before (running javadoc or gradle javadoc):

Documenting code snippets of other languages with backticks also works as expected. e.g. in GameBoard.java we can add a code block for javascript:

    /// Converts the game board to a JSON string representation for serialization. Format
    /// corresponds to the following JSON schema with content as a 1D array of strings of size
    /// dimension x dimension.
    ///
    /// ```javascript
    /// { "dimension": int, "content": [ string, string, ..., string ] } }
    /// ```
    /// @return the game board as a JSON string
    /// @see JsonSerializable
    String asJsonString();

which renders the following Javadoc:


ZGC: Generational Mode by Default

As we discussed in Generational ZGC the ZGC, a “scalable low-latency garbage collector” was introduced in JDK11 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.

In JDK21 was made multi-generational, enabled with a command-line option, taking advantage of the common use case that most created objects are short-lived and those can be more frequently collected. This means less frequent full GCs and overall better performance. In JDK 23 the generational ZGC is now the default mode when using it52 and non-generational ZGC is deprecated for removal.

It’s now enabled with the ZGC flag -XX:+UseZGC. It can currently be disabled with -XX:+UseZGC -ZZ:-ZGenerational but that functionality will be removed in future.

Example

In our tic-tac-toe game we have a few VSCode launch configurations, applying the setting is as simple as updating the vmArgs:

From:

{
	"type": "java",
	"name": "GameServer (on ZGC)",
	"request": "launch",
	"vmArgs": "-XX:+UseZGC -XX:+ZGenerational",
	"mainClass": "org.example.GameServer",
	"projectName": "app",
	"env": {
		"LIB_PATH": "${workspaceFolder}/app/build/cargo/debug",
		"PATH": "${workspaceFolder}/app/build/cargo/debug", // For Windows
		"LD_LIBRARY_PATH": "${workspaceFolder}/app/build/cargo/debug", // For Linux
		"DYLD_LIBRARY_PATH": "${workspaceFolder}/app/build/cargo/debug" // For macOS
	},
}

to:

{
	"type": "java",
	"name": "GameServer (on ZGC)",
	"request": "launch",
	"vmArgs": "-XX:+UseZGC",
	"mainClass": "org.example.GameServer",
	"projectName": "app",
	"env": {
		"LIB_PATH": "${workspaceFolder}/app/build/cargo/debug",
		"PATH": "${workspaceFolder}/app/build/cargo/debug", // For Windows
		"LD_LIBRARY_PATH": "${workspaceFolder}/app/build/cargo/debug", // For Linux
		"DYLD_LIBRARY_PATH": "${workspaceFolder}/app/build/cargo/debug" // For macOS
	},
}

Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal

JEP 47153 represents an important step in the evolution of the Java platform. By deprecating the memory-access methods in sun.misc.Unsafe, we’re being encouraged to adopt safer, more robust APIs that align with the language’s overall goals of security, maintainability, and forward compatibility.

The sun.misc.Unsafe API is an internal API that provides low-level operations, including direct memory access, allowing developers to bypass Java’s memory safety guarantees for performance, but was never meant for public consumption. With VarHandle and the FFM API its functionality has been superseded with standard APIs in the SDK that allow developers to access performance and perform memory access in a way that aligns with the Java safety and integrity-first ethos.

Java’s direction with the Foreign Memory Access API shows a move towards balancing control and safety, similar to modern languages like Rust, though Java’s runtime safety mechanisms as a GC language differ in fundamental ways from Rust’s compile-time guarantees.

Example

Previously, we already made use of the FFM API, integrating with Rust to have a native representation of the GameBoard. When we did so, we created a player id, which used an AtomicInteger to map an increasing identifier to the player marker (typically ‘X’ or ‘O’), meaning the first player to move would get ID = 1, the next ID = 2, etc.

We could, similarly, create an only-increasing number fountain of our own using the lower level but safer APIs provided by VarHandle. It’s over-engineering for sure, since AtomicInteger is highly optimized but that’s why we’re here; also we have the benefit of only using exposing the functionality we need.

To do that we can use one of the bimodal memory-access methods VarHandle.getAndAdd:

public class PlayerIds {
	private volatile int nextId;
	private static final VarHandle NEXT_ID_VH;
 
	static {
		try {
			NEXT_ID_VH = MethodHandles.lookup().findVarHandle(PlayerIds.class, "nextId", int.class);
		} catch (NoSuchFieldException | IllegalAccessException e) {
			throw new ExceptionInInitializerError(e);
		}
	}
 
	public PlayerIds(int initialValue) {
		this.nextId = initialValue;
	}
 
	public int getNextId() {
		return nextId;
	}
 
	public int getNextIdAndIncrement() {
		return (int) NEXT_ID_VH.getAndAdd(this, 1);
	}
}

In the code about the VarHandle NEXT_ID_VH grabs the nextId field and uses atomic operations to provide simple id generator functionality with thread-safety guarantees.

Benchmarking the new PlayerIds class (using JMH54 - the Java Microbenchmark Harness) against AtomicInteger and a naive control implementation that uses a ReentrantLock to surround our atomic operations shows that our new implementation is marginally the fastest but at the very least comparable to an AtomicInteger implementation:

Benchmark                                            Mode  Cnt      Score     Error  Units
PlayerIdsBenchmark.testAtomicIntegerGetAndIncrement  avgt   25   7352.476 ± 267.315  ns/op
PlayerIdsBenchmark.testAtomicIntegerGetId            avgt   25     26.847 ±   0.626  ns/op
PlayerIdsBenchmark.testLockGetAndIncrement           avgt   25  13114.880 ± 458.892  ns/op
PlayerIdsBenchmark.testLockGetId                     avgt   25  12996.146 ± 312.691  ns/op
PlayerIdsBenchmark.testPlayerIdsGetAndIncrementId    avgt   25   7305.610 ± 247.107  ns/op
PlayerIdsBenchmark.testPlayerIdsGetId                avgt   25     26.858 ±   0.625  ns/op


Algorithmic Interlude

Over-Engineering Tic-Tac-Toe — An Algorithmic Interlude

Abstract

Exploring intelligent AI in Tic-Tac-Toe with Minimax, MaxN, Paranoid, Alpha-Beta, and Monte Carlo Tree Search.

This is the first of a series of expected interludes for the overall Multi-Part Series: Road to JDK 25 — Over-Engineering Tic-Tac-Toe.

Previously: Introduction to JDK 23


So far our primary task has been to progressively over-engineer tic-tac-toe (available for your pleasure at the overengineering-tictactoe GitHub repository: here) focused primarily on using finalized features from Java Enhancement Proposals).

In this article, though, we’ll take a break — or rather, a massive side quest from JDK enhancements to zoom in improve upon and explore the primary algorithms used in the game and look at a few used in game theory in general. It’s an interesting (even if a bit niche) diversion but worth the time especially with the rather long wait to JDK 24.

If you’re not familiar with Java, no need to worry — the code is intentionally written in a way that reads language neutral. All you have to do is wrap your head around all of the recursion!

Quick Note:

If you’ve been following the series through GitHub, you’ll note the classes have had a significant cleanup/refactor since the last update for JDK 23. The newGameState class captures the current player, current board, last move and has methods to understand if moves are available in the game or not which proxy some of those previously found in GameBoard.

//   
// https://github.com/briancorbinxyz/overengineering-tictactoe  
// GameState.java - key method signatures  
//  
public record GameState(  
    GameBoard board,  
    List<String> playerMarkers,  
    int currentPlayerIndex,  
    int lastMove  
) {  
  
  public GameState(GameState state);  
  public String currentPlayer();  
  public boolean hasMovesAvailable();  
  public boolean hasChain(String player);  
  public List<Integer> availableMoves();  
  public boolean isTerminal();  
  public GameState afterPlayerMoves(int move);  
  public boolean lastPlayerHasChain();   
  private String lastPlayer();  
  public int lastPlayerIndex();  
}


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.

It is what’s known as a zero-sum game. In a zero-sum game, whatever one player gains, the other player loses. There’s a fixed amount of “win” to go around, so if you win, your opponent loses an equal amount — and vice versa. In other words, the sum of gains and losses between players is always zero.

Here’s why this matters: in a zero-sum game, your goal isn’t just to win — it’s to make sure your opponent doesn’t win. Every move you make either increases your chances of victory or limits your opponent’s.


Algorithm Level 1

Random Strategy

Imagine you’re playing tic-tac-toe, but instead of thinking about strategy, you decide to make your moves randomly — just picking any available square without a plan. Sometimes you might win by chance, but other times, you’ll lose because you didn’t consider what your opponent might do next. It’s like flipping a coin, or casting die in a game where strategy is everything.

That’s exactly what our first bot implementation did; it naively picked moves from those available at random in the vein hope that it would win, mimicking the strategy of a tic-tac-toe newbie:

public final class Random implements BotStrategy {  
  private final RandomGenerator random;  
  private final GameState state;  
  
  public Random(GameState state) {  
    this.state = state;  
    this.random = new SecureRandom();  
  }  
  
  @Override  
  public int bestMove() {  
    var availableMoves = state.board().availableMoves();  
    return availableMoves.get(random.nextInt(availableMoves.size()));  
  }  
}

How does it work? Rather simply. Given a player and a board (part of the game state), it simply picks a random move from the list of board.availableMoves() .

Tic-Tac-Toe using a “Random” strategy for choosing the next best move

More often than not, though, picking moves at random proves to be a losing strategy against any experienced opponent.

But what if you could improve on that pure randomness with a strategy so powerful it’s like having a genius guiding your every move? Enter the minimax algorithm — a zero-sum method that transforms your gameplay from random guessing to calculated genius.


Algorithm Level 2

Minimax Strategy

Minimax is designed specifically for zero-sum games like tic-tac-toe. It’s a strategy that helps you navigate the game by maximizing your minimum gains and minimizing your maximum losses.

In a zero-sum context, this means you’re always trying to increase your share of the “win” while reducing your opponent’s.

Here’s how it works:

  1. Planning Ahead: Instead of making random moves, minimax considers every possible move you can make and imagines what your opponent might do in response. It looks several steps ahead to predict the outcome of each potential path.
  2. Scoring Outcomes: Minimax assigns a score to each possible outcome — e.g. +100 if you win (maximum gain), -100 if you lose (maximum loss), and 0 if it’s a tie. In a zero-sum game, these scores reflect the perfect balance of victory and defeat between you and your opponent (a maximum loss for you is a maximum gain for your opponent and vice-versa).
  3. Choosing the Best Move: With all possible outcomes in mind, minimax picks the move that guarantees you the best possible result, even if your opponent plays perfectly. It’s like finding the safest path through a battlefield, ensuring that you always come out on top or, at the very least, avoid defeat.

In a random strategy, you might accidentally give your opponent the upper hand without realizing it. But with minimax, every move is carefully calculated to maximize your advantage while minimizing your opponent’s chances of success.

In a zero-sum game like tic-tac-toe, this kind of strategic thinking is crucial if you want to win. There’s no room for mistakes because every loss you suffer is a direct gain for your opponent. Minimax helps you navigate this balance, ensuring that you’re always making the smartest possible move.

How does it work? The genius is encapsulated in the Minimax class. This class is like a super-intelligent brain for a game-playing AI. It’s constantly asking, “What’s the absolute best move I, the maximizer, can make right now?” And it doesn’t just think one step ahead — it’s thinking ALL the steps ahead — It’s like a time machine for games!

The class takes the current game board and like Dr. Strange plays out EVERY possible future — every move, every countermove, all the way to the end of the game. It’s like having an all-seeing eye that shows all possible futures!

private int minimax(GameState state, boolean isMaximizing, int depth) {  
  if (state.hasChain(maximizer)) {  
    return MAX_SCORE - depth;  
  } else if (state.hasChain(opponent(maximizer))) {  
    return MIN_SCORE + depth;  
  } else if (!state.hasMovesAvailable() || config.exceedsMaxDepth(depth)) {  
    return DRAW_SCORE;  
  }  
  
  if (isMaximizing) {  
    int value = -Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      var newState = state.afterPlayerMoves(move);  
      int score = minimax(newState, false, depth + 1);  
      value = Math.max(value, score);  
    }  
    return value;  
  } else {  
    int value = Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      var newState = state.afterPlayerMoves(move);  
      int score = minimax(newState, true, depth + 1);  
      value = Math.min(value, score);  
    }  
    return value;  
  }  
}

But wait, there’s more! As it’s exploring all these possible futures, it’s keeping score. Winning moves get high scores, whilst losing moves get low scores. And here’s the clever bit: it assumes both players are playing their absolute best. So it’s always prepared for the toughest competition!

Now, the key part is the bestMove() method. This is where all that future-gazing pays off. It looks at all the possible next moves determined by minimax(), figures out which one leads to the best possible future, and says, “That’s the one! That’s the best move!”

// Minimax.java  
public int bestMove() {  
  int bestMove = -1;  
  int maxScore = -Integer.MAX_VALUE;  
  for (int move : initialState.availableMoves()) {  
    var newState = initialState.afterPlayerMoves(move);  
    int score = minimax(newState, false, 0);  
    log(move, score, 0);  
    if (score > maxScore) {  
      maxScore = score;  
      bestMove = move;  
    }  
  }  
  return bestMove;  
}

Also get this — it even has a built-in preference for quick wins! (by tracking the depth ) If it sees two paths to victory, it’ll choose the faster one. i.e. a win now (lesser depth) is preferred over a win later (greater depth), and a loss later (greater depth) is preferred over a loss now (lesser depth). How cool is that?

Tic-Tac-Toe using a minimax strategy to pick the next best move

Seen in full:

public final class Minimax implements BotStrategy {  
  
  private static final int MIN_SCORE = -100;  
  private static final int MAX_SCORE = 100;  
  private static final int DRAW_SCORE = 0;  
  
  private final String maximizer;  
  private final BotStrategyConfig config;  
  private final GameState initialState;  
  
  public Minimax(GameState state) {  
    this(state, BotStrategyConfig.empty());  
  }  
  
  public Minimax(GameState initialState, BotStrategyConfig config) {  
    this.initialState = initialState;  
    this.maximizer = initialState.currentPlayer();  
    if (initialState.playerMarkers().size() != 2) {  
      throw new IllegalArgumentException("Minimax requires exactly two players");  
    }  
    this.config = config;  
  }  
  
  public int bestMove() {  
    int bestMove = -1;  
    int maxScore = -Integer.MAX_VALUE;  
    for (int move : initialState.availableMoves()) {  
      var newState = initialState.afterPlayerMoves(move);  
      int score = minimax(newState, false, 0);  
      if (score > maxScore) {  
        maxScore = score;  
        bestMove = move;  
      }  
    }  
    return bestMove;  
  }  
  
  private int minimax(GameState state, boolean isMaximizing, int depth) {  
    if (state.hasChain(maximizer)) {  
      return MAX_SCORE - depth;  
    } else if (state.hasChain(opponent(maximizer))) {  
      return MIN_SCORE + depth;  
    } else if (!state.hasMovesAvailable() || config.exceedsMaxDepth(depth)) {  
      return DRAW_SCORE;  
    }  
  
    if (isMaximizing) {  
      int value = -Integer.MAX_VALUE;  
      for (int move : state.availableMoves()) {  
        var newState = state.afterPlayerMoves(move);  
        int score = minimax(newState, false, depth + 1);  
        value = Math.max(value, score);  
      }  
      return value;  
    } else {  
      int value = Integer.MAX_VALUE;  
      for (int move : state.availableMoves()) {  
        var newState = state.afterPlayerMoves(move);  
        int score = minimax(newState, true, depth + 1);  
        value = Math.min(value, score);  
      }  
      return value;  
    }  
  }  
  
  private String opponent(String playerMarker) {  
    return initialState.playerMarkers().stream()  
        .dropWhile(playerMarker::equals)  
        .findFirst()  
        .orElseThrow();  
  }  
}

So that’s it, right? Not quite. This is over-engineered tic-tac-toe, so although that traditionally means a two-player, zero-sum game on a 3x3 board, our over-engineered game is supposed to support n-player, any x any grid games which makes minimax limiting. Furthermore, as the board size increases, the number of possible moves increase exponentially making generating the bestMove using minimax computationally impractical without also introducing a limit on the depth the algorithm will search for future moves (which we limit using config.exceedsMaxDepth())


Minimax Strategy (w. Alpha-Beta Pruning)

A variation on pure minimax is to use alpha-beta pruning to only consider possibilities that are stronger that those already considered. The algorithm is implemented in the alphabeta() method of the AlphaBeta class. As with minimax this method recursively explores possible future game states, alternating between maximizing the score on the current player’s turn and minimizing on the opponent’s turn. The algorithm uses two values, alpha and beta, to prune (ignore) branches of the game tree that are guaranteed to be worse than already explored options. This pruning significantly reduces the number of game states that need to be evaluated, making the algorithm more efficient than vanilla minimax.

private int alphabeta(GameState state, boolean isMaximizing, int depth) {  
  return alphabeta(state, isMaximizing, -Integer.MAX_VALUE, Integer.MAX_VALUE, depth);  
}  
  
private int alphabeta(GameState state, boolean isMaximizing, int alpha, int beta, int depth) {  
  if (state.hasChain(maximizer)) {  
    return MAX_SCORE - depth;  
  } else if (state.hasChain(opponent(maximizer))) {  
    return MIN_SCORE + depth;  
  } else if (!state.hasMovesAvailable() || config.exceedsMaxDepth(depth)) {  
    return DRAW_SCORE;  
  }  
  
  if (isMaximizing) {  
    int value = -Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      var newState = state.afterPlayerMoves(move);  
      int score = alphabeta(newState, false, alpha, beta, depth + 1);  
      value = Math.max(value, score);  
      if (value > beta) {  
        break;  
      }  
      alpha = Math.max(alpha, value);  
    }  
    return value;  
  } else {  
    int value = Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      var newState = state.afterPlayerMoves(move);  
      int score = alphabeta(newState, true, alpha, beta, depth + 1);  
      value = Math.min(value, score);  
      if (value < alpha) {  
        break;  
      }  
      beta = Math.min(beta, value);  
    }  
    return value;  
  }  
}

The earlier you are in the game tree — with either only a few moves made or a larger than standard board, the more the benefits of this becomes apparent. A worked example is below:

Minimax algorithm using Alpha-Beta pruning to reduce the size of the game tree to analyze

Unfortunately, even with all this new algorithmic magic we’re still limited to just two players. If only there was an algorithm like minimax that supported an arbitrary number of players instead of just 2. As it turns out there is: Enter Maxn — a generalization of the minimax algorithm for n-player games.


Algorithm Level 3

Maxn Strategy

Maxn is built to deal with games that involve three or more players, where the interactions are more complicated. While Minimax is designed for two-player games where one player’s gain is exactly the other player’s loss, what happens when there are 3 or more players?

In these games, the relationships between players aren’t strictly zero-sum; one player’s gain doesn’t necessarily mean the same amount of loss for another player.

Maxn extends minimax to handle these multi-player situations. It considers each player’s potential moves and strategies, not just focusing on a simple win-loss scenario, but rather on how each player’s actions affect all others. This allows Maxn to evaluate the move that maximizes the score for the current player.

To achieve its purpose, the MaxN class uses a recursive algorithm. The bestMove() method starts by considering all the available moves for the current player. For each move, it simulates making that move and then calls the maxn() method to evaluate the resulting game state.

public int bestMove() {  
  int bestMove = -1;  
  int[] maxScores = new int[numberOfPlayers()];  
  Arrays.fill(maxScores, Integer.MIN_VALUE);  
  
  for (int move : initialState.availableMoves()) {  
    var newState = initialState.afterPlayerMoves(move);  
    int[] scores = maxn(newState, 0);  
    log(move, scores, 0);  
  
    if (scores[newState.lastPlayerIndex()] > maxScores[newState.lastPlayerIndex()]) {  
      maxScores = scores;  
      bestMove = move;  
    }  
  }  
  return bestMove;  
}

The maxn() method then recursively explores all possible future moves for all players, up to a certain depth or until the game ends, producing a vector of best scores.

private int[] maxn(GameState state, int depth) {  
  if (state.lastPlayerHasChain()) {  
    int[] scores = new int[numberOfPlayers()];  
    Arrays.fill(scores, MIN_SCORE + depth);  
    scores[state.lastPlayerIndex()] = MAX_SCORE - depth;  
    return scores;  
  } else if (!state.hasMovesAvailable() || config.exceedsMaxDepth(depth)) {  
    return new int[numberOfPlayers()]; // Draw, all scores 0  
  }  
  
  int[] bestScores = new int[numberOfPlayers()];  
  Arrays.fill(bestScores, Integer.MIN_VALUE);  
  
  for (int move : state.availableMoves()) {  
    var newState = state.afterPlayerMoves(move);  
    int[] scores = maxn(newState, depth + 1);  
  
    if (scores[state.currentPlayerIndex()] > bestScores[state.currentPlayerIndex()]) {  
      bestScores = scores;  
    }  
  }  
  
  return bestScores;  
}

MaxN strategy applied to tic-tac-toe, which converges to Minimax in a two-player game


Paranoid Strategy

An alternative approach to MaxN which requires less computational resources for those larger multiplayer games is to assume everyone is out to get you. Paranoid is an AI so cautious, so on-edge, that it thinks everyone else in the game is out to get it. The Paranoid algorithm takes the complexity of multiplayer games and simplifies it in the most pessimistic way possible, effectively reducing the game to a two-player “me vs. the world” scenario.

By simplifying the game to a two-player scenario, we can potentially search deeper in the game tree with the same computational resources — essentially reducing the algorithm to a minimax search: the current player (maximizer) vs everyone else.

private int paranoid(GameState state, int depth) {  
  // Terminal state checks  
  if (state.hasChain(maximizer())) {  
    return MAX_SCORE - depth;  
  } else if (state.lastPlayerIndex() != maximizerIndex() && state.lastPlayerHasChain()) {  
    return MIN_SCORE + depth;  
  } else if (!state.hasMovesAvailable() || config.exceedsMaxDepth(depth)) {  
    return MIN_SCORE + depth;  
  }  
  
  if (maximizerIndex() == state.currentPlayerIndex()) {  
    // Our turn: maximize our score  
    int maxScore = -Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      GameState newState = state.afterPlayerMoves(move);  
      int score = paranoid(newState, depth + 1);  
      maxScore = Math.max(maxScore, score);  
    }  
    return maxScore;  
  } else {  
    // Opponent's turn: minimize our score  
    int minScore = Integer.MAX_VALUE;  
    for (int move : state.availableMoves()) {  
      GameState newState = state.afterPlayerMoves(move);  
      int score = paranoid(newState, depth + 1);  
      minScore = Math.min(minScore, score);  
    }  
    return minScore;  
  }  
}

The bestMove() method is similar to the one used by minimax:

public int bestMove() {  
  int bestMove = -1;  
  int maxScore = Integer.MIN_VALUE;  
  
  for (int move : initialState.board().availableMoves()) {  
    var newState = initialState.afterPlayerMoves(move);  
    int score = paranoid(newState, 0);  
  
    if (score > maxScore) {  
      maxScore = score;  
      bestMove = move;  
    }  
  }  
  return bestMove;  
}

A worked example shows interesting differences between how some of the other algorithms reach the goal, certainly with less computational resources.

Paranoid Strategy for Tic-Tac-Toe reduces the game to “Me vs. The World”

The issue with most of the approaches so far is that we typically have to limit the depth the algorithm analyzes the game tree, or else wait days for it to complete its analysis when the board size grows — this may leave these algorithms blind to winning (or losing) strategies. Similarly in multiplayer games, predicting what everyone will do gets exponentially harder with each added player. We could, of course, as we did with minimax, improve upon the base algorithms with alpha-beta pruning, but that still faces similar limitations.

Can we solve for this issue in a way that is also easier to tune and configure? There is a class of powerful algorithms which improve with iterations or time. Enter Monte Carlo, named after the Monte Carlo Casino in Monaco, where the main developer of the method was inspired by his uncle’s gambling habits! Monte Carlo methods are ones I’m very familiar with having worked so long in financial risk management.

We come full-circle as “random” comes back, but this time in a useful way. It uses random sampling for deterministic problems which are difficult, uncertain, or impossible to solve using other approaches, is used across Science, Engineering, Finance, and a range of other disciplines.

Monte-Carlo methods mimic the trial-and-error learning process that we humans often use to achieve our goals — a process known as reinforcement learning


Algorithm Level 4

Monte Carlo Tree Search Strategy

This Monte Carlo approach to tic-tac-toe demonstrates how reinforcement learning concepts can be applied to game playing. The algorithm learns from experience (simulations) and gradually improves its decision-making, balancing between exploring new possibilities and exploiting known good strategies.

Here’s how it works: First, it takes the current game state and starts simulating possible moves. Then, for each of these moves, it plays out the rest of the game — not just once, but many, many times. It’s like speed-running through thousands of parallel universes of the game!

But wait, there’s more! As it’s zipping through these game simulations, it’s keeping score. Which moves led to more wins? Which ones were total busts? It’s building this incredible tree of knowledge, figuring out which branches are worth exploring further and which ones are dead ends, always ready to give its best move at any time as it’s learning.

And the best part? It doesn’t need to know all the strategic ins and outs of the game like other algorithms. It just needs to know the rules and then it learns the rest through sheer trial and error.

This is why Monte Carlo Tree Search is so powerful. It can tackle complex games that other algorithms struggle with, and it does it with style. It’s not just playing the game — it’s writing a playbook with each and every single move.

MonteCarloTreeSearch is our key class that implements a strategy for playing the game using the Monte Carlo Tree Search (MCTS) algorithm. The purpose of this code is to determine the best move for a player in a given game state using a configurable maximum time allowed for calculation. The MCTSNode class represents a node in the game tree. Each node keeps track of the game state, its parent node, child nodes, the number of times it has been visited, and the scores for each player.

public int bestMove() {  
  return monteCarloTreeSearch(initialState);  
}  
  
private int monteCarloTreeSearch(GameState state) {  
  MCTSNode root = new MCTSNode(state, null);  
  var startTime = System.currentTimeMillis();  
  
  while (!config.exceedsMaxTimeMillis(System.currentTimeMillis() - startTime)) {  
    MCTSNode node = treePolicy(root);  
    double[] reward = defaultPolicy(node.state);  
    backpropagate(node, reward);  
  }  
  
  return bestChild(root).state.lastMove();  
}

The algorithm works a little differently to those we’ve built to date — by building a tree of possible future game states and repeatedly simulating games from these states. It starts with the current game state as the root of the tree. Then, it follows these steps:

  1. Selection: It chooses a promising node in the tree to explore further. As it simulates, it balances exploration of new possibilities with exploitation of known good strategies, using the UCT, Upper Confidence Bound for Trees formula allowing it to make intelligent decisions even in complex game situations.
public MCTSNode select() {  
  MCTSNode selected = null;  
  double bestValue = Double.NEGATIVE_INFINITY;  
  for (MCTSNode child : children) {  
    double uctValue =  
        child.scores[state.currentPlayerIndex()] / child.visits  
            + Math.sqrt(2 * Math.log(visits) / child.visits);  
    if (uctValue > bestValue) {  
      selected = child;  
      bestValue = uctValue;  
    }  
  }  
  return selected;  
}

2. Expansion: If the chosen node isn’t fully explored, it adds a new child node representing a possible next move.

private MCTSNode treePolicy(MCTSNode node) {  
  while (!node.state.isTerminal()) {  
    if (!node.isFullyExpanded()) {  
      return expand(node);  
    } else {  
      node = node.select();  
    }  
  }  
  return node;  
}

3. Simulation: It plays out a random game from the new node to its conclusion.

  • The treePolicy() method decides which node to explore next, balancing between exploring new possibilities and exploiting known good moves.
  • The expand() method adds new child nodes to the tree, representing unexplored moves.
  • The defaultPolicy() method simulates a random game from a given state to its conclusion, using our reward function defaultReward() to quantitatively reinforce promising paths through terminal states.
private MCTSNode expand(MCTSNode node) {  
  var untriedMoves = new ArrayList<>(node.state.board().availableMoves());  
  untriedMoves.removeAll(  
      node.children.stream()  
        .map(child -> child.state.lastMove())  
        .collect(Collectors.toList()));  
  
  int move = untriedMoves.get(new Random().nextInt(untriedMoves.size()));  
  var newState = node.state.afterPlayerMoves(move);  
  var child = new MCTSNode(newState, node);  
  node.children.add(child);  
  return child;  
}  
  
private double[] defaultPolicy(GameState state) {  
  var tempState = new GameState(state);  
  while (!tempState.isTerminal()) {  
    var moves = tempState.board().availableMoves();  
    int move = moves.get(new Random().nextInt(moves.size()));  
    tempState = tempState.afterPlayerMoves(move);  
  }  
  return defaultReward(tempState);  
}  
  
public double[] defaultReward(GameState state) {  
  var reward = new double[state.playerMarkers().size()];  
  int winningPlayerIndex = -1;  
  for (int i = 0; i < state.playerMarkers().size(); i++) {  
    if (state.board().hasChain(state.playerMarkers().get(i))) {  
      winningPlayerIndex = i;  
      break;  
    }  
  }  
  for (int i = 0; i < state.playerMarkers().size(); i++) {  
    if (i == winningPlayerIndex) {  
      reward[i] = MAX_SCORE;  
    } else if (winningPlayerIndex != -1) {  
      reward[i] = MIN_SCORE;  
    } else {  
      reward[i] = DRAW_SCORE;  
    }  
  }  
  return reward;  
}

4. Back-propagation: It updates the statistics of all nodes in the path from the new node back to the root based on the game’s outcome. Here, transforms the abstract idea of “good moves” into concrete statistics by repeatedly simulating games and aggregating their results up the game tree.

private void backpropagate(MCTSNode node, double[] reward) {  
  while (node != null) {  
    node.visits++;  
    for (int i = 0; i < initialState.playerMarkers().size(); i++) {  
      node.scores[i] += reward[i];  
    }  
    node = node.parent;  
  }  
}

These steps are repeated many times within the allowed time limit. The more iterations, the more improved our algorithm’s decision-making becomes.

Finally, the bestChild() method selects the most promising move at the end of all simulations.

private MCTSNode bestChild(MCTSNode node) {  
  return node.children.stream().max(Comparator.comparingDouble(c -> c.visits)).orElseThrow();  
}

Visualizing a few worked scenarios we see how impressive this technique is especially with the UCT formula ensuring we have the right balance between exploration and exploitation:

Monte Carlo Tree Search with a low number of iterations is about as good as our level 1 Algorithm: Random

With more iterations MCTS becomes more confident of the right path to choose.

Monte-Carlo Tree Search applied to Tic-Tac-Toe makes efficient use of computational resources to beat out our other algos


And there we have it. A game as simple as Tic-Tac-Toe, is transformed from a naive newbie to an all-powerful game AI. So the next time you’re playing a multiplayer game and the AI seems suspiciously good at surviving against all odds, remember: you might be up against one of these algorithms!

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

References

  1. M. P. D. Schadd and M. H. M. Winands, “Best-Reply Search for Multi-Player Games”.
  2. N. Sturtevant, M. Zinkevich, and M. Bowling, “Prob-Maxn: Playing N-Player Games with Opponent Models”.
  3. A. M. Nijssen and M. H. M. Winands, “An Overview of Search Techniques in Multi-Player Games”.
  4. M. Świechowski, K. Godlewski, B. Sawicki, and J. Mańdziuk, “Monte Carlo Tree Search: a review of recent modifications and applications,” Artif Intell Rev, vol. 56, no. 3, pp. 2497–2562, Mar. 2023, doi: 10.1007/s10462-022-10228-y
  5. S. J. Russell and P. Norvig, Artificial intelligence : a modern approach. Pearson, 2016. Available: https://thuvienso.hoasen.edu.vn/handle/123456789/8967. [Accessed: Aug. 27, 2024]
Link to original


Introduction to JDK 24

The journey will continue to JDK 24…

Introduction to JDK 25

The journey will continue to 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

Footnotes

  1. “TIOBE Index,” TIOBE. Available: https://www.tiobe.com/tiobe-index/. [Accessed: Jul. 16, 2024]

  2. When in Rome (I currently live in the US) I speak as the Romans do but I grew up in the UK calling this “Noughts and crosses”!

  3. SDKMAN! “JDK Distributions - SDKMAN! The Software Development Kit Manager.” Accessed July 9, 2024. https://sdkman.io/jdks.

  4. “JDK 17.” Available: https://openjdk.org/projects/jdk/17/. [Accessed: Jul. 09, 2024]

  5. “JEP 398: Deprecate the Applet API for Removal.” Accessed July 10, 2024. https://openjdk.org/jeps/398.

  6. “JEP 411: Deprecate the Security Manager for Removal.” Accessed July 10, 2024. https://openjdk.org/jeps/411.

  7. “CWE - 2020 CWE Top 25 Most Dangerous Software Weaknesses.” Accessed July 10, 2024. https://cwe.mitre.org/top25/archive/2020/2020_cwe_top25.html.

  8. “JEP 385: Deprecate RMI Activation for Removal.” Available: https://openjdk.org/jeps/385. [Accessed: Jul. 16, 2024]

  9. “JEP 409: Sealed Classes.” Accessed July 10, 2024. https://openjdk.org/jeps/409.

  10. “Don’t Wanna Be a Player.” In Wikipedia, January 25, 2024. https://en.wikipedia.org/w/index.php?title=Don%27t_Wanna_Be_a_Player&oldid=1198753099.

  11. Bloch, Joshua. Effective java. Addison-Wesley Professional, 2017.

  12. Meyer, Bertrand. Object-oriented software construction. Vol. 2. Englewood Cliffs: Prentice hall, 1997.

  13. “JEP 410: Remove the Experimental AOT and JIT Compiler.” Available: https://openjdk.org/jeps/410. [Accessed: Jul. 16, 2024]

  14. “JDK 18.” Available: https://openjdk.org/projects/jdk/18/. [Accessed: Jul. 09, 2024]

  15. “JEP 421: Deprecate Finalization for Removal.” Available: https://openjdk.org/jeps/421. [Accessed: Jul. 17, 2024] 2

  16. “JEP 416: Reimplement Core Reflection with Method Handles.” Available: https://openjdk.org/jeps/416. [Accessed: Jul. 16, 2024] 2

  17. “JEP 400: UTF-8 by Default.” Available: https://openjdk.org/jeps/400. [Accessed: Jul. 16, 2024]

  18. “JEP 408: Simple Web Server.” Available: https://openjdk.org/jeps/408. [Accessed: Jul. 17, 2024]

  19. “JEP 413: Code Snippets in Java API Documentation.” Available: https://openjdk.org/jeps/413. [Accessed: Jul. 16, 2024]

  20. “JEP 418: Internet-Address Resolution SPI.” Available: https://openjdk.org/jeps/418. [Accessed: Jul. 17, 2024]

  21. JDK 19.” Available: https://openjdk.org/projects/jdk/19/. [Accessed: Jul. 09, 2024]

  22. “JDK 20.” Available: https://openjdk.org/projects/jdk/20/. [Accessed: Jul. 09, 2024]

  23. “JDK 21.” Available: https://openjdk.org/projects/jdk/21/. [Accessed: Jul. 09, 2024]

  24. “JEP 449: Deprecate the Windows 32-Bit X86 Port for Removal.” Accessed July 21, 2024. https://openjdk.org/jeps/449.

  25. “JEP 452: Key Encapsulation Mechanism API.” Accessed July 22, 2024. https://openjdk.org/jeps/452. 2

  26. “JEP 431: Sequenced Collections.” Accessed July 17, 2024. https://openjdk.org/jeps/431.

  27. “JEP 440: Record Patterns.” Accessed July 17, 2024. https://openjdk.org/jeps/440.

  28. “JEP 441: Pattern Matching for Switch.” Accessed July 18, 2024. https://openjdk.org/jeps/441.

  29. Azul | Better Java Performance, Superior Java Support. “Azul C4 Garbage Collector.” Accessed July 18, 2024. https://www.azul.com/products/components/pgc/.

  30. “JEP 439: Generational ZGC.” Accessed July 17, 2024. https://openjdk.org/jeps/439.

  31. “The Reactive Manifesto.” Accessed July 19, 2024. https://www.reactivemanifesto.org/.

  32. “JEP 444: Virtual Threads.” Accessed July 19, 2024. https://openjdk.org/jeps/444.

  33. Schwabe, Peter. “Kyber.” Text. Accessed July 22, 2024. https://pq-crystals.org/kyber/index.shtml.

  34. Schwabe, Peter. “Dilithium.” Text. Accessed July 24, 2024. https://pq-crystals.org/dilithium/.

  35. “(776) Chalk Talk - YouTube.” Accessed July 24, 2024. https://www.youtube.com/@chalktalkmath.

  36. “diffplug/spotless.” DiffPlug, Jul. 25, 2024. Available: https://github.com/diffplug/spotless. [Accessed: Jul. 25, 2024]

  37. “JEP 264: Platform Logging API and Service.” Available: https://openjdk.org/jeps/264. [Accessed: Jul. 25, 2024]

  38. “JEP 330: Launch Single-File Source-Code Programs.” Accessed July 26, 2024. https://openjdk.org/jeps/330.

  39. “JEP 458: Launch Multi-File Source-Code Programs.” Accessed July 26, 2024. https://openjdk.org/jeps/458.

  40. “JEP 456: Unnamed Variables & Patterns.” Available: https://openjdk.org/jeps/456. [Accessed: Jul. 27, 2024]

  41. “Google Java Style Guide.” Accessed July 27, 2024. https://google.github.io/styleguide/javaguide.html#s6.2-caught-exceptions.

  42. National Security Agency, “Software Memory Safety”, Nov 2022, https://media.defense.gov/2022/Nov/10/2003112742/-1/-1/0/CSISOFTWAREMEMORYSAFETY.PDF [Accessed: Jul. 27, 2024]

  43. V. E. Moghadam, G. Serra, F. Aromolo, G. Buttazzo, and P. Prinetto, “Memory Integrity Techniques for Memory-Unsafe Languages: A Survey,” IEEE Access, vol. 12, pp. 43201–43221, 2024, doi: 10.1109/ACCESS.2024.3380478

  44. “JEP 423: Region Pinning for G1.” Available: https://openjdk.org/jeps/423. [Accessed: Jul. 27, 2024]

  45. “JEP 454: Foreign Function & Memory API.” Accessed August 6, 2024. https://openjdk.org/jeps/454.

  46. “Java Development Kit Version 22 API Specification - java.lang.foreign.” Accessed August 6, 2024. https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/foreign/package-summary.html.

  47. “Java Development Kit Version 22 API Specification - MethodHandle.” Accessed August 6, 2024. https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/invoke/MethodHandle.html.

  48. “Java Development Kit Version 22 API Specification - VarHandle.” Accessed August 6, 2024. https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/invoke/VarHandle.html.

  49. “[JDK-8329949] Remove the String Templates preview feature - Java Bug System.” Available: https://bugs.openjdk.org/browse/JDK-8329949. [Accessed: Aug. 09, 2024]

  50. “JEP 12: Preview Features.” Available: https://openjdk.org/jeps/12. [Accessed: Aug. 09, 2024]

  51. “JEP 467: Markdown Documentation Comments.” Accessed August 7, 2024. https://openjdk.org/jeps/467.

  52. “Documentation - Rust By Example.” Accessed August 7, 2024. https://doc.rust-lang.org/rust-by-example/meta/doc.html. 2

  53. “JEP 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal.” Available: https://openjdk.org/jeps/471. [Accessed: Aug. 08, 2024]

  54. “OpenJDK: jmh.” Available: https://openjdk.org/projects/code-tools/jmh/. [Accessed: Aug. 08, 2024]