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.greeterexposes aGreeterAPIcom.acme.appconsumes 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.greeterdoes 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
ClassNotFoundException20 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-debugto remove debug symbols from the runtime--no-header-files --no-man-pagesto cut file count--compress=2to 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
opensdirectives 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). Preferopens ... torather thanopenseverywhere.
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.apidefinesPaymentProvidercom.acme.payments.stripeimplements itcom.acme.appdiscovers 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)throwsNullPointerException. 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.
Listkeeps order.Set.ofdoes not promise a specific iteration order. If ordering matters, use aListor build aLinkedHashSetyourself.
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, useMap.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
ArrayListis clearer) - I need stable iteration order for a set or map and want to encode that intent (
LinkedHashSet,LinkedHashMap)
Traditional vs modern style
Pre-Java 9 style
—
new ArrayList(); add(...);
List.of(...) new HashMap(); put(...);
Map.of(...) verbose builders everywhere
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,/importsto inspect session state./saveand/opento 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.jshfile 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 trailing18, 16too.takeWhilestops 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 youlimit.
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/dropWhilecan 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
filtersemantics, usefilter.
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.PublisherFlow.SubscriberFlow.SubscriptionFlow.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
CompletableFutureor 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.javacorrectly. - 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
StackWalkerfor low-overhead caller/correlation utilities.
API style and safety
- Prefer
List.of/Map.offor constants and fixtures. - Use
Optional.streamto 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.


