1 概述
什么是锁?锁其实是一种同步机制,或者说是实现同步的一种手段,其他的同步机制还有信号量、管程等。其功能就是将一部分代码块或者方法(函数)包围起来作为一个“临界区”,线程访问这段临界区时需要先获取锁(可以理解为获取权限),获取成功则可以进入临界区执行内部代码,否则不能进入临界区。那获取锁失败的线程会干嘛呢?这取决于具体实现,有可能是轮询再次尝试获取,也可能是进入阻塞状态,等待唤醒,甚至可能直接放弃。
锁的分类方式有很多,多种分类方式中有交叉部分,例如悲观锁也可以是公平锁。这里我想表达的是:分类方式其实是指定了某个特定的场景或者说是环境,然后在此基础上对锁进行符合条件的分类。常见的锁类型有如下几种:
- 公平锁和非公平锁,强调公平性。
- 乐观锁和悲观锁,强调的是如何看待并发,即是以乐观的态度,认为没有加锁的必要,还是以悲观的态度,认为肯定要加锁。
- 可重入锁,强调的是其“可重入”特性。
- 独占锁和共享锁,强调的是持有锁的状态。
- 轻量级锁和重量级锁,强调的是锁的“量级”,如果对一个方法或者代码块的开销很大,那么该锁就是一个重量级的。
..........
Java中的锁机制主要有两种(Java5之前,只有一种,即内置锁),内置锁和显式锁。本文主要就是围绕这两种机制展开讨论,不会涉及到底层和操作系统相关的知识。
2 内置锁
所谓内置锁其实就是synchronized关键字,在之前的文章 Java并发编程(二):线程安全 中,我介绍过synchronized关键字的几种用法,所以这里我就不再介绍其用法了,而是介绍一下其实现原理。
2.1 synchronized的实现原理
下面我仍然使用计数的例子来作为演示代码,如下所示:
public class Main {
private static int count;
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0; i < 5; i++) {
service.execute(() -> {
for (int j = 0; j < 10000; j++) {
increment(1);
}
});
}
service.shutdown();
service.awaitTermination(100, TimeUnit.SECONDS);
System.out.println(count);
}
public synchronized static void increment(int delta) {
count += delta;
}
}
使用javac编译并且用javap来查看字节码信息:
> javac Main.java
> javap -verbose Main.class > Main.txt #重定向到文件中,方便查看
Main.txt里内容很多,常量池、MD5校验码等等,不过这里我们只需要关注increment()方法就行了,其相关的字节码如下所示:
public static synchronized void increment(int);
descriptor: (I)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #11 // Field count:I
3: iload_0
4: iadd
5: putstatic #11 // Field count:I
8: return
LineNumberTable:
line 31: 0
line 32: 8
可以看到flags里有一个flag是ACC_SYNCHRONIZED,即同步的意思,因为在演示代码中,synchronized关键是作用在方法上的,JVM运行程序的时候会看到这个ACC_SYNCHRONIZED标记,当执行到该方法时,JVM检查到该方法有ACC_SYNCHRONIZED标记,此时执行该方法的线程就需要先持有一个monitor,然后再执行方法,执行方法完毕之后再释放monitor,该monitor是具有互斥性的,即如果一个线程持有了monitor,他们其他线程就无法获取monitor了。如果在执行方法的过程中发生异常,并且方法内部无法处理异常,那么monitor将在异常抛出时自动释放,保证不会发生monitor无法释放的问题。
演示代码中的synchronized是作用在方法上的,那么如果synchronized作用在代码块中,会是怎么一个情况呢?现在来修改一下increment()方法,变成这样:
public static void increment(int delta) {
synchronized (Main.class) {
count += delta;
}
}
再次编译,并用javap打印出字节码信息。还是一样,我们只关注increment()方法相关的信息,如下所示:
public static void increment(int);
descriptor: (I)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #13 // class top/yeonon/post4/Main
2: dup
3: astore_1
4: monitorenter
5: getstatic #11 // Field count:I
8: iload_0
9: iadd
10: putstatic #11 // Field count:I
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
....
发现,和作用在方法上不一样,此时flags集合中没有了ACC_SYNCHRONIZED标记。但指令比之前多了,那多了哪些呢?仔细对比,可以发现,最最主要的是多了monitorenter和monitorexit指令!又遇到monito这个东西了,那这monitorenter和monitorexit是个什么意思呢?
monitorenter指令指明了同步代码块的起始位置,相应的,monitorexit指令指明了同步代码块的结束位置。这两个指令包裹的区域就是所谓的“临界区”,在示例中的这几条指令其实就对应着count += delta;这行代码,正是源代码中synchronized包裹的代码块。
在线程执行这段代码块之前,需要先获取和与objectref(即对象引用,synchronized括号里的那个对象的引用)对应的monito,如果该monito的计数器值为0,则获取成功,并且将计数器值+1,如果该线程尝试再次获取monito(注意此时monito值不为0),那么也能获取成功,并且将计数器再+1(此时值为2),这就是可重入。在该线程执行完毕之后会释放monito并且将计数器值设置为0,以便其他线程获取monito。
如果在代码块中抛出了异常并且没有处理异常的逻辑,那么该异常就会被默认添加的异常处理器捕获异常(该异常处理器捕获的是任意异常),然后跳转到代码块外部去执行异常处理逻辑,从字节码中,我们可以看出,序号20也是一个monitorexit指令,这个monitorexit指令其实是属于异常处理器的处理逻辑,目的是为了保证即使发生了异常,也一定会释放monito,其实如果对字节码的异常表有了解的话,这个过程会理解的比较深刻。
2.1 synchronized可重入性
上面提到了synchronized的可重入性,现在来稍微介绍一下什么是可重入,维基百科上对于可重入有如下解释:
若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。
下面的例子在一个同步方法中调用了另一个同步方法:
public class ReentrantTest implements Runnable {
public synchronized void get() {
System.out.println(Thread.currentThread().getName() + ";get");
set();
}
public synchronized void set() {
System.out.println(Thread.currentThread().getName() + ";set");
}
public void run() {
get();
}
public static void main(String[] args) {
ReentrantTest rt = new ReentrantTest();
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
service.execute(() -> {
for (int j = 0; j < 1000; j++) {
rt.get();
}
});
}
service.shutdown();
}
}
截取了一小段输出:
pool-1-thread-1;get
pool-1-thread-1;set
pool-1-thread-1;get
pool-1-thread-1;set
pool-1-thread-1;get
pool-1-thread-1;set
对于没有可重入性的锁来说,锁被某个线程获取之后,需要等待线程将锁释放之后,该锁才能再次被获取。假设线程A调用了get()方法,获取了锁(现在锁其实就是rt对象锁),如果该锁是不可重入的,那么当调用set()方法时(也是rt对象锁),就会被阻塞,因为该锁已经被获取过了,更严重的是,现在其他线程也没办法获取到rt对象锁,也就是说发生了“死锁”。但如果锁是可重入的,线程A就可以继续获取rt对象锁,成功执行set方法逻辑,最后代码顺利执行完毕,不会发生死锁。
2.3 synchronized锁优化
从上面的分析中,可以知道synchronized中的锁对象其实就是实例对象(作用在实例方法上或者代码块中括号里的对象是实例字段)或者类对象(作用在静态方法上或者代码块中括号里的对象是类字段)。无论是那种类型的对象,终究都是对象,每个对象都有一个对象头,在对象头中有一部分用来保存对象运行时数据的信息,叫做"Mark Word"。里面包含了锁相关的标记,下面即将要提到的锁升级就是其实就在对锁标记进行修改。
锁的状态有四种,无锁状态、偏向锁、轻量级锁、重量级锁,等级自低向高,开销也自低向高,安全性也同样。Java之所以要分出这4种状态,最主要的原因就是为了提高锁性能,即所谓的锁优化。对于不存在锁竞争的场合,无锁状态是没有同步开销的,所以性能最好,对于竞争不激烈的场合,无锁状态显然不合适了,所以不得不将无锁状态“升级”,但不会直接升级成重量级的锁,而是先升级成开销较小偏向锁,如果偏向锁失效(即可能会导致线程安全问题了),就再次“升级”,最终升级到重量级锁,基本上到了重量级锁,无论竞争是否激烈,都不会发生线程安全问题了。
那锁能否降级呢?说实话,我不确定,有些文章说不能降级,但有的文章说实际上是存在锁降级的,而且其论述让我感觉也符合逻辑。
上面提到几个锁,下面逐一介绍这几个类型的锁,其中穿插着锁升级的过程。
2.3.1 偏向锁
偏向锁是Java6之后新加入的锁类型,具有“偏向(偏心)”的特性。在没有锁竞争的场景下(例如单线程环境),如果一个线程获取到了锁并执行逻辑,那么当该线程再次执行这段逻辑时,就不再需要去获取锁了,即减少了获取锁的次数,因此降低了开销,提高性能。因此,即使我们在单线程环境下执行同步方法或者同步代码块,其实也不会有太大的性能损失。如果此时另外一个线程也要执行这段逻辑(即发生了锁竞争),没有锁竞争的情况就不复存在了,虚拟机识别到这种情况就会进行锁升级,尝试将偏向锁升级到轻量级锁。
2.3.2 轻量级锁
轻量级锁是基于这么一个假设提前的:对绝大部分的锁,在整个同步周期内都不存在竞争。即如果一个线程获取了轻量级锁,然后执行逻辑,此时其他线程不会来尝试获取锁。轻量级锁的开销大于偏向锁,但安全性要优于偏向锁,但如果上述的假设其他被打破,那么轻量级锁就会升级到重量级锁。
2.3.3 重量级锁
基本上就是我们普遍认知的锁,具有很强的互斥性,当一个线程获取到重量级锁之后,无论竞争是否激烈,都会阻止其他线程获取锁,知道该线程主动放弃锁,或者逻辑执行完毕。
在这里顺便说一下所谓的“锁消除”机制(这其实不应该属于锁优化的一部分,应该属于JIT即时编译优化的部分,但由于和锁相关,就放在这里说了。)如果虚拟机支持JIT,那么JIT可能会通过分析上下文,得出某段同步代码其实并不需要同步的结论,并采取“锁消除”的行动,即将同步措施去掉,从而提高性能。这个过程也许会有风险,不过现在的JIT很完善,具有“回滚”的能力,所以并不会对系统造成太大的影响,最多是损失一些性能而已。
3 显式锁
在Java5之前,锁只有一种synchronized内置锁,Java5中新增了Lock、ReadWriteLock等接口及其实现,使得Java多了一种同步手段,一般把这种同步手段称作“显式锁”。
为什么叫“显式锁”呢?在之前介绍synchronized内置锁的时候,我们看到,虽然synchronized用起来不需要用户手动的加锁,解锁,但实际上是编译器帮我们对某个区域进行了加锁和解锁,甚至还附带了默认异常处理器来处理异常情况,可以说是非常贴心了。而接下来要介绍的这种锁,需要用户自己手动的加锁、解锁、处理异常情况等,相对synchronized来说,这是一种显式的操作,故称作“显式锁”。
3.1 Lock和ReentrantLock
Lock接口位于java.util.concurrent.locks包下,是Doug Lea大佬的作品。该接口总共提供了6个抽象方法,如下所示:
public interface Lock {
//加锁
void lock();
//加锁,但是是可中断的
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,只有在锁是空闲的时候才会获取成功
boolean tryLock();
//尝试获取锁,只有在给定时间内锁是空闲的情况下,才会获取成功
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//解锁
void unlock();
//获取一个新的Condition,Condition是一个非常方便的用来协调线程的类
Condition newCondition();
}
ReentrantLock(可重入锁)实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取锁和释放锁的时候,也与synchronized有相同的内存语义,而且还和synchronized一样,是可重入的。可见,ReentrantLock和synchronized非常相似,那么为什么要专门搞出这么一套东西呢?因为synchronized的功能有一些局限,例如在线程获取锁的时候,无法被中断,或者无法实现非阻塞的加锁规则等等,相比之下ReentrantLock具有更丰富的功能以及灵活性,但也比synchronized的使用更加麻烦、易错。
这里我不打算对ReentrantLock的源码做介绍,因为ReentrantLock最最核心的部分涉及到了AQS(AbstractQueuedSynchronizer),而AQS在本文以及之前的文章中没有介绍过(虽然遇到过),所以在后续文章中介绍AQS的时候再把ReentrantLock作为一个例子来讲可能会更好一些。
3.1.1 lock()
下面仍然以计数器的例子来作演示:
public class Main {
private static int count = 0;
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
service.execute(() -> {
for (int j = 0; j < 25000; j++) {
increment(1);
}
});
}
service.shutdown();
service.awaitTermination(1000, TimeUnit.SECONDS);
System.out.println(count);
}
public static void increment(int delta) {
lock.lock();
try {
count += delta;
} finally {
lock.unlock();
}
}
}
和之前不同是这次不再使用内置锁synchronized,而是使用ReentrantLock,要使用ReentrantLock,首先要做的当然是声明ReentrantLock变量并初始化一个实例对象赋值给该变量。使用显式锁的标准格式是这样的:
lock.lock();
try {
.....
} finally {
lock.unlock();
}
//如果有catch的话,再加入即可。
之所以要使用finally来释放lock,是为了保证无论被锁住的区域是否发生异常,都会触发解锁操作,防止出现线程获取了锁但永远不会释放锁的情况。这里有一道常见的面试题(顺便说一下,该规则已被加入到阿里巴巴Java开发手册里),为什么加锁操作不在try内部呢?因为如果放入了try块内部,当lock发生异常的时候(注意此时加锁失败,线程没有获取到锁),程序最终还是会走到finally块并执行解锁操作,Lock.unlock()方法的文档中有类似这样的描述:当非锁持有线程调用该方法的时候会抛出unchecked异常,最终可能会导致加锁失败的异常被该异常覆盖,使得程序员无法通过异常堆栈定位问题。
3.1.2 tryLock()
下面来看看tryLock()方法的使用,还是刚刚的例子,只修改了increment()方法:
public static void increment(int delta) {
if (lock.tryLock()) {
try {
count += delta;
} finally {
lock.unlock();
}
}
}
暂时不分析tryLock起到怎么一个作用,先运行一下试试,发现运行多次都很难出现正确的答案(正确答案应该是100000),为什么?
lock.tryLock()的文档有这么一句话:
Acquires the lock only if it is free at the time of invocation.
Acquires the lock if it is available and returns immediately
with the value {@code true}.
If the lock is not available then this method will return
immediately with the value {@code false}.
即如果调用tryLock()方法成功获取到锁,那么就立即返回true,否则立即返回false,隐含的意思就是放弃本次操作。结合这段描述以及实际情况来看,线程调用这个方法的时候即使获取失败也不会进入阻塞状态,而是直接放弃本次操作!回到上面的例子,假设现在有两个线程同时竞争锁,肯定只会有一个线程获胜,另一个获取失败的线程不会傻傻的等待机会再次获取锁,而是直接放弃,不干了!最终导致“丢失”了很多次+1操作,这就解释了为什么示例输出总是小于等于100000。
tryLock()方法还有一个有两个参数的重载形式,第一个参数是时间数值,第一个参数是时间单位,该方法的意义是,如果在设置的时间内成功获取锁,那么就返回true,和无参的tryLock()不同,如果获取失败,也不会立即返回false并放弃此次操作,而是在设置的时间内获取失败,才会返回false并放弃本次操作,在这段时间内,可以多次尝试。下面稍微修改一下示例中的increment()的代码:
//注意在上层调用代码中处理InterruptedException异常
public static void increment(int delta) throws InterruptedException {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
count += delta;
} finally {
lock.unlock();
}
}
}
发现输出的结果出现100000的概率高了很多!但是要注意,并不是这样做了之后就一定能保证每次都一定会输出正确的结果100000,这取决于机器的性能,如果机器非常非常慢,仍然会出现小于100000的情况,至于为什么,建议仔细再看看上面我对tryLock(long,TimeUnit)的解释。
那该方法用在什么场合呢?显然,我们的计数器示例并不适合使用该方法加锁,更加合适的场景是那种如果超时就放弃的场景,即使放弃本次操作也不会造成太大的影响。例如Web场景,当请求达到服务端,服务端线程在若干秒(配置好的)都无法获取到锁,那么就直接放弃本次操作,并将失败结果返回给前端,就好像服务降级一样(但实际的服务降级并不会那么简单)。
3.1.3 lockInterruptibly()
还剩下一个Lock.lockInterruptibly()方法,从名字上大致能猜出该方法的作用是:获取锁的时候是可响应中断的。下面是一个示例:
public class Main {
private static int count = 0;
private static ReentrantLock lock = new ReentrantLock();
//为了简单,我没按照标准写法写
public static void main(String[] args) throws InterruptedException {
//main线程直接获取锁
lock.lock();
Runnable runnable = () -> {
try {
increment(1);
} catch (InterruptedException e) {
System.out.println("interrupt!");
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.interrupt();
Thread.sleep(200);
thread2.interrupt();
thread1.join();
thread2.join();
lock.unlock();
System.out.println(count);
}
public static void increment(int delta) throws InterruptedException {
lock.lockInterruptibly();
try {
count += delta;
} finally {
lock.unlock();
}
}
}
main线程一开始就获取到了锁,显然后面的两个线程都无法成功获取,所以会陷入阻塞状态,等待锁被释放。但由于lockInterruptibly()是可以响应中断的,所以如果尝试给出中断信号,线程会从阻塞状态中醒过来并抛出InterruptedException异常,如果有处理异常的逻辑,那么就可以捕获该异常并做相应的处理。运行程序,会发现有类似活下输出:
interrupt!
interrupt!
0
0是正确的结果,因为确实两个线程都没能成功获取锁,也就没有能执行+1操作。
lockInterruptibly()的例子可能并不太合适,不过应该足够说明lockInterruptibly()的功能了。(举例子真的挺难的......)
3.1.4 ReentrantLock公平性
ReentrantLock还有一个重载的构造函数,该构造函数接受一个布尔类型的参数fair,传入的值是true表示该锁是公平锁,否则就是非公平锁。公平意味着线程获取到锁的顺序与他到达的顺序一致,即不会出现“插队”的现象,而非公平就会出现某些线程得到“特殊对待”,会出现“插队”。
公平锁和非公平锁除了上述的区别之外,还有一个重要的区别:性能。下面是《Java并发编程实战》一书中给出的一张性能对比图:
从图中可以看出,线程的数量越多,公平锁的吞吐率越低。而非公平锁吞吐率虽然也在降低,但是降低的速度没公平锁那么快。为什么呢?
首先,线程越多意味着竞争也越激烈,假设现在有两个线程A和B竞争同一个锁,A比较快,先获取了锁,B只能阻塞等待。但A释放锁时,B会被唤醒,此时又有一个线程C来请求获取锁,如果该锁是非公平锁,那么C很有会在B被完全唤醒之前获取到锁,然后执行逻辑,释放锁,随后B被完全唤醒,成功获取该锁,如果该锁是公平锁,那么C无法在B之前获取锁,只能等待B被完全唤醒,然后执行逻辑,释放锁。
3.2 ReadWriteLock
ReadWriteLock即读写锁,该接口仅有两个抽象方法:
Lock readLock();
Lock writeLock();
readLock()会返回一个读锁,writeLock()会返回一个写锁。在JDK里有一个类ReentrantReadWriteLock,该类实现了该接口,在其内部还有两个重要的静态内部类WriteLock和ReadLock,这两个类都实现了Lock接口。如下所示:
public static class WriteLock implements Lock, java.io.Serializable {
//....
}
public static class ReadLock implements Lock, java.io.Serializable {
//...
}
ReentrantReadWriteLock对ReadWriteLock的两个实现方法也非常简单,就是返回WriteLock和ReadLock的实例,如下所示:
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
WriteLock和ReadLock的使用方法和ReentrantLock没什么区别,只是语义上有些区别而已。ReadLock即读锁,该锁可以被多个线程共享,WriteLock即写锁,该锁是互斥的,同一时刻只能被一个线程持有。
这里我对读写锁的介绍非常少,原因是之前已经比较详细的介绍了ReentrantLock,读写锁和ReentrantLock不同的地方只是在于具体的实现,而具体的实现又涉及到AQS,所以就没有介绍太多。
4 内置锁synchronized和显式锁如何选择
在Java5和Java6中,显式锁的性能高于synchronized内置锁,而且功能比synchronized丰富,例如支持可中断的等待,可定时的等待以及公平性等,缺点就是较synchronized复杂一些,代码不如synchronized简洁,而且容易出错(例如忘了在finally中释放锁,或者把加锁操作放到try块内部),那究竟如何在两者之间做选择呢?
我个人倾向于优先选择内置锁synchronized,除非需要使用到一些内置锁无法提供的功能。因为synchronized是JVM支持的一种同步机制,可操作或者说可优化空间很大,JVM完全可以在底层实现中对synchronized做优化,例如上面提到过的锁升级,锁消除等,所以性能上并不会比内置锁差,而且实际上Java也一直在新的版本中对synchronized做优化。而显式锁本质是只是“类库”中的一员,难以获得JVM底层的支持,可优化空间比较小。
最后在重复一遍:除非需要使用一些内置锁无法提供的高级功能,例如支持可中断的线程等待、公平性等,否则建议优先选择内置锁synchronized。
5 死锁
死锁是这么一种情况:假设有两个线程A和B以及两个锁lock1和lock2,线程A持有锁lock2,但是想获取锁lock2,而线程B持有lock2,但想获取lock1,这样导致的结果就是A和B都无法获得他们想获取的锁,从而导致两个线程永远处于阻塞等待的状态。
死锁有四个必要条件,其中任何一个条件不成立,都不会造成死锁:
- 占有并等待,线程在持有锁的情况下,仍然想要获取另一个锁。
- 禁止抢占,不能抢占其他线程持有的锁。
- 互斥等待,锁同时只能被一个线程持有。
- 循环等待,一系列线程互相持有其他线程所需要的锁。
下面是一个死锁的示例:
public class DeadLockTest {
public static void main(String[] args) throws InterruptedException {
String lock1 = "lock1";
String lock2 = "lock2";
DeadLock deadLock1 = new DeadLock(lock1, lock2);
DeadLock deadLock2 = new DeadLock(lock2, lock1);
Thread thread1 = new Thread(deadLock1);
Thread thread2 = new Thread(deadLock2);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
private static class DeadLock implements Runnable {
private final String lockA;
private final String lockB;
private DeadLock(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread() + " get " + lockA);
try {
Thread.sleep(250);
synchronized (lockB) {
System.out.println(Thread.currentThread() + "get " + lockB);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
运行一下,输出结果大致如下所示:
Thread[Thread-1,5,main] get lock2
Thread[Thread-0,5,main] get lock1
线程1获取了lock2,想要获取lock1,但lock1已经被线程0持有了,同时线程0还想获取lock2(满足上述四个条件中的条件1、4),而且lock1和lock2都是互斥锁(满足条件3),默认情况下,这两个锁都是不可强占的(满足条件2)。至此,上述四个条件都满足了,所以发生死锁也是必然的。
这个示例很简单,我们可以立即知道死锁在哪里发生,为什么会发生死锁,但如果在大型的程序中发生死锁,往往很难发现,因为死锁不会抛出异常,也就不会有异常堆栈来让我们分析,那么如果在大型程序中发生了死锁,该如何定位问题呢?下面我将介绍两种方法来定位问题,分析问题。
5.1 使用jstack工具来定位死锁问题
jstack是JDK自带的堆栈分析工具,关于该工具的使用我已经在之前的文章中有过介绍,在此不再赘述,直接开始动手。
先使用Jps来获取进程ID:
> jps
30512 Jps
33348 Launcher
43716 DeadLockTest
49188
51500 RemoteMavenServer
得到DeadLockTest的进程ID是43716,然后使用jstack打印其堆栈信息:
> jstack -l 43716
#省略了部分内容
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0000000002d1cc98 (object 0x00000000d5fbd1b8, a java.lang.String),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0000000018d1b808 (object 0x00000000d5fbd1f0, a java.lang.String),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at top.yeonon.post4.DeadLockTest$DeadLock.run(DeadLockTest.java:42)
- waiting to lock <0x00000000d5fbd1b8> (a java.lang.String)
- locked <0x00000000d5fbd1f0> (a java.lang.String)
at java.lang.Thread.run(Thread.java:748)
"Thread-0":
at top.yeonon.post4.DeadLockTest$DeadLock.run(DeadLockTest.java:42)
- waiting to lock <0x00000000d5fbd1f0> (a java.lang.String)
- locked <0x00000000d5fbd1b8> (a java.lang.String)
at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.
jstack已经帮我们发现了死锁问题,并且还打印了堆栈信息供我们分析。从分析中,可以看出Thread-1等待0x00000000d5fbd1b8锁,并持有0x00000000d5fbd1f0锁,Thread-0等待0x00000000d5fbd1f0锁,并持有0x00000000d5fbd1b8锁,发生问题的地方是DeadLockTest.java的源码第42行,虽然这个行数可能并不准确,但至少可以缩小排查范围,能更快的定位问题,解决问题。
5.2 使用VistualVM来定位死锁问题
VistualVM在之前的文章中也有介绍过,在这里也不再多说了,直接使用。打开VistualVM时候,选择DeadLockTest进程,然后选择上边Tab栏的Threads选项卡,点进来就发看到VistualVM给我们一个大大的死锁相关的提示,如下所示:
点击右边的Thread Dump,就能查看线程堆栈信息了,其信息和Jstack打印出来的几乎一摸一样,就不多少了。
现在死锁问题是找到源头了,但如何解决问题呢?死锁既然已经发生了,一般就很难依靠程序自己去解决了,只能依靠人工杀死其中一个线程来解决问题了,关于死锁的预防、避免等,在这里我就不多说了(其实很多手段我自己也没搞太明白,建议各位自己多多查找网上资料学习)。
6 CAS
CAS即Compare And Set(比较并设置),包含的意义是:先拿旧值和当前值比较,如果相等即表示值没有发生变化,最后再将新的值赋值给变量。
那这个机制有什么用呢?如果是单线程的环境下,这样做显得有些多余,但在多线程环境下确实能保证一定的线程安全,而且是无锁的(即没有锁的开销)。假设现在有两个线程A和B对同一个共享变量做修改操作并且程序使用了CAS机制,当A线程对共享变量进行修改的时候,他会先检查一下该共享变量和之前读到的是否一致,如果一致,就将该变量的值更新,否则就放弃更新操作,继续轮询,直到一致为止。那为什么会出现不一致的情况呢?因为也许A线程刚刚读到该变量的值就被CPU换出去了,B线程开始执行,读取该变量值、然后检查该值的实际值和上一步读到的变量一致,这时候没有其他线程干预,会发现是一致的,然后就修改该值,写入内存,然后又切换到A线程,很明显,此时A线程去做CAS的时候,发现两个值不一致,所以就会放弃本次操作,继续轮询。
说了那么多,可能有些抽象,下面是一个例子,使用Unsafe对象提供了API:
public void increment(int delta) {
int oldValue;
do {
oldValue = unsafe.getIntVolatile(this, countOffset);
} while (!unsafe.compareAndSwapInt(this, countOffset, oldValue, oldValue + delta));
}
仔细看看代码,结合上面我说的那一串逻辑,应该就能理解整个过程了。下面是完整版代码:
public class CASTest {
private static Unsafe unsafe;
private volatile int count;
public CASTest(int count) {
this.count = count;
}
private static long countOffset;
static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
countOffset = unsafe.objectFieldOffset(CASTest.class.getDeclaredField("count"));
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
}
public void increment(int delta) {
int oldValue;
do {
oldValue = unsafe.getIntVolatile(this, countOffset);
} while (!unsafe.compareAndSwapInt(this, countOffset, oldValue, oldValue + delta));
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
CASTest casTest = new CASTest(0);
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
service.execute(() -> {
for (int j = 0; j < 25000; j++) {
casTest.increment(1);
}
});
}
service.shutdown();
service.awaitTermination(1000, TimeUnit.SECONDS);
System.out.println(casTest.getCount());
}
}
6.1 ABA问题
CAS操作非常容易出现ABA问题。什么是ABA问题呢?假设有两个线程A和B对同一变量做修改操作,初始化时该变量值为0,之后A线程使用CAS操作将其修改为1,但出于某种需求,又把值修改为0,此时B线程进行CAS操作时,检查发现刚刚读取到的值和当前值没有变化,所以检查成功,并将其修改为1,但整个过程中,B完全不知道其实该共享变量已经被修改过了,有时候出现这种情况对整个系统没什么影响,有时候影响又非常严重,尤其是涉及到转账等金融计算问题的时候,下面的这个例子演示了ABA问题对转账的影响:
上图中最后余额应该是100(100-50+50),但最终却变成了50。最根本的原因就是因为ABA问题,这里我就不多做解释了,希望读者能理解这么个意思。要解决ABA问题,现在常见的方法有加入版本号,这个版本号会随着操作次数增加而增加,这样线程在比较的时候就可以知道共享变量是否已经被修改过了,也可以换成时间戳,效果差不多。
6.2 CAS的优缺点
优点:
- CAS是无锁的同步机制,开销比锁要小很多。
缺点:
- CPU开销大,在竞争激烈的场合,空转的时间占比会比较大。
- 代码可读性差。
- 编程模型也较为复杂。(Java中能使用Unasfe提供的API已经算是大大简化了开发)
- 上面说到的ABA问题。
所以,虽然CAS开起来是一个能提高性能的技术,但问题也很多,选择的时候要非常非常慎重,开发的时候也需要小心翼翼。
7 小结
本文介绍了什么是锁,比较详细的介绍了Java内置锁以及显式锁,两种类型的锁都很常用,内存语义也非常相似,最大的区别是显式锁的功能较为丰富,支持可中断的线程等待等功能。内置锁是Java大力发展、支持的对象,性能随着版本迭代也越来越好,建议优先考虑使用内置锁,只有在内置锁无法满足需求的情况下,才选择使用显式锁。之后还简单介绍了死锁的概念以及死锁发生的四个必要条件,并且介绍了两种方法来定位死锁问题所在,但没有涉及到死锁的避免、预防等,这方面的知识,各位可以去看看银行家算法的相关资料。最后讲了一下什么是CAS,Java对CAS的支持以及所谓的“ABA”问题。
这篇文章我写了好多天,但还是感觉很多东西没有讲到,例如分布式锁,数据库表锁,行锁等。希望读者能继续扩展阅读其他优秀资料,丰富自己的技能树,逐步解开“锁”的神秘面纱!
8 参考资料
《Java并发编程实战》
互联网上相关博客文章(实在太多了,很多博客的地址都记不住了,抱歉)