Java并发面试题(三)

发布于 2025-09-23 17:12:12 浏览 96 次

1.什么是 Java 内存模型(JMM)?

Java 内存模型(Java Memory Model,JMM)是 Java 虚拟机(JVM)定义的一套规范,用于解决多线程环境下 “内存可见性、原子性、有序性” 问题,它不直接定义内存结构,而是通过约定线程与内存的交互规则,确保多线程程序在不同硬件和操作系统上的一致性。​
JMM 的核心目标是:让程序员在编写多线程代码时,无需关注底层硬件的内存差异,只需遵循 JMM 规范,就能写出线程安全的程序。其核心规则包括:​
内存划分:将内存抽象为 “主内存” 和 “工作内存”—— 所有变量存储在主内存,线程操作变量时需先将变量加载到自身的工作内存,修改后再刷新回主内存;​
交互规则:定义线程对主内存变量的操作(读取、加载、使用、赋值、存储、写入)的顺序和可见性约束,例如 “线程修改变量后必须刷新回主内存,其他线程才能读取到最新值”;​
同步机制:通过 volatile、synchronized、final 以及锁机制,满足不同场景下的线程安全需求,例如 volatile 确保变量的可见性和有序性,synchronized 确保原子性、可见性和有序性。

2.什么是 Java 中的原子性、可见性和有序性?​

原子性、可见性、有序性是 Java 多线程安全的三大核心特性,也是 JMM 需解决的核心问题,具体定义如下:​
原子性:指一个操作或一组操作 “要么全部执行,且执行过程中不被其他线程打断;要么全部不执行”,不存在 “部分执行” 的中间状态。​
示例:int a = 1 是原子操作(直接赋值单个变量);a++ 不是原子操作(包含读取 a、修改 a+1、写入 a 三个步骤,可能被其他线程打断);​
保障方式:通过 synchronized、ReentrantLock 等锁机制,或 AtomicInteger 等原子类实现。​
可见性:指当一个线程修改了共享变量的值后,其他线程能 “立即看到” 该修改后的最新值,避免因 “工作内存缓存” 导致的线程间数据不一致。​
问题场景:线程 A 修改共享变量 a 后,未及时刷新回主内存,线程 B 从主内存读取 a 时仍获取旧值;​
保障方式:通过 volatile(修改后强制刷新回主内存,读取时强制从主内存加载)、synchronized(释放锁时刷新回主内存,获取锁时加载最新值)实现。​
有序性:指程序执行的顺序 “与代码的书写顺序一致”,避免因编译器或 CPU 的 “指令重排序” 导致的线程安全问题。​
问题场景:代码中 “int a = 1; int b = 2”,编译器可能重排序为 “int b = 2; int a = 1”,单线程下无影响,但多线程下可能导致逻辑错误(如双重检查单例模式中的半初始化问题);​
保障方式:通过 volatile(禁止被修饰变量的指令重排序)、synchronized(禁止同步块内指令与块外重排序)、happens-before 规则实现。​

3.什么是 Java 的 happens-before 规则?​

happens-before 规则是 JMM 定义的一套 “天然的有序性约束”,用于判断多线程环境下操作的执行顺序是否合法,以及是否存在数据竞争。若操作 A happens-before 操作 B,则意味着 “操作 A 的执行结果对操作 B 可见,且 A 的执行顺序在 B 之前”。​
JMM 定义的 8 条核心 happens-before 规则如下:​
程序次序规则:在单线程中,代码书写在前面的操作 happens-before 书写在后面的操作(单线程内按代码顺序执行);​
管程锁定规则:线程 A 释放锁的操作 happens-before 线程 B 获取同一把锁的操作(释放锁后,后续获取锁的线程能看到释放前的修改);​
volatile 变量规则:线程 A 对 volatile 变量的写操作 happens-before 线程 B 对该变量的读操作(volatile 写的结果对后续读可见);​
线程启动规则:主线程调用线程 B 的 start () 方法 happens-before 线程 B 内的任意操作(start () 后,子线程能看到主线程启动前的修改);​
线程终止规则:线程 B 内的任意操作 happens-before 主线程检测到线程 B 终止的操作(如主线程调用 B.join () 或 B.isAlive () 检测到 B 终止);​
线程中断规则:主线程调用线程 B 的 interrupt () 方法 happens-before 线程 B 检测到中断(如 B 通过 Thread.interrupted () 检测到中断);​
传递性规则:若操作 A happens-before 操作 B,操作 B happens-before 操作 C,则操作 A happens-before 操作 C;​
对象终结规则:对象的构造函数执行完成 happens-before 该对象的 finalize () 方法执行(对象初始化完成后,才会执行终结方法)。​
happens-before 规则的核心作用:无需显式使用同步工具(如 volatile、synchronized),只要符合上述规则,就能保证操作的有序性和可见性,简化多线程代码的编写。​

4.什么是 Java 中的指令重排?

指令重排是编译器或 CPU 为提升程序执行效率,对 “不存在数据依赖的指令” 进行的顺序调整,分为 “编译器重排” 和 “CPU 重排” 两类,是导致多线程有序性问题的主要原因之一。​
(1)指令重排的原理​
数据依赖判断:编译器和 CPU 会先判断指令间是否存在 “数据依赖”(如指令 A 的结果是指令 B 的输入,A 和 B 存在数据依赖),若存在则不能重排;若不存在数据依赖(如 “int a = 1; int b = 2”),则可重排;​
重排目的:通过调整指令顺序,减少 CPU 等待时间(如将耗时的 IO 指令后面的无依赖指令提前执行,避免 CPU 空闲),提升程序吞吐量;​
重排范围:编译器重排发生在编译阶段(将源代码编译为字节码时),CPU 重排发生在运行阶段(执行字节码指令时)。​
(2)指令重排的问题​
单线程环境下,指令重排不会影响执行结果(因编译器和 CPU 会确保 “单线程内的语义一致性”);但多线程环境下,重排可能导致线程安全问题。例如:​

