Javaマルチスレッドおよび高並行性CAS(楽観的ロック)の詳細な解釈

ディレクトリ

CASとは

はじめに

背景紹介

ソースコード分析

AtomicInteger 

unsafe.cpp 

どのような問題が解決されますか?

欠陥は何ですか?

1. ABAの問題(リンクされたリストはデータを失う)

2.長いスピンは非常にCPUを集中的に使用します

3.シェア変数のアトミック操作のみが保証されます

アプリケーションシナリオ

Java 8 incrementAndGet最適化

偽りの共有

 


CASとは

  • CAS(比較およびスワップ)比較および置換、比較および置換は、スレッド並行処理アルゴリズムで使用されるテクノロジーです。

  • CASは、同時同期を保証するのではなく、同時セキュリティを保証するアトミック操作です。

  • CASはCPUの命令です

  • CASは非ブロッキングで軽量の楽観的ロックです

はじめに

CASはCompareAndSwapの正式名称であり、マルチスレッド環境で同期を実現するメカニズムです。CASオペレーションには、3つのオペランド、メモリロケーション、期待値、新しい値が含まれます。CASの実装ロジックは、メモリロケーションの値を期待値と比較し、等しい場合は、メモリロケーションの値を新しい値に置き換えます。それらが等しくない場合、操作は行われません[インターネット上の多くの記事はループとして説明されていますが、これは不正確です]

背景紹介

CPUはバスとメモリを介したデータ転送であり、マルチコア時代には、複数のコアがバスを介してメモリやその他のハードウェアと通信します。以下に示すように:

画像ソース「コンピュータシステムの詳細な理解」

上の図は比較的単純なコンピューター構造図ですが、単純ですが問題を説明するには十分です。上の図では、CPUは2つの青い矢印でマークされたバスを介してメモリと通信します。CPUの複数のコアがメモリプロセスで同時に動作し、それを制御しない場合、どのような問題が発生しますか?

Core 1が32ビット帯域幅バスを介して64ビットデータをメモリに書き込むと仮定すると、Core 1は2つの書き込み操作を実行して、操作全体を完了する必要があります。コア1が初めて32ビットデータを書き込む場合、コア2はコア11によって書き込まれたメモリロケーションから64ビットデータを読み取ります。 2メモリ位置からデータの読み取りを開始すると、読み取ったデータは無秩序でなければなりません。しかし、この問題については、実際に心配する必要はありません。Pentiumプロセッサ以降、Intelプロセッサは64ビット境界で整列したクワッドワードのアトミックな読み書きを保証します。

上記の説明によれば、Intelプロセッサは、シングルアクセスメモリアライメント命令がアトミックに実行されることを保証できます。しかし、それが2回のメモリアクセス命令である場合はどうでしょうか。答えは保証されません。たとえば、インクリメント命令inc dword ptr [...]は、DEST = DEST +1 同等です。この命令には、2つのアクセスを含む、読み取り->変更->書き込みの3つの操作が含まれます。メモリ内の指定された場所に値1が格納されている状況を考えます。これで、両方のCPUコアが同時に命令を実行します。2つのコアを交互に実行するプロセスは次のとおりです。

1.コア1は、メモリ内の指定された場所から値1を読み取り、それをレジスターにロードします。

2.コア2は、メモリ内の指定された場所から値1を読み取り、それをレジスターにロードします。

3.コア1は、レジスターの値を1つインクリメントします。

4.コア2は、レジスターの値を1ずつ増分します。

5.コア1は変更された値をメモリに書き戻します

6.コア2が変更された値をメモリに書き戻します。

上記のプロセスの後、メモリの最終的な値は2になり、3が予想されますが、これは問題です。この問題に対処するには、2つ以上のコアが同じメモリ領域を同時に操作しないようにする必要があります。それを避ける方法は?これは、この記事の主人公-lockプレフィックスを紹介します。

LOCK-LOCK#信号プレフィックスの
アサート付随する命令の実行中にプロセッサのLOCK#信号をアサートします(命令をアトミック命令に変換します)。マルチプロセッサ環境では、LOCK#信号、信号がアサートされている間、プロセッサが共有メモリ排他的に使用できるようにします

マルチプロセッサ環境では、LOCK#信号により、プロセッサが一部の共有メモリを独占的に使用できるようになります。ロックは、ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、CMPXCHG16B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、XCHGの各命令の前に追加できます。

