Arrays.stream() in Java: Practical Patterns, Edge Cases, and Performance Notes

Arrays.stream() in Java: Practical Patterns, Edge Cases, and Performance Notes

A few years ago I reviewed a production bug that looked “impossible”: a payment batch was missing records, but only when the input arrived as a primitive int[]. The code looked innocent—Stream.of(ids)—and the rest of the pipeline was correct. The catch was that Stream.of(int[]) produces a Stream with a single element (the whole array), not a stream of the ints inside it. One silent mismatch, and your filters, joins, and collectors stop doing what you think they’re doing.

That’s why I reach for Arrays.stream() whenever I’m turning arrays into streams. It’s explicit, it’s readable, and—crucially—it has overloads that do the right thing for both object arrays and primitive arrays.

I’ll walk you through how Arrays.stream() behaves, the overloads you’ll use most, the difference between Stream and primitive streams (IntStream, LongStream, DoubleStream), and the failure modes I see in real code reviews. I’ll also show patterns you can copy into day-to-day services: validation, parsing, aggregation, and range processing—without surprises.

Arrays.stream(): what you actually get

Arrays.stream() lives in java.util.Arrays. Conceptually it takes an array and gives you a sequential stream over its elements.

  • “Sequential” means it runs in encounter order by default.
  • “Stream over its elements” means one stream element per array slot, not a wrapper around the entire array.

That last point matters because other entry points can behave differently.

Here’s the baseline example for an object array. Language: Java.

import java.util.Arrays;

import java.util.stream.Stream;

public class ArrayStreamBasics {

public static void main(String[] args) {

String[] words = { "Geeks", "for", "Geeks" };

Stream stream = Arrays.stream(words);

stream.forEach(w -> System.out.print(w + " "));

}

}

When I look at this code, I know exactly what it means: “make a stream of the strings inside the array.”

A stream is also single-use. Once you run a terminal operation (forEach, collect, toList, sum, count, etc.), you cannot reuse the same stream instance. If you need to traverse again, call Arrays.stream(array) again.

One more important property: the stream is backed by the array. That’s usually what you want (zero copying), but it has implications:

  • If you mutate the array before the stream consumes an element, the stream may observe the mutated value.
  • If you mutate the array concurrently (especially across threads), you’re in “don’t do that” territory: results become timing-dependent and hard to reason about.

In other words: treat the source array as immutable for the duration of the stream pipeline.

The overloads you’ll use: whole array vs range

There are two core overloads for object arrays:

1) Whole array

  • public static Stream stream(T[] array)

2) Range

  • public static Stream stream(T[] array, int startInclusive, int endExclusive)

The range overload is one of those APIs that looks trivial until you hit a boundary bug at 2 a.m. It follows the same convention as List.subList and most of the JDK: start is inclusive, end is exclusive.

A quick mental model I teach juniors is: the range describes “the slice you’d get if you copied elements at indexes startInclusive up to but not including endExclusive.”

Range example: selecting a window from a String[]

Language: Java.

import java.util.Arrays;

import java.util.stream.Stream;

public class RangeOnObjectArray {

public static void main(String[] args) {

String[] tokens = { "alpha", "beta", "gamma", "delta" };

// indexes: 0 1 2 3

// range: [1, 3) => beta, gamma

Stream window = Arrays.stream(tokens, 1, 3);

window.forEach(t -> System.out.print(t + " "));

}

}

Range rules (and what happens if you break them)

  • startInclusive must be >= 0
  • endExclusive must be <= array.length
  • startInclusive must be <= endExclusive

Break these and you’ll get an ArrayIndexOutOfBoundsException.

Also, if array itself is null, you’ll get a NullPointerException. If array is non-null but contains null elements, the stream will contain those nulls as elements—so the pipeline might throw later depending on what you do (map(String::trim) will throw on a null element).

A subtle but useful behavior: empty ranges

If startInclusive == endExclusive, you get an empty stream. That’s often convenient when you’re doing window logic and you’d rather have “empty” than “special-case.”

