Java中提供了两种实现同步的基础语义: synchronized 方法和 synchronized 块 ,先来个案例进行分析!
public class SyncTest {
public void syncBlock(){
synchronized (this){
System.out.println("sync block balabala....");
}
}
public synchronized void syncMethod(){
System.out.println("sync method hahaha....");
}
public static void main(String[] args) {
}
}
将SyncTest.java 编译为SyncTest.class文件,我们使用 javap -v SyncTest.class 查看class文件对应的JVM字节码信息。这里我使用的是JVM版本是JDK1.8。首先看看syncBlock()方法的字节码:
再看看syncMethod()方法的字节码:
从上面字节码可以看出,对于Synchronized 关键字而言,javac 在编译时,会生成对应的 monitorenter 和monitorexit指令,分别对应sync同步块进入和退出同步代码块,这里读者很容易发现有两个monitorexit 退出指令,原因是为了保证在程序抛出异常时最终也会释放锁,
所有javac为同步代码块添加了一个隐式的try-finally,在finally中会调用 monitorexit 指令释放锁。而对于Synchronized方法而言,javac 为其生成一个 ACC_SYNCHRONIZED 关键字,在JVM进行方法调用时,发现调用的方法被 ACC_SYNCHRONIZED修饰时,则会先尝试获取锁。
依赖于系统的同步函数,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高,这使得传统意义上的锁(重量级锁)效率低下。
在JDK1.6 之前,Synchronized 只有传统意义上的锁,而在JDK1.6进口了两种新型锁机制(偏向锁和轻量级锁),它们的引入是为了解决在多线程并发不高场景下使用传统锁(重量级锁)带来的性能开销问题。
在了解这几种锁的实现机制之前,我们先来了解下对象头,它是多种锁机制的基础。
对象头
因为在java中任意对象都可以用作锁,因此必然需要有一个映射关系(存储该对象及其对应的锁信息),比如当前那个线程持有锁,哪些线程在等待。这就有点类似于我们学习的Map,但是如果使用Map来记录这些对应关系,需要保证Map集合的线程安全问题,
不同的Synchronized之间会相互影响,性能差,另外,当同步对象比较多时,该Map会占用比较多的内存。
why 使用对象头?因为对象头本身也有一些hashcode、GC相关数据。在JVM中,对象在内存中除了本身数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark work 和类型指针。
另外对于数组而言还会有一份记录数组长度的数据。mark work用于存储对象的hashcode 、GC分代年龄、锁状态等信息。在32位系统上 mark work长度是32bit,64位系统是64bit.为了能在有限的空间中存储更多的信息,其存储格式是不固定的,下面分别对应32bit 操作系统和64bit操作系统:
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |状态
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |无锁态
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |偏向锁
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |轻量级锁
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |重量级锁
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |GC标记
|-------------------------------------------------------|--------------------|
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |状态
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |无锁态
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |偏向锁
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |轻量级锁
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |重量级锁
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |GC标记
|------------------------------------------------------------------------------|--------------------|
可以看到锁信息是存在对象的 mark work 中的。当对象状态为:
Biased
),Mark
Word
存储的是偏向的线程ID;Lightweight
Locked
),Mark
Word
存储的是指向线程栈中 lock_record的指针
;
Heavyweight
Locked
),存储的是指向堆中的 monitor
对象指针。当状态为重量级锁(Heavyweight
Locked)
时存储的是指向堆中的monitor
对象的指针 。那么这个monitor
对象包括哪些信息呢?
一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。其中cxq ,EntryList ,WaitSet都是ObjectWaiter的链表结构,owner指向持有锁的线程。
1、ContentionList :竞争队列,所有请求锁的线程首先被放在这个竞争队列中 2、EntryList:ContentionList中那些有资格成为候选资源的线程被移动到EntryList中 3、WaitSet:那些调用wait方法被阻塞的线程被放置在这里 4、OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程成功OnDeck 5、Owner:当前已经获取到资源的线程称为Owner 6、!Owner:当前释放锁的线程。
JVM 每次从队列尾部取出一个数据用于锁竞争候选者(OnDeck),但是在并发情况下,ContentionList 会被大量的并发线程进行CAS访问,为了降低尾部元素的竞争,JVM会将一部分线程移动到 EntryList 中作为候选竞争线程。Owner线程并不是直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁,这样虽然牺牲了一些公平性,但是能极大提高系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList 中,如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻被notify或者notifyAll唤醒,会重新进入EntryList 中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的。
Synchronized是非公平锁,在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
偏向锁,顾名思义,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程竞争的情况,则线程是不需要触发同步机制,在这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其它线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁!
加锁过程
1、访问Mark workd中的偏向锁的标识是否设置为1,锁标志位是否为01,确认为可偏向状态。 2、如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤三。 3、如果线程ID并未指向当前线程,则通过CAS操作竞争锁,如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,则执行4. 4、如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致STW,但时间很短). 5、执行同步代码。
解锁过程
偏向锁的撤销在上述第四步中有提到。偏向锁只有遇到其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤离需要等待全局安全点(在某个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。
另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令 -XX:BiasedLockingStartupDelay=0
来关闭延迟。
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入到锁争用的时候,偏向锁就会升级为你轻量级锁;
加锁过程
1.在代码进入同步块的时候,如果同步对象锁状态为无锁状态,或者偏向锁状态,虚拟机首先将当前线程的栈帧中建立一个锁记录(Lock Record)空间,用于存储锁对象目前的mark word的拷贝。 2、拷贝对象头中的mark word 复制到锁记录(Lock Record)中; 3.拷贝成功后,直接通过CAS指令将
Lock Record
的地址存储在对象头的mark word
中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。 4.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record
第一部分(Displaced Mark Word
)为null,起到了一个重入计数器的作用。然后结束。 5.走到这一步说明发生了竞争,需要膨胀为重量级锁,锁标志位变为10,。
解锁过程
1.遍历线程栈,找到所有
obj
字段等于当前锁对象的Lock Record
。 2.如果Lock Record
的Displaced Mark Word
为null,代表这是一次重入,将obj
设置为null后continue。 3.如果Lock Record
的Displaced Mark Word
不为null,则利用CAS指令将对象头的mark word
恢复成为Displaced Mark Word
。如果成功,则continue,否则膨胀为重量级锁。