同步:锁
参考:
[文末赠书]Java中的ReentrantLock锁,你get到了吗?
Java 读写锁 ReentrantReadWriteLock 源码分析
e.printStackTrace()不是打印吗,还能锁死?
基础
锁:
解决资源占用的问题;保证同一时间一个对象只有一个线程在访问;
锁机制的作用:有些业务逻辑在执行过程中要求对数据进行排他性的访问,于是需要通过一些机制保证在此过程中数据被锁住不会被外界修改,这就是所谓的锁机制。
互斥锁:
对共享资源的访问必须是顺序的,
也就是说当多个线程对共享资源访问的时候,只能有一个线程可以获得该共享资源的锁,
当线程A尝试获取线程B的锁时,线程A必须等待或者阻塞,直到线程B释放该锁为止,否则线程A将一直等待下去,
因此java内置锁也称作互斥锁,也即是说锁实际上是一种互斥机制。
可中断锁:
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,
可能由于等待时间过长,线程B不想等待了,想先处理其他事情,
我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
在Java中,synchronized就不是可中断锁,而Lock是可中断锁。
非公平锁:
刚刚讲到的食堂打饭的例子,就是一个不公平锁的例子;
synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。这样就可能导致某个或者一些线程永远获取不到锁。
公平锁:
公平锁即尽量以请求锁的顺序来获取锁。
比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁,这种就是公平锁。
读写锁:
就是将一个资源的访问分成两个锁,一个读锁,一个写锁;正因为有了读写锁,才使得多个线程之间的读写操作不会发生冲突。
ReadWriteLock就是读写锁,可以通过readLock()获取读锁,通过writeLock()获取写锁。
自旋锁:
举个例子:
获取到资源的线程A对这个资源加锁,其他线程比如B要访问这个资源首先要获得锁,
而此时A持有这个资源的锁,只有等待线程A逻辑执行完,释放锁,这个时候B才能获取到资源的锁进而获取到该资源。
这个过程中,A一直持有着资源的锁,那么没有获取到锁的其他线程比如B怎么办?通常就会有两种方式:
1. 一种是没有获得锁的进程就直接进入阻塞(BLOCKING),这种就是互斥锁
2. 另外一种就是没有获得锁的进程,不进入阻塞,而是一直循环着,看是否能够等到A释放了资源的锁,这种就是自旋锁
什么时候用自旋锁比较好?
如果A线程占用锁的时间比较短,这个时候用自旋锁比较好,可以节省CPU在不同线程间切换花费的时间开销;
如果A线程占用锁的时间比较长,那么使用自旋锁的话,
B线程就会长时间浪费CPU的时间而得不到执行(要执行一个线程需要CPU,并且需要获得锁),
这个时候不建议使用自旋锁;还有递归的时候尽量不要使用自旋锁,可能会造成死锁。
悲观锁与乐观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,
这样别人想拿这个数据就会阻塞直到它拿到锁。这样可以保证每次都只有一个线程在访问这个数据;
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。
乐观锁:
很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,那么就会有很多对象可以同时访问这个锁里面的数据,
但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量。
适用场景:
悲观锁:
比较适合写入操作比较频繁的场景,
如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。
乐观锁:
比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,
为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。
对比
公平锁 / 非公平锁
公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,
有可能后申请的线程比先申请的线程优先获取锁。有可能会造成优先级反转或者饥饿现象。
对于 Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。
非公平锁的优点在于吞吐量比公平锁大。
对于 Synchronized而言,也是一种非公平锁。
由于其并不像 ReentrantLock是通过 AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
可重入锁 / 不可重入锁
可重入锁
广义上的可重入锁指的是可重复可递归调用的锁,
在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。
ReentrantLock和 synchronized都是可重入锁
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,
setB可能不会被当前线程执行,可能造成死锁
不可重入锁
不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。
看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
//这句是很经典的“自旋”语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
代码也比较简单,使用原子引用来存放线程,同一线程两次调用lock()方法,
如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,
而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。
把它变成一个可重入锁:
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
private int state = 0;
public void lock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
state++;
return;
}
//这句是很经典的“自旋”式语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
if (state != 0) {
state--;
} else {
owner.compareAndSet(current, null);
}
}
}
}
在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。
ReentrantLock中可重入锁实现
这里看非公平锁的锁获取方法:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//就是这里
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在AQS中维护了一个private volatile int state来计数重入次数,
避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。
可重入锁:
如果锁具备可重入性,则称作为可重入锁。
像synchronized和ReentrantLock都是可重入锁,
可重入性实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。
什么是可重入性?
举个简单的例子,当一个线程执行到某个synchronized方法时,
比如说method1,而在method1中会调用另外一个synchronized方法method2,
此时线程不必重新去申请锁,而是可以直接执行方法method2。如下面的代码:
class MyClass {
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
}
method1和method2都是synchronized修饰的方法,在method1里面调用method2的时候,不需要重新申请锁,可以直接调用就行了
其实可以反过来想一想,如果synchronized不具有重入性,
当我调用了method1的时候,得申请锁,申请好了之后那么method1就拥有了这个锁,
那么调用method2的时候,又要重新申请锁,而锁在method1的手上,这时候又要重新申请锁,显然是不可能得到的,这不科学。
所以,synchronize和lock都是具有可重入性的)
死锁与活锁的区别,死锁与饥饿的区别
死锁:
在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源,就会造成死锁。
尽管死锁很少发生,但一旦发生就会造成应用的停止响应(就像夫妻吵架,都等着对方先道歉,就会造成死锁)
是指两个或两个以上的进程(或线程)在执行过程中,
因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
产生死锁的必要条件
互斥条件:所谓互斥就是进程在某一时间内独占资源。
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
活锁
任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试、失败、尝试、失败。
是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,
线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。
这样你让我,我让你,最后两个线程都无法使用资源。
活锁和死锁的区别
处于活锁的实体是在不断的改变状态,所谓的“活”,
而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能
饥饿
一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
是指如果线程T1占用了资源R,线程T2又请求封锁R,于是T2等待。
T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。
然后T4又请求资源R,当T3释放了R上的封锁之后,系统又批准了T4的请求,T2可能永远等待。
这也就是ReentrantLock显示锁里提供的不公平锁机制。
当然了,ReentrantLock也提供了公平锁的机制,由用户根据具体的使用场景而决定到底使用哪种锁策略,
不公平锁能够提高吞吐量但不可避免的会造成某些线程的饥饿。
Java中导致饥饿的原因
高优先级线程吞噬所有的低优先级线程的CPU时间。
线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续地获得唤醒。
死锁和饥饿的区别:
死锁进程等待永远不会被释放的资源,饿死进程等待会被释放但却不会分配给自己的资源,
表现为等待时限没有上界(排队等待或忙式等待;
死锁一定发生了循环等待,而饿死则不然。这也表明通过资源分配图可以检测死锁存在与否,但却不能检测是否有进程饿死;
死锁一定涉及多个进程,而饥饿或被饿死的进程可能只有一个。
死锁
线程死锁
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。
由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》)
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
Output
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,
然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。
线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,
这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。
学过操作系统的朋友都知道产生死锁必须具备以下四个条件:
互斥条件:该资源任意一个时刻只由一个线程占用。
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免线程死锁
我们只要破坏产生死锁的四个条件中的其中一个就可以了。
破坏互斥条件
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
破坏请求与保持条件
一次性申请所有的资源。
破坏不剥夺条件
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
我们对线程 2 的代码修改成下面这样就不会产生死锁了。
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();
Output
Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2
Process finished with exit code 0
我们分析一下上面的代码为什么避免了死锁的发生?
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。
然后线程 1 再去获取 resource2 的监视器锁,可以获取到。
然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。
这样就破坏了破坏循环等待条件,因此避免了死锁。
锁优化
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,
频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁。
何谓自旋锁? 所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。
怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),
虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,
反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。
同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;
如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。
假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。
于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,
因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功的,
那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明
锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,
但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。
锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。
变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?
我们会在明明知道不存在数据竞争的代码块前加上同步吗?
但是有时候程序并不是我们所想的那样?
我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,
这个时候会存在隐形的加锁操作。
比如StringBuffer的append()方法,Vector的add()方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,
所以JVM可以大胆地将vector内部的加锁操作消除。
锁粗化
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,
这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。
但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:
vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,
即加锁解锁操作会移到for循环之外。
轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,
其步骤如下:
<1>
获取锁判断当前对象是否处于无锁状态(hashcode、0、01),
若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,
用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);
否则执行步骤(3);
<2>
JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,
如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;
如果失败则执行步骤(3);
<3>
判断当前对象的Mark Word是否指向当前线程的栈帧,
如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;
否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,
锁标志位变成10,后面等待的线程将会进入阻塞状态;
释放锁
轻量级锁的释放也是通过CAS操作来进行的,
主要步骤如下:
取出在获取轻量级锁保存在Displaced Mark Word中的数据;
用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,
如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;
下图是轻量级锁的获取和释放过程

偏向锁
引入偏向锁主要目的是:
为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。
上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。
那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。
只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:
获取锁
检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻 塞在安全点的线程继续往下执行同步代码块;
执行同步代码块
释放锁 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。
偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。
其步骤如下:
暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态

重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,
操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
参考文章


浙公网安备 33010602011771号