reduce とは、ストリームの要素を先頭から順に「これまでの結果」と「次の要素」で1つにまとめていき、最後に1個の値を得る終端操作です。 合計、最大、最小、積、文字列の連結など、「たくさんの値を1つにする」処理の土台になります。引数の数によって3つの形があり、戻り値の型と空のストリームでの結果がそれぞれ違います。

この記事では、3つの形を早見表で整理し、途中の累積値を表示して動きを追います。そのうえで sum や max、joining などの専用メソッドとの選び方、BigDecimal の合計、オーバーフロー、並列実行で結果が変わる例、collect との違いを、すべて実行結果つきで説明します。

確認環境:Ubuntu 24.04/OpenJDK 21.0.10(CPU 2個。java ファイル名.java の単一ファイル実行)。並列実行の出力順や所要時間は、実行のたびに変わります。

reduceの3つの形 早見表

形 書き方の例 戻り値の型 空ストリームのとき 使いどころ
1引数 reduce(Integer::max) Optional<T> Optional.empty 最大・最小など、初期値を決めにくい集約
2引数 reduce(0, Integer::sum) T(要素と同じ型) 初期値がそのまま返る 合計・積・連結など、初期値(単位元)がある集約
3引数 reduce(0, (acc, s) -> acc + s.length(), Integer::sum) U(要素と別の型でもよい) 初期値がそのまま返る 要素の型と結果の型が違う集約

引数の呼び名は、順に identity(初期値)、accumulator(累積の関数)、combiner(部分結果どうしをまとめる関数) です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Stream reduceの使い方 合計・最大とcollectとの違い」

やりたいこと まず検討する書き方 reduce で書くなら
int の合計 mapToInt(...).sum() reduce(0, Integer::sum)
最大・最小 max(Comparator)、min(Comparator) reduce(Integer::max)
平均 mapToInt(...).average() 合計と件数を別々に求める
件数・合計・最小・平均・最大を一度に summaryStatistics() (専用メソッドが向く)
文字列の連結 Collectors.joining(", ") reduce("", (a, b) -> a + b)
BigDecimal の合計 (専用メソッドなし) reduce(BigDecimal.ZERO, BigDecimal::add)
リストや Map に集める toList()、collect(...) (collect が向く)

reduceの動きを累積値の表示で追う

ラムダ式の中で println して、accumulator がどう呼ばれるかを見てみます。

import java.util.List;
import java.util.Optional;

public class ThreeForms {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4);

        // 1引数: 初期値なし。最初の要素から始まり、戻り値は Optional
        System.out.println("--- 1引数");
        Optional<Integer> r1 = nums.stream().reduce((acc, x) -> {
            System.out.println("  acc=" + acc + " x=" + x + " -> " + (acc + x));
            return acc + x;
        });
        System.out.println("結果: " + r1);

        // 2引数: 初期値あり。戻り値は要素と同じ型
        System.out.println("--- 2引数");
        Integer r2 = nums.stream().reduce(0, (acc, x) -> {
            System.out.println("  acc=" + acc + " x=" + x + " -> " + (acc + x));
            return acc + x;
        });
        System.out.println("結果: " + r2);

        // 3引数: 要素と違う型にまとめる (String の長さを int に足し込む)
        System.out.println("--- 3引数");
        List<String> words = List.of("java", "stream", "reduce");
        Integer r3 = words.stream().reduce(0,
                (acc, w) -> {
                    System.out.println("  accumulator: acc=" + acc + " w=" + w + " -> " + (acc + w.length()));
                    return acc + w.length();
                },
                (a, b) -> {
                    System.out.println("  combiner: " + a + " + " + b);
                    return a + b;
                });
        System.out.println("結果: " + r3);
    }
}

実行結果:

--- 1引数
  acc=1 x=2 -> 3
  acc=3 x=3 -> 6
  acc=6 x=4 -> 10
結果: Optional[10]
--- 2引数
  acc=0 x=1 -> 1
  acc=1 x=2 -> 3
  acc=3 x=3 -> 6
  acc=6 x=4 -> 10
結果: 10
--- 3引数
  accumulator: acc=0 w=java -> 4
  accumulator: acc=4 w=stream -> 10
  accumulator: acc=10 w=reduce -> 16
