java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java synchronized关键字

深入剖析Java中的synchronized关键字

作者:半亩方塘立身

在 Java 程序中,我们可以利用 synchronized 关键字来对程序进行加锁,它既可以用来声明一个 synchronized 代码块,也可以直接标记静态方法或者实例方法,本文就带大家深入了解Java中的synchronized关键字,感兴趣的同学可以参考阅读

synchronized介绍

synchronized关键字可以解决的是多个线程之间访问资源的同步性。synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 程序中,我们可以利用 synchronized 关键字来对程序进行加锁。它既可以用来声明一个 synchronized 代码块,也可以直接标记静态方法或者实例方法。

关键字在代码块上

代码如下:

public void methodA() {
    Object obj = new Object();
    synchronized (obj) {
        //
    }
}

编译结果(javap -v)

public void methodA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
  stack=2, locals=4, args_size=1
     0: new           #3                  // class java/lang/Object
     3: dup
     4: invokespecial #1                  // Method java/lang/Object."<init>":()V
     7: astore_1
     8: aload_1
     9: dup
    10: astore_2
    11: monitorenter
    12: aload_2
    13: monitorexit
    14: goto          22
    17: astore_3
    18: aload_2
    19: monitorexit
    20: aload_3
    21: athrow
    22: return
  Exception table:
     from    to  target type
        12    14    17   any
        17    20    17   any

上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。 synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。

关键字在方法上

代码如下:

public synchronized void methodB() {  
    //  
    i++;  
}

当synchronized修饰同步方法时,编译器会在生成的字节码中添加一个额外的指令来获取和释放方法的监视器锁(monitor lock)。同时,编译器还会设置方法的ACC_SYNCHRONIZED标志。

public synchronized void methodB();
  descriptor: ()V
  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 15: 0
        line 16: 10

当JVM加载字节码文件并解析类的时候,会检查方法的访问标志。如果ACC_SYNCHRONIZED标志被设置,表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

synchronized原理

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。在对象头中保存了锁标志位和指向 monitor 对象的起始地址,如下图所示,右侧就是对象对应的 Monitor 对象。当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。另外 Monitor 中还有两个队列,用来存放进入及等待获取锁的线程。

为了提升性能,JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的 Java 对象头实现了锁升级功能。当 Java 对象被 Synchronized 关键字修饰成为同步锁后,围绕这个锁的一系列升级操作都将和 Java 对象头有关。

对象头

Java中对象头由三个部分组成:Mark Word、Klass Pointer、Length。

锁升级过程

当一个线程访问增加了synchronized关键字的代码块时,如果偏向锁是开启状态,则先尝试通过偏向锁来获得锁资源,这个过程仅仅通过CAS来完成。如果当前已经有其他线程获得了偏向锁,那么抢占锁资源的线程由于无法获得锁,所以会尝试升级到轻量级锁来进行锁资源抢占,轻量级锁就是通过多次CAS(也就是自旋锁)来完成的。如果这个线程通过多次自旋仍然无法获得锁资源,那么最终只能升级到重量级锁来实现线程的等待。

下图显示了对象头的布局和不同对象状态的表示:

偏向锁的原理

偏向锁其实可以认为是在没有多线程竞争的情况下访问synchronized修饰的代码块的加锁场景,也就是在单线程执行的情况下。

实际上对程序开发来说,加锁是为了防范线程安全性的风险,但是是否有线程竞争并不由我们来控制,而是由应用场景来决定。假设这种情况存在,就没有必要使用重量级锁基于操作系统级别的Mutex Lock来实现锁的抢占,这样显然很耗费性能。

所以偏向锁的作用就是,线程在没有线程竞争的情况下去访问synchronized同步代码块时,会尝试先通过偏向锁来抢占访问资格,这个抢占过程是基于CAS来完成的,如果抢占锁成功,则直接修改对象头中的锁标记。其中,偏向锁标记为1,锁标记为01,以及存储当前获得锁的线程ID。而偏向的意思就是,如果线程X获得了偏向锁,那么当线程X后续再访问这个同步方法时,只需要判断对象头中的线程ID和线程X是否相等即

获取偏向锁的流程

下图代表获取偏向锁的粗粒度流程图,偏向锁是在没有线程竞争的情况下实现的一种锁,不能排除存在锁竞争的情况,所以偏向锁的获取有两种情况。

偏向锁的释放

在偏向锁执行完synchronized同步代码块后,会触发偏向锁释放的流程,需要注意的是,偏向锁本质上并没有释放,因为当前锁对象lock仍然是偏向该线程的。释放的过程只是把Lock Record释放了,也就是说把Lock Record保存的锁对象的Mark Word设置为空。

偏向锁批量重偏向当一个锁对象lock只被同一个线程访问时,该锁对象的锁状态就是偏向锁,并且一直偏向该线程。当有任何一个线程来访问该锁对象lock时,不管之前获得偏向锁线程的状态是存活还是死亡,lock锁对象都会升级为轻量级锁,并且锁在升级之后是不可逆的。

