极客时间已完结课程限时免费阅读

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

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

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

讲述:王少泽

时长18:29大小14.81M

你好,我是朱晔。作为课程的第一讲,我今天要和你聊聊使用并发工具类库相关的话题。
在代码审核讨论的时候,我们有时会听到有关线程安全和并发工具的一些片面的观点和结论,比如“把 HashMap 改为 ConcurrentHashMap,就可以解决并发问题了呀”“要不我们试试无锁的 CopyOnWriteArrayList 吧,性能更好”。事实上,这些说法都不太准确。
的确,为了方便开发者进行多线程编程,现代编程语言会提供各种并发工具类。但如果我们没有充分了解它们的使用场景、解决的问题,以及最佳实践的话,盲目使用就可能会导致一些坑,小则损失性能,大则无法确保多线程情况下业务逻辑的正确性。
我需要先说明下,这里的并发工具类是指用来解决多线程环境下并发问题的工具类库。一般而言并发工具包括同步器和容器两大类,业务代码中使用并发容器的情况会多一些,我今天分享的例子也会侧重并发容器。
接下来,我们就看看在使用并发工具时,最常遇到哪些坑,以及如何解决、避免这些坑吧。

没有意识到线程重用导致用户信息错乱的 Bug

之前有业务同学和我反馈,在生产上遇到一个诡异的问题,有时获取到的用户信息是别人的。查看代码后,我发现他使用了 ThreadLocal 来缓存获取到的用户信息。
我们知道,ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。如果用户信息的获取比较昂贵(比如从数据库查询用户信息),那么在 ThreadLocal 中缓存数据是比较合适的做法。但,这么做为什么会出现用户信息错乱的 Bug 呢?
我们看一个具体的案例吧。
使用 Spring Boot 创建一个 Web 应用程序,使用 ThreadLocal 存放一个 Integer 的值,来暂且代表需要在线程中保存的用户信息,这个值初始是 null。在业务逻辑中,我先从 ThreadLocal 获取一次值,然后把外部传入的参数设置到 ThreadLocal 中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("wrong")
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;
}
按理说,在设置用户信息之前第一次获取的值始终应该是 null,但我们要意识到,程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的。
顾名思义,线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息。
为了更快地重现这个问题,我在配置文件中设置一下 Tomcat 的参数,把工作线程池最大线程数设置为 1,这样始终是同一个线程在处理请求:
server.tomcat.max-threads=1
运行程序后先让用户 1 来请求接口,可以看到第一和第二次获取到用户 ID 分别是 null 和 1,符合预期:
随后用户 2 来请求接口,这次就出现了 Bug,第一和第二次获取到用户 ID 分别是 1 和 2,显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程。从图中可以看到,两次请求的线程都是同一个线程:http-nio-8080-exec-1。
这个例子告诉我们,在写业务代码时,首先要理解代码会跑在什么线程上:
我们可能会抱怨学多线程没用,因为代码里没有开启使用多线程。但其实,可能只是我们没有意识到,在 Tomcat 这种 Web 服务器下跑的业务代码,本来就运行在一个多线程环境(否则接口也不可能支持这么高的并发),并不能认为没有显式开启多线程就不会有线程安全问题
因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。
理解了这个知识点后,我们修正这段代码的方案是,在代码的 finally 代码块中,显式清除 ThreadLocal 中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。修正后的代码如下:
@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 {
//在finally代码块中删除ThreadLocal中的数据,确保数据不串
currentUser.remove();
}
}
重新运行程序可以验证,再也不会出现第一次查询用户信息查询到之前用户请求的 Bug:
ThreadLocal 是利用独占资源的方式,来解决线程安全问题,那如果我们确实需要有资源在线程之间共享,应该怎么办呢?这时,我们可能就需要用到线程安全的容器了。

使用了线程安全的并发工具,并不代表解决了所有线程安全问题