// 线程 A​
int a = 1; // 操作 1​
boolean flag = true; // 操作 2​
​
// 线程 B​
while (flag) { // 操作 3​
    System.out.println(a); // 操作 4​
}​
​

操作 1 和操作 2 无数据依赖,可能被重排为 “先执行操作 2,再执行操作 1”。若线程 A 重排后,先将 flag 设为 true,线程 B 立即进入循环执行操作 4,此时 a 尚未被赋值为 1,线程 B 会打印出 0(默认值),导致逻辑错误。​
(3)禁止指令重排的方式​
通过 volatile、synchronized 或 happens-before 规则,可禁止特定场景下的指令重排,确保多线程有序性。​
5.Java 中的 final 关键字是否能保证变量的可见性?​
能,但有条件限制 ——final 关键字仅能保证 “final 变量初始化完成后,其值对其他线程可见”,无法保证 “final 变量引用的对象内部字段的可见性”,具体规则如下:​
(1)final 保证可见性的场景​
final 基本类型变量:当 final 基本类型变量(如 final int a = 1)在构造函数中初始化完成,且构造函数没有 “逸出”(即构造函数未将当前对象的引用传递给其他线程),则其他线程获取该变量时,一定能看到初始化后的最终值,不会看到默认值(如 0);​
final 引用类型变量:当 final 引用类型变量(如 final List list = new ArrayList())在构造函数中初始化完成,且无构造函数逸出,则其他线程获取该变量时,一定能看到 “引用的对象地址是最终的”(即 list 不会指向其他对象),但无法保证 “对象内部字段的可见性”(如 list.add (1) 后,其他线程可能看不到 list 中的元素 1)。​
(2)final 不保证可见性的场景​
构造函数逸出:若构造函数中,将当前对象的引用传递给其他线程(如 public Test() { otherThread.setObj(this); }),则其他线程可能在构造函数未执行完时获取 final 变量,看到未初始化的默认值;​
引用对象的内部字段:final 仅保证引用地址不变,不保证引用对象内部字段的可见性。例如 final User user = new User(),线程 A 修改 user.setName("a") 后,线程 B 可能看不到 “name 为 a”,需额外通过 volatile 或 synchronized 保证内部字段的可见性。​
(3)底层原理​
JMM 对 final 变量的初始化设置了 “内存屏障”:在 final 变量初始化完成后、构造函数结束前,插入 StoreStore 内存屏障,禁止 final 变量的初始化指令与构造函数外的指令重排,确保 final 变量初始化完成后,才能被其他线程访问。​
6.为什么在 Java 中需要使用 ThreadLocal?​
ThreadLocal 是 Java 提供的 “线程本地存储” 工具,用于为每个线程创建 “独立的变量副本”,解决多线程环境下 “共享变量的线程安全问题”,其核心价值在于 “避免线程间变量竞争,简化线程安全代码的编写”,主要使用场景如下:​
解决共享变量的线程安全问题:​
若多个线程操作同一个共享变量(如日期格式化工具 SimpleDateFormat),直接使用会导致线程安全问题(如格式化结果错误);​
通过 ThreadLocal 为每个线程创建独立的 SimpleDateFormat 副本,线程仅操作自己的副本,无需同步锁,既保证线程安全,又避免锁的性能开销。​
简化线程间的数据传递:​
在多层级调用(如 Controller→Service→DAO)中,若需传递线程相关的数据(如用户登录信息、请求 ID),传统方式需通过方法参数层层传递,代码冗余;​
将数据存入 ThreadLocal,线程内的任意层级代码均可直接获取,无需显式传递,简化代码结构。​
避免线程安全的性能开销:​
传统的线程安全方案(如 synchronized、ReentrantLock)通过阻塞线程实现同步,存在上下文切换开销;​
ThreadLocal 无需锁机制,每个线程操作自己的变量副本,无竞争、无阻塞,性能更优,尤其适合高并发场景。​
常见使用案例:Web 开发中存储用户登录状态、数据库连接池的线程绑定连接、日志框架的 MDC(Mapped Diagnostic Context)存储请求 ID 等。​

7.Java 中的 ThreadLocal 是如何实现线程资源隔离的?​

ThreadLocal 的核心实现原理是 “每个线程维护独立的 ThreadLocalMap,存储线程本地变量的副本”,具体结构和流程如下:​
(1)核心数据结构​
Thread 类的 threadLocals 字段:每个 Thread 对象内部有一个 ThreadLocal.ThreadLocalMap 类型的成员变量 threadLocals,默认值为 null;​
ThreadLocalMap 是 ThreadLocal 的静态内部类,本质是一个 “哈希表”,key 为 ThreadLocal 对象本身,value 为线程本地变量的副本;​
ThreadLocal 的作用:ThreadLocal 本身不存储数据,仅作为 “key” 用于在 ThreadLocalMap 中查找当前线程的变量副本。​
(2)线程资源隔离的流程​
以 ThreadLocal.set(T value) 和 ThreadLocal.get() 方法为例,说明隔离原理:​
set () 方法流程:​
① 获取当前线程的 Thread 对象;​
② 从 Thread 对象中获取 threadLocals 字段(ThreadLocalMap);​
③ 若 threadLocals 为 null,创建新的 ThreadLocalMap 并赋值给 threadLocals;​
④ 以当前 ThreadLocal 对象为 key,将 value 作为 value,存入 ThreadLocalMap(若 key 已存在,则覆盖旧值);​
最终结果:每个线程的 ThreadLocalMap 中,存储该线程对应的变量副本,线程间的 ThreadLocalMap 相互独立,实现资源隔离。​
get () 方法流程:​
① 获取当前线程的 Thread 对象;​
② 从 Thread 对象中获取 threadLocals 字段;​
③ 若 threadLocals 为 null,调用 initialValue () 方法初始化默认值(如返回 null 或自定义默认值),并存入 threadLocals;​
④ 以当前 ThreadLocal 对象为 key,从 ThreadLocalMap 中获取对应的 value(即当前线程的变量副本);​
最终结果:线程仅能获取自己 ThreadLocalMap 中的变量副本,无法访问其他线程的副本,确保隔离性。​
(3)核心特点​
线程私有:每个线程的 ThreadLocalMap 仅属于当前线程,其他线程无法访问;​
一一对应:一个 ThreadLocal 对象对应线程中一个变量副本,若需存储多个变量,需创建多个 ThreadLocal 对象。​

