设计同步器的意义 :
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,
这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。
- 共享:资源可以由多个线程同时访问。
- 可变:资源可以在其生命周期内被修改。
引出的问题:
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状
态的访问那么我们怎么解决线程并发安全问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是 序列化访
问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥
访问。
Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock 。
同步器的本质就是加锁
加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同
步互斥访问) 不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量 并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。
接下来我们就来了解下synchronized关键字相关知识。
对象内存布局
关于对象的内存分布的相关内容可参考深入JVM六:对象内存分配和对象内存布局,相关内容在此不再赘述。
synchronized底层原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实 现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。如图所示:
我们可以通过反编译.class文件来查看,首先来看下代码:
public class SynKeyDemo {
public Object object = new Object();
public void doSomething() {
synchronized (object) {
System.out.println("hello");
}
}
}
这里在doSomething方法中添加了synchronized关键进行同步,接下来我们通过javac和javap查看编译后的信息:
E:\learnIdea\tuling-concurrent\src\main\java\com\pang\concurrent\syn>javac SynKeyDemo.java
E:\learnIdea\tuling-concurrent\src\main\java\com\pang\concurrent\syn>javap -c SynKeyDemo.class
Compiled from "SynKeyDemo.java"
public class com.pang.concurrent.syn.SynKeyDemo {
public java.lang.Object object;
public com.pang.concurrent.syn.SynKeyDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init>":()V
12: putfield #3 // Field object:Ljava/lang/Object;
15: return
public void doSomething();
Code:
0: aload_0
1: getfield #3 // Field object:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter //加锁
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #5 // String hello
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_1
16: monitorexit // 解锁
17: goto 25
20: astore_2
21: aload_1
22: monitorexit
23: aload_2
24: athrow
25: return
Exception table:
from to target type
7 17 20 any
20 23 20 any
}
注意上面doSomething的第6行和第16行,在JVM编译时会将synchronized编译成一组monitorenter和monitorexit指令,然后监视器锁调用底层的的指令Mutex Lock(互斥锁)从而实现加锁。如下图所示:
那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?答案是锁状态是被记录在每个对象的对象头(Mark Word)中。
我们从对象内存布局那儿可以了解到当synchronized加锁的对象对应的锁为重量级锁时,其对象头的30bit用于存储指向监视器锁(Monitor),我们知道监视器锁Monitor是依赖于Mutex Lock实现互斥的,那么他是如何管理等待线程以及记录获取锁的线程的呢?我们可查看其源码objectMonitor.hpp文件如下:
ObjectMonitor() {
_header = NULL; // 对象头
_count = 0; // 记录加锁次数(实现可重入锁)
_waiters = 0; // 当前处于wait状态的线程
_recursions = 0;
_object = NULL;
_owner = 0; // 指向持有ObjectMonitor对象的线程
_WaitSet = NULL; // 等待线程集合
_WaitSetLock = 0;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL;
FreeNext = NULL;
_EntryList = NULL;
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
...
}
从源码中我们可以看到objectMonitor中用于记录等待线程、当前线程以及指向被锁对象的信息,通过这些信息从而实现锁的控制可线程的调度。(AQS的实现与此相似,后续再介绍)
Jvm内置锁的优化
JVM内置锁(synchronized)在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等 技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。
自旋锁与自适应自旋
之前我们讨论过synchronized实现锁是通过底层的Mutex Lock(互斥锁)实现的,他是重量级的,在线程切换时会涉及到线程上下文的切换,从而需要由用户态转换到内核态,这种操作给系统并发性能带来很大压力,同时虚拟机的开发人员注意到,共享数据的锁定状态不会持续很久,那么在等待这段时间将线程挂起阻塞是很不值得的,所以可以让后面的那个线程稍微”等待一下”(可以理解为使用for循环什么也不做,只是进行for循环,不做其他事情),但是不放弃cpu的执行时间,看看持有锁的线程是否会很快释放锁。为了让线程等待,让线程执行一个忙循环(自旋),这技术就是所谓的自旋。自旋是在1.4.2版本引入的,但是是默认关闭的,使用-XX:+UserSpinning进行开启,直到1.6修改为默认开启,同时自旋默认是10次,也就是进行10次的忙循环,可以通过参数-XX:PreBlockSpin来指定次数。
自适应自旋是在1.6之后引入的,通过统计线程每次获取锁进行的忙循环次数,从而动态的改变线程每次获取锁的自旋次数,从而使jvm更加智能。
锁消除
对于被锁的对象,不存在被其他线程的栈上变量引用,那么对该对象的锁就会被消除。看如下示例:
public class LockEliminationDemo {
public static void main(String[] args) {
synchronized (new Object()) {
System.out.println("this lock will be eliminated");
}
}
}
看上面的代码,这里new Object()是一个匿名对象,不会其其他线程引用他,那么这里的synchronized所加的所将会消除。相似的还有StringBuffer,当其作为局部变量时,调用appned()方法同样的锁也会消除。
锁粗化
同样以StringBuffer为例,当在一个方法中连续的调用append方法时会频繁涉及到锁的获取和释放,明显会对并发性能产生影响,jvm对其进行优化将其锁升级到只有一个,那么减少了锁的获取和释放,从而提高并发性能。
轻量级锁
轻量级锁是在JDK1.6之中加入的新型锁机制,其轻量级是相对于使用操作系统的互斥量来实现的传统锁而言的。这里我们应该明确一点:轻量级锁不是用来代替重量级锁的,他的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量而产生的性能消耗的,也就是说轻量级锁是没有并发量时减少使用操作系统的互斥锁(Mutex Lock),而是使用JVM的优化手段减少性能开销的。
那么轻量级锁是如何实现的呢?
我们知道synchronized通过变更对象的对象头数据结构从而记录锁的状态,对象头的接口在这里不再赘述,我们来了解下轻量级锁的jvm实现:
- 在线程进入synchronized同步代码块时,首先查看对象头中MarkWord的锁标志位,当其状态为(01)时,那么会在当前线程的栈帧上创建一个名称为Lock Record的空间,用于存储锁对象当前的Mark Word,然后通过CAS算法将对象头的MarkWord变更为指向当前线程的Lock Record的指针,如果成功,那么将对象头Mark Word的标志位修改为00,即表示锁对象处于轻量级锁状态。如下图:
- 通过CAS更新操作失败后,虚拟机先检查当前锁对象的对象头的MarkWord是否指向当前线程的栈帧中的Lock Record,是则进入synchronized代码块执行,如果不是则代表有其他线程已经获取当前锁,此时轻量级锁不再有效,则会膨胀成重量锁,那么锁标志位会被设置为10,同时锁对象的Mark Word会变成对象监视器ObjectMonitor的内存地址,那么线程也会进入阻塞状态。
- 在释放锁时同时也是通过CAS操作进行操作,将锁对象的Mark Word和当前线程的栈帧中的Lock Record替换回来,如果成功则释放锁成功,如果失败证明有其他线程获取过锁(此时已经是重量级锁),那么需要唤醒其他等待的线程。
轻量级锁的优缺点:
之前我们已经提到过轻量级锁不是替代重量级锁的,而是对于那种在整个同步周期中不存在竞争的情况下,防止使用操作系统的互斥锁而进行的优化,为什么当多个线程争抢锁时会升级为重量级锁呢?因轻量级锁在并发竞争时除了使用CAS操作之外,还使用了重量级的互斥量锁,那么在并发的情况下索性就不进行CAS操作,直接进行锁的竞争,反而可以去除CAS操作的性能消耗。
偏向锁
偏向锁与轻量级锁机制类似,轻量级锁是在无竞争下使用CAS操作去消除互斥锁的,那么偏向锁是在无竞争下将整个同步消除。
其机制如下:
当第一个线程获取锁时,会通过CAS将锁对象的对象头Mark Word修改为当前线程的id,同时会将是否偏向状态修改为1,那么当前线程再次进入这个锁对象时就不需要任何CAS操作。当其释放锁时将其标志位修改为01,同时是否偏向修改为0。
当然当有另外一个线程尝试获取锁时,那么其就会膨胀为轻量级锁,将其标志位修改为00。
偏向锁的优缺点:
偏向锁对于同步周期中不存在竞争的话,对轻量级锁进行优化,减少了CAS的操作,但是对于同步周期存在竞争很明显就对于了,所以说偏向锁是否开启取决于应用场景,偏向锁是在1.6引入的同时默认值为开启的,我们可以使用-XX:-UseBiasedLocking将其关闭。
偏向锁的状态下hashCode如何获取?
对象在内存中创建后,没有调用其hashCode方法进行计算hashCode值时,对象头是没有存储对象的hashCode值的,当对象在无锁状态下进行了hashCode的计算后,则将hashCode值存入对象头的Mark Word中,那么后续对对象上锁时则不会再加偏向锁了,因为此时Mark Word中存储了hashCode值。当然如果对象没有进行过hashCode,则在加锁时会添加偏向锁(偏向锁开启),在添加偏向锁期间进行hashCode计算时会怎么样呢?偏向锁会被撤销,同时升级为重量级锁,此时对象的Mark Word字段存储了MonitorObject的指针,而在MonitorObject中有字段存储对象的Mark Word字段,那么就可以进行hashCode计算了,计算结果存放到MonitorObject的指定字段以便下次调用可直接获取。
偏向锁和轻量级锁的应用场景
偏向锁:
偏向锁对应的场景是单线程执行同步代码块。
轻量级锁:
轻量级锁对应的场景是线程一次轮询执行,线程交替时不存在激烈的竞争,可通过自旋进行锁的等待。如下图:
在线程运行过程中,线程2来争抢锁的资源,这时线程2会进行忙运行(不会释放cpu执行权),当自旋后线程1释放锁,这时线程获取锁继续执行,避免了使用操作系统的互斥锁带来的性能问题。
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的
竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单
向的,也就是说只能从低到高升级,不会出现锁的降级。