「コレクションの初期容量」ってそもそも何?
まずことばの整理からいきます。
ArrayList や HashMap などのコレクションは、内部で
「ある程度まとめてメモリを確保してから」
そこに要素を入れていきます。
この「最初に用意しておく入れ物の大きさ」が
初期容量(initial capacity) です。
たとえば new ArrayList<>() とすると、
「中で使う配列の最初の長さ」が決まります。
足りなくなってきたら、その都度「もっと大きい配列を作ってコピーし直す」
という処理が走ります。
ポイントはここです。
どれくらい入るか分からないからとりあえず小さめに作る
→ 後で足りなくなって何回も「拡張+コピー」が走る
どれくらい入るかあらかじめ分かっている
→ 最初から十分な容量で作っておけば、無駄な拡張が減る
初期容量は、この「拡張+コピー」(や HashMap なら「再ハッシュ」)の回数を減らして
無駄な処理を抑えるためのチューニングポイントです。
ArrayList の初期容量と「拡張コスト」
new ArrayList<>() は中でどう動いているか
ArrayList は内部的に配列を持っていて、
その配列の長さが「今使える容量」です。
次の 2 つの書き方を比べてみます。
List<String> list1 = new ArrayList<>();
List<String> list2 = new ArrayList<>(1000);
Javalist1 は「容量指定なし」list2 は「1000 個ぶんの容量を最初から確保」
という意味です。
list1 に add を繰り返していくと、
あるタイミングで内部配列が足りなくなり、
「より大きな配列を作って、元の配列の中身を全部コピーする」
という処理が何度か走ります。
これは自分で for 文を書かなくても勝手にやってくれますが、
「重い処理であることは変わらない」というのがミソです。
大量に入ることが分かっているなら、最初から容量を指定する
たとえば 1 万件のレコードを読み込むと分かっているなら、
次のように書いたほうが、無駄が減ります。
int expectedSize = 10_000;
List<String> list = new ArrayList<>(expectedSize);
for (int i = 0; i < expectedSize; i++) {
list.add("data-" + i);
}
Java容量を 1 万にしておけば、
途中で「配列足りないからコピーし直し」が起こる回数がかなり減ります。
逆に、数件しか入らないのにやたら大きな容量で作ると、
メモリだけ余分に食うので、それもまた無駄です。
実務感覚としては、
おおよその件数が分かっているなら、そのくらいを初期容量に指定
全く読めないなら、デフォルトのままで気にしない
くらいで十分です。
HashMap / HashSet の初期容量と「再ハッシュ」の話
HashMap の内部で何が起きているか
HashMap は内部で「バケット」と呼ばれる配列のようなものを持っています。
このバケットの長さが、初期容量に対応します。
Map<String, Integer> map1 = new HashMap<>();
Map<String, Integer> map2 = new HashMap<>(1024);
Javamap1 は容量指定なし、map2 は「バケット数を 1024 からスタート」というイメージです。
要素をどんどん put していくと、
ある割合を超えたあたりで「再ハッシュ(resize + rehash)」が走ります。
再ハッシュとは、ざっくりいうと
もっと大きいバケット配列を新しく用意する
すでに入っているすべてのキーについて、
新しい配列のどこに入るべきかを計算し直して入れ直す
という処理です。
当然ながら、これはそれなりに重いです。
たくさん入るのが分かっているなら、最初から大きめにしておく
たとえば、「ユーザー 5 万人ぶんの ID → ユーザー情報」を Map に詰める処理があるとします。
Map<String, User> users = new HashMap<>();
// or
Map<String, User> users = new HashMap<>(50_000);
Java前者は途中で何度か「再ハッシュ」しながらサイズアップしていきます。
後者は、最初からだいたい足りるサイズでスタートするので、
再ハッシュの回数を減らせます。
これもまた、
件数が読めるなら初期容量に反映
読めないならデフォルトでもいい
というバランスです。
「初期容量」と「サイズ」の違いを混同しないこと
ここで一つ、よく混ざるポイントを整理します。
ArrayList で考えると分かりやすいです。
List<String> list = new ArrayList<>(100);
Javaこのとき、
今の「サイズ」(size) → 0
内部配列の「容量」 → 100
です。
size() は実際に入っている要素の数。
初期容量は「入る準備ができている最大数」であって、
入っている数とは別です。
サイズ 0 で容量 100 のリストに、1 個 add しただけでは
「99 個分の空き箱を持っているリスト」状態です。
Map でも同様で、
size() は「put 済みのキーの数」
初期容量は「バケットの数」であって、キー数とは一致しない
ここをごちゃ混ぜにしないことが大事です。
いつ初期容量を意識すべきか/しなくていいか
気にしなくていい場面
プログラミング初心者のうちは、
まずは「正しく動くこと」を優先するべきです。
要素数が数十〜数百程度
パフォーマンスが全く問題になっていない
実験・学習・小さめのツール
こういうケースでは、初期容量を気にする必要はほぼありません。
new ArrayList<>()new HashMap<>()
と素直に書いておけばよく、
「動くようになってから、必要なら見直す」で十分です。
初期容量を意識すると「一段上に行ける」場面
逆に、こういうケースでは初期容量を考える価値が大きくなります。
外部システムや DB から、何件ぐらいデータを取得するかが分かっている
バッチ処理などで、何万件〜何十万件クラスのデータを扱う
性能をチューニングしていて、「オブジェクトの追加がボトルネックになっている」と分かった
このとき、
「どうせ 5 万件入るなら、最初から 5 万くらいの容量で用意しておこう」
と書き換えるだけで、無駄な拡張が激減して
処理時間が目に見えて変わることがあります。
初期容量を指定する書き方まとめと、考え方の軸
最後に、書き方と考え方をコンパクトに整理します。
ArrayList の初期容量指定:
List<String> list = new ArrayList<>(expectedSize);
JavaHashMap / HashSet の初期容量指定:
Map<String, String> map = new HashMap<>(expectedSize);
Set<String> set = new HashSet<>(expectedSize);
Java「expectedSize」は、ざっくりの見積もりで構いません。
大事なのは、
- サイズ(size)と容量(capacity)は別物
- 容量を大きくしすぎるとメモリの無駄、小さすぎると拡張コストが増える
- データ件数が読めるときだけ、初期容量をきちんと指定する価値が高い
という三つの感覚です。