8.为什么 Java 中的 ThreadLocal 对 key 的引用为弱引用?​

ThreadLocalMap 中,key 对 ThreadLocal 对象的引用是 “弱引用(WeakReference)”,这一设计的核心目的是 “避免内存泄漏”,具体原因需结合 ThreadLocal 的内存引用关系和垃圾回收机制分析:​
(1)ThreadLocal 的内存引用关系​
正常使用时,ThreadLocal 的引用链为:Thread → ThreadLocalMap → Entry(key=弱引用 ThreadLocal,value=变量副本),同时存在 “用户代码中的强引用”(如 private ThreadLocal<String> tl = new ThreadLocal<>())。​
(2)若 key 为强引用的内存泄漏风险​
若 ThreadLocalMap 的 key 对 ThreadLocal 是强引用,当用户代码中的强引用被释放(如 tl = null),ThreadLocal 对象仍会被 ThreadLocalMap 的 key 强引用持有,无法被垃圾回收;同时,ThreadLocalMap 中的 value 也会被 key 间接持有,无法回收。​
若线程长期存活(如线程池中的核心线程),这些未回收的 ThreadLocal 对象和 value 会不断积累,导致内存泄漏。​
(3)弱引用的解决方案​
将 key 设计为弱引用后,当用户代码中的强引用释放(tl = null),ThreadLocal 对象仅被 ThreadLocalMap 的 key 弱引用持有,此时:​
垃圾回收时,弱引用对象会被优先回收,ThreadLocal 对象会被销毁;​
ThreadLocalMap 会在后续的 get ()、set ()、remove () 方法中,检测到 “key 为 null 的 Entry”(称为 “过期 Entry”),并将其 value 设为 null,释放 value 的引用,避免 value 内存泄漏。​
(4)仍需注意的内存泄漏问题​
弱引用仅能解决 ThreadLocal 对象的内存泄漏,若线程长期存活且未调用 get ()、set ()、remove () 方法,过期 Entry 的 value 仍会被持有,导致内存泄漏。因此,使用 ThreadLocal 时,需在 “线程不再使用变量” 时调用 remove () 方法,主动释放 value 引用,彻底避免内存泄漏。​

9.Java 中使用 ThreadLocal 的最佳实践是什么?

为避免 ThreadLocal 导致的内存泄漏、线程安全问题,结合其实现原理,最佳实践如下:​
使用 private static 修饰 ThreadLocal 对象:​
原因:① private 避免 ThreadLocal 对象被外部修改;② static 确保线程内仅创建一个 ThreadLocal 实例,避免重复创建导致的资源浪费;​
示例:private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();​
线程结束或变量不再使用时,主动调用 remove () 方法:​
原因:避免线程长期存活(如线程池核心线程)时,过期 Entry 的 value 无法回收导致内存泄漏;​
场景:Web 开发中,在 Controller 方法结束时、拦截器的 afterCompletion () 方法中调用 remove ();线程池任务执行完成后,在 finally 块中调用 remove ();​
示例:​

try {​
    userThreadLocal.set(user);​
    // 业务逻辑​
} finally {​
    userThreadLocal.remove(); // 主动释放​
}​

避免存储大对象或长期存活的对象:​
原因:ThreadLocal 存储的对象若过大(如大集合、大文件流),即使调用 remove (),也可能因 GC 延迟导致短期内存占用过高。

9.Java 中使用 ThreadLocal 的最佳实践是什么?


建议:若需存储大对象,可在使用完成后立即调用 remove (),或通过软引用 / 弱引用包装对象,降低内存占用风险。​
避免 ThreadLocal 的线程池滥用:​
线程池中的线程会复用,若前一个任务使用 ThreadLocal 后未调用 remove (),后一个任务可能获取到前一个任务的变量副本,导致数据污染;​
解决方案:在线程池任务的 finally 块中强制调用 remove (),确保任务间数据隔离。​
自定义 initialValue () 方法初始化默认值:​
若 ThreadLocal 变量需默认值(如空集合、默认配置),可重写 initialValue () 方法,避免 get () 时返回 null 导致空指针异常;​
示例:​

private static ThreadLocal<List<String>> listThreadLocal = new ThreadLocal<List<String>>() {​
    @Override​
    protected List<String> initialValue() {​
        return new ArrayList<>(); // 默认初始化空集合​
    }​
};​

10.Java 中的 InheritableThreadLocal 是什么?​

