Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner6 min readUpdated
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 \n into 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 switch expressions — 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:

  1. Records for every data shape — especially LLM inputs and outputs.
  2. spring.threads.virtual.enabled: true — free concurrency for I/O-bound model calls.
  3. Text blocks for every prompt — maintainability you will feel immediately.

Next

Frequently Asked Questions

Why does Java 21 matter specifically for AI applications?
Two features carry most of the value. Virtual threads make it cheap to hold thousands of concurrent, mostly-idle LLM calls — the exact shape of an agentic workload, where a request spends almost all its time waiting on a remote model. Records give you zero-boilerplate immutable types that both Spring AI and LangChain4j use directly as structured-output targets.
Do I need Java 21, or is 17 enough?
Java 17 is the practical minimum because Spring Boot 3.x requires it. Java 21 is strongly recommended: it is a Long-Term Support release, virtual threads are finalised in it, and pattern matching for switch is complete. If you are starting a new AI project, start on 21.
Are records a good fit for LLM structured output?
They are ideal. A record declares an immutable data shape in one line, and both Spring AI (.entity(Recipe.class)) and LangChain4j derive a JSON schema from it automatically, then deserialise the model response into it. You get validation and type safety with no manual parsing.
What are virtual threads in simple terms?
Lightweight threads managed by the JVM rather than the operating system. A blocking call on a virtual thread parks the thread instead of pinning a scarce OS thread, so you can have millions of them. For I/O-bound work like calling an LLM API, this removes the need for reactive programming in most cases.

Related tutorials