Part 24: Java 9 Collection & Stream Enhancements – Cleaner APIs, Immutable Collections and Smarter Streams

Introduction

One of the major goals of Java 9 was to make everyday programming simpler.

While Java 8 introduced Streams, Lambdas, and the Date & Time API, developers still found themselves writing verbose code for common tasks such as:

  • Creating small collections
  • Building immutable data
  • Processing streams until a condition was met
  • Handling nullable values in streams

Java 9 addressed these issues by introducing several practical improvements that quickly became part of everyday Java programming.

These enhancements include:

  • Collection Factory Methods
  • Immutable Collections
  • takeWhile()
  • dropWhile()
  • Enhanced iterate()
  • Optional.stream()
  • Stream.ofNullable()

Although individually small, together they significantly reduce boilerplate code and improve readability.


Learning Objectives

By the end of this article, you will be able to:

  • Create immutable collections using factory methods.
  • Understand the difference between immutable and unmodifiable collections.
  • Use takeWhile() and dropWhile().
  • Generate finite streams using the new iterate() method.
  • Convert Optional into Streams.
  • Handle nullable objects in Stream pipelines.
  • Apply these features in enterprise applications.

Collection Factory Methods

Before Java 9

Creating a simple list required multiple statements.

List<String> countries = new ArrayList<>();

countries.add("India");
countries.add("USA");
countries.add("Japan");

If immutability was required:

List<String> countries =
        Collections.unmodifiableList(
                Arrays.asList(
                        "India",
                        "USA",
                        "Japan"));

This is verbose and still leaves room for accidental modification of the underlying collection if it is shared.


Java 9 Solution

List<String> countries =
        List.of(
                "India",
                "USA",
                "Japan");

Simple.

Readable.

Immutable.


Characteristics of List.of()

List<String> list =
        List.of("A", "B", "C");

Properties:

  • Immutable
  • Preserves insertion order
  • Does not allow null
  • Optimized for small collections

Attempting to modify it:

list.add("D");

Results in:

UnsupportedOperationException

Set.of()

Set<String> roles =

        Set.of(

                "ADMIN",

                "USER",

                "AUDITOR");

Properties:

  • Immutable
  • Duplicate elements are not allowed
  • null values are not allowed

Example:

Set.of("A", "A");

throws:

IllegalArgumentException

This is an important difference from HashSet, which silently ignores duplicate insertions.


Map.of()

Before Java 9:

Map<String,Integer> scores =

        new HashMap<>();

scores.put("Math",90);

scores.put("Physics",95);

Java 9:

Map<String,Integer> scores =

        Map.of(

                "Math",90,

                "Physics",95);

Much cleaner.


Map.ofEntries()

For larger maps:

Map<String,Integer> marks =

        Map.ofEntries(

                Map.entry("Math",90),

                Map.entry("Science",95),

                Map.entry("English",88));

This avoids the limitation of Map.of() overloads and improves readability for many entries.


Immutable vs Unmodifiable

This distinction is often misunderstood.

Consider:

List<String> list =

        new ArrayList<>();

Then:

List<String> view =

        Collections.unmodifiableList(list);

view cannot be modified directly.

However, modifying the original list changes what view exposes.

list.add("Java");

The unmodifiable view reflects the change.

By contrast:

List<String> list =

        List.of("Java");

creates an immutable collection whose contents cannot be changed through any reference.


Enterprise Use Cases

Collection factory methods are useful for:

  • Application configuration
  • Constants
  • Role definitions
  • Country lists
  • HTTP headers
  • Supported currencies
  • Feature flags

They make intent explicit: the collection is not expected to change.


Stream takeWhile()

Suppose we process transaction amounts.

100

200

300

450

600

700

Requirement:

Process values while they are less than 500.

Java 9:

List<Integer> values =

        numbers.stream()

                .takeWhile(n -> n < 500)

                .toList();

Result:

100

200

300

450

Processing stops as soon as the predicate becomes false.


Stream dropWhile()