inc命令の前にロック接頭辞を追加することにより、命令をアトミックにすることができます。複数のコアが同じinc命令を同時に実行すると、それらはシリアル方式で進行し、上記の状況でいっぱいになります。

ロックプレフィックスは、コアが特定のメモリ領域を独占することをどのように保証しますか?

Intelプロセッサでは、プロセッサの特定のコアが特定のメモリ領域を独占することを保証する2つの方法があります。1つ目の方法は、特定のコアがバスをロックしてバスを独占的に使用できるようにすることですが、このコストは高すぎ、バスがロックされた後、他のコアはメモリにアクセスできなくなり、他のコアが短時間で動作を停止する可能性があります。キャッシュをロックする方法は、メモリデータがプロセッサキャッシュにキャッシュされている場合、プロセッサによって発行されるロック#信号はバスをロックせず、キャッシュに対応するメモリ領域をロックします。他のプロセッサはこのメモリ領域でロックされていますが、このメモリ領域で関連する操作を実行することはできません。バスのロックと比較して、キャッシュをロックするコストは大幅に小さくなります。

簡単に言えば、ロック機能:

  • アクセスするメモリ領域(メモリ領域)がロックプレフィックス命令の実行中にプロセッサの内部キャッシュにロックされている場合(つまり、メモリ領域を含むキャッシュラインが現在排他的または変更された状態)、およびメモリ領域完全に単一のキャッシュラインに含まれているため、プロセッサは直接命令を実行します。命令の実行中、キャッシュラインは常にロックされているため、他のプロセッサは、命令がアクセスするメモリ領域を読み書きできないため、命令の実行はアトミックに保証されます。

  • この命令と前および後続の読み取りおよび書き込み命令との順序変更を禁止する

  • 書き込みバッファ内のすべてのデータをメモリにフラッシュします

ソースコード分析

上記の背景知識があれば、CASのソースコードをゆっくりと読むことができます。分析には、java.util.concurrent.atomicのサブクラスAtomicIntegerでcompareAndSetメソッドを使用します。

説明:

mpxchg:「比較および交換」コマンド

dword:フルネームはダブルワードですx86 / x64システムでは、ワード= 2バイト、dword = 4バイト= 32ビット

ptr:フルネームはポインタで、前のdwordと組み合わせて使用​​されます。アクセスされるメモリユニットがダブルワードユニットであることを示します[edx]:[...]はメモリユニットを表し、edxはレジスタであり、destポインタ値はedxに格納されます。次に[edx]は、メモリアドレスがdestであるメモリユニットを意味します

AtomicInteger 

public class AtomicInteger extends Number implements java.io.Serializable {

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            // 计算变量 value 在类对象中的偏移
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
    
