并发工具类库加持,线程安全真的万无一失?

开发过程中,当你满心欢喜地引入并发工具类库,以为就此给程序的线程安全上了 “双保险”,却在上线后遭遇诡异的线程错误,是不是会怀疑人生?

你本以为用 ConcurrentHashMap 完美替代了 HashMap,多线程下数据读写就能高枕无忧;用 ThreadLocal 来代替普通变量,轻轻松松就能实现线程间的数据隔离。

但现实却给你沉重一击,程序在高并发时状况百出。并发工具类库加持下,线程安全真的就万无一失了吗?

今天,咱们就来好好聊聊这背后隐藏的门道。
在这里插入图片描述

1. ThreadLocal 翻车现场:你的数据被“共享”了!

你以为用了 ThreadLocal,数据就安全了?Too young, too simple!

问题复现

前几天,同事接到了一个需求,在测试阶段,程序偶尔会出现用户会话信息错乱的情况。起初,我们都以为是代码逻辑出了问题,但仔细排查后才发现,原来是 ThreadLocal 和线程池“打架”了。

代码大概是这样的:

@RestController
@RequestMapping("/threadlocal")
public class ThreadLocalController {
    private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

    @GetMapping("/wrong")
    public Map<String, String> wrong(@RequestParam("userId") Integer userId) {
        // 设置用户信息之前先查询一次 ThreadLocal 中的用户信息
        String before = Thread.currentThread().getName() + ":" + currentUser.get();

        // 设置用户信息到 ThreadLocal
        currentUser.set(userId);

        // 设置用户信息之后再查询一次 ThreadLocal 中的用户信息
        String after = Thread.currentThread().getName() + ":" + currentUser.get();

        // 汇总输出两次查询结果
        Map<String, String> result = new HashMap<>();
        result.put("before", before);
        result.put("after", after);
        return result;
    }
}

按理说,在设置用户信息之前,第一次获取的值始终应该是 null,因为我们在 ThreadLocal 的初始化时已经明确指定了初始值为 null。

然而,我们却看到了意外的结果。问题的关键在于:程序运行在 Tomcat 中,而 Tomcat 的工作线程是基于线程池的。

为快速复现问题,我在配置文件里设置 Tomcat 参数,将工作线程池最大线程数设为 1,这样请求始终由同一线程处理。

server.tomcat.max-threads=1

我们可以通过以下步骤复现这个问题:

  1. 用户 1 请求接口
    • 用户 1 的 userId 是 1。
    • 第一次获取 ThreadLocal 的值:null(因为线程是第一次被使用)。
    • 设置 ThreadLocal 的值为 1。
    • 第二次获取 ThreadLocal 的值:1。

用户 1 的返回结果:

在这里插入图片描述
2. 用户 2 请求接口

  • 用户 2 的 userId 是 2。
  • 第一次获取 ThreadLocal 的值:1(因为线程复用了用户 1 的数据)。
  • 设置 ThreadLocal 的值为 2。
  • 第二次获取 ThreadLocal 的值:2。

用户 2 的返回结果:

在这里插入图片描述

问题的根本原因

问题的根本原因在于:线程池中的线程被复用时,ThreadLocal 中的数据没有被清理。

ThreadLocal 的设计初衷是为每个线程提供独立的数据副本,但它不会自动清理数据。如果线程被复用,而 ThreadLocal 中的数据没有被清理,后续请求就会读取到之前请求遗留的数据。

解决方案:清理 ThreadLocal 数据

为了避免这种问题,我们需要在每次请求结束时清理 ThreadLocal 中的数据。可以通过在 finally 块中调用 remove() 方法来实现。

修改后的代码如下:

@GetMapping("/right")
public Map<String, String> right(@RequestParam("userId") Integer userId) {
    // 设置用户信息之前先查询一次 ThreadLocal 中的用户信息
    String before = Thread.currentThread().getName() + ":" + currentUser.get();

    // 设置用户信息到 ThreadLocal
    currentUser.set(userId);

    try {
        // 设置用户信息之后再查询一次 ThreadLocal 中的用户信息
        String after = Thread.currentThread().getName() + ":" + currentUser.get();

        // 汇总输出两次查询结果
        Map<String, String> result = new HashMap<>();
        result.put("before", before);
        result.put("after", after);
        return result;
    } finally {
        // 清理 ThreadLocal 中的数据
        currentUser.remove();
    }
}

