Skip to main content

Java New Features: Java 8 through Java 21+

A practical guide to the most impactful features introduced in modern Java versions โ€” from lambdas and streams (Java 8) to virtual threads and pattern matching (Java 21).


1. Java 8 (LTS) โ€” The Big Leapโ€‹

Java 8 was a transformational release that introduced functional programming constructs to Java.

Lambda Expressionsโ€‹

Anonymous function syntax for implementing functional interfaces:

// Before: anonymous inner class
Comparator<String> comp = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};

// After: lambda
Comparator<String> comp = (a, b) -> a.length() - b.length();

Functional Interfacesโ€‹

An interface with exactly one abstract method. Annotated with @FunctionalInterface:

InterfaceMethodUse Case
Function<T, R>R apply(T t)Transform: T โ†’ R
Predicate<T>boolean test(T t)Filter: T โ†’ boolean
Consumer<T>void accept(T t)Side-effect: T โ†’ void
Supplier<T>T get()Factory: () โ†’ T
UnaryOperator<T>T apply(T t)Transform: T โ†’ T
BiFunction<T, U, R>R apply(T t, U u)Transform: (T, U) โ†’ R

Stream APIโ€‹

Declarative pipeline for processing collections:

List<String> names = people.stream()
.filter(p -> p.getAge() > 18) // filter
.sorted(Comparator.comparing(Person::getName)) // sort
.map(Person::getName) // transform
.distinct() // remove duplicates
.limit(10) // take first 10
.collect(Collectors.toList()); // terminal operation

// Reduction
int totalAge = people.stream()
.mapToInt(Person::getAge)
.sum();

// Grouping
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::getCity));

// Parallel stream (use with caution)
long count = list.parallelStream()
.filter(s -> s.length() > 5)
.count();

Optionalโ€‹

A container that may or may not hold a value. Eliminates explicit null checks:

// Creating
Optional<String> opt = Optional.of("value");
Optional<String> empty = Optional.empty();
Optional<String> nullable = Optional.ofNullable(mayBeNull);

// Using
String result = opt
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.orElse("default");

// Chaining
String city = getUser()
.flatMap(User::getAddress)
.map(Address::getCity)
.orElse("Unknown");

Date-Time API (java.time)โ€‹

Replaces the problematic java.util.Date and Calendar:

// Immutable, thread-safe
LocalDate date = LocalDate.of(2024, 3, 15);
LocalTime time = LocalTime.of(14, 30);
LocalDateTime dateTime = LocalDateTime.now();
ZonedDateTime zoned = ZonedDateTime.now(ZoneId.of("Asia/Tokyo"));

// Duration and Period
Duration duration = Duration.between(time1, time2);
Period period = Period.between(date1, date2);

// Formatting
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
String formatted = dateTime.format(fmt);
LocalDateTime parsed = LocalDateTime.parse("2024-03-15 14:30", fmt);

Interface Default Methodsโ€‹

Interfaces can now have method implementations:

public interface Collection<E> {
// Abstract method
boolean add(E e);

// Default method โ€” existing implementations don't break
default boolean isEmpty() {
return size() == 0;
}

// Static method
static <T> Collection<T> empty() {
return Collections.emptyList();
}
}

2. Java 9 โ€” Modularityโ€‹

Module System (JPMS)โ€‹

Organizes code into modules with explicit dependencies:

// module-info.java
module com.myapp {
requires java.sql;
requires java.logging;
exports com.myapp.api; // visible to other modules
opens com.myapp.internal; // accessible via reflection
}

JShell (REPL)โ€‹

Interactive Java shell for experimenting:

jshell> int x = 42;
x ==> 42

jshell> "Hello".chars().sum()
$1 ==> 500

Collection Factory Methodsโ€‹

List<String> list = List.of("a", "b", "c"); // immutable
Set<Integer> set = Set.of(1, 2, 3); // immutable
Map<String, Integer> map = Map.of("a", 1, "b", 2); // immutable

