初心者でも納得!JavaのArrayList内部構造を徹底解剖する特別講義
こんにちは。ゆうせいです。
プログラミングを学んでいると、必ずと言っていいほどお世話になるのがArrayListですよね。配列と同じようにデータを並べて管理できる便利な道具ですが、その中身がどうなっているか気になったことはありませんか。
「魔法のようにサイズが伸び縮みするけれど、裏側では何が起きているの?」
「普通の配列と何が違うの?」
そんな疑問を抱えている新人エンジニアのあなたに向けて、今回はJDKのソースコードを読み解きながら、ArrayListの正体を暴いていこうと思います。難しい専門用語も、身近な例え話でしっかり噛み砕いて説明するので安心してくださいね。
それでは、Javaの標準ライブラリという最高のお手本から、プロフェッショナルの技術を学んでいきましょう!
本講義の章構成
本日は全4章に分けて、ArrayListの核心に迫ります。
- 第1章:魔法の箱の正体「動的配列」と「1.5倍」の秘密
- 第2章:なぜ何でも入るのか?「Object配列」と型の安全性
- 第3章:超高速移動の舞台裏「System.arraycopy」の威力
- 第4章:裏切りを許さない「fail-fast」という防衛本能
第1章:魔法の箱の正体「動的配列」と「1.5倍」の秘密
概要
ArrayListは、要素を追加するだけで自動的にサイズが大きくなります。しかし、Javaの「配列」という仕組み自体は、一度作ると後から長さを変えることができません。では、なぜArrayListは伸び縮みできるのでしょうか。この章では、サイズ拡張(リサイズ)の仕組みと、なぜ1.5倍という絶妙な数字が選ばれているのかを解説します。
コード抜粋
ArrayListの内部で、新しいサイズを計算している非常に重要な箇所を見てみましょう。
private int newCapacity(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0)
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
逐次解説
一行ずつ、このロジックの意味を解き明かしていきます。
- int oldCapacity = elementData.length;今、現在使っている配列の長さを確認しています。elementDataというのが、データを格納している実際の配列です。
- int newCapacity = oldCapacity + (oldCapacity >> 1);ここが今回の最重要ポイントです!「oldCapacity >> 1」というのは、現在のサイズを右に1ビットシフトする、つまり「半分にする」という意味です。元のサイズにその半分を加えるので、結果として1.5倍の新しいサイズを計算しています。
- if (newCapacity - minCapacity <= 0)新しく計算した1.5倍のサイズでも、追加しようとしている要素数(minCapacity)に足りない場合の処理です。
- return Math.max(DEFAULT_CAPACITY, minCapacity);もしリストが空の状態なら、デフォルトのサイズ(通常は10)か、要求されたサイズの大きい方を採用します。
- throw new OutOfMemoryError();サイズがマイナスになる(メモリの限界を超える)ような異常事態なら、エラーを投げます。
- return (newCapacity - MAX_ARRAY_SIZE <= 0) ? ...最後に、Javaが扱える配列の最大サイズを超えていないかをチェックして、新しいサイズを決定します。
図解イメージ
ArrayListの拡張を「引っ越し」に例えてみましょう。
あなたは現在、10人乗りの小さなバス(配列)に乗っています。11人目が来たら、どうしますか。バスの壁を突き破って座席を増やすことはできませんよね。
そこで、ArrayListは「1.5倍の大きさの、新しい15人乗りのバス」を別で用意します。そして、元の10人を新しいバスに一人ずつ移動させ、古いバスは捨ててしまいます。これが「動的配列」の仕組みです。あなたは外から見ているので、バスが勝手に大きくなったように見えるわけです。
よくある誤解
「要素を1つ追加するたびに、サイズを1つ増やしている」と思っていませんか。
もし、追加のたびに新しい配列を作ってコピーしていたら、動作が非常に重くなってしまいます。そのため、ある程度余裕を持ったサイズ(1.5倍)で一気に確保しておくのが、効率化のコツなのです。
実務での重要性
なぜ「2倍」ではなく「1.5倍」なのでしょうか。
2倍だとサイズが急激に大きくなりすぎてメモリを無駄にする可能性が高まります。一方で、増分が小さすぎると頻繁に「引っ越し(コピー)」が発生してパフォーマンスが落ちます。1.5倍という数字は、メモリ効率と実行速度のバランスを考慮した、先人たちの知恵の結晶と言えるでしょう。
演習問題
問1:現在のArrayListの容量(capacity)が20のとき、新しい要素を追加してリサイズが発生した場合、次の容量はいくつになるでしょうか。
問2:ArrayListの内部で、要素を保持するために実際に使われているデータ構造の名前を答えてください。
第1章では、ArrayListが「1.5倍の引っ越し」を繰り返して大きくなる仕組みを学びましたね。さて、ここで一つ疑問が浮かびませんか。
「JavaにはStringやInteger、自分で作ったUserクラスなど、無数の型があるのに、なぜArrayListはそれらすべてを一つの箱に入れられるのか?」
その秘密を解き明かすのが、この第2章です。
第2章:なぜ何でも入るのか?「Object配列」と型の安全性
概要
ArrayListのソースコードを覗くと、データを保持しているのは特定の型ではなく、Objectという型の配列であることがわかります。Javaにおける「すべてのクラスの親」であるObjectを使うことで、どんなデータも受け入れる懐の深さを実現しているのです。ここでは、その内部構造と、私たちが普段使っている「ジェネリクス(<E>)」との関係を理解しましょう。
コード抜粋
ArrayListの心臓部ともいえる、変数宣言の部分を見てみましょう。
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer.
*/
transient Object[] elementData; // non-private to simplify nested class access
逐次解説
この短い一行に、ArrayListの汎用性のすべてが詰まっています。
- transientこれは直列化(オブジェクトをファイルに保存したりすること)の対象から外すためのキーワードですが、今は「特殊な設定がついているんだな」くらいの認識で大丈夫です。
- Object[]ここが最大のポイントです!Javaのすべてのクラスは、たどっていくと必ずObjectクラスに行き着きます。つまり、Object型の配列を用意しておけば、どんなクラスのインスタンスでも代入できるというわけです。
- elementDataこれがArrayListの実体です。私たちが add(e) と呼び出すとき、実際にはこの配列の空いている場所に要素が放り込まれています。
図解イメージ
ArrayListを「万能なクローゼット」に例えてみましょう。
このクローゼットの中には「Object」というラベルが貼られた引き出しがたくさん並んでいます。Javaのルールでは、Objectというラベルの引き出しには、衣類(String)でも、本(Integer)でも、あるいは得体の知れないガジェット(自作クラス)でも、何でも入れることができます。
しかし、取り出すときは少し大変です。中身が何かわからないまま取り出すと、服だと思って引っ張ったのに重い本が出てきて怪我をするかもしれません。これを防ぐために、Java 5からは「ジェネリクス(<String>など)」という仕組みが導入され、クローゼットの入り口で「これはString専用のクローゼットですよ」と制限をかけるようになりました。
よくある誤解
「ArrayList<String>と書いたら、内部でもString[]が作られている」と思っていませんか。
実は、コンパイルが終わった後の実行時のコードでは、依然としてObject[]として扱われています。これを「型消去(Type Erasure)」と呼びます。ジェネリクスは、あくまで開発者が間違った型を入れないようにコンパイラがチェックしてくれる「ガードレール」のような存在なのです。
実務での重要性
実務では、生(Raw型)のArrayListを使うことはまずありません。必ず ArrayList<User> のように型を指定します。なぜなら、内部がObject[]である以上、取り出すときに「これはUser型だ」とキャスト(型変換)する必要があり、間違った型が入っていると実行時にプログラムがクラッシュ(ClassCastException)してしまうからです。内部構造を知ることで、ジェネリクスがいかに安全な開発に貢献しているかが痛感できますね。
演習問題
問1:ArrayListの内部で要素を保持している配列の型は何ですか。
問2:ArrayList<Integer> を定義したとき、内部の配列から要素を取り出す際に、Javaが自動的に行ってくれている処理(型を戻す作業)を何と呼びますか。
第1章と第2章で、ArrayListが「Object型の配列」を使い、「1.5倍のサイズ」で新しい場所を確保することを学びましたね。でも、ちょっと待ってください。
「古い配列から新しい配列へ、中身を一つずつ移し替えるのって時間がかかりそうじゃない?」
そう思ったあなたは非常に鋭い視点を持っています!数万、数十万というデータを扱うとき、チマチマと1つずつコピーしていたら、コンピュータの動きがガクンと落ちてしまいます。そこで登場するのが、Javaが誇るスピードスター「System.arraycopy」です。
第3章:超高速移動の舞台裏「System.arraycopy」の威力
概要
ArrayListの中で要素を追加したり、削除したり、あるいはサイズを拡張したりするとき、内部では「配列のコピー」が頻繁に行われています。このコピー作業を、Javaの標準的なループ処理(for文など)ではなく、OSやCPUに近いレベルで一気に実行するのがSystem.arraycopyメソッドです。この章では、その驚異的な効率性と仕組みを理解しましょう。
コード抜粋
ArrayListのサイズを拡張する際、実際にコピーを行っているメソッド(grow)の一部を見てみましょう。
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
※Arrays.copyOfの内部では、最終的にSystem.arraycopyが呼び出されています。
逐次解説
このコードが、どのように「爆速の引っ越し」を実現しているのか紐解きます。
- Arrays.copyOf(elementData, newCapacity)このメソッドを呼び出すと、新しいサイズ(newCapacity)の配列が作られ、元のelementDataの中身がすべてコピーされます。
- System.arraycopy(内部で呼ばれる真の主役)これは「ネイティブメソッド」と呼ばれ、JavaではなくC言語などのより機械に近い言葉で書かれています。
- 引数の指定System.arraycopyは「コピー元」「開始位置」「コピー先」「開始位置」「個数」を細かく指定できます。これにより、配列の「一部だけ」をずらすといった器用な真似も可能です。
図解イメージ
配列のコピーを「バケツリレー」と「ベルトコンベア」で比較してみましょう。
通常のfor文によるコピーは、人間が一人ずつバケツを手渡しする「バケツリレー」です。100個のデータを移すなら、100回の手渡し作業が発生します。
対してSystem.arraycopyは、巨大な「ベルトコンベア」です。データが載ったパレットをそのままガサッと持ち上げて、一瞬で隣のトラックにスライドさせるようなイメージです。個別の要素を意識せず、メモリ上のデータを「塊(ブロック)」としてそのまま転送するため、圧倒的に速いのです。
よくある誤解
「便利なメソッドだから、自分でプログラムを書くときも何でもかんでもSystem.arraycopyを使うべきだ」と考えてしまうかもしれません。
しかし、数個程度のデータをコピーするなら、普通のfor文や拡張for文の方がコードが読みやすくなります。System.arraycopyは、あくまで「大量のデータを一括で扱う」ときにその真価を発揮する、いわばプロ向けの重機のような道具だと覚えておきましょう。
実務での重要性
実務において、大量のデータを扱うリストの先頭に要素を追加(add(0, element))するのはタブーとされています。なぜなら、先頭に1つ入れるために、後ろにあるすべての要素を「1つずつ後ろにずらす」というSystem.arraycopyが発生するからです。内部構造を知っていれば、「あ、これは配列全体を動かすから重い処理だな」と直感的に判断できるようになります。
演習問題
問1:ArrayListの要素を削除(remove)したとき、削除された場所以降の要素を前に詰めるために使われている仕組みは何ですか。
問2:System.arraycopyが、通常のfor文よりも高速に動作する主な理由を説明してください。
いよいよ最終章ですね。これまでは「どうやって動くか」という仕組みを見てきましたが、最後は「どうやって自分を守るか」というArrayListの防衛本能についてお話しします。
プログラミングの世界では、一つのリストを複数の場所から同時に書き換えようとすると、データがぐちゃぐちゃになる危険があります。そんなとき、ArrayListは黙って壊れるのではなく、自ら「異常事態だ!」と叫んで止まる仕組みを持っているのです。
第4章:裏切りを許さない「fail-fast」という防衛本能
概要
ArrayListには、作成されてから「何回変更(追加や削除)されたか」を記録するカウンターがあります。もし、繰り返し処理(ループ)の最中に、そのカウンターが予期せず書き換わっていたら、ArrayListは即座に例外を投げて処理を中断します。この「ダメなら早く失敗させる」という設計思想を「fail-fast(フェイルファスト)」と呼びます。
コード抜粋
Iterator(反復子)を使ってリストを走査する際、内部で行われているチェック処理を見てみましょう。
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
逐次解説
この短い3行が、データの整合性を守る最後の砦です。
- modCountこれはArrayList本体が持っている変数で、addやremoveが行われるたびに 1 ずつ加算されます。いわば「更新履歴数」です。
- expectedModCountループ(Iterator)を開始した瞬間の modCount の値をコピーして保持している変数です。「私がループを始めたときは、更新回数は n 回だったはずだ」という記憶です。
- if (modCount != expectedModCount)ループの途中で、この2つの値を突き合わせます。もし値が違っていれば、ループの外側で誰かが勝手にリストを書き換えた(要素を足したり消したりした)証拠です!
- throw new ConcurrentModificationException();「誰か勝手に書き換えたでしょ!もう正確なループは継続できません!」と怒って、例外を投げます。
図解イメージ
この仕組みを「図書館の蔵書点検」に例えてみましょう。
あなたは今、棚にある本のリスト(ArrayList)を持って、1冊ずつ中身を確認(ループ)しています。作業を始める前に、館長から「今は本棚に100冊あるよ(modCount = 100)」と聞きました。
ところが、あなたが50冊目を点検している最中に、別のスタッフがこっそり新しい本を1冊追加してしまいました。館長の記録は101に増えます。あなたがふと記録を確認すると「あれ?さっきは100って言ったのに、今は101になってる!これじゃあ私の点検リストと数が合わなくて、正確な点検ができない!」となりますよね。
これが ConcurrentModificationException の正体です。
よくある誤解
「拡張for文(for-each)を使っていれば、ループ中の削除も安全だ」と思っていませんか。
実は、拡張for文も内部ではこの Iterator を使っています。そのため、ループの中で list.remove() を直接呼ぶと、この例外が発生してしまいます。ループ中に安全に削除したい場合は、Iterator 自体の remove() メソッドを使うか、Java 8以降なら removeIf() メソッドを使うのが正解です。
実務での重要性
「バグは、発生した瞬間に見つかるのが一番いい」というのがエンジニアの鉄則です。もしこのチェックがなければ、おかしなデータが入り込んだまま処理が続き、数時間後や数日後に原因不明の不具合として現れるかもしれません。fail-fast は、傷口が広がる前に「ここで止まれ!」と教えてくれる、とても親切な仕組みなのです。
演習問題
問1:ArrayListの変更回数を記録している内部変数の名前は何ですか。
問2:拡張for文でリストをループしている最中に、list.add() を実行するとどうなるでしょうか。
まとめと今後の学習指針
お疲れ様でした!全4章を通して、ArrayListの裏側に隠された工夫を覗いてきました。
- 1.5倍の拡張:メモリと速度の絶妙なバランス。
- Object配列:ジェネリクスによる安全性と汎用性の両立。
- System.arraycopy:OSレベルの高速なデータ移動。
- fail-fast:異常を即座に検知する防衛策。
これらはすべて、私たちが「ただ便利に使う」ためだけに、Javaの設計者たちが心血を注いで作り上げた芸術作品のようなコードです。
次のステップへの指針
今回の講義で、ArrayListが得意なこと(後ろへの追加、インデックス指定での参照)と、苦手なこと(先頭や途中への挿入・削除)が見えてきたはずです。
次はぜひ、LinkedList のソースコードも読んでみてください。「要素をずらす」必要がない代わりに、何が犠牲になっているのか。ArrayListと比較することで、データ構造の選択眼が劇的に向上しますよ。
自分でもIDE(開発環境)で java.util.ArrayList のソースを開き、今回紹介したメソッドを自分の目で探してみてください。本物のコードを読むことこそ、最高の研修です!
また分からないことがあれば、いつでも聞いてくださいね。応援しています!
セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。
投稿者プロフィール

- 代表取締役
-
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。
学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。