InheritableThreadLocal 是 ThreadLocal 的子类,扩展了 ThreadLocal 的功能,核心特性是 “支持子线程继承父线程的 ThreadLocal 变量副本”,解决了 ThreadLocal 中 “子线程无法获取父线程本地变量” 的问题。​
(1)与 ThreadLocal 的核心区别​
ThreadLocal:父线程的本地变量仅属于父线程,子线程创建后无法获取父线程的 ThreadLocal 变量副本;​
InheritableThreadLocal:子线程创建时,会自动复制父线程 InheritableThreadLocal 中的变量副本到子线程的 InheritableThreadLocalMap 中,子线程可直接获取父线程的变量值。​
(2)实现原理​
核心数据结构:Thread 类除了 threadLocals 字段,还包含 inheritableThreadLocals 字段(类型也是 ThreadLocalMap),专门存储 InheritableThreadLocal 的变量副本;​
继承逻辑:当父线程创建子线程时,JVM 会调用 Thread 类的 init() 方法,在 init() 中判断父线程的 inheritableThreadLocals 是否不为 null,若是则:​
复制父线程 inheritableThreadLocals 中的所有 Entry 到子线程的 inheritableThreadLocals 中;​
复制为 “浅拷贝”—— 若变量是引用类型,子线程和父线程的副本指向同一个对象,修改对象内部字段会相互影响。​
(3)适用场景​
父线程需向子线程传递上下文数据的场景,如:​
日志框架中,父线程的请求 ID 传递给子线程,确保子线程日志也包含请求 ID;​
分布式追踪中,父线程的追踪 ID 传递给子线程,确保链路追踪的完整性。​
(4)注意事项​
浅拷贝问题:继承的是变量副本的引用,若变量是可变对象(如 ArrayList),父线程和子线程修改对象内部字段会相互影响,需通过 “深拷贝”(如复制对象新实例)解决;​
线程池不支持继承:线程池中的线程是预先创建的,子线程(线程池线程)创建时父线程可能已结束,无法继承父线程的 InheritableThreadLocal 变量,需使用 TransmittableThreadLocal 解决;​
内存泄漏风险:与 ThreadLocal 类似,使用后需调用 remove () 方法,避免线程长期存活导致内存泄漏。​
11.ThreadLocal 的缺点?​
尽管 ThreadLocal 是多线程环境下的常用工具,但仍存在以下缺点,使用时需注意规避:​
内存泄漏风险:​
若线程长期存活(如线程池核心线程),且使用 ThreadLocal 后未调用 remove () 方法,ThreadLocalMap 中 “key 为 null 的 Entry” 的 value 会被长期持有,无法被垃圾回收,导致内存泄漏;​
即使 key 是弱引用,若未触发 get ()、set ()、remove () 方法,过期 Entry 的 value 仍无法释放。​
线程池环境下的数据污染:​
线程池中的线程会复用,若前一个任务使用 ThreadLocal 后未清理(未 remove ()),后一个任务获取 ThreadLocal 时会拿到前一个任务的变量副本,导致数据污染(如子线程拿到父线程的旧数据);​
例如:线程池线程执行任务 A 时设置 tl.set("a"),未 remove (),执行任务 B 时 tl.get() 会拿到 "a",而非任务 B 预期的默认值。​
无法跨线程传递数据:​
普通 ThreadLocal 不支持父子线程数据传递,子线程无法获取父线程的 ThreadLocal 变量副本,需使用 InheritableThreadLocal,但 InheritableThreadLocal 又不支持线程池场景。​
多变量存储需创建多个 ThreadLocal 实例:​
一个 ThreadLocal 实例仅能存储一个变量副本,若线程需存储多个不同类型的变量,需创建多个 ThreadLocal 实例,增加代码冗余(可通过封装一个 “上下文对象” 存储多变量,再将该对象存入单个 ThreadLocal 解决)。​
不支持分布式场景:​
ThreadLocal 的变量副本仅存储在当前 JVM 进程的线程中,跨 JVM 进程(如分布式系统的多个服务节点)无法共享,分布式场景下需使用分布式上下文(如 TraceId 通过 HTTP 头传递)。​
12.为什么 Netty 不使用 ThreadLocal 而是自定义了一个 FastThreadLocal ?​
Netty 自定义 FastThreadLocal 替代 JDK 原生 ThreadLocal,核心原因是 “优化原生 ThreadLocal 的性能瓶颈”,解决原生 ThreadLocal 在高并发场景下的查找效率低、内存占用高等问题,具体差异如下:​
(1)原生 ThreadLocal 的性能瓶颈​
哈希表查找效率低:​
原生 ThreadLocalMap 是哈希表结构,key 为 ThreadLocal 对象,查找时需计算哈希值,若存在哈希冲突,需通过 “开放地址法” 线性探测,高并发下冲突概率高,查找耗时;​
内存占用高:​
ThreadLocalMap 的 Entry 数组默认初始容量为 16,扩容阈值为 2/3,扩容时需创建新数组并复制元素,频繁扩容会增加内存开销;​
过期 Entry 清理不及时:​
原生 ThreadLocalMap 仅在 get ()、set ()、remove () 时清理过期 Entry(key 为 null 的 Entry),若长期不调用这些方法,过期 Entry 会堆积,浪费内存且影响查找效率。​
(2)FastThreadLocal 的优化设计​
数组索引直接定位,无需哈希计算:​
FastThreadLocal 为每个实例分配一个唯一的 “索引值(index)”,通过 InternalThreadLocalMap(Netty 自定义的 ThreadLocalMap)存储变量副本,InternalThreadLocalMap 内部使用数组存储,变量副本直接存在数组的 index 位置;​
查找时无需计算哈希值,直接通过 index 定位数组元素,时间复杂度为 O (1),远快于原生 ThreadLocal 的哈希查找。​
数组动态扩容,内存高效:​
InternalThreadLocalMap 的数组初始容量较小,且仅在需要时动态扩容(按需分配索引),避免原生 ThreadLocalMap 初始容量过大导致的内存浪费;​
例如:若仅创建 3 个 FastThreadLocal 实例,数组仅需 3 个位置,无需像原生 ThreadLocalMap 那样初始占用 16 个位置。​
主动清理过期 Entry,避免内存泄漏:​
Netty 的 FastThreadLocalThread(Netty 自定义的线程类)在执行完任务后,会主动调用 FastThreadLocal.removeAll() 方法,清理当前线程 InternalThreadLocalMap 中的所有变量副本,彻底避免内存泄漏,无需依赖用户手动调用 remove ();​
原生 ThreadLocal 需用户手动调用 remove (),否则存在内存泄漏风险。​
支持批量清理,适配 Netty 线程模型:​
Netty 采用 “单线程事件循环模型”(EventLoop),线程长期存活且处理大量任务,FastThreadLocal 的批量清理机制(removeAll ())能确保线程复用过程中无数据污染,适配 Netty 的高并发场景;​
原生 ThreadLocal 若未手动清理,线程池复用会导致数据污染,无法满足 Netty 的可靠性要求。​
(3)适用场景匹配​
Netty 作为高性能网络框架,需处理百万级并发连接,对线程本地存储的查找效率、内存占用、清理机制要求极高,FastThreadLocal 的优化设计恰好匹配这些需求,而原生 ThreadLocal 的性能和可靠性无法满足 Netty 的高并发场景。

