Java 9 Features with Examples (Practical, Production-Oriented Guide)

Java 9 came out on September 21, 2017, and if you only remember it as "the one with modules," you missed how many day-to-day developer annoyances it removed. I still run into Java 9-era ideas constantly in 2026: smaller runtime images for containers, clearer dependency boundaries, faster experimentation in a REPL, and APIs that push you toward safer defaults (like immutable collections).

I like to frame Java 9 as the release where the JDK stopped being a monolith you "just put on the classpath" and started being a platform you can shape: you can carve out a runtime with only what you need, reason about dependency edges, and write library code that is harder to misuse.

I am going to walk through the Java 9 features I actually feel when writing and shipping code, with runnable examples and the kinds of pitfalls I see on real teams. If you are maintaining Java 8 code, you will also get a practical migration mental model: what changes behavior, what changes packaging, and what changes your debugging workflow.

The Platform Module System (JPMS): Stronger Boundaries, Smaller Runtimes

Before Java 9, most Java apps lived on the classpath. The classpath is flexible, but it is also chaotic: split packages, accidental dependency exposure, and "works on my machine" issues when the runtime happens to contain something you never declared.

Java 9 introduced the Java Platform Module System (JPMS). The core concept is simple: a module explicitly says what it exports (public API) and what it requires (dependencies). That declaration becomes part of compilation, packaging, and runtime resolution.

A minimal module example

Imagine a tiny app with two modules:

  • com.acme.greeter exposes a Greeter API
  • com.acme.app consumes it

com.acme.greeter/module-info.java:

module com.acme.greeter {

exports com.acme.greeter;

}

com.acme.greeter/com/acme/greeter/Greeter.java:

package com.acme.greeter;

public final class Greeter {

public String message(String name) {

return "Hello, " + name + "!";

}

}

com.acme.app/module-info.java:

module com.acme.app {

requires com.acme.greeter;

}

com.acme.app/com/acme/app/Main.java:

package com.acme.app;

import com.acme.greeter.Greeter;

public final class Main {

public static void main(String[] args) {

String name = args.length > 0 ? args[0] : "Avery";

System.out.println(new Greeter().message(name));

}

}

Compile and run using a module path (not a classpath):

# Compile modules into out/

javac -d out --module-source-path src $(find src -name "*.java")

Run the app module

java --module-path out -m com.acme.app/com.acme.app.Main

What I gain in practice

  • No more accidental API exposure. If com.acme.greeter does not export an internal package, consumers cannot import it.
  • Faster debugging of dependency problems. A missing dependency becomes a module resolution error, not a runtime ClassNotFoundException 20 minutes into a test.
  • A path to smaller deployments. JPMS enables jlink, which builds a trimmed runtime image.

Building a smaller runtime image with jlink

If you ship Java in containers, a custom runtime often matters more than people expect. In 2026, container images are frequently scanned and signed, and smaller images reduce transfer time and attack surface.

Example (conceptual command shape):

# Analyze module dependencies of your app

jdeps --multi-release 9 --print-module-deps --module-path mods -m com.acme.app

Build a runtime image containing only required modules

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

--add-modules com.acme.app \

--output build/runtime

Run using the generated runtime

./build/runtime/bin/java -m com.acme.app/com.acme.app.Main

In real builds, I usually add a few pragmatic flags:

  • --strip-debug to remove debug symbols from the runtime
  • --no-header-files --no-man-pages to cut file count
  • --compress=2 to reduce size further (tradeoff: slightly more CPU on startup/extraction depending on environment)

Example:

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

--add-modules com.acme.app \

--strip-debug \

--no-header-files \

--no-man-pages \

--compress=2 \

--output build/runtime

Common mistakes I see with JPMS

  • Confusing classpath and module path. If you put everything on the classpath, you are not getting module checks.
  • Split packages. Two JARs containing the same package name will blow up module resolution.
  • Assuming reflection "just works." Strong encapsulation can break deep reflection. If you use reflection frameworks, you may need opens directives or runtime flags.

A quick rule I follow: if you are building a library, start with automatic modules only if you must. If you are building an application, treat module-info.java as part of your public contract.

Practical JPMS patterns I actually use

JPMS has a reputation for being "all or nothing." In practice, I treat it like a toolbox:

1) Applications get the most value (runtime images, strong boundaries, clearer dependency graphs).

2) Libraries can adopt gradually (automatic modules first, then real module descriptors when the API is stable).

Here are the module directives I reach for most:

  • requires transitive: I use this when my module is essentially a facade and I want consumers to automatically read the underlying API module.
  • exports ... to ...: I use this for “friend” exports: public to specific modules, not to the world.
  • opens ... / opens ... to ...: I use this when frameworks need deep reflection (serialization, DI, ORMs). Prefer opens ... to rather than opens everywhere.