Language: Java.

import java.util.Arrays;

public class EmptyWindow {

public static void main(String[] args) {

String[] tokens = { "a", "b", "c" };

long count = Arrays.stream(tokens, 2, 2).count();

System.out.println(count); // 0

}

}

Primitive arrays: IntStream, LongStream, DoubleStream (and why you should care)

For primitive arrays, Arrays.stream() has dedicated overloads that return primitive streams:

  • Arrays.stream(int[]) -> IntStream
  • Arrays.stream(long[]) -> LongStream
  • Arrays.stream(double[]) -> DoubleStream

These matter for two reasons:

1) Correctness: you avoid the Stream.of(int[]) pitfall.

2) Performance: you avoid boxing every element (int -> Integer) just to run a pipeline.

Example: int[] to IntStream

Language: Java.

import java.util.Arrays;

import java.util.stream.IntStream;

public class IntArrayToIntStream {

public static void main(String[] args) {

int[] scores = { 10, 20, 30, 40 };

IntStream stream = Arrays.stream(scores);

stream.forEach(s -> System.out.print(s + " "));

}

}

Example: range on int[]

Language: Java.

import java.util.Arrays;

import java.util.stream.IntStream;

public class RangeOnIntArray {

public static void main(String[] args) {

int[] ids = { 101, 102, 103, 104, 105 };

// [1, 4) => 102, 103, 104

IntStream subset = Arrays.stream(ids, 1, 4);

subset.forEach(id -> System.out.print(id + " "));

}

}

Boxing traps: when you accidentally create Stream

If you start from an int[], prefer staying in IntStream as long as you can:

  • Good: Arrays.stream(ints).filter(...).sum()
  • Often avoidable: Arrays.stream(ints).boxed().collect(...)

Boxing is not “bad” by default, but it’s a real cost: you allocate objects and put pressure on GC. I usually box only when I truly need object-only collectors or APIs.

When you do need objects: controlled boxing

Sometimes you genuinely need Stream—for example, when building a Set, using Collectors.groupingBy, or interacting with a legacy API that only accepts boxed types.

In those cases, I box deliberately and as late as possible.

Language: Java.

import java.util.Arrays;

import java.util.Set;

import java.util.stream.Collectors;

public class BoxLate {

public static void main(String[] args) {

int[] ids = { 1, 2, 2, 3 };

Set unique = Arrays.stream(ids)

.boxed()

.collect(Collectors.toSet());

System.out.println(unique);

}

}

Patterns I reach for in real services

Streams shine when you want a readable, linear pipeline: take inputs, transform them, filter invalid data, and finish with a clear terminal step.

I’ll show a few patterns that come up constantly.

1) Validation + normalization on a String[]

Scenario: you receive environment-driven configuration like allowed regions.

Goals:

  • trim whitespace
  • drop empties
  • normalize case
  • remove duplicates
  • materialize a stable list

Language: Java.

import java.util.Arrays;

import java.util.List;

import java.util.Locale;

public class NormalizeRegions {

public static void main(String[] args) {

String[] rawRegions = { " us-east ", "EU-west", "", "Us-East", null, "ap-south" };

List regions = Arrays.stream(rawRegions)

.filter(r -> r != null)

.map(String::trim)

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

.map(r -> r.toLowerCase(Locale.ROOT))

.distinct()

.toList();

System.out.println(regions);

}

}

Notes from my code reviews:

  • I prefer Locale.ROOT for case normalization in systems code.
  • distinct() preserves encounter order on sequential streams, which is usually what you want for config lists.
  • toList() (modern JDK) returns an unmodifiable list; if you need mutation, wrap it in new java.util.ArrayList(...).

2) Aggregation on int[] without boxing

Scenario: you have response times in milliseconds from a batch of calls.

Language: Java.

import java.util.Arrays;

