Java锁分析 - Synchronized执行流程

本文详细分析了Java中的锁机制,包括乐观锁与悲观锁的区别,重点讲解了对象头Mark Word、偏向锁、轻量级锁、自旋锁和重量级锁的工作原理。文中还探讨了Java线程阻塞的代价,并介绍了锁优化策略,如减少锁的时间、减少锁的粒度、锁粗化、锁分离和锁消除,旨在提高并发性能。
摘要由CSDN通过智能技术生成

乐观锁 vs 悲观锁

乐观锁
 总是认为不会产生并发问题,因此并不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用 版本号机制或CAS操作实现。
悲观锁
总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁等),当其他线程想要访问数据时,都需要阻塞挂起。

 

Java线程阻塞的代价

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的接入,需要在用户态与内核态之间切换。这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间、寄存器等,用户态切换至内核态需要传递需要变量、参数给内核,内核也需要保护好用户态在切换时现场,包括一些寄存器的值、变量等,以便内核态调用结束后进行现场恢复。

 

对象头Mark Word

 HotSpot虚拟机中,对象在内存中存储的布局可以分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐补充(Padding)。其中对象头(Header)包括两部分:Mark Word和类型指针。

 

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、偏向线程ID等等,占用内存大小与虚拟机位长一致(32位或64位)。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。下图是32位虚拟机中Mark Word在各个状态下存储的内容:



由此可知锁的状态保存在对象头中,是否偏向锁和锁标志位可以确定对象唯一的锁状态。
 

偏向锁、轻量级锁、自旋锁、重量级锁

 

上图中显示Java对象头中涉及到的4种锁,分别是偏向锁、轻量级锁、自旋锁和重量级锁。其中重量级锁属于悲观锁,而偏向锁、轻量级锁、自旋锁属于乐观锁。

 

1. 偏向锁

顾名思义,偏向锁会偏向于第一个占有锁的线程。如果没有竞争,已经获得偏向锁的线程,在将来进入同步块时不会进行同步操作。如果在运行过程中,其他线程也请求相同的锁时,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,升级为轻量级锁。

 

偏向锁消除了资源无竞争情况下的同步原语,可以提高程序的运行性能。但如果在竞争激烈的场合,偏向锁反而会增加系统负担,降低程序的运行性能。

 

偏向锁的获取

  1. 检查Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态
  2. 如果为可偏向状态,则检查线程ID是否指向当前线程,如果是,则执行步骤5,否则执行步骤3
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程,然后执行步骤5;如果竞争失败,则执行步骤4
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时,获得偏向锁的线程被挂起(撤销偏向锁,会导致stop the world),偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码

偏向锁的释放

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。如果没有竞争,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(没有字节码正在执行),JVM会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(锁标志位为01)或轻量级锁(锁标志位为00)的状态。

 

偏向锁开启/关闭(默认启用)

  • 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

2. 轻量级锁

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

 

轻量级锁的加锁过程

  • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为01,是否偏向锁为0),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。这时候线程堆栈与对象头的状态如下图



 

  • 将对象头的Mark Word复制到Lock Record中
  • 复制成功后,JVM将使用CAS操作尝试将对象头的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向Mark Word。如果更新成功,则执行步骤4,否则执行步骤5
  • 如果更新成功,则表示这个线程拥有了该对象的锁,并且对象Mark Word的锁标志位设置为00,即表示对象处于轻量级锁状态。此时线程堆栈与对象头的状态如下图



 

  • 如果更新失败,JVM首先会检查锁对象的Mark Word是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行。否则说明多个线程竞争锁,当前线程尝试使用自旋来获取锁。如果自旋获取锁成功,则依然处于轻量级锁状态,否则轻量级锁就要膨胀为重量级锁,锁标志位设置为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

轻量级锁的释放 

  • 通过CAS操作将Lock Record中的Mark Word拷贝(Displaced Mark Word)替换锁对象的stack pointer(指向Lock Record的指针)
  • 如果操作成功,则同步完成
  • 如果失败,则说明已经有其他线程竞争当前对象锁。在当前线程持有锁时,如果其他线程竞争同一个锁,则竞争线程会修改对象头的Mark Word,将锁标志位设置为10,并更新为指向重量级锁的指针。当前线程(持有锁的线程)尝试自旋等待,如果自旋获取锁成功,则依然处于轻量级锁状态,否则轻量级锁就要膨胀为重量级锁,锁标志位设置为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

 

3. 自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

 

但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时线程会停止自旋进入阻塞状态。

 

JDK1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化。

  • 如果平均负载小于CPUs则一直自旋
  • 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节电模式则停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

JDK1.7后默认启用,无需额外进行设置。

 

4. 重量级锁Synchronized

 Synchronized关键字用于保证同步,它可以把任意一个非NULL的对象当作锁。

  • 作用于实例方法时,锁住的是对象的实例(this)
  • 当作用于静态方法时,锁住的是Class实例

 

 小结

 

 Synchronized的执行流程:

  • 检查Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态
  • 如果为可偏向状态,则检查线程ID是否指向当前线程,如果是则表示当前线程处于偏向锁状态,然后执行同步代码
  • 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程,偏向标志位设置为1,锁标志位设置为01,然后执行同步码块
  • 如果竞争失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁
  • 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  • 如果替换失败,表示其他线程竞争锁,当前线程尝试自旋获取锁
  • 如果自旋成功,则依然处于轻量级锁状态
  • 如果自旋失败,则轻量级锁膨胀为重量级锁(monitor),后面等待锁的线程也要进入阻塞状态

 锁优化

减少锁的时间

不需要同步的代码,尽量不要放在同步块中执行,以便减少锁的持有时间,尽快释放锁。

 

减少锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁的竞争。(空间换时间)

 

Java中很多数据结构都是采用这种方法提高并发操作的效率,比如ConcurrentHashMap中的segment数组(Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁。put操作时先确定往哪个Segment放数据,只需要锁定这个Segment,其它的Segment不会被锁定)、LinkedBlockingQueue(LinkedBlockingQueue在队列头入队,在队列尾出队,入队和出队使用不同的锁)等。

 

锁粗化

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度。

在以下场景下需要粗化锁的粒度: 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的。

 

锁分离

锁分离也是一种减小锁粒度的一种,这里强调对锁的功能进行分离,典型的就是读写锁ReentrantReadWriteLock,读操作加读锁,可以并发读;写操作使用写锁,不能并发写,而且写操作时无法获取读锁。读写锁可以在读多写少的系统中提高系统性能。

 

使用锁分离的数据结构有CopyOnWriteArrayList、 CopyOnWriteArraySet 等容器(即写时复制)、LinkedBlockingQueue等。

 

锁消除

在即时编译时,如果发现不可能被共享的对象加了锁(逃逸分析),则可以消除这些对象的锁操作。

 

无锁

锁是悲观操作,而无锁是乐观操作,在竞争不激烈的情况下,效率会比较高。无锁的一种实现方式是CAS(Compare And Swap)操作,默认不进行同步操作,在更新不成功的情况下重试。

 

参考

  1. 《深入JVM内核—原理、诊断与优化》系列课程 - 葛一鸣老师
  2. 【Java对象解析】不得不了解的对象头
  3.  java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值