Example: a module that exports a public API but opens an internal package only to a JSON framework.

module com.acme.orders {

exports com.acme.orders.api;

// Only the JSON module can reflectively access the model package.

opens com.acme.orders.model to com.fasterxml.jackson.databind;

requires transitive com.acme.money;

requires java.sql;

}

If you have a framework that still relies on deep reflection into JDK internals (or into your non-open packages), you will often see runtime flags like:

  • --add-opens com.acme.orders/com.acme.orders.model=some.framework.module

I treat those flags as debt: acceptable short-term, but I try to replace them with opens ... to ... as soon as I can.

Services with uses / provides: decoupling without a DI framework

One underused JPMS feature is the built-in service mechanism (it builds on the older ServiceLoader, but becomes first-class in modules). This is a clean way to keep your app modular without turning the core module into a dependency magnet.

Example idea:

  • com.acme.payments.api defines PaymentProvider
  • com.acme.payments.stripe implements it
  • com.acme.app discovers providers at runtime

com.acme.payments.api/module-info.java:

module com.acme.payments.api {

exports com.acme.payments.api;

}

com.acme.payments.api/com/acme/payments/api/PaymentProvider.java:

package com.acme.payments.api;

public interface PaymentProvider {

String name();

boolean supportsCurrency(String currency);

String charge(String accountId, long cents);

}

com.acme.payments.stripe/module-info.java:

module com.acme.payments.stripe {

requires com.acme.payments.api;

provides com.acme.payments.api.PaymentProvider

with com.acme.payments.stripe.StripePaymentProvider;

}

com.acme.payments.stripe/com/acme/payments/stripe/StripePaymentProvider.java:

package com.acme.payments.stripe;

import com.acme.payments.api.PaymentProvider;

public final class StripePaymentProvider implements PaymentProvider {

@Override

public String name() {

return "stripe";

}

@Override

public boolean supportsCurrency(String currency) {

return "USD".equals(currency) || "EUR".equals(currency);

}

@Override

public String charge(String accountId, long cents) {

// Pretend to talk to Stripe.

return "ch" + accountId + "" + cents;

}

}

com.acme.app/module-info.java:

module com.acme.app {

requires com.acme.payments.api;

uses com.acme.payments.api.PaymentProvider;

}

com.acme.app/com/acme/app/Main.java:

package com.acme.app;

import com.acme.payments.api.PaymentProvider;

import java.util.ServiceLoader;

public final class Main {

public static void main(String[] args) {

ServiceLoader loader = ServiceLoader.load(PaymentProvider.class);

for (PaymentProvider p : loader) {

if (p.supportsCurrency("USD")) {

System.out.println("Using provider: " + p.name());

System.out.println("Charge id: " + p.charge("acct_123", 499));

}

}

}

}

The practical win: I can add or remove implementations without editing core application code, and JPMS still knows the dependency edges.

Migration mental model (Java 8 -> Java 9)

When teams struggle with Java 9 migration, it’s usually because they mix these concerns:

  • Language + libraries: new APIs, small language tweaks
  • Packaging and runtime: module path vs classpath, illegal reflective access
  • Deployment: custom runtimes, smaller images

I handle migration in phases:

1) Upgrade to Java 9+ but run mostly on the classpath (get green builds).

2) Fix illegal reflective access warnings and remove reliance on internal JDK APIs.

3) Introduce modules for the application boundary (not necessarily for every library).

4) Add jlink later if and when runtime size matters.

Collection Factory Methods: Immutable by Default, Less Boilerplate

Java 9 added factory methods on List, Set, and Map (and Map.entry) that create immutable collections:

  • List.of(...)
  • Set.of(...)
  • Map.of(...)
  • Map.ofEntries(...)
  • Map.entry(k, v)

These are small additions that change the feel of codebases. I use them constantly for configuration-like data and test fixtures.

List and Set examples

import java.util.List;

import java.util.Set;

public final class CollectionFactoriesDemo {

public static void main(String[] args) {

List regions = List.of("us-east-1", "us-west-2", "eu-west-1");

Set enabledFlags = Set.of("rate-limits", "audit-logging");

System.out.println(regions);

System.out.println(enabledFlags);

// regions.add("ap-south-1"); // throws UnsupportedOperationException

}

}

Map examples

import java.util.Map;

