没啥深刻实际的实践系同学,在应用并发工具时,总是认为把 HashMap 改为 ConcurrentHashMap,就完满解决并发了呀。或者应用写时复制的 CopyOnWriteArrayList,性能更佳呀!技术舆论尽管自在,但面对魔鬼面试官时,咱们更在乎的是这些真的正确吗?
1 线程重用导致用户信息错乱
生产环境中,有时获取到的用户信息是他人的。查看代码后,发现是应用了 ThreadLocal 缓存获取到的用户信息。
ThreadLocal 实用于变量在线程间隔离,而在办法或类间共享的场景。若用户信息的获取比拟低廉(比方从 DB 查问),则在 ThreadLocal 中缓存比拟适合。问题来了,为什么有时会呈现用户信息错乱?
1.1 案例
应用 ThreadLocal 寄存一个 Integer 值,代表须要在线程中保留的用户信息,初始 null。先从 ThreadLocal 获取一次值,而后把内部传入的参数设置到 ThreadLocal 中,模仿从以后上下文获取用户信息,随后再获取一次值,最初输入两次取得的值和线程名称。
固定思维认为,在设置用户信息前第一次获取的值始终是 null,但要分明程序运行在 Tomcat,执行程序的线程是 Tomcat 的工作线程,其基于线程池。而线程池会重用固定线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其余用户的申请遗留的值。这时,ThreadLocal 中的用户信息就是其余用户的信息。
1.2 bug 重现
在配置文件设置 Tomcat 参数 - 工作线程池最大线程数设为 1,这样始终是同一线程在解决申请:
server.tomcat.max-threads=1
- 先让用户 1 申请接口,第一、第二次获取到用户 ID 别离是 null 和 1,合乎预期
- 用户 2 申请接口,bug 复现!第一、第二次获取到用户 ID 别离是 1 和 2,显然第一次获取到了用户 1 的信息,因为 Tomcat 线程池重用了线程。两次申请线程都是同一线程:http-nio-45678-exec-1。
写业务代码时,首先要了解代码会跑在什么线程上:
- Tomcat 服务器下跑的业务代码,本就运行在一个多线程环境(否则接口也不可能反对这么高的并发),并不能认为没有显式开启多线程就不会有线程平安问题
- 线程创立较低廉,所以 Web 服务器会应用线程池解决申请,线程会被重用。应用相似 ThreadLocal 工具存放数据时,需注意在代码运行完后,显式清空设置的数据。
1.3 解决方案
在 finally 代码块显式革除 ThreadLocal 中数据。即便新申请过去,应用了之前的线程,也不会获取到谬误的用户信息。修改后代码:
ThreadLocal 利用独占资源的解决线程平安问题,若就是要资源在线程间共享怎么办?就须要用到线程平安的容器。应用了线程平安的并发工具,并不代表解决了所有线程平安问题。
1.4 ThreadLocalRandom 可将其实例设置到动态变量,在多线程下重用吗?
current() 的时候初始化一个初始化种子到线程,每次 nextseed 再应用之前的种子生成新的种子:
UNSAFE.putLong(t = Thread.currentThread(), SEED,
r = UNSAFE.getLong(t, SEED) + GAMMA);
如果你通过主线程调用一次 current 生成一个 ThreadLocalRandom 实例保留,那么其它线程来获取种子的时候必然取不到初始种子,必须是每一个线程本人用的时候初始化一个种子到线程。能够在 nextSeed 设置一个断点看看:
UNSAFE.getLong(Thread.currentThread(),SEED);
2 ConcurrentHashMap 真的平安吗?
咱们都晓得 ConcurrentHashMap 是个线程平安的哈希表容器,但它仅保障提供的原子性读写操作线程平安。
2.1 案例
有个含 900 个元素的 Map,当初再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。开发人员误以为应用 ConcurrentHashMap 就不会有线程平安问题,于是不加思索地写出了上面的代码:在每一个线程的代码逻辑中先通过 size 办法拿到以后元素数量,计算 ConcurrentHashMap 目前还须要补充多少元素,并在日志中输入了这个值,而后通过 putAll 办法把短少的元素增加进去。
为不便察看问题,咱们输入了这个 Map 一开始和最初的元素个数。
- 拜访接口
剖析日志输入可得:
- 初始大小 900 合乎预期,还需填充 100 个元素
- worker13 线程查问到以后须要填充的元素为 49,还不是 100 的倍数
- 最初 HashMap 的总项目数是 1549,也不合乎填充斥 1000 的预期
2.2 bug 剖析
ConcurrentHashMap 就像是一个大篮子,当初这个篮子里有 900 个桔子,咱们冀望把这个篮子装满 1000 个桔子,也就是再装 100 个桔子。有 10 个工人来干这件事儿,大家先后到岗后会计算还须要补多少个桔子进去,最初把桔子装入篮子。ConcurrentHashMap 这篮子自身,能够确保多个工人在装货色进去时,不会相互影响烦扰,但无奈确保工人 A 看到还须要装 100 个桔子然而还未装时,工人 B 就看不到篮子中的桔子数量。你往这个篮子装 100 个桔子的操作不是原子性的,在他人看来可能会有一个霎时篮子里有 964 个桔子,还须要补 36 个桔子。
ConcurrentHashMap 对外提供能力的限度:
- 应用不代表对其的多个操作之间的状态统一,是没有其余线程在操作它的。如果须要确保须要手动加锁
- 诸如 size、isEmpty 和 containsValue 等聚合办法,在并发下可能会反映 ConcurrentHashMap 的中间状态。因而在并发状况下,这些办法的返回值只能用作参考,而不能用于流程管制。显然,利用 size 办法计算差别值,是一个流程管制
- 诸如 putAll 这样的聚合办法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到局部数据
2.3 解决方案
整段逻辑加锁:
- 只有一个线程查问到需补 100 个元素,其余 9 个线程查问到无需补,最初 Map 大小 1000
既然应用 ConcurrentHashMap 还要全程加锁,还不如应用 HashMap 呢?不齐全是这样。
ConcurrentHashMap 提供了一些原子性的简略复合逻辑办法,用好这些办法就能够施展其威力。这就引申出代码中常见的另一个问题:在应用一些类库提供的高级工具类时,开发人员可能还是依照旧的形式去应用这些新类,因为没有应用其实在个性,所以无奈施展其威力。
3 知己知彼,屡战屡败
3.1 案例
应用 Map 来统计 Key 呈现次数的场景。
- 应用 ConcurrentHashMap 来统计,Key 的范畴是 10
- 应用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 Key
- 如果 Key 不存在的话,首次设置值为 1。
show me code:
有了上节教训,咱们这间接锁住 Map,再做
- 判断
- 读取当初的累计值
- +1
- 保留累加后值
这段代码在性能上确实毫无没有问题,但却无奈充分发挥 ConcurrentHashMap 的性能,优化后:
- ConcurrentHashMap 的原子性办法 computeIfAbsent 做复合逻辑操作,判断 K 是否存在 V,若不存在,则把 Lambda 运行后后果存入 Map 作为 V,即新创建一个 LongAdder 对象,最初返回 V 因为 computeIfAbsent 返回的 V 是 LongAdder,是个线程平安的累加器,可间接调用其 increment 累加。
这样在确保线程平安的状况下达到极致性能,且代码行数骤减。
3.2 性能测试
- 应用 StopWatch 测试两段代码的性能,最初的断言判断 Map 中元素的个数及所有 V 的和是否合乎预期来校验代码正确性
- 性能测试后果:
比应用锁性能晋升至多 5 倍。
3.3 computeIfAbsent 高性能之道
Java 的 Unsafe 实现的 CAS。它在 JVM 层确保写入数据的原子性,比加锁效率高:
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 并发工具就是高性能的高并发程序。
辨明 computeIfAbsent、putIfAbsent
- 当 Key 存在的时候,如果 Value 获取比拟低廉的话,putIfAbsent 就白白浪费工夫在获取这个低廉的 Value 上(这个点特地留神)
- Key 不存在的时候,putIfAbsent 返回 null,小心空指针,而 computeIfAbsent 返回计算后的值
- 当 Key 不存在的时候,putIfAbsent 容许 put null 进去,而 computeIfAbsent 不能,之后进行 containsKey 查问是有区别的(当然了,此条针对 HashMap,ConcurrentHashMap 不容许 put null value 进去)
3.4 CopyOnWriteArrayList 之殇
再比方一段简略的非 DB 操作的业务逻辑,工夫耗费却超出预期工夫,在批改数据时操作本地缓存比回写 DB 慢许多。原来是有人应用了 CopyOnWriteArrayList 缓存大量数据,而该业务场景下数据变动又很频繁。CopyOnWriteArrayList 尽管是一个线程平安版的 ArrayList,但其每次批改数据时都会复制一份数据进去,所以只实用读多写少或无锁读场景。所以一旦应用 CopyOnWriteArrayList,肯定是因为场景合适而非炫技。
CopyOnWriteArrayList V.S 一般加锁 ArrayList 读写性能
- 测试并发写性能
- 测试后果:高并发写,CopyOnWriteArray 比同步 ArrayList 慢百倍
- 测试并发读性能
- 测试后果:高并发读(100 万次 get 操作),CopyOnWriteArray 比同步 ArrayList 快 24 倍
高并发写时,CopyOnWriteArrayList 为何这么慢呢?因为其每次 add 时,都用 Arrays.copyOf 创立新数组,频繁 add 时内存申请开释性能耗费大。
4 总结
4.1 Don’t !!!
- 不要只会用并发工具,而不相熟线程原理
- 不要感觉用了并发工具,就怎么都线程平安
- 不相熟并发工具的优化实质,就难以施展其真正性能
- 不要不联合以后业务场景,就随便选用并发工具,可能导致系统性能更差
4.2 Do !!!
- 认真浏览官网文档,了解并发工具实用场景及其各 API 的用法,并自行测试验证,最初再应用
- 并发 bug 本就不易复现,多自行进行性能压力测试
举荐浏览
为什么阿里巴巴的程序员成长速度这么快,看完他们的内部资料我懂了
月薪在 30K 以下的 Java 程序员,可能听不懂这个我的项目;
对于【暴力递归算法】你所不晓得的思路
开拓鸿蒙,谁做零碎,聊聊华为微内核
=
看完三件事❤️
如果你感觉这篇内容对你还蛮有帮忙,我想邀请你帮我三个小忙:
点赞,转发,有你们的『点赞和评论』,才是我发明的能源。
关注公众号『Java 斗帝』,不定期分享原创常识。
同时能够期待后续文章 ing????