ThreadLocal 错误使用分析

ThreadLocal 错误使用分析

ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景,但是如果未正确使用 ThreadLocal, 就会出现很多奇怪问题。

常见错误

未清理 ThreadLocal 变量内存泄漏

每次使用完ThreadLocal都调用它的remove()方法清除数据, 否则是会造成内存泄漏的情况,下面通过一个案例来模拟内存泄漏场景。

创建了一个核心线程数和最大线程数都为5的线程池,向线程池里面放入 50 个任务,设置当前线程的 localVariable 变量,也就是把 LocalVariable 变量放入当前线程的 threadLocals 变量中。

由于没有调用线程池的 shutdown 或者 shutdownNow 方法,所以线程池里面的用户线程不会退出,进而 JVM 也不会退出。

public class MemoryLeakTest {
     static class LocalVariable {
         private byte[] a = new byte[1024 * 1024 * 10];
    }

    final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 50; ++i) {
            poolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    localVariable.set(new LocalVariable());
                    System.out.println("use local variable");
                    // localVariable.remove();
                }
            });
            Thread.sleep(1000);
        }
        System.out.println("pool execute over");
    }
}

运行代码, 当主进程处于休眠时,手动执行 gc,使用 jconsole 监控堆内存变化

在这里插入图片描述

去掉 localVariable.remove();注释, 再次运行,观察堆内存变化

在这里插入图片描述

从运行结果可知,两次测试结果,堆内存占用相差 50 MB 左右,由此可知,第一次测试时,发生了内存泄漏。

测试案例实际上是通过复用 5 条线程,循环设置 localVariable.set(new LocalVariable()),这里拿其中的一条线程 thread1 来分析。

注释掉 localVariable.remove()

  1. thread1 首次执行 localVariable.set(new LocalVariable()),实际上会初始化 thread 线程ThreadLocalMap threadLocals对象, 并将 localVariable 对象的弱引用做为 key,new LocalVariable() 做为value,添加到 threadLocals 中;

  2. thread1 再次执行 localVariable.set(new LocalVariable()),会找到key为localVariable 的数据,并将新的LocalVariable 对象设置为value值;

  3. 重复步骤2,直到 thread1 线程最后一次执行 localVariable.set(new LocalVariable()),最终 thread1 线程ThreadLocalMap threadLocals 里面还剩下一个 localVariable 对象的弱引用做为 key,new LocalVariable() 做为value的数据,大概占 10MB 空间。

而取消 remove()注释后,最终 thread1 线程ThreadLocalMap threadLocals 中将不会再存有数据;与注释掉remove()方法的情况相比,两种情况大致相差了一个 LocalVariable 对象,而每个LocalVariable 对象大小为 10MB左右;

这也就是为什么两次实验堆内存会有50MB左右差异,这是因为第一次测试堆内存比第二次测试多了 5 个 LocalVariable对象,而每个 LocalVariable对象刚好是10MB,5个也就是50MB,数据刚好对得上!!

总结:如果在线程池里面设置了 ThreadLocal 变量,则 一 定要记得及时清理,因为线 程池里面的核心线程是一直存在的,如果不清理,线程池的核心线程的 threadLocals 变量 会一直持有 ThreadLocal 变量。

没有意识到线程重用导致用户信息错乱的 Bug

使用 Spring Boot 创建一个 Web 应用程序,使用 ThreadLocal 存放一个 Integer 的值,来暂且代表需要在线程中保存的用户信息,这个值初始是 null。在业务逻辑中,我先从 ThreadLocal 获取一次值,然后把外部传入的参数设置到 ThreadLocal 中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。

@RestController
@RequiredArgsConstructor
public class ThreadLocalTest {
    private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

    @GetMapping("wrong")
    public Map wrong(@RequestParam("userId") Integer userId) {
        Map result = getResult(userId);
        return result;
    }

    private Map getResult(Integer userId) {
        String before = Thread.currentThread().getName() + ":" + currentUser.get();
        currentUser.set(userId);
        String after = Thread.currentThread().getName() + ":" + currentUser.get();
        Map result = new HashMap(2);
        result.put("before", before);
        result.put("after", after);
        return result;
    }
}

按理说,在设置用户信息之前第一次获取的值始终应该是 null,但我们要意识到,此程序是基于 SpringBoot 的应用,默认情况下, 程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的。

为了更好的得到错误的效果,可以手动将 Tomcat 的最大线程设置成 1:

# 设置最大工作线程
server.tomcat.threads.max=1

运行程序后先让用户 1 来请求接口,可以看到第一和第二次获取到用户 ID 分别是 null 和 1,符合预期:

在这里插入图片描述

随后用户 2 来请求接口,这次就出现了 Bug,第一和第二次获取到用户 ID 分别是 1 和 2,显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程。从图中可以看到,两次请求的线程都是同一个线程:http-nio-8080-exec-1

在这里插入图片描述

因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。

修正这段代码的方案是,在代码的 finally 代码块中,显式清除 ThreadLocal 中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。修正后的代码如下:

@GetMapping("success")
    public Map success(@RequestParam("userId") Integer userId) {
        try {
            return getResult(userId);
        } finally {
            currentUser.remove();
        }
    }

下载案例

Shaun/learn

参考文章

https://blog.csdn.net/u011047968/article/details/101422919

https://blog.csdn.net/weixin_42488909/article/details/118604890

https://juejin.im/post/6887937212780904462

https://blog.csdn.net/weixin_45333509/article/details/115756410

https://blog.csdn.net/srs1995/article/details/109351177

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值