Java 17 to 21 — What's New for AI Developers
The Java 17-to-21 features that matter most for AI work: records, sealed classes, pattern matching, text blocks and virtual threads — each shown with a concrete AI use case.
On this page
You do not need every Java 21 feature to build AI applications, but a handful of them change how you write the code. This is the subset that actually shows up in Spring AI and LangChain4j work, each with the AI use case that makes it click.
Key Takeaways
- Records are the natural type for LLM structured output — one line, immutable, schema-derivable.
- Virtual threads make thousands of concurrent LLM calls cheap, and remove the main reason you used to reach for reactive programming.
- Pattern matching and sealed classes make handling the different shapes an AI response can take exhaustive and compiler-checked.
- Text blocks turn multi-line prompts from an unreadable mess of
\ninto something you can actually maintain.
Records
A record is an immutable data carrier declared in a single line. The compiler generates the
constructor, accessors, equals, hashCode and toString.
// The old way: 30 lines of boilerplate for four fields.
// The record way:
public record Recipe(String name, List<String> ingredients, int prepMinutes) {}This matters for AI because structured output is a record's home turf:
public record BugTriage(String summary, Priority priority, List<String> components) {
public enum Priority { LOW, MEDIUM, HIGH, CRITICAL }
}
// Spring AI derives a JSON schema from the record and parses the response into it:
BugTriage triage = chatClient.prompt().user(report).call().entity(BugTriage.class);You wrote no parser, no schema and no validation loop. The record is the contract. See structured output with Spring AI.
Compact constructors for validation
public record Temperature(double value) {
public Temperature {
// Runs before the field is assigned. A good place to reject the values
// a model might plausibly but wrongly produce.
if (value < 0.0 || value > 2.0) {
throw new IllegalArgumentException("temperature must be 0.0-2.0");
}
}
}Sealed classes and pattern matching
Sealed classes let you say "these are the only subtypes." Combined with pattern matching in
switch, the compiler forces you to handle every case — which is exactly what you want when an AI
operation can end several ways.
sealed interface AgentResult permits Answer, ToolRequest, NeedsClarification, Failure {}
record Answer(String text) implements AgentResult {}
record ToolRequest(String tool, Map<String, Object> args) implements AgentResult {}
record NeedsClarification(String question) implements AgentResult {}
record Failure(String reason) implements AgentResult {}
String handle(AgentResult result) {
// No default branch needed — the compiler knows these are exhaustive.
// Add a fifth result type later and this switch fails to compile until
// you handle it. That is the safety net an agent loop needs.
return switch (result) {
case Answer a -> a.text();
case ToolRequest t -> execute(t.tool(), t.args());
case NeedsClarification c -> ask(c.question());
case Failure f -> "Sorry: " + f.reason();
};
}Pattern matching for instanceof
Small, but you will use it constantly when inspecting loosely-typed model metadata:
Object raw = response.getMetadata().get("usage");
// Test and bind in one step — no separate cast.
if (raw instanceof Map<?, ?> usage) {
Object tokens = usage.get("total_tokens");
if (tokens instanceof Number n) {
recordTokens(n.longValue());
}
}Text blocks
Prompts are multi-line strings. Before text blocks they were a wall of \n and +. Now:
String systemPrompt = """
You are a support agent for the Acme billing product.
Answer only questions about Acme billing.
If a question is out of scope, say so and suggest contacting support.
Never invent prices or policy.
""";The indentation is stripped to the least-indented line, so it reads naturally in code and arrives clean at the model. Every prompt in this course uses text blocks for this reason.
Virtual threads — the big one
An AI request spends 99% of its time waiting for a remote model to respond. That is pure I/O. With platform (OS) threads, holding a thousand concurrent waiting requests means a thousand expensive OS threads and a large stack footprint. Virtual threads change the arithmetic completely.
// application.yml — one line turns Spring Boot's request handling onto
// virtual threads. Each blocking model call now parks a virtual thread
// instead of pinning a platform thread.
// spring.threads.virtual.enabled: true// Fan out 100 independent model calls concurrently, cheaply:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = questions.stream()
.map(q -> executor.submit(() -> chatClient.prompt().user(q).call().content()))
.toList();
for (Future<String> future : futures) {
System.out.println(future.get());
}
}Each of those 100 tasks blocks on a network call, and that is fine — a blocked virtual thread costs almost nothing. On platform threads you would need a carefully sized pool and would still bottleneck.
One caveat: pinning
A virtual thread that blocks inside a synchronized block cannot be unmounted — it "pins" a
platform thread, defeating the purpose. If you use synchronized around a call that blocks (a model
call, a database query), switch to a ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
public String guarded(String input) {
lock.lock();
try {
return chatClient.prompt().user(input).call().content(); // safe to block
} finally {
lock.unlock();
}
}Other useful additions
Stream.toList()(Java 16) — replaces.collect(Collectors.toList()). Small, but you type it constantly.- Enhanced
switchexpressions — return a value directly, no fall-through. SequencedCollection(Java 21) —getFirst()/getLast()on lists, handy for grabbing the most recent message in a conversation window.
What to actually adopt first
If you change three things in your Java style for AI work, make them:
- Records for every data shape — especially LLM inputs and outputs.
spring.threads.virtual.enabled: true— free concurrency for I/O-bound model calls.- Text blocks for every prompt — maintainability you will feel immediately.
Next
- Functional programming in Java —
streams and
CompletableFuture, which pair with virtual threads - Setting up Spring AI with OpenAI — where records and virtual threads first pay off
Frequently Asked Questions
Why does Java 21 matter specifically for AI applications?
Do I need Java 21, or is 17 enough?
Are records a good fit for LLM structured output?
What are virtual threads in simple terms?
Related tutorials
- Functional Programming in Java for AI PipelinesFunctional Java refreshed for AI work: streams for document pipelines, Optional for safe metadata access, and CompletableFuture for concurrent model calls — with practical examples.
- Reactive Programming with Project ReactorProject Reactor for AI developers: Mono, Flux, back-pressure and WebFlux — and the one place they are genuinely the right tool, streaming LLM tokens to a browser.
- Microservices Architecture Deep DiveMicroservices patterns that matter for AI systems: API gateway, circuit breakers around model calls, the saga pattern for agent workflows, and where an AI service fits in the topology.
- Containerization with Docker & KubernetesContainerize and deploy a Spring Boot AI application: a production Dockerfile with layered JARs, Kubernetes deployment with secrets for API keys, health probes and resource limits.