13.什么是 Java 的 TransmittableThreadLocal?​

TransmittableThreadLocal(TTL)是阿里巴巴开源的 Java 工具类(依赖 com.alibaba:transmittable-thread-local),扩展了 InheritableThreadLocal 的功能,核心特性是 “支持线程池环境下父子线程的 ThreadLocal 变量传递”,解决了 InheritableThreadLocal 无法应对线程池复用的问题。​
(1)解决的核心问题​
InheritableThreadLocal 的局限性:​
InheritableThreadLocal 仅在 “子线程创建时” 复制父线程的变量副本,而线程池中的线程是预先创建的(如核心线程),子线程(线程池线程)创建时父线程可能未设置变量,或变量已更新,导致子线程无法获取最新的父线程变量;​
例如:父线程在线程池线程创建后设置 inheritableTl.set("newValue"),线程池线程执行任务时,inheritableTl.get() 仍获取到线程创建时的旧值,无法拿到 “newValue”。​
TransmittableThreadLocal 的解决方案:​
TTL 通过 “在任务提交到线程池时,捕获父线程的 TTL 变量副本;线程池线程执行任务前,将捕获的副本注入线程;任务执行后,清理注入的副本” 的流程,实现线程池环境下的变量传递。​
(2)核心实现流程​
以线程池提交 Runnable 任务为例,TTL 的执行流程如下:​
任务提交时(父线程):​
调用 TTL.wrap(Runnable runnable) 方法,包装 Runnable 任务;​
包装过程中,捕获父线程所有 TransmittableThreadLocal 的变量副本,存储在 Transmitter 中。​
任务执行前(子线程 / 线程池线程):​
线程池线程执行包装后的 Runnable 时,Transmitter 先保存子线程当前的 TTL 变量副本(备份);​
将父线程捕获的 TTL 变量副本注入子线程的 TTL 中,确保子线程能获取父线程的最新变量。​
任务执行后(子线程 / 线程池线程):​
任务执行完成后,Transmitter 恢复子线程的 TTL 变量副本(将步骤 2 中的备份还原),避免子线程复用导致的数据污染。​
(3)适用场景​
线程池环境下需传递上下文数据的场景,如:​
分布式追踪:父线程的 TraceId 传递给线程池线程,确保分布式链路追踪的完整性;​
用户上下文:Web 服务中,父线程的用户登录信息(如 userId)传递给线程池线程,确保异步任务能获取用户信息;​
配置传递:父线程的动态配置(如限流阈值)传递给线程池线程,确保异步任务使用最新配置。​
(4)使用方式​
引入依赖:​

<dependency>​
    <groupId>com.alibaba</groupId>​
    <artifactId>transmittable-thread-local</artifactId>​
    <version>最新版本</version>​
</dependency>​
​

替换 ThreadLocal 为 TransmittableThreadLocal:​

private static final TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();​


包装线程池任务:​

ExecutorService executor = Executors.newFixedThreadPool(5);​
// 包装 Runnable 任务​
executor.submit(TtlRunnable.get(() -> {​
    String value = ttl.get(); // 能获取父线程设置的 value​
    // 业务逻辑​
}));​

14.Java 中 Thread.sleep 和 Thread.yield 的区别?​

Thread.sleep 和 Thread.yield 都是 Java 中用于 “让渡 CPU 使用权” 的方法,但二者的作用机制、适用场景和对线程状态的影响有显著区别:​
作用机制不同:​
Thread.sleep (long millis):让当前线程 “休眠指定时间”,期间主动放弃 CPU 使用权,进入 TIMED_WAITING 状态;休眠时间结束后,线程重新进入 RUNNABLE 状态,等待 CPU 调度;​
Thread.yield ():让当前线程 “临时放弃 CPU 使用权”,不进入阻塞状态,而是直接从 RUNNING 状态切换为 RUNNABLE 状态,重新加入 CPU 调度队列,与其他 RUNNABLE 状态的线程竞争 CPU 时间片;是否能重新获取 CPU 取决于线程调度器,可能立即再次获取 CPU。​
对线程状态的影响不同:​
Thread.sleep ():导致线程状态从 RUNNING → TIMED_WAITING(休眠期间)→ RUNNABLE(休眠结束);​
Thread.yield ():线程状态始终在 RUNNABLE 状态内,仅从 “正在执行” 切换为 “等待调度”,无阻塞状态切换。​
是否释放锁不同:​
Thread.sleep ():休眠期间不会释放当前线程持有的锁(如 synchronized 锁、ReentrantLock 锁),若线程持有锁,其他线程仍无法获取锁;​
Thread.yield ():让渡 CPU 时也不会释放持有的锁,仅放弃 CPU 使用权,锁仍由当前线程持有。​
使用场景不同:​
Thread.sleep ():用于 “需要精确控制线程暂停时间” 的场景,如定时任务(每隔 1 秒执行一次)、模拟耗时操作(如测试时模拟接口延迟);​
Thread.yield ():用于 “降低当前线程优先级,给其他同优先级或更高优先级线程机会” 的场景,如循环执行的任务中,避免单个线程长期占用 CPU(如后台统计任务,定期 yield 让其他任务执行)。​
平台依赖性不同:​
Thread.sleep ():休眠时间受操作系统时钟精度影响,实际休眠时间可能略长于指定时间,但总体可控;​
Thread.yield ():完全依赖操作系统的线程调度器,不同平台(如 Windows、Linux)的调度策略不同,可能存在 “调用 yield () 后线程仍继续执行” 的情况,无法保证让渡效果。

