前回の番外編でhashmapの実装原理を紹介しましたので、今回はconcurrentHashmapの実装原理を紹介します。
コンカレントハッシュマップとは何ですか?
名前が示すように、concurrent = は同時に発生するため、concurrentHashmap はおそらく同時ハッシュマップに変換できます。実際、これを使用して、同時実行性の高いシナリオを処理することができます。Hashmap がパスワードなしのパブリック ストレージ ボックスである場合、ConcurrentHashmap はロック付きのストレージ ボックスです。concurrentHashmapの最大の特徴はロックセグメンテーション技術、つまりセグメントであり、これがHashmapと異なる最大の特徴です。さらに、concurrentHashmap は基本ストレージ ユニットのエントリを変更し、可視性を確保するために一部のパラメータが volatile で変更されます。(バージョンjdk7.0)
なぜ ConcurrentHashmap を使用するのでしょうか?
前回の Hashmap の紹介では、Hashmap の問題について簡単に紹介しました。つまり、同時実行の場合、循環リンク リストが発生して無限ループが発生するということです。この数日間の研究の結果、循環リンク リストが出現した理由についての理解が深まりました。(バージョンはjdk7です)
まず第一に、ハッシュマップはリンクされたリストの配列であり、ハッシュの競合の解決策はチェーン アドレス方式であることを知っています。図にあるように、
このストレージ構成では通常は問題ありませんが、問題が発生してループが発生する条件は以下のとおりです。
- 同時アクセス
- 拡張する必要がある
ソースコードを見てみましょう。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
// 找节点位置
int i = indexFor(hash, table.length);
//遍历数组,查找到了就返回原值如果没有就添加新entry节点
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i); // 注意这里,进行新的节点的添加
return null;
}
上記は要素を置くソースコードです
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 新增一个节点并将节点头指针改为这个新增的节点,因为第四个参数表示next对象
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold) //注意这里,进行扩容
resize(2 * table.length);
}
上記は新規エントリノードを追加するソースコードですが、ノードを追加してから展開することに注意してください。ConcurrentHashmapは、まず展開が必要かどうかを判断し、展開が必要な場合は、展開してからエントリノードを追加することで、無効な展開を回避できます(展開後にノード要素が追加されない)。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity]; //1.0
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash); //1.1
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); //1.2
}
これは jdk7.0 バージョンの拡張コードで、主に 3 つのステップに分かれています。
1.0 -- 新しい配列を作成する
1.1 -- 元の配列のデータをコピーする
1.2 -- しきい値 (しきい値) を再定義する
もう一度引き継ぎコードを確認してください
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; //step 1.0
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//查找位置
e.next = newTable[i];//注意这里,将节点指向新节点
newTable[i] = e;//注意这里,头插法插入新数组
e = next;//注意这里,进行下一次的插入
}
}
}
ここのコードはさらに興味深いもので、ヘッダー補間を通じて元の配列の値を新しい配列に挿入します。次に写真を見てみますと、
スレッド 1 はまだ拡張を開始していませんが、拡張する準備ができています。つまり、ステップ 1.0 のコード位置に到達しています。このとき、クソスレッド 2 が来ます。転送関数のテーブルは公開されているため、つまり、各スレッドは元の配列のバックアップを持っており、テーブル内の e が指すオブジェクトはすべて同じオブジェクトです。 。この前提で次の図を見てみましょう
スレッド 2 の拡張が完了し、この時点でスレッド 1 が拡張を開始します (これは単なる偶然の状況であり、専門用語では競合状態と呼ばれます)。その後、スレッド 1 は先頭でノード A を指し、A.next -> 次のステートメントを実行した後の新しい配列 B には、B.next -> A があるため、リングリンクが存在します。
循環リンク リストが表示された後、後続の読み取り (get) で無限ループが発生します。
さらに、マルチスレッドでハッシュマップの非 NULL 要素を配置した後、get 操作で NULL 値が取得されます。スレッド put 操作により要素が失われます。興味のある技術者は自分で確認してください。
ハッシュマップの問題と比較して、concurrentHashmap には多くの利点があります。第一に、コンカレント コンテナとも呼ばれる同時アクセスをサポートします。第二に、拡張後に最初にストレージ要素を判断して、無効な拡張 (拡張してもノードが挿入されない問題) を回避します。繰り返しますが、concurrentHashmap はエントリ構造を最適化し、可視性を維持するために value と next を volatile で変更します。最も強力な点は、concurrentHashmap がロック セグメンテーション テクノロジを使用しているため、各セグメント (セグメント) にロックが装備されており、異なるセグメントに同時にアクセスできることです。(JDK7)
ConcurrentHashmap ソースコード分析
jdk7 の ConcurrentHashmap のクラス図は大まかに上の図に示されており、ソース コードの分析は以下から始まります。
まず、hashEntry のコードを見てください。
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value;
final HashEntry<K,V> next;
}
ここでの値は可視性を確保するために volatile で変更され、他のメンバー変数はリンクされたリストの構造が破壊されないように Final で変更されていることに注意してください。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile int count; //Segment中元素的数量
transient int modCount; // 修改次数
transient int threshold;// 阈值(扩容临界值)
transient volatile HashEntry<K,V>[] table; // hashentry节点数组
final float loadFactor; //负载因子
}
ノードの構造を理解した後、concurrentHashmapの初期化メソッドを見てみましょう。
concurrentHashmap を初期化する
/* ---------------- Constants -------------- */
/**
* 最大的容量,是2的幂次方(java 数组索引和分配的最大值约为 1<<30,32位的hash值前面两位用于控制)
*/
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 数组容量,为2的幂次方,
* 1<=DEFAULT_CAPACITY<=MAXIMUM_CAPACITY
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 最大的数组容量(被toArray和其他数组方法调用时获取所需要)
*/
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 默认的并发等级.
* 为12、13、14、15、16表示segment数组大小默认为16
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 负载因子,考虑到红黑树和链表的平均检索时间,取0.75为宜。
* 这样接近O(1)
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
* >=8时进行扩容该节点会红黑树化
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
* <= 6 时进行扩容该节点仍为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小的链表数组容量(至少为4倍的TREEIFY_THRESHOLD。即32)
* 以防止扩容和红黑树化阈值的冲突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Minimum number of rebinnings per transfer step. Ranges are
* subdivided to allow multiple resizer threads. This value
* serves as a lower bound to avoid resizers encountering
* excessive memory contention. The value should be at least
* DEFAULT_CAPACITY.
*/
private static final int MIN_TRANSFER_STRIDE = 16;
/**
* 扩容戳,和resizeStamp函数有关
* Must be at least 6 for 32bit arrays.(至少6位以满足32位的数组)
* rs(RESIZE_STAMP_BITS) = 1 << (RESIZE_STAMP_BITS - 1)
* rs(6) = 1 << (6-1) = 32
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* 最大的可扩容线程数
* 线程在扩容时会将高RESIZE_STAMP_BITS作为扩容后的标记,高 32- RESIZE_STAMP_BITS 为作为扩容线程数
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
/**
* The bit shift for recording size stamp in sizeCtl.
* 扩容戳的位偏移
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// ...
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize - 1;
this.segments = Segment.newArray(ssize);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
//最小Segment中存储元素的个数为2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
その初期化にはいくつかのパラメータがあります。
- 負荷係数 負荷係数
- InitialCapacity 初期容量サイズ (セグメント * セグメント容量に等しい)
- concurrencyLevel セグメントの長さを決定するために使用される同時実行レベル (セグメントのセグメント サイズが 16 の場合は concurrencyLevel = 13,14,15,16 など)
- sshift は、同時実行レベルが占める桁数 (セグメント数) を示し、セグメント オフセットのサイズを決定するために使用されます。セグメント オフセット = 32 - sshift は、後でハッシュするときに右にシフトされる桁数を示します。これについては後で説明します。
- ssize はセグメントのサイズを示し、concurrencyLevel の 2 の累乗以上です。
- セグメントリハッシュには、後述するsegmentShiftセグメントオフセットが使用されます。
- セグメントマスクは後述しますが、上位 n ビットを取得するためのセグメントの再ハッシュに使用されます。
- MAXIMUM_CAPACITY はセグメントの最大数です。
- c、cap は各セグメントの容量を決定するために使用されます。これも 2 のべき乗であり、負荷率も各セグメント内のオブジェクトに適用されます。
初期化プロセスの概要:
- パラメータの検証を実行する
- 同時実行レベルが最大値を超えているかどうかを確認し、超えている場合は同時実行レベルを最大値に設定します。
- ssize (セグメント長) を取得し、同時実行レベルに応じて sshift を実行します。
- セグメントシフト (セグメント オフセット) = 32 - sshift を計算し、リハッシュ時に AND 演算する必要がある上位データ オフセット (上位ビットが右に移動して上位ビットが低くなるビット数) を決定します。 )。
- セグメントマスク (セグメントマスク) = ssize -1 を計算します。つまり、再ハッシュのためにオフセット後の下位セグメントマスクビットを取得します。(つまり、元の上位ビット n のデータでセグメント位置を決定できます)
- 各セグメントの hashEntry の容量を計算します。デフォルトでは、initialCapacity は 16、loadFactor は 0.75 です。計算すると、cap=1、threshold=0 となります。
concurrentHashmap に要素を挿入する
まず、セグメント画像ソースの構造を見てみましょう
:
https://blog.csdn.net/m0_37135421/article/details/80551884
static final class Segment<K, V> extends ReentrantLock implements Serializable {
/**
* scanAndLockForPut中自旋循环获取锁的最大自旋次数。
*/
static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
/**
* 链表数组,数组中的每一个元素代表了一个链表的头部
*/
transient volatile HashEntry<K, V>[] table;
/**
* 用于记录每个Segment桶中键值对的个数
*/
transient int count;
/**
* 对table的修改次数
*/
transient int modCount;
/**
* 阈值,Segment里面元素的数量超过这个值依旧就会对Segment进行扩容
*/
transient int threshold;
/**
* 负载因子,用于确定threshold,默认是1
*/
final float loadFactor;
}
static final class HashEntry<K, V> {
final int hash;
final K key;
volatile V value; //设置可见性
volatile HashEntry<K, V> next; //不再用final关键字,采用unsafe操作保证并发安全
}
セグメントはリエントラント ロックを使用して、セグメントに対する各操作がアトミックであることを保証します。セグメントが操作されるたびに、最初にセグメントのロックが取得されてから操作が実行されます。また、異なるロックが存在するため、セグメント間の操作は相互に干渉しません。
putメソッドを見てみましょう
// ConcurrentHashMap类的put()方法
public V put(K key, V value) {
Segment<K,V> s;
//concurrentHashMap不允许key/value为空
if (value == null)
throw new NullPointerException();
//hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
int hash = hash(key);
//返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment,即进行再散列操作
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null) s = ensureSegment(j);
// 调用Segment类的put方法
return s.put(key, hash, value, false);
}
// Segment类的put()方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 注意这里,这里进行加锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value); //如果加锁失败,则调用该方法
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 根据hash计算在table[]数组中的位置
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
//若不为null,则持续查找,知道找到key和hash值相同的节点,将其value更新
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
//如果在链表中没有找到对应的node
if (node != null) //如果scanAndLockForPut方法中已经返回的对应的node,则将其插入first之前
node.setNext(first);
else //否则,new一个新的HashEntry
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 判断table[]是否需要扩容,并通过rehash()函数完成扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else //设置node到Hash表的index索引处
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
put 操作の手順は次のとおりです。
- 値が空かどうかを判断する
- キーをハッシュする
- キーのハッシュ値に基づいてデータストアのセグメント(セグメント)の場所を決定します
- キーと値のキーと値のペアをセグメントの hashEntry に挿入し、古い値が存在する場合はそれを返し、存在しない場合は新しいノードを作成します。ここにロックが挿入されていることに注意してください。
concurrentHashmap から要素を取得する
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//先定位Segment,再定位HashEntry
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get 操作は、再ハッシュ値に従ってセグメント位置が決定され、キーと値のペアに従ってハッシュトリ位置が決定される限り、比較的単純です。
concurrentHashmap はどのように拡張を実現するのでしょうか?
- セグメント内の hashentry 配列がしきい値に達しているかどうかを判定し、超えている場合は容量を拡張してから要素を挿入します
- 拡張は通常 2 倍の拡張で、元の配列の要素が再ハッシュされてから新しい配列に挿入されます。効率性を高めるため、concurrentHashmap はセグメントのみを拡張しますが、コンテナー全体は拡張しません。
- - - - - - - - - - - - - つづく - - - - - - - - - - - --------