Java has changed. If you’ve been away for a few years, the language you return to might feel different, in a very good way. The old reputation of being verbose and slow to evolve is fading. Today, Java moves quickly, adding features that solve real, everyday problems for developers writing production systems. I want to talk about some of these features not as academic curiosities, but as tools you can start using now to write cleaner, safer, and more efficient code.
Let’s look at some of the most impactful additions from Java 17 and beyond.
Sometimes, you design a class or an interface and you want complete control over what can extend it. In the past, you could only use final to allow no extensions, or make it public and accept that anyone could subclass it. There was no middle ground. Now, with sealed classes, you can define that middle ground.
You declare a class or interface as sealed and explicitly list who is allowed to extend or implement it. The compiler enforces this. This is perfect for modeling a fixed set of possibilities.
public sealed interface Document permits Invoice, Report, Contract {
String getId();
}
public final class Invoice implements Document {
@Override
public String getId() { return "INV_001"; }
}
public final class Report implements Document { /* ... */ }
public final class Contract implements Document { /* ... */ }
// This will cause a compile-time error:
// public class Memo implements Document {} // Not permitted!
Why is this useful? It makes your code more predictable. When you have a variable of type Document, you and the compiler know exactly what the possible concrete types are. This leads us directly to a powerful companion feature.
The traditional switch statement has gotten a massive upgrade. It’s no longer just for primitive types and enums. It can now work on any object, and it can deconstruct that object as part of the check—this is called pattern matching.
The old way involved a chain of if-else statements with instanceof checks and awkward casting.
// Old, verbose way
String formatOld(Object obj) {
if (obj instanceof Integer) {
Integer i = (Integer) obj;
return "Integer: " + i;
} else if (obj instanceof String) {
String s = (String) obj;
return "String: " + s;
} else {
return "Unknown";
}
}
The new way is concise and safe. The casting is gone. You can even add extra conditions right in the case label.
// New, clean way with pattern matching for switch
String formatNew(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case String s when s.length() > 5 -> "A long String: " + s;
case String s -> "String: " + s;
case null -> "It was null!";
default -> "Unknown: " + obj;
};
}
Notice the null case. You can now handle null explicitly within the switch itself, which often removes the need for a separate null check before the switch. This syntax turns switch from a simple multi-way branch into a powerful data query tool.
How many times have you written a class that just holds data? You create fields, a constructor, getters, equals(), hashCode(), and toString(). It’s a lot of repetitive code. Records fix this.
A record is a transparent carrier for immutable data. You declare its components, and the compiler does all the work.
// This single line generates everything.
public record CustomerOrder(String orderId, Customer customer, List<Item> items, Instant orderDate) {}
// Using it is straightforward.
CustomerOrder order = new CustomerOrder("123", customer, items, Instant.now());
System.out.println(order.orderId()); // Accessor method is orderId()
System.out.println(order); // Automatically has a useful toString()
Records are final and immutable. Their fields are private final. They are ideal for data transfer objects (DTOs), return types from methods, or keys in a map. They cut through the boilerplate and let you focus on what the data is.
Writing multi-line strings in Java used to be a pain. You had to escape newlines and quotes, which made embedding things like JSON, XML, or SQL queries hard to read.
// The old, cluttered way
String oldJson = "{\n" +
" \"name\": \"John\",\n" +
" \"age\": 30\n" +
"}";
Text blocks solve this. They start and end with three double-quote characters and preserve the formatting you write.
// The new, clear way
String newJson = """
{
"name": "John",
"age": 30
}
""";
String sql = """
SELECT o.id, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING'
""";
The compiler handles the indentation intelligently. Your code becomes much more readable, and editing these embedded snippets is far easier.
This is a small change with a huge impact on daily life. We’ve all spent time staring at a NullPointerException and trying to figure out which variable was actually null. Was it customer? Or was it customer.name? You had to trace through the line.
Starting with Java 14, the JVM includes the detail in the message. It tells you precisely what was null.
// Before Java 14
Exception in thread "main" java.lang.NullPointerException
// With Java 17+
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.toUpperCase()" because the return value of "com.example.Customer.getLastName()" is null
It spells it out: “I tried to call .toUpperCase() on a String, but I couldn’t because the value returned by getLastName() was null.” This feature is on by default. It doesn’t require any code changes; it just makes you fix bugs faster.
Interacting with native libraries (C, C++, etc.) used to mean using the Java Native Interface (JNI). It was complex, error-prone, and could easily crash the JVM. The new Foreign Function & Memory API (FFM) provides a safer, more modern way to do this.
It allows you to allocate memory outside the Java heap and call foreign functions in a controlled way. The key is safety: it prevents common mistakes like use-after-free errors.
// Example: Allocating native memory and calling a C function
import java.lang.foreign.*;
void callNativeFunction() throws Throwable {
// Define the C function signature: `void puts(const char *str);`
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
FunctionDescriptor descriptor = FunctionDescriptor.ofVoid(ValueLayout.ADDRESS);
MethodHandle puts = linker.downcallHandle(stdlib.find("puts").orElseThrow(), descriptor);
// Allocate memory for a C string inside a confined arena (auto-closed)
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateFrom("Hello from Java!");
// Invoke the native function
puts.invoke(cString);
} // Memory is automatically freed here
}
This API is for advanced use cases, but for tasks like high-performance I/O, data science, or using specialized hardware libraries, it’s a game-changer. It brings Java closer to the metal without sacrificing safety.
For years, the rule was: threads are expensive. Don’t create too many. This forced us into complex asynchronous programming models when we had many concurrent I/O tasks (like handling web requests), because threads would block and waste resources.
Virtual threads change the fundamental economics. They are lightweight threads managed by the Java runtime, not the operating system. You can have millions of them.
The beautiful part is you don’t need to rewrite your code in a new async style. You write the simple, blocking code you already know.
void handleConcurrentRequests(List<Request> requests) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit 10,000 tasks? No problem.
for (Request req : requests) {
executor.submit(() -> {
// This is synchronous, blocking code.
String auth = validateAuth(req); // May block on I/O
DatabaseResponse data = fetchFromDatabase(auth); // May block on I/O
return processResponse(data);
});
}
} // All tasks complete here
}
Under the hood, when a virtual thread blocks on I/O, the JVM parks it and schedules another virtual thread to run. The underlying OS thread (the “carrier” thread) is never idle. This allows you to use the straightforward thread-per-request model even for massively concurrent applications. It simplifies everything.
Virtual threads give you scalability. Structured Concurrency gives you reliability. It treats a group of related concurrent tasks as a single unit of work. If one task fails or the parent is interrupted, all tasks in the scope are canceled. This prevents thread leaks and makes error handling cleaner.
Think of it like this: if you fork two subtasks to fetch user data and order data, you want them to succeed or fail together. If the user fetch fails, cancel the order fetch.
Response handleUserRequest(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork subtasks
Future<User> userFuture = scope.fork(() -> fetchUserFromService(userId));
Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrdersFromService(userId));
// Wait for both to complete or one to fail
scope.join();
scope.throwIfFailed(); // If any subtask failed, throw here
// Both succeeded, so get the results
User user = userFuture.resultNow();
List<Order> orders = ordersFuture.resultNow();
return new Response(user, orders);
}
// The scope is closed here, guaranteeing all subtasks are done.
}
This structure makes the flow of your concurrent code mirror the structure in your mind. The lifetime of the child tasks is confined to a clear block, which makes the code easier to reason about and debug.
Most modern CPUs have special instructions that can perform the same operation on multiple pieces of data at once. This is called Single Instruction, Multiple Data (SIMD). Think of adding eight integers to eight other integers in one CPU cycle.
The Vector API gives you a way to write platform-independent Java code that the JIT compiler can optimize into these SIMD instructions.
void vectorAddition(int[] a, int[] b, int[] result) {
var species = IntVector.SPECIES_PREFERRED;
int i = 0;
// Process array in chunks matching the CPU's vector width
for (; i < species.loopBound(a.length); i += species.length()) {
var va = IntVector.fromArray(species, a, i);
var vb = IntVector.fromArray(species, b, i);
var vc = va.add(vb);
vc.intoArray(result, i);
}
// Process any remaining elements in a scalar way
for (; i < a.length; i++) {
result[i] = a[i] + b[i];
}
}
For numerical computations in fields like scientific computing, financial modeling, or image processing, this can lead to significant speedups. You express your algorithm in a vector-aware way, and Java tries to run it as fast as the hardware allows.
The Stream API is powerful, but sometimes you need an operation that isn’t built-in, like grouping elements into batches or creating a sliding window over the data. Previously, you’d have to break out of the stream or use complex collectors.
Gatherers, introduced as a preview in Java 22, let you build custom intermediate stream operations. They are to Stream what collectors are to terminal operations, but for the middle of the pipeline.
Imagine you have a stream of items and you want to process them in fixed-size batches.
// Creating a custom gatherer to batch elements (simplified concept)
List<List<String>> batchedItems = items.stream()
.gather(Gatherers.windowFixed(5)) // Groups into lists of 5
.toList();
// Or a sliding window:
List<List<String>> slidingWindows = items.stream()
.gather(Gatherers.windowSliding(3)) // [a,b,c], [b,c,d], [c,d,e]...
.toList();
This opens up the Stream API to be extended for your domain-specific transformations while keeping the fluent, declarative style. You can build reusable, composable operations for complex data processing pipelines.
These features aren’t just academic. They address specific, long-standing frustrations. Records kill boilerplate. Sealed classes and pattern matching make your domain logic explicit and your code safer. Text blocks make complex strings manageable. Virtual threads let you write simple code that scales. Better NullPointerException messages save you time every single day.
You don’t have to use them all at once. Start by introducing a record for your next DTO. Try a pattern matching switch on an enum or a sealed type. Use a text block for that SQL query you’re about to write. Each step makes your code a little cleaner, a little more robust, and a little easier for the next developer—who might be you in six months—to understand.
Java is moving fast, and it’s moving in a direction that helps developers build solid systems. These features are the proof. They’re worth exploring and integrating into your production toolkit.