JAVA / STREAMS, LAMBDAS AND OPTIONAL
Primitive streams and avoiding boxing overhead
Move between Stream<Integer> and IntStream deliberately, so numeric pipelines carry raw values and use sum, average and summaryStatistics instead of reduce.
What you will learn
- Convert with mapToInt/mapToLong/mapToDouble and return with boxed() or mapToObj()
- Replace reduce(0, Integer::sum) with sum(), average() or summaryStatistics()
- Read OptionalInt and OptionalDouble results with getAsInt, getAsDouble or orElse
- Widen with asLongStream() before sum() when an int total could overflow
Understanding Primitive streams and avoiding boxing overhead
Java generics only range over reference types, so there is no Stream<int>; a Stream<Integer> is a stream of pointers to heap objects, each wrapping a single 4-byte value inside an object header. Every arithmetic step then costs a dereference to read the int back out, and every new numeric result costs an Integer.valueOf call. IntStream, LongStream and DoubleStream exist to delete that layer: their elements are plain 32- or 64-bit values handed along the pipeline the way an argument is handed to a method. The mental model to carry is that Stream<Integer> moves addresses while IntStream moves numbers.
You cross between the two worlds at named points: mapToInt, mapToLong and mapToDouble go in, boxed() and mapToObj come back out, and asLongStream/asDoubleStream widen one primitive stream into another. Because the element type is fixed, the lambda types change too. IntStream.map wants an IntUnaryOperator, whose single method is int applyAsInt(int), and that signature is precisely why no wrapper ever appears. The same reasoning explains the results: max() returns OptionalInt and average() returns OptionalDouble, since an Optional<Integer> would box the one value you were trying to keep unboxed.
Primitive streams also change what the terminal stage can offer. sum(), average(), min(), max() and summaryStatistics() are built in, so the reduce(0, Integer::sum) boilerplate disappears and a null element can no longer blow up with a NullPointerException. The trade is that widths become your problem: IntStream.sum() returns int and wraps silently past 2147483647, so widen before summing when totals can be large. HotSpot's escape analysis sometimes removes boxing on its own, but only where it can inline and prove the wrapper never escapes, which is not something to count on across a real pipeline.
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.IntStream;
public class PrimitiveStreamsDemo {
public static void main(String[] args) {
List<String> words = List.of("stream", "box", "unbox", "int");
// Object pipeline: every length is wrapped in an Integer before it can flow
int viaObjects = words.stream()
.map(String::length) // Stream<Integer>
.reduce(0, Integer::sum);
// Primitive pipeline: the same lengths travel as raw int values
int viaPrimitives = words.stream()
.mapToInt(String::length) // IntStream
.sum();
System.out.println("viaObjects = " + viaObjects);
System.out.println("viaPrimitives = " + viaPrimitives);
IntSummaryStatistics stats = words.stream()
.mapToInt(String::length)
.summaryStatistics();
System.out.println("count=" + stats.getCount()
+ " sum=" + stats.getSum()
+ " min=" + stats.getMin()
+ " max=" + stats.getMax()
+ " avg=" + stats.getAverage());
int sumOfSquares = IntStream.rangeClosed(1, 5)
.map(n -> n * n)
.sum();
System.out.println("sumOfSquares = " + sumOfSquares);
// Box only where objects are genuinely required
List<Integer> squares = IntStream.rangeClosed(1, 5)
.map(n -> n * n)
.boxed()
.toList();
System.out.println("squares = " + squares);
}
}Stream<Integer> carries heap-allocated wrappers while IntStream carries raw values, so convert once at the boundary and stay primitive until the pipeline ends.
Worked examples
Primitive results and their optionals
Shows why terminal stages on an IntStream hand back OptionalInt, OptionalDouble or a plain int rather than wrappers.
import java.util.OptionalInt;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class PrimitiveResults {
public static void main(String[] args) {
int[] temps = {18, 24, 21};
OptionalInt hottest = IntStream.of(temps).max();
OptionalInt nothing = IntStream.of(new int[0]).max();
String labels = IntStream.of(temps)
.mapToObj(t -> t + "C")
.collect(Collectors.joining(", "));
System.out.println("hottest = " + hottest.getAsInt());
System.out.println("empty? = " + nothing.isPresent());
System.out.println("orElse = " + nothing.orElse(-1));
System.out.println("empty sum = " + IntStream.of(new int[0]).sum());
System.out.println("mean = " + IntStream.of(temps).average().orElse(0.0));
System.out.println("labels = " + labels);
}
}Example explained
Line 1max() returns OptionalInt, not Optional<Integer>, so the answer itself is never wrapped; getAsInt() reads it out.
Line 2An empty stream has no maximum, hence the empty optional, while sum() is defined as 0 because addition has an identity value.
Line 3average() returns OptionalDouble because the mean of ints is usually not an int, and orElse(0.0) states what the empty case means here.
Line 4mapToObj is the exit door from the primitive world: once each element becomes a String, the ordinary Stream API is available again.
sum() is an int, and it wraps
Demonstrates the silent overflow of IntStream.sum() and the two primitive-only ways to widen the arithmetic.
import java.util.stream.IntStream;
public class WideningSums {
public static void main(String[] args) {
int[] big = {2_000_000_000, 2_000_000_000};
System.out.println("int sum = " + IntStream.of(big).sum());
System.out.println("long sum = " + IntStream.of(big).asLongStream().sum());
System.out.println("mapToLong = " + IntStream.of(big).mapToLong(n -> n).sum());
System.out.println("average = " + IntStream.of(big).average().getAsDouble());
}
}Example explained
Line 1IntStream.sum() is declared to return int, so the true total of 4000000000 wraps around 2^32 and prints negative with no exception.
Line 2asLongStream() widens every element to long before any addition happens, which is why the same data now totals correctly.
Line 3mapToLong(n -> n) is the same fix spelled out: the IntToLongFunction widens by primitive conversion, so no Integer or Long is allocated.
Line 4average() escapes the problem because the running total is accumulated in a long and only then divided into a double.
Bridging arrays and boxed lists
Contrasts the correct entry points into a primitive stream with the int[] trap that produces a one-element object stream.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class ArrayBridges {
public static void main(String[] args) {
List<Integer> ids = List.of(7, 3, 9);
int[] raw = {7, 3, 9};
System.out.println("unboxed sum = " + ids.stream().mapToInt(Integer::intValue).sum());
System.out.println("array max = " + Arrays.stream(raw).max().getAsInt());
System.out.println("wrapped = " + Stream.of(raw).count());
System.out.println("flattened = " + Stream.of(raw).flatMapToInt(Arrays::stream).sum());
}
}Example explained
Line 1mapToInt(Integer::intValue) unboxes once at the boundary; every later stage works on raw ints, and mapToInt(i -> i) compiles to the same thing.
Line 2Arrays.stream is overloaded for int[] and returns an IntStream, which is why max() and getAsInt() are available without any wrapper.
Line 3Stream.of(raw) cannot spread an int[] into varargs because int[] is not an Object[], so you get a Stream<int[]> of size 1 and count() prints 1.
Line 4flatMapToInt turns each int[] element into an IntStream and splices them together, giving one primitive stream to sum.
Important notes
Integer.valueOf caches -128..127, so a boxed stream of tiny numbers reuses shared instances; a micro test on small values will understate what boxing costs on real data such as ids, hashes or timestamps.
There are only three specializations. String.chars() therefore returns an IntStream, so forEach(System.out::println) prints code point numbers, and you need mapToObj(c -> (char) c) to see characters.
Common mistakes
Writing words.stream().map(String::length).sum(): Stream<Integer> has no sum() method, so it fails to compile, and no cast helps because mapToInt is what changes the stream type.
Trusting IntStream.sum() for large data: it returns int, so a total past 2147483647 wraps to a negative number and the bug shows up as plausible-looking wrong output rather than an error.
Writing mapToInt(...).boxed().reduce(0, Integer::sum): boxed() re-creates a wrapper for every element and every partial total, paying back the exact cost the conversion removed.
Try it yourself
Change, predict, then run
Take List.of("alpha", "bee", "cactus", "dew") and print the total, longest and mean word length from a single mapToInt(...).summaryStatistics() call. Then add a second version that computes the total with asLongStream().sum() and confirm both totals agree.
Open the Java workspaceCheck your understanding
All four pipelines below compute the same total word length for a List<String>. Which one never converts a length into an Integer object?
- words.stream().map(String::length).reduce(0, Integer::sum)
- words.stream().mapToInt(String::length).sum()
- words.stream().map(String::length).mapToInt(Integer::intValue).sum()
- words.stream().mapToInt(String::length).boxed().reduce(0, Integer::sum)
Show answer
Option 1 passes the int returned by String.length() straight into an int-accumulating terminal, so Integer.valueOf is never called anywhere in the pipeline. Option 2 is tempting because it also ends in sum(), but map(String::length) has already boxed every length and mapToInt merely unwraps them afterwards, so the boxing has happened before the primitive stage begins.