15.Java 中 Thread.sleep (0) 的作用是什么?​

Thread.sleep (0) 看似 “休眠 0 毫秒”,实际作用是 “触发线程调度器重新调度 CPU”,让当前线程主动放弃 CPU 使用权,给其他 RUNNABLE 状态的线程竞争 CPU 的机会,具体原理和作用如下:​
(1)核心原理​
Thread.sleep (0) 调用后,当前线程会从 RUNNING 状态切换为 TIMED_WAITING 状态,但由于休眠时间为 0,立即切换回 RUNNABLE 状态;​
这个 “状态切换” 的过程会触发操作系统的线程调度器重新评估所有 RUNNABLE 状态的线程,根据线程优先级和调度策略,重新分配 CPU 时间片;​
若存在其他优先级相同或更高的 RUNNABLE 线程,调度器可能将 CPU 分配给其他线程;若不存在其他 RUNNABLE 线程,当前线程会立即重新获取 CPU。​
(2)实际作用​
强制线程调度器重新调度:​
在单 CPU 环境下,若当前线程长期占用 CPU(如无阻塞的循环),其他线程会一直处于等待状态;调用 Thread.sleep (0) 可强制调度器重新分配 CPU,给其他线程执行机会,避免 “线程饥饿”;​
例如:循环处理任务的线程,每次循环末尾调用 Thread.sleep (0),确保其他线程能获得 CPU 时间片。​
触发线程优先级生效:​
部分操作系统的线程调度器对 “相同优先级线程” 采用 “时间片轮转” 策略,但可能存在 “当前线程时间片未用完时,其他同优先级线程无法获取 CPU” 的情况;​
Thread.sleep (0) 可强制当前线程放弃剩余时间片,重新加入调度队列,让其他同优先级线程有机会执行,确保优先级策略生效。​
调试时辅助线程切换:​
调试多线程程序时,若某线程执行过快,难以观察其他线程的执行过程,可在该线程中插入 Thread.sleep (0),触发线程切换,便于观察其他线程的行为。

16.Java 中的 wait、notify 和 notifyAll 方法有什么作用?


wait、notify、notifyAll 是 Object 类的 native 方法,用于实现 “线程间的协作通信”,通常结合 synchronized 锁使用,解决多线程环境下 “线程等待特定条件” 和 “条件满足后唤醒线程” 的问题,三者的作用和使用规则如下:​
(1)wait () 方法​
作用:让当前线程 “释放持有的锁,进入等待状态”,直到被其他线程调用 notify () 或 notifyAll () 唤醒,或等待时间超时(重载方法 wait (long timeout))。​
核心逻辑:​
调用 wait () 前,当前线程必须持有对象的 synchronized 锁(否则抛出 IllegalMonitorStateException);​
调用后,线程释放锁,从 RUNNING 状态切换为 WAITING(或 TIMED_WAITING,若指定超时时间),进入该对象的 “等待队列(Wait Set)”;​
线程被唤醒后,需重新竞争对象的 synchronized 锁,获取锁后才能继续执行 wait () 之后的代码。​
使用场景:线程需等待特定条件满足才能执行时,如生产者 - 消费者模型中,消费者线程等待 “队列非空” 条件,调用 wait () 进入等待状态。​
(2)notify () 方法​
作用:唤醒 “当前对象等待队列(Wait Set)中任意一个线程”,让其从 WAITING/TIMED_WAITING 状态切换为 RUNNABLE 状态,参与锁竞争。​
核心逻辑:​
调用 notify () 前,当前线程必须持有对象的 synchronized 锁(否则抛出 IllegalMonitorStateException);​
调用后,仅唤醒等待队列中的一个线程(具体唤醒哪个线程由 JVM 调度策略决定,通常是最早进入队列的线程);​
被唤醒的线程不会立即执行,需等待当前线程释放锁后,重新竞争锁,获取锁后才能继续执行。​
使用场景:特定条件满足后,只需唤醒一个等待线程即可,如生产者 - 消费者模型中,生产者向队列添加一个元素后,调用 notify () 唤醒一个等待的消费者线程。​
(3)notifyAll () 方法​
作用:唤醒 “当前对象等待队列(Wait Set)中的所有线程”,让所有等待线程从 WAITING/TIMED_WAITING 状态切换为 RUNNABLE 状态,参与锁竞争。​
核心逻辑:​
调用规则与 notify () 一致,需持有对象的 synchronized 锁;​
调用后,等待队列中的所有线程都会被唤醒,所有线程共同竞争对象锁,最终仅一个线程能获取锁并执行,其他线程继续等待锁。​
使用场景:特定条件满足后,需唤醒所有等待线程,如生产者 - 消费者模型中,队列从 “满” 变为 “非满” 时,调用 notifyAll () 唤醒所有等待的生产者线程,避免部分生产者线程长期饥饿。​
(4)三者的核心使用规则​
必须结合 synchronized 锁:wait、notify、notifyAll 依赖对象的 synchronized 锁实现,调用前必须持有锁,否则抛出 IllegalMonitorStateException;​
wait () 释放锁,notify/notifyAll 不释放锁:wait () 调用时会主动释放锁,而 notify/notifyAll 调用后,当前线程仍持有锁,需等待当前同步块执行完毕后才释放;​
唤醒后需重新检查条件:线程被唤醒后,之前等待的条件可能已发生变化(如其他线程已修改条件),因此需在循环中调用 wait (),唤醒后重新检查条件,避免 “虚假唤醒”;​
示例(正确写法):​