Other Notable Featuresโ€‹

  • Optional.ifPresentOrElse(), Optional.stream()
  • Private interface methods
  • Stream.takeWhile(), Stream.dropWhile(), Stream.ofNullable()
  • G1 becomes the default garbage collector
  • Compact Strings (internal byte[] instead of char[] for Latin-1)

3. Java 10 โ€” Local Variable Type Inferenceโ€‹

var keywordโ€‹

Lets the compiler infer local variable types:

// Explicit type
ArrayList<Map<String, List<Integer>>> data = new ArrayList<>();

// With var โ€” much cleaner
var data = new ArrayList<Map<String, List<Integer>>>();

// Works in for loops
for (var entry : map.entrySet()) {
var key = entry.getKey();
var value = entry.getValue();
}

Rules:

  • Only for local variables with initializers
  • Not for method parameters, return types, or fields
  • Not for null or lambda: var x = null; โŒ var f = () -> {}; โŒ

4. Java 11 (LTS) โ€” HTTP Client & Moreโ€‹

HttpClient APIโ€‹

Modern, asynchronous HTTP client replacing HttpURLConnection:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.header("Accept", "application/json")
.GET()
.build();

// Synchronous
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

// Asynchronous
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString());

Other Featuresโ€‹

  • var in lambda parameters: (var x, var y) -> x + y
  • String methods: isBlank(), strip(), lines(), repeat(int)
  • Files methods: Files.readString(path), Files.writeString(path, content)
  • ZGC (experimental) โ€” ultra-low-latency garbage collector

5. Java 12โ€“13 โ€” Switch Expressions & Text Blocksโ€‹

Switch Expressions (Preview โ†’ Standard in 14)โ€‹

// Traditional switch โ€” verbose, fall-through prone
switch (day) {
case MONDAY:
case FRIDAY:
System.out.println("Work hard");
break;
case SATURDAY:
case SUNDAY:
System.out.println("Rest");
break;
}

// Switch expression โ€” concise, no fall-through
String activity = switch (day) {
case MONDAY, FRIDAY -> "Work hard";
case SATURDAY, SUNDAY -> "Rest";
default -> "Regular day";
};

Text Blocks (Preview โ†’ Standard in 15)โ€‹

Multi-line string literals:

// Before: messy concatenation
String json = "{\n" +
" \"name\": \"John\",\n" +
" \"age\": 30\n" +
"}";

// After: text blocks
String json = """
{
"name": "John",
"age": 30
}
""";

6. Java 14โ€“15 โ€” Records & Sealed Classesโ€‹

Recordsโ€‹

Immutable data carriers with auto-generated equals(), hashCode(), toString(), and accessors:

// Before: verbose POJO
public class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; }
public int y() { return y; }
@Override public boolean equals(Object o) { /* ... */ }
@Override public int hashCode() { /* ... */ }
@Override public String toString() { /* ... */ }
}

// After: one line
public record Point(int x, int y) { }

// Usage
Point p = new Point(3, 4);
int x = p.x(); // accessor (not getX())
System.out.println(p); // Point[x=3, y=4]

Records can have:

  • Custom constructors (compact or canonical)
  • Instance methods
  • Static fields and methods
  • Implement interfaces

Records cannot: extend other classes, have mutable fields, be subclassed.

Sealed Classes (Preview โ†’ Standard in 17)โ€‹

Restrict which classes can extend or implement a type:

public sealed interface Shape permits Circle, Rectangle, Triangle { }

public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public final class Triangle implements Shape { /* ... */ }

Why sealed classes?

  • Enables exhaustive pattern matching (compiler knows all subtypes)
  • Models closed type hierarchies (algebraic data types)

7. Java 16 โ€” Pattern Matching for instanceofโ€‹

Eliminates redundant casting:

// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}

// After โ€” binding variable
if (obj instanceof String s) {
System.out.println(s.length()); // s is already cast
}

