Java 9 Features with Examples: Practical Guidance from Real Projects

When I onboard teams onto a mature Java codebase, I still see Java 9 features underused. That’s a problem because these features weren’t just “nice to have” in 2017—they quietly changed how we design APIs, structure services, and reason about performance. The gap isn’t ignorance; it’s habit. Most teams moved from Java 8 to newer LTS releases without ever revisiting the Java 9 features that made that leap possible. In my experience, revisiting Java 9 is one of the fastest ways to make your code cleaner, safer, and more maintainable—even if you are already on Java 17 or 21. In this guide, I’ll walk you through the core Java 9 features with concrete, runnable examples and the practical reasoning I use when deciding whether to adopt them. You’ll learn how to create immutable collections without boilerplate, interactively explore APIs with JShell, build stream pipelines that express intent, design interfaces with private helpers, handle multi-resolution images, modularize your application, improve process handling, and use the HTTP/2 client. I’ll also call out common mistakes and when you should not use a feature. Think of this as the Java 9 knowledge you wish you had when you upgraded.

Improved Javadoc and Why It Matters in Daily Work

I remember the days when I spent more time searching for documentation than reading it. Java 9’s documentation overhaul didn’t just add a search bar; it reorganized the content and made it easier to discover module ownership and API context. That sounds minor, but it changes how you reason about dependencies. When you know a class lives in a specific module, you can avoid dragging in the wrong dependencies or accidentally exposing internals.

Practical impact:

  • Faster onboarding: new team members can inspect JDK module boundaries quickly.
  • Cleaner architectures: you can identify which APIs are intended for public use.
  • Better build diagnostics: module-aware Javadoc helps you align your code with the platform module system.

If you maintain internal libraries, you should mirror this style: add module-level documentation and keep interfaces tight. I often use a “library guide” section in my docs that mirrors the JDK approach: “Here is the module, here are public entry points, and here is how you should consume it.” The insight is simple: documentation is not just for humans; it’s for architectural alignment.

How I apply this in real projects

When a library grows, it tends to accumulate accidental public API: classes intended for internal use get referenced by other modules, tests, or downstream teams. Once those dependencies exist, you’re stuck supporting them. Module-aware docs make it harder to accidentally treat internal packages as public.

I like to place an explicit section in my docs:

  • Module Name: billing.core
  • Exports: com.acme.billing.api
  • Not for public use: com.acme.billing.internal

That single page stops a surprising amount of architectural drift.

Factory Methods for Collections: Immutable by Default

Java 9 added static factory methods like List.of, Set.of, and Map.of, and I consider them one of the most immediately useful changes. The goal is to reduce boilerplate and push immutable collections as the default. That reduces accidental mutation bugs, which are still one of the most common sources of defects in business systems.

Why I use them

  • Less code: no new ArrayList() plus multiple add calls.
  • Stronger intent: immutable collections make it clear you’re sharing data safely.
  • Safer APIs: callers can’t mutate your internal state.

Examples

import java.util.List;

import java.util.Map;

import java.util.Set;

public class ImmutableCollectionsDemo {

public static void main(String[] args) {

List regions = List.of("North America", "Europe", "Asia");

Set roles = Set.of("ADMIN", "EDITOR", "VIEWER");

Map statusCodes = Map.of(

200, "OK",

404, "Not Found",

500, "Server Error"

);

System.out.println(regions);

System.out.println(roles);

System.out.println(statusCodes);

}

}

These collections are immutable. If you attempt regions.add("South America"), you’ll get an UnsupportedOperationException. That’s a good thing—immutability forces you to model changes explicitly.

Common mistakes

  • Assuming order: Set.of does not guarantee order. If you need order, use List.of or LinkedHashSet explicitly.
  • Allowing nulls: these factory methods throw NullPointerException if any element is null. That’s deliberate to prevent null-heavy data structures.
  • Exceeding Map.of limit: Map.of supports up to 10 key-value pairs. For larger maps, use Map.ofEntries.

When not to use

If you need frequent updates or large incremental builds, these methods are not ideal. In those cases, build with a mutable collection and then wrap it with Collections.unmodifiableList or List.copyOf once you’re done.

Practical pattern: defensive copying

I use List.copyOf and Set.copyOf as a guardrail when accepting external collections. It gives me an immutable snapshot and protects against caller mutation.

