没啥深刻实际的实践系同学,在应用并发工具时,总是认为把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????