🧵
Java 21

Java 21 Features

Virtual Threads & Record Patterns
💡 Java 21 is like a big restaurant that completely changed its staffing model — before, every table needed its own dedicated waiter (a heavy OS thread), now a small, flexible staff (virtual threads) can efficiently handle millions of tables, because a waiter picks up another table the moment one is "waiting" on something.

Java 21 (September 2023) is the third major LTS (Long-Term Support) release after Java 17 — it brought Project Loom's biggest result, Virtual Threads, to stable status, fundamentally changing the way concurrency gets written in Java.

The biggest highlights: Virtual Threads (millions of lightweight threads), Sequenced Collections (common methods like getFirst/getLast/reversed), Record Patterns (destructuring records), and Pattern Matching for switch (finally stable, with guard conditions).

A few features are still in preview (String Templates, Structured Concurrency, Unnamed Variables) — meaning their syntax might change a bit in future versions, but understanding the concept still matters for interviews.

// Virtual Thread — lakhon threads, halke wazan
Thread.ofVirtual().start(() -> System.out.println("Lightweight!"));

// Record Pattern — destructuring
record Point(int x, int y) {}
if (obj instanceof Point(int x, int y)) {
  System.out.println(x + ", " + y);
}

// Sequenced Collection
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
System.out.println(list.getFirst() + " " + list.getLast()); // A C
🧵
Java 21 is like a big restaurant that completely changed its staffing model — before, every table needed its own dedicated waiter (a heavy OS thread), now a small, flexible staff (virtual threads) can efficiently handle millions of tables, because a waiter picks up another table the moment one is "waiting" on something.
1 / 6
⚡ Quick Recap
  • Java 21 = LTS release (Sept 2023)
  • Virtual Threads = millions of lightweight threads
  • Sequenced Collections = getFirst/getLast/reversed
  • Record Patterns = destructuring records
  • Pattern Matching for switch = now stable
On this page (8 subtopics)

A traditional Java thread is a direct wrapper around an OS thread — creating each one costs memory (typically ~1MB of stack) and OS resources, so practically only a few thousand threads can exist in a JVM without slowing the system down. High-concurrency applications (like a web server handling millions of requests) run straight into this limit.

A virtual thread is managed entirely inside the JVM (not an OS thread) — it's extremely lightweight (kilobytes, not MB), and millions of virtual threads can exist in a single JVM without trouble. When a virtual thread blocks (like waiting on I/O), the JVM frees up the underlying OS thread for other virtual threads to use — meaning a small number of OS threads (carrier threads) can serve a huge number of virtual threads.

The best part: the way you write code stays exactly the same — use Thread.ofVirtual().start(...) or Executors.newVirtualThreadPerTaskExecutor(), and everything else (synchronized, try-catch, ordinary blocking calls) works exactly like it did on a normal thread. No new async/reactive syntax to learn.

An interview trap: using ThreadLocal with virtual threads needs care — if millions of virtual threads each hold their own ThreadLocal state, memory usage can balloon. That's why Java 21 also introduced "Scoped Values" (preview), a lighter alternative.

  • Memory footprint in kilobytes, far less than an OS thread's ~1MB
  • Millions of virtual threads can run in one JVM
  • The carrier thread is freed automatically while blocked
  • Existing blocking code works without any changes
// Ek virtual thread banao
Thread.ofVirtual().start(() -> {
  System.out.println("Running on: " + Thread.currentThread());
});

// Ya ek executor se, jaise normal thread pool
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  for (int i = 0; i < 100_000; i++) {
    executor.submit(() -> {
      Thread.sleep(Duration.ofSeconds(1)); // block hone par bhi thread cheap hai
      return null;
    });
  }
} // 1 lakh threads bhi easily chal jaate hain!
⚠️Common Mistake: Virtual threads don't help with CPU-bound work (like heavy calculations) — they only pay off when a thread is waiting on I/O (network, file, database). For CPU-bound tasks, traditional platform threads/parallel streams are still better.

