前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >synchronized

synchronized

作者头像
胖虎
发布2020-12-08 14:47:33
4490
发布2020-12-08 14:47:33
举报
文章被收录于专栏:晏霖晏霖

曾经有人关注了我

后来他有了女朋友

Synchronized是同步中的鼻祖,很多人叫他重量级锁,也是最基本的同步互斥手段。随着Java版本不断提高,尤其是在Java6之后Synchronized进行了很多性能优化。本章首先要简单介绍对象头的内容,然后引申出Synchronized的实现原理,锁的储存结构和锁升级等,以及相关所有锁的概念,都会一一向大家介绍。

2.6.1 Java对象头

Java是面向对象语言,同样Java中的锁也是基于对象的,那么锁的信息存在什么位置呢。

在JVM中对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对其填充(padding)。而对象头又分为两部分信息,第一部分用于存储自身运行时的数据,如HashCode、GC年龄分代、锁状态标志、线程持有的锁、偏向线程ID、偏向时的时间戳等,这部分数据在32位(1字宽度等于4字节)和64位虚拟机中分别用32bit和64bit,(如表2-7所示)官方成为“Mark World”。而对象头的另一部分是类型指针,即对象指向他的类元数据的指针,JVM通过这个判断对象是哪个类的实例。如图2-14。

表2-7Java对象头长度

长度

内容

说明

32/64bit

Mark World

存储对象HashCode和锁信息等

32/64bit

Class Metadata Address

存储到对象类型数据的指针

32/64bit

Array length

数组长度(当前对象是数组)

图2-14对象的内存布局

我们主要来看看Mark Word的格式,如下表所示。

表2-8 JVM中对象头Mark Word

锁状态

29 bit 或 61 bit

1 bit 是否是偏向锁?

2 bit 锁标志位

无锁

0

01

偏向锁

线程ID

1

01

轻量级锁

指向栈中锁记录(Lock Record )的指针

此时这⼀位不⽤于标识偏向锁

00

重量级锁

指向互斥量(重量级锁)的指针

此时这⼀位不⽤于标识偏向锁

10

GC标记

此时这⼀位不⽤于标识偏向锁

11

从表中得知,当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁时, Mark Word 为指向堆中的monitor对象的指针,这也后面我们要解释Synchronized实现原理。

2.6.2 Synchronized实现原理

Java对象监视器(monitor),JVM基于进入和退出monitor对象来实现方法同步和代码同步,虽然两者实现的细节有些不同,但代码同步和方法同步均是使用monitorenter和monitorexit两个指令实现的。monitorenter指令是插入在同步的开始位置,而monitorexit插入同步的结束位置和异常位置,两个指令是成对出现的(JVM生成异常处理器会在字节码指令中多出monitorexit,后面介绍)。任意对象都与一个monitor相关联,当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取。

线程执行monitorexit指令时放弃monitor的所有权。执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

我们来结合代码和反编译指令来更好的理解Synchronized是如何实现对代码块进行同步的。例代码2-15。

代码清单2-15 SynchronizedByteCode.java

