Synchronized的几个灵魂拷问
一、synchronized的简单介绍
关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代volatile功能)。简单来说概括就是三个特性:
- 原子性:确保线程互斥的访问同步代码;
- 可见性:可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到
- 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”
二、synchronized应用
1.synchronized使用场景
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
2.synchronized使用的注意事项:
- 若是对象锁,则每个对象都持有一把自己的独一无二的锁,且对象之间的锁互不影响 。若是类锁,所有该类的对象共用这把锁。
- 一个线程获取一把锁,没有得到锁的线程只能排队等待;
- synchronized 是可重入锁,避免很多情况下的死锁发生。
- synchronized 方法若发生异常,则JVM会自动释放锁。
- 锁对象不能为空,否则抛出NPE(NullPointerException)
- synchronized 本身是不具备继承性的:即父类的synchronized 方法,子类重写该方法,分情况讨论:没有synchonized修饰,则该子类方法不是线程同步的。
- synchronized本身修饰的范围越小越好。毕竟是同步阻塞。
3.synchronized的常见问题
-
同时访问synchronized的静态和非静态方法,能保证线程安全吗?
不能,两者的锁对象不一样。前者是类锁(XXX.class),后者是this -
同时访问synchronized方法和非同步方法,能保证线程安全吗?
结论:不能,因为synchronized只会对被修饰的方法起作用。 -
两个线程同时访问两个对象的非静态同步方法能保证线程安全吗?
结论:不能,每个对象都拥有一把锁。两个对象相当于有两把锁,导致锁对象不一致。(PS:如果是类锁,则所有对象共用一把锁) -
若synchronized方法抛出异常,会导致死锁吗?
JVM会自动释放锁,不会导致死锁问题 -
若synchronized的锁对象能为空吗?会出现什么情况?
锁对象不能为空,否则抛出NPE(NullPointerException) -
若synchronized的锁对象能为空吗?会出现什么情况?
锁对象不能为空,否则抛出NPE(NullPointerException)
三、synchronized原理
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
1、关于Java对象头与Monitor
对象在内存中的布局分为三块区域:1、对象头、2、实例数据和3、对齐填充。
- 实例变量:存放类的属性数据信息
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
- Java头对象:Mark Word 和 Class Metadata Address 组成
头对象结构 | 说明 |
---|---|
Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
Mark Word 被设计成为一个非固定的数据结构,默认的存储结构如下:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:
32位JVM的Mark Word可能存储4种数据由synchronized的对象锁,指针指向的是monitor对象(也称为管程或监视器锁)的地址,所以每个对象都存在着一个 monitor 与之关联,monitor是由ObjectMonitor实现的(C++实现的),源码如下:
ObjectMonitor() {
... //其他忽略,核心为如下三个
_count = 0; //记录个数
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}
可以看到ObjectMonitor中有两个队列,_WaitSet 和 _EntryList 用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),其工作流程大致如下:
- 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合。
- 当线程获取到对象的monitor 后进入 _Owner 区域,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。
- 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
2、synchronized字节码语义
将synchronized修饰的同步代码块利用javap反编译后得到字节码如下:
我们主要需要关注如下:
3: monitorenter //进入同步方法
//..........省略其他
13: monitorexit //退出同步方法
14: goto 22
//省略其他.......
19: monitorexit //退出同步方法
monitorenter指令,线程尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit指令,线程执行完毕释放锁,过程如下:
- monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。
- monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
3、synchronized方法的底层原理
上面讲的是同步代码块的方式,方法级的同步是隐式,无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
- 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后在方法完成( 无论是正常完成还是非正常完成 )时释放monitor。
- 在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。
//省略没必要的字节码
public synchronized void syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
}
四、synchronized的改进与优化
Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
在Java 6之后Java官方对从JVM层面对synchronized较大优化,引入了轻量级锁和偏向锁,锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。由轻到重的顺序就是:偏向锁-->轻量级锁-->重量级锁。 JDK 1.6 中默认是开启偏向锁和轻量级锁的。
1.偏向锁
适用于:不存在多线程竞争,而且总是由同一线程多次获得。
偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。对于没有锁竞争的场合,偏向锁有很好的优化效果。
2.轻量级锁
适用于:当锁竞争升级了后,有可能每次申请锁的线程都是不相同的,但时线程交替执行同步块的场合,,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁的思想是依赖经验情况,对绝大部分的锁,在整个同步周期内都不存在竞争。
3.重量级锁
轻量级锁失败后,虚拟机为了避免线程在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
4.锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
//StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
//因此sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 10000000; i++) {
rmsync.add("abc", "123");
}
}
}
syncchronized的深度思考
1、面试官:为什么synchronized无法禁止指令重排,却能保证有序性?
首先,最好的解决有序性问题的办法,就是禁止处理器优化和指令重排,就像volatile中使用内存屏障一样。但是synchronized没有使用内存屏障。
在synchronized这边,加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞。所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的。在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义,所有硬件优化的前提都是必须遵守as-if-serial语义(as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变)。
因为有as-if-serial语义保证,单线程的有序性就天然存在了。
2、既然synchronized是"万能"的,为什么还需要volatile呢?
这个是针对DSL的单例模式来谈的,我们知道对singleton使用volatile约束,保证他的初始化过程不会被指令重排。但是synchronized是无法禁止指令重排和处理器优化的。也就是只看Thread1的话,因为编译器会遵守as-if-serial语义,所以这种优化不会有任何问题,对于这个线程的执行结果也不会有任何影响。但是Thread1内部的指令重排却对Thread2产生了影响。
我们可以说,synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。