Java 17 Features (LTS)
Java 17 Features (LTS)
Java 17, released in September 2021, is the third Long-Term Support release, following Java 8 and Java 11. Unlike Java 11, which mostly polished existing APIs, Java 17 is where the language itself changed shape — sealed classes and records, both finalized in this release, give developers a genuinely new way to model data and restrict inheritance that simply did not exist in Java 8 or 11.
What Changed in Java 17?
| Feature | What It Adds |
|---|---|
| Sealed classes and interfaces | Restrict which classes or interfaces may extend or implement a type, using sealed and permits |
| Records | Compact, immutable data carriers with auto-generated constructor, accessors, equals(), hashCode(), and toString() |
Pattern matching for instanceof | Combines a type check and a cast into one expression, finalized in Java 16 |
| Text blocks | Multi-line string literals without escape-character clutter, finalized in Java 15 |
| Switch expressions | The -> arrow form of switch that returns a value, finalized in Java 14 |
| Strong encapsulation of JDK internals | Internal JDK APIs are no longer accessible by default, even with --illegal-access flags |
Pattern matching for switch | Preview only in Java 17 — matching on a sealed type's subtypes directly in a switch case |
| Foreign Function & Memory API | An incubating API for calling native code and managing off-heap memory without JNI |
Why Java 17 Mattered
A sealed interface's whole point is visible in one small diagram — the set of implementations is closed, named, and known at compile time, not left open to anything that happens to implement the interface later.
sealed interface Shape
permits Circle, Square, Rectangle
|
+----------+----------+----------+
| | | |
Circle Square Rectangle (nothing else is ever legal here)
No fourth implementation can compile against Shape from outside this permits list, which is exactly the guarantee a plain class hierarchy could never make.
Interview tip: if asked "what's the difference between sealed and final," the answer interviewers actually want is that final forbids all subclassing, while sealed allows a specific, named, finite set of subclasses — not "they're basically the same."
Modeling a fixed, closed set of related types used to mean either a plain class hierarchy anyone could extend, or a workaround like an enum with no room for per-case data.
1// File: BeforeJava17.java
2
3public class BeforeJava17 {
4
5 static class Shape {}
6 static class Circle extends Shape {
7 double radius;
8 Circle(double radius) { this.radius = radius; }
9 }
10 // Nothing here stops another class, anywhere in the codebase, from
11 // also extending Shape - the set of possible shapes is never closed
12
13 static double area(Shape shape) {
14 if (shape instanceof Circle) {
15 Circle c = (Circle) shape;
16 return Math.PI * c.radius * c.radius;
17 }
18 throw new IllegalStateException("Unknown shape");
19 }
20
21 public static void main(String[] args) {
22 Shape circle = new Circle(2.0);
23 System.out.println("Circle area: " + area(circle));
24 }
25}Output:
Circle area: 12.566370614359172
Sealed classes close that gap directly, and pattern matching for instanceof removes the separate explicit cast the old code needed.
1// File: AfterJava17.java
2
3public class AfterJava17 {
4
5 sealed interface Shape permits Circle {}
6 record Circle(double radius) implements Shape {}
7
8 static double area(Shape shape) {
9 if (shape instanceof Circle c) {
10 return Math.PI * c.radius() * c.radius();
11 }
12 throw new IllegalStateException("Unknown shape");
13 }
14
15 public static void main(String[] args) {
16 Shape circle = new Circle(2.0);
17 System.out.println("Circle area: " + area(circle));
18 }
19}Output:
Circle area: 12.566370614359172
Shape now has exactly one permitted implementation by declaration, not by convention, and Circle needs no hand-written constructor, accessor, or cast at all.
A Tour of Java 17's Core Features
Sealed Classes and Interfaces
A sealed type declares exactly which classes or interfaces are allowed to extend or implement it, using a permits clause — anything not listed simply cannot compile against it. Every permitted direct subtype must itself be declared final, sealed, or non-sealed, so the set of possibilities stays fully known at every level.
1// File: SealedShapeExample.java
2
3public class SealedShapeExample {
4
5 sealed interface Shape permits Circle, Square, Rectangle {}
6
7 record Circle(double radius) implements Shape {}
8 record Square(double side) implements Shape {}
9 record Rectangle(double width, double height) implements Shape {}
10
11 static double area(Shape shape) {
12 if (shape instanceof Circle c) {
13 return Math.PI * c.radius() * c.radius();
14 } else if (shape instanceof Square s) {
15 return s.side() * s.side();
16 } else if (shape instanceof Rectangle r) {
17 return r.width() * r.height();
18 }
19 throw new IllegalStateException("Unknown shape");
20 }
21
22 public static void main(String[] args) {
23 Shape circle = new Circle(2.0);
24 Shape square = new Square(3.0);
25
26 System.out.println("Circle area: " + area(circle));
27 System.out.println("Square area: " + area(square));
28 }
29}Output:
Circle area: 12.566370614359172
Square area: 9.0
Shape here can never gain a fourth implementation from outside this file without editing the permits clause itself, which is what makes sealing valuable for a fixed set of cases like shapes, payment outcomes, or order states — the exact modeling this article's real-world example builds on. On its own, sealing does not make the if/else chain above exhaustiveness-checked by the compiler; that specific guarantee comes from pattern matching for switch, which was still a preview feature in Java 17 and is covered where it becomes final in this series' Java 21 article.
Records
A record declares its components once, in the header, and the compiler generates a canonical constructor, an accessor method per component, and equals(), hashCode(), and toString() implementations based on those components — a compact constructor can still validate input before it is stored.
1// File: RecordBasicsExample.java
2
3public class RecordBasicsExample {
4
5 record Point(int x, int y) {
6 Point {
7 if (x < 0 || y < 0) {
8 throw new IllegalArgumentException("Coordinates must not be negative");
9 }
10 }
11 }
12
13 public static void main(String[] args) {
14 Point a = new Point(3, 4);
15 Point b = new Point(3, 4);
16
17 System.out.println("a: " + a);
18 System.out.println("a.x(): " + a.x());
19 System.out.println("a equals b: " + a.equals(b));
20
21 try {
22 new Point(-1, 5);
23 } catch (IllegalArgumentException e) {
24 System.out.println("Rejected: " + e.getMessage());
25 }
26 }
27}Output:
a: Point[x=3, y=4]
a.x(): 3
a equals b: true
Rejected: Coordinates must not be negative
Pattern Matching for instanceof
instanceof can now bind the matched value directly to a variable in the same expression, removing the separate cast that every earlier version required — already used throughout the examples above. The full mechanics, including scoping rules for the bound variable, are covered in this series' dedicated Pattern Matching article.
Text Blocks
Multi-line string literals, delimited with """, avoid the escape-character clutter of building something like a JSON payload or SQL query with + and \n. This series covers text blocks in full in its dedicated Text Blocks article.
Switch Expressions
The -> form of switch, which returns a value directly and requires no break, was finalized in Java 14 and remains unchanged in Java 17. The full syntax and rules around exhaustiveness for enums are covered in this series' dedicated Switch Expressions article.
Strong Encapsulation of JDK Internals
Starting in Java 17, internal JDK APIs — packages like sun.misc that were never meant for application use — are no longer accessible by reflection at all, even with the --illegal-access flag that previously offered an escape hatch. Any library still reaching into JDK internals needs to migrate to a supported public API before upgrading past Java 16.
Pattern Matching for switch (Preview) and the Foreign Function & Memory API (Incubator)
Two more features shipped in Java 17 but not as finished, stable APIs. Pattern matching for switch — writing a case Circle c -> branch directly against a sealed type — requires the --enable-preview flag in Java 17 and was not finalized until Java 21. The Foreign Function & Memory API, for calling native code and managing off-heap memory without JNI, shipped as an incubator module in Java 17, meaning its API was still expected to change before reaching a stable release. Neither is something to build production code against on Java 17 itself.
Real-World Example
A ticket-booking system needs to represent the outcome of a booking attempt as one of exactly three cases — confirmed, waitlisted, or failed — and handle each one distinctly, without an unrelated caller ever being able to invent a fourth outcome type.
1// File: BookingResult.java
2
3public sealed interface BookingResult
4 permits BookingResult.Confirmed, BookingResult.Waitlisted, BookingResult.Failed {
5
6 record Confirmed(String bookingId, int seatCount) implements BookingResult {}
7 record Waitlisted(int position) implements BookingResult {}
8 record Failed(String reason) implements BookingResult {}
9}1// File: BookingService.java
2
3public class BookingService {
4
5 public BookingResult book(int requestedSeats, int availableSeats) {
6 if (requestedSeats <= 0) {
7 return new BookingResult.Failed("Requested seat count must be positive");
8 }
9 if (requestedSeats <= availableSeats) {
10 String bookingId = "BMS-" + requestedSeats + "-" + availableSeats;
11 return new BookingResult.Confirmed(bookingId, requestedSeats);
12 }
13 int waitlistPosition = requestedSeats - availableSeats;
14 return new BookingResult.Waitlisted(waitlistPosition);
15 }
16}1// File: BookingResultDemo.java
2
3public class BookingResultDemo {
4
5 static String describe(BookingResult result) {
6 if (result instanceof BookingResult.Confirmed c) {
7 return "Confirmed as " + c.bookingId() + " for " + c.seatCount() + " seat(s)";
8 } else if (result instanceof BookingResult.Waitlisted w) {
9 return "Waitlisted at position " + w.position();
10 } else if (result instanceof BookingResult.Failed f) {
11 return "Failed: " + f.reason();
12 }
13 throw new IllegalStateException("Unknown booking result");
14 }
15
16 public static void main(String[] args) {
17 BookingService service = new BookingService();
18
19 BookingResult confirmed = service.book(2, 5);
20 BookingResult waitlisted = service.book(6, 5);
21 BookingResult failed = service.book(0, 5);
22
23 System.out.println(describe(confirmed));
24 System.out.println(describe(waitlisted));
25 System.out.println(describe(failed));
26 }
27}Output:
Confirmed as BMS-2-5 for 2 seat(s)
Waitlisted at position 1
Failed: Requested seat count must be positive
A mistake that appears often in fresher pull requests is modeling an outcome like this with a single class carrying a status enum plus a pile of optional fields — a bookingId that only makes sense when status == CONFIRMED, a position that only makes sense when status == WAITLISTED, and no compiler check tying the two together. Splitting each outcome into its own record under a sealed interface, exactly as BookingResult does here, makes an invalid combination like a Confirmed with a waitlist position impossible to construct in the first place, rather than merely undocumented.
Combining Java 17 Features With Each Other
Records implementing a sealed interface, exactly as BookingResult does above, is the core pattern Java 17 is built around — the sealed interface closes the set of cases, and each record supplies only the data that specific case actually needs. Pattern matching for instanceof is what makes reading those cases back out concise, binding the matched variable in the same line as the type check instead of a separate cast. Text blocks pair naturally with records used as simple DTOs, since a record's toString() output or a JSON payload built for it is often multi-line by nature.
Best Practices
Reach for a sealed interface with one record per case whenever a value can be exactly one of a small, known set of outcomes — a booking result, a payment result, a parse result — instead of a single class with a status flag and a pile of optional fields.
Use a record's compact constructor to validate input once, at construction time, rather than trusting every caller to check values before passing them in.
Keep records themselves free of business logic beyond simple derived accessors — a record represents data, and behavior that spans multiple records still belongs in a separate service class, exactly as BookingService does here.
Avoid pattern matching for switch in Java 17 production code, since it remains a preview feature requiring --enable-preview until Java 21 finalizes it.
Common Mistakes
Declaring a permitted subclass of a sealed type without one of final, sealed, or non-sealed does not compile — every direct subtype must pick exactly one, so the compiler always knows whether the hierarchy is still open at that point.
1// This does not compile - Circle must be declared final, sealed, or non-sealed
2sealed interface Shape permits Circle {}
3class Circle implements Shape {}Assuming a record's auto-generated equals() performs a deep comparison on an array-typed component is a second, easy-to-miss trap — arrays never override equals(), so a record's generated equals() compares them by reference, exactly like any other reference-typed component.
1// File: RecordArrayMistake.java
2import java.util.Arrays;
3
4public class RecordArrayMistake {
5
6 record Payload(String name, int[] values) {}
7
8 public static void main(String[] args) {
9 Payload a = new Payload("batch", new int[] {1, 2, 3});
10 Payload b = new Payload("batch", new int[] {1, 2, 3});
11
12 System.out.println("a equals b: " + a.equals(b));
13 System.out.println("Arrays.equals: " + Arrays.equals(a.values(), b.values()));
14 }
15}Output:
a equals b: false
Arrays.equals: true
Both arrays hold identical elements, but a.values() and b.values() are two distinct array objects, so the record's generated equals() reports them as unequal — a record with an array component needs a hand-written equals() if content-based comparison is actually required.
Interview Questions
Q1. What are the headline features Java 17 brought as an LTS release?
Sealed classes and records, both finalized in this release, are the two headline language features. Java 17 also carried forward pattern matching for instanceof, text blocks, and switch expressions from the non-LTS releases between 11 and 17, and strongly encapsulated JDK internal APIs. This is usually an opening question, so interviewers are mainly listening for whether you can distinguish what Java 17 actually finalized from what it merely carried forward.
Q2. What is a sealed class, and what problem does it solve?
A sealed class or interface uses sealed and a permits clause to declare exactly which other classes or interfaces may extend or implement it. It solves the problem of an "open" type hierarchy where any class, anywhere, can subclass a type never designed to be extended arbitrarily — useful for modeling a genuinely fixed set of cases, like a booking result or a payment outcome. The follow-up product-based interviewers ask is why this matters for exhaustiveness checking, which only pays off once pattern matching for switch is finalized in Java 21.
Q3. What are the modifier requirements for a class that directly extends a sealed class?
It must be declared exactly one of final (no further subclassing allowed), sealed (further restricted subclassing, with its own permits clause), or non-sealed (reopens unrestricted subclassing from that point on). Leaving off all three is a compile error, which is the exact detail service-based interviews check for since it's easy to get wrong from memory.
Q4. What does a Java record automatically generate, and what can you customize?
A record automatically generates a canonical constructor, a public accessor method per component, and equals(), hashCode(), and toString() implementations based on all components. A compact constructor can add validation without restating the parameter list, and additional non-canonical constructors are allowed as long as they eventually delegate to the canonical one. The nuance interviewers listen for is whether you know a record cannot declare additional instance fields beyond its components.
Q5. Why does comparing two records with an array component using equals() not behave the way you might expect?
Arrays in Java never override equals(), so comparing two array objects with ==-based equals() semantics compares references, not contents. A record's generated equals() does not special-case array components, so two records holding equal-content-but-different-instance arrays report as unequal unless the record's equals() is hand-written to use Arrays.equals() instead. This is a favorite product-company trick question precisely because it looks like it should just work.
Q6. Is pattern matching for switch available and stable in Java 17?
It is available only as a preview feature in Java 17, requiring the --enable-preview compiler and runtime flag, and its exact syntax was still subject to change. It was not finalized as a standard language feature until Java 21 — getting the version right here is exactly what separates a candidate who has actually used both releases from one reciting a feature list.
Q7. What happened to Java EE-related and other JDK-internal APIs by Java 17?
The Java EE modules removed in Java 11 stayed removed, and Java 17 went further by strongly encapsulating internal JDK APIs — packages never meant for application use became inaccessible by reflection entirely, even with the --illegal-access flag that had previously offered a workaround. Interviewers at companies running older codebases ask this specifically to gauge migration experience, not textbook knowledge.
FAQs
Is Java 17 an LTS release?
Yes, Java 17 is the third Long-Term Support release, following Java 8 and Java 11, and it received extended support well beyond the standard six-month release cycle.
Can a record implement an interface?
Yes. A record's implicit superclass is always java.lang.Record, but it can still implement any number of interfaces, including a sealed interface, exactly as Confirmed, Waitlisted, and Failed do in this article's real-world example.
Do records support additional, non-canonical constructors?
Yes, as long as every additional constructor eventually delegates to the canonical constructor via this(...), so the record's validation and field assignment logic stays in one place.
Is the permits clause always required on a sealed class?
No. If every permitted direct subtype is declared in the same source file as the sealed type, the compiler infers the permits clause automatically. It becomes required only once a permitted subtype lives in a separate file.
Can a sealed interface be implemented by a class in a different file?
Yes, as long as that class is in the same module as the sealed interface and is explicitly listed in the interface's permits clause — the clause cannot be inferred once the subtypes are spread across multiple files.
What's the difference between a sealed class and simply making a class final?
final prevents any subclassing at all. sealed allows subclassing, but only by an explicit, finite list of named classes or interfaces — useful when a type genuinely needs a small number of specific variants, rather than none at all.
Is the Foreign Function & Memory API ready to use in Java 17?
No, it shipped as an incubator module in Java 17, meaning its API surface was still expected to change in later releases and it was not intended for production use at that stage.
Summary
Java 17 is where the language itself gained new modeling tools — sealed classes close off a type hierarchy to a known, finite set of variants, and records eliminate the boilerplate of writing constructors, accessors, and equals()/hashCode()/toString() by hand for simple data carriers. Combined with pattern matching for instanceof, text blocks, and switch expressions carried over from earlier non-LTS releases, Java 17 is a substantially more expressive language than Java 11, even though its headline features arrived as an evolution rather than a single dramatic rewrite.
The habit worth carrying forward from this article's booking-result example is reaching for a sealed interface with one record per case whenever a value is genuinely one of a small, fixed set of outcomes, and remembering that a record's generated equals() still needs a hand-written override the moment an array component is involved.
What to Read Next
Learn what's new in the Java 21 long-term support release.