代码语言:javascript
复制
public class SynchronizedByteCode {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

进入上述java文件当前目录下,执行javac SynchronizedByteCode.java得到class文件,然后执行javap -c SynchronizedByteCode.class如下所示:

代码清单2-16 SynchronizedByteCode.class

代码语言:javascript
复制
Compiled from "SynchronizedByteCode.java"
public class com.px.book.SynchronizedByteCode {
  public com.px.book.SynchronizedByteCode();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void method();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String Method 1 start
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1
      13: monitorexit
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

上面我们说过monitorenter和monitorexit是成对出现的,那么看到字节码中,有俩个monitorexit指令,这是为什么呢?是这样的,编译器需要确保方法中调用过的每条monitorenter指令都要执行对应的monitorexit 指令。为了保证在方法异常时,monitorenter和monitorexit指令也能正常配对执行,编译器会自动产生一个异常处理器,它的目的就是用来执行 异常的monitorexit指令。而字节码中多出的monitorexit指令,就是异常结束时,被执行用来释放monitor的。

Synchronized在处理同步代码块和同步方法有一些细节上的不同,下面我们再来看一下同步方法的反编译结果。首先看案例代码2-17所示。

代码清单2-17 SynchronizedMethod.java

代码语言:javascript
复制
public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

编译class方法相同,当我们查看指令时可以使用javap -verbose SynchronizedMethod.class命令,查看JVM反编译后的完整常量池。可以看到反编译结果将每一个cp_info结构的类型和值都很明确地呈现了出来,这里由于篇幅原因不详细介绍cp_info以及其他访问标示含义。

编译字节码指令如代码2-18所示。

代码清单2-18 SynchronizedMethod.class

代码语言:javascript
复制
Classfile /Users/yanlin/ideaworkspace/book/chapter2/src/main/java/com/px/book/SynchronizedMethod.class
  Last modified 2020-4-1; size 431 bytes
  MD5 checksum a3b681043376423b264c1925299a57ee
  Compiled from "SynchronizedMethod.java"
public class com.px.book.SynchronizedMethod
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#14         // java/lang/Object."<init>":()V
   #2 = Fieldref           #15.#16        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #17            // Hello World!
   #4 = Methodref          #18.#19        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #20            // com/px/book/SynchronizedMethod
   #6 = Class              #21            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               method
  #12 = Utf8               SourceFile
  #13 = Utf8               SynchronizedMethod.java
  #14 = NameAndType        #7:#8          // "<init>":()V
  #15 = Class              #22            // java/lang/System
  #16 = NameAndType        #23:#24        // out:Ljava/io/PrintStream;
  #17 = Utf8               Hello World!
  #18 = Class              #25            // java/io/PrintStream
  #19 = NameAndType        #26:#27        // println:(Ljava/lang/String;)V
  #20 = Utf8               com/px/book/SynchronizedMethod
  #21 = Utf8               java/lang/Object
  #22 = Utf8               java/lang/System
  #23 = Utf8               out
  #24 = Utf8               Ljava/io/PrintStream;
  #25 = Utf8               java/io/PrintStream
  #26 = Utf8               println
  #27 = Utf8               (Ljava/lang/String;)V
{
  public com.px.book.SynchronizedMethod();//类名
    descriptor: ()V               //类描述符
    flags: ACC_PUBLIC            //类访问标志
    Code://code开始
      stack=1, locals=1, args_size=1//最大栈深、局部变量数、参数列表size
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 9: 0

  public synchronized void method();//方法名
    descriptor: ()V          //方法描述符
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED//方法访问标志
    Code://code开始
      stack=2, locals=1, args_size=1//最大栈深、局部变量数、参数列表size
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable://行号表,将上面操作与java中行号做对应
        line 11: 0
        line 12: 8
}
SourceFile: "SynchronizedMethod.java"

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

2.6.3 锁的升级及锁的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,锁升级单向的目的是为了提高获得锁和释放锁的效率。关于重量级锁(synchronization),前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段。

JDK1.6中对synchronized进行了很多手段的优化,这其中涉及到偏向锁、轻量级锁、自旋锁、锁消除等手段。

1.偏向锁

JDK专家组经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程首次获得了对象,虚拟机将会把对象头标志位设为“01”,那么就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,同时使用CAS操作把当前对象头Mark Word里存储了当前偏向线程的线程ID。以后当这个线程再次请求同步块时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。我们可以把偏向模式按照字面意思理解,“偏”字理解为偏袒,偏心,锁总是偏向第一个进来的线程,并给他贴了一个标签,代表此线程请求同步块时不做任何同步操作就可以执行同步块,如果当有线程出现竞争时,哪怕就来一个竞争者,锁就会不认可这种偏向模式了,也就是偏向锁就失效了。下面演示偏向锁的获得和撤销流程,如图2-15所示。

看起来偏向锁很脆弱,如果偏向锁线程不再活动,则会把对象头设置无锁状态。如果另外一个线程去尝试获取这个锁,偏向锁模式也会宣告结束,锁就会升级成轻量级锁,这里升级也有一些细节问题,只有等待全局安全点(在这个时间点上没有正在执行的字节码,会在JVM章节详细介绍安全点),偏向锁撤销总结为以下流程。

1. 在⼀个安全点(在这个时间点上没有字节码正在执⾏)停⽌拥有锁的线程。

2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。

3. 唤醒被停⽌的线程,将当前锁升级成轻量级锁。

图 2-15 偏向锁的获得和撤销流程

引入偏向锁,是为了减少不必要的CAS操作,可以提高同步且无竞争的程序性能,他不一定总对程序有利,如果当前程序存在频繁的线程竞争,偏向模式就显得多余。JVM可通过-XX:-UseBiasedLocking=false禁用偏向锁,程序就会直接进入轻量级锁模式。默认开启偏向锁并且在虚拟机启动的4秒后,偏向锁才会被打开,因为前4s JVM会有内部锁的争用,如果启动偏向锁反而不效率,这个4秒也是经过大量研究而定的数字。也可以通过参数:-XX:BiasedLockingStartupDelay=0来关闭延迟

2.轻量级锁

我们继续上述场景来说,当前锁已经是偏向锁或为无锁状态(锁标志位为“01”状态),虚拟机首先将当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,用于存储当前对象的Mark Work的拷贝(官方称这份拷贝为Displaced Mark Word)。当前线程尝试使用 CAS 操作将对象中的 Mark Word 更新为指向Lock Record的指针,如果成功,当前线程获得锁,并且对象Mark Word的锁标志位游“01”转变为“00”,即表示此对象处于轻量级锁状态。如果获取锁失败,代表当前锁有其他线程竞争,此时线程尝试使用自旋来获取资源,自旋是需要消耗CPU的,如果达到自旋最大次数还是没有获取到锁,线程就会处于阻塞状态,锁升级为重量级锁。虚拟机不会让每一个尝试使用自旋获取锁的线程都会自旋规定的次数,这样对资源利用不友好对,所以JDK采用来更“聪明”的方式——自适应自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。上述为轻量级锁加锁流程。

轻量级锁的解锁过程也是通过CAS操作进行的。当前线程会使⽤CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发⽣竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。锁膨胀的流程图如下图所示。

图 2-16 争夺锁导致的锁膨胀流程

3.重量级锁

我们可以结合2.6.2章节内容。对于monitor我们可以把它理解为一个同步工具,也可以描述为一种同步机制,所有的Java对象是天生的monitor,每一个Java对象都有成为monitor的潜质,因为在Java的设计中 ,每一个对象自出生就带了一把看不见的锁,它叫做内部锁或者monitor锁。monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

l Contention List:竞争队列,所有请求锁的线程将被⾸先放置到该竞争队列;

l Entry List:Contention List中那些有资格成为候选⼈的线程被移到Entry List;

l Wait Set:那些调用wait⽅法被阻塞的线程被放置到这里;

l OnDeck:任何时刻最多只能有⼀个线程正在竞争锁,该线程称为OnDeck;

l Owner:获得锁的线程称为Owner;

l !Owner:释放锁的线程;

执行流程如下图

图2-17 重量级锁执行流程

执行流程分为获取monitor和释放monitor

获取monitor

1. 线程首先通过CAS尝试将monitor的owner设置为自己。

2. 若执行成功,则判断该线程是不是重入。若是重入,则执行recursions + 1,否则执行recursions = 1。

3. 若失败,则将自己封装为ObjectWaiter,并通过CAS加入到竞争队列中。

释放monitor

1. 判断是否为重量级锁,是则继续流程。

2. recursions - 1。

3. 根据不同的策略设置一个OnDeckThread。

下面我们对偏向锁轻量级锁和重量级锁做一个总结。

下图表明了一个锁的升级过程。

图2-18 Java锁升级过程

下表来源于网络,表明锁的优缺点对比

表2-9 锁优缺点对比

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距

如果线程间存在锁竞争,会带来额外的锁撤销的消耗

适用于只有一个线程访问同步块场景

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度

如果始终得不到锁竞争的线程使用自旋会消耗CPU

追求响应时间,锁占用时间很短

重量级锁

线程竞争不使用自旋,不会消耗CPU

线程阻塞,响应时间缓慢

追求吞吐量,锁占用时间较长

4.自旋锁与自适应自旋

对于自旋锁和自适应自旋上面我有略微提到过,所以不会太陌生。同步互斥是阻塞实现的,阻塞是对性能有很大的影响,有阻塞就要有唤醒,线程的这种操作需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时,我们也发现,这种对于共享数据的锁定状态只会持续很短的时间,例如几个线程之间对整型数据的累加操作,这种短暂的资源竞争就要把其他线程挂起和恢复其实是没有必要的,完全可以让存在竞争的线程“稍等”,不用放弃CPU的执行时间,让他自行循环等待,这就叫自旋锁。自旋锁早在JDK1.4就引入了,在JDK1.6才默认开启的,自旋是不放弃CPU时间片的,如果大量线程自旋是一笔很大的开销,所以自旋也会规定次数,超过次数还没成功获取锁才会选择挂起,也就是线程阻塞状态。在JDK1.6前(包含1.6),线程的自旋一般默认是10次,也可以根据参数-XX:PreBlockSpin 来赋值更改,。并且在使用JDK1.5时一定要要开启自旋,使用参数:-XX:+UseSpinning,此参数在JDK1.6后是默认开启,使用者不必担心。发展到JDK1.7后,自旋锁参数取消了,虚拟机不支持用户自定义自旋次数,由虚拟机自动调整自旋次数,这也是我们下面要绍的自适应自旋。

虚拟机开发团队发现单单用次数去限制自旋的时间和次数还不是最优雅的方法,所以在JDK1.6引入来自适应自旋锁,让自旋变得更聪明,让锁的拥有者来决定。如果线程在这个锁对象通过自旋方式刚刚获取到锁,那么虚拟机认为你下次来的时候获取到的机会会很大,所以允许他自旋的时间可以超过标准的次数,相反如果某个锁通过自旋方式很少获取成功,那么存在竞争的话虚拟机可能会省掉自旋的过程,避免这样的锁再来浪费处理器的资源,因此

自适应自旋就是用一种维度来控制自旋的时间和次数,主要为了节省处理器的资源。其实我们目前使用的主流JDK版本都是1.8以上,使用者根本不用操心要不要使用自旋和修改自旋的次数,但是我还是介绍了很多关于JDK1.5和JDK1.6的内容,其实就想让读者清楚虚拟机一次次更新为我们提供了很大了便利,甚至从某些角度讲,他的进步使我们的开发变得更容易。

5.锁粗化

在编写同步代码的时候,我们说推荐把同步块的作用范围越小越好,只在需要同步的地方进行同步,这样同步操作的数量尽可能变小,让等待的线程尽快拿到锁。粗化其实是相反的操作,让同步的范围扩大,但是为什么粗化也是锁优化的方式呢?我们假设一个场景,例如在循环中进行字符串的append()方法同步拼接,加锁的位置是在循环体内,那么每次循环都要对同一个对象反复加锁和解锁,即使没有竞争,这种互斥操作也会造成不必要的性能损失,所以加锁操作会在循环体外进行,这样只需加一次锁就可以了,这就叫锁粗化。如代码2-19所示。

代码清单 2-19 LockCoarsening.java

代码语言:javascript
复制
public class LockCoarsening {
    private static Object lock;

