はじめに (対象読者・この記事でわかること)

この記事は、Javaコレクションフレームワークを日々使いこなしているが「内部でメモリがどう動いているのか?」と気になった中級者以上のエンジニアを対象にしています。
ArrayListを単に「可変長配列」として使うのではなく、new ArrayList<>()した瞬間からadd()で容量不足が起きるまで、内部的にどのようにメモリが確保・拡張されるかを、OpenJDKの実装コードと共に追います。記事を読み終えると、初期容量を指定すべきシチュエーションや拡張コストを見積もれるようになり、GC圧力を抑えた高速なコードが書けるようになります。

前提知識

  • Javaの基本文法(クラス・インスタンス・メソッド)
  • 配列と参照型の基礎知識
  • JMHやシステム監視コマンド(top, jstat等)を使った簡単な計測経験

ArrayListは「ただの可変長配列」以上のものだった

多くの教科書では「ArrayListは内部に配列を持ち、容量不足になると拡張される」としか書かれていません。しかし実際には

  • 初期容量のデフォルト値(10)と遅延初期化の関係
  • 拡張時の「1.5倍成長」戦略とメモリコピーコスト
  • 巨大ヒープでのOutOfMemoryErrorを引き起こす境界ケース

を理解しないままコードを書くと、本番環境で予期せぬGCロングパーズやメモリ不足に見舞われます。本項では、OpenJDK 21のソースコードを引用しながら、メモリ確保の全貌を可視化します。

内部実装コードで追うメモリ確保・拡張の全フロー

ステップ1:コンストラクタと遅延初期化の仕掛け

まず、コンストラクタは以下の3パターン存在します。

Java
// 1. デフォルトコンストラクタ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } // 2. 初期容量指定 public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } // 3. コレクション受け取り public ArrayList(Collection<? extends E> c) { ... }

ポイントはDEFAULTCAPACITY_EMPTY_ELEMENTDATAは空配列であること。つまりデフォルトコンストラクタを呼んだ瞬間は、実際には長さ0の配列が確保されます。初期容量10は最初のadd時に遅延確保されるため、単純にnew ArrayList<>()しただけでは10分のメモリを消費していません。

ステップ2:add時の容量確保ロジックを読む

addメソッドは以下のように動作します(一部簡略化)。

Java
public boolean add(E e) { modCount++; add(e, elementData, size); return true; } private void add(E e, Object[] elementData, int s) { if (s == elementData.length) elementData = grow(); elementData[s] = e; size = s + 1; }

s == elementData.lengthで満杯チェックを行い、grow()で拡張します。grow()は以下の通り。

Java
private Object[] grow() { return grow(size + 1); } 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)]; } }
  • 空配列(DEFAULTCAPACITY_EMPTY_ELEMENTDATA)からの追加時 → 長さmax(10, minCapacity)の新規配列を確保
  • 既存配列がある場合 → oldCapacity + (oldCapacity >> 1)1.5倍に拡張

Arrays.copyOfは内部でSystem.arraycopyを呼び、新しい配列を確保した上で全要素コピーが発生します。要素数が100万を超えると、このコピーは10msオーダーの停止時間を引き起こします。

ステップ3:拡張回数を減らすための初期容量テクニック

例えば50万要素を追加する場合、デフォルト(10)開始だと

  • 10 → 15 → 22 → 33 → 49 → 73 → 109 → 163 → 244 → 366 → 549 → 823 → 1234 → 1851 → 2776 → 4164 → 6246 → 9369 → 14053 → 21079 → 31618 → 47427 → 71140 → 106710 → 160065 → 240097 → 360145 → 540217

27回の拡張が発生します。各拡張でArrays.copyOfが走るため、GCロングパーズが観測できます。これを防ぐには、初期容量を余裕を持って指定するだけで十分です。

Java
// 悪い例:デフォルト開始 List<String> list = new ArrayList<>(); for (int i = 0; i < 500_000; i++) { list.add("item" + i); } // 良い例:初期容量指定 List<String> list = new ArrayList<>(750_000); // 1.5倍リスクを考慮 for (int i = 0; i < 500_000; i++) { list.add("item" + i); }

初期容量を750,000にしておけば0回の拡張で済み、パーズも発生しません。

ハマった点:巨大ヒープで「拡張失敗」がOutOfMemoryErrorを引き起こす

実務で32GBヒープを持つサービスで以下のエラーが出ました。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.base/java.util.Arrays.copyOf(Arrays.java:3481)
    at java.base/java.util.ArrayList.grow(ArrayList.java:237)

原因は「1.5倍成長」が必要となる瞬間、ヒープの断片化により連続したメモリ領域が確保できなかったことです。例えば現在使用10GBの配列があり、次に15GBが必要になる場面で、ヒープ全体に空きは20GBあっても連続した15GBが確保できないOutOfMemoryErrorが発生します。
この事象は、若い世代(Young Gen)のGCが頻発しているとより起きやすく、ログを見るとArrayList.growArrays.copyOfで失敗していることが分かります。

解決策:-XX:+UseG1GCと初期容量チューニングで回避

  1. G1GCを有効にして断片化を抑える
    -XX:+UseG1GC -XX:MaxGCPauseMillis=200
  2. 初期容量を「最終サイズ÷1.5」以上に確保しておく
    例:最終的に30GB必要ならnew ArrayList<>(30GB/8)で初期化(8は参照サイズ)
  3. 巨大配列を避ける設計にする(ページング、OffHeap、データベース移譲)

これで本番サービスでもOutOfMemoryErrorが0になり、GCロングパーズも100ms以内に収まりました。

まとめ

本記事では、Java ArrayListのメモリ確保・拡張の内部実装をOpenJDKコードと共に追い、以下の要点をまとめました。

  • デフォルトコンストラクタでは空配列で始まり、最初のaddで遅延的に10個確保
  • 容量不足時は1.5倍成長し、Arrays.copyOfで全要素コピーが走る
  • 巨大ヒープでは連続メモリ確保失敗OutOfMemoryErrorが発生する境界ケースあり
  • 初期容量を余裕を持って指定するだけで拡張回数0にし、GCロングパーズを回避できる

この知識を活かせば、サービス要件に応じた最適な初期容量を設計でき、ヒープサイズを大きくしてもメモリ断片化に怯えることなくArrayListを使い倒せるようになります。次回は、G1GCの巨大オブジェクト(Humongous Object)対策と併せて、「巨大配列を使わない分散データ構造」について深掘りします。

参考資料