Java并发包的引入,可以非常方便的帮助开发者避免一些多线程情况下的线程安全问题。那我们在使用Java并发工具时,就真的可以高枕无忧了吗?

然而现实总是残酷的,我们依旧会遇到一些坑。这里将带领大家一起扒开这些坑去看一看……

线程重用导致用户信息错乱

我们常常使用ThreadLocal来实现同一线程内的变量共享,通常这些资源是比较昂贵的,比如说用户信息。但美好设想背后可能也会有意想不到的陷阱。

下面我们采用一个具体的示例来看看这个陷阱。

下面这段代码使用了一个ThreadLocal存放Integer类型的用户id,它的初始值为null。业务逻辑是先从当前线程变量中获取值连同线程id一并记录下来,然后再修改变量再记录,最后将记录返回放到一个map中返回。

private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);


public Map<String, Object> exec(Integer userId) {
    // 设置用户信息之前先查询一次ThreadLocal中的用户信息
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    // 设置用户信息到ThreadLocal
    currentUser.set(userId);
    // 设置用户信息之后再查询一次ThreadLocal中的用户信息
    String after  = Thread.currentThread().getName() + ":" + currentUser.get();
    // 汇总输出两次查询结果
    Map<String, Object> result = new HashMap<>();
    result.put("before", before);
    result.put("after", after);
    return result;
}

如果是不同线程访问这段程序,通常来讲第一次取到的id一定是null值。但如果我们使用线程池来访问呢?

大家都了解,线程池一般会设定核心线程和最大线程,通常有几个固定线程资源。那一旦发生线程复用,后面的访问可能取到的ThreadLocal的值就不是我们预想的那样了。

接下来我们编写一个测试demo来看看:

@Test
public void exec() {
    // 创建一个固定线程数为1的线程池
    ExecutorService threadPool = Executors.newFixedThreadPool(1);

    List<Object> result = new ArrayList<>(2);

    for (int i = 0; i < 2; i++) {
        threadPool.execute(() -> {
            ThreadReuse threadReuse = new ThreadReuse();
            Map<String, Object> res = threadReuse.exec(1);
            result.add(res);
        });
    }

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        System.out.println(e.getMessage());
    }
    result.forEach(System.out::println);
    threadPool.shutdown();
}

执行上面程序得到结果为:

{before=pool-1-thread-1:null, after=pool-1-thread-1:1}
{before=pool-1-thread-1:1, after=pool-1-thread-1:1}

最终验证发现,线程复用的情况下,从共享变量里取到的值就是上一个业务遗留的。同样的,我们在使用Tomcat时,由于tomcat内部也是线程复用的,所以也会存在同样的情况。

那针对这个问题,我们的解决方案通常是,及时释放资源。修正后的代码为:

private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);


public Map<String, Object> exec(Integer userId) {
    // 设置用户信息之前先查询一次ThreadLocal中的用户信息
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    // 设置用户信息到ThreadLocal
    currentUser.set(userId);
    //设置用户信息之后再查询一次ThreadLocal中的用户信息
    String after  = Thread.currentThread().getName() + ":" + currentUser.get();
    try {
        // 通常是执行具体业务逻辑... 省略
        // 汇总输出两次查询结果
        Map<String, Object> result = new HashMap<>();
        result.put("before", before);
        result.put("after", after);
        return result;
    } finally {
        // 释放ThreadLocal中的用户信息
        currentUser.remove();
    }
}

此时再继续执行我们的测试程序,得到的结果就是预期的了。

{before=pool-1-thread-1:null, after=pool-1-thread-1:1}
{before=pool-1-thread-1:null, after=pool-1-thread-1:1}

这个例子我们,在编写程序的时候,一定要清晰的理解我们的业务逻辑和所使用的工具原理,避免在复杂的场景中出现不可预期的结果。