JDK 1.5 后推出的 ConcurrentHashMap,是一个高性能的线程安全的哈希表容器。“线程安全”这四个字特别容易让人误解,因为 ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的。
我在相当多的业务代码中看到过这个误区,比如下面这个场景。有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题,于是不加思索地写出了下面的代码:在每一个线程的代码逻辑中先通过 size 方法拿到当前元素数量,计算 ConcurrentHashMap 目前还需要补充多少元素,并在日志中输出了这个值,然后通过 putAll 方法把缺少的元素添加进去。
为方便观察问题,我们输出了这个 Map 一开始和最后的元素个数。
//线程个数
private static int THREAD_COUNT = 10;
//总元素数量
private static int ITEM_COUNT = 1000;
//帮助方法,用来获得一个指定元素数量模拟数据的ConcurrentHashMap
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);
//初始900个元素
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);
//最后元素个数会是1000吗?
log.info("finish size:{}", concurrentHashMap.size());
return "OK";
}
访问接口后程序输出的日志内容如下:
从日志中可以看到:
初始大小 900 符合预期,还需要填充 100 个元素。
worker1 线程查询到当前需要填充的元素为 36,竟然还不是 100 的倍数。
worker13 线程查询到需要填充的元素数是负的,显然已经过度填充了。
最后 HashMap 的总项目数是 1536,显然不符合填充满 1000 的预期。
针对这个场景,我们可以举一个形象的例子。ConcurrentHashMap 就像是一个大篮子,现在这个篮子里有 900 个桔子,我们期望把这个篮子装满 1000 个桔子,也就是再装 100 个桔子。有 10 个工人来干这件事儿,大家先后到岗后会计算还需要补多少个桔子进去,最后把桔子装入篮子。
ConcurrentHashMap 这个篮子本身,可以确保多个工人在装东西进去时,不会相互影响干扰,但无法确保工人 A 看到还需要装 100 个桔子但是还未装的时候,工人 B 就看不到篮子中的桔子数量。更值得注意的是,你往这个篮子装 100 个桔子的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有 964 个桔子,还需要补 36 个桔子。
回到 ConcurrentHashMap,我们需要注意 ConcurrentHashMap 对外提供的方法或能力的限制
使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。
诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映 ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。显然,利用 size 方法计算差异值,是一个流程控制。
诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据。
代码的修改方案很简单,整段逻辑加锁即可:
@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);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
//下面的这段复合逻辑需要锁一下这个ConcurrentHashMap
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";
}
重新调用接口,程序的日志输出结果符合预期:
可以看到,只有一个线程查询到了需要补 100 个元素,其他 9 个线程查询到不需要补元素,最后 Map 大小为 1000。
到了这里,你可能又要问了,使用 ConcurrentHashMap 全程加锁,还不如使用普通的 HashMap 呢。
其实不完全是这样。
ConcurrentHashMap 提供了一些原子性的简单复合逻辑方法,用好这些方法就可以发挥其威力。这就引申出代码中常见的另一个问题:在使用一些类库提供的高级工具类时,开发人员可能还是按照旧的方式去使用这些新类,因为没有使用其特性,所以无法发挥其威力。

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

我们来看一个使用 Map 来统计 Key 出现次数的场景吧,这个逻辑在业务代码中非常常见。
使用 ConcurrentHashMap 来统计,Key 的范围是 10。
使用最多 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 -> {
//获得一个随机的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;
}
我们吸取之前的教训,直接通过锁的方式锁住 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);
//利用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 方法进行累加。
这样在确保线程安全的情况下达到极致性能,把之前 7 行代码替换为了 1 行。
我们通过一个简单的测试比较一下修改前后两段代码的性能:
@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";
}
这段测试代码并无特殊之处,使用 StopWatch 来测试两段代码的性能,最后跟了一个断言判断 Map 中元素的个数以及所有 Value 的和,是否符合预期来校验代码的正确性。测试结果如下:
可以看到,优化后的代码,相比使用锁来操作 ConcurrentHashMap 的方式,性能提升了 10 倍
你可能会问,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.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
像 ConcurrentHashMap 这样的高级并发工具的确提供了一些高级 API,只有充分了解其特性才能最大化其威力,而不能因为其足够高级、酷炫盲目使用。

没有认清并发工具的使用场景,因而导致性能问题