    public final boolean compareAndSet(int expect, int update) {
        /*
         * compareAndSet 实际上只是一个壳子,主要的逻辑封装在 Unsafe 的 
         * compareAndSwapInt 方法中
         */
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
  //该方法功能是Interger类型加1
		public final int getAndIncrement() {
		//主要看这个getAndAddInt方法
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

		//var1 是this指针
		//var2 是地址偏移量
		//var4 是自增的数值,是自增1还是自增N
		public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
	        //获取内存值,这是内存值已经是旧的,假设我们称作期望值E
            var5 = this.getIntVolatile(var1, var2);
            //compareAndSwapInt方法是重点,
            //var5是期望值,var5 + var4是要更新的值
            //这个操作就是调用CAS的JNI,每个线程将自己内存里的内存值M
            //与var5期望值E作比较,如果相同将内存值M更新为var5 + var4,否则做自旋操作
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    // ......
}

public final class Unsafe {
    // compareAndSwapInt 是 native 类型的方法,继续往下看
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);
    // ......
}

unsafe.cpp 

// unsafe.cpp
/*
 * 这个看起来好像不像一个函数,不过不用担心,不是重点。UNSAFE_ENTRY 和 UNSAFE_END 都是宏,
 * 在预编译期间会被替换成真正的代码。下面的 jboolean、jlong 和 jint 等是一些类型定义(typedef):
 * 
 * jni.h
 *     typedef unsigned char   jboolean;
 *     typedef unsigned short  jchar;
 *     typedef short           jshort;
 *     typedef float           jfloat;
 *     typedef double          jdouble;
 * 
 * jni_md.h
 *     typedef int jint;
 *     #ifdef _LP64 // 64-bit
 *     typedef long jlong;
 *     #else
 *     typedef long long jlong;
 *     #endif
 *     typedef signed char jbyte;
 */
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  // 根据偏移量,计算 value 的地址。这里的 offset 就是 AtomaicInteger 中的 valueOffset
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  // 调用 Atomic 中的函数 cmpxchg,该函数声明于 Atomic.hpp 中
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

// atomic.cpp
unsigned Atomic::cmpxchg(unsigned int exchange_value,
                         volatile unsigned int* dest, unsigned int compare_value) {
  assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
  /*
   * 根据操作系统类型调用不同平台下的重载函数,这个在预编译期间编译器会决定调用哪个平台下的重载
   * 函数。相关的预编译逻辑如下:
   * 
   * atomic.inline.hpp:
   *    #include "runtime/atomic.hpp"
   *    
   *    // Linux
   *    #ifdef TARGET_OS_ARCH_linux_x86
   *    # include "atomic_linux_x86.inline.hpp"
   *    #endif
   *   
   *    // 省略部分代码
   *    
   *    // Windows
   *    #ifdef TARGET_OS_ARCH_windows_x86
   *    # include "atomic_windows_x86.inline.hpp"
   *    #endif
   *    
   *    // BSD
   *    #ifdef TARGET_OS_ARCH_bsd_x86
   *    # include "atomic_bsd_x86.inline.hpp"
   *    #endif
   * 
   * 接下来分析 atomic_windows_x86.inline.hpp 中的 cmpxchg 函数实现
   */
  return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
                                       (jint)compare_value);
}

上記の分析はよりよく見えますが、主要なプロセスは複雑ではありません。詳細なコードに煩わされていない場合でも、移動は比較的簡単です。次に、WinプラットフォームでのAtomic :: cmpxchg関数を分析します。

// atomic_windows_x86.inline.hpp
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
              
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // alternative for InterlockedCompareExchange
  // mp是“os::is_MP()”的返回结果,“os::is_MP()”是一个内联函数,用来判断当前系统是否为多处理器
  //如果当前系统是多处理器,该函数返回1。否则,返回0。
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    // LOCK_IF_MP(mp)会根据mp的值来决定是否为cmpxchg指令添加lock前缀。如果通过mp判断当前系统是多处理器(即mp值为1),则为cmpxchg指令添加lock前缀。否则,不加lock前缀。
    // 这是一种优化手段,认为单处理器的环境没有必要添加lock前缀,只有在多核情况下才会添加lock前缀,因为lock会导致性能下降。cmpxchg是汇编指令,作用是比较并交换操作数。
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

上記のコードは、LOCK_IF_MPプリコンパイル済み識別子とcmpxchg関数で構成されています。より明確に表示するために、cmpxchg関数のLOCK_IF_MPを実際のコンテンツに置き換えます。次のように:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // 判断是否是多核 CPU
  int mp = os::is_MP();
  __asm {
    // 将参数值放入寄存器中
    mov edx, dest    // 注意: dest 是指针类型,这里是把内存地址存入 edx 寄存器中
    mov ecx, exchange_value
    mov eax, compare_value
    
    // LOCK_IF_MP
    cmp mp, 0
    /*
     * 如果 mp = 0,表明是线程运行在单核 CPU 环境下。此时 je 会跳转到 L0 标记处,
     * 也就是越过 _emit 0xF0 指令,直接执行 cmpxchg 指令。也就是不在下面的 cmpxchg 指令
     * 前加 lock 前缀。
     */
    je L0
    /*
     * 0xF0 是 lock 前缀的机器码,这里没有使用 lock,而是直接使用了机器码的形式。至于这样做的
     * 原因可以参考知乎的一个回答:
     *     https://www.zhihu.com/question/50878124/answer/123099923
     */ 
    _emit 0xF0
L0:
    /*
     * 比较并交换。简单解释一下下面这条指令,熟悉汇编的朋友可以略过下面的解释:
     *   cmpxchg: 即“比较并交换”指令
     *   dword: 全称是 double word,在 x86/x64 体系中,一个 
     *          word = 2 byte,dword = 4 byte = 32 bit
     *   ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元
     *   [edx]: [...] 表示一个内存单元,edx 是寄存器,dest 指针值存放在 edx 中。
     *          那么 [edx] 表示内存地址为 dest 的内存单元
     *          
     * 这一条指令的意思就是,将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值
     * 进行对比,如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
     */
    cmpxchg dword ptr [edx], ecx
  }
}

 