​
synchronized (lockObj) {​
    while (!condition) { // 循环检查条件,避免虚假唤醒​
        lockObj.wait();​
    }​
    // 条件满足后的业务逻辑​
}​

17.Java 中什么情况会导致死锁?如何避免?​

死锁是多线程环境下的严重问题,指 “两个或多个线程互相持有对方需要的锁,且均不释放自己的锁,导致所有线程永久阻塞”,具体产生条件和避免方法如下:​
(1)导致死锁的 4 个必要条件​
死锁的产生必须同时满足以下 4 个条件,缺一不可:​
互斥条件:线程对获取的锁具有 “独占性”,即同一时间仅一个线程能持有该锁,其他线程需等待;​
持有并等待条件:线程持有一个锁后,又尝试获取其他线程持有的锁,且在获取新锁期间不释放已持有的锁;​
不可剥夺条件:线程持有的锁只能由自身主动释放,其他线程无法强制剥夺;​
循环等待条件:多个线程形成 “循环依赖”,如线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1,形成循环。​
(2)常见的死锁场景​
多锁嵌套场景:线程在同步块中嵌套获取多个锁,且获取顺序不一致;​
示例:​

​
// 线程 A​
synchronized (lock1) {​
    synchronized (lock2) { // 持有 lock1,等待 lock2​
        // 业务逻辑​
    }​
}​
​
// 线程 B​
synchronized (lock2) {​
    synchronized (lock1) { // 持有 lock2,等待 lock1​
        // 业务逻辑​
    }​
}​
​

线程池任务依赖场景:线程池中的任务 A 等待任务 B 的结果,任务 B 又等待任务 A 的结果,且二者均持有锁;​
资源交叉依赖场景:多个线程依赖不同的资源(如数据库连接、文件句柄),且资源获取顺序混乱。​
(3)避免死锁的方法​
避免死锁的核心思路是 “破坏死锁的 4 个必要条件中的任意一个或多个”,常见方法如下:​
固定锁的获取顺序:破坏 “循环等待条件”,所有线程获取多把锁时,严格按照统一的顺序(如按锁对象的哈希值从小到大)获取,避免循环依赖;​
示例(修复上述死锁场景):​

// 线程 A 和线程 B 均按“lock1 → lock2”的顺序获取锁​
if (lock1.hashCode() < lock2.hashCode()) {​
    synchronized (lock1) {​
        synchronized (lock2) { /* 业务逻辑 */ }​
    }​
} else {​
    synchronized (lock2) {​
        synchronized (lock1) { /* 业务逻辑 */ }​
    }​
}​


一次性获取所有锁:破坏 “持有并等待条件”,线程在执行前一次性获取所有需要的锁,若无法获取所有锁,则释放已获取的锁,重新尝试;​
可通过 ReentrantLock.tryLock(long timeout, TimeUnit unit) 实现:尝试在指定时间内获取所有锁,超时则释放已获取的锁。​
使用可中断锁:破坏 “不可剥夺条件”,使用支持中断的锁(如 ReentrantLock),线程在等待锁时可响应中断,若检测到死锁风险,可中断线程并释放持有的锁;​
示例:通过 lock.lockInterruptibly() 让线程在等待锁时可被中断。​
使用定时锁:破坏 “持有并等待条件”,线程获取锁时设置超时时间(如 tryLock(100, TimeUnit.MILLISECONDS)),若超时未获取到锁,则释放已持有的锁,避免永久等待;​
减少锁的粒度和持有时间:通过拆分锁(如 ConcurrentHashMap 的分段锁)、缩短同步块范围,减少线程持有锁的时间和对多锁的依赖,降低死锁概率;​
使用死锁检测工具:在开发和测试阶段,通过 JDK 自带工具(如 jstack、jconsole)检测死锁,提前定位问题;​
示例:执行 jstack <进程ID>,若存在死锁,会输出死锁线程的栈信息和持有的锁。​

18.Java 中 volatile 关键字的作用是什么?


volatile 是 Java 中的轻量级同步关键字,主要用于解决多线程环境下 “共享变量的可见性和有序性问题”,但不保证原子性,核心作用如下:​
(1)保证共享变量的可见性​
可见性问题:多线程环境下,线程会将主内存中的共享变量加载到自身的工作内存中操作,修改后若未及时刷新回主内存,其他线程读取到的仍是旧值,导致数据不一致;​
volatile 的解决机制:​
线程修改 volatile 变量后,会立即将修改结果刷新到主内存,避免工作内存缓存导致的旧值残留;​
其他线程读取 volatile 变量时,会直接从主内存加载最新值,跳过工作内存的缓存,确保读取到的是最新修改结果;​
示例:​

private volatile boolean isRunning = true;​
​
// 线程 A:修改变量​
public void stop() {​
    isRunning = false; // 修改后立即刷新到主内存​
}​
​
// 线程 B:读取变量​
public void run() {​
    while (isRunning) { // 每次读取都从主内存加载最新值​
        // 业务逻辑​
    }​
}​


