java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > java synchronized锁升级

java中synchronized锁的升级过程

作者:NetWhite

这篇文章主要介绍了java中synchronized锁的升级过程,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教

synchronized锁的升级(偏向锁、轻量级锁及重量级锁)

java同步锁前置知识点

本文主要关注synchronized锁的升级。

synchronized同步锁

java对象头

每个java对象都有一个对象头,对象头由类型指针和标记字段组成。

在64位虚拟机中,未开启压缩指针,标记字段占64位,类型指针占64位,共计16个字节。

锁类型信息为标记字段的最后2位:00表示轻量级锁,01表示无锁或偏向锁,10表示重量级锁;如果倒数第3位为1表示这个类的偏向锁启用,为0表示类的偏向锁被禁用。

如下图,图片来源wiki

左侧一列表示偏向锁启用(方框1),右侧一列表示偏向锁禁用(方框3)。1和3都表示无锁的初始状态,如果启用偏向锁,锁升级的步骤应该是1->2->4->5,如果禁用偏向锁,锁升级步骤是3->4->5。

我用的jdk8,打印了参数看了下,默认是启用偏向锁,如要是禁用: -XX:-UseBiasedLocking

关于偏向锁还有另外几个参数:

注意BiasedLockingStartupDelay参数,默认值4000ms,表示虚拟机启动的延迟4s才会使用偏向锁(先使用轻量级锁)。

偏向锁

偏向锁处理的场景是大部分时间只有同一条线程在请求锁,没有多线程竞争锁的情况。看对象头图的红框2,有个thread ID字段:当第一次线程加锁的时候,jvm通过cas将当前线程地址设置到thread ID标记位,最后3位是101。下次同一线程再获取锁的时候只用检查最后3位是否为101,是否为当前线程,epoch是否和锁对象的类的epoch相等(wiki上说没有再次cas设置是为了针对现在多处理器上的cas操作的优化)。

偏向锁优化带来的性能提升指的是避免了获取锁进行系统调用导致的用户态和内核态的切换,因为都是同一条线程获取锁,没有必要每次获取锁的时候都要进行系统调用。

如果当前线程获取锁的时候(无锁状态下)线程ID与当前线程不匹配,会将偏向锁撤销,重新偏向当前线程,如果次数达到BiasedLockingBulkRebiasThreshold的值,默认20次,当前类的偏向锁失效,影响就是epoch的值变动,加锁类的epoch值加1,后续锁对象会重新copy类的epoch值到图中的epoch标记位。如果总撤销次数达到BiasedLockingBulkRevokeThreshold的值(默认40次),就禁用当前类的偏向锁了,就是对象头右侧列了,加锁直接从轻量锁开始了(锁升级了)。

偏向锁的撤销是个很麻烦的过程,需要所有线程达到安全点(发生STW),遍历所有线程的线程栈检查是否持有锁对象,避免丢锁,还有就是对epoch的处理。

如果存在多线程竞争,那偏向锁就要升级了,升级到轻量级锁。

轻量级锁

轻量级锁处理的场景是在同的时间段有不同的线程请求锁(线程交替执行)。即使同一时间段,存在多条线程竞争锁,获取到锁的线程持有锁的时间也特别短,很快就释放锁了。

线程加锁的时候,判断不是重量级锁,就会在当前线程栈内开辟一个空间,作为锁记录,将锁对象头的标记字段复制过来(复制过来是做一个记录,因为后面要把锁对象头的标记字段的值替换为刚才复制这个标记字段的空间地址,就像对象头那个图片中的pointer to lock record部分,至于最后2位,因为是内存对齐的缘故,所以是00)。然后基于CAS操作将复制这个标记字段的地址设置为锁对象头的标记位的值,如果成功就是获取到锁了。如果加锁的时候判断不是重量级锁,最后两位也不是01(从偏向锁或无锁状态过来的),那就说明已经有线程持有了,如果是当前线程在(需要重入),那就设置一个0,这里是个栈结构,直接压入一个0即可。最后释放锁的时候,出栈,最后一个元素记录的就是锁对象原来的标记字段的值,再通过CAS设置到锁对象头即可。

注意在获取锁的时候,cas失败,当前线程会自旋一会,达到一定次数,升级到重量级锁,当前线程也会阻塞。

重量级锁

重量级就是我们平常说的加的同步锁,也就是java基础的锁实现,获取锁与释放锁的时候都要进行系统调用,从而导致上下文切换。

关于自旋锁