// Works with logical operators
if (obj instanceof String s && s.length() > 5) {
System.out.println(s.toUpperCase());
}

8. Java 17 (LTS) โ€” Sealed Classes Finalizedโ€‹

Java 17 is a Long-Term Support release. Key finalized features:

  • Sealed classes (from preview to standard)
  • Pattern matching for instanceof (standard)
  • Text blocks (standard)
  • Records (standard)
  • Strong encapsulation of JDK internals (cannot access internal APIs by default)

Migration Noteโ€‹

Java 17 is the recommended upgrade target from Java 8 or 11. Key breaking changes:

  • Strong encapsulation of sun.misc.* APIs
  • Removed SecurityManager deprecation
  • Removed RMI Activation
  • Need --add-opens for frameworks using deep reflection

9. Java 21 (LTS) โ€” Virtual Threads & Pattern Matchingโ€‹

Virtual Threads (Finalized)โ€‹

Lightweight threads managed by the JVM, enabling massive concurrency:

// Create a virtual thread
Thread.ofVirtual().start(() -> {
System.out.println("Running in virtual thread");
});

// Virtual thread executor โ€” 1 virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit 100,000 tasks โ€” each gets a virtual thread
List<Future<String>> futures = IntStream.range(0, 100_000)
.mapToObj(i -> executor.submit(() -> fetchUrl("https://example.com/" + i)))
.toList();
}

Pattern Matching for switch (Finalized)โ€‹

// Type patterns + guards
String describe(Object obj) {
return switch (obj) {
case Integer i when i > 0 -> "positive integer: " + i;
case Integer i -> "non-positive integer: " + i;
case String s -> "string of length " + s.length();
case null -> "null!";
default -> "something else";
};
}

// Sealed class exhaustiveness
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.base() * t.height() / 2;
// no default needed โ€” Shape is sealed, compiler knows all cases
};
}

Record Patterns (Finalized)โ€‹

Deconstruct records in pattern matching:

record Point(int x, int y) { }

// Deconstruct directly
if (obj instanceof Point(int x, int y)) {
System.out.println("x=" + x + ", y=" + y);
}

// Nested patterns in switch
switch (shape) {
case Circle(var radius) when radius > 10 -> "large circle";
case Circle(var radius) -> "small circle with radius " + radius;
default -> "not a circle";
}

Sequenced Collectionsโ€‹

New interfaces for collections with defined encounter order:

// SequencedCollection
interface SequencedCollection<E> extends Collection<E> {
SequencedCollection<E> reversed();
void addFirst(E e);
void addLast(E e);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
}

// Usage
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst(); // "a"
list.getLast(); // "c"
list.reversed(); // ["c", "b", "a"]

10. Feature Timeline Summaryโ€‹

VersionYearKey FeaturesLTS?
82014Lambdas, Streams, Optional, Date-Time APIโœ…
92017Modules (JPMS), JShell, Collection factories
102018var local variable type inference
112018HttpClient, var in lambdas, ZGC (exp.)โœ…
122019Switch expressions (preview)
132019Text blocks (preview)
142020Records (preview), switch expressions (standard)
152020Sealed classes (preview), text blocks (standard)
162021Records (standard), pattern matching instanceof
172021Sealed classes (standard), strong encapsulationโœ…
182022UTF-8 default, simple web server
192022Virtual threads (preview), structured concurrency (incubator)
202023Virtual threads (2nd preview), scoped values (incubator)
212023Virtual threads, pattern matching switch, record patterns (all standard)โœ…
Java 8 โ†’ Java 17 (LTS) โ†’ Java 21 (LTS)
  • Java 8 โ†’ 17: Biggest jump. Gain modules, records, sealed classes, text blocks, new GCs.
  • Java 17 โ†’ 21: Gain virtual threads, pattern matching for switch, record patterns.

11. Stream Internals โ€” How Pipelines Actually Executeโ€‹

