- JAVA
Java Collectors一覧|groupingBy・toMapで集計する書き方
Collectorsとは、Stream の要素を List・Map・文字列・集計値などにまとめるための「集め方」を用意したクラスで、stream.collect(Collectors.xxx()) の形で使います。 SQL でいう GROUP BY・COUNT・SUM にあたる処理を、ループを書かずに1つの式で表せます。
この記事では、Collectors の全メソッドを早見表にしたうえで、主要なものを record のサンプルデータで実行し、出力をそのまま掲載しています(OpenJDK 21.0.10 で実行。JDK 25.0.4.1 でも同じコードを実行して差分を確認しました)。toMap の重複キーで出る IllegalStateException、null 値で出る NullPointerException の再現と直し方、groupingBy の多段・下流コレクタ・並び順を保つ指定、Java 16 の Stream.toList() との違いまで扱います。Stream の基本(filter・map など)は「Java Stream API の使い方」の記事にまとめているので、ここでは collect に絞ります。
Collectors全メソッドの早見表
Java 25 の公式 API ドキュメントで確認したところ、Collectors の static メソッドは名前で30種類(オーバーロードを数えると44個)です。「戻り値型」は collect の結果の型です。
| メソッド | 用途 | 戻り値型 | 追加 |
|---|---|---|---|
toList() |
List に集める | List<T>(変更可否の保証なし) |
8 |
toSet() |
Set に集める(重複は1つに) | Set<T> |
8 |
toCollection(供給) |
実装クラスを指定して集める | 指定した型(TreeSet<T> など) |
8 |
toUnmodifiableList() |
変更できない List | List<T>(null 不可) |
10 |
toUnmodifiableSet() |
変更できない Set | Set<T>(null 不可) |
10 |
toMap(キー, 値) |
Map に集める(1キー1値) | Map<K,U> |
8 |
toMap(キー, 値, マージ) |
重複キーの扱いを指定 | Map<K,U> |
8 |
toMap(キー, 値, マージ, 供給) |
Map の実装も指定 | 指定した型(LinkedHashMap など) |
8 |
toUnmodifiableMap(...) |
変更できない Map | Map<K,U> |
10 |
toConcurrentMap(...) |
並列ストリーム向けの Map | ConcurrentMap<K,U> |
8 |
joining() / joining(区切り) / joining(区切り, 前, 後) |
文字列を連結 | String |
8 |
groupingBy(分類) |
キーごとに List にまとめる | Map<K, List<T>> |
8 |
groupingBy(分類, 下流) |
キーごとに集計する | Map<K, D> |
8 |
groupingBy(分類, 供給, 下流) |
Map の実装も指定 | 指定した型 | 8 |
groupingByConcurrent(...) |
並列ストリーム向けのグループ化 | ConcurrentMap<K, D> |
8 |
partitioningBy(条件) |
true / false の2つに分ける | Map<Boolean, List<T>> |
8 |
partitioningBy(条件, 下流) |
2つに分けて集計する | Map<Boolean, D> |
8 |
counting() |
件数 | Long |
8 |
summingInt / summingLong / summingDouble |
合計 | Integer / Long / Double |
8 |
averagingInt / averagingLong / averagingDouble |
平均 | Double |
8 |
summarizingInt / summarizingLong / summarizingDouble |
件数・合計・最小・平均・最大を一度に | IntSummaryStatistics など |
8 |
minBy(比較) / maxBy(比較) |
最小 / 最大の要素 | Optional<T> |
8 |
mapping(変換, 下流) |
変換してから下流に渡す | 下流の型 | 8 |
filtering(条件, 下流) |
絞り込んでから下流に渡す | 下流の型 | 9 |
flatMapping(展開, 下流) |
入れ子を平らにして下流に渡す | 下流の型 | 9 |
collectingAndThen(下流, 仕上げ) |
集めた結果をさらに変換 | 仕上げ関数の戻り値型 | 8 |
teeing(下流1, 下流2, 合成) |
2つの集計を同時に行い1つにまとめる | 合成関数の戻り値型 | 12 |
reducing(...) |
汎用のたたみ込み | T または Optional<T> |
8 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Collectors一覧|groupingBy・toMapで集計する書き方」
サンプルデータと実行方法
この記事のコードは、Order record(id・member・category・item・price)の6件のデータを使います(最後の章だけ tags を加えます)。各コードはそのまま1ファイルに貼って java ファイル名.java で実行できる完成形です。import static java.util.stream.Collectors.*; を書いているので、Collectors. の接頭辞を省いています。
toList・toSet・toCollection・joiningで集める
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;
import static java.util.stream.Collectors.*;
public class CollectBasic {
record Order(int id, String member, String category, String item, int price) {}
static final List<Order> ORDERS = List.of(
new Order(1, "sato", "book", "Java入門", 2800),
new Order(2, "suzuki", "food", "コーヒー豆", 1200),
new Order(3, "sato", "food", "紅茶", 900),
new Order(4, "tanaka", "book", "SQL入門", 3200),
new Order(5, "suzuki", "goods", "マグカップ", 1500),
new Order(6, "sato", "book", "Git入門", 2400));
public static void main(String[] args) {
List<String> items = ORDERS.stream().map(Order::item).collect(toList());
System.out.println(items + " " + items.getClass().getSimpleName());
Set<String> cats = ORDERS.stream().map(Order::category).collect(toSet());
System.out.println(cats + " " + cats.getClass().getSimpleName());
TreeSet<String> members = ORDERS.stream().map(Order::member).collect(toCollection(TreeSet::new));
System.out.println(members);
LinkedHashSet<String> catsInOrder = ORDERS.stream().map(Order::category).collect(toCollection(LinkedHashSet::new));
System.out.println(catsInOrder);
// joining は Stream<String> に対して使う
System.out.println(ORDERS.stream().map(Order::item).collect(joining()));
System.out.println(ORDERS.stream().map(Order::item).collect(joining(", ")));
System.out.println(ORDERS.stream().map(Order::item).collect(joining(", ", "[", "]")));
System.out.println(Stream.<String>empty().collect(joining(", ", "[", "]")));
System.out.println(ORDERS.stream().map(o -> String.valueOf(o.id())).collect(joining(",")));
}
}
実行結果(java CollectBasic.java、OpenJDK 21.0.10):
[Java入門, コーヒー豆, 紅茶, SQL入門, マグカップ, Git入門] ArrayList
[book, goods, food] HashSet
[sato, suzuki, tanaka]
[book, food, goods]
Java入門コーヒー豆紅茶SQL入門マグカップGit入門
Java入門, コーヒー豆, 紅茶, SQL入門, マグカップ, Git入門
[Java入門, コーヒー豆, 紅茶, SQL入門, マグカップ, Git入門]
[]
1,2,3,4,5,6
toList()の実体は ArrayList、toSet()の実体は HashSet でした。ただし公式ドキュメントは「返される List の型・変更可否・スレッドセーフ性は保証しない」としています。実装クラスを決めたいときはtoCollection(TreeSet::new)のように指定します。joiningは文字列の Stream 専用です。数値はString.valueOfなどで文字列に変換してから渡します。空の Stream でも前後の文字([と])は付きます。
toList()・Stream.toList()・toUnmodifiableList()の違い
Java 16 で Stream に直接 toList() メソッドが加わりました。3つは「変更できるか」と「null を入れられるか」が異なります。
import java.util.List;
import java.util.stream.Stream;
import static java.util.stream.Collectors.*;
public class ToListDiff {
public static void main(String[] args) {
List<String> a = Stream.of("a", "b").collect(toList());
a.add("c");
System.out.println("collect(toList()) add: " + a);
List<String> b = Stream.of("a", "b").toList();
try {
b.add("c");
} catch (Exception e) {
System.out.println("Stream.toList add: " + e);
}
List<String> c = Stream.of("a", "b").collect(toUnmodifiableList());
try {
c.add("c");
} catch (Exception e) {
System.out.println("toUnmodifiableList add: " + e);
}
System.out.println("toList null: " + Stream.of("a", null).collect(toList()));
System.out.println("Stream.toList null: " + Stream.of("a", null).toList());
try {
Stream.of("a", null).collect(toUnmodifiableList());
} catch (Exception e) {
System.out.println("toUnmodifiableList null: " + e);
}
}
}
実行結果:
collect(toList()) add: [a, b, c]
Stream.toList add: java.lang.UnsupportedOperationException
toUnmodifiableList add: java.lang.UnsupportedOperationException
toList null: [a, null]
Stream.toList null: [a, null]
toUnmodifiableList null: java.lang.NullPointerException
| 書き方 | 追加 | 結果を変更 | null 要素 | 選ぶ場面 |
|---|---|---|---|---|
collect(Collectors.toList()) |
Java 8 | この実行ではできた(仕様上は保証なし) | 入る | あとで追加・削除する。確実に変更したいなら toCollection(ArrayList::new) |
stream.toList() |
Java 16 | できない | 入る | 読むだけの結果。最も短く書ける |
collect(Collectors.toUnmodifiableList()) |
Java 10 | できない | NullPointerException | null を許さない不変の List がほしい |
新しく書くコードで「集めて読むだけ」なら stream.toList() が最短です。 既存の collect(toList()) を toList() に置き換えるときは、結果に add や sort をしている箇所がないかを先に確認すると安心です。
toMapでMapに変換する(重複キー・null値の例外と直し方)
toMap は「キーを作る関数」と「値を作る関数」を渡して Map を作ります。便利な反面、例外が出やすいメソッドでもあるので、再現してから直します。
重複キーでIllegalStateExceptionになる
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import static java.util.stream.Collectors.*;
public class ToMapDemo {
record Order(int id, String member, String category, String item, int price) {}
static final List<Order> ORDERS = List.of(
new Order(1, "sato", "book", "Java入門", 2800),
new Order(2, "suzuki", "food", "コーヒー豆", 1200),
new Order(3, "sato", "food", "紅茶", 900),
new Order(4, "tanaka", "book", "SQL入門", 3200),
new Order(5, "suzuki", "goods", "マグカップ", 1500),
new Order(6, "sato", "book", "Git入門", 2400));
public static void main(String[] args) {
// キーが重複しない場合(id は一意)
Map<Integer, String> byId = ORDERS.stream().collect(toMap(Order::id, Order::item));
System.out.println(byId + " " + byId.getClass().getSimpleName());
Map<Integer, Order> index = ORDERS.stream().collect(toMap(Order::id, Function.identity()));
System.out.println(index.get(4).item());
// 直す前:member は重複するので例外になる
try {
ORDERS.stream().collect(toMap(Order::member, Order::item));
} catch (Exception e) {
System.out.println(e);
}
// 直した後:第3引数(マージ関数)で重複時の扱いを決める
Map<String, String> first = ORDERS.stream().collect(toMap(Order::member, Order::item, (a, b) -> a));
Map<String, String> last = ORDERS.stream().collect(toMap(Order::member, Order::item, (a, b) -> b));
Map<String, Integer> sum = ORDERS.stream().collect(toMap(Order::member, Order::price, Integer::sum));
System.out.println("first : " + first);
System.out.println("last : " + last);
System.out.println("sum : " + sum);
// 第4引数で Map の実装を指定する(並び順を決める)
Map<String, Integer> linked = ORDERS.stream()
.collect(toMap(Order::member, Order::price, Integer::sum, LinkedHashMap::new));
Map<String, Integer> tree = ORDERS.stream()
.collect(toMap(Order::member, Order::price, Integer::sum, TreeMap::new));
System.out.println("LinkedHashMap: " + linked);
System.out.println("TreeMap : " + tree);
}
}
実行結果:
{1=Java入門, 2=コーヒー豆, 3=紅茶, 4=SQL入門, 5=マグカップ, 6=Git入門} HashMap
SQL入門
java.lang.IllegalStateException: Duplicate key sato (attempted merging values Java入門 and 紅茶)
first : {tanaka=SQL入門, suzuki=コーヒー豆, sato=Java入門}
last : {tanaka=SQL入門, suzuki=マグカップ, sato=Git入門}
sum : {tanaka=3200, suzuki=2700, sato=6100}
LinkedHashMap: {sato=6100, suzuki=2700, tanaka=3200}
TreeMap : {sato=6100, suzuki=2700, tanaka=3200}
例外メッセージ Duplicate key sato (attempted merging values Java入門 and 紅茶) には、重複したキーと、ぶつかった2つの値が出ています。ここを読めば、どのデータが原因かがすぐわかります。
マージ関数は、(a, b) -> a で先勝ち、(a, b) -> b で後勝ち、Integer::sum で合算です。マージ関数なし(引数2つ)は、キーが必ず一意で、重複をデータの異常として検出したいときに使います。
引数2つの toMap は HashMap を返すので、並び順は保証されません。 出てきた順を保ちたいなら LinkedHashMap::new、キーでソートしたいなら TreeMap::new を第4引数に渡します。第4引数を使うときは、重複しないとわかっていても第3引数のマージ関数が必要です。
なお、1つのキーに複数の値をまとめたいときは、次章の groupingBy が向いています。「1キー1値なら toMap、1キー複数値なら groupingBy」 が選び方の基本です。
null値でNullPointerExceptionになる
HashMap は null の値を持てますが、toMap は値が null だと NullPointerException になります。 例外メッセージが空なので、原因に気づきにくい箇所です。
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import static java.util.stream.Collectors.*;
public class ToMapNull {
record Profile(String name, String nickname) {}
public static void main(String[] args) {
List<Profile> ps = List.of(new Profile("sato", "sa"), new Profile("suzuki", null));
// 直す前:値が null の要素がある
try {
ps.stream().collect(toMap(Profile::name, Profile::nickname));
} catch (Exception e) {
System.out.println(e);
System.out.println(" at " + e.getStackTrace()[1].getMethodName());
}
// 直し方1:null のまま持ちたい → 3引数の collect で HashMap に put する
Map<String, String> ok1 = ps.stream()
.collect(HashMap::new, (m, p) -> m.put(p.name(), p.nickname()), HashMap::putAll);
System.out.println(ok1);
// 直し方2:既定値に置き換える
Map<String, String> ok2 = ps.stream()
.collect(toMap(Profile::name, p -> Objects.requireNonNullElse(p.nickname(), "")));
System.out.println(ok2);
// 直し方3:Optional で包む
Map<String, Optional<String>> ok3 = ps.stream()
.collect(toMap(Profile::name, p -> Optional.ofNullable(p.nickname())));
System.out.println(ok3);
// 直し方4:null の要素を除く
Map<String, String> ok4 = ps.stream()
.filter(p -> p.nickname() != null)
.collect(toMap(Profile::name, Profile::nickname));
System.out.println(ok4);
// groupingBy はキーが null だと例外になる
List<Profile> nullKey = List.of(new Profile(null, "x"));
try {
nullKey.stream().collect(groupingBy(Profile::name));
} catch (Exception e) {
System.out.println(e);
}
}
}
実行結果:
java.lang.NullPointerException
at lambda$uniqKeysMapAccumulator$1
{sato=sa, suzuki=null}
{sato=sa, suzuki=}
{sato=Optional[sa], suzuki=Optional.empty}
{sato=sa}
java.lang.NullPointerException: element cannot be mapped to a null key
スタックトレースの2行目に Collectors の uniqKeysMapAccumulator が出ていたら、toMap の null 値が原因です(Java 25 ではこのラムダの番号が $0 と表示されました)。
groupingByでグループ化して集計する
groupingBy は SQL の GROUP BY にあたります。第2引数(下流コレクタ)を渡すと、グループごとの List ではなく集計値を得られます。
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static java.util.stream.Collectors.*;
public class GroupingDemo {
record Order(int id, String member, String category, String item, int price) {}
record Key(String member, String category) {}
static final List<Order> ORDERS = List.of(
new Order(1, "sato", "book", "Java入門", 2800),
new Order(2, "suzuki", "food", "コーヒー豆", 1200),
new Order(3, "sato", "food", "紅茶", 900),
new Order(4, "tanaka", "book", "SQL入門", 3200),
new Order(5, "suzuki", "goods", "マグカップ", 1500),
new Order(6, "sato", "book", "Git入門", 2400));
public static void main(String[] args) {
System.out.println("-- 1. 基本(値は List)");
Map<String, List<Order>> g1 = ORDERS.stream().collect(groupingBy(Order::category));
g1.forEach((k, v) -> System.out.println(k + " -> " + v.stream().map(Order::item).toList()));
System.out.println("-- 2. 下流コレクタで集計");
Map<String, Long> count = ORDERS.stream().collect(groupingBy(Order::category, counting()));
Map<String, Integer> sum = ORDERS.stream().collect(groupingBy(Order::category, summingInt(Order::price)));
Map<String, Double> avg = ORDERS.stream().collect(groupingBy(Order::category, averagingInt(Order::price)));
Map<String, List<String>> names = ORDERS.stream().collect(groupingBy(Order::category, mapping(Order::item, toList())));
Map<String, String> maxName = ORDERS.stream().collect(groupingBy(Order::category,
collectingAndThen(maxBy(Comparator.comparingInt(Order::price)), o -> o.map(Order::item).orElse("-"))));
System.out.println("counting : " + count);
System.out.println("summingInt : " + sum);
System.out.println("averagingInt : " + avg);
System.out.println("mapping : " + names);
System.out.println("max の名前 : " + maxName);
System.out.println("-- 4. 並び順を指定する");
Map<String, Long> hash = ORDERS.stream().collect(groupingBy(Order::member, counting()));
Map<String, Long> linked = ORDERS.stream().collect(groupingBy(Order::member, LinkedHashMap::new, counting()));
Map<String, Long> tree = ORDERS.stream().collect(groupingBy(Order::member, TreeMap::new, counting()));
System.out.println("HashMap(既定) : " + hash);
System.out.println("LinkedHashMap : " + linked);
System.out.println("TreeMap : " + tree);
// 集計値の大きい順に並べ替える
Map<String, Integer> totals = ORDERS.stream().collect(groupingBy(Order::member, summingInt(Order::price)));
Map<String, Integer> ranked = totals.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
System.out.println("合計の大きい順 : " + ranked);
System.out.println("-- 5. 多段のグループ化");
Map<String, Map<String, List<String>>> g2 = ORDERS.stream().collect(
groupingBy(Order::member, TreeMap::new,
groupingBy(Order::category, TreeMap::new, mapping(Order::item, toList()))));
g2.forEach((m, v) -> System.out.println(m + " -> " + v));
System.out.println("-- 6. 複合キー(record)で1段にまとめる");
Map<Key, Long> g4 = ORDERS.stream().collect(
groupingBy(o -> new Key(o.member(), o.category()), LinkedHashMap::new, counting()));
g4.forEach((k, v) -> System.out.println(k + " = " + v));
}
}
実行結果:
-- 1. 基本(値は List)
book -> [Java入門, SQL入門, Git入門]
goods -> [マグカップ]
food -> [コーヒー豆, 紅茶]
-- 2. 下流コレクタで集計
counting : {book=3, goods=1, food=2}
summingInt : {book=8400, goods=1500, food=2100}
averagingInt : {book=2800.0, goods=1500.0, food=1050.0}
mapping : {book=[Java入門, SQL入門, Git入門], goods=[マグカップ], food=[コーヒー豆, 紅茶]}
max の名前 : {book=SQL入門, goods=マグカップ, food=コーヒー豆}
-- 4. 並び順を指定する
HashMap(既定) : {tanaka=1, suzuki=2, sato=3}
LinkedHashMap : {sato=3, suzuki=2, tanaka=1}
TreeMap : {sato=3, suzuki=2, tanaka=1}
合計の大きい順 : {sato=6100, tanaka=3200, suzuki=2700}
-- 5. 多段のグループ化
sato -> {book=[Java入門, Git入門], food=[紅茶]}
suzuki -> {food=[コーヒー豆], goods=[マグカップ]}
tanaka -> {book=[SQL入門]}
-- 6. 複合キー(record)で1段にまとめる
Key[member=sato, category=book] = 2
Key[member=suzuki, category=food] = 1
Key[member=sato, category=food] = 1
Key[member=tanaka, category=book] = 1
Key[member=suzuki, category=goods] = 1
下流コレクタの読み方
groupingBy(分類, 下流) は、「まず分類し、グループごとに下流コレクタで集める」 と読みます。下流を省略したときは toList() が使われます。
counting() の型が Long である点は、変数の型を書くときにつまずきやすい箇所です。Map<String, Integer> で受けるとコンパイルエラーになるので、Map<String, Long> で受けるか、collectingAndThen(counting(), Long::intValue) で変換します。
並び順を保つ指定(LinkedHashMap・TreeMap)
groupingBy の既定の Map は HashMap なので、キーの並び順は保証されません。 上の実行結果でも、tanaka, suzuki, sato と、データの出現順ともソート順とも違う順になっています。
データに出てきた順なら groupingBy(分類, LinkedHashMap::new, 下流)、キーの昇順なら TreeMap::new、降順なら () -> new TreeMap<>(Comparator.reverseOrder()) を渡します。集計値の大きい順は、上の例のように集計後の entrySet() をソートして LinkedHashMap に toMap します。
3引数の形では下流コレクタを省略できないので、List のまま持ちたいときは groupingBy(分類, TreeMap::new, toList()) と書きます。
多段のグループ化は Map<String, Map<String, ...>> と型が深くなります。キーの組み合わせで1段にしたいときは、record を複合キーにすると扱いやすくなります。
partitioningByと数値の集計(counting・summingInt・summarizingInt)
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static java.util.stream.Collectors.*;
public class PartitionStats {
record Order(int id, String member, String category, String item, int price) {}
static final List<Order> ORDERS = List.of(
new Order(1, "sato", "book", "Java入門", 2800),
new Order(2, "suzuki", "food", "コーヒー豆", 1200),
new Order(3, "sato", "food", "紅茶", 900),
new Order(4, "tanaka", "book", "SQL入門", 3200),
new Order(5, "suzuki", "goods", "マグカップ", 1500),
new Order(6, "sato", "book", "Git入門", 2400));
public static void main(String[] args) {
// partitioningBy:条件で true / false に分ける
Map<Boolean, List<Order>> p1 = ORDERS.stream().collect(partitioningBy(o -> o.price() >= 2000));
System.out.println(p1.get(true).stream().map(Order::item).toList());
System.out.println(p1.get(false).stream().map(Order::item).toList());
Map<Boolean, Long> p2 = ORDERS.stream().collect(partitioningBy(o -> o.price() >= 2000, counting()));
System.out.println(p2);
// 該当が0件のとき:partitioningBy はキーが両方そろう
Map<Boolean, Long> p3 = ORDERS.stream().collect(partitioningBy(o -> o.price() >= 99999, counting()));
Map<Boolean, Long> g3 = ORDERS.stream().collect(groupingBy(o -> o.price() >= 99999, counting()));
System.out.println("partitioningBy: " + p3 + " / groupingBy: " + g3);
// 全体の集計
System.out.println(ORDERS.stream().collect(counting()));
System.out.println(ORDERS.stream().collect(summingInt(Order::price)));
System.out.println(ORDERS.stream().collect(averagingInt(Order::price)));
System.out.println(Stream.<Order>empty().collect(averagingInt(Order::price)));
// summarizingInt:件数・合計・最小・平均・最大を一度に
IntSummaryStatistics st = ORDERS.stream().collect(summarizingInt(Order::price));
System.out.println(st);
System.out.println(st.getCount() + " " + st.getSum() + " " + st.getMin() + " " + st.getMax() + " " + st.getAverage());
}
}
実行結果:
[Java入門, SQL入門, Git入門]
[コーヒー豆, 紅茶, マグカップ]
{false=3, true=3}
partitioningBy: {false=6, true=0} / groupingBy: {false=6}
6
12000
2000.0
0.0
IntSummaryStatistics{count=6, sum=12000, min=900, average=2000.000000, max=3200}
6 12000 900 3200 2000.0
partitioningByは、該当が0件でも true と false の両方のキーが必ず入ります。groupingByで boolean を返すと、存在するキーだけになります(4行目)。get(true)が null にならないので、2分割にはpartitioningByが向いています。averagingIntは空の Stream で0.0を返します(例外にはなりません)。「データなし」と「平均が0」を区別したいときは、summarizingIntのgetCount()を合わせて確認します。- 合計・平均・最大・最小を複数使うなら
summarizingIntが1回の走査で済みます。
filtering・flatMapping・collectingAndThen・teeingで下流を組み立てる
Java 9 以降に加わったコレクタは、下流コレクタの前後に処理を挟むためのものです。
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import static java.util.stream.Collectors.*;
public class DownstreamDemo {
record Order(int id, String member, String category, String item, int price, List<String> tags) {}
record Stats(long count, int total) {}
static final List<Order> ORDERS = List.of(
new Order(1, "sato", "book", "Java入門", 2800, List.of("java", "入門")),
new Order(2, "suzuki", "food", "コーヒー豆", 1200, List.of("drink")),
new Order(3, "sato", "food", "紅茶", 900, List.of("drink", "gift")),
new Order(4, "tanaka", "book", "SQL入門", 3200, List.of("sql", "入門")),
new Order(5, "suzuki", "goods", "マグカップ", 1500, List.of("gift")),
new Order(6, "sato", "book", "Git入門", 2400, List.of("git", "入門")));
public static void main(String[] args) {
// filtering:先に filter すると、該当のないグループがキーごと消える
Map<String, Long> before = ORDERS.stream()
.filter(o -> o.price() >= 2000)
.collect(groupingBy(Order::category, TreeMap::new, counting()));
Map<String, Long> after = ORDERS.stream()
.collect(groupingBy(Order::category, TreeMap::new, filtering(o -> o.price() >= 2000, counting())));
System.out.println("filter → groupingBy : " + before);
System.out.println("groupingBy + filtering : " + after);
// flatMapping:入れ子の List を平らにして集める
Map<String, List<List<String>>> nested = ORDERS.stream()
.collect(groupingBy(Order::category, TreeMap::new, mapping(Order::tags, toList())));
Map<String, TreeSet<String>> flat = ORDERS.stream()
.collect(groupingBy(Order::category, TreeMap::new,
flatMapping(o -> o.tags().stream(), toCollection(TreeSet::new))));
System.out.println("mapping : " + nested);
System.out.println("flatMapping : " + flat);
// collectingAndThen:集めたあとに仕上げの変換をする
List<String> fixed = ORDERS.stream().map(Order::item)
.collect(collectingAndThen(toList(), Collections::unmodifiableList));
try {
fixed.add("x");
} catch (Exception e) {
System.out.println(e);
}
int n = ORDERS.stream().collect(collectingAndThen(counting(), Long::intValue));
Map<String, Integer> sizes = ORDERS.stream()
.collect(groupingBy(Order::category, TreeMap::new, collectingAndThen(toList(), List::size)));
System.out.println(n + " " + sizes);
// teeing:2つの集計を同時に行い、結果を1つにまとめる(Java 12)
Stats s = ORDERS.stream().collect(teeing(counting(), summingInt(Order::price), Stats::new));
System.out.println(s);
String range = ORDERS.stream().collect(teeing(
minBy(Comparator.comparingInt(Order::price)),
maxBy(Comparator.comparingInt(Order::price)),
(min, max) -> min.get().item() + " 〜 " + max.get().item()));
System.out.println(range);
String ratio = ORDERS.stream().collect(teeing(
filtering(o -> o.category().equals("book"), counting()),
counting(),
(book, all) -> "%d/%d (%.1f%%)".formatted(book, all, book * 100.0 / all)));
System.out.println(ratio);
}
}
実行結果:
filter → groupingBy : {book=3}
groupingBy + filtering : {book=3, food=0, goods=0}
mapping : {book=[[java, 入門], [sql, 入門], [git, 入門]], food=[[drink], [drink, gift]], goods=[[gift]]}
flatMapping : {book=[git, java, sql, 入門], food=[drink, gift], goods=[gift]}
java.lang.UnsupportedOperationException
6 {book=3, food=2, goods=1}
Stats[count=6, total=12000]
紅茶 〜 SQL入門
3/6 (50.0%)
| コレクタ | 何をするか | 使いどころ |
|---|---|---|
mapping(変換, 下流) |
変換してから集める | グループごとに特定の項目だけ集める |
filtering(条件, 下流) |
絞り込んでから集める | 0件のグループもキーを残したい |
flatMapping(展開, 下流) |
平らにしてから集める | 要素が List を持っている |
collectingAndThen(下流, 仕上げ) |
集めた結果を変換 | 不変化、型変換、Optional を外す |
teeing(下流1, 下流2, 合成) |
2つの集計を1回の走査で | 件数と合計、最小と最大、割合 |
filter してから groupingBy するのと、groupingBy の下流で filtering するのは結果が違います。 前者は該当のないカテゴリ(food・goods)がキーごと消え、後者は 0 として残ります。「全カテゴリを一覧に出し、該当なしは0件と表示する」画面では filtering が合います。
つまずきやすい点と対処のまとめ
| 起きること | 原因 | 対処 |
|---|---|---|
IllegalStateException: Duplicate key ... |
toMap のキーが重複した |
第3引数にマージ関数。複数値を持つなら groupingBy |
メッセージのない NullPointerException |
toMap の値が null |
既定値に置き換える、filter で除く、3引数 collect を使う |
element cannot be mapped to a null key |
groupingBy の分類結果が null |
分類関数で Objects.requireNonNullElse(値, "未設定") などに置き換える |
| Map の並び順が毎回思った順にならない | 既定が HashMap | LinkedHashMap::new か TreeMap::new を渡す |
Map<String, Integer> で受けるとコンパイルエラー |
counting() は Long |
Map<String, Long> で受ける |
UnsupportedOperationException |
Stream.toList() などの結果を変更した |
toCollection(ArrayList::new) で集める |
println(...collect(groupingBy(k, TreeMap::new, v))) がコンパイルエラー |
println のオーバーロードで型推論が決まらない | 上のコード例のように、いったん型を書いた変数に代入する |
| 該当なしのグループが消える | 先に filter している |
下流で filtering を使う |
最後から2つ目は、この記事のコードを書く途中で実際に出たコンパイルエラー(incompatible bounds)で、変数で受ける形にすると通りました。
Java 25との差分
同じコードを JDK 25.0.4.1 でも実行しました。出力はすべて Java 21.0.10 と一致し、違いは toMap の null 値で出るスタックトレース内のラムダ名(Java 21 は lambda$uniqKeysMapAccumulator$1、Java 25 は lambda$uniqKeysMapAccumulator$0)と行番号だけでした。Collectors のメソッド構成も、Java 25 の API ドキュメントで確認した範囲では Java 12 の teeing 追加以降変わっていません。
よくある質問
Q. toMap と groupingBy はどう使い分けますか?
A. 1つのキーに1つの値なら toMap、1つのキーに複数の要素が集まるなら groupingBy です。キーごとの合計は toMap(k, v, Integer::sum) と groupingBy(k, summingInt(v)) のどちらでも書けますが、件数・平均・一覧など集計の種類を変えやすいのは groupingBy です。
まとめ
- Collectors の static メソッドは30種類。「集める(toList・toMap)」「分ける(groupingBy・partitioningBy)」「数える(counting・summingInt)」「組み立てる(mapping・filtering・teeing)」に分けて覚える
toMapは重複キーで IllegalStateException、null 値で NullPointerException。マージ関数と null の置き換えで直す- 1キー1値は
toMap、1キー複数値はgroupingBy groupingBy(分類, 下流)の下流コレクタで、件数・合計・平均・一覧を切り替える- 並び順が必要なら
LinkedHashMap::new/TreeMap::newを渡す(既定は HashMap) - 2分割は
partitioningBy。該当0件でも true / false の両方のキーがそろう - 読むだけの List は
stream.toList()(Java 16)が最短。変更するならtoCollection(ArrayList::new)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Collectors一覧|groupingBy・toMapで集計する書き方」
株式会社RJCでは、Stream と Collectors を使った集計の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。