equals(), they must return the same hashCode(). HashMap uses hashCode to find the right bucket, then equals to find the exact entry. If you override equals but forget hashCode, two logically equal objects land in different buckets — map.get() returns null even though the key is there. The classic bug is adding a mutable field to a key object, then mutating it after it's in the map — the hash changes, so the entry is now in the wrong bucket and lost. I always use IDE-generated or Lombok @EqualsAndHashCode, and never use mutable fields in keys."
The contract has one core rule: if two objects are equal via equals(), they must return the same hashCode(). The reverse doesn't have to be true — two objects can share a hash code without being equal, that's just a collision. Breaking this contract silently corrupts any hash-based structure — HashMap, HashSet, anything that uses hashing to find elements.
When you call map.get(key), HashMap first calls hashCode() to find the right bucket, then calls equals() to find the exact entry within that bucket. If you override equals() but forget hashCode(), two logically equal objects land in different buckets. get() returns null even though the key "exists" — and you'll spend a long time debugging it because the code looks correct.
The worst case I've seen is mutable keys. You put an object into a map, then mutate one of the fields that hashCode depends on — now the object is in the wrong bucket and the map can never find it again:
Account acc = new Account("ACC001");
map.put(acc, balance);
acc.id = "ACC999"; // mutation after insert — hash changed
map.get(acc); // returns null — it's in the wrong bucket now
Rule: anything used as a map key should be effectively immutable.
The other one I hit regularly is Lombok's @Data on JPA entities. It generates equals/hashCode from all fields including lazy collections, which either triggers N+1 queries or throws LazyInitializationException when you put the entity in a Set. The fix is always explicit:
@EqualsAndHashCode(of = "id") // use only the business key, nothing else
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Transaction t)) return false;
return id.equals(t.id);
}
@Override
public int hashCode() { return Objects.hash(id); }
volatile to the id field, do I still need hashCode?"if (!map.containsKey(k)) map.put(k, v), another thread can insert between the check and the put. That's why CHM has putIfAbsent() and computeIfAbsent() — atomic compound operations built in."
HashMap is not thread-safe — concurrent writes can corrupt it, and in older JVMs could even cause an infinite loop during resize. ConcurrentHashMap is designed for concurrent use. In Java 8+, it uses CAS for inserting into an empty bucket and only synchronizes on the bucket head when there's a collision. Reads are completely lock-free — they use volatile reads on node values. So you get very high read throughput with minimal write contention.
CHM makes individual operations atomic, but not compound operations. This is the classic check-then-act race:
// BROKEN — another thread can insert between the check and the put:
if (!map.containsKey(key)) {
map.put(key, expensiveCompute());
}
// CORRECT — single atomic operation:
map.computeIfAbsent(key, k -> expensiveCompute());
But computeIfAbsent has its own trap — it holds the bucket lock for the entire duration of the lambda. If your compute function does I/O or calls an external service, you've just serialised every thread that touches that bucket. I always move the slow work outside:
// Pre-compute outside the map operation if computation is expensive:
Value v = expensiveCompute(); // no lock held here
map.putIfAbsent(key, v); // atomic, fast
CHM has no cross-key atomicity. If you need to atomically move a value from one key to another — like transferring a balance — CHM can't help. You need a ReadWriteLock around the whole operation, or rethink the data structure entirely.
Also worth knowing: size() on CHM is approximate. It uses a LongAdder internally with per-cell counters to avoid contention, and sums them at read time. Under heavy concurrent modification, the count can be slightly stale.
// Atomic word count — cleaner than compute():
map.merge(word, 1, Integer::sum);
// if absent: inserts 1. If present: applies Integer::sum atomically.
synchronized. It's simpler, the JVM manages the release automatically even on exception, and since Java 6 it's heavily optimized — biased locking, spin locks, only escalating to a full OS mutex under real contention. I reach for ReentrantLock only when I need something synchronized can't do: timed tryLock to avoid deadlock, interruptible waiting, multiple condition variables for producer-consumer patterns, or fairness. The most important thing to know about ReentrantLock is that you must call unlock() in a finally block — miss it once and you have a permanent deadlock."
My default is always synchronized. It's JVM-managed — the lock is automatically released when the block exits, even if an exception is thrown. Since Java 6, the JVM also optimises it adaptively: it starts as a biased lock with near-zero cost, escalates to a thin spin lock under mild contention, and only inflates to a full OS mutex when there's real contention. So for most cases it's both simpler and fast enough.
I reach for ReentrantLock only when I need capabilities that synchronized doesn't have.
Three things come up in practice:
1. Timed lock attempt — tryLock()
Useful when you need to acquire two locks at once and can't guarantee ordering — the classic deadlock setup. Instead of blocking forever, you back off and retry:
// Transfer between two accounts — must lock both without deadlocking
boolean transfer(Account from, Account to, long amount) throws InterruptedException {
while (true) {
if (from.lock.tryLock(50, MILLISECONDS)) {
try {
if (to.lock.tryLock(50, MILLISECONDS)) { // try second lock
try {
from.debit(amount);
to.credit(amount);
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
// both locks not acquired — back off and retry
Thread.sleep(1);
}
}
With synchronized you can't do this — once you're blocked waiting for the second lock, you're stuck. tryLock lets you release what you have and try again.
2. Interruptible waiting — lockInterruptibly()
Useful when threads need to respond to shutdown signals. With synchronized, a thread blocked on a lock ignores interrupts — you can't cancel it. With lockInterruptibly(), the thread wakes up and throws InterruptedException when interrupted:
void processTask() throws InterruptedException {
lock.lockInterruptibly(); // throws if thread is interrupted while waiting
try {
doWork();
} finally { lock.unlock(); }
}
// On graceful shutdown:
workerThread.interrupt(); // thread unblocks from lockInterruptibly() and exits cleanly
// With synchronized: interrupt() is ignored — thread stays blocked until lock is released
3. Multiple condition queues
With synchronized you have one condition queue, so notifyAll() wakes every waiting thread — producers and consumers alike. With ReentrantLock you create separate conditions and signal only the right threads:
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// Producer wakes only consumers — not other producers:
lock.lock();
try {
while (queue.size() == MAX) notFull.await();
queue.add(item);
notEmpty.signal();
} finally { lock.unlock(); }
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// Producer wakes only consumers — not other producers:
lock.lock();
try {
while (queue.size() == MAX) notFull.await();
queue.add(item);
notEmpty.signal();
} finally { lock.unlock(); }
You must call unlock() yourself, always in a finally block. If you forget and an exception is thrown, the lock is held forever and every waiting thread hangs. That's a production incident. With synchronized the JVM handles this for you — one less thing to get wrong.
| Need | Use |
|---|---|
| Simple mutual exclusion | synchronized |
| Timed / interruptible lock | ReentrantLock |
| Multiple condition queues | ReentrantLock |
| Read-heavy, write-rare | ReadWriteLock / StampedLock |
counter++ on a volatile field is still read-modify-write: three steps, not one. Two threads can both read the same stale value and overwrite each other. The classic trap is double-checked locking without volatile — the JIT can publish the reference before the constructor completes, so another thread sees a non-null but uninitialized object."
The Java Memory Model defines what guarantees you have about visibility and ordering across threads. The key concept is happens-before: if action A happens-before action B, then everything A wrote is visible to B. Without a happens-before relationship, threads can see stale or reordered values — even if the code looks sequential.
People often describe volatile as "flushing to main memory" but that's an oversimplification. The real guarantee is: a volatile write happens-before any subsequent volatile read of the same variable. Everything written before the volatile write is also visible after the volatile read.
volatile boolean ready = false;
int data = 0;
// Thread 1:
data = 42; // (1)
ready = true; // (2) volatile write — establishes HB
// Thread 2:
while (!ready); // (3) volatile read — sees HB from (2)
System.out.println(data); // guaranteed to print 42, not 0
Without volatile on ready, the JIT or CPU is free to reorder (1) and (2), or cache ready in a register — Thread 2 might never see the update.
1. It doesn't give you atomicity. counter++ on a volatile field is still three operations — read, increment, write. Two threads can both read the same value and both write back incremented values, losing one update. Use AtomicLong or LongAdder for counters.
2. It doesn't protect compound actions. if (!flag) { init(); flag = true; } — two threads can both pass the null check. You still need synchronized or a CAS operation for that.
This one trips people up in real code:
// BROKEN — JIT can reorder: write reference BEFORE constructor finishes
if (instance == null) {
synchronized (MyClass.class) {
if (instance == null)
instance = new Singleton(); // another thread sees non-null but uninitialised!
}
}
// CORRECT — volatile prevents the reorder:
private volatile static Singleton instance;
Without volatile, another thread can see a non-null reference to a partially constructed object. The JIT is allowed to publish the reference before the constructor body completes. Adding volatile inserts a memory barrier that prevents this reordering.
@FunctionalInterface enforces this at compile time. Lambdas implement them. Under the hood, lambdas are NOT anonymous inner classes — the compiler uses invokedynamic instead. At the first call, the JVM generates a class at runtime that implements the interface, then caches it. This means no extra .class file per lambda at compile time and the JVM can optimize the implementation strategy. The key practical consequence: lambdas that capture mutable local variables don't compile — the variable must be effectively final. This isn't arbitrary — it prevents race conditions when lambdas run in parallel."
Any interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation enforces this at compile time but is not required.
@FunctionalInterface
interface Transformer<T, R> {
R transform(T input); // only one abstract method
default R andLog(T input) { ... } // default methods allowed
static Transformer<?,?> identity() { ... } // static methods allowed
}
| Interface | Signature | Use |
|---|---|---|
Function<T,R> | R apply(T t) | transform |
Predicate<T> | boolean test(T t) | filter |
Consumer<T> | void accept(T t) | side effect |
Supplier<T> | T get() | lazy produce |
BiFunction<T,U,R> | R apply(T t, U u) | two-arg transform |
UnaryOperator<T> | T apply(T t) | T → T (Function specialisation) |
Lambdas are not anonymous inner classes. The compiler emits an invokedynamic instruction (Java 7+). At first call, the JVM calls a bootstrap method (LambdaMetafactory) which generates a class at runtime and caches it. Subsequent calls go direct — effectively as fast as a method call.
// What you write:
Function<String, Integer> len = s -> s.length();
// What happens at runtime (conceptually):
// 1. invokedynamic → LambdaMetafactory.metafactory()
// 2. JVM generates a class implementing Function<String,Integer>
// 3. apply() delegates to the lambda body compiled as a private static method
// 4. Result cached — class generated only once
.class file at compile time. Lambdas generate a class at runtime via invokedynamic — more flexible (JVM can optimize the strategy) and avoids class explosion on disk.int threshold = 100; // effectively final — OK
list.stream().filter(x -> x > threshold);
int counter = 0;
list.forEach(x -> counter++); // compile error — counter is mutated
// Why: lambda captures a copy of the local variable.
// If it could mutate it, you'd have a stale copy in the lambda.
// Workaround for counters:
AtomicInteger counter = new AtomicInteger();
list.forEach(x -> counter.incrementAndGet()); // reference is final, object is mutable
String::toUpperCase // instance method — instance provided by stream
System.out::println // instance method on specific object
Integer::parseInt // static method
ArrayList::new // constructor reference
// They are syntactic sugar — identical bytecode to lambda equivalents
// Prefer method references when the lambda just calls one method — more readable
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
// andThen: trim → upper
Function<String, String> clean = trim.andThen(upper);
// Predicate composition:
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> both = isPositive.and(isEven);
list.stream().filter(both).collect(...);
A stream pipeline has three parts: source → intermediate operations → terminal operation. Intermediate ops (filter, map, flatMap) are lazy — nothing executes until a terminal op (collect, forEach, findFirst) is called.
list.stream()
.filter(u -> u.isActive()) // lazy — no work yet
.map(User::getEmail) // lazy — no work yet
.findFirst(); // terminal — pipeline executes, stops at first match
// Without laziness, filter+map would scan all 1M items even if findFirst matches element #2
filter, map, flatMap): process one element at a time — streamable, parallelisable.sorted, distinct, limit): must see multiple elements before producing output — require buffering, break parallelism efficiency.// map: one-to-one. Returns Stream<Stream<String>> — nested:
orders.stream().map(o -> o.getItems().stream())
// flatMap: one-to-many, flattens. Returns Stream<String>:
orders.stream().flatMap(o -> o.getItems().stream())
.collect(Collectors.toList());
// groupingBy — Map<Department, List<Employee>>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment));
// downstream collector — Map<Department, Long>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));
// partitioningBy — Map<Boolean, List<T>>
employees.stream().collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
// joining
names.stream().collect(Collectors.joining(", ", "[", "]"));
// Uses ForkJoinPool.commonPool() — shared across the entire JVM!
list.parallelStream().map(this::heavyCpuWork).collect(...);
// Custom pool to isolate parallelism:
ForkJoinPool pool = new ForkJoinPool(4);
pool.submit(() -> list.parallelStream().map(this::work).collect(...)).get();
commonPool — heavy tasks starve other users (e.g. Spring's async methods).findFirst, forEachOrdered) kill parallel speedup with coordination overhead.Future: blocking .get(), no chaining, no combining.CompletableFuture: non-blocking, chainable (thenApply, thenCompose), combinable (allOf, anyOf), error-handling (exceptionally, handle).CompletableFuture.supplyAsync(() -> fetch()) // ForkJoinPool.commonPool()
.thenApply(data -> transform(data)) // same thread OR calling thread — unpredictable!
.thenApplyAsync(data -> enrich(data), exec) // explicit executor — predictable
Async suffix, the continuation runs on whichever thread completed the previous stage. This causes subtle bugs under load. Always use explicit executors in production — ForkJoinPool.commonPool() is shared JVM-wide.ExecutorService io = Executors.newFixedThreadPool(50);
ExecutorService cpu = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
CompletableFuture<Profile> pf = CompletableFuture.supplyAsync(() -> fetchProfile(id), io);
CompletableFuture<Recs> rf = CompletableFuture.supplyAsync(() -> fetchRecs(id), io);
CompletableFuture<History> hf = CompletableFuture.supplyAsync(() -> fetchHistory(id), io);
CompletableFuture.allOf(pf, rf, hf)
.thenApplyAsync(__ -> build(pf.join(), rf.join(), hf.join()), cpu)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> buildFallback());
// thenApply: maps T → R. If R is another CF, you get CF<CF<R>> — nested!
CF<CF<User>> bad = cf.thenApply(id -> fetchUser(id)); // fetchUser returns CF<User>
// thenCompose: flatMap — unwraps nested CF
CF<User> good = cf.thenCompose(id -> fetchUser(id));
exceptionally(fn): only on exception, recovers to a value.handle((r, ex) -> ...): always runs, handles both success and failure.whenComplete((r, ex) -> ...): side-effect only, doesn't change the result.join() vs get(): join throws unchecked CompletionException; use it inside lambdas.This is one of the most common bugs with CompletableFuture in production code. allOf() returns a CF<Void> that completes when all futures complete — but if one fails, the rest still run to completion. The exception from the failed future is swallowed unless you explicitly handle it:
// BROKEN — if pf fails, the exception is silently swallowed!
CompletableFuture.allOf(pf, rf, hf)
.thenApplyAsync(__ -> build(pf.join(), rf.join(), hf.join()), cpu)
.join(); // this throws CompletionException, but pf.join() inside may already have failed
// CORRECT — handle exceptions on each individual future:
CompletableFuture<Profile> pf = CompletableFuture.supplyAsync(() -> fetchProfile(id), io)
.exceptionally(ex -> { log.error("profile failed", ex); return Profile.empty(); });
// Or collect exceptions and fail fast if any task failed:
CompletableFuture.allOf(pf, rf, hf)
.whenComplete((ignored, ex) -> {
if (ex != null) throw new RuntimeException("one or more tasks failed", ex);
Stream.of(pf, rf, hf)
.filter(f -> f.isCompletedExceptionally())
.forEach(f -> f.exceptionally(t -> { log.error("task error", t); return null; }));
});
Also: allOf() does not cancel the remaining futures when one fails. If profile fetch fails after 10ms, Redis and DB queries still run to completion. To actually cancel siblings, you must track the individual CompletableFuture references and call .cancel(true) in the exception handler.
supplyAsync() and continuations without an explicit executor all use ForkJoinPool.commonPool(), which is shared JVM-wide. If any of those tasks block (waiting for a lock, I/O, or a downstream CF.get()), they hold a thread in the common pool and prevent other tasks from running:
// STARVATION: both levels use the common pool
CompletableFuture<String> outer = CompletableFuture.supplyAsync(() -> {
// this blocks waiting for 'inner' — occupies a common pool thread
return inner.join(); // inner also needs a common pool thread → DEADLOCK under load
});
CompletableFuture<String> inner = CompletableFuture.supplyAsync(() -> callService());
// FIX: use separate executors for blocking and non-blocking work
ExecutorService ioPool = Executors.newFixedThreadPool(50); // for blocking I/O
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()); // for CPU work
CompletableFuture.supplyAsync(() -> callService(), ioPool)
.thenApplyAsync(data -> transform(data), cpuPool);
With virtual threads (Java 21+): use Executors.newVirtualThreadPerTaskExecutor() as the I/O executor — each blocking call gets its own virtual thread, carrier threads are never blocked, and starvation is eliminated by design.
MaxGCPauseMillis target.| Scenario | GC |
|---|---|
| General REST service | G1 (default) |
| Low-latency API (<10ms p99) | ZGC |
| Batch / throughput-heavy | ParallelGC |
| Large heap (>32GB) with latency needs | ZGC |
-XX:MaxGCPauseMillis=100 # pause target (not a hard limit)
-XX:G1HeapRegionSize=16m # larger = fewer humongous objects
-XX:InitiatingHeapOccupancyPercent=35 # start concurrent marking earlier
-Xms8g -Xmx8g # fix heap size to avoid expansion STW
spring.threads.virtual.enabled=true — Tomcat switches from a thread pool to virtual threads per request. The important caveats: synchronized blocks pin virtual threads to their carrier, which kills the scalability benefit — use ReentrantLock instead. And ThreadLocal becomes expensive at millions of threads — ScopedValues are the replacement."
Virtual threads are lightweight threads managed by the JVM — not mapped 1:1 to OS threads. You can create millions of them cheaply (a few hundred bytes each vs ~1MB for OS threads). When a virtual thread blocks on I/O, the JVM automatically unmounts it from its carrier (OS) thread and mounts another virtual thread — so the carrier thread is never idle waiting. This makes blocking I/O code as scalable as async/reactive code, without the callback complexity.
# application.yaml — one line to enable everywhere
spring:
threads:
virtual:
enabled: true
// Or explicitly in Java config:
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcat() {
return handler -> handler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
With this, each HTTP request gets its own virtual thread. You can write plain blocking JDBC, RestTemplate, or Redis calls — the JVM handles the scheduling. No need for WebFlux.
Virtual threads are pinned to their carrier OS thread when they hit a synchronized block or a native method. If I/O happens inside a synchronized block, the carrier thread blocks — defeating the whole point.
// PROBLEM: if doNetworkCall() blocks inside synchronized, carrier is pinned
synchronized (lock) {
result = doNetworkCall(); // carrier thread stuck — can't mount other virtual threads
}
// FIX: replace synchronized with ReentrantLock — supports unmounting
lock.lock();
try {
result = doNetworkCall(); // virtual thread unmounts while blocked, carrier is free
} finally { lock.unlock(); }
This is why the Spring team and library authors (JDBC drivers, Redis clients) are actively replacing synchronized with ReentrantLock. Check your dependencies for pinning with -Djdk.tracePinnedThreads=full.
ThreadLocal still works but becomes expensive: with millions of virtual threads, each carrying their own ThreadLocal copy, memory pressure explodes. Java 20+ introduces ScopedValues as a replacement — immutable, inherited by child threads, GC'd when scope exits:
// ScopedValue — available in Java 21 as preview, 23 as final
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, user)
.run(() -> {
processOrder(); // CURRENT_USER.get() works here and in any method called
});
// after run() returns, value is gone — no cleanup needed
| Factor | Virtual Threads | Reactive (Reactor) |
|---|---|---|
| Code style | Plain blocking — easy to read | Functional pipelines — learning curve |
| Debugging | Normal stack traces | Reactor stacktraces are hard to read |
| Backpressure | Not built-in | First-class (Flux, onBackpressure*) |
| CPU-bound work | Same as OS threads | Same — both block the event loop |
| Streaming data | Not designed for it | Natural fit (Flux.fromStream) |
Records replace boilerplate POJOs: the compiler generates equals(), hashCode(), toString(), and accessor methods from the header. They're final (can't be extended) and all fields are final.
// Before Java 16 — 30+ lines of boilerplate
public class Point {
private final double x, y;
public Point(double x, double y) { this.x = x; this.y = y; }
public double x() { return x; }
public double y() { return y; }
// equals, hashCode, toString...
}
// Java 16+ — same contract, one line
public record Point(double x, double y) {}
// Compact constructor for validation
public record UserId(String value) {
public UserId { // compact constructor — no parameter list
Objects.requireNonNull(value);
if (value.isBlank()) throw new IllegalArgumentException("UserId must not be blank");
}
}
// Records work great as DTO / API response types
public record OrderResponse(String orderId, BigDecimal total, OrderStatus status) {}
// Jackson serializes/deserializes records natively (Spring Boot 2.7+)
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable String id) {
return new OrderResponse(id, total, status);
}
java.lang.Record). They can implement interfaces — useful for making records part of a sealed hierarchy.Sealed classes restrict which classes can extend them. Combined with permits, the compiler knows the complete set of subtypes — enabling exhaustive pattern matching and eliminating default cases.
// Sealed hierarchy for a payment result
public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {
record Success(String transactionId, BigDecimal amount) implements PaymentResult {}
record Failure(String reason, int retryAfterSeconds) implements PaymentResult {}
record Pending(String correlationId) implements PaymentResult {}
}
// Pattern matching for switch — exhaustive, no default needed
String message = switch (result) {
case PaymentResult.Success s -> "Paid: " + s.transactionId();
case PaymentResult.Failure f -> "Failed: " + f.reason() +
", retry after " + f.retryAfterSeconds() + "s";
case PaymentResult.Pending p -> "Pending: " + p.correlationId();
// No default: compiler verifies all cases are handled
};
// Guards in pattern matching (Java 21+)
String category = switch (result) {
case PaymentResult.Success s when s.amount().compareTo(BigDecimal.valueOf(1000)) > 0
-> "high-value";
case PaymentResult.Success s -> "standard";
case PaymentResult.Failure f -> "failed";
case PaymentResult.Pending p -> "pending";
};
when on a sealed class or Rust's exhaustive match.// Before: verbose cast-after-check
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.length());
}
// After: binding variable in the pattern
if (obj instanceof String s) {
System.out.println(s.length()); // s is in scope and typed
}
// Useful in equals() — the correct pattern for records/classes
@Override
public boolean equals(Object o) {
return o instanceof Point p && p.x == x && p.y == y;
}
Java 21 adds SequencedCollection, SequencedSet, and SequencedMap interfaces that provide uniform first/last access across List, LinkedHashSet, LinkedHashMap, TreeMap, etc.:
// Before Java 21 — inconsistent and verbose
list.get(0); // first element of List
deque.peekFirst(); // first element of Deque
sortedSet.first(); // first element of SortedSet
linkedHashMap.entrySet().iterator().next(); // first entry of LinkedHashMap
// Java 21 — uniform API
collection.getFirst(); // works on List, Deque, LinkedHashSet
collection.getLast();
collection.reversed(); // returns reversed view — no copy
map.firstEntry(); // works on LinkedHashMap, TreeMap
map.lastEntry();
| Feature | Version | Status |
|---|---|---|
Text Blocks ("""...""") | Java 15 | Final |
| Records | Java 16 | Final |
| Pattern matching instanceof | Java 16 | Final |
| Sealed Classes | Java 17 | Final |
| Switch Expressions (value-returning) | Java 14 | Final |
| Pattern Matching for switch | Java 21 | Final |
| Virtual Threads | Java 21 | Final |
| SequencedCollections | Java 21 | Final |
| StructuredTaskScope | Java 21 | Preview → Final in 23 |
| ScopedValues | Java 21 | Preview → Final in 23 |
| String Templates | Withdrawn from Java 23 | Being redesigned |
java.lang.Record, and Java has single inheritance. This makes sealed interface + record subtype the correct pattern for sum types (ADTs): define the sealed interface, then implement it with records for each case.sealed interface Shape permits Circle, Rectangle, Triangle. Each shape becomes a record holding only its specific data. Replace instanceof chains and visitor classes with pattern matching switch expressions. Benefits: compiler verifies all shapes are handled; adding a new shape forces updates at every switch site; records provide value semantics naturally. This converts an open inheritance hierarchy into a closed discriminated union — much safer and more maintainable.x() not getX()) — ensure jackson-module-parameter-names is on the classpath or use @JsonProperty. (2) Records are immutable, so no setter-based deserialization — Jackson uses the constructor. (3) Spring Boot 2.7+ includes spring-boot-starter-json which auto-configures Jackson with record support. Production issue: if you're running an older Spring Boot, add @JsonDeserialize or upgrade — silently-null fields after deserialization are the failure mode.@Data generates setters — records have none. More importantly, records are a language feature (not annotation processing), so they work with pattern matching, exhaustiveness checks, and reflection without any annotation processor on the classpath.