Understanding the Stream machinery separates senior developers from mid-level ones in interviews.

Lazy Evaluation and Pipelinesโ€‹

Stream operations are split into intermediate (lazy) and terminal (eager):

  • Intermediate: filter(), map(), sorted(), distinct(), limit() โ€” builds a pipeline but does nothing
  • Terminal: collect(), count(), findFirst(), forEach() โ€” triggers actual execution
// This code does NOTHING until collect() is called:
Stream<String> pipeline = names.stream()
.filter(n -> { System.out.println("filter: " + n); return n.length() > 3; })
.map(n -> { System.out.println("map: " + n); return n.toUpperCase(); });

// Execution only starts here โ€” and is interleaved element-by-element:
List<String> result = pipeline.collect(Collectors.toList());
// Output: filter: Alice, map: Alice, filter: Bob, filter: Charlie, map: Charlie
// (NOT: all filters first, then all maps)

Each element flows through the entire pipeline before the next element is processed โ€” this enables short-circuiting and minimizes memory use.

Short-Circuiting Optimizationโ€‹

names.stream()
.filter(n -> n.length() > 3)
.findFirst(); // stops as soon as first match found โ€” doesn't process rest

limit(), findFirst(), findAny(), anyMatch(), allMatch(), noneMatch() all short-circuit.

Spliterator: The Engine Behind Streamsโ€‹

A Spliterator is the iterator that powers streams, including parallel stream splitting:

// Custom object that can be split for parallel processing
Spliterator<String> spliterator = list.spliterator();
Spliterator<String> chunk = spliterator.trySplit(); // splits off a chunk for parallel work

Characteristics (bitmask flags on Spliterators):

  • ORDERED โ€” encounter order is maintained
  • DISTINCT โ€” no duplicate elements
  • SORTED โ€” elements are in sorted order
  • SIZED โ€” known size upfront (enables parallelism optimizations)
  • NONNULL โ€” no null elements

Parallel stream performance depends on these characteristics. SIZED + SUBSIZED lets ForkJoinPool split evenly. LinkedList (non-SIZED) splits poorly for parallel streams.

Common Senior Interview Trapsโ€‹

// โŒ forEach ordering is undefined for parallel streams
list.parallelStream().forEach(System.out::println); // order not guaranteed

// โœ… Use forEachOrdered for parallel + ordered
list.parallelStream().forEachOrdered(System.out::println);

// โŒ Stateful lambdas in parallel streams are dangerous
List<String> result = new ArrayList<>();
list.parallelStream().filter(...).forEach(result::add); // race condition!

// โœ… Collect into thread-safe structure
List<String> result = list.parallelStream().filter(...).collect(Collectors.toList());

11a. Iteration Paradigms: For Loops vs. Streams vs. Parallel Streamsโ€‹

Choosing between traditional loops, sequential streams, and parallel streams requires balancing syntax readability, control flow demands, and hardware resource constraints.

1. Architectural and Behavioral Matrixโ€‹

AspectTraditional for / for-each LoopSequential StreamParallel Stream
Programming ParadigmImperative: Focuses on how to do it (explicit state changes).Declarative: Focuses on what to do (functional pipelines).Declarative Concurrent: Focuses on what to do, executed across threads.
Iteration MechanismExternal: Client code controls how elements are pulled and navigated.Internal: The framework manages traversal behind the scenes.Internal: Traversal is handled by the framework and chunked across threads.
Control FlowSupports break, continue, and early return.Bypasses break/continue (uses filter/limit instead); early returns are restricted.Cannot use break/continue; early returns are non-trivial (uses short-circuiting).
State MutabilityEncourages mutating shared variables.Promotes immutable data and stateless operations.Requires stateless, thread-safe, and independent operations.
Memory AllocationZero framework-level allocations; highly memory efficient.Instantiates intermediate pipeline descriptors and sinks (minor heap tax).High overhead due to Fork/Join tasks, thread local structures, and merging.
DebuggabilityEasy: Stack traces map 1:1 with execution line; local variables are inspectable.Complex: Lazy execution hides actual execution lines in massive stack traces.Difficult: Threads interleave execution; debugger tracing is non-linear.

