并发编程之锁(一)--volatile与synchronized
前言
本文是对并发编程中的锁一个系统性总结。
什么是死锁
1. 定义:
theadA已经持有了资源2,同时还想申请资源1,theadB已经持有了资源1,同时还想申请资源2,所以theadA与theadB因为相互等待对方已经持有的资源进入死锁状态。
2. 死锁的四个条件
互斥条件:指线程对已经获取到的资源进行排他性使用。
请求并持有条件:指一个线程已经持有至少一个资源同时又想申请新的资源,但是新的资源被其他线程占有,所以该线程会被阻塞,但是阻塞的同时并不释放自己已经获取的资源。
不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有等待其使用完了。
环路等待条件:指发生死锁时,必然存在一个线程-资源的环形链。
什么是线程安全
当多个线程同时运行某段代码时,如果每次运行的结果与单线程运行的结果一致,而且其他的变量值也与预期的一致,那么我们就说这段代码是线程安全的。
相关知识
-
三大特性
1. 原子性
指一个线程的操作不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。
2. 可见性
指某一个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。
3. 有序性
指为了优化程序执行和提高CPU的处理性能,JVM和操作系统都是对指令进行重排,也就是说前面的代码不一定会在后面的代码之前执行。 -
锁的分类
1. 可重入锁
如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁。
2. 可中断锁
顾名思义,就是可以响应中断的锁。synchronized就不是可中断锁,而Lock是可中断锁。
3. 公平锁/非公平锁
即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。
4. 独占锁/共享锁
独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占方式实现的。共享锁可以同时由多个线程持有,ReadWriteLock读写锁,其读锁允许一个资源被多线程同时进行读操作,是共享锁。
5. 乐观锁/悲观锁
乐观锁和悲观锁是从数据库中引入的概念,悲观锁一般使用数据库的排他锁来实现的,乐观锁并不会使用数据库提供的锁机制,一般在表中添加version或者使用业务状态来实现。
悲观锁在Java中的使用,就是利用各种锁。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
6. 分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
7. 偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
8. 自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
volatile关键字
上面介绍了多线程相关的三个知识,下面介绍的volatile关键字只具备了其中的可见性和有序性,而并不具备原子性。
1. 使用介绍:
有volatile关键字修饰的共享变量会在每次更改变量后回写至内存,从而导致其他线程的该变量缓存无效,进而保证了共享变量对所有线程可见。
下面的方法如果不使用volatile关键字,则永远不会退出,因为主线程对共享变量isStop不可见。
private static volatile boolean isStop = false;
public static void stop(){
isStop=true;
}
static class Worker implements Runnable{
@Override
public void run() {
try {
java.lang.Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop();
}
}
public static void main(String[] args) {
new Thread(new Worker()).start();
while(!isStop){
//println方法使用了synchronized关键字,如果在这里面打印不用volatile关键字也会退出
//System.out.println("continue....");
}
System.out.println("stop");
}
2. 原理分析
volatile 的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JVM 采用了保守策略。
策略如下:
- 在每一个 volatile 写操作前面,插入一个 StoreStore 屏障
- 在每一个 volatile 写操作后面,插入一个 StoreLoad 屏障
- 在每一个 volatile 读操作后面,插入一个 LoadLoad 屏障
- 在每一个 volatile 读操作后面,插入一个 LoadStore 屏障
原因如下:
- StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作,都已经刷新到主内存中。
- StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操作重排序。
- LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序。
- LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序。
synchronized关键字
1. 使用介绍
synchronized一直是元老级别的存在,是重量级的锁,它比volatile高级,它满足上面的三个特性。一般有如下三种表现形式:
- 对于普通的方法,锁是当前实例对象;
- 对于静态方法,锁是当前类的Class实例,又因为 Class 的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
- 对于同步方法块,锁是synchronized括号里面配置的对象。
下面用代码展示下:
public static void main(String[] args) throws InterruptedException {
//1.对于普通方法上加锁,锁是该对象,如果操作的是同一个对象该锁是有左右的
// Counter1 counter1 = new Counter1();
// Thread thread1 = new Thread(counter1);
// Thread thread2 = new Thread(counter1);
// thread1.start();
// thread2.start();
// thread1.join();
// thread2.join();
// System.out.println(Counter1.i);
// 此时锁并不生效
// Counter1 counter11 = new Counter1();
// Counter1 counter12 = new Counter1();
// Thread thread3 = new Thread(counter11);
// Thread thread4 = new Thread(counter12);
// thread3.start();
// thread4.start();
// thread3.join();
// thread4.join();
// System.out.println(Counter1.i);
//2.静态方法锁
// Counter2 counter21 = new Counter2();
// Counter2 counter22 = new Counter2();
// Thread thread5 = new Thread(counter21);
// Thread thread6 = new Thread(counter22);
// thread5.start();
// thread6.start();
// thread5.join();
// thread6.join();
// System.out.println(Counter2.i);
// Counter2 counter21 = new Counter2();
// Thread thread5 = new Thread(counter21);
// Thread thread6 = new Thread(new Runnable() {
// @Override
// public void run() {
// for (int i = 0; i < 1000; i++) {
// counter21.addNoSyn();
// }
// }
// });
// thread5.start();
// thread6.start();
// thread5.join();
// thread6.join();
// System.out.println(Counter2.i);
//3.同步代码块
Counter3 counter31 = new Counter3();
Counter3 counter32 = new Counter3();
Thread thread7 = new Thread(counter31);
Thread thread8 = new Thread(counter32);
thread7.start();
thread8.start();
thread7.join();
thread8.join();
System.out.println(Counter3.i);
}
/**
* 普通方法锁
*/
static class Counter1 implements Runnable{
static int i=0;
synchronized void add(){
i++;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
add();
}
}
}
/**
* 静态方法锁
*/
static class Counter2 implements Runnable{
static int i=0;
static synchronized void add(){
i++;
}
synchronized void addNoSyn(){
i++;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
add();
}
}
}
//锁代码块
static class Counter3 implements Runnable{
static String syn = "true";
static int i=0;
@Override
public void run() {
synchronized (syn){
for (int j = 0; j < 1000000; j++) {
i++;
}
}
}
}
以上三种其实就是所谓的对象锁与类锁。
第二个方法也证明了类锁和对象锁互不干涉,add方法锁的是类锁,而addNoSyn锁的是对象锁,两个并不是同一把锁,所以不存在竞争关系。
2. 原理分析
使用Classpy工具打开上面我们写的demo(Classpy工具可以从github上下载):
TestSync$Counter1.class
TestSync$Counter3.class
TestSync$Counter3.class
我们可以看到:
- 同步代码块是使用monitorenter和monitorexit指令实现的;
- 同步方法依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。
下面我们进一步来分析synchronized的实现:
2.1 对象头
synchronized用的锁是存在Java对象头里的。对象头中的数据:
- Mark Word(存储对象的hashCode和锁信息等)
- Class Pointer(存储对象类型数据的指针)
-
Array Length(如果当前对象是数组才有的字段,表示数组的长度)
下面是Java对象头的存储结构(32位虚拟机):
Java对象头的存储结构
2.2 Monitor
Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象,主要特点是互斥和信号机制:
- 互斥: 一个Monitor锁在同一时刻只能被一个线程占用,其他线程无法占用;
- 信号机制(signal): 占用Monitor锁失败的线程会暂时放弃竞争并等待某个谓词成真(条件变量),但该条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新竞争锁。
Monitor Record是Java线程私有的数据结构,每一个线程都有一个可用MR列表,同时还有一个全局的可用列表,其中:
- 一个被锁住的对象都会和一个MR关联(对象头的MarkWord中的LockWord指向MR的起始地址);
- MR中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
其结构如下:
Monitor Record结构图
3. 锁优化
synchronized是重量级锁,在JDK1.6中对synchronized的实现进行了各种优化,比如锁粗化、锁消除、锁升级、自旋锁、适应性自旋锁等技术来减少锁操作的开销。下面我们来看看:
3.1 锁粗化
- 定义:多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
- 例子:
for(int i=0;i<size;i++){
synchronized(lock){
}
}
//锁粗化之后
synchronized(lock){
for(int i=0;i<size;i++){
}
}
JVM 检测到对同一个对象(lock)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。这个例子只是一个抽象的概念,实际上这种写法JVM并不会进行所优化,我们来看一个实际的例子:
public static class CoarsingTest implements Runnable {
public static String name = "Tom";
@Override
public void run() {
//#System.out.println()是加锁的,锁粗化后,name变量具有可见性
while(!"Bob".equals(name)) {
System.out.println("我不是Bob");
}
//这种写法,反编译后,#System.out.println()是在循环外面的,所以name是不可见的
// while (true) {
// if ("Bob".equals(name)) {
// System.out.println(name);
// break;
// }
// }
}
}
public static void main(String[] args) throws InterruptedException {
CoarsingTest coarsingTest = new CoarsingTest();
Thread thread = new Thread(coarsingTest);
thread.start();
Thread.sleep(1000);
CoarsingTest.name = "Bob";
}
3.2 锁消除
- 定义:锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
- 例子:
public static void main(String[] args) throws InterruptedException {
long tsStart = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
getString("AB", "CD");
}
//-XX:+DoEscapeAnalysis -XX:+EliminateLocks 开启锁消除模式下999ms
//-XX:+DoEscapeAnalysis -XX:-EliminateLocks 关闭锁消除模式下1447ms
System.out.println((System.currentTimeMillis() - tsStart) + " ms");
}
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
根据逃逸分析,变量sb没有逃逸出方法#getString(),所以JVM可以大胆的将StringBuffer内部的锁消除掉。
3.3 锁升级
锁的4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)
- 偏向锁
引入目的:为了在无多线程竞争的情况下,尽量减少不必要的锁竞争。
获取与升级:当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
开启与关闭:偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;这样子默认会进入轻量级锁。 - 轻量级锁
引入目的:在竞争锁对象的线程不多,而且线程持有锁的时间也不长的情况下,由于阻塞线程需要CPU从用户态转到内核态,代价较大,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
获取与升级:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。 -
重量级锁
重量级锁通过对象内部的监视器(Monitor)实现。其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高。
对比图
3.4 自旋锁
定义:所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。其实轻量级锁就是一种自旋锁。
在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。
但是这种手动设置自旋次数也不太合理,所以JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。即自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
总结
volatile相对于synchronized稍微轻量些,在某些场合它可以替代synchronized,但是又不能完全取代synchronized 。
volatile经常使用的场景:状态标记变量。
参考资料
- 《深入理解Java虚拟机》
- 《Java并发编程的艺术》
- Java 8 并发篇 - 冷静分析 Synchronized(下)
- 通过踩坑带你读透虚拟机的“锁粗化”
- Java锁消除和锁粗化
- Java并发——Synchronized关键字和锁升级,详细分析偏向锁和轻量级锁的升级