public final class MapFactoriesDemo {

public static void main(String[] args) {

Map httpStatus = Map.of(

200, "OK",

404, "Not Found",

500, "Internal Server Error"

);

System.out.println(httpStatus.get(404));

Map ports = Map.ofEntries(

Map.entry("http", 80),

Map.entry("https", 443)

);

System.out.println(ports);

}

}

Things that bite people

  • These collections reject nulls. List.of(null) throws NullPointerException. That is usually good, but it can surprise you during migration.
  • They are immutable, not just unmodifiable views. You cannot mutate them, and they are safe to share.
  • Iteration order. List keeps order. Set.of does not promise a specific iteration order. If ordering matters, use a List or build a LinkedHashSet yourself.

Less obvious pitfalls (the ones I see in code reviews)

  • Duplicate elements/keys throw at creation time. This is great because it fails fast, but it can surprise people when the source data might contain duplicates.
import java.util.Set;

public final class DuplicateSetDemo {

public static void main(String[] args) {

// Set.of("a", "a"); // throws IllegalArgumentException

}

}

  • Map.of(...) has a size limit. There are overloads up to 10 key/value pairs. For larger maps, use Map.ofEntries(...).
  • Don’t confuse immutability with deep immutability. List.of(someMutableObject) doesn’t freeze the object itself.

When I use vs when I don’t

I use these factory methods when:

  • the data is a constant, a default, or a test fixture
  • I want safe sharing across threads
  • I want to prevent accidental mutation (especially in constructors)

I don’t use them when:

  • I’m building a large collection incrementally (a builder pattern or ArrayList is clearer)
  • I need stable iteration order for a set or map and want to encode that intent (LinkedHashSet, LinkedHashMap)

Traditional vs modern style

Task

Pre-Java 9 style

Java 9+ style —

— Small constant list

new ArrayList(); add(...);

List.of(...) Small constant map

new HashMap(); put(...);

Map.of(...) Test fixtures

verbose builders everywhere

concise, immutable fixtures

In my experience, pushing immutable defaults reduces "who mutated this" debugging sessions more than any single lint rule.

JShell (REPL): Faster Feedback Loops for APIs and Ideas

Java 9 introduced jshell, a REPL (read-eval-print loop) for Java. This is not just a toy; it is a serious workflow upgrade.

When I am evaluating a new library, checking a regex, validating a time conversion, or sanity-checking stream behavior, I prefer jshell over writing a full class and running it.

A practical JShell session

You can start it from the terminal:

jshell

Then experiment:

jshell> var regions = java.util.List.of("us-east-1", "us-west-2");

regions ==> [us-east-1, us-west-2]

jshell> regions.contains("eu-west-1")

$2 ==> false

jshell> regions.stream().map(r -> r.toUpperCase()).toList()

| Error:

| cannot find symbol

| symbol: method toList()

That last line is a useful reminder: Stream.toList() arrived later (Java 16). In JShell, you immediately see the mismatch between the JDK you are using and the code you assumed.

Here is the Java 9-compatible alternative:

jshell> regions.stream().map(String::toUpperCase).collect(java.util.stream.Collectors.toList())

$3 ==> [US-EAST-1, US-WEST-2]

JShell tips I actually use

  • /vars, /methods, /imports to inspect session state.
  • /save and /open to persist experiments.
  • Start JShell with a classpath or module path when you want to explore your own code.

Here are two flags I use often:

# Explore a jar/classpath dependency

jshell --class-path libs/some-lib.jar

Explore your own modules

jshell --module-path out --add-modules com.acme.app

Turning JShell into a repeatable scratchpad

One thing I didn’t appreciate at first: JShell can be used like a tiny scripting environment for internal dev tooling.

  • I keep a scratch.jsh file for experiments.
  • I check it into a private repo sometimes when it captures useful reasoning (like parsing edge cases).

Example scratch.jsh:

import java.time.*;

import java.util.*;

var now = Instant.now();

var tomorrow = LocalDate.now().plusDays(1);

System.out.println(now);

System.out.println(tomorrow);

Then run:

jshell scratch.jsh

This is especially useful when I want fast, auditable experiments without introducing a new Gradle/Maven module.

Stream API Improvements: takeWhile, dropWhile, ofNullable, and a Better iterate

Java 9 improved streams in ways that make data pipelines cleaner and sometimes faster, especially for ordered streams.

New methods:

  • takeWhile(Predicate)
  • dropWhile(Predicate)
  • Stream.ofNullable(T)
  • Stream.iterate(seed, hasNextPredicate, nextFunction)

takeWhile and dropWhile: think "prefix" operations

These methods are easiest to understand on ordered streams. I explain them with an analogy: imagine you are reading logs until the first error, or skipping warm-up metrics until real traffic starts.

import java.util.List;

