阅读 1441

synchronized原理及其应用(详细且认真)

1. 概述

在jdk1.6之前,synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统的性能开销。在锁竞争激烈的情况下,synchronized同步锁的性能很糟糕。JDK 1.6,Java对synchronized同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了Lock同步锁

我们先来讲解synchronized关键字的底层原理,再讲解一下应用。

2. synchronized实现原理

synchronized 在 JVM 的实现原理是基于进入和退出管程(Monitor)对象来实现同步。但 synchronized 关键字实现同步代码块和同步方法的细节不一样,代码块同步是使用 monitorenter 和 monitorexit 指令实现的,方法同步通过调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

2.1 Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

对象

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

Java对象头是synchronized实现的关键,synchronized用的锁是存在Java对象头里的。

synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字宽(一个字宽代表4个字节,一个字节8bit)来存储对象头(如果对象是数组则会分配3个字宽,多出来的1个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。

虚拟机位数 对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
32/64bit Array length 数组的长度(如果当前对象是数组)

其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中默认状态为下:

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

运行期间,Mark Word里存储的数据随锁标志位的变化而变化,可能存在如下4种数据。

Mark Word

2.2 synchronized同步的底层实现

上面说到 JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步。

Monitor(监视器锁)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。

下面讲解 synchronized 同步代码块的过程

public class SynTest{
	public int i;

    public void syncTask(){
		synchronized (this){
			i++;
		}
	}
}
复制代码

反编译后结果如下:

D:\Desktop>javap SynTest.class
Compiled from "SynTest.java"
public class SynTest {
  public int i;
  public SynTest();
  public void syncTask();
}

D:\Desktop>javap -c SynTest.class
Compiled from "SynTest.java"
public class SynTest {
  public int i;

  public SynTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void syncTask();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: aload_0
       5: dup
       6: getfield      #7                  // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #7                  // 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:
       from    to  target type
           4    16    19   any
          19    22    19   any
}
复制代码

关注 monitorenter 和 monitorexit:

3: monitorenter
//省略
15: monitorexit
16: goto          24
//省略
21: monitorexit
复制代码

从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令

我们再来看看同步方法的过程:

public class SynTest{
	public int i;

    public synchronized void syncTask(){
		i++;
	}
}
复制代码

反编译:javap -verbose -p SynTest

Classfile /D:/Desktop/SynTest.class
  Last modified 2020年4月2日; size 278 bytes
  SHA-256 checksum 0e7a02cd496bdaaa6865d5c7eb0b9f4bfc08a5922f13a585b5e1f91053bb6572
  Compiled from "SynTest.java"
public class SynTest
  minor version: 0
  major version: 57
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // SynTest
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // SynTest.i:I
   #8 = Class              #10            // SynTest
   #9 = NameAndType        #11:#12        // i:I
  #10 = Utf8               SynTest
  #11 = Utf8               i
  #12 = Utf8               I
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               syncTask
  #16 = Utf8               SourceFile
  #17 = Utf8               SynTest.java
{
  public int i;
    descriptor: I
    flags: (0x0001) ACC_PUBLIC

  public SynTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public synchronized void syncTask();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #7                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #7                  // Field i:I
        10: return
      LineNumberTable:
        line 5: 0
        line 6: 10
}
SourceFile: "SynTest.java"

复制代码

注意: flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED

JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。

3. 同步过程(锁升级过程)

上面讲解了,synchronized 在开始的时候是依靠操作系统的互斥锁来实现的,是个重量级操作,为了减少获得锁和释放锁带来的性能消耗,在 JDK 1.6中,引入了偏向锁和轻量级锁。锁一共有4中状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几种状态会随着竞争情况逐渐升级,但不能降级,目的是为了提高锁和释放锁的效率。

3.1 偏向锁

大部分情况下,锁不存在多线程竞争,偏向锁就是为了在只有一个线程执行同步块时提高性能。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

获得过程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为 01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试 Thread ID 是否指向当前线程,如果是,执行同步代码。
  3. 如果不是指向当前线程,使用 CAS 竞争锁,如果竞争成功,则将 Mark Word 中 Thread ID 设置为当前线程ID,并在栈帧中锁记录(Lock Record)里存储当前线程ID。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint,在这个时间点上没有正在执行的字节码)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁)。
  5. 如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。

锁释放过程:

其实就是上面锁获得过程的四五步。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销,需要等待全局安全点(这个时间点没有正在执行的字节码)。

  1. 到全局安全点后,先暂停拥有偏向锁的线程,检查该线程是否或者。

  2. 不活动或已经退出代码块,则对象头设置为无锁状态,然后重新偏向新的线程。

  3. 如果仍然活着,则遍历线程栈中所有的 Lock Record,如果能找到对应的 Lock Record 说明偏向的线程还在执行同步代码块中的代码。需要升级为轻量级锁,直接修改偏向线程栈中的Lock Record。

  4. 此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。

