深入理解synchronized实现原理

在多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这种资源可能是对象、变量、文件等。由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问。

synchronized可以保证变量的原子性,可见性和顺序性,所以可以保证方法或者代码块在运行时只有一个方法可以进入临界区获取资源,同时还可以保证内存变量的内存可见性。并且synchronized是一个可重入锁。

synchronized特性
  • 原子性
  • 可见性
  • 有序性
  • 可重入性
如何使用

synchronized既可以同步方法(同步实例方法、类方法),也可以同步代码块。

//同步实例方法:
public synchronized void methodA(){
	System.out.println("同步方法");
} 

//同步类方法:
public static synchronized void methodA(){
	System.out.println("同步类方法");
} 

//同步代码块:
public void methodB(){
	Integer a = 10;
    synchronized (a) {
    	System.out.println("同步代码块");
    }
}

可先简单理解:synchronized同步实例方法时,锁是当前实例对象;同步类方法时,锁是当前类对象;同步代码块时,锁是括号里的对象,synchronized要求锁定的资源是Object类型的,而Object类是所有类的父类,意思很明显了吧~

通过代码我们也可以看出来,synchronized锁是隐式锁,隐式锁简单说就是不需要我们自己写代码去加锁和解锁。与隐式锁对应的就是显示锁,比如Lock锁,需要通过 lock() 方法上锁,通过 unlock() 方法进行解锁。

同步代码块同步原理

当使用synchronized同步代码块时,让我们通过反编译,看一下它是如何实现的。
使用IDEA中External Tools反编译的结果如下图:

在这里插入图片描述

monitor监视器:

每个对象都有一个监视器,在同步代码块中,JVM通过monitorenter和monitorexist指令实现同步锁的获取和释放功能。
当一个线程获取同步锁时,即是通过获取monitor监视器进而等价为获取到锁。
monitor的实现类似于操作系统中的管程。

monitorenter指令:

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

每个对象都有一个监视器。当该监视器被占用时即是锁定状态(或者说获取监视器即是获得同步锁)。线程执行monitorenter指令时会尝试获取监视器的所有权,过程如下:
若该监视器的进入次数为0,则该线程进入监视器并将进入次数设置为1,此时该线程即为该监视器的所有者。
若线程已经占有该监视器并重入,则进入次数+1。
若其他线程已经占有该监视器,则线程会被阻塞直到监视器的进入次数为0,之后线程间会竞争获取该监视器的所有权。
只有首先获得锁的线程才能允许继续获取多个锁。

monitorexit指令:

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
执行monitorexit指令将遵循以下步骤:
执行monitorexit指令的线程必须是对象实例所对应的监视器的所有者。
指令执行时,线程会先将进入次数-1,若-1之后进入次数变成0,则线程退出监视器(即释放锁)
其他阻塞在该监视器的线程可以重新竞争该监视器的所有权。

同步原理:

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性会在之后的博客中继续总结),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

同步方法同步原理

当使用synchronized同步方法时,让我们通过反编译以下代码,看一下它是如何实现的。
新建一个文本文档,改.txt后缀为.java,写如下简单代码。

public class SynchronizedMethod {
    public int i;

    public synchronized void syncTask() {
        i++;
		System.out.println(i);
    }
}

cmd命令窗口,先定位到.java文件的路径下,使用如下命令进行编译:

javac SynchronizedMethod.java

然后使用如下命令进行反编译:

javap -v SynchronizedMethod.class

反编译的主要部分截图如下:
在这里插入图片描述

同步原理:

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的是ACC_SYNCHRONIZED标识,该标识指明该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

不得不说的对象头和monitor

先来认识一下对象的内存结构:在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

  • 对象头:它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 和 Class Metadata Address组成,其中Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。Class Metadata Address是类型指针,指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 对其填充:不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。

在这里插入图片描述

Mark Word在32位JVM中存储内容如下图所示:
在这里插入图片描述

接下来再看monitor,主要分析锁状态是重量级锁的时候。指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。来一张大佬的精彩图示:
在这里插入图片描述

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。

JVM对synchronized的优化

Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。