public class CustomerProfile {

private final List tags;

public CustomerProfile(List tags) {

// defensively copy and freeze

this.tags = List.copyOf(tags);

}

public List getTags() {

return tags;

}

}

Performance notes

Creating immutable collections via List.of often creates compact internal implementations. For small lists, it’s faster and uses less memory than allocating a mutable list plus adds. For very large datasets, I still build mutably first, then freeze with List.copyOf, because repeated of calls are not designed for large iterative builds.

JShell: Your Fastest Feedback Loop

JShell is a REPL for Java. I use it as my “scratchpad” when I want to understand an API or validate a small algorithm before I commit it to a codebase. It’s the same reason Python developers love notebooks: you get immediate feedback.

Why JShell changes behavior

The old workflow was:

  • Create a class
  • Add a main method
  • Compile and run

That’s friction. With JShell, I type a line and see the result in seconds. It makes micro-experiments feel effortless.

Examples

jshell> int[] numbers = {4, 8, 15, 16, 23, 42};

jshell> java.util.Arrays.stream(numbers).average();

$1 ==> OptionalDouble[18.0]

That may look small, but over a day it saves minutes. Over a year, it changes how you learn APIs.

Real-world scenario

I often explore date-time conversions in JShell because java.time has many factory methods. Instead of running a full program, I can confirm a formatting pattern quickly:

jshell> java.time.LocalDate.parse("2026-01-28").getDayOfWeek();

$2 ==> WEDNESDAY

When not to use

JShell is not a replacement for unit tests. It’s a quick experiment tool, not a validation framework. Once the logic matters, it belongs in a test.

Practical JShell workflows I recommend

  • Exploratory parsing: try JSON parsing, CSV splits, or regexes before coding them.
  • Performance sanity checks: do quick micro-iterations to see if a data structure is too slow.
  • Library exploration: if you’re evaluating a new library, JShell is a low-commitment way to test it.

Common pitfall

JShell does not automatically save your history across sessions unless you configure it. I keep a small “scratchpad” file with commands I regularly reuse, so I can paste them into JShell quickly.

Stream API Improvements: Expressing Intent Clearly

Java 8 streams already made transformations more declarative. Java 9 refined the API with methods that make your intent clearer and reduce boilerplate. The new methods I use most are takeWhile, dropWhile, ofNullable, and the new iterate overload.

takeWhile and dropWhile

These methods let you process a stream until a condition fails (take) or skip elements until a condition is false (drop). They’re perfect for ordered data.

import java.util.List;

public class StreamTakeDropDemo {

public static void main(String[] args) {

List values = List.of(2, 4, 6, 7, 8, 10);

// Take while values are even

values.stream()

.takeWhile(v -> v % 2 == 0)

.forEach(System.out::println); // 2, 4, 6

// Drop while values are even

values.stream()

.dropWhile(v -> v % 2 == 0)

.forEach(System.out::println); // 7, 8, 10

}

}

ofNullable

This method is perfect for integrating nullable values into stream pipelines.

import java.util.stream.Stream;

public class StreamOfNullableDemo {

public static void main(String[] args) {

String region = null;

Stream.ofNullable(region)

.map(String::toUpperCase)

.forEach(System.out::println); // Prints nothing

region = "europe";

Stream.ofNullable(region)

.map(String::toUpperCase)

.forEach(System.out::println); // EUROPE

}

}

iterate with a predicate

This avoids manual break conditions in loops.

import java.util.stream.Stream;

public class StreamIterateDemo {

public static void main(String[] args) {

Stream.iterate(1, n -> n n + 1)

.forEach(System.out::println);

}

}

When not to use

If you need to stop based on non-ordered data, takeWhile and dropWhile won’t work as expected. These methods depend on encounter order. If order is irrelevant, use filter with a predicate instead.

Deeper practical example: pagination with takeWhile

Imagine you’re consuming a paginated API and want to stop when a page is empty. I use iterate with a predicate to keep the pipeline clean.

import java.util.List;

import java.util.stream.Stream;

public class PaginationStreamDemo {

static List fetchPage(int page) {

if (page > 3) return List.of();

return List.of("item-" + page + "-1", "item-" + page + "-2");

}

public static void main(String[] args) {

Stream.iterate(1, page -> page page + 1)

.map(PaginationStreamDemo::fetchPage)

.takeWhile(pageItems -> !pageItems.isEmpty())

.flatMap(List::stream)

.forEach(System.out::println);

}

}