除了 ConcurrentHashMap 这样通用的并发工具类之外,我们的工具包中还有些针对特殊场景实现的生面孔。一般来说,针对通用场景的通用解决方案,在所有场景下性能都还可以,属于“万金油”;而针对特殊场景的特殊实现,会有比通用解决方案更高的性能,但一定要在它针对的场景下使用,否则可能会产生性能问题甚至是 Bug。
之前在排查一个生产性能问题时,我们发现一段简单的非数据库操作的业务逻辑,消耗了超出预期的时间,在修改数据时操作本地缓存比回写数据库慢许多。查看代码发现,开发同学使用了 CopyOnWriteArrayList 来缓存大量的数据,而数据变化又比较频繁。
CopyOnWrite 是一个时髦的技术,不管是 Linux 还是 Redis 都会用到。在 Java 中,CopyOnWriteArrayList 虽然是一个线程安全的 ArrayList,但因为其实现方式是,每次修改数据时都会复制一份数据出来,所以有明显的适用场景,即读多写少或者说希望无锁读的场景。
如果我们要使用 CopyOnWriteArrayList,那一定是因为场景需要而不是因为足够酷炫。如果读写比例均衡或者有大量写操作的话,使用 CopyOnWriteArrayList 的性能会非常糟糕。
我们写一段测试代码,来比较下使用 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");
//循环100000次并发往CopyOnWriteArrayList写入随机元素
IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -> copyOnWriteArrayList.add(ThreadLocalRandom.current().nextInt(loopCount)));
stopWatch.stop();
stopWatch.start("Write:synchronizedList");
//循环100000次并发往加锁的ArrayList写入随机元素
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;
}
//帮助方法用来填充List
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");
//循环1000000次并发从CopyOnWriteArrayList随机查询元素
IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -> copyOnWriteArrayList.get(ThreadLocalRandom.current().nextInt(count)));
stopWatch.stop();
stopWatch.start("Read:synchronizedList");
//循环1000000次并发从加锁的ArrayList随机查询元素
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 万次 add 操作),CopyOnWriteArray 几乎比同步的 ArrayList 慢一百倍
而在大量读的场景下(100 万次 get 操作),CopyOnWriteArray 又比同步的 ArrayList 快五倍以上:
你可能会问,为何在大量写的场景下,CopyOnWriteArrayList 会这么慢呢?
答案就在源码中。以 add 方法为例,每次 add 时,都会用 Arrays.copyOf 创建一个新数组,频繁 add 时内存的申请释放消耗会很大:
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
}

重点回顾

今天,我主要与你分享了,开发人员使用并发工具来解决线程安全问题时容易犯的四类错。
一是,只知道使用并发工具,但并不清楚当前线程的来龙去脉,解决多线程问题却不了解线程。比如,使用 ThreadLocal 来缓存数据,以为 ThreadLocal 在线程之间做了隔离不会有线程安全问题,没想到线程重用导致数据串了。请务必记得,在业务逻辑结束之前清理 ThreadLocal 中的数据。
二是,误以为使用了并发工具就可以解决一切线程安全问题,期望通过把线程不安全的类替换为线程安全的类来一键解决问题。比如,认为使用了 ConcurrentHashMap 就可以解决线程安全问题,没对复合逻辑加锁导致业务逻辑错误。如果你希望在一整段业务逻辑中,对容器的操作都保持整体一致性的话,需要加锁处理。
三是,没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。比如,使用了 ConcurrentHashMap,但没有充分利用其提供的基于 CAS 安全的方法,还是使用锁的方式来实现逻辑。你可以阅读一下ConcurrentHashMap 的文档,看一下相关原子性操作 API 是否可以满足业务需求,如果可以则优先考虑使用。
四是,没有了解清楚工具的适用场景,在不合适的场景下使用了错误的工具导致性能更差。比如,没有理解 CopyOnWriteArrayList 的适用场景,把它用在了读写均衡或者大量写操作的场景下,导致性能问题。对于这种场景,你可以考虑是用普通的 List。
其实,这四类坑之所以容易踩到,原因可以归结为,我们在使用并发工具的时候,并没有充分理解其可能存在的问题、适用场景等。所以最后,我还要和你分享两点建议
一定要认真阅读官方文档(比如 Oracle JDK 文档)。充分阅读官方文档,理解工具的适用场景及其 API 的用法,并做一些小实验。了解之后再去使用,就可以避免大部分坑。
如果你的代码运行在多线程环境下,那么就会有并发问题,并发问题不那么容易重现,可能需要使用压力测试模拟并发场景,来发现其中的 Bug 或性能问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。

