Java 8 Summarizing Methods with Examples
-
Last Updated: May 4, 2024
-
By: javahandson
-
Series
Learn Java in a easy way
Java 8 summarizing methods give you count, sum, min, max, and average from a single pass over a stream. This guide covers summarizingInt, summarizingLong, summarizingDouble, the statistics objects they return, and the empty-stream trap that catches almost everyone.
Java 8 summarizing methods solve a small problem you hit over and over. You have a list of objects, and you need five numbers out of it: how many, the total, the smallest, the biggest, and the average.
The Streams API packs all of it into a single collector. One pass, one object, five getters. Think of a cashier counting a till: they do not count the notes once for the total and again for the largest note. They handle each note once and track everything as they go.
Java gives you three flavours of this collector, one per numeric type. They behave identically, so learning one teaches you all three. The interesting parts are the object they hand back and the surprising thing it does when the stream is empty.
We start with the problem these collectors fix, then work through each variant with a runnable example. Here is the plan:
Basic familiarity with streams and method references helps. If either feels new, our guides on lambda expressions and method references are a good warm-up.
Before the syntax, let us look at the pain these collectors remove. It shows up in almost every reporting task.
Say you need a quick summary of student marks. Without a summarizing collector, the code sprawls.
long count = studentList.stream().count(); int total = studentList.stream().mapToInt(Student::getMarks).sum(); int max = studentList.stream().mapToInt(Student::getMarks).max().orElse(0); int min = studentList.stream().mapToInt(Student::getMarks).min().orElse(0); double avg = studentList.stream().mapToInt(Student::getMarks).average().orElse(0);
Five statements, five passes over the list. Each one repeats the same mapToInt call, and each one needs its own fallback for the empty case.
A summarizing collector collapses all five lines into one.
IntSummaryStatistics stats = studentList.stream()
.collect(Collectors.summarizingInt(Student::getMarks));
System.out.println(stats.getCount());
System.out.println(stats.getSum());
System.out.println(stats.getMin());
System.out.println(stats.getMax());
System.out.println(stats.getAverage());The collector visits each student once. As it goes, it bumps a counter, adds to a running total, and keeps the smallest and largest values seen so far. The average falls out of the total and the count at the end.
All three methods sit in java.util.stream.Collectors, next to toList, groupingBy, and the rest of the factory methods.
Our introduction to the Collectors class maps out the wider family if you want the full picture first.
Three methods, three numeric types, one shared design. Learn the shape once and you have learned all of them.
| Method | Mapper | Returns | sum / min / max |
|---|---|---|---|
| summarizingInt | ToIntFunction | IntSummaryStatistics | long / int / int |
| summarizingLong | ToLongFunction | LongSummaryStatistics | long / long / long |
| summarizingDouble | ToDoubleFunction | DoubleSummaryStatistics | double / double / double |
That last column lists the types of getSum(), getMin(), and getMax(), in that order. One row there deserves a second look. The int variant returns a long from getSum(), not an int, and section 4.5 explains why that choice matters.
The rule is simple: match the variant to the type your getter already returns.
Never narrow the other way with a cast. Squeezing a long id into summarizingInt silently mangles large values, and section 11.2 shows exactly how.
Start here. Once this variant clicks, the other two take about a minute each.
public static <T> Collector<T, ?, IntSummaryStatistics>
summarizingInt(ToIntFunction<? super T> mapper)Generic signatures look worse than they are. Break this one into four pieces and it turns friendly.
Start with public static. The method belongs to the class rather than an instance, so you always write Collectors.summarizingInt(…).
Then comes <T>, the type your stream carries. Stream a list of Student objects and T becomes Student. Generics keep the method usable with any class you write.
Next is the return type, Collector<T, ?, IntSummaryStatistics>. A Collector declares three types:
Finally, ToIntFunction<? super T> mapper is the extractor. It is a functional interface that takes one object and returns a plain int. Write it as a method reference such as Student::getMarks, or as a lambda such as s -> s.getMarks().
package com.javahandson.collectors.summarizing;
public class Student {
int rollNumber;
String name;
int marks;
public Student(int rollNumber, String name, int marks) {
this.rollNumber = rollNumber;
this.name = name;
this.marks = marks;
}
public String getName() {
return name;
}
public int getMarks() {
return marks;
}
}Let us pull a full summary of the class marks in one shot.
package com.javahandson.collectors.summarizing;
import java.util.Arrays;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public class SummarizingIntDemo {
public static void main(String[] args) {
List<Student> studentList = Arrays.asList(
new Student(101, "Suraj", 450),
new Student(102, "Iqbal", 470),
new Student(103, "Amar", 430),
new Student(104, "Amit", 400),
new Student(105, "Suchit", 380),
new Student(106, "Kartik", 490));
IntSummaryStatistics marksStats = studentList.stream()
.collect(Collectors.summarizingInt(Student::getMarks));
System.out.println("Number of students: " + marksStats.getCount());
System.out.println("Total marks: " + marksStats.getSum());
System.out.println("Maximum marks: " + marksStats.getMax());
System.out.println("Minimum marks: " + marksStats.getMin());
System.out.println("Average marks: " + marksStats.getAverage());
}
}
// Output:
// Number of students: 6
// Total marks: 2620
// Maximum marks: 490
// Minimum marks: 380
// Average marks: 436.6666666666667Six students, one pass, five answers. Check the sum if you like. The six marks add up to 2620, and six goes into that 436.67 times.
You can also print the object directly. Its toString packs everything onto one line, which is handy while debugging.
System.out.println(marksStats);
// Output:
// IntSummaryStatistics{count=6, sum=2620, min=380, average=436.666667, max=490}Here is a design detail worth knowing. You feed int values in, yet getSum() hands back a long.
The reason is overflow. An int tops out just above two billion. Add up ten thousand salaries or a million page-view counts, and an int total wraps around into a negative number without any warning.
A long holds roughly nine quintillion. To overflow it with int inputs you would need billions of elements, which no realistic stream reaches. So the sum stays correct no matter how long your list grows.
long total = marksStats.getSum(); // long, safe from overflow int worst = marksStats.getMin(); // int, matches the input type int best = marksStats.getMax(); // int long howMany = marksStats.getCount(); double mean = marksStats.getAverage();
Keep this in mind when you assign the result. Writing int total = marksStats.getSum(); fails to compile, and that error is the compiler protecting you.
Same idea, wider numbers. Use this variant when your extractor returns a long.
public static <T> Collector<T, ?, LongSummaryStatistics>
summarizingLong(ToLongFunction<? super T> mapper)Put the two signatures side by side and only two words move.
Typical long fields include database ids, epoch timestamps, file sizes in bytes, and view counters. Anything that might outgrow two billion belongs here.
package com.javahandson.collectors.summarizing;
public class Product {
long productId;
String productName;
double price;
public Product(long productId, String productName, double price) {
this.productId = productId;
this.productName = productName;
this.price = price;
}
public long getProductId() {
return productId;
}
public String getProductName() {
return productName;
}
public double getPrice() {
return price;
}
}package com.javahandson.collectors.summarizing;
import java.util.Arrays;
import java.util.List;
import java.util.LongSummaryStatistics;
import java.util.stream.Collectors;
public class SummarizingLongDemo {
public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product(101L, "Wheat", 155.50),
new Product(102L, "Rice", 255.00),
new Product(103L, "Cooking Oil", 525.70),
new Product(104L, "Cookies", 200.00),
new Product(105L, "Beans", 150.50));
LongSummaryStatistics idStats = products.stream()
.collect(Collectors.summarizingLong(Product::getProductId));
System.out.println(idStats);
}
}
// Output:
// LongSummaryStatistics{count=5, sum=515, min=101, average=103.000000, max=105}Section 4.5 explained how the int variant escapes overflow by widening to long. The long variant has no wider type to escape into.
So a LongSummaryStatistics sum can wrap around. Java does not throw here. It quietly rolls past the maximum into negative territory, and your report shows a total that makes no sense.
The third variant handles anything with a decimal point. Prices, salaries, ratings, temperatures, and percentages all land here.
public static <T> Collector<T, ?, DoubleSummaryStatistics>
summarizingDouble(ToDoubleFunction<? super T> mapper)By now the shape should feel familiar. Swap in ToDoubleFunction, get back a DoubleSummaryStatistics, and everything else carries over.
package com.javahandson.collectors.summarizing;
import java.util.Arrays;
import java.util.DoubleSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public class SummarizingDoubleDemo {
public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product(101L, "Wheat", 155.50),
new Product(102L, "Rice", 255.00),
new Product(103L, "Cooking Oil", 525.70),
new Product(104L, "Cookies", 200.00),
new Product(105L, "Beans", 150.50));
DoubleSummaryStatistics priceStats = products.stream()
.collect(Collectors.summarizingDouble(Product::getPrice));
System.out.println(priceStats);
System.out.printf("Cheapest: %.2f%n", priceStats.getMin());
System.out.printf("Average : %.2f%n", priceStats.getAverage());
}
}
// Output:
// DoubleSummaryStatistics{count=5, sum=1286.700000, min=150.500000, average=257.340000, max=525.700000}
// Cheapest: 150.50
// Average : 257.34That sum=1286.700000 looks odd at first. Nobody stored six decimal places anywhere.
The explanation is plain formatting. Inside toString, the JDK uses the %f pattern, and %f always prints six decimals. The stored value is still an ordinary double.
Adding many doubles loses precision. Each addition rounds a little, and those roundings pile up across thousands of values.
DoubleSummaryStatistics fights back with compensated summation. It tracks the tiny error left by each addition and folds it back in later. You get a noticeably more accurate total than a plain running += would produce.
One value defeats it completely, though. Feed in a single NaN and the sum, the average, the minimum, and the maximum all turn into NaN. There is no partial recovery.
These three classes live in java.util, not in the stream package. They are ordinary objects you can create, feed, and merge by hand.
The three variant columns below stand for IntSummaryStatistics, LongSummaryStatistics, and DoubleSummaryStatistics.
| Getter | Int variant | Long variant | Double variant |
|---|---|---|---|
| getCount() | long | long | long |
| getSum() | long | long | double |
| getMin() | int | long | double |
| getMax() | int | long | double |
| getAverage() | double | double | double |
Notice that getCount() and getAverage() never change type. A count is always a long, and an average is always a double, whatever went in.
You do not need a stream at all. Create the object, call accept per value, and read the getters.
IntSummaryStatistics stats = new IntSummaryStatistics();
stats.accept(450);
stats.accept(470);
stats.accept(380);
System.out.println(stats);
// Output:
// IntSummaryStatistics{count=3, sum=1300, min=380, average=433.333333, max=470}The combine method folds one statistics object into another. Counts add up, sums add up, and the extremes stretch to cover both sides.
IntSummaryStatistics morning = new IntSummaryStatistics();
morning.accept(450);
morning.accept(470);
IntSummaryStatistics evening = new IntSummaryStatistics();
evening.accept(380);
evening.accept(490);
morning.combine(evening);
System.out.println(morning);
// Output:
// IntSummaryStatistics{count=4, sum=1790, min=380, average=447.500000, max=490}This method is exactly what makes parallel streams work. Each worker thread builds its own statistics object, and the framework merges them pairwise at the end.
The javadoc says it plainly: these classes are not thread-safe. Two threads calling accept on one shared object will corrupt the counts.
Yet a parallel stream with summarizingInt is perfectly safe. Why the difference? The framework never shares one object. It hands each thread a private instance and merges them with combine afterwards.
So the rule is easy. Let the collector manage the objects and go parallel freely. Build one by hand and you own the synchronisation. Our article on parallel streams in Java 8 covers when parallel is worth it at all.
This section is the one to remember. It causes real bugs, and the behaviour surprises almost everybody the first time.
Run a summarizing collector over an empty list. You might expect zeros everywhere, or maybe an exception. Neither happens.
List<Student> nobody = new ArrayList<>();
IntSummaryStatistics stats = nobody.stream()
.collect(Collectors.summarizingInt(Student::getMarks));
System.out.println(stats);
// Output:
// IntSummaryStatistics{count=0, sum=0, min=2147483647, average=0.000000, max=-2147483648}Read those two extremes again. The minimum came back as 2147483647, which is Integer.MAX_VALUE. The maximum came back as -2147483648, which is Integer.MIN_VALUE.
That behaviour is deliberate, and the logic is sound. To find a minimum, you start at the highest possible value and lower it as smaller numbers arrive. If nothing ever arrives, that starting value survives untouched.
| Getter | Int variant | Long variant | Double variant |
|---|---|---|---|
| getCount() | 0 | 0 | 0 |
| getSum() | 0 | 0 | 0.0 |
| getMin() | 2147483647 | 9223372036854775807 | Infinity |
| getMax() | -2147483648 | -9223372036854775808 | -Infinity |
| getAverage() | 0.0 | 0.0 | 0.0 |
The double variant is the loudest of the three. It reports Infinity and -Infinity, which at least looks obviously wrong on screen rather than plausibly wrong.
Count and sum stay sensible in every case. The average comes back as 0.0 too, so nothing ever divides by zero.
The fix takes one line. Check the count before you trust the extremes.
IntSummaryStatistics stats = studentList.stream()
.collect(Collectors.summarizingInt(Student::getMarks));
if (stats.getCount() == 0) {
System.out.println("No students yet.");
} else {
System.out.println("Lowest : " + stats.getMin());
System.out.println("Highest: " + stats.getMax());
}Make that guard a habit in any code that prints or stores a minimum or maximum. Count and sum you can read straight out; the extremes always deserve the check.
The Collectors class is not the only route to these figures. Two alternatives come up constantly in interviews and code reviews.
Primitive streams carry their own summaryStatistics() method. Map to an IntStream first, then ask it directly.
// Route A: the collector
IntSummaryStatistics viaCollector = studentList.stream()
.collect(Collectors.summarizingInt(Student::getMarks));
// Route B: the primitive stream
IntSummaryStatistics viaStream = studentList.stream()
.mapToInt(Student::getMarks)
.summaryStatistics();
// Both produce identical numbersRoute B reads a little more naturally for a simple case. Route A wins the moment you need the statistics nested inside another collector, which section 10 demonstrates.
When you genuinely need one number, the narrower collectors say so more clearly.
int total = studentList.stream()
.collect(Collectors.summingInt(Student::getMarks)); // 2620
double mean = studentList.stream()
.collect(Collectors.averagingInt(Student::getMarks)); // 436.6666666666667Two details separate them from the summarizing family. summingInt returns an Integer, so it can overflow where getSum() cannot. And averagingInt returns a plain 0.0 on an empty stream, with no way to tell that apart from a genuine average of zero.
Our article on the summing method in the Collectors API goes through that family in detail.
Here is where summarizing collectors really earn their place. Because they are Collectors, you can drop them into any collector that accepts a downstream.
Pass a summarizing collector as the downstream and every group gets its own full set of numbers.
Map<String, IntSummaryStatistics> byBand = studentList.stream()
.collect(Collectors.groupingBy(
student -> student.getMarks() >= 450 ? "TOP" : "REST",
Collectors.summarizingInt(Student::getMarks)));
System.out.println("TOP : " + byBand.get("TOP"));
System.out.println("REST : " + byBand.get("REST"));
// Output:
// TOP : IntSummaryStatistics{count=3, sum=1410, min=450, average=470.000000, max=490}
// REST : IntSummaryStatistics{count=3, sum=1210, min=380, average=403.333333, max=430}The other three total 1210, which averages to 403.33. All of it comes from one pass over the list. Our guide on grouping in Java 8 covers the classifier side properly.
When the split is a simple yes or no, partitioningBy reads better and guarantees both keys exist.
Map<Boolean, IntSummaryStatistics> byPass = studentList.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() >= 420,
Collectors.summarizingInt(Student::getMarks)));
System.out.println("Passed : " + byPass.get(true));
System.out.println("Failed : " + byPass.get(false));
// Output:
// Passed : IntSummaryStatistics{count=4, sum=1840, min=430, average=460.000000, max=490}
// Failed : IntSummaryStatistics{count=2, sum=780, min=380, average=390.000000, max=400}Four students cleared 420 and two did not. Because partitioningBy always fills both keys, neither get call can return null, even when every student passes.
That guarantee is the main reason to prefer it over a grouping with a boolean classifier. Our article on partitioning in Java 8 digs into the difference.
Six mistakes cover nearly every summarizing bug we run into during code reviews.
A ToIntFunction must return an int, so the compiler rejects a long getter. Developers often silence that with a cast, and the cast destroys large values.
// Wrong: the cast wraps ids above 2147483647 into negatives Collectors.summarizingInt(product -> (int) product.getProductId()); // Right: match the variant to the type Collectors.summarizingLong(Product::getProductId);
Treat a cast inside a mapper as a warning sign. It usually means you reached for the wrong variant.
Doubles cannot represent every decimal exactly. Adding 0.1 ten times does not land on 1.0, and those tiny gaps become visible on an invoice.
Compensated summation reduces the drift, yet it cannot make a double exact. For real currency work, keep amounts in BigDecimal, or store paise and cents as a long and use summarizingLong.
For a dashboard average or an approximate report, summarizingDouble is fine. Just keep it away from anything a customer gets billed for.
A stream works exactly once. Store one in a variable, collect from it twice, and the second call throws.
Stream<Student> stream = studentList.stream(); IntSummaryStatistics a = stream.collect(Collectors.summarizingInt(Student::getMarks)); // IllegalStateException: stream has already been operated upon or closed IntSummaryStatistics b = stream.collect(Collectors.summarizingInt(Student::getMarks));
Call studentList.stream() fresh each time. The source list stays reusable; only the stream burns out.
One NaN ruins every figure in a DoubleSummaryStatistics. Filter suspect values before they reach the collector.
DoubleSummaryStatistics safe = products.stream()
.filter(product -> !Double.isNaN(product.getPrice()))
.collect(Collectors.summarizingDouble(Product::getPrice));Better still, stop NaN entering your objects at all. Validate at the boundary where the data arrives, and the rest of the pipeline stays clean.
Let us build one small report that uses two variants and a grouping together.
We have a list of sales, each with a region, a unit count, and an amount. The team wants a short summary:
Units are whole numbers and money has decimals, so this needs both summarizingInt and summarizingDouble.
package com.javahandson.collectors.summarizing;
import java.util.Arrays;
import java.util.DoubleSummaryStatistics;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class SalesReport {
static class Sale {
private final String region;
private final int units;
private final double amount;
Sale(String region, int units, double amount) {
this.region = region;
this.units = units;
this.amount = amount;
}
public String getRegion() { return region; }
public int getUnits() { return units; }
public double getAmount() { return amount; }
}
public static void main(String[] args) {
List<Sale> sales = Arrays.asList(
new Sale("North", 12, 1500.00),
new Sale("South", 8, 900.50),
new Sale("North", 20, 2400.00),
new Sale("East", 5, 620.25),
new Sale("South", 15, 1750.75));
IntSummaryStatistics unitStats = sales.stream()
.collect(Collectors.summarizingInt(Sale::getUnits));
DoubleSummaryStatistics moneyStats = sales.stream()
.collect(Collectors.summarizingDouble(Sale::getAmount));
Map<String, DoubleSummaryStatistics> byRegion = sales.stream()
.collect(Collectors.groupingBy(Sale::getRegion,
Collectors.summarizingDouble(Sale::getAmount)));
if (unitStats.getCount() == 0) {
System.out.println("No sales recorded.");
return;
}
System.out.println("Orders : " + unitStats.getCount());
System.out.println("Total units : " + unitStats.getSum());
System.out.println("Biggest order: " + unitStats.getMax() + " units");
System.out.printf("Revenue : %.2f%n", moneyStats.getSum());
System.out.printf("Average sale : %.2f%n", moneyStats.getAverage());
System.out.println("North revenue: " + byRegion.get("North").getSum());
System.out.println("South average: " + byRegion.get("South").getAverage());
System.out.println("East orders : " + byRegion.get("East").getCount());
}
}
// Output:
// Orders : 5
// Total units : 60
// Biggest order: 20 units
// Revenue : 7171.50
// Average sale : 1434.30
// North revenue: 3900.0
// South average: 1325.625
// East orders : 1Check the numbers against the data. The units 12, 8, 20, 5, and 15 add up to 60, and 20 is the largest. Revenue comes to 7171.50, which divided across five orders gives an average of 1434.30.
Region figures fall out of the grouping. North sold 1500.00 plus 2400.00, so 3900.0. South sold 900.50 plus 1750.75, and halving that total gives 1325.625.
Two habits from earlier sections show up in this code. The count guard runs before anything reads a maximum, and printf formats the money rather than trusting toString.
A: They are collectors in java.util.stream.Collectors that compute count, sum, minimum, maximum, and average in a single pass over a stream. The three variants are summarizingInt, summarizingLong, and summarizingDouble, and each returns a matching SummaryStatistics object.
A: Only the numeric type changes. summarizingInt takes a ToIntFunction and returns IntSummaryStatistics. summarizingLong takes a ToLongFunction and returns LongSummaryStatistics. summarizingDouble takes a ToDoubleFunction and returns DoubleSummaryStatistics. Match the variant to the type your getter already returns.
A: Count and sum come back as 0 and the average as 0.0, but getMin() returns Integer.MAX_VALUE (2147483647) and getMax() returns Integer.MIN_VALUE (-2147483648). Always check getCount() before you trust the minimum or maximum.
A: To avoid overflow. An int caps out just above two billion, so adding many int values could wrap into a negative total. Widening the sum to long makes overflow practically impossible for any realistic stream size.
A: summingInt gives you one number and returns an Integer, which can overflow. summarizingInt gives you all five figures in one object, and its sum is a long. Use summingInt when you truly need only the total, and summarizingInt whenever you need two or more figures.
A: Yes. Pass Collectors.summarizingInt(…) as the downstream collector and every group gets its own statistics object. The same works with partitioningBy, which additionally guarantees that both the true and false keys exist.
A: No. The class carries no synchronisation, so two threads calling accept() on one shared instance will corrupt it. A parallel stream is still safe, because the framework gives each thread a private instance and merges them with combine().
A: Both produce identical numbers in a single pass. After a mapToInt call, summaryStatistics() reads more naturally. Collectors.summarizingInt is the only option when you need to nest the statistics inside groupingBy or partitioningBy.
A: Its toString method formats the numbers with %f, and %f always shows six decimals. The stored values are ordinary doubles. Read them with the getters and format the output yourself using printf or DecimalFormat.
A: The sum, average, minimum, and maximum all become NaN. A single bad value poisons every figure, so filter with Double.isNaN() before collecting, or validate the data where it enters your system.
A: Not for anything a customer gets billed for. A double cannot represent every decimal exactly, and small errors accumulate. Use BigDecimal for currency, or store the smallest unit such as paise or cents as a long and use summarizingLong.
A: Call combine() on one and pass the other. Counts and sums add together, and the minimum and maximum stretch to cover both. The call mutates the receiver and leaves the argument unchanged, which is exactly how parallel streams merge partial results.
Let us wrap up what we covered. Java 8 summarizing methods pull count, sum, minimum, maximum, and average out of a stream in one pass, instead of five.
Three variants cover the numeric types. Pick summarizingInt for int fields, summarizingLong for ids and timestamps, and summarizingDouble for prices and percentages. The mapper interface and the returned class are the only things that change.
Watch two type details. The int variant widens its sum to a long so it cannot overflow, while the long variant has nowhere wider to go and still can.
Above all, remember the empty stream. Minimum and maximum come back as the extreme values of their type, not as zero, so guard every report with a getCount() check.