結果: 16
  • 1引数:最初の要素 1 が最初の acc になり、2番目の要素から計算が始まります。4要素で呼び出しは3回です。戻り値は Optional[10] です。
  • 2引数:acc は初期値 0 から始まり、4要素で4回呼ばれます。戻り値は Optional ではなく値そのもの(10)です。
  • 3引数:acc は Integer、要素は String と型が違っています。文字数を足し込んで 16 になりました。逐次のストリームでは combiner は呼ばれていません。

2引数の reduce は、「変数を初期値で用意し、for 文で1要素ずつ足し込む」処理と同じ動きです。

空のストリームと要素1個のとき

import java.util.List;

public class EmptyStream {
    public static void main(String[] args) {
        List<Integer> empty = List.of();
        System.out.println("1引数: " + empty.stream().reduce(Integer::sum));
        System.out.println("2引数: " + empty.stream().reduce(0, Integer::sum));
        System.out.println("3引数: " + empty.stream().reduce(0, (acc, x) -> acc + x, Integer::sum));
        System.out.println("orElse: " + empty.stream().reduce(Integer::sum).orElse(0));
        System.out.println("要素1個: " + List.of(7).stream().reduce((a, b) -> {
            System.out.println("呼ばれない");
            return a + b;
        }));
        try {
            empty.stream().reduce(Integer::sum).get();
        } catch (Exception e) {
            System.out.println(e);
        }
    }
}

実行結果:

1引数: Optional.empty
2引数: 0
3引数: 0
orElse: 0
要素1個: Optional[7]
java.util.NoSuchElementException: No value present

1引数の reduce が Optional を返すのは、空のストリームでは返す値が無いためです。 orElse(0) で既定値を決めるか、2引数の形で初期値を渡します。空の Optional に get を呼ぶと、最後の行のとおり NoSuchElementException になります。要素が1個だけのときは accumulator が一度も呼ばれず、その要素がそのまま結果になりました。

3引数の combiner が呼ばれるのは並列のとき

import java.util.List;

public class Combiner {
    public static void main(String[] args) {
        List<String> words = List.of("java", "stream", "reduce", "collect");
        Integer total = words.parallelStream().reduce(0,
                (acc, w) -> {
                    System.out.println("  accumulator: acc=" + acc + " w=" + w + " [" + Thread.currentThread().getName() + "]");
                    return acc + w.length();
                },
                (a, b) -> {
                    System.out.println("  combiner: " + a + " + " + b);
                    return a + b;
                });
        System.out.println("結果: " + total);
        // 同じことを map + reduce で書く
        System.out.println("map+reduce: " + words.stream().map(String::length).reduce(0, Integer::sum));
        System.out.println("mapToInt.sum: " + words.stream().mapToInt(String::length).sum());
    }
}

実行結果(行の順番は実行ごとに変わります):

  accumulator: acc=0 w=stream [ForkJoinPool.commonPool-worker-1]
  accumulator: acc=0 w=java [ForkJoinPool.commonPool-worker-1]
  accumulator: acc=0 w=reduce [main]
  accumulator: acc=0 w=collect [main]
  combiner: 6 + 7
  combiner: 4 + 6
  combiner: 10 + 13
結果: 23
map+reduce: 23
mapToInt.sum: 23

並列ストリームでは、要素がいくつかのかたまりに分けられ、それぞれが初期値 0 から accumulator で計算されたあと、combiner で部分結果どうしが足し合わされます。 上の出力では 4+6、6+7 がまとめられ、最後に 10+13 で 23 になっています。

3引数の形は、多くの場合 map と2引数の reduce に分けて書けます。 map(String::length).reduce(0, Integer::sum) や mapToInt(String::length).sum() も同じ 23 でした。こちらのほうが読みやすいので、まず分けて書けないかを考えるとよいでしょう。

合計・最大・最小・平均・連結:reduce版と専用メソッド版

同じ集計を、reduce と専用メソッドの両方で書いて並べます。

import java.util.Comparator;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;

