详解JUC包下各种锁的使用
[toc]
# 写在文章开头
在多线程编程中,线程安全是一个无法回避的核心问题。Java的java.util.concurrent(JUC)包为开发者提供了一系列强大的工具,其中各种锁的实现尤为关键。从经典的ReentrantLock到高效的StampedLock,再到灵活的ReadWriteLock,JUC包中的锁机制不仅能够帮助我们解决并发问题,还能显著提升程序的性能和可扩展性。然而,面对如此丰富的锁类型,如何选择合适的锁、如何避免常见的并发陷阱,以及如何优化锁的使用,都是开发者需要深入掌握的技能。本文将详细解析JUC包中各种锁的使用场景、实现原理以及最佳实践,助你在多线程编程中游刃有余,轻松应对复杂的并发挑战。
# Java中的锁
我们日常开发过程中为了保证临界资源的线程安全可能会用到synchronized,但是synchronized局限性也是很强的,它无法做到以下几点:
- 让当前线程立刻释放锁。
- 判断线程持有锁的状态。
- 线程争抢的公平争抢。
所以为了保证用户能够在合适的场景找到合适的锁,Java设计者按照不同的维度为我们提供了各种锁,锁的分类按照不同的特征分为以下几种:

# 详解Lock接口
# 为什么需要Lock接口式的锁
锁是一种解决资源共享问题的解决方案,相比于synchronized锁,Lock锁的自类增加了一些更高级的功能:
- 锁等待。
- 锁中断。
- 可随时中断释放。
- 锁重入。
但这并不能表明,Lock锁是synchronized锁的替代品,它俩都有各自的适用场合。
# Lock接口的基本规范
宏观角度来看Lock接口不仅支持简单的上锁和释放锁,还支持超时等待锁、上可中断锁,锁中断等操作:
public interface Lock {
//上锁
void lock();
//上一把可中断的锁
void lockInterruptibly() throws InterruptedException;
//非阻塞尝试取锁
boolean tryLock();
//超时等待锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//锁释放
void unlock();
//......
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 使用Lock的优雅姿势
我们以ReentrantLock来演示一下Lock类的加锁和解锁操作。细心的读者在阅读源码时可能会发现下面这样一段注释,这就是lock类上锁的解锁的基本示例了。
* <pre> {@code
* class X {
* private final ReentrantLock lock = new ReentrantLock();
* // ...
*
* public void m() {
* lock.lock(); // block until condition holds
* try {
* // ... method body
* } finally {
* lock.unlock()
* }
* }
* }}
2
3
4
5
6
7
8
9
10
11
12
13
14
所以我们也按照上面这段示例编写一下一段demo代码。注意lock锁必须手动释放,所以为了保证释放的安全我们常常会在finally语句块中进行锁释放,如官方给出的代码示例一样:
ReentrantLock lock = new ReentrantLock();
//上锁
lock.lock();
try {
System.out.println("当前线程" + Thread.currentThread().getName() + "获得锁,进行异常操作");
int i = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
} finally {
//语句块中优雅释放
lock.unlock();
}
log.info("当前锁是否被锁定:{}", lock.isLocked());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
对应的我们也给出输出结果:
当前线程main获得锁,进行异常操作
15:41:05.499 [main] INFO com.sharkChili.Main - 当前锁是否被锁定:false
java.lang.ArithmeticException: / by zero
at com.sharkChili.Main.main(Main.java:23)
2
3
4
# tryLock
相比于普通的lock来说,tryLock相对更加强大一些,tryLock可以根据当前线程是否取得锁进行一些定制化操作。
而且tryLock可以立即返回或者在一定时间内取锁,如果拿得到就拿锁并返回true,反之返回false。
我们现在创建一个任务给两个线程使用,逻辑很简单,在每个线程在while循环中,flag为1的先取锁1,flag为2的先取锁2。
flag为1的先在规定时间内获取锁1,获得锁1后再获取锁2,如果锁2获取失败则释放锁1休眠一会。让另一个先获取锁2在获取锁1的线程执行完再进行获取锁。
public class TryLockDemo implements Runnable {
//注意使用static 否则锁的粒度用错了会导致无法锁住彼此
private static Lock lock1 = new ReentrantLock();
private static Lock lock2 = new ReentrantLock();
//flag为1的先取锁1再去锁2,反之先取锁2在取锁1
private int flag;
public int getFlag() {
return flag;
}
public void setFlag(int flag) {
this.flag = flag;
}
@Override
public void run() {
while (true) {
//flag为1先取锁1再取锁2
if (flag == 1) {
try {
//800ms内尝试取锁,如果失败则直接输出尝试获取锁1失败
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"拿到了第一把锁lock1");
//睡一会,保证线程2拿锁锁2
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"取到锁2");
System.out.println(Thread.currentThread().getName()+"拿到两把锁,执行业务逻辑了。。。。");
break;
} finally {
lock2.unlock();
}
} else {
System.out.println(Thread.currentThread().getName()+"获取第二把锁锁2失败");
}
} finally {
//休眠一会再次获取锁
lock1.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println(Thread.currentThread().getName()+"尝试获取锁1失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
try {
//3000ms内尝试获取锁2,如果娶不到直接输出失败
if (lock2.tryLock(3000, TimeUnit.MILLISECONDS)) {
try{
System.out.println(Thread.currentThread().getName()+"先拿到了锁2");
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"取到锁1");
System.out.println(Thread.currentThread().getName()+"拿到两把锁,执行业务逻辑了。。。。");
break;
} finally {
lock1.unlock();
}
} else {
System.out.println(Thread.currentThread().getName()+"获取第二把锁1失败");
}
}finally {
//休眠一会,顺便把锁释放让其他线程获取
lock2.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println(Thread.currentThread().getName()+"获取锁2失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
测试代码
public class TestTryLock {
public static void main(String[] args) {
//先获取锁1
TryLockDemo t1 = new TryLockDemo();
t1.setFlag(1);
//先获取锁2
TryLockDemo t2 = new TryLockDemo();
t2.setFlag(2);
new Thread(t1,"t1").start();
new Thread(t2,"t2").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
输出结果如下,可以看到tryLock的存在使得我们可以不再阻塞的去获取锁,而是可以根据锁的持有情况进行下一步逻辑。
t1拿到了第一把锁lock1
t2先拿到了锁2
t1获取第二把锁锁2失败
t2取到锁1
t2拿到两把锁,执行业务逻辑了。。。。
t1拿到了第一把锁lock1
t1取到锁2
t1拿到两把锁,执行业务逻辑了。。。。
2
3
4
5
6
7
8
# 可被中断的lock
为避免synchronized这种获取锁过程无法中断,进而出现死锁的情况。JUC包下的锁提供了lockInterruptibly方法,即在获取锁过程中的线程可以被打断。
public class LockInterruptiblyDemo implements Runnable {
private static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 尝试取锁");
try {
//设置为可被中断的获取锁
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + " 取锁成功");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " 执行业务逻辑时被中断");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "尝试取锁时被中断");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
测试代码如下,我们先让线程1获取锁成功,此时线程2取锁就会失败,我们可以手动通过interrupt将其打断。
public class LockInterruptiblyTest {
public static void main(String[] args) throws InterruptedException {
LockInterruptiblyDemo lockInterruptiblyDemo = new LockInterruptiblyDemo();
//线程1先获取锁,会成功
Thread thread0 = new Thread(lockInterruptiblyDemo);
thread0.start();
//线程2获取锁失败,不会中断
Thread thread1 = new Thread(lockInterruptiblyDemo);
thread1.start();
Thread.sleep(5000);
//手动调用interrupt将线程中断
thread1.interrupt();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Lock锁的可见性保证
可能很多人会对这些操作有这样的疑问,我们lock的结果如何对之后操作该资源的线程保证可见性呢?
其实根据happens-before原则,前一个线程操作的结果,对后一个线程是都可见的原理即可保证锁操作的可见性。

# 详解不同分类的锁以及使用
# 按照是否锁住资源分类
# 悲观锁
悲观锁认为自己在修改数据过程中,其他人很可能会过来修改数据,为了保证数据的准确性,他会在自己修改数据时候持有锁,在释放锁之前,其他线程是无法持有这把锁。在Java中synchronized锁和lock锁都是典型的悲观锁。

对应的我们给出悲观锁的使用示例:
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread(()->{
doSomething();
countDownLatch.countDown();
});
Thread t2 = new Thread(()->{
doSomething();
countDownLatch.countDown();
});
t1.start();
t2.start();
countDownLatch.await();
}
/**
* synchronized 悲观锁,保证上锁成功后才能操作临界资源
*/
public synchronized static void doSomething() {
log.info("{} do something", Thread.currentThread().getName());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 乐观锁
乐观锁认为自己的修改数据时不会有其他人会修改数据,所以他每次修改数据后会判断修改前的数据是否被修改过,如果没有就将更新结果写入,反之重新拉取数据的最新结果进行更新再重复之前步骤完成写入,在Java中乐观锁常常用CAS原子类来实现:

如下代码所示,原子类就是通过CAS乐观锁实现的:
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
}
2
3
4
我们可以看看cas原子类getAndIncrement的源码,它会调用unsafe的getAndAddInt,将this和偏移量,还有1传入。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
2
3
查看getAndAddInt的工作流程我们即可知晓CAS乐观锁操作的实现细节:
- 通过
getIntVolatile方法获取到需要操作的变量的地址。 - 通过
compareAndSwapInt的方式查看原有的值是否发生变化,如果没有则将修改后的结果v + delta写入到变量地址空间中。 - 如果发生变化则
compareAndSwapInt会返回false,继续从步骤1开始,直到修改操作成功。

对应的我们给出getAndAddInt的源码实现:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
//拉取操作变量最新值
v = getIntVolatile(o, offset);
//比对拉取结果与最新结果是否一致,若一致则写入最新结果,反之继续循环直到修改成功
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
2
3
4
5
6
7
8
9
# 悲观锁和乐观锁的比较
该问题我们可以从以下两个角度进行说明
- 从资源开销的角度:悲观锁的开销远高于乐观锁,但它确实一劳永逸的,临界区持有锁的时间就算越来越长也不会对互斥锁有任何的影响。反之乐观锁假如持有锁的时间越来越长的话,其他等待线程的自选时间也会增加,从而导致资源消耗愈发严重。
- 从场景适用角度:悲观更适合那些经常操作修改的场景,而乐观锁更适合读多修改少的情况。
# 按照是否可重入进行锁分类
# 可重入锁示例
代码如下所示,我们创建一个MyRecursionDemo ,这个类的逻辑很简单,让当前线程通过递归的方式连续获得锁5次。
public class MyRecursionDemo {
private ReentrantLock lock = new ReentrantLock();
public void accessResource() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 第" + lock.getHoldCount() + "次处理资源中");
if (lock.getHoldCount() < 5) {
System.out.println("当前线程是否是持有这把锁的线程" + lock.isHeldByCurrentThread());
System.out.println("当前等待队列长度" + lock.getQueueLength());
System.out.println("再次递归处理资源中........................................");
//再次递归调用该方法,尝试重入这把锁
accessResource();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("处理结束,释放可重入锁");
lock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
测试代码
public class MyRecursionDemoTest {
public static void main(String[] args) {
MyRecursionDemo myRecursionDemo=new MyRecursionDemo();
myRecursionDemo.accessResource();
}
}
2
3
4
5
6
7
从输出结果来看main线程第一次成功取锁之后,在不释放的情况下,连续尝试取ReentrantLock 5次都是成功的,是支持可重入的。
main 第1次处理资源中
当前线程是否是持有这把锁的线程true
当前等待队列长度0
再次递归处理资源中........................................
main 第2次处理资源中
当前线程是否是持有这把锁的线程true
当前等待队列长度0
再次递归处理资源中........................................
main 第3次处理资源中
当前线程是否是持有这把锁的线程true
当前等待队列长度0
再次递归处理资源中........................................
main 第4次处理资源中
当前线程是否是持有这把锁的线程true
当前等待队列长度0
再次递归处理资源中........................................
main 第5次处理资源中
处理结束,释放可重入锁
处理结束,释放可重入锁
处理结束,释放可重入锁
处理结束,释放可重入锁
处理结束,释放可重入锁
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 不可重入锁
NonReentrantLock就是典型的不可重入锁,代码示例如下:
public class NonReentrantLockDemo {
public static void main(String[] args) {
NonReentrantLock lock=new NonReentrantLock();
lock.lock();
System.out.println(Thread.currentThread().getName()+"第一次获取锁成功");
lock.lock();
System.out.println(Thread.currentThread().getName()+"第二次获取锁成功");
}
}
2
3
4
5
6
7
8
9
10
从输出结果来看,第一次获取锁之后就无法再次重入锁了。
main第一次获取锁成功
2
# 源码解析可重入锁和非可重入锁区别
查看ReentrantLock可重入锁源码可知,可重入锁进行锁定逻辑时,会判断持有锁的线程是否是当前线程,如果是则将c(即count的缩写)自增:
final boolean nonfairTryAcquire(int acquires) {
.....
//如果当前线程仍然持有这把锁,记录一下持有锁的次数 并返回拿锁成功
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
相比之下不可重入锁的逻辑就比较简单了,如下源码NonReentrantLock所示,通过CAS修改取锁状态,若成功则将锁持有者设置为当前线程。
同一个线程再去取锁时并没有重入的处理,仍然是进行CAS操作,很明显这种情况是会失败的。
@Override
protected final boolean tryAcquire(int acquires) {
// 通过CAS修改锁状态
if (compareAndSetState(0, 1)) {
//若成功则将锁持有者设置为当前线程
owner = Thread.currentThread();
return true;
}
return false;
}
2
3
4
5
6
7
8
9
10
# 公平锁和非公平锁
公平锁可以保证线程持锁顺序会有序进行,在线程争抢锁的过程中如果上锁失败是会统一提交到等待队列中,后续由队列统一管理唤醒:

非公平锁的设计初衷也很明显,非公平锁的设计就是为了在线程唤醒期间的空档期让其他线程可以插队,所以即使等待队列中有线程,其他的不在队列中的线程依然可以持有这把锁:

# 公平锁代码示例
我们先创建一个任务类的代码,run方法逻辑很简单,上一次锁打印输出一个文件,这里会上锁两次打印两次。构造方法中要求传一个布尔值,这个布尔值如果为true则说明ReentrantLock 为公平,反之为非公平。
public class MyPrintQueue implements Runnable {
private boolean fair;
public MyPrintQueue(boolean fair) {
this.fair = fair;
}
/**
* true为公平锁 false为非公平锁
*/
private ReentrantLock lock = new ReentrantLock(fair);
/**
* 上锁两次打印输出两个文件
*/
public void printStr() {
lock.lock();
try {
int s = new Random().nextInt(10) + 1;
System.out.println("正在打印第一份文件。。。。当前打印线程:" + Thread.currentThread().getName() + " 需要" + s + "秒");
Thread.sleep(s * 1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
lock.lock();
try {
int s = new Random().nextInt(10) + 1;
System.out.println("正在打印第二份文件。。。。当前打印线程:" + Thread.currentThread().getName() + " 需要" + s + "秒");
Thread.sleep(s * 1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
@Override
public void run() {
printStr();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
测试代码
public class FairLockTest {
public static void main(String[] args) {
//创建10个线程分别执行这个任务
MyPrintQueue task=new MyPrintQueue(true);
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
try{
Thread.sleep(100);
}catch (Exception e){
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
从输出结果来看,线程是按顺序执行的
正在打印第一份文件。。。。当前打印线程:Thread-0 需要2秒
正在打印第二份文件。。。。当前打印线程:Thread-0 需要8秒
正在打印第一份文件。。。。当前打印线程:Thread-1 需要1秒
正在打印第二份文件。。。。当前打印线程:Thread-1 需要8秒
正在打印第一份文件。。。。当前打印线程:Thread-2 需要2秒
正在打印第二份文件。。。。当前打印线程:Thread-2 需要9秒
正在打印第一份文件。。。。当前打印线程:Thread-3 需要10秒
正在打印第二份文件。。。。当前打印线程:Thread-3 需要2秒
正在打印第一份文件。。。。当前打印线程:Thread-4 需要10秒
正在打印第二份文件。。。。当前打印线程:Thread-4 需要1秒
正在打印第一份文件。。。。当前打印线程:Thread-5 需要5秒
正在打印第二份文件。。。。当前打印线程:Thread-5 需要8秒
正在打印第一份文件。。。。当前打印线程:Thread-6 需要9秒
正在打印第二份文件。。。。当前打印线程:Thread-6 需要6秒
正在打印第一份文件。。。。当前打印线程:Thread-7 需要9秒
正在打印第二份文件。。。。当前打印线程:Thread-7 需要8秒
正在打印第一份文件。。。。当前打印线程:Thread-8 需要6秒
正在打印第二份文件。。。。当前打印线程:Thread-8 需要6秒
正在打印第一份文件。。。。当前打印线程:Thread-9 需要6秒
正在打印第二份文件。。。。当前打印线程:Thread-9 需要4秒
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
非公平锁将标志调整为false即可,这里就不多做演示了。
# 通过源码查看两者实现逻辑
如下所示,我们可以在构造方法中看到公平锁和非公平锁是如何根据参数决定的。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
2
3
我们不妨看看ReentrantLock公平锁的内部类公平锁FairSync的源码,如下所示,可以看到,他的取锁逻辑必须保证当前取锁的节点没有前驱节点才能抢锁,这也就是为什么我们的线程会排队取锁。
static final class FairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//当前节点没有前驱节点的情况下才能进行取锁
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
相比之下,非公平锁就很粗暴了,我们看看ReentrantLock内部类NonfairSync,只要CAS成功就行了,所以锁一旦空闲,所有线程都可以随机争抢。
final void lock() {
//无论队列情况,直接CAS成功后即可持有锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
2
3
4
5
6
7
# 小结一下公平锁和非公平锁
相对之下公平锁由于是有序执行,所以相对非公平锁来说执行更慢,吞吐量更小一些。
而非公平锁可以在特定场景下实现插队,所以很有可能出现某些线程被频繁插队而导致"线程饥饿"的情况。
# 共享锁和非共享锁
共享锁最常见的使用就是ReentrantReadWriteLock,他的读锁就是共享锁,当某一线程使用读锁时,其他线程也可以使用读锁,因为读不会修改数据,无论多少个线程读都可以。
而写锁就是独占锁的典型,当某个线程执行写时,为了保证数据的准确性,其他线程无论使用读锁还是写锁,都得阻塞等待当前正在使用写锁的线程释放锁才能执行。

# 读写锁使用示例
代码的逻辑也很简单,获取读锁读取数据,获取写锁修改数据。
public class BaseRWdemo {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//读锁
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//写锁
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
//获取读锁,读取数据
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}finally {
System.out.println(Thread.currentThread().getName() + "释放了读锁");
readLock.unlock();
}
}
private static void write() {
//获取写锁,写数据
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁");
Thread.sleep(1000);
} catch (Exception e) {
}finally {
System.out.println(Thread.currentThread().getName() + "释放了写锁");
writeLock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
测试代码:
public static void main(String[] args) {
//读锁可以一起获取
new Thread(() -> read(), "thread1").start();
new Thread(() -> read(), "thread2").start();
//等上面读完写锁才能用 从而保证线程安全问题
new Thread(() -> write(), "thread3").start();
//等上面写完 才能开始写 避免线程安全问题
new Thread(() -> write(), "thread4").start();
}
2
3
4
5
6
7
8
9
10
11
从输出结果不难看出,一旦资源被上了读锁,写锁就无法操作,只有读锁操作结束,写锁才能操作资源。
thread1得到读锁
thread2得到读锁
thread1释放了读锁
thread2释放了读锁
# 写锁必须等读锁释放了才能操作
thread3得到写锁
thread3释放了写锁
thread4得到写锁
thread4释放了写锁
2
3
4
5
6
7
8
9
10
11
12
# 读写锁非公平场景下的插队问题
读写锁ReentrantReadWriteLock设置为true,即公平锁,其底层也很上述的ReentrantLock类似,同样是通过AQS管理线程流程控制,同样是非公平情况下任意线程都可以直接尝试争抢锁而非,对应的代码示例如下,可以看到笔者初始化读写锁ReentrantReadWriteLock并将fair标识设置为true即公平锁:
//设置为false之后 非公平 等待队列前是读锁 就可以让读锁插队
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
2
3
4
5
同时我们也给出读写锁的实用代码,逻辑比较简单,上锁后休眠1000毫秒,同时在尝试上锁、得到锁、释放锁附近打印日志:
private static void read() {
log.info("尝试获取读锁");
readLock.lock();
try {
log.info("获取读锁成功,执行业务逻辑......");
ThreadUtil.sleep(1000);
} catch (Exception e) {
//......
} finally {
readLock.unlock();
log.info("释放读锁");
}
}
private static void write() {
log.info("尝试获取写锁");
writeLock.lock();
try {
log.info("获取写锁成功,执行业务逻辑......");
Thread.sleep(1000);
} catch (Exception e) {
} finally {
writeLock.unlock();
log.info("释放写锁");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
对应的我们也给出使用的代码示例,感兴趣的读者可以基于标识自行调试一下:
public static void main(String[] args) {
new Thread(() -> write(), "t0").start();
new Thread(() -> read(), "t1").start();
new Thread(() -> read(), "t2").start();
new Thread(() -> write(), "t3").start();
new Thread(() -> read(), "t4").start();
ThreadUtil.sleep(1, TimeUnit.DAYS);
}
2
3
4
5
6
7
8
9
10
# 源码解析非公平锁插队原理
我们可以看到一个tryAcquireShared方法,因为我们设置的是非公平锁,所以代码最后只能会走到NonfairSync 的tryAcquireShared。
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -2694183684443567898L;
NonfairSync(int permits) {
super(permits);
}
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}
2
3
4
5
6
7
8
9
10
11
可以看到逻辑也很简单,一旦队首节点释放锁之后,就会通知其他节点进行争抢,而其他节点都会走到这段逻辑,只要判断到没有人持有锁,就直接进行CAS争抢。这就应证了我们上述的观点,等待队列首节点是写锁占有锁的情况下,一旦写锁释放之后,后续的线程可以任意插队抢占并上读锁或者写锁,这也就是为什么我们上文的线程3先于线程2上了读锁的原因。
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
//如果小于0则说明没有人持有可以直接通过CAS进行争抢
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
2
3
4
5
6
7
8
9
10
# 自旋锁和非自旋锁
我们都知道Java阻塞或者唤醒一个线程都需要切换CPU状态的,这样的操作非常耗费时间,而很多线程切换后执行的逻辑仅仅是一小段代码,为了这一小段代码而耗费这么长的时间确实是一件得不偿失的事情。对此java设计者就设计了一种让线程不阻塞,原地"稍等"即自旋一下的操作。
# 自旋锁代码示例
如下代码所示,我们通过AtomicReference原子类实现了一个简单的自旋锁,通过compareAndSet尝试让当前线程持有资源,如果成功则执行业务逻辑,反之循环等待。
public class MySpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public void lock() {
Thread curThread = Thread.currentThread();
//使用原子类自旋设置原子类线程,若线程设置为当前线程则说明当前线程上锁成功
while (!sign.compareAndSet(null, curThread)) {
System.out.println(curThread.getName() + "未得到锁,自旋中");
}
}
public void unLock() {
Thread curThread = Thread.currentThread();
sign.compareAndSet(curThread, null);
System.out.println(curThread.getName() + "释放锁");
}
public static void main(String[] args) {
MySpinLock mySpinLock = new MySpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "尝试获取自旋锁");
mySpinLock.lock();
System.out.println(Thread.currentThread().getName() + "得到了自旋锁");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
mySpinLock.unLock();
System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
}
}
};
Thread t1=new Thread(runnable,"t1");
Thread t2=new Thread(runnable,"t2");
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
输出结果
t1尝试获取自旋锁
t2尝试获取自旋锁
t1得到了自旋锁
t2未得到锁,自旋中
t2未得到锁,自旋中
t2未得到锁,自旋中
t2未得到锁,自旋中
t2未得到锁,自旋中
t2未得到锁,自旋中
t1释放锁
t2得到了自旋锁
t1释放了自旋锁
t2释放锁
t2释放了自旋锁
2
3
4
5
6
7
8
9
10
11
12
13
14
# 可中断锁和非可中断锁
可中断锁上文lockInterruptibly上文已经演示过了,这里就不多做赘述了。
public class LockInterruptiblyDemo implements Runnable {
//设置为static,所有对象共享
private static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 尝试取锁");
try {
//设置锁可以被打断
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + " 取锁成功");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " 执行业务逻辑时被中断");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "尝试取锁时被中断");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
测试代码
public static void main(String[] args) throws InterruptedException {
LockInterruptiblyDemo lockInterruptiblyDemo = new LockInterruptiblyDemo();
//线程1启动
Thread thread0 = new Thread(lockInterruptiblyDemo);
thread0.start();
//线程2启动
Thread thread1 = new Thread(lockInterruptiblyDemo);
thread1.start();
//主线程休眠,让上述代码执行,然后执行打断线程1逻辑 thread0.interrupt();
Thread.sleep(2000);
thread0.interrupt();
}
2
3
4
5
6
7
8
9
10
11
12
这里补充一下可中断锁的原理,可中断锁实现的可中断的方法很简单,通过acquireInterruptibly建立一个可中断的取锁逻辑。
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
2
3
我们不如源码可以看到,对于没有获得锁的线程,判断走到interrupted看看当前线程是否被打断,如果打断了则直接抛出中断异常。
public final void acquireInterruptibly(int arg)
throws InterruptedException {
//当线程被打断时,直接抛出中断异常
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}
2
3
4
5
6
7
8
# 使用锁的注意事项
# 缩小同步代码块,尽量不要锁住方法,减少锁的粒度
假如代码块里有耗时的操作,锁的粒度过大会导致大量线程处于等待状态,影响系统执行效率。
# 锁中尽量不要包含锁
在锁住包含锁,假设线程A的业务先拿锁1再拿锁2,线程2先拿锁2再拿锁1,极有可能出现线程死锁问题,所以我们使用时尽可能不要在锁中包含锁。
# 选择合适的锁类型
结合实际场景选用合适的锁,例如对于竞争不是很激烈的业务选用自旋锁即可,尽可能去避免线程上下文切换的开销。对于竞争激烈的我们建议使用synchronized关键字。
# 参考文献
Lock锁------lockInterruptibly()方法:https://blog.csdn.net/qq_43323776/article/details/82939344 (opens new window)