本文为博主自学笔记整理,内容来源于互联网,如有侵权,请联系删除。
我们来看看在使用并发工具时,经常遇到哪些坑,以及如何解决、避免这些坑。
踩坑1:线程池中使用 ThreadLocal 导致数据串了
- 案例场景
某业务组同学在生产上有时获取到的用户信息是别人的。使用的代码如下。
@GetMapping("wrong")
public Map wrong(@RequestParam("userId") Integer userId) {
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(userId);
String after = Thread.currentThread().getName() + ":" + currentUser.get();
Map result = new HashMap();
result.put("before", before);
result.put("after", after);
return result;
}
为了更快地重现这个问题,我在配置文件中设置一下 Tomcat 的参数,把工作线程池最大线程数设置为 1,这样始终是同一个线程在处理请求:
server.tomcat.max-threads=1
先让用户 1 来请求接口,然后调用接口得到如下结果:
{
"before": "http-nio-8080-exec-1:null",
"after": "http-nio-8080-exec-1:1"
}
可以看到第一和第二次获取到用户 ID 分别是 null 和 1,符合预期。接口用户 2 来请求接口,这次就出现了 Bug:
{
"before": "http-nio-8080-exec-1:1",
"after": "http-nio-8080-exec-1:2"
}
第一次和第二次获取到用户 ID 分别是 1 和 2,显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程。从上面结果可以看到,两次请求的线程都是同一个线程:http-nio-8080-exec-1。
- 原因分析
ThreadLocal 适用于变量在线程间隔离,上述代码使用了 ThreadLocal 来缓存获取到的用户信息。程序运行在 Tomcat 中,而 Tomcat 的工作线程是基于线程池的。
- 解决方案
@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(userId);
try {
String after = Thread.currentThread().getName() + ":" + currentUser.get();
Map result = new HashMap();
result.put("before", before);
result.put("after", after);
return result;
} finally {
currentUser.remove();
}
}
踩坑2:使用 ConcurrentHashMap 没对复合逻辑加锁导致业务逻辑错误
- 案例场景
有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。
开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题。于是写出了下面的代码:
private static int THREAD_COUNT = 10;
private static int ITEM_COUNT = 1000;
private ConcurrentHashMap<String, Long> getData(int count) {
return LongStream.rangeClosed(1, count)
.boxed()
.collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
(o1, o2) -> o1, ConcurrentHashMap::new));
}
@GetMapping("wrong")
public String wrong() throws InterruptedException {
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
// 计算还需要补充多少元素
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("gap size:{}", gap);
// 将缺少的元素添加进去
concurrentHashMap.putAll(getData(gap));
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info("finish size:{}", concurrentHashMap.size());
return "OK";
}
结果输出日志如下:
初始大小 900 符合预期,还需要填充 100 个元素。
worker1 线程查询到当前需要填充的元素为 36,竟然还不是 100 的倍数。worker13 线程查询到需要填充的元素数是负的,显然已经过度填充了。最后 HashMap 的总项目数是 1536,显然不符合填充满 1000 的预期。
- 原因分析
ConcurrentHashMap 就像是一个大篮子, 现在这个篮子里有 900 个桔子,我们期望把这个篮子装满 1000 个桔子,也就是再装 100 个桔子。有 10 个工人来干这件事儿,大家先后到岗后会计算还需要补多少个桔子进去,最后把桔子装入篮子。
但是,工人往这个篮子装 100 个桔子的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有 964 个桔子,还需要补 36 个桔子。
回到 ConcurrentHashMap,它只能保证原子性读写的操作是线程安全的。而诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下反映的都是 ConcurrentHashMap 的中间状态。
- 解决方案
解决思路,还是以上面的场景举例。哪个工人先拿到这个篮子,就由这个工人将剩下的桔子放进篮子里去。
@GetMapping("right")
public String right() throws InterruptedException {
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
// parallel 默认是 cpu-1 个并发
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
synchronized (concurrentHashMap) {
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("gap size:{}", gap);
concurrentHashMap.putAll(getData(gap));
}
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info("finish size:{}", concurrentHashMap.size());
return "OK";
}
输出日志如下:
踩坑3:使用了 ConcurrentHashMap 但没有充分利用其基于 CAS 安全的方法导致性能问题
- 案例场景
使用 Map 来统计 Key 出现的次数。具体使用如下:
- 使用 ConcurrentHashMap 来统计,Key 的范围是 item0~item10;
- 使用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 Key;
- 如果 Key 不存在的话,首次设置值为 1。
private static int LOOP_COUNT = 10000000;
private static int THREAD_COUNT = 10;
private static int ITEM_COUNT = 10;
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;
}
为什么要对 freqs 加锁?
如果没有锁,2个线程判断 item0 在集合中不存在,同时走到 else 块,会造成统计 item0 应该为 2,而实际为 1。
这种实现方式是一般的实现,所以叫 normal use。不会导致错误的结果,但在性能上却存在问题,因为锁的粒度较大。
- 原因分析
computeIfAbsent 高效的原因是它使用了 Java 自带的 Unsafe 实现的 CAS,它在虚拟机层面确 保了写入数据的原子性,比加锁的效率高得多。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
- 解决方案
private Map<String, Long> gooduse() throws InterruptedException {
// 这里的 Key 变成 LongAdder 了
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);
// LongAdder # increment() 是线程安全的
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())
);
}
来做一个性能测试,测试代码如下:
@GetMapping("good")
public String good() throws InterruptedException {
StopWatch stopWatch = new StopWatch();
stopWatch.start("normaluse");
Map<String, Long> normaluse = normaluse();
stopWatch.stop();
Assert.isTrue(normaluse.size() == ITEM_COUNT, "normaluse size error");
Assert.isTrue(normaluse.entrySet().stream()
.mapToLong(item -> item.getValue())
.reduce(0, Long::sum) == LOOP_COUNT
, "normaluse count error");
stopWatch.start("gooduse");
Map<String, Long> gooduse = gooduse();
stopWatch.stop();
Assert.isTrue(gooduse.size() == ITEM_COUNT, "gooduse size error");
Assert.isTrue(gooduse.entrySet().stream()
.mapToLong(item -> item.getValue())
.reduce(0, Long::sum) == LOOP_COUNT
, "gooduse count error");
log.info(stopWatch.prettyPrint());
return "OK";
}
结果如下:
性能提升了 2.8s/0.3s = 9.3 倍。
踩坑4:在写操作很多的场景下使用 CopyOnWriteArrayList 导致性能问题
- 案例场景
之前在排查一个生产性能问题时,我们发现一段简单的非数据库操作的业务逻辑,消耗了超出预期的时间,在修改数据时操作本地缓存比回写数据库慢许多。查看代码发现,开发同学使用了 CopyOnWriteArrayList 来缓存大量的数据,而数据变化又比较频繁。
- 原因分析
在 Java 中,CopyOnWriteArrayList 虽然是一个线程安全的 ArrayList,但因为其实现方式是,每次修改数据时都会用 Arrays.copyOf 复制一份数据出来。下面是 CopyOnWriteArrayList 部分源码。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
每次 add 时,都会用 Arrays.copyOf 创建一个新数组,频繁 add 时内存的申请释放消耗会很大。所以有明显的适用场景,即读多写少或者说希望无锁读的场景。
通过下面代码来分析一下 CopyOnWriteArrayList 和普通加锁方式 ArrayList 的读写性能。
@GetMapping("write")
public Map testWrite() {
List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
StopWatch stopWatch = new StopWatch();
int loopCount = 100000;
stopWatch.start("Write:copyOnWriteArrayList");
IntStream.rangeClosed(1, loopCount)
.parallel()
.forEach(__ -> copyOnWriteArrayList.add(ThreadLocalRandom.current().nextInt(loopCount)));
stopWatch.stop();
stopWatch.start("Write:synchronizedList");
IntStream.rangeClosed(1, loopCount)
.parallel()
.forEach(__ -> synchronizedList.add(ThreadLocalRandom.current().nextInt(loopCount)));
stopWatch.stop();
log.info(stopWatch.prettyPrint());
Map result = new HashMap();
result.put("copyOnWriteArrayList", copyOnWriteArrayList.size());
result.put("synchronizedList", synchronizedList.size());
return result;
}
private void addAll(List<Integer> list) {
list.addAll(IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList()));
}
@GetMapping("read")
public Map testRead() {
List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
addAll(copyOnWriteArrayList);
addAll(synchronizedList);
StopWatch stopWatch = new StopWatch();
int loopCount = 1000000;
int count = copyOnWriteArrayList.size();
stopWatch.start("Read:copyOnWriteArrayList");
IntStream.rangeClosed(1, loopCount)
.parallel()
.forEach(__ -> copyOnWriteArrayList.get(ThreadLocalRandom.current().nextInt(count)));
stopWatch.stop();
stopWatch.start("Read:synchronizedList");
IntStream.range(0, loopCount)
.parallel()
.forEach(__ -> synchronizedList.get(ThreadLocalRandom.current().nextInt(count)));
stopWatch.stop();
log.info(stopWatch.prettyPrint());
Map result = new HashMap();
result.put("copyOnWriteArrayList", copyOnWriteArrayList.size());
result.put("synchronizedList", synchronizedList.size());
return result;
}
测试结果如下。
10 万次写操作,CopyOnWriteArray 比同步的 ArrayList 慢 11 倍:
100 万次读操作,CopyOnWriteArray 比同步的 ArrayList 快 5 倍:
- 解决方案
使用 ConcurrentHashMap 来缓存。