[关闭]
@boothsun 2018-03-26T11:49:48.000000Z 字数 11709 阅读 2130

synchronized 使用及原理

Java多线程


优秀博文地址:
1. 深入理解Java并发之synchronized实现原理
2. java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁
3. jdk源码剖析二: 对象内存布局、synchronized终极原理

synchronized是Java中非公平的互斥锁。在JDK1.5及之前,synchronized是一个重量级锁。但是随着Java SE 1.6对synchronized进行了各种优化之后,synchronized也不再那么重了。

synchronized的使用

Java中的每一个对象都可以作为锁。

synchronized修饰非静态方法:

此时的锁是实例对象,也就是说一个类的多个实例对象之间不共享同一把锁,各自都有各自的锁,也能并执行。但是对于同一个实例对象,多个加锁实例方法,则不能并发执行,比如有A、B两个非静态方法被synchronized修饰,则同一个时刻,只能执行其中的一个方法。

  1. // synchronized修饰非静态方法,多个实例对象,不共享同一把锁
  2. public class synchronizedTest implements Runnable {
  3. static int num = 0 ;
  4. public static void main(String[] args) throws Exception {
  5. Thread t1 = new Thread(new synchronizedTest());
  6. Thread t2 = new Thread(new synchronizedTest());
  7. t1.start();
  8. t2.start();
  9. t1.join();
  10. t2.join();
  11. System.out.println(synchronizedTest.num);
  12. }
  13. @Override
  14. public synchronized void run() {
  15. for(int i = 0 ; i < 1000 ; i++) {
  16. num++ ;
  17. }
  18. }
  19. }

image.png-18.5kB

synchronized修饰静态方法

假设类中有一个static的方法M1和一个非static方法M2,且M1和M2都使用synchronized修饰,则两个线程各自并发访问M1和M2方法,这两个线程不会互斥,因为static方法使用的是当前类作为锁,而非static方法使用当前类实例对象作为锁。

  1. /**
  2. * 静态方法使用Class实例对象作为锁,而非静态方法使用对象实例作为锁
  3. */
  4. public class synchronizedTest {
  5. public static void main(String[] args) throws Exception {
  6. Thread t1 = new Thread(()->{
  7. new Operation().add();
  8. },"Non-Static");
  9. Thread t2 = new Thread(Operation::subtraction,"Static");
  10. t1.start();
  11. t2.start();
  12. t1.join();
  13. t2.join();
  14. }
  15. }
  16. class Operation {
  17. public synchronized void add () {
  18. System.out.println(Thread.currentThread().getName() + "进入 add方法");
  19. try {
  20. Thread.sleep(2 * 1000L);
  21. } catch (InterruptedException e) {
  22. e.printStackTrace();
  23. }
  24. System.out.println(Thread.currentThread().getName() + "离开 add方法");
  25. }
  26. public static synchronized void subtraction () {
  27. System.out.println(Thread.currentThread().getName() + "进入 subtraction方法");
  28. try {
  29. Thread.sleep(2 * 1000L);
  30. } catch (InterruptedException e) {
  31. e.printStackTrace();
  32. }
  33. System.out.println(Thread.currentThread().getName() + "离开 subtraction方法");
  34. }
  35. }

synchronized修饰代码块

锁由外部传入,可以是一个对象实例也可以是Class对象等。

synchronized支持重入

synchronized是支持可重入的内置锁。

  1. public class synchronizedTest {
  2. static AtomicInteger getLock = new AtomicInteger(1);
  3. static AtomicInteger realseLock = new AtomicInteger(0);
  4. public static void main(String[] args) throws Exception {
  5. Operation.subtraction();
  6. }
  7. }
  8. class Operation {
  9. static synchronized void doSomething() {
  10. System.out.println(Thread.currentThread().getName() + " 第 " + getLock.intValue() + " 次获得锁");
  11. getLock.incrementAndGet();
  12. try {
  13. Thread.sleep(2 * 1000L);
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. realseLock.incrementAndGet();
  18. System.out.println(Thread.currentThread().getName() + " 第 " + realseLock.intValue() + " 次释放锁");
  19. }
  20. static synchronized void subtraction() {
  21. System.out.println(Thread.currentThread().getName() + " 第 " + getLock.intValue() + " 次获得锁");
  22. getLock.incrementAndGet();
  23. doSomething();
  24. realseLock.incrementAndGet();
  25. System.out.println(Thread.currentThread().getName() + " 第 " + realseLock.intValue() + " 次释放锁");
  26. }
  27. }

image.png-7.1kB

我们知道上面的代码逻辑是同一个线程先获取subtraction方法上的锁,再获取doSomething上的锁,由于这两个方法都是Operation类的静态方法,所以这两个方式使用的同一个锁(Operation.class上的锁)。从程序无死锁成功运行结束,我们知道上面的synchronized是可以重入的。

这里 常考的一个点 就是父类中有一个加锁的方法A,而子类中也有一个加锁的方法B,B在执行过程中,会调用A方法,问此时会不会产生死锁?

synchronized 到底是公平锁还是非公平锁?

synchronized是非公平锁,具体可以参见将synchronized的实现原理部分

结论:

synchronized实现原理

synchronized底层语义原理

Java虚拟机中的同步(Synchronization)是基于进入和退出监视器(Monitor)对象实现的, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

理解Java对象头与Monitor

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

image.png-46.1kB

而对于顶部,则是Java头对象,它是实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:

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

由于Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等信息,下面是32位JVM的Mark Word默认存储结构:

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

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能会变化为存储以下4中数据,如下表所示:

image.png-71.8kB

其中轻量级锁和偏向锁是Java 6 对 synchronized锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

  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. }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor后进入_Owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:

image.png-195.9kB

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析)。有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。

synchronized代码块底层原理

现在我们重新定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i,如下:

  1. public class SyncCodeBlock {
  2. public int i;
  3. public void syncTask(){
  4. //同步代码库
  5. synchronized (this){
  6. i++;
  7. }
  8. }
  9. }

编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):

  1. public class com.zejian.concurrencys.SyncCodeBlock
  2. minor version: 0
  3. major version: 52
  4. flags: ACC_PUBLIC, ACC_SUPER
  5. Constant pool:
  6. //........省略常量池中数据
  7. //构造函数
  8. public com.zejian.concurrencys.SyncCodeBlock();
  9. descriptor: ()V
  10. flags: ACC_PUBLIC
  11. Code:
  12. stack=1, locals=1, args_size=1
  13. 0: aload_0
  14. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  15. 4: return
  16. LineNumberTable:
  17. line 7: 0
  18. //===========主要看看syncTask方法实现================
  19. public void syncTask();
  20. descriptor: ()V
  21. flags: ACC_PUBLIC
  22. Code:
  23. stack=3, locals=3, args_size=1
  24. 0: aload_0
  25. 1: dup
  26. 2: astore_1
  27. 3: monitorenter //注意此处,进入同步方法
  28. 4: aload_0
  29. 5: dup
  30. 6: getfield #2 // Field i:I
  31. 9: iconst_1
  32. 10: iadd
  33. 11: putfield #2 // Field i:I
  34. 14: aload_1
  35. 15: monitorexit //注意此处,退出同步方法
  36. 16: goto 24
  37. 19: astore_2
  38. 20: aload_1
  39. 21: monitorexit //注意此处,退出同步方法
  40. 22: aload_2
  41. 23: athrow
  42. 24: return
  43. Exception table:
  44. //省略其他字节码.......
  45. }

我们主要关注字节码中的如下代码:

  1. 3: monitorenter //进入同步方法
  2. //..........省略其他
  3. 15: monitorexit //退出同步方法
  4. 16: goto 24
  5. //省略其他.......
  6. 21: monitorexit //退出同步方法

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

synchronized方法底层原理

方法级的同步是隐式的,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

  1. public class SyncMethod {
  2. public int i;
  3. public synchronized void syncTask(){
  4. i++;
  5. }
  6. }

使用javap反编译后的字节码如下:

  1. Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
  2. Last modified 2017-6-2; size 308 bytes
  3. MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
  4. Compiled from "SyncMethod.java"
  5. public class com.zejian.concurrencys.SyncMethod
  6. minor version: 0
  7. major version: 52
  8. flags: ACC_PUBLIC, ACC_SUPER
  9. Constant pool;
  10. //省略没必要的字节码
  11. //==================syncTask方法======================
  12. public synchronized void syncTask();
  13. descriptor: ()V
  14. //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
  15. flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  16. Code:
  17. stack=3, locals=1, args_size=1
  18. 0: aload_0
  19. 1: dup
  20. 2: getfield #2 // Field i:I
  21. 5: iconst_1
  22. 6: iadd
  23. 7: putfield #2 // Field i:I
  24. 10: return
  25. LineNumberTable:
  26. line 12: 0
  27. line 13: 10
  28. }
  29. SourceFile: "SyncMethod.java"

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的;并且我们知道,Java线程是会映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态,因此状态转换也需要耗费很多的处理器时间。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。

更细化的锁实现原理

synchronized的实现如下图所示:

image.png-189.2kB

它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

  1. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
  2. Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
  3. Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
  5. Owner:当前已经获取到所资源的线程被称为Owner;
  6. !Owner:当前释放锁的线程。

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

Java虚拟机对synchronized的优化

在JAVA SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁、轻量级锁、重量级锁,这几种状态会随着竞争情况逐渐升级,锁可以升级但不能降级。

偏向锁

出现的理论基础:经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让同一线程获得同一个锁的代价更低而引入了偏向锁。(可重入的支持)

实现思路:

  1. 当锁对象第一次被线程获取的时候,虚拟机把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中的偏向线程ID,并将是否偏向锁的状态位置置为1。

  2. 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,直接检查ThreadId是否和自身线程Id一致, 如果一致,则认为当前线程已经获取了锁,虚拟机就可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。

  3. 当同时有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。会先测试一下Mark Word中偏向锁的标识是否设置为1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程,指向失败则对偏向锁进行撤销。

偏向锁的撤销:

偏向锁可以提供带有同步但无竞争的程序性能。如果程序中大多数的锁总是被多个不同的线程访问,那偏向锁模式将不再适用。

轻量级锁

作用: 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

加锁过程:

线程在执行同步块之前,如果此同步对象没有被锁定(锁状态标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象的目前的Mark Word的拷贝(官方称为:Displaced Mark Word)。

然后虚拟机将使用CAS操作尝试将对象头中的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个Bits)将转变为“00”,即表示此对象处于轻量级锁定的状态。

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争抢同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

释放锁的过程:

轻量级解锁也是通过CAS操作来进行的,如果对象的Mark Word仍然指向这线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁与重量级锁的比较:

轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争很大的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

总结:

  1. 偏向锁:是在需要同步但实际中无竞争的情况下,使用偏向第一次获取锁的线程的形式来减少 重量级锁的同步带来的性能问题。
  2. 轻量级锁:是在竞争不大的情况下(不超过两条线程同步竞争),使用CAS里代替互斥量的开销。

三种锁的优缺点对比

image.png-143.9kB

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注