Java基础08—— 锁

107 阅读10分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

锁优化策略

自适应自旋锁锁粗化锁消除锁升级等策略。

自旋锁:使用自旋锁的线程会反复检查自旋变量是否可用,若自旋锁变量不可用不会让出CPU,会进入空等待状态直到锁被释放

大部分时候,锁被占用和共享变量被锁定的时间很短,所以没必要挂起线程,增加线程切换的开销。

自适应自旋锁自旋次数不由人为设定,由锁拥有者的状态和该锁上一次的自旋时间决定。

锁粗化,是逐渐扩大加锁范围避免反复加锁和解锁

锁消除,是基于逃逸分析的优化策略,编译器会去除不可能被其他线程访问到的锁。

逃逸分析

什么是逃逸分析?

逃逸分析是**跨函数全局数据流分析算法,可以减少程序同步负载和堆内存分配压力**。通过逃逸分析,Hotspot的编译器能够分析出一个对象的引用的使用范围,从而使用栈上分配锁消除(同步省略)、分离对象等优化策略.

基于逃逸分析的代码优化策略

image-20220322140922680

栈上分配的原理?

Java本身的限制(对象只能分配到堆中),为了减少临时对象在堆内分配的数量,在一个方法体内定义一个局部变量,并且该变量在方法执行过程中未发生逃逸,按照JVM调优机制,首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行,这是JVM优化前的方式。

采用逃逸分析对JVM进行优化。即针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。如此操作,是优化前在堆中,优化后在栈中,从而减少了堆中对象的分配和销毁压力,从而优化性能

锁的升级

JDK1.6引入了锁升级的策略。

锁升级简述

synchronized锁升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁(锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态)

偏向锁:为了减少获取锁的代价而引入,多数情况不存在锁竞争,常常一个线程多次获得同一个锁第一个线程获取的锁为偏向锁,若有第二个线程竞争锁,线程1存活且不释放锁,则偏向锁 升级为轻量级锁,锁标志位改变

轻量级锁:为了降低线程切换的成本而引入,若线程1获取了轻量级锁,会使用CAS操作(先将mark word在栈帧中存一个副本,再试图修改)将对象头Mark Word变成指向栈帧中所记录的指针并改变对象头中锁的标记位00,线程2的CAS操作失败,则会使用自旋锁进行空等待等待线程1释放锁,若自旋达到一定的次数,或线程3来竞争锁,则轻量级锁升级为重量级锁。

重量级锁:为了减少CPU空转的成本而引入,会把除了拥有锁的线程都阻塞,防止CPU空转

偏向锁获取过程

img   引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令

轻量级锁是为了提高多线程交替执行同步块时的性能(减小线程切换开销),而偏向锁则是提高只有一个线程执行同步块时的性能(减少获取锁的开销)。

偏向锁获取过程:

(1)确认为可偏向状态,访问Mark Word锁标志位是否为01偏向锁标识是否设置成1

(2)如果为可偏向状态,则比较偏向线程ID是否是当前线程ID,如果是,进入步骤(5),否则进入步骤(3)

(3)通过CAS操作将偏向线程ID修改为当前线程ID,Mark Word初始线程ID为0,成则(5),败则执行(4)

(4)如果CAS获取偏向锁失败,则表示有竞争,要撤销偏向锁升级为轻量级锁,当到达全局安全点时获得偏向锁的线程被挂起(主动中断),然后撤销偏向锁,执行获取轻量级锁的逻辑。

(5)执行同步代码

安全点,(非抢占式中断,主动中断),对于JVM的GC,线程暂停的这行字节码指令不会导致引用关系变化(方法调用、循环跳转、异常跳转等)。

安全区,业务线程都不执行,处于 Sleep 或是 Blocked 状态。

轻量级锁的加锁过程

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),将在当前线程的栈帧中建立一个锁记录(Lock Record)

(2)拷贝锁对象Mark Word的到锁记录中。

(3)使用CAS操作尝试将对象的Mark Word更新为指向栈帧中锁记录的指针,并将锁记录的owner指针指向Object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。

