C
Java/コレクション 関数型/Lesson 05

コレクション + 関数型 — List · Set · Map · Lambda · Stream

60分·theory

コレクション + 関数型 — List · Set · Map · Lambda · Stream

🎯 このlessonを読み終えると

このlessonをすべて読み終えると、以下の3つを自信を持ってできるようになります。

  • ✅ List · Set · Map それぞれの使いどころ
  • ✅ Stream APIのmap/filter/reduceチェーンでforループをリファクタリング
  • ✅ Iterator vs for-each + ConcurrentModificationExceptionの落とし穴

学習目標をチェックリストとして持ち、すべてに答えられるようになったらlessonを閉じてください。

Javaコレクションとは — データを入れる*入れ物*

核心を一言で

Java Collections Framework = 複数のデータを入れる標準の入れ物。1998年のJava 2で導入され、今日まですべてのJava開発者が毎日使う最も基本的なツール。

なぜ4つのインターフェースに分かれているか

データの格納方法は目的によって異なります。

  • List順序があり重複も許容。「ショッピングカートに入った商品」のようなもの。1番目・2番目・3番目のようなインデックスが意味を持つ場合に使います。
  • Set重複なし、順序は通常なし。「今日訪問したユーザーIDのリスト」のようなもの。同じ人が100回来ても1回としてカウントしたいとき。
  • Mapキー → 値のマッピング。「ユーザーID → ユーザー情報」のようにペアで扱いたいとき。最もよく使う構造。
  • Queue入った順に出てくる。ジョブキュー・イベント処理。FIFOが自然な場合。

この4つの抽象化のおかげで、どの実装を選んでも同じ方法で扱えます。ListがArrayListであれLinkedListであれ、コードは同様にlist.add() · list.get()

最もよく見るArrayList vs LinkedList

名前は似ていますが、まったく異なる動作をします。

ArrayListは内部的に配列を使います。そのためインデックスで直接ジャンプできます — n番目の要素を1ステップで取得します (O(1))。ただし中間への挿入は後ろのすべての要素を1つずつずらす必要があり遅いです。それでもメモリが連続して配置されているためCPUキャッシュに優しく、実務ではほとんどの場合より速いです。

LinkedListはノードが互いを指す連結リスト。中間挿入はリンクを変えるだけなのでO(1)に見えますが、その位置を探すのにO(n)かかります。さらにノードごとに別々のメモリを使うためキャッシュ効率が悪いです。

結論: インデックスアクセスが多ければArrayList。中間挿入が本当に多い場合のみLinkedListを検討しますが、実際にはほとんど使いません。名前とは裏腹に、LinkedListの方が遅いケースが多いです。

HashMap — Javaで最もよく使われるデータ構造

HashMap<String, User>のようなコードは毎日目にします。どう動くか一度は理解しておくべきです。

内部は配列 + 連結リストの組み合わせです。キーのhashCode()を計算して配列インデックスを求め、同じインデックスに複数のキーが衝突した場合はそこで連結リストとしてつなぎます。Java 8からは衝突が8個以上になるとその部分が自動的にRed-Blackツリーに変換され、最悪の場合でもO(log n)が保証されます。

最も多い間違い: キーとして使うオブジェクトのhashCode()equals()を両方正しく実装する必要があります。どちらか一方だけをオーバーライドすると検索ができません。Lombokの@EqualsAndHashCodeまたはrecord (Java 14+)が自動的に処理してくれます。

マルチスレッド環境 — ConcurrentHashMap

複数のスレッドが同時に同じHashMapを変更すると無限ループに陥ったりデータが壊れる可能性があります。よくある事故はHashMapをサーバーのキャッシュとして共有する場合です。

解決策はConcurrentHashMapです。内部的にバケット単位でロックを取得し並行性を許容します。Collections.synchronizedMap()という全体ロック方式もありますが、並行性を高める必要がある場合はConcurrentHashMapが事実上の標準です。

同様の理由でCopyOnWriteArrayListもあります — 読み取りが多く書き込みが稀な場合(例:イベントリスナーリスト)に使われます。

