序文
HashMap の基礎となる構造は配列 + リンク リストです。つまり、ハッシュの競合を解決するためにチェーン アドレス法が使用されます。配列の各要素はリンク リストであり、リンク リストには等しいハッシュ値を持つ要素のセットが格納されます。この構造体の一般的に使用されるメソッドは put() と get() です。
いくつかの静的定数
//默认初始化的数组的大小,即当用户构造HashMap没有指定数组大小时使用;容量必须为2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的数组大小,当用户构造HashMap时如果指定的大小超过了这个值,就会以这个值作为数组的大小(必须是2的n次幂,如果1 << 31就是负数了)
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当单个链表上节点达到8个的时候就将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
負荷率がデフォルトで 0.75 である理由
公式には、0.75 が空間と時間のバランスに優れています。ハッシュ競合の存在により負荷係数が 1.0 など高すぎる場合、拡張しきい値に達したときに一部のバケットに多くの要素が格納される可能性があり、リンク リストの長さまたは高さが赤くなります。この場合、スペース使用率は増加しましたが、一部のバケットに格納されている要素が多すぎるため、クエリや挿入などの操作の時間効率が低下しました。
また、負荷率が 0.5 など低すぎる場合、領域全体の半分だけを占有して拡張することと同じになり、バケット内の要素の数は比較的少なくなり、クエリ効率は比較的高くなりますが、ストレージスペースの無駄が発生し、スペースが削減され、使用率が高くなり、容量拡張の頻度も増加します。
0.75を選択すると、時間効率とスペース効率があまり変わらず、バランスがとれるため、デフォルトの負荷率として0.75を選択します。
NodeNode クラス (エントリ)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return key; }
public final V getValue() {
return value; }
public final String toString() {
return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
NodeクラスはhashCodeメソッドをオーバーライドしており、計算方法はkeyのハッシュコードとvalueのハッシュコードのXOR演算を行います。
メンバー変数
/*
存放数据的数组,在第一次被使用的时候被初始化;而且在必要的时候会被重新调整大小
长度必须是2的N次幂
*/
transient Node<K,V>[] table;
/*
存放键值对数据的集合
*/
transient Set<Map.Entry<K,V>> entrySet;
/*
map中存放的键值对的数目
*/
transient int size;
/*
map结构被修改的次数
*/
transient int modCount;
/*
下次扩容的阙值,即当size大小达到这个值的时候就进行扩容。值等于数组长度乘以负载因子(capacity * load factor)
*/
int threshold;
/*
哈希表的负载因子
*/
final float loadFactor;
modCountフィールドについては、ArrayListのソースコード解析で内容を確認できます。
ハッシュ法
まず、hashCodeとhashを区別する必要があります。hashCode は Object クラスのメソッドであり、オブジェクトの一貫したハッシュ コードを計算するために使用されます。オーバーライドされていない場合は、Object クラスに実装されたとおりに計算されます。HashMap でのオブジェクト ハッシュ値の計算は、次の値に基づいています
。 hashCode の hash メソッドは次のように実装されます。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
計算されるのはマップ上のオブジェクトのハッシュ値で、オブジェクトがテーブル内で格納されている場所、つまりテーブル内のどのバケットに格納されているかの
インデックスを計算する際に、ハッシュ値とテーブルのビットごとのAND演算が行われます。容量 -1。計算によって取得されます。詳細については後続のコードを参照してください
キーが null の場合、ハッシュ メソッドは 0 を返すことがわかります。これは、HashMap が null キーの保存を許可し、そのハッシュ値が 0 であることを意味します。キーが null でない場合、その hashCode と hashCode は符号なしで右シフトされます。 by 16 ビットが異なるか取得した後の値 hashCode が 0~65535 の場合、上位 16 ビットがすべて 0 の場合、符号なし右シフトを 16 ビット行った結果が 32 ビットすべて 0 の場合、元のハッシュコードと同じです。XOR 後に得られた結果も元のハッシュコードのままです。ハッシュコードが 65535 より大きい場合にのみ、最終結果は異なります。
優れたハッシュ アルゴリズムでは、計算結果が分散され、均一であることが必要であることがわかっています。ここでのアルゴリズムでは、キーのハッシュ コードを使用して、ハッシュ結果を確実に分散させることができます。
施工方法
初期容量と負荷率を指定するコンストラクター
空の HashMap を作成し、初期容量と負荷係数を指定します。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
拡張しきい値は 2 の n 乗である必要があるため、次の拡張のしきい値としてユーザー指定の初期容量に基づいて 2 の n 乗を取得し、メソッドを通じてそれを返す必要がありますtableSizeFor
。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; //1
n |= n >>> 2; //2
n |= n >>> 4; //3
n |= n >>> 8; //4
n |= n >>> 16; //5
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
キャップの範囲は [0,MAXIMUM_CAPACITY] です。以下では、n が -1 または 0 の場合は考慮しません。n
> 0 の場合の 5 つのステートメント 1、2、3、4、および 5 の役割を考慮します。n には少なくとも 1 つの 1 が必要です。最高位の 1 だけを調べます。n が 0001xxxx であるとします。n >>> 1 は、最高位の 1 を 1 ビット右にシフトして 00001xxx を取得するのと同じです。その後、ビット単位の OR で最高位が得られます。 n のビットと最上位ビットの右側のビットが両方とも 1 で、00011xxx が得られます。次に 2 ビット右にシフトすると、0000011x が得られ、次にビットごとの AND で 0001111x が得られます。続ければ、最終的にはget n as 00011111... ステートメント 5 の後に最終的に取得される n は、元の n に基づく必要があり、最上位の 1 以降のすべてのビットが 1 に変更され、最終的な戻り値は n + 1 になることがわかります。 n の最初の n 値は 2 の n 乗であるため、cap が 2 の n 乗である場合、最終的に cap の値が返されます。つまり、このメソッドは最終的に、cap 以上の最初の 2 の n 乗を返します。
cap が 0 に等しい場合、n=-1、その補数の最上位ビットは 1 ですが、n の最上位ビットは演算後も 1 のままです、つまり、負の数のままです。ステートメントの最後の n が負の数の場合、メソッドは 1 を返します。
cap が 1 で n=0 の場合、演算後も n は 0 のままで、最終的な戻り値は 1 のままです。
要約すると、ユーザーが初期容量を指定すると、構築メソッドは初期容量以上の最初の 2 の n 乗を計算し、この数値をしきい値に割り当てます。これが、テーブルを直接作成する代わりに、次の展開を行います。配列は最初の put まで展開されず、しきい値の容量を持つテーブル配列が作成されます。これは怠惰なアイデアです。利点は何ですか? ユーザーが次のように構築すると仮定します。この HashMap を作成し、構築メソッドによって実際のデータも構築されます。データを格納する配列ですが、ユーザーはそれを使用しません。つまり、この HashMap を指す参照変数がない場合、この HashMap は後でガベージ コレクションされます。割り当てられているが使用されていない領域に相当し、リサイクルされます。メモリ領域が無駄になり、割り当て中の実行リソースも無駄になります。したがって、遅延割り当てを使用すると、ユーザーが実際にスペースを使用する必要があるまで待機することで、無駄なオーバーヘッドを節約できます。
初期容量を指定するコンストラクター
ユーザーは初期容量を指定し、負荷係数はデフォルトの 0.75 を使用します。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
引数のないコンストラクター
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
メソッドを取得する
public V get(Object key) {
Node<K,V> e;
//1.调用getNode方法
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//2.根据哈希值找到key存在数组哪个索引位置上
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//3.检查该索引上的第一个节点(链表即头节点,红黑树即根节点)是不是要找的key,是的话返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//4.否则看现在数组元素是链表还是红黑树,如果是红黑树,就调用getTreeNode方法继续搜索
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//5.否则就继续遍历链表查找key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
putメソッド
put メソッドはキーと値のペアを追加します。指定されたキーが元の HashMap に既に存在する場合は、そのキーに対応する値が置き換えられ、元のマップのキーに対応する値が返されます。キーは元々 null が返されます。もちろん null が返されます。また、このキーが元のマップに存在し、対応する値が null である可能性もあります。
HashMap では、null 値を含むキーと値のペアが許可されているため、あいまいさの問題が発生します。get メソッドがキーに対して呼び出されても null が返された場合、キーは存在しないか、キーに対応する値が null です。教えて。解決策は、最初に contains メソッドを使用してキーが存在するかどうかを確認し、次に get メソッドを使用して値が存在する場合は値を取得することです。ただし、このメソッドにはスレッドアンセーフの問題もあります。contains メソッド内にキーが存在し、get の前に削除される可能性があります。このとき、get の戻り値は null である必要があるため、キーは確実に に対応していると考えられます。 value は null ですが、このキーに対応する値が null ではなく、削除されたために返される可能性があります。
public V put(K key, V value) {
//1.调用putVal方法
return putVal(hash(key), key, value, false, true);
}
/**
* hash,key的哈希值
* key,目标key
* value,key对应的值
* onlyIfAbsent 该值如果传入true,就不修改原先存在的值
* evict,该值如果传入false,存数据的table数组就进入creation模式
*
* 方法返回值返回原有key对应的值,若不存在key则返回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;
//2.如果table为null或者table数组长度为0,就调用resize方法进行扩容
//(构造方法没有初始化table数组,推迟到了第一次put的时候才会为table数组分配空间)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//3.如果key哈希值所对应的数组索引位置为null,就直接创建一个新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//4.否则就先看索引位置上的首节点的key是不是指定的key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) //可以看出HashMap是允许null为键的
e = p;
//5.如果不是,就判断这个索引上的数组元素是不是红黑树,是的话就调用putTreeVal继续完成插入操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//6.如果不是红黑树那就是链表,遍历链表查看有没有存在key的节点,没有的话就创建一个节点在尾部,有的话就返回找到的节点
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果会找到null,此时链表节点个数为binCount + 1,所以判断该链表是否达到树化阙值
//就是判断binCount + 1 >= TREEIFY_THRESHOLD(8)
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;
}
}
//7.最后e如果不是null,说明原先map中就存在key的键值对,当onlyIfAbsent或者键值对中原先的值为null,就把e的value属性改为指定的value,然后返回旧值
if (e != null) {
// existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//8.如果没有找到原先有key的键值对,就会新增一个节点,那就要判断新增完键值对数目size是不是大于要扩容的阙值,是就扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
putIfAbsent
@Override
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
put メソッドとの違いは、putVal メソッドを呼び出すときに 4 番目のパラメーターが true に調整されることです。これは、元の値が変更されないことを意味します (キーが最初に保存されている場合)。
サイズ変更方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//旧数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧数组的扩容阙值
int oldThr = threshold;
int newCap, newThr = 0;
//非第一次扩容
if (oldCap > 0) {
//如果数组的长度已经达到了MAXIMUM_CAPACITY,那么就不应该再扩容了
if (oldCap >= MAXIMUM_CAPACITY) {
//直接将下一次扩容阙值设为整型最大值,这个值肯定小于oldCap * loadFaactor,意思就是以后不会再经历扩容操作了
threshold = Integer.MAX_VALUE;
//无需扩容,直接返回
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//oldCap不大于0,所以是第一次扩容
else if (oldThr > 0) //当oldThr即threshold大于0时,表示的就是根据用户指定的初始容量计算得到的初始容量
newCap = oldThr;
else {
//oldThr为0,说明是使用无参构造函数构造的map,那么要扩容的大小就使用newCap默认的DEFAULT_INITIAL_CAPACITY
newCap = DEFAULT_INITIAL_CAPACITY;
//下次扩容的阙值就是当前容量table数组大小乘以负载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//检查下次扩容阙值newThr是否未被修改
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//正式开始扩容
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//迁移旧的记录
if (oldTab != null) {
//遍历旧的table数组中每一个桶(即每个table[i])
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
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;
}
要素の再割り当て
バケット上の要素がリンク リストの形式で編成されている場合、要素が再割り当てされると、要素のハッシュ値は古い配列サイズと AND 演算されます。配列サイズは 2 の整数乗であることがわかっています。つまり、1 つのビットのみが 1 で、配列サイズのバイナリ値が 1 であるビットの要素のハッシュ値も 1 である場合、AND 結果は古い配列サイズになり、それ以外の場合は 0 になります。したがって、この特性に従って、結果が 0 の場合は要素を元のバケットに残し、それ以外の場合は要素を新しいバケットに置きます。i 番目のバケットに対応する新しいバケットは、i 番目のバケットにサイズを加えたものになります。この場合、配列サイズが 2 であるため、拡張された新しいバケットは実際に古い配列の各バケットに対応し、古いバケット内の要素の再分散が完了します。
テーブル配列のサイズが 2 の整数乗でなければならない理由
ソース コードは、(n - 1) & hash
要素がテーブル配列内でどのインデックス位置に配置されるかを計算するために使用されます。n が 2 の整数乗の場合、n - 1 は 0000...111111 の形式になります。最初の k ビットが次であると仮定します。 1, then ビットごとの AND 計算方法によると、(n - 1) & ハッシュ計算の結果はハッシュの最初の k ビットになります。このアルゴリズムでは、異なる要素のハッシュを計算したときに得られる結果が均等に分散されます。table 配列 分布は均一であり、これも優れたハッシュ アルゴリズムの要件の 1 つであり、さまざまな要素をハッシュして得られる結果は均一である必要があります。
そして、コンピューターが演算を実行するとき、ビット演算が最も速い演算であることがわかっています。一般に、ハッシュ テーブル内の要素の位置を選択するときは、ハッシュ %n が使用されます。ここで、n はハッシュ テーブル (配列) のサイズです。これは、インデックス位置を計算するには、配列サイズ n の範囲内である必要があります。n が 2 の整数乗の場合、ハッシュ % n = ハッシュ & (n - 1) が存在します。これにより、計算されたインデックスの位置が n の範囲内にあることが保証されるだけでなく、高速な計算速度と均等に分散された計算が行われます。結果。
証明: n が 2 の n 乗の場合、ハッシュ % n = ハッシュ & (n - 1)
検索しても検索しても、n が 2 の n 乗のとき、ハッシュ % n = hash & (n - 1) が存在する理由が見つからなかったので、自分で証明しようとしました。
2 進数では、ハッシュが m ビットの 2 進数であると仮定すると、次のようになります。
ハッシュ = a 0 2 0 + a 1 2 1 + a 2 2 2 + … + a k 2 k + … + a m-1 2 m-1
ここで、a 0、a 1、a 2、...は 0 または 1 に等しい。n は 2 の整数乗であるため、n = 2 k (k は非負の整数、m - 1 > k) と仮定します。さらにハッシュを取得すると、次のものが得られます。
ハッシュ = a 0 2 0 + a 1 2 1 + a 2 2 2 + a k-1 2 k-1 + 2 k (a k + a k+1 2 + … + a m-1 2 m-k-1 )
0からk-1までがすべて 1 である場合でも、最初の k 項目の合計は 2 k - 1 < 2 kであるため、ハッシュ % 2 kの結果、つまりハッシュ % n は、 0 番目から k - 1 番目の項目、つまりハッシュ バイナリの最初の k ビット
ハッシュ & (n - 1) をもう一度見てみると、 n = 2 kであるため、n は 1000...00000 であることを意味します。ここで 1 は k + 1 番目のビット (最上位から数えて)、最初の k ビットはすべて 0、その後 n - 1 つまり、0111…11111、最初の k ビットはすべて 1、他のビットはすべて 0 になります。その後、ビットごとの AND 演算の特性に従って、ハッシュ & (n - ) の結果が得られます。 1) ハッシュの最初の k ビットです
要約すると、hash % n = hash & (n - 1)、結果はハッシュの上位 k ビットです
したがって、配列サイズ n が 2 の n 乗でなければならない理由も次のように理解できます: 通常、要素の格納場所の計算はハッシュ % n によって計算されます。n は 2 の n 乗であるため、ハッシュ % n = ハッシュがあります。 & ( n - 1) この関係では、ビット単位の AND & 演算を使用して効率を高めるために、n は 2 の n 乗でなければならないと規定されており、要素の格納場所を計算する際に、ハッシュ & ( n - 1) を使用できます。
hashCode()とequals()の書き換えについて
- Object の平等メソッドの実装では、「==」記号を直接使用して 2 つのオブジェクトを比較します。つまり、それらの参照アドレスが等しいかどうか、つまり 2 つのオブジェクトが同じオブジェクトであるかどうかを比較します。それらが同じオブジェクトではない場合、結果は常に false を返します。
String クラスでは、2 つのオブジェクトが等しいかどうかの比較ルールは、2 つのオブジェクトの文字列の内容が同じかどうかです。アドレスが異なる 2 つのオブジェクトの文字列の内容が同じである可能性は十分にあります。unrepeatable メソッドを直接使用します。このように 2 つのオブジェクトを比較する場合、equals メソッドは常に false を返します。これでは、このクラスにおける equals の役割を果たせないため、書き直す必要があります。 - したがって、この 2 つのメソッドを書き換える必要があるかどうかは、簡単に言うと、本来の Object メソッドの実装と使用目的が異なる場合に、それらを使用する必要がある場合に書き換えるということです。自分の意図を書き換えるには、equals メソッドを使用する必要がある場合は、equals メソッドを書き換えるだけです。
- hashCode メソッドはオブジェクトのハッシュ コードを返すため、一般的に、2 つのオブジェクトが同じである場合、つまり、equals メソッドの戻り値が true である場合、それらのオブジェクトの hashCode が等しいことも期待されます。テーブル構造
は、まず hashCode を介してハッシュ テーブル上のインデックス値 (実際にはハッシュ値、hashCode を処理した後に得られる値) を計算し、次に、インデックス位置にある競合する要素に対して equals メソッドを呼び出して、さらに重複を排除します。 hashCode メソッドをオーバーライドしないと、2 つのオブジェクトが equals メソッドを呼び出したときに、戻り値は true ですが、ハッシュコードが異なる、つまり、発生するはずだったハッシュの競合が発生しない可能性があり、その結果、 2 つの等しいオブジェクトがコレクションに登場 - これら 2 つのメソッドの使用シナリオは通常、コレクションと切り離せないため、equals メソッドを書き換える際には hashCode メソッドも書き換えることをお勧めします。
なぜ hashCode と equals の両方が必要なのでしょうか?
確かに、equals を使用して 2 つの要素が同じかどうかを判断することは可能であり、Map、Set、および判断が必要なその他のセットに適用できます。
ただし、equals のみを使用して n 個の要素がセット内にあるかどうかを判断する場合は、equals メソッドを n 回呼び出す必要があります。また、hashCode メソッドがある場合は、まず hashCode を使用して要素が配置されているバケットを特定し、競合する可能性のある要素をこのバケットに追加します。範囲内で、バケット内の要素を等しいものと比較すると、等しいものの呼び出し数を大幅に減らすことができます。また、等しいもののコストは hashCode よりも大きい場合が多いため、等しいものの数を減らすことができます。コレクション全体の効率も大幅に向上します。
リンクされたリストの長さが 8 を超える場合、なぜ赤黒ツリーに変換する必要があるのでしょうか?
ソース コード内の開発者のコメントによると、赤黒ツリー ノードのサイズは通常のノードの約 2 倍であるため、ハッシュ バケットがしきい値に達した場合にのみ赤黒ツリーに変換されます (つまり、ツリーしきい値 TREEIFY_THRESHOLD)。
良好な分散特性を備えたハッシュ手法では、ツリーを使用する必要があるケースはほとんどありません。理想的には、各ノード独自のランダム ハッシュ コードを利用して、ハッシュ バケット内のノード数の確率分布がポアソン In に準拠します。緩やかな分布では、1 つのバケット内のノード数が 8 である確率はわずか 0.00000006 であり、すでに非常に小さくなっています。そこで、ツリーのしきい値として 8 を選択しました。
ノードの数が十分でない場合、リンク リストを使用すると、クエリを完了するのに十分な速度が得られます。赤黒ツリーを使用する必要はなく、赤黒ツリーのノード メモリ空間は、赤黒ツリーのノード メモリ空間よりもはるかに大きくなります。普通のノード。リンクされたリストを選択することが優先されます
赤黒の木をそのまま使うことはできないのでしょうか?
単一バケット内のエントリの数が少ない場合、最初から検索効率を向上させることができる赤黒ツリーや二分探索ツリー (AVL) を使用せずに、リンク リストを使用する理由は次のとおりです。これは時間と空間のトレードオフによるものであるはずです
ハッシュの競合が比較的少ない場合、または 1 つのバケット内のエントリが比較的少ない場合、赤黒ツリーを使用した時間の節約効果は特に大きくなく、put 中に効率が低下する可能性があります。 Put を実行するたびに、色の変更や回転などの複雑な操作が必要になるほか、ツリーの各ノードがより多くのスペースを占有することになるため、節約された時間と比較すると、このスペース占有の方が利益の方が若干上回るように見えます。
スレッドの安全でない問題
JDK 1.7 以前では、HashMap のスレッドの安全性の問題は、リンク リストの無限ループと、容量拡張時のデータの上書きという 2 つの側面に反映されていました。JDK 1.8 以降、無限ループの問題は修正されましたが、データの上書きの問題は依然として存在します。 。
無限ループ
無限ループの問題は、JDK 1.7 以前の transfer() メソッドに起因しており、このメソッドは、容量拡張中に特定のバケットのリンク リストからデータを移行するために使用されます。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; //1
newTable[i] = e; //2
e = next;
}
}
}
2 つのスレッドがステートメント 1 を同時に実行すると、一方のスレッドは何らかの理由で一時停止され、もう一方のスレッドはステートメント 2 が実行されるまで通常どおり実行を続け、その後も一時停止されます。このとき、現在移動されているフィールドの次のフィールドが削除されます。ノードは新しいバケットのヘッド ノードを指しており、新しいバケットのヘッド ノードは移動されたノードでもあります。最初のスレッドが実行を再開すると、移動されたノードの次のフィールドがバケットのヘッド ノードを指します。新しいバケットです。自分自身なので、このノードで無限ループが作成されます。後続のスレッドがノードが配置されているリンク リストをトラバースする必要がある場合、無限ループ状態に陥り、ある程度のデッドロックが発生します。
1.8以降、特定のバケット上のノードを移行する際の末尾挿入方法が変更され、ヘッドノードの無限ループの問題が解決されました。
データ範囲
put メソッドはスレッドアンセーフであるため、2 つのユーザー スレッドが同時に同じキーを置く可能性があり、1 つのスレッドの操作が上書きされます。
もう 1 つの方法は、最初に containsKey を使用してキーが存在しないことを判断してからそれを配置することですが、containsKey が実際にキーが存在しないと判断した後、配置する前に別のスレッドによって配置される可能性があります。これも論理エラーの原因となります。
スレッドセーフな ConcurrentHashMap
HashMap はシングルスレッド環境で実装されるように設計されているため、マルチスレッドの同時環境では安全ではありません。マルチスレッド同時実行セキュリティが必要な場合は、ConcurrentHashMap を選択する必要があります。
ConcurrentHashMap については、2 つの状況で説明する必要があります。1 つは JDK 1.7 以前のバージョン、もう 1 つは JDK 1.8 以降のバージョンです。
JDK 1.7 以前では、ConcurrentHashMap の最下層は配列 + リンク リストの構造に基づいています。スレッドの安全性を確保するためにセグメント ロックが使用されます。配列は 16 のセグメントに分割され、各セグメントにロックが割り当てられるため、最大16 スレッドが許可されます。同じ ConcurrentHashMap オブジェクトに対する同時操作
JDK 1.8 以降では、ConcurrentHashMap に赤黒ツリー構造も導入されました。同時に、セグメント化されたロックは使用されなくなりましたが、CAS + synchronized キーワードは、ハッシュ バケット レベルの粒度で、よりきめ細かいロックを実現するために使用されます。
具体的には、新しい kv を置くときに対応するバケットを計算し、バケットが空の場合は CAS を使用して値を変更し、バケットが空でない場合はヘッド ノードをロックして以降の操作を完了します。さらに、このメソッドは値が正常に保存されるまでロジック全体をループするため、CAS が失敗した場合は最終的にロック ステップに到達します。つまり、バケットが空の場合は CAS を使用して変更し、それ以外の場合はヘッド ノードをロックして変更し、バケット レベルの同時実行性を実現します。