当前位置: 首页 > news >正文

synchronized加锁原理以及锁升级过程

由于synchronized中用到了CAS技术,此处先对CAS做个简单介绍。

1、CAS

CAS(compareAndSwap)是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。   

CAS(V,A,B)操作中至少包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。(CAS 的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)CAS 有效地说明了“ 我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可这其实和乐观锁的冲突检查+数据更新的原理是一样的。

CAS的底层原理是通过lock cmpxchg汇编指令实现的,cmpxchg指令是将内存中的数据放到CPU寄存器中进行修改,加上lock是为了防止多核CPU对同一块内存数据修改引起的不一致问题。在硬件层面,lock指令就是锁定一个北桥信号(北桥信号是主板上的一块芯片)

AtomicInteger类中就使用了CAS操作,如下所示:

public final boolean compareAndSet(int expect, int update) {return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

Unsafe类提供了三个CAS方法:compareAndSwapObject、compareAndSwapInt、compareAndSwapLong,这三个方法都是原子的,每个方法都有四个参数,第一个参数表示调用该方法的对象,第二个参数表示预期参数的偏移量,根据第一个和第二个参数可以找到预期参数的位置,即上面提到的位置V。第三个参数表示预期参数的原值,第四个参数表示预期参数需要被修改后的值。Unsafe类中提供的方法几乎都是native方法,由于Unsafe类中提供了一些直接对内存进行操作的方法,所以是不安全的,因此该类的名称是Unsafe。

public final native boolean compareAndSwapObject(Object var1,long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

CAS经常配合循环使用,由于CAS是线程安全的操作,因此被称为自旋锁。在Unsafe类中有个getAndAddInt方法,该方法中就是通过CAS与循环结合使用来形成自旋锁的。首先获取预期参数位置上的原值,然后通过CAS修改,如果修改成功,则结束循环,否则重新循环获取原值,继续判断并修改,直到成功为止。

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;
}

CAS有个ABA问题:假设原值是A,有个线程把A修改成了B,然后又从B修改成了A,其他线程看到的结果仍然是A,其他线程就会认为没有线程对A进行修改过。可以使用版本号解决ABA的问题(MySQL中乐观锁就是通过加上版本号字段实现的)。

AtomicInteger类中提供了incrementAndGet方法代替普通的i++操作(不是原子操作),因为该方法底层是调用了Unsafe类的CAS操作,是线程安全的。AtomicInteger类中的方法底层都是通过CAS实现的,因此AtomicInteger类是线程安全的类,是原子类。

public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

2、锁升级过程

synchronized给对象进行加锁,对象的锁信息是存储在对象头的markword中的。

在32位的虚拟机中,markword中存储的信息如下所示:

在64位的虚拟机中,markword中存储的信息如下所示:

偏向锁和轻量级锁(又称自旋锁)是jdk1.6对锁优化时引进的,偏向锁在jdk1.8默认是打开的。jdk1.6同时引入了自适应自旋的概念

synchronized给对象加锁修饰方法或代码块时,底层原理大致如下所示:

在java代码层级,是通过synchronized关键字给对象加锁实现的,在Bytecode字节码层级,是通过monitorenter和monitorexit指令实现的,在JVM(Hotspot)层级,通过InterpreterRuntime::monitorenter方法判断是否使用了UseBiasedLocking(偏向锁),jdk1.8默认打开偏向锁,所以默认使用偏向锁,如果存在线程竞争,调用ObjectSynchronizer::slow_enter方法使偏向锁升级为轻量级锁(又称自旋锁),如果竞争加剧,会调用ObjectSynchronizer::inflate方法,将轻量级锁膨胀为重量级锁。

锁的四种状态:无锁、偏向锁、轻量级锁、重量级锁。

Object o = new Object();   synchronized(o){//需要同步的代码}

1、无锁:一个对象刚刚被创建时,处于无锁的状态,锁的标志位是001。

2、偏向锁:顾名思义,对象锁会偏向第一个获取锁的线程,在对象头的markword中记录第一个获取锁的线程ID。如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给对象加一个偏向锁。因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。获取偏向锁的线程不会主动释放锁

3、轻量级锁(又称自旋锁):轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋着等待锁释放。自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能会有大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒操作的消耗(会导致线程在用户态和内核态之间上下文切换)。自旋的过程需要消耗CPU资源

4、重量级锁:如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费,这时就需要重量级锁。

重量级锁在锁对象的markword中对应的锁标志位是10,且markword中存储了指向重量级监视器锁的指针,在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),其与同步相关的数据结构如下:

ObjectMonitor() {_count        = 0; //用来记录该对象被线程获取锁的次数_waiters      = 0;_recursions   = 0; //锁的重入次数_owner        = NULL; //指向持有ObjectMonitor对象的线程 _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet_WaitSetLock  = 0 ;_EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}

线程在获取锁时状态的转换:

线程的生命周期存在六个状态:new、runnable、waiting、timed_waiting、blocked和terminated。重量级锁的实现过程大致如下:

(1)当多个线程同时访问某个被synchronized修饰的方法时,只有一个线程可以获取锁,其他线程会从用户态切换到内核态,并被放进_EntryList队列,此时线程处于blocked状态。

(2)当获取锁的线程释放锁时,操作系统从_EntryList队列中调度一个线程获取实例对象的监视器(monitor)锁,那么该线程就从内核态切换到用户态,进入runnable状态,执行同步代码,此时ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取。

(3)当runnable状态的线程调用wait()方法,那么当前线程释放monitor对象,从用户态切换到内核态,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify或notifyAll方法唤醒该线程,则该线程被放进_EntryList队列,此时线程处于blocked状态,等待操作系统调度。

(4)如果当前线程执行完毕同步代码,那么也释放monitor对象,ObjectMonitor对象的_owner变为null,_count减1

锁的升级过程:synchronized锁升级的过程与markword息息相关,markword中用最低的三位表示锁的状态,倒数第三位表示偏向锁的标识,最后两位表示锁的标志位。

(1)无锁->偏向锁:使用synchronized关键字给对象加锁,当第一个线程尝试获取对象锁执行同步方法或同步代码块时,通过CAS操作将当前线程ID保存到对象头的markword中,同时将锁标志位变成101,由无锁升级为偏向锁。当第一个线程想要再次获取对象锁执行同步代码时,首先比较markword中的线程ID与当前线程ID是不是一样,如果一样,直接执行同步代码,无需与其他线程竞争再使用CAS操作获取锁。

在无锁态升级为偏向锁时有两个细节:

第一个细节:JDK1.8中偏向锁默认是打开的,不过会有4s的延迟。因为JVM有一些默认启动的线程(如main、Attach Listener、Signal Dispatcher、Reference Handler、Finalizer),里面有很多同步代码,JVM在启动执行这些同步代码时就知道肯定会存在竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。因此,在一个对象刚刚被创建,并使用synchronized给对象加锁时,对象由无锁态(锁标志位001)升级为轻量级锁(锁标志位00)。

第二个细节:可使用-XX:BiasedLockingStartupDelay=0参数不让偏向锁延迟。如果设置了该参数,对象刚刚被创建时就是处于偏向锁状态(锁标志位101),此时markword中的线程ID(在64位操作系统占用了54bit)为0,即54个0,对象处于可偏向状态,没有具体偏向某个线程,此时偏向锁是匿名偏向锁(AnonymousBiasedLock)。第一个获取对象锁的线程会将自己的线程ID保存到markword中,偏向锁就会偏向该线程。

开启偏向锁:-XX:+UseBiasedLocking=true,关闭偏向锁:-XX:-UseBiasedLocking=false

(2)偏向锁->轻量级锁:一个线程获取了对象锁,将锁升级为偏向锁之后,第二个线程尝试获取锁执行同步代码。由于偏向锁不会主动释放,第二个线程发现偏向锁被其他线程占有了,它会比较锁对象的对象头的markword中的threadID是不是当前线程ID,如果是,直接执行同步代码。如果不是,需要判断markword中记录的threadID对应的线程是否还存活(实际是检查该线程的虚拟机栈中是否存在对锁对象的引用,如果没有引用,说明该线程不再持有锁对象),如果没有存活,第二个线程通过CAS操作将自己的线程ID记录到对象头的markword中,成功获取偏向锁。如果依然存活,当到达全局安全点(safepoint,这个时间点上没有正在执行的字节码)时暂停持有偏向锁的线程,撤销偏向锁,升级为轻量级锁(锁标志位00)。每个线程会在自己的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象的markword的拷贝,称之为Displaced Mark Word。每个线程使用CAS操作尝试将锁对象的markword更新为指向自己栈帧中Lock Record的指针,更新成功的线程就获得了对象锁,执行同步代码。

撤销偏向锁升级为轻量级锁时会导致stop the world操作,性能会下降。

(3)轻量级锁->重量级锁:竞争轻量级锁失败的线程会通过CAS自旋一直尝试获取锁,如果获取成功,则执行同步代码。在jdk1.6之前,如果有线程自旋次数(可以通过参数-XX:PreBlockSpin=10设置)超过10次,或者自旋线程数超过CPU核数的一半(在jdk1.6引入了自适应自旋,意味着线程自旋的次数不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,由JVM决定。),某个达到最大自旋次数的线程通过CAS操作将锁标志位修改成10,轻量级锁就会升级为重量级锁(锁标志位10),markword中记录指向重量级锁的指针。除了已经获取锁的那个线程外,其他尝试通过自旋获取锁的线程,以及后续想要获取锁的线程都会被阻塞,这些线程从用户态切换到内核态(用户线程工作在用户空间-ring3级,操作系统工作在内核空间-ring0级),并加入到阻塞队列(ObjectMonitor对象的_EntryList队列),释放CPU资源,不再自旋。当操作系统调度其中一个线程获取到锁对象,该线程从内核态切换到用户态,执行同步代码。

轻量级锁的解锁过程也是通过CAS操作来进行的。如果锁对象的Mark Word仍然保存指向线程栈中锁记录的指针,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明曾有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被阻塞的线程。

扩展:

每当使用java命令执行一个带main方法的类时,就会启动JVM(应用程序),实际上就是在操作系统中启动一个JVM进程,JVM启动时,必然会创建以下5个线程:

1-main:主线程,执行我们指定的启动类的main方法

2-Attach Listener:负责接收外部命令的线程,如:java -version、jmap、jps、jstack等。如果该线程在jvm启动的时候没有初始化,则会在用户第一次执行jvm命令时得到启动。

3-Signal Dispatcher:分发处理发送给JVM信号的线程。Attach Listener线程接收命令成功之后,交给Signal Dispatcher线程去进行分发到各个不同的模块处理命令,并且返回处理结果。Signal Dispatcher线程也是在第一次接收外部jvm命令时,进行初始化工作。

4-Reference Handler:处理引用的线程,主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题。

5-Finalizer:调用对象的finalize方法的线程,就是垃圾回收的线程。JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收。 


http://www.mrgr.cn/news/65084.html

相关文章:

  • 曼切斯特编码原理以及FPGA实现
  • ORACLE 删除archivelog日志
  • 解决缓存击穿的代码[最佳实践版]
  • Python酷库之旅-第三方库Pandas(192)
  • 开源 AI 智能名片 2 + 1 链动模式 S2B2C 商城小程序中积分使用价值的拓展策略
  • 数据结构与算法——Java实现 53.力扣938题——二叉搜索树的范围和
  • 2025上海市公务员考试报名流程详细教程
  • 数据结构之树
  • 简记Vue3(三)—— ref、props、生命周期、hooks
  • 如何基于pdf2image实现pdf批量转换为图片
  • Java毕业设计-基于SpringBoot+Vue的体育用品库存管理系统
  • 【英特尔IA-32架构软件开发者开发手册第3卷:系统编程指南】2001年版翻译,2-12
  • 【Android面试八股文】你能说说kotlin怎么取消CPU密集型任务吗?
  • CentOS 7 软件/程序安装示例
  • 每周算法比赛
  • c++模板入门
  • Golang--函数、包、defer、系统函数、内置函数
  • 线性代数:Matrix2x2和Matrix3x3
  • 数据结构-二叉树中的递归
  • DBeaver的sql查询结果突然不见了,怎么办?
  • 练习题 - Scrapy爬虫框架 Cookies 本地终端数据
  • 每一次放纵自己,意味着比以前更弱小(8)
  • 数据结构-链表【chapter1】【c语言版】
  • Unity Job System详解(3)——NativeList源码分析
  • Pandas进行数据查看与检查
  • 交换排序(冒泡/快排)