まとめ

インターフェース最も一般的な実装いつ使うか
ListArrayListインデックス・順序・重複がすべて必要
SetHashSet / LinkedHashSet重複除去 / 順序も必要な場合
MapHashMap / ConcurrentHashMapkey→value、並行性が必要なら後者
QueueArrayDequeFIFOジョブキュー
SortedTreeMap / TreeSetキーのソートが必要な場合

> 💡 現場基準: 90%のケースではArrayList + HashMapから始めます。マルチスレッドならConcurrentHashMap。ソートが必要になったらTreeMapに切り替えます。

ラムダと関数型 — Javaが*生まれ変わった*瞬間

なぜJava 8が転換点なのか

2014年、Java 8がラムダ式Stream APIを導入しました。それ以前のJavaはオブジェクト指向のみの言語でした。関数一つを渡すのにnew Runnable() { public void run() { ... } }のような匿名クラス5行のコードを書く必要がありました。

ラムダはこれを1行にまとめます。

java
// 旧スタイル
new Thread(new Runnable() {
    public void run() { System.out.println("hi"); }
}).start();

// ラムダ — Java 8+
new Thread(() -> System.out.println("hi")).start();

コードが短くなったことが本質ではありません。本当の変化は関数をデータのように扱えるようになったことです。変数に保存し、他の関数に引数として渡し、返り値にできます。

Functional Interfaceの正体

ラムダが可能な理由は抽象メソッドがちょうど1つのインターフェース — Functional Interface — のおかげです。コンパイラはラムダを見るとどの関数型インターフェースかを推論して実装クラスを自動生成します。

よく使う4つ:

  • Function<T, R> — Tを受け取りRを返す。変換するとき。例: 文字列 → 長さ。
  • Predicate<T> — Tを受け取りbooleanを返す。フィルタリングするとき。例: 「成人か?」
  • Consumer<T> — Tを受け取り何も返さない消費のみ。例: ログ出力。
  • Supplier<T>何も受け取らずTを返す。供給のみ。例: 現在時刻の取得。

名前が直感的です。受け取る・返す・両方・どちらでもないの4つの組み合わせ。

Stream API — コレクションをフローとして

Streamはコレクションデータをパイプラインで処理するツールです。昔のコードはこうでした:

java
List<String> result = new ArrayList<>();
for (User u : users) {
    if (u.getAge() >= 30) {
        result.add(u.getName().toUpperCase());
    }
}
Collections.sort(result);

Streamで書くと意図がコードに直接表れます:

java
List<String> result = users.stream()
    .filter(u -> u.getAge() >= 30)
    .map(u -> u.getName().toUpperCase())
    .sorted()
    .toList();

.filterは条件に合うものだけ通過。.mapは各要素を変換.sortedはソート。.toList収集してListに。上から下に読むとデータが流れていく様子が目に浮かびます。

groupingBy — よく使う魔法

Streamの真価は集計で発揮されます。部門ごとに従業員をグループ化したいとき:

java
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDept));

1行で完了です。SQLのGROUP BYがJavaに来たと思えばいいです。部門別の平均給与も同様に:

java
Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDept,
        Collectors.averagingDouble(Employee::getSalary)));

よくある落とし穴 — 知っておくと良いこと

Streamの中で外部変数を変更してはいけません。並列処理時にレースコンディションが発生し、関数型の考え方自体を壊します。代わりにcollect · reduce不変の結果を得るべきです。

parallelStream()は万能ではありません。小さなデータやI/O処理ではむしろ遅くなります。コンテキストスイッチのコストの方が大きいです。CPUをたくさん使う大きなデータにのみ効果があります。

Optionalも同時期に登場しました。nullの代わりに値が存在しない可能性があることを明示するボックスです。Optional<User>を受け取るとnullかもしれないとすぐわかります。

java
Optional<User> u = userRepo.findById(id);
String name = u.map(User::getName).orElse("(名前なし)");

NullPointerExceptionの80%がこのパターンで消えます。

