JDK 16 Key Features — Records Finalized and instanceof Pattern Matching

JDK 16 Overview

Java 16, released in March 2021, is a non-LTS release. Records and Pattern Matching for instanceof were finalized after their preview stages, and convenience APIs like Stream.toList() were added. Rather than major innovations, this version focused on stabilizing features that had been experimental since JDK 14-15.

JEPFeatureStatus
JEP 395RecordsFinalized
JEP 394Pattern Matching for instanceofFinalized
JEP 392jpackage (Native Packaging)Finalized
JEP 338Vector APIIncubator
JEP 376ZGC Concurrent Thread-Stack ProcessingImprovement
JEP 396Strong Encapsulation of Internal APIs (Default)Change

Records — Immutable Data Carriers (JEP 395)

Records are dedicated classes for holding immutable data. The compiler auto-generates equals(), hashCode(), toString(), and accessor methods. They cleanly replace DTOs (Data Transfer Objects) that were previously filled with boilerplate code.

import java.util.List;

// Record definition — complete immutable data class in one line
record Product(String name, int price, String category) {}

public class RecordExample {
    public static void main(String[] args) {
        // Create Record instances
        Product laptop = new Product("MacBook Pro", 2_490_000, "Electronics");
        Product phone = new Product("Galaxy S25", 1_350_000, "Electronics");

        // Auto-generated accessor methods (same as field name, not getters)
        System.out.println(laptop.name());    // MacBook Pro
        System.out.println(laptop.price());   // 2490000

        // Auto-generated toString()
        System.out.println(laptop);
        // Product[name=MacBook Pro, price=2490000, category=Electronics]

        // Auto-generated equals() — all field values compared
        Product another = new Product("MacBook Pro", 2_490_000, "Electronics");
        System.out.println(laptop.equals(another)); // true

        // Using with lists
        List<Product> products = List.of(laptop, phone);
        products.stream()
            .filter(p -> p.price() > 2_000_000)
            .forEach(p -> System.out.println(p.name() + " -> premium product"));
        // MacBook Pro -> premium product
    }
}

Here’s a summary of Record’s key characteristics:

  • final class -> cannot be extended
  • All fields are private final -> immutable
  • Static fields/methods and instance methods can be added
  • Validation via custom constructors (Compact Constructor)
  • implements allowed, extends not allowed (implicitly extends java.lang.Record)

Pattern Matching for instanceof (JEP 394)

Previously, a separate cast was required after an instanceof check. Starting with JDK 16, type checking and variable declaration happen in one step.

import java.util.List;

public class PatternMatchingExample {
    // Method that handles various types
    static String describe(Object obj) {
        // Old way: repeated instanceof + casting
        // if (obj instanceof String) {
        //     String s = (String) obj;
        //     return "String(length=" + s.length() + ")";
        // }

        // JDK 16 way: casting eliminated with pattern variable
        if (obj instanceof String s) {
            return "String(length=" + s.length() + ")";
        } else if (obj instanceof Integer i && i > 0) {
            // Pattern variable + condition combination
            return "Positive integer: " + i;
        } else if (obj instanceof int[] arr) {
            return "int array(size=" + arr.length + ")";
        } else if (obj instanceof List<?> list && !list.isEmpty()) {
            return "Non-empty list(size=" + list.size() + ")";
        }
        return "Unknown type: " + obj.getClass().getSimpleName();
    }

    public static void main(String[] args) {
        System.out.println(describe("Hello Java 16"));   // String(length=14)
        System.out.println(describe(42));                  // Positive integer: 42
        System.out.println(describe(-7));                  // Unknown type: Integer
        System.out.println(describe(new int[]{1, 2, 3})); // int array(size=3)
        System.out.println(describe(List.of("a", "b")));  // Non-empty list(size=2)
    }
}

Pattern variable scope is valid only “when the type matches.” Combining with the && operator allows natural expression of additional conditions. This feature later extends to Sealed Classes + switch pattern matching in JDK 17.

Stream.toList() — Concise List Collection

