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 multipleaddcalls. - 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.ofdoes not guarantee order. If you need order, useList.oforLinkedHashSetexplicitly. - Allowing nulls: these factory methods throw
NullPointerExceptionif any element is null. That’s deliberate to prevent null-heavy data structures. - Exceeding Map.of limit:
Map.ofsupports up to 10 key-value pairs. For larger maps, useMap.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
mainmethod - 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.apiis 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.javaper boundary: start with one module and keep it small. - Use
requires transitivesparingly: 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. UsethenApply,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=denyoccasionally 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
Why It’s Better
—
—
Mutable collections everywhere
List.of, Set.of, Map.of Safer defaults and clearer intent
Classpath-only builds
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
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.ofbut 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
takeWhileon 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.ofandMap.of. - Stream enhancements: review any complex stream pipelines and see if
takeWhile,dropWhile, orofNullablereduces boilerplate. - Interfaces: refactor duplicated default method code into private interface helpers.
- Process handling: if you spawn processes, switch to
ProcessHandleand ensure you can manage descendants. - HTTP client: migrate off
HttpURLConnectionand 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.