In older Java, List (which has order) and LinkedHashSet/LinkedHashMap (which also have a predictable order) had no common, consistent way to get the "first element" or "last element" — List.get(0) vs List.get(size()-1), or different methods across different collections.

Java 21 introduced three new interfaces: SequencedCollection, SequencedSet, SequencedMap — giving ordered collections like List, LinkedHashSet, and LinkedHashMap common methods: getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast(), and reversed() (which gives a reversed view of the collection, without creating a copy).

A real-world use-case: when you need the "most recent" or "oldest" item in a list (like an activity feed or an undo history), you used to have to do manual index math — now getLast()/getFirst() give you direct, readable code.

  • getFirst() / getLast() — grab the first / last element
  • addFirst() / addLast() — insert at the start / end
  • removeFirst() / removeLast() — remove from the start / end
  • reversed() — a reversed view, no copy created
List<String> names = new ArrayList<>(List.of("Aman", "Riya", "Zoya"));
System.out.println(names.getFirst());  // Aman
System.out.println(names.getLast());   // Zoya
names.addFirst("Aarav");
System.out.println(names.reversed());  // [Zoya, Riya, Aman, Aarav]

// LinkedHashMap bhi ab yehi methods deta hai:
LinkedHashMap<String, Integer> scores = new LinkedHashMap<>();
scores.put("A", 90);
scores.putFirst("Z", 100); // naya method — sabse pehle daalta hai
💡Tip: reversed() returns a "view" (doesn't create a new copy) — meaning if the original collection changes, the reversed view automatically reflects it too.

Records arrived in Java 17 for data classes, and instanceof pattern matching arrived in Java 16+. Java 21 combined the two — now you can directly "destructure" the fields inside a record within an instanceof or switch check, without calling record.field().

For example, if you have record Point(int x, int y), writing if (obj instanceof Point(int x, int y)) means that if obj is a Point, x and y are directly available — a record can even contain another record (nested destructuring), which gives very clean code for deeply nested data structures.

An interviewer might combine this with sealed classes — together they give you something like the "algebraic data type" pattern from functional languages, where both the shape of the data and the logic that processes it are explicit and type-safe.

record Point(int x, int y) {}
record Line(Point start, Point end) {}

Object obj = new Point(3, 4);

// Record pattern — seedha destructure
if (obj instanceof Point(int x, int y)) {
  System.out.println("x=" + x + ", y=" + y);
}

// Nested record pattern
Object line = new Line(new Point(0, 0), new Point(3, 4));
if (line instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
  System.out.println("Line from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")");
}

This was a preview feature in Java 17; it finally became stable/final in Java 21. Now a switch expression/statement can combine type-based branching, record destructuring, and guard conditions (a when clause) all together.

null can now be handled directly inside a switch too (case null ->) — previously a switch would throw a NullPointerException on null, forcing a separate null check. A guard condition (when) lets you add an extra condition even inside a type-match, like case Integer i when i > 0 -> ....

The order of guard conditions matters — a switch checks cases top to bottom, so a more specific guarded case (like when i > 0) needs to come before the generic one, otherwise the generic case matches first.

  • Type patterns — case Integer i -> ...
  • Record patterns — case Point(int x, int y) -> ...
  • Guard conditions — case Integer i when i > 0 -> ...
  • case null -> — can now be handled directly
Object obj = 42;

String result = switch (obj) {
  case null -> "It's null";
  case Integer i when i > 0 -> "Positive integer: " + i;
  case Integer i -> "Non-positive integer: " + i;
  case String s -> "String: " + s;
  case Point(int x, int y) -> "Point at (" + x + "," + y + ")"; // record pattern bhi
  default -> "Unknown";
};
💡Tip: Combine this with sealed classes and the compiler can confirm on its own that every possible case in the switch is covered ("exhaustiveness") — you don't even need a default case.

Both String concatenation (joining with +) and String.format() get a bit clunky for long strings. String Templates bring a new syntax — something like STR."Hello \{name}!" — where you put Java expressions directly inside \{...}, and they get automatically evaluated and embedded into the result.

This is a preview feature in Java 21 (meaning you need the --enable-preview flag to compile/run it, and it might still change in future versions) — but it shows where Java is taking string handling: readable, type-safe, and it opens the door to custom template processors (like a SQL processor) that can easily prevent things like SQL injection too.

An interviewer might ask what STR actually is — it's a built-in "template processor" (implementing the StringTemplate.Processor interface). Developers can build their own processors too, like one that automatically HTML-escapes to prevent XSS.

String name = "Aarav";
int age = 25;

// Purana tareeka:
String s1 = "Hello " + name + ", you are " + age + " years old";
String s2 = String.format("Hello %s, you are %d years old", name, age);

// String Template (preview, Java 21):
String s3 = STR."Hello \{name}, you are \{age} years old";
// Expressions bhi chal sakti hain:
String s4 = STR."Next year you'll be \{age + 1}";
⚠️Common Mistake: This is a preview feature — production code won't even compile without the --enable-preview flag, and the syntax may still change in future JDK versions. Understanding the concept matters for interviews, memorizing the exact syntax not so much.

When completing a task requires running multiple sub-tasks in parallel (like fetching a user's profile, price, and inventory from different services), managing them manually — cancel the rest if one fails, wait for all, handle errors — gets error-prone fast.

StructuredTaskScope treats sub-tasks in a "structured" way — the parent task doesn't finish until every child task it started has finished, and if one child fails, the whole group can automatically get cancelled (fail-fast). Combined with virtual threads, this makes concurrent code much safer and more readable.

This is far more readable than traditional manual thread management with try-catch-finally — in the old way, if one task failed you had to manually cancel the others and risk thread leaks; StructuredTaskScope handles all of this automatically.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
  Future<String> user = scope.fork(() -> fetchUser());
  Future<String> orders = scope.fork(() -> fetchOrders());

  scope.join();           // dono ka wait karo
  scope.throwIfFailed();  // agar koi fail hua, exception throw karo

  System.out.println(user.resultNow() + orders.resultNow());
} // scope band hote hi saare threads cleanup ho jaate hain
💡Tip: This is also a preview feature (Java 21). What matters is understanding the core idea: treat concurrent sub-tasks as a single unit, so no thread ever "leaks" or gets left orphaned.

In Java 17, ZGC (the Z Garbage Collector) was experimental, designed for low latency (pause times consistently under 10ms) — but it wasn't "generational", meaning it didn't treat young and old objects differently, which reduced efficiency for some workloads.

In Java 21, ZGC became "generational" (like other modern GCs such as G1) — it collects the young generation (where most objects "die" quickly) more often and more efficiently, and collects the old generation less often. This improved both throughput and memory efficiency, without losing the low-latency guarantee.

An interviewer might ask why generational ZGC was even needed if G1 was already generational — the answer: G1 being generational didn't give it ZGC's ultra-low pause times; Generational ZGC delivers both low pause time AND generational efficiency together.

GCFocus
G1 (default)Balanced throughput + pause time, general-purpose
ZGC (Java 17)Ultra-low pause time, non-generational
Generational ZGC (Java 21)Ultra-low pause time + better throughput (generational)

Sometimes you have to declare a variable you don't actually use (like catch (Exception e) where e is never used, or a record pattern where you only need some fields, not all). In the Java 21 preview, you can use an underscore (_) as an "unnamed variable" — signaling this value is being intentionally ignored.

This is useful with record patterns too — say Point(int x, int y) and you only need x, you can write case Point(int x, var _), explicitly saying "I don't care about y", which gives readers of the code more clarity.

This feature especially helps in large codebases where static analysis tools (like SonarQube) flag "unused variable" warnings — using an underscore (_) explicitly signals the value is intentionally ignored, and clears up the warning too.

// catch block mein unused exception:
try {
  riskyOperation();
} catch (IOException _) {
  System.out.println("Kuch to gaya galat, details ki zaroorat nahi");
}

// Record pattern mein sirf zaroori field:
if (obj instanceof Point(int x, var _)) {
  System.out.println("x = " + x + " (y ignore kiya)");
}