(4)更新成功则拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00” ,即表示此对象处于轻量级锁定状态

(5)更新操作失败,会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

公平锁和非公平锁

公平锁:多个线程按申请锁的顺序获取锁,线程会直接进入队列排队,永远是队首线程得到锁

  • 优点:所有的线程都能得到锁不会出现饿死的情况。
  • 缺点吞吐量会下降很多,除了队首线程,其他线程都会阻塞增加CPU唤醒线程的开销

非公平锁:多个线程获取锁时会直接尝试获取获取不到再进入队列排队。

  • 优点整体的吞吐效率会更高减少CPU唤醒线程的开销
  • 缺点:可能使得队列中间的线程长时间获取不到锁导致线程饿死

ReentrantLock使用参数true设置公平锁synchronized非公平锁

可重入锁

一个线程试图获取已经持有的锁时,那么会立刻获取成功,并且会将这个锁的计数器加1,当线程退出同步代码块时,计数器将会递减,当计数器等于0时释放锁。

如果不是可重入锁,线程在第二次企图获得锁时将会进入死锁状态。

ReentrantLocksynchronized都是可重入锁。

乐观锁与悲观锁

锁分为两类:

悲观锁:假设随时有可能有冲突操作数据时会上锁,会导致其它所有需要锁的线程挂起

优点能解决并发中的各种问题

缺点不支持并发操作效率低

乐观锁:每次不加锁而是假设没有冲突而去完成某项操作,如果存在冲突失败就重试,直到成功为止。

优点:支持并发操作,效率高

乐观锁的原理

乐观锁(CAS)是比较并交换读数据时不上锁,写数据时会判断数据在此期间是否被其他线程修改。如果修改,就写入失败;如果没有被修改,那就写操作成功。

CAS主要波及到三个值:当前内存值V、预期值(旧的内存值)O、将更新的内存值U,当且仅当预期值O与当前内存值V相等时,将内存值V批改为更新值U,返回true,否则返回false。

CAS的三个问题:ABA问题、自旋带来的耗费、CAS只能单变量

(1)ABA问题:线程初次读取变量V的值是A,并且在准备修改数据时检查到它仍然是A,如果在这段期间A被修改为B,又改回A,那CAS操作会误认为它从来没有被修改过

解决:可以使用带版本号时间戳的CAS,A值被更新后,版本号加1或者更新时间戳。如果更新时版本号或时间戳改变了,则可以知道数据被修改过。

(2)自旋带来的耗费:CAS操作若长时间失败而自旋,则会给CPU带来很大的开销。

解决:使用合适的自旋次数代替死循环。

(3) CAS只能单变量:CAS操作可以保障一个共享变量的原子操作,但有多个共享变量时,无法直接使用CAS操作保障原子性。

解决:JDK1.5开始,提供了AtomicReference类来保障引用对象的原子性,就能够把多个变量放在一个对象里来进行CAS操作。

读写锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问。

读锁:共享锁(则允许多个线程同时获取读锁,并发访问共享资源),可能发生死锁

写锁:独占锁(每次只能有一个线程能持有写锁),可能发生死锁

读写锁可以降级锁降级可以提高数据的可见性,具体为:获取写锁->获取读锁->释放写锁->释放读锁

死锁

什么是死锁 image-20210922224739260 两个或者两个以上进程执行时,因为争夺资源而造成一种互相等待的现象,如果没有外力干涉就无法再执行下去,称为死锁。

产生死锁原因

(1)系统资源不足

(2)进程运行的推进顺序不合适

(3)资源分配不当

产生死锁的情况

(1)锁套锁的情况,即两个及以上的synchronize同步块相互嵌套

(2)—个synchronize同步块同时持有多个对象的锁

(3)线程重复请求一个不可重入锁

(4)两个线程同时获取了读锁且不释放锁,又同时想写对方的数据

(5)ReentrantLock发生异常时不调用unlock就不会主动释放锁,可能造成死锁,因此unlock要放在finally块中synchronized 在异常时会自动释放线程占有的锁