A quick tour through new, production-ready features when LTS-upgrading from Java 17 to Java 21.

Photo by Levi Lei, https://unsplash.com/photos/-D9StQNyrSko

Feature Java JEP / Issue
Code Snippets in API Doc 18 413
HashMap.newHashMap() / HashSet.newHashSet() 19 JDK-8186958
Sequenced Collections 21 431
Record Patterns 21 440
Pattern Matching for Switch 21 441
Virtual Threads 21 444
StringBuilder/StringBuffer.repeat() 21 JDK-8302323
String.indexOf() 21 JDK-8303648
String.splitWithDelimiters() 21 JDK-8305486

Note that this article highlights just those features that will be relevant for most developers - many more improvements have been made apart from these.

All articles in this series:

Code Snippets in Java API Documentation

Instead of using a combination of <pre> and @code for including source code in JavaDoc, this can now be done with the following syntax:

/**
 * Example for an inline snippet:
 *
 * {@snippet :
 * System.out.println("Hello!");
 * }
 *
 */

Alternatively, the actual snippet can be provided in a separate file, which is then referenced like this:

/**
 * Example for an external snippet:
 *
 * {@snippet file="SomeSnippet.java"}
 *
 */

Some advantages and features of these snippets are:

  • IDEs and other tools can easily recognize source code snippets.
  • Automatic syntax highlighting.
  • Property files and languages other than Java are supported.
  • Highlighting specific parts (e.g., as bold or italic) is supported.
  • Linking specific parts to their API doc is supported.
  • Hardly any need to escape special characters, especially with external snippets.
  • Different parts of an external snippet can be referenced using “regions” within this snippet.

HashMap.newHashMap() / HashSet.newHashSet()

To create a new HashMap or HashSet that can hold (for example) up to 42 elements without being resized, we can now use

HashMap.newHashMap(42);
HashSet.newHashSet(42);

When compared to the following

new HashMap(42);
new HashSet(42);

these constructors result in data structures that will be resized as soon as 75% of the specified initial capacity are occupied, which might not be what the developer actually intended.

Sequenced Collections

Three new interfaces have been added to the java.util package to more consistently express data structures whose elements have a defined encounter order:

Photo by Stuart Marks, https://cr.openjdk.org/~smarks/collections/SequencedCollectionDiagram20220216.png

SequencedCollection

interface SequencedCollection<E> extends Collection<E> {

    E getFirst();
    E getLast();
    
    void addFirst(E);  // optional
    void addLast(E);   // optional
    
    E removeFirst();  // optional
    E removeLast();   // optional
    
    SequencedCollection<E> reversed();
    
}

SequencedSet

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {

    SequencedSet<E> reversed();
    
}

SequencedMap

interface SequencedMap<K,V> extends Map<K,V> {

    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    
    Entry<K, V> pollFirstEntry(); // optional
    Entry<K, V> pollLastEntry();  // optional

    V putFirst(K, V); // optional
    V putLast(K, V);  // optional

    SequencedMap<K,V> reversed();
    
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();

}

Record Patterns

Pattern matching for records is capable of directly assigning the record’s components to variables:

if (obj instanceof Point(int x, int y)) {
    System.out.println(x+y);
}

Instead of explicitly stating the component types, you can let the compiler infer them via var:

if (obj instanceof Point(var x, var y)) {
    System.out.println(x + y);
}

This “disaggregation” (or “destructuring”) of records also works for nested components:

if (obj instanceof ColoredPoint(Point(int x, int y), Color c)) {
    System.out.println(x + y);
    System.out.println(c);
}

Apart from instanceof, record patterns can also be used in switch statements and expressions (see below).

Pattern Matching for Switch

The case labels of switch statements (and switch expressions) can hold patterns:

switch (obj) {
    case Integer i -> System.out.println("int: " + i);
    case String s  -> System.out.println("str: " + s);
    default        -> System.out.println("obj: " + obj);
}

Moreover, null is also allowed for case labels:

switch (str) {
    case null  -> System.out.println("Null!");
    case "Foo" -> System.out.println("Foo!");
    default    -> System.out.println("Something else!");
}

The default case does not match null. Without the case null, a null value for str would throw a NullPointerException. However, it is possible to combine the null and default case if they require the same handling:

switch (str) {
    case "Foo" -> System.out.println("Foo!");
    case null, default -> System.out.println("Null or something else!");
}

Pattern case labels can be further refined with non-constant guards via when clauses:

switch (str) {
    case String s
        when s.equalsIgnoreCase("foo") -> System.out.println("Foo!");
    case String s
        when s.equalsIgnoreCase("bar") -> System.out.println("Bar!");
    default -> System.out.println("Some other string!");
}

It is not possible to provide multiple when guards to a single case label. Instead, the case label must be duplicated as in the example above.

As soon as a switch statement uses any of the newly introduced features

  • pattern labels
  • null labels
  • switch over something other than the switch “legacy” types (four primitives and their wrappers, String, enums)

the compiler checks the cases for exhaustiveness, which might require you to add a default case.

Virtual Threads

Types of threads:

  • Operating system (OS) thread: relatively expensive and scarce
  • Platform thread: a java.lang.Thread that
    • is a thin wrapper around an OS thread (1:1 and hence, just as expensive and scarce)
    • occupies the OS thread even while waiting for something else (like I/O)
  • Virtual thread: a java.lang.Thread that
    • gets mounted to a platform thread (“carrier”) whenever it has work to do (i.e., while it performs calculations on the CPU)
    • gets unmounted from a platform thread whenever it blocks / waits for something (with certain exceptions), making room for other virtual threads that have something to do

Hence, a large number of virtual threads can be mapped to a small number of platform threads. Instead of occupying precious OS threads with idle, waiting platform threads, the hardware usage can be optimized by assigning busy threads, and un-assigning idle virtual threads. For typical server applications with frequent blocking operations (database or backend access) and a high number of concurrent requests, using virtual threads can significantly increase the overall throughput (i.e., the number of requests that can be processed per second).

There are different ways for creating virtual threads (instead of platform threads):

When running your Java application within a server or framework, consult its documentation on how to enable virtual threads. In Spring Boot, for example, virtual threads can be enabled via the property spring.threads.virtual.enabled (beginning with version 3.2).

The method Thread.isVirtual() can be used to check whether the current thread is a virtual thread.

Be sure to follow certain guidelines to prevent some caveats that might arise with virtual threads:

  • Prefer simple, synchronous code (thread-per-request style) over asynchronous, “reactive” style.
    • Because the latter is more complex, harder to debug, and does not benefit from virtual threads.
  • Never pool virtual threads.
    • Because virtual threads are cheap and plentiful.
  • Don’t use virtual thread pools to limit the number of concurrent accesses to a limited resource (such as an easily overloaded backend).
    • Because virtual threads should not be pooled (see above).
    • Typically, use Semaphore instead.
    • Database connection pools do not require an additional semaphore, because they already serve as a semaphore themselves.
  • Don’t cache expensive reusable objects in Thread-local variables.
    • Because this only makes sense with pooled threads that are actually reused (which is not the case for virtual threads).
    • Scoped values (JEP 506 / Java 25) might be a suitable alternative in certain cases.
    • For checking the locations where a virtual thread sets a thread-local value at runtime, use the system property jdk.traceVirtualThreadLocals, which triggers a stack trace whenever this happens.
  • Be aware that virtual threads cannot be unmounted from their carrier thread in the following cases:
    • When it executes a native method or a foreign function.
    • When it executes code inside a synchronized block or method (until Java 24, where this was solved with JEP 491).

Math.clamp()

The static method

returns its first parameter if it is within the given min/max bounds. Otherwise, if the value is out-of-bounds, it returns the bound that is closer to the value. There are also overloads for double and float.

StringBuilder/StringBuffer.repeat()

The methods

insert the given character sequence for the given number of times (so you don’t have to use Apache Commons StringUtils for this any more).

String.indexOf()

The methods

return the index of the first parameter within the bounds given by the other two parameters, and throw a StringIndexOutOfBoundsException if the given bounds are invalid.

String.splitWithDelimiters()

The method

splits a string into an array of substrings using the given regex as delimiter, with the given limit indicating the number of times the regex shall be applied. When providing a limit of 0, this method behaves exactly like String.split(String regex), which is available since Java 1.4.