每个时代,都不会亏待会学习的人
大家好,我是yes。
本来打算继续写消息队列的东西的,但是最近在带新同事,发现新同事对于锁这方面有一些误解,所以今天就来谈谈“锁”事和 Java 中的并发安全容器使用有哪些注意点。
不过在这之前还是得先来盘一盘为什么需要锁这玩意,这得从并发 BUG 的源头说起。
并发 BUG 的源头
这个问题我 19 年的时候写过一篇文章, 现在回头看那篇文章真的是羞涩啊。
让我们来看下这个源头是什么,我们知道电脑有CPU、内存、硬盘,硬盘的读取速度最慢,其次是内存的读取,内存的读取相对于 CPU 的运行又太慢了,因此又搞了个CPU缓存,L1、L2、L3。
正是这个CPU缓存再加上现在多核CPU的情况产生了并发BUG。
这就一个很简单的代码,如果此时有线程 A 和线程 B 分别在 CPU - A 和 CPU - B 中执行这个方法,它们的操作是先将 a 从主存取到 CPU 各自的缓存中,此时它们缓存中 a 的值都是 0。
然后它们分别执行 a++,此时它们各自眼中 a 的值都是 1,之后把 a 刷到主存的时候 a 的值还是1,这就出现问题了,明明执行了两次加一最终的结果却是 1,而不是 2。
这个问题就叫可见性问题。
在看我们 a++ 这条语句,我们现在的语言都是高级语言,这其实和语法糖很类似,用起来好像很方便实际上那只是表面,真正需要执行的指令一条都少不了。
高级语言的一条语句翻译成 CPU 指令的时候可不止一条, 就例如 a++ 转换成 CPU 指令至少就有三条。
- 把 a 从内存拿到寄存器中;
- 在寄存器中 +1;
- 将结果写入缓存或内存中;
所以我们以为 a++ 这条语句是不可能中断的是具备原子性的,而实际上 CPU 可以能执行一条指令时间片就到了,此时上下文切换到另一个线程,它也执行 a++。再次切回来的时候 a 的值其实就已经不对了。
这个问题叫做原子性问题。
并且编译器或解释器为了优化性能,可能会改变语句的执行顺序,这叫指令重排,最经典的例子莫过于单例模式的双重检查了。而 CPU 为了提高执行效率,还会乱序执行,例如 CPU 在等待内存数据加载的时候发现后面的加法指令不依赖前面指令的计算结果,因此它就先执行了这条加法指令。
这个问题就叫有序性问题。
至此已经分析完了并发 BUG 的源头,即这三大问题。可以看到不管是 CPU 缓存、多核 CPU 、高级语言还是乱序重排其实都是必要的存在,所以我们只能直面这些问题。
而解决这些问题就是通过禁用缓存、禁止编译器指令重排、互斥等手段,今天我们的主题和互斥相关。
互斥就是保证对共享变量的修改是互斥的,即同一时刻只有一个线程在执行。而说到互斥相信大家脑海中浮现的就是锁。没错,我们今天的主题就是锁!锁就是为了解决原子性问题。
锁
说到锁可能 Java 的同学第一反应就是 synchronized 关键字,毕竟是语言层面支持的。我们就先来看看 synchronized,有些同学对 synchronized 理解不到位所以用起来会有很多坑。
synchronized 注意点
我们先来看一份代码,这段代码就是咱们的涨工资之路,最终百万是洒洒水的。而一个线程时刻的对比着我们工资是不是相等的。我简单说一下IntStream.rangeClosed(1,1000000).forEach
,可能有些人对这个不太熟悉,这个代码的就等于 for 循环了100W次。
你先自己理解下,看看觉得有没有什么问题?第一反应好像没问题,你看着涨工资就一个线程执行着,这比工资也没有修改值,看起来好像没啥毛病?没有啥并发资源的竞争,也用 volatile 修饰了保证了可见性。
让我们来看一下结果,我截取了一部分。
可以看到首先有 log 打出来就已经不对了,其次打出来的值竟然还相等!有没有出乎你的意料之外?有同学可能下意识就想到这就raiseSalary
在修改,所以肯定是线程安全问题来给raiseSalary
加个锁!
请注意只有一个线程在调用raiseSalary
方法,所以单给raiseSalary
方法加锁并没啥用。
这其实就是我上面提到的原子性问题,想象一下涨工资线程在执行完yesSalary++
还未执行yourSalary++
时,比工资线程刚好执行到yesSalary != yourSalary
是不是肯定是 true ?所以才会打印出 log。
再者由于用 volatile 修饰保证了可见性,所以当打 log 的时候,可能yourSalary++
已经执行完了,这时候打出来的 log 才会是yesSalary == yourSalary
。
所以最简单的解决办法就是把raiseSalary()
和 compareSalary()
都用 synchronized 修饰,这样涨工资和比工资两个线程就不会在同一时刻执行,因此肯定就安全了!
看起来锁好像也挺简单,不过这个 synchronized 的使用还是对于新手来说还是有坑的,就是你要关注 synchronized 锁的究竟是什么。
比如我改成多线程来涨工资。这里再提一下parallel
,这个其实就是利用了 ForkJoinPool 线程池操作,默认线程数是 CPU 核心数。
由于 raiseSalary()
加了锁,所以最终的结果是对的。这是因为 synchronized 修饰的是yesLockDemo
实例,我们的 main 中只有一个实例,所以等于多线程竞争的是一把锁,所以最终计算出来的数据正确。
那我再修改下代码,让每个线程自己有一个 yesLockDemo 实例来涨工资。
你会发现这锁怎么没用了?这说好的百万年薪我就变 10w 了??这你还好还有 70w。
这是因为此时我们的锁修饰的是非静态方法,是实例级别的锁,而我们为每个线程都创建了一个实例,因此这几个线程竞争的就根本不是一把锁,而上面多线程计算正确代码是因为每个线程用的是同一个实例,所以竞争的是一把锁。如果想要此时的代码正确,只需要把实例级别的锁变成类级别的锁。
很简单只需要把这个方法变成静态方法,synchronized 修饰静态方法就是类级别的锁。
我们来小结一下,使用 synchronized 的时候需要注意锁的到底是什么,如果修饰静态字段和静态方法那就是类级别的锁,如果修饰非静态字段和非静态方法就是实例级别的锁。
锁的粒度
相信大家知道 Hashtable 不被推荐使用,要用就用 ConcurrentHashMap,是因为 Hashtable 虽然是线程安全的,但是它太粗暴了,它为所有的方法都上了同一把锁!我们来看下源码。
你说这 contains 和 size 方法有啥关系? 我在调用 contains 的时候凭啥不让我调 size ? 这就是锁的粒度太粗了我们得评估一下,不同的方法用不同的锁,这样才能在线程安全的情况下再提高并发度。
但是不同方法不同锁还不够的,因为有时候一个方法里面有些操作其实是线程安全的,只有涉及竞争竞态资源的那一段代码才需要加锁。特别是不需要锁的代码很耗时的情况,就会长时间占着这把锁,而且其他线程只能排队等着,比如下面这段代码。
很明显第二段代码才是正常的使用锁的姿势,不过在平时的业务代码中可不是像我代码里贴的 sleep 这么容易一眼就看出的,有时候还需要修改代码执行的顺序等等来保证锁的粒度足够细。
而有时候又需要保证锁足够的粗,不过这部分JVM会检测到,它会帮我们做优化,比如下面的代码
可以看到明明是一个方法里面调用的逻辑却经历了加锁-执行A-解锁-加锁-执行B-解锁
,很明显的可以看出其实只需要经历加锁-执行A-执行B-解锁
。
所以 JVM 会在即时编译的时候做锁的粗化,将锁的范围扩大,类似变成下面的情况。
而且 JVM 还会有锁消除的动作,通过逃逸分析判断实例对象是线程私有的,那么肯定是线程安全的,于是就会忽略对象里面的加锁动作,直接调用。