この時点で、CASの実装プロセスは終了し、CASの実装はプロセッサのサポートと切り離せません。上記のコードが多すぎるため、コアコードは実際にはロックプレフィックス付きのcmpxchg命令、つまり、ロックcmpchg dword ptr [edx]、ecxです。

注:CASは操作の原子性のみを保証し、変数の可視性を保証しないため、変数はvolatileキーワードを追加する必要があります

 

どのような問題が解決されますか?

JDK1.5より前のJava言語では、同期されたキーワードを使用して同期を確保していたため、ロックが発生していましたが、ロックメカニズムには次の問題があります。

  • マルチスレッドの競合状況では、ロックの追加と解放により、コンテキストの切り替えとスケジューリングの遅延が増加し、パフォーマンスの問題が発生します

  • ロックを保持しているスレッドは、ロックを必要とする他のすべてのスレッドをハングさせます。

  • 優先度の高いスレッドが優先度の低いスレッドがロックを解放するのを待つと、優先度が逆になり、パフォーマンスリスクが発生します。

揮発性はスレッド間のデータの可視性を保証できる優れたメカニズムですが、揮発性は元の実行を保証できません。したがって、同期のために、ロックメカニズムに戻る必要があります。

排他ロックは悲観的ロックであり、同期は排他ロックです。これにより、現在のロックを必要とする他のすべてのスレッドがハングし、ロックを保持しているスレッドがロックを解放するのを待ちます。そしてもう1つのより効果的なロックは、楽観的ロックです。いわゆる楽観的ロックとは、競合が発生しないと想定して、毎回ロックを想定せずに操作を完了し、競合が原因で失敗した場合は、成功するまで再試行することです。

欠陥は何ですか?

1. ABAの問題(リンクされたリストはデータを失う)

CASは、値を操作するときに下限値が変更されたかどうかを確認し、変更がない場合は更新する必要がありますが、値が元々Aで、Bに変更され、次にAに変更された場合、CASを使用して確認するときその値は変更されていませんが、実際には変更されています。ABA問題の解決策は、バージョン番号を使用することです。変数の前にバージョン番号を追加し、変数が更新されるたびにバージョン番号を増分すると、A-B-Aは1A-2B-3Aになります

2.長いスピンは非常にCPUを集中的に使用します

スピンはcasの操作サイクルです。スレッドが特に不運な場合は、取得した値が他のスレッドによって変更されるたびに、成功するまでスピン比較を実行し続けます。このプロセスでは、CPUオーバーヘッドが非常に高くなります。大きいので避けてください。JVMがプロセッサによって提供される一時停止命令をサポートできる場合、効率はある程度向上します。一時停止命令には2つの機能があります。最初に、命令のパイプライン実行を遅らせる(de-pipeline)ため、CPUが過剰な実行リソースを消費しません。遅延時間は特定の実装バージョンによって異なり、一部のプロセッサでは遅延時間がゼロです。第2に、ループの終了時にメモリ順序違反によるCPUパイプラインのフラッシュ(CPUパイプラインのフラッシュ)を回避できるため、CPU実行効率が向上します。

3.シェア変数のアトミック操作のみが保証されます

シェア変数で操作を実行する場合、循環CASメソッドを使用してアトミック操作を保証できますが、複数のシェア変数で操作する場合、循環CASは操作のアトミック性を保証できません。Java 1.5以降、JDKはAtomicReferenceクラスを提供して、参照されるオブジェクト間の原子性を保証します。1つのオブジェクトに複数の変数を配置して、CAS操作を実行できます。

アプリケーションシナリオ

  • スピンロック

  • トークンバケットの現在のリミッター(EurekaのRateLimiter :: refillToken)。マルチスレッドの状況で、スレッドの充填トークンと消費トークンがブロックされないようにします。

public class RateLimiter {

    private final long rateToMsConversion;

    private final AtomicInteger consumedTokens = new AtomicInteger();
    private final AtomicLong lastRefillTime = new AtomicLong(0);