This is a clean expression of “fetch until empty.” In pre-Java 9 code, you’d likely have an external loop with a break.

Performance considerations

takeWhile and dropWhile can short-circuit, which is a big win for large datasets. But they only short-circuit on ordered streams. On parallel streams, the behavior is still correct but can be less intuitive, so I only use them with sequential streams unless I have a good reason.

Private Methods in Interfaces: Cleaner, DRY Interfaces

Java 8 introduced default methods to interfaces, which was a big shift. But it left a gap: you couldn’t factor out shared logic without repeating code in multiple default methods. Java 9 fixed that with private methods in interfaces.

I use this to keep interface APIs small and to avoid forcing implementers to inherit messy code. Private methods let you build reusable logic inside the interface itself.

Example

public interface InvoiceFormatter {

default String formatForEmail(String invoiceId) {

return header() + "Invoice: " + invoiceId + footer();

}

default String formatForPdf(String invoiceId) {

return header() + "PDF Invoice: " + invoiceId + footer();

}

private String header() {

return "=== Company Billing ===\n";

}

private String footer() {

return "\n=== End ===";

}

}

Common mistakes

  • Overloading interfaces: just because you can add logic doesn’t mean you should. Keep interfaces focused.
  • Leaking behavior: private methods are great for small helpers, but heavy logic belongs in an abstract class or a shared component.

When not to use

If your interface is intended for multiple distinct implementations with different behavior, centralizing logic can become a constraint. In those cases, keep defaults minimal or avoid them.

Practical tip

Keep private interface methods short and predictable. If a private method grows large, that’s a signal it belongs in a utility class or abstract base class. I use a rough heuristic: if the method contains complex branching or external dependencies, I move it out.

Multi-Resolution Image API: Working With Modern Displays

High-DPI screens are the norm in 2026, and Java 9’s multi-resolution image API helps you serve the right image for each display density. It’s part of the AWT imaging packages and allows you to package multiple versions of an image in a single object.

I’ve used this for desktop apps where icons looked blurry on high-DPI screens. With multi-resolution images, you supply several sizes and let the system pick the best match.

Example

import java.awt.Image;

import java.awt.image.BaseMultiResolutionImage;

import javax.swing.ImageIcon;

import javax.swing.JFrame;

import javax.swing.JLabel;

public class MultiResolutionImageDemo {

public static void main(String[] args) {

Image img16 = new ImageIcon("icon16.png").getImage();

Image img32 = new ImageIcon("icon32.png").getImage();

Image img64 = new ImageIcon("icon64.png").getImage();

Image multi = new BaseMultiResolutionImage(img16, img32, img64);

JFrame frame = new JFrame("HiDPI Demo");

frame.setDefaultCloseOperation(JFrame.EXITONCLOSE);

frame.add(new JLabel(new ImageIcon(multi)));

frame.pack();

frame.setVisible(true);

}

}

Performance considerations

Loading multiple image sizes costs memory. For a small icon set, it’s negligible. For large image catalogs, you should lazily load variants or cache only common sizes. I’ve seen memory spikes if you eagerly load dozens of high-res images in a single window.

When not to use

If your application is server-side or runs headless, you don’t need it. This is a UI feature.

Practical scenario: icon packs

If you ship a desktop tool with 100+ icons, I recommend grouping image sizes and only loading the needed sizes at startup. For example, load 16 and 32 for normal UI, then load 64 only for high-DPI or zoomed contexts. That avoids unnecessary memory use.

The Java Platform Module System: Strong Encapsulation

The module system (Project Jigsaw) is the biggest conceptual shift in Java 9. It lets you explicitly declare which packages your module exports and which other modules it depends on. I think of it like a manifest for your code: it makes architecture explicit instead of implicit.

Why it matters

  • Encapsulation: internal packages can stay truly internal.
  • Smaller runtime: you can build a custom runtime with only needed modules.
  • Clear dependencies: no accidental classpath “mystery” dependencies.

Minimal example

Suppose we have a module called billing.core.

module-info.java:

module billing.core {

exports com.acme.billing.api;

requires java.sql;

}

This says:

  • Only com.acme.billing.api is accessible to other modules.
  • The module depends on java.sql.

Simple module structure

project-root/

src/

billing.core/

module-info.java

com/acme/billing/api/InvoiceService.java

com/acme/billing/internal/InvoiceRepository.java

Here’s the key: com.acme.billing.internal is not exported. Other modules cannot access it even if they know the package name.

