前の2つの講義では、Javaコレクションフレームワークの典型的なコンテナクラスを紹介しましたが、それらのほとんどはスレッドセーフではありません。VectorやStackなどのスレッドセーフな実装だけでは、パフォーマンスの点で満足のいくものではありません。幸い、Java言語は並行パッケージ(java.util.concurrent)を提供します。これは、高い並行性要件に対するより包括的なツールサポートを提供します。
今日お聞きしたいのは、コンテナがスレッドセーフであることを確認する方法です。ConcurrentHashMapはどのようにして効率的なスレッドセーフを実現しますか?
典型的な答え
Javaは、さまざまなレベルのスレッドセーフサポートを提供します。従来のコレクションフレームワークでは、Hashtableなどの同期コンテナーに加えて、いわゆる同期ラッパーも提供します。Collectionsツールクラスによって提供されるパッケージ化メソッドを呼び出して、同期パッケージングコンテナー(Collections.synchronizedMapなど)を取得できます。 、しかし、それらはすべて非常に粗い同期方法を使用しており、同時実行性が高い場合、パフォーマンスは比較的低くなります。
さらに、より一般的な選択は、並行パッケージによって提供されるスレッドセーフなコンテナークラスを使用することです。
- ConcurrentHashMap、CopyOnWriteArrayListなどのさまざまな並行コンテナ。
- ArrayBlockingQueue、SynchronousQueueなどのさまざまなスレッドセーフキュー(Queue / Deque)。
- 注文されたさまざまなコンテナなどのスレッドセーフバージョン。
スレッドセーフを確保するための具体的な方法には、単純な同期メソッドから、個別のロックに基づくConcurrentHashMapなどの並行実装など、より洗練されたメソッドまでが含まれます。具体的な選択は、開発シナリオの要件によって異なります。一般に、並行パッケージで提供されるコンテナーの一般的なシナリオは、初期の単純な同期の実装よりもはるかに優れています。
テストサイト分析
スレッドセーフと同時実行性に関しては、Javaインタビューのテストポイントであると言えます。上記の回答は比較的大まかな要約であり、ConcurrentHashMapなどの並行コンテナの実装も進化しています。一般化することはできません。
深く考えて、この質問とその拡張機能に答えたい場合は、少なくとも次のものが必要です。
- 基本的なスレッドセーフツールを理解します。
- 従来のコレクションフレームワークの並行プログラミングにおけるMapの問題を理解し、単純な同期方法の欠点に注意してください。
- 並行性パッケージ、特に並行性のパフォーマンスを向上させるためにConcurrentHashMapが採用した方法を整理します。
- ConcurrentHashMap自体の進化を把握できることが最善です。現在の分析データの多くは、以前のバージョンに基づいています。
今日は、主に前の2つの講義の内容を続け、同時に検討されることが多いHashMapとConcurrentHashMapの解釈に焦点を当てます。今日の講義は並行性の包括的なレビューではありません。結局のところ、これは完全なものを紹介できるコラムではありません。前菜です。CASや他の下位レベルのメカニズムに似ています。後で、のトピックについて説明します。 JavaAdvancedモジュールの並行性。より体系的な導入があります。
知識の拡大
1. ConcurrentHashMapが必要なのはなぜですか?
ハッシュテーブル自体は比較的非効率的です。その実装は基本的に、put、get、size、およびその他のメソッドに「同期」を追加することであるためです。簡単に言うと、これにより、すべての同時操作が同じロックをめぐって競合します。スレッドが同期操作を実行しているとき、他のスレッドは待機することしかできないため、同時操作の効率が大幅に低下します。
前述のように、HashMapはスレッドセーフではありません。同時実行により、CPU使用率が100%になるなどの問題が発生します。Collectionsが提供する同期ラッパーを使用して問題を解決できますか?
以下のコードスニペットを見ると、同期ラッパーは入力マップを使用して別の同期バージョンを構築していることがわかります。すべての操作は同期メソッドとして宣言されなくなりましたが、相互に排他的なミューテックスとして「this」を使用しています。本当の意味。改善!
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
// …
public int size() {
synchronized (mutex) {return m.size();}
}
// …
}
したがって、ハッシュテーブルまたは同期パッケージバージョンは、並行性が高くないシナリオにのみ適しています。
2.ConcurrentHashMap分析
ConcurrentHashMapがどのように設計および実装されているか、そしてなぜそれが並行性の効率を大幅に改善できるのかを見てみましょう。
まず、ConcurrentHashMapの設計と実装が実際に進化していることを強調します。たとえば、Java 8では非常に大きな変更がありました(Java 7には実際に多くの更新があります)ので、構造と実装を比較します。メカニズムなど、異なるバージョン間の主な違いを比較します。
初期のConcurrentHashMap、その実装は以下に基づいています。
- ロックを分離します。つまり、HashMapに似たHashEntryの配列である内部をセグメント化し、同じハッシュを持つエントリもリンクリストの形式で保存されます。
- HashEntryは、内部で揮発性値フィールドを使用して可視性を確保し、不変オブジェクトメカニズムを使用して、揮発性アクセスなどのUnsafeが提供する基本機能の使用を改善し、一部の操作を直接完了してパフォーマンスを最適化します。安全ではないこれらはすべて、JVM組み込みによって最適化されています。
初期のConcurrentHashMapの内部構造の次の図を参照できます。そのコアは、セグメンテーションデザインを使用することです。同時操作を実行する場合、対応するセグメントのみをロックする必要があります。これにより、全体的な同期の問題が効果的に回避されます。ハッシュ可能で、パフォーマンスが大幅に向上します。
構築する場合、セグメントの数は、いわゆるconcurrentcyLevel(デフォルトでは16)によって決定されます。または、対応するコンストラクターで直接指定することもできます。Javaでは、2の電力値である必要があることに注意してください。入力が15などの非電力値の場合、16などの2の電力値に自動的に調整されます。
特定の状況について、いくつかの基本的なMap操作のソースコードを見てみましょう。これは、JDK7の比較的新しいgetコードです。特定の最適化の部分については、理解を容易にするために、get操作で可視性を確保する必要があるため、同期ロジックがないことをコードセグメントで直接コメントしました。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key.hashCode());
//利用位操作替换普通数学运算
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 以Segment为单位,进行定位
// 利用Unsafe直接进行volatile access
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//省略
}
return null;
}
put操作の場合、最初は2番目のハッシュを介してハッシュの競合を回避し、次にUnsafe呼び出しメソッドを使用して対応するセグメントを直接取得し、スレッドセーフなput操作を実行します。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
コアロジックは、次の内部メソッドで実装されます。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// scanAndLockForPut会去查找是否有key相同Node
// 无论如何,确保获取锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有value...
}
else {
// 放置HashEntry到特定位置,如果超过阈值,进行rehash
// ...
}
}
} finally {
unlock();
}
return oldValue;
}
したがって、上記のソースコードから、同時書き込み操作を実行する場合は次のことが明らかです。
- ConcurrentHashMapは、データの整合性を確保するために再入力ロックを取得します。セグメント自体はReentrantLockの拡張実装に基づいているため、同時変更中に、対応するセグメントがロックされます。
- 初期段階では、対応するキー値がすでに配列にあるかどうかを判断するために繰り返しスキャンが実行され、操作を更新するか配置するかを決定するために、コードに対応するコメントが表示されます。繰り返しスキャンして競合を検出することは、ConcurrentHashMapの一般的な手法です。
- 最後の列でHashMapを紹介したときに、ConcurrentHashMapにも存在する可能性のある拡張の問題について説明しました。ただし、全体の容量を拡張するのではなく、セグメントを個別に拡張するという明らかな違いがあり、詳細は紹介していません。
別のマップのサイズメソッドにも注意が必要であり、その実装には、分離されたロックの副作用が含まれます。
同期せずにすべてのセグメントの合計値を単純に計算すると、同時プットのために結果が不正確になる可能性がありますが、計算のためにすべてのセグメントを直接ロックすると非常にコストがかかることを想像してみてください。実際、分離ロックはマップの初期化やその他の操作も制限します。
したがって、ConcurrentHashMapの実装は、再試行メカニズム(RETRIES_BEFORE_LOCK、再試行回数2を指定)を介して信頼できる値を取得しようとすることです。(Segment.modCountを比較して)変更が検出されない場合は直接戻ります。それ以外の場合は、ロックが取得されて操作が実行されます。
Java 8以降のバージョンで、ConcurrentHashMapでどのような変更が行われたかを比較してみましょう。
- 全体的な構造に関しては、その内部ストレージは、列で紹介したHashMap構造と非常によく似ています。これも大きなバケット配列であり、内部にはいわゆるリンクリスト構造(bin)もあります。同期の粒度。より詳細に。
- 内部にはまだセグメント定義がありますが、これはシリアル化中の互換性を確保するためだけのものであり、構造的な用途はありません。
- セグメントが使用されなくなったため、初期化操作が大幅に簡素化され、遅延読み込み形式に変更されました。これにより、初期オーバーヘッドを効果的に回避し、古いバージョンの多くの人々の不満を解決できます。データストレージは揮発性を使用して可視性を確保します。
- CASおよびその他の操作を使用して、特定のシナリオでロックフリーの並行操作を実行します。
- UnsafeやLongAdderなどの低レベルのメソッドを使用して、極端な状況を最適化します。
データストレージの現在の内部実装を見ると、ライフサイクル中にアイテムのキーを変更することは不可能であるため、キーが最終的なものであることがわかります。同時に、valは可視性を確保するために揮発性として宣言されています。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// …
}
ここでは、getメソッドとコンストラクターについてはもう紹介しません。比較的単純で、同時プットがどのように実装されているかを見てください。
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 利用CAS去进行无锁线程安全操作,如果bin是空的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // 不加锁,进行检查
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// Bin超过阈值,进行树化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
初期化操作は、一般的なCAS使用シナリオであるinitTableに実装され、相互に排他的な手段としてvolatile sizeCtlを使用します。競合する初期化が見つかった場合は、そこでスピンして状態が回復するのを待ちます。それ以外の場合は、CASを使用して排他的な設定を行います。国旗。成功した場合は初期化し、そうでない場合は再試行します。
次のコードを参照してください。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果发现冲突,进行spin等待
if ((sc = sizeCtl) < 0)
Thread.yield();
// CAS成功返回true,则进入真正的初始化逻辑
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
ビンが空の場合、ロックする必要はなく、CAS操作によって配置されます。
同期ロジックの観点から、一般的に推奨されるReentrantLockの代わりに同期を使用していることに気づきましたか?なぜですか?最新のJDKでは、同期が継続的に最適化されているため、パフォーマンスの違いについてあまり心配する必要がなくなります。さらに、ReentrantLockと比較して、メモリ消費量を削減できるため、非常に大きな利点があります。
同時に、Unsafeを使用することで、より詳細な実装が最適化されます。たとえば、tabAtはgetObjectAcquireを直接使用して、間接呼び出しのオーバーヘッドを回避します。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
サイズ操作がどのように実装されているかを見てみましょう。コードを読むと、実際のロジックがsumCountメソッドにあることがわかります。それでは、sumCountは何をするのでしょうか。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
アイデアは以前と同じですが、カウントしてから合計するために分割されて征服されていますが、実装は奇妙なCounterCellに基づいていることがわかりました。その値はより正確ですか?データの整合性はどのように保証されますか?
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
実際、CounterCellの操作はjava.util.concurrent.atomic.LongAdderに基づいています。これは、JVMがStriped64内の複雑なロジックを使用して、より高い効率と引き換えにスペースを使用する方法です。これは非常にニッチです。ほとんどの場合、ほとんどのアプリケーションのパフォーマンス要件を満たすのに十分なAtomicLongを使用することをお勧めします。
今日は、スレッドセーフの問題から始め、基本的なコンテナツールを概念的に要約し、コンテナの初期同期を分析してから、ConcurrentHashMapがJava7とJava8でどのように設計および実装されたかを分析しました。ConcurrentHashMapの同時実行スキルがあなたの役に立てば幸いです。日常生活。開発は助けることができます
1つのレッスンを練習する
今日私たちが話していることを知っていますか?質問を残してください。製品コードに、ConcurrentHashMapのような並行コンテナーを使用する必要がある典型的なシナリオはありますか?
他の古典的な答え
以下は、ネチズンのCaiGuangmingからの回答です。
1.7
プットロック
はセグメントごとにセグメントをロックします。ハッシュマップには複数のセグメントがあり、各セグメントには複数のバケットがあります。バケットにはリンクリストがKVの形式で格納されます。データが配置されると、キーハッシュを使用して要素が取得されます。セグメントに追加し、セグメントをロックしてからハッシュし、要素を追加するバケットを計算してから、バケット内のリンクリストをトラバースし、ノードをバケットに置き換えるか追加して、
サイズの
セグメントは二回、結果は同じ倍リターンである、そうでなければ、すべてのセグメントのロックを再計算する。
1.8
プットCASロック
1.8は、セグメントロックに依存せず、その数
のセグメントは同じであるバケットの数、第一、コンテナが空かどうかを判断し、空の場合は初期化し、揮発性sizeCtlを相互に使用します。競合する初期化が見つかった場合は、そこで一時停止し、状態が回復するのを待ちます。それ以外の場合は、CASを使用して排他フラグを設定します( U.compareAndSwapInt(this、SIZECTL、sc、-1));それ以外の場合は
、キーハッシュの計算を再試行します。キーが格納されているバケットの場所、バケットが空かどうかを判断し、CASを使用して新しいノードを設定します。空の
場合は、同期を使用してロックし、バケット内のデータをトラバースし、バケットに新しいポイントを置き換えるか追加し、
最後にそれを赤黒ツリーに変換する必要があるかどうかを判断します。変換する前に、
サイズを拡張し、
LongAddを使用して計算を累積する必要があります
以下は、各レッスンに対するネチズンのショーンの答えです。
ConcurrentHashMapを使用する最近のシナリオは、システムが公共サービスであるため、プロセス全体が非同期で処理されるというものです。最後のリンクでは、アクセスシステムにアクティブに応答するためにhttp restが必要です。したがって、要求をカスタマイズするには、nettyを使用して非同期httpclinetのバージョンを記述します。これは、tcpリンクをキャッシュするときに使用されます。
下の友人がスピンロックと偏向ロックについて話しているのを見ました。
スピンロックは、casのアプリケーションを個人的に理解しています。並行パッケージのアトミッククラスは典型的なアプリケーションです。
バイアスロックの個人的な理解は、取得ロックの最適化です。これは、ロックが取得された後のスレッド再突入の問題を実現するためにReentrantLockで使用されます。
理解に誤りがあるかどうかはわかりません。修正と議論へようこそ。ありがとうございました
次の答えは、ネチズンのQQGuaiからのものです。
コンカレントハッシュマップのsizeメソッドはネストされたループであることを覚えています:
1:すべてのセグメントをトラバースします;
2:すべてのセグメント要素を累積します;
3:すべてのセグメントの変更数を累積します;
4:セグメント変更の数が前変更の合計回数。より大きい場合は、現在の時刻にまだ変更があることを意味します。再カウントして再試行します。そうでない場合は、変更がなく終了することを意味します
。5:の場合試行回数がしきい値を超えると、各セグメントがロックされて再起動されます。統計、最後に4つの手順を再試行します。変更の総数が最後の変更の数よりも多くなるまで、ロックを解除すると、統計が終了します。