    @Deprecated
    public RateLimiter() {
        this(TimeUnit.SECONDS);
    }

    public RateLimiter(TimeUnit averageRateUnit) {
        switch (averageRateUnit) {
            case SECONDS:
                rateToMsConversion = 1000;
                break;
            case MINUTES:
                rateToMsConversion = 60 * 1000;
                break;
            default:
                throw new IllegalArgumentException("TimeUnit of " + averageRateUnit + " is not supported");
        }
    }

    //提供给外界获取 token 的方法
    public boolean acquire(int burstSize, long averageRate) {
        return acquire(burstSize, averageRate, System.currentTimeMillis());
    }

    public boolean acquire(int burstSize, long averageRate, long currentTimeMillis) {
        if (burstSize <= 0 || averageRate <= 0) { // Instead of throwing exception, we just let all the traffic go
            return true;
        }

        //添加token
        refillToken(burstSize, averageRate, currentTimeMillis);

        //消费token
        return consumeToken(burstSize);
    }

    private void refillToken(int burstSize, long averageRate, long currentTimeMillis) {
        long refillTime = lastRefillTime.get();
        long timeDelta = currentTimeMillis - refillTime;

        //根据频率计算需要增加多少 token
        long newTokens = timeDelta * averageRate / rateToMsConversion;
        if (newTokens > 0) {
            long newRefillTime = refillTime == 0
                    ? currentTimeMillis
                    : refillTime + newTokens * rateToMsConversion / averageRate;

            // CAS 保证有且仅有一个线程进入填充
            if (lastRefillTime.compareAndSet(refillTime, newRefillTime)) {
                while (true) {
                    int currentLevel = consumedTokens.get();
                    int adjustedLevel = Math.min(currentLevel, burstSize); // In case burstSize decreased
                    int newLevel = (int) Math.max(0, adjustedLevel - newTokens);
                    // while true 直到更新成功为止
                    if (consumedTokens.compareAndSet(currentLevel, newLevel)) {
                        return;
                    }
                }
            }
        }
    }

    private boolean consumeToken(int burstSize) {
        while (true) {
            int currentLevel = consumedTokens.get();
            if (currentLevel >= burstSize) {
                return false;
            }

            // while true 直到没有token 或者 获取到为止
            if (consumedTokens.compareAndSet(currentLevel, currentLevel + 1)) {
                return true;
            }
        }
    }

    public void reset() {
        consumedTokens.set(0);
        lastRefillTime.set(0);
    }
}

Java 8 incrementAndGet最適化

CASのこのメソッドはメソッドのロックに使用されないため、すべてのスレッドがインクリメント()メソッドに入ることができます。このメソッドに入るスレッドが多すぎると、問題が発生します。実行されるスレッドがあるたびに3番目のステップでは、iの値が常に変更されるため、スレッドは最初のステップに戻り、再び開始し続けます。

そして、これは問題を引き起こします:スレッドが密度が高すぎるため、あまりにも多くの人々がiの値を変更したいのですが、ほとんどの人はそれを失敗して、リソースを無駄にします。

最適化について簡単に説明します。これは、配列Cell []とbaseを内部的に維持し、値はCellで維持されます。競合が発生すると、JDKはアルゴリズムに従ってセルを選択し、値に値を実行します。操作、まだ競合がある場合は、別のセルで再試行し、最後にセル[]に値とベースを追加して、最終結果を取得します。

その中のコードはより複雑なので、いくつかのより重要な質問を選び、質問付きのソースコードを調べました。

  1. セル[]が初期化されたとき。

  2. 競争がない場合は、どこから見ても基地でのみ動作します。

  3. Cell []を初期化するためのルールは何ですか。

  4. Cell []の展開のタイミングは?

  5. セル[]を初期化し、セル[]を拡張する方法は、スレッドの安全性を保証します。

public void add(long x) {
        Cell[] cs; long b, v; int m; Cell c;
        if ((cs = cells) != null || !casBase(b = base, b + x)) {//第一行
            boolean uncontended = true;
            if (cs == null || (m = cs.length - 1) < 0 ||//第二行
                (c = cs[getProbe() & m]) == null ||//第三行
                !(uncontended = c.cas(v = c.value, v + x)))//第四行
                longAccumulate(x, null, uncontended);//第五行
        }
    }