If typing Collectors.toList() every time was tedious, this is welcome news. Stream.toList() was added starting with JDK 16.

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class StreamToListExample {
    public static void main(String[] args) {
        // Old way: using Collectors.toList()
        List<Integer> oldWay = IntStream.rangeClosed(1, 10)
            .boxed()
            .filter(n -> n % 2 == 0)
            .collect(Collectors.toList());
        System.out.println("Old: " + oldWay); // Old: [2, 4, 6, 8, 10]

        // JDK 16 way: Stream.toList() — more concise
        List<Integer> newWay = IntStream.rangeClosed(1, 10)
            .boxed()
            .filter(n -> n % 2 == 0)
            .toList();
        System.out.println("New: " + newWay); // New: [2, 4, 6, 8, 10]

        // Note: toList() returns an unmodifiable list
        try {
            newWay.add(12); // UnsupportedOperationException thrown!
        } catch (UnsupportedOperationException e) {
            System.out.println("toList() result is unmodifiable");
        }

        // If a mutable list is needed, still use Collectors.toList()
        List<Integer> mutableList = IntStream.rangeClosed(1, 5)
            .boxed()
            .collect(Collectors.toList());
        mutableList.add(6);
        System.out.println("Mutable: " + mutableList); // Mutable: [1, 2, 3, 4, 5, 6]
    }
}
MethodReturn TypeMutableAllows null
Collectors.toList()ArrayListYesYes
Stream.toList()Unmodifiable ListNoYes
Collectors.toUnmodifiableList()Unmodifiable ListNoNo

Stream.toList() allows null elements, but Collectors.toUnmodifiableList() throws NullPointerException if there are nulls. It’s good to remember this distinction.

Day Period Support

Using the B pattern in DateTimeFormatter enables finer-grained expressions like “morning”, “noon”, “evening”, “night” instead of just “AM” and “PM”.

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class DayPeriodExample {
    public static void main(String[] args) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("a hh:mm / B hh:mm")
            .withLocale(Locale.ENGLISH);

        // Check Day Period at various times
        LocalTime[] times = {
            LocalTime.of(6, 0),   // Morning
            LocalTime.of(12, 0),  // Noon
            LocalTime.of(18, 30), // Evening
            LocalTime.of(23, 0)   // Night
        };

        for (LocalTime time : times) {
            System.out.println(time + " -> " + formatter.format(time));
        }
        // 06:00 -> AM 06:00 / in the morning 06:00
        // 12:00 -> PM 12:00 / noon 12:00
        // 18:30 -> PM 06:30 / in the evening 06:30
        // 23:00 -> PM 11:00 / at night 11:00
    }
}

Performance and Tooling Improvements

ZGC Concurrent Thread-Stack Processing (JEP 376): ZGC’s thread-stack processing changed from safepoint-based to concurrent processing. GC pause times are further reduced, enabling sub-millisecond pauses.

jpackage Finalized (JEP 392): jpackage, which started as an incubator in JDK 14, became an official tool. It can create platform-specific native packages (deb, rpm, msi, dmg) from Java applications.

# Package JAR as a Windows installer
jpackage --input target/ \
         --name MyApp \
         --main-jar my-app.jar \
         --main-class com.example.Main \
         --type msi

Vector API (Incubator, JEP 338): A vector computation API leveraging SIMD instructions appeared for the first time. It can dramatically improve numerical computation performance and continues to evolve in later JDK versions.

Strong Encapsulation of Internal APIs (JEP 396): The default for --illegal-access changed to deny. Code that directly accesses internal APIs like sun.misc.Unsafe will not run without explicit --add-opens.

Summary

JDK 16 is a “from experiment to stability” release. Here are the key points:

  • Records: Define DTOs and value objects in one line. Auto-generated equals/hashCode/toString
  • Pattern Matching for instanceof: Eliminate casting boilerplate. Use the if (obj instanceof String s) pattern
  • Stream.toList(): Use instead of collect(Collectors.toList()) for conciseness. Note: returned list is unmodifiable
  • jpackage: Official tool for packaging JARs as native installers
  • Vector API: First steps toward SIMD operations (Incubator)
  • Internal API Encapsulation: sun.misc.* direct access blocked by default

JDK 16’s Records and pattern matching combine with Sealed Classes in the next LTS (JDK 17) to form a more powerful type system. Even if you skip JDK 16, it’s worth learning these two features.

Was this article helpful?