    public void doSomethingMethod() {
        synchronized (lock) {
            //do some thing
        }
        //这是还有一些代码,做其它不需要同步的工作,但能很快执行完毕
        synchronized (lock) {
            //do other thing
        }

        for (int i = 0; i < 10; i++) {
            synchronized (lock) {
            }
        }
    }

    public void coarsening() {
        //进行锁粗化:整合成一次锁请求、同步、释放
        synchronized (lock) {
            //do some thing
            //做其它不需要同步但能很快执行完的工作
            //do other thing
        }

        synchronized (lock) {
            for (int i = 0; i < 10; i++) {
            }
        }
    }
}

6.锁消除

锁消除是在虚拟机运行期对一些代码上同步的操作进行检测,虚拟机编译时检测同步代码根本没必要同步,即不可能存在共享数据的竞争,这样的同步在执行的时候会被忽略。虚拟机不仅会忽略程序员编写无用的同步,也会对一些本身具有同步性质的操作,进行反复调用而增加无用的加锁。例如2-20代码所示

代码清单2-20 LockElimination.java

代码语言:javascript
复制
public class LockElimination {
    public String concatString(String s1, String s2, String s3) {
        return s1 + s2 + s3;
    }
}

String是不可变的类,对于这种操作在JDK1.5之前javac会编译成使用StringBuffer对象进行append()。笔者使用JDK1.11,对于StringBuffer的append()这种本身具有线程安全的操作又近一步优化,改为StringBuilder进行连续的append()操作,如代码2-21所示是2-20编译后的样子。

代码清单2-21 LockElimination.class

代码语言:javascript
复制
Compiled from "LockElimination.java"
public class com.px.book.LockElimination {
  public com.px.book.LockElimination();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public java.lang.String concatString(java.lang.String, java.lang.String, java.lang.String);
    Code:
       0: new           #2                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
       7: aload_1
       8: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      11: aload_2
      12: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_3
      16: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #5                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: areturn
      }
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 晏霖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 执行流程分为获取monitor和释放monitor
  • 获取monitor
  • 释放monitor
  • 下面我们对偏向锁轻量级锁和重量级锁做一个总结。
  • 下表来源于网络,表明锁的优缺点对比
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档