これは比較的簡単です。compareAndSetメソッドを呼び出して、成功したかどうかを判断します。

  • 現在競争がない場合は、trueを返します。

  • 現在競争がある場合、スレッドはfalseを返します。

最初の行に戻ると、この判断の全体的な解釈です。セル[]が初期化されているか、競合がある場合、コードの2行目に入力されます。競合や初期化がなければ、コードの2行目には入りません。

これは2番目の質問に答えます:競争がない場合、基地でのみ機能し、ここから見ることができます

コードの2行目:||判定。前者はcsが[is NULL]かどうかを判定し、後者は(cs-1の長さ)が[0より大きい]かどうかを判定します。これらの判断の両方で、Cell []が初期化されているかどうかを判断する必要があります。初期化されていない場合は、5行目のコードに入ります。

コードの3行目:セルが初期化されている場合は、[getProbe()&m]アルゴリズムを使用して数値を取得し、cs [number]が[is NULL]かどうかを判断し、[is NULL]の場合はcs [number]をcに割り当てます。 ]、コードの5行目に入ります。getProbe()で何が行われるかを単純に調べる必要があります。

static final int getProbe() {
        return (int) THREAD_PROBE.get(Thread.currentThread());
    }

    private static final VarHandle THREAD_PROBE;

コードの4行目:CAS操作はcで実行され、成功したかどうかを確認し、戻り値は非競合に割り当てられます。現在競合がない場合は成功します。現在競合がある場合は失敗します。外側に1つあります!()、したがって、CASは失敗し、コードの5行目に入ります。これはすでにCell要素に対する操作であることに注意してください。

コードの5行目:このメソッドは内部的に非常に複雑です。まず、メソッド全体を見てみましょう。

3つのifがあります。1.セルが初期化されているかどうかを判別します。初期化されている場合は、これを入力します。

その中には6つのifがありますが、これはひどいですが、ここでは、上記の問題を解決することを目標としているため、それらに注意を払う必要はありません。

最初に見てみましょう:

最初の判断:アルゴリズムに従って、cs []の要素を取り出してcに割り当て、[is NULL]かどうかを判断します。[is NULL]の場合は、これを入力します。

if (cellsBusy == 0) {       // 如果cellsBusy==0,代表现在“不忙”,进入这个if
    Cell r = new Cell(x);   //创建一个Cell
    if (cellsBusy == 0 && casCellsBusy()) {//再次判断cellsBusy ==0,加锁,这样只有一个线程可以进入这个if
        //把创建出来Cell元素加入到Cell[]
        try {       
            Cell[] rs; int m, j;
            if ((rs = cells) != null &&
                (m = rs.length) > 0 &&
                rs[j = (m - 1) & h] == null) {
                rs[j] = r;
                break done;
            }
        } finally {
            cellsBusy = 0;//代表现在“不忙”
        }
        continue;           // Slot is now non-empty
    }
}
collide = false;

これは最初の質問を補足します。Cell[]を初期化すると、要素の1つがNULLになります。ここでは、NULLである要素が初期化されます。つまり、この要素が使用される場合のみ、初期化されます。

6番目の判断:cellsBusyが0かどうかを判断してロックし、成功した場合は、Cell []の容量を拡張する場合にこれを入力します。

try {
     	if (cells == cs)        // Expand table unless stale
             cells = Arrays.copyOf(cs, n << 1);
         } finally {
                        cellsBusy = 0;
             }
         collide = false;
         continue; 

これは5番目の質問の半分に答えます。Cell[]を拡張する場合、CASを使用してロックを追加するため、スレッドの安全性が保証されます。

4番目の質問はどうですか?まず、最外部がfor(;;)デッドループであり、それが壊れたときにのみループが終了することに注意する必要があります。

最初は、衝突はfalseです。3番目のifの場合、セルはCAS操作されます。成功した場合、セルは壊れます。したがって、失敗したと見なして、4番目のifに入る必要があります。4番目のifは、セル[ ]の長さがCPUコアの数よりも大きいかどうか、コアの数よりも小さい場合は、5番目の判断に入り、今回はコリジョンがfalseであり、ifが入力され、コリジョンがtrueに変更されます。新しいTHREAD_PROBE、再びループ。3番目の場合でもCASが失敗する場合は、セル[]の長さがコアの数よりも大きいかどうかを再度判断します。それがコアの数よりも小さい場合、5番目の判断に入ります。このとき、衝突は真なので、5番目の場合は入りません。真ん中へ行き、容量を拡張するための6番目の判断に入りました。複雑ですか?