マルチスレッド — *同時に*処理させる方法

なぜスレッドが必要か

最近のCPUはコアが複数あります(通常8〜16個)。しかし普通のJavaプログラムはコア1つしか使いません。残りは遊んでいます。スレッドをうまく使えば遊んでいるコアを活用して処理速度がN倍になります。

もう一つの理由は待機です。DBレスポンスを待つ1秒間、CPUは何もしません。その時間に他の処理をさせればスループットが急増します。これがWebサーバーが同時に1000人のリクエストを処理できる秘訣です。

スレッドを作る3つの方法

1. 直接作成: new Thread(() -> { ... }).start()。最もシンプルですが手動管理が煩わしいです。1万個作るとOSが悲鳴を上げます。

2. ExecutorService: スレッドプールを作りタスクを投入します。実務の標準です。

java
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    pool.submit(() -> doWork());
}
pool.shutdown();

10個のスレッドで1000個のタスクを順次処理します。毎回新しいスレッドを作るコストがなくなります。

3. CompletableFuture非同期の組み合わせの最終形態です。「AとBを同時に取得して両方終わったら合わせてCを呼び出す」のようなフローをすっきり表現できます。

java
CompletableFuture<User> userFut    = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<Order> orderFut  = CompletableFuture.supplyAsync(() -> fetchOrders());
CompletableFuture<UserDto> dto = userFut.thenCombine(orderFut, UserDto::new);

Java 21の真の革命 — バーチャルスレッド

長い間、Javaでスレッドを数千個作るのは危険でした。各スレッドがOSリソースを消費するためです。通常数百個が限界でした。

Java 21バーチャルスレッドはこの限界を破りました。JVMが仮想的にスレッドを管理するため1万個・10万個でも軽く作れます。I/O待機中は自動的にyieldして他の処理が割り込めます。

java
Thread.startVirtualThread(() -> doWork());

この1行がGoのgoroutineやKotlinのコルーチンと同じレベルの並行性をJavaにもたらしました。ただしCPUバウンドな処理には効果がありません — 実際のCPUコア数は変わらないためです。

最も重要な落とし穴 — レースコンディション

2つのスレッドが同じ変数を同時に変更すると結果が予測不能になります。

java
int counter = 0;
// スレッドA: counter = counter + 1;  → 0を読む → 1を書く
// スレッドB: counter = counter + 1;  → 0を読む → 1を書く
// 結果: counter = 1 (期待値は2)

counter + 13つの命令(読み取り・加算・書き込み)に分かれていて、その間に別のスレッドが割り込める可能性があるためです。

解決策はロック (synchronized · Lock) またはアトミック操作 (AtomicInteger):

java
private final AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();   // アトミックな+1

デッドロック — 永遠の待機

2つのスレッドがお互いのロックを待つと永遠に停止します。

AがロックXを持ちYを待ち、BがロックYを持ちXを待つと — 両方とも永遠に進みません。デッドロックです。

最も多い原因はロックを取得する順序が異なる場合です。すべてのスレッドが常に同じ順序でロックを取得するよう強制すればデッドロックは発生しません。

実務でデッドロックが疑われる場合はjstack <PID>ですべてのスレッドのスタックトレースを取得します。Javaがデッドロックを自動検出して「Found one Java-level deadlock」メッセージを出力してくれます。

まとめ

スレッド = CPUをより有効活用するツール。核心は共有データをいかに安全に扱うかです。ロック・アトミック操作・メッセージパッシングの中から状況に合ったものを選ぶのが実務の核心スキルです。

Java 21からはバーチャルスレッドで並行性への参入障壁が大きく下がりました。新しいプロジェクトなら積極的に検討する価値のある選択肢です。

💻 📌 よく使うコード(暗記不要、参考用)
// ========================================
// 1. Stream — フィルター・変換・収集 最もよく使うパターン
// ========================================
List<Order> orders = orderRepo.findAll();

// 決済完了注文のユーザーメール (重複除去)
List<String> emails = orders.stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .map(o -> o.getUser().getEmail())
    .distinct()
    .toList();

