JavaEE初阶——JUC的工具类和死锁
目录
一、信号量 Semaphoe
二、CountDownLatch
三、线程安全的集合类
3.1 ArrayList
3.1.1 Collections.synchronizedList(new ArrayList)
3.1.2 CopyOnWriteArrayList
3.2多线程使用队列
3.3 多线程使用哈希表
3.3.1 Hashtable
3.3.2 ConcurrentHashMap
四、死锁
4.1 死锁的情景
4.1.1 一个线程获取一把锁
4.1.2 两个线程获取两把锁
4.1.3 多个线程获取多把锁
4.2 造成死锁的原因
4.3 解决死锁问题
一、信号量 Semaphoe
信号量用来表示“可用资源的个数”,本质上就是一个计数器,控制对共享资源的并发访问数量,本质是 “资源访问许可证”—— 计数器大于 0 时允许访问,等于 0 时阻塞等待,释放资源时计数器递增,唤醒等待线程。
我们用停车场举例:
- 停车场外面通常会有一个显示牌,牌子上会显示当前停车场中车位的可用个数
- 一辆车进入停车场,显示牌显示的个数减1,表示停车位资源减少1
- 一辆车从停车场出来,显示牌显示的个数加1,释放了一份停车场资源,外面等待的车就可以进入
- 如果停车场的车位占满了,那么显示牌上就显示0,这是外面的车如果要进入停车场则需要阻塞等待
在Java中我们可以用Semaphore类来表示信号量,我们通过传给构造方法一个参数来设定信号量的可用资源有多少
Semaphore semaphore = new Semaphore(5);
按照上述代码,semaphore信号量有5个可用资源
acquire()方法表示申请资源,也就是车辆进入停车场的过程
semaphore.acquire();
release()方法表示释放资源,也就是车辆出停车场的过程
semaphore.release();
我们用代码来测试一下信号量的工作流程
public class Demo_1201 {public static void main(String[] args) {// 初始化一个信号量的对象, 指定系统可用资源的数量, 相当于一个停车场有5个车位Semaphore semaphore = new Semaphore(5);// 定义线程的任务Runnable runnable = new Runnable() {@Overridepublic void run() {System.out.println(Thread.currentThread().getName() + "开始申请资源...");try {// 申请资源, 相当进入停车场,可用车位数减1semaphore.acquire();System.out.println(Thread.currentThread().getName() + "====== 已经申请到资源 ======");// 处理业务逻辑, 用休眠来模拟, 相当于停车时间TimeUnit.SECONDS.sleep(1);// 释放资源, 相当于出停车场, 可用车位数加1semaphore.release();System.out.println(Thread.currentThread().getName() + "***** 释放资源");} catch (InterruptedException e) {e.printStackTrace();}}};// 创建线程执行任务, 10相当于有20辆车需要进停车场for (int i = 0; i < 10; i++) {// 创建线程并指定任务Thread thread = new Thread(runnable);// 启动线程thread.start();}}
}

我们观察代码
- 前5个线程都是申请资源之后里面申请到了资源,这是因为信号量有5个可用资源,申请即可获得。
- 再看信号量5个资源被申请完后的结果,剩下5个线程申请资源都没有获取到,这是因为之前5个线程还没有释放资源,信号量现在没有可用资源
- 当前五个线程释放资源后,后来申请资源的5个线程陆续获得了资源
我们可以通过信号量限制系统中并发执行的线程个数
二、CountDownLatch
CountDownLatch 是 JUC包的线程同步工具,核心功能是:让一个或多个线程等待 “等待”,直到其他指定数量的线程完成任务后,再继续执行。可以理解为 “倒计时门闩”—— 先设定一个倒计时数,线程完成任务后倒计时减 1,直到倒计时归 0,等待的线程才会被 “放行”。
我们通过传入参数count到构造方法创建一个CountDownLatch对象,下面代码的count就是10
CountDownLatch countDownLatch = new CountDownLatch(10);
调用countDown()方法后count就减1
countDownLatch.countDown();
调用await()方法就会让主线程阻塞等待,直到所有的线程都运行结束,也就是count归0
countDownLatch.await();
我们用跑步比赛举例
- 组委会说:“10 人参赛,全到齐才算结束”(初始化倒计时 10)。
- 裁判喊预备,10 名选手同时开跑(线程启动)。
- 选手们陆续冲线,每到 1 人,倒计时减 1(
countDown())。 - 裁判在终点等待,直到最后 1 人到齐(
await()等待倒计时 0)。 - 所有人到齐后,裁判宣布结束并颁奖。
public class Demo_1202 {public static void main(String[] args) throws InterruptedException {// 指定参赛选手的个数(线程数)CountDownLatch countDownLatch = new CountDownLatch(10);System.out.println("各就各位,预备...");for (int i = 0; i < 10; i++) {Thread player = new Thread(() -> {try {System.out.println(Thread.currentThread().getName() + "开跑.");// 模拟比赛过程, 休眠2秒TimeUnit.SECONDS.sleep(2);System.out.println(Thread.currentThread().getName() + "到达.");// 标记选手已达到终点,让countDownLatch的计数减1, 当计数到0时,表示所有的选手都到达终点,比赛结束countDownLatch.countDown();} catch (InterruptedException e) {e.printStackTrace();}}, "player" + i);// 启动线程player.start();}TimeUnit.MILLISECONDS.sleep(10);System.out.println("===== 比赛进行中 =====");// 等待比赛结束countDownLatch.await();// 颁奖System.out.println("比赛结束, 进行颁奖");}}

三、线程安全的集合类
我们知道我们之前使用的很多集合类都是线程不安全的集合类,会产生线程安全问题。
3.1 ArrayList
创建10个线程向list里面写入数据
public class Demo_1203 {public static void main(String[] args) {// 先定义一个集合对象(线程不安全)List<Integer> list = new ArrayList<>();// 多个线程同时对这个集合进行读写操作for (int i = 0; i < 10; i++) {int j = i + 1;Thread t = new Thread(() -> {// 写list.add(j);// 读System.out.println(list);});// 启动线程t.start();}}
}

我们可以看到执行结果报错,ConcurrentModificationException(并发修改异常)的核心原因是:多个线程同时操作了同一个 ArrayList,其中一个线程在遍历集合,另一个线程在修改集合(添加 / 删除元素),导致遍历过程中集合结构被意外改变,触发了 Java 的并发安全检查。
那如果我们需要使用集合类ArrayList,除了可以用我们之前学的synchronized或ReentrantLock同步机制,还可以使用什么方法保证线程安全呢
3.1.1 Collections.synchronizedList(new ArrayList)
synchronizedList是标准库提供的⼀个基于synchronized进⾏线程的List
public class Demo_1204 {public static void main(String[] args) {// 创建一个普通集合对象List<Integer> arrayList = new ArrayList<>();// 通过工具类把普通集合对象,转换线程安全的集合对象List<Integer> list = Collections.synchronizedList(arrayList);// 多个线程同时对这个集合进行读写操作for (int i = 0; i < 10; i++) {int j = i + 1;Thread t = new Thread(() -> {// 写list.add(j);// 读System.out.println(list);});// 启动线程t.start();}}
}

我们利用synchronizedList工具类把普通集合类转化成了线程安全的集合类,我们来通过源码来分析这是如何做到的

我们能看到调用synchronizedList方法之后返回了一个SynchronizedList实例对象,我们来看一下这个类的方法

观察SynchronizedList类的方法,发现每个方法都是被synchronized包裹的,这就是为什么synchronizedList可以把线程不安全的集合类转化成线程安全的类
3.1.2 CopyOnWriteArrayList
我们也可以直接使用CopyOnWriteArrayList类,这是一个线程安全的类,基于 “写时复制”(Copy-On-Write)的思想设计,适用于读多写少的场景。
核心原理
- 当对
CopyOnWriteArrayList进行修改操作(如添加、删除、修改元素)时- 它不会直接修改原数组,而是先复制一份原数组的副本,在副本上执行修改操作
- 完成后再将原数组的引用指向新副本
- 而读操作则直接访问原数组,无需加锁,因此读操作效率很高,且不会阻塞其他线程的读或写。
我们来读源码,分析一下add方法


可以看到add方法全部上锁,方法中新创建了一个es集合类副本,在es副本中添加元素,随后调用setArray方法,这个方法里让array指向了es副本,最后释放锁。实现了线程安全的ArrayList集合类
3.2多线程使用队列
- ArrayBlockingQueue:基于数组实现的阻塞队列
- LinkedBlockingQueue:基于链表实现的阻塞队列
- LinkedBlockingQueue:基于堆实现的优先级阻塞队列
- LinkedBlockingQueue:最多只包含一个元素的阻塞队列
3.3 多线程使用哈希表
多线程环境下使用HashMap是不安全的
3.3.1 Hashtable
![]()
![]()
Hashtable实现线程安全是依靠把关键方法加上synchronized关键字来实现的
但是此时就会出现一个问题,当我们调用put方法时,其实我们只会操作一个hash桶,但是这样整体上锁会把整个哈希表锁住,大大降低了效率。而且⼀旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到⼤量的元素拷⻉,效率会⾮常低。


3.3.2 ConcurrentHashMap
ConcurrentHashMap相比于Hashtable做出了一系列的改进和优化
- 读操作没有加锁(但是使⽤了volatile保证从内存读取结果)
- 只对写操作进⾏加锁.加锁的⽅式仍然是是⽤synchronized,但是不是锁整个对象,⼤⼤降低了锁冲突的概率

同时,ConcurrentHashMap在扩容上也做了优化
- 扩容时把数组的容量增加到原来的两倍,但并不是一次性把Map中的数据全部复制到新Map中
- 而只是复制当前访问的下标的元素,这样的操作会使两个Map同时存在一段时间
- 当查询的时候同时在两个Map里面进行查询,删除也是在两个Map中删除
- 写入操作时只往新的Map中写
四、死锁
死锁是这样⼀种情形:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌
4.1 死锁的情景
4.1.1 一个线程获取一把锁
一个线程如果重复获取同一把锁两次以上,如果锁是可重入锁,那就不会出现死锁问题
如果是不可重入锁,就会发生死锁
4.1.2 两个线程获取两把锁
假设有两个线程,线程A和线程B,两把锁,锁A和锁B
此时线程A持有锁A,等待锁B;线程B持有锁B,等待锁A。这样循环等待也会造成死锁
public class Demo_1302 {public static void main(String[] args) {// 定义两个锁对象Object locker1 = new Object();Object locker2 = new Object();// 创建线程1,先获取locker1 再获取locker2Thread t1 = new Thread(() -> {System.out.println("t1申请locker1....");synchronized (locker1) {System.out.println("t1获取到了locker1");// 模拟业务执行时间try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}// 在持有locker1的基础上获取locker2synchronized (locker2) {System.out.println("t1获取了所有的锁资源。");}}});// 创建线程2,先获取locker2 再获取locker1Thread t2 = new Thread(() -> {System.out.println("t2申请locker2....");synchronized (locker2) {System.out.println("t2 获取到了locker2.");// 模拟业务执行时间try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}// 持有locker2 的基础上获取locker1synchronized (locker1) {System.out.println("t2获取了所有的锁资源。");}}});// 启动两个线程t1.start();t2.start();}
}

观察结果显示,两个线程都获取到了一把锁,然后线程想获取另一把锁,代码发生死锁。
4.1.3 多个线程获取多把锁
我们以著名的哲学家就餐问题为例

可以看到有五个座位,每个座位上坐着一个哲学家,他们只有两个状态,一个是就餐状态一个是思考状态。每个哲学家左右都有一只筷子,规定只有获取到了两只筷子才可以用餐
我们可以让哲学家先拿左边的筷子,再拿右边的筷子,用完餐再放回原位,等待下一次用餐,这个模型大多数情况运行良好
- 但是可能会出现极端情况,这种情况就容易出现死锁问题
当每个哲学家都同时拿起了左手筷子,此时他们都要获取右手筷子,都在等待旁边的哲学家放下筷子,从而发生了死锁问题

4.2 造成死锁的原因
- 互斥使⽤,即当资源被⼀个线程使⽤(占有)时,别的线程不能使⽤
- 不可抢占,资源请求者不能强制从资源占有者⼿中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在⼀个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了⼀个等待环路。
当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。
4.3 解决死锁问题
我们根据造成死锁的原因来逐个分析
- 互斥使用:这是锁自带的特性,我们无法破坏
- 不可抢占:这也是锁自带的特性,我们无法破坏
- 保持与请求:这和代码的设计相关,其实我们只需要设计合理的获取锁顺序就可以打破
- 循环等待:同样与代码设计相关,我们合理设计就可以打破
public class Demo_1303 {public static void main(String[] args) {// 定义两个锁对象Object locker1 = new Object();Object locker2 = new Object();// 所有的线程都是先拿locker1再拿locker2// 创建线程1Thread t1 = new Thread(() -> {System.out.println("t1申请locker1....");synchronized (locker1) {System.out.println("t1获取到了locker1");// 模拟业务执行时间try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}// 在持有locker1的基础上获取locker2synchronized (locker2) {System.out.println("t1获取了所有的锁资源。");}}});// 创建线程2Thread t2 = new Thread(() -> {System.out.println("t2申请locker1....");synchronized (locker1) {System.out.println("t2获取到了locker1");// 模拟业务执行时间try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}// 在持有locker1的基础上获取locker2synchronized (locker2) {System.out.println("t2获取了所有的锁资源。");}}});// 启动两个线程t1.start();t2.start();}
}
我们更换了一下获取锁的顺序,代码此时就执行正确了,两个线程都成功获取了锁资源

我们再回顾上文说的哲学家进餐问题,我们可以怎么设计获取锁策略来解决死锁问题呢?
我们可以给筷子编号,让每个哲学家都先拿编号小的筷子,再拿编号大的筷子

abcd哲学家都获取到了小编号的筷子之后,此时e想要获取编号1的筷子,但是此时筷子被哲学家a持有,所以e获取不到筷子1,也无法获取筷子5。
这个时候哲学家d就可以获取到筷子5,可以开始用餐,用完餐之后放下筷子,哲学家c也可以开始用餐,如此往复,不会出现死锁问题