簡単に言えば、Cell []の拡張のタイミングは、Cell []の長さがCPUコアの数よりも少なく、Cell CASが2回失敗したときです。


2.最初の2つの判断は理解しやすく、主に3番目の判断に注目します。

 final boolean casCellsBusy() {
        return CELLSBUSY.compareAndSet(this, 0, 1);
    }

casはCELLSBUSYを1に設定します。ロックはすぐに初期化されるため、ロックを追加すると理解できます。

 try {                           // Initialize table
                    if (cells == cs) {
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        break done;
                    }
                } finally {
                    cellsBusy = 0;
                }

セル[]を初期化すると、アルゴリズムに従って長さが2であることがわかります。つまり、要素の1つを初期化します。つまり、セル[]の長さは2ですが、要素の1つはNULLのままで、要素の1つだけです。初期化後、cellsBusyは最終的に0に変更されました。これは、現在「ビジーではない」ことを意味します。

これが最初の質問の答えです。競合が発生し、セル[]が初期化されていない場合、セル[]が初期化されます。4番目の問題:初期化の規則は長さ2の配列を作成することですが、要素の1つだけが初期化され、他の要素はNULLになります。5番目の質問の半分:Cell []を初期化するときにCASを使用してロックを追加したため、スレッドの安全性を保証できます。

3.上記のすべてが失敗した場合は、ベースでCAS操作を実行します。

私と一緒にソースコードを見ると、これまでに見たことがないかもしれないというメモが見つかります。

このコメントは何をしますか?競合は、誤った共有を解決するために使用されます

まあ、それは知識の盲点につながります。それは、偽りの共有とは何ですか。

偽りの共有

CPUとメモリの関係はわかっています。CPUがデータを必要とする場合、CPUは最初にキャッシュを調べ、キャッシュにない場合はメモリにアクセスしてそれを見つけます。取り出してください。

しかし、このステートメントは完全ではありません。キャッシュ内のデータは、キャッシュラインの形式保存されます。これはどういう意味ですか?1つのキャッシュラインに複数のデータがある場合があります。キャッシュラインのサイズが64バイトの場合、CPUはメモリに移動してデータをフェッチし、隣接する64バイトのデータを取り出して、キャッシュにコピーします。

これはシングルスレッドの最適化です。CPUがデータを必要とする場合、隣接するすべてのBCDEデータがメモリから取り出されてキャッシュに入れられると想像してください。CPUが再びBCDEデータを必要とする場合は、直接キャッシュに移動して取得できます。

ただし、同じスレッドのデータを一度に読み取ることができるのは1つのスレッドだけであり、これを擬似共有といいます。

この問題を解決する方法はありますか?賢い開発者が考えた方法:キャッシュラインのサイズが64バイトの場合、最大64バイトになるようにいくつかの冗長フィールドを追加できます。

たとえば、long型のフィールドが1つだけ必要です。ここで、パディングとしてlong型の6つのフィールドを追加します。1つのlongは8バイトを占有します。これで、long型の7つのフィールド(56バイト)になります。さらに、オブジェクトヘッダー1バイトのキャッシュラインに十分な8バイト、正確には64バイトが必要です。

しかし、このメソッドは十分にエレガントではないため、@ jdk.internal.vm.annotation.Contendedアノテーションが誤った共有の問題を解決するためにJava8に導入されました。ただし、開発者がこのアノテーションを使用する場合は、JVMパラメータを追加する必要があります。自分でテストしていないため、ここでは特定のパラメータについては触れません。

 

参照ドキュメント:

 

https://www.cnblogs.com/nullllun/p/9039049.html

https://blog.csdn.net/v123411739/article/details/79561458?depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-3&utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-3

https://juejin.im/post/5a73cbbff265da4e807783f5

https://juejin.im/post/5a75db20f265da4e826320a9

https://juejin.im/post/5cd4e7996fb9a0323e3ad6ff

https://juejin.im/post/5c7a86d2f265da2d8e7101a1

 

元の記事を10件公開 6 件を獲得 1435件を訪問

おすすめ

転載: blog.csdn.net/yueyazhishang/article/details/105621191