# 《并发设计模式》第41章-线程特有存储模式-用户信息怎么错乱了?

作者:冰河
星球:http://m6z.cn/6aeFbs (opens new window)
博客:https://binghe.gitcode.host (opens new window)
文章汇总:https://binghe.gitcode.host/md/all/all.html (opens new window)
源码获取地址:https://t.zsxq.com/0dhvFs5oR (opens new window)

沉淀,成长,突破,帮助他人,成就自我。

  • 本章难度:★★☆☆☆
  • 本章重点:了解线程特有存储模式的应用场景,重点理解线程特有存储模式解决线程安全的核心思路与原理,能够融会贯通,并能够结合自身项目实际场景思考如何将线程特有存储模式灵活应用到自身实际项目中。

大家好,我是冰河~~

灵魂三问:明明线上运行良好的应用服务,为何会突然出现用户信息错乱的情况呢?到底是如何产生的问题呢?我们又该如何解决这个问题呢?虽然这次生产环境出现的问题不是小菜写的代码导致的,但是小菜本着积极上进、勇于挑战苦难和解决问题的态度,还是主动站出来解决了这个问题(小菜态度真好)。

# 一、故事背景

这天,公司技术部门接到运营人员反馈:生产环境数据统计大盘偶尔会看到用户信息错乱的情况,这对运营查询数据统计大盘,以及对后续的运营决策产生了困扰。需要技术部门尽快排查和修复问题。技术部门接到这个任务后,作为技术部门的老大,老王经过详细的了解后,将问题交给了小菜进行处理。小菜经过排查和定位问题,这次顺利解决了问题,圆满完成了任务(真不容易,自己独立完成了,给个鸡腿作为奖励)。

# 二、分析问题

接到任务后,小菜立即打开研发环境排查代码逻辑,经过认真的排查、调试和定位问题,最终发现在登录接口中,代码使用了ThreadLocal来存储用户的信息,以便于在后续的业务逻辑中能够快速方便的获取用户信息。但是,最初写这个业务逻辑代码的同事可能没想到即使使用了ThreadLocal存储了用户的信息,但是用户的信息可能还是会出现错乱的情况。这是为什么呢?

从事Java开发的小伙伴都知道,一般Java服务上线后,大部分情况下是运行在Tomcat服务中,对于Tomcat服务而言,内部使用的是线程池来处理请求任务。那么重点来了,Tomcat使用了线程池,这就意味着请求可以共用Tomcat线程池中的线程,在这种情况下,如果对ThreadLcoal使用不当,就有可能出现ThreadLocal中存储的数据发生错乱的情况,如图41-1所示。


可以看到,当大量请求到来时,由于Tomcat内部使用的是线程池来处理请求任务,此时就可能会出现不同的请求共用了线程池中同一个线程的情况,线程中会使用ThreadLocal的get()方法获取数据,随后再使用ThreadLocal的set()方法存储数据,这就可能会出现数据错乱的情况。

# 三、重现问题

为了便于大家更好的了解问题所在,这里,我们写一段代码来模拟用户请求登录接口,登录成功后查询并存储用户信息到ThreadLocal的逻辑。源码详见:io.binghe.concurrent.design.threadlocal.wrong.ThreadLocalWrongTest。

public class ThreadLocalWrongTest {
    
    private static final ExecutorService THREAD_POOL = new ThreadPoolExecutor(1,
            1,
            1,
            TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(1024));
    
    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    private static final int REQUEST_COUNT = 2;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(REQUEST_COUNT);
        System.out.println("重现问题开始");
        long startTime = System.currentTimeMillis();
        for (int i = 1; i <= REQUEST_COUNT; i++){
            int count = i;
            THREAD_POOL.execute(() -> {
                //先从THREAD_LOCAL中获取数据
                String username = THREAD_LOCAL.get();
                System.out.println("第" + count + "个请求第1次获取到的数据为:" + username);
                //存储数据到THREAD_LOCAL中
                THREAD_LOCAL.set("binghe00" + count);
                //再次从THREAD_LOCAL中查询数据
                username = THREAD_LOCAL.get();
                System.out.println("第" + count + "个请求第2次获取到的数据为:" + username);
                countDownLatch.countDown();
            });
        }
        System.out.println("重现问题结束,耗时:" + (System.currentTimeMillis() - startTime) + "ms");
        countDownLatch.await();
        THREAD_POOL.shutdown();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

可以看到,在ThreadLocalWrongTest类的代码中,创建了一个核心线程数和最大线程数都为1的线程池,来模拟线程池中只有一个线程的Tomcat服务。同时,定义了一个THREAD_LOCAL常量来模拟获取和存储用户信息,并且以请求两次为例重现问题。接下来,我们看看main()方法的实现。

main()方法中,最主要的逻辑就是在for循环,这里for循环模拟的是请求次数,由于线程池核心线程数和最大线程数都是1,此时有两个请求到来,那两个请求就一定会共用线程池中唯一的一个线程。此时,我们在for循环中将模拟的请求提交到线程池。首先,会从THREAD_LOCAL中获取数据并进行打印,随后会向THREAD_LOCAL中存储数据,接下来,再次从THREAD_LOCAL中获取数据并进行打印。按理说,两个请求之前没有影响才对,可事实却是在第2次请求中会获取到第1次请求存储到THREAD_LOCAL中的数据。

# 查看全文

加入冰河技术 (opens new window)知识星球,解锁完整技术文章与完整代码