public class ResponseTimeStats {

public static void main(String[] args) {

int[] ms = { 12, 18, 14, 40, 19, 17 };

long count = Arrays.stream(ms).count();

int max = Arrays.stream(ms).max().orElse(0);

int min = Arrays.stream(ms).min().orElse(0);

double avg = Arrays.stream(ms).average().orElse(0.0);

System.out.println("count=" + count + ", min=" + min + ", max=" + max + ", avg=" + avg);

}

}

If the array can be empty, prefer the orElse branches you actually want. I’ve seen bugs where empty input silently becomes 0 and that later looks like “real data.” Sometimes that’s correct; often it’s not. If empty is exceptional, throw.

2a) Aggregation, but more complete: summaryStatistics()

If you need multiple stats, calling Arrays.stream(ms) several times is totally fine for small arrays, but it does mean multiple traversals. For bigger arrays or hot paths, I often use summaryStatistics() to traverse once.

Language: Java.

import java.util.Arrays;

import java.util.IntSummaryStatistics;

public class ResponseTimeSummary {

public static void main(String[] args) {

int[] ms = { 12, 18, 14, 40, 19, 17 };

IntSummaryStatistics stats = Arrays.stream(ms).summaryStatistics();

System.out.println("count=" + stats.getCount());

System.out.println("min=" + stats.getMin());

System.out.println("max=" + stats.getMax());

System.out.println("sum=" + stats.getSum());

System.out.println("avg=" + stats.getAverage());

}

}

I like this because it makes “these values come from one pass” obvious to a reader.

3) Parsing a CSV-ish line into typed values

Scenario: you get a single line with comma-separated integers, and you want clean parsing with decent error behavior.

Language: Java.

import java.util.Arrays;

public class ParseCsvNumbers {

public static void main(String[] args) {

String line = " 10, 20, , 30, not-a-number, 40 ";

String[] parts = line.split(",");

int[] values = Arrays.stream(parts)

.map(String::trim)

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

.flatMapToInt(p -> {

try {

return java.util.stream.IntStream.of(Integer.parseInt(p));

} catch (NumberFormatException e) {

// In production I would record the error with context.

return java.util.stream.IntStream.empty();

}

})

.toArray();

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

}

}

Why I like this shape:

  • error handling is local and explicit
  • successful parse yields an IntStream of one element
  • failed parse yields an empty stream

If you need “fail-fast,” replace the empty() with throw e;.

3a) Parsing with richer error reporting (without losing the stream shape)

When parsing user input, I often want to keep the pipeline but still capture which tokens failed.

A pragmatic pattern is: collect valid values, and separately collect invalid tokens.

Language: Java.

import java.util.Arrays;

import java.util.List;

public class ParseWithInvalids {

public static void main(String[] args) {

String line = " 10, 20, , 30, not-a-number, 40 ";

String[] parts = line.split(",");

List cleaned = Arrays.stream(parts)

.map(String::trim)

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

.toList();

int[] values = cleaned.stream()

.flatMapToInt(p -> {

try {

return java.util.stream.IntStream.of(Integer.parseInt(p));

} catch (NumberFormatException e) {

return java.util.stream.IntStream.empty();

}

})

.toArray();

List invalid = cleaned.stream()

.filter(p -> {

try {

Integer.parseInt(p);

return false;

} catch (NumberFormatException e) {

return true;

}

})

.toList();

System.out.println("values=" + Arrays.toString(values));

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

}

}

Is this two passes? Yes. Is it okay? Often yes. If it’s too slow, it’s also a sign you might want a loop with explicit error collection. Streams are a tool, not a religion.

4) Joining for output without manual loops

Scenario: you have String[] of tags and you need a stable, human-readable string.

Language: Java.

import java.util.Arrays;

import java.util.stream.Collectors;

public class JoinTags {

public static void main(String[] args) {

String[] tags = { "java", "streams", "arrays" };

String joined = Arrays.stream(tags)

.collect(Collectors.joining(", "));

System.out.println(joined);

}

}

For a String[], String.join(", ", tags) is also great. I still show the stream form because it composes cleanly when you need mapping/filtering.

5) Building lookup sets from arrays (fast membership checks)

