ThreadLocal
ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。使用Springboot创建一个web项目,我们使用ThreadLocal来保存用户信息。
问题
按常理来说,第一次获取ThreadLocal的值时应该为null,但是在多线程的情况下可能会有值。原因是因为程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的,所以存在线程的服用,因此我们获取的ThreadLocal的值是上一次线程调用所存储的值,即上一个用户的信息。(并不能认为没有显式开启多线程就不会有线程安全问题。)
解决方案
在代码的 finally 代码块中,显式清除ThreadLocal 中的数据。这样一来,新的请求过来即使使用了旧线程也不会获取到错误的用户信息了。
ConcurrentHashMap
ConcurrentHashMap是一个高性能的线程安全的哈希表容器,但是它只能保证原子性读写操作是线程安全的。并不代表着在多线程下使用它就不会有线程安全的问题。
- 使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。
- 诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。显然,利用 size 方法计算差异值,是一个流程控制。
- 诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据。
在使用一些类库提供的高级工具类时,开发人员可能还是按照旧的方式去使用这些新类,因为没有使用其特性,所以无法发挥其威力。
现在有一个场景:使用 Map 来统计 Key 出现次数。
老方法
private Map<String, Long> normaluse() throws InterruptedException {
ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
//************************
synchronized (freqs) {
if (freqs.containsKey(key)) {
freqs.put(key, freqs.get(key) + 1);
} else {
freqs.put(key, 1L);
}
}
//************************
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return freqs;
}
直接通过锁的方式锁住 Map,然后做判断、读取现在的累计值、加1、保存累加后值的逻辑。这段代码在功能上没有问题,但无法充分发挥ConcurrentHashMap的威力。
新方法
private Map<String, Long> gooduse() throws InterruptedException {
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
//判断key所对应的value是否存在,若不存在则创建一个LongAdder对象返回作为它的value,最后自增
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
//************************
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return freqs.entrySet().stream()
.collect(Collectors.toMap(
e -> e.getKey(),
e -> e.getValue().longValue())
);
}
LongAdder,是一个线程安全的累加器,因此可以直接调用其 increment 方法进行累加。这样在确保线程安全的情况下达到极致性能,把之前 7 行代码替换为了 1 行,且性能更高。 新方法的关键点在于使用了computeIfAbsent,它是通过CAS算法保证写入数据的原子性,比直接加锁更高效。
总结
- 以为 ThreadLocal 在线程之间做了隔离不会有线程安全问题,没想到线程重用导致数据串了。
- 没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。
- 没有了解清楚工具的适用场景,在不合适的场景下使用了错误的工具导致性能更差。比如:没有理解 CopyOnWriteArrayList 的适用场景,把它用在了读写均衡或者大量写操作的场景下,导致性能问题。对于这种场景,可以考虑直接使用普通的 List。