Synchronized 实现原理

​记得刚刚开始学习Java的时候,一遇到多线程状况就是synchronized。对于当时的咱们来讲,synchronized是如此的神奇且强大。咱们赋予它一个名字“同步”,也成为咱们解决多线程状况的良药,百试不爽。可是,随着学习的深刻,咱们知道synchronized是一个重量级锁,相对于Lock,它会显得那么笨重,以致于咱们认为它不是那么的高效。随着Javs SE 1.6对synchronized进行各类优化后,synchronized不会显得那么重。java

synchronized能够保证方法或者代码块在运行时,同一时刻只有一个方法进入临界区,同时还能够保证共享变量的内存可见性。编程

一、实现原理

Java中的每个对象均可以做为锁,这是synchronized实现同步的基础:数组

  • 普通同步方法,锁是当前实例对象。
  • 静态同步方法,锁时当前类的Class对象。
  • 同步代码块,锁是synchronized括号里配置的对象。
    当一个线程访问同步代码块时,必须先得到锁才能执行同步代码,当退出或者跑出异常时必须释放锁。咱们会疑惑锁到底存在哪里?锁里面又会保存什么信息?

JVM规范定义:JVM基于进入与退出Monitor对象与来实现方法同步和代码块同步:安全

  • 代码块同步:使用monitorenter和monitorexit指令实现。
  • 方法同步:使用另一种方式,可是一样是使用这两个指令实现。只是具体表现形式有所不一样
public class SynchronizedTest {
    public synchronized void test1() {
    }

    public void test2() {
        synchronized (this) {
        }
    }
}
复制代码

使用javap -verbose SynchronizedTest.class工具查看生成的class文件信息分析synchronized实现:性能优化

省略部分代码,以下所示:多线程

{
  public com.zero.test.SynchronizedTest();
   .....

  public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 5: 0

  public void test2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_1
         5: monitorexit
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return
    
    ......
}

复制代码

从上面能够看出同步代码块使用monitorenter和monitorexit指令实现的,同步方法使用方法修饰符上的ACCSYNCHRONIZED实现。不管哪种方式,其本质都是一个对象的监视器(monitor)进行获取。并发

在咱们继续分析深刻以前,要先了解两个概念:Java对象头、Monitor。app

1.一、 Java对象头

synchronized用的锁是存在Java对象头里的,Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。工具

Mark Word

Java对象头里的Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头通常占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),可是若是对象是数组类型,则须要三个机器码,由于JVM虚拟机能够经过Java对象的元数据信息肯定Java对象的大小,可是没法从数组的元数据来确认数组的大小,因此用一块来记录数组长度。,32位 JVM 的Mark Word的默认存储结构以下表格:性能

锁状态 25bit 4bit 1bit是不是偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象的分代年龄 0 01

在运行期间,Mark Word里存储的数据随着锁标志位的变化而变化

Monitor

咱们能够把它理解为一个同步工具,也能够描述为一种同步机制,它一般被描述为一个对象。

与一切皆对象同样,全部的Java对象是天生的Monitor,每个Java对象都有成为Monitor的潜质,由于在Java的设计中 ,每个Java对象自打娘胎里出来就带了一把看不见的锁,它叫作内部锁或者Monitor锁。

二、synchronize性能优化

咱们知道synchronized是重量级锁,效率不怎么滴,同时这个观念也一直存在咱们脑海里,不过在JDK 1.6中对synchronize的实现进行了各类优化,使得它显得不是那么重了,那有哪些优化手段?

2.一、 锁优化

JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减小锁操做的开销。

锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。他们会随着竞争的激烈而逐渐升级。注意锁能够升级不可降级,这种策略是为了提升得到锁和释放锁的效率。

2.1.一、 自旋锁

线程的阻塞和唤醒须要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来讲是一件负担很重的工做,势必会给系统的并发性能带来很大的压力。同时咱们发如今许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是很是不值得的。

何谓自旋锁?

所谓自旋锁,就是让该线程等待一段时间,不会被当即挂起,看持有锁的线程是否会很快释放锁。

2.1.二、 适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数再也不是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

它怎么作呢?

线程若是自旋成功了,那么下次自旋的次数会更加多,由于虚拟机认为既然上次成功了,那么这次自旋也颇有可能会再次成功,那么它就会容许自旋等待持续的次数更多。反之,若是对于某个锁,不多有自旋可以成功的,那么在之后要或者这个锁的时候自旋的次数会减小甚至省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的情况预测会愈来愈准确,虚拟机会变得愈来愈聪明。

