众所周知synchronized关键字是解决并发问题常用的解决方案,有以下三种使用方式:
- 同步普通方法,锁的是当前对象
- 同步静态方法,锁的是当前Class对象
- 同步块,锁的是()中的对象
实现原理:
JVM是通过进入、退出对象管程(Monitor)来实现对方法、同步块的同步的。
无论是显式同步(有明确monitorenter和monitorexit指令,即同步代码块)还是隐式同步都是如此。同步方法并不是右monitorenter和monitorexit指令来实现同步的,而是由方法条用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标识来隐式实现的。
大概流程图如下:
通过一段代码来演示同步代码块:
public static void main(String[] args) {
synchronized (Synchronize.class){
System.out.println("Synchronize");
}
}
使用javap -c Synchronized 可以查看编译之后的具体信息。
public class com.crossoverjie.synchronize.Synchronize {
public com.crossoverjie.synchronize.Synchronize();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // class com/crossoverjie/synchronize/Synchronize
2: dup
3: astore_1
**4: monitorenter**
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String Synchronize
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
**14: monitorexit**
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
}
可以清楚的看到在同步代码块的入口和出口分别有monitorenter,monitorexit指令,也成称之为显示同步。下面先了解一些java对象头,这有助于我们更加深入了解synchronized实现原理
理解java对象头与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图:
- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括长度,这部分内存按4字节对齐
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这里只需了解
而对于顶部,这是java对象头,它实现synchronized锁对象的基础,这点我们来重点分析,一般而言,synchronized使用的锁对象是存储在java对象头里的,jvm中采用2个字节来存储对象头(如果对象是数组则会分配3个字节,多出来的1个字节记录的数组长度),其主要结构是右Mark Word 和Class Metadata Address 组成,其结构说明如下表
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64 bit | Mark Word | 存储对象的hashcode、锁信息或分代年龄或GC标志信息 |
32/64 bit | Class Metadata Address | 类型指针指向对象的类远数据,JVM通过这个指针确定该对象是哪个类的实例 |
其中Mark Word在默认情况下存储着对象的hashcode、分代年龄、锁标记为等。以下是32JVM的Mark Word默认的存储结构
锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成一个非固定的数据结构,以便存储更多的有效数据,它会根据对象的状态服用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet和_EntryList,用于保存ObjectWaiter对象列表(每个等待的线程都会别封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取对象的monitor后进入_owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count+1,若线程条用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count-1,同时该线程进入_WaitSet集合中等待唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:
锁优化
Synchronized大家都愿意称之为重量级锁,JDK1.6中对synchronized进行了各种优化,为了减少获取和释放锁带来的消耗引入了偏向锁和轻量锁。
偏向锁
java6之后加入的新锁,是一种针对加锁操作的优化手段。经过调查发现,在大多数情况下,锁不仅存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也编程偏向锁结构,当这个线程再次请求锁时,无需在做任何同步操作,即获得锁的过程,这样就省去大量有关锁申请的操作,从而也就提高了程序的性能。当锁竞争很激烈的场合,偏向锁就会失效,当不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量锁
当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Recode)区域,同时将锁对象的对象头中Mark Word拷贝到锁记录中,再尝试使用CAS和Mark word更新为指向锁记录的指针。
如果更新成功,当前线程就获得了锁。
如果更新失败JVM会先检查锁对象的Mark Word是否指向当前线程的锁记录。
如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。
不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量锁
自旋锁
轻量级锁失败之后,JVM为了避免线程真实地在操作系统层面挂起,还会进行一项成为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,关系到用户态转换到核心态,这个转换相对来说比较耗时间。假设在不久的将来,当前线程能获得锁,因此JVM会让当前想要获取锁的线程做几个空循环,一般是50或100个循环,循环过程中如果得到了锁,就顺利进入临界区。如果还不能获取到锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。
锁消除
jvm在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁。如StringBuffer的append使用同步方法,但是在add方法中StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情况,JVM会自动将其锁消除。