Java 并发 100 问:从面试到生产(一)
1.java线程池有哪些内置拒绝策略?
| 策略 | 是否抛异常 | 是否丢失任务 | 执行者 | 典型场景 |
|---|---|---|---|---|
| AbortPolicy | 是 | 否 | 无(抛异常) | 默认策略,重要业务需感知异常 |
| CallerRunsPolicy | 否 | 否 | 提交任务的线程 | 允许降速,不能丢失任务 |
| DiscardPolicy | 否 | 是(当前任务) | 无(直接丢弃) | 无关紧要的边缘任务 |
| DiscardOldestPolicy | 否 | 是(最老任务) | 无(丢弃队头) | 实时性要求高,新任务优先 |
2.Web项目,Controller中使用线程池执行耗时长的任务,拒绝策略可以使用CallerRunsPolicy吗,请说明原因?
不可以,CallerRunsPolicy会让提交任务的线程来执行任务,有可能会造成Tomcat 的Worker 线程被全部耗尽并阻塞。完整过程如下
- 请求进入:用户发起耗时任务请求,Tomcat 从 Worker 线程池中分配一个线程(假设叫
Worker-1)来执行 Controller 逻辑。 - 任务提交:Controller 代码执行到
bizThreadPool.execute(longTimeTask),试图将耗时任务交给自定义业务线程池。 - 触发拒绝:此时,如果自定义业务线程池的工作线程全忙,且阻塞队列已满,新提交的任务会被拒绝。
- 策略生效:由于配置了
CallerRunsPolicy,被拒绝的任务不会被丢弃,而是直接调用r.run()。 - 线程被绑架:执行
r.run()的线程是谁?正是提交任务的 Tomcat Worker-1 线程!此时,Worker-1被迫同步执行这个耗时任务,原本毫秒级就能返回的execute()方法,现在要等耗时任务执行完(比如 10 秒)才能继续往下走。 - 全盘皆输:随着并发请求不断增加,Tomcat 的
Worker-2,Worker-3… 都会重复上述过程。很快,Tomcat 的 200 个 Worker 线程(默认个数)全部被“绑架”去执行耗时的长任务。 - 服务假死:此时,哪怕是再简单的请求(如获取系统时间、读取缓存),Tomcat 也没有空闲的 Worker 线程去处理了,整个应用表现为无响应(假死)。
3.线程池使用无界队列,总线程数能达到最大线程数吗
不能。
Java 线程池的扩容逻辑是:核心线程满 -> 塞队列 -> 队列满 -> 创建非核心线程到最大线程数 -> 依然放不下 -> 拒绝。
无界队列(如默认容量为Integer.MAX_VALUE的LinkedBlockingQueue)的特点是永远不会满。既然队列永远装不满,线程池就永远没有机会走到去创建非核心线程。因此,无论你提交多少任务,线程池最多只会创建corePoolSize个线程,maximumPoolSize形同虚设。
实际开发中的影响与建议
使用无界队列会导致两个严重问题:
- 最大线程数失效: 如上所述,
maximumPoolSize参数完全无效,系统无法通过创建临时线程来应对突发流量。 - 内存溢出(OOM)风险: 在高并发场景下,核心线程处理不过来,大量任务会无限堆积在无界队列中,最终导致内存溢出。
正确的做法是使用有界队列。Executors.newFixedThreadPool() 和 Executors.newSingleThreadExecutor() 底层使用的都是无界队列(LinkedBlockingQueue),这也是阿里开发规范中禁止使用它们来创建线程池的原因,必须手动使用ThreadPoolExecutor构造函数并指定有界队列。
4.读写锁的原则是读写互斥,那么ReentrantReadWriteLock获取写锁后,可以再获取读锁吗
可以。互斥的本质是保护数据,而不是限制自己
读写锁的“读写互斥”原则,其根本目的是保证数据的一致性:
- 写-读互斥:如果线程A正在写数据,线程B去读,线程B可能会读到写了一半的脏数据。所以必须互斥。
- 写-写互斥:如果两个线程同时写,数据会互相覆盖。所以必须互斥。
但是,如果当前线程已经持有了写锁,意味着它正在修改数据。此时如果它自己去读自己刚刚修改的数据,会不会读到脏数据?显然不会。因为自己对自己修改的结果是完全已知且一致的。
如果连自己读自己的数据都要阻塞,那就违背了锁的初衷(锁是用来防止别人干扰,而不是用来把自己锁死)。因此,写锁对读锁的排斥,仅限于其他线程。
5. 为什么必须支持锁降级,也就是“写锁获取读锁”?
假设我们需要在修改完数据后,立刻读取刚才修改的数据。如果不支持锁降级,只有两种选择:
- 选择A:一直持有写锁去读数据。
这虽然能保证数据正确,但极其影响并发性能。因为写锁是独占的,在你读取数据的整个过程中,其他所有想读数据的线程都被阻塞了。但实际上,你只是在读,完全可以允许别人一起读。 - 选择B:先释放写锁,再获取读锁。
这会导致严重的数据一致性问题。在你释放写锁、还未获取到读锁的这极小一段时间内,其他线程可以趁机获取写锁并修改数据。此时你再获取读锁去读,读到的可能已经不是刚才自己修改的数据了,这在很多业务场景下是不可接受的(比如修改了配置项,想立刻读取验证)。
锁降级完美解决了这个问题:在释放写锁之前,先拿住读锁。这样当你释放写锁时,其他写线程无法获取写锁(因为还有读锁被占用),但其他读线程可以正常获取读锁。既保证了数据的一致性(自己读到的一定是自己刚写的),又提高了并发度(读的时候允许别人也来读)。
标准流程如下:
- 获取写锁
- 修改数据
- 获取读锁
- 释放写锁
- 读取数据
- 释放读锁
6. ReentrantReadWriteLock为什么不允许“锁升级”?
理解了锁降级,我们自然会问:那能不能先获取读锁,再获取写锁(锁升级)?
答案是:ReentrantReadWriteLock 绝对不允许锁升级(会导致死锁)。
原因很简单:假设允许锁升级。
- 线程A获取了读锁。
- 线程B也获取了读锁。
- 线程A想升级为写锁,因为线程B持有读锁,所以线程A必须等待线程B释放读锁。
- 线程B也想升级为写锁,因为线程A持有读锁,所以线程B必须等待线程A释放读锁。
于是,A等B,B等A,产生了经典的死锁。
为了避免这种情况,ReentrantReadWriteLock 在源码中严格规定:如果当前线程持有读锁,再去尝试获取写锁,必定会阻塞(失败)。
7. 线程本地变量ThreadLocal是如何设计实现的?
ThreadLocal 的核心设计是 “空间换时间”,每个线程独自维护一份变量副本,从而避免同步开销。具体实现依靠三个层级:
- Thread 类:内部持有一个
ThreadLocal.ThreadLocalMap实例,相当于每个线程自带一个专属的背包。 - ThreadLocalMap:一个定制化的哈希表(只有数组,没有链表,采用线性探测法解决冲突)。它不实现标准 Map 接口,仅限 ThreadLocal 内部使用。
- Entry:Map 中的键值对节点。关键设计在于它继承了
WeakReference<ThreadLocal<?>>:- Key:就是
ThreadLocal实例本身,被 弱引用 持有(隐式存在父类中)。 - Value:就是业务存入的变量值,被 强引用 持有(显式声明
Object value)。
- Key:就是
存取过程:当执行 threadLocal.set(value) 时,实际上是获取当前线程的 ThreadLocalMap,然后把当前的 threadLocal 作为 Key,业务数据作为 Value,存入一个 Entry 中。
8.使用ThreadLocal变量为什么可能造成内存泄漏,如何预防?
根本原因在于 Key 与 Value 引用强度的设计差异,以及 线程的生命周期过长(特别是线程池场景)。
- Key 被回收:当外部不再使用
ThreadLocal对象(强引用断开)时,由于 Entry 对 Key 是弱引用,JVM 发生 GC 时会回收该ThreadLocal实例,导致Entry.key = null。 - Value 仍存活:此时 Entry 变成了
key=null, value=大对象的状态。因为 Value 是强引用,且引用链为:线程 -> ThreadLocalMap -> Entry -> Value,只要当前线程不死,这个无法被访问到的 Value 就永远无法被 GC 回收,造成内存泄漏。
如何预防?
- 最佳实践(根本解决):在业务代码中,每次使用完
ThreadLocal后,务必在finally代码块中显式调用threadLocal.remove()。这会主动断开 Entry 与 Value 的强引用,彻底清除数据。 - 框架兜底(被动防御):ThreadLocal 源码设计了自愈机制。在调用
get()、set()、remove()时,ThreadLocalMap 会顺带扫描并清理那些key == null的过期 Entry,释放其 Value 的内存。但这依赖于你后续还有对该 ThreadLocal 的操作,不可靠。
9. ThreadLocalMap里的属性没有map,只有 Entry[] table,那么它的key在哪里?
Entry 是这样定义的:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
// ...
}
因为 ThreadLocalMap 的 Key 永远只能是 ThreadLocal 对象,所以设计者直接让 Entry 继承 WeakReference<ThreadLocal<?>>。
这就意味着:
- Key 被隐式地藏在了父类
WeakReference的referent属性里(通过super(k)传入)。 - Value 显式地声明为
Object value。
所以,表面上看 Entry 只有一个 value 属性,但实际上它包含了两部分信息:隐式的弱引用 Key(在父类里)和显式的强引用 Value(在自己里)。
10. SimpleDateFormat 不是线程安全的,那如果需要在并发场景下使用它,该怎么办呢
10.1. 使用 JDK 8+ 的 java.time 格式化类(⭐ 最推荐)
如果你使用的是 JDK 8 及以上版本,彻底抛弃 SimpleDateFormat 是最佳选择。取而代之的是 java.time.format.DateTimeFormatter。
DateTimeFormatter 是不可变对象(Immutable),天生线程安全。你完全可以将其定义为 private static final 的静态常量在多线程下共享。
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public String format(LocalDateTime dateTime) {
return FORMATTER.format(dateTime);
}
public LocalDateTime parse(String text) {
return LocalDateTime.parse(text, FORMATTER);
}
10.2. 使用 ThreadLocal(适用于 JDK 7 及以下,或旧代码兼容)
如果你还在使用 JDK 7 或旧代码中大量使用了 SimpleDateFormat,不想大范围重构,可以使用 ThreadLocal 为每一个线程提供一个独立的 SimpleDateFormat 实例。
这种方式既避免了频繁创建对象的开销,又保证了线程安全。
private static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL_FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public String format(Date date) {
return THREAD_LOCAL_FORMATTER.get().format(date);
}
public Date parse(String text) throws ParseException {
return THREAD_LOCAL_FORMATTER.get().parse(text);
}
更多推荐
所有评论(0)