2.1.三、 锁消除

为了保证数据的完整性,咱们在进行操做时须要对这部分操做进行同步控制,可是在有些状况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

咱们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操做。

好比StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
    }
    System.out.println(vector);
}
复制代码

在运行这段代码时,JVM能够明显检测到变量vector没有逃逸出方法vectorTest()以外,因此JVM能够大胆地将vector内部的加锁操做消除。

2.1.四、锁粗化

咱们知道在使用同步锁的时候,须要让同步块的做用范围尽量小,仅在共享数据的实际做用域中才进行同步。这样作的目的是为了使须要同步的操做数量尽量缩小,若是存在锁竞争,那么等待锁的线程也能尽快拿到锁。 在大多数的状况下,上述观点是正确的。可是若是一系列的连续加锁解锁操做,可能会致使没必要要的性能损耗,因此引入锁粗化的概念。

那什么是锁粗化?

就是将多个连续的加锁、解锁操做链接在一块儿,扩展成一个范围更大的锁。

如上面实例:vector每次add的时候都须要加锁操做,JVM检测到对同一个对象(vector)连续加锁、解锁操做,会合并一个更大范围的加锁、解锁操做,即加锁解锁操做会移到for循环以外。

2.1.五、偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的状况下尽可能减小没必要要的轻量级锁执行路径。轻量级锁的加锁解锁操做是须要依赖屡次CAS原子指令的。那么偏向锁是如何来减小没必要要的CAS操做呢?咱们能够查看Mark work的结构就明白了。其实只要简单的测试下对象头的Mark Word里是否存储着指向当前线程的偏向锁。若是测试成功,表示线程已经得到了锁。若是测试失败:则须要再测试下偏向锁标识是否设置成1(表示当前是偏向锁),若是没有设置,那么只能使用CAS竞争锁了,若是设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

流程以下:

  1. 检测Mark Word是否为可偏向状态,便是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,若是是,则执行步骤(5),不然执行步骤(3);
  3. 若是线程ID不为当前线程ID,则经过CAS操做竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,不然执行线程(4);
  4. 经过CAS竞争锁失败,证实当前存在多线程竞争状况,当到达全局安全点,得到偏向锁的线程被挂起,偏向锁升级为轻量级锁,而后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块。

释放锁偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,须要等待其余线程来竞争。偏向锁的撤销须要等待全局安全点(这个时间点是没有正在执行的代码)。

其步骤以下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态。

2.1.六、轻量级锁

引入轻量级锁的主要目的是在只有少许线程竞争的前提下,减小传统的重量级锁使用操做系统互斥量产生的性能消耗。

当关闭偏向锁功能或者多个线程竞争偏向锁致使偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤以下:

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

释放锁轻量级锁的释放也是经过CAS操做来进行的,主要步骤以下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操做将取出的数据替换当前对象的Mark Word中,若是成功,则说明释放锁成功,不然执行(3);
  3. 若是CAS操做替换失败,说明有其余线程尝试获取该锁,则须要在释放锁的同时须要唤醒被挂起的线程。

2.1.七、重量级锁

重量级锁经过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操做系统的Mutex Lock实现,操做系统实现线程之间的切换须要从用户态到内核态的切换,切换成本很是高。

开设这个公众号将本身的知识分享,之后会持续输出,但愿给读者朋友们带来帮助。客官以为有用请点赞或收藏,关注公众号JavaStorm,你将发现一个有趣的灵魂。

三、 锁的对比

下面对偏向锁、轻量级锁和重量级锁进行比较:

表3 各类锁的优缺点及适用场景

优势 缺点 适用场景
偏向锁 加锁和解锁不须要额外的消耗,与执行非同步方法仅存在纳秒级的差距 若是线程间存在竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的状况
轻量级锁 竞争的线程不会堵塞,提升了程序的响应速度 始终得不到锁的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度很是块,只有两个线程竞争锁
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程堵塞,响应时间缓慢 追求吞吐量,同步块执行速度比较慢,竞争锁的线程大于2个

表格来源:《Java并发编程的艺术》第16页

客官以为有用请点赞或收藏,关注公众号JavaStorm,你将发现一个有趣的灵魂。祝你们有个愉快的周末!

参考资料:

  1. 周志明:《深刻理解Java虚拟机》
  2. 方腾飞:《Java并发编程的艺术》

关注公众号 JavaStorm 获取最新内容

JavaStorm.png
相关文章
相关标签/搜索