01 |使用了并发工具类库,线程安全就高枕无忧了吗?

01 |使用了并发工具类库,线程安全就高枕无忧了吗?

ThreadLocal

在生产上遇到一个诡异的问题,有时获取到的用户信息是别人的。查看代码后,我发现他使用了 ThreadLocal 来缓存获取到的用户信息。
在spring boot里,使用ThreadLocal 存放一个 Integer 的值,初始值应该是null,先获取值,在根据信息去设置值。

public Map wrong(@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 result = new HashMap();
  result.put("before", before);
  result.put("after", after);
  return result;
}

第一次运行:userId=1
before: http-n1o-8080-exec-1: null
after: http-nio-8080-exec-1: 1

第二次运行:userId=2
before: http-n1o-8080-exec-1: 1
after: http-nio-8080-exec-1: 2

首先要理解代码会跑在什么线程上,在 Tomcat 这种 Web 服务器下跑的业务代码,本来就运行在一个多线
程环境(否则接口也不可能支持这么高的并发),并不能认为没有显式开启多线程就不会有线程安全问题
因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。
修改成这样

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 {
    //在finally代码块中删除ThreadLocal中的数据,确保数据不串
    currentUser.remove();
  }
}

ConcurrentHashMap

不是使用了ConcurrentHashMap线程就安全了。
场景:有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。
实现:在每一个线程的代码逻辑中先通过 size 方法拿到当前元素数量,计算 ConcurrentHashMap 目前还需要补充多少元素,并在日志中输出了这个值,然后通过 putAll 方法把缺少的元素添加进去。
结果:最后 HashMap 的总项目数是 1536,显然不符合填充满 1000 的预期。
这个过程分成了几步了,不是原子性了,导致了并发安全问题
使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。
诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映ConcurrentHashMap 的中间状态
解决:加一个锁就可以了。

没有充分了解并发工具的特性,从而无法发挥其威力

场景:使用 ConcurrentHashMap 来统计,Key 的范围是 10。使用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 Key。如果 Key 不存在的话,首次设置值为 1。
吸取之前的教训,直接通过锁的方式锁住 Map,然后做判断、读取现在的累计值、加1、保存累加后值的逻辑

//循环次数
private static int LOOP_COUNT = 10000000;
//线程数量
private static int THREAD_COUNT = 10;
//元素数量
private static int ITEM_COUNT = 1000;
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()
  //获得一个随机的Key
  String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
  synchronized (freqs) {
    if (freqs.containsKey(key)) {
      //Key存在则+1
      freqs.put(key, freqs.get(key) + 1);
    } else {
      //Key不存在则初始化为1
      freqs.put(key, 1L);
    }
  }
  }));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return freqs;
}

但无法充分发挥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()
    String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
    // 利用computeIfAbsent() 方法来实例化 LongAdder,然后利用LongAdder来进行
    freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
  }
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//因为我们的Value是LongAdder而不是Long,所以需要做一次转换才能返回
return freqs.entrySet().stream().collect(Collectors.toMap(
  e -> e.getKey(), e -> e.getValue().longValue()) );
}

使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 来做复合逻辑操作,判断Key 是否存在 Value,如果不存在则把 Lambda 表达式运行后的结果放入 Map 作为Value,也就是新创建一个 LongAdder 对象,最后返回 Value。由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一个线程安全的累加器,因此可以直接调用其 increment 方法进行累加。
computeIfAbsent 高效的原因是java 自带的 Unsafe 实现的 CAS。它在虚拟机层面确保了写入数据的原子性
这样在确保线程安全的情况下达到极致性能,把之前 7 行代码替换为了 1 行。

CopyOnWriteArrayList

没有认清并发工具的使用场景,因而导致性能问题
CopyOnWriteArrayList 虽然是一个线程安全的 ArrayList,但因为其实现方式是,每次修改数据时都会复制一份数据出来,所以有明显的适用场景,即读多写少或者说希望无锁读的场景。
大量写的场景(10 万次 add 操作),CopyOnWriteArray 几乎比同步的 ArrayList 慢一百倍。
而在大量读的场景下(100 万次 get 操作),CopyOnWriteArray 又比同步的 ArrayList快五倍以上

  1. 一定要认真阅读官方文档(比如 Oracle JDK 文档)。充分阅读官方文档,理解工具的适用场景及其 API 的用法,并做一些小实验。了解之后再去使用,就可以避免大部分坑。
  2. 如果你的代码运行在多线程环境下,那么就会有并发问题,并发问题不那么容易重现,可能需要使用压力测试模拟并发场景,来发现其中的 Bug 或性能问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值