HashMap#
一、基礎概念#
JDK8 以降、HashMap は赤黒木構造を導入し、その底層のデータ構造は
配列+連結リスト
から配列+連結リストおよび配列+赤黒木
に変わりました。
HashMap 全体構造:
JDK1.8 以降の HashMap の赤黒木構造
赤黒木の変換#
参考: https://juejin.im/entry/5839ad0661ff4b007ec7cc7a
いくつかのパラメータ:
// 一つのバケットの木化閾値、バケット内の要素数がこの値を超えると、赤黒木ノードに置き換えられます
static final int TREEIFY_THRESHOLD = 8;
// 一つの木の連結リスト復元閾値、拡張時にバケット内の要素数がこの値未満になると、木形のバケット要素が復元(分割)されます
static final int UNTREEIFY_THRESHOLD = 6;
// ハッシュテーブルの最小木形化容量、ハッシュテーブルの容量がこの値を超えると、テーブル内のバケットは木形化できます。そうでない場合、バケット内の要素が多すぎると拡張されます
// 拡張と木形化の選択の衝突を避けるため、この値は4 * TREEIFY_THRESHOLD未満であってはなりません
static final int MIN_TREEIFY_CAPACITY = 64;
要素を追加する際、デフォルトではバケット内の連結リストの数が 7 を超えると、連結リストは赤黒木に変換されることを試みます:
// HashMapの容量が64未満の場合、直接赤黒木に変換するのではなく、拡張を選択します
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
resize();
}
// TREEIFY_THRESHOLDはデフォルトで8です
if (binCount >= TREEIFY_THRESHOLD - 1) {
treeifyBin(tab, hash);
}
resize メソッド () を呼び出して拡張する際、現在のバケット内の要素構造が赤黒木であり、要素数が連結リスト復元閾値 UNTREEIFY_THRESHOLD(デフォルトは 6)未満である場合、split () メソッドを呼び出してバケット内の木形構造を縮小または直接復元(分割)します:
if (lc <= UNTREEIFY_THRESHOLD) {
tab[index] = loHead.untreeify(map);
}
二、HashMap のデフォルトパラメータ理解#
なぜ HashMap の Entry 配列の長さはデフォルトで 16 なのか?なぜ配列の長さは必ず 2 の n 乗でなければならないのか?#
まず HashMap が hashcode を計算する方法を見て、格納位置を取得します:
static int indexFor(int h, int length) {
return h & (length-1);
}
長さ 16 または他の 2 の幂の場合、length - 1 の値はすべての二進数ビットが 1 であるため、この場合、index の結果は hashcode の後の数ビットの値と等しくなります。入力された hashcode 自体が均等に分布している限り、hash アルゴリズムの結果も均等になります。
したがって:HashMap のデフォルトの長さが 16 であり、配列の長さが 2 の幂であることは、hash 衝突の確率を低下させるためです。
HashMap の拡張制限の負荷係数はなぜ 0.75 なのか?#
HashMap の拡張方法は、新しい配列を以前の配列の 2 倍の長さで新たに作成し、現在の Entry 配列の要素をすべて移動させることによって行われます。拡張後の新しい配列の長さは以前の 2 倍になるため、拡張は相対的にリソースを消費する操作です。
拡張のトリガー条件は:臨界値 = 配列のデフォルトの長さ x 負荷係数
- 負荷係数が 0.5 またはそれ以下の場合、最終的に得られる一時的な臨界値は明らかに非常に小さくなり、このような状況では割り当てられたメモリが無駄になり、余分な無用のメモリ空間が存在し、ハッシュテーブルの均等分布の状況も満たされません。
- 負荷係数が 1 に達した場合、つまり Entry 配列が満杯になったときにのみ拡張が発生し、大量の hash 衝突が発生し、連結リストが長すぎて get クエリデータの効率に影響を与えます。
- したがって、0.5〜1 の妥協点、つまり 0.75 を選択し、上記の状況を均衡的に解決しました。
三、HashMap の基本操作#
get 操作#
/**
* hash(key) メソッドを使用して key のハッシュ値を計算します
* getNode() メソッドを使用して node を取得します
* node が null の場合は null を返し、そうでない場合は node.value を返します
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* getNode は以下のステップに分かれます:
* 1. ハッシュテーブルが空であるか、key に対応するバケットが空である場合、null を返します
* 2. 空でない場合、バケット内の最初のノードが指定されたパラメータhashとkeyに一致する場合、このノードを直接返します
* 3. 最初のノードが一致しない場合、後続の検索を行います:
* 3.1、現在のバケットが赤黒木を使用している場合、赤黒木の getTreeNode を呼び出してノード値を取得します
* 3.2、連結リスト構造の場合、連結リストを直接遍歴して検索します
* 4. ノードが見つかった場合は value を返し、そうでない場合は null を返します
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// ハッシュテーブルと key に対応するバケットが空でない場合
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 最適化ポイント 1: 不必要なクエリを減らす
// バケット内の最初のノードが指定されたパラメータhashとkeyに一致する場合、このノードを直接返します
if (first.hash == hash && // 常に最初のノードを確認
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 最初のノードが一致しない場合、後続の検索を行います
if ((e = first.next) != null) {
// 現在のバケットが赤黒木を使用している場合、赤黒木の getTreeNode を呼び出してノード値を取得します
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 連結リスト構造の場合、連結リストを直接遍歴して検索します
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
put 操作#
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* putValメソッドは以下のステップに分かれます
* 1. ハッシュテーブルが空である場合、resizeメソッドを呼び出して作成します
* 2. 指定されたkey値が衝突しない場合、すなわちハッシュ衝突がない場合、直接キーと値のペアを挿入します
* 3. 衝突がある場合、この衝突ノードを見つけます
* 3.1 最初のノードが一致した場合、記録します。そうでない場合は、さらに検索を続けます
* 3.2 現在が赤黒木構造の場合、putTreeValメソッドを呼び出して挿入します
* 3.3 連結リスト構造の場合、ループで連結リストを遍歴して検索し、最後まで見つからない場合は尾挿入法でノードを挿入し、見つかった場合はこのノードを記録し、ループを終了します
* 4. 衝突ノードが見つかった場合、onlyIfAbsentがtrueであるかどうかを判断し、すなわち古い値を変更することを許可するかどうかを示します
* 5. HashMapの変更回数modCountと容量サイズsizeをインクリメントします + 1、もしsize > thresholdであれば、臨界値に達したことを意味し、拡張を行います
*
* @param hash 指定されたkeyのhash値
* @param key 指定されたkey
* @param value 挿入する必要があるvalue値
* @param e、古い値の置き換えを許可しないことを示します
* @param evict HashMapが作成モードにあるかどうかを示します
* @return 指定されたkeyが存在する場合、古い値が置き換えられた場合はこの値を返し、そうでない場合はnullを返します。もちろんvalue値がnullである可能性もあります
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// ハッシュテーブルが空である場合、resizeメソッドを呼び出して作成します
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 指定されたkey値が衝突しない場合、すなわちハッシュ衝突がない場合、直接キーと値のペアを挿入します
// (n - 1) & hash は hash % n と等価ですが、ビット演算の方が速いです
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 衝突がある場合、この衝突ノードを見つけます
else {
Node<K,V> e; K k;
// 最初のノードが一致した場合、記録します。そうでない場合は、さらに検索を続けます
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 現在が赤黒木構造の場合、putTreeValメソッドを呼び出して挿入します
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 連結リスト構造の場合、ループで連結リストを遍歴して検索し、最後まで見つからない場合は尾挿入法でノードを挿入します
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 連結リストの長さが臨界値に達しているかどうかを確認し、達している場合は赤黒木構造に変換します
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 見つかった場合はこのノードを記録し、ループを終了します
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 値がnullでない場合、衝突ノードが見つかったことを示します
if (e != null) { // 既存のキーのマッピング
V oldValue = e.value;
// onlyIfAbsentがtrueであるかどうかを判断し、古い値を変更することを許可するかどうかを示します
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// sizeが臨界値に達しているかどうかを判断し、拡張を行います
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
拡張操作#
/**
* tableを初期化または拡張します。拡張する場合、毎回容量は倍増し、二進数計算後、元のノードは元の位置にいるか、または「元の位置 + 旧容量」の位置に割り当てられます。
* resizeのステップの要約:
* 1. 拡張後の容量、臨界値を計算します
* 2. HashMapの臨界値を更新します
* 3. 計算された拡張容量に基づいて新しい配列を作成し、HashMapのtable参照を新しい配列に指向させます
* 4. 古い配列の要素をtableにコピーします
*/
final Node<K,V>[] resize() {
// 新しいoldtab配列を作成して拡張前の配列tableを保存します
Node<K,V>[] oldTab = table;
// 古い配列の長さを取得します
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 古い配列の臨界値
int oldThr = threshold;
int newCap, newThr = 0;
// 古い配列の容量が0より大きい場合
if (oldCap > 0) {
// 古い配列の容量が最大値を超えた場合、臨界値を整数の最大値に設定します
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 古い配列の容量がデフォルト容量(16)を超え、倍増後の新しい配列の容量が最大値未満の場合、古い配列の臨界値も倍増します
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 臨界値を倍増
}
// 古い容量 <= 0 かつ 臨界値 > 0 の場合
else if (oldThr > 0) // 初期容量は臨界値に配置されました
// 新しい配列の容量を古い配列の臨界値に設定します
newCap = oldThr;
// 古い容量 <= 0 かつ 臨界値 <= 0 の場合
else {
// 新しい容量をデフォルト値(16)に設定します
newCap = DEFAULT_INITIAL_CAPACITY;
// 新しい臨界値をデフォルト容量 * デフォルト負荷係数(16 * 0.75)= 12に設定します
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 上記の条件判断の中で、oldThr > 0 の場合のみ newThr == 0 になります
if (newThr == 0) {
// 合法的な判断を行い、新しい臨界値を設定します
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 新しい配列に古い配列の内容をコピーします
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 古いハッシュテーブルの各ノードを遍歴して配列にコピーします
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// eに古いノードの値を記録します
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 古いバケットのストレージ衝突hash値の構造が赤黒木の場合
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 古いバケットのストレージ衝突hash値の構造が連結リスト構造の場合
else { // 順序を保持
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// java 7はwhileループの中で、単一の計算を行った後、単一の配列に挿入しますが、多スレッドの場合、環状問題が発生します
// java 8は連結リスト全体のwhileループが終了した後に配列に値を割り当てるため、多スレッドの場合も環状にはなりません
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// ノードは元の位置に留まります
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// ノードは「元の位置 + 旧容量」の位置に割り当てられます
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
四、Java における HashMap の key 値がクラスオブジェクトである場合、そのクラスはどのような条件を満たす必要がありますか#
一般的に String、Integer のようなラッパークラスを key として使用する理由は:
- このラッパータイプは final タイプであり、変更不可能性を持つため、hash 値の不変性と計算の正確性が保証されます
- 内部で equals ()、hashcode () メソッドをオーバーライドしています
したがって、HashMap の key がクラスオブジェクトである場合、hashcode と equals () メソッドをオーバーライドする必要があります。
五、HashMap はどのようにハッシュ衝突を解決しますか?#
ハッシュ衝突を解決する方法#
オープンアドレス法#
衝突が発生した単位から、一定の順序でハッシュテーブルから空いている単位を見つけます。そして、衝突した要素をその単位に格納する方法で、必要なテーブルの長さは格納する要素の数以上でなければなりません。
以下の方法が含まれます:
- 線形探索法
衝突が発生した単位から、次の単位が空いているかどうかを順次判断し、最後の単位に達したら、テーブルの先頭から順次判断します。空いている単位に出会うまで、またはすべての単位を探査するまで続けます。 - 平方探索法
衝突が発生した場合、衝突が発生した単位 d [i] に 1²、2² などを加えます。すなわち d [i] + 1²、d [i] + 2²、d [i] + 3²... 空いている単位が見つかるまで続けます。 - 二重ハッシュ関数探索法
チェインアドレス法(ラチェ法)#
チェインアドレス法の考え方は、ハッシュ値が同じ要素を同義語の単一連結リストに構成し、単一連結リストの頭ポインタをハッシュテーブルの i 番目の単位に格納し、検索、挿入、削除は主に同義語の連結リスト内で行います。
再ハッシュ法#
異なるハッシュ関数を複数同時に構築することです:
Hi = RHi(key) i= 1,2,3 ... k;
H1 = RH1 (key) で衝突が発生した場合、H2 = RH2 (key) を使用して計算し、衝突が発生しなくなるまで続けます。この方法は集約を生じにくいですが、計算時間が増加します。
六、HashMap は JDK1.7 と JDK1.8 でどのように異なりますか#
-
1.8 では赤黒木が追加され、遍歴検索の時間計算量が元の連結リストの O (n) から O (logn) に変わりました。
-
データを挿入する際、1.7 は頭挿入法に基づいており、逆順と環状連結リストの無限ループ問題が発生しますが、1.8 は尾挿入法に基づいてこの問題を回避しました。詳細は:jdk1.7HashMap の連結リストが環状になる原因と jdk1.8 の解決策を参照してください。
-
拡張後のデータ格納位置の計算方法が異なります。
- JDK 1.7 は hashCode () + 4 回のビット演算 + 5 回の排他的論理和演算(9 回の攪拌)を使用します。
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}```
2. JDK 1.8 は攪拌関数を簡素化し、2 回の攪拌のみを行います = 1 回のビット演算 + 1 回の排他的論理和演算。
Java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // a. key = nullの場合、hash値 = 0となるため、HashMapのkeyはnullにすることができます。 // 注:HashTableと比較すると、HashTableはkeyを直接hashCode()にし、keyがnullの場合は例外をスローするため、HashTableのkeyはnullにできません。 // b. key ≠ nullの場合、まずkeyのhashCode()を計算し(これをhと呼びます)、次にハッシュコードに攪拌処理を行います:ビット排他的論理和(^)をハッシュコード自身の右シフト16ビット後の二進数と行います。 }
七、HashMap はなぜ直接 hashCode () 処理後のハッシュ値を直接 table のインデックスとして使用しないのか?#
hashCode () を直接使用せず、攪拌関数処理
を選択することで、key に基づいて生成されたハッシュコード(ハッシュ値)の分布がより均等で、よりランダム性を持ち、ハッシュ値の衝突を避けることができます。
八、HashMap、LinkedHashMap、TreeMap の違い#
- HashMap を遍歴する際、その出力は無秩序です。
- LinkedHashMap は HashMap から継承され、高効率であり、HashMap の基礎の上に、内部に要素の順序を保持するための連結リストを追加しています。
- TreeMap は SortedMap インターフェースを実装しており、要素をソートできます。
- LinkedHashMap は集合に入る順序またはアクセスされた順序に基づいてソートされ、TreeMap は要素の固有の順序(Comparator または Comparable によって決定される)に基づいています。
九、HashMap データ損失問題#
参考: HashMap のソースコードとマルチスレッド環境でのデータ損失の問題