public class Compare {
    public static void main(String[] args) {
        List<Integer> scores = List.of(72, 88, 95, 60, 81);
        List<String> names = List.of("sato", "suzuki", "tanaka");

        // 合計
        System.out.println("合計  reduce : " + scores.stream().reduce(0, Integer::sum));
        System.out.println("合計  sum    : " + scores.stream().mapToInt(Integer::intValue).sum());
        // 最大
        System.out.println("最大  reduce : " + scores.stream().reduce(Integer::max));
        System.out.println("最大  max    : " + scores.stream().max(Comparator.naturalOrder()));
        System.out.println("最大  IntStream.max : " + scores.stream().mapToInt(Integer::intValue).max());
        // 最小
        System.out.println("最小  reduce : " + scores.stream().reduce(Integer::min));
        System.out.println("最小  min    : " + scores.stream().min(Comparator.naturalOrder()));
        // 平均 (reduce では合計と件数を別々に求める)
        int sum = scores.stream().reduce(0, Integer::sum);
        long count = scores.stream().count();
        System.out.println("平均  reduce : " + (double) sum / count);
        System.out.println("平均  average: " + scores.stream().mapToInt(Integer::intValue).average());
        // まとめて
        IntSummaryStatistics st = scores.stream().mapToInt(Integer::intValue).summaryStatistics();
        System.out.println("summaryStatistics : " + st);
        // 件数
        System.out.println("件数  reduce : " + scores.stream().map(x -> 1).reduce(0, Integer::sum));
        System.out.println("件数  count  : " + scores.stream().count());
        // 文字列連結
        System.out.println("連結  reduce : " + names.stream().reduce("", (a, b) -> a + b));
        System.out.println("連結  reduce(区切り) : " + names.stream().reduce((a, b) -> a + ", " + b).orElse(""));
        System.out.println("連結  joining: " + names.stream().collect(Collectors.joining(", ")));
        System.out.println("連結  joining(前後): " + names.stream().collect(Collectors.joining(", ", "[", "]")));
        System.out.println("連結  String.join : " + String.join(", ", names));
        // 初期値つきで区切り文字を入れると先頭に余分が付く
        System.out.println("連結  初期値+区切り : " + names.stream().reduce("", (a, b) -> a + ", " + b));
    }
}

実行結果:

合計  reduce : 396
合計  sum    : 396
最大  reduce : Optional[95]
最大  max    : Optional[95]
最大  IntStream.max : OptionalInt[95]
最小  reduce : Optional[60]
最小  min    : Optional[60]
平均  reduce : 79.2
平均  average: OptionalDouble[79.2]
summaryStatistics : IntSummaryStatistics{count=5, sum=396, min=60, average=79.200000, max=95}
件数  reduce : 5
件数  count  : 5
連結  reduce : satosuzukitanaka
連結  reduce(区切り) : sato, suzuki, tanaka
連結  joining: sato, suzuki, tanaka
連結  joining(前後): [sato, suzuki, tanaka]
連結  String.join : sato, suzuki, tanaka
連結  初期値+区切り : , sato, suzuki, tanaka
集計 reduce 版 専用メソッド版 選び方
合計 reduce(0, Integer::sum) mapToInt(...).sum() int・long・double は sum が短く、ボクシングも避けられる
最大 reduce(Integer::max) max(Comparator.naturalOrder()) 意図が伝わる max。オブジェクトは max(Comparator.comparing(...))
最小 reduce(Integer::min) min(Comparator.naturalOrder()) 同上
平均 合計 ÷ 件数を自分で計算 mapToInt(...).average() average。空のときは OptionalDouble.empty
まとめて (複数回たどる) summaryStatistics() 1回の走査で5つの値が得られる
件数 map(x -> 1).reduce(0, Integer::sum) count() count
連結 reduce("", (a, b) -> a + b) Collectors.joining(", ") 区切り・前後の文字も指定できる joining
BigDecimal の合計 reduce(BigDecimal.ZERO, BigDecimal::add) なし reduce

専用メソッドがある集計は専用メソッド、無い型や独自のまとめ方は reduce、が基本の選び方です。

連結の最後の行にも注目してください。初期値つきの reduce で区切り文字を入れると , sato, suzuki, tanaka と先頭に余分な区切りが付きました。初期値の空文字と最初の要素の間にも区切りが入るためです。区切りつきの連結は joining か String.join が確実です。

