大家好,我是王有志,欢迎和我聊技术,聊漂泊在外的生活。
相信你经常会听到读锁/写锁,公平锁/非公平锁,乐观锁/悲观锁等五花八门的锁,那么每种锁有什么用呢?它们又有什么区别呢?今天我们就一起聊聊并发编程中的各种锁。
问题其实不多,基本上都是围绕着锁的设计理论提问。常见的问题如下:
依旧使用图片代替Markdown的表格(在线Excel同步更新中)。
引用维基百科中锁的解释:
In computer science, a lock or mutex (from mutual exclusion) is a synchronization primitive: a mechanism that enforces limits on access to a resource when there are many threads of execution. A lock is designed to enforce a mutual exclusion concurrency control policy, and with a variety of possible methods there exists multiple unique implementations for different applications.
可以这么理解:锁用于保证并发环境中对共享资源访问的互斥,限制共享资源访问的同步机制。
Tips:
画一个锁的简易模型:
模型不难理解,获取锁的线程进入临界区执行程序,访问共享资源,它描述了一种最简单的互斥锁模型。
Tips:临界区源自于操作系统进程调度的概念,是访问共享资源的程序片段。
我们把在并发编程中经常出现的锁全部列出来:
看到这么多名字有没有头晕眼花?没关系,我们透过现象给它们分类,或许能帮助你理解:
前面看到,锁的基础是提供线程间互斥的能力以保证访问共享资源的安全性,之后的发展中为了提升性能或适应不同场景而添加了各种各样的特性。
除此之外,你还会听过偏向锁,轻量级锁,重量级锁,它们归类到sychronized
的状态会比较合适,会在下一篇中详细说明。至于分段锁,我也将它归类到锁的设计中,具体的我们放到ConcurrentHashMap
中讨论。
Tips:
锁是为了保证并发访问的互斥,但所有的场景都需要互斥吗?
有时候,临界区只有读操作,使用互斥锁的话就很呆。因此诞生了共享锁,允许多个线程同时申请到共享锁。不过共享锁也限制了线程的操作范围,持有共享锁的线程只允许读取数据。
实际上,单纯使用共享锁没有太多意义,因为读取操作不产生并发安全问题。但是对只有读取操作的临界区使用互斥锁,有点“大材小用”,因此结合两者产生了“共享-互斥锁”,通常称呼为读写锁。
读写锁的特点:
Tips:Java中提供了读写锁ReentrantReadWriteLock
,我们后面慢慢聊。
相较于单纯的互斥锁,读写锁保证了读取的并发量,提高了程序的性能。但它真的那么好吗?
陈硕老师在《Linux多线程服务端编程》 中提到了慎用读写锁,并说道:
读写锁(Readers-Writer lock,简写为rwlock)是个看上去很美的抽象。
并给出了4点理由:
Tips:
第1点和第2点比较容易理解,不过多解释,第3点在ReentrantReadWriteLock
的部分和大家解释,我们今天来看第4点,读写锁引起的写饥饿。
如下,由于不断的获取读锁,导致线程t2虽然很早申请写锁,但要等到所有读线程都执行后才能获取到写锁,这就是写饥饿。
Tips:ReentrantReadWriteLock
存在写饥饿的情况,Java 8虽然进行了增强,但不是对ReentrantReadWriteLock
增强。
接下来是按照特性分类了,先来看最容易理解的功能–公平性。不知道咋回事,想起来张麻子了~~
并发环境中,大量线程是瞬间涌入的,当执行到临界区时,开始尝试获取互斥锁,虽然看似是同时请求,但实际上还是有一丢丢时间差距。
公平锁维护等待队列,当线程尝试获取锁时,如果等待队列为空,或当前线程位于队首,那么线程就持有锁,否则添加到队尾,按照FIFO的顺序出队。
非公平锁,线程直接尝试获取锁,失败后再进入等待队列。
Tips:
ReentrantLock
的“公平模式”和“非公平模式”的都借助了AQS。公平锁严格按照申请顺序获取锁,每个线程都有机会获取锁;非公平锁允许直接抢占,无需判断等待队列是否有等待线程。
对于非公平锁来说,如果就是那么“寸”,等待队列队首的线程每次尝试获取锁时,都被其它线程“截胡”了,那么队列中的线程就永远无法获取锁,这就是线程饥饿。
那么非公平锁有优点吗?
以Java中ReentrantLock
的公平锁FairSync
和非公平锁NonfairSync
加锁过程为例:
根据算法复杂度分析,以图中的内容来估算,FairSync
的加锁时间是NonfairSync
的两倍,加锁速度上非公平锁加锁速度更快。
Tips:如果不熟悉算法复杂度,可以看预备知识:算法的复杂度分析。
等待队列非空时,尝试获取公平锁的线程进入等待队列,轮到时唤醒该线程;对于非公平锁来说,如果抢占成功,直接执行程序,无需进入等待队列后等待唤醒,如果抢占失败,则进入等待队列。
最后,做个总结:
/ | 优点 | 缺点 |
---|---|---|
公平锁 | 每个线程都有执行的机会 | 加锁慢,可能需要额外的唤醒操作 |
非公平锁 | 加锁快,抢占成功无需额外的唤醒操作 | 线程饥饿 |
我把悲观锁和乐观锁分到了锁的设计类别中,我们先来了解悲观锁和乐观锁,再来看我这么分类的理由。
悲观锁(Pessimistic Locking):认为并发访问共享资源总是会发生修改,因此在进入临界区前进行加锁操作,退出临界区后进行解锁。
根据上面的描述,几乎所有的锁都可以划分到悲观锁的范畴。那么共享锁算不算悲观锁?
我认为共享锁(读锁,S锁,共享锁)也是悲观锁,有2个理由:
悲观锁是计算机领域最常见的同步机制,数据库中的行锁,表锁,Java中的synchronized
等都是悲观锁。
乐观锁(Optimistic Locking):认为并发访问共享资源不会发生修改,因此无需加锁操作,真正发生修改准备提交数据前,会检查该数据是否被修改。
与悲观锁相反,乐观锁认为并发访问不会发生修改,因此允许线程“长驱直入”,如果发生了修改要怎么处理?
乐观锁(乐观并发控制,Optimistic Concurrency Control)由孔祥重教授(华裔,台湾省出生的美国计算机科学家)提出,并为乐观锁设计了4个阶段:
如果按照以上4个步骤实现乐观锁会有什么问题么?
如果在校验和提交阶段发生线程切换,会导致值的覆盖。通常了为了保证校验和提交操作的原子性,会借助CPU提供的CAS并发原语来保证。
CAS(Compare And Swap)指的是比较并替换,虽然是两个操作,但却是一条原子指令。
Tips:《Intel® 64 and IA-32 Architectures Software Developer’s Manual》2A中描述,Intel和IA-32架构使用的是CMPXCHG
指令,即Compare and Exchange。
CAS操作3个数:
其过程可以简单描述为:
好了,目前解决了原子操作的问题,是不是可以愉快的实现乐观锁了?别急,我们再看另一种情况:
线程t1,t2和t3都读取V的值,线程t2和t3先后修改V的值,V的变化轨迹:A→B→A。
虽然对于线程t1来说,修改的还是A,看起来好像没有问题,但真正的ABA问题可比上面的复杂多了。我们举个例子,假设有单向连表实现的栈A→B→C→D:
最常用的手段是,为数据添加版本,比较数据的同时也要对版本号进行比较,修改数据时,同时更新版本号。
这里举个最常用的通过数据库实现的乐观锁:
-- 查询库存信息
select book_id, book_count, version from book where book_id = #{bookId};
-- 程序计算扣减库存操作
......
-- 更新数据库库存
update book set book_count = #{bookCount}, version = version + 1 where book_id = #{bookId} and version = #{version}
Tips:Java提供了AtomicStampedReference
来解决CAS带来的ABA问题。
通常,我们认为乐观锁的性能优于悲观锁,因为悲观锁的粒度会更粗,而乐观锁的竞争只发生在产生冲突时。
一般,会在读多写少的场景使用乐观锁,这样减少加锁/解锁的次数,提高系统的吞吐量;而在写多读少的场景选择悲观锁,如果经常产生冲突,乐观锁需要不断的回滚(或其他方式),反而会降低性能。
另外,CAS指令只保证对一个共享变量的原子操作,当需要操作多个共享变量时,无法保证多个CAS操作的原子性。
最后,乐观锁需要自行实现,往往设计逻辑比较复杂,如果本身业务逻辑就已经很复杂了,那么首要保证的是正确的业务逻辑,然后再考虑性能。
Tips:CAS是实现乐观锁的关键技术,但使用CAS并不等于使用乐观锁。例如ReentrantLock
中使用了compareAndSet
,但它是悲观锁。
自旋锁(Spin Lock)和阻塞锁都是互斥锁,我们所说的自旋和阻塞是对未抢占到锁的线程来说的:
自旋锁中,线程未获取锁,不会进入休眠,而是不断的尝试获取锁;
阻塞锁中,线程未获取锁,进入休眠状态。
也就是说,阻塞锁存在休眠到唤醒的过程,而自旋锁只需要执行自旋逻辑。什么场景该使用自旋锁呢?
假设我们只有两个线程t1和t2,t1进入临界区,t2进入自旋,t2自旋的耗时应当与t1在临界区的执行时间相近。
如果临界区执行时间非常短,自旋耗时远小于一次休眠与唤醒,此时使用自旋锁的的代价会比阻塞锁小很多。如果临界区执行时间很长,与其让自旋锁耗尽CPU时间片,倒不如让给其它线程使用。
我们实现一个简单的自旋逻辑:
int count = 0;
while(!lock.tryLock() && count < 10) {
count ++;
}
Tips:单核服务器就不要使用自旋锁了。
可重入锁指的是同一线程可以对其多次加锁,可重入锁的特性和递归很相似,因此POSIX中称这种锁为递归锁。
为什么要实现锁的可重入呢?假设有不可重入锁lock
,我们执行一段递归删除文件夹下文件的逻辑:
public void deleteFile(File directory) {
if(lock.tryLock()) {
File[] files = directory.listFiles();
for (File subFile : files) {
if(file.isDirectory()) {
deleteFile(subFile);
} else {
file.delete();
}
}
}
}
当遇到第一个子文件夹时,执行lock.tryLock
会被阻塞,因为lock
已经被持有了,这时候就产生了死锁。
可重入锁的实现一般要在内部维护计数器,每次进入可重入锁时计数器加1,退出时计数器减1,进入和退出的次数要匹配。
到这里就把Java常用到的锁的基础知识和设计思想介绍完了,希望通过这篇文章,小伙伴对这些五花八门的锁有更清晰的认知。
其实总结起来,锁的基础功能是保证并发的安全(可以理解为互斥),再此基础上诞生的公平锁/非公平锁,悲观锁/乐观锁,自旋锁/阻塞锁的目的是为了提升锁的性能,而可重入锁的出现是为了解决重入带来的死锁问题(或许是为了方便开发者解决死锁的问题)。
大部分的锁都能在Java中找到它们的实现:
ReentrantLock#FairSync
ReentrantLock#NonfairSync
synchronized
,ReentrantLock
synchronized
,ReentrantLock
ReentrantReadWriteLock
我会在未来的文章中和大家分享对它们设计思想的理解。
补充一些计算机基础的相关内容。
并发编程领域存在两种基本通信模型:
传统的面向对象编程语言采用的是共享内存的方式进行线程间通信,如:Java,C++等,但Java可以通过Akka实现Actor模型的消息传递。
近年来的“搅局者”(存疑)Go语言则是消息传递的忠实拥趸,在《Go Proverbs》中第一句便是:
Don’t communicate by sharing memory, share memory by communicating.
不要通过共享内存来通信,要通过通信来共享内存。
Tips:近年来“编程语言哲学”比较普遍,前有Python大名鼎鼎的《The Zen of Python》,后来者Go也搞了《Go Proverbs》。
好了,今天就到这里了,Bye~~