Technology  /  Java

โ˜• Java 15 guides ยท updated 2026

From the JVM to streams, generics, and concurrency โ€” the language behind most enterprise backends, taught with modern Java in mind.

Java Streams and Lambdas: From Loops to Pipelines Without the Confusion

Java 8โ€™s lambdas and streams changed how Java reads more than anything before or since. Code that used to be nested loops with accumulator variables became declarative pipelines: what you want, not how to loop for it.

They also spawned a new failure mode: stream soup โ€” six-line chains nobody can debug, streams used where a loop was obviously clearer, and Optional abuse. This guide teaches the API and the judgment.


Lambdas in Five Minutes

A lambda is an inline implementation of a single-method interface (functional interface):

// before: anonymous class ceremony
Comparator<String> byLength = new Comparator<String>() {
@Override public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// after
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

Syntax gradient, all equivalent forms:

x -> x * 2 // one param, expression body
(a, b) -> a + b // two params
(String s) -> s.isBlank() // explicit types (rarely needed)
x -> { log(x); return x * 2; } // block body needs return
() -> "hello" // no params

Method references are lambdas that just call one existing method โ€” prefer them when they fit, they read like English:

names.forEach(System.out::println); // x -> System.out.println(x)
names.sort(String::compareToIgnoreCase); // (a,b) -> a.compareToIgnoreCase(b)
names.stream().map(String::length) // x -> x.length()
users.stream().map(User::email) // x -> x.email()
Stream.generate(StringBuilder::new) // () -> new StringBuilder()

One rule that surprises people: lambdas can capture local variables only if theyโ€™re effectively final (never reassigned). int count = 0; list.forEach(x -> count++) wonโ€™t compile. Thatโ€™s not pedantry โ€” itโ€™s Java refusing to let you write a race condition; the stream way to count is count() or a collector, below.


The Stream Pipeline Model

Every stream chain has the same anatomy:

source intermediate ops (lazy) terminal op (triggers everything)
โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
orders.stream() .filter(o -> o.total() > 500) .toList()
.map(Order::customerEmail)
.distinct()

Three facts that prevent 90% of stream confusion:

  1. Intermediate ops are lazy. filter and map do nothing when called โ€” they build a recipe. Only the terminal op (toList, forEach, count, collectโ€ฆ) runs the pipeline. A chain without a terminal op is a no-op that looks like work.
  2. Elements flow one at a time, not stage by stage: the first order goes through filterโ†’mapโ†’collect, then the second. This is why streams can short-circuit (findFirst stops the whole pipeline at the first hit) and process infinite sources.
  3. Streams are single-use. Calling a second terminal op on a consumed stream throws IllegalStateException. Need to traverse twice? Re-create the stream or collect first.

And the golden safety rule: donโ€™t mutate external state from inside a pipeline. map(x -> { sideList.add(x); return x; }) compiles, then breaks silently under .parallel() and confuses every reader. Results leave a stream through the terminal op, nowhere else.


The Operations That Matter

Youโ€™ll use eight operations for 95% of stream code:

record Order(String customer, String city, long paise, boolean priority) {}
List<Order> orders = ...;
// filter โ€” keep matching elements
orders.stream().filter(Order::priority)
// map โ€” transform each element
orders.stream().map(Order::customer) // Stream<String>
// sorted โ€” with a Comparator built from key extractors
orders.stream().sorted(Comparator.comparing(Order::paise).reversed())
// distinct, limit, skip โ€” dedupe (by equals) and paginate
orders.stream().map(Order::city).distinct().limit(10)
// anyMatch / allMatch / noneMatch โ€” boolean answers, short-circuiting
boolean anyBig = orders.stream().anyMatch(o -> o.paise() > 1_00_000);
// count
long delhiOrders = orders.stream().filter(o -> o.city().equals("Delhi")).count();
// findFirst โ€” returns Optional<Order>, stops at first hit
Optional<Order> first = orders.stream().filter(Order::priority).findFirst();
// collect / toList โ€” materialize results
List<String> emails = orders.stream().map(Order::customer).toList(); // Java 16+

Comparator.comparing(keyExtractor) deserves a highlight โ€” composed comparators replaced pages of comparison logic:

orders.sort(Comparator.comparing(Order::city)
.thenComparing(Order::paise, Comparator.reverseOrder()));

Numbers: primitive streams

Stream<Integer> boxes every element (the wrapper-in-a-loop trap). For math, switch to IntStream/LongStream:

long revenue = orders.stream().mapToLong(Order::paise).sum();
IntStream.rangeClosed(1, 100).filter(i -> i % 3 == 0).sum();
var stats = orders.stream().mapToLong(Order::paise).summaryStatistics();
// stats.getMin(), getMax(), getAverage(), getCount() in one pass

mapToLong/mapToInt cross into primitive land; boxed() crosses back.


Collectors: Where Streams Earn Their Keep

collect with Collectors handles the โ€œbuild a data structure from elementsโ€ endgame โ€” and groupingBy alone justifies learning streams:

import static java.util.stream.Collectors.*;
// group orders by city โ†’ Map<String, List<Order>>
Map<String, List<Order>> byCity = orders.stream().collect(groupingBy(Order::city));
// count per city โ†’ Map<String, Long>
Map<String, Long> countByCity = orders.stream()
.collect(groupingBy(Order::city, counting()));
// revenue per city โ†’ Map<String, Long>
Map<String, Long> revenueByCity = orders.stream()
.collect(groupingBy(Order::city, summingLong(Order::paise)));
// customers per city (downstream mapping)
Map<String, Set<String>> customersByCity = orders.stream()
.collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
// split into two groups
Map<Boolean, List<Order>> byPriority = orders.stream()
.collect(partitioningBy(Order::priority));
// join strings
String cities = orders.stream().map(Order::city).distinct()
.collect(joining(", ", "[", "]")); // "[Delhi, Pune, Mumbai]"
// build a lookup map โ€” MUST handle duplicate keys or it throws
Map<String, Order> latestByCustomer = orders.stream()
.collect(toMap(Order::customer, o -> o,
(a, b) -> a.paise() > b.paise() ? a : b)); // merge function

That toMap merge function is a real-world landmine: without it, the first duplicate key throws IllegalStateException โ€” usually in production, on the first day real data has duplicates. If keys can repeat, either supply a merge function or use groupingBy.

Each of these one-liners replaces the 6-line computeIfAbsent loop pattern from the collections guide. Grouping and aggregation is the stream sweet spot โ€” when you see nested maps of counts being built by hand, streams are the refactor.


Optional: The Type findFirst Forces You to Learn

Optional<T> is an explicit maybe โ€” the APIโ€™s way of making โ€œmight be absentโ€ impossible to ignore:

Optional<Order> maybe = orders.stream().filter(Order::priority).findFirst();
// good โ€” declare the fallback
Order o = maybe.orElse(Order.none());
Order o2 = maybe.orElseGet(() -> expensiveDefault()); // lazy version
Order o3 = maybe.orElseThrow(() -> new NotFoundException("no priority orders"));
// good โ€” transform if present
String city = maybe.map(Order::city).orElse("unknown");
// legal but pointless โ€” you've reinvented a null check, verbosely
if (maybe.isPresent()) { use(maybe.get()); }

Guidance that matches how the JDK team intended it: Optional is a return type for โ€œmay be absentโ€ queries. Donโ€™t take it as a method parameter, donโ€™t put it in fields, donโ€™t wrap collections in it (return an empty list instead), and treat bare .get() as a code smell โ€” if you know itโ€™s present, orElseThrow() says so honestly.


When NOT to Use Streams

The judgment section most tutorials omit. Prefer a plain loop when:

The test worth applying: read the pipeline aloud. โ€œOrders, priority only, take the emails, dedupe, first tenโ€ โ€” if the chain narrates like that, itโ€™s good code. If narrating requires โ€œthen it collects into a map of maps which we then re-streamโ€ฆโ€, refactor: extract helper methods, or use a loop.

// stream soup โ€” technically correct, cognitively hostile
var r = os.stream().collect(groupingBy(Order::city,
mapping(o -> Map.entry(o.customer(), o.paise()),
toMap(Map.Entry::getKey, Map.Entry::getValue, Long::sum))));
// same result, readable
Map<String, Map<String, Long>> spendByCityAndCustomer = new HashMap<>();
for (Order o : os) {
spendByCityAndCustomer
.computeIfAbsent(o.city(), k -> new HashMap<>())
.merge(o.customer(), o.paise(), Long::sum);
}

Streams are a tool, not a religion. Senior code review feedback is as often โ€œthis should be a loopโ€ as the reverse.


Worked Example: A Small Report, Both Ways

Top 3 cities by revenue, formatted:

String report = orders.stream()
.collect(groupingBy(Order::city, summingLong(Order::paise))) // Map<String,Long>
.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(3)
.map(e -> "%s: โ‚น%,.2f".formatted(e.getKey(), e.getValue() / 100.0))
.collect(joining("\n"));

Two-stage pipelines like this โ€” aggregate into a map, then stream the entries for ranking โ€” are the standard shape for โ€œtop N by Xโ€ and worth having in muscle memory. Narrated: group revenue by city, sort entries by value descending, keep three, format, join. Passes the read-aloud test โ€” barely. One stage more complex and it should become two named statements.


Practice: Convert These Loops

The fastest way to make streams automatic is translating loop patterns you already know. Try each before reading the answer.

1. Collect the names of active users, uppercased:

List<String> result = new ArrayList<>();
for (User u : users) {
if (u.isActive()) result.add(u.name().toUpperCase());
}

Answer: users.stream().filter(User::isActive).map(u -> u.name().toUpperCase()).toList();

2. Does any order exceed one lakh paise?

boolean found = false;
for (Order o : orders) {
if (o.paise() > 1_00_000) { found = true; break; }
}

Answer: orders.stream().anyMatch(o -> o.paise() > 1_00_000); โ€” and note the stream version keeps the early exit; anyMatch stops at the first hit exactly like the break.

3. Index of the first failing check โ€” careful:

int idx = -1;
for (int i = 0; i < checks.size(); i++) {
if (!checks.get(i).passed()) { idx = i; break; }
}

Answer: this one should probably stay a loop. The stream version needs IntStream.range(0, checks.size()).filter(i -> !checks.get(i).passed()).findFirst().orElse(-1) โ€” legal, but the loop reads better. Recognizing this is the skill.

4. Flatten: all line items across all orders:

List<LineItem> all = new ArrayList<>();
for (Order o : orders) {
all.addAll(o.lines());
}

Answer: orders.stream().flatMap(o -> o.lines().stream()).toList(); โ€” flatMap is the operation this guide hasnโ€™t needed until now: one element in, a whole stream out, all flattened into the main flow. Nested-collection problems (โ€œall X across all Yโ€) are its home ground, and itโ€™s the answer to most โ€œstream of listsโ€ confusion.

Frequently Asked Questions

Do streams replace loops for performance? No โ€” for simple traversal, a plain loop is usually marginally faster (no pipeline setup, no lambda indirection), though JIT compilation narrows the gap to irrelevance in most code. You choose streams for clarity of intent on transform/filter/aggregate shapes, not speed. Choose loops for index access, early-exit complexity, and mutation.

Why canโ€™t I reuse a stream? A stream is a one-shot cursor over data, not a container. Reuse would require buffering everything (defeating laziness) or re-reading sources that may not be re-readable. Keep the collection, create streams from it freely โ€” list.stream() costs almost nothing.

forEach or an enhanced for loop? For pure side effects over a collection, the for loop โ€” it supports break/continue/checked exceptions and reads as what it is. forEach earns its place at the end of a pipeline that already did filtering/mapping. collection.forEach(...) with a multi-line lambda body is the worst of both.

What are mapMulti and takeWhile/dropWhile? Later additions worth knowing: takeWhile/dropWhile (Java 9) cut a stream at the first element failing a predicate โ€” like limit, but condition-based, ideal for sorted data. mapMulti (Java 16) is a lower-ceremony flatMap for cases where each element expands into few or zero elements.

Self-Check

  1. orders.stream().filter(o -> o.paise() > 100); โ€” what work does this line perform?
  2. Why does stream.map(...).count() sometimes skip running your map lambda entirely?
  3. collect(toMap(Order::customer, Order::paise)) throws in production but not in tests. What happened, and whatโ€™s the fix?
  4. When is orElseGet(() -> makeDefault()) meaningfully different from orElse(makeDefault())?
  5. A teammate adds .parallel() to a stream over 40 elements doing string formatting. Better or worse? Why?

(Answers: none โ€” no terminal op, it builds an unexecuted recipe. The JDK may optimize count from the source size when no op can change the count โ€” another reason side effects in map are forbidden. Duplicate customer keys in real data; add a merge function or use groupingBy. orElse always evaluates its argument, even when the Optional is present โ€” costly or side-effecting defaults need orElseGet. Worse โ€” parallelism overhead dwarfs 40 cheap tasks; parallel needs large data and CPU-bound work.)

Next, the series turns to failure: Exception Handling โ€” checked vs unchecked, try-with-resources, and designing error paths people can actually maintain.