skip to content
everything.functional.primitiveStreams

primitive streams

IntStream and friends, and the cost of boxing.

chapter
chapter 07.08
reading time
6 min read

a Stream<Integer> is a stream of objects: every number is boxed into its own Integer on the heap. IntStream, LongStream and DoubleStream carry raw primitives instead, and they add the number-y terminal operations you actually want: sum, average, max, summaryStatistics.

convert with mapToInt / mapToLong / mapToDouble, and go back with boxed() or mapToObj.

the picture

you know how List<Integer> can't hold an int, so java quietly wraps each one in an Integer? a Stream<Integer> has the same problem, and in a stream it happens once per element, sometimes twice (once going in, once for every intermediate result).

think of shipping marbles. you can put every marble in its own little gift box with a label (boxing), or pour them straight into a tube (primitives). the gift boxes cost cardboard and somebody has to throw them away afterwards. that somebody is the garbage collector.

stack
main
boxed
raw
heap
Integer[]
0
1
2
Integer
1000
Integer
1001
Integer
1002
int[]
10000
10011
10022
three ints, two ways. a list of Integer is an array of references to separate objects (each 16 bytes on a 64-bit JVM, plus the 4-byte reference). an int[] is just the numbers, back to back.

what boxing actually costs

summing a million ints, four ways. measured on jdk 25 on an arm mac: bytes allocated by the summing thread for one call, after warm-up.

IntStream.range(0, n).sum()144 bytes
list.stream().mapToInt(Integer::intValue).sum()232 bytes
list.stream().reduce(0, Integer::sum)15,999,880 bytes
IntStream.range(0, n).boxed().reduce(0, Integer::sum)31,997,904 bytes
log scale
bytes allocated per call, log scale. boxed().reduce boxes every element and every partial sum: a million Integers each way, 16 bytes apiece. mapToInt on the same boxed list unboxes on the fly and allocates next to nothing.

time follows the garbage. in the same run (best of 100 calls, timed with System.nanoTime, so treat it as rough, not a JMH result) the primitive sum took about 1.0 ms and the boxed one about 3.8 ms. the allocation numbers are the solid part: they don't move between runs.

the four kinds, and how to get between them

boxed() · mapToObj(f)asLongStream()asDoubleStream()Stream<T>IntStreamLongStreamDoubleStream
holdsraw ints. no objects, no boxing.leaves viaboxed() · mapToObj(f) → Stream<T>asLongStream() → LongStreamasDoubleStream() → DoubleStreammake oneIntStream.range(a, b), rangeClosed, of, iterate, "text".chars(), new Random().ints()only heresum() → int, average() → OptionalDouble, max() → OptionalInt, summaryStatistics()exampleIntStream.rangeClosed(1, 5).sum()
click a stream kind. the arrows are the methods that leave it. widening between primitives is free (asLongStream), anything that could lose information needs a function you write (mapToInt(f)), and the only way back to objects is boxed() or mapToObj.
Main.java
import java.util.*;
import java.util.stream.*;
 
void main() {
    IO.println(IntStream.range(0, 5).boxed().toList());     // end exclusive
    IO.println(IntStream.rangeClosed(1, 5).sum());           // end inclusive
    IO.println(IntStream.of(4, 8, 15, 16, 23, 42).summaryStatistics());
    IO.println(IntStream.of(4, 8, 15).average());            // OptionalDouble
    IO.println(IntStream.empty().average());                 // nothing to average
 
    List<String> words = List.of("fig", "kiwi", "avocado");
    IO.println(words.stream().mapToInt(String::length).sum());       // objects → ints
    IO.println(IntStream.range(0, 3).mapToObj(i -> "#" + i).toList()); // ints → objects
    IO.println(IntStream.iterate(1, i -> i <= 100, i -> i * 3).boxed().toList());
}
output
[0, 1, 2, 3, 4]
15
IntSummaryStatistics{count=6, sum=108, min=4, average=18.000000, max=42}
OptionalDouble[9.0]
OptionalDouble.empty
14
[#0, #1, #2]
[1, 3, 9, 27, 81]

summaryStatistics() is the underrated one: count, sum, min, average and max in a single pass. and IntStream.range(0, n) is the stream version of for (int i = 0; i < n; i++).

predict the output
java
"hi".chars().forEach(System.out::print);

gotchas

quick check
which of these allocates one Integer per element?
one level deeper:why not just Stream<int>?+

because java generics only work with reference types: Stream<int> isn't legal, so the JDK had to write IntStream and friends by hand, three times over, plus the 30-odd primitive functional interfaces in java.util.function (see functional interfaces). it stopped at int, long and double, which is why there's no CharStream or FloatStream. project valhalla aims to fix the root problem by letting generics work over primitive-like value types. see valhalla.

keylines
  • Stream<Integer> boxes every element. IntStream doesn't.
  • mapToInt / mapToLong / mapToDouble to get primitive, boxed() / mapToObj to get back.
  • primitive streams add sum, average, max, summaryStatistics, and range.
  • IntStream.sum() can overflow. widen with asLongStream() when the total might be big.
  • "text".chars() is an IntStream of codes, not characters.