Java has a reputation problem. Developers who last used it in 2010 remember verbose getters and setters, XML-heavy Spring configuration, and threads that consumed megabytes of memory each. They moved on to Python, Go, or Node.js and never looked back.
That Java is gone. The language that ships in 2026 has records, sealed classes, pattern matching, and virtual threads capable of handling millions of concurrent connections with blocking code. GraalVM compiles it to native binaries that start in under 100ms and use 50MB of memory. The six-month release cadence that Java adopted in 2018 has delivered more innovation in the last five years than in the prior decade.
This is an honest look at what modern Java actually is, for developers who dismissed it years ago and haven’t checked since.
The Release Cadence Changed Everything
The old Java model—a major release every three to five years—meant features arrived slowly and companies stayed on old versions for decades. Java 8 (2014) dominated production deployments for years because there was no compelling reason to upgrade and significant risk in doing so.
In 2018, Java shifted to a six-month feature release cadence with Long-Term Support releases every two years:
| Version |
Type |
Released |
Support Until |
| Java 17 |
LTS |
Sept 2021 |
Sept 2029 |
| Java 21 |
LTS |
Sept 2023 |
Sept 2031 |
| Java 25 |
LTS |
Sept 2025 |
Sept 2033 |
| Java 29 |
LTS (expected) |
Sept 2027 |
— |
Feature releases (Java 22, 23, 24, 26, 27, 28) ship every six months with new language previews. Most production shops upgrade only on LTS boundaries: 17 → 21 → 25. The previews let the community evaluate features before they stabilize in an LTS.
The practical effect: Java 21 (current stable LTS) contains years of accumulated improvements that most developers who left the ecosystem haven’t seen.
Records: Immutable Data Without the Boilerplate
Before records, a simple data class in Java required dozens of lines:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Java 8: a "simple" data class
public class User {
private final String name;
private final String email;
private final UserRole role;
public User(String name, String email, UserRole role) {
this.name = name;
this.email = email;
this.role = role;
}
public String getName() { return name; }
public String getEmail() { return email; }
public UserRole getRole() { return role; }
@Override
public boolean equals(Object o) { /* 10 lines */ }
@Override
public int hashCode() { /* 5 lines */ }
@Override
public String toString() { /* 3 lines */ }
}
|
Records (finalized in Java 16, production-ready in 17) collapse this to one line:
1
2
|
// Java 17+
public record User(String name, String email, UserRole role) {}
|
You get a constructor, accessor methods (user.name(), user.email()), equals, hashCode, and toString for free. Records are immutable by design—their components are final.
You can add methods and custom validation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public record User(String name, String email, UserRole role) {
// Compact constructor for validation
public User {
Objects.requireNonNull(name, "name required");
if (!email.contains("@")) throw new IllegalArgumentException("invalid email");
name = name.strip(); // normalize in the compact constructor
}
public boolean isAdmin() {
return role == UserRole.ADMIN;
}
public User withRole(UserRole newRole) {
return new User(name, email, newRole); // "wither" pattern
}
}
|
Records work naturally with JSON serialization (Jackson serializes them out of the box), database mapping (Spring Data JPA projections), and pattern matching.
Sealed Classes: Controlled Inheritance
Sealed classes define a fixed, exhaustive set of subtypes. This is the key to making pattern matching safe:
1
2
3
4
5
6
|
// The compiler knows these are ALL the possible shapes
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
|
The payoff is exhaustiveness checking in switch expressions:
1
2
3
4
5
6
7
8
|
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — compiler verifies all cases are covered
};
}
|
Add a new Pentagon to Shape without updating area(), and the code won’t compile. This is the kind of exhaustiveness guarantee you’d get from Haskell or Rust’s enums, now in Java.
Sealed classes work for more complex hierarchies too:
1
2
3
4
5
6
|
public sealed interface ApiResponse<T>
permits ApiResponse.Success, ApiResponse.Error, ApiResponse.Loading {}
public record Success<T>(T data) implements ApiResponse<T> {}
public record Error<T>(String message, int code) implements ApiResponse<T> {}
public record Loading<T>() implements ApiResponse<T> {}
|
Pattern Matching
Pattern matching lets you test the type of a value and bind it to a variable in one step.
Pattern Matching for instanceof (Java 17)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After
if (obj instanceof String s) {
System.out.println(s.length());
}
// With guards
if (obj instanceof String s && s.length() > 10) {
System.out.println("Long string: " + s);
}
|
Pattern Matching for switch (Java 21)
Switch expressions now accept type patterns with guards:
1
2
3
4
5
6
7
8
9
10
11
|
String describe(Object obj) {
return switch (obj) {
case Integer i when i > 0 -> "positive int: " + i;
case Integer i when i < 0 -> "negative int: " + i;
case Integer _ -> "zero";
case String s when s.isEmpty() -> "empty string";
case String s -> "string of length " + s.length();
case null -> "null";
default -> "something else: " + obj.getClass().getSimpleName();
};
}
|
Record Patterns (Java 21)
Destructure records inline within patterns:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Nested destructuring
String describeShape(Object shape) {
return switch (shape) {
case Circle(Point(int x, int y), double r) ->
"Circle centered at (%d, %d) with radius %.1f".formatted(x, y, r);
case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
"Line from (%d,%d) to (%d,%d)".formatted(x1, y1, x2, y2);
default -> "unknown shape";
};
}
|
Combined, sealed classes + records + pattern matching give you algebraic data types with exhaustiveness checking—a pattern that was previously available only in functional languages.
Virtual Threads: The Biggest Change in 20 Years
Project Loom’s virtual threads, finalized in Java 21, solve the core scaling problem of Java web services without requiring reactive programming.
The Problem They Solve
Traditional Java web servers assign one OS thread per request. OS threads cost about 1 MB of stack memory each. A server with 8 GB of heap can handle roughly 8,000 concurrent threads before running out of memory—which means 8,000 concurrent requests maximum.
The reactive alternative (Spring WebFlux, Vert.x) solves this with non-blocking I/O and callback-based code, but it’s cognitively expensive. You trade readable blocking code for Mono/Flux chains that are hard to debug and impossible to use with blocking libraries.
Virtual threads take a third path:
1
2
3
4
5
6
7
8
9
10
11
12
|
// Virtual thread executor — creates one VT per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// This looks like blocking code
// But when it blocks on I/O, the VT is unmounted from its carrier thread
// The carrier thread is freed to run other VTs while this one waits
var result = database.query("SELECT * FROM users WHERE id = ?", i);
processResult(result);
});
}
}
|
Virtual threads cost ~2 KB instead of ~1 MB. The JVM multiplexes them onto a small pool of OS “carrier” threads. When a virtual thread blocks on I/O, the JVM parks it and runs another virtual thread on that carrier. The result: millions of concurrent blocking operations on a handful of OS threads, with code that reads as straightforwardly sequential.
Using Virtual Threads with Spring Boot
1
2
|
# application.properties — one line enables virtual threads throughout
spring.threads.virtual.enabled=true
|
With this set, Spring Boot uses virtual threads for all request handling. Your @RestController methods run on virtual threads. JdbcTemplate calls, RestTemplate calls, file I/O—all become VT-compatible. You get reactive-level concurrency with synchronous code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@RestController
@RequestMapping("/api")
public class OrderController {
private final OrderService orderService;
private final InventoryClient inventoryClient;
@GetMapping("/orders/{id}")
public OrderDetail getOrder(@PathVariable Long id) {
// Both of these block, but on virtual threads
// They unmount while waiting for responses, freeing carriers
var order = orderService.findById(id); // DB call
var stock = inventoryClient.checkStock(id); // HTTP call
return new OrderDetail(order, stock);
}
}
|
Structured Concurrency
For coordinating multiple concurrent operations, Java 21 adds structured concurrency:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
OrderDetail fetchOrderWithDetails(Long orderId) throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork both tasks
var orderFuture = scope.fork(() -> orderService.findById(orderId));
var inventoryFuture = scope.fork(() -> inventoryClient.checkStock(orderId));
scope.join(); // Wait for both
scope.throwIfFailed(); // Propagate any failure
return new OrderDetail(
orderFuture.resultNow(),
inventoryFuture.resultNow()
);
}
// If either task fails, the other is cancelled automatically
// No orphaned threads, no resource leaks
}
|
If inventoryClient.checkStock throws, orderService.findById is cancelled immediately. The scope boundary makes concurrency lifetime explicit and deterministic.
Text Blocks
Multiline strings without escape sequences:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Java 8: painful
String sql = "SELECT u.name, u.email, o.total\n" +
"FROM users u\n" +
"JOIN orders o ON o.user_id = u.id\n" +
"WHERE u.created_at > ?\n" +
"ORDER BY o.total DESC";
// Java 17+: readable
String sql = """
SELECT u.name, u.email, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > ?
ORDER BY o.total DESC
""";
String json = """
{
"service": "api",
"version": "%s",
"environment": "%s"
}
""".formatted(version, env);
|
The closing """ determines the indentation stripping—content is de-indented to the minimum indentation level.
GraalVM Native Image
GraalVM’s native-image tool compiles Java bytecode ahead-of-time to a native binary:
1
2
3
4
5
6
|
# Build a native executable
./mvnw -Pnative native:compile
# Run it
./target/my-service
# Startup: ~60ms Memory: ~65MB
|
Compare to the JVM:
1
2
|
java -jar target/my-service.jar
# Startup: ~2.5s Memory: ~280MB
|
|
JVM |
Native Image |
| Startup time |
2–5 seconds |
50–150ms |
| Memory footprint |
200–500 MB |
50–100 MB |
| Peak throughput |
100% |
~85% |
| Build time |
Seconds |
5–15 minutes |
The trade-off: native images are statically analyzed at build time. Features that rely on runtime reflection or dynamic class loading (some older frameworks, runtime bytecode generation) require explicit configuration hints. Spring Boot 3.x handles this automatically for its own components.
Where native image wins:
- AWS Lambda / serverless (cold start dominates cost)
- CLI tools distributed to users (small binary, instant start)
- Container workloads with aggressive autoscaling
- Memory-constrained environments
Where the JVM wins:
- Long-running services (JIT compilation outperforms AOT over time)
- Applications using frameworks not yet native-image-compatible
- When build time is a bottleneck
Spring Boot 3.x has first-class native image support. Build configuration is one Maven profile flag. For a standard CRUD service, Spring Boot native just works.
Maven remains the default for large enterprises—XML configuration is verbose but explicit, and the central repository model is well-understood. Maven 4.0 requires JDK 17+ and improves build performance significantly.
Gradle is preferred in cloud-native shops for incremental builds that can be 10–100x faster than Maven for large projects. Uses Groovy or Kotlin DSL. The Kotlin DSL is now the recommended choice for new projects.
Version Management
SDKMAN! is the standard for managing JDK versions alongside other JVM ecosystem tools:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Install
curl -s "https://get.sdkman.io" | bash
# Install a JDK
sdk install java 21.0.5-tem # Eclipse Temurin
sdk install java 25.0.2-oracle # Oracle JDK
sdk install java 21.0.2-graalce # GraalVM Community
# Switch versions
sdk use java 21.0.5-tem
# Per-project version
sdk env init # Creates .sdkmanrc
sdk env # Activates version from .sdkmanrc
|
SDKMAN also manages Maven, Gradle, Spring Boot CLI, and JBang from the same tool.
JBang: Java as a Scripting Language
JBang transforms Java into a scripting language—no project setup, no build files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.0
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.*;
import java.net.URI;
void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://api.github.com/repos/openjdk/jdk"))
.header("Accept", "application/vnd.github.v3+json")
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var mapper = new ObjectMapper();
var repo = mapper.readTree(response.body());
System.out.println("Stars: " + repo.get("stargazers_count").asInt());
}
|
1
|
jbang github-stars.java
|
JBang downloads dependencies, compiles, and runs. Combine with Java 25’s unnamed classes (void main() without a class declaration) and Java scripting is genuinely usable.
Spring Boot 3.x
Spring Boot 3 requires Java 17 minimum, targets Java 21 for new projects, and has native image support built in.
Observability
Spring Boot 3 unified observability under Micrometer:
1
2
3
4
5
6
7
8
9
10
|
@Service
public class PaymentService {
// @Observed automatically creates traces, metrics, and logs
@Observed(name = "payment.process", contextualName = "processPayment")
public PaymentResult processPayment(PaymentRequest request) {
// Span automatically opened, latency tracked, errors recorded
return paymentGateway.charge(request);
}
}
|
1
2
3
4
|
# application.properties
management.tracing.sampling.probability=1.0
management.zipkin.tracing.endpoint=http://zipkin:9411/api/v2/spans
management.metrics.export.prometheus.enabled=true
|
With one annotation and a few properties, you get distributed tracing, Prometheus metrics, and structured logging with trace IDs attached automatically.
Spring Data
Spring Data 3.x modernized its interfaces with Java 21 features:
1
2
3
4
5
6
7
8
9
10
11
|
public interface UserRepository extends JpaRepository<User, Long> {
// Derived queries work with records
List<User> findByRole(UserRole role);
// Return records directly from queries
@Query("SELECT new com.example.UserSummary(u.name, u.email) FROM User u WHERE u.active = true")
List<UserSummary> findActiveSummaries();
}
// UserSummary is a record — Spring Data maps to it automatically
public record UserSummary(String name, String email) {}
|
A Complete Modern Java 21 Service
Here’s what a realistic modern Java service looks like—a user lookup endpoint using records, sealed types, virtual threads, and pattern matching:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
// Domain model
public record User(Long id, String name, String email, UserRole role) {}
public enum UserRole { ADMIN, VIEWER, GUEST }
// Type-safe response envelope using sealed interface
public sealed interface ServiceResult<T>
permits ServiceResult.Found, ServiceResult.NotFound, ServiceResult.Error {
record Found<T>(T value) implements ServiceResult<T> {}
record NotFound<T>(String message) implements ServiceResult<T> {}
record Error<T>(String cause, int httpStatus) implements ServiceResult<T> {}
}
// Service
@Service
public class UserService {
private final UserRepository repo;
private final ExternalProfileClient profileClient;
@Autowired
public UserService(UserRepository repo, ExternalProfileClient profileClient) {
this.repo = repo;
this.profileClient = profileClient;
}
public ServiceResult<User> findUser(Long id) {
return repo.findById(id)
.map(u -> (ServiceResult<User>) new ServiceResult.Found<>(u))
.orElse(new ServiceResult.NotFound<>("User %d not found".formatted(id)));
}
}
// Controller
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
// Pattern matching on sealed result type — exhaustive, no default needed
return switch (userService.findUser(id)) {
case ServiceResult.Found<User>(var user) ->
ResponseEntity.ok(user);
case ServiceResult.NotFound<User>(var msg) ->
ResponseEntity.status(404).body(Map.of("error", msg));
case ServiceResult.Error<User>(var cause, var status) ->
ResponseEntity.status(status).body(Map.of("error", cause));
};
}
}
|
1
2
3
4
|
// application.properties
spring.threads.virtual.enabled=true // Virtual threads for all requests
spring.application.name=user-service
management.tracing.sampling.probability=1.0
|
This is concise, type-safe, handles all error cases explicitly, and scales to millions of concurrent requests with blocking code on virtual threads. There’s no callback hell, no reactive operators, no boilerplate.
GC Options for Production
Java’s garbage collectors have matured significantly:
| GC |
Pause Time |
Memory Overhead |
Best For |
| G1GC (default) |
10–20ms avg |
Baseline |
Most workloads |
| ZGC |
<1ms (p99.9) |
+15–30% |
Ultra-low-latency |
| Shenandoah |
<10ms |
+10–20% |
Mid-latency requirements |
| Generational ZGC |
<1ms |
+15% (improved from original ZGC) |
New recommended low-latency choice |
For most Spring Boot services: G1GC with sensible heap sizing. For latency-sensitive services (payment processing, trading systems): Generational ZGC.
1
2
3
4
5
6
7
|
# Recommended JVM flags for containerized microservices
java \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+ExitOnOutOfMemoryError \
-XX:+UseContainerSupport \
-jar service.jar
|
UseContainerSupport (default since Java 11) makes the JVM respect cgroup memory limits rather than sizing the heap to the host’s total RAM—critical for Kubernetes deployments.
When to Choose Java
Java makes sense when:
-
Long-term maintenance is the primary concern. Static typing, explicit interfaces, and a mature IDE ecosystem make 10-year-old Java code understandable. The investment in type safety pays dividends over a decade.
-
The team is already Java/JVM-focused. The ecosystem is genuinely large—Spring, JPA, Kafka clients, metrics, tracing, security libraries all battle-tested.
-
Throughput and latency requirements are serious. Virtual threads + JIT compilation + ZGC is a competitive stack for high-throughput services.
-
You need GraalVM native image for serverless. Sub-100ms cold starts make Java viable for Lambda workloads where it historically lost to Python or Go.
-
The domain is complex. Pattern matching, sealed types, and the type system help model complex business domains accurately.
Go is the better choice for infrastructure tooling and simple services that benefit from a small single binary. Python wins for data science and ML workloads. Node.js makes sense for JavaScript-heavy teams or real-time streaming. But for backend services that will live for years, be maintained by teams, and need to handle serious load—Java in 2026 is a strong choice.
The Quick Version
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// Records replace POJOs
record User(String name, String email) {}
// Sealed classes enable exhaustive switching
sealed interface Result<T> permits Result.Ok, Result.Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String msg) implements Result<T> {}
// Pattern matching on the sealed type
String handle(Result<User> r) {
return switch (r) {
case Ok<User>(var u) -> "Hello, " + u.name();
case Err<User>(var m) -> "Error: " + m;
};
}
// Virtual threads: millions of blocking calls, no reactive ceremony
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000)
.forEach(i -> exec.submit(() -> processBlocking(i)));
}
|
Java stopped being the language you use because you have to. It’s now a language you might choose.
Comments