2. Functional Equivalence (Coding Example)โ€‹

Consider filtering a list of integers to keep only even numbers, multiplying them by two, and collecting them into a list:

Imperative (Traditional Loop)โ€‹

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> result = new ArrayList<>();
for (Integer num : numbers) {
if (num % 2 == 0) {
result.add(num * 2);
}
}

Declarative (Sequential Stream)โ€‹

List<Integer> result = numbers.stream()
.filter(num -> num % 2 == 0)
.map(num -> num * 2)
.collect(Collectors.toList());

Declarative (Parallel Stream)โ€‹

List<Integer> result = numbers.parallelStream()
.filter(num -> num % 2 == 0)
.map(num -> num * 2)
.collect(Collectors.toList());

3. Under the Hood: Internal Mechanicsโ€‹

A. Traditional Loopsโ€‹

At the bytecode level, a traditional loop is compiled directly into local variable read/write instructions and simple conditional jump instructions (such as goto and if_icmpge). The JVM executes this loop sequentially within a single thread's stack frame. Because there is no object wrapper layer, the JIT compiler can aggressively optimize this code using loop unrolling and escape analysis.

B. Sequential Streamsโ€‹

When a sequential stream pipeline is executed, Java performs two distinct steps:

  1. Pipeline Construction: It wraps the source collection in a linked chain of AbstractPipeline objects, with each intermediate operation (e.g., filter, map) representing a node.
  2. Terminal Evaluation: When the terminal operation (e.g., collect) is invoked, the stream engine constructs a nested chain of Sink objects from the last node back to the first. It then uses the source collection's Spliterator to push elements down the Sink chain one by one. The data flows sequentially, processing each element through the entire pipeline before moving to the next.

C. Parallel Streamsโ€‹

Parallel streams build the same pipeline, but instead of traversing sequentially, they partition the data and run it concurrently:

  1. Splitting the Source: The terminal operation queries the source Spliterator and recursively invokes trySplit() to split the collection into balanced sub-chunks.
  2. Task Scheduling: It wraps these sub-chunks in ForkJoinTask objects and submits them to the JVM's shared ForkJoinPool.commonPool().
  3. Execution: Worker threads run the task chunks in parallel using work-stealing queues.
  4. Reduction/Merging: Once threads complete their tasks, they merge their results back using Combiner functions (specified by the Collector or reduction operator).
[Source Collection]
|
(Spliterator.trySplit())
|
+---------------+---------------+
| |
[Sub-Chunk 1] [Sub-Chunk 2]
| |
(ForkJoinTask 1) (ForkJoinTask 2)
| |
[Worker Thread 1] [Worker Thread 2]
| |
[Result 1] [Result 2]
+---------------+---------------+
|
(Combiner Merge)
|
[Final Result]

4. Performance Tradeoffs and Production Trapsโ€‹

Goetz's N \times Q Rule of Thumbโ€‹

To determine if parallel streams are faster than sequential ones, use Brian Goetz's formula: \text{Performance Gain} \propto N \times Q Where:

  • N: The number of data elements.
  • Q: The computational cost to process a single element (e.g. arithmetic vs. string parsing).

If N \times Q > 10,000, parallelization overhead (splitting, thread scheduling, and merging) is usually offset by concurrent CPU execution. If Q is extremely low (such as simple addition), N must be millions of elements to justify a parallel stream.

Data Source Splittability (Spliterator Efficiency)โ€‹