Common mistakes

  • Exporting too much: if you export internal packages, you lose the encapsulation benefit.
  • Using unnamed modules: if your build tooling runs on the classpath without modules, you lose enforcement. I recommend starting with “automatic modules” and then tightening.

When not to use

If you’re working on small scripts or simple utilities, the module system may feel like overhead. I still recommend it for libraries or long-lived services because it prevents architectural drift.

Practical migration strategy

Most teams can’t flip a big codebase to modules overnight. Here’s how I usually approach it:

  • Identify stable boundaries: find packages that are already “cleanly separated.”
  • Create a module-info.java per boundary: start with one module and keep it small.
  • Use requires transitive sparingly: only if you truly want downstream modules to automatically inherit dependencies.
  • Enforce internal boundaries early: do not export internal packages “just to make it work.” Fix the dependency instead.

Example with service usage

If you want to use the service loader pattern, modules give you explicit declarations.

module billing.core {

exports com.acme.billing.api;

uses com.acme.billing.api.InvoiceService;

}

module billing.impl {

requires billing.core;

provides com.acme.billing.api.InvoiceService with com.acme.billing.impl.DefaultInvoiceService;

}

This makes service wiring explicit and prevents accidental runtime surprises.

Process API Improvements: Better Control Over Child Processes

Java 9 improved the Process API to make it easier to inspect and manage OS processes. It added support for process IDs, descendants, and more process metadata. This is surprisingly useful for orchestration and integration work.

Example: tracking child processes

import java.io.IOException;

import java.lang.ProcessHandle;

public class ProcessHandleDemo {

public static void main(String[] args) throws IOException {

Process process = new ProcessBuilder("bash", "-c", "sleep 2").start();

ProcessHandle handle = process.toHandle();

System.out.println("PID: " + handle.pid());

handle.info().command().ifPresent(cmd -> System.out.println("Command: " + cmd));

}

}

Real-world scenario

If you build a Java-based build tool or container orchestration service, you can use ProcessHandle to monitor spawned processes, kill them reliably, and log metadata for auditing.

When not to use

If you’re running in restricted environments (like some managed containers), process inspection may be limited. Always handle missing metadata gracefully.

Practical example: killing process trees

One of the pain points pre-Java 9 was killing a process and discovering its child processes were still running. ProcessHandle makes this safer.

import java.io.IOException;

import java.time.Duration;

import java.util.concurrent.TimeUnit;

public class ProcessTreeKiller {

public static void main(String[] args) throws IOException, InterruptedException {

Process process = new ProcessBuilder("bash", "-c", "sleep 30").start();

ProcessHandle handle = process.toHandle();

// Kill children first, then the parent

handle.descendants().forEach(ph -> ph.destroy());

handle.destroy();

process.waitFor(2, TimeUnit.SECONDS);

System.out.println("Process ended: " + !handle.isAlive());

}

}

This pattern is more reliable than shelling out to OS-specific tools. I use it in cross-platform automation tools.

HTTP/2 Client: A Modern Networking API

Java 9 introduced an incubator HTTP/2 client that later became standard in Java 11. Still, it’s important to understand how it started because the API design encourages asynchronous, non-blocking calls.

Example (Java 9 incubator API style)

import jdk.incubator.http.HttpClient;

import jdk.incubator.http.HttpRequest;

import jdk.incubator.http.HttpResponse;

import java.net.URI;

public class Http2ClientDemo {

public static void main(String[] args) throws Exception {

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()

.uri(URI.create("https://api.example.com/status"))

.GET()

.build();

HttpResponse response = client.send(request, HttpResponse.BodyHandler.asString());

System.out.println(response.statusCode());

System.out.println(response.body());

}

}

Modern context in 2026

If you’re on Java 17+ you should use the standard java.net.http module instead of the incubator. The usage is similar but the package name is different. I recommend moving to the standard API if possible because it’s stable and documented.

Performance considerations

HTTP/2 can multiplex multiple requests over a single connection, which reduces latency and improves throughput for services that make many small calls. In practice, the benefit ranges from subtle to dramatic depending on how chatty your client is. For REST-heavy services, HTTP/2 can cut the number of TCP connections substantially.

Practical example: async requests

One of the most useful patterns is async calls with CompletableFuture, which avoids blocking threads.

import java.net.URI;

import java.net.http.HttpClient;

import java.net.http.HttpRequest;

import java.net.http.HttpResponse;

