什么是ThreadLocal?
从 官方文件中的描述: 类用于提供线程内部的局部变量。通过多线程环境(通过 和 方法访问)可以确保每个线程的变量相对独立于其他线程。 例子一般都是 关联线程和线程上下文使用类型。
我们可以知道 其功能是提供线程中的局部变量,不同的线程不会相互干扰。该变量在线程的生命周期中工作,以减少同一线程中多个函数或组件之间的一些公共变量传输的复杂性。
- 线程并发:多线程并发场景
- 通过传递数据:我们可以通过 公共变量在同一线程中传递(有点相似) ?)
- 线程隔离:每个线程的变量是独立的,不会相互影响
基本使用
在介绍 在使用它之前,我们首先知道几个 的常见方法
使用案例
让我们来看看以下线程不安全的案例,感受一下 线程隔离的特点。
/** * 要求:线程隔离 * 在多线程并发的情况下,每个线程中的变量是独立的 * 线程A:设置变量1,获取变量2 * 线程B:设置变量2,获取变量2 * @author: 陌溪 */ public class MyDemo01 { // 变量 private String content; public String getContent() { return content; } public void setContent(String content) { this.content = content; } public static void main(String[] args) { MyDemo01 myDemo01 = new MyDemo01(); for (int i = 0; i < 5; i ) { new Thread(() -> { myDemo01.setContent(Thread.currentThread().getName() "的数据"); System.out.println("-----------------------------------------"); System.out.println(Thread.currentThread().getName() "\t " myDemo01.getContent()); }, String.valueOf(i)).start(); } } }
运行后的效果
----------------------------------------- ----------------------------------------- ----------------------------------------- 3 4的数据 ----------------------------------------- 2 4的数据 ----------------------------------------- 1 4的数据 4 4的数据 0 4的数据
从以上可以看出,线程不隔离的问题,即线程1取出了线程4的内容,那么如何解决呢?
这个时候就可以用了 我们通过了 将变量绑定到当前线程,然后 获取当前线程绑定的变量
/** * 需求:线程隔离 * 在多线程并发的情况下,每个线程中的变量是独立的 * 线程A:设置变量1,获取变量2 * 线程B:设置变量2,获取变量2 * @author: 陌溪 */ public class MyDemo01 { // 变量 private String content; public String getContent() { return content; } public void setContent(String content) { this.content = content; } public static void main(String[] args) { MyDemo01 myDemo01 = new MyDemo01(); ThreadLocal<String> threadLocal = new ThreadLocal<>(); for (int i = 0; i < 5; i ) { new Thread(() -> { threadLocal.set(Thread.currentThread().getName() "的数据"); System.out.println("-----------------------------------------"); System.out.println(Thread.currentThread().getName() "\t " threadLocal.get()); }, String.valueOf(i)).start(); } } }
通过引入 运行结果如下:
----------------------------------------- ----------------------------------------- 4 4的数据 ----------------------------------------- 3 3的数据 ----------------------------------------- 2 2的数据 ----------------------------------------- 1 1的数据 0 0的数据
发现上述情况不会发生,即当前线程只能获得线程线程存储的对象
ThreadLocal类和Synchronized关键字
Synchronized同步方式
对于上面的例子,这个功能可以通过加锁来实现。让我们看看 代码块的效果:
public static void main(String[] args) { MyDemo03 myDemo01 = new MyDemo03(); for (int i = 0; i < 5; i ) { new Thread(() -> { synchronized (MyDemo03.class) { myDemo01.setContent(Thread.currentThread().getName() "的数据"); System.out.println("-----------------------------------------"); System.out.println(Thread.currentThread().getName() "\t " myDemo01.getContent()); } }, String.valueOf(i)).start(); } }
如下所示,发现加锁可以实现运行结果ThreadLocal但并发性降低了线程隔离的功能。
----------------------------------------- 0 0的数据 ----------------------------------------- 4 4的数据 ----------------------------------------- 3 3的数据 ----------------------------------------
2 2的数据
-----------------------------------------
1 1的数据
ThreadLocal与Synchronized的区别
虽然 模式与 关键字都用于处理多线程并发访问变量的问题,不过两者处理问题的角度和思路不同。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Ic3SqEA-1656569102476)(https://upload-images.jianshu.io/upload_images/27937678-3459a4a1bc08f5ac.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
总结:在刚刚的案例中,虽然使用 和 都能解决问题,但是使用 更为合适,因为这样可以使程序拥有更高的并发性。
运用场景
通过以上的介绍,我们已经基本了解 的特点,但是它具体是运用在什么场景中的呢?接下来让我们看一个案例:事务操作
转账案例
这里首先构建一个简单的转账场景:有一个数据表 ,里面有两个用户 和,用户 给用户 转账。这个案例的实现主要是用 数据库, 和 框架,以下是详细代码
这里们先构建一个简单的转账场景:有一个数据表 ,里面有两个用户 和,用户 给用户 转账。案例的实现主要是用 数据库, 和 框架,以下是详细代码
引入事务
案例中转账涉及两个 操作:一个转出,一个转入。这些操作是需要具备原子性的,不可分割。不然有可能出现数据修改异常情况。
public class AccountService {
public boolean transfer(String outUser, String isUser, int money) {
AccountDao ad = new AccountDao();
try {
// 转出
ad.out(outUser, money);
// 模拟转账过程中的异常
int i = 1/0;
// 转入
ad.in(inUser, money);
} catch(Exception e) {
e.printStackTrace();
return false;
}
return true;
}
}
所以这里就需要操作事务,来保证转入和转出具备原子性,要么成功,要么失败。
中关于事务操作的
开启事务的注意点
- 为了保证所有操作在一个事务中,案例中使用的连接必须是同一个;
- 层开启事务的 需要跟 层访问数据库的 保持一致
- 线程并发情况下,每个线程只能操作各自的 ,也就是线程隔离
常规解决方法
基于上面给出的前提,大家通常想到的解决方法
- 从 层将 对象向 层传递
- 加锁
常规解决方法的弊端
- 提高代码的耦合度(因为我们需要从 层 传入 参数)
- 降低程序的性能(加了同步代码块,失去了并发性)
这个时候就可以通过 和当前线程进行绑定,来降低代码之间的耦合
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-05lNJ0ED-1656569102483)(https://upload-images.jianshu.io/upload_images/27937678-3d1b4555becd1497.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
使用ThreadLocal解决
针对上面出现的情况,我们需要对原来的JDBC连接池对象进行更改
-
将原来从连接池中获取对象,改成直接获取当前线程绑定的连接对象
-
如果连接对象是空的
-
再去连接池中获取连接
-
将此连接对象跟当前线程进行绑定
ThreadLocal<Connection> tl = new ThreadLocal();
public static Connection getConnection() {
Connection conn = tl.get();
if(conn == null) {
conn = ds.getConnection();
tl.set(conn);
}
return conn;
}
ThreadLocal实现的好处
从上述的案例中我们可以看到,在一些特定场景下,ThreadLocal方案有两个突出的优势:
- 传递数据:保存每个线程绑定的数据,在需要的地方可以直接获取,避免参数直接传递带来的代码耦合问题
- 线程隔离:各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失
ThreadLocal的内部结构
通过以上的学习,我们对 的作用有了一定的认识。现在我们一起来看一下 的内部结构,探究它能够实现线程数据隔离的原理。
常见误解
如果我们不去看源代码的话,可能会猜测 是这样子设计的:每个 都创建一个 ,然后用线程作为 的 ,要存储的局部变量作为 的 ,这样就能达到各个线程的局部变量隔离的效果。这是最简单的设计方法,JDK最早期的 确实是这样设计的,但现在早已不是了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EwkgB9ae-1656569102484)(https://upload-images.jianshu.io/upload_images/27937678-d4daf01d61877d04.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
现在的设计
但是, 后面优化了设计方案,在 中 的设计是:每个 维护一个,这个 的 是 实例本身, 才是真正要存储的值 。具体的过程是这样的:
- 每个 线程内部都有一个 ()
- 里面存储 对象 和线程的变量副本
- 内部的 是由 维护的,由 负责向 获取和设置线程的变量值。
- 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。
从上面变成 的设计有什么好处?
- 每个 存储的 数量变少,因为原来的 数量是由 决定,而现在是由 决定的。真实开发中, 的数量远远大于 的数量
- 当 销毁的时候, 也会随之销毁,因为 是存放在 中的,随着 销毁而消失,能降低开销。
ThreadLocalMap源码分析
在分析 方法的时候,我们了解到 的操作实际上是围绕 展开的。 的源码相对比较复杂,我们从以下三个方面进行讨论。
基本结构
是 的内部类,没有实现 接口,用独立的方式实现了 的功能,其内部的 也是独立实现。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yLwkWjtR-1656569102485)(https://upload-images.jianshu.io/upload_images/27937678-29dcf3636b0e83cd.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
成员变量
/**
* 初始容量 - 必须是2的整次幂
**/
private static final int INITIAL_CAPACITY = 16;
/**
*存放数据的table ,Entry类的定义在下面分析,同样,数组的长度必须是2的整次幂
**/
private Entry[] table;
/**
*数组里面entrys的个数,可以用于判断table当前使用量是否超过阈值
**/
private int size = 0;
/**
*进行扩容的阈值,表使用量大于它的时候进行扩容
**/
private int threshold; // Default to 0
跟 类似, 代表这个 的初始容量; 是一个 类型的数组,用于存储数据; 代表表中的存储数目; 代表需要扩容时对应的 的阈值。
存储结构 - Entry
/*
*Entry继承WeakRefefence,并且用ThreadLocal作为key.
如果key为nu11(entry.get()==nu11),意味着key不再被引用,
*因此这时候entry也可以从table中清除。
*/
static class Entry extends weakReference<ThreadLocal<?>>{
object value;Entry(ThreadLocal<?>k,object v){
super(k);
value = v;
}}
在 中,也是用 来保存 结构数据的。不过 中的 只能是 对象,这点在构造方法中已经限定死了。
另外, 继承 ,也就是 **key(ThreadLocal)**是弱引用,其目的是将 对象的生命周期和线程生命周期解绑。
弱引用和内存泄漏
有些程序员在使用 的过程中会发现有内存泄漏的情况发生,就猜测这个内存泄漏跟Entry中使用了弱引用的 有关系。这个理解其实是不对的。
我们先来回顾这个问题中涉及的几个名词概念,再来分析问题。
内存泄漏相关概念
:内存溢出,没有足够的内存提供申请者使用。
:内存泄漏是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统溃等严重后果。I内存泄漏的堆积终将导致内存溢出。
弱引用相关概念
Java中的引用有4种类型:强、软、弱、虚。当前这个问题主要涉及到强引用和弱引用:
:就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾回收器就不会回收这种对象。
:垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
如果key使用强引用,那么会出现内存泄漏?
假设 中的 使用了强引用,那么会出现内存泄漏吗?
此时 的内存图(实线表示强引用)如下:
- 假设在业务代码中使用完 ,被回收了
- 但是因为 的 强引用了 ,造成 无法被回收。
- 在没有手动删除这个 以及 依然运行的前提下,始终有强引用链 , 就不会被回收( 中包括了ThreadLocal实例和value),导致Entry内存泄漏。
也就是说, 中的 使用了强引用,是无法完全避免内存泄漏的。
如果key使用弱引用,那么会出现内存泄漏?
- 同样假设在业务代码中使用完 , 被回收了。
- 由于 只持有 的弱引用,没有任何强引用指向 实例,所以 就可以顺利被 回收,此时 中的 。
- 但是在没有手动删除这个 以及 依然运行的前提下,也存在有强引用链 , 不会被回收,而这块 永远不会被访问到了,导致 内存泄漏。
也就是说, 中的 使用了弱引用,也有可能内存泄漏。
出现内存泄漏的真实原因
比较以上两种情况,我们就会发现,内存泄漏的发生跟 中的 是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?
细心的同学会发现,在以上两种内存泄漏的情况中,都有两个前提:
- 没有手动删除这个
- 依然运行
第一点很好理解,只要在使用完 ,调用其 方法删除对应的,就能避免内存泄漏。
第二点稍微复杂一点,由于 是 的一个属性,被当前线程所引用,所以它的生命周期跟 一样长。那么在使用完 的使用,如果当前 也随之执行结束, 自然也会被 回收,从根源上避免了内存泄漏。
综上, 内存泄漏的根源是:由于 的生命周期跟 一样长,如果没有手动删除对应 就会导致内存泄漏。
为什么要使用弱引用?
根据刚才的分析,我们知道了:无论 中的 使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。
要避免内存泄漏有两种方式:
- 使用完 ,调用其 方法删除对应的
- 使用完 ,当前 也随之运行结束
相对第一种方式,第二种方式显然更不好控制,特别是使用线程池的时候,线程结束是不会销毁的,而是接着放入了线程池中。
也就是说,只要记得在使用完 及时的调用 ,无论 是强引用还是弱引用都不会有问题。那么为什么 要用弱引用呢?
事实上,在 中的 方法中,会对 为 (也即是 为)进行判断,如果为 的话,那么是会对 置为 的。
这就意味着使用完 , 依然运行的前提下,就算忘记调用 方法,弱引用比强引用可以多一层保障:弱引用 的被回收,对应的 在下一次 调用 中的任一方法的时候会被清除,从而避免内存泄漏。