Java6以后,对synchronized进行了较大优化,所以现在synchronized锁效率也优化得很不错了。引入偏向锁和轻量级锁的目的都很像,都是为了在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量,而偏向锁在无竞争的情况下会把整个同步都消除掉。

锁的状态总共有四种,依次是:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

偏向锁:

经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了减少不必要的CAS操作,引入了偏向锁。这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。

偏向锁获取步骤如下:

  1. 获取对象的Mark Word;
  2. 判断Mark Word是否为可偏向状态,即Mark Word的偏向锁标志位为 1,锁标志位为 01;
  3. 判断Mark Word中线程ID的状态:如果为空,则进入步骤(4);如果指向当前线程,则执行同步代码块;如果指向其它线程,进入步骤(5);
  4. 通过CAS原子指令设置Mark Word中线程ID为当前线程ID,如果执行CAS成功,则执行同步代码块,否则进入步骤(5);
  5. 如果执行CAS失败,表示当前存在多个线程竞争锁,当达到全局安全点(safepoint),获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级锁,升级完成后被阻塞在安全点的线程继续执行同步代码块;

偏向锁撤销步骤如下:

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁 。

  1. 偏向锁的撤销动作必须等待全局安全点(safepoint);
  2. 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态;
  3. 撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为 00)的状态;

下图展示偏向锁的获取和撤销过程:
在这里插入图片描述

关闭偏向锁:

偏向锁在Java6及更高版本中是默认启用的,但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁, 那么程序会直接进入轻量级锁状态。

轻量级锁:

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁加锁过程:

  1. 判断当前对象是否处于无锁状态,若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象头中的Mark Word中的线程ID修改为指向自己的锁记录的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word中的线程ID是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

轻量级锁解锁过程:

轻量级解锁时,会使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生,释放锁成功。如果失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时唤醒被挂起的线程。

下图展示两个线程同时竞争锁,导致锁膨胀:
在这里插入图片描述

照图描述一下轻量级锁加锁解锁过程, 有线程1和线程2来竞争对象c的锁(如: synchronized©{} ), 这时线程1和线程2同时将对象c的MarkWord复制到自己的锁记录中, 两者竞争去获取锁, 假设线程1成功获取锁, 并将对象c的对象头中的线程ID(MarkWord中)修改为指向自己的锁记录的指针, 这时线程2仍旧通过CAS去获取对象c的锁, 因为对象c的MarkWord中的内容已经被线程1改了, 所以获取失败.。此时为了提高获取锁的效率,线程2会循环去获取锁,这个循环是有次数限制的,如果在循环结束之前CAS操作成功,那么线程2就获取到锁,如果循环结束依然获取不到锁,则获取锁失败,对象c的MarkWord中的记录会被修改为重量级锁,然后线程2就会被挂起,之后有线程3来获取锁时,看到对象c的MarkWord中的是重量级锁的指针,说明竞争激烈,直接挂起。解锁时,线程1尝试使用CAS将对象c的MarkWord改回自己栈中复制的那个MarkWord,因为对象c中的MarkWord已经被指向为重量级锁了,所以CAS失败,线程1会释放锁并唤起等待的线程,进行新一轮的竞争。

自旋锁与自适应自旋锁:

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过–XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以通过修改–XX:PreBlockSpin来更改。

自适应自旋锁:

在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

重量级锁:

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗CPU。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

锁消除:

锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

锁粗化:

原则上,我们再编写代码的时候,总是推荐将同步快的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗,所以引入锁粗话的概念。即:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

for(int i=0; i<1000; i++){
    synchronized(this){
        ...
    }
}

上面的代码将会优化为:

synchronized(this){
    for(int i=0; i<1000; i++){
        ...
    }
}
小结

ok,篇幅应该是已经很长了,本意是想写短博客的,结果越写越多,梳理总结之后,也清晰许多。如有错误,欢迎指正,谢谢~

参考博客:

https://blog.csdn.net/javazejian/article/details/72828483#synchronized底层语义原理

https://blog.csdn.net/u012998254/article/details/82558178

https://blog.csdn.net/weixin_34380781/article/details/87950449

https://www.jianshu.com/p/c5058b6fe8e5

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值