import java.util.concurrent.CompletableFuture;

public class AsyncHttpDemo {

public static void main(String[] args) {

HttpClient client = HttpClient.newHttpClient();

HttpRequest req1 = HttpRequest.newBuilder(URI.create("https://api.example.com/a")).build();

HttpRequest req2 = HttpRequest.newBuilder(URI.create("https://api.example.com/b")).build();

CompletableFuture<HttpResponse> f1 = client.sendAsync(req1, HttpResponse.BodyHandlers.ofString());

CompletableFuture<HttpResponse> f2 = client.sendAsync(req2, HttpResponse.BodyHandlers.ofString());

CompletableFuture.allOf(f1, f2).join();

System.out.println(f1.join().body());

System.out.println(f2.join().body());

}

}

Common pitfalls

  • Assuming HTTP/2 will always be used: the server must support it, otherwise it falls back to HTTP/1.1.
  • Blocking on async: calling join() immediately defeats the purpose. Use thenApply, thenCombine, or reactive patterns if you want true non-blocking pipelines.
  • TLS configuration issues: many servers only allow HTTP/2 over TLS. Ensure your client is configured to use HTTPS if you want HTTP/2.

When not to use

If you need extremely low-level socket control or custom protocols, HttpClient might be too high-level. In that case, consider NIO or a specialized library. But for most REST and HTTP workloads, it’s the right tool.

New H2: Compact Strings and Memory Efficiency

One of the quiet performance wins in Java 9 is the “compact strings” optimization. It’s not a new API, but it affects how strings are stored. The JVM can store Latin-1 characters in a more compact form, effectively reducing memory usage for many typical workloads.

Why I care

In enterprise systems, strings dominate memory. For log-heavy apps, APIs, and data processing services, a large portion of heap is string data. Compact strings can reduce memory footprint, which translates into less GC pressure and better throughput.

Practical impact

  • If your data is mostly ASCII or Latin-1, you get memory savings automatically.
  • If your data contains many Unicode characters outside Latin-1, the JVM falls back to UTF-16, so no regression.

When not to overthink it

You don’t need to change code to get the benefit. But you should be aware of it when analyzing memory usage: if you see lower memory use in Java 9+, that’s not magic. It’s compact strings.

New H2: Stack-Walking API

Java 9 introduced a stack-walking API that is more efficient and flexible than older Throwable.getStackTrace() patterns. If you’ve ever written logging or monitoring code, this matters.

Why it matters

  • Performance: you can lazily walk the stack without eagerly capturing everything.
  • Control: you can filter or limit stack frames.

Example

import java.lang.StackWalker;

import java.lang.StackWalker.StackFrame;

import java.util.List;

public class StackWalkerDemo {

public static void main(String[] args) {

List frames = StackWalker.getInstance().walk(s -> s.limit(5).toList());

frames.forEach(f -> System.out.println(f.getClassName() + "::" + f.getMethodName()));

}

}

When I use it

  • Logging frameworks that want short stack traces
  • Debug tooling that needs to identify caller context without heavy cost
  • Security checks that validate call chains

When not to use

If you just need a simple stack trace for debugging in a throwaway tool, Throwable is still fine. The stack-walking API shines when you need control or performance.

New H2: try-with-resources Enhancement

Java 9 allows you to use effectively final resources declared outside the try-with-resources statement. This is a small change, but it reduces boilerplate and makes resource handling easier to refactor.

Example

import java.io.BufferedReader;

import java.io.FileReader;

import java.io.IOException;

public class TryWithResourcesDemo {

public static void main(String[] args) throws IOException {

BufferedReader reader = new BufferedReader(new FileReader("data.txt"));

try (reader) {

String line = reader.readLine();

System.out.println(line);

}

}

}

Why it matters

I often build resource-handling code in steps. With Java 9, I can declare the resource early (maybe because I need to test for null or configure it) and still use try-with-resources without re-declaring.

When not to use

If the resource is only used inside the try block, keep it there for clarity. The enhancement is for cases where the resource needs to exist before the try block.

New H2: Optional Improvements

Java 9 added a few methods to Optional that make it more expressive, including ifPresentOrElse, or, and stream. These changes encourage more fluent code and reduce awkward null handling.

Example: ifPresentOrElse

import java.util.Optional;

public class OptionalDemo {

public static void main(String[] args) {

Optional region = Optional.ofNullable("Europe");

region.ifPresentOrElse(

r -> System.out.println("Region: " + r),

() -> System.out.println("No region")

);

}

}

