1、概述
synchronized是一个同步关键字,在某些多线程场景下,如果不进行同步会导致共享数据不安全,而synchronized关键字就可以用于代码同步 。
本文主要介绍Synchronized的原理和优化。要弄明白原理,得先明白2个东西,一个是java对象的内存布局(重点关注mark word),还有一个Monitor。
2、java对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
2.1 对象头
java对象的对象头由两部分组成,一个是mark word,一个是类型指针。要注意的是如果是数组对象,还会有一个额外的部分用于存储数组长度。
2.1.1 Mark Word
mark word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特。
由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态,这些状态下对象头的存储内容如图所示:
2.1.2 类型指针
类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。Java对象的类数据保存在方法区。
2.1.2 数组长度(只有数组对象才有)
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度。如果对象是数组类型,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
2.2 实例数据
实例数据部分存放类的属性数据信息,包括父类的属性信息.
通过示例说明每个区域具体存放哪些内容:
class Student {
private String name;public Student(String name) {
this.name = name;
}
}public class Demo {
public static void main(String[] args) {
Student studentA = new Student("zhangsan");
Student studentB = new Student("lisi");
}
}
JVM结构图如下所示:
2.3 对齐数据
对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作 用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是 任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
3、对象监视器ObjectMonitor
ObjectMonitor对象监视器结构如下:
ObjectMonitor() {
_header = NULL; //锁对象的原始对象头
_count = 0; //抢占当前锁的线程数量
_waiters = 0, //调用wait方法后等待的线程数量
_recursions = 0; //记录锁重入次数
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor的线程
_WaitSet = NULL; //处于wait状态的线程队列,等待被唤醒
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //等待锁的线程队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
这里有四个属性需要重点关注:
1、count 抢占当前锁的线程数
2、owner 指向持有ObjectMonitor的线程
3、recursions 记录锁重入次数
4、EntryList 存储等待锁的线程队列
4、原理
synchronized实现代码同步主要用到了monitor,一个对象拥有一个监视器monitor,当判断需要加锁的时候会判断monitor的_count是否为0,如果为0则把_count +1,同时owner指向当前线程。如果count不为0且owner不是自己,则获取不到owner的所有权,同时将线程塞入entrylist中等待。获取到owner的线程执行完同步代码后会将count减1,把owner置空,同时唤醒entrylist中所有阻塞的线程;让他们去竞争owner,这种竞争是非公平的,不是先到先得(所以说synchronized是非公平锁)。通过这样互斥的行为就保证了多个线程只能有一个获取到monitor中owner的所有权。从而保证了代码的同步。
上面说的是一个大概流程,能初步解释 synchronized是怎么实现代码的同步,还有一些细节需要进一步阐述;
比如上面说了“判断需要加锁”,那synchronized是怎么判断要不要加锁的呢?
synchronized主要使用在三个地方,一个是修饰静态方法,一个是普通方法,还有一个是代码块。当修饰的是普通方法和静态方法时候,观察字节码文件会发现有个方法的flags里有个ACC_SYNCHRONIZED,静态方法多个ACC_STATIC,修饰代码块的时候,在字节码文件的方法的code中会有monitorenter和monitorexit来控制同步的开始和结束。ACC_SYNCHRONIZED会隐式的调用monitorenter和monitorexit。
下面写的一段代码:
public class Test3 {private static String a;public synchronized static void test(){a = "zzw";}public synchronized void test2(){a = "zzw";}public static void test3(){synchronized (Test3.class){a = "zzw";}}public void test4(){a = "zzw";} }
编译后, 在它的class文件目录,使用cmd命令,然后使用 javap -v
这里有个技巧:windows进入目录后直接在路径中全选然后输入cmd再回车就可以了。
上面是加了synchronized的,下面是没有
方法块中你会发现有2个monitorexit,这是因为synchronized有个隐式的trycatch,保证有异常也能释放锁。第二个monitorexit就是出现异常后保证退出的。
5、锁优化
JDK1.6之前,synchronized是重量级锁,性能较差,为什么呢?在JVM中monitorenter和monitorexit命令依赖操作系统mutex lock来实现的,是同mutex luck需要将当前线程从用户态切换到内核态来申请锁资源,申请完再切换用户态。整个切换流程是非常消耗资源的。
为了解决这个问题,JDK1.6中对synchronized进行了大量的优化,锁自旋,锁膨胀,锁消除,锁粗化等。
5.1 自旋锁
在jdk1.6前多线程竞争锁时,当一个线程A获取锁时,它会阻塞其他所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。由于在实际环境中,很多线程的锁定状态只会持续很短的一段时间,会很快释放锁,为了如此短暂的时间去挂起和阻塞其他所有竞争锁的线程,是非常浪费资源的,我们完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间,等待持有锁的线程A释放锁,就里面去获得锁。这其实就是自旋锁
但是我们也无法保证线程获取锁之后,就一定很快释放锁。万一遇到有线程,长时间不释放锁,其会带来更多的性能开销。因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会消耗掉CPU资源。所以我们需要对锁自旋的次数有所限制,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该重新使用传统的方式去挂起线程了。在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。
后来也有改进型的自适应自旋锁,自适应意味着自旋的次数不在固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态共同决定。 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很可能再次成功的,进而它将会允许线程自旋相对更长的时间。如果对于某个锁,线程很少成功获得过,则会相应减少自旋的时间甚至直接进入阻塞的状态,避免浪费处理器资源。
5.2 锁粗化
一般来说,同步块的作用范围应该尽可能小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快获取锁 但某些情况下,可能会对同一个锁频繁访问,或者有人在循环里面写上了synchronized关键字,为了降低短时间内大量的锁请求、释放带来的性能损耗,Java虚拟机发现了之后会适当扩大加锁的范围,以避免频繁的拿锁释放锁的过程。将多个锁请求合并为一个请求,这就是锁粗化
public class LockCoarseningTest {
public String test() {
StringBuffer sb = new StringBuffer();
for(int i = 0; i < 100; i++) {
sb.append("test");
}
return sb.toString();
}
}
append() 为同步方法,短时间内大量进行锁请求、锁释放,JVM 会自动进行锁粗化,将加锁范围扩大至 for 循环外部,从而只需要进行一次锁请求、锁释放
5.3 锁消除
锁消除:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。其实就是即时编译器通过对运行上下文的扫描,对不可能存在共享资源竞争的锁进行消除,从而节约大量的资源开销,提高效率
public class LockEliminateTest {
static int i = 0;
public void method1() {
i++;
}
public void method2() {
Object obj = new Object();
synchronized (obj) {
i++;
}
}
}
method2() 方法中的 obj 为局部变量,显然不可能被共享,对其加锁也毫无意义,故被即时编译器消除
5.4 锁膨胀
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 偏向锁、轻量级锁,这两个锁既是一种优化策略,也是一种膨胀过程,接下来我们分别聊聊
6. 锁升级(锁膨胀)
6.1 偏向锁
在大多数情况下虽然加了锁,但是没有锁竞争的发生,甚至是同一个线程反复获得这个锁,那么多次的获取锁和释放锁会带来很多不必要的性能开销和上下文切换。偏向锁就为了针对这种情况而出现的。
假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK 6
起HotSpot虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)
6.2 偏向锁升级到轻量级锁
当另一个线程试图去尝试获取这个锁的情况,偏向模式就马上宣告结束。JVM会检测到存在潜在竞争,触发偏向锁的撤销(Revoke Bias)。
偏向锁的撤销过程:
-
暂停原线程(STW安全点):JVM需要暂停持有偏向锁的线程(通过安全点),确保其状态稳定。
-
检查原线程状态:
-
若原线程已退出同步块:对象头重置为无锁状态,随后新线程通过CAS竞争轻量级锁(偏向标识
biased_lock=0
,锁标志01,注意
偏向标识 被更新成0,后面再有线程进来就是竞争轻量级锁了)。 -
若原线程仍在同步块中:偏向锁直接升级为轻量级锁(偏向标识
biased_lock=0
,锁标志00)
。JVM会在原线程的栈帧中创建锁记录(Lock Record),并将对象头替换为指向该锁记录的指针(轻量级锁标记)。
-
复习下markword的存储
6.3 轻量级锁
轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁的工作过程:在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图:
然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个
更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图所示:
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,或者自旋获取锁的次数超过阈值(默认自旋次数由 JVM 自适应策略决定,如 -XX:PreBlockSpin
或自适应自旋逻辑),必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。