同期の原理について正式に話す前に、スピン ロックについて話しましょう。スピン ロックは最適化において非常に重要な役割を果たすためです。同期の大きな効果。 Spin Lock を理解するには、まず atomicity とは何かを理解する必要があります。
いわゆるアトミック性簡単に言えば、各操作は完了しないか、すべて完了します。すべて完了するとは、変数などの操作中に中断できないことを意味しますデータ
インクリメント操作を実行するには、次の 3 つの手順があります。
data
をメモリからレジスタにロードします。
data
の値に 1 を加算します。
結果をメモリに書き込みます。
原子性とは、スレッドがインクリメント操作を実行するときに、他のスレッドがその操作を中断できないことを意味します。このスレッドがこれら 3 つのプロセスを完了した場合にのみ、他のスレッドがデータを操作できるようになります。 ## #データ###。 それでは、コードで体験してみましょう。Java では、
を使用して、整数データに対してアトミック操作を実行できます: <pre class="brush:java;">import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
public static void main(String[] args) throws InterruptedException {
AtomicInteger data = new AtomicInteger();
data.set(0); // 将数据初始化位0
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
data.addAndGet(1); // 对数据 data 进行原子加1操作
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
data.addAndGet(1);// 对数据 data 进行原子加1操作
}
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行完成
t1.join();
t2.join();
// 打印最终的结果
System.out.println(data); // 200000
}
}</pre>
上記のコード分析から、次のことがわかります。つまり、一般的な整数変数の場合、2 つのスレッドが同時に動作すると、最終結果は 200,000 未満になります。
次に、一般的な整変数を使用して問題のプロセスをシミュレートしましょう。
メイン メモリの初期値
data は 0 に等しく、 は、 2 つのスレッド データ
初期値は 0 です。
次に、スレッド 1 が
data に 1 を追加し、次にスレッド 1 が data
の値をメイン メモリに同期させます。メモリ全体 データの変更は次のようになります。
スレッド 2 の
data が 1 を追加し、data## の値を同期します。 # メイン メモリに戻ります (元のメモリを置き換えます。メイン メモリの値は上書きされます):
当初、data
の値が次のようになることを期待していました。上記の変更後は2## になります # ただし、スレッド 2 が値を上書きするため、マルチスレッドの状況では、最終的な結果は小さくなります。 しかし、上記のプログラムでは、最終的な出力結果は 20000 に等しくなります。これは、
data
に対する
の操作がアトミックであり、分割できないためです。 、他のスレッドは data
を操作できません。これが 原子性
によってもたらされる利点です。 実際、上記の 1 アトミック操作は
を通じて実装されています。AtomicInteger
のソース コードを見てみましょう。 public final int addAndGet(int delta) {
// 在 AtomicInteger 内部有一个整型数据 value 用于存储具体的数值的
// 这个 valueOffset 表示这个数据 value 在对象 this (也就是 AtomicInteger一个具体的对象)
// 当中的内存偏移地址
// delta 就是我们需要往 value 上加的值 在这里我们加上的是 1
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
上記のコードは、最終的には UnSafe
クラスのメソッドを呼び出すことによって実装されます。そのソース コードをもう一度見てみましょう:
public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); // 从对象 o 偏移地址为 offset 的位置取出数据 value ,也就是前面提到的存储整型数据的变量 } while (!compareAndSwapInt(o, offset, v, v + delta)); return v; }
上記のコードは、メモリ内のオフセット アドレス offset
のデータを継続的に取得し、ステートメント
## を実行します。 ## 主な機能は、オブジェクト o
のメモリ オフセット アドレス offset
を持つデータが
と等しいかどうかを比較することです。 v、オフセットは次のようになります。アドレス
offset のデータは
v delta に設定されます。このステートメントが正常に実行されると、
true が返されます。それ以外の場合は、
false が返されます。これが私たちが通常行うことです。Java の
CAS について話します。
これを見ると、上記のステートメントの実行が失敗した場合、while ループ操作は操作が成功するまで続行されることがわかります。操作が成功するまで while ループは終了しません。操作は成功しません。「スピン」は継続します。「ここで、このような操作は
spin であり、この
spin メソッドによって形成されるロックは spin lock と呼ばれます
オブジェクトのメモリ レイアウトJVM には、Java オブジェクト用に 3 つの主要なメモリがあります:
オブジェクト ヘッダー。これには 2 つのものが含まれます。 data の部分、それぞれKclass pointer
)。インスタンス データは、クラス内で定義するさまざまなデータです。
アライメント パディングでは、JVM では、実装中に各オブジェクトが占有するメモリ サイズが 8 バイトの整数倍である必要があります。オブジェクトのデータが占有するメモリ サイズがそうでない場合、 8 バイトの整数倍の場合は 8 バイトまで埋める必要があり、例えば対象局が 60 バイトの場合、最終的には 64 バイトまで埋められます。我们在使用synchronized时,如果我们是将synchronized用在同步代码块,我们需要一个锁对象。对于这个锁对象来说一开始还没有线程执行到同步代码块时,这个4个字节的内容如上图所示,其中有25个比特用来存储哈希值,4个比特用来存储垃圾回收的分代年龄(如果不了解可以跳过),剩下三个比特其中第一个用来表示当前的锁状态是否为偏向锁,最后的两个比特表示当前的锁是哪一种状态:
如果最后三个比特是:001,则说明锁状态是没有锁。
如果最后三个比特是:101,则说明锁状态是偏向锁。
如果最后两个比特是:00, 则说明锁状态是轻量级锁。
如果最后两个比特是:10, 则说明锁状态是重量级锁。
而synchronized锁升级的顺序是:无????->偏向????->轻量级????->重量级????。
在Java当中有一个JVM参数用于设置在JVM启动多少秒之后开启偏向锁(JDK6之后默认开启偏向锁,JVM默认启动4秒之后开启对象偏向锁,这个延迟时间叫做偏向延迟,你可以通过下面的参数进行控制):
//设置偏向延迟时间 只有经过这个时间只有对象锁才会有偏向锁这个状态 -XX:BiasedLockingStartupDelay=4 //禁止偏向锁 -XX:-UseBiasedLocking //开启偏向锁 -XX:+UseBiasedLocking
我们可以用代码验证一下在无锁状态下,MarkWord的内容是什么:
import org.openjdk.jol.info.ClassLayout; import java.util.concurrent.TimeUnit; public class MarkWord { public Object o = new Object(); public synchronized void demo() { synchronized (o) { System.out.println("synchronized代码块内"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); } } public static void main(String[] args) throws InterruptedException { System.out.println("等待4s前"); System.out.println(ClassLayout.parseInstance(new Object()).toPrintable()); TimeUnit.SECONDS.sleep(4); MarkWord markWord = new MarkWord(); System.out.println("等待4s后"); System.out.println(ClassLayout.parseInstance(new Object()).toPrintable()); Thread thread = new Thread(markWord::demo); thread.start(); thread.join(); System.out.println(ClassLayout.parseInstance(markWord.o).toPrintable()); } }
上面代码输出结果,下面的红框框住的表示是否是偏向锁和锁标志位(可能你会有疑问为什么是这个位置,不应该是最后3个比特位表示锁相关的状态吗,这个其实是数据表示的大小端问题,大家感兴趣可以去查一下,在这你只需知道红框三个比特就是用于表示是否为偏向锁和锁的标志位):
从上面的图当中我们可以分析得知在偏向延迟的时间之前,对象锁的状态还不会有偏向锁,因此对象头中的Markword当中锁状态是01,同时偏向锁状态是0,表示这个时候是无锁状态,但是在4秒之后偏向锁的状态已经变成1了,因此当前的锁状态是偏向锁,但是还没有线程占有他,这种状态也被称作匿名偏向,因为在上面的代码当中只有一个线程进入了synchronized同步代码块,因此可以使用偏向锁,因此在synchronized代码块当中打印的对象的锁状态也是偏向锁。
上面的代码当中使用到了jol包,你需要在你的pom文件当中引入对应的包:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
上图当中我们显示的结果是在64位机器下面显示的结果,在64位机器当中在Java对象头当中的MarkWord和Klcass Pointer内存布局如下:
其中MarkWord占8个字节,Kclass Pointer占4个字节。JVM在64位和32位机器上的MarkWord内容基本一致,64位机器上和32位机器上的MarkWord内容和表示意义是一样的,因此最后三位的意义你可以参考32位JVM的MarkWord。
假如你写的synchronized代码块没有多个线程执行,而只有一个线程执行的时候这种锁对程序性能的提高还是非常大的。他的具体做法是JVM会将对象头当中的第三个用于表示是否为偏向锁的比特位设置为1,同时会使用CAS操作将线程的ID记录到Mark Word当中,如果操作成功就相当于获得????了,那么下次这个线程想进入临界区就只需要比较一下线程ID是否相同了,而不需要进行CAS或者加锁这样花费比较大的操作了,只需要进行一个简单的比较即可,这种情况下加锁的开销非常小。
可能你会有一个疑问在无锁的状态下Mark Word存储的是哈希值,而在偏向锁的状态下存储的是线程的ID,那么之前存储的Hash Code不就没有了嘛!你可能会想没有就没有吧,再算一遍不就行了!事实上不是这样,如果我们计算过哈希值之后我们需要尽量保持哈希值不变(但是这个在Java当中并没有强制,因为在Java当中可以重写hashCode方法),因此在Java当中为了能够保持哈希值的不变性就会在第一次计算一致性哈希值(Mark Word里面存储的是一致性哈希值,并不是指重写的hashCode返回值,在Java当中可以通过 Object.hashCode()或者System.identityHashCode(Object)方法计算一致性哈希值)的时候就将计算出来的一致性哈希值存储到Mark Word当中,下一次再有一致性哈希值的请求的时候就将存储下来的一致性哈希值返回,这样就可以保证每次计算的一致性哈希值相同。但是在变成偏向锁的时候会使用线程ID覆盖哈希值,因此当一个对象计算过一致性哈希值之后,他就再也不能进行偏向锁状态,而且当一个对象正处于偏向锁状态的时候,收到了一致性哈希值的请求的时候,也就是调用上面提到的两个方法,偏向锁就会立马膨胀为重量级锁,然后将Mark Word 储在重量级锁里。
下面的代码就是验证当在偏向锁的状态调用System.identityHashCode
函数锁的状态就会升级为重量级锁:
import org.openjdk.jol.info.ClassLayout; import java.util.concurrent.TimeUnit; public class MarkWord { public Object o = new Object(); public synchronized void demo() { System.out.println("System.identityHashCode(o) 函数之前"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { System.identityHashCode(o); System.out.println("System.identityHashCode(o) 函数之后"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); } } public static void main(String[] args) throws InterruptedException { TimeUnit.SECONDS.sleep(5); MarkWord markWord = new MarkWord(); Thread thread = new Thread(markWord::demo); thread.start(); thread.join(); TimeUnit.SECONDS.sleep(2); System.out.println(ClassLayout.parseInstance(markWord.o).toPrintable()); } }
轻量级锁也是在JDK1.6加入的,当一个线程获取偏向锁的时候,有另外的线程加入锁的竞争时,这个时候就会从偏向锁升级为轻量级锁。
在轻量级锁的状态时,虚拟机首先会在当前线程的栈帧当中建立一个锁记录(Lock Record),用于存储对象MarkWord的拷贝,官方称这个为Displaced Mark Word。然后虚拟机会使用CAS操作尝试将对象的MarkWord指向栈中的Lock Record,如果操作成功说明这个线程获取到了锁,能够进入同步代码块执行,否则说明这个锁对象已经被其他线程占用了,线程就需要使用CAS不断的进行获取锁的操作,当然你可能会有疑问,难道就让线程一直死循环了吗?这对CPU的花费那不是太大了吗,确实是这样的因此在CAS满足一定条件的时候轻量级锁就会升级为重量级锁,具体过程在重量级锁章节中分析。
当线程需要从同步代码块出来的时候,线程同样的需要使用CAS将Displaced Mark Word替换回对象的MarkWord,如果替换成功,那么同步过程就完成了,如果替换失败就说明有其他线程尝试获取该锁,而且锁已经升级为重量级锁,此前竞争锁的线程已经被挂起,因此线程在释放锁的同时还需要将挂起的线程唤醒。
所谓重量级锁就是一种开销最大的锁机制,在这种情况下需要操作系统将没有进入同步代码块的线程挂起,JVM(Linux操作系统下)底层是使用pthread_mutex_lock
、pthread_mutex_unlock
、pthread_cond_wait
、pthread_cond_signal
和pthread_cond_broadcast
这几个库函数实现的,而这些函数依赖于futex
系统调用,因此在使用重量级锁的时候因为进行了系统调用,进程需要从用户态转为内核态将线程挂起,然后从内核态转为用户态,当解锁的时候又需要从用户态转为内核态将线程唤醒,这一来二去的花费就比较大了(和CAS自旋锁相比)。
在有两个以上的线程竞争同一个轻量级锁的情况下,轻量级锁不再有效(轻量级锁升级的一个条件),这个时候锁为膨胀成重量级锁,锁的标志状态变成10,MarkWord当中存储的就是指向重量级锁的指针,后面等待锁的线程就会被挂起。
因为这个时候MarkWord当中存储的已经是指向重量级锁的指针,因此在轻量级锁的情况下进入到同步代码块在出同步代码块的时候使用CAS将Displaced Mark Word替换回对象的MarkWord的时候就会替换失败,在前文已经提到,在失败的情况下,线程在释放锁的同时还需要将被挂起的线程唤醒。
以上がJava 同期ロックのアップグレードの原理とプロセスは何ですか?の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。