java基础之synchronized关键字

引言

在java中有两种加锁方式,一种使用同步关键字synchronized,另一种使用concurrent包下的Lock(比如我们所熟知的ReentrantLock)在我刚刚工作的时候很多前辈或者文章都说慎用同步关键字,太重了,很耗性能,与ReentrantLock相比性能没它好。但是好像是在jdk1.6之后(未验证过)特定对同步关键字的实现进行过优化,这两种锁性能已经不相上下了,甚至在某些情况下同步关键字反而更胜一筹。今天我们就窥探下同步关键字的原理。(提示:看这篇文章前需要对JVM有一定理解,需要了解栈帧、CAS等概念,且本文是针对hotSpot虚拟机的)

用法

synchronized的关键字可以作用在方法上也可以作用在部分代码上组成同步代码块。如果同步关键字作用在方法上则获取的对象锁为当前类对象,如果是同步代码块,则获取的锁对象为同步代码块括号中的对象。建议优先使用同步代码块,只有线程运行到同步代码块后线程才会阻塞,除非你确定整个方法都需要同步,否则应该尽量减小同步的粒度。伪代码如下:

public synchronized void test(){
    
}

public void test (){
    
    synchronized (object) {
        
    }
}

基本原理

synchronized经过编译后在关键字前后会多出monitorenter和monitorexit字节码指令,所以也有人说同步关键字在jvm层面叫做监视器锁。当一个线程获取锁执行时,jvm为当前线程维护一个计数器,线程进入就加1,退出执行monitorexit时就减1,所以当一个线程获取了锁要执行多个同步方法是可以的,这叫可重入性。当该计数器为零时就说明线程执行代码完成锁被释放,其他阻塞的线程被唤醒。当然,jdk1.6之前同步关键字未被优化前是这么干的,在后面的版本中jdk团队进行了一系列化的优化,提出了偏向锁、轻量级锁、自旋锁、和重量级锁。

Java对象头

锁信息存在java的对象头中,如果java对象是非数组则大小为2个字宽,在32位虚拟机中一个字宽=4bytes,如果是数组则为3个字宽。以32bit虚拟机为例:

长度 内容 说明
32bit Mark Word 存储对象的hashcode或者锁信息
32bit Class Metadata Address 存储到对象类型数据的指针
32bit 数组长度 如果对象为数组的话为数组长度,若不是则不存在这一列

锁信息是存放在对象头里的Mark Word中的,Mark Word结构如下:

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否允许偏向锁 锁标志位
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量的指针 10
GC标记 11
偏向锁 线程ID Epoch 对象分代年龄 1 01

偏向锁

在乐观情况下,锁不仅不会竞争而且经常是同一个线程多次获得锁,所以hotSpot虚拟机开发人员为这种情况进行优化,提出了偏向锁的概念,当在没有竞争的情况下,线程申请获取锁只需要通过CAS将线程ID设置进锁对象中,以后该线程申请获取锁只需要对比一下锁对象中的线程ID是否一致并且epoch和锁记录中的Klass的mark_prototype中的epoch是否一致。如果都一致就直接获取锁。也就是说偏向锁的开销只有一次CAS操作。

当线程申请获取锁时,当前线程会查询锁对象的锁标志位,看当前是偏向锁还是轻量级锁还是其他。如果判断是标志位为01,则当前处于偏向锁状态,然后接着会判断是否允许偏向锁
也就是判断上图的的是否允许偏向锁状态位,JDK1.6以后默认是允许偏向锁的。

偏向锁存在以下三种状态:

  • 匿名偏向(Anonymously biased)
    在此状态下是偏向锁最初始的情况,此时线程ID还是null,还没有偏向任何线程。只要有线程申请锁理论上是可以申请成功的。
  • 可重偏向(Rebiasable)
    可重偏向状态是在偏向锁撤销时发生,锁撤销下面会详细讲。如果偏向锁处于可重偏向状态则说明之前偏向的线程获取的锁已经失效,锁记录中的Epoch字段与锁对象的klass的mark_prototype的epoch字段不一致。以后其他线程想获取锁,通过CAS操作替换线程ID成功就行。
  • 已偏向(Biased)
    在这种情况下,锁对象存在线程ID(thread_ptr字段不为空),并且epoch值是有效的,总之就说明锁已经偏向锁对象中的线程ID的线程。

锁撤销必须拿出来讲一讲,锁撤销存在两种情况,一种是当前锁处于已偏向状态,并且偏向的线程正在执行同步方法,此时其他线程也申请锁,换句话说就是存在竞争了,此时会触发锁撤销,锁撤销完成后如果当前偏向线程还没退出同步代码块,那么就升级成轻量级锁。而另一种是其他线程申请锁,也就是通过CAS操作尝试把自己的线程ID设置到锁对象中,如果成功(要成功只能是原偏向的线程没有进入同步代码块)那么锁就重新偏向新的线程。如果CAS操作失败则启动偏向锁撤销操作,原持有偏向锁的线程到达全局安全点后JVM会检查当前线程状态,如果当前偏向线程已退出同步代码块,那么偏向锁撤销成功。如果撤销总数超过一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40)该参数超过阈值JVM会认为该线程不适合偏向
,不适合偏向JVM会将锁升级成轻量级锁。

如上所述并不是所有场景都适合偏向锁,比如基于生产者消费者模型的场景等竞争激烈的情况下,最好将偏向锁禁掉,JVM默认是启用的,通过使用以下命令禁止:

-XX:-UseBiasedLocking=false

轻量级锁

当真正出现竞争时,偏向锁会升级成轻量级锁。当线程申请轻量级锁时当前线程在自身栈帧中创建一片用于存放锁记录的空间,然后把锁对象头中的Mark Word内容复制到锁记录中(Displace Mark Word),同时通过CAS操作把锁对象头的Mark Word内容修改为指向当前线程栈帧的指针。如果修改成功那么当前线程获取到锁并开始执行同步代码块。如果获取锁失败那么当前线程先自旋一定次数,自旋的想法是JVM开发团队认为一有竞争就上mutex还是太浪费了,让这些获取锁失败的线程先等一段时间吧,而不是立马阻塞,毕竟开销太大,线程切换都是小的,还有用户态和内核态切换+系统调用这个大头呢。说不定当前运行的同步代码块粒度很小,锁立马释放了。这里自旋的次数只有初次自旋是用默认的自旋参数,之后将会通过自适应的方式来调整自旋次数,比如当前线程自旋时成功拿到了锁,下次自旋可能会多自旋几次。

轻量级锁通过自旋的方式尽可能避开使用重量级锁,但是这样也因为多个线程空转浪费了cpu资源,如果当前场景是计算密集型的任务,cpu就没有很好的利用好。

重量级锁

如果轻量级锁被其他线程拿到了,当前线程又自旋了规定次数,锁还没有被释放,此时就锁将膨胀成重量级锁,重量级锁是我们的老朋友了,在同步关键字没有优化前一直就是这个家伙。当锁膨胀成重量级锁,未获取锁的(那些自旋的线程)线程被挂起,直到当前线程释放锁。

锁膨胀图
参考:https://monkeysayhi.github.io/2018/01/02/%E6%B5%85%E8%B0%88%E5%81%8F%E5%90%91%E9%94%81%E3%80%81%E8%BD%BB%E9%87%8F%E7%BA%A7%E9%94%81%E3%80%81%E9%87%8D%E9%87%8F%E7%BA%A7%E9%94%81/

https://blog.csdn.net/lengxiao1993/article/details/81568130

猜你喜欢

转载自blog.csdn.net/nethackatschool/article/details/87624269