1、常见的锁策略
在学习多线程的时候,我们经常会听到:
text
悲观锁
乐观锁
重量级锁
轻量级锁
自旋锁
公平锁
非公平锁
可重入锁
读写锁
这些名字看起来很多,但实际上它们是从不同角度对锁进行分类。
所以在学习这些锁之前,我们首先要理解为什么需要锁?
1.1 乐观锁和悲观锁
1.什么是悲观锁
我们先来看一个最简单的计数器。
java
public class Counter {
private int count = 0;
public void increment() {
count++;
}
}
假设现在有 10 个线程,每个线程执行 1000 次 counter.increment() ,我们希望最后得到 10000 。
但是在多线程环境下,最终结果可能小于 10000。
原因我们在初阶已经讲过。
count++ 并不是一个真正意义上的原子操作。
如果两个线程同时执行:
text
线程1:读取 count = 10
线程2:读取 count = 10
线程1:10 + 1 = 11
线程2:10 + 1 = 11
线程1:写回 11
线程2:写回 11
本来应该增加两次,最后却只增加了一次。
解决这个问题,最直接的方法就是加锁。
java
public synchronized void increment() {
count++;
}
按照多线程代码的执行流程,这里的思想就是我认为线程之间很可能发生冲突,所以在访问共享资源之前,先把它锁住。
这种思想就是:悲观锁。
2.什么是乐观锁
还有一种完全不同的思路。
如果我们认为多个线程发生冲突的概率比较低。
那么是不是可以不直接加锁?这里应该是可以的。
我们可以先读取数据,然后尝试修改。
修改的时候,再判断:我刚才读取的数据有没有被其他线程修改?
如果没有修改则是修改成功,如果已经被其他线程修改则是修改失败,然后再次读取、重新尝试。
这就是:乐观锁。
3.乐观锁和悲观锁的区别
可以把两者简单理解成:
text
悲观锁:如果认为冲突很容易发生,那么先加锁再操作
乐观锁:如果认为冲突不一定发生,那么先操作最后检查
悲观锁更关注:如何避免冲突。
乐观锁更关注:发生冲突之后如何发现并处理。
1.2 重量级锁和轻量级锁
1.什么是重量级锁
假设线程 1 获取了一把锁,此时线程 2 也想获取这把锁,发现线程1已经占用了,如果线程 2 长时间拿不到锁,那么它可能进入阻塞状态。
线程阻塞和唤醒会涉及操作系统层面的线程调度,因此会产生一定的开销。
这种情况下,通常称为:重量级锁。
2.什么是轻量级锁
如果线程持有锁的时间非常短,比如说:线程1获取到锁之后,就执行了几条代码然后就释放锁,那么线程 2 其实没有必要马上进入阻塞状态。
可以先尝试等待一下,然后再获取锁。
这种思想就是:轻量级锁。
轻量级锁希望尽量减少线程阻塞和唤醒所产生的开销。
1.3 自旋锁
1.什么是自旋
还是刚才的场景。
线程 1 获取锁后,线程 2 也想获取这个相同的锁,此时线程 2 可以直接阻塞。
但是还有另外一种方式:
text
线程2尝试获取锁
↓
失败
↓
继续尝试
↓
失败
↓
继续尝试
↓
成功
这种行为就叫:自旋。
也就是说:线程获取锁失败之后,不立即阻塞,而是不断尝试获取锁。
2.自旋有什么好处
如果线程 1 很快就释放锁呢,这样就可能减少阻塞和唤醒带来的开销。
如果线程 1 抢到锁后,线程 2 直接阻塞,那么就存在线程阻塞和唤醒的开销。
3.自旋是不是越久越好?
当然不是。
如果锁很长时间都不会释放:
text
线程2
↓
不断尝试
↓
不断尝试
↓
不断尝试
↓
......
那么线程 2 就会一直消耗 CPU。
这反而是一种浪费。
所以,自旋更加适合:锁持有时间比较短的情况。
如果等待时间非常长,直接阻塞可能更加合适。
1.4 公平锁和非公平锁
1.什么是公平锁
假设现在有三个线程:线程1、线程2、线程3,它们依次等待同一把锁。
如果严格按照先来后到的顺序获取锁,那么这种锁就是:公平锁。
公平锁强调:先到的线程优先获取锁。
2.什么是非公平锁
非公平锁不保证线程严格按照等待顺序获取锁。
虽然线程 1 先等待,但是线程 3 也可能在某个时机竞争锁成功。
因此:非公平锁不保证先来先得。
3.为什么需要非公平锁?
如果每次都严格按照顺序获取锁,就需要维护线程的等待顺序。
而非公平锁可以让线程直接尝试竞争。
text
线程到达后,直接尝试获取锁
成功 -> 获取锁
失败 -> 等待
这样在某些场景下可以减少额外的调度和排队开销。
Java 中的 ReentrantLock 默认使用非公平策略:
java
ReentrantLock lock = new ReentrantLock();
如果希望使用公平锁:
java
ReentrantLock lock = new ReentrantLock(true);
这里的 true 表示使用公平策略。
1.5 可重入锁和不可重入锁
1.什么是可重入锁
先来看一个例子:
java
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
执行 method1() 后,线程首先获取 method1() 对应的锁。
然后进入 method2() ,它同样需要获取这把锁。
如果这个时候线程已经持有这把锁了,还能不能再次获取?
经过测试,发现是可以的,那么这就是:可重入锁。
synchronized 是可重入的。
2.为什么需要可重入?
如果锁是不可重入的话,那么在调用 method2() 的时候,申请的就是同一把锁了,这就会导致等待着上一个锁释放才可以进入。
但是这把锁又在谁手里?
就在当前线程手里。
于是就变成自己等自己。
最终线程就无法继续执行。
所以可重入锁可以避免线程因为重复获取自己已经持有的锁而把自己阻塞。
3.ReentrantLock
Java 中有一个非常直观的类:ReentrantLock
其中 Reentrant 就是可重入的。
所以 ReentrantLock 也是一把可重入锁。
1.6 读写锁
1.为什么需要读写锁?
假设现在有一个共享数据:
java
private int data;
多个线程都可以读取。
如果大家都只是读取数据,那么这些操作通常不会互相修改数据。
但是,如果使用普通的互斥锁:
text
线程1 -> 读取
线程2 -> 等待
线程3 -> 等待
就会限制并发程度。
那么能不能做到:
text
线程1 -> 读取
线程2 -> 读取
线程3 -> 读取
多个线程同时读?可以。
这就是:读写锁。
2.读写锁的规则
读写锁通常包含:读锁、写锁
基本规则是:
text
读 + 读 -> 可以并发
读 + 写 -> 互斥
写 + 读 -> 互斥
写 + 写 -> 互斥
也就是说多个线程可以同时读取,但是写操作需要独占。
Java 中常见读写锁的类是:ReentrantReadWriteLock
例如:
java
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
读取数据时:
java
readLock.lock();
try {
// 读取数据
} finally {
readLock.unlock();
}
修改数据时:
java
writeLock.lock();
try {
// 修改数据
} finally {
writeLock.unlock();
}
1.7 常见锁策略总结
到这里,我们已经学习了很多锁。
可以通过下面的表格进行整理:
| 锁 | 核心思想 |
|---|---|
| 悲观锁 | 假设容易发生冲突,先加锁 |
| 乐观锁 | 假设冲突较少,先操作再检查 |
| 重量级锁 | 竞争失败后可能进入阻塞 |
| 轻量级锁 | 尽量减少阻塞带来的开销 |
| 自旋锁 | 获取失败后不断尝试 |
| 公平锁 | 尽量按照等待顺序获取 |
| 非公平锁 | 不保证严格按照等待顺序 |
| 可重入锁 | 同一个线程可以重复获取同一把锁 |
| 读写锁 | 读读可以并发,读写互斥 |
需要注意的是,这些分类不是互相排斥的。
例如一把锁可以同时具有:非公平 + 可重入。
也可以同时具有其他特征。
2、CAS
前面学习乐观锁的时候,我们提到了CAS。
CAS 是 Java 并发编程中非常重要的一个概念。
很多我们后面要学习的内容,例如:原子类、自旋锁、ConcurrentHashMap 的部分实现思想都和 CAS 有关系。
2.1 什么是 CAS
CAS 的全称是:Compare And Swap ,翻译成中文就是:比较并交换。
CAS 通常涉及三个值:
text
V:内存中的值
A:线程认为内存中的旧值
B:准备修改的新值
执行 CAS 的时候,会进行判断:
java
V == A ?
如果相等:
java
V = B
如果不相等就什么都不做。
伪代码:
java
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue){
&address = swapValue;
return true;
}
return false;
}