Scenario: you receive a list of allowed IDs (maybe from config, maybe from a database) and you need to check membership repeatedly.

For object arrays, this is straightforward.

Language: Java.

import java.util.Arrays;

import java.util.Set;

import java.util.stream.Collectors;

public class BuildLookupSet {

public static void main(String[] args) {

String[] allowed = { "A12", "B34", "C56", "B34" };

Set allowedSet = Arrays.stream(allowed)

.filter(s -> s != null && !s.isBlank())

.map(String::trim)

.collect(Collectors.toUnmodifiableSet());

System.out.println(allowedSet.contains("B34"));

}

}

If you’re on a JDK where toUnmodifiableSet() isn’t available, Collectors.toSet() works, but it’s mutable.

6) Partitioning and grouping: turning arrays into maps

Scenario: you have String[] of email addresses and want to separate internal vs external.

Language: Java.

import java.util.Arrays;

import java.util.Map;

import java.util.Objects;

import java.util.stream.Collectors;

public class PartitionEmails {

public static void main(String[] args) {

String[] emails = { "[email protected]", "[email protected]", null, "[email protected]" };

Map<Boolean, java.util.List> partitioned = Arrays.stream(emails)

.filter(Objects::nonNull)

.map(String::trim)

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

.collect(Collectors.partitioningBy(e -> e.endsWith("@company.com")));

System.out.println(partitioned);

}

}

A grouping example can look like: “group by top-level domain.”

Language: Java.

import java.util.Arrays;

import java.util.Map;

import java.util.Objects;

import java.util.stream.Collectors;

public class GroupByTld {

public static void main(String[] args) {

String[] emails = { "[email protected]", "[email protected]", "[email protected]", "[email protected]" };

Map<String, java.util.List> byTld = Arrays.stream(emails)

.filter(Objects::nonNull)

.collect(Collectors.groupingBy(e -> {

int idx = e.lastIndexOf(‘.‘);

return idx >= 0 ? e.substring(idx + 1) : "unknown";

}));

System.out.println(byTld);

}

}

7) Sliding windows and chunk processing using the range overload

Scenario: you have int[] of sample values and want to compute rolling averages of a fixed window size.

Streams aren’t always the best tool for sliding windows, but for moderate sizes they can be readable.

Language: Java.

import java.util.Arrays;

public class RollingAverage {

public static void main(String[] args) {

int[] samples = { 10, 20, 30, 40, 50 };

int window = 3;

double[] avgs = java.util.stream.IntStream.rangeClosed(0, samples.length – window)

.mapToDouble(start -> Arrays.stream(samples, start, start + window).average().orElse(0.0))

.toArray();

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

}

}

This example uses IntStream.rangeClosed to generate valid start indexes, and Arrays.stream(samples, start, start + window) to produce the subrange. That combination (indexes + range overload) is one of the cleanest ways I know to express window logic in the standard library.

Arrays.stream() vs alternatives: what I recommend and why

You can turn arrays into stream-like workflows a few different ways. The right choice depends on array type and what you need next.

Here’s the comparison I keep in my head.

Goal

Classic approach

Modern approach I recommend

Why

Iterate an object array

for (T x : arr)

Arrays.stream(arr).forEach(...)

Stream is clearer when you’re chaining transformations; loop is fine for simple side effects.

Iterate a primitive array

for (int x : arr)

Arrays.stream(arr) (primitive stream)

Avoid boxing and avoid Stream.of(int[]) traps.

Convert array to List

Arrays.asList(arr) (object arrays only)

Arrays.stream(arr).toList()

toList() is explicit and works with mapping/filtering; asList is a fixed-size view backed by the array.

Process a subrange

for (i=start; i<end; i++)

Arrays.stream(arr, start, end)

Clear inclusive/exclusive boundaries and composable pipelines.

Handle int[] with stream

IntStream.range + index

Arrays.stream(int[])

