前言
Java不秃,面试不慌!
欢迎来到这片 Java修炼场!这里没有枯燥的教科书,只有每日一更的 硬核知识+幽默吐槽,让你在欢笑中掌握 Java基础、算法、面试套路,摆脱“写代码如写诗、看代码如看天书”的困境。
记住: 代码会背叛你,但知识不会! 坚持积累,总有一天,HR会为你的八股文落泪,面试官会因你的算法沉默。
ThreadLocal的底层原理
想象一下,你的公司有一台超级复印机,每个员工(线程)都可以用它来存放自己的小笔记本数据)。但是,每个人的笔记本都不一样,而且只有自己能翻阅,其他人完全看不到。这台神奇的复印机就是 ThreadLocal,它能确保你的笔记本不会被其他员工偷看或误用。
1. 线程自己的小秘密
ThreadLocal 是 Java 提供的一种 线程本地存储 机制,简单来说,它允许你在一个线程内部存放自己的数据,而 不和别的线程分享。就像每个人都有自己的私人笔记本,里面记着自己专属的小秘密,别人无法偷看。
2. ThreadLocalMap:藏在线程里的私密抽屉
ThreadLocal 并不是一个存储数据的仓库,而是一个钥匙(key),它的核心秘密藏在 ThreadLocalMap 里面。
每个 Thread(线程) 都有一个自己的 ThreadLocalMap,这个 Map 的 key 是 ThreadLocal 对象,value 是你想存的数据,大致结构如下:
class Thread {
ThreadLocalMap threadLocals; // 每个线程都有一个 ThreadLocalMap
}
class ThreadLocal {
public void set(Object value) {
Thread currentThread = Thread.currentThread();
currentThread.threadLocals.put(this, value);
}
public Object get() {
Thread currentThread = Thread.currentThread();
return currentThread.threadLocals.get(this);
}
}
说白了,每个线程都带着自己的小抽屉(ThreadLocalMap),这个抽屉里放着你设置的东西(value),别人看不见,拿不到,甚至不知道你抽屉里有啥。
ThreadLocal 在线程池里用,会有内存泄漏?
问题:你的垃圾还在你桌子上
如果你用的是 线程池,就会遇到一个问题:线程不会被回收,但你的数据还留在 ThreadLocalMap 里,导致 内存泄漏。
这就像你的公司为了节约资源,员工(线程)不会辞职(销毁),而你用完的笔记本(数据)也没扔,桌子上的东西(ThreadLocalMap)越积越多,最后连自己都找不到键盘在哪儿了。
为什么会这样?
- ThreadLocalMap 是线程的一个属性,它被线程对象(Thread)强引用。
- Entry(键值对)里面的 key 是 ThreadLocal 的弱引用,但 value 仍然是强引用。
- 如果 ThreadLocal 对象被 GC 回收了,key 变成 null 了,但是 value 还留在 ThreadLocalMap 里,导致内存泄漏。
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("秘密数据");
// 这里 threadLocal 变量被置为 null,但数据还在 ThreadLocalMap 里
threadLocal = null;
// 线程不会马上结束,ThreadLocalMap 里的数据就一直在,导致泄漏!
解决方案
👉 用完一定要手动清理!
用完 ThreadLocal 之后,记得 调用 remove()
方法,清空数据,避免内存泄漏。
threadLocal.remove(); // 该扔的垃圾还是得扔
经典应用场景:数据库连接管理
想象你在餐厅吃饭(一个请求),你需要一套独立的餐具(数据库连接)。每个顾客(线程)都有自己的一套餐具,不会和别人共用,否则容易吃到别人的口水(数据错乱)。
ThreadLocal 可以完美解决这个问题!
每个线程都维护自己的数据库连接,而不是所有线程共用一个连接池,这样就不会产生并发问题。
public class DBConnectionManager {
private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
public static Connection getConnection() {
if (connectionHolder.get() == null) {
connectionHolder.set(createNewConnection());
}
return connectionHolder.get();
}
public static void closeConnection() {
try {
if (connectionHolder.get() != null) {
connectionHolder.get().close();
connectionHolder.remove();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
这个逻辑就像你在吃饭的时候,侍应生会在你的座位上放一套餐具(数据库连接),你吃完饭(请求完成)之后,餐具会被收走(remove),不会影响下一位顾客。
总结
- ThreadLocal 是一个线程局部变量存储机制,让数据只在当前线程可见,线程之间不会相互干扰。
- 底层是通过 ThreadLocalMap 来实现,每个线程都持有一个自己的 Map,存储着不同的 ThreadLocal 变量。
- 在线程池中使用 ThreadLocal 可能会导致内存泄漏,因为线程不会被销毁,ThreadLocalMap 里的数据不会自动清理。
- 使用完 ThreadLocal 后,一定要调用
remove()
方法,否则会有内存泄漏问题。 - 最经典的应用场景是数据库连接管理,确保每个线程持有自己的数据库连接,避免多个线程共用同一个连接导致数据错乱。
并发、并⾏、串⾏之间的区别:
想象你在一个餐厅里点餐,餐厅的运作方式可以有三种:串行、并发、并行。
1. 串行(Serial):排队等候,先来后到
就像你去一个只有 一个厨师 的小饭馆,他每次只能做一道菜。
- 你点了炒饭,厨师开始炒,别的菜只能等他炒完再做。
- 等炒饭做完,他才去做下一道菜,比如宫保鸡丁。
- 如果有10道菜,那就只能一步一步来,没有任何重叠。
特点:任务必须一个接一个地完成,不能同时进行。
void serialExecution() {
taskA(); // 先执行任务A
taskB(); // A做完后再执行B
}
2. 并行(Parallel):多个任务同时进行,互不干扰
这就像一个大厨房里有 多个厨师,每个人可以独立做不同的菜。
- 厨师A炒饭,厨师B炸鸡,厨师C煮汤,大家互不影响,一起忙活。
- 这样所有菜都能同时出锅,速度比串行快多了。
特点:多个任务在同一时间真正“同时”运行,彼此不会干扰。
(需要多核CPU支持,每个任务分配到不同的CPU核心上执行)
void parallelExecution() {
new Thread(this::taskA).start(); // 任务A在一个线程中运行
new Thread(this::taskB).start(); // 任务B在另一个线程中运行
}
3. 并发(Concurrent):快速切换,制造“同时运行”的假象
这就像你去了一家只有 一个厨师 但又很聪明的饭馆。
- 他炒饭炒一半,觉得油温还没到,于是先去切宫保鸡丁的肉。
- 肉切了一半,听到水开了,又去煮汤。
- 他并不是同时做这些菜,而是不断切换,让每道菜都“看起来”在做。
特点:虽然看似多个任务在一起执行,但其实是“交替”运行的,真正的同时进行并不多。
(单核CPU也能支持并发,因为它通过时间片轮转快速切换任务)
void concurrentExecution() {
ExecutorService executor = Executors.newFixedThreadPool(1); // 只有一个线程
executor.submit(this::taskA); // 先执行任务A
executor.submit(this::taskB); // 任务A没执行完,切换到任务B
executor.shutdown();
}
并发的三大特性
想象你在一个银行里转账、查账、存款,银行的系统必须保证你的数据不会被乱改、不会看错余额、不会因为服务器优化导致计算错误。这背后就涉及 并发的三大特性:原子性、可见性、有序性。
1. 原子性(Atomicity):要么全做,要么不做
你去银行给朋友转 1000 块钱,这个操作一定包含两个步骤:
- 从你的账户里扣掉 1000 块
- 给朋友的账户里加上 1000 块
如果这两个操作 不能保证原子性,可能发生这样离谱的情况:
- 银行先扣了你的钱,然后突然服务器崩溃了,朋友那边的钱还没到账!
- 又或者,你的账户钱已经扣了,朋友的账户还没加上,结果另一个线程查到账户余额时发现钱没了,但实际上钱根本没真正转出去。
所以 要么两步都完成,要么一步都不做,这就是 原子性 的保证。
但有些看似很简单的操作,比如自增 i++
,其实并不是原子性的!
i++ // 实际上分成了三步:
1. 读取变量 i
2. 让 i 加 1
3. 把 i 写回内存
如果两个线程同时执行 i++
,可能出现数据丢失的问题。
如何保证原子性?
synchronized
:加锁,确保多个线程不会同时执行某段代码。AtomicInteger
:使用 Java 提供的 原子类 让自增变成原子操作。Lock
:使用ReentrantLock
显式控制锁的获取和释放。
示例:用 synchronized
让 i++ 变成原子操作
private int count = 0;
public synchronized void increment() {
count++;
}
这样,即使多个线程同时执行 increment()
,它们也必须一个个来,保证不会出错。
2. 可见性(Visibility):你的修改,别人必须立刻能看到
问题: 你去 ATM 机存了 500 块,但你打开手机银行,余额竟然还是之前的数字?!
这就是 可见性问题——你的改动没有及时同步到“主存”(共享数据区),导致其他线程(手机银行)看不到你存的钱。
在 Java 中,每个线程都有自己的工作内存,它可能会缓存变量的值,但不一定立刻同步到主存。例如:
// 线程1
boolean stop = false;
while (!stop) {
doSomething();
}
// 线程2
stop = true; // 线程2修改了 stop
问题: stop = true
一定能让线程1停下来吗?
不一定! 因为 stop
可能还停留在线程2的本地缓存,线程1 根本没看到 stop 变成 true,所以它会一直循环不停。
如何保证可见性?
volatile
关键字:强制线程每次都从主存读取变量,而不是用自己的缓存。synchronized
:保证锁的释放时会把最新数据写回主存,并让其他线程立刻看到变化。final
:在对象初始化完成后,确保值不会再变。
示例:用 volatile
解决可见性问题
volatile boolean stop = false;
public void run() {
while (!stop) {
doSomething();
}
}
public void stopThread() {
stop = true; // 线程1能立刻看到这个变化
}
volatile
让 stop 直接同步到主存,这样线程1立刻就能看到变化,避免死循环。
3. 有序性(Ordering):代码可能不是按你写的顺序执行
你写代码的顺序,不代表 CPU 就会按这个顺序执行。
Java 为了优化性能,可能会对代码进行 指令重排序(reordering)。在单线程下,这种优化通常不会有问题,但在多线程下,它可能导致严重的错误。
示例:
int a = 0;
boolean flag = false;
public void write() {
a = 2; // (1)
flag = true; // (2)
}
public void multiply() {
if (flag) { // (3)
int ret = a * a; // (4)
}
}
可能发生的问题:
- 你以为
a=2
先执行,然后flag=true
才执行。- 但 Java 可能会优化,把 (2)
flag = true;
提前执行!- 这样,如果
multiply()
先看到flag
是true
,但a
可能还没赋值,导致计算a * a
出错。
如何保证有序性?
volatile
:volatile
禁止指令重排序,确保a=2
一定先执行,再执行flag=true
。synchronized
:线程进入同步块时,会刷新变量值,保证指令按顺序执行。
示例:用 volatile
防止重排序
volatile int a = 0;
volatile boolean flag = false;
public void write() {
a = 2;
flag = true; // 这里不会被提前执行
}
public void multiply() {
if (flag) {
int ret = a * a; // 现在一定是 2 * 2
}
}
总结
特性 | 定义 | 问题举例 | 解决方案 |
---|---|---|---|
原子性 | 要么全做,要么不做 | i++ 在多线程下可能丢失更新 | synchronized 、AtomicInteger |
可见性 | 修改的数据,其他线程能立刻看到 | 线程1修改变量,线程2读取到的还是旧值 | volatile 、synchronized |
有序性 | 代码执行顺序和编写顺序可能不一样 | a=2; flag=true; 可能被重排序导致错误 | volatile 、synchronized |
最终结论:
synchronized
同时满足 原子性、可见性、有序性,但性能开销较大。volatile
只能保证可见性和有序性,不保证原子性(比如i++
仍然不是原子操作)。- 如果
volatile
能满足需求,就用volatile
,否则用synchronized
或Lock
。
最后的话:关注不迷路,点赞不迟到! 🎉
如果这篇文章让你对 并发三大特性(原子性、可见性、有序性) 有了更清晰的理解,记得给个 点赞 👍、收藏 ⭐、关注 ❤️,这样下次学习并发的时候就不会迷路啦!
你的 点赞 和 关注,就是对我最大的支持,让我更有动力继续为大家输出 通俗易懂、幽默风趣的技术干货! 🚀
如果你有任何疑问或者更好的理解方式,欢迎 评论区交流,让我们一起 成为高并发高手! 💪
感谢阅读,我们 下篇文章见! 🎊