偏向锁获得流程

《Java并发编程的艺术》书中这一部分是这样说的:

当一个线程访问同步块并获得锁时,会在对象头和栈帧中锁记录存储锁偏向的线程ID,以后该线程在进入退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获得了锁。如果失败,则需要再测试 Mark Word 中偏向锁的标识是否设置为 1(表示当前是偏向锁),如果没有设置,则使用 CAS 竞争锁,如果设置了,则尝试 CAS 将对象头的偏向锁指向当前线程。

个人觉得这一部分书中似乎稍微有点出入,我查看了很多博客,正常按逻辑分析的话,应该也是先判断锁标志位,判断出现在锁的状态,而不是先判断锁的线程ID是否指向自己。

偏向锁的底层实现,如果想要详细了解的话,可以参考这篇文章--有讲解底层 c++ 实现

3.2 轻量级锁

锁获得过程:

  1. 如果锁对象不是偏向模式或已经偏向其他线程,这时候会构建一个无锁状态的mark word设置到Lock Record中去,我们称Lock Record中存储对象mark word的字段叫 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中。然后虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。
  3. 如果更新成功,当前线程获得锁,执行同步代码。如果更新失败,当前线程便尝试使用自旋来获取锁。
  4. 当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞。

锁释放过程:

  1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  2. 如果替换成功,整个同步过程就完成了。
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁

3.3 重量级锁

重量级锁的上锁过程参考上面步骤 4 ,轻量级锁膨胀为重量级锁,Mark Word的锁标记位更新为10,Mark Word 指向互斥量(重量级锁)。

Synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的,文章开头有讲解。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。

4. synchronized的三种应用方式

Java中每一个对象都可以作为锁,具体表现为如下三种形式:

  1. 对于实例方法,也就是普通同步方法,锁是当前实例对象。
  2. 对于静态同步方法,锁是当前类的 class 对象。
  3. 对于同步方法块,锁是 synchronized 括号里配置的对象。

简单地说,synchronized修饰,表现为两种锁,一种是对调用该方法的对象加锁,俗称对象锁或实例锁,另一种是对该类对象加锁,俗称类锁。

4.1 对象锁

形象的理解:

Java 中每个对象都有一个锁,并且是唯一的。假设分配的一个对象空间,里面有多个方法,相当于空间里面有多个小房间,如果我们把所有的小房间都加锁,因为这个对象只有一把钥匙,因此同一时间只能有一个人打开一个小房间,然后用完了还回去,再由 JVM 去分配下一个获得钥匙的人。

这样的话,对于一些面试问题就好解决了。

  1. 同一个对象在两个线程中分别访问该对象的两个同步方法

  2. 不同对象在两个线程中调用同一个同步方法

第一个问题,因为锁针对的是对象,当对象调用一个synchronized方法时,其他同步方法需要等待其执行结束并释放锁后才能执行。

第二个问题,因为是两个对象,锁针对的是对象,并不是方法,所以可以并发执行,不会互斥。形象的来说就是因为我们每个线程在调用方法的时候都是new 一个对象,那么就会出现两个空间,两把钥匙。

4.2 类锁

类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,一把钥匙。

问题:

  1. 用类直接在两个线程中调用两个不同的同步方法
  2. 用一个类的静态对象在两个线程中调用静态方法或非静态方法
  3. 一个对象在两个线程中分别调用一个静态同步方法和一个非静态同步方法

因为对静态对象加锁实际上对类(.class)加锁,类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,因此房间(同步方法)之间一定是互斥的。因为是一个对象调用,所以,1、2都会互斥。

第三个问题,因为虽然是一个对象调用,但是两个方法的锁类型不同,调用的静态方法实际上是类对象在调用,即这两个方法产生的并不是同一个对象锁,因此不会互斥,会并发执行。

5. synchronized 其他锁优化

5.1 锁消除

锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。

5.2 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

5.3 自旋锁与自适应自旋锁

引入自旋锁的原因:

互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

自旋锁:

让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开启;在JDK1.6中默认开启。

自旋锁的缺点:

自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。

自适应的自旋锁:

JDK1.6 引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

6. synchronized 的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在 java 中 synchronized 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。

7. 小结&参考资料

小结

synchronized特点: 保证内存可见性、操作原子性。在经过jdk6的优化,synchronized 的性能其实不必 JVM 实现的 Reentrantlock 差,甚至有的时候比它更优秀,这也是 Java concurrent 包下很多类的原理都是基于 synchronized 实现的原因。

锁升级过程

参考资料