重新运行程序可以验证,再也不会出现第一次查询用户信息查询到之前用户请求的 Bug:
在这里插入图片描述

小结

从这个例子能看出,咱们写业务代码,得先搞清楚代码会在啥线程上跑。

好多人可能会吐槽学多线程没啥用,觉得自己代码里又没主动开启多线程。但实际上,在 Tomcat 这种 Web 服务器里跑的业务代码,本来就是在多线程环境下运行的(不然接口咋支持高并发呢)。

所以啊,别以为没显式开启多线程,就不会有线程安全问题。

要知道,创建线程挺费资源的,所以 Web 服务器一般会用线程池来处理请求,这就导致线程会被重复使用。咱们用 ThreadLocal 存数据的时候,可得多留个心眼,代码跑完后,一定要显式地把存的数据清空。要是代码里用了自定义线程池,也会碰到同样的问题。

2. ConcurrentHashMap 的坑:你以为的线程安全并不安全

很多人认为,只要把 HashMap 换成 ConcurrentHashMap,就能解决所有的线程安全问题。

然而,事实并非如此。ConcurrentHashMap 虽然提供了线程安全的操作,但它并不能保证多个操作的组合是线程安全的。

问题场景

假设我们有一个需求:统计用户访问次数。我们使用 ConcurrentHashMap 来存储用户的访问次数,代码如下:

public class UserVisitCounter {
    private static final ConcurrentHashMap<String, Integer> visitCountMap = new ConcurrentHashMap<>();

    public void visit(String userId) {
        Integer count = visitCountMap.get(userId);
        if (count == null) {
            visitCountMap.put(userId, 1);
        } else {
            visitCountMap.put(userId, count + 1);
        }
    }

    public int getVisitCount(String userId) {
        return visitCountMap.getOrDefault(userId, 0);
    }

    public static void main(String[] args) throws InterruptedException {
        UserVisitCounter counter = new UserVisitCounter();
        String userId = "user1";

        // 创建 100 个线程,每个线程访问一次
        ExecutorService executor = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            executor.submit(() -> counter.visit(userId));
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        // 输出访问次数
        System.out.println("访问次数: " + counter.getVisitCount(userId));
    }
}
  • 预期结果:访问次数应该是 100。
  • 实际结果:访问次数可能小于 100,比如 98、99 等等。

问题的根本原因

  1. 因为多个线程可能同时执行 get,拿到相同的 count 值,然后分别执行 put,导致最终的计数结果丢失。
  2. count + 1 不是原子指令

解决方案

方案一:使用 AtomicInteger

我们可以用 ConcurrentHashMap<String, AtomicInteger> 来存储访问次数,利用 AtomicInteger 的原子操作来保证线程安全。

public class UserVisitCounter {
    private static final ConcurrentHashMap<String, AtomicInteger> visitCountMap = new ConcurrentHashMap<>();

    public void visit(String userId) {
        visitCountMap.computeIfAbsent(userId, k -> new AtomicInteger(0)).incrementAndGet();
    }

    public int getVisitCount(String userId) {
        return visitCountMap.getOrDefault(userId, new AtomicInteger(0)).get();
    }
}

方案二:使用 compute 方法

ConcurrentHashMap 提供了 compute 方法,可以原子地更新值。

public class UserVisitCounter {
    private static final ConcurrentHashMap<String, Integer> visitCountMap = new ConcurrentHashMap<>();

    public void visit(String userId) {
        visitCountMap.compute(userId, (k, v) -> v == null ? 1 : v + 1);
    }

    public int getVisitCount(String userId) {
        return visitCountMap.getOrDefault(userId, 0);
    }
}

小结

  1. ConcurrentHashMap 并不能解决所有线程安全问题:它只能保证单个操作的线程安全,无法保证多个操作的组合是线程安全的。
  2. 多操作组合需要额外的同步机制:比如使用 AtomicInteger 或 compute 方法。

记住: 线程安全无小事,谨慎使用并发工具类库!

最后,我最近弄了一个Java技术交流群,讨论面试、后端等领域知识,如果你感兴趣,欢迎关注我公众号回复【1】,我拉你入群哈~(我的公众号:程序员徐述)
目前已经有近 100 人加入。如果你已经在群里,请忽略~

综上,希望今天这篇文章对你帮助!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值