1. Synchronized同步锁实现原理

1.1 Synchronized分类

1.1.1 根据修饰对象分类

  • 修饰代码块
    • synchronized(this|object) {}
    • synchronized(类.class) {}
  • 修饰方法
    • 修饰非静态方法
    • 修饰静态方法

1.1.2 根据获取的锁分类

  • 获取对象锁
    • synchronized(this|object) {}
    • 修饰非静态方法
  • 获取类锁
    • synchronized(类.class) {}
    • 修饰静态方法

1.2 对象锁和类锁

1.2.1 对象锁

在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。

1.2.2 类锁

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。

1.3 实现

  1. // 关键字在实例方法上,锁为当前实例
  2. public synchronized void method1() {
  3. // code
  4. }
  5. // 关键字在代码块上,锁为括号里面的对象
  6. public void method2() {
  7. Object o = new Object();
  8. synchronized (o) {
  9. // code
  10. }
  11. }

反编译查看具体字节码实现

  1. javac -encoding UTF-8 SyncTest.java // 先运行编译 class 文件命令
  2. javap -v SyncTest.class // 再通过 javap 打印出字节文件

1.3.1 修饰同步代码块

Synchronized在修饰同步代码块时,是由monitorenter和monitorexit指令来实现同步的。进入monitorenter指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象

  1. public void method2();
  2. descriptor: ()V
  3. flags: ACC_PUBLIC
  4. Code:
  5. stack=2, locals=4, args_size=1
  6. 0: new #2
  7. 3: dup
  8. 4: invokespecial #1
  9. 7: astore_1
  10. 8: aload_1
  11. 9: dup
  12. 10: astore_2
  13. 11: monitorenter //monitorenter 指令
  14. 12: aload_2
  15. 13: monitorexit //monitorexit 指令
  16. 14: goto 22
  17. 17: astore_3
  18. 18: aload_2
  19. 19: monitorexit
  20. 20: aload_3
  21. 21: athrow
  22. 22: return
  23. Exception table:
  24. from to target type
  25. 12 14 17 any
  26. 17 20 17 any
  27. LineNumberTable:
  28. line 18: 0
  29. line 19: 8
  30. line 21: 12
  31. line 22: 22
  32. StackMapTable: number_of_entries = 2
  33. frame_type = 255 /* full_frame */
  34. offset_delta = 17
  35. locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ]
  36. stack = [ class java/lang/Throwable ]
  37. frame_type = 250 /* chop */
  38. offset_delta = 4

1.3.2 修饰同步方法

查看同步方法的字节码,Synchronized修饰同步方法时,并没有使用monitorenter和monitorexit指令,是使用了
ACC_SYNCHRONIZED 标志。JVM通过ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将会先持有Monitor对象,然后再执行方法。在该方法运行期间,其他线程将无法获取到该Monitor对象,当方法执行完成后,再释放该Monitor对象。

  1. public synchronized void method1();
  2. descriptor: ()V
  3. flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志
  4. Code:
  5. stack=0, locals=1, args_size=1
  6. 0: return
  7. LineNumberTable:
  8. line 8: 0

1.3.3 锁原理

JVM的同步是基于进入和退出管程(Monitor)对象实现的。每个对象示例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor是由ObjectMonitor实现的,而ObjectMonitor是由C++的ObjectMonitor.hpp文件实现

  1. ObjectMonitor() {
  2. _header = NULL;
  3. _count = 0; // 记录个数
  4. _waiters = 0,
  5. _recursions = 0;
  6. _object = NULL;
  7. _owner = NULL;
  8. _WaitSet = NULL; // 处于 wait 状态的线程,会被加入到 _WaitSet
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL ;
  11. _succ = NULL ;
  12. _cxq = NULL ;
  13. FreeNext = NULL ;
  14. _EntryList = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
  15. _SpinFreq = 0 ;
  16. _SpinClock = 0 ;
  17. OwnerIsThread = 0 ;
  18. }