Example: stream integration

import java.util.List;

import java.util.Optional;

import java.util.stream.Collectors;

public class OptionalStreamDemo {

public static void main(String[] args) {

List<Optional> values = List.of(Optional.of("A"), Optional.empty(), Optional.of("B"));

List result = values.stream()

.flatMap(Optional::stream)

.collect(Collectors.toList());

System.out.println(result); // [A, B]

}

}

When not to use

Optional is not a general-purpose container. I avoid using it as a field in data objects or in large collections where it adds overhead. It’s great for return types and local control flow.

New H2: Convenience Methods in Stream and Collection APIs

Java 9’s smaller API additions add up. Stream.ofNullable and Optional.stream remove a lot of conditional logic. It’s easy to underestimate these, but I’ve seen them reduce boilerplate across entire codebases.

Practical example: filtering optional config values

import java.util.Map;

import java.util.Optional;

import java.util.stream.Collectors;

public class ConfigDemo {

public static void main(String[] args) {

Map<String, Optional> config = Map.of(

"region", Optional.of("eu"),

"tier", Optional.empty()

);

Map resolved = config.entrySet().stream()

.filter(e -> e.getValue().isPresent())

.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get()));

System.out.println(resolved);

}

}

With Java 9, the same logic can be rewritten to avoid get():

import java.util.Map;

import java.util.Optional;

import java.util.stream.Collectors;

public class ConfigDemo2 {

public static void main(String[] args) {

Map<String, Optional> config = Map.of(

"region", Optional.of("eu"),

"tier", Optional.empty()

);

Map resolved = config.entrySet().stream()

.flatMap(e -> e.getValue().stream().map(v -> Map.entry(e.getKey(), v)))

.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

System.out.println(resolved);

}

}

It’s slightly more advanced, but it removes a common source of mistakes (Optional.get).

New H2: Deprecation and Removal Guidance

Java 9 introduced a more structured deprecation mechanism, including @Deprecated(since = "9", forRemoval = true). This is useful for library authors because it signals intent clearly.

Why I use it

When I deprecate an API, I want to tell users two things:

  • When it was deprecated
  • Whether it’s going away

This helps teams prioritize migration.

Example

public class LegacyService {

/

* @deprecated since 9; use newService() instead.

*/

@Deprecated(since = "9", forRemoval = true)

public void oldService() {

// legacy behavior

}

public void newService() {

// modern behavior

}

}

When not to use forRemoval

If you maintain a long-lived API with backward compatibility guarantees, be cautious with forRemoval = true. It’s a strong signal. I only use it when I’m confident I can remove the API within a defined timeframe.

New H2: The Module System and Reflection Pitfalls

One of the biggest surprises for teams is how the module system affects reflection. Code that worked on the classpath may break when modules are enabled because internal packages are no longer accessible by default.

Common scenario

A framework uses reflection to access a private field in another module. In Java 8, that might work (with warnings). In Java 9 modules, it often fails unless you open the package.

Example: opening packages

module billing.core {

exports com.acme.billing.api;

opens com.acme.billing.internal to some.framework.module;

}

Guidance

  • Avoid opening packages broadly (e.g., opens ... to ALL-UNNAMED) unless you have no choice.
  • Prefer explicit opens for specific modules.
  • If you control the framework, consider switching to public APIs instead of reflection.

New H2: Custom Runtime Images with jlink

jlink is a tool introduced with Java 9 that lets you build a custom runtime containing only the modules you need. This is a powerful way to reduce deployment size and startup overhead.

Why I use it

For CLI tools or desktop apps, shipping a full JDK runtime is heavy. With jlink, you can ship a smaller runtime tailored to your app.

Example command

jlink --module-path $JAVA_HOME/jmods:mods \

--add-modules billing.core \

--output billing-runtime

Practical benefits

  • Smaller distribution size
  • Potentially faster startup
  • Fewer attack surfaces (only shipped modules are available)

When not to use

For large server deployments, the benefit is less obvious because the runtime is often shared. But for desktop and CLI tools, it’s a big win.

New H2: Tooling Updates That Matter in Teams

Java 9 didn’t just change APIs; it affected tooling. I’ve seen teams run into weird issues because their build tools didn’t fully support modules or JShell integration.

