synchronized从字节码分析实现原理,对线面试官不怂

满怀忧思,不如先干再说!通过学习,重新定义自己!

本文章收录于《Java并发编程》合集,此文章默认你已经掌握 synchronized 的使用,如还不了解可以阅读合集中对synchronized 的讲解!

本文为原理性知识,涉及内容较深,较广,建议收藏反复耐心阅读,如有帮助还望点在支持一下!

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为同步锁,保证在同一时刻,被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。

synchronized三种应用

synchronized关键字最主要有以下3种应用方式,此内容已在《Java并发编程》合集中

  • 修饰普通方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

synchronized关键字三大特性是什么

synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized

  • 原子性:一个或多个操作全部执行成功或者全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。
  • 可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行lockunlock原子操作,保证可见性。
  • 有序性:程序的执行顺序会按照代码的先后顺序执行。

上述内容在 《Java并发编程》合集《volatile》关键字一文介绍

synchronized的实现基础

synchronized的底层实现是完全依赖JVM的,Java中可以将任意对象当做锁,所以谈synchronized的底层实现,就需要说一下对象在JVM内存的存储:

对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

而对象头是实现synchronized锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里的,JVM中采用2个字宽/字长来存储对象头【如果对象是数组则会分配3个字宽/字长,多出来的1个字宽/字长记录的是数组长度】,其主要结构是由Mark WordClass Metadata Address组成,其结构说明如下表:

虚拟机位数

头对象结构

说明

32/64bit

Mark Word

存储对象的hashCode、锁信息或分代年龄或GC标志等信息

32/64bit

Class Metadata Address

类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

重量级锁的底部实现原理:Monitor

Java是基于C++语言精简和优化得来的一门语言,在JVM源码中,synchronized的对象锁指针指向的是monitor对象【也称为管程或监视器锁】的起始地址,每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成。

当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机【HotSpot】中,monitor是由ObjectMonitor实现的,其主要数据结构如下【位于HotSpot虚拟机源码ObjectMonitor.hpp文件由C++实现】

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁的计数器,获取锁时count数值加1,释放锁时count值减1
    _waiters      = 0,  //等待线程数
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 阻塞在EntryList上的单向线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存ObjectWaiter对象列表【每个等待锁的线程都会被封装成ObjectWaiter对象】,_owner指向持有ObjectMonitor对象的线程

当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入_WaitSet集合中等待被唤醒。

若当前线程执行完毕也将释放monitor【锁】并复位变量的值,以便其他线程进入获取monitor【锁】

其中 _owner_WaitSet_EntryList 字段比较重要转换关系如下图:

从上图可以总结获取Monitor和释放Monitor的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
  2. 当获取锁的线程调用wait()方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。

synchronized底层实现语义

Java虚拟机支持方法级的同步方法内一段指令的同步,这两种同步结构都是使用管程【英文是Monitor,更常见的是直接将它称为监视器】实现。

同步代码块:即在方法中使用synchronized代码块是显示同步,在Java虚拟机的指令集中表示为monitorentermonitorexit两条指令支持,实现synchronized,通过javap命令反编字节码可以看到,后边会操作。

同步方法:方法级的同步是隐式的,即方法使用synchronized修饰,包括静态同步方法,无须通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标识得知一个方法是否被声明为同步方法。

当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成【无论是正常完成还是非正常完成】时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

synchronized作用于同步代码块的实现原理

通过javap命令对字节码文件进行反编译,查看JVM运行指令来解释实现原理

从上述字节码中可以看到同步代码块的实现是由monitorentermonitorexit指令完成的,其中monitorenter指令所在的位置是同步代码块开始的位置,第一个monitorexit指令是用于正常结束同步代码块的指令,第二个monitorexit指令是用于异常结束时所执行的释放Monitor指令。 

此时发现上锁和解锁指令为1:2,问:monitorenter指令和monitorexit指令一定是1:2吗?

如果你的同步代码块中抛出了异常,就只会有一个解锁指令,如下:

一般情况下同步代码块中,1个 monitorenter 上锁指令,对应 2个 monitorexit 解锁指令,当同步代码块中需要抛出异常,则只会有一个解锁指令,抛出异常程序其实就已经结束,就已经释放锁

synchronized作用于同步方法的实现原理

发现同步方法【包括静态同步方法】,反编译的指令中没有monitorentermonitorexit

此时我们可以通过 javap -v命令查看详细信息,来看到 ACC_SYNCHRONIZED访问标记:

javap -v SynchronizedTest.class

使用相同的命令查看同步代码块的方法,是没有 ACC_SYNCHRONIZED 标识的,自己动手试一下哦!

同步方法没有monitorentermonitorexit 这两个指令,而`ACC_SYNCHRONIZED `标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。

synchronized作用于静态同步方法的实现原理

与普通同步方法不同的是,静态同步方法多了 ACC_STATIC 静态标识,来区分是否为静态同步方法,根据是否为静态同步方法,来判断是否要获取类锁

面试中简洁地如何回答synchroized的底层原理

Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorentermonitorexit 指令实现的,而方法同步是通过Access flags【访问标识】后面的标识来确定该方法是否为同步方法。

调用指令将会检查方法的ACC SYNCHRONIZED访问标志是否被设置,如果设置,执行线程会将先持有monitor锁,然后再执行方法,最后在方法完成【无论是正常完成还是非正常完成】时释放monitor。

