目录
[一、信号量 Semaphoe](#一、信号量 Semaphoe)
[3.1 ArrayList](#3.1 ArrayList)
[3.1.1 Collections.synchronizedList(new ArrayList)](#3.1.1 Collections.synchronizedList(new ArrayList))
[3.1.2 CopyOnWriteArrayList](#3.1.2 CopyOnWriteArrayList)
[3.3 多线程使用哈希表](#3.3 多线程使用哈希表)
[3.3.1 Hashtable](#3.3.1 Hashtable)
[3.3.2 ConcurrentHashMap](#3.3.2 ConcurrentHashMap)
[4.1 死锁的情景](#4.1 死锁的情景)
[4.1.1 一个线程获取一把锁](#4.1.1 一个线程获取一把锁)
[4.1.2 两个线程获取两把锁](#4.1.2 两个线程获取两把锁)
[4.1.3 多个线程获取多把锁](#4.1.3 多个线程获取多把锁)
[4.2 造成死锁的原因](#4.2 造成死锁的原因)
[4.3 解决死锁问题](#4.3 解决死锁问题)
一、信号量 Semaphoe
信号量用来表示"可用资源的个数",本质上就是一个计数器,控制对共享资源的并发访问数量,本质是 "资源访问许可证"------ 计数器大于 0 时允许访问,等于 0 时阻塞等待,释放资源时计数器递增,唤醒等待线程。
我们用停车场举例:
- 停车场外面通常会有一个显示牌,牌子上会显示当前停车场中车位的可用个数
- 一辆车进入停车场,显示牌显示的个数减1,表示停车位资源减少1
- 一辆车从停车场出来,显示牌显示的个数加1,释放了一份停车场资源,外面等待的车就可以进入
- 如果停车场的车位占满了,那么显示牌上就显示0,这是外面的车如果要进入停车场则需要阻塞等待
在Java中我们可以用Semaphore类来表示信号量,我们通过传给构造方法一个参数来设定信号量的可用资源有多少
java
Semaphore semaphore = new Semaphore(5);
按照上述代码,semaphore信号量有5个可用资源
acquire()方法表示申请资源,也就是车辆进入停车场的过程
java
semaphore.acquire();
release()方法表示释放资源,也就是车辆出停车场的过程
java
semaphore.release();
我们用代码来测试一下信号量的工作流程
java
public class Demo_1201 {
public static void main(String[] args) {
// 初始化一个信号量的对象, 指定系统可用资源的数量, 相当于一个停车场有5个车位
Semaphore semaphore = new Semaphore(5);
// 定义线程的任务
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始申请资源...");
try {
// 申请资源, 相当进入停车场,可用车位数减1
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + "====== 已经申请到资源 ======");
// 处理业务逻辑, 用休眠来模拟, 相当于停车时间
TimeUnit.SECONDS.sleep(1);
// 释放资源, 相当于出停车场, 可用车位数加1
semaphore.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
java
CountDownLatch countDownLatch = new CountDownLatch(10);
调用countDown()方法后count就减1
java
countDownLatch.countDown();
调用await()方法就会让主线程阻塞等待,直到所有的线程都运行结束,也就是count归0
java
countDownLatch.await();
我们用跑步比赛举例
- 组委会说:"10 人参赛,全到齐才算结束"(初始化倒计时 10)。
- 裁判喊预备,10 名选手同时开跑(线程启动)。
- 选手们陆续冲线,每到 1 人,倒计时减 1(
countDown())。 - 裁判在终点等待,直到最后 1 人到齐(
await()等待倒计时 0)。 - 所有人到齐后,裁判宣布结束并颁奖。
java
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里面写入数据
java
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
java
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。这样循环等待也会造成死锁
java
public class Demo_1302 {
public static void main(String[] args) {
// 定义两个锁对象
Object locker1 = new Object();
Object locker2 = new Object();
// 创建线程1,先获取locker1 再获取locker2
Thread 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的基础上获取locker2
synchronized (locker2) {
System.out.println("t1获取了所有的锁资源。");
}
}
});
// 创建线程2,先获取locker2 再获取locker1
Thread 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 的基础上获取locker1
synchronized (locker1) {
System.out.println("t2获取了所有的锁资源。");
}
}
});
// 启动两个线程
t1.start();
t2.start();
}
}

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

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

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

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

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