JUC面试题
# 谈谈你对ThreadLocal的理解
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享,不同方法可以共享同一个 ThreadLocal
变量。
底层原理
# ThreadLocal的底层原理实现吗?
ThreadLocal其实是线程Thread类在内部维护了一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
# ThreadLocal会导致内存溢出 (opens new window)
ThreadLocal 如果使用不当,确实可能导致内存泄漏,核心原因是 ThreadLocalMap 的 key 是弱引用,而 value 是强引用,加上线程复用(如线程池),无效的 Entry 无法被及时清理。
ThreadLocal 的数据存储在 线程的 ThreadLocalMap 中,key 是 ThreadLocal 对象本身, 它是弱引用,会被 GC 回收变为 null,但 Value 仍被 Entry 强引用,导致无法回收。
当线程一直存在不被清除时,比如线程池复用时,ThreadLocalMap 会一直存在
ThreadLocal有两种情况导致OOM:
当 ThreadLocal 是局部变量,用完被清除,失去强引用,key 因弱引用被 GC 回收变为null,而value会一直存在导致OOM。
当ThreadLocal 是静态变量。虽然 key 不会被回收(静态变量是强引用),但如果线程复用时不调用 remove()
,多次 set()
会导致旧 value 无法释放(例如线程池任务中重复使用同一个 ThreadLocal)。
解决方法:
threadLocalMap有清除机制,会在调用set() / get() 时自动清除key为null的数据。 但是使用ThreadLocal 时通常把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
总:
不恰当使用 ThreadLocal
可能导致内存泄漏。主要原因在于其底层 ThreadLocalMap
的 key
是一个弱引用(WeakReference
)。弱引用的特性是:无论是否存在直接引用关系,只要 ThreadLocal
实例没有其他强引用关联,垃圾回收(GC)时 key
就会被回收。从而导致 key
变为 null
,但 value
仍被强引用关联,造成这块内存无法访问和回收,出现内存泄漏的问题
规避内存泄漏的两种方法:
扩大 ThreadLocal
变量的作用域(例如声明为 static
或全局变量),避免 ThreadLocal
实例被 GC 回收,不过若后续线程不再访问该 key
,value
仍会长期占用内存,最终可能导致内存溢出(OOM)。
在数据使用完成后主动调用remove方法清除 Entry
,这是在实际使用中最好的方式
# ThreadLocal有哪些使用场景
ThreadLocal是一种多线程隔离机制,通过多线程环境下对共享变量的副本存储,解决了线程安全问题,避免了多线程竞争加锁的开销。其使用场景包括:
线程的上下文传递
- 在处理请求的过程中保持用户特定的数据(如用户的登录信息)。通过
ThreadLocal
可以方便地在同一个线程内的不同方法调用之间共享这些数据,而不用担心线程安全问题。
- 在处理请求的过程中保持用户特定的数据(如用户的登录信息)。通过
数据库连接管理
- 在多线程应用中,每个线程可以使用
ThreadLocal
来独立管理自己的数据库连接,避免线程之间的竞争与冲突。如mybatis的sqlsession
- 在多线程应用中,每个线程可以使用
事务管理等
- 使用
ThreadLocal
可以让每个线程拥有独立的事务上下文,保证事务的隔离性。Spring 的TransactionSynchronizationManager
就使用ThreadLocal
来存储当前线程的事务资源(如数据库连接)。
- 使用
在使用ThreadLocall时,需要注意避免内存泄漏的问题。
# volatile关键字有什么用
volatile关键字有两个作用 可以保证多线程环境下,对共享变量的可见性, 可以通过增加内存屏障,去防止多个指令之间的重排序,
可见性就是当一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值, 可见性问题,是在多线程环境下,每个线程都有自己的工作内存(缓存),线程操作变量时,可能会先读取缓存中的副本,而不是直接访问主内存。这可能导致一个线程修改了变量,但另一个线程看不到最新值。
- 当一个线程修改
volatile
变量时,会立即将新值写回主内存。 - 其他线程读取该变量时,会强制从主内存重新加载最新值,而不是使用本地缓存。
指令重排序就是指令在编写的顺序和执行顺序是不一致的,为了提高性能,JVM 和 CPU 可能会对指令进行重排序(在不改变单线程执行结果的前提下)。但在多线程环境下,重排序可能导致线程安全问题(如单例模式的双重检查锁失效)。
- 通过内存屏障(Memory Barrier) 禁止 JVM 对
volatile
变量的读写操作进行重排序。 - 确保
volatile
变量的写操作一定在读操作之前完成(happens-before
原则)。
# 如何理解线程安全
线程安全是指在多线程环境下,当多个线程同时访问共享资源时,程序能够正确、一致地工作。这要求对共享数据的访问必须是原子的、可见的和有序的。
原子性:一个线程访问共享资源时的操作不能被中断,如果被中断,可能会导致执行结果不一致的问题,CPU上下文切换是导致原子性问题的一个核心,jvm提供了synchronized关键字来解决原子性问题。 可见性:一个线程对共享变量的修改能够及时被其他线程看到,导致可见性问题的问题有很多,比如像CPU的高速缓存、CPU的指令重排序、编译器的指令重排序 有序性:程序编写的指令顺序和CPU最终运行的指令顺序可能不一致,这种现象称为指令重排序
可见性和有序性问题可以通过volatile关键字解决
实现线程安全的常见方法包括:使用同步机制、使用线程安全的数据结构。但是过度同步会影响性能,因此需要根据具体场景选择合适的线程安全策略
# 保证线程安全
- 在方法内使用,局部变量则是线程安全的
- 使用线程安全的ArrayList和LinkedList
# SimpleDateFormat 是线程安全的吗
SimpleDateFormat
内部维护了一个 Calendar
对象引用,用于存储和解析日期信息。当多个线程共享同一个 SimpleDateFormat
实例时,会出现以下问题:
- 共享
Calendar
引用:多个线程同时操作同一个Calendar
对象,导致数据竞争。 - 数据不一致:可能引发日期解析错误、格式化异常,甚至程序崩溃。
有四种方法可以解决这个问题 第一种把SimpleDateFormat定义成一个局部变量,每个线程调用这个方法的时候,都创建一个新的实例, 第二种我们可以使用ThreadLocal,把SimpleDateFormat变成一个线程私有的, 第三种加一个同步锁,在同一个时刻,只允许一个线程去操作SimpleDateFormat, 第四种,在java8里面呢引入了一些线程安全的日期API,比如说像localDataTimer,DateTimeFormatter等等
# 什么是守护线程?
守护线程(Daemon Thread)是一种在后台运行的线程,它的生命周期依赖于非守护线程(即用户线程)。当所有的非守护线程结束时,JVM 会自动退出,并终止所有仍在运行的守护线程,即使它们还未完成任务
守护线程的创建方法和普通线程是一样的,只需要调用setDaemon方法设置参数为true就表示创建一个守护线程
守护线程通常用于执行非关键任务,如垃圾回收、日志记录等,这个场景的特点就是当jvm 的进程结束后,守护线程的本身就没有存在的 意义了,不能因为进行垃圾回收就阻止jvm进程无法结束。而守护线程不能用于线程池或者IO的一些任务场景,因为一旦jvm退出后,守护线程就会直接退出,导致任务还没有执行完成或者资源没有释放等问题。
# 伪共享的概念以及如何避免
由于CPU采用缓存行(Cache Line,64字节)加载数据(基于空间局部性原理),若多个线程修改同一缓存行中的不同变量(如变量X和Y),会触发缓存一致性协议(如MESI),导致其他CPU的缓存行失效,这种不必要的竞争称为伪共享,会显著降低并发性能。
解决办法有两个
- 使用对齐填充,读取的目标数据,小于64个字节时,可以增加一些无意义的成员变量来填充到64个字节,
- 在java8提供了@Contented的注解,被@Contented的注解标记的类或者字段,JVM会自动填充缓存行
# DCL单例模式为什么需要volatile关键字修饰
DCL单例模式(Double-Checked Locking)通过双重检验机制实现线程安全的延迟初始化:
- 第一次检查:判断
instance
是否已初始化(避免已初始化时进入同步块,提升性能)。 - 同步锁:若未初始化,通过
synchronized
防止多线程并发创建实例。 - 第二次检查:在同步块内再次检查
instance
(防止首次初始化时多个线程通过第一次检查后的重复创建)。
通过第二次检查的进程会执行 instance = new Singleton()
的初始化操作
但这个操作并非原子操作,实际会被JVM拆分为以下三条指令:
- 分配对象内存空间
- 初始化对象(调用构造函数)
- 将引用赋值给
instance
由于指令重排序(JVM优化),可能导致步骤3先于步骤2执行。此时若其他线程访问 instance
,会获取到未初始化的对象(非 null
但状态不一致)。
所以需要使用volatile关键字修饰instance变量,volatile会在底层使用了一个内存屏障机制,来去避免指令重排序,并强制线程每次访问 instance
时从主内存读取最新值。
# 请你说一下对Happens-Before的理解
Happens-Before 是 Java 内存模型(JMM)提供的一种内存可见性保证机制。在多线程环境下,由于指令重排序和CPU缓存一致性问题,可能导致一个线程对共享变量的修改对另一个线程不可见。JMM 通过 Happens-Before 关系,向开发者提供了一种跨线程的内存可见性约束,确保某些操作的执行结果对其他操作可见。
Happens-Before 的核心概念:
- 可见性保证:如果操作 A Happens-Before 操作 B,那么 A 的执行结果对 B 可见。
- 不约束执行顺序:Happens-Before 仅规定可见性,并不强制要求指令的实际执行顺序。只要不影响最终结果,JVM 仍然可以优化指令顺序。
JMM中存在的happy before规则,
- 程序顺序规则:同一线程中的每个操作 happens-before 于该线程中任意后续操作,也就i是不管怎么改变,单线程的执行结果不能改变。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C
- volatile变量规则:对一个 volatile 变量的写操作 happens-before 于任意后续对这个 volatile 变量的读操作
- 监视器锁规则:对一个锁的解锁 happens-before 于后续对这个锁的加锁操作
- 线程启动规则:线程 A 启动线程 B,那么线程 A 启动 B 之前的操作 happens-before 于线程 B 中的任意操作
- 线程终止规则:线程 A 中的任何操作都 happens-before 于其他 检测到线程 A 已经终止的 线程