ThreadLocal与JMM
# ThreadLocal与JMM
ThreadLocal 是 Java 中用于实现线程封闭(Thread Confinement)的核心工具,它通过为每个线程提供独立的变量副本,避免多线程共享变量导致的并发问题。
# ThreadLocal的作用
ThreadLocal的作用是为每个线程提供独立的变量副本,避免多线程间的共享问题。常见的应用场景可能有用户会话信息、数据库连接管理、防止参数传递等。比如在Web应用中,每个请求可能对应一个线程,用ThreadLocal保存用户信息,方便在方法间传递,而不用显式传参。
# 核心使用场景
# 线程安全的上下文传递
场景:在多层方法调用中传递上下文信息(如用户身份、事务ID),避免显式传参。
示例:
- Web 框架中存储用户会话信息(如 Spring Security 的
SecurityContextHolder
)。 - 数据库连接管理(如某些 ORM 框架用 ThreadLocal 绑定连接,确保同一事务使用同一连接)。
# 性能优化
场景:复用线程内频繁创建的高开销对象(如缓冲区)。
private static final ThreadLocal<ByteBuffer> buffer =
ThreadLocal.withInitial(() -> ByteBuffer.allocate(1024));
2
# ThreadLocal 的内存泄漏问题与解决方案
- 内存泄漏的根本原因
- 数据结构:每个线程内部维护一个
ThreadLocalMap
,其Entry
的 Key 是 ThreadLocal 的弱引用,Value 是强引用
。 - 问题:
- 如果 ThreadLocal 实例被回收(如置为
null
),Key 变为null
,但 Value 仍被强引用,导致无法回收。 - 线程池场景下,线程长期存活,累积的无效 Entry 会导致内存泄漏。
- 如果 ThreadLocal 实例被回收(如置为
ThreadLocalMap中的key是弱引用,而value是强引用。
什么是强引用,什么是弱引用。记不清楚,就复习一遍。
- 解决方案
强制调用
remove()
:使用完 ThreadLocal 后,必须调用
remove()
1清理当前线程的 Entry。
try { threadLocal.set(data); // ... 业务逻辑 } finally { threadLocal.remove(); // 确保清理 }
1
2
3
4
5
6避免使用全局静态 ThreadLocal: 非静态的 ThreadLocal 实例应随着对象的生命周期结束而自然释放。
# 一个例子-示例代码
# 场景:模拟用户登录信息存储
public class UserContext {
// 定义 ThreadLocal(推荐静态 final)
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
// 设置当前用户
public static void setUser(User user) {
currentUser.set(user);
}
// 获取当前用户
public static User getUser() {
return currentUser.get();
}
// 清理用户信息
public static void clear() {
currentUser.remove(); // 必须手动清理
}
// 用户类
public static class User {
private final String name;
public User(String name) { this.name = name; }
public String getName() { return name; }
}
public static void main(String[] args) {
// 模拟请求处理
Runnable task = () -> {
try {
// 1. 设置当前用户
setUser(new User(Thread.currentThread().getName()));
// 2. 执行业务逻辑
System.out.println("当前用户: " + getUser().getName());
// 3. 模拟后续操作(可能调用其他方法)
} finally {
// 4. 强制清理,避免内存泄漏
clear();
}
};
// 启动两个线程
new Thread(task).start(); // 输出:当前用户: Thread-0
new Thread(task).start(); // 输出:当前用户: Thread-1
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 代码解析
- ThreadLocal 定义:
currentUser
是静态 final 变量,确保全局唯一性。 - 资源绑定与清理:
setUser()
:将用户信息绑定到当前线程。getUser()
:从当前线程获取用户信息。clear()
:在finally
块中调用remove()
,确保线程池中线程重用时不会残留旧数据。
- 内存泄漏防御:
finally
块中的clear()
是关键,即使业务代码抛出异常,也能保证清理。
# JMM的三大特性
Java 内存模型(JMM)中,原子性、可见性、有序性 是并发编程的核心问题。
# 三大特性的诞生
现代计算机的硬件架构(如 CPU 缓存、多核 CPU、指令重排序优化)导致以下问题:
- 可见性问题:一个线程对共享变量的修改,其他线程可能无法立即看到。
- 有序性问题:编译器或 CPU 可能对指令进行重排序优化,导致代码执行顺序与预期不符。
现代计算机系统通常拥有多个层级的缓存(L1, L2, L3等),为了提高性能,每个CPU核心都有自己的缓存。当一个线程在某个核心上修改了一个共享变量时,这个修改首先会反映在其本地缓存中,而不会立即写回到主内存。这就可能导致运行在不同核心上的其他线程无法立即看到这个更新,因为它们访问的是自己核心上的缓存副本。所以说,多级缓存导致了可见性问题。
而有序性问题则是因为,提高多核cpu的性能而导致的。
比如CPU-B想要读取数据D的时候,还需要等待CPU-A将数据D写回主存,那么这种行为是难以忍受的,因此计算机科学家们做了一些优化,怎么优化呢?整体思想上就是将同步改成异步。比如CPU-B想要读取数据D的时候,发现D正在被其他的CPU修改。那么此时CPU-B可以注册一个读取D的消息,自己回头去做其他事情。其他CPU写回数据D后,响应了这个注册消息。此时CPU-B发现消息被响应后,再去读取地,这样就能够有效的提升效率。但是对于CPU-B来说,程序看上去就不是顺序执行的了,可能会出现先运行后面的指令,再返回头去运行前面的指令这种行为。这就体现出了一种指令重排序。
而原子性则是因为机器本身的指令导致的。因为有些功能可能需要多个指令才能实现,这里就不展开了。你只需要记住,原子性指一个操作或多个操作要么全部执行且不被打断,要么完全不执行。若某个操作需要多个步骤完成,且这些步骤可能被线程切换打断,则称为非原子操作。
JMM 定义了线程与主内存交互的原子操作(如
read
、load
、use
、assign
、store
、write
),并规定这些操作必须按顺序成对出现。
计算机科学家们,设计了硬件内存模型,其目标是为了让汇编代码能够运行在一个具有一致性的内存视图上。保证数据的一致性。
# JMM的诞生
随着高级语言的流行,工程师们开始设计编程语言级别的内存模型。这是为了能够使用该语言进行编程的时候,也能拥有一个一致性的内存视图。于是在硬件内存模型之上,还存在着为编程语言设计的内存模型。比如Java内存模型就屏蔽掉了各种硬件和操作系统的内存访问差异
,实现了让java程序能够在各种硬件平台下都能够按照预期的方式来运行。
也就是说JMM是为了保证在各个硬件平台上(各个操作系统上),在保证数据一致的情况下运行,或者说Java内存模型是对物理内存的一种模拟。
现在你能回答为什么,需要定义JMM(Java内存模型或者说 线程模型)了吗?
答:我们在开发时如果直接调用操作系统的接口来创建和回收线程,不是更加直接吗?这个问题的答案其实很容易理解,就像我们现在为什么不常用汇编语言来进行开发,而是使用更加简单,更加容易上手的高级语言一样。这是一种自下而上的抽象方式。JVM线程的对不同操作系统的原生线程进行了高级抽象,使开发者一般情况下可以不用关注下层的细节,而只要专注上层的开发就行了。但是在学习过程中,我们需要秉持知其然并知其所以然的态度,就要去理解这种抽象方式。这也有助于我们在将来自己进行一些设计的时候,能够调用前人的思想理解了什么是线程模型,为什么要线程模型。
以下是它们的一些介绍及如何通过 synchronized
和 volatile
保证的详细分析:
# 原子性(Atomicity)
定义:一个操作不可被中断,要么全部执行成功,要么完全不执行。
非原子操作示例(如 i++
):
public class AtomicityExample {
private int count = 0;
public void increment() {
count++; // 非原子操作(实际是三步:读值、+1、写回)
}
}
2
3
4
5
6
7
多线程调用 increment()
会导致结果不一致(如两个线程同时读 count=5
,最终结果可能是 6
而非 7
)。
# 保证原子性
使用synchronized,加锁
使用原子类
使用 AtomicInteger
等原子类(底层基于 CAS):
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
2
3
4
volatile
不能保证原子性,它只能保证单次读/写的原子性(如
volatile int x = 10
的赋值是原子的),但x++
仍是非原子的。
# 可见性(Visibility)
定义
一个线程修改共享变量后,其他线程能立即看到修改后的值。
可见性问题代码示例:
public class VisibilityExample {
private boolean flag = false; // 非 volatile
public void writer() {
flag = true; // 线程 A 修改
}
public void reader() {
while (!flag); // 线程 B 可能永远无法退出循环
System.out.println("Flag is now true");
}
}
2
3
4
5
6
7
8
9
10
11
12
由于线程 B 的本地缓存未感知到 flag
的修改,导致死循环。
# 保证可见性
使用 volatile 关键字,强制每次读写直接操作主内存,禁用本地缓存:
private volatile boolean flag = false;
或者通过加锁,synchronized,的方式实现
# 有序性(Ordering)
定义
程序执行的顺序按照代码的先后顺序执行(禁止指令重排序)。
代码体现
指令重排序导致的问题(双重检查锁的单例模式):
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 可能发生指令重排序
}
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
new Singleton()
的指令可能被重排序为:
- 分配内存空间
- 将引用指向内存(此时
instance != null
) - 初始化对象
若线程 A 执行到第 2 步时,线程 B 可能拿到未初始化的
instance
。
# 保证有序性
volatile
禁止 JVM 对 volatile
变量的读写指令进行重排序:
private static volatile Singleton instance; // 修复双重检查锁问题
synchronized
通过锁的互斥性保证临界区内代码的顺序执行(但锁外代码仍可能被重排序)。
# 加锁过程是如何保证3个特性的
# volatile的实现
那么volatile是如何保证可见性跟指令重排的。
Volatile关键字下层的实现保证了若一个被volatile修饰的变量被修改,那么总是会主动
的写入内存。若要读取一个被volatile修饰的变量,那么总是从主存中读取。这样的话相当于操作被volatile修饰的变量时,都是直接去读写主存,这样就能够解决上面的可见性问题。
而指令重排,则是volatile是因为内部做了内存屏障所以让程序运行到有volatile关键字修饰的变量操作时强制顺序执行,而不会出现指令重排序的情况。
# synchronized的实现
关于原子性的实现:
- 互斥性(Mutual Exclusion):锁确保同一时刻只有一个线程能进入临界区(Critical Section),操作共享资源时不会被打断。
- 临界区内的操作不可分割:线程在临界区内的所有操作会被视为一个原子操作。
关于可见性的实现:
在同步代码块中,在monitor的基础上当读写变量时,会隐式的执行上文提到的内存lock指令,并清空工作内存中该。变量的值需要使用该变量时,必须从主存中获取。同理也会隐式的执行内存unlock指令,将修改过的变量刷新回主存,这样就也能够解决可见性问题。
总结下就是:
- 锁的获取(Acquire):线程进入
synchronized
块或调用lock()
时,会强制从主内存重新加载共享变量的最新值(清空工作内存的旧值)。 - 锁的释放(Release):线程退出
synchronized
块或调用unlock()
时,会将工作内存中对共享变量的修改强制刷新到主内存。
锁通过插入 内存屏障 实现以下效果:
- 禁止重排序:临界区内的代码不会被编译器或 CPU 重排序到临界区外。
- 强制同步:确保共享变量的修改对其他线程立即可见。
关于原子性的实现:
- 程序顺序规则:单线程内,临界区内的代码顺序与程序代码顺序一致(编译器/CPU 不会对临界区内的代码进行破坏语义的重排序)。
- Happens-Before 规则:
- 锁的释放 Happens-Before 后续锁的获取:线程 A 释放锁后,线程 B 获取锁时能看到线程 A 的所有修改。
- 临界区内的操作不会被重排序到临界区外。
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待