Requirement:

Ignore values until reaching 500.

List<Integer> values =

        numbers.stream()

                .dropWhile(n -> n < 500)

                .toList();

Result:

600

700

The stream skips the initial matching elements, then processes the remainder.


iterate() Enhancement

Java 8

Infinite stream:

Stream.iterate(

        1,

        n -> n + 1);

Limiting required:

.limit(10)

Java 9

Finite stream:

Stream.iterate(

        1,

        n -> n <= 10,

        n -> n + 1);

The termination condition is built into the stream definition, improving readability.


Optional.stream()

Suppose:

Optional<Customer> customer

Java 8 required extra work to integrate with Streams.

Java 9:

customer.stream()

        .map(Customer::getName)

        .forEach(System.out::println);

If the Optional is empty, the resulting stream is empty.

This is particularly useful when composing Stream pipelines.


Stream.ofNullable()

Before Java 9:

if(customer != null){

    process(customer);

}

Java 9:

Stream.ofNullable(customer)

        .forEach(this::process);

This creates:

  • A stream with one element if the value is non-null.
  • An empty stream if the value is null.

It eliminates many small null checks.


Enterprise Examples

Feature Flags

Set<String> enabledFeatures =

        Set.of(

                "PAYMENTS",

                "REPORTS",

                "NOTIFICATIONS");

Supported Countries

List<String> countries =

        List.of(

                "India",

                "USA",

                "Japan");

Processing Transactions

transactions.stream()

        .takeWhile(Transaction::isValid)

        .forEach(processor::process);

Configuration Maps

Map<String,String> config =

        Map.of(

                "ENV","PROD",

                "CACHE","REDIS");

Small configuration maps become concise and self-documenting.


Performance Considerations

The factory methods create compact immutable collection implementations optimized for small collections.

Benefits include:

  • Lower memory overhead
  • Fewer objects
  • Better cache locality
  • Clearer intent

For large mutable datasets, traditional collection implementations remain appropriate.


Common Mistakes

Expecting Mutability

List<String> list =

        List.of("A","B");

list.add("C");

Results in:

UnsupportedOperationException

Using null

List.of("A", null);

Throws:

NullPointerException

Unlike many legacy collections, the factory methods reject null values.


Confusing takeWhile() with filter()

takeWhile() stops processing once the predicate fails.

filter() evaluates every element independently.

This distinction is especially important for ordered streams.


Best Practices

✔ Use List.of(), Set.of(), and Map.of() for immutable constants.

✔ Prefer Map.ofEntries() for larger immutable maps.

✔ Use takeWhile() and dropWhile() only when stream encounter order matters.

✔ Use Stream.ofNullable() to simplify nullable pipelines.

✔ Use Optional.stream() when integrating Optional with Streams.


Interview Questions

What is the difference between Collections.unmodifiableList() and List.of()?

Collections.unmodifiableList() returns an unmodifiable view of another collection. Changes to the underlying collection are still visible.

List.of() creates a truly immutable collection.


Does List.of() allow null elements?

No. Attempting to include a null element results in a NullPointerException.


When should takeWhile() be preferred over filter()?

When processing should stop as soon as the predicate becomes false on an ordered stream.


What does Optional.stream() return?

A stream containing one element if the Optional has a value, or an empty stream if it does not.


Summary

Java 9’s Collection and Stream enhancements refined many of the APIs introduced in Java 8. Factory methods made immutable collections concise and expressive, while Stream improvements reduced boilerplate and improved readability.

Although these features are relatively small compared to JPMS, they have a significant impact on everyday development and are widely used in modern Java codebases.


Coming Up Next

Part 25 – Java 9 Platform Enhancements: JShell, Private Interface Methods, Process API Improvements, Try-with-Resources Enhancements, Multi-Release JARs, and jlink

We’ll explore the remaining Java 9 features that improved developer productivity, platform capabilities, deployment, and runtime customization.

Leave a Reply

Your email address will not be published. Required fields are marked *