2.2 CAS 的基本工作过程
假设:
java
count = 10
线程 1 读取:
java
A = 10
准备修改成:
java
B = 11
执行:
java
CAS(10, 11)
如果当前内存中的值仍然是:
java
10
那么:
java
10 == 10
比较成功。
于是就修改成功。
1.如果发生竞争呢?
假设:
java
线程1读取:10
线程2读取:10
两个线程都准备修改:
java
10 -> 11
如果线程 1 先执行,此时内存已经变成里的指已经变为 11 。
线程 2 再执行 CAS:
text
当前值:11
期望值:10
比较:
java
11 != 10
所以修改失败。
text
线程1 -> CAS成功 -> 10 → 11
线程2 -> CAS失败 -> 重新读取
这就是 CAS 的核心思想。
2.3 CAS 的特点
CAS 最大的特点是:CAS 是一种原子操作。
这里的"原子"可以简单理解为:比较 + 修改
这一过程不会被其他线程插入破坏。
因此 CAS 可以在不使用传统互斥锁的情况下,实现一些线程安全操作。
1.CAS 并不是没有缺点
CAS 虽然很强大,但并不是完美的。
它主要存在几个问题:
text
ABA 问题
循环重试带来的 CPU 开销
只能保证单个共享变量的原子更新
其中最经典的就是:ABA 问题。
我们后面再说。
2.4 CAS 的应用
1.实现原子类
Java 提供了很多原子类。
例如:
text
AtomicInteger
AtomicLong
AtomicBoolean
其中最常见的是:
java
AtomicInteger
例如:
java
AtomicInteger count = new AtomicInteger(0);
增加:
java
count.incrementAndGet();
获取:
java
int value = count.get();
如果多个线程同时执行:
java
count.incrementAndGet();
就可以保证这个操作的原子性。
2.原子类为什么能够保证线程安全?
例如:
java
count++;
存在竞争问题。
而:
java
count.incrementAndGet();
内部使用了原子操作思想,可以避免简单的 count++ 所产生的线程安全问题。
可以把它理解成:
text
读取旧值
↓
计算新值
↓
CAS 尝试更新
↓
成功 → 返回
↓
失败 → 重新尝试
所以原子类通常是:CAS + 循环重试这种思想的典型应用。
3.实现自旋锁
CAS 还可以用来实现自旋锁。
例如:
java
public class SpinLock {
private final AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
}
}
public void unlock() {
locked.set(false);
}
}
获取锁:
java
locked.compareAndSet(false, true)
意思是就是:如果当前是 false,就修改成 true,然后获取锁成功。
如果当前已经是 true 的话,那么 CAS 失败。
于是:
java
while (!locked.compareAndSet(false, true)) {
}
就会一直尝试。
这就是自旋锁。
2.5 CAS 的 ABA 问题
1.什么是 ABA 问题
我们来看一个例子。
有一个初始值:
java
num = A
线程 1:读取 num,拿到值 A。准备执行 CAS(期望 A,要改成 C)
这时候线程 1 被挂起(时间片用完,暂停了,但是它手里保存的期望值还是 A)。
线程 2 开始抢到CPU:要把值A改为值B,然后又把值B修改回值A。
现在共享变量的值又变回 A 了!但是中间经历过 A -> B -> A,数据被改动过两次。
此时线程 1 恢复执行,执行CAS。
进行比较的时候发现内存里的值 == 我之前读到的期望值(A==A)。
于是CAS就判断为相等,认为数据没有发生变化,然后执行修改。
但是实际上数据已经发生过变化了,这就是ABA 问题。
2.ABA 问题有什么影响?
对于一些简单的数值修改:
text
A -> B -> A
可能没有太大的影响。
但是在某些数据结构中,ABA 可能导致逻辑错误。
例如:
text
线程1正在读取节点A,随后线程1暂停
线程2又执行修改了节点A,经过一些操作后再修改回原来的节点A
此时线程1恢复,发现还是A
这样就认为数据没有变化
线程 1 无法知道这个 A 中间是否经历过 ABA 的变化过程。虽然最终值相同,但中间状态已经被修改过。
所以问题不在于最终值是不是相同,而在于中间是否发生过变化。
举个生活的例子:
有个钱包余额:100(A=100)
- 线程 1 读到余额 100,准备 CAS 扣钱,被暂停
- 线程 2:先把余额改成 50(B),又存回 100(A)
- 线程 1 醒来一看余额还是 100,以为没人动过,继续扣钱。
实际上这笔钱已经被拿走过一次,又放回来了,业务逻辑出错。
3.如何解决 ABA 问题?
一个常见解决方案是:增加版本号。
原来的数据 "A" 变成 "A + 版本号"。
例如:
text
A, 1
第一次修改:
text
A, 1 -> B, 2
再次修改:
text
B, 2 -> A, 3
虽然最终数据又变成了 A ,但是版本号已经变成 3 。
所以 A, 1 和 A, 3 并不相同。
Java 中可以使用:
java
AtomicStampedReference
来增加版本号信息,从而处理 ABA 问题。
3、synchronized 原理
前面我们已经大量使用过synchronized。
例如:
java
public synchronized void add() {
count++;
}
我们知道它可以解决线程安全问题。
但是问题来了,synchronized 到底是怎么工作的?
3.1 synchronized 的基本特点
synchronized 主要有三个重要特点。
1.互斥
同一时刻,只允许一个线程执行受保护的临界区。
text
线程1 -> 获取锁 -> 执行
线程2 -> 等待
线程3 -> 等待
2.可重入
同一个线程可以重复获取自己已经持有的锁。
java
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
不会因为重复加锁而把自己锁死。
3.保证内存可见性
synchronized 不只是控制谁可以执行,还涉及线程之间的内存可见性。
一个线程释放锁之前对共享变量的修改,在另一个线程随后成功获取同一把锁之后,可以被正确观察到。
所以 synchronized 同时解决了:互斥 + 内存可见性。
3.2 synchronized 的加锁方式
synchronized 常见有三种使用方式。
1.修饰实例方法
java
public synchronized void method() {
}
这种情况下,锁住的是当前对象:this
也就是说 obj.method() 本质上相当于对 obj 进行加锁。
2.修饰静态方法
java
public static synchronized void method() {
}
静态方法属于类。
因此这里锁住的是Class 对象。
简单理解:
text
实例方法锁的是对象实例
静态方法锁的是 Class 对象
3.修饰代码块
java
public void method() {
synchronized (this) {
// 代码块
}
}
也可以指定其他对象:
java
Object lock = new Object();
synchronized (lock) {
}
代码块方式最大的优点就是可以精确控制加锁范围。
3.3 synchronized 的加锁过程
在学习 JVM 的 synchronized 时,会看到锁的逐步升级:
text
偏向锁 -> 轻量级锁 -> 重量级锁
这里需要特别说明:这是经典 HotSpot 锁优化模型中的概念。
尤其是偏向锁,在现代 JDK 中已经不应该简单理解成"现在每个 synchronized 都会严格经历偏向锁 -> 轻量级锁 -> 重量级锁"。
学习这些概念的重点,是理解 JVM 曾经采用以及围绕 synchronized 进行的锁优化思路。
1.偏向锁
早期 HotSpot 的一种优化思想是:如果一把锁长期被同一个线程获取,那么没有必要每次都进行复杂的竞争操作。
例如:
text
线程1
↓
第一次获取锁
↓
以后还是线程1获取
↓
继续快速获取
这就是偏向锁的核心思想:偏向于第一个获取锁的线程。
如果一直没有其他线程竞争,就可以减少锁操作的开销。
注意:偏向锁在 JDK 15 中已经被默认禁用,JDK 21 中已被移除。现代 JVM 更倾向于简洁的锁实现和可预测的性能。
2.轻量级锁
如果出现线程竞争:
text
线程1 -> 获取锁
线程2 -> 也想获取锁
此时 JVM 可以尝试使用轻量级的方式处理竞争。
如果竞争时间很短,线程1很快释放锁,那么线程 2 就不需要马上进入阻塞状态。
3.重量级锁
如果竞争越来越激烈:
text
线程1持有锁
线程2等待
线程3等待
线程4等待
线程5等待
并且锁长时间无法释放,那么继续让线程自旋就没有意义了。
最终可能进入更加重量级的同步机制。
text
竞争激烈 -> 线程阻塞 -> 等待唤醒
所以从学习角度,可以把经典模型理解成:
text
竞争少 -> 尽量轻量处理
竞争增加 -> 进一步升级
竞争严重 -> 重量级处理
3.4 synchronized 的其他优化
1.锁消除
先来看一个例子:
java
public void method() {
Object lock = new Object();
synchronized (lock) {
System.out.println("hello");
}
}
这里的 lock 是 method 这个方法内部创建的对象。
其他线程根本拿不到这个对象。
那么:
text
线程1获取 lock
其他线程根本无法获取这个 lock
既然不存在竞争,那么加锁实际上就没有意义。
JIT 编译器在满足条件时,可以通过分析等优化手段判断:这个锁不会产生实际竞争。
于是可能进行锁消除。
2.锁粗化
假设代码中出现大量连续的加锁和解锁:
java
synchronized (lock) {
method1();
}
synchronized (lock) {
method2();
}
synchronized (lock) {
method3();
}
如果这些操作本来就可以放在一起,那么频繁:
text
加锁
解锁
加锁
解锁
加锁
解锁
也会产生一定开销。
于是可以进行锁粗化:
java
synchronized (lock) {
method1();
method2();
method3();
}
从而减少加锁和解锁的次数。
4、JUC(java.util.concurrent)的常见类
前面我们使用多线程的时候,主要依赖:
text
Thread
Runnable
synchronized
wait()
notify()
但是 Java 实际上还提供了一套非常完整的并发工具。
这些工具主要位于:
java
java.util.concurrent
这个包通常简称JUC。
JUC 中提供了很多已经封装好的并发工具,可以帮助我们更加方便地编写多线程程序。
4.1 什么是 JUC
JUC 的全称就是:
java
java.util.concurrent
里面包含了很多常用工具,例如:
text
Lock
AtomicInteger
ThreadPoolExecutor
Semaphore
CountDownLatch
ConcurrentHashMap
这些类能够帮助我们减少直接操作底层线程同步机制的复杂度。
4.2 Callable 接口
1.为什么需要 Callable?
前面我们学习 Runnable:
java
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("执行任务");
}
};
Runnable 最大的问题之一是 run() 没有返回值。
例如:
java
线程1计算 10 + 20 -> 得到 30
如果我们希望把结果返回给主线程怎么办?
这时候就可以使用 Callable 。
2.Callable 的基本使用
java
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() {
return 10 + 20;
}
};
可以看到 call() 可以返回结果。
3.Callable 和 Runnable 的区别
可以简单理解:
| Runnable | Callable |
|---|---|
run() |
call() |
| 没有返回值 | 有返回值 |
| 不能直接抛出受检异常 | 可以抛出异常 |
Callable 通常和 Future 一起使用。
例如:
java
ExecutorService pool = Executors.newFixedThreadPool(3);
Future<Integer> future = pool.submit(() -> {
return 10 + 20;
});
Integer result = future.get();
System.out.println(result);
pool.shutdown();
执行流程:
java
提交 Callable
↓
线程池执行任务
↓
得到结果
↓
Future 保存结果
↓
调用Future的 get()
↓
获取结果
4.3 ReentrantLock
1.ReentrantLock 的基本使用
除了 synchronized ,Java 还提供了 ReentrantLock。
例如:
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
这里需要注意,加锁之后,一定要释放锁。
所以通常推荐:
java
lock.lock();
try {
} finally {
lock.unlock();
}
这样即使代码发生异常,也能够保证释放锁。
2.synchronized 和 ReentrantLock 的区别
两者都可以实现互斥和可重入。
但是 ReentrantLock 提供了更加丰富的功能。
例如:
java
lock.tryLock();
可以尝试获取锁,当然与之重载的还有可以指定等待时间。
还可以使用:
java
lock.lockInterruptibly();
让等待锁的线程能够响应中断。
同时:
java
new ReentrantLock(true)
还可以创建公平锁。
所以可以简单理解:
text
synchronized:语法简单、自动释放锁
ReentrantLock:功能更加丰富、需要手动 unlock()
3.公平锁和非公平锁
java
ReentrantLock lock1 = new ReentrantLock();
默认使用非公平策略。
如果:
java
ReentrantLock lock2 = new ReentrantLock(true);
则使用公平策略。
4.4 原子类
1.什么是原子类
前面我们已经学习了 CAS。
CAS 一个非常重要的应用就是原子类。
Java 提供了:
text
AtomicInteger
AtomicLong
AtomicBoolean
等类。
例如:
java
AtomicInteger count = new AtomicInteger(0);
增加:
java
count.incrementAndGet();
获取:
java
count.get();
设置:
java
count.set(100);
2.AtomicInteger
来看一个例子:
java
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
多个线程同时执行 increment() ,就可以保证对这个整数变量的原子更新。
4.5 线程池
1.为什么需要线程池?
假设服务器不断收到任务:
text
任务1
任务2
任务3
任务4
任务5
......
如果每来一个任务,就创建一个线程,任务很多的时候会带来:
text
线程创建开销
线程销毁开销
线程调度开销
资源消耗
因此,我们可以提前准备一些线程。
任务来了以后:
text
提交到线程池 -> 线程池选择线程执行 -> 任务完成 -> 线程继续等待下一个任务
这就是线程池。
2.ExecutorService 和 Executors
Java 提供了:
java
ExecutorService
用于表示线程池。
例如:
java
ExecutorService pool = Executors.newFixedThreadPool(3);
提交任务:
java
pool.submit(() -> {
System.out.println("执行任务");
});
关闭:
java
pool.shutdown();
3.Executors
Executors 是一个工具类,可以帮助我们快速创建一些常见线程池。
例如:
java
Executors.newFixedThreadPool(3);
创建固定数量线程的线程池。
还有:
java
Executors.newSingleThreadExecutor();
单线程线程池。
以及:
java
Executors.newScheduledThreadPool(3);
可以执行定时或周期性任务。
不过在实际开发中,如果需要精细控制线程池参数,通常会直接使用:
java
ThreadPoolExecutor
4.6 ThreadPoolExecutor
1.什么是 ThreadPoolExecutor?
ThreadPoolExecutor 是线程池的重要实现类。
例如:
java
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
2,
4,
10,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
它可以让我们更加明确地控制线程池。
2.线程池的核心参数
最重要的参数包括:
text
corePoolSize
maximumPoolSize
keepAliveTime
unit
workQueue
threadFactory
handler
分别表示:
text
核心线程数
最大线程数
空闲线程存活时间
时间单位
任务队列
线程工厂
拒绝策略
3.核心线程数
例如:
java
corePoolSize = 2
表示线程池会优先维护2 个核心线程。
4.最大线程数
例如:
java
maximumPoolSize = 4
表示线程池最多可以创建 4 个线程。
5.工作队列
如果核心线程都在执行任务:
java
线程1 -> 执行任务
线程2 -> 执行任务
此时又来了任务3,一般会先进入任务队列。
text
线程1 -> 任务1
线程2 -> 任务2
任务队列:
任务3
6.线程池的工作流程
这是线程池中非常重要的知识。
假设:
text
核心线程数 = 2
最大线程数 = 4
队列容量 = 2
任务不断提交:
text
任务1
任务2
任务3
任务4
任务5
任务6
......
执行过程可以简单理解为:
text
提交任务1 -> 创建核心线程1 -> 执行任务1
提交任务2 -> 创建核心线程2 -> 执行任务2
提交任务3 -> 核心线程已满 -> 放入任务队列
提交任务4 -> 放入任务队列
提交任务5 -> 队列已满 -> 创建非核心线程
提交任务6 -> 继续创建非核心线程
直到:核心线程数 + 非核心线程数 = 最大线程数
如果此时线程满了 + 队列也满了,再提交任务就需要执行拒绝策略。
7.线程池的拒绝策略
Java 提供了常见的拒绝策略:
text
AbortPolicy
CallerRunsPolicy
DiscardPolicy
DiscardOldestPolicy
例如:
java
new ThreadPoolExecutor.AbortPolicy()
如果无法接受新的任务,就直接抛出异常。
8.线程池为什么可以提高效率?
如果每个任务都创建新线程:
text
任务1 -> 创建线程 -> 销毁线程
任务2 -> 创建线程 -> 销毁线程
任务3 -> 创建线程 -> 销毁线程
线程创建和销毁都有开销。
线程池则是:
text
创建线程 -> 重复使用 -> 执行任务 -> 继续执行下一个任务
这样可以减少线程频繁创建和销毁带来的开销,同时也可以限制线程数量。
4.7 Semaphore
1.什么是 Semaphore?
假设现在有一个资源:
text
数据库连接
我们希望最多允许 3 个线程同时访问。
这时候可以使用:Semaphore(信号量)。
可以把 Semaphore 理解成:许可证数量 = 3
线程想访问资源之前,先申请许可证,访问完成之后归还许可证。
2.Semaphore 的基本使用
java
Semaphore semaphore = new Semaphore(3);
获取许可证:
java
semaphore.acquire();
释放许可证:
java
semaphore.release();
完整代码:
java
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
try {
// 执行任务
} finally {
semaphore.release();
}
如果已经有三个线程获取许可证:
text
线程1 -> 许可证
线程2 -> 许可证
线程3 -> 许可证
线程4则进行等待
直到其中一个线程释放:
text
线程1 -> release()
线程 4 才有机会继续。
3.Semaphore 的应用场景
例如:
text
数据库连接池
服务器连接数限制
限流
资源池
核心思想就是限制同时访问某个资源的线程数量。
4.8 CountDownLatch
1.什么是 CountDownLatch?
假设现在有三个任务:任务1、任务2、任务3。
主线程希望等三个任务全部执行完成之后,再继续执行。
这时候可以使用 CountDownLatch 。
2.CountDownLatch 的基本使用
创建:
java
CountDownLatch latch = new CountDownLatch(3);
三个任务分别执行:
java
latch.countDown();
主线程等待:
java
latch.await();
执行过程:
text
任务1完成 -> countDown()
任务2完成 -> countDown()
任务3完成 -> countDown() -> 计数器变成0 -> 主线程继续执行
3.CountDownLatch 的应用场景
例如:
text
程序启动 -> 加载配置 -> 初始化数据库 -> 初始化缓存 -> 初始化其他资源 -> 全部完成 -> 程序正式运行
主线程可以等待多个初始化任务完成。
5、线程安全的集合类
前面学习线程安全的时候,我们主要关注共享变量,但是实际开发中,还有一个非常常见的共享资源:集合。
例如ArrayList、HashMap,如果多个线程同时操作这些集合,也可能出现线程安全问题。
5.1 多线程环境下使用 ArrayList
来看一个例子:
java
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(i);
}
如果只有一个线程操作,那是没问题的,但是如果多个线程同时执行:
java
list.add(i);
就可能出现线程安全问题。因为 ArrayList 本身并不是线程安全的。
1.怎么解决?
一种简单方式:
java
List<Integer> list = Collections.synchronizedList(
new ArrayList<>()
);
这样可以对很多操作进行同步处理。
2.其他方式
也可以根据实际场景选择CopyOnWriteArrayList,它适合读多写少的场景。
例如:读取操作非常多、修改操作比较少,这时候可以考虑使用。
5.2 多线程环境下使用队列
如果多个线程之间需要传递任务:
text
线程1-> 生产任务放入队列 -> 线程2 -> 消费任务
这时候非常适合使用 BlockingQueue。
例如:
java
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
生产者:queue.put(10);
消费者:Integer value = queue.take();
这就是阻塞队列。
5.3 多线程环境下使用哈希表
1.Hashtable
早期 Java 提供了Hashtable,它是线程安全的。
例如:
java
Hashtable<String, Integer> table = new Hashtable<>();
它的一些方法通过同步保证线程安全。
但是** Hashtable 的并发性能通常不如现代并发集合**。
所以在现代 Java 开发中,更常见的是 ConcurrentHashMap 。
2.ConcurrentHashMap
java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
它是专门为并发场景设计的哈希表。
它能够让多个线程更加高效地并发访问数据。
例如:
java
map.put("Java", 100);
Integer value = map.get("Java");
还可以使用一些原子化操作:
java
map.putIfAbsent("Java", 100);
意思是:如果不存在 "Java",才放入 100。
3.HashMap、Hashtable 和 ConcurrentHashMap
可以简单理解:
| 集合 | 线程安全 | 并发能力 |
|---|---|---|
| HashMap | 否 | 不适合直接用于并发修改 |
| Hashtable | 是 | 并发性能相对较低 |
| ConcurrentHashMap | 是 | 专门针对并发场景设计 |
6、死锁
前面我们一直在:加锁、加锁、加锁。
那么是不是锁越多越好?当然不是。
锁使用不当,就可能出现死锁。
6.1 什么是死锁
来看一个例子,现在有两把锁:
java
Object lockA = new Object();
Object lockB = new Object();
线程 1:
java
synchronized (lockA) {
synchronized (lockB) {
}
}
线程 2:
java
synchronized (lockB) {
synchronized (lockA) {
}
}
假设:
text
线程1获取 lockA
线程2获取 lockB
然后:
text
线程1想获取 lockB
线程2想获取 lockA
结果:
text
线程1 持有 lockA ,然后等待 lockB
线程2 持有 lockB ,然后等待 lockA
于是:
text
线程1等线程2释放 lockB
线程2等线程1释放 lockA
两边互相等待。
谁都无法继续执行。
这就是死锁(Deadlock)。
6.2 死锁产生的条件
经典的死锁条件通常包括四个:
1.互斥条件
资源一次只能被一个线程占用。
text
lockA -> 线程1持有
线程2无法同时持有。
2.请求并持有
线程已经持有一个资源,同时又请求其他资源。
text
线程1 -> 持有 lockA -> 继续请求 lockB
3.不可剥夺
线程已经获得的锁,不能被其他线程强行抢走。
只能由持有锁的线程自己释放。
4.循环等待
线程之间形成循环等待:
text
线程1 -> 等待线程2
线程2 -> 等待线程1
或者:
text
线程1 -> 等待线程2
线程2 -> 等待线程3
线程3 -> 等待线程1
形成环。
6.3 如何避免死锁
1.保持加锁顺序一致
刚才的问题是:
text
线程1:
lockA -> lockB
线程2:
lockB -> lockA
如果统一规定:
text
所有线程都按照顺序来:lockA -> lockB
那么:
text
线程1:
lockA -> lockB
线程2:
lockA -> lockB
这样就不会形成死锁了。
所以一个非常重要的思想就是:统一获取锁的顺序。
2.减少锁的嵌套
尽量避免:
java
synchronized (lockA) {
synchronized (lockB) {
synchronized (lockC) {
}
}
}
锁嵌套越复杂,出现死锁的风险越高。
3.使用 tryLock
如果使用 ReentrantLock 的话,可以使用 tryLock()尝试获取锁。
例如:
java
if (lock.tryLock()) {
try {
// 执行任务
} finally {
lock.unlock();
}
} else {
// 获取锁失败
}
这样就不一定需要无限等待。
6.4 如何排查死锁
如果程序出现:
text
程序卡住
线程不再继续执行
CPU 使用率异常
就需要考虑是否存在死锁。
Java 可以通过 jstack 等工具查看线程状态。
如果发现线程处于等待状态并且互相等待对方持有的锁,就需要进一步检查是否形成死锁。