关于自旋锁,我查阅相关资料,主要有两种说明:

1、是轻量级锁竞争失败,不会立即膨胀为重量级而是先自旋一定次数尝试获取锁;

2、是重量级锁竞争失败也不会立即阻塞,也是自旋一定次数(这里涉及到一个自调整算法)。

关于这个说明,还是要看jvm的源码实现才能确定哪个是真实的:

打印偏向锁的参数

如下:

-XX:+UnlockDiagnosticVMOptions

-XX:+PrintBiasedLockingStatistics

我在main方法循环获取同一把锁,打印结果如下:

    public static void main(String[] args) {
        int num = 0;
        for (int i = 0; i < 1_000_000000; i++) {
            synchronized (lock) {
                num++;
            }
        }
    }

synchronized原理解析

一:synchronized原理解析

1:对象头

首先,我们要知道对象在内存中的布局:

已知对象是存放在堆内存中的,对象大致可以分为三个部分,分别是对象头、实例变量和填充字节。

对象头示例

通过第一部分可以知道,Synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么Synchronized锁对象是存在哪里的呢?答案是存在锁对象的对象头的MarkWord中。那么MarkWord在对象头中到底长什么样,也就是它到底存储了什么呢?

在32位的虚拟机中:

在64位的虚拟机中:

上图中的偏向锁和轻量级锁都是在java6以后对锁机制进行优化时引进的,下文的锁升级部分会具体讲解,Synchronized关键字对应的是重量级锁,接下来对重量级锁在Hotspot JVM中的实现锁讲解。

2:Synchronized在JVM中的实现原理

重量级锁对应的锁标志位是10,存储了指向重量级监视器锁的指针,在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),其跟同步相关的数据结构如下:

ObjectMonitor() {
    _count        = 0; //用来记录该对象被线程获取锁的次数
    _waiters      = 0;
    _recursions   = 0; //锁的重入次数
    _owner        = NULL; //指向持有ObjectMonitor对象的线程 
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
  }

光看这些数据结构对监视器锁的工作机制还是一头雾水,那么我们首先看一下线程在获取锁的几个状态的转换:

线程的生命周期存在5个状态,start、running、waiting、blocking和dead

对于一个synchronized修饰的方法(代码块)来说:

那么Synchronized修饰的代码块/方法如何获取monitor对象的呢?

在JVM规范里可以看到,不管是方法同步还是代码块同步都是基于进入和退出monitor对象来实现,然而二者在具体实现上又存在很大的区别。通过javap对class字节码文件反编译可以得到反编译后的代码。

(1)Synchronized修饰代码块:

Synchronized代码块同步在需要同步的代码块开始的位置插入monitorentry指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorentry和monitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。

例如,同步代码块如下:

public class SyncCodeBlock {
   public int i;
   public void syncTask(){
       synchronized (this){
           i++;
       }
   }
}

对同步代码块编译后的class字节码文件反编译,结果如下(仅保留方法部分的反编译内容):

  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  //注意此处,进入同步方法
         4: aload_0
         5: dup
         6: getfield      #2             // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2            // Field i:I
        14: aload_1
        15: monitorexit   //注意此处,退出同步方法
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit //注意此处,退出同步方法
        22: aload_2
        23: athrow
        24: return
      Exception table:
      //省略其他字节码.......

可以看出同步方法块在进入代码块时插入了monitorentry语句,在退出代码块时插入了monitorexit语句,为了保证不论是正常执行完毕(第15行)还是异常跳出代码块(第21行)都能执行monitorexit语句,因此会出现两句monitorexit语句。

(2)Synchronized修饰方法:

Synchronized方法同步不再是通过插入monitorentry和monitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。

同步方法代码如下:

public class SyncMethod {
   public int i;
   public synchronized void syncTask(){
           i++;
   }
}

对同步方法编译后的class字节码反编译,结果如下(仅保留方法部分的反编译内容):

public synchronized void syncTask();
    descriptor: ()V
    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}

可以看出方法开始和结束的地方都没有出现monitorentry和monitorexit指令,但是出现的ACC_SYNCHRONIZED标志位。

三、锁的优化

1、锁升级

锁的4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)

(1)偏向锁:

为什么要引入偏向锁?

偏向锁的升级:

偏向锁的取消:

(2)轻量级锁

为什么要引入轻量级锁?

轻量级锁什么时候升级为重量级锁?

注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

(3)这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)

2、锁粗化

3、锁消除

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文