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

每一个时代,都不会亏待会学习的人java

你们好,我是yes。web

原本打算继续写消息队列的东西的,可是最近在带新同事,发现新同事对于锁这方面有一些误解,因此今天就来谈谈“锁”事和 Java 中的并发安全容器使用有哪些注意点。面试

不过在这以前仍是得先来盘一盘为何须要锁这玩意,这得从并发 BUG 的源头提及。算法

并发 BUG 的源头

这个问题我 19 年的时候写过一篇文章, 如今回头看那篇文章真的是羞涩啊。sql

让咱们来看下这个源头是什么,咱们知道电脑有CPU、内存、硬盘,硬盘的读取速度最慢,其次是内存的读取,内存的读取相对于 CPU 的运行又太慢了,所以又搞了个CPU缓存,L一、L二、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 事件处理模块最终改为了单线程事件队列模式将涉及到共享数据竞争相关方面的访问抽象成事件,将事件塞入阻塞队列中,而后单线程处理

因此在用锁以前咱们要先想一想,有必要么?能简化么?否则以后维护起来有多痛苦到时候你就知道了。

最后

以后继续开始写消息队列相关的包括 RocketMQ 和 Kafka,有很多同窗在后台留言想和我深刻的交流一下,发生点关系,我把公众号菜单加了个联系我,有需求的小伙伴能够加我微信。

StampedLock bug 的那个连接:http://ifeve.com/stampedlock-bug-cpu/


我是 yes,从一点点到亿点点,咱们下篇见

往期推荐:

Redis 之父关于 CRC64 的神秘往事

面试官:知道时间轮算法吗?在Netty和Kafka中如何应用的?

消息队列面试热点一锅端

Kafka和RocketMQ底层存储之那些你不知道的事

图解+代码|常见限流算法以及限流在单机分布式场景下的思考

面试官:说说Kafka处理请求的全流程


本文分享自微信公众号 - yes的练级攻略(yes_java)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。