Java 25 Interview Questions (Free Preview)
Free sample of 15 from 53 questions available
Scoped Values
What is the performance advantage of scoped values over ThreadLocal?
The 30-Second Answer: Scoped values provide up to 2-3x better read performance and dramatically lower memory overhead compared to ThreadLocal. They achieve this through specialized JVM optimizations, immutability guarantees, and automatic scope-based cleanup that eliminates the need for manual memory management.
The 2-Minute Answer (If They Want More):
The performance advantages of scoped values stem from several key optimizations:
Memory Efficiency: ThreadLocal maintains a map entry in each thread's ThreadLocalMap for the entire thread lifetime. With platform threads, this is manageable, but with millions of virtual threads, it becomes a massive overhead. Scoped values exist only during the scope's execution and are automatically released when the scope ends, making them vastly more memory-efficient for virtual thread scenarios.
Read Performance: The JVM can optimize scoped value reads more aggressively because of immutability guarantees. Once a scoped value is bound, it cannot change, allowing the JIT compiler to perform more aggressive optimizations like constant folding and elimination of redundant reads. ThreadLocal reads require map lookups and synchronization checks.
Write Performance: ThreadLocal requires hash map operations (put/remove) and synchronization. Scoped values use a more efficient binding mechanism that's optimized for the common case of single-level binding. The where().run() pattern creates a lightweight binding frame that's much faster than map operations.
Cache Efficiency: Scoped values have better CPU cache locality because they don't require traversing hash map structures. The binding information is stored in a way that's more cache-friendly for the common access patterns.
No Cleanup Overhead: ThreadLocal requires explicit remove() calls to prevent memory leaks, which adds overhead and is error-prone. Scoped values automatically clean up, eliminating this overhead entirely.
Code Example:
import java.util.concurrent.*;
public class PerformanceComparison {
// ThreadLocal approach
private static final ThreadLocal<RequestContext> threadLocal = new ThreadLocal<>();
// ScopedValue approach
private static final ScopedValue<RequestContext> scopedValue = ScopedValue.newInstance();
record RequestContext(String requestId, String userId, long timestamp) {}
// ThreadLocal benchmark
public void benchmarkThreadLocal(int iterations) {
long start = System.nanoTime();
for (int i = 0; i < iterations; i++) {
RequestContext ctx = new RequestContext("req-" + i, "user-123", System.currentTimeMillis());
try {
threadLocal.set(ctx);
// Simulate multiple reads (common pattern)
processWithThreadLocal();
processWithThreadLocal();
processWithThreadLocal();
} finally {
threadLocal.remove(); // Required cleanup
}
}
long duration = System.nanoTime() - start;
System.out.printf("ThreadLocal: %d ms%n", duration / 1_000_000);
}
private void processWithThreadLocal() {
RequestContext ctx = threadLocal.get(); // Map lookup required
// Do something with ctx
String requestId = ctx.requestId();
}
// ScopedValue benchmark
public void benchmarkScopedValue(int iterations) {
long start = System.nanoTime();
for (int i = 0; i < iterations; i++) {
RequestContext ctx = new RequestContext("req-" + i, "user-123", System.currentTimeMillis());
ScopedValue.where(scopedValue, ctx).run(() -> {
// Simulate multiple reads (common pattern)
processWithScopedValue();
processWithScopedValue();
processWithScopedValue();
}); // Automatic cleanup
}
long duration = System.nanoTime() - start;
System.out.printf("ScopedValue: %d ms%n", duration / 1_000_000);
}
private void processWithScopedValue() {
RequestContext ctx = scopedValue.get(); // Optimized access
// Do something with ctx
String requestId = ctx.requestId();
}
// Virtual thread scenario - shows dramatic difference
public void benchmarkWithVirtualThreads(int numThreads) throws Exception {
// ThreadLocal with virtual threads
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < numThreads; i++) {
final int taskId = i;
executor.submit(() -> {
RequestContext ctx = new RequestContext("req-" + taskId, "user-123", System.currentTimeMillis());
try {
threadLocal.set(ctx);
processWithThreadLocal();
} finally {
threadLocal.remove();
}
});
}
}
long threadLocalTime = System.nanoTime() - start;
// ScopedValue with virtual threads
start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < numThreads; i++) {
final int taskId = i;
executor.submit(() -> {
RequestContext ctx = new RequestContext("req-" + taskId, "user-123", System.currentTimeMillis());
ScopedValue.where(scopedValue, ctx).run(() -> {
processWithScopedValue();
});
});
}
}
long scopedValueTime = System.nanoTime() - start;
System.out.printf("ThreadLocal with %d virtual threads: %d ms%n", numThreads, threadLocalTime / 1_000_000);
System.out.printf("ScopedValue with %d virtual threads: %d ms%n", numThreads, scopedValueTime / 1_000_000);
System.out.printf("Performance improvement: %.2fx faster%n", (double) threadLocalTime / scopedValueTime);
}
public static void main(String[] args) throws Exception {
PerformanceComparison benchmark = new PerformanceComparison();
// Warm up
benchmark.benchmarkThreadLocal(1000);
benchmark.benchmarkScopedValue(1000);
// Real benchmarks
System.out.println("\n=== Sequential Performance ===");
benchmark.benchmarkThreadLocal(100_000);
benchmark.benchmarkScopedValue(100_000);
System.out.println("\n=== Virtual Thread Performance ===");
benchmark.benchmarkWithVirtualThreads(10_000);
}
}
Performance Characteristics:
| Metric | ThreadLocal | ScopedValue | Improvement |
|---|---|---|---|
| Read Latency | ~10-20ns | ~3-5ns | 2-4x faster |
| Write Latency | ~50-100ns | ~20-30ns | 2-3x faster |
| Memory per Thread | ~40-80 bytes | ~16-24 bytes | 2-3x less |
| Cleanup Overhead | Manual (risky) | Automatic | Eliminates errors |
| Virtual Thread Scalability | Poor (millions of maps) | Excellent | 10-100x better |
| Cache Misses | Higher | Lower | Better locality |
References:
↑ Back to topJava 25 LTS Overview
What is the difference between Java 25 LTS and previous LTS versions (Java 21, Java 17)?
The 30-Second Answer: Java 25 builds on Java 21's "modernization" foundation (virtual threads, pattern matching) by focusing on runtime optimization—delivering 30% faster startup, 50% smaller object headers, and production-ready generational GC. Java 17→21 modernized the language; Java 21→25 optimizes the runtime while finalizing preview features like Scoped Values and Flexible Constructor Bodies.
The 2-Minute Answer (If They Want More):
The progression from Java 17 to Java 25 represents distinct evolutionary phases. Java 17 (September 2021) established the modern baseline with sealed classes and enhanced pattern matching. Java 21 (September 2023) became the "modernization LTS" introducing virtual threads, record patterns, sequenced collections, and finalizing pattern matching for switch. Java 25 (September 2025) is the "runtime evolution LTS" that refines and optimizes these capabilities.
Performance and memory improvements in Java 25 are substantial. Compact Object Headers reduce memory footprint from 128 to 64 bits per object on 64-bit JVMs—a 50% reduction that significantly impacts applications with many small objects. Generational Shenandoah, experimental in Java 21, is now production-ready with improved pause times and throughput. AOT Method Profiling enables faster warmup by recording and reusing execution profiles across runs.
Language evolution shows measured progression. Java 21 previewed Flexible Constructor Bodies; Java 25 finalizes them, allowing initialization code before super() calls. Module Import Declarations, previewed in Java 23-24, are finalized in Java 25. Scoped Values, previewed in Java 21-24, are now production-ready, offering a thread-safe alternative to ThreadLocal with better performance in virtual thread scenarios.
Concurrency models have matured significantly. Java 21 introduced stable virtual threads (Project Loom). Java 24 eliminated virtual thread pinning in synchronized blocks. Java 25 adds production-ready Scoped Values and fifth-preview Structured Concurrency, creating a comprehensive lightweight concurrency stack. The combination delivers both simplicity (virtual threads) and control (structured concurrency) unavailable in Java 17.
Security and cryptography advances with Java 25's post-quantum cryptography support (ML-KEM) and finalized Key Derivation Function API. Java 21 focused on TLS improvements; Java 25 prepares for quantum-resistant algorithms. Markdown in JavaDoc, introduced in Java 23, improves documentation authoring. Notably, Java 25 drops 32-bit x86 support entirely—a clear signal of platform evolution.
| Feature Category | Java 17 (Sep 2021) | Java 21 (Sep 2023) | Java 25 (Sep 2025) |
|---|---|---|---|
| Key Theme | Modern Baseline | Modernization | Runtime Evolution |
| Pattern Matching | Preview | Finalized for switch | Primitive types (3rd preview) |
| Virtual Threads | Not available | Finalized (Project Loom) | Enhanced (no pinning) |
| GC Improvements | ZGC improvements | Generational ZGC | Generational Shenandoah (final) |
| Object Headers | 128 bits | 128 bits | 64 bits (Compact Headers) |
| Scoped Values | Not available | Preview | Finalized |
| Constructor Bodies | Traditional | Preview | Flexible (finalized) |
| Module Imports | Not available | Not available | Finalized |
| Instance Main | Not available | Preview | Finalized |
| Quantum Crypto | Not available | Not available | ML-KEM support |
| 32-bit Support | Yes | Yes | No (64-bit only) |
| Stream API | Original | Original | Stream Gatherers (custom ops) |
| Startup Performance | Baseline | Improved | 30% faster (AOT profiling) |
| Memory Efficiency | Baseline | Improved | 50% smaller headers |
Code Example:
// Java 17: Traditional pattern matching
Object obj = "Hello";
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
}
// Java 21: Pattern matching for switch + Record patterns
record Point(int x, int y) {}
Object shape = new Point(10, 20);
String result = switch (shape) {
case Point(int x, int y) -> "Point at " + x + "," + y;
case String s -> s.toUpperCase();
default -> "Unknown";
};
// Java 25: All of the above PLUS...
// 1. Flexible Constructor Bodies
class ConfiguredService {
private final Logger logger;
private final String config;
ConfiguredService(String config) {
// Can validate and log BEFORE calling super()
logger = Logger.getLogger(getClass().getName());
if (config == null) {
logger.warning("Null config provided");
}
super(); // Now allowed after statements
this.config = config != null ? config : "default";
}
}
// 2. Scoped Values (finalized)
class RequestProcessor {
private static final ScopedValue<String> USER_ID =
ScopedValue.newInstance();
void handleRequest(String userId) {
// Thread-safe, inheritance-friendly, faster than ThreadLocal
ScopedValue.runWhere(USER_ID, userId, () -> {
processRequest();
callDatabase();
// userId accessible throughout call chain
});
}
void processRequest() {
String currentUser = USER_ID.get();
System.out.println("Processing for: " + currentUser);
}
}
// 3. Compact Object Headers benefit (transparent improvement)
List<String> items = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
items.add("item" + i);
}
// In Java 25: ~50% less memory overhead per String object
// 4. Module Import Declarations
import module java.net.http; // Import entire module at once
class HttpClient {
// HttpClient, HttpRequest, HttpResponse all available
void fetch(String url) {
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder(URI.create(url)).build();
// ...
}
}
Diagram:
flowchart LR
A[Java 17 LTS<br/>Sep 2021] --> B[Java 21 LTS<br/>Sep 2023]
B --> C[Java 25 LTS<br/>Sep 2025]
A -->|3 Years| D[Modern Baseline<br/>Sealed Classes<br/>Pattern Matching<br/>Text Blocks]
B -->|2 Years| E[Modernization<br/>Virtual Threads<br/>Record Patterns<br/>Sequenced Collections]
C -->|Current| F[Runtime Evolution<br/>Compact Headers<br/>Gen Shenandoah<br/>Scoped Values]
D -.->|Foundation| E
E -.->|Optimization| F
style A fill:#e1f5ff
style B fill:#b3e0ff
style C fill:#66c2ff
References:
- The Return of the LTS: What Java 25 Brings Beyond 21 - DEV Community
- Java 25 vs Java 21: The Upgrade That Saves You Weeks | Medium
- Java 25 vs Java 21 – Key Differences - JAVAHANDSON
- Java 17 vs Java 21 vs Java 25 Technical Comparison | Medium
- Java 25 LTS and IntelliJ IDEA | JetBrains
Pattern Matching Enhancements
What is the difference between type patterns and record patterns?
The 30-Second Answer: Type patterns test if a value is an instance of a type and bind it to a variable, while record patterns additionally destructure the record's components into separate variables. Type patterns are for matching types; record patterns are for matching structure and extracting components in one operation.
The 2-Minute Answer (If They Want More): Type patterns are the foundational pattern matching construct. They test whether a value is an instance of a specified type and, if so, bind the value to a variable of that type. This is what you use with instanceof and simple switch cases - you're checking the type and getting a properly-typed reference.
Record patterns build on type patterns by adding destructuring capabilities. Since records have a well-defined structure with named components, record patterns let you match the record type and simultaneously extract its components into individual variables. You can nest record patterns to destructure complex hierarchies in a single pattern, making it easy to work with deeply nested data structures.
The key difference is granularity of access. With type patterns, you get the whole object and then call accessors. With record patterns, you immediately extract the components you need. Record patterns also support nesting - if a record contains another record, you can destructure both levels in one pattern. Type patterns don't have this nested capability because regular classes don't have the same guaranteed structure as records.
Both pattern types can be used with guards and in all pattern contexts (instanceof, switch, enhanced for). However, record patterns are only applicable to records, while type patterns work with any type. When working with sealed hierarchies of records, combining both pattern types creates extremely expressive and type-safe code.
Code Example:
// Type patterns - matching the type
Object obj = "Hello";
// Type pattern in instanceof
if (obj instanceof String s) {
// s is bound to the String
System.out.println(s.toUpperCase());
}
// Type pattern in switch
String result = switch (obj) {
case String s -> s.toUpperCase();
case Integer i -> "Number: " + i;
default -> "Unknown";
};
// Record patterns - destructuring components
record Point(int x, int y) {}
record Circle(Point center, double radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}
// Type pattern: get the whole record
if (obj instanceof Circle c) {
// Need to call accessors
Point center = c.center();
double radius = c.radius();
System.out.println("Circle at " + center.x() + "," + center.y());
}
// Record pattern: destructure immediately
if (obj instanceof Circle(Point center, double radius)) {
// Components directly available
System.out.println("Circle at " + center.x() + "," + center.y());
}
// Nested record patterns
if (obj instanceof Circle(Point(int x, int y), double radius)) {
// Fully destructured in one pattern
System.out.println("Circle at " + x + "," + y + " with radius " + radius);
}
// Comparison in switch
sealed interface Shape permits Circle, Rectangle {}
double getArea(Shape shape) {
return switch (shape) {
// Type pattern - access via methods
case Circle c ->
Math.PI * c.radius() * c.radius();
// Record pattern - direct component access
case Rectangle(Point(int x1, int y1), Point(int x2, int y2)) ->
Math.abs(x2 - x1) * Math.abs(y2 - y1);
};
}
// Complex nested destructuring
record Customer(String name, Address address, List<Order> orders) {}
record Address(String street, String city, String zipCode) {}
record Order(String id, double amount) {}
void processCustomer(Object obj) {
// Record pattern with nesting
if (obj instanceof Customer(
String name,
Address(String street, String city, var zip),
var orders)) {
System.out.println(name + " from " + city + " on " + street);
// orders available as List<Order>
}
}
// Partial destructuring - using var for unwanted components
if (obj instanceof Circle(var center, double radius)) {
// Only care about radius, not center details
System.out.println("Radius: " + radius);
}
// Type pattern when structure is complex
class ComplexClass {
private String data;
private int count;
// No clean destructuring possible - not a record
}
if (obj instanceof ComplexClass complex) {
// Must use type pattern - can't destructure
// Access via methods/fields
}
Comparison Table:
| Aspect | Type Patterns | Record Patterns |
|---|---|---|
| Purpose | Match type and bind value | Match type and destructure components |
| Applicable To | Any type | Records only |
| Variable Binding | Single variable for whole object | Multiple variables for components |
| Nesting | Not supported | Fully supported |
| Component Access | Via accessor methods | Direct in pattern |
| Syntax | Type var |
RecordType(component1, component2) |
| Use Case | Type checking and casting | Structure extraction |
Code Example - Side by Side:
record Person(String firstName, String lastName, int age) {}
Object obj = new Person("John", "Doe", 30);
// TYPE PATTERN
if (obj instanceof Person p) {
String first = p.firstName(); // Call accessor
String last = p.lastName(); // Call accessor
int age = p.age(); // Call accessor
System.out.println(first + " " + last + ", age " + age);
}
// RECORD PATTERN
if (obj instanceof Person(String first, String last, int age)) {
// Components directly bound
System.out.println(first + " " + last + ", age " + age);
}
// TYPE PATTERN in switch
String greeting = switch (obj) {
case Person p -> "Hello, " + p.firstName() + " " + p.lastName();
default -> "Hello, stranger";
};
// RECORD PATTERN in switch
String greeting2 = switch (obj) {
case Person(String first, String last, int age) when age < 18 ->
"Hello, young " + first;
case Person(String first, String last, var age) ->
"Hello, " + first + " " + last;
default -> "Hello, stranger";
};
References:
↑ Back to topWhat is pattern matching in Java and how has it evolved through Java 25?
The 30-Second Answer: Pattern matching is a feature that allows you to test whether an object has a particular structure and extract components from it in a single operation. It evolved from simple instanceof checks in Java 16, to switch expressions in Java 21, and now includes unnamed patterns, record patterns, and comprehensive pattern support in Java 25.
The 2-Minute Answer (If They Want More): Pattern matching fundamentally changes how we write conditional logic and data extraction in Java. Instead of writing verbose instanceof checks followed by explicit casts, pattern matching combines the test, cast, and variable binding into a single concise operation.
The evolution began with Pattern Matching for instanceof (Java 16), which eliminated the need for explicit casts. Java 17 added sealed classes that work perfectly with patterns. Java 21 introduced Pattern Matching for switch as a final feature, allowing patterns in switch statements and expressions. Record Patterns (Java 21) enabled destructuring of record components directly in pattern matching contexts.
Java 25 completes this evolution with several refinements: unnamed patterns (using _) for values you don't need, primitive type patterns in switch, and improved support for guarded patterns. The language now treats pattern matching as a first-class feature with consistent syntax across instanceof, switch, and enhanced for loops.
This progression represents a shift toward more declarative, data-oriented programming in Java, making code more readable and less error-prone while maintaining Java's strong type safety guarantees.
Code Example:
// Evolution of pattern matching across Java versions
// Java 8-15: Traditional approach
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
// Java 16: Pattern matching for instanceof
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
}
// Java 21: Pattern matching for switch
String result = switch (obj) {
case String s -> s.toUpperCase();
case Integer i -> "Number: " + i;
case null -> "null value";
default -> "Unknown type";
};
// Java 25: With unnamed patterns and primitives
String formatted = switch (value) {
case int i when i > 0 -> "Positive: " + i;
case int i when i < 0 -> "Negative: " + i;
case int _ -> "Zero"; // Unnamed pattern
case null -> "null";
default -> "Not an integer";
};
// Record patterns (Java 21+)
record Point(int x, int y) {}
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at: " + x + ", " + y);
}
Diagram:
flowchart TD
A[Java 16: instanceof patterns] --> B[Java 17: Sealed classes]
B --> C[Java 21: switch patterns]
C --> D[Java 21: Record patterns]
D --> E[Java 25: Unnamed patterns<br/>& primitive patterns]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#fff3cd
References:
- JEP 394: Pattern Matching for instanceof
- JEP 441: Pattern Matching for switch
- JEP 432: Record Patterns
Sealed Classes
What is the difference between sealed, non-sealed, and final modifiers?
The 30-Second Answer:
sealed restricts inheritance to a specific set of permitted classes, final prevents all inheritance, and non-sealed reopens a sealed hierarchy to allow unrestricted extension. Every permitted subclass of a sealed class must choose one of these three modifiers.
The 2-Minute Answer (If They Want More): These three modifiers represent different points on the inheritance control spectrum, and understanding when to use each is critical for effective API design.
A sealed class declares an exhaustive list of permitted subclasses using the permits clause. This creates a controlled hierarchy where you know exactly which classes extend your base class. The key constraint is that all permitted subclasses must be accessible when the sealed class is compiled - typically they're in the same module or package.
A final class completely prevents inheritance. When used as a permitted subclass of a sealed class, it represents a leaf node in the hierarchy - no further extension is possible. This is useful when you want to provide a complete, unchangeable implementation.
A non-sealed class reopens the hierarchy, allowing any class to extend it following normal inheritance rules. This is useful when you want to maintain the sealed constraint at higher levels of the hierarchy but allow flexibility at lower levels. For example, a sealed Vehicle might permit Car, Truck, and Motorcycle, but Car could be non-sealed to allow specific car types to extend it freely.
Every permitted subclass of a sealed class must explicitly choose one of these modifiers. This forced choice ensures that class designers consciously decide how their part of the hierarchy should behave. You cannot have a permitted subclass that is neither sealed, non-sealed, nor final - the compiler will reject it.
Code Example:
// Sealed: Controlled hierarchy with permitted subclasses
public sealed class Vehicle
permits Car, Truck, Motorcycle {
protected String licensePlate;
public Vehicle(String licensePlate) {
this.licensePlate = licensePlate;
}
}
// Final: Cannot be extended further (leaf node)
public final class Motorcycle extends Vehicle {
private int engineCC;
public Motorcycle(String licensePlate, int engineCC) {
super(licensePlate);
this.engineCC = engineCC;
}
}
// Non-sealed: Reopens hierarchy for unrestricted extension
public non-sealed class Car extends Vehicle {
protected int doors;
public Car(String licensePlate, int doors) {
super(licensePlate);
this.doors = doors;
}
}
// Sealed again: Maintains control at this level
public sealed class Truck extends Vehicle
permits PickupTruck, SemiTruck {
protected int payloadCapacity;
public Truck(String licensePlate, int payloadCapacity) {
super(licensePlate);
this.payloadCapacity = payloadCapacity;
}
}
// Since Car is non-sealed, any class can extend it
public class Sedan extends Car {
public Sedan(String licensePlate) {
super(licensePlate, 4);
}
}
public class SUV extends Car {
private boolean fourWheelDrive;
public SUV(String licensePlate, boolean fourWheelDrive) {
super(licensePlate, 4);
this.fourWheelDrive = fourWheelDrive;
}
}
// Truck is sealed, so only permitted classes can extend
public final class PickupTruck extends Truck {
public PickupTruck(String licensePlate) {
super(licensePlate, 1000);
}
}
public final class SemiTruck extends Truck {
public SemiTruck(String licensePlate) {
super(licensePlate, 25000);
}
}
// This would NOT compile - Vehicle is sealed and doesn't permit Boat
// public final class Boat extends Vehicle { }
// This would NOT compile - Truck is sealed and doesn't permit DumpTruck
// public final class DumpTruck extends Truck { }
// This would NOT compile - Motorcycle is final
// public class SportBike extends Motorcycle { }
Comparison Table:
| Modifier | Can be Extended? | Must List Permitted Subclasses? | Use Case |
|---|---|---|---|
sealed |
Yes, by permitted classes only | Yes (via permits clause) |
Control inheritance to known subtypes; enable exhaustive checking |
final |
No | N/A | Prevent any extension; represent leaf nodes in hierarchy |
non-sealed |
Yes, by any class | No | Reopen sealed hierarchy for flexible extension at lower levels |
Key Rules:
| Rule | Description |
|---|---|
| Mandatory Choice | Every permitted subclass must be sealed, non-sealed, or final |
| Same Module/Package | Permitted subclasses must be accessible at compile time |
| Explicit Declaration | Must use permits clause (unless all subclasses are in same file) |
| Transitive Control | Sealed subclasses can further restrict their own subclasses |
Diagram:
flowchart TD
A[sealed Vehicle] -->|permits| B[final Motorcycle]
A -->|permits| C[non-sealed Car]
A -->|permits| D[sealed Truck]
C -->|anyone can extend| E[Sedan]
C -->|anyone can extend| F[SUV]
D -->|permits| G[final PickupTruck]
D -->|permits| H[final SemiTruck]
B -.no extension possible.-> B
E -.can be extended further.-> E
F -.can be extended further.-> F
style A fill:#e1f5ff
style D fill:#e1f5ff
style C fill:#fff4e1
style B fill:#ffe1e1
style G fill:#ffe1e1
style H fill:#ffe1e1
References:
↑ Back to topStructured Concurrency
What is the difference between ShutdownOnSuccess and ShutdownOnFailure policies?
The 30-Second Answer:
ShutdownOnFailure cancels all tasks when any task fails (fail-fast behavior), ideal when you need all results. ShutdownOnSuccess cancels all tasks when any task succeeds (first-success behavior), perfect for racing multiple alternatives where only one result is needed.
The 2-Minute Answer (If They Want More): These two policies represent opposite strategies for handling concurrent task completion and are designed for fundamentally different use cases.
ShutdownOnFailure implements an "all-or-nothing" approach. When you need all subtask results to produce a final result, this policy ensures efficient failure handling. If any subtask throws an exception, the scope immediately cancels all remaining tasks and captures the exception. After calling join(), you use throwIfFailed() to check for and propagate any failures. This prevents wasting resources on remaining tasks when you already know the overall operation cannot succeed. Think of it like parallel data fetching where you need all pieces to construct a complete response.
ShutdownOnSuccess implements a "race to completion" approach. It's used when you have multiple ways to obtain the same result and want whichever completes first. As soon as any subtask completes successfully, the scope cancels all other tasks and captures that result. After join(), you call result() to retrieve the winning value. This is perfect for scenarios like trying multiple servers, using different algorithms for the same computation, or implementing timeout patterns with fallbacks.
The key difference is in intent: ShutdownOnFailure assumes you need all results and optimizes for early failure detection, while ShutdownOnSuccess assumes any single result is sufficient and optimizes for getting the fastest response.
Code Example:
import java.util.concurrent.StructuredTaskScope;
import java.time.Duration;
import java.time.Instant;
public class ConcurrencyPolicyExamples {
// ShutdownOnFailure: Need ALL results
public Dashboard buildDashboard(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(userId));
var metricsTask = scope.fork(() -> fetchMetrics(userId));
var notificationsTask = scope.fork(() -> fetchNotifications(userId));
// Wait for ALL tasks to complete
scope.join();
// If ANY task failed, this throws an exception
scope.throwIfFailed();
// All tasks succeeded - build complete dashboard
return new Dashboard(
userTask.get(),
metricsTask.get(),
notificationsTask.get()
);
} // If one task fails, others are cancelled immediately
}
// ShutdownOnSuccess: Need ANY ONE result
public WeatherData fetchWeatherFastest(String city) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<WeatherData>()) {
// Try multiple weather APIs in parallel
scope.fork(() -> fetchFromOpenWeather(city));
scope.fork(() -> fetchFromWeatherAPI(city));
scope.fork(() -> fetchFromAccuWeather(city));
// Wait for FIRST successful result
scope.join();
// Get the first successful result (or throw if all failed)
return scope.result();
} // Once we have a result, other tasks are cancelled
}
// Practical example: Timeout with fallback using ShutdownOnSuccess
public UserData getUserWithTimeout(String userId, Duration timeout)
throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<UserData>()) {
// Primary: Fetch from fast cache
scope.fork(() -> fetchFromCache(userId));
// Fallback: Fetch from database (slower but reliable)
scope.fork(() -> {
Thread.sleep(timeout.toMillis());
return fetchFromDatabase(userId);
});
scope.join();
return scope.result();
}
}
// Advanced: Custom policy combining both approaches
public SearchResults searchWithPartialResults(String query) throws Exception {
// First attempt: Get all results quickly
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var webTask = scope.fork(() -> searchWeb(query));
var imageTask = scope.fork(() -> searchImages(query));
var newsTask = scope.fork(() -> searchNews(query));
// Try to get all results with short timeout
scope.joinUntil(Instant.now().plusSeconds(2));
if (scope.exception() == null) {
// All succeeded within timeout
return new SearchResults(
webTask.get(),
imageTask.get(),
newsTask.get(),
false // not partial
);
}
} catch (Exception e) {
// Timeout or failure - fall through to partial results
}
// Fallback: Get whatever results we can
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var webTask = scope.fork(() -> searchWeb(query));
var imageTask = scope.fork(() -> searchImages(query));
var newsTask = scope.fork(() -> searchNews(query));
scope.join();
// Return partial results (some may be null)
return new SearchResults(
getOrNull(webTask),
getOrNull(imageTask),
getOrNull(newsTask),
true // is partial
);
}
}
private <T> T getOrNull(StructuredTaskScope.Subtask<T> task) {
try {
return task.get();
} catch (Exception e) {
return null;
}
}
// Mock methods
private User fetchUser(String userId) { return new User(userId); }
private Metrics fetchMetrics(String userId) { return new Metrics(); }
private List<String> fetchNotifications(String userId) { return List.of(); }
private WeatherData fetchFromOpenWeather(String city) { return new WeatherData(); }
private WeatherData fetchFromWeatherAPI(String city) { return new WeatherData(); }
private WeatherData fetchFromAccuWeather(String city) { return new WeatherData(); }
private UserData fetchFromCache(String userId) { return new UserData(); }
private UserData fetchFromDatabase(String userId) { return new UserData(); }
private List<String> searchWeb(String query) { return List.of(); }
private List<String> searchImages(String query) { return List.of(); }
private List<String> searchNews(String query) { return List.of(); }
}
// Supporting records
record Dashboard(User user, Metrics metrics, List<String> notifications) {}
record WeatherData() {}
record UserData() {}
record Metrics() {}
record SearchResults(List<String> web, List<String> images,
List<String> news, boolean isPartial) {}
Diagram:
flowchart TB
subgraph SOF["ShutdownOnFailure (All Results Needed)"]
SOF1[Fork Task A] --> SOF2[Fork Task B]
SOF2 --> SOF3[Fork Task C]
SOF3 --> SOF4{Wait for All}
SOF4 --> SOF5{Any Failed?}
SOF5 -->|Yes| SOF6[Cancel Others]
SOF5 -->|No| SOF7[Return All Results]
SOF6 --> SOF8[Throw Exception]
end
subgraph SOS["ShutdownOnSuccess (First Result Wins)"]
SOS1[Fork Task A] --> SOS2[Fork Task B]
SOS2 --> SOS3[Fork Task C]
SOS3 --> SOS4{Wait for First Success}
SOS4 --> SOS5[One Succeeds]
SOS5 --> SOS6[Cancel Others]
SOS6 --> SOS7[Return First Result]
SOS4 --> SOS8[All Fail]
SOS8 --> SOS9[Throw Exception]
end
style SOF5 fill:#ffe1e1
style SOS5 fill:#e1ffe1
style SOF7 fill:#e1f5ff
style SOS7 fill:#e1f5ff
Comparison Table:
| Aspect | ShutdownOnFailure | ShutdownOnSuccess |
|---|---|---|
| Use Case | Need all results | Need any one result |
| Triggers Shutdown | Any task failure | Any task success |
| Result Method | throwIfFailed() then individual get() |
result() |
| Success Condition | All tasks succeed | At least one task succeeds |
| Failure Handling | Immediate cancellation of others | Continues until one succeeds |
| Typical Scenarios | Parallel data aggregation, batch processing | Service racing, fallback chains, fastest response |
| Performance Goal | Minimize wasted work on failure | Minimize latency to first success |
Real-World Scenarios:
| Scenario | Recommended Policy | Reasoning |
|---|---|---|
| Build user profile from multiple services | ShutdownOnFailure | Need complete profile |
| Try multiple CDN mirrors | ShutdownOnSuccess | First available is sufficient |
| Parallel database queries for report | ShutdownOnFailure | Report needs all data |
| Health check multiple servers | ShutdownOnSuccess | Any healthy server works |
| Batch credit card processing | ShutdownOnFailure | All must succeed or rollback |
| Find available appointment slot | ShutdownOnSuccess | First available slot wins |
References:
- JEP 462: Structured Concurrency
- Java API: StructuredTaskScope.ShutdownOnFailure
- Java API: StructuredTaskScope.ShutdownOnSuccess
What is structured concurrency and why is it important?
The 30-Second Answer: Structured concurrency is a programming paradigm introduced in Java 21 (finalized in Java 23) that treats multiple concurrent tasks as a single unit of work with a clear lifecycle. It ensures that all subtasks complete before the parent task completes, preventing thread leaks and making concurrent code more reliable and maintainable.
The 2-Minute Answer (If They Want More): Structured concurrency fundamentally changes how we think about concurrent programming by treating multiple tasks as a single, structured unit of work—similar to how structured programming uses blocks with clear entry and exit points. Traditional concurrent programming with threads or CompletableFuture often leads to problems: orphaned tasks continuing to run after their results are no longer needed, unclear error propagation, and resource leaks.
With structured concurrency, task lifetimes are strictly hierarchical. A parent task spawns child tasks, and the parent cannot complete until all children have completed (or the operation has been cancelled). This "happens-before" relationship makes reasoning about concurrent code much easier.
The importance of structured concurrency lies in its guarantees. It eliminates a whole class of concurrency bugs related to task lifecycle management. Tasks are always cleaned up properly, errors are propagated reliably, and cancellation cascades through the task hierarchy automatically. This makes concurrent code more maintainable and less error-prone.
Java's implementation uses StructuredTaskScope as the foundation, providing built-in policies for common patterns like "return first successful result" or "fail if any task fails." The approach aligns with virtual threads (Project Loom), making it practical to spawn thousands of concurrent operations without the overhead traditionally associated with thread-per-task models.
Code Example:
// Traditional approach - potential for thread leaks
public Product fetchProductInfo(String productId) {
CompletableFuture<Details> detailsFuture =
CompletableFuture.supplyAsync(() -> fetchDetails(productId));
CompletableFuture<Reviews> reviewsFuture =
CompletableFuture.supplyAsync(() -> fetchReviews(productId));
// If one fails, the other might keep running
return new Product(detailsFuture.join(), reviewsFuture.join());
}
// Structured concurrency approach
public Product fetchProductInfo(String productId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Details> detailsTask =
scope.fork(() -> fetchDetails(productId));
Subtask<Reviews> reviewsTask =
scope.fork(() -> fetchReviews(productId));
// Wait for all tasks, propagate exceptions
scope.join().throwIfFailed();
// Both tasks guaranteed to be complete here
return new Product(detailsTask.get(), reviewsTask.get());
} // Scope guarantees cleanup of all tasks
}
Diagram:
flowchart TD
A[Parent Task Starts] --> B[Fork Subtask 1]
A --> C[Fork Subtask 2]
A --> D[Fork Subtask 3]
B --> E[Subtask 1 Executes]
C --> F[Subtask 2 Executes]
D --> G[Subtask 3 Executes]
E --> H[Join Point]
F --> H
G --> H
H --> I{Any Failures?}
I -->|Yes| J[Propagate Exception]
I -->|No| K[Combine Results]
K --> L[Parent Task Completes]
J --> L
L --> M[Scope Closes - All Resources Cleaned]
style A fill:#e1f5ff
style H fill:#fff4e1
style M fill:#e8f5e9
References:
- JEP 462: Structured Concurrency (Second Preview)
- JEP 453: Structured Concurrency (Preview)
- Java Documentation: StructuredTaskScope
Generational ZGC
What is the difference between ZGC, G1GC, and Generational ZGC?
The 30-Second Answer: ZGC is a low-latency collector targeting sub-millisecond pause times regardless of heap size, G1GC is a region-based collector balancing throughput and latency with predictable pause times (typically 10-200ms), and Generational ZGC combines ZGC's ultra-low latency with generational collection for improved throughput. The choice depends on whether you prioritize latency (ZGC/Gen ZGC), balanced performance (G1GC), or maximum throughput (Gen ZGC).
The 2-Minute Answer (If They Want More):
G1GC (Garbage First Garbage Collector), introduced in JDK 7 and default since JDK 9, divides the heap into regions and uses generational collection. It aims for predictable pause times (configurable via -XX:MaxGCPauseMillis) typically in the 10-200ms range. G1GC performs concurrent marking and incremental evacuation, making it suitable for applications with medium to large heaps (4GB-64GB) where moderate pause times are acceptable. It provides good throughput and reasonable latency for most applications.
ZGC (Z Garbage Collector), production-ready since JDK 15, is designed for ultra-low latency with pause times rarely exceeding 1ms, even with multi-terabyte heaps. It achieves this through concurrent operations, colored pointers, and load barriers. However, the original ZGC was non-generational, treating all objects equally, which limited throughput compared to generational collectors because it had to scan the entire heap during each cycle.
Generational ZGC, introduced in JDK 21, combines the best of both worlds. It maintains ZGC's sub-millisecond pause times while adding generational collection to improve throughput by 5-25%. Young generation collections happen frequently but process less data, while old generation collections occur less often. This makes Generational ZGC ideal for latency-sensitive applications that also need good throughput.
The key trade-offs: G1GC offers the best balance for general-purpose applications and is the safest default choice. Classic ZGC prioritizes latency above all else but with some throughput cost. Generational ZGC provides both ultra-low latency and competitive throughput, making it the best choice for demanding applications on modern hardware running JDK 21+.
Code Example:
// G1GC Configuration (default in JDK 9+)
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-Xmx8g \
-Xlog:gc*:file=g1gc.log \
MyApplication
// Classic ZGC Configuration (JDK 15-20)
java -XX:+UseZGC \
-XX:-ZGenerational \
-Xmx16g \
-XX:ConcGCThreads=4 \
-Xlog:gc*:file=zgc.log \
MyApplication
// Generational ZGC Configuration (JDK 21+, recommended)
java -XX:+UseZGC \
-XX:+ZGenerational \
-Xmx16g \
-XX:ConcGCThreads=4 \
-Xlog:gc*:file=gen-zgc.log \
MyApplication
// Comparison example with metrics
public class GCComparison {
public static void main(String[] args) {
// Get GC MXBeans to monitor behavior
List<GarbageCollectorMXBean> gcBeans =
ManagementFactory.getGarbageCollectorMXBeans();
System.out.println("Active Garbage Collectors:");
for (GarbageCollectorMXBean gcBean : gcBeans) {
System.out.println("- " + gcBean.getName());
System.out.println(" Collection Count: " + gcBean.getCollectionCount());
System.out.println(" Collection Time: " + gcBean.getCollectionTime() + "ms");
}
// Memory pool information
System.out.println("\nMemory Pools:");
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println("- " + pool.getName() + " (" + pool.getType() + ")");
MemoryUsage usage = pool.getUsage();
System.out.println(" Used: " + usage.getUsed() / (1024 * 1024) + " MB");
System.out.println(" Max: " + usage.getMax() / (1024 * 1024) + " MB");
}
// Workload simulation
performWorkload();
// Print final statistics
System.out.println("\nFinal GC Statistics:");
for (GarbageCollectorMXBean gcBean : gcBeans) {
System.out.println("- " + gcBean.getName());
System.out.println(" Total Collections: " + gcBean.getCollectionCount());
System.out.println(" Total Time: " + gcBean.getCollectionTime() + "ms");
double avgPause = gcBean.getCollectionCount() > 0
? (double) gcBean.getCollectionTime() / gcBean.getCollectionCount()
: 0;
System.out.println(" Average Pause: " + String.format("%.2f", avgPause) + "ms");
}
}
private static void performWorkload() {
List<byte[]> longLived = new ArrayList<>();
// Create some long-lived objects
for (int i = 0; i < 100; i++) {
longLived.add(new byte[1024 * 1024]);
}
// Create many short-lived objects
for (int i = 0; i < 1000; i++) {
List<String> temp = new ArrayList<>();
for (int j = 0; j < 1000; j++) {
temp.add("Temporary object " + i + "-" + j);
}
}
}
}
// Performance testing wrapper
public class GCBenchmark {
private static final int WARMUP_ITERATIONS = 100;
private static final int TEST_ITERATIONS = 1000;
public static void main(String[] args) {
System.out.println("GC: " + getGCName());
System.out.println("Heap Size: " +
Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
// Warmup
System.out.println("\nWarming up...");
runBenchmark(WARMUP_ITERATIONS);
// Actual test
System.out.println("\nRunning benchmark...");
long startTime = System.nanoTime();
runBenchmark(TEST_ITERATIONS);
long endTime = System.nanoTime();
double totalSeconds = (endTime - startTime) / 1_000_000_000.0;
System.out.println("\nTotal time: " + String.format("%.2f", totalSeconds) + "s");
System.out.println("Throughput: " +
String.format("%.2f", TEST_ITERATIONS / totalSeconds) + " ops/sec");
}
private static void runBenchmark(int iterations) {
for (int i = 0; i < iterations; i++) {
allocateAndProcess();
}
}
private static void allocateAndProcess() {
List<byte[]> data = new ArrayList<>();
for (int i = 0; i < 100; i++) {
data.add(new byte[10240]); // 10KB allocations
}
// Process data
int sum = 0;
for (byte[] arr : data) {
sum += arr.length;
}
}
private static String getGCName() {
return ManagementFactory.getGarbageCollectorMXBeans()
.stream()
.map(GarbageCollectorMXBean::getName)
.collect(Collectors.joining(", "));
}
}
Comparison Table:
| Feature | G1GC | ZGC (Classic) | Generational ZGC |
|---|---|---|---|
| Introduced | JDK 7 (default JDK 9) | JDK 15 (production) | JDK 21 |
| Pause Time | 10-200ms (configurable) | <1ms (typically) | <1ms (typically) |
| Throughput | High | Medium | High |
| Heap Size | 4GB-64GB optimal | Multi-TB capable | Multi-TB capable |
| Generational | Yes | No | Yes |
| CPU Overhead | Medium | Higher | Medium-High |
| Memory Overhead | 10-20% | 20-30% | 15-25% |
| Concurrent Marking | Yes | Yes | Yes |
| Concurrent Compaction | No | Yes | Yes |
| Region-Based | Yes | Yes | Yes |
| Best For | General purpose | Ultra-low latency | Low latency + throughput |
Diagram:
flowchart TD
A[Choose Garbage Collector] --> B{Heap Size?}
B -->|< 4GB| C[G1GC or Parallel GC]
B -->|4-64GB| D{Latency Requirements?}
B -->|> 64GB| E{JDK Version?}
D -->|< 200ms OK| F[G1GC - Best Balance]
D -->|< 10ms required| G{JDK Version?}
G -->|JDK 21+| H[Generational ZGC]
G -->|JDK 15-20| I[Classic ZGC]
E -->|JDK 21+| H
E -->|JDK 15-20| I
H --> J[Sub-ms pauses + Good throughput]
I --> K[Sub-ms pauses + Medium throughput]
F --> L[Predictable pauses + High throughput]
style H fill:#c8e6c9
style I fill:#fff9c4
style F fill:#bbdefb
style J fill:#a5d6a7
style K fill:#fff59d
style L fill:#90caf9
References:
- JEP 439: Generational ZGC
- JEP 333: ZGC: A Scalable Low-Latency Garbage Collector
- Oracle GC Tuning Guide
- G1GC Documentation
Virtual Threads
What is thread pinning and how do you avoid it?
The 30-Second Answer: Thread pinning occurs when a virtual thread cannot be unmounted from its carrier thread during a blocking operation, wasting carrier thread resources. It happens with synchronized blocks/methods and native/foreign function calls. Avoid it by replacing synchronized with ReentrantLock and minimizing critical section scope.
The 2-Minute Answer (If They Want More): The core benefit of virtual threads is that they unmount from carrier threads during blocking operations, allowing the carrier to run other virtual threads. Thread pinning breaks this mechanism - the virtual thread stays mounted, blocking the carrier thread and reducing throughput.
Two main causes of pinning exist. First, synchronized blocks and methods: when a virtual thread executes synchronized code and blocks (e.g., Object.wait(), I/O inside synchronized), it cannot unmount. The JVM team is working to eliminate this limitation, but as of Java 25, it still exists. Second, native code execution or foreign function calls: these cannot be interrupted by the JVM scheduler.
The impact varies by workload. If synchronized blocks are short and don't contain blocking operations, pinning is minimal. But if you call Thread.sleep(), perform I/O, or wait for resources inside synchronized code, you waste carrier threads and lose virtual thread benefits.
Detection is possible with JDK Flight Recorder events or the system property -Djdk.tracePinnedThreads=full. This logs stack traces when pinning occurs, helping you identify problematic code. The fix is usually straightforward: replace synchronized with java.util.concurrent.locks.ReentrantLock, which fully supports virtual thread unmounting.
Code Example:
import java.util.concurrent.locks.*;
import java.time.Duration;
import java.util.concurrent.*;
public class ThreadPinningExample {
// BAD: Causes thread pinning
private final Object lock = new Object();
public void problematicMethod() {
synchronized(lock) {
try {
// This blocks while pinned to carrier thread!
Thread.sleep(Duration.ofSeconds(1));
// Or any I/O operation
performDatabaseQuery();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// GOOD: No pinning with ReentrantLock
private final ReentrantLock reentrantLock = new ReentrantLock();
public void fixedMethod() {
reentrantLock.lock();
try {
// Virtual thread can unmount during this sleep
Thread.sleep(Duration.ofSeconds(1));
performDatabaseQuery();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
reentrantLock.unlock();
}
}
// BETTER: Use try-lock with timeout for better control
public boolean improvedMethod() throws InterruptedException {
if (reentrantLock.tryLock(5, TimeUnit.SECONDS)) {
try {
Thread.sleep(Duration.ofSeconds(1));
performDatabaseQuery();
return true;
} finally {
reentrantLock.unlock();
}
}
return false; // Couldn't acquire lock
}
// Condition variables also work correctly with virtual threads
private final ReentrantLock condLock = new ReentrantLock();
private final Condition condition = condLock.newCondition();
private boolean ready = false;
public void waitForCondition() throws InterruptedException {
condLock.lock();
try {
while (!ready) {
// No pinning - virtual thread unmounts during await
condition.await();
}
processData();
} finally {
condLock.unlock();
}
}
public void signalCondition() {
condLock.lock();
try {
ready = true;
condition.signalAll();
} finally {
condLock.unlock();
}
}
// Demonstration of pinning impact
public static void demonstratePinning() throws InterruptedException {
int numTasks = 1000;
// With synchronized (causes pinning)
System.out.println("Testing with synchronized (pinning):");
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
ThreadPinningExample example = new ThreadPinningExample();
for (int i = 0; i < numTasks; i++) {
executor.submit(() -> {
example.problematicMethod();
return null;
});
}
}
System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
// With ReentrantLock (no pinning)
System.out.println("\nTesting with ReentrantLock (no pinning):");
start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
ThreadPinningExample example = new ThreadPinningExample();
for (int i = 0; i < numTasks; i++) {
executor.submit(() -> {
example.fixedMethod();
return null;
});
}
}
System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
}
private void performDatabaseQuery() {
// Simulate I/O
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void processData() {
System.out.println("Processing data");
}
}
// Enable pinning detection with JVM flag:
// -Djdk.tracePinnedThreads=full
// or
// -Djdk.tracePinnedThreads=short
// Example output when pinning occurs:
// Thread[#23,ForkJoinPool-1-worker-1,5,CarrierThreads]
// java.base/java.lang.VirtualThread$VThreadContinuation.onPinned
// java.base/java.lang.System.arraycopy(Native Method)
// ...
Diagram:
sequenceDiagram
participant VT as Virtual Thread
participant CT as Carrier Thread
participant OS as OS Thread
Note over VT,OS: Normal Operation (No Pinning)
VT->>CT: Mount
CT->>OS: Use OS Thread
VT->>VT: Blocking I/O starts
VT->>CT: Unmount (yield)
Note over VT: Waiting in heap
Note over CT: Available for other VThreads
VT->>VT: I/O completes
VT->>CT: Mount again
Note over VT,OS: Thread Pinning (synchronized)
VT->>CT: Mount
CT->>OS: Use OS Thread
VT->>VT: Enter synchronized block
VT->>VT: Blocking I/O starts
Note over VT,CT: PINNED - Cannot unmount!
Note over CT: Blocked, unavailable
Note over OS: Wasted OS thread
VT->>VT: I/O completes
VT->>VT: Exit synchronized
VT->>CT: Finally unmount
References:
↑ Back to topString Templates
What security benefits do string templates provide?
The 30-Second Answer: String templates provide compile-time type safety, automatic escaping through custom processors, and separation of structure from data - preventing injection attacks. Unlike concatenation where malicious input can alter query/command structure, template processors validate and sanitize expressions before composition, making it nearly impossible to inject unintended code or commands.
The 2-Minute Answer (If They Want More): String templates fundamentally change how untrusted data is incorporated into strings by enforcing a clear separation between the template structure (controlled by the developer) and the embedded values (potentially from untrusted sources). This architectural shift makes entire classes of security vulnerabilities much harder to introduce.
The primary security benefit comes from the template processor pattern. When you use a custom processor designed for a specific context (SQL, HTML, shell commands), that processor can apply context-appropriate validation and escaping. For example, a SQL template processor can ensure that embedded values are properly parameterized, making SQL injection attacks impossible even if the value contains malicious SQL syntax.
Traditional string concatenation treats all parts of the string equally - the code has no way to distinguish between the structural parts (like SQL keywords) and the data parts (user input). This means malicious input can include characters that change the structure of the command. String templates maintain this distinction at the language level: the template literal defines structure, and the embedded expressions provide data. The processor mediates between them.
Another security benefit is compile-time validation. The compiler verifies that expressions are well-typed and exist in scope, preventing runtime errors that might expose system information. Custom processors can add additional compile-time checks specific to their domain.
Finally, string templates encourage using purpose-built processors rather than manual escaping, which developers often forget or implement incorrectly. By making the safe approach the most convenient approach, string templates guide developers toward secure-by-default code.
Code Example:
import java.sql.*;
import static java.lang.StringTemplate.RAW;
public class SecurityDemo {
// UNSAFE: Traditional concatenation - SQL Injection vulnerable
public static User findUserUnsafe(Connection conn, String username) throws SQLException {
// If username = "admin' OR '1'='1", this becomes:
// SELECT * FROM users WHERE username = 'admin' OR '1'='1'
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql); // VULNERABLE!
// Returns all users instead of just one
return extractUser(rs);
}
// SAFER: PreparedStatement (traditional safe approach)
public static User findUserSafe(Connection conn, String username) throws SQLException {
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, username); // Safely parameterized
ResultSet rs = stmt.executeQuery();
return extractUser(rs);
}
// SAFEST: Custom SQL template processor (hypothetical)
// This processor would automatically parameterize values
public static User findUserTemplate(Connection conn, String username) throws SQLException {
// Custom SQL processor ensures values are parameterized
PreparedStatement stmt = SQL."SELECT * FROM users WHERE username = \{username}";
// The SQL processor creates a PreparedStatement with parameters automatically
ResultSet rs = stmt.executeQuery();
return extractUser(rs);
}
// HTML escaping example
public static String renderUserProfile(String userName, String bio) {
// UNSAFE: XSS vulnerability if bio contains <script>alert('XSS')</script>
String unsafeHtml = "<div><h1>" + userName + "</h1><p>" + bio + "</p></div>";
// SAFE: Custom HTML processor would escape automatically
// String safeHtml = HTML."<div><h1>\{userName}</h1><p>\{bio}</p></div>";
// Processor escapes: <script> becomes <script>
return unsafeHtml;
}
// Demonstration of structure vs. data separation
public static void demonstrateSeparation() {
String userInput = "'; DROP TABLE users; --";
// With concatenation, user input alters structure
String dangerous = "DELETE FROM logs WHERE id = '" + userInput + "'";
// Becomes: DELETE FROM logs WHERE id = ''; DROP TABLE users; --'
// This executes TWO commands!
// With template processor, structure is fixed
// SQL."DELETE FROM logs WHERE id = \{userInput}"
// Processor treats entire userInput as a single string value
// Executes: DELETE FROM logs WHERE id = '''; DROP TABLE users; --'
// The quotes are escaped, making it a literal search for that weird string
}
// Compile-time safety
public static void compileTimeSafety() {
String name = "Alice";
// int age = 30;
// This won't compile - 'age' is commented out
// String msg = STR."Name: \{name}, Age: \{age}";
// Compile error: cannot find symbol 'age'
// Concatenation would compile but fail at runtime
// String unsafe = "Name: " + name + ", Age: " + age;
// Runtime: variable 'age' might not have been initialized
}
private static User extractUser(ResultSet rs) throws SQLException {
if (rs.next()) {
return new User(rs.getString("username"), rs.getString("email"));
}
return null;
}
record User(String username, String email) {}
}
Diagram:
flowchart TD
A[User Input: admin' OR '1'='1] --> B{String Composition Method}
B --> C[Concatenation]
C --> D["SQL: WHERE user = 'admin' OR '1'='1'"]
D --> E[Structure Modified]
E --> F[SQL Injection Success]
B --> G[String Template + Processor]
G --> H[Processor Validates/Escapes]
H --> I["SQL: WHERE user = 'admin\\' OR \\'1\\'=\\'1'"]
I --> J[Structure Preserved]
J --> K[Attack Prevented]
L[Template Literal] --> M[Fixed Structure]
N[Embedded Expression] --> O[Data Only]
M --> G
O --> H
style F fill:#ff6b6b
style K fill:#51cf66
References:
↑ Back to topForeign Function and Memory API
What is the Foreign Function and Memory API?
The 30-Second Answer: The Foreign Function and Memory API (FFM API) is a standard Java API that allows Java programs to interoperate with native code and manage off-heap memory safely and efficiently. It provides a modern replacement for JNI, offering better performance, safety, and ease of use through the Foreign-Memory Access API and Foreign Linker API.
The 2-Minute Answer (If They Want More): The FFM API, finalized in Java 22 as JEP 454, is a revolutionary addition to the Java platform that enables seamless interaction between Java and native libraries. It consists of two main components: the Memory Segments API for managing off-heap memory, and the Foreign Linker API for calling native functions.
The API addresses longstanding pain points with JNI by providing a pure-Java approach to native interoperability. Memory segments offer deterministic deallocation, spatial and temporal safety checks, and thread-confinement guarantees. The Foreign Linker eliminates the need for writing C glue code, using method handles and memory layouts to define native function signatures directly in Java.
Unlike JNI's error-prone manual memory management and brittle JNI headers, the FFM API leverages modern Java features like sealed classes, records, and pattern matching. The jextract tool can automatically generate Java bindings from C header files, dramatically reducing boilerplate and maintenance burden.
The API operates in a restricted mode by default, requiring explicit permission to access foreign functions and memory, providing an additional security layer. This makes it suitable for cloud and containerized environments where security boundaries are critical.
Code Example:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class FFMExample {
public static void main(String[] args) throws Throwable {
// Obtain a native linker
Linker linker = Linker.nativeLinker();
// Allocate native memory using Arena
try (Arena arena = Arena.ofConfined()) {
// Allocate a C string
MemorySegment cString = arena.allocateUtf8String("Hello from Java!");
// Define memory layout for a native struct
StructLayout pointLayout = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")
);
// Allocate and initialize a struct
MemorySegment point = arena.allocate(pointLayout);
point.set(ValueLayout.JAVA_INT, 0, 10); // x = 10
point.set(ValueLayout.JAVA_INT, 4, 20); // y = 20
// Access struct fields
int x = point.get(ValueLayout.JAVA_INT, 0);
int y = point.get(ValueLayout.JAVA_INT, 4);
System.out.println("Point: (" + x + ", " + y + ")");
} // Memory automatically freed when arena closes
}
}
Diagram:
flowchart TD
A[Java Application] --> B[FFM API]
B --> C[Foreign-Memory Access API]
B --> D[Foreign Linker API]
C --> E[Memory Segments]
C --> F[Memory Layouts]
C --> G[Arena]
D --> H[Method Handles]
D --> I[Function Descriptors]
E --> J[Off-Heap Memory]
H --> K[Native Library Functions]
style B fill:#4CAF50
style C fill:#2196F3
style D fill:#FF9800
References:
↑ Back to topStream Gatherers
What is the windowFixed() gatherer and when would you use it?
The 30-Second Answer: windowFixed(n) is a built-in gatherer that batches consecutive stream elements into fixed-size lists of n elements. It's ideal for bulk processing scenarios like batch database inserts, paginated API calls, or processing large datasets in manageable chunks, with the last batch potentially containing fewer elements if the stream size isn't evenly divisible.
The 2-Minute Answer (If They Want More): The windowFixed() gatherer partitions a stream into non-overlapping windows (batches) of a specified size. Unlike windowing in reactive streams or time-series databases, this is purely count-based—it groups elements by quantity, not by time or other criteria. When you call Stream.gather(Gatherers.windowFixed(5)), every 5 consecutive elements become a List that's emitted downstream.
This is particularly valuable for performance optimization when dealing with systems that perform better with batch operations. For example, database bulk inserts are typically much faster than individual inserts. Instead of inserting 10,000 records one at a time, you can use windowFixed(100) to create batches of 100, then execute batch inserts. Similarly, many REST APIs have batch endpoints that accept multiple items, reducing network overhead.
The last window deserves special attention—if your stream has 23 elements and you use windowFixed(10), you'll get three windows: two with 10 elements and one with 3 elements. This automatic handling of the remainder makes it robust for real-world data where counts rarely align perfectly with batch sizes. Your downstream code should be prepared to handle variable-sized windows, or you can filter out incomplete batches if needed.
windowFixed() differs from windowSliding() in that windows don't overlap. Fixed windows are mutually exclusive partitions (elements 1-5, 6-10, 11-15), while sliding windows overlap (elements 1-5, 2-6, 3-7). Choose windowFixed() when you need to process each element exactly once in batch form, and windowSliding() when you need context from neighboring elements (like calculating moving averages).
Code Example:
import java.util.stream.Gatherers;
import java.util.stream.Stream;
import java.util.List;
import java.util.ArrayList;
import java.sql.Connection;
import java.sql.PreparedStatement;
public class WindowFixedExamples {
public static void main(String[] args) {
// BASIC USAGE
List<List<Integer>> batches = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
.gather(Gatherers.windowFixed(3))
.toList();
System.out.println("Fixed windows of 3: " + batches);
// Output: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11]]
// Note: Last batch has only 2 elements
// USE CASE 1: Batch Database Inserts
List<User> users = fetchUsersToImport(); // 10,000 users
// WITHOUT windowFixed - slow, 10,000 individual inserts
users.forEach(user -> insertUser(user));
// WITH windowFixed - fast, 100 batch operations of 100 users each
users.stream()
.gather(Gatherers.windowFixed(100))
.forEach(batch -> batchInsertUsers(batch));
// USE CASE 2: Paginated API Calls
List<String> productIds = getAllProductIds(); // 5,000 IDs
// API accepts max 50 IDs per request
List<ProductDetails> allProducts = productIds.stream()
.gather(Gatherers.windowFixed(50))
.flatMap(batch -> fetchProductDetails(batch).stream())
.toList();
// USE CASE 3: Processing Large Files in Chunks
long totalSize = Stream.iterate(0, n -> n + 1)
.limit(1000)
.gather(Gatherers.windowFixed(100))
.peek(batch -> System.out.println("Processing chunk of " + batch.size()))
.mapToLong(batch -> batch.stream().mapToLong(Integer::longValue).sum())
.sum();
System.out.println("Total: " + totalSize);
// USE CASE 4: Handling Variable-Sized Last Batch
Stream.of("A", "B", "C", "D", "E", "F", "G")
.gather(Gatherers.windowFixed(3))
.forEach(batch -> {
if (batch.size() < 3) {
System.out.println("Partial batch: " + batch);
// Handle incomplete batch differently
} else {
System.out.println("Full batch: " + batch);
}
});
// Output:
// Full batch: [A, B, C]
// Full batch: [D, E, F]
// Partial batch: [G]
// USE CASE 5: Filtering Out Incomplete Batches
List<List<Integer>> completeBatchesOnly = Stream.iterate(1, n -> n + 1)
.limit(25)
.gather(Gatherers.windowFixed(10))
.filter(batch -> batch.size() == 10) // Only full batches
.toList();
System.out.println("Complete batches: " + completeBatchesOnly.size());
// Output: 2 (batches of [1-10] and [11-20], [21-25] is filtered out)
// USE CASE 6: Combining with Other Stream Operations
List<Double> averages = Stream.of(10, 20, 30, 40, 50, 60, 70, 80, 90)
.gather(Gatherers.windowFixed(3)) // Batch into groups of 3
.map(batch -> batch.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0)) // Calculate average of each batch
.toList();
System.out.println("Batch averages: " + averages);
// Output: [20.0, 50.0, 80.0]
// USE CASE 7: Parallel Stream Processing with Batches
List<Integer> largeDataset = Stream.iterate(1, n -> n + 1)
.limit(10000)
.toList();
long parallelSum = largeDataset.parallelStream()
.gather(Gatherers.windowFixed(500)) // Process in chunks of 500
.parallel()
.mapToLong(batch -> processBatchHeavyComputation(batch))
.sum();
System.out.println("Parallel processed sum: " + parallelSum);
}
// Helper methods for examples
private static List<User> fetchUsersToImport() {
return new ArrayList<>(); // Placeholder
}
private static void insertUser(User user) {
// Single insert - slow
}
private static void batchInsertUsers(List<User> users) {
// Batch insert using PreparedStatement.addBatch()
// Much faster than individual inserts
}
private static List<String> getAllProductIds() {
return new ArrayList<>(); // Placeholder
}
private static List<ProductDetails> fetchProductDetails(List<String> ids) {
// API call: POST /products/batch with IDs
return new ArrayList<>(); // Placeholder
}
private static long processBatchHeavyComputation(List<Integer> batch) {
// Simulate expensive operation on each batch
return batch.stream().mapToLong(Integer::longValue).sum();
}
static class User {}
static class ProductDetails {}
}
Diagram:
flowchart TD
A[Stream: 1,2,3,4,5,6,7,8,9,10,11] --> B[windowFixed 3]
B --> C[Window 1: 1,2,3]
B --> D[Window 2: 4,5,6]
B --> E[Window 3: 7,8,9]
B --> F[Window 4: 10,11]
C --> G[Downstream Processing]
D --> G
E --> G
F --> G
style F fill:#ffe1e1
subgraph "Note: Last window may be partial"
F
end
Practical Comparison:
flowchart LR
subgraph "windowFixed(3) - Non-overlapping"
A1[1,2,3] --> A2[4,5,6] --> A3[7,8,9]
end
subgraph "windowSliding(3) - Overlapping"
B1[1,2,3] --> B2[2,3,4] --> B3[3,4,5] --> B4[4,5,6]
end
style A1 fill:#ccffcc
style A2 fill:#ccffcc
style A3 fill:#ccffcc
style B1 fill:#ccccff
style B2 fill:#ccccff
style B3 fill:#ccccff
style B4 fill:#ccccff
References:
↑ Back to topBest Practices and Migration
What is the recommended approach for concurrent programming in Java 25?
The 30-Second Answer: Default to virtual threads for I/O-bound tasks using Executors.newVirtualThreadPerTaskExecutor(), as they scale to millions of concurrent operations with minimal overhead. Use structured concurrency for coordinating related tasks, and reserve platform threads with ForkJoinPool only for CPU-intensive parallel computations. Avoid traditional thread pools for blocking operations.
The 2-Minute Answer (If They Want More):
Java 25's concurrency model centers on virtual threads (Project Loom), which fundamentally change how we approach concurrent programming. Virtual threads are lightweight, managed by the JVM rather than the OS, allowing millions to run concurrently. For any I/O-bound work—database calls, HTTP requests, file operations—use virtual threads without worrying about thread pool sizing or blocking.
Structured concurrency provides a framework for managing related concurrent tasks as a unit. It ensures that if a parent task is cancelled or fails, all child tasks are automatically cleaned up, preventing resource leaks and zombie threads. This is particularly valuable for microservices making multiple parallel calls, where you want all-or-nothing semantics.
For CPU-bound tasks like data processing, image manipulation, or complex calculations, platform threads with parallel streams or ForkJoinPool remain the better choice. Virtual threads provide no advantage here since they're designed for blocking operations, not CPU utilization. The key is matching the concurrency primitive to your workload.
Modern Java also emphasizes thread-safe immutability over synchronization. Use immutable records and sealed types where possible, leverage concurrent collections from java.util.concurrent, and consider using Software Transactional Memory patterns through libraries. Only use synchronized or locks when you genuinely need shared mutable state, and prefer higher-level abstractions like CompletableFuture or StructuredTaskScope.
Code Example:
// Modern Java 25 concurrent programming patterns
import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.*;
// 1. Virtual threads for I/O-bound operations
public class OrderService {
private final HttpClient httpClient = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
// Each request gets its own virtual thread - scales easily
public CompletableFuture<Order> fetchOrder(String orderId) {
return CompletableFuture.supplyAsync(() -> {
try {
var request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/orders/" + orderId))
.build();
var response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
return parseOrder(response.body());
} catch (Exception e) {
throw new RuntimeException("Failed to fetch order", e);
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
}
// 2. Structured concurrency for coordinated tasks
public class CustomerDashboardService {
public record DashboardData(
Customer customer,
List<Order> orders,
List<Recommendation> recommendations
) {}
// All subtasks succeed or fail together
public DashboardData loadDashboard(String customerId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Launch multiple concurrent tasks
Subtask<Customer> customerTask = scope.fork(() ->
fetchCustomer(customerId));
Subtask<List<Order>> ordersTask = scope.fork(() ->
fetchOrders(customerId));
Subtask<List<Recommendation>> recommendationsTask = scope.fork(() ->
fetchRecommendations(customerId));
// Wait for all to complete (or first failure)
scope.join();
scope.throwIfFailed();
// All succeeded, collect results
return new DashboardData(
customerTask.get(),
ordersTask.get(),
recommendationsTask.get()
);
}
}
// Variation: Return as soon as any succeeds (race pattern)
public String findFirstAvailableInventory(String productId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Check multiple warehouses concurrently
scope.fork(() -> checkWarehouse("US-EAST", productId));
scope.fork(() -> checkWarehouse("US-WEST", productId));
scope.fork(() -> checkWarehouse("EU-CENTRAL", productId));
scope.join();
return scope.result(); // First successful result
}
}
}
// 3. CPU-bound tasks: Use parallel streams
public class DataProcessor {
public List<ProcessedData> processLargeDataset(List<RawData> dataset) {
// Uses common ForkJoinPool for CPU parallelism
return dataset.parallelStream()
.map(this::cpuIntensiveTransform)
.collect(Collectors.toList());
}
private ProcessedData cpuIntensiveTransform(RawData raw) {
// Complex calculation, no I/O
return new ProcessedData(/* ... */);
}
}
// 4. Combining virtual threads with reactive patterns
public class EventProcessor {
public void processEventStream(Flow.Publisher<Event> events) {
// Each event processed in its own virtual thread
events.subscribe(new Flow.Subscriber<>() {
private Flow.Subscription subscription;
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(Event event) {
// Process each event in virtual thread
Thread.startVirtualThread(() -> handleEvent(event));
}
@Override
public void onError(Throwable throwable) {
log.error("Stream error", throwable);
}
@Override
public void onComplete() {
log.info("Stream completed");
}
});
}
private void handleEvent(Event event) {
// Can block safely in virtual thread
try {
saveToDatabase(event);
notifySubscribers(event);
} catch (Exception e) {
log.error("Event processing failed", e);
}
}
}
// 5. Thread-safe patterns with immutability
public class AccountService {
// Use concurrent collections for shared mutable state
private final ConcurrentHashMap<String, Account> accounts =
new ConcurrentHashMap<>();
// Operations return new immutable records
public Account updateBalance(String accountId, BigDecimal delta) {
return accounts.compute(accountId, (id, account) -> {
if (account == null) {
throw new IllegalArgumentException("Account not found");
}
// Create new immutable account with updated balance
return new Account(
account.id(),
account.owner(),
account.balance().add(delta)
);
});
}
public record Account(String id, String owner, BigDecimal balance) {}
}
// 6. Scoped values for context propagation (replacement for ThreadLocal)
public class RequestContextService {
private static final ScopedValue<String> REQUEST_ID =
ScopedValue.newInstance();
public void handleRequest(String requestId) {
// Bind value for this scope and all child virtual threads
ScopedValue.where(REQUEST_ID, requestId)
.run(() -> {
processRequest();
// REQUEST_ID automatically available in all virtual threads
});
}
private void processRequest() {
String requestId = REQUEST_ID.get();
// Use in logging, tracing, etc.
Thread.startVirtualThread(() -> {
// REQUEST_ID still available in child virtual thread
String childRequestId = REQUEST_ID.get();
callExternalService(childRequestId);
});
}
}
// 7. Async error handling with CompletableFuture
public class ResilientService {
public CompletableFuture<String> callWithRetry(String endpoint) {
return CompletableFuture.supplyAsync(() ->
callEndpoint(endpoint),
Executors.newVirtualThreadPerTaskExecutor()
)
.exceptionally(ex -> {
// Retry on failure
return callEndpoint(endpoint);
})
.orTimeout(5, TimeUnit.SECONDS)
.handle((result, ex) -> {
if (ex != null) {
log.error("Call failed after retry", ex);
return "DEFAULT_VALUE";
}
return result;
});
}
}
Diagram:
flowchart TD
A[Concurrent Task] --> B{Task Type?}
B -->|I/O Bound| C[Virtual Threads]
C --> D[Single Task: Thread.startVirtualThread]
C --> E[Executor Service: newVirtualThreadPerTaskExecutor]
C --> F[Coordinated Tasks: StructuredTaskScope]
B -->|CPU Bound| G[Platform Threads]
G --> H[Parallel Streams]
G --> I[ForkJoinPool]
B -->|Mixed| J[Hybrid Approach]
J --> K[Virtual threads for I/O]
J --> L[Platform threads for CPU work]
F --> M{Failure Handling?}
M -->|Fail Fast| N[ShutdownOnFailure]
M -->|First Success| O[ShutdownOnSuccess]
A --> P{Shared State?}
P -->|Yes| Q[Concurrent Collections]
P -->|No| R[Immutable Data + Records]
A --> S{Context Propagation?}
S -->|Yes| T[ScopedValue]
S -->|No| U[Direct Parameters]
style C fill:#90EE90
style G fill:#FFB6C1
style R fill:#87CEEB
References:
- JEP 444: Virtual Threads
- JEP 453: Structured Concurrency
- JEP 464: Scoped Values
- Java Concurrency in Practice
- Project Loom Documentation
Record Classes
What are the best practices for designing immutable data with records?
The 30-Second Answer: Best practices include using defensive copying for mutable components (like collections), validating inputs in compact constructors, preferring immutable types for components, documenting nullability contracts, and returning new record instances instead of mutating state. Design records as pure data carriers with minimal behavior, favoring composition and factory methods for complex construction.
The 2-Minute Answer (If They Want More):
Designing effective immutable data with records requires careful attention to deep immutability, not just shallow field-level immutability. While record fields are automatically final, if a component is a mutable object (like a List or array), external code can still modify it. Always use defensive copying in the compact constructor for mutable components, converting them to immutable variants using List.copyOf(), Set.copyOf(), or Collections.unmodifiable*().
Validation should occur in compact constructors to enforce invariants early. Validate inputs, normalize data (like trimming strings or converting to lowercase), and throw appropriate exceptions for invalid states. This ensures records always represent valid domain objects, never leaving them in inconsistent states.
Choose component types wisely. Prefer immutable types like String, Integer, LocalDate, and immutable collections over mutable alternatives. If you must include complex objects, ensure they're also immutable (ideally records themselves). This creates a chain of immutability throughout your data model.
Document nullability clearly. Records don't inherently prevent null values - if null is invalid, validate in the constructor or use Objects.requireNonNull(). Consider using Optional for components that may legitimately be absent, making the optionality explicit in the type signature.
Keep records focused on data, not behavior. While you can add methods, resist the temptation to add complex business logic. Records should be transparent data carriers - if you need significant behavior, consider whether a traditional class is more appropriate. Static factory methods and named constructors can make record construction more expressive without cluttering the record itself.
For derived or computed values, use instance methods rather than storing them as components. This maintains the single source of truth and prevents redundancy. For example, a Rectangle record might store width and height, with an area() method computing the area on demand rather than storing it.
Code Example:
// ❌ BAD: Mutable components without defensive copying
public record BadGrades(String studentId, List<Integer> scores) {}
void problemWithMutability() {
List<Integer> scores = new ArrayList<>(List.of(85, 90, 95));
BadGrades grades = new BadGrades("S123", scores);
scores.add(100); // MUTATES the record's internal state!
System.out.println(grades.scores()); // [85, 90, 95, 100] - modified!
}
// âś… GOOD: Defensive copying ensures true immutability
public record GoodGrades(String studentId, List<Integer> scores) {
public GoodGrades {
// Defensive copy + immutability
scores = List.copyOf(scores); // Unmodifiable copy
// Validation
if (studentId == null || studentId.isBlank()) {
throw new IllegalArgumentException("Student ID required");
}
for (Integer score : scores) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("Invalid score: " + score);
}
}
}
}
// âś… BEST PRACTICE: Null safety and validation
public record Email(String address) {
public Email {
Objects.requireNonNull(address, "Email address cannot be null");
address = address.toLowerCase().trim();
if (!address.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
throw new IllegalArgumentException("Invalid email format");
}
}
}
// âś… BEST PRACTICE: Using Optional for nullable components
public record UserProfile(
String username,
Optional<String> middleName, // Explicitly optional
Optional<LocalDate> birthDate
) {
public UserProfile {
Objects.requireNonNull(username, "Username required");
Objects.requireNonNull(middleName, "Middle name Optional cannot be null");
Objects.requireNonNull(birthDate, "Birth date Optional cannot be null");
}
// Convenience constructor for common case
public UserProfile(String username) {
this(username, Optional.empty(), Optional.empty());
}
}
// âś… BEST PRACTICE: Computed values via methods, not stored fields
public record Rectangle(double width, double height) {
public Rectangle {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
}
// Computed on demand, not stored
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
public boolean isSquare() {
return width == height;
}
}
// âś… BEST PRACTICE: Factory methods for complex construction
public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
Objects.requireNonNull(start, "Start date required");
Objects.requireNonNull(end, "End date required");
if (start.isAfter(end)) {
throw new IllegalArgumentException("Start must be before or equal to end");
}
}
// Named constructors make intent clear
public static DateRange ofDays(LocalDate start, int days) {
return new DateRange(start, start.plusDays(days));
}
public static DateRange currentMonth() {
LocalDate now = LocalDate.now();
LocalDate start = now.withDayOfMonth(1);
LocalDate end = now.withDayOfMonth(now.lengthOfMonth());
return new DateRange(start, end);
}
public static DateRange currentYear() {
LocalDate now = LocalDate.now();
return new DateRange(now.withDayOfYear(1), now.withDayOfYear(now.lengthOfYear()));
}
// Methods return new instances for transformations
public DateRange extend(int days) {
return new DateRange(start, end.plusDays(days));
}
public DateRange shiftBy(int days) {
return new DateRange(start.plusDays(days), end.plusDays(days));
}
}
// âś… BEST PRACTICE: Prefer immutable components (composition of records)
public record Address(String street, String city, String zipCode, String country) {
public Address {
Objects.requireNonNull(street, "Street required");
Objects.requireNonNull(city, "City required");
Objects.requireNonNull(zipCode, "Zip code required");
Objects.requireNonNull(country, "Country required");
}
}
public record Customer(String id, String name, Address address, List<String> tags) {
public Customer {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
Objects.requireNonNull(address); // Address is immutable record
tags = List.copyOf(tags); // Defensive copy
}
// Returns new instance with updated address
public Customer withAddress(Address newAddress) {
return new Customer(id, name, newAddress, tags);
}
// Returns new instance with added tag
public Customer addTag(String tag) {
List<String> newTags = new ArrayList<>(tags);
newTags.add(tag);
return new Customer(id, name, address, newTags);
}
}
// âś… BEST PRACTICE: Handling collections properly
public record ShoppingCart(String userId, List<CartItem> items) {
public ShoppingCart {
Objects.requireNonNull(userId);
// Deep copy for true immutability
items = items.stream()
.map(item -> new CartItem(item.productId(), item.quantity()))
.toList(); // Creates immutable list
}
public record CartItem(String productId, int quantity) {
public CartItem {
Objects.requireNonNull(productId);
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
}
}
public ShoppingCart addItem(CartItem item) {
List<CartItem> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(userId, newItems);
}
public double totalItems() {
return items.stream().mapToInt(CartItem::quantity).sum();
}
}
// âś… BEST PRACTICE: Type-safe builders for complex records
public record Order(
String orderId,
String customerId,
List<OrderItem> items,
LocalDateTime createdAt,
OrderStatus status
) {
public enum OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
public record OrderItem(String productId, int quantity, double price) {
public OrderItem {
Objects.requireNonNull(productId);
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
}
}
public Order {
Objects.requireNonNull(orderId);
Objects.requireNonNull(customerId);
Objects.requireNonNull(status);
items = List.copyOf(items);
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
// Builder pattern for complex construction
public static class Builder {
private String orderId;
private String customerId;
private List<OrderItem> items = new ArrayList<>();
private LocalDateTime createdAt;
private OrderStatus status = OrderStatus.PENDING;
public Builder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public Builder customerId(String customerId) {
this.customerId = customerId;
return this;
}
public Builder addItem(String productId, int quantity, double price) {
this.items.add(new OrderItem(productId, quantity, price));
return this;
}
public Builder status(OrderStatus status) {
this.status = status;
return this;
}
public Order build() {
return new Order(orderId, customerId, items, createdAt, status);
}
}
public static Builder builder() {
return new Builder();
}
}
Diagram:
flowchart TD
A[Record Best Practices] --> B[Immutability]
A --> C[Validation]
A --> D[API Design]
A --> E[Type Safety]
B --> B1[Defensive copying of mutable components]
B --> B2[Use List.copyOf, Set.copyOf]
B --> B3[Prefer immutable types]
B --> B4[Compose with other records]
C --> C1[Validate in compact constructor]
C --> C2[Use Objects.requireNonNull]
C --> C3[Normalize data early]
C --> C4[Fail fast with clear exceptions]
D --> D1[Keep behavior minimal]
D --> D2[Use factory methods]
D --> D3[Computed values as methods]
D --> D4[Return new instances for changes]
E --> E1[Use Optional for nullable fields]
E --> E2[Document null contracts]
E --> E3[Strong typing over primitives]
E --> E4[Sealed hierarchies for variants]
Best Practices Checklist:
| Practice | Why | Example |
|---|---|---|
| Defensive copying | Prevents external mutation of mutable components | List.copyOf(items) |
| Null validation | Ensures components are never null if required | Objects.requireNonNull(name) |
| Input validation | Enforces business rules and invariants | if (age < 0) throw ... |
| Data normalization | Ensures consistent format | email.toLowerCase().trim() |
| Immutable components | Prevents mutation at any level | Use String, LocalDate, other records |
| Optional for nullable | Makes optionality explicit in type | Optional<String> middleName |
| Computed methods | Avoids redundant stored data | double area() { return w * h; } |
| Factory methods | Provides expressive construction | DateRange.currentMonth() |
| Immutable returns | Maintains immutability in APIs | withName(String n) { return new ...} |
| Minimal behavior | Keeps records as data carriers | Move complex logic to services |
References:
- Effective Java (3rd Edition) - Item 17: Minimize mutability
- Java Records Best Practices
- Oracle Java Records Guide
- Modern Java in Action - Records and Immutability
Sequenced Collections
What are sequenced collections and why were they introduced?
The 30-Second Answer:
Sequenced collections, introduced in Java 21 (JEP 431), are collections with a defined encounter order and uniform APIs for accessing first and last elements. They solve the problem of inconsistent APIs across ordered collections like List, Deque, and SortedSet by providing a unified interface with methods like getFirst(), getLast(), and reversed().
The 2-Minute Answer (If They Want More):
Prior to Java 21, working with ordered collections required remembering different APIs for different collection types. To get the first element, you'd use list.get(0) for List, deque.getFirst() for Deque, or sortedSet.first() for SortedSet. This inconsistency made code harder to write and maintain, especially when working with generic ordered collections.
The sequenced collections framework introduces three new interfaces: SequencedCollection, SequencedSet, and SequencedMap. These interfaces define a consistent API for all collections with a defined encounter order. The framework provides uniform methods for accessing elements at both ends of the sequence and for obtaining reversed views.
This enhancement doesn't just improve API consistency—it also enables better polymorphism. You can now write methods that accept SequencedCollection as a parameter and work uniformly with any ordered collection, whether it's a List, Deque, or LinkedHashSet. The reversed views are particularly powerful because they're live views that reflect changes to the original collection, making bidirectional iteration more efficient and intuitive.
Code Example:
import java.util.*;
public class SequencedCollectionsDemo {
public static void main(String[] args) {
// Unified API across different collection types
demonstrateSequencedCollection();
demonstrateSequencedSet();
demonstrateSequencedMap();
}
private static void demonstrateSequencedCollection() {
// List implements SequencedCollection
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
System.out.println("First: " + list.getFirst()); // A
System.out.println("Last: " + list.getLast()); // C
// Deque also implements SequencedCollection
Deque<String> deque = new ArrayDeque<>(List.of("X", "Y", "Z"));
System.out.println("First: " + deque.getFirst()); // X
System.out.println("Last: " + deque.getLast()); // Z
// Reversed view - live view of the collection
List<String> reversed = list.reversed();
System.out.println("Reversed: " + reversed); // [C, B, A]
list.add("D");
System.out.println("Reversed after add: " + reversed); // [D, C, B, A]
}
private static void demonstrateSequencedSet() {
SequencedSet<String> set = new LinkedHashSet<>();
set.add("First");
set.add("Second");
set.add("Third");
System.out.println("First element: " + set.getFirst()); // First
System.out.println("Last element: " + set.getLast()); // Third
// Reversed view maintains set semantics
SequencedSet<String> reversedSet = set.reversed();
System.out.println("Reversed: " + reversedSet); // [Third, Second, First]
}
private static void demonstrateSequencedMap() {
SequencedMap<Integer, String> map = new LinkedHashMap<>();
map.put(1, "One");
map.put(2, "Two");
map.put(3, "Three");
// Access first and last entries
System.out.println("First entry: " + map.firstEntry()); // 1=One
System.out.println("Last entry: " + map.lastEntry()); // 3=Three
// Reversed view of the map
SequencedMap<Integer, String> reversed = map.reversed();
System.out.println("Reversed keys: " + reversed.sequencedKeySet());
// [3, 2, 1]
}
// Generic method working with any sequenced collection
public static <T> void processSequencedCollection(SequencedCollection<T> collection) {
if (!collection.isEmpty()) {
T first = collection.getFirst();
T last = collection.getLast();
System.out.println("Processing from " + first + " to " + last);
// Iterate in reverse without creating a new collection
for (T element : collection.reversed()) {
System.out.println(element);
}
}
}
}
Diagram:
flowchart TD
Collection[Collection<E>]
SequencedCollection[SequencedCollection<E><br/>+getFirst<br/>+getLast<br/>+reversed<br/>+addFirst<br/>+addLast<br/>+removeFirst<br/>+removeLast]
List[List<E>]
Deque[Deque<E>]
Set[Set<E>]
SequencedSet[SequencedSet<E>]
SortedSet[SortedSet<E>]
ArrayList[ArrayList]
LinkedList[LinkedList]
ArrayDeque[ArrayDeque]
LinkedHashSet[LinkedHashSet]
TreeSet[TreeSet]
Map[Map<K,V>]
SequencedMap[SequencedMap<K,V><br/>+firstEntry<br/>+lastEntry<br/>+pollFirstEntry<br/>+pollLastEntry<br/>+putFirst<br/>+putLast<br/>+reversed<br/>+sequencedKeySet<br/>+sequencedValues<br/>+sequencedEntrySet]
SortedMap[SortedMap<K,V>]
LinkedHashMap[LinkedHashMap]
TreeMap[TreeMap]
Collection --> SequencedCollection
SequencedCollection --> List
SequencedCollection --> Deque
SequencedCollection --> SequencedSet
Set --> SequencedSet
SequencedSet --> SortedSet
List --> ArrayList
List --> LinkedList
Deque --> LinkedList
Deque --> ArrayDeque
SequencedSet --> LinkedHashSet
SortedSet --> TreeSet
Map --> SequencedMap
SequencedMap --> SortedMap
SequencedMap --> LinkedHashMap
SortedMap --> TreeMap
style SequencedCollection fill:#e1f5ff
style SequencedSet fill:#e1f5ff
style SequencedMap fill:#e1f5ff
References:
↑ Back to top