在多线程并发编程中synchronized一直是元老级角色,被很多人称为重量级锁。但是,这都是JDK1.6之前的事了,随着JDK1.6对synchronized进行了各种优化之后,其性能得到了很大的提升,重量级只是部分情况了。
synchronized 的实现原理
synchronized实现同步的基础:Java中的每一个对象都可以作为锁。表现为以下3种形式:
-
对于普通同步方法,锁是当前实例对象
-
对于静态同步方法,锁是当前类的Class对象
-
对于同步方法块,锁是Synchronized括号里配置的对象
Java中使用synchronized加锁的几种情况:
public class TestSynchronized {
Object object = new Object();
public static void main(String[] args) {
TestSynchronized t = new TestSynchronized();
t.m1();
t.m2();
t.m3();
m4();
}
/**
* 1. 锁默认为当前对象的实例
*/
public void m() {
synchronized (this) {
}
}
/**
* 2. 与1相同,锁默认为当前对象的实例
*/
public synchronized void m1() {
}
/**
* 3. 锁为括号中配置为对象
*/
public void m2() {
synchronized (TestSynchronized.class) {
}
}
/**
* 4. 对象锁,Java中每一个继承自Object的对象都可以被线程获取锁
*/
public void m3() {
synchronized (object) {
}
}
/**
* 5. 静态方法的锁默认为 Class 对象:TestSynchronized.class
*/
public static synchronized void m4() {
}
}
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。看如下字节码(只列举出了m1()、m2()的字节码):
public synchronized void m1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 在方法上加锁会使用ACC_SYNCHRONIZED 修饰
Code:
stack=0, locals=1, args_size=1
0: return
public void m2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #4 // class com/yuangh/AQS/TestSynchronized
2: dup
3: astore_1
4: monitorenter // 开始获取锁
5: aload_1
6: monitorexit // 结束时释放锁
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 异常退出时必须释放锁
13: aload_2
14: athrow
15: return
Synchronized在JVM中的实现原理:JVM基于进入和退出Monitor对象来实现方法同步和代码块同步(对象监视器机制)。monitorenter指令是在编译后插入同步代码块的开始位置,monitorexit指令插入到方法结束处和异常处,monitorenter和monitorexit时成对出现的。任何对象都有一个monitor与之关联,当一个monitor被持有之后,该对象将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得该对象的锁。
Java对象头
在说synchronized实现的锁之前,先说一下Java的对象头, synchronized用的锁是存在Java对象头里的。
HotSpot虚拟机对象头包括两部分信息:
-
第一部分用于存储对象自身的运行时数据,如哈希吗(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32Bits和64Bits,官方称为 “Mark Word”,它是实现轻量级锁和偏向锁的关键。
-
第二部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外的存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
32位JVM的Mark Word的默认存储结构如下表:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无状态锁 | 对象的HashCode | 对象分代年龄 | 0 | 01 |
64位JVM的Mark Word的默认存储结构如下表:
HotSpot虚拟机对象头中Mark Word中的存储内容,标志位和状态:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希吗, 对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
32位HotSpot虚拟机Mark Word的状态变化
锁的状态
JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入“偏向锁”和“轻量级锁”,JDK1.6中的锁一共有四种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,如果偏向锁升级成轻量级锁,将无法降级为偏向锁。这种锁升级但不降级的策略是为了提高获得锁和释放锁的效率。
无锁
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一资源,但同时只有一个线程能够修改成功。
无锁的特点:修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。CAS原理及应用就是无锁的实现,无锁无法全面代替有锁,但在某些场合下的性能却非常高。
偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做
经研究发现(HotSpot),大多数情况下,锁不仅不存在多线程竞争,而且频繁的由同一线程获得。为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
- 偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态(置为 01);如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程:
- 偏向锁的开启与关闭
偏向锁是默认启用的。关闭偏向锁后,程序默认会进入轻量级锁状态。
//关闭偏向锁激活延迟:
-XX:BaisedLockingStartupDelay=0
//关闭偏向锁
-XX:UseBiasedLocking=false
//开启偏向锁
-XX:UseBiasedLocking=true
轻量级锁
轻量级锁的目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
- 轻量级锁加锁
在代码进入同步快的时候,如果此时同步对象吗,没有被锁定(锁标志为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝(Displaced Mark Word)。然后把Lock Record的地址使用CAS放到Mark Word当中,并且把锁标志位改为00, 表示此对象已经处于轻量级锁定状态,可以继续进入临界区执行。
如果更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指向说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一把锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为"10",Mark Word中存储的就是指向重量级锁(互斥量:Mutex。重量级锁需要操作系统的帮忙,依赖操作系统底层的Mutex Lock)的指针,后面等待锁的线程也要进入阻塞状态。
- 轻量级锁及膨胀流程如下
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
偏向锁,轻量级锁,重量级锁的状态转化以及对象Mark Word的关系图如下:
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量 同步块执行速度较慢 |
总结: 偏向锁通过对比Mark Word中的状态值来解决加锁问题,避免执行CAS操作;轻量级锁通过使用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能;重量级锁会将除了拥有锁的线程以外的线程全部阻塞。
锁的内存语义(synchronized)
锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息(线程间通信)
synchronized建立的happens-before关系
//假设线程A执行writer方法,线程B执行reader方法
class MonitorExample {
int a = 0;
public synchronized void writer() { // 1 线程A获取锁
a++; // 2 线程A执行临界区代码
} // 3 线程A释放锁
public synchronized void reader() { // 4 线程B获取同一把锁
int i = a; // 5 线程B执行临界区中的代码
……
} // 6 线程B释放锁
}
上面代码执行的过程包含的happens-before关系可以分为3类:
-
根据程序次序规则:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happensbefore 6。
-
根据监视器锁规则:3 happens-before 4。
-
根据happens-before的传递性:2 happens-before 5。
锁的释放和获取的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
对比锁释放——获取的内存语义和volatile写-读的内存语义:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
-
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A
对共享变量所做修改的)消息。 -
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共
享变量所做修改的)消息。 -
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发
送消息。
参考
- 《深入理解Java虚拟机》 《Java并发编程实战》 《Java并发编程的艺术》
- http://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf
- https://docs.oracle.com/javase/specs/jls/se8/html/index.html
- https://docs.oracle.com/javase/specs/jvms/se8/html/index.html