概要

一覧データを部署順・金額順でソートする、部署別に人数や合計を集計する、一定の閾値で2グループに分割する。こうした処理は業務システムで頻繁に登場しますが、Comparator の合成や Collectors の組み合わせに慣れるまでは記述が冗長になりがちです。この記事では、Comparator.comparing と thenComparingInt による複合キーソート、groupingBy + counting / summingInt による部門別集計、partitioningBy による二分割、joining による文字列結合、さらに Java 12 で追加された Collectors.teeing による同時集計まで、業務で実際に書く形のコードで解説します。record とメソッド参照を活用して、読みやすさと型安全性を両立させるパターンも示します。

使いどころ

社員一覧を部署昇順 → 給与降順でソートし、管理帳票や画面表示に使う

売上データを商品カテゴリ別に groupingBy で集計し、カテゴリごとの件数・合計金額・平均単価を一覧化する

受注データを金額閾値(例: 100万円以上/未満)で partitioningBy して、承認フローの振り分けに使う

コード例

SortAndGrouping.java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class SortAndGrouping {

    record Employee(String name, String department, int salary) {
        @Override
        public String toString() {
            return name + "(" + department + ", " + salary + "円)";
        }
    }

    public static void main(String[] args) {
        var employees = List.of(
            new Employee("田中", "営業", 350000),
            new Employee("鈴木", "開発", 420000),
            new Employee("佐藤", "営業", 380000),
            new Employee("山田", "開発", 450000),
            new Employee("伊藤", "人事", 320000),
            new Employee("加藤", "開発", 390000)
        );

        // ① 複合キーソート(部署昇順 → 給与昇順)
        var sorted = employees.stream()
                .sorted(Comparator.comparing(Employee::department)
                        .thenComparingInt(Employee::salary))
                .toList();
        System.out.println("部署→給与順:");
        sorted.forEach(e -> System.out.println("  " + e));

        // ② 部署別人数(groupingBy + counting)
        Map<String, Long> countByDept = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::department, Collectors.counting()));
        System.out.println("\n部署別人数: " + countByDept);

        // ③ 部署別給与合計(groupingBy + summingInt)
        Map<String, Integer> totalByDept = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::department,
                        Collectors.summingInt(Employee::salary)));
        System.out.println("部署別給与合計: " + totalByDept);

        // ④ 部署別平均給与(groupingBy + averagingInt)
        Map<String, Double> avgByDept = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::department,
                        Collectors.averagingInt(Employee::salary)));
        System.out.println("部署別平均給与: " + avgByDept);

        // ⑤ 金額閾値で二分割(partitioningBy)
        Map<Boolean, List<Employee>> partition = employees.stream()
                .collect(Collectors.partitioningBy(
                        e -> e.salary() >= 400000));
        System.out.println("\n40万円以上: " + partition.get(true));
        System.out.println("40万円未満: " + partition.get(false));

        // ⑥ 名前を区切り文字で結合(joining)
        String nameList = employees.stream()
                .map(Employee::name)
                .collect(Collectors.joining("、"));
        System.out.println("\n社員一覧: " + nameList);

        // ⑦ 複数集計を1パスで実行(teeing, Java 12+)
        record DeptSummary(long count, int total, double average) {}
        Map<String, DeptSummary> summaryByDept = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::department,
                        Collectors.teeing(
                                Collectors.counting(),
                                Collectors.summingInt(Employee::salary),
                                (count, total) -> new DeptSummary(
                                        count, total,
                                        (double) total / count)
                        )));
        summaryByDept.forEach((dept, s) ->
                System.out.printf("%s: %d人, 合計%d円, 平均%.0f円%n",
                        dept, s.count(), s.total(), s.average()));
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record + メソッド参照で groupingBy のキー指定が簡潔に。Stream.toList() でイミュータブルな結果を取得。teeing も利用可能。

Java 17
// Java 17: record + メソッド参照で簡潔に
record Employee(String name, String department,
                int salary) {}
var sorted = employees.stream()
    .sorted(Comparator.comparing(Employee::department)
        .thenComparingInt(Employee::salary))
    .toList();

Library Comparison

標準 Stream API(Comparator + Collectors)ソート・集計・分割の基本パターンで事足りる場面。依存なしで記述量も十分に少ない。複数の集計軸を同時に処理する場合、teeing のネストが深くなりやすい。
DB / SQL 側での集計(GROUP BY + ORDER BY)大量データの集計を DB に任せた方がパフォーマンス上有利なとき。Java 側のビジネスロジックが SQL に漏れ出し、テストの DB 依存が増える。集計後の加工は結局 Java で行うことが多い。
Eclipse CollectionsgroupByEach や Bag など、標準 API にない集計構造が必要な大規模データ処理。学習コストが高く、標準 API で書けるレベルの処理には過剰。チーム内での認知負荷も考慮が必要。

注意点

Comparator.comparing のラムダでフィールドの型が曖昧な場合、明示的な型パラメータが必要になる。(Employee e) -> e.department のように引数に型を書くか、メソッド参照 Employee::department を使う

groupingBy のキーに null が含まれると NullPointerException になる。事前に filter(e -> e.department() != null) で除外するか、デフォルト値に置換する

Collectors.toList() が返すリストは可変だが、Stream.toList()(Java 16+)は不変リスト。ソート結果を後から変更する必要があるなら collect(Collectors.toList()) を使う

thenComparing で int フィールドを比較する場合は thenComparingInt を使う。thenComparing(e -> e.salary) だとオートボクシングが発生し、大量データでパフォーマンスに影響する

Collectors.teeing は Java 12 以降で利用可能。Java 8 環境では2回の collect に分けるか、カスタム Collector を書く必要がある

FAQ

Comparator.comparing と Comparator.naturalOrder の違いは何ですか。

comparing はフィールドを抽出するキー関数を受け取って比較します。naturalOrder は Comparable を実装した要素そのものの自然順序で並べます。DTO のフィールド単位でソートするなら comparing を使います。

groupingBy の結果を特定のキー順で取り出すにはどうすればよいですか。

groupingBy の第2引数に TreeMap::new を渡すと、キーの自然順序でソートされた Map が返ります。Collectors.groupingBy(Employee::department, TreeMap::new, Collectors.toList()) の形です。

partitioningBy と groupingBy の使い分けの基準は何ですか。

2グループへの二分割なら partitioningBy が適しています。戻り値の Map<Boolean, List<T>> で true/false の両グループが必ず返るため、該当なしの場合も空リストが保証されます。3つ以上の分類には groupingBy を使います。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます