コレクション + 関数型 — List · Set · Map · Lambda · Stream
コレクション + 関数型 — 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もあります — 読み取りが多く書き込みが稀な場合(例:イベントリスナーリスト)に使われます。
まとめ
> 💡 現場基準: 90%のケースではArrayList + HashMapから始めます。マルチスレッドならConcurrentHashMap。ソートが必要になったらTreeMapに切り替えます。
ラムダと関数型 — Javaが*生まれ変わった*瞬間
なぜJava 8が転換点なのか
2014年、Java 8がラムダ式とStream APIを導入しました。それ以前のJavaはオブジェクト指向のみの言語でした。関数一つを渡すのにnew Runnable() { public void run() { ... } }のような匿名クラス5行のコードを書く必要がありました。
ラムダはこれを1行にまとめます。
コードが短くなったことが本質ではありません。本当の変化は関数をデータのように扱えるようになったことです。変数に保存し、他の関数に引数として渡し、返り値にできます。
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はコレクションデータをパイプラインで処理するツールです。昔のコードはこうでした:
Streamで書くと意図がコードに直接表れます:
.filterは条件に合うものだけ通過。.mapは各要素を変換。.sortedはソート。.toListは収集してListに。上から下に読むとデータが流れていく様子が目に浮かびます。
groupingBy — よく使う魔法
Streamの真価は集計で発揮されます。部門ごとに従業員をグループ化したいとき:
1行で完了です。SQLのGROUP BYがJavaに来たと思えばいいです。部門別の平均給与も同様に:
よくある落とし穴 — 知っておくと良いこと
Streamの中で外部変数を変更してはいけません。並列処理時にレースコンディションが発生し、関数型の考え方自体を壊します。代わりにcollect · reduceで不変の結果を得るべきです。
parallelStream()は万能ではありません。小さなデータやI/O処理ではむしろ遅くなります。コンテキストスイッチのコストの方が大きいです。CPUをたくさん使う大きなデータにのみ効果があります。
Optionalも同時期に登場しました。nullの代わりに値が存在しない可能性があることを明示するボックスです。Optional<User>を受け取るとnullかもしれないとすぐわかります。
NullPointerExceptionの80%がこのパターンで消えます。
マルチスレッド — *同時に*処理させる方法
なぜスレッドが必要か
最近のCPUはコアが複数あります(通常8〜16個)。しかし普通のJavaプログラムはコア1つしか使いません。残りは遊んでいます。スレッドをうまく使えば遊んでいるコアを活用して処理速度がN倍になります。
もう一つの理由は待機です。DBレスポンスを待つ1秒間、CPUは何もしません。その時間に他の処理をさせればスループットが急増します。これがWebサーバーが同時に1000人のリクエストを処理できる秘訣です。
スレッドを作る3つの方法
1. 直接作成: new Thread(() -> { ... }).start()。最もシンプルですが手動管理が煩わしいです。1万個作るとOSが悲鳴を上げます。
2. ExecutorService: スレッドプールを作りタスクを投入します。実務の標準です。
10個のスレッドで1000個のタスクを順次処理します。毎回新しいスレッドを作るコストがなくなります。
3. CompletableFuture — 非同期の組み合わせの最終形態です。「AとBを同時に取得して両方終わったら合わせてCを呼び出す」のようなフローをすっきり表現できます。
Java 21の真の革命 — バーチャルスレッド
長い間、Javaでスレッドを数千個作るのは危険でした。各スレッドがOSリソースを消費するためです。通常数百個が限界でした。
Java 21のバーチャルスレッドはこの限界を破りました。JVMが仮想的にスレッドを管理するため1万個・10万個でも軽く作れます。I/O待機中は自動的にyieldして他の処理が割り込めます。
この1行がGoのgoroutineやKotlinのコルーチンと同じレベルの並行性をJavaにもたらしました。ただしCPUバウンドな処理には効果がありません — 実際のCPUコア数は変わらないためです。
最も重要な落とし穴 — レースコンディション
2つのスレッドが同じ変数を同時に変更すると結果が予測不能になります。
counter + 1は3つの命令(読み取り・加算・書き込み)に分かれていて、その間に別のスレッドが割り込める可能性があるためです。
解決策はロック (synchronized · Lock) またはアトミック操作 (AtomicInteger):
デッドロック — 永遠の待機
2つのスレッドがお互いのロックを待つと永遠に停止します。
AがロックXを持ちYを待ち、BがロックYを持ちXを待つと — 両方とも永遠に進みません。デッドロックです。
最も多い原因はロックを取得する順序が異なる場合です。すべてのスレッドが常に同じ順序でロックを取得するよう強制すればデッドロックは発生しません。
実務でデッドロックが疑われる場合はjstack <PID>ですべてのスレッドのスタックトレースを取得します。Javaがデッドロックを自動検出して「Found one Java-level deadlock」メッセージを出力してくれます。
まとめ
スレッド = CPUをより有効活用するツール。核心は共有データをいかに安全に扱うかです。ロック・アトミック操作・メッセージパッシングの中から状況に合ったものを選ぶのが実務の核心スキルです。
Java 21からはバーチャルスレッドで並行性への参入障壁が大きく下がりました。新しいプロジェクトなら積極的に検討する価値のある選択肢です。
Iteratorパターン — for-eachがどう動くか
for-eachはIteratorの糖衣構文
上記のコードはコンパイル時に実際にはこのように変換されます:
Iterableインターフェースを実装したコレクションはすべてfor-eachが使えます。ArrayList · HashSet · LinkedListすべて対応しています(HashMapはentrySet/keySet/values経由で)。
Iteratorの3つのメソッド
イテレーション中の変更の落とし穴 — ConcurrentModificationException
イテレーション中にコレクションを変更すると例外が発生します。解決策は2つ:
自分でIterableを作る — カスタムコレクション
Iterableを実装するだけでfor-eachが使えます — これがJavaコレクションの核心的なデザインです。
☕ 実際に試してみよう — List · Map · Stream
🤖 AIにこう頼んでみよう
このlessonの概念を知っていると、AIに具体的に指示できます。漠然とした「直して」ではなく語彙を持ったリクエスト — それがトークン節約の出発点です。
- ▸「このforループをStream APIのmap/filter/reduceチェーンに書き換えて」
- ▸「このArrayListの操作をIteratorの中で安全にremoveできるよう直して」
- ▸「このコードのListを不変のList.of()にリファクタリングして」
なぜこれがトークンを減らすか
概念を知らないとAIの回答を受け取っても「それって何ですか?」と再び聞く必要があります。その「再質問」がトークンを消費します。概念を一度理解しておけば会話が一度で終わります。