读写锁
读写锁就是我们上面提交的根据场景减小锁的粒度了,把一个锁拆成了读锁和写锁,特别适合在读多写少的情况下使用,例如自己实现的一个缓存。
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。
它与 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 的那个链接:ifeve.com/stampedlock…