Parallel stream efficiency is highly dependent on how cheaply the source collection can be divided into equal parts.

  • ArrayList, Arrays, and IntStream.range() (Excellent): Split in O(1) time by adjusting array index boundaries. They are SIZED and SUBSIZED, allowing the scheduler to divide tasks perfectly.
  • HashSet and HashMap (Good): Split reasonably well based on internal hash bucket structures, but hash collisions can create unequal chunk sizes.
  • LinkedList and BufferedReader.lines() (Poor): Splitting requires traversing the list nodes, which is an O(N) operation. This makes parallel streams on LinkedLists slower than sequential iteration.

The Shared ForkJoinPool Starvation Hazardโ€‹

By default, all parallel streams in a running JVM share a single, static thread pool: ForkJoinPool.commonPool().

  • The Trap: If a developer runs blocking operations (like database calls, HTTP requests, or Thread.sleep) inside a parallel stream, those threads become blocked:
    // โš ๏ธ TRAP: Blocks shared JVM threads!
    listOfOrders.parallelStream()
    .map(order -> callExternalPaymentGateway(order)) // Blocking I/O
    .collect(Collectors.toList());
  • The Impact: Because the pool is shared, blocking these threads starves every other parallel stream running in the application (even unrelated background tasks).
  • The Solution: For blocking operations, bypass parallel streams and use an explicit custom thread pool, or wrap the parallel stream in a custom ForkJoinPool submission:
    // Custom thread pool execution
    ForkJoinPool customPool = new ForkJoinPool(8);
    List<Result> results = customPool.submit(() ->
    listOfOrders.parallelStream().map(this::callExternalPaymentGateway).toList()
    ).get();

12. Scoped Values (Java 21+ โ€” Preview)โ€‹

ScopedValue is the modern replacement for ThreadLocal โ€” designed for virtual threads and structured concurrency where thread pool reuse breaks ThreadLocal semantics.

// Declare a scoped value (like a typed "dynamic variable")
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// Set and run โ€” value lives only within the scope (not leaked to thread pool)
ScopedValue.where(CURRENT_USER, authenticatedUser).run(() -> {
processRequest(); // CURRENT_USER is readable anywhere in this call stack
});

// Read anywhere inside the scope
public void processRequest() {
User user = CURRENT_USER.get(); // Safe, no ThreadLocal memory leak risk
}

ScopedValue vs ThreadLocalโ€‹

ThreadLocalScopedValue
ScopeEntire thread lifetimeBounded to a code scope
Virtual thread safeโŒ (can leak across reused threads)โœ…
Memory leak riskHigh (must call remove())None (automatically scoped)
MutableYesImmutable (rebind = new scope)
Structured concurrencyPoor fitFirst-class fit

Advanced Editorial Pass: Feature Adoption with Migration Disciplineโ€‹

Decision Frameworkโ€‹

  • Adopt features when they reduce defect rate or cognitive load, not for novelty.
  • Sequence upgrades by platform compatibility, library ecosystem readiness, and team fluency.
  • Protect rollout with compatibility tests across runtime and build toolchain.

Adoption Risksโ€‹

  • Mixed-language style across modules increases maintenance friction.
  • Incomplete migration strategies create hidden behavioral inconsistencies.
  • Feature use outpaces debugging and observability competence.

Migration Heuristicsโ€‹

  1. Define approved feature subsets per Java version and team maturity.
  2. Enforce consistent style through reviews and static analysis.
  3. Pair language upgrades with targeted knowledge-sharing and incident drills.

Compare Nextโ€‹

Interview Questions (Senior Level)โ€‹

  1. How do you prioritize Java language feature adoption across teams with mixed service maturity and SLAs?
  2. What migration plan would you propose from Java 11 to 21 for a large microservice estate?
  3. When do virtual threads provide meaningful gains, and where can they hurt system behavior?
  4. How do records, sealed classes, and pattern matching improve domain modeling in real codebases?

Short answer guide:

  • Adopt by business value, tooling readiness, and operational safety.
  • Use phased upgrades, compatibility test matrices, and runtime observability gates.
  • Apply virtual threads for blocking I/O concurrency, not CPU-bound work.
  • Use modern type features to reduce boilerplate and illegal state space.