public final class TakeDropWhileDemo {

public static void main(String[] args) {

List responseTimesMs = List.of(12, 14, 15, 19, 120, 130, 18, 16);

// Take values while they look like "normal" latency.

var warmPath = responseTimesMs.stream()

.takeWhile(ms -> ms < 100)

.toArray();

// Drop the initial normal region (prefix) and keep the rest.

var fromFirstSpike = responseTimesMs.stream()

.dropWhile(ms -> ms < 100)

.toArray();

System.out.println("Warm path count: " + warmPath.length);

System.out.println("From first spike count: " + fromFirstSpike.length);

}

}

Important nuance: these are not "filter". They operate on the start of the stream.

  • filter(ms -> ms < 100) would keep the trailing 18, 16 too.
  • takeWhile stops at the first element that fails the predicate.

#### Real-world example: read lines until a sentinel

If you parse files that have a header block, a blank line, and then data, takeWhile/dropWhile can express that intent directly.

import java.util.List;

public final class HeaderParsingDemo {

public static void main(String[] args) {

List lines = List.of(

"# report=v1",

"# generated=2026-02-01",

"",

"alice,12",

"bob,15"

);

List header = lines.stream()

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

.toList();

List data = lines.stream()

.dropWhile(s -> !s.isEmpty())

.dropWhile(String::isEmpty)

.toList();

System.out.println("header=" + header);

System.out.println("data=" + data);

}

}

(Again: toList() here is Java 16+; on Java 9, use collect(Collectors.toList()). In real code, I either avoid toList() when targeting Java 9 or I centralize it behind a helper.)

Stream.ofNullable: stop writing null-guard boilerplate

I often have optional values coming from config, environment variables, or parsed input. Before Java 9, you usually wrote value == null ? Stream.empty() : Stream.of(value).

Java 9:

import java.util.stream.Stream;

public final class OfNullableDemo {

public static void main(String[] args) {

String optionalHeader = System.getenv("REQUESTIDHEADER"); // may be null

long count = Stream.ofNullable(optionalHeader)

.map(String::trim)

.filter(s -> !s.isEmpty())

.count();

System.out.println("Present and non-empty? " + (count == 1));

}

}

A pattern I like: build a list of candidate values, some of which may be null, then sanitize.

import java.util.List;

import java.util.stream.Stream;

public final class CandidateValuesDemo {

public static void main(String[] args) {

String a = null;

String b = " ";

String c = "value";

List cleaned = Stream.of(a, b, c)

.flatMap(Stream::ofNullable)

.map(String::trim)

.filter(s -> !s.isEmpty())

.toList();

System.out.println(cleaned);

}

}

A better Stream.iterate: stop conditions without hacks

Java 8:

  • Stream.iterate(seed, next) is infinite unless you limit.

Java 9 adds:

  • Stream.iterate(seed, hasNext, next)

Example: generate retry delays up to a max.

import java.time.Duration;

import java.util.stream.Stream;

public final class IterateDemo {

public static void main(String[] args) {

Duration max = Duration.ofSeconds(10);

Stream.iterate(

Duration.ofMillis(100),

d -> d.compareTo(max) <= 0,

d -> d.multipliedBy(2)

)

.forEach(d -> System.out.println("Backoff: " + d.toMillis() + "ms"));

}

}

Performance and correctness notes

  • takeWhile/dropWhile can short-circuit on ordered streams, which can reduce work.
  • On unordered streams, behavior can be surprising. If ordering matters, keep the stream ordered.
  • Prefer clarity: if a teammate expects filter semantics, use filter.

If I’m performance-tuning, I also watch for accidental de-optimization:

  • Don’t convert to streams just to do one or two operations on a small collection.
  • Avoid creating multiple streams over the same data if one pass is enough.

Private Methods in Interfaces: Default Methods Without Copy-Paste

Java 8 gave us default methods and static methods in interfaces, which enabled evolvable APIs. The downside: default methods often want shared helper logic, but Java 8 forced you to repeat code or push helpers into separate utility classes.

Java 9 allows private and private static methods inside interfaces. This is mainly about readability and maintainability.

Runnable example

import java.time.Instant;

public interface AuditLogger {

default void recordLogin(String userId) {

record("login", userId);

}

default void recordPasswordReset(String userId) {

record("password_reset", userId);

}

// Shared default-method logic stays inside the interface.

private void record(String eventType, String userId) {

String line = formatLine(eventType, userId);

writeLine(line);

}

private static String formatLine(String eventType, String userId) {

return Instant.now() + " event=" + eventType + " user=" + userId;

}

// Implementors provide the output mechanism.

void writeLine(String line);

}

