Java 8+ Features
1. Overview of Java 8
Java 8, released in March 2014, was one of the most significant releases since Java 5. It fundamentally changed the way Java developers write code by introducing functional programming concepts into the language. Before Java 8, Java was purely object-oriented; after Java 8, it became a multi-paradigm language supporting functional-style programming.
Key Features Introduced in Java 8
- Lambda Expressions — concise anonymous function syntax
- Functional Interfaces — single-abstract-method interfaces
- Stream API — declarative data processing pipeline
- Optional<T> — null-safe container type
- Method References — shorthand for lambdas calling existing methods
- Default and Static Interface Methods — interface evolution without breaking implementations
- New Date and Time API (java.time) — immutable, thread-safe date/time classes
- Collectors — powerful reduction operations for streams
- Nashorn JavaScript Engine — embedded JS engine (later removed)
- Base64 Encoding/Decoding — built-in java.util.Base64
Impact: Java 8 made Java competitive with functional languages like Scala and Haskell while retaining full backward compatibility.
2. Lambda Expressions
A lambda expression is a concise way to represent an anonymous function — a block of code that can be passed around as a value. Lambdas enable you to treat functionality as a method argument and eliminate verbose anonymous class boilerplate.
Syntax
(parameters) -> expression
(parameters) -> { statements; }
import java.util.*;
public class LambdaDemo {
public static void main(String[] args) {
// Example 1: Replacing anonymous Runnable
// Before Java 8
Runnable oldWay = new Runnable() {
@Override
public void run() {
System.out.println("Old way: anonymous class");
}
};
// Java 8 lambda
Runnable newWay = () -> System.out.println("New way: lambda");
oldWay.run();
newWay.run();
// Example 2: Replacing anonymous Comparator
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
// Before Java 8
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});
System.out.println("Sorted (old): " + names);
// Java 8 lambda
names.sort((a, b) -> b.compareTo(a)); // reverse
System.out.println("Sorted (lambda): " + names);
// Example 3: Lambda with block body and multiple statements
List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5);
nums.forEach(n -> {
int square = n * n;
System.out.println(n + " squared = " + square);
});
}
}
New way: lambda
Sorted (old): [Alice, Bob, Charlie]
Sorted (lambda): [Charlie, Bob, Alice]
1 squared = 1
2 squared = 4
3 squared = 9
4 squared = 16
5 squared = 25
3. Functional Interfaces
A functional interface is an interface with
exactly one abstract method. Lambda expressions
are instances of functional interfaces — the compiler infers which
abstract method the lambda implements. The
@FunctionalInterface annotation is
optional but recommended: it causes a compile error if the
interface accidentally gets a second abstract method.
import java.util.concurrent.Callable;
// Custom functional interface
@FunctionalInterface
interface Transformer {
String transform(String input);
// Adding a second abstract method here would cause a compile error
}
public class FunctionalInterfaceDemo {
public static void main(String[] args) {
// Runnable — no args, no return value
Runnable r = () -> System.out.println("Running!");
r.run();
// Comparator — two args, returns int
java.util.Comparator<String> comp =
(s1, s2) -> s1.length() - s2.length();
System.out.println("Compare: " + comp.compare("Hi", "Hello"));
// Callable — no args, returns value, can throw
Callable<Integer> callable = () -> 42;
try {
System.out.println("Callable result: " + callable.call());
} catch (Exception e) {
e.printStackTrace();
}
// Custom functional interface
Transformer upper = s -> s.toUpperCase();
Transformer shout = s -> s.toUpperCase() + "!!!";
System.out.println(upper.transform("hello world"));
System.out.println(shout.transform("hello world"));
}
}
Compare: -3
Callable result: 42
HELLO WORLD
HELLO WORLD!!!
4. Predicate<T>
Predicate<T> (in
java.util.function) represents a
boolean-valued function of one argument. Its single abstract
method is boolean test(T t).
Predicates can be composed using
and(),
or(), and
negate().
import java.util.*;
import java.util.function.Predicate;
public class PredicateDemo {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> isGreater5 = n -> n > 5;
// test
System.out.println("4 is even? " + isEven.test(4));
System.out.println("7 is even? " + isEven.test(7));
// and — both conditions must be true
Predicate<Integer> evenAndGreater5 = isEven.and(isGreater5);
System.out.print("Even AND > 5: ");
numbers.stream()
.filter(evenAndGreater5)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// or — at least one condition true
Predicate<Integer> evenOrGreater5 = isEven.or(isGreater5);
System.out.print("Even OR > 5: ");
numbers.stream()
.filter(evenOrGreater5)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// negate — opposite condition
System.out.print("NOT even: ");
numbers.stream()
.filter(isEven.negate())
.forEach(n -> System.out.print(n + " "));
System.out.println();
// Filtering a list of strings
List<String> words = Arrays.asList("apple", "ant", "banana", "avocado", "cherry");
Predicate<String> startsWithA = s -> s.startsWith("a");
Predicate<String> longerThan5 = s -> s.length() > 5;
System.out.print("Starts with 'a' and longer than 5: ");
words.stream()
.filter(startsWithA.and(longerThan5))
.forEach(s -> System.out.print(s + " "));
System.out.println();
}
}
7 is even? false
Even AND > 5: 6 8 10
Even OR > 5: 2 4 6 7 8 9 10
NOT even: 1 3 5 7 9
Starts with 'a' and longer than 5: avocado
5. Consumer<T>
Consumer<T> represents an
operation that accepts a single input argument and returns no
result. Its abstract method is
void accept(T t). Use
andThen() to chain multiple consumers
so they execute sequentially.
import java.util.*;
import java.util.function.Consumer;
public class ConsumerDemo {
public static void main(String[] args) {
Consumer<String> printUpper = s -> System.out.println(s.toUpperCase());
Consumer<String> printLen = s -> System.out.println("Length: " + s.length());
// accept — execute the consumer
printUpper.accept("hello");
printLen.accept("hello");
// andThen — chain consumers (both execute in order)
Consumer<String> combined = printUpper.andThen(printLen);
System.out.println("--- Combined ---");
combined.accept("lambda");
// forEach with Consumer
List<Integer> nums = Arrays.asList(10, 20, 30, 40);
Consumer<Integer> printSquare = n -> System.out.println(n + " -> " + (n * n));
System.out.println("--- Squares ---");
nums.forEach(printSquare);
// BiConsumer example (two inputs, no result)
java.util.function.BiConsumer<String, Integer> printRepeat =
(str, times) -> System.out.println(str.repeat(times));
printRepeat.accept("Ha", 3);
}
}
Length: 5
--- Combined ---
LAMBDA
Length: 6
--- Squares ---
10 -> 100
20 -> 400
30 -> 900
40 -> 1600
HaHaHa
6. Supplier<T>
Supplier<T> represents a
supplier of results. Its abstract method is
T get(). It takes no arguments and
returns a value. It is useful for lazy initialization — the value
is only computed when get() is called,
not when the Supplier is created.
import java.util.function.Supplier;
import java.util.*;
public class SupplierDemo {
public static void main(String[] args) {
// Simple value supplier
Supplier<String> greeting = () -> "Hello, World!";
System.out.println(greeting.get());
// Lazy initialization — expensive object created only when needed
Supplier<List<String>> listSupplier = () -> {
System.out.println("(Creating list...)");
return new ArrayList<>(Arrays.asList("A", "B", "C"));
};
System.out.println("Supplier created, list not yet created.");
List<String> list = listSupplier.get(); // list created here
System.out.println("List: " + list);
// Supplier for random values
Supplier<Integer> randomInt = () -> new Random().nextInt(100);
System.out.println("Random 1: " + randomInt.get());
System.out.println("Random 2: " + randomInt.get());
// Supplier used in Optional.orElseGet (lazy — only called if empty)
Optional<String> empty = Optional.empty();
String result = empty.orElseGet(() -> "Default from Supplier");
System.out.println("orElseGet result: " + result);
}
}
Supplier created, list not yet created.
(Creating list...)
List: [A, B, C]
Random 1: 47
Random 2: 83
orElseGet result: Default from Supplier
7. Function<T,R> and Method References
Function<T,R> represents a
function that accepts one argument of type T and returns a result
of type R. Its abstract method is
R apply(T t). You can compose
functions using andThen() (apply this
then the next) and compose() (apply
other first, then this).
Method References (:: operator)
Method references are a shorthand notation for lambdas that call an existing method. There are four kinds:
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class FunctionMethodRefDemo {
static String staticToUpper(String s) { return s.toUpperCase(); }
String instanceToLower(String s) { return s.toLowerCase(); }
public static void main(String[] args) {
// Function apply + andThen
Function<String, Integer> strLen = s -> s.length();
Function<Integer, String> intToStr = i -> "Length: " + i;
Function<String, String> combined = strLen.andThen(intToStr);
System.out.println(combined.apply("Java 8"));
// --- 4 types of method references ---
// 1. Static method reference: ClassName::staticMethod
Function<String, String> upperRef = FunctionMethodRefDemo::staticToUpper;
System.out.println("Static ref: " + upperRef.apply("hello"));
// 2. Instance method of a specific object: instance::instanceMethod
FunctionMethodRefDemo obj = new FunctionMethodRefDemo();
Function<String, String> lowerRef = obj::instanceToLower;
System.out.println("Instance ref: " + lowerRef.apply("WORLD"));
// 3. Instance method of an arbitrary object of a type: ClassName::instanceMethod
// (first argument becomes the receiver)
List<String> words = Arrays.asList("banana", "apple", "cherry");
words.sort(String::compareTo); // String::compareTo is a BiFunction-style ref
System.out.println("Sorted: " + words);
// 4. Constructor reference: ClassName::new
Supplier<ArrayList<String>> listFactory = ArrayList::new;
ArrayList<String> newList = listFactory.get();
newList.add("created via constructor ref");
System.out.println("Constructor ref: " + newList);
// Common use with streams
List<String> names = Arrays.asList("alice", "bob", "charlie");
List<String> uppered = names.stream()
.map(String::toUpperCase) // method ref
.collect(Collectors.toList());
System.out.println("Uppered: " + uppered);
// Printing with method reference
System.out.println("--- forEach method ref ---");
uppered.forEach(System.out::println);
}
}
Static ref: HELLO
Instance ref: world
Sorted: [apple, banana, cherry]
Constructor ref: [created via constructor ref]
Uppered: [ALICE, BOB, CHARLIE]
--- forEach method ref ---
ALICE
BOB
CHARLIE
8. Stream API
The Stream API (in java.util.stream)
enables functional-style operations on sequences of elements. A
stream is not a data structure — it does not store
elements. It is a pipeline that processes data from a source
(collection, array, I/O channel).
Key characteristics:
- Lazy evaluation — intermediate operations are not executed until a terminal operation is invoked.
- Single use — a stream can only be consumed once.
- Non-mutating — stream operations do not modify the source collection.
-
Potentially parallelizable — switch to parallel
with
.parallelStream().
Intermediate vs Terminal Operations
| Type | Operations | Returns |
|---|---|---|
| Intermediate (lazy) | filter, map, flatMap, sorted, distinct, limit, skip, peek | Stream<T> |
| Terminal (triggers execution) | forEach, collect, count, reduce, findFirst, anyMatch, allMatch, noneMatch, min, max, toList | Result or void |
import java.util.*;
import java.util.stream.*;
public class StreamBasics {
public static void main(String[] args) {
// Create streams from various sources
Stream<String> fromCollection = List.of("a", "b", "c").stream();
Stream<Integer> fromArray = Arrays.stream(new Integer[]{1, 2, 3});
Stream<String> ofStream = Stream.of("x", "y", "z");
Stream<Integer> generated = Stream.iterate(0, n -> n + 2).limit(5);
System.out.print("Generated even numbers: ");
generated.forEach(n -> System.out.print(n + " "));
System.out.println();
// Lazy evaluation demo
System.out.println("--- Lazy Evaluation ---");
List.of(1, 2, 3, 4, 5)
.stream()
.filter(n -> { System.out.println("filter: " + n); return n % 2 == 0; })
.map(n -> { System.out.println("map: " + n); return n * 10; })
.findFirst() // terminal — stops as soon as first match found
.ifPresent(n -> System.out.println("Result: " + n));
}
}
--- Lazy Evaluation ---
filter: 1
filter: 2
map: 2
Result: 20
Stream Sources and Primitive Streams
Streams can be created from collections, arrays, individual
values, generators, and I/O APIs. Use the primitive stream types
IntStream,
LongStream, and
DoubleStream when processing numbers
to avoid unnecessary boxing and to access numeric operations such
as sum and
average.
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Stream;
Stream<String> fromValues = Stream.of("Java", "Spring", "SQL");
Stream<String> fromArray = Arrays.stream(new String[]{"A", "B", "C"});
IntStream numbers = IntStream.rangeClosed(1, 5);
int sum = numbers.sum();
int[] doubled = IntStream.of(1, 2, 3)
.map(n -> n * 2)
.toArray();
System.out.println("Sum: " + sum);
System.out.println("Doubled: " + Arrays.toString(doubled));
Doubled: [2, 4, 6]
Remember: A stream is consumed by its terminal operation. Store the source collection if the data must be processed again; do not try to reuse an already-consumed stream.
9. Common Stream Operations
Here is a comprehensive walkthrough of the most-used stream operations, each with a working example.
import java.util.*;
import java.util.stream.*;
public class StreamOperations {
public static void main(String[] args) {
List<Integer> nums = Arrays.asList(5, 3, 8, 1, 9, 2, 7, 4, 6, 3, 8);
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve", "Bob");
// filter — keep elements matching predicate
System.out.print("filter (even): ");
nums.stream().filter(n -> n % 2 == 0)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// map — transform each element
System.out.print("map (x2): ");
nums.stream().distinct().sorted().map(n -> n * 2)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// sorted — natural order
System.out.print("sorted: ");
nums.stream().sorted().forEach(n -> System.out.print(n + " "));
System.out.println();
// distinct — remove duplicates
System.out.print("distinct: ");
nums.stream().distinct().sorted()
.forEach(n -> System.out.print(n + " "));
System.out.println();
// limit — take first N elements
System.out.print("limit(4): ");
nums.stream().sorted().limit(4)
.forEach(n -> System.out.print(n + " "));
System.out.println();
// count — terminal: number of elements
long evenCount = nums.stream().filter(n -> n % 2 == 0).count();
System.out.println("count (even): " + evenCount);
// collect — gather into a List
List<Integer> evenList = nums.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println("collect toList: " + evenList);
// reduce — combine elements into single result
int sum = nums.stream().reduce(0, Integer::sum);
System.out.println("reduce (sum): " + sum);
Optional<Integer> product = nums.stream().distinct()
.reduce((a, b) -> a * b);
System.out.println("reduce (product of distinct): " + product.orElse(0));
// min and max
Optional<Integer> min = nums.stream().min(Comparator.naturalOrder());
Optional<Integer> max = nums.stream().max(Comparator.naturalOrder());
System.out.println("min: " + min.get() + ", max: " + max.get());
// anyMatch / allMatch / noneMatch — boolean terminal ops
System.out.println("anyMatch >8: " + nums.stream().anyMatch(n -> n > 8));
System.out.println("allMatch >0: " + nums.stream().allMatch(n -> n > 0));
System.out.println("noneMatch <0: " + nums.stream().noneMatch(n -> n < 0));
// map on strings
System.out.print("names uppercase: ");
names.stream().distinct().map(String::toUpperCase)
.forEach(s -> System.out.print(s + " "));
System.out.println();
// flatMap — flatten nested lists
List<List<Integer>> nested = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5),
Arrays.asList(6, 7, 8, 9)
);
List<Integer> flat = nested.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
System.out.println("flatMap: " + flat);
}
}
map (x2): 2 4 6 8 10 12 14 16 18
sorted: 1 2 3 3 4 5 6 7 8 8 9
distinct: 1 2 3 4 5 6 7 8 9
limit(4): 1 2 3 3
count (even): 5
collect toList: [8, 2, 4, 6, 8]
reduce (sum): 56
reduce (product of distinct): 362880
min: 1, max: 9
anyMatch >8: true
allMatch >0: true
noneMatch <0: true
names uppercase: ALICE BOB CHARLIE DAVID EVE
flatMap: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Short-Circuiting and Safe Pipelines
Operations such as findFirst,
findAny,
anyMatch,
allMatch,
noneMatch, and
limit can stop processing as soon as
the result is known. Keep stream lambdas stateless and avoid
changing external collections inside map
or filter; side effects make ordering,
parallel execution, and debugging harder to reason about.
import java.util.Arrays;
import java.util.List;
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Diana");
String firstLongName = names.stream()
.filter(name -> name.length() > 4)
.findFirst()
.orElse("No match");
boolean hasShortName = names.stream()
.anyMatch(name -> name.length() < 4);
System.out.println(firstLongName);
System.out.println(hasShortName);
true
Parallel Streams
A stream can run in parallel with
parallelStream() or
parallel(). Parallelism is not
automatically faster: it is most useful for large, CPU-bound,
independent workloads. Avoid shared mutable state, blocking I/O,
and order-sensitive operations unless their costs and behavior
are understood. Use sequential() to
return to sequential processing when needed.
import java.util.Arrays;
import java.util.List;
List<Integer> values = Arrays.asList(1, 2, 3, 4, 5, 6);
int total = values.parallelStream()
.mapToInt(Integer::intValue)
.sum();
System.out.println("Parallel sum: " + total);
10. Optional<T>
Optional<T> is a container that
may or may not hold a non-null value. It forces the developer to
explicitly handle the "no value" case, eliminating unexpected
NullPointerExceptions. It was designed
to be used as a return type — not as a field type or
method parameter.
import java.util.*;
public class OptionalDemo {
// Return Optional instead of null
static Optional<String> findUser(int id) {
Map<Integer, String> db = Map.of(1, "Alice", 2, "Bob");
return Optional.ofNullable(db.get(id));
}
public static void main(String[] args) {
// Creating Optionals
Optional<String> present = Optional.of("Hello");
Optional<String> empty = Optional.empty();
Optional<String> nullable = Optional.ofNullable(null); // same as empty
// isPresent and isEmpty
System.out.println("present.isPresent(): " + present.isPresent());
System.out.println("empty.isEmpty(): " + empty.isEmpty());
// get — throws NoSuchElementException if empty; always check first
System.out.println("present.get(): " + present.get());
// orElse — default value if empty
System.out.println("empty.orElse: " + empty.orElse("Default"));
// orElseGet — lazy default via Supplier
System.out.println("empty.orElseGet: " + empty.orElseGet(() -> "Lazy Default"));
// orElseThrow — throw custom exception if empty
try {
empty.orElseThrow(() -> new IllegalStateException("Value required"));
} catch (IllegalStateException e) {
System.out.println("Caught: " + e.getMessage());
}
// map — transform value if present
Optional<Integer> length = present.map(String::length);
System.out.println("map length: " + length.orElse(0));
// filter — keep value if predicate matches
Optional<String> filtered = present.filter(s -> s.startsWith("H"));
System.out.println("filter result: " + filtered.orElse("No match"));
// ifPresent — run action if value present
present.ifPresent(s -> System.out.println("ifPresent: " + s));
// findUser example
System.out.println("User 1: " + findUser(1).orElse("Not found"));
System.out.println("User 9: " + findUser(9).orElse("Not found"));
// Chaining Optionals safely
String result = findUser(1)
.map(String::toUpperCase)
.filter(s -> s.length() > 3)
.orElse("Too short or not found");
System.out.println("Chained: " + result);
}
}
empty.isEmpty(): true
present.get(): Hello
empty.orElse: Default
empty.orElseGet: Lazy Default
Caught: Value required
map length: 5
filter result: Hello
ifPresent: Hello
User 1: Alice
User 9: Not found
Chained: ALICE
Warning: Never call
optional.get() without first
checking isPresent(). Prefer
orElse,
orElseGet, or
ifPresent for safer code.
11. Collectors
Collectors (in
java.util.stream) is a utility class
providing implementations of the
Collector interface for common
reduction operations. They are used with
stream.collect().
import java.util.*;
import java.util.stream.*;
import java.util.function.Function;
public class CollectorsDemo {
public static void main(String[] args) {
List<String> fruits = Arrays.asList(
"Apple", "Banana", "Cherry", "Avocado", "Blueberry", "Apricot"
);
List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5);
// toList
List<String> aFruits = fruits.stream()
.filter(f -> f.startsWith("A"))
.collect(Collectors.toList());
System.out.println("toList: " + aFruits);
// toSet
Set<Integer> numSet = nums.stream().collect(Collectors.toSet());
System.out.println("toSet: " + numSet);
// toMap — key: fruit name, value: its length
Map<String, Integer> fruitLengths = fruits.stream()
.collect(Collectors.toMap(Function.identity(), String::length));
System.out.println("toMap (sample): Apple=" + fruitLengths.get("Apple")
+ ", Banana=" + fruitLengths.get("Banana"));
// groupingBy — group fruits by first letter
Map<Character, List<String>> grouped = fruits.stream()
.collect(Collectors.groupingBy(s -> s.charAt(0)));
grouped.forEach((k, v) -> System.out.println("Group " + k + ": " + v));
// groupingBy with counting downstream
Map<Character, Long> countByLetter = fruits.stream()
.collect(Collectors.groupingBy(s -> s.charAt(0), Collectors.counting()));
System.out.println("countByLetter: " + countByLetter);
// joining — concatenate strings
String joined = fruits.stream().collect(Collectors.joining(", ", "[", "]"));
System.out.println("joining: " + joined);
// counting
long count = fruits.stream()
.filter(f -> f.length() > 5)
.collect(Collectors.counting());
System.out.println("counting (len>5): " + count);
// summarizingInt
IntSummaryStatistics stats = nums.stream()
.collect(Collectors.summarizingInt(Integer::intValue));
System.out.println("sum=" + stats.getSum()
+ " avg=" + stats.getAverage()
+ " min=" + stats.getMin()
+ " max=" + stats.getMax());
// partitioningBy — split values into true and false groups
Map<Boolean, List<Integer>> partitions = nums.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("partitions: " + partitions);
}
}
toSet: [1, 2, 3, 4, 5]
toMap (sample): Apple=5, Banana=6
Group A: [Apple, Avocado, Apricot]
Group B: [Banana, Blueberry]
Group C: [Cherry]
countByLetter: {A=3, B=2, C=1}
joining: [Apple, Banana, Cherry, Avocado, Blueberry, Apricot]
counting (len>5): 4
sum=15 avg=3.0 min=1 max=5
partitions: {false=[1, 3, 5], true=[2, 4]}
12. Java 9–21 Highlights
Java has continued to evolve since Java 8. Here are four landmark features from subsequent versions:
var — Local Variable Type Inference (Java 10)
The var keyword lets the compiler
infer the type of a local variable from the initializer. It
reduces verbosity without sacrificing type safety — the type is
still static and known at compile time.
// Java 10+
var list = new ArrayList<String>(); // inferred as ArrayList<String>
var map = new HashMap<String, Integer>();
list.add("Hello");
map.put("one", 1);
var name = "Java 10"; // inferred as String
// name = 42; // compile error — type is String, not int
// Useful in for-each loops
for (var entry : map.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
var can only be used for local
variables with initializers. It cannot be used for fields,
method parameters, or return types.
Records (Java 16)
Records are concise immutable data classes. The compiler
automatically generates the constructor, accessors (name(), age()),
equals(),
hashCode(), and
toString().
// Java 16+
record Person(String name, int age) {}
// Usage
Person p = new Person("Alice", 30);
System.out.println(p.name()); // Alice
System.out.println(p.age()); // 30
System.out.println(p); // Person[name=Alice, age=30]
// Records are immutable — no setters
// p.name = "Bob"; // compile error
Sealed Classes (Java 17)
Sealed classes restrict which other classes can extend or implement them. This gives the author of a class hierarchy explicit control over all permitted subtypes — a prerequisite for exhaustive pattern matching.
// Java 17+
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double ht) implements Shape {}
// Pattern matching switch (exhaustive — no default needed)
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
case Triangle t -> 0.5 * t.base() * t.ht();
};
}
Virtual Threads (Java 21)
Virtual threads are lightweight threads managed by the JVM rather than the OS. They allow writing simple, synchronous-looking code that scales to millions of concurrent tasks — previously only achievable with complex reactive/async frameworks.
// Java 21+
// Creating a virtual thread
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running in virtual thread: "
+ Thread.currentThread().isVirtual());
});
vt.join();
// Using ExecutorService with virtual threads
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> System.out.println("Task " + taskId));
}
}
// Output: Task 0 through Task 9 (order may vary)
Key benefit: Virtual threads are not pinned to platform (OS) threads during blocking I/O. You can run millions of them simultaneously with minimal memory overhead — each uses only a few kilobytes, versus ~1 MB for a platform thread.