Directly expresses element streaming, not index streaming.A few sharp edges worth calling out:

  • Arrays.asList(objectArray) returns a fixed-size list backed by the original array. If you do list.set(0, ...), you mutate the array. If you do list.add(...), you get UnsupportedOperationException.
  • Stream.of(objectArray) usually does what you expect (one element per array slot), but I still prefer Arrays.stream(objectArray) for consistency across object and primitive arrays.
  • Stream.of(primitiveArray) is almost always wrong if you meant “stream the primitives.” If you see it in a PR, stop and re-check intent.

When NOT to use Arrays.stream()

I like Arrays.stream(), but there are cases where I intentionally don’t use it.

1) Tiny, obvious loops with side effects

If the work is “send an email for each address” or “write each line to a file,” a for-loop is often clearer and less error-prone. Streams can hide the side effects inside lambdas, and that’s a maintainability cost.

My rule of thumb: if your pipeline is mostly side effects, the stream is just a fancy loop.

2) Complex control flow (break/continue, early exits, multiple accumulators)

Streams are great for “map/filter/reduce” shapes. They’re awkward for:

  • early exits with complex conditions
  • nested loops with labeled breaks
  • multiple mutable accumulators that must stay in sync

You can force those shapes with streams, but I rarely think the result is more readable.

3) Very hot, CPU-bound micro-optimizations

If you’ve profiled a hotspot and it’s an array traversal doing trivial per-element work, a loop can be faster and more predictable. In those cases, I prioritize the profile.

The nice part is: you can still keep most code readable with streams and only “drop down” to loops where it matters.

Working with multidimensional arrays (T[][] and friends)

Arrays.stream() becomes even more valuable when arrays are nested, because it lets you express “stream the outer array, then flatten.”

Flattening a String[][] into Stream

Language: Java.

import java.util.Arrays;

public class Flatten2D {

public static void main(String[] args) {

String[][] grid = {

{ "a", "b" },

{ "c" },

{ "d", "e", "f" }

};

String joined = Arrays.stream(grid)

.filter(row -> row != null)

.flatMap(row -> Arrays.stream(row))

.filter(s -> s != null)

.reduce((x, y) -> x + y)

.orElse("");

System.out.println(joined);

}

}

A couple notes:

  • The outer stream is Stream.
  • flatMap(row -> Arrays.stream(row)) turns each row array into a stream and flattens them into one Stream.
  • Null handling becomes important at both levels (row can be null; elements can be null).

Flattening an int[][] into IntStream

There’s no Arrays.stream(int[][]) overload that returns an IntStream (because int[][] is an array of int[]), but you can do:

Language: Java.

import java.util.Arrays;

public class FlattenInt2D {

public static void main(String[] args) {

int[][] matrix = {

{ 1, 2, 3 },

{ 4 },

{ 5, 6 }

};

int sum = Arrays.stream(matrix)

.filter(row -> row != null)

.flatMapToInt(row -> Arrays.stream(row))

.sum();

System.out.println(sum);

}

}

This is one of those cases where Arrays.stream() reads like exactly what’s happening, and I find it more maintainable than nested loops when the transformation is simple.

Index-aware processing: when you need positions, not just values

A common pushback I hear is: “streams don’t give me indexes.” That’s true for Stream and primitive element streams from arrays. But you can generate an index stream and map it to values.

This pattern becomes especially useful when you need:

  • “value + index” (like labeling items)
  • comparing adjacent elements
  • building an index-based data structure

Example: validate that values are strictly increasing (with index context)

Language: Java.

import java.util.stream.IntStream;

public class IncreasingCheck {

public static void main(String[] args) {

int[] values = { 10, 11, 11, 13 };

boolean strictlyIncreasing = IntStream.range(1, values.length)

.allMatch(i -> values[i] > values[i – 1]);

System.out.println(strictlyIncreasing);

}

}

This isn’t Arrays.stream() directly—but it pairs with array-based workflows constantly. I often start with Arrays.stream(values) for value-based processing, and switch to IntStream.range when I need positions.

Example: produce "index=value" strings from an array

Language: Java.

import java.util.Arrays;

import java.util.stream.IntStream;