思考与讨论

今天我们多次用到了 ThreadLocalRandom,你觉得是否可以把它的实例设置到静态变量中,在多线程情况下重用呢?
ConcurrentHashMap 还提供了 putIfAbsent 方法,你能否通过查阅JDK 文档,说说 computeIfAbsent 和 putIfAbsent 方法的区别?
你在使用并发工具时,还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 112

提建议

上一篇
开篇词 | 业务代码真的会有这么多坑?
下一篇
02 | 代码加锁:不要让“锁”事成为烦心事
unpreview
 写留言

精选留言(105)

  • broccoli
    置顶
    2020-03-10
    尝试回答一下思考题: - 1. 先说结论:不可以,结果是除了初始化 ThreadLocalRandom 的主线程获取的随机值是无模式的(调用者不可预测下个返回值,满足我们对伪随机的要求)之外,其他线程获得随机值都不是相互独立的(本质上来说,是因为他们用于生成随机数的种子 seed 的值可预测的,为 i*gamma,其中 i 是当前线程调用随机数生成方法次数,而 gamma 是 ThreadLocalRandom 类的一个 long 静态字段值)。例如,一个有趣的现象是,所有非初始化 ThreadLocalRandom 实例的线程如果调用相同次数的 nextInt() 方法,他们得到的随机数串是完全相同的。 造成这样现象的原因在于,ThreadLocalRandom 类维护了一个类单例字段,线程通过调用 ThreadLocalRandom#current() 方法来获取 ThreadLocalRandom 单例,然后以线程维护的实例字段 threadLocalRandomSeed 为种子生成下一个随机数和下一个种子值。 那么既然是单例模式,为什么多线程共用主线程初始化的实例就会出问题呢。问题就在于 current 方法,线程在调用 current() 方法的时候,会根据用每个线程的 thread 的一个实例字段 threadLocalRandomProbe 是否为 0 来判断是否当前线程实例是否为第一次调用随机数生成方法,从而决定是否要给当前线程初始化一个随机的 threadLocalRandomSeed 种子值。因此,如果其他线程绕过 current 方法直接调用随机数方法,那么它的种子值就是 0, 1*gamma, 2*gamma... 因此也就是可预测的了。 - 2. 两个方法的区别除了其他同学在评论区提出的参数类型不同以及抛出异常类型不同之外,在文中示例选择 CIA 而不选择 PIA 的原因(以及老师为什么点出来的原因)在于他们在面对 absent key值上的区别: - CIA 根据 mappingFunction 返回的值插入键值对,然后返回这个新值 - 而 PIA 是插入 KV 对后,返回 null 值 因此,如果我们将文中的 CIA 替换成 PIA,如果插入的是 absent key 会抛出空指针异常。其实,在我看来文中示例用 PIA 也不是不行,只要改成先 PIA,然后再去 get(key) 获取那个原子类型 long 然后再自增就 ok 了。(不确定对错,还请老师指正) 那么老师为什么没有这么写呢? - 一是每调用一次这些方法都伴随着一次片段锁的获取与释放,显然 PIA 方法性能要差 - (二就是不够优雅,老师嫌字多...)
    展开

    作者回复: 说的非常细非常好 computeIfAbsent和putIfAbsent区别是三点: 1、当Key存在的时候,如果Value获取比较昂贵的话,putIfAbsent就白白浪费时间在获取这个昂贵的Value上(这个点特别注意) 2、Key不存在的时候,putIfAbsent返回null,小心空指针,而computeIfAbsent返回计算后的值 3、当Key不存在的时候,putIfAbsent允许put null进去,而computeIfAbsent不能,之后进行containsKey查询是有区别的(当然了,此条针对HashMap,ConcurrentHashMap不允许put null value进去)

    共 8 条评论
    97
  • 何岸康
    置顶
    2020-03-16
    问题一:不可以。ThreadLocalRandom文档里写了Usages of this class should typically be of the form:ThreadLocalRandom.current().nextX(...)} (where X is Int, Long, etc)。 ThreadLocalRandom类中只封装了一些公用的方法,种子存放在各个线程中。 ThreadLocalRandom中存放一个单例的instance,调用current()方法返回这个instance,每个线程首次调用current()方法时,会在各个线程中初始化seed和probe。 nextX()方法会调用nextSeed(),在其中使用各个线程中的种子,计算下一个种子并保存(UNSAFE.getLong(t, SEED) + GAMMA)。 所以,如果使用静态变量,直接调用nextX()方法就跳过了各个线程初始化的步骤,只会在每次调用nextSeed()时来更新种子。 问题二 1.参数不一样,putIfAbsent是值,computeIfAbsent是mappingFunction 2.返回值不一样,putIfAbsent是之前的值,computeIfAbsent是现在的值 3.putIfAbsent可以存入null,computeIfAbsent计算结果是null只会返回null,不会写入。
    展开

    作者回复: 非常完美的回答

    62
  • Wiggle Wiggle
    置顶
    2020-03-10
    关于 ThreadLocalRandom,其目的是为了避免多线程共享 Random 时竟态条件下性能差的问题(我认为关键在于 Random#nextSeed 方法中使用自旋保证线程安全,而自旋在面对高并发时性能差),官方文档上说正确用法是 ThreadLocalRandom.current().nextX(...),但是没说设置为 static 的话会发生什么,我想进一步研究一下,就去看了一下源码,不知道理解对不对,请老师指正:ThreadLocalRandom#nextSeed 方法中用到了 UnSafe,这块我不了解,但是我没有看到任何保证线程安全的代码,如果并发调用的话会导致无法预料的问题。
    展开

    作者回复: 基本原理是,current()的时候初始化一个初始化种子到线程,每次nextseed再使用之前的种子生成新的种子: UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA); 如果你通过主线程调用一次current生成一个ThreadLocalRandom的实例保存起来,那么其它线程来获取种子的时候必然取不到初始种子,必须是每一个线程自己用的时候初始化一个种子到线程,你可以在nextSeed设置一个断点看看: UNSAFE.getLong(Thread.currentThread(),SEED);

    15
  • le
    2020-03-09
    我有一点不太明白,那ThreadLocal的意义呢? 难得是在特定情况下?如:没有用线程池?或者是不想写参数传递值? 用ThreadLocal 从controller传递到dao中 一个请求结束之前给他把值 清空吗(小白一个...求大佬解答)

    作者回复: controller向dao传值没有必要,ThreadLocal可以理解为绑定到线程的Map,相同线程的不同逻辑需要共享数据(但又无法通过传值来共享数据),或为了避免相同线程重复创建对象希望重用数据,可以考虑使用ThreadLocal

    共 8 条评论
    27
  • pedro
    2020-03-10
    第一节就已经收获颇丰了,吾尝终日而思矣,不如须臾之所学也。
    25
  • 汝林外史
    2020-03-09
    老师的文章真的是最贴近开发实际,绝对超值。看您代码中都是用的lambda表达式,我工作中都不知道怎么应用,请问老师针对lambda表达式应该怎么深入学习呢?

    作者回复: 专栏会有一篇加餐来介绍

    共 8 条评论
    18
  • Darren
    2020-03-10
    试着回答下问题: 1、ThreadLocalRandom,不能使用静态变量,因为在初始化的时候,通过Unsafe把seed和当前线程绑定了,在多线程情况下,只有主线程和seed绑定了,其他线程在获取seed的时候就是有问题的; 2、computeIfAbsent的value是接受一个Function,而putIfAbsent是是接受一个具体的value,所以computeIfAbsent的使用应该是非常灵活的。
    展开

    作者回复: 👍🏻

    共 2 条评论
    17
  • Daizl
    2020-03-16
    老师,一般而言并发工具包括同步器和容器两大类,这2大类没太明白怎么区分的。

    作者回复: 举例: 容器:ConcurrentHashMap、ConcurrentSkipListMap、CopyOnWriteArrayList、ConcurrentSkipListSet 同步器:CountDownLatch、Semaphore、CyclicBarrier、Phaser、Exchanger

    16
  • 编程界的小学生
    2020-03-09
    看完这篇文章才恍然大悟ThreadLocal内存泄露原来是线程池线程复用导致的。
    共 3 条评论
    10
  • Jialin
    2020-03-09
    问题1:ThreadLocalRandom 是 ThreadLocal 类和 Random 类的组合,ThreadLocal的出现就是为了解决多线程访问一个变量时候需要进行同步的问题,让每一个线程拷贝一份变量,每个线程对变量进行操作时候实际是操作自己本地内存里面的拷贝,从而避免了对共享变量进行同步,ThreadLocalRandom的实现也是这个原理,解决了Random类在多线程下多个线程竞争内部唯一的原子性种子变量而导致大量线程自旋重试的不足,因此,类似于ThreadLocal,ThreadLocalRandom的实例也可以设置成静态变量。 问题2: public V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)此方法首先判断缓存map中是否存在指定key的值,如果不存在,会自动调用mappingFunction(key)计算key的value,然后将key = value放入到缓存Map,如果mappingFunction(key)返回的值为null或抛出异常,则不会有记录存入map。 public V putIfAbsent(K key, V value)此方法如果不存在(新的entry),那么会向map中添加该键值对,并返回null。如果已经存在,那么不会覆盖已有的值,直接返回已经存在的值。 相同点:两者均是指定的key不存在其对应的value时,进行操作,指定的key存在对应的value时,直接返回value。 不同点: 线程安全性:putIfAbsent线程非安全,computeIfAbsent线程安全; 返回值:指定key对应的value不存在时,putIfAbsent进行设置并返回null,computeIfAbsent进行计算并返回新值; 异常类型:putIfAbsent可能抛出NullPointerException,computeIfAbsent除了NullPointerException,还存在IllegalStateException()和RuntimeException异常
    展开

    作者回复: 问题1不太对,ThreadLocalRandom的正确使用方式是ThreadLocalRandom.current().nextX(...),不能在多线程之间共享ThreadLocalRandom

    共 3 条评论
    9
  • L.
    2020-03-15
    老师您好,ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的。能否替小白通俗的解释下 怎么理解这句话的原子性与线程安全?谢谢。

    作者回复: 线程安全是指多线程访问的操作ConcurrentHashMap,并不会出现状态不一致,数据错乱,异常等问题。 原子性在于两个方面: 第一,ConcurrentHashMap提供的那些针对单一Key读写的API可以认为是线程安全的,但是诸如putAll这种涉及到多个Key的操作,并发读取可能无法确保读取到完整的数据。 第二,ConcurrentHashMap只能确保提供的API是线程安全的,但是使用者组合使用多个API,ConcurrentHashMap无法从内部确保使用过程中的状态一致。

    8
  • hellojd
    2020-03-11
    ThreadLocalRandom 的使用场景是啥?第一次听说。感觉是为了解决random随机数生成的线程安全问题。线程间传值用TheadLocal就够了

    作者回复: 为了性能,Random用到了compareAndSet + synchronized来解决线程安全问题,虽然可以使用ThreadLocal<Random>来避免竞争,但是无法避免synchronized/compareAndSet带来的开销。考虑到性能还是建议替换使用ThreadLocalRandom(有3倍以上提升),这不是ThreadLocal包装后的Random,而是真正的使用ThreadLocal机制重新实现的Random。

    8
  • 若镜O
    2020-03-09
    super实战性 ,多谢老师的精心整理..

    作者回复: 谢谢

    共 2 条评论
    8
  • 向前走
    2020-03-11
    今天终于知道我们平常没有写Thread,或者线程池,其实它工作在Tomcat的容器下,其实它也是在多线程的环境下,也需要注意多线程下的一些线程安全问题。 老师,我想问下下面的两个问题 1.我们平常在1个方法里,读取mysql的某个表的list数据的时候,在方法里面,用ArrayList来接收,这样会有问题么? 我的理解是首先它在方法里,方法执行时是以栈帧的形式入栈出栈的,栈上面的是线程私有的,所以它是线程安全的,我只是读取,没有修改,那只会不同时候查询出来的数据不一致,可能有新增的数据 2.如果在问题1获取到的数据库列表数据里,进行一些添加和删除操作列表元素,比如我获取到数据库列表的数据后,要加一排合计字段到list中,这样它还是线程安全的么? 是不是只有在类上定义的成员变量,(各种共享资源)如数组,map,list,然后在某个方法里去操作这个共享的集合时才会存在线程安全问题呢 不知道我的理解是不是正确呢,辛苦老师了
    展开

    作者回复: 所谓线程安全问题,只有多线程访问操作共享的资源才会有问题。通常CRUD,获取到的数据,只是局部变量或者是在方法之间串联传递(Controller/Service/Repository),至始至终只有一个线程在操作这些数据,也就是Web服务的工作线程,除非你把这些数据又提及到一个线程池或一个线程去处理。 当这些list是类的字段的时候就要小心了,尤其是当类又是Bean可能成为单例的话就要更小心了,字段被多个线程并发访问都可能有线程安全问题。 业务代码一般都是直肠子的CRUD,并且三层架构都是无状态的,如果Controller/Service是有状态的,或是你使用了线程池做一些异步处理,那么需要小心多线程问题。

    7
  • Monday
    2020-03-13
    lambda看起来真的是起劲。要好好补补课。
    6
  • yihang
    2020-03-18
    还是有疑问❓看了Random源码,只有setSeed方法加了synchronized,而它只会在实现类并非Random类时才会调用(139行),那么这同步开销从何而来?只有next方法是用了cas。

    作者回复: 嗯,锁开销在这里不是主要问题,并发下ThreadLocalRandom会比ThreadLocal<Random>性能好不少,估计在3到8倍左右,既然性能更高就没必要用后者,至于为什么前者会更快你可以继续研究一下 我想了一下,使用ThreadLocal<Random>其实是两个过程,先是从ThreadLocal获得Random,这个开销其实不少的,而ThreadLocalRandom是直接使用unsafe从thread上去拿信息的,2步变为1步,这个开销不得不考虑

    共 4 条评论
    4
  • 刘大明
    2020-03-14
    今天早上5点起来,配置老师的环境,实际的跑一遍老师的代码.学到很多.而且也看到了自己很多知识点的欠缺. 1.很多并发知识并不知道怎么用,怎么学呢? 2.怎么像老师一样熟练学lambda表达式. 希望跟着老师专栏学习的同时,也能好好补一下其他的知识点.

    作者回复: 1. 所有并发工具全部自己做一遍实验,写一下demo 2. 需要时间,下周的加餐我会再来介绍一下lambda的学习

    共 4 条评论
    4
  • 小氘
    2020-03-09
    课后思考题: 1 不能。ThreadLocalRandom的用法是每个线程各用各的,官方文档说ThreadLocalRandom.current().nextX(...)这么用就不会导致在多线程之间共享。 2 他们都是原子操作,都会根据key的存在情况做后续操作,putIfAbsent不会对value处理,computeIfAbsent的第二个参数是Function接口可做的更多。

    作者回复: 👍🏻

    4
  • 听雨
    2020-03-09
    ConcurrentHashMap怎么保证内部数组元素的可见性呢,我看源码里只用volatile修饰了table,但是volatile也保证不了数组里面元素的可见性呀,还请老师解惑!

    作者回复: 看一下tabAt方法,其使用Unsafe的getObjectVolatile直接读取内存数据

    4
  • syp
    2020-04-06
    把所有评论和老师的解答全看了一遍竟然有了更深的理解,正所谓授业解惑👍
    3