Synchronize是重量级锁吗?是互斥锁吗?
它的实现原理?
前言
线程安全是并发编程中的重要关注点,造成线程安全问题的主要诱因有两点,一是存在共享数据(也称临界资源),二是存在多个线程共同操作共享数据。因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。
在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。
synchronized底层语义原理
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的
要深入理解synchronized实现原理,就需要先来了解在JVM中Java对象的结构,如图所示:在堆中的对象分为三块区域:对象头、实例数据和对齐填充。
-
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
-
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
- 对象头:如下表格
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
synchronized实现的三种方式
1、同步普通方法
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}
java -P
Classfile src/main/java/com/zejian/concurrencys/SyncMethod.class Last modified 2017-6-2; size 308 bytes MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94 Compiled from "SyncMethod.java" public class com.zejian.concurrencys.SyncMethod minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool; //==================syncTask方法====================== 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 } SourceFile: "SyncMethod.java"
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放
2、同步方法块
public void sync1() {
synchronized(this) {
// do somethings 锁的是当前实例对象
}
}
public void sync2() {
synchronized(MyTest.css) {
// do somethings 锁的的是当前类class对象
}
}
//反编译class文件后,可以看到在sysnchronized
1:flags: ACC_PUBLIC
.............
........
3: monitorenter //进入同步方法 //
4:..........省略其他
15: monitorexit //退出同步方法
16: goto
24 //省略其他.......
21: monitorexit //退出同步方法
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置.
3、同步静态方法
1 public static synchronized void sync2() {
2 // do somethings //锁的是当前类class
3 }
总结:JVM基于进入和退出Monitor对象来实现方法同步和代码块同步, 但是两者的实现细节不一样.
- 代码块同步: 通过使用monitorenter和monitorexit指令实现的.
- 同步方法: ACC_SYNCHRONIZED修饰
锁的竞争
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
JVM 默认几秒后开启偏向锁
如果你确定应用程序中所有的锁通常是在竞争状态,你可以通过JVM参数关闭偏向锁UseBiasedLocking = false,那么程序会默认进入轻量锁状态。
1、偏向锁(A线程独占锁,不用上下文切换。对象头标识)
2、轻量锁(A线程拥有锁,B获取,竞争,自旋(jdk1.7以后智能自转))
如果说偏向锁是为了解决同步代码在单线程下访问性能问题,那么轻量锁是为了解决减少无实际竞争情况下,使用重量级锁产生的性能消耗
轻量锁,顾名思义,轻量是相对于重量的问题,使用轻量锁时,不需要申请互斥量(mutex),而是将mark word中的信息复制到当前线程的栈中,然后通过cas尝试修改mark word并替换成轻量锁,如果替换成功则执行同步代码。如果此时有线程2来竞争,并且他也尝试cas修改mark word但是失败了,那么线程2会进入自旋状态,如果在自旋状态也没有修改成功,那么轻量锁将膨胀成状态,mark word会被修改成重量锁标记(10) ,线程进入阻塞状态。当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
3、自旋锁 (A线程拥有锁,B线程自旋尝试获取)
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,会进行自旋锁的优化。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(默认10次),在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。最后没办法也就只能升级为重量级锁了。
4、重量锁 (B线程自旋获取不到锁,膨胀重量锁,阻塞A线程。直到B执行完)
在jvm规范中,synchronized是基于监视器锁(monitor)来实现的。如前文所提到的,它会在同步代码之前添加一个monitorenter
指令,获取到该对象的monitor,同时它会在同步代码结束处和异常处添加一个monitorexit
指令去释放该对象的monitor,需要注意的是每一个对象都有一个monitor与之配对,当一个monitor被获取之后 也就是被monitorenter
,它会处于一个锁定状态,其他尝试获取该对象的monitor的线程会获取失败,只有当获取该对象的monitor的线程执行了monitorexit
指令后,其他线程才有可能获取该对象的monitor成功。
所以从上面描述可以得出,监视器锁就是monitor
它是互斥的(mutex)。由于它是互斥的,那么它的操作成本就非常的高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了。