当多个线程同事访问一段同步代码时,多个线程会先被存放在EntryList集合中,处于block状态的线程,都会被加入到该列表。当线程获取到对象的Monitor时,是通过底层操作系统的Mutex Lock来实现互斥的,线程申请Mutex成功,则持有该Mutex,其它线程无法获取到该Mutex。
如果线程调用wait()方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex.
图片.png

1.3.4 总结

Monitor是依赖底层的操作系统实现,存在用户态和内核态之间的切换,所以增加了性能开销。

2. 锁升级优化

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

2.1 Java对象头

在JDK 1.6 JVM中,对象实例在堆内存中被分为了三个部分:对象头、实力数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。
Mark Word记录了对象和锁有关的信息,在64位JVM中的长度是64bit,存储结构如下表

锁状态 25 bit(unused) 31 bit(hashcode) 4 bit 1 bit 2 bit
54 bit 2 bit 是否偏向锁 锁标志位
无锁 对象的HashCode 分代年龄 0 01
偏向锁 线程ID Epoch 分代年龄 1 01
轻量级锁 指向轻量级锁的指针 00
重量级锁 指向重量级锁的指针 10
GC标志 11

锁升级功能依赖于Mark Word中的锁标志位和是否偏向锁标志位,Synchronized同步锁是从偏向锁开始的,随着竞争的加剧,偏向锁升级至轻量级锁,最后至重量级锁。

2.1.1 偏向锁

偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,比如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。
偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的MarkWord中取判断一下是否有偏向锁指向它的ID,无需再进入Monitor去竞争对象。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,“是否偏向锁”标志位设置为1,并且记录抢到锁的线程ID,表示进入偏向锁状态。
一旦出现其他线程竞争资源锁资源时,偏向锁就会被撤销。偏向锁的插销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之被其他线程抢占。
因此,在高并发场景下,当大量线程同时竞争同一个资源时,偏向锁就会被撤销,发生stop-the-world后,开启偏向锁无疑会带来更大的性能开销,这时可以通过添加JVM参数关闭偏向锁来跳跃性能,指令如下

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

2.1.2 轻量级锁

当有另外一个线程竞争这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID不是自己的线程ID,就会进行CAS操作获取锁, 如果获取成功,直接替换Mark Word中的线程为自己的ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有竞争,偏向锁将升级成轻量级锁。
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

2.1.3 自旋锁与重量级锁

轻量级锁CAS抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源了。
JVM提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间不会太长,线程被挂起阻塞会导致性能降低。
从JDK 1.7开始,自旋锁默认启用,自旋次数由JVM设置决定,这里笔者建议不要将重试次数设置过多,因为CAS重试操作意味着长时间占用CPU。
自旋锁重试之后如果抢锁依然失败,同步锁就会升级成重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会进入Monitor, 之后会被阻塞在_WaitSet队列中。
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或者所占用时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。
在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统性能,如下

  1. -XX:-UseSpinning //参数关闭自旋锁优化(默认打开)
  2. -XX:-PreBlockSpin //参数修改默认的自旋次数 JDK1.7后,此参数已经无用,次数由JVM控制

2.1.4 动态编译是锁消除/锁粗化

除了锁升级优化,Java还使用了编译器对锁进行优化。JIT编译器在动态编译同步块的时候,借助逃逸分析,来判断同步块使用的锁对象是否能够只被一个线程访问,而没有被发布到其他线程。如果是的话,那么JIT编译器在编译这个同步块的时候不会生成synchronized所表示的锁的申请与释放的机器码,即消除了锁的使用。锁粗化是JIT编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那边JIT编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。

2.1.5 减小锁粒度

除了锁内部优化和编译器优化以外,我们仍然可以通过代码层来实现锁的优化,减少锁粒度就是一种惯用的方法。当锁对象是一个数组或队列,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。可以考虑将一个数组和队列拆成多个小对象,来降低锁竞争,提升并行度。

3. 总结

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