JDK 15 is not an LTS release — that designation belongs to JDK 11 and JDK 17. But JDK 15 (September 2020) occupies an important position in the Java feature timeline: it is where text blocks became a production API, where ZGC and Shenandoah left experimental status, and where sealed classes and records appeared in their second preview rounds — close enough to their final form that engineers could evaluate them seriously. Understanding JDK 15 helps explain why the JDK 17 LTS features look the way they do.
Text Blocks: Multi-line Strings Without Escape Hell
Text blocks (JEP 378) were finalized in JDK 15. They are multi-line string literals that use a triple-quote delimiter and handle indentation stripping automatically. The incidental indentation — the leading whitespace that aligns the text block with the surrounding code — is stripped by the compiler. The result is a string with only the intentional content, without leading whitespace artifacts from code alignment.
The use cases where text blocks eliminate real friction: SQL queries, JSON payloads for tests, HTML templates, and any multi-line string where the previous alternative was a stream of concatenated string literals with embedded newlines and escape sequences. Text blocks do not change the runtime type — the value is still a String. The compiler strips trailing whitespace from each line and normalizes line endings to \n by default.
// Java 15+ — text blocks; incidental leading whitespace is stripped at compile time
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
public class OrderRepository {
private static final Logger log = LoggerFactory.getLogger(OrderRepository.class);
// Before text blocks: fragile escape sequences, hard to read
private static final String QUERY_BEFORE =
"SELECT o.id, o.amount, c.name\n" +
"FROM orders o\n" +
"JOIN customers c ON c.id = o.customer_id\n" +
"WHERE o.status = ? AND o.created_at > ?\n" +
"ORDER BY o.created_at DESC";
// Text block: indentation stripped, preserves intent
private static final String QUERY = """
SELECT o.id, o.amount, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = ? AND o.created_at > ?
ORDER BY o.created_at DESC
"""; // closing delimiter position controls trailing newline
public void fetchOrders(Connection conn, String status) throws Exception {
try (var ps = conn.prepareStatement(QUERY)) {
ps.setString(1, status);
ps.setTimestamp(2, java.sql.Timestamp.valueOf(
java.time.LocalDateTime.now().minusDays(30)));
var rs = ps.executeQuery();
while (rs.next()) {
log.info("Order {}: ${} by {}", rs.getString(1), rs.getLong(2), rs.getString(3));
}
}
}
}Code language: Java (java)
Text blocks also support template methods: formatted(args) replaces String.format() for text blocks, and stripIndent() / translateEscapes() can be applied at runtime. The most common production use is exactly what the example shows: SQL and JSON embedded in Java code, where the readability improvement is immediate and the change is low-risk.
Sealed Classes (Preview): Bounded Hierarchies Take Shape
Sealed classes appeared in JDK 15 as a first preview (JEP 360) and in JDK 16 as a second preview before being finalized in JDK 17. In JDK 15 the API was essentially complete — sealed, permits, non-sealed, and final on permitted subclasses were all present. The preview period allowed real feedback on edge cases: sealed interfaces versus sealed classes, the interaction with the module system and package-private permits, and how they compose with pattern matching (which was also in preview).
The core concept is unchanged from what finalized in JDK 17: a sealed type declares its permitted subtypes explicitly. Each permitted type must be in the same package (or compilation unit), and must declare its relationship to the hierarchy as final, sealed, or non-sealed. The compiler enforces exhaustiveness in switch expressions over sealed types when all permitted subtypes have cases and there is no default. This combination of sealed types with pattern matching is the feature pair that unlocked algebraic data types in Java.
Records (Second Preview): Immutable Data Carriers
Records entered their second preview in JDK 15 (JEP 384) and were finalized in JDK 16. A record is a class whose primary purpose is to carry immutable data. The compiler generates: a canonical constructor matching the record components, accessor methods named identically to the components (no get prefix), and equals(), hashCode(), and toString() derived from all components.
// Java 16+ (preview available in 15) — records for immutable data carriers
// Compiler generates: constructor, accessors, equals, hashCode, toString
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.Objects;
// Record with validation in the compact constructor
public record OrderEvent(
String orderId,
String customerId,
long amountCents,
Instant occurredAt
) {
// Compact constructor — runs before field assignment; validates without repeating field names
public OrderEvent {
Objects.requireNonNull(orderId, "orderId required");
Objects.requireNonNull(customerId, "customerId required");
if (amountCents < 0) throw new IllegalArgumentException("amount must be non-negative");
Objects.requireNonNull(occurredAt, "occurredAt required");
}
// Custom method alongside generated ones
public double amountDollars() {
return amountCents / 100.0;
}
}
class OrderEventHandler {
private static final Logger log = LoggerFactory.getLogger(OrderEventHandler.class);
public void handle(OrderEvent event) {
// Accessors match component names: event.orderId(), not event.getOrderId()
log.info("Handling order {} for customer {}, amount ${:.2f}",
event.orderId(), event.customerId(), event.amountDollars());
// equals() and hashCode() are component-based — safe to use in maps and sets
process(event);
}
private void process(OrderEvent event) { /* implementation */ }
}Code language: Java (java)
Records cannot extend classes (only implement interfaces), cannot declare additional instance fields beyond the components, and cannot be abstract. These restrictions exist by design — they are what allows the compiler to generate correct equals() and hashCode() implementations automatically. Records are the natural building block for the permitted types in a sealed hierarchy, since they provide the value semantics that make sealed-type dispatch clean.
ZGC and Shenandoah: Production-Ready Low-Latency GC
ZGC (JEP 377) and Shenandoah (JEP 379) both graduated from experimental to production-ready in JDK 15. Both are low-latency collectors that do the majority of GC work concurrently with the application, targeting sub-millisecond pause times regardless of heap size. The graduation means the -XX:+UnlockExperimentalVMOptions flag is no longer required — just -XX:+UseZGC or -XX:+UseShenandoahGC.
ZGC and Shenandoah differ in their concurrency implementation and their strengths: ZGC uses a colored pointer scheme to track object relocation concurrently, making it effective for very large heaps (terabytes); Shenandoah uses a different approach (Brooks pointers / load barriers) and is generally stronger on mid-sized heaps. Both eliminate the GC pause unpredictability that G1 exhibits under allocation pressure spikes. For latency-sensitive workloads where 99th-percentile response time matters, either collector is worth evaluating against G1. The test is straightforward: switch the GC flag, run a load test, compare tail latency histograms.
Hidden Classes: Framework Internals Support
Hidden classes (JEP 371) are class file definitions that cannot be discovered by name, cannot be extended by other classes, and are unloaded as soon as their defining class loader is no longer referenced. They are the official replacement for Unsafe.defineAnonymousClass(), which was widely used by bytecode-generation frameworks (Hibernate, Spring proxies, lambda metafactory, invokedynamic implementations) to create temporary runtime classes that should not be visible to the rest of the application.
For most application developers, hidden classes are not directly relevant — you use them indirectly through the frameworks that generate proxies and lambdas. The relevance for understanding JDK 15 is that this completes the migration path away from sun.misc.Unsafe.defineAnonymousClass(), which was one of the internal JDK APIs that the module system was blocking. Framework authors adopting hidden classes in JDK 15+ produce code that works cleanly with strong encapsulation on JDK 16+.
Interview Questions
What happens when a text block contains a line that ends with a backslash?
A line-ending backslash in a text block acts as a line continuation — the newline after the backslash is suppressed, joining the current line with the next. This is one of two special escape sequences added specifically for text blocks: \ at end of line for line continuation, and \s to preserve trailing whitespace that would otherwise be stripped. These escapes address two common formatting needs: wrapping a long string across source lines without embedding a newline in the actual value, and preserving intentional trailing spaces in fixed-format output. Neither escape existed in regular string literals because they were not needed there.
How does the system behave when a record is used as a key in a HashMap and one of its components is a mutable object?
The generated hashCode() for a record is derived from all its components using Objects.hash(). If a component is a mutable object and that object is mutated after the record is inserted into a HashMap, the hash code of the record changes. The record is now in the wrong bucket in the map, and containsKey() and get() will fail to find it — the same problem as using a mutable object directly as a map key. Records do not enforce immutability of their components; they only prevent the components themselves from being reassigned. A record with a List component is a record with a reference to a mutable list. If you intend to use records as map keys, components should either be immutable types or deeply copied in the compact constructor.
What issues arise when switching from G1 to ZGC for a Java service with a 4GB heap that has high allocation rate but modest latency requirements?
ZGC will reduce pause times but increase CPU consumption for concurrent GC work. On a high-allocation workload, ZGC may consume 10-15% more CPU than G1 at the same throughput. If the service is already CPU-constrained, ZGC may reduce throughput or require additional CPU capacity. The second risk is that ZGC requires more headroom in the heap — because it relocates objects concurrently with the application, it needs free regions to relocate into. The rule of thumb is at least 20-25% heap headroom. A 4GB heap running at 3.5GB live set will trigger allocation stalls in ZGC that would not occur with G1. The correct evaluation approach is to test under production-level load, measuring not just pause times but throughput and CPU utilization, and to configure the heap with adequate headroom before comparing results.
Summary
JDK 15 delivered text blocks as a stable API that removes one of the most persistent friction points in Java code — multi-line strings with escape sequences. It delivered production-ready ZGC and Shenandoah, eliminating the experimental flag barrier for latency-sensitive deployments. It delivered the second preview of sealed classes and records in near-final form, allowing engineers to build real systems with them ahead of the JDK 17 LTS. For teams that understood JDK 15, JDK 17 was not a surprise — it was the formalization of a direction that had been visible for two releases.