// ========================================
// 2. 集計 — グループ化・平均・合計
// ========================================
// 部署別従業員グループ化
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDept));

// 部署別平均年俸
Map<String, Double> avgSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDept,
        Collectors.averagingDouble(Employee::getSalary)));

// 全体売上合計
BigDecimal total = orders.stream()
    .map(Order::getAmount)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// ========================================
// 3. Optional — null 安全処理
// ========================================
Optional<User> u = userRepo.findById(id);
String name = u.map(User::getName).orElse("(名前なし)");

u.ifPresent(user -> sendEmail(user.getEmail()));

// ========================================
// 4. CompletableFuture — 非同期組み合わせ
// ========================================
CompletableFuture<User> userFut    = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<List<Order>> ordersFut = CompletableFuture.supplyAsync(() -> fetchOrders(id));

CompletableFuture<UserDto> dto = userFut.thenCombine(ordersFut,
    (user, orders) -> new UserDto(user, orders));

dto.thenAccept(d -> System.out.println(d))
   .exceptionally(ex -> { log.error("失敗", ex); return null; });

// ========================================
// 5. Virtual Thread (Java 21+) — 1万同時リクエスト
// ========================================
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Response>> results = userIds.stream()
        .map(id -> executor.submit(() -> httpClient.get("/api/users/" + id)))
        .toList();
    // ユーザーが1万人いてもOSスレッド1万個なしで処理
}

Iteratorパターン — for-eachがどう動くか

for-eachはIteratorの糖衣構文

java
List<String> list = List.of("a", "b", "c");
for (String s : list) {
    System.out.println(s);
}

上記のコードはコンパイル時に実際にはこのように変換されます:

java
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    System.out.println(s);
}

Iterableインターフェースを実装したコレクションはすべてfor-eachが使えます。ArrayList · HashSet · LinkedListすべて対応しています(HashMapはentrySet/keySet/values経由で)。

Iteratorの3つのメソッド

java
public interface Iterator<E> {
    boolean hasNext();   // 次の要素があるか
    E next();            // 次の要素を取り出しカーソルを進める
    default void remove();  // 現在の要素を削除(オプション)
}

イテレーション中の変更の落とし穴 — ConcurrentModificationException

java
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) list.remove(s);   // ❌ 例外が発生
}

イテレーション中にコレクションを変更すると例外が発生します。解決策は2つ:

java
// ✅ 1. Iteratorを直接使用 + Iterator.remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();
}

// ✅ 2. removeIf (Java 8+)
list.removeIf(s -> s.equals("b"));

自分でIterableを作る — カスタムコレクション

java
class Range implements Iterable<Integer> {
    private final int start, end;
    Range(int s, int e) { this.start = s; this.end = e; }

    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<>() {
            int cur = start;
            public boolean hasNext() { return cur < end; }
            public Integer next()    { return cur++; }
        };
    }
}

for (int i : new Range(1, 5)) System.out.println(i);  // 1,2,3,4

Iterableを実装するだけでfor-eachが使えます — これがJavaコレクションの核心的なデザインです。

☕ 実際に試してみよう — List · Map · Stream

コレクション + Stream APIの核心。関数型変換・フィルタリング・集計。
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ 実行ボタンを押してください
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 AIにこう頼んでみよう

このlessonの概念を知っていると、AIに具体的に指示できます。漠然とした「直して」ではなく語彙を持ったリクエスト — それがトークン節約の出発点です。

  • 「このforループをStream APIのmap/filter/reduceチェーンに書き換えて」
  • 「このArrayListの操作をIteratorの中で安全にremoveできるよう直して」
  • 「このコードのListを不変のList.of()にリファクタリングして」

なぜこれがトークンを減らすか

概念を知らないとAIの回答を受け取っても「それって何ですか?」と再び聞く必要があります。その「再質問」がトークンを消費します。概念を一度理解しておけば会話が一度で終わります。

コレクション + 関数型 — List · Set · Map · Lambda · Stream - Java