1.java线程池有哪些内置拒绝策略?

策略是否抛异常是否丢失任务执行者典型场景
AbortPolicy无(抛异常)默认策略,重要业务需感知异常
CallerRunsPolicy提交任务的线程允许降速,不能丢失任务
DiscardPolicy是(当前任务)无(直接丢弃)无关紧要的边缘任务
DiscardOldestPolicy是(最老任务)无(丢弃队头)实时性要求高,新任务优先

2.Web项目,Controller中使用线程池执行耗时长的任务,拒绝策略可以使用CallerRunsPolicy吗,请说明原因?

不可以,CallerRunsPolicy会让提交任务的线程来执行任务,有可能会造成Tomcat 的Worker 线程被全部耗尽并阻塞。完整过程如下

  1. 请求进入:用户发起耗时任务请求,Tomcat 从 Worker 线程池中分配一个线程(假设叫 Worker-1)来执行 Controller 逻辑。
  2. 任务提交:Controller 代码执行到 bizThreadPool.execute(longTimeTask),试图将耗时任务交给自定义业务线程池。
  3. 触发拒绝:此时,如果自定义业务线程池的工作线程全忙,且阻塞队列已满,新提交的任务会被拒绝。
  4. 策略生效:由于配置了 CallerRunsPolicy,被拒绝的任务不会被丢弃,而是直接调用 r.run()
  5. 线程被绑架:执行 r.run() 的线程是谁?正是提交任务的 Tomcat Worker-1 线程!此时,Worker-1 被迫同步执行这个耗时任务,原本毫秒级就能返回的 execute() 方法,现在要等耗时任务执行完(比如 10 秒)才能继续往下走。
  6. 全盘皆输:随着并发请求不断增加,Tomcat 的 Worker-2Worker-3… 都会重复上述过程。很快,Tomcat 的 200 个 Worker 线程(默认个数)全部被“绑架”去执行耗时的长任务。
  7. 服务假死:此时,哪怕是再简单的请求(如获取系统时间、读取缓存),Tomcat 也没有空闲的 Worker 线程去处理了,整个应用表现为无响应(假死)。

3.线程池使用无界队列,总线程数能达到最大线程数吗

不能。
Java 线程池的扩容逻辑是:核心线程满 -> 塞队列 -> 队列满 -> 创建非核心线程到最大线程数 -> 依然放不下 -> 拒绝。

无界队列(如默认容量为Integer.MAX_VALUELinkedBlockingQueue)的特点是永远不会满。既然队列永远装不满,线程池就永远没有机会走到去创建非核心线程。因此,无论你提交多少任务,线程池最多只会创建corePoolSize个线程,maximumPoolSize形同虚设。

实际开发中的影响与建议

使用无界队列会导致两个严重问题:

  1. 最大线程数失效: 如上所述,maximumPoolSize参数完全无效,系统无法通过创建临时线程来应对突发流量。
  2. 内存溢出(OOM)风险: 在高并发场景下,核心线程处理不过来,大量任务会无限堆积在无界队列中,最终导致内存溢出。

正确的做法是使用有界队列。Executors.newFixedThreadPool() 和 Executors.newSingleThreadExecutor() 底层使用的都是无界队列(LinkedBlockingQueue),这也是阿里开发规范中禁止使用它们来创建线程池的原因,必须手动使用ThreadPoolExecutor构造函数并指定有界队列。

4.读写锁的原则是读写互斥,那么ReentrantReadWriteLock获取写锁后,可以再获取读锁吗

可以。互斥的本质是保护数据,而不是限制自己

读写锁的“读写互斥”原则,其根本目的是保证数据的一致性

  • 写-读互斥:如果线程A正在写数据,线程B去读,线程B可能会读到写了一半的脏数据。所以必须互斥。
  • 写-写互斥:如果两个线程同时写,数据会互相覆盖。所以必须互斥。

但是,如果当前线程已经持有了写锁,意味着它正在修改数据。此时如果它自己去读自己刚刚修改的数据,会不会读到脏数据?显然不会。因为自己对自己修改的结果是完全已知且一致的。

如果连自己读自己的数据都要阻塞,那就违背了锁的初衷(锁是用来防止别人干扰,而不是用来把自己锁死)。因此,写锁对读锁的排斥,仅限于其他线程

5. 为什么必须支持锁降级,也就是“写锁获取读锁”?

假设我们需要在修改完数据后,立刻读取刚才修改的数据。如果不支持锁降级,只有两种选择:

  • 选择A:一直持有写锁去读数据。
    这虽然能保证数据正确,但极其影响并发性能。因为写锁是独占的,在你读取数据的整个过程中,其他所有想读数据的线程都被阻塞了。但实际上,你只是在读,完全可以允许别人一起读。
  • 选择B:先释放写锁,再获取读锁。
    这会导致严重的数据一致性问题。在你释放写锁、还未获取到读锁的这极小一段时间内,其他线程可以趁机获取写锁并修改数据。此时你再获取读锁去读,读到的可能已经不是刚才自己修改的数据了,这在很多业务场景下是不可接受的(比如修改了配置项,想立刻读取验证)。

锁降级完美解决了这个问题:在释放写锁之前,先拿住读锁。这样当你释放写锁时,其他写线程无法获取写锁(因为还有读锁被占用),但其他读线程可以正常获取读锁。既保证了数据的一致性(自己读到的一定是自己刚写的),又提高了并发度(读的时候允许别人也来读)。
标准流程如下:

  1. 获取写锁
  2. 修改数据
  3. 获取读锁
  4. 释放写锁
  5. 读取数据
  6. 释放读锁

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)。

存取过程:当执行 threadLocal.set(value) 时,实际上是获取当前线程的 ThreadLocalMap,然后把当前的 threadLocal 作为 Key,业务数据作为 Value,存入一个 Entry 中。

8.使用ThreadLocal变量为什么可能造成内存泄漏,如何预防?

根本原因在于 Key 与 Value 引用强度的设计差异,以及 线程的生命周期过长(特别是线程池场景)。

  1. Key 被回收:当外部不再使用 ThreadLocal 对象(强引用断开)时,由于 Entry 对 Key 是弱引用,JVM 发生 GC 时会回收该 ThreadLocal 实例,导致 Entry.key = null
  2. 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);
}

更多推荐