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);
        });
    }
}
Old way: anonymous class
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"));
    }
}
Running!
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();
    }
}
4 is even? true
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);
    }
}
HELLO
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);
    }
}
Hello, World!
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);
    }
}
Length: 6
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));
    }
}
Generated even numbers: 0 2 4 6 8
--- 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));
Sum: 15
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);
    }
}
filter (even): 8 2 4 6 8
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);
Alice
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);
Parallel sum: 21

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);
    }
}
present.isPresent(): true
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);
    }
}
toList: [Apple, Avocado, Apricot]
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.

Continue learning