一: ReentrantLock
ReentrantLock 和 synchronized 定位类似, 都是用来实现互斥效果保证线程安全,ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”,ReentrantLock 的常用方法如下:
方法 | 功能描述 |
---|---|
lock() | 如果锁可用,获取锁;如果锁不可用,当前线程阻塞,直到获取到锁为止。 |
unlock() | 释放锁的方法,使用完锁后必须显式调用此方法解锁,否则可能导致死锁。 |
tryLock() | 尝试获取锁,如果锁可用,立即获取并返回 true;否则立即返回 false,不等待。 |
tryLock(long timeout, TimeUnit unit) | 尝试获取锁,如果锁可用,立即获取;如果获取不到锁,等待指定时间后放弃加锁,获取到锁返回 true,否则返回 false。 |
下面是一个使用 ReentrantLock 的简单示例:
class MyThread implements Runnable {
private static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
try {
lock.lock(); // 加锁
// 临界区代码
} finally {
lock.unlock(); // 解锁
}
}
}
ReentrantLock 和 synchronized 的区别:
特性 | synchronized | ReentrantLock |
---|---|---|
实现方式 | 关键字,由 JVM 内部实现 | 标准库类,由 JVM 外部实现 |
释放锁 | 不需要手动释放锁,JVM 自动管理 | 需要手动调用 unlock() 释放锁,灵活但容易遗漏 |
锁申请失败时的行为 | 死等,直到获取锁 | 可使用 tryLock() 设置等待时间,超时则放弃 |
唤醒机制 | 使用 Object 的 wait / notify,唤醒随机线程 | 使用 Condition,可精确唤醒指定线程 |
公平锁支持 | 非公平锁 | 默认非公平锁,可通过构造方法设置为公平锁 |
ReentrantLock 的构造方法:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
如何选择使用哪个锁?
场景 | 建议使用的锁 | 原因 |
---|---|---|
锁竞争不激烈时 | synchronized | 效率更高,JVM 自动释放锁,使用简单方便 |
锁竞争激烈时 | ReentrantLock | 可搭配 tryLock 使用,更灵活控制加锁行为,避免死等 |
需要使用公平锁时 | ReentrantLock | 支持通过构造方法设置公平锁模式 |
二:线程池
虽然线程比进程更轻量,但是在频繁创建销毁线程的时候还是会比较低效,线程池就是为了解决这个问题。如果某个线程不再使用了,不需要把线程释放而是放到一个 "池子"中,下次如果需要用到线程就直接从池子中取,不必通过系统来创建了。
2.1 ExecutorService 和 Executors
// ExecutorService 表示线程池, Executors 是一个工厂类, 能够创建出几种不同风格的线程池
ExecutorService pool = Executors.newFixedThreadPool(10);
// ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
我们之前提到过:Executors 本质上是 ThreadPoolExecutor 类的封装,那么什么 ThreadPoolExecutor 呢?
2.2 ThreadPoolExecutor
ThreadPoolExecutor 是 Java 中用于创建和管理线程池的类。它提的构造方法提供了更多的可选参数, 可以进一步细化线程池行为的设定。下面是 ThreadPoolExecutor 常用的四个构造方法的讲解。
2.2.1 第一种构造方法
ThreadPoolExecutor (int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue< Runnable > workQueue):
参数名 | 类型 | 作用说明 |
---|---|---|
corePoolSize | int | 核心线程池大小 |
maximumPoolSize | int | 最大线程池大小 |
keepAliveTime | long | 非核心线程的最大空闲时间 |
unit | TimeUnit | 用于指定 keepAliveTime 的时间单位 |
workQueue | BlockingQueue< Runnable > | 用于保存等待执行任务的阻塞队列 |
2.2.2 第二种构造方法
ThreadPoolExecutor ( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue< Runnable > workQueue,ThreadFactory threadFactory ):
该构造方法与第一个构造方法相同,只不过多了一个参数 threadFactory。
名称 | 作用说明 |
---|---|
ThreadFactory | 用于自定义创建线程的类,可以通过实现 ThreadFactory 接口来自定义线程的行为,例如指定线程名称、设置线程优先级等。 |
2.2.3 第三种构造方法
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue< Runnable > workQueue,RejectedExecutionHandler handler ):
该构造方法与第一个构造方法相同,不过多了一个 RejectedExecutionHandler,用于处理任务被拒绝后的策略,RejectedExecutionHandler 是一个接口,有四种预定义的实现:
策略名 | 作用说明 |
---|---|
AbortPolicy | 默认策略,直接抛出 RejectedExecutionException 异常。 |
CallerRunsPolicy | 使用调用线程来执行被拒绝的任务。 |
DiscardOldestPolicy | 丢弃阻塞队列中最旧的任务,并尝试重新提交被拒绝的任务。 |
DiscardPolicy | 直接丢弃被拒绝的任务,不抛出异常,也不执行任务。 |
2.2.4 第四种构造方法
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue< Runnable > workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler ):
该构造方法将前面三个构造方法的参数都包含进来。
2.2.5 参数理解
理解 ThreadPoolExecutor 构造方法的参数:把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
参数名 | 类比为公司中的含义 | 详细说明 |
---|---|---|
corePoolSize | 正式员工的数量 | 核心线程数,即公司中永久雇佣的员工数量,无论是否有任务都不会被辞退。 |
maximumPoolSize | 正式员工 + 临时工的数量 | 最大线程数,即公司最多可以雇佣的总人数,包括正式员工和临时工。 |
keepAliveTime | 临时工允许的空闲时间 | 如果临时工在此时间内没有任务可做,就会被解雇(线程销毁)。 |
unit | keepAliveTime 的时间单位 | 空闲时间的单位。 |
workQueue | 任务传递的阻塞队列 | 用来存放待处理任务的队列,当所有正式员工忙碌时,任务将暂存在该队列中。 |
threadFactory | 创建线程的工厂 | 用于定义线程的创建方式,比如为每个线程命名、设置优先级等。 |
RejectedExecutionHandler | 拒绝策略 | 当任务超出公司最大负荷时的处理方式,例如拒绝任务、交由提交任务的线程执行等。 |
ThreadPoolExecutor 使用示例:
ExecutorService pool = new ThreadPoolExecutor(
1, // corePoolSize
2, // maximumPoolSize
1000, // keepAliveTime
TimeUnit.MILLISECONDS, // unit
new SynchronousQueue<Runnable>(), // workQueue
Executors.defaultThreadFactory(), // threadFactory
new ThreadPoolExecutor.AbortPolicy() // RejectedExecutionHandler
);
for (int i = 0; i < 3; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
2.3 线程池的工作流程
1. 开始:提交新任务
|
v
2. 判断当前线程数是否小于核心线程数?
|-- 是 --> 3. 创建核心线程执行任务
|
|-- 否 --> 4. 判断阻塞队列是否已满?
|-- 否 --> 5. 将任务添加到阻塞队列
|
|-- 是 --> 6. 判断当前线程数是否小于最大线程数?
|-- 是 --> 7. 创建临时线程执行任务
|
|-- 否 --> 8. 执行拒绝策略
2.4 信号量 Semaphore
信号量用来表示 “可用资源的个数”,信号量本质上就是一个计数器,可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
- 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1,这个称为信号量的 P 操作
- 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1,这个称为信号量的 V 操作
如果计数器的值已经为 0 了,此时还尝试申请资源就会进行阻塞等待,直到有其他线程释放资源,下面是 Semaphore 的使用示例:
// 创建一个拥有 4 个资源的信号量对象
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
// acquire 用于获取信号量中的一个资源,相当于 P 操作
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
// release 用于释放信号量中的一个资源,相当于 V 操作
semaphore.release();
System.out.println("我释放资源了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 循环创建并启动 20 个线程
for (int i = 0; i < 20; i++) {
// 创建一个线程并传入 runnable 实现
Thread t = new Thread(runnable);
// 启动线程,开始执行 runnable 的 run 方法
t.start();
}
2.5 CountDownLatch
就好像跑步比赛,10 个选手依次就位,哨声响才同时出发,当所有选手都通过终点,才能公布成绩。
public class Demo {
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(10); // 表示有 10 个任务需要完成.
Runnable r = new Runable() {
@Override
public void run() {
try {
Thread.sleep(Math.random() * 10000);
latch.countDown();// 每个任务执行完毕都调用 latch.countDown() ,让 CountDownLatch 内部的计数器自减一
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
}
三:线程安全的集合类
原来的集合类大部分都不是线程安全的,我们改如何在多线程的环境下使用相关的集合呢?
3.1 多线程环境使用 ArrayList
在多线程环境中使用 ArrayList 时,由于它本身不是线程安全的,因此有以下三种常见的解决方案。下面详细介绍每种方法:
3.1.1 自己使用 synchronized 或 ReentrantLock
使用 synchronized 或 ReentrantLock 手动对访问 ArrayList 的代码块进行同步,确保多个线程不会同时操作该列表,适用于对同步控制要求严格且需要高度自定义的场景。
3.1.2 使用 Collections.synchronizedList
使用 Collections.synchronizedList 方法将 ArrayList 包装为线程安全的集合,适用于需要线程安全的 ArrayList,且线程间无需频繁写操作的简单场景。
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 添加元素
list.add("A");
// 遍历列表时需要手动同步
synchronized (list) {
for (String item : list) {
System.out.println(item);
}
}
虽然 Collections.synchronizedList 的关键操作上都带有 synchronized,这些操作是线程安全的,但迭代操作需要手动加锁,如果在迭代过程中没有手动同步会抛出异常。
3.1.3 使用 CopyOnWriteArrayList
CopyOnWriteArrayList 是 Java 提供的线程安全的动态数组,每次写操作,比如 add、remove 等方法都会在对当前容器进行拷贝,接着在被拷贝的容器上进行写操作,之后再将原容器的引用指向新的容器,读操作则直接使用现有数组,不需要加锁,非常适合读多写少的场景,因为读操作不需要加锁。
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// 添加元素
list.add("A");
// 遍历列表(不需要手动同步)
for (String item : list) {
System.out.println(item);
}
3.1.4 总结
方法 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
自己使用 synchronized 或 ReentrantLock | 需要精确控制同步行为的复杂场景 | 灵活性高,可以精准同步 | 代码复杂,易出错,可读性差 |
Collections.synchronizedList | 线程安全,简单场景 | 易用,支持基本的线程安全 | 遍历时需手动同步,性能不如 CopyOnWriteArrayList |
CopyOnWriteArrayList | 读多写少场景 | 读操作性能高,天然线程安全 | 写操作开销大,不适合写操作频繁的场景 |
3.2 多线程环境使用队列
-
ArrayBlockingQueue:基于数组实现的阻塞队列
-
LinkedBlockingQueue:基于链表实现的阻塞队列
-
PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
-
TransferQueue:最多只包含一个元素的阻塞队列
这些都是线程安全的队列。
3.3 多线程环境使用哈希表
HashMap 不是线程安全的,在多线程环境下想要使用哈希表可以使用 Hashtable 和 ConcurrentHashMap
3.3.1 Hashtable
Hashtable 只是简单的把关键方法加上了 synchronized 关键字,这相当于直接针对 Hashtable 对象本身加锁,一个 Hashtable 只有一把锁,当多个线程同时访问 Hashtable 带锁的方法就会出现锁竞争,导致线程阻塞等待。
3.3.2 ConcurrentHashMap
ConcurrentHashMap 相比于 Hashtable 做出了一系列的改进和优化
- 读操作没有加锁,只对写操作进行加锁,加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶”
- “锁桶” 指的是将哈希表中的数据分成多个独立的存储单元(桶),每个桶对应一个锁。操作时,只锁定数据所在的桶,而不是锁住整个哈希表,大大降低了锁冲突的概率。
- 充分利用 CAS 特性,比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
四:死锁
死锁是多个进程或线程由于竞争共享资源而陷入的一种无限等待的状态,导致它们都无法继续执行,在死锁中,每个进程或线程都在等待其他进程或线程释放资源,但这些资源都被其他进程或线程占用,从而导致所有参与者都无法继续执行,形成了相互等待的循环。死锁通常发生在以下四个必要条件同时满足时:
- 互斥条件:资源一次只能被一个线程独占使用,其他线程必须等待该资源被释放。
- 请求和保持条件:线程在持有资源的同时请求其他资源,且不释放已持有的资源。
- 不剥夺条件:资源只能由持有它的线程主动释放,不能被其他线程强行抢占。
- 循环等待条件:资源申请顺序成环,每个进程或线程都在等待下一个资源的释放。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。