假设一个线程t1针对大量的锁对象增加了偏向锁,之后线程t2来访问这些锁对象,在不考虑锁竞争的情况下,需要对之前所有偏向线程t1的锁对象进行偏向锁撤销和升级,这个过程比较耗时,而且虚拟机会认为这个锁不适合再偏向于原来的t1线程,于是当偏向锁撤销次数达到20次时,会触发批量重偏向,把所有的锁对象全部偏向线程t2。偏向锁撤销并批量重偏向的触发阈值可以通过XX:BiasedLockingBulkRebiasThreshold = 20来配置,默认是20。

在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM 参数关闭偏向锁来调优系统性能:

-XX:-UseBiasedLocking //关闭偏向锁(默认打开)
或者
-XX:+UseHeavyMonitors  //设置重量级锁

轻量级锁的原理

在线程没有竞争时,使用偏向锁能够在不影响性能的前提下获得锁资源,但是同一时刻只允许一个线程获得锁资源,如果有多个线程来访问同步方法,于是就有了轻量级锁的设计。

所谓的轻量级锁,就是没有抢占到锁的线程,进行一定次数的重试(CAS)。比如线程第一次没抢到锁则重试几次,如果在重试的过程中抢占到了锁,那么这个线程就不需要阻塞,这种实现方式我们称为自旋锁,具体的实现流程如图所示。

线程通过重试来抢占锁的方式是有代价的,因为线程如果不断自旋重试,那么CPU会一直处于运行状态。如果持有锁的线程占有锁的时间比较短,那么自旋等待的实现带来性能的提升会比较明显。反之,如果持有锁的线程占用锁资源的时间比较长,那么自旋的线程就会浪费CPU资源,所以线程重试抢占锁的次数必须要有一个限制。从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,根据前一次在同一个锁上的自旋次数及锁持有者的状态来决定的。如果在同一个锁对象上,通过自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么JVM会认为此次自旋也有很大的机会获得锁,因此会将这个线程的自旋时间相对延长。反之,如果在一个锁对象中,通过自旋锁获得锁很少成功,那么JVM会缩短自旋次数。

在高负载、高并发的场景下,我们可以通过设置 JVM 参数来关闭自旋锁,优化系统性能:

-XX:-UseSpinning //参数关闭自旋锁优化(默认打开) 
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制

如果偏向锁存在竞争或者偏向锁未开启,那么当线程访问synchronized(lock)同步代码块时就会采用轻量级锁来抢占锁资源,获得访问资格,轻量级锁的加锁如图所示:

获取轻量级锁的流程

相对偏向锁来说,轻量级锁的原理比较简单,它只是通过CAS来修改锁对象中指向Lock Record的指针。从功能层面来说,偏向锁和轻量级锁最大的不同是:

轻量级锁的释放

偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。

重量级锁的原理分析

轻量级锁能够通过一定次数的重试让没有获得锁的线程有可能抢占到锁资源,但是轻量级锁只有在获得锁的线程持有锁的时间较短的情况下才能起到提升同步锁性能的效果。如果持有锁的线程占用锁资源的时间较长,那么不能让那些没有抢占到锁资源的线程不断自旋,否则会占用过多的CPU资源,这反而是一件得不偿失的事情。如果没抢占到锁资源的线程通过一定次数的自旋后,发现仍然没有获得锁,就只能阻塞等待了,所以最终会升级到重量级锁,通过系统层面的互斥量(Mutex)来抢占锁资源。重量级锁的实现原理如图所示:

如果线程在运行synchronized(lock)同步代码块时,发现锁状态是轻量级锁并且有其他线程抢占了锁资源,那么该线程就会触发锁膨胀升级到重量级锁。因此,重量级锁是在存在线程竞争的场景中使用的锁类型。

获取重量级锁的流程

重量级锁的实现流程如图所示:

重量级锁的实现是在ObjectMonitor中完成的,所以锁膨胀的意义就是构建一个ObjectMonitor,继续关注图中ObjectMonitor的实现部分,在ObjectMonitor中锁的实现过程如下:

重量级锁的释放

锁的释放是在synchronized同步代码块结束后触发的,释放的逻辑比较简单。

总结

JVM 在 JDK1.6 中引入了分级锁机制来优化 Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。减少锁竞争,是优化 Synchronized 同步锁的关键。我们应该尽量使 Synchronized 同步锁处于轻量级锁或偏向锁,这样才能提高 Synchronized 同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高 Synchronized 同步锁在自旋时获取锁资源的成功率,避免 Synchronized 同步锁升级为重量级锁。

以上就是深入剖析Java中的synchronized关键字的详细内容,更多关于Java synchronized关键字的资料请关注脚本之家其它相关文章!

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