文字列の連結は joining が速い

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class JoinTime {
    public static void main(String[] args) {
        List<String> parts = IntStream.range(0, 20_000).mapToObj(i -> "item" + i).toList();
        for (int round = 1; round <= 3; round++) {
            long t0 = System.nanoTime();
            String a = parts.stream().reduce("", (x, y) -> x + y);
            long t1 = System.nanoTime();
            String b = parts.stream().collect(Collectors.joining());
            long t2 = System.nanoTime();
            System.out.printf("%d回目 reduce: %d ms / joining: %d ms / 同じ結果: %b / 長さ: %d%n",
                    round, (t1 - t0) / 1_000_000, (t2 - t1) / 1_000_000, a.equals(b), a.length());
        }
    }
}

実行結果(時間は実行ごとに変わります):

1回目 reduce: 1855 ms / joining: 25 ms / 同じ結果: true / 長さ: 168890
2回目 reduce: 917 ms / joining: 1 ms / 同じ結果: true / 長さ: 168890
3回目 reduce: 587 ms / joining: 0 ms / 同じ結果: true / 長さ: 168890

2万個の文字列の連結で、reduce は 587〜1855 ミリ秒、joining は 0〜25 ミリ秒でした。reduce は1回ごとに新しい文字列を作ってコピーするのに対し、joining は内部の入れ物に追記していくためです。Oracle の java.util.stream パッケージの説明にも、reduce での文字列連結は結果は得られるものの、大量の文字列コピーが発生し、実行時間が文字数に対して O(n^2) になる、と書かれています。

BigDecimal の合計と大きな数の積

BigDecimal には sum に当たる専用のストリームが無いので、reduce の出番です。

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.List;
import java.util.stream.IntStream;

public class BigDec {
    record Line(String name, BigDecimal price, int qty) {}