Practical advice

  • Update build tools: make sure your Maven/Gradle versions support Java 9+ modules.
  • CI sanity check: run tests with --illegal-access=deny occasionally to catch reflective access issues early.
  • IDE settings: ensure your IDE is aware of module boundaries, or you’ll get confusing errors.

Comparison Table: Traditional vs Java 9 Approach

This is a quick mental model I use to help teams adopt Java 9 features without feeling overwhelmed.

Traditional Approach

Java 9 Approach

Why It’s Better

Mutable collections everywhere

List.of, Set.of, Map.of

Safer defaults and clearer intent

Classpath-only builds

Modules with explicit exports/requires

Strong encapsulation, fewer surprises

Manual loops for stream conditions

takeWhile, dropWhile, new iterate

Cleaner, more expressive pipelines

Old HttpURLConnection

HttpClient (incubator in 9, standard later)

Async, modern HTTP/2 support

Manual process management

ProcessHandle

Better inspection and control

Verbose try-with-resources

Enhanced try-with-resources

Cleaner resource handling

Common Mistakes I See in Real Codebases

Here are the most frequent issues I’ve seen teams hit when they “adopt” Java 9 superficially:

  • Using List.of but still exposing internal mutable references

– Fix: always copy external collections with List.copyOf.

  • Enabling modules but exporting everything “just to compile”

– Fix: start small, export only intended APIs, and refactor dependencies instead of loosening boundaries.

  • Using takeWhile on unordered collections

– Fix: ensure the stream has encounter order, or use filter.

  • Assuming HTTP/2 is always active

– Fix: verify server capability and ensure HTTPS usage.

  • Ignoring reflective access warnings

– Fix: treat them as future failures. Add explicit opens or switch to public APIs.

When You Should Not Adopt a Feature

A good engineer knows when not to use a tool. Here’s my short list:

  • Modules: skip if you have a tiny script or an extremely dynamic plugin-based architecture that relies heavily on reflective access and you can’t safely open packages.
  • JShell: avoid as a validation tool. It’s great for learning, not for proving correctness.
  • Immutable collections: skip for large or frequently changing datasets. Use a mutable builder and freeze later.
  • HTTP/2 client: skip if you need a library with advanced features like circuit breakers and tracing out of the box; in those cases, you might choose a higher-level HTTP client.

Production Considerations: Monitoring and Stability

Java 9 features often change operational behavior subtly. Here’s what I keep an eye on in production:

  • Memory profiling: compact strings can change heap usage. I compare baseline memory before and after upgrades.
  • GC behavior: smaller heaps often mean faster GC but more frequent cycles. Monitor throughput.
  • Modules and reflection: log and investigate illegal-access warnings. They often signal future breakage.
  • HTTP client timeouts: the new HTTP client makes it easy to forget timeouts—always set them.

A Practical Adoption Checklist

If I’m advising a team that is already on Java 17 or 21, this is the checklist I use to ensure Java 9 features are actually applied:

  • Collection factories: replace ad-hoc list and map construction in DTOs with List.of and Map.of.
  • Stream enhancements: review any complex stream pipelines and see if takeWhile, dropWhile, or ofNullable reduces boilerplate.
  • Interfaces: refactor duplicated default method code into private interface helpers.
  • Process handling: if you spawn processes, switch to ProcessHandle and ensure you can manage descendants.
  • HTTP client: migrate off HttpURLConnection and validate async usage patterns.
  • Modules: at least define modules for internal libraries, even if applications still run on the classpath.

Closing Thoughts

Java 9 was a pivot release. It didn’t just add shiny syntax; it changed how we organize and run Java applications. The features are pragmatic: immutable collections help avoid bugs, JShell accelerates learning, stream improvements make intent visible, interface private methods keep defaults tidy, and modules enforce architecture. Even if you’re on Java 17 or 21, these features still matter because they’re the foundation of modern Java.

Whenever I revisit a codebase that skipped Java 9, I find easy wins: simplified collections, clearer stream pipelines, fewer reflection hacks, and stronger API boundaries. The best part is that most of these upgrades are low risk and high reward. If you adopt just a handful of them, you’ll feel the improvement immediately. And if you adopt the module system thoughtfully, you’ll set your system up for a decade of maintainable growth.

If you want to take this further, I’d start by upgrading the smallest, most stable library in your system to modules, adding List.of and Map.of across DTOs, and making ProcessHandle the default way you manage external processes. Those changes create momentum and make the rest of Java 9 feel natural rather than scary.

Scroll to Top