Jdk1.6为什么要对synchronized进行优化?

因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。

jDK1.6对synchronized做了哪些优化?

锁的升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁轻量级锁,锁的状态变成了四种,如下图所示。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级。这种只能升级不能降级的策略是为了提高获得锁和释放锁的效率。

升级方向:

锁解决了数据的安全性,但是同样带来了性能的下降,hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。

所以基于这样一个概率,synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。

1.无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

2.偏向锁

偏向锁是JDK6中引入的一项锁优化,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁会偏向于第一个获得它的线程,若在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步

3.轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能

4.重量级锁

原始的synchronized就是重量级锁,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

偏向锁

常见面试题:偏向锁的原理(或偏向锁的获取流程)、偏向锁的好处是什么(获取偏向锁的目的是什么)

引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。

偏向锁的获取流程:

  1. 检查对象头中Mark Word是否为可偏向状态,如果不是则直接升级为轻量级锁。
  2. 如果是,判断Mark Work中的线程ID是否指向当前线程,如果是,则执行同步代码块。
  3. 如果不是,则进行CAS操作竞争锁,如果竞争到锁,则将Mark Work中的线程ID设为当前线程ID,执行同步代码块。
  4. 如果竞争失败,升级为轻量级锁。

偏向锁的获取流程如下图:

偏向锁的撤销:

只有等到竞争,持有偏向锁的线程才会撤销偏向锁。偏向锁撤销后会恢复到无锁或者轻量级锁的状态。

  1. 偏向锁的撤销需要到达全局安全点,全局安全点表示一种状态,该状态下所有线程都处于暂停状态。
  2. 判断锁对象是否处于无锁状态,即获得偏向锁的线程如果已经退出了临界区,表示同步代码已经执行完了。重新竞争锁的线程会进行CAS操作替代原来线程的ThreadID。
  3. 如果获得偏向锁的线程还处于临界区之内,表示同步代码还未执行完,将获得偏向锁的线程升级为轻量级锁。

一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。

轻量级锁

引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。

轻量级锁的获取流程:首先判断当前对象是否处于一个无锁的状态,如果是,Java虚拟机将在当前线程的栈帧建立一个锁记录(Lock Record),用于存储对象目前的Mark Word的拷贝,如图所示。

将对象的Mark Word复制到栈帧中的Lock Record中,并将Lock Record中的owner指向当前对象,并使用CAS操作将对象的Mark Word更新为指向Lock Record的指针,如图所示。

如果第二步执行成功,表示该线程获得了这个对象的锁,将对象Mark Word中锁的标志位设置为“00”,执行同步代码块。

如果第二步未执行成功,需要先判断当前对象的Mark Word是否指向当前线程的栈帧,如果是,表示当前线程已经持有了当前对象的锁,这是一次重入,直接执行同步代码块。如果不是表示多个线程存在竞争,该线程通过自旋尝试获得锁,即重复步骤2,自旋超过一定次数,轻量级锁升级为重量级锁。

轻量级锁的解锁:

  轻量级的解锁同样是通过CAS操作进行的,线程会通过CAS操作将Lock Record中的Mark Word(官方称为Displaced Mark Word)替换回来。如果成功表示没有竞争发生,成功释放锁,恢复到无锁的状态;如果失败,表示当前锁存在竞争,升级为重量级锁。

  一句话总结轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并将对象的Mark Word更新为指向Lock Record的指针。

自旋锁

Java锁的几种状态并不包括自旋锁,当轻量级锁的竞争就是采用的自旋锁机制。

什么是自旋锁:当线程A已经获得锁时,线程B再来竞争锁,线程B不会直接被阻塞,而是在原地循环 等待,当线程A释放锁后,线程B可以马上获得锁。

引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。

自旋锁的缺点:自旋等待虽然可以避免线程切花的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,自旋锁的效果就比较好,如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数-XX:PreBlockSpin调整,一般默认为10。

自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。

synchronized关键字可以实现什么类型的锁?

  • 悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
  • 非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
  • 可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
  • 独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

下方的锁消除锁粗化,在《Java并发编程合集》中的《线程安全问题和解决方案》文末解释过,考虑有小伙伴没到那篇文章,也是面试高频问题点,这里再说一下

锁消除

  锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。

锁粗化

  一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。

  如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。可以看下面这个经典案例。

for(int i=0;i<n;i++){
    synchronized(lock){
    }
}

这段代码会导致频繁地加锁和解锁,锁粗化后

synchronized(lock){
    for(int i=0;i<n;i++){
    }
}

至此《Java并发编程》合集已更新8篇文章,涵盖:

  • Java线程创建方式和线程状态
  • 并发编程中线程安全问题和解决方案
  • volitale关键字
  • 线程通信,死锁
  • 终止线程方法,8锁现象以及本篇的synchronized原理

从使用到原理,可以说Java并发编程已经算是入门了,接下来继续介绍Callable接口线程池ThreadLocal 等Java并发编程技术栈。

长期稳定高频输出编程技术干货,只为心中曾经的教育梦,自我总结和帮助热爱技术,需要技术的同行者,一起加油吧!喜欢的朋友记得关注,点赞支持一下哦!

举报
评论 0