    public static void main(String[] args) {
        List<BigDecimal> prices = List.of(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"));
        // BigDecimal には sum() が無いので reduce を使う
        BigDecimal total = prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        System.out.println("BigDecimal 合計 : " + total);
        // double で足すと誤差が出る
        System.out.println("double 合計     : " + prices.stream().mapToDouble(BigDecimal::doubleValue).sum());
        System.out.println("0.1+0.2+0.3     : " + (0.1 + 0.2 + 0.3));

        // 単価 × 数量 の合計
        List<Line> lines = List.of(
                new Line("pen", new BigDecimal("120.50"), 3),
                new Line("note", new BigDecimal("298.00"), 2),
                new Line("ink", new BigDecimal("1050.25"), 1));
        BigDecimal amount = lines.stream()
                .map(l -> l.price().multiply(BigDecimal.valueOf(l.qty())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        System.out.println("金額合計        : " + amount);
        System.out.println("平均単価        : " + lines.stream().map(Line::price)
                .reduce(BigDecimal.ZERO, BigDecimal::add)
                .divide(BigDecimal.valueOf(lines.size()), 2, RoundingMode.HALF_UP));
        System.out.println("最大単価        : " + lines.stream().map(Line::price).reduce(BigDecimal::max));
        System.out.println("空リストの合計  : " + List.<BigDecimal>of().stream().reduce(BigDecimal.ZERO, BigDecimal::add));

        // 階乗 (積) は BigInteger で
        BigInteger fact = IntStream.rangeClosed(1, 25).mapToObj(BigInteger::valueOf)
                .reduce(BigInteger.ONE, BigInteger::multiply);
        System.out.println("25!             : " + fact);
        System.out.println("25! を long で  : " + IntStream.rangeClosed(1, 25).asLongStream().reduce(1L, (a, b) -> a * b));
    }
}

実行結果:

BigDecimal 合計 : 0.6
double 合計     : 0.6
0.1+0.2+0.3     : 0.6000000000000001
金額合計        : 2007.75
平均単価        : 489.58
最大単価        : Optional[1050.25]
空リストの合計  : 0
25!             : 15511210043330985984000000
25! を long で  : 7034535277573963776
  • reduce(BigDecimal.ZERO, BigDecimal::add) が定番の形です。 0.1+0.2+0.3 がきっかり 0.6 になりました。double のまま 0.1 + 0.2 + 0.3 と足すと 0.6000000000000001 です。
  • 平均は合計を件数で割ります。割り切れない場合に備えて、小数の桁数と丸め方(ここでは小数2桁・四捨五入)を指定します。
  • 積も同じ考え方です。25 の階乗は BigInteger なら正しく求まり、long では桁があふれて別の値になりました。

Integer の合計がオーバーフローする例と直し方

import java.util.List;

public class Overflow {
    public static void main(String[] args) {
        List<Integer> big = List.of(2_000_000_000, 2_000_000_000);

        System.out.println("reduce(0, Integer::sum) : " + big.stream().reduce(0, Integer::sum));
        System.out.println("mapToInt.sum()          : " + big.stream().mapToInt(Integer::intValue).sum());
        System.out.println("Integer.MAX_VALUE       : " + Integer.MAX_VALUE);

        // 直し方1: long で足す
        System.out.println("mapToLong.sum()         : " + big.stream().mapToLong(Integer::longValue).sum());
        System.out.println("reduce(0L, ...)         : " + big.stream().reduce(0L, (acc, x) -> acc + x, Long::sum));
        // 直し方2: あふれたら例外にして気づけるようにする
        try {
            big.stream().reduce(0, Math::addExact);
        } catch (ArithmeticException e) {
            System.out.println("Math::addExact          : " + e);
        }
    }
}

実行結果:

reduce(0, Integer::sum) : -294967296
mapToInt.sum()          : -294967296
Integer.MAX_VALUE       : 2147483647
mapToLong.sum()         : 4000000000
reduce(0L, ...)         : 4000000000
Math::addExact          : java.lang.ArithmeticException: integer overflow

20億+20億は int の最大値 2147483647 を超えるため、結果が -294967296 になりました。reduce でも sum でも、int のままなら同じ結果で、例外は出ません。

直し方は、mapToLong(...).sum() か3引数の reduce で long にする方法と、Math::addExact であふれたら ArithmeticException にする方法の2つです。

合計が大きくなりうるときは long で集計し、範囲を超えたら気づけるようにしたいときは Math::addExact を使います。

初期値を0以外にして並列実行すると結果が変わる

2引数・3引数の reduce の初期値には決まりがあります。それを外すとどうなるかを見てみます。

import java.util.List;
import java.util.stream.IntStream;

public class Identity {
    public static void main(String[] args) {
        List<Integer> nums = IntStream.rangeClosed(1, 8).boxed().toList();   // 合計 36

        System.out.println("CPU数 = " + Runtime.getRuntime().availableProcessors());
        System.out.println("初期値 0  逐次 : " + nums.stream().reduce(0, Integer::sum));
        System.out.println("初期値 0  並列 : " + nums.parallelStream().reduce(0, Integer::sum));
        System.out.println("初期値 10 逐次 : " + nums.stream().reduce(10, Integer::sum));
        System.out.println("初期値 10 並列 : " + nums.parallelStream().reduce(10, Integer::sum));

        // 初期値が何回使われたかを見る
        int r = nums.parallelStream().reduce(10, (acc, x) -> {
            if (acc == 10) {
                System.out.println("  初期値 10 から開始: x=" + x);
            }
            return acc + x;
        }, Integer::sum);
        System.out.println("結果 = " + r);

        // 直し方: 初期値は単位元(足し算なら 0)にして、上乗せ分は後から足す
        System.out.println("後から足す 並列 : " + (10 + nums.parallelStream().reduce(0, Integer::sum)));

        // 引き算は結合法則を満たさないので、並列で結果が変わる
        System.out.println("引き算 逐次 : " + nums.stream().reduce(0, (a, b) -> a - b));
        System.out.println("引き算 並列 : " + nums.parallelStream().reduce(0, (a, b) -> a - b));
    }
}

実行結果:

CPU数 = 2
初期値 0  逐次 : 36
初期値 0  並列 : 36
初期値 10 逐次 : 46
初期値 10 並列 : 76
  初期値 10 から開始: x=5
  初期値 10 から開始: x=7
  初期値 10 から開始: x=3
  初期値 10 から開始: x=1
結果 = 76
後から足す 並列 : 46
引き算 逐次 : -36
引き算 並列 : 0

1〜8 の合計は 36 です。初期値を 10 にすると、逐次では 46、並列では 76 になりました。並列ではかたまりごとに初期値から計算が始まるため、10 が4回足された(36+10×4=76) ことが、途中の表示からわかります。かたまりの数は CPU の数やデータ量で変わるので、環境によって別の値になります。

引き算も同様に、逐次では -36、並列では 0 と結果が分かれました。Oracle の java.util.stream パッケージの説明では、reduce に渡す値と関数に次の条件が示されています。

条件 内容 満たす例 満たさない例
初期値は単位元 どの値とまとめても、その値のままになる 足し算の 0、掛け算の 1、連結の "" 足し算の 10
結合法則 (a op b) op c と a op (b op c) が同じ 足し算、掛け算、max、min 引き算、割り算
combiner と accumulator の整合 分けて計算してまとめても同じ結果になる 文字数を足す+ Integer::sum まとめ方が別の計算

上乗せしたい値があるときは、初期値には単位元を渡し、reduce の外で足します。 10 + nums.parallelStream().reduce(0, Integer::sum) は並列でも 46 でした。

ArrayListをreduceで組み立てると起きることと collect での直し方

「初期値に空の ArrayList を渡し、accumulator で add する」という書き方は、逐次では動いて見えます。並列にすると次のようになります。

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ListReduce {
    public static void main(String[] args) {
        List<Integer> nums = IntStream.rangeClosed(1, 1000).boxed().toList();

        // 逐次では動いて見える
        List<Integer> seq = nums.stream().reduce(new ArrayList<>(),
                (list, x) -> { list.add(x * 2); return list; },
                (a, b) -> { a.addAll(b); return a; });
        System.out.println("逐次 reduce  : size=" + seq.size());

        // 並列にすると、全スレッドが同じ 1 個の ArrayList を書き換える
        for (int i = 1; i <= 3; i++) {
            try {
                List<Integer> par = nums.parallelStream().reduce(new ArrayList<>(),
                        (list, x) -> { list.add(x * 2); return list; },
                        (a, b) -> { a.addAll(b); return a; });
                System.out.println("並列 reduce " + i + ": size=" + par.size());
            } catch (Exception e) {
                System.out.println("並列 reduce " + i + ": " + e);
            }
        }

        // 直し方1: collect (スレッドごとに new ArrayList される)
        List<Integer> c1 = nums.parallelStream()
                .collect(ArrayList::new, (list, x) -> list.add(x * 2), ArrayList::addAll);
        System.out.println("collect 3引数 : size=" + c1.size() + " 先頭=" + c1.subList(0, 3));

        // 直し方2: map してから toList / Collectors.toList
        List<Integer> c2 = nums.parallelStream().map(x -> x * 2).toList();
        List<Integer> c3 = nums.parallelStream().map(x -> x * 2).collect(Collectors.toList());
        System.out.println("map + toList  : size=" + c2.size() + " 先頭=" + c2.subList(0, 3));
        System.out.println("Collectors    : size=" + c3.size() + " 先頭=" + c3.subList(0, 3));

        // reduce のまま正しく書くなら、毎回新しいリストを作る (動くがコピーが増える)
        List<Integer> immut = nums.parallelStream().reduce(List.of(),
                (list, x) -> { List<Integer> n = new ArrayList<>(list); n.add(x * 2); return n; },
                (a, b) -> { List<Integer> n = new ArrayList<>(a); n.addAll(b); return n; });
        System.out.println("毎回コピー    : size=" + immut.size() + " 先頭=" + immut.subList(0, 3));
    }
}

実行結果:

逐次 reduce  : size=1000
並列 reduce 1: size=6000
並列 reduce 2: size=6000
並列 reduce 3: size=6000
collect 3引数 : size=1000 先頭=[2, 4, 6]
map + toList  : size=1000 先頭=[2, 4, 6]
Collectors    : size=1000 先頭=[2, 4, 6]
毎回コピー    : size=1000 先頭=[2, 4, 6]

1000 個のはずが 6000 個になりました。別の回では 5916 や 4736 になったこともあります。reduce の初期値は1個のオブジェクトなので、すべてのスレッドが同じ1個の ArrayList に add し、さらに combiner が同じリストどうしを addAll するため、要素が重複します。 ArrayList は複数スレッドからの同時の変更を想定していないため、件数も回によって変わります。

直し方は collect です。 collect(ArrayList::new, (list, x) -> list.add(x * 2), ArrayList::addAll) は、最初の引数が「入れ物を作る関数」なので、かたまりごとに新しい ArrayList が作られます。結果は 1000 個で、順番も保たれました。ふだんは map(...).toList() や Collectors.toList() を使えば十分です。

reduce のまま正しく書くなら、最後の例のように毎回新しいリストを作って返します。結果は正しくなりますが、要素のたびにリスト全体をコピーするので、件数が多いと時間がかかります。

逐次でも初期値のリストは使い回される

import java.util.ArrayList;
import java.util.List;

public class SeqTrap {
    public static void main(String[] args) {
        // 逐次でも、初期値のリストを使い回すと前回の結果が残る
        List<String> identity = new ArrayList<>();
        List<String> first = List.of("a", "b").stream().reduce(identity,
                (list, x) -> { list.add(x); return list; }, (a, b) -> a);
        System.out.println("1回目: " + first);
        List<String> second = List.of("c").stream().reduce(identity,
                (list, x) -> { list.add(x); return list; }, (a, b) -> a);
        System.out.println("2回目: " + second);
        System.out.println("first == second : " + (first == second));
    }
}

実行結果:

1回目: [a, b]
2回目: [a, b, c]
first == second : true

同じ初期値のリストで2回 reduce すると、2回目の結果に1回目の a、b が残りました。first と second は同じ1個のリストです。reduce は「値を書き換えずに、新しい値を返す」まとめ方のための操作、と考えると整理しやすくなります。

reduce と collect の違い

観点 reduce collect
まとめ方 2つの値から新しい値を作る 入れ物に要素を追加していく
初期値 値を1個渡す(全体で共有) 入れ物を作る関数を渡す(必要な数だけ作られる)
向いている結果 Integer、BigDecimal、String など変更できない値 List、Set、Map、StringBuilder など変更できる入れ物
代表的な書き方 reduce(BigDecimal.ZERO, BigDecimal::add) collect(Collectors.toList())、Collectors.joining()

「1個の値に畳み込むなら reduce、入れ物に集めるなら collect」 と覚えておけば、ほとんどの場面で迷いません。

数値以外の reduce:オブジェクトの最大・グループごとの集計・関数の合成

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class Others {
    record Product(String name, String category, int price) {}

    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("pen", "文具", 120),
                new Product("note", "文具", 300),
                new Product("mouse", "機器", 2500),
                new Product("cable", "機器", 800));

        // 一番高い商品 (オブジェクトの最大)
        Optional<Product> top = products.stream()
                .reduce(BinaryOperator.maxBy(Comparator.comparingInt(Product::price)));
        System.out.println("最高値 reduce : " + top.map(Product::name));
        System.out.println("最高値 max    : " + products.stream().max(Comparator.comparingInt(Product::price)).map(Product::name));

        // 一番長い名前 (同じ長さなら先に出たほう)
        System.out.println("最長の名前    : " + products.stream().map(Product::name)
                .reduce((a, b) -> a.length() >= b.length() ? a : b));

        // 価格の合計 (3引数 / mapToInt)
        System.out.println("合計 3引数    : " + products.stream().reduce(0, (acc, p) -> acc + p.price(), Integer::sum));
        System.out.println("合計 mapToInt : " + products.stream().mapToInt(Product::price).sum());

        // カテゴリごとの合計 (groupingBy の中で使う Collectors.reducing)
        Map<String, Integer> byCat = products.stream().collect(Collectors.groupingBy(
                Product::category, TreeMap::new, Collectors.reducing(0, Product::price, Integer::sum)));
        System.out.println("reducing      : " + byCat);
        Map<String, Integer> byCat2 = products.stream().collect(Collectors.groupingBy(
                Product::category, TreeMap::new, Collectors.summingInt(Product::price)));
        System.out.println("summingInt    : " + byCat2);

        // 積
        System.out.println("積            : " + IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b));

        // 関数を順番につなぐ
        Function<String, String> pipeline = Stream.<Function<String, String>>of(
                String::trim, String::toUpperCase, s -> s.replace(" ", "_"))
                .reduce(Function.identity(), Function::andThen);
        System.out.println("関数の合成    : " + pipeline.apply("  java stream reduce "));

        // すべて true か (allMatch のほうが途中で打ち切れる)
        System.out.println("論理積 reduce : " + Stream.of(true, true, false).reduce(true, Boolean::logicalAnd));
        System.out.println("allMatch      : " + Stream.of(true, true, false).allMatch(b -> b));

        // プリミティブ用ストリームの reduce
        System.out.println("IntStream 1引数: " + IntStream.of(3, 9, 4).reduce(Integer::max));
        System.out.println("IntStream 2引数: " + IntStream.of(3, 9, 4).reduce(0, Integer::sum));
    }
}

