1. 概述
上文中,我们介绍了 java 的内存模型和 volatile 关键字实现。
我们提到,volatile 可以在满足以下两个条件的情况下保证线程安全性: 1. 对变量的写操作不依赖于当前值 2. 该变量没有包含在具有其他变量的不变式中 大部分场景下,我们的并发环境是无法满足这两个条件的,这时就需要使用锁机制了,本篇日志我们就来介绍一下 java 原生的 synchronized 锁是如何实现的以及我们应该如何去使用它。
2. synchronized 的三种应用方式
synchronized 关键字可以在以下三种场景中应用:
2.1. 实例方法加锁
在类实例方法上声明 synchronized 关键字,作用于当前实例(this 引用)加锁,进入同步代码前需要先获取当前实例的锁。 我们来看下面的代码:
代码语言:javascript复制public class AccountingSync implements Runnable {
//共享资源(临界资源)
static int i=0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i ;
}
@Override
public void run() {
for(int j=0;j<1000000;j ){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
AccountingSync instance = new AccountingSync();
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
运行上述代码,打印出了我们预期的结果 2000000,由于 操作是非线程安全的,所以这一结果表明我们加锁是成功的。 两个线程是通过同一个实例 instance 运行的,所以他们通过这个实例的同一把锁实现了线程的并发安全,而下面的代码就将无法得到预期的结果:
代码语言:javascript复制public class AccountingSyncBad implements Runnable {
static int i=0;
public synchronized void increase(){
i ;
}
@Override
public void run() {
for(int j=0;j<1000000;j ){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新实例
Thread t1 = new Thread(new AccountingSyncBad());
//new新实例
Thread t2 = new Thread(new AccountingSyncBad());
t1.start();
t2.start();
//join含义:当前线程A等待thread线程终止之后才能从thread.join()返回
t1.join();
t2.join();
System.out.println(i);
}
}
运行结果打印出了 1452317,这是因为两个线程分别运行在两个 new 出来的不同的实例中,这意味着他们有着两个不同的实例对象锁,因此他们各自对 increase 方法加锁是无法实现锁的作用的。
2.2. static 方法加锁
与实例方法不同,static 方法是无法获取到实例对象的 this 引用的,因此对 static 方法加锁,锁定的目标就是 class 对象,所有使用该类的线程都将获取到同一把锁。
代码语言:javascript复制public class AccountingSyncClass implements Runnable{
static int i=0;
/**
* 作用于静态方法,锁是当前class对象,也就是
* AccountingSyncClass类对应的class对象
*/
public static synchronized void increase(){
i ;
}
@Override
public void run() {
for(int j=0;j<1000000;j ){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新实例
Thread t1 = new Thread(new AccountingSyncClass());
//new新实例
Thread t2 = new Thread(new AccountingSyncClass());
//启动线程
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
此时运行上述代码可以得到预期的加锁效果,虽然我们通过 new 传入不同的实例,但是因为 static 方法上的 synchronized 关键字是通过对 class 对象加锁,两个线程仍然是在竞争同一把锁。
2.3. synchronized 同步代码块
编写 synchronized 同步代码块是最灵活的一种加锁方式了,他不仅可以实现上述两种加锁方式的功能,还可以实现更加精细化的加锁控制。 在某些情况下,我们编写的方法体可能比较大,也可能存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。
代码语言:javascript复制public class AccountingSync implements Runnable{
static AccountingSync instance = new AccountingSync();
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<1000000;j ){
i ;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
上述代码我们通过对代码执行的一部分加锁,实现了上述对实例方法加锁的相同功能,我们通过对类的 static 成员 instance 加锁,实现并发安全性。 常用的使用方法有对 this 引用加锁和对 class 对象加锁:
代码语言:javascript复制//this,当前实例对象锁
synchronized(this){
for(int j=0;j<1000000;j ){
i ;
}
}
//class对象锁
synchronized(AccountingSync.class){
for(int j=0;j<1000000;j ){
i ;
}
}
3. java 对象头与锁机制
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。 被 synchronized 修饰的同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
在 JVM 中,堆内存中的对象分为三块区域: 1. 对象头 — 占 64 位空间,存储 Mark Word 和 Class Metadata Address,详见下表 2. 实例数据 — 存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐 3. 对齐填充数据 — 由于虚拟机要求对象起始地址必须是8字节的整数倍,所以该部分内存用来填充数据以保证字节对齐
3.1. 堆内存对象头信息
java 堆内存中对象头信息
占用内存空间大小 | 名称 | 说明 |
---|---|---|
32bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例 |
3.2. 对象头 Mark Word 字段信息
轻量级锁和偏向锁是 java6 对 synchronized 锁进行优化后新增的锁类型,通常我们使用的 synchronized 对象锁指的就是重量级锁。 重量级锁中互斥量指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。
3.3. Monitor 对象
每个对象都存在着一个 monitor 与之关联,当一个 monitor 被某个线程持有后,它便处于锁定状态,下面是 HotSpot 虚拟机的 monitor 类数据结构:
代码语言:javascript复制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 ;
}
可以看到,每一个 Monitor 对象都维护了两个队列:_WaitSet 和 _EntryList。 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,获取到锁则将线程引用赋值给 _owner 字段,否则将被加入到 _WaitSet 队列中。 可见,Monitor 对象存在于每个 Java 对象的对象头中,这也是为什么在 Java 中任何对象都可以作为锁的原因。 这也为对象的 notify、notifyAll、wait 方法的提供了实现。
4. synchronized 实现原理
4.1. synchronized 代码块实现原理
java 字节码中,用来实现同步代码块的是 monitorenter 和 monitorexit 指令。 monitorenter 指令执行时,当前线程试图获取 objectref 所存储的对象锁(ObjectMonitor 对象) 如果取到的 monitor 对象 _count 字段为 0,则将该线程引用存储在 monitor 对象的 _owner 字段中,并且将 _count 字段加1,线程继续执行,直到线程执行 monitorexit 指令。 如果取到的 monitor 对象 _count 字段不为 0,那么首先会判断该线程是否与 monitor 对象的 _owner 字段存储的引用相同,如果相同,_count 值加 1,仍然可以继续运行,这保证了 synchronized 的可重入性,如果不同,则将该线程放入 _WaitSet,线程进入 wait 状态等待唤醒。
4.2. synchronized 方法实现原理
与 synchronized 代码块不同,JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor 对象,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。 如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
5. JVM 对 synchronized 的优化
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。 Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了。 上面我们其实已经有提到过,java6 的优化主要是引入了偏向锁和轻量级锁,从而减少了获取锁和释放锁的性能消耗。 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
5.1. 偏向锁
研究表明,在大多数情况下,锁不仅不存在多线程竞争,甚至总是由同一个线程多次获取,因此,java 为了减少同一线程反复获取没有竞争的锁造成的性能损耗而引入了偏向锁。 偏向锁的核心思想是,如果一个线程试图获取偏向锁,如果他与锁对象头信息中的线程 ID 相同,那么他将直接进入到代码的执行而不需要任何同步机制。 一旦两次获取锁的线程不同,偏向锁将立即升级为轻量级锁。
5.2. 轻量级锁
与偏向锁一样,轻量级锁也是 jdk6 引入的新特性。 他解决加锁性能问题的依据是,绝大部分锁,即使被多个线程共享,在整个同步周期内,也都不存在竞争。 JVM 将 monitor 对象放在栈内存中,而不进行任何同步机制,从而保证了性能。 一旦出现获取轻量级锁失败,JVM 会尝试通过自旋的方式等待锁而不是让线程挂起。
5.3. 自旋锁
大部分情况下,即使存在竞争,竞争的线程也不会超过十位数,同时每个线程的等待时间也不会很长,此时,如果让线程挂起,并且通过操作系统的 Mutex 锁实现同步,性能将会显著下降。 JVM 对这一情况的优化是通过自旋锁实现的,也就是说,当获取轻量级锁失败后,JVM 并不会立即让线程挂起,而是经过 50 到 100 次空循环后重新获取锁,这也就是他被称为自旋锁的原因。 如果还不能获得锁,那就会将线程在操作系统层面挂起,也就只能升级为重量级锁了。
5.4. 锁消除
JVM 在 JIT 编译时,通过运行上下文扫描,会发现不可能存在竞争的锁,这样的锁在编译过程中会被直接消除,从而节省毫无意义的请求锁时间。
6. 等待唤醒与 synchronized
6.1. 不能被中断的 synchronized
我们知道,通过线程的 interrupt 方法,我们可以将处于被阻塞状态或者试图执行一个阻塞操作的线程中断,那么,在等待 synchronized 锁的线程是否可以被 interrupt 方法中断呢? 答案是不可以的,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
6.2. 等待唤醒机制与 synchronized
所谓等待唤醒机制本篇主要指的是 notify/notifyAll 和 wait 方法,在使用这3个方法时,必须处于 synchronized 代码块或者 synchronized 方法中,否则就会抛出 IllegalMonitorStateException 异常。 原因是这几个方法都必须拿到当前对象的 monitor 对象,只有进入 synchronized 代码块或 synchronized 方法时,线程才会去获取 monitor 对象。 需要注意的是,wait 方法调用后,线程将马上释放 monitor 锁,直到有线程调用 notify/notifyAll 方法后才能继续执行,而 sleep 方法只是让线程休眠一定时间,并不会释放锁。
7. 参考资料
《深入理解Java虚拟机 —— JVM高级特性与最佳实践》。 https://blog.csdn.net/javazejian/article/details/72828483。