final class StdoutAuditLogger implements AuditLogger {

@Override

public void writeLine(String line) {

System.out.println(line);

}

public static void main(String[] args) {

AuditLogger logger = new StdoutAuditLogger();

logger.recordLogin("user_4821");

logger.recordPasswordReset("user_4821");

}

}

When I use this feature

  • I own an interface that is used across multiple services.
  • I need to add behavior without forcing all implementors to change.
  • I want one place to keep validation or formatting logic.

When I avoid it

If the interface starts to feel like a mini framework, I stop and consider whether it should be an abstract class or a separate component. Private methods help, but they should not hide an overly complex design.

One more practical caution: default methods can become an accidental compatibility trap if you’re not careful about semantics. I treat default methods like public API: I write tests for them, and I think through how they interact with existing implementors.

HTTP/2 Client (Incubator) + Reactive Streams: Modern I/O Foundations

Java 9 introduced two big stepping stones toward modern network and async programming:

  • An HTTP client as an incubator module (jdk.incubator.httpclient) with HTTP/2 support.
  • Reactive Streams interfaces under java.util.concurrent.Flow.

A quick reality check for 2026: most production code uses the standard java.net.http client finalized later (Java 11). Still, the Java 9 feature matters historically and conceptually, and you will see it in older services or in migration guides.

HTTP client example (Java 9 incubator shape)

The Java 9 client lives in an incubator module, so usage differs from modern java.net.http. The conceptual workflow is the same: build a request, send it async, handle the response.

Here is a compact example that shows the pattern (you will need the incubator module available in your JDK 9 environment):

import jdk.incubator.http.HttpClient;

import jdk.incubator.http.HttpRequest;

import jdk.incubator.http.HttpResponse;

import java.net.URI;

import java.time.Duration;

import java.util.concurrent.CompletableFuture;

public final class Http2ClientDemo {

public static void main(String[] args) {

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()

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

.timeout(Duration.ofSeconds(5))

.GET()

.build();

CompletableFuture<HttpResponse> future =

client.sendAsync(request, HttpResponse.BodyHandler.asString());

future.thenApply(HttpResponse::body)

.thenAccept(body -> {

System.out.println("Body length: " + body.length());

System.out.println(body.substring(0, Math.min(120, body.length())));

})

.exceptionally(ex -> {

System.err.println("Request failed: " + ex);

return null;

})

.join();

}

}

#### Production notes I care about

  • Timeouts: Always set them. If you don’t, you will eventually end up with a stuck request holding a thread or memory.
  • Executors: If you are doing high concurrency, decide explicitly which executor you want, rather than implicitly using shared pools.
  • Backpressure: If you stream bodies or handle many concurrent responses, you need to think about consumer speed.

Reactive Streams (Flow): standard interfaces, not a whole framework

Java 9 added java.util.concurrent.Flow, which contains the standard Reactive Streams interfaces:

  • Flow.Publisher
  • Flow.Subscriber
  • Flow.Subscription
  • Flow.Processor

On its own, Flow is not “reactive programming in a box.” It’s a vocabulary and a compatibility layer. The value is that different libraries can interop without inventing their own interfaces.

Here is a small runnable example using SubmissionPublisher (also added in Java 9) to demonstrate backpressure.

import java.util.concurrent.Flow;

import java.util.concurrent.SubmissionPublisher;

public final class FlowDemo {

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

try (SubmissionPublisher publisher = new SubmissionPublisher()) {

Flow.Subscriber subscriber = new Flow.Subscriber() {

private Flow.Subscription subscription;

private int processed;

@Override

public void onSubscribe(Flow.Subscription subscription) {

this.subscription = subscription;

// Request the first item.

subscription.request(1);

}

@Override

public void onNext(Integer item) {

processed++;

System.out.println("Got " + item);

// Simulate slow work.

try {

Thread.sleep(50);

} catch (InterruptedException e) {

Thread.currentThread().interrupt();

}

// Request the next item only when we are ready.

subscription.request(1);

}

@Override

public void onError(Throwable throwable) {

System.err.println("Error: " + throwable);

}

@Override

public void onComplete() {

System.out.println("Complete. processed=" + processed);

}

};

publisher.subscribe(subscriber);

for (int i = 1; i <= 10; i++) {

publisher.submit(i);

}

// Give the subscriber time to finish.

Thread.sleep(800);

}

}

}

The key idea I want people to internalize: request(n) is the backpressure signal. If you ignore it (for example by requesting Long.MAX_VALUE everywhere), you often end up with bursty memory usage and unpredictable latency.

When I use Flow (and when I don’t)

