synchronized同步
Java中每一个对象都可以作为锁。具体表现为以下3种形式:
-
普通同步方法,锁是当前实例对象
-
静态同步方法,锁是当前类的class对象
-
同步方法块,锁是synchronized括号里配置的对象
当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
public class SyncTest {
public void syncBlock() {
synchronized (this) {
System.out.println("hello block");
}
}
public synchronized void syncMethod() {
System.out.println("hello method");
}
}
当SyncTest.java被编译成class文件的时候,synchronized关键字和synchronized方法的字节码略有不同,我们可以用javap -v 命令查看class文件对应的JVM字节码信息,部分信息如下:
........省略代码........
{
public SyncTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public void syncBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
LineNumberTable:
line 3: 0
line 4: 4
line 5: 12
line 6: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class SyncTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hello method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 8: 0
line 9: 8
}
从上面的中文注释处可以看到,
同步代码块:
对于synchronized关键字而言,javac在编译时,会生成对应的monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。
同步方法:
而对于synchronized方法而言,javac为其生成了一个ACCSYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACCSYNCHRONIZED修饰,则会先尝试获得锁。
对象头
synchronized用的锁是存在Java对象头里的,对象头如下图
- Mark Word:存储了对象的hashcode 以及锁信息、GC年龄等。
- Class Metadata Address:存储类的元信息--Class对象的指针(Class对象在方法区中)。
- Array length:如果数组的话,还会再存储下数组的长度。
在运行期间,MarkWord里存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为以下4中数据
重量级锁
重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制(线程阻塞需要从用户态切换到核心态)去实现Java中的线程同步。
重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。
Monitor 有两个队列 WaitSet 和 _EntryList,存储ObjectWaiter列表(所有等待的线程都会被包装成ObjectWaiter);
① 线程申请owner Monitor对象,首先会被加入到 _EntryList ;
② 线程申请owner Monitor对象,进入到 Owner区域,此时count +1;
③线程调用wait方法,释放锁,进入到 WaitSet ,此时count -1
④ 线程再次申请owner
⑤ 线程处理完毕后释放资源并退出。
轻量级锁
轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁加锁
1、在代码进入同步块的时候,JVM首先会在当前线程的栈帧中建立一个名为锁记录的空间(LockRecord),用于存储锁对象头中目前的MarkWord的拷贝,官方称之为Displaced Mark Word,这时候线程堆栈与对象头的状态如下图
2、然后虚拟机使用CAS(CompareAndSwap原子操作)尝试将对象的MarkWord更新为指向LockRecord的指针
2.1、如果更新成功,那么这个线程就拥有了该对象的锁,并且对象MarkWord的锁标志变为00,表示对象处于轻量级锁定状态,如下图
2.2、如果更新失败,JVM会先检查对象的MarkWord是否指向当前线程的栈桢,如果是则说明当前线程已经拥有了这个对象的锁(锁重入),就可以继续进入同步块继续执行。否则说明该锁对象已经被其他线程抢占了,当前线程变尝试使用自旋来获取锁。如果自旋获取锁成功,则继续以轻量级锁进入同步块;否则,(1)锁膨胀,将当前锁对象头的MarkWord修改成重量级锁、(2)该线程进入阻塞状态。
轻量级锁解锁
轻量级解锁时,使用原子的CAS操作将DisplacedMarkWord替换到对象头MarkWord中,
如果替换成功,则表示没有发生锁竞争,结束。
如果失败,说明有其他线程尝试过获取锁(并且将锁对象膨胀为重量级锁),存在锁竞争,释放锁并唤醒被挂起的线程。
整体流程图如下图:(解锁过程中,对象膨胀为重量级锁的具体细节不太清楚,锁对象的MarkWord中如何指向了其他线程创建的那个重量级锁)
偏向锁(整体的细节都不是很清楚,因为描述方式因为角度问题而不一致)
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
获取锁
描述一、
当锁对象第一次被线程A获取的时候,JVM将会把对象头的MarkWord的是否是偏向锁设置为1(即偏向模式)。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord之中。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,JVM都可以不在进行任何同步操作。
当有另外一个线程B去尝试获取这个锁时,偏向锁模式就宣告失败,进入全局安全点,暂停拥有偏向锁的线程,检查其是否活着。
如果线程A不处于活跃状态,则将对象头的MarkWord的【是否是偏向锁】设置为0,无锁定状态,然后升级为轻量级锁。
如果线程A处于活跃状态,锁对象目前处于被锁定状态,原偏向的线程继续拥有锁,当前线程B则走入到锁升级的逻辑里;
描述二、
case 1:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态(下图中的左分支),则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁(下图中的右边分支)。
case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后,会往当前线程的栈中添加一条Displaced Mark Word为空的锁记录Lock Record,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;(由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。)
case 3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁(中间下面指向右边的箭头),原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中(中间上面指向右边的箭头),则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要执行额外的消耗,就是直接操作锁对象头的MarkWord,和执行非同步相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,则带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 使用CAS消除互斥量 竞争的线程不会阻塞,提高了程序的响应速度。 采用自旋,不需要因阻塞而从用户态切换到核心态。 |
使用自旋会消耗CPU | 追求响应速度 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量 同步块执行时间较长 |