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

本引用仅供学习,禁商用
引用自 https://mp.weixin.qq.com/s/fJsYrkBIN6QEK_Xwzsz0eg

 

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

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

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

 

今天,咱们就来好好聊聊这背后隐藏的门道。

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

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

1.1 问题复现

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

代码大概是这样的:

@RestController@RequestMapping("/threadlocal")public class ThreadLocalController {    private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);    @GetMapping("/wrong")    public Map<StringStringwrong(@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<StringString> 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 的返回结果:

 

 

1.2 问题的根本原因

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

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

1.3 解决方案:清理 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:

 

 

1.4 小结

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

好多人可能会吐槽学多线程没啥用,觉得自己代码里又没主动开启多线程。

但实际上,在 Tomcat 这种 Web 服务器里跑的业务代码,本来就是在多线程环境下运行的(不然接口咋支持高并发呢)。

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

要知道,创建线程挺费资源的,所以 Web 服务器一般会用线程池来处理请求,这就导致线程会被重复使用。

咱们用 ThreadLocal 存数据的时候,可得多留个心眼,代码跑完后,一定要显式地把存的数据清空。要是代码里用了自定义线程池,也会碰到同样的问题。

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

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

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

2.1 问题场景

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

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

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 (inti = 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 等等。

2.2 问题的根本原因

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

方案一:使用 AtomicInteger

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

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

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));
    }
}

 


方案二:使用 compute 方法

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

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

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));
    }
}

  



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

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

posted @ 2025-05-27 22:37  IT6889  阅读(19)  评论(0)    收藏  举报