Java 5 (2004) was a watershed release:
- Generics: Type-safe collections and classes
- Enhanced for-loop (for-each):
for (String s : list) - Autoboxing/unboxing: Automatic primitive-wrapper conversion
- Enums:
enum Direction { NORTH, SOUTH, EAST, WEST } - Varargs:
void method(String... args) - Annotations:
@Override,@Deprecated,@SuppressWarnings, custom annotations - Static imports:
import static java.lang.Math.PI - Concurrent utilities:
java.util.concurrent– Executor framework, locks, atomic classes - Formatted I/O:
printf,String.format Iterableinterface: Enables for-each on custom classes
Java 5 generics are implemented via type erasure – generic type info is removed at runtime. This is a compromise for backward compatibility but has implications: can't do new T(), instanceof T, or List<String>.class.
Generics enable type-safe, reusable code by parameterizing types.
// Without generics (Java 1.4)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // Unsafe cast, possible ClassCastException
// With generics (Java 5+)
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // No cast needed, type-safe
Type erasure: At compile time, List<String> is checked. At runtime, it becomes List (raw type). Generic info is mostly lost.
Wildcards:
? extends T(upper bounded): read-only, covariant. "Producer Extends"? super T(lower bounded): write-capable, contravariant. "Consumer Super"- PECS rule (Producer Extends, Consumer Super)
// Copy elements from src to dest
<T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) dest.add(item);
}
Bounded type parameters:
<T extends Comparable<T>> T max(T a, T b) { return a.compareTo(b) > 0 ? a : b; }
Generic methods vs generic classes: Method can have its own type parameters independent of the class.
Reifiable types: Only raw types, non-generic types, unbounded wildcards are reifiable (accessible at runtime via reflection).
The enhanced for-loop (for-each) iterates over arrays and Iterable implementations:
// Array
int[] numbers = {1, 2, 3};
for (int n : numbers) { System.out.println(n); }
// Iterable
List<String> names = List.of("Alice", "Bob");
for (String name : names) { System.out.println(name); }
Under the hood, for Iterable, the compiler generates:
Iterator<String> it = names.iterator();
while (it.hasNext()) {
String name = it.next();
// body
}
For arrays, it generates a traditional index-based loop.
Limitations:
- Can't modify the collection during iteration (ConcurrentModificationException)
- Can't access index
- Can't iterate multiple collections simultaneously
- Can't iterate in reverse
For indexed access, use for (int i = 0; i < list.size(); i++). For removal during iteration, use it.remove() or list.removeIf().
- try-with-resources: Auto-closing of
AutoCloseableresources - Multi-catch:
catch (IOException | SQLException e) - Diamond operator:
List<String> list = new ArrayList<>()(infer generic type) - String in switch:
switch (str) { case "value": } - Numeric literals with underscores:
1_000_000,0xFF_FF - Binary literals:
0b1010 - Fork/Join framework:
ForkJoinPool,RecursiveTask - NIO.2 (java.nio.file):
Path,Files,WatchService, better file handling - invokedynamic bytecode: Foundation for lambdas in Java 8
Java 7 Project Coin: Small language improvements collected under "Project Coin".
try-with-resources automatically closes resources that implement AutoCloseable:
// Java 6 way (verbose, easy to forget closing in finally)
Connection conn = null;
try {
conn = dataSource.getConnection();
// use connection
} finally {
if (conn != null) conn.close(); // can throw too!
}
// Java 7 way
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// use conn and stmt
// both auto-closed in reverse order on exit
}
Close order: Resources closed in reverse order of opening (stmt closed before conn).
Suppressed exceptions: If body throws AND close() throws, the close() exception is suppressed and attached to the primary exception:
try {
Throwable t = e.getSuppressed()[0]; // access suppressed exception
}
Java 9 improvement: Can reference effectively-final variables:
Connection conn = getConnection();
try (conn) { // no need to redeclare
// use conn
}
Multi-catch allows catching multiple exception types in one catch block:
// Java 6
try { ... }
catch (IOException e) { log(e); throw new ServiceException(e); }
catch (SQLException e) { log(e); throw new ServiceException(e); } // code duplication
// Java 7
try { ... }
catch (IOException | SQLException e) {
log(e); // DRY
throw new ServiceException(e);
}
Important rule: In multi-catch, the variable e is implicitly final (can't reassign). This makes sense because e could be either type.
Can't catch related exceptions: catch (Exception | IOException e) is illegal because IOException extends Exception – it would be redundant.
Java 8 (2014) was the most transformative release since Java 5:
- Lambda expressions:
(x, y) -> x + y - Functional interfaces:
@FunctionalInterface - Stream API: Declarative data processing
- Optional
: Null-safe value containers - Default and static methods in interfaces
- Method references:
String::toUpperCase,System.out::println - New Date/Time API (java.time):
LocalDate,LocalDateTime,ZonedDateTime,Duration,Period - CompletableFuture: Async programming
- Nashorn JavaScript engine (deprecated in 11)
- Base64 encoding:
Base64.getEncoder() - forEach on Iterable:
list.forEach(System.out::println) - Map.getOrDefault()
,Map.computeIfAbsent()`, etc. - StringJoiner
- Parallel array sorting:
Arrays.parallelSort()
Java 8 enabled functional programming patterns in Java and drastically changed how Java code is written.
A lambda is a concise anonymous function that can be passed around as a value.
Syntax: (parameters) -> expression or (parameters) -> { statements; }
// Old way
Runnable r = new Runnable() {
@Override
public void run() { System.out.println("Hello"); }
};
// Lambda
Runnable r = () -> System.out.println("Hello");
// With parameters
Comparator<String> comp = (a, b) -> a.compareTo(b);
// Multi-line
Function<String, String> process = input -> {
String trimmed = input.trim();
return trimmed.toUpperCase();
};
Target typing: Lambda type is inferred from context. The target must be a functional interface (exactly one abstract method).
Variable capture: Lambdas can capture effectively final local variables (not just final). Effectively final = not reassigned after initialization.
String prefix = "Hello"; // effectively final Function<String, String> greet = name -> prefix + " " + name; // OK prefix = "Hi"; // This would make lambda illegal
Method references are lambdas in disguise: String::toUpperCase = s -> s.toUpperCase()
Types of method references:
- Static:
Integer::parseInt - Instance (bound):
str::contains(specific instance) - Instance (unbound):
String::toUpperCase(called on parameter) - Constructor:
ArrayList::new
A functional interface has exactly one abstract method. Lambdas can be used wherever a functional interface is expected.
@FunctionalInterface annotation: Optional but recommended (compile-time check).
Built-in functional interfaces (java.util.function):
| Interface | Signature | Use |
|---|---|---|
Runnable | () -> void | Run code |
Supplier<T> | () -> T | Produce value |
Consumer<T> | T -> void | Consume value |
Function<T,R> | T -> R | Transform |
Predicate<T> | T -> boolean | Test condition |
BiFunction<T,U,R> | (T,U) -> R | Two-arg transform |
UnaryOperator<T> | T -> T | Transform same type |
BinaryOperator<T> | (T,T) -> T | Reduce same type |
Functional interfaces can have:
- Default methods
- Static methods
- Methods from
Object(equals,toString, etc.) – don't count as abstract
Predicate<T>: Takes T, returns boolean
Predicate<String> isLong = s -> s.length() > 10;
isLong.test("Hello World!"); // true
// Composing predicates
Predicate<String> isLongAndUpper = isLong.and(s -> s.equals(s.toUpperCase()));
Predicate<String> isLongOrEmpty = isLong.or(String::isEmpty);
Predicate<String> isShort = isLong.negate();
Function<T, R>: Takes T, returns R
Function<String, Integer> length = String::length;
length.apply("Hello"); // 5
// Composing functions
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> trimThenUpper = trim.andThen(upper);
Function<String, String> upperAfterTrim = upper.compose(trim); // same result
Use Predicate for filtering/testing, Function for transforming.
BiPredicate<T, U>: Two arguments, returns boolean.
BiFunction<T, U, R>: Two arguments, returns R.
Stream API provides a declarative, functional approach to processing sequences of elements.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// Imperative
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() > 4) result.add(name.toUpperCase());
}
Collections.sort(result);
// Stream (declarative)
List<String> result = names.stream()
.filter(name -> name.length() > 4)
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
Key characteristics:
- Lazy: Intermediate operations don't execute until a terminal operation is called
- Single use: Streams can't be reused
- Non-interfering: Should not modify the source during processing
- Stateless (mostly): Operations should be side-effect-free for parallel safety
Operations:
- Intermediate (lazy):
filter,map,flatMap,sorted,distinct,limit,skip,peek - Terminal (trigger execution):
collect,forEach,reduce,count,findFirst,anyMatch,allMatch,toList()(Java 16)
Specialized streams: IntStream, LongStream, DoubleStream – avoid boxing overhead.
IntStream.range(0, 10).sum(); // 45 IntStream.of(1, 2, 3).average(); // OptionalDouble
| Collection | Stream |
|---|---|
| Stores data | Processes data |
| In-memory data structure | Computational pipeline |
| Can be traversed multiple times | Single-use |
| Eager: elements computed when added | Lazy: computed on demand |
| Mutate elements | No mutation (produces new stream/result) |
| Has size | May be infinite (Stream.iterate, Stream.generate) |
Iterable | BaseStream |
Key distinction: A Collection is data. A Stream processes data.
// Infinite stream (possible only with laziness)
Stream.iterate(0, n -> n + 2)
.filter(n -> n % 6 == 0)
.limit(5)
.forEach(System.out::println); // 0, 6, 12, 18, 24
Streams can be created FROM collections (.stream()), arrays (Arrays.stream()), values (Stream.of()), files (Files.lines()), etc.
Optional<T> is a container that may or may not contain a value. It's a way to express "nullable return" explicitly in the type system.
// Instead of returning null
public Optional<User> findById(Long id) {
return users.stream()
.filter(u -> u.getId().equals(id))
.findFirst(); // Returns Optional<User>
}
// Usage
Optional<User> user = findById(1L);
// Bad practice (defeats the purpose)
if (user.isPresent()) user.get(); // like null check
// Good practice
user.ifPresent(u -> sendEmail(u.getEmail()));
String name = user.map(User::getName).orElse("Unknown");
User found = user.orElseThrow(() -> new UserNotFoundException(id));
Methods:
isPresent(),isEmpty()(Java 11)get()(throws if empty – use cautiously)orElse(default)– always evaluates defaultorElseGet(supplier)– lazy, only evaluates if emptyorElseThrow(supplier)map(),flatMap(),filter()ifPresent(),ifPresentOrElse()(Java 9)or(supplier)(Java 9) – return other Optional if emptystream()(Java 9) – 0 or 1 element stream
When NOT to use Optional:
- As method parameter (use overloads or nullability annotations instead)
- As field in a class (not serializable, overhead)
- In collections (use empty collection instead of Optional
- )
Java Platform Module System (JPMS / Project Jigsaw) – module-info.java
module com.company.service {
requires java.sql; // depend on module
requires transitive java.logging; // transitive dependency
exports com.company.service.api; // make packages available
exports com.company.service.impl to com.company.other; // qualified export
opens com.company.service.model; // allow reflection
uses com.company.spi.Plugin; // ServiceLoader
provides com.company.spi.Plugin with com.company.service.DefaultPlugin;
}
Goals:
- Strong encapsulation: Even public types in unexported packages aren't accessible
- Reliable configuration: Explicit dependency graph; missing modules detected at startup
- Scalable platform: Build custom JREs with only needed modules (
jlink)
Module types:
- Named module: Has
module-info.java - Unnamed module: Classpath code (backward compatible)
- Automatic module: JAR on module path without
module-info.java
Impact in practice: Most enterprise apps still use classpath (unnamed module) for backward compatibility. Libraries are adding module-info.java. Spring Boot has limited module support.
jlink is a tool to create custom, minimal JRE images containing only the modules your application needs.
jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.example.myapp \
--output custom-runtime \
--strip-debug \
--compress=2
Benefits:
- Smaller deployments: Runtime can be 20–50MB vs 200MB+ full JDK
- Faster startup: Fewer classes to load
- Security: Reduced attack surface
- Docker images: Much smaller container images
Used heavily in containerized microservices and GraalVM native image scenarios.
Java 10 introduced var for local variable type inference:
// Before ArrayList<Map<String, List<Integer>>> map = new ArrayList<>(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // With var var map = new ArrayList<Map<String, List<Integer>>>(); var conn = (HttpURLConnection) url.openConnection();
var is not a type – it's syntactic sugar for type inference in local variables. The compiler infers the type from the right-hand side; the type is still static.
Where var can be used:
- Local variable declarations with initializer
- for-loop index and enhanced for-loop
- try-with-resources
Where var CANNOT be used:
- Method parameters or return types
- Fields
var x = null;(can't infer from null)- Lambda parameters (actually CAN in Java 11:
(var x, var y) -> x + y)
Senior considerations:
- Improves readability for complex generic types
- Can harm readability with primitives or unclear types:
var result = compute();– what type is result? - Good practice: use var when the type is obvious from the right side
varis a reserved type name, not a keyword –int var = 5;is still valid (terrible style, but compiles)
Java 11 (2018) – LTS release, major features:
- HTTP Client API (java.net.http) – finalized from Java 9 incubator
- String new methods:
isBlank(),lines(),strip(),stripLeading(),stripTrailing(),repeat(n) - Files.readString()
,Files.writeString()` varin lambda parameters:(var x, var y) -> x + y- Running single-file programs:
java HelloWorld.javawithout compiling - Epsilon GC: No-op GC (for testing/benchmarking)
- ZGC: Low-latency GC (experimental)
- Nest-based access control: Inner classes don't need synthetic accessor methods
- Removal of Java EE and CORBA modules:
javax.xml.bind,javax.activationremoved (need to add as dependencies) - Removed Nashorn JavaScript engine (deprecated, finally removed in 15)
strip() vs trim(): trim() removes ASCII whitespace (≤ '\u0020'). strip() removes Unicode whitespace (uses Character.isWhitespace()).
Key differences for a senior developer:
| Area | Java 8 | Java 11 |
|---|---|---|
| String API | No isBlank(), lines(), strip() | Added these |
| HTTP Client | HttpURLConnection (old) | java.net.http (modern, async) |
| Local var | No var | var in lambdas |
| GC | CMS (deprecated), G1 default | ZGC (experimental), Epsilon |
| Single file | Must compile first | java File.java directly |
| Java EE | javax.* modules included | Removed (must add as deps) |
| Optional | No isEmpty(), or(), ifPresentOrElse() | Added in 9/10 |
| Collection | No List.copyOf() etc. | Added in 10 |
| Modules | Not available | JPMS available (since 9) |
Practically: Java 8 → Java 11 migration requires: removing Java EE dependencies (add explicitly), updating deprecated APIs, checking module compatibility.
java.net.http (finalized in Java 11) – modern replacement for HttpURLConnection:
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.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());
future.thenApply(HttpResponse::body).thenAccept(System.out::println);
Features:
- HTTP/1.1 and HTTP/2 support
- WebSocket support
- Async (CompletableFuture)
- Reactive streams support
- Authentication, cookie management, redirect policies
Java 12:
switchexpressions (preview):yieldkeywordString.indent(),String.transform()Collectors.teeing()– combine two collectors
Java 13:
switchexpressions (second preview)- Text blocks (preview): Multi-line strings
Switch expressions (finalized in Java 14) vs switch statements:
// Old switch statement (fall-through bug-prone)
String result;
switch (day) {
case MONDAY: case TUESDAY: case WEDNESDAY:
result = "Weekday"; break;
case FRIDAY: case SATURDAY: case SUNDAY:
result = "Weekend"; break;
default: result = "Unknown";
}
// Java 14 switch expression (arrow form)
String result = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
// With blocks and yield
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
default -> {
String s = day.toString();
yield s.length(); // yield returns value
}
};
Benefits:
- Expression (produces a value, can be used in assignments)
- Exhaustiveness: Compiler checks all cases covered (for enums)
- No fall-through: Arrow form doesn't fall through
- Cleaner: No
breakstatements
Pattern matching in switch (Java 21):
Object obj = ...;
String result = switch (obj) {
case Integer i -> "Integer: " + i;
case String s when s.length() > 5 -> "Long string";
case String s -> "Short string";
default -> "Other";
};
Records (finalized in Java 16) are immutable data carrier classes with concise syntax:
// Traditional immutable class (boilerplate)
public final 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() { ... }
}
// Record
public record Point(int x, int y) {}
// Compiler auto-generates: constructor, accessors x(), y(), equals(), hashCode(), toString()
Customization:
public record Range(int min, int max) {
// Compact constructor (validates)
Range {
if (min > max) throw new IllegalArgumentException("min > max");
}
// Custom method
public int size() { return max - min; }
// Custom accessor
@Override public int min() { return Math.abs(min); } // override accessor
}
Records can:
- Implement interfaces
- Have static fields/methods
- Have instance methods
Records cannot:
- Extend classes (implicitly extend Record)
- Be extended
- Have instance fields beyond record components
- Be abstract
- DTOs (Data Transfer Objects): API request/response models
public record UserDTO(String name, String email, int age) {}
- Value objects (DDD): Domain primitives
public record Money(BigDecimal amount, Currency currency) {}
public record EmailAddress(String value) {
EmailAddress { if (!value.contains("@")) throw new IllegalArgumentException(); }
}
- Multiple return values:
public record PagedResult<T>(List<T> items, int totalCount) {}
- Composite map keys:
record CacheKey(String region, Long id) {} // equals/hashCode auto-generated
Map<CacheKey, Object> cache = new HashMap<>();
- Intermediate stream results:
.map(person -> new PersonScore(person.name(), calculateScore(person))) .filter(ps -> ps.score() > 50)
Records work well with pattern matching (Java 21):
if (obj instanceof Point(int x, int y)) { // deconstruct
System.out.println("x=" + x + " y=" + y);
}
Sealed classes (finalized in Java 17) restrict which classes can extend or implement a class/interface:
public sealed class Shape
permits Circle, Rectangle, Triangle {
}
public final class Circle extends Shape {
private final double radius;
}
public non-sealed class Rectangle extends Shape { // can be freely extended
private final double width, height;
}
public sealed class Triangle extends Shape
permits EquilateralTriangle, RightTriangle { // further restricted
}
Permitted classes must be: in the same package (or module), and must be final, sealed, or non-sealed.
Benefits:
- Controlled hierarchy: Know all possible subtypes at compile time
- Pattern matching exhaustiveness: Compiler knows all cases
- Domain modeling: Express algebraic data types
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> calculateTriangleArea(t);
// No default needed - compiler knows all cases!
};
Pattern matching (finalized in Java 16 for instanceof):
// Old way
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.length());
}
// Java 16 pattern matching
if (obj instanceof String s) {
System.out.println(s.length()); // s is in scope here
}
// With condition
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}
Pattern matching in switch (Java 21):
static String describe(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "Negative integer: " + i;
case Integer i -> "Positive integer: " + i;
case String s -> "String of length " + s.length();
case int[] arr -> "int array of length " + arr.length;
case null -> "null";
default -> "Something else";
};
}
Record patterns (Java 21):
if (obj instanceof Point(int x, int y) p) {
System.out.println("Point at " + x + ", " + y);
}
Java 17 (September 2021) is an LTS (Long-Term Support) release – the most significant LTS since Java 11 and Java 8.
Why it matters:
- Oracle provides 8+ years of support
- Most enterprises use LTS releases in production
- Spring Boot 3+ requires Java 17 minimum
- Many frameworks aligned to Java 17 as baseline
New features finalized in Java 17:
- Sealed classes (Java 15 preview)
- Pattern matching for instanceof (Java 14 preview)
- Records (Java 14 preview)
- Switch expressions (Java 14)
- Text blocks (Java 13 preview)
Deprecations/Removals:
- Applet API deprecated for removal
- Security Manager deprecated
- RMI Activation removed
- Strong encapsulation of JDK internals (Unsafe-like access restricted)
| GC | Introduced | Characteristics |
|---|---|---|
| G1 (Garbage First) | Java 7, default from 9 | Region-based, aims for predictable pause times |
| ZGC | Java 11 (experimental), 15 (production) | Sub-millisecond pauses, scalable to TBs |
| Shenandoah | Java 12 (RedHat), 15 OpenJDK | Low-pause, concurrent, scale with heap |
| Epsilon | Java 11 | No-op GC, for performance testing |
G1 improvements over time:
- Java 10: Parallel Full GC
- Java 12: Promptly return memory to OS
- Java 14: NUMA-aware memory allocation
ZGC evolution:
- Java 11: 4TB max heap, requires 8-core+
- Java 15: Production-ready
- Java 16: Thread-local handshakes, improved latency
- Java 21: Generational ZGC (default:
-XX:+UseZGC)
Virtual threads (Java 21 – Project Loom) are lightweight threads managed by the JVM, not the OS.
// Platform thread (expensive, OS-backed)
Thread.ofPlatform().start(() -> handleRequest());
// Virtual thread (cheap, JVM-managed)
Thread.ofVirtual().start(() -> handleRequest());
// With executor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> handle(req));
}
}
Why it matters:
- Platform threads: ~1MB stack, OS-limited (~thousands)
- Virtual threads: ~few KB, JVM-limited (millions possible)
- Virtual threads block without blocking OS thread – when a virtual thread blocks (I/O, sleep), the carrier OS thread is freed to run other virtual threads
One-thread-per-request model becomes viable again for high-concurrency I/O workloads.
Not a replacement for async: Same mental model as blocking code, but with performance of reactive. You write:
String response = httpClient.get(url); // looks blocking // but virtual thread is suspended, OS thread is freed
Considerations:
- Don't use with
ThreadLocalthat holds expensive resources (useScopedValueinstead) synchronizedblocks still pin virtual thread to carrier (useReentrantLockfor fine-grained locking)- Not beneficial for CPU-bound tasks (no concurrency gain)
Project Loom delivered over multiple releases:
- Java 19: Virtual threads (preview)
- Java 20: Virtual threads (second preview), Structured Concurrency (incubator)
- Java 21: Virtual threads (GA!), Structured Concurrency (preview), Scoped Values (preview)
Structured Concurrency (Java 21 preview):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> user = scope.fork(() -> fetchUser(id));
Future<Order> order = scope.fork(() -> fetchOrder(id));
scope.join().throwIfFailed();
return new Response(user.resultNow(), order.resultNow());
}
// scope.close() cancels subtasks if any fail
Benefits:
- Subtask lifetimes are bound to parent scope
- Error handling and cancellation are structured
- Easier to reason about than CompletableFuture chains
Scoped Values: Thread-local alternative designed for virtual threads.
- Spring Boot 3.x requires Java 17 minimum (dropped Java 8/11 support)
- Spring Boot 3.x is based on Spring Framework 6, which embraces Java 17 features (records as DTOs, sealed classes, etc.)
- Virtual threads in Spring Boot 3.2+:
spring.threads.virtual.enabled=trueenables virtual thread executor - GraalVM native image support in Spring Boot 3 (ahead-of-time compilation)
- Jakarta EE 10 (renamed from Java EE):
javax.→jakarta.namespace migration in Spring Boot 3 - Pattern matching, records, text blocks used in Spring source and application code
Java has a strong commitment to backward compatibility:
- Programs compiled with Java 5 generally run on Java 21 JVM
--releaseflag:javac --release 8 Source.javacompiles for Java 8 target- Deprecation process: Features deprecated before removal (usually multiple versions)
Breaking changes DO happen:
- Strong encapsulation:
--add-opensneeded for deep reflection - Module system: Some internal APIs (sun., com.sun.) no longer accessible
- Removed APIs: CMS GC removed (Java 14), Nashorn (Java 15), Applet API (Java 17)
- JEE modules removed in Java 11
Tooling:
jdeprscan: Find usage of deprecated APIs--illegal-access=warn: Identify reflective access that will break in future versions- Migration guides on OpenJDK site
| Feature | Removed Version |
|---|---|
| PermGen | Java 8 (replaced by Metaspace) |
| Java EE/CORBA modules | Java 11 |
| Nashorn JS engine | Java 15 |
| CMS GC | Java 14 |
| RMI Activation | Java 15 |
| Applet API | Java 17 (deprecated), removal pending |
| Security Manager | Java 17 (deprecated), pending |
finalize() | Java 18 (deprecated), pending |
Decision factors:
- LTS vs non-LTS: Production → LTS (8, 11, 17, 21). Non-LTS for early adoption.
- Framework support: Spring Boot 3 → Java 17+. Most modern frameworks: Java 11+
- Vendor support lifecycle: Oracle, Amazon Corretto, Eclipse Temurin differ
- Team readiness: Training, code review awareness of new features
- Library compatibility: Third-party JARs may not support newer Java
- GC requirements: If you need ZGC/Shenandoah, Java 15+
- Virtual threads: Need Java 21
- Security patches: Older versions may lack backports
Current recommendation (2024): Java 21 for new projects (LTS, virtual threads, records, sealed classes, pattern matching all stable). Java 17 for projects on Spring Boot 3. Java 11 only if library constraints exist. Java 8 only if legacy systems force it.
Step-by-step migration approach:
- Assess: Run
jdeprscanandjlink --list-modules. Identify deprecated API usage, illegal reflective access.
- Update dependencies: Upgrade all third-party libraries to versions compatible with target Java. Add
javax.→jakarta.if going to 11+ with Spring Boot 3.
- Fix compilation: Compile with
--release <target>. Fix compiler errors.
- Handle module encapsulation: Add
--add-opensJVM flags for necessary reflective access (temporary). Work to remove them over time.
- Update build tool: Maven/Gradle plugins must support target Java.
- Test thoroughly: Behavioral differences can be subtle. Especially concurrency, GC behavior.
- Incremental: Go 8 → 11 → 17 → 21 stepwise rather than jumping.
- JVM flags audit: Some old GC flags may be unrecognized (CMS flags removed). Use
-XX:+PrintFlagsFinalto validate.
- Docker base image: Update to Java 17/21 base images.
- Feature adoption: After migration, gradually adopt new features (records, var, etc.) during refactoring.