I use Flow when:

  • I need a simple in-process publisher/subscriber relationship
  • I want a standard interface boundary for a library
  • I’m bridging between async APIs (for example, turning events into a stream)

I don’t use it when:

  • the team already standardized on a reactive library (adding a second model adds confusion)
  • the problem is simpler with CompletableFuture or blocking I/O

Optional Improvements: stream(), ifPresentOrElse(), or()

Java 9 made Optional much nicer to use in real pipelines. The three additions I reach for most are:

  • Optional.stream()
  • Optional.ifPresentOrElse(...)
  • Optional.or(...)

Optional.stream(): integrate Optional into stream pipelines

Before Java 9, you often had to do awkward conversions if you wanted to flatten optionals.

Java 9:

import java.util.List;

import java.util.Optional;

import java.util.stream.Collectors;

public final class OptionalStreamDemo {

static Optional normalize(String s) {

if (s == null) return Optional.empty();

String t = s.trim();

return t.isEmpty() ? Optional.empty() : Optional.of(t);

}

public static void main(String[] args) {

List raw = List.of(" alice ", "", " ", "bob", "carol");

List cleaned = raw.stream()

.map(OptionalStreamDemo::normalize)

.flatMap(Optional::stream)

.collect(Collectors.toList());

System.out.println(cleaned);

}

}

This is one of those features that quietly removes a lot of "ceremony".

ifPresentOrElse(): handle both branches without awkward code

import java.util.Optional;

public final class IfPresentOrElseDemo {

public static void main(String[] args) {

Optional token = Optional.ofNullable(System.getenv("API_TOKEN"));

token.ifPresentOrElse(

t -> System.out.println("Token length: " + t.length()),

() -> System.out.println("No token set; running in limited mode")

);

}

}

This reads like what I mean, and that matters.

or(): compose fallbacks lazily

or(...) is a nice complement to orElse(...) because it is lazy and returns another Optional.

import java.util.Optional;

public final class OptionalOrDemo {

static Optional fromEnv(String key) {

return Optional.ofNullable(System.getenv(key));

}

static Optional fromArgs(String[] args) {

return args.length > 0 ? Optional.of(args[0]) : Optional.empty();

}

public static void main(String[] args) {

Optional name = fromEnv("USER")

.or(() -> fromArgs(args))

.or(() -> Optional.of("anonymous"));

System.out.println("Hello, " + name.get());

}

}

CompletableFuture Improvements: timeouts and better async control

Java 9 improved CompletableFuture in ways that make async code safer:

  • orTimeout(timeout, unit)
  • completeOnTimeout(value, timeout, unit)
  • delayedExecutor(delay, unit)
  • newIncompleteFuture() (useful for subclassing)

Example: add a timeout to a slow call

import java.util.concurrent.*;

public final class CompletableFutureTimeoutDemo {

public static void main(String[] args) {

CompletableFuture slow = CompletableFuture.supplyAsync(() -> {

try {

Thread.sleep(2000);

} catch (InterruptedException e) {

Thread.currentThread().interrupt();

}

return "done";

});

String result = slow

.orTimeout(500, TimeUnit.MILLISECONDS)

.exceptionally(ex -> "timeout")

.join();

System.out.println(result);

}

}

In production, I prefer timeouts that fail fast with a clear error, rather than hanging until some outer system kills the request.

Example: fallback value after a deadline

import java.util.concurrent.*;

public final class CompleteOnTimeoutDemo {

public static void main(String[] args) {

CompletableFuture maybeSlow = CompletableFuture.supplyAsync(() -> {

try {

Thread.sleep(2000);

} catch (InterruptedException e) {

Thread.currentThread().interrupt();

}

return "real";

});

String result = maybeSlow

.completeOnTimeout("fallback", 300, TimeUnit.MILLISECONDS)

.join();

System.out.println(result);

}

}

This pattern is useful for non-critical enrichment calls (like optional personalization) where a fallback is acceptable.

Performance note

Timeouts and retries are a latency-management tool, but they can also become a self-inflicted DDoS if you retry too aggressively. When I add timeouts, I also look at:

  • maximum concurrency
  • retry budgets (how many retries per request / per time window)
  • backoff strategy (which Java 9’s better Stream.iterate(...) can help express)

Try-with-resources Enhancement: effectively final resources

Java 9 made a small but meaningful improvement: you can use a resource declared outside the try as long as it is effectively final.

Before (Java 8): resource must be declared in the try

try (InputStream in = Files.newInputStream(path)) {

// ...

}

Java 9: reuse an existing variable

import java.io.InputStream;

import java.nio.file.Files;

import java.nio.file.Path;