実行結果:

最高値 reduce : Optional[mouse]
最高値 max    : Optional[mouse]
最長の名前    : Optional[mouse]
合計 3引数    : 3720
合計 mapToInt : 3720
reducing      : {文具=420, 機器=3300}
summingInt    : {文具=420, 機器=3300}
積            : 120
関数の合成    : JAVA_STREAM_REDUCE
論理積 reduce : false
allMatch      : false
IntStream 1引数: OptionalInt[9]
IntStream 2引数: 16
  • オブジェクトの最大:BinaryOperator.maxBy(Comparator) を渡すと reduce で最大の要素を選べます。max(Comparator) でも同じ結果です。
  • 独自の選び方:「名前が一番長いもの、同じなら先に出たほう」のような規則は、2つを比べて残すほうを返すラムダ式で書けます。
  • グループごとの集計:groupingBy の中では Collectors.reducing(初期値, 取り出す関数, まとめる関数) が使えます。合計なら summingInt が同じ結果を短く書けます。
  • 関数の合成:関数のストリームを Function::andThen で畳み込むと、trim、大文字化、置換を順に行う1つの関数になります。
  • 論理積:reduce(true, Boolean::logicalAnd) でも求まりますが、allMatch は結果が決まった時点で打ち切れます。
  • IntStream の reduce:1引数は OptionalInt、2引数は int を返します。