public class IndexLabeling {

public static void main(String[] args) {

String[] items = { "apple", "banana", "cherry" };

String[] labeled = IntStream.range(0, items.length)

.mapToObj(i -> i + "=" + items[i])

.toArray(String[]::new);

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

}

}

Defensive null handling: making pipelines robust

Object arrays can contain null elements, and it’s common in real systems:

  • partially filled arrays
  • arrays created from external inputs
  • arrays where “missing value” is represented as null

I prefer making null handling explicit near the start of the pipeline.

Use Objects::nonNull and stop worrying later

Language: Java.

import java.util.Arrays;

import java.util.Objects;

public class NonNullEarly {

public static void main(String[] args) {

String[] names = { " Alice ", null, "", "Bob" };

String[] cleaned = Arrays.stream(names)

.filter(Objects::nonNull)

.map(String::trim)

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

.toArray(String[]::new);

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

}

}

I call this “buying certainty.” Once I filter nonNull, every later stage can assume non-null and stay clean.

If null is meaningful, don’t filter it away

Sometimes null means something real (like “unknown region”), and filtering it out would be a data loss bug. In those cases I map null into a sentinel value early.

Language: Java.

import java.util.Arrays;

public class NullToSentinel {

public static void main(String[] args) {

String[] regions = { "us-east", null, "eu-west" };

String[] normalized = Arrays.stream(regions)

.map(r -> r == null ? "unknown" : r)

.toArray(String[]::new);

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

}

}

Practical collection targets: lists, sets, maps, and arrays

You rarely stream “just to stream.” Usually you want a concrete structure at the end.

Collecting back to an array

For object streams, I often prefer toArray(String[]::new) over toArray() because it’s type-safe and avoids casts.

Language: Java.

import java.util.Arrays;

public class BackToArray {

public static void main(String[] args) {

String[] raw = { "a", " b ", "", "c" };

String[] out = Arrays.stream(raw)

.map(String::trim)

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

.toArray(String[]::new);

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

}

}

For primitives, you already have toArray() on the primitive stream types.

Collecting to a map safely (handling duplicate keys)

One of the most common stream bugs I see in code review is Collectors.toMap(...) throwing because of duplicate keys.

If duplicates are possible, you should decide how to resolve them.

Language: Java.

import java.util.Arrays;

import java.util.Map;

import java.util.stream.Collectors;

public class ToMapWithMerge {

public static void main(String[] args) {

String[] pairs = { "a=1", "b=2", "a=3" };

Map map = Arrays.stream(pairs)

.map(s -> s.split("=", 2))

.filter(arr -> arr.length == 2)

.collect(Collectors.toMap(

arr -> arr[0],

arr -> Integer.parseInt(arr[1]),

(oldV, newV) -> newV

));

System.out.println(map);

}

}

That merge function is your “policy.” It’s better to make the policy explicit than to rely on an exception happening in production.

Performance and memory notes (with realistic expectations)

When people ask “is Arrays.stream() faster than a loop?” my answer is: for small arrays, it often won’t matter; for hot paths, measure.

In practical systems work:

  • A plain for-loop is hard to beat for raw throughput when the work per element is tiny.
  • A stream pipeline often wins on clarity when you have multiple steps (filter + transform + aggregate).
  • For primitive arrays, primitive streams (IntStream, etc.) avoid boxing and usually behave well.

Costs to keep in mind:

1) Allocation and boxing

  • Stream over boxed numbers (Integer, Long) allocates objects if you start from primitives and box.
  • Collectors can allocate intermediate structures.

2) Lambda overhead and readability

  • The overhead is frequently lost in I/O or database time.
  • In CPU-only loops with extremely low per-element work, the overhead can be noticeable.

3) Parallel streams

  • Arrays.stream() is sequential by default.
  • You can call .parallel() but I rarely recommend doing that “because it’s easy.” Parallel streams can hurt latency in services due to thread contention and work stealing overhead.

