锁的机制与底层优化原理
😄生命不息,写作不止
🔥 继续踏上学习之路,学之分享笔记
👊 总有一天我也能像各位大佬一样
🏆 一个有梦有戏的人 @怒放吧德德
🌝分享学习心得,欢迎指正,大家一起学习成长!
文章目录
前言
最近经常研究一些关于线程并发的问题,再开发中也实实在在遇到过许多的并发问题,之前所学的是如何解决这些问题,然而接下来就得理解一下底层原理。
简单例子
首先用一个简单的例子来进行对锁的开篇认知。
如下代码,我们定义一个类,在这个类中提供了一个自增的方法。我们通过多线程的方式去执行自增,并且主线程也加入进行自增,最后输出这个值。这段代码都知道在自增的时候会出现并发问题,我们在通过加锁,控制对互斥资源的访问,最后就能得到期望的值。
public class Number {
int num = 0;
public int getNum() {
return num;
}
public void autoAccretion() {
synchronized(this) {
num++;
}
}
}
public class TestNum {
public static void main(String[] args) throws InterruptedException {
Number number = new Number();
long startTime = System.currentTimeMillis();
Thread thread = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
number.autoAccretion(); // 自增
}
});
thread.start();
// 主线程也执行
for (int i = 0; i < 10000000; i++) {
number.autoAccretion();
}
thread.join();
long endTime = System.currentTimeMillis();
System.out.println(String.format("%sms", endTime - startTime));
System.out.println(number.num);
}
}
我们通过执行会发现,这个值并不是我们期望所得到的,这是因为这里面出现了并发的问题。
当然,我们都知道要想解决这个并发问题,只需要在调用对象的方法上加上synchronized
就行。
锁的机制
通过以上代码,我们使用synchronized锁来控制对num++的执行,当多个线程进来的时候,只有一个锁能拿到synchronized这把锁。
1、jdk1.6之前
在jdk1.6以前,真正加锁的对象是synchronized内部的monitor对象[1];那么,如果拿不到synchronized锁的线程最后会是怎样的呢?他会放到一个队列中(即重量级锁),直到锁被释放后才能让下个线程拿到锁,这是jdk1.6以前的做法,如果一直不释放锁,那么就会导致这些等待线程一直处于等待,很明显这样会导致性能的问题。
在来说一下重量级锁,在底层可能出现线程阻塞,上下文切换,等待得到锁的线程将锁释放掉,通过操作系统对线程的调度,将阻塞态的线程唤醒。操作系统实现线程的切换还需要从用户态切换到核心态,成本非常高。
[1]在操作系统和并发编程领域中,Monitor(监视器)是一种同步机制,用于控制对共享资源的访问。它可以用于确保在任何时刻只有一个线程能够进入临界区(Critical Section)并执行相关操作,从而实现线程安全。
2、CAS机制
CAS(Compare and Swap)简单说就是比较并交换,它是一种并发编程中常用的原子操作,用于实现无锁的线程安全操作。它通常用于解决多个线程同时对同一个共享变量进行修改的竞争问题。我们通常将cas称为无锁、自旋锁、乐观锁以及轻量级锁。
CAS操作包含三个操作数:内存位置(或称为期望值),当前值和新值。CAS操作会比较内存位置的当前值与期望值是否相等,如果相等,则将内存位置的值更新为新值;如果不相等,则不进行任何操作。CAS操作是原子的,即在执行过程中不会被其他线程中断。它通过比较当前值和期望值来确定内存位置是否被修改,从而避免了传统的锁机制带来的竞争和阻塞。
CAS的大致流程如下:
①获取内存中的原始值,即备份数据。
②进行比较,将当前值与期望值进行比对看是否相等。
③如果相等,就将当前值覆盖旧的值,反之通过循环,重复操作,直到获得到对的值。
我们最常见的AtomicInteger类,他就是属于原子性操作的,它可以在并发环境下进行原子操作,确保对整数的操作是线程安全的。如下代码,我们可以通过这个类的自增方法来替换用synchronized锁包围的自增运算。
public void autoAccretion() {
// synchronized (this) {
// num++;
// }
atomicInteger.incrementAndGet();
}
我们可以看它的底层代码,通过unsafe类调用自增方法,实际底层原理也是进行比较交换的规则来保证原子性。会先获取内部原始的值,在将这个值自增1,在进行比较,如果当前值等于期望值,则自动将值设置为给定的更新值。但是,如果比较不相等,可能是在获取原始值之后做自增的时候,原始值已经被其他线程给操作成功覆盖了,则这个新的值是错误,需要刷新备份数据,再去循环尝试,直到得到对的数据才会去刷新旧值。大量的线程过来执行这个compareAndSet方法,如果在执行的时候没有被其他线程执行,那就能够将新的值将旧的值替换掉,就算说是失败了,能够通过循环继续执行,在多线程的执行能够确保数据的正确性,至于线程的先后执行也只是看运气。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
//unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // 最后的底层原理通过循环自备份,自增与比较
return var5;
}
我们可以看到内部代码是看不到的,它底层是通过c++编写的。Java中的CAS操作都是通过sun包下Unsafe类实现,而Unsafe类中的方法都是native方法,表示该方法的实现是由外部的本地代码,这里我下载了lookaside_java-1.8.0-openjdk的源码(可以从github上拉取),可以从host底层来看源码。
就AtomicInteger内的compareAndSwapInt方法,我们通过jdk1.8的 hotspot/src/share/vm/prims/unsafe.cpp
下的源码可以看到底层由c++编写。它使用原子比较和交换操作来比较和替换指定内存地址上的整数值,并返回比较结果。
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
// We are about to write to this entry so check to see if we need to copy it.
// 执行了一个写屏障操作(write barrier),用于保证在修改对象之前进行必要的处理。
// JNIHandles::resolve(obj)将obj从JNI句柄解析为Java对象,并使用oopDesc::bs()执行写屏障操作。
oop p = oopDesc::bs()->write_barrier(JNIHandles::resolve(obj));
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);// 计算出要进行原子比较和交换操作的内存地址
return (jint)(Atomic::cmpxchg(x, addr, e)) == e; // 核心代码 执行原子的比较和交换操作
UNSAFE_END
(jint)(Atomic::cmpxchg(x, addr, e)) == e;: 这一行代码是主要的核心代码,使用Atomic::cmpxchg函数执行原子的比较和交换操作。它尝试将addr指向的内存地址上的值与e进行比较,如果相等,则将其替换为x。最后,它将比较结果与e进行比较,如果相等,则返回true,否则返回false。
接下来在jdk1.8的源码:atomic_linux_x86.inline.hpp中看一下这个核心代码的底层逻辑,这段代码会先通过操作系统的内核方法判断是否为多处理器系统,在通过LOCK_IF_MP获取lock指令,实际上拿到的汇编指令lock与cmpxchgl来实现原子性。当拿到lock指令的时候就能给进行比较并交换,没有得到锁的情况需要等待锁被释放,这就达到了原子性问题。当缓存不是很大的情况是使用缓存行锁,但如果超过了缓存行大小,就会使用总线锁。
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP(); // 调用操作系统内核方法用于判断当前的系统是否为多处理器系统。
// LOCK_IF_MP获取lock指令,他是判断是否位多处理器
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)" // 汇编指令,前面已经定义#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
Synchronized底层的锁优化机制
1、锁的状态升级变迁
在jdk1.6以后Synchronized锁引用了许多状态切换:无状态、偏向锁、轻量级锁、重量级锁,根据不同的条件进行状态的切换升级,能够在一定的程度中使性能提升。
(1)、锁状态mark word结构
synchronized锁在线程第一次访问的时候,实际上是没有加锁的,只是在mark word中记录了线程ID,这种就是偏向锁,默认是认为不会有多个线程抢着用,mark word是通过64bit来表示的,通过最低2位也就是锁标志位,偏向锁与无锁的值是01,轻量级锁用00表示,重量级锁用10表示,标记了GC的用11表示,无锁与偏向锁低2位是一致的,在倒数第3位有1位来表示偏向锁位:值为1表示偏向锁。
(2)、锁升级流程
在Java中,synchronized锁的状态可以根据竞争情况进行升级和降级,结合上图,我们就可以清晰的了解synchronized底层锁的状态变化过程。
初始状态下,对象没有被任何线程锁定,此时是无状态锁;当有一个线程第一次进入synchronized代码块时,JVM会偏向该线程,将锁的对象头标记为偏向锁,此时还会记录这个线程ID,能够直接进入同步块,标记偏向线程id是为了等下次线程过来访问的时候,会进行线程id比较,如果相同,就能够获取这把锁;然而,当多个线程来争抢这把锁,这时候就会进行锁升级,会将偏向锁升级为轻量级锁,它会使用CAS操作来尝试将锁的对象头设置为指向锁记录(Lock Record)的指针,如果CAS成功,就能够获得这把锁,如果获得不到,会通过自旋;当轻量级锁竞争失败时,锁会升级为重量级锁。此时,JVM会使用操作系统的互斥量(Mutex)来实现锁的互斥操作。重量级锁涉及到用户态和内核态之间的切换,开销较大。
(3)、轻量级锁一定比重量级锁性能高吗?
当线程足够多的时候,如果使用轻量级锁,很多个线程会自旋,没有成功将会一直自旋,这样还会消耗cpu,此时还不如直接放在队列中使用重量级锁。总的来说如果竞争不激烈,轻量级锁可以提供更好的性能。而在高度竞争的情况下,重量级锁可能更适合,避免了自旋和不断重试的开销。在实际使用中,需要根据具体情况进行评估和测试,选择适当的锁机制。
2、synchronized锁升级状态变化原理
接下来我们从代码的形式来了解synchronized锁的升级状态变化。
因为使用了 java对象的内存布局以及使用ClassLayout查看布局,首先需要导入依赖, 0.13是显示二进制,0.17最新版本是显示十六进制。
<!-- java对象的内存布局以及使用ClassLayout查看布局 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
我们在main方法中,通过ClassLayout查看布局,这里User只是定义的一个对象实体,里面包含id和name属性。
User userTemp = new User();
/*java对象的内存布局以及使用ClassLayout查看布局*/
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
我们看一下这段代码的输出,可以对照锁的状态图来看,这些信息包含对象头mark,class,还有对象属性,最后4字节是对齐位,因为位数是8的整数倍。mark由8个字节,64bit组成,以下是十六进制,我们转换成二进制:0…001对比锁状态图来看是无锁状态。
无状态(001):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
先给出全部代码,后面针对代码进行解析,通过这段代码,就能够清晰看见锁是如何升级的。
/**
* @Author: lyd
* @Description: synchronized锁升级状态变化
* @Date: 2023/6/11
*/
public class LockUpgrade {
public static void main(String[] args) throws InterruptedException { // 主线程
User userTemp = new User();
/*java对象的内存布局以及使用ClassLayout查看布局*/
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
// jvm默认延迟4s自动开机偏向锁,可以通过-XX:BiasedLockingStartupDelay = 0 取消延迟
// 如果不需要偏向锁,使用-XX:- UseBiasedLocking = false 关闭
// -> 一开始是不会自动使用偏向锁的,如果一开始就使用synchronized锁,就会直接使用重量级锁,jvm中需要延迟4s才能够开启偏向锁。所以这里延迟了5s
Thread.sleep(5000);
// 偏向锁认为一开始只有一个线程来访问
User user = new User(); // 重新new一个对象
System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());
for (int i = 0; i < 2; i++) {
synchronized (user) { // 加上偏向锁
System.out.println("偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
// 虽然这里会释放偏向锁,但实际上不会主动释放,头部(高54位)是不会做修改的,这些数值代表属于哪个线程
// 在偏向锁中,高54位来标识线程id,主要是如果同个线程过来的,就直接走这个偏向锁
System.out.println("释放偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
new Thread(() -> { // 多个线程竞争,可能升级为轻量级锁
synchronized (user) {
System.out.println("轻量级锁(00)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
try {
System.out.println("睡眠3秒钟==========================================");
Thread.sleep(3000);
} catch (Exception e) {
throw new RuntimeException(e);
}
System.out.println("轻量级锁升级重量级锁(10):" + ClassLayout.parseInstance(user).toPrintable());
}
}).start();
Thread.sleep(1000);
// 开启新的线程, 第一个线程还在睡眠中,意味着线程还没有结束,此时第二个线程就执行了,这样就会导致多个线程的访问,这是就会升级位重量级锁
new Thread(() -> {
synchronized (user) {
System.out.println("重量级锁(10)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
}).start();
}
}
代码解析:
首先是无锁的状态,这个在前面已经介绍了,现在就不再继续赘述。我们要知道,jvm默认是延迟4s才自动开机偏向锁,我们可以通过-XX:BiasedLockingStartupDelay = 0
取消延迟。
一开始是不会自动使用偏向锁的,如果一开始就使用synchronized锁,就会直接使用重量级锁,jvm中需要延迟4s才能够开启偏向锁。所以这里延迟了5s。
重新new一个对象,此时我们可以看到下启用了偏向锁,注意,这里虽然还没有上锁,但是锁的使用是需要先开启锁的。
User user = new User(); // 重新new一个对象
System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());
看一下控制台的输出,可以看到头部mark信息,转换二进制就是000…0101,这可以看到偏向锁就已经启用了,但是我们观察到偏向线程id的54bit都是0,显然这时候还没有上锁,只是开启了偏向锁而已。
启用偏向锁(101):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
通过for循环,主线程两次访问user这把锁,此时启动的是偏向锁,这里是携带了线程id,在偏向锁中,高54位来标识线程id,主要是如果同个线程过来的,就直接走这个偏向锁。输出完后释放偏向锁,实际上不会主动释放,头部高54位并没有做修改。
for (int i = 0; i < 2; i++) {
synchronized (user) { // 加上偏向锁
System.out.println("偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
// 虽然这里会释放偏向锁,但实际上不会主动释放,头部(高54位)是不会做修改的,这些数值代表属于哪个线程
// 在偏向锁中,高54位来标识线程id,主要是如果同个线程过来的,就直接走这个偏向锁
System.out.println("释放偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
因为是主线程来两次访问,线程id都是相同的,根据头mark的二进制可以解出线程id:000…001100111111010100这是将0x00000000033f5005转换成二进制,取高54位的来。
偏向锁(101)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000033f5005 (biased: 0x000000000000cfd4; epoch: 0; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
释放偏向锁(101)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000033f5005 (biased: 0x000000000000cfd4; epoch: 0; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
偏向锁(101)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000033f5005 (biased: 0x000000000000cfd4; epoch: 0; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
释放偏向锁(101)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000000033f5005 (biased: 0x000000000000cfd4; epoch: 0; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
刚刚跑的是主线程,我们可以创建一个新的线程,使得多个线程竞争,这就可能将偏向锁升级为轻量级锁。当线程获取到轻量级锁,其他线程进来会拿不到,此时会自旋。这里我们睡眠3s,来模拟线程紧握锁不放,这时候我们在开一个新的线程,还是来争抢user这把锁,这就导致CAS自旋失败,锁膨胀就会将轻量级锁升级为重量级锁。
new Thread(() -> { // 多个线程竞争,可能升级为轻量级锁
synchronized (user) {
System.out.println("轻量级锁(00)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
try {
System.out.println("睡眠3秒钟==========================================");
Thread.sleep(3000);
} catch (Exception e) {
throw new RuntimeException(e);
}
System.out.println("轻量级锁升级重量级锁(10):" + ClassLayout.parseInstance(user).toPrintable());
}
}).start();
Thread.sleep(1000);
// 开启新的线程, 第一个线程还在睡眠中,意味着线程还没有结束,此时第二个线程就执行了,这样就会导致多个线程的访问,这是就会升级位重量级锁
new Thread(() -> {
synchronized (user) {
System.out.println("重量级锁(10)(带线程ID):" + ClassLayout.parseInstance(user).toPrintable());
}
}).start();
通过以下日志,我们可以将头mark转换成二进制,取最后两位锁标记位,能够清晰看到轻量级锁升级到重量级锁。
轻量级锁(00)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000002a11ef08 (thin lock: 0x000000002a11ef08)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
睡眠3秒钟==========================================
轻量级锁升级重量级锁(10):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000024232f6a (fat lock: 0x0000000024232f6a)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
重量级锁(10)(带线程ID):cn.lyd.test.service.user.dto.User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000024232f6a (fat lock: 0x0000000024232f6a)
8 4 (object header: class) 0xf800c143
12 4 int User.id 0
16 4 java.lang.String User.name null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total