在 Java 语言中解决线程不安全的问题通常有两种手段:
- 使用锁(使用 synchronized 或 Lock);
- 使用 ThreadLocal。
ThreadLocal采用了空间换时间的设计思想,也就是说每个线程里面都有一个专门的容器来存储共享变量的副本信息,然后每个线程只对自己的变量副本做相对应的更新操作,这样避免了多线程锁竞争的开销。
在阿里Java开发规约中,有强制性地提到SimpleDateFormat 是线程不安全的类。其实主要的原因是由于多线程操作SimpleDateFormat中的Calendar对象引用,然后出现脏读导致的。
public class DateFormmatTest{
private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//将字符串解析为Date对象
public static Date parse(String dateString){
Date date = null;
try{
date = simpleDateFormat.parse(dateSting);
}catch(ParseException e){
e.printStackTrace();
}
return date;
}
//测试
public static void main(String[] args){
ExecutorService executorService = Executors.newFixedThreadPool(20);
for(int i = 0;i < 20;i++){
ececutorService.execute(() -> {
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}
优化上面的代码
public class DateFormatTest {
private static final ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));//通过ThreadLocal进行对SimpleDateFormat初始化
public static Date parse(String dateString) {
Date date = null;
try {
date = dateFormatThreadLocal.get().parse(dateString);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 20; i++) {
executorService.execute(()->{
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}
运行了一下,完全正常了。
使用场景:
-
如上所示:针对SimpleDateFormat的封装
-
用来替代参数链传递
在编写API接口时,可以将需要传递的参数放入ThreadLocal中,从而不需要在每个调用的方法上都显式地传递这些参数。这种方法虽然不如将参数封装为对象传递来得常见,但在某些情况下可以简化代码结构。 -
数据库连接和会话管理:在某些应用中,如Web应用程序,ThreadLocal可以用来保持对数据库连接或会话的管理,以简化并发控制并提高性能。
例如,可以使用ThreadLocal来维护一个连接池,使得每个请求都能共享相同的连接,而不是每次都需要重新建立连接。 -
全局存储信息:例如在前后端分离的应用中,ThreadLocal可以用来在服务端维护用户的上下文信息或者一些配置信息,而不需要通过HTTP请求携带大量的用户信息。这样做可以在不改变原有架构的情况下,提供更好的用户体验。
ThreadLocal原理
- 图中有两个线程Thread1以及Thread2。
- Thread类中有一个叫做threadLocals的成员变量,它是ThreadLocal.ThreadLocalMap类型的。
- ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。
- 并发场景下,每个线程都会存储当前变量副本到自己的ThreadLocalMap中,后续这个线程对于共享变量的操作,都是从TheadLocalMap里进行变更,不会影响全局共享变量的值。
高并发场景下ThreadLocal会造成内存泄漏吗?什么原因导致?如何避免?
Entry中以key和value的形式存储,key是ThreadLocal本身,上面代码中我们看到entry进行key设置的时候用的是super(k)。那就意味着调用的父类的方法去设置了key,我们再看一下父类是什么,父类其实是WeakReference。关于WeakReference底层的实现,大家有兴趣可以展开去看看源代码。
先使用“-Xmx50m”的参数来设置一下 Idea,它表示将程序运行的最大内存设置为 50m,如果程序的运行超过这个值就会出现内存溢出的问题,设置方法如下:点击“Edit Configurations…”找到“VM options”选项,设置上“-Xmx50m”参数
创建一个大对象,这个对象中会有一个 10m 大的数组,然后我们将这个大对象存储在 ThreadLocal 中,再使用线程池执行大于 5 次添加任务,因为设置了最大运行内存是 50m,所以理想的情况是执行 5 次添加操作之后,就会出现内存溢出的问题,实现代码如下:
public class ThreadLocalOOMExample{
static class MyTask{
//创建一个 10m 的数组(单位转换是 1M -> 1024KB -> 1024*1024B)
private byte[] bytes = new byte[10 * 1024 * 1024];
}
private static ThreadLocal<MyTask> taskThreadLocal = new ThreadLocal<>();
//线程池执行任务
private static void executeTask(ThreadPoolExecutor threadPoolExecutor){
threadPoolExecutor.execute(new Runnable(){
@Override
public void run(){
System.out.println("创建对象");
// 创建对象(10M)
MyTask myTask = new MyTask();
// 存储 ThreadLocal
taskThreadLocal.set(myTask);
// 将对象设置为 null,表示此对象不在使用了
myTask = null;
}
});
}
public static void main(String[] args)throws InterruptedException{
//创建线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
//执行10次调用
for(int i = 0;i < 10;i++){
executeTask(threadPoolExecutor);
Thread.sleep(1000);
}
}
}
当程序执行到第 5 次添加对象时就出现内存溢出的问题了,这是因为设置了最大的运行内存是 50m,每次循环会占用 10m 的内存,加上程序启动会占用一定的内存,因此在执行到第 5 次添加任务时,就会出现内存溢出的问题。
Thread -> ThreadLocalMap -> Entry -> Key,Value,因此当我们使用线程池来存储对象时,因为线程池有很长的生命周期,所以线程池会一直持有 value 值,那么垃圾回收器就无法回收 value,所以就会导致内存一直被占用,从而导致内存溢出问题的发生。
WeakReference 如字面意思,弱引用,当一个对象仅仅被weak reference(弱引用)指向, 而没有任何其他strong reference(强引用)指向的时候, 如果这时GC运行, 那么这个对象就会被回收,不论当前的内存空间是否足够,这个对象都会被回收。
关于这些引用的强弱,稍微聊一下,这里其实涉及到jvm的回收机制。在JDK1.2之后,java对引用的概念其实做了扩充的,分为强引用,软引用,弱引用,虚引用。
强引用:其实就是咱们一般用“=”的赋值行为,如 Student s = new Student(),只要强引用还在,对象就不会被回收。
软引用:不是必须存活的对象,jvm在内存不够的情况下即将内存溢出前会对其进行回收。例如缓存。
弱引用:非必须存活的对象,引用关系比软引用还弱,无论内存够还是不够,下次的GC一定会被回收。
虚引用:别名幽灵引用或者幻影引用。等同于没有引用,唯一的目的是对象被回收的时候会受到系统通知。
Key其实是弱引用,而里面的value因为是直接赋值行为所以是强引用。
图中我们可以看到由于threadLocal对象是弱引用,如果外部没有强引用指向的话,它就会被GC回收,那么这个时候导致Entry的key就为NULL,如果此时value外部也没有强引用指向的话,那么这个value就永远无法访问了,按道理也该被回收。但是由于entry还在强引用value(看源代码)。那么此时value就无法被回收,此时内存泄漏就出现了。本质原因是因为value成为了一个永远无法被访问也无法被回收的对象。
那肯定有小伙伴会有疑问了,线程本身生命周期不是很短么,如果短时间内被销毁,就不会内存泄漏了,因为只要线程销毁,那么value也会被回收。这话是没错。但是咱们的线程是计算机珍贵资源,为了避免重复创建线程带来开销,系统中我们往往会使用线程池,如果使用线程池的话,那么线程的生命周期就被拉长了,那么就可想而知了。
解决:
- 每次使用完毕之后记得调用一下remove()方法清除数据。
- ThreadLocal变量尽量定义成static final类型,避免频繁创建ThreadLocal实例。这样可以保证程序中一直存在ThreadLocal强引用,也能保证任何时候都能通过ThreadLocal的弱引用访问Entry的value值,从而进行清除。
不过话说回来,其实ThreadLocal内部也做了优化的。在set()的时候也会采样清理,扩容的时候也会检查(这里希望大家自己深入看一下源代码),在get()的时候,如果没有直接命中或者向后环形查找的时候也会进行清理。但是为了系统的稳健万无一失,所以大家尽量还是将上面的两个注意点在写代码的时候注意下。
/**
只需要在 finally 中执行 ThreadLocal 的 remove 方法之后就不会在出现内存溢出的问题了
直接将 Thread 中的 ThreadLocalMap 对象移除掉,这样 Thread 就不再持有 ThreadLocalMap 对象了,
所以即使 Thread 一直存活,也不会造成因为(ThreadLocalMap)内存占用而导致的内存溢出问题了
*/
public class App{
static class MyTask{
private byte[] bytes = new byte[10 * 1024 * 1024];
}
private static ThreadLocal<MyTask> taskThreadLocal = new ThreadLocal<>();
public static void main(String[] args)throws InterruptedException{
// 创建线程池
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(5, 5, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
// 执行 n 次调用
for (int i = 0; i < 10; i++) {
// 执行任务
executeTask(threadPoolExecutor);
Thread.sleep(1000);
}
}
private static void executeTask(ThreadPoolExecutor threadPoolExecutor) {
// 执行任务
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
System.out.println("创建对象");
try {
// 创建对象(10M)
MyTask myTask = new MyTask();
// 存储 ThreadLocal
taskThreadLocal.set(myTask);
// 其他业务代码...
} finally {
// 释放内存
taskThreadLocal.remove();
}
}
});
}
}