若 isRunning 不使用 volatile,线程 B 可能长期读取到工作内存中的旧值(true),即使线程 A 将其改为 false,线程 B 仍无法感知,导致循环无法停止。​
(2)禁止指令重排序​
指令重排序问题:编译器或 CPU 为提升性能,会对无数据依赖的指令进行重排序,单线程下无影响,但多线程下可能导致逻辑错误(如双重检查单例模式中的半初始化问题);​
volatile 的解决机制:JVM 会为 volatile 变量的读写操作插入 “内存屏障”,禁止特定类型的指令重排序:​
写屏障(StoreBarrier):在 volatile 变量的写操作后插入,禁止写操作与后续的读写指令重排序,确保写操作完成后才执行后续指令;​
读屏障(LoadBarrier):在 volatile 变量的读操作前插入,禁止读操作与之前的读写指令重排序,确保读操作前已完成之前的指令;​
示例(双重检查单例模式):​

private static volatile Singleton instance; // 必须用 volatile 禁止重排序​
​
public static Singleton getInstance() {​
    if (instance == null) { // 第一次检查​
        synchronized (Singleton.class) {​
            if (instance == null) { // 第二次检查​
                instance = new Singleton(); // 若无 volatile,可能重排序为“先分配内存→设置引用→初始化对象”​
            }​
        }​
    }​
    return instance;​
}​
​

若 instance 不使用 volatile,new Singleton() 可能被重排序为 “先设置 instance 引用(非 null),再初始化对象”,导致其他线程获取到未初始化的 instance,引发空指针异常。​
(3)不保证原子性​
原子性问题:volatile 仅保证单个变量的读写操作的可见性和有序性,但无法保证 “复合操作” 的原子性,如 i++(包含读取、修改、写入三个步骤);​
示例:​

private volatile int i = 0;​
​
// 多线程调用 increment(),最终 i 的值可能小于预期(如 1000 线程各调用 1 次,结果可能是 998)​
public void increment() {​
    i++; // 复合操作,volatile 无法保证原子性​
}​
​

若需保证原子性,需结合 synchronized、ReentrantLock 或 AtomicInteger 等原子类使用。​
(4)适用场景​
存储线程间的状态标记(如 isRunning、isStop);​
双重检查单例模式中的实例变量;​
确保多线程下变量的最新值可见(如配置参数的动态更新)。

19.什么是 Java 中的 ABA 问题?如何解决?​

ABA 问题是 CAS(Compare-And-Swap)操作的典型缺陷,指 “线程在执行 CAS 时,发现变量的值从 A 变为 B 后又变回 A,此时 CAS 误判变量未被修改,导致错误的更新操作”,具体原理和解决方法如下:​
(1)ABA 问题的原理​
以 AtomicInteger 的 CAS 操作为例,ABA 问题的发生流程如下:​
初始状态:主内存中变量 x 的值为 A,线程 1 读取 x 的值为 A(预期值),计划将 x 改为 C;​
线程切换:线程 1 被挂起,线程 2 开始执行,将 x 的值从 A 改为 B;​
再次修改:线程 2 继续执行,又将 x 的值从 B 改回 A;​
CAS 执行:线程 1 恢复执行,执行 CAS 操作(比较 x 当前值与预期值 A),发现二者相等,于是将 x 改为 C;​
问题核心:线程 1 误以为 x 未被修改(始终为 A),但实际上 x 已被线程 2 修改过(A→B→A),若变量的修改会影响业务逻辑(如链表的节点引用),则会导致数据不一致。​
(2)ABA 问题的实际影响​
在简单的数值场景(如计数器)中,ABA 问题通常无严重影响(只要最终值正确即可),但在 “引用类型场景” 中,可能导致严重错误,例如:​
链表节点删除场景:线程 1 计划删除链表中的节点 A(CAS 修改节点 A 的前驱节点的引用),但线程 2 先删除节点 A,再添加一个新的节点 A(值相同,地址不同),线程 1 执行 CAS 时误判节点未变,导致错误删除新节点 A,破坏链表结构。​
(3)解决 ABA 问题的方法​
解决 ABA 问题的核心思路是 “为变量增加‘版本号’或‘时间戳’,让变量的修改可追溯,避免仅通过值判断是否被修改”,常见方法如下:​
使用 AtomicStampedReference(版本号机制):​
AtomicStampedReference 是 Java 并发包提供的类,支持为变量绑定一个 “版本号(stamp)”,CAS 操作时不仅比较变量的值,还比较版本号;​
每次修改变量时,版本号都会递增,即使变量的值从 A 变为 B 再变回 A,版本号也会不同,CAS 会检测到版本号变化,避免误判;​
示例:​

// 初始化:变量值为 A,版本号为 1​
AtomicStampedReference<String> stampedRef = new AtomicStampedReference<>("A", 1);​
​
// 线程 1 执行 CAS:预期值 A,新版本号 2,目标值 C​
int oldStamp = stampedRef.getStamp(); // 获取当前版本号 1​
boolean success = stampedRef.compareAndSet("A", "C", oldStamp, oldStamp + 1);​
​
// 若线程 2 已修改变量(A→B→A),则版本号会大于 1,CAS 会返回 false,避免 ABA 问题​
​

使用 AtomicMarkableReference(标记机制):​
AtomicMarkableReference 为变量绑定一个 “布尔标记(mark)”,而非版本号,用于标记变量 “是否被修改过”;​
每次修改变量时,将标记设为 true(表示已修改),CAS 操作时同时比较变量值和标记,若标记为 true,则拒绝更新;​
适用场景:只需判断变量 “是否被修改过”,无需记录修改次数的场景(如简单的节点引用更新)。​
避免变量重复使用:​
在引用类型场景中,避免重复创建值相同的对象(如链表节点),确保每个对象的引用唯一,从根本上避免 “值相同但对象不同” 的情况,减少 ABA 问题的发生概率。

0 条评论

发布
问题