If you’re curious about numbers, I’ve seen simple pipelines over arrays of a few thousand elements run in the “single-digit milliseconds” range on developer laptops, and in microseconds when the work is trivial and the JVM is warmed up. The only honest rule is: benchmark in your context (JMH if it’s important), and include warm-up.

A practical performance mindset I use

When I’m reviewing code, I ask:

  • Is the pipeline on the hot path (per request, per record, inside a tight loop)?
  • Are we doing boxing that we can avoid by staying in primitive streams?
  • Are we collecting to a list only to iterate again immediately?
  • Would a single summaryStatistics() or reduce remove multiple passes?

For most non-hot-path code, readability wins. For hot paths, measurement wins.

Common mistakes I see (and how to avoid them)

Mistake 1: Using the wrong stream source for primitive arrays

Bad intent-to-code match:

// This is a Stream with ONE element.

java.util.stream.Stream.of(new int[] { 1, 2, 3 });

What you usually want:

java.util.Arrays.stream(new int[] { 1, 2, 3 });

If you remember only one thing from this post, remember that.

Mistake 2: Off-by-one errors with the range overload

Because the end is exclusive, these are different:

  • Arrays.stream(arr, 0, 3) -> elements at indexes 0,1,2
  • Arrays.stream(arr, 0, 4) -> elements at indexes 0,1,2,3

My habit: I name variables startInclusive and endExclusive in local code when the values aren’t obvious.

Mistake 3: Reusing a stream after a terminal operation

This fails at runtime:

var s = Arrays.stream(words);

s.count();

s.forEach(System.out::println); // IllegalStateException

Fix: create a new stream each time, or materialize a collection once.

Mistake 4: Side effects inside map/filter

You can technically do anything inside a lambda, but pipelines are much easier to reason about when intermediate stages are pure transformations.

If you must record metrics or logs, prefer doing it in a clear, isolated step. For example, I’ll sometimes use peek for debugging locally, but I avoid peek as “business logic.” In code review, I ask: can we keep the pipeline deterministic and move side effects to the edges?

Mistake 5: Null handling surprises

Object arrays can contain null. If your pipeline assumes non-null elements, enforce it:

Arrays.stream(values)

.filter(java.util.Objects::nonNull)

Mistake 6: Confusing array-backed views with copies

Two common surprises:

  • Arrays.asList(arr) is backed by arr (changes reflect both ways), and it’s fixed-size.
  • stream.toList() is unmodifiable (you can’t add/remove), and it’s not backed by the original array.

If your downstream code expects mutability, be explicit:

  • new java.util.ArrayList(Arrays.stream(arr).toList())

Mistake 7: Accidental multiple passes over big arrays

This is subtle because it looks clean:

  • Arrays.stream(ms).min()
  • Arrays.stream(ms).max()
  • Arrays.stream(ms).average()

For small arrays, it’s fine. For large arrays on hot paths, I prefer summaryStatistics() so it’s one traversal.

A quick checklist I use in code reviews

When I see array-to-stream code, I mentally check:

  • If the array is primitive: are we using Arrays.stream(primitiveArray) (not Stream.of)?
  • If there’s a range: are the indices named clearly and validated?
  • Are we filtering nulls (or intentionally mapping them) before dereferencing?
  • Are we boxing when we don’t need to?
  • Are we doing multiple passes when a single-pass collector/statistics would do?
  • Is this pipeline doing side effects in the middle?

If the code passes that checklist, it’s usually in good shape.

Expansion Strategy

Add new sections or deepen existing ones with:

  • Deeper code examples: More complete, real-world implementations
  • Edge cases: What breaks and how to handle it
  • Practical scenarios: When to use vs when NOT to use
  • Performance considerations: Before/after comparisons (use ranges, not exact numbers)
  • Common pitfalls: Mistakes developers make and how to avoid them
  • Alternative approaches: Different ways to solve the same problem

If Relevant to Topic

  • Modern tooling and AI-assisted workflows (for infrastructure/framework topics)
  • Comparison tables for Traditional vs Modern approaches
  • Production considerations: deployment, monitoring, scaling
Scroll to Top