よくある質問

Q. reduce と sum はどちらを使えばよいですか? A. int、long、double の合計なら mapToInt(...).sum() などの専用メソッドが短く、意図も伝わります。BigDecimal のように専用メソッドが無い型や、独自のまとめ方をしたいときに reduce を使います。

Q. 1引数の reduce が Optional を返すのはなぜですか? A. 空のストリームでは返す値が無いためです。実行結果でも Optional.empty になりました。orElse で既定値を決めるか、2引数の形を使います。

Q. reduce でリストを作ってもよいですか? A. 初期値の ArrayList に add する書き方は、並列で件数が変わり、逐次でも初期値が使い回されました。リストや Map に集めるときは toList や collect を使うのがおすすめです。

まとめ

  • reduce は要素を1個の値に畳み込む終端操作で、1引数・2引数・3引数の3つの形がある。
  • 1引数は Optional を返し、空なら Optional.empty。2引数・3引数は空なら初期値が返る。
  • 合計・最大・平均・連結は sum、max、average、joining などの専用メソッドが第一候補。 BigDecimal の合計は reduce(BigDecimal.ZERO, BigDecimal::add) です。
  • int の合計はオーバーフローしても例外にならない。 long で集計するか Math::addExact を使います。
  • 初期値は単位元(足し算の 0、掛け算の 1)、関数は結合法則を満たすものにする。 外すと並列実行で結果が変わります。
  • リストや Map に集めるなら collect。 reduce の初期値は全体で共有される1個の値です。
  • 3引数の形は、map と2引数の reduce に分けると読みやすくなることが多い。

参考・出典

  • Oracle「Java SE 21 API 仕様 java.util.stream パッケージ」(Reduction operations、Mutable reduction、Associativity の節。https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Stream reduceの使い方 合計・最大とcollectとの違い」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?