public final class TryWithResourcesDemo {

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

Path path = Path.of("data.txt");

InputStream in = Files.newInputStream(path);

try (in) {

System.out.println(in.read());

}

}

}

This is not flashy, but it reduces scope clutter in methods where you need to create the stream in one place (maybe after some validation) and close it in another.

InputStream.transferTo(): simple, fast stream copying

Java 9 added InputStream.transferTo(OutputStream), which is exactly what it sounds like.

Before, I saw a lot of hand-rolled copy loops (often subtly buggy). Java 9 gives you a standard method.

import java.io.*;

public final class TransferToDemo {

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

byte[] data = "hello".getBytes();

try (InputStream in = new ByteArrayInputStream(data);

ByteArrayOutputStream out = new ByteArrayOutputStream()) {

in.transferTo(out);

System.out.println(out.toString());

}

}

}

In production code, this improves readability and reduces the chance of copy/paste buffer bugs.

Process API Updates: ProcessHandle for observability and control

Java 9 significantly improved the process API via ProcessHandle. If you build tooling, launch subprocesses, or run on servers where process management matters, this is a real upgrade.

Inspect the current process

import java.time.Instant;

import java.util.Optional;

public final class ProcessHandleDemo {

public static void main(String[] args) {

ProcessHandle self = ProcessHandle.current();

ProcessHandle.Info info = self.info();

System.out.println("pid=" + self.pid());

System.out.println("command=" + info.command().orElse("?"));

System.out.println("start=" + info.startInstant().orElse(Instant.EPOCH));

System.out.println("user=" + info.user().orElse("?"));

Optional totalCpu = info.totalCpuDuration().map(d -> d.toMillis());

System.out.println("cpuMs=" + totalCpu.orElse(-1L));

}

}

List child processes and terminate them

If your application spawns subprocesses (image conversion, Git, ffmpeg, etc.), it’s useful to see children and handle cleanup.

public final class ProcessChildrenDemo {

public static void main(String[] args) {

ProcessHandle.current().children()

.forEach(ph -> System.out.println("child pid=" + ph.pid()));

// Example: kill descendants on shutdown (be careful; this is powerful).

Runtime.getRuntime().addShutdownHook(new Thread(() -> {

ProcessHandle.current().descendants().forEach(ph -> {

ph.destroy();

});

}));

}

}

Practical warning: process cleanup logic can be dangerous. I only do aggressive termination when I fully control what’s launched and I have good logs.

Stack-Walking API: StackWalker for cheaper, cleaner stack introspection

Before Java 9, stack introspection often meant Thread.currentThread().getStackTrace() or new Exception().getStackTrace(). Those work, but they can be more expensive than you expect, and they encourage sloppy patterns.

Java 9 introduced StackWalker, which lets you walk stack frames with more control.

Example: build a “caller id” for logs

import java.lang.StackWalker.StackFrame;

public final class StackWalkerDemo {

private static final StackWalker WALKER = StackWalker.getInstance(StackWalker.Option.RETAINCLASSREFERENCE);

static String caller() {

return WALKER.walk(stream -> stream

.skip(1) // skip caller() itself

.findFirst()

.map(StackFrame::getDeclaringClass)

.map(Class::getName)

.orElse("unknown"));

}

public static void main(String[] args) {

System.out.println("caller=" + caller());

}

}

I like this for debugging utilities, lightweight tracing, and enforcing rules in test helpers (for example: ensure a helper is only called from test code).

VarHandles: a safer alternative to low-level unsafe tricks

Java 9 added VarHandle, which provides a standard way to do low-level, atomic, and memory-ordering operations without relying on internal APIs.

I don’t use VarHandle every day, but when I need it, it’s a lifesaver: it allows library authors and performance-critical code to stop depending on unsupported internals.

Example: atomic update of a field

import java.lang.invoke.MethodHandles;

import java.lang.invoke.VarHandle;

public final class VarHandleDemo {

static final class Counter {

volatile int value;

private static final VarHandle VALUE;

static {

try {

VALUE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);

} catch (ReflectiveOperationException e) {

throw new ExceptionInInitializerError(e);

}

}

int incrementAndGet() {

return (int) VALUE.getAndAdd(this, 1) + 1;

}

}

public static void main(String[] args) {

Counter c = new Counter();

System.out.println(c.incrementAndGet());

System.out.println(c.incrementAndGet());

}

}

If you’re writing concurrent libraries, this can replace a bunch of fragile patterns.

Multi-Release JARs: one artifact, multiple runtime-specific implementations

Multi-release JARs (MRJARs) let you ship a single JAR that contains different class implementations for different Java versions.

This is a library-author feature more than an application feature. When I maintain a library that supports Java 8 but can take advantage of Java 9+ APIs, MRJARs are a practical compromise.

Mental model

  • Base classes live in the normal package path (compatible with Java 8).
  • Version-specific overrides live under META-INF/versions/9/ (or 10, 11, etc.).
  • At runtime, the JVM loads the appropriate class version.

Example layout

  • com/acme/Foo.class (Java 8 compatible)
  • META-INF/versions/9/com/acme/Foo.class (Java 9+ enhanced)

Pitfalls

  • You need build tooling that understands MRJARs.
  • You can accidentally create version skew in behavior (tests must run on multiple JDKs).
  • Debugging can be confusing if you don’t realize which version of a class got loaded.

When I use MRJARs, I keep the delta small and very well-tested.

Unified JVM Logging: one system for GC, class loading, and more

Java 9 introduced unified JVM logging via -Xlog, which replaces a zoo of older flags.

If you’ve ever dealt with GC logging differences across environments, this is a big cleanup.

Example: GC logs

# Unified logging style (Java 9+)

java -Xlog:gc*:stdout:time,level,tags -jar app.jar

This is much easier to standardize in deploy scripts than the old combinations like -XX:+PrintGCDetails and friends.

Why I care

  • Logs become more consistent across environments.
  • It is easier to capture the right signals during incidents.
  • Ops and developers can speak the same language (tags/categories) instead of memorizing flag sets.

@Deprecated upgrades: communicate intent with since/forRemoval

Java 9 extended @Deprecated so you can document intent more clearly:

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

Example

public final class LegacyApi {

/

* Use newMethod() instead.

*/

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

public void oldMethod() {

// ...

}

public void newMethod() {

// ...

}

}

This helps teams plan migrations: “deprecated” can mean anything from “don’t use in new code” to “this will break your build next quarter.” forRemoval makes it explicit.

Small but high-leverage library additions I use constantly

Java 9 added a bunch of small APIs that reduce boilerplate. Two I reach for a lot:

Objects.requireNonNullElse / requireNonNullElseGet

import java.util.Objects;

public final class NonNullElseDemo {

public static void main(String[] args) {

String region = Objects.requireNonNullElse(System.getenv("REGION"), "us-east-1");

System.out.println(region);

}

}

This is a clean way to express defaults without nesting if-statements.

Convenience: Optional improvements + immutable factories

These features compose nicely. I often write code like:

  • read a nullable value
  • normalize it
  • fall back
  • store it in an immutable map

That combination nudges codebases toward safer defaults.

Diamond operator for anonymous inner classes (small, but nice)

Java 9 allowed the diamond operator () with anonymous inner classes in more cases. This mostly affects generic-heavy code and reduces repetition.

I consider this a “paper cut” fix: you feel it in codebases with lots of callbacks and generic adapters.

Production checklist: what changes when you adopt Java 9 features

When I help teams adopt Java 9 features, these are the areas I insist on covering.

Modules and runtime behavior

  • Decide whether you’re staying on the classpath initially or moving to the module path.
  • Watch for illegal reflective access warnings and treat them as actionable, not noise.
  • If you create custom runtimes with jlink, ensure monitoring/diagnostic needs are met (for example, if you rely on tools that assume a full JDK, plan accordingly).

Tooling and builds

  • Ensure your build tool handles module-info.java correctly.
  • If you publish libraries, consider stable module names and avoid split packages.
  • If you use MRJARs, run tests on multiple JDKs.

Debugging workflow

  • Use unified logging (-Xlog) to standardize JVM observability.
  • Consider StackWalker for low-overhead caller/correlation utilities.

API style and safety

  • Prefer List.of/Map.of for constants and fixtures.
  • Use Optional.stream to keep pipelines clean.
  • Add timeouts to async flows (CompletableFuture.orTimeout) and decide on fallbacks.

Wrap-up: how I think about Java 9 today

Java 9 isn’t just modules. It’s a collection of features that make Java more intentional:

  • JPMS gives you a way to express boundaries and build smaller, more controlled runtimes.
  • Collection factories push you toward immutability by default.
  • JShell reduces feedback loop time.
  • Stream and Optional improvements reduce boilerplate and clarify intent.
  • Flow and the HTTP client incubator marked a shift toward modern async I/O.
  • New APIs like ProcessHandle, StackWalker, VarHandle, and unified logging improve operability and correctness.

If you’re coming from Java 8, my practical advice is: adopt the “small wins” immediately (collections, Optional, stream improvements, JShell), then plan modules deliberately (application boundary first), and only then optimize deployment with jlink when it truly pays off.

Scroll to Top