你用对锁了吗?浅谈 Java “锁” 事

Hollis

共 5921字,需浏览 12分钟

 ·

2020-08-28 13:57

并发 BUG 的源头

我们知道电脑有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 还会有锁消除的动作,通过逃逸分析判断实例对象是线程私有的,那么肯定是线程安全的,于是就会忽略对象里面的加锁动作,直接调用。

 

读写锁

读写锁就是我们上面提交的根据场景减小锁的粒度了,把一个锁拆成了读锁和写锁,特别适合在读多写少的情况下使用,例如自己实现的一个缓存。

ReentrantReadWriteLock

读写锁允许多个线程同时读共享变量,但是写操作是互斥的,即写写互斥、读写互斥。讲白了就是写的时候就只能一个线程写,其他线程也读不了也写不了。
我们来看个小例子,里面也有个小细节。这段代码就是模拟缓存的读取,先上读锁去缓存拿数据,如果缓存没数据则释放读锁,再上写锁去数据库取数据,然后塞入缓存中返回。
这里面的小细节就是再次判断 data = getFromCache() 是否有值,因为同一时刻可能会有多个线程调用getData(),然后缓存都为空因此都去竞争写锁,最终只有一个线程会先拿到写锁,然后将数据又塞入缓存中。
此时等待的线程最终一个个的都会拿到写锁,获取写锁的时候其实缓存里面已经有值了所以没必要再去数据库查询。
当然 Lock 的使用范式大家都知道,需要用 try- finally,来保证一定会解锁。而读写锁还有一个要点需要注意,也就是说锁不能升级。什么意思呢?我改一下上面的代码。
但是写锁内可以再用读锁,来实现锁的降级,有些人可能会问了这写锁都加了还要什么读锁。
还是有点用处的,比如某个线程抢到了写锁,在写的动作要完毕的时候加上读锁,接着释放了写锁,此时它还持有读锁可以保证能马上使用写锁操作完的数据,而别的线程也因为此时写锁已经没了也能读数据
其实就是当前已经不需要写锁这种比较霸道的锁!所以来降个级让大家都能读。
小结一下,读写锁适用于读多写少的情况,无法升级,但是可以降级。Lock 的锁需要配合 try- finally,来保证一定会解锁。
对了,我再稍稍提一下读写锁的实现,熟悉 AQS 的同学可能都知道里面的 state ,读写锁就是将这个 int 类型的 state 分成了两半,高 16 位与低 16 位分别记录读锁和写锁的状态。它和普通的互斥锁的区别就在于要维护这两个状态和在等待队列处区别处理这两种锁
所以在不适用于读写锁的场景还不如直接用互斥锁,因为读写锁还需要对state进行位移判断等等操作。

StampedLock

这玩意我也稍微提一下,是 1.8 提出来的出镜率似乎没有 ReentrantReadWriteLock 高。它支持写锁、悲观读锁和乐观读。写锁和悲观读锁其实和 ReentrantReadWriteLock 里面的读写锁是一致的,它就多了个乐观读。
从上面的分析我们知道读写锁在读的时候其实是无法写的,而 StampedLock 的乐观读则允许一个线程写。乐观读其实就是和我们知道的数据库乐观锁一样,数据库的乐观锁例如通过一个version字段来判断,例如下面这条 sql。
StampedLock 乐观读就是与其类似,我们来看一下简单的用法。
它与 ReentrantReadWriteLock 对比也就强在这里,其他的不行,比如 StampedLock 不支持重入,不支持条件变量。还有一点使用 StampedLock 一定不要调用中断操作,因为会导致CPU 100%,我跑了下并发编程网上面提供的例子,复现了。
具体的原因这里不再赘述,文末会贴上链接,上面说的很详细了。
所以出来一个看似好像很厉害的东西,你需要真正的去理解它,熟悉它才能做到有的放矢。

 

CopyOnWrite

写时复制的在很多地方也会用到,比如进程 fork() 操作。对于我们业务代码层面而言也是很有帮助的,在于它的读操作不会阻塞写,写操作也不会阻塞读。适用于读多写少的场景。
例如 Java 中的实现 CopyOnWriteArrayList,有人可能一听,这玩意线程安全读的时候还不会阻塞写,好家伙就用它了!
你得先搞清楚,写时复制是会拷贝一份数据,你的任何一个修改动作在CopyOnWriteArrayList 中都会触发一次Arrays.copyOf,然后在副本上修改。假如修改的动作很多,并且拷贝的数据也很大,这将是灾难!


并发安全容器

最后再来谈一下并发安全容器的使用,我就拿相对而言大家比较熟悉的 ConcurrentHashMap 来作为例子。我看新来的同事好像认为只要是使用并发安全容器一定就是线程安全了。其实不尽然,还得看怎么用。
我们先来看下以下的代码,简单的说就是利用 ConcurrentHashMap 来记录每个人的工资,最多就记录 100 个。
最终的结果都会超标,即 map 里面不仅仅只记录了100个人。那怎么样结果才会是对的?很简单就是加个锁。
看到这有人说,你这都加锁了我还用啥 ConcurrentHashMap ,我 HashMap 加个锁也能完事!是的你说的没错!因为当前我们的使用场景是复合型操作,也就是我们先拿 map 的 size 做了判断,然后再执行了 put 方法,ConcurrentHashMap 无法保证复合型的操作是线程安全的!
而 ConcurrentHashMap 合适只是用其暴露出来的线程安全的方法,而不是复合操作的情况下。比如以下代码
当然,我这个例子不够恰当其实,因为 ConcurrentHashMap 性能比 HashMap + 锁高的原因在于分段锁,需要多个 key 操作才能体现出来,不过我想突出的重点是使用的时候不能大意,不能纯粹的认为用了就线程安全了。

 

总结一下

今天谈了谈并发 BUG 的源头,即三大问题:可见性问题、原子性问题和有序性问题。然后简单的说了下 synchronized 关键字的注意点,即修饰静态字段或者静态方法是类层面的锁,而修饰非静态字段和非静态方法是实例层面的类。
再说了下锁的粒度,在不同场景定义不同的锁不能粗暴的一把锁搞定,并且方法内部锁的粒度要细。例如在读多写少的场景可以使用读写锁、写时复制等。
最终要正确的使用并发安全容器,不能一味的认为使用并发安全容器就一定线程安全了,要注意复合操作的场景。
当然我今天只是浅浅的谈了一下,关于并发编程其实还有很多点,要写出线程安全的代码不是一件容易的事情,就像我之前分析的 Kafka 事件处理全流程一样,原先的版本就是各种锁控制并发安全,到后来bug根本修不动,多线程编程难,调试也难,修bug也难。
因此 Kafka 事件处理模块最终改成了单线程事件队列模式将涉及到共享数据竞争相关方面的访问抽象成事件,将事件塞入阻塞队列中,然后单线程处理
所以在用锁之前我们要先想想,有必要么?能简化么?不然之后维护起来有多痛苦到时候你就知道了。

有道无术,术可成;有术无道,止于术

欢迎大家关注Java之道公众号


好文章,我在看❤️

浏览 29
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报