コレクションは、Java開発の日常の開発でよく使用され、KV構造の典型的なデータ構造として、HashMapは確かにJava開発者にとって見知らぬ人ではありません。
日常の開発では、次のようにHashMapを作成することがよくあります。
Map<String, String> map = new HashMap<String, String>();
ただし、上記のコードではHashMapの容量を指定していないと思ったことはありませんか?この時点で新しく作成されたHashMapのデフォルトの容量はどれくらいですか?どうして?
この記事では、この問題を分析します。
容量とは
Javaには、データを保存するための2つの比較的単純なデータ構造があります。配列とリンクリストです。配列の特徴は次のとおりです。アドレス指定が簡単、挿入と削除が困難、リンクリストの特徴は次のとおりです。アドレス指定が困難、挿入と削除が簡単。HashMapは、配列とリンクリストの組み合わせであり、両方の利点を活用して、リンクリストの配列として理解できます。
HashMapには、混乱しやすい2つの重要なフィールドがあります。サイズと容量です。容量はマップの容量であり、サイズはマップ内の要素の数と呼ばれます。
簡単な例えで理解しやすくなります。HashMapは「バケット」であり、容量(容量)はこのバケット内の現在の最大要素数であり、要素数(サイズ)はバケットに含まれる要素の数を示します。すでに含まれています。
次のコードなど:
Map<String, String> map = new HashMap<String, String>();
map.put("hollis", "hollischuang");
Class<?> mapType = map.getClass();
Method capacity = mapType.getDeclaredMethod("capacity");
capacity.setAccessible(true);
System.out.println("capacity : " + capacity.invoke(map));
Field size = mapType.getDeclaredField("size");
size.setAccessible(true);
System.out.println("size : " + size.get(map));
出力結果:
capacity : 16、size : 1
上記では、新しいHashMapを定義し、その中に要素を配置してから、リフレクションによって容量とサイズを出力します。その容量は16で、格納される要素の数は1です。
前の例で、HashMapを作成するときに、その容量を指定しない場合、デフォルトの容量が16のマップを取得することがわかりました。次に、この容量はどのように取得されますか?なぜこの数なのですか?
容量とハッシュ
このデフォルト容量の理由を明確にするために、最初にこの容量の用途を知る必要がありますか?
容量はHashMapの「バケット」の数であることがわかっています。次に、HashMapに要素を配置する場合、特定のアルゴリズムを使用して、どのバケットを配置するかを決定する必要があります。このプロセスはまさにそれです。はハッシュと呼ばれ、HashMapのハッシュメソッドに対応します。
ハッシュメソッドの機能は、キーに基づいてリンクリスト配列内のこのKVの位置を特定することであることがわかっています。つまり、ハッシュメソッドの入力はObject型のキーであり、出力はint型の配列添え字である必要があります。このメソッドの設計を依頼された場合、どうしますか?
実際、これは簡単です。整数を返すObjectオブジェクトのhashCode()メソッドを呼び出すだけで、この数値を使用してHashMapの容量を調整できます。
本当に簡単であれば、HashMapの容量設定ははるかに簡単になりますが、効率やその他の問題を考慮すると、HashMapのハッシュメソッドの実装はまだやや複雑です。
ハッシュの実装
次に、HashMapでのハッシュメソッドの実装原理を紹介します。(次の部分は私の記事を参照しています:ネットワーク全体のMapでのhash()の分析に関する最も徹底的な記事は他にありません 。PS:インターネット上のHashMapのハッシュメソッドの分析に関する多くの記事は私の代わりに。記事から「派生」。)
具体的な実装に関しては、int hash(Object k)とint indexFor(int h、int length)の2つのメソッドによって実装されます。
ハッシュ:このメソッドは、主にオブジェクトを整数に変換するためのものです。
indexFor:このメソッドは、主に、ハッシュによって生成された整数をリンクリスト配列の添え字に変換するためのものです。
この記事の焦点に焦点を合わせるために、indexForメソッドを見てみましょう。まず、Java 7の実装の詳細を見てみましょう(Java 8にはそのような別個のメソッドはありませんが、添え字を照会するためのアルゴリズムはJava 7の場合と同じです)。
static int indexFor(int h, int length) {
return h & (length-1);
}
indexForメソッドは、実際には、ハッシュコードをリンクリスト配列の添え字に置き換えることです。2つのパラメーターhは要素のハッシュコード値を表し、長さはHashMapの容量を表します。では、return h&(length-1)はどういう意味ですか?
実際、彼はただ型を取るだけです。すべてのJavaは、モジュロ演算(%)ではなくビット演算(&)を使用します。最も重要な考慮事項は、効率です。
ビット演算(&)の効率は、モジュロ演算(%)を代入する効率よりもはるかに高くなります。主な理由は、ビット演算がメモリデータを直接操作し、10進数に変換する必要がないため、処理速度が非常に速いためです。 。
では、なぜビット演算(&)を使用してモジュロ演算(%)を実装できるのでしょうか。この実現の原則は次のとおりです。
X % 2^n = X & (2^n – 1)
nが3であるとすると、2 ^ 3 = 8であり、2進数で表されると1000になります。2^ 3 -1 = 7、つまり0111です。
このとき、X&(2 ^ 3 – 1)は、Xのバイナリシステムの最後の3桁を取得することと同じです。
バイナリの観点からは、X / 8はX >> 3に相当します。つまり、Xを3桁右にシフトします。このとき、X / 8の商が取得され、削除された部分(最後の部分)が取得されます。 3桁)はX%8で、余りです。
上記の説明を理解しているかどうかはわかりません。理解していなくても構いません。このテクニックを覚えておくだけで済みます。または、試してみる例をいくつか見つけることができます。
6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2
したがって、h&(length-1);を返します。長さの長さが2 ^ nであることが保証されている限り、モジュロ演算を実装できます。
したがって、ビット演算はメモリデータを直接操作し、10進数に変換する必要がないため、ビット演算はモジュロ演算よりも効率的です。したがって、HashMapは、配列に格納される要素のインデックスを計算するときに、代わりにビット演算を使用します。操作。同等の置換を行うことができる理由は、HashMapの容量が2 ^ nでなければならないため です。
では、2 ^ nなので、なぜ16でなければならないのでしょうか。なぜ4、8、32になれないのですか?
このデフォルト容量の選択に関して、JDKは公式の説明をしておらず、著者はこれに関する貴重な情報をインターネット上で見つけていません。(誰かが関連する信頼できる情報やアイデアを持っている場合は、交換のためにメッセージを残すことができます)
著者の推測によると、これは経験値である必要があります。デフォルトの2 ^ nを初期値として設定する必要があるため、効率とメモリ使用量の間にはトレードオフがあります。この値は小さすぎても大きすぎてもかまいません。
小さすぎると、容量の拡張が頻繁に発生し、効率に影響を与える可能性があります。大きすぎてスペースを浪費し、費用対効果が高くありません。
そのため、経験値として16を採用した。
JDK 8では、デフォルトの容量の定義は次のとおりです。staticfinal int DEFAULT_INITIAL_CAPACITY = 1 << 4; //別名16。これは、この場所が2の累乗であることを開発者に思い出させるために意図的に16を1 << 4として書き込みます。コメントの別名16 も1.8の新機能であることに注意してください 。
それでは、次にそれについて話しましょう。HashMapはどのようにしてその容量が2 ^ nでなければならないことを保証しますか?ユーザーが自分で設定するとどうなりますか?
この部分に関して、HashMapは、容量が変更される可能性のある2つの場所、つまり、指定された容量が初期化されるときと拡張されるときに互換性処理を実行しました。
容量の初期化を指定します
HashMap(int initialCapacity)を使用して初期容量を設定する場合、HashMapは必ずしも渡した値を直接使用する必要はありませんが、計算後に新しい値が取得されます。目的は、ハッシュの効率を向上させることです。(1-> 1、3-> 4、7-> 8、9-> 16)
JDK1.7とJDK1.8では、この容量のHashMap初期化のタイミングが異なります。JDK 1.8では、HashMapコンストラクタを呼び出してHashMapを定義すると、容量が設定されます。JDK 1.7では、この操作は最初のput操作まで実行されません。
JDKが、渡された指定値よりも大きい2の1乗を見つける方法を見てください。
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
上記のアルゴリズムの目的は非常に単純です。つまり、ユーザーから渡された容量値(コード内のキャップ)に従って、計算によって、2の1乗が取得されて返されます。
上記のいくつかの例の青いフォントの変更に注意してください。いくつかのルールが見つかるかもしれません。5-> 8、9-> 16、19-> 32、37-> 64は主に2つの段階を経ています。
ステップ1,5-> 7
ステップ2、7-> 8
ステップ1、9-> 15
ステップ2、15-> 16
ステップ1、19-> 31
ステップ2、31-> 32
上記のコードに対応して、ステップ1:
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
上記のコードに対応して、ステップ2:
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
手順2は比較的簡単です。つまり、制限値を判断してから、手順1で取得した値を1に加算します。
ステップ1どのようにそれを理解しますか?実際には、2進数を1つずつ右にシフトしてから、元の値とのORを取ります。その目的は、0ではない最初のビットから開始し、後続のすべてのビットを1に設定するデジタルバイナリ用です。
2進数を取り、上記の式をもう一度設定して、その目的を見つけてください。
1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111
いくつかの符号なし右シフトとビット単位のOR演算の後、1100 110011100を11111111 1111に変換し、次に1を1111 1111 1111に加算すると、1 0000 0000 0000が得られます。これは、1100 11001100の累乗の最初の値です。 2.2。
これで、ステップ1とステップ2のコードについて説明しました。数値をそれ自体よりも大きい2の1乗に変換することができます。
ただし、上記の式が適用できない特殊なケースがあり、これらの数値は2の累乗です。数字の4が式を適用する場合。結果は8になりますが、実際には、この問題も解決されています。特定の検証方法とJDKソリューションについては、ネットワーク全体のMapでのhash()の分析に関する最も詳細な記事を参照してください。他に方法はありません。ここでは拡張しません。
つまり、HashMapは、符号なし右シフトとビット単位のOR演算を使用して、ユーザーから渡された初期容量に基づいて、この数値よりも大きい2の1乗を計算します。
拡張
初期化中にHashMapの容量を指定することに加えて、その容量は拡張中にも変更される可能性があります。
HashMapには拡張メカニズムがあります。つまり、拡張条件に達すると拡張します。HashMapの拡張条件は、HashMapの要素数(サイズ)がしきい値(しきい値)を超えると、自動的に拡張されることです。
HashMapでは、threshold = loadFactor * capacityです。
loadFactorは、HashMapがどれだけいっぱいであるかを示す負荷係数です。デフォルト値は0.75fです。0.75に設定することには利点があります。つまり、0.75は正確に3/4であり、容量は2の累乗です。したがって、2つの数値の積は整数です。
デフォルトのHashMapの場合、デフォルトでは、サイズが12(16 * 0.75)より大きい場合に拡張がトリガーされます。
以下は、HashMapの拡張メソッド(サイズ変更)のセクションです。
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
上記のコードからわかるように、展開されたテーブルのサイズは元のサイズの2倍になります。この手順が実行された後、展開されたテーブルが調整されます。この部分はこの記事の焦点ではないため、省略されています。
HashMapの要素数(サイズ)がしきい値(しきい値)を超えると、自動的に拡張され、拡張は元の容量の2倍、つまり16から32、64、128になります。 ..
したがって、初期容量が2の累乗であり、拡張時に容量が前の容量の2倍に拡張されるようにすることで、HashMapの容量が常に2の累乗になることが保証されます。
総括する
HashMapはデータ構造であり、要素はプットプロセス中にハッシュされる必要があります。目的は、要素がハッシュマップに格納されている特定の場所を計算することです。
ハッシュ演算のプロセスは、実際にはターゲット要素のキーをハッシュコード化してから、マップの容量を変調することです。モジュロ取得の効率を向上させるために、JDKのエンジニアはモジュロ演算の代わりにビット演算を使用します。マップの特定の容量。2の累乗である必要があります。
デフォルトの容量として、大きすぎたり小さすぎたりすることは適切ではないため、16がより適切な経験値として採用されました。
いずれにせよ、マップの容量が2の累乗であることを保証するために、HashMapには2つの場所に制限があります。
まず、ユーザーが初期容量を設定すると、HashMapはこの数値よりも大きい2の1乗を初期容量として計算します。
また、容量を拡張すると容量も2倍になり、4が8、8が16になります。
この記事では、HashMapのデフォルト容量が16である理由を分析することにより、HashMapの原則を深く掘り下げ、基本的な原則を分析します。コードから、JDKエンジニアがさまざまなビット演算を極限まで使用し、さまざまなことを試したことがわかります。それらを最適化する方法。有効性。学ぶ価値があります!