美文网首页Java面试都问啥?
06.一文看懂并发编程中的锁

06.一文看懂并发编程中的锁

作者: 王有志 | 来源:发表于2022-11-26 10:57 被阅读0次

    大家好,我是王有志。关注王有志,一起聊技术,聊游戏,从北漂生活谈到国际风云。

    相信你经常会听到读锁/写锁,公平锁/非公平锁,乐观锁/悲观锁等五花八门的锁,那么每种锁有什么用呢?它们又有什么区别呢?今天我们就一起聊聊并发编程中的各种锁。

    关于锁的理论,他们都问什么?

    问题其实不多,基本上都是围绕着锁的设计理论提问。常见的问题如下

    锁的理论问题.png

    依旧使用图片代替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

    • 本文中访问操作既包含读取变量,也包含修改变量;
    • 不断提到的共享资源在拓展内容中做了补充;
    • 同步和互斥,可以回顾关于线程你必须知道的8个问题(下)
    • mutex在计算机科学中被翻译为互斥互斥量,会大量出现。

    画一个锁的简易模型:

    简易锁模型.png

    模型不难理解,获取锁的线程进入临界区执行程序,访问共享资源,它描述了一种最简单的互斥锁模型。

    Tips临界区源自于操作系统进程调度的概念,是访问共享资源的程序片段

    锁的分类

    我们把在并发编程中经常出现的锁全部列出来:

    • 读锁,S锁,共享锁,写锁,X锁,独占锁,排他锁,读写锁
    • 公平锁,非公平锁
    • 乐观锁,悲观锁
    • 自旋锁,阻塞锁
    • 可重入锁,不可重入锁

    看到这么多名字有没有头晕眼花?没关系,我们透过现象给它们分类,或许能帮助你理解:

    • 本质,指的是互斥与共享的本质;
      • 互斥:写锁,X锁,独占锁,排他锁;
      • 共享:读锁,S锁,共享锁;
    • 设计,指的是锁的设计方式;
      • 乐观锁,悲观锁;
      • 读写锁;
    • 特性,指的是本质上添加的特性;
      • 公平锁,非公平锁;
      • 自旋锁,阻塞锁;
      • 可重入锁,不可重入锁。

    前面看到,锁的基础是提供线程间互斥的能力以保证访问共享资源的安全性,之后的发展中为了提升性能或适应不同场景而添加了各种各样的特性。

    除此之外,你还会听过偏向锁,轻量级锁,重量级锁,它们归类到sychronized的状态会比较合适,会在下一篇中详细说明。至于分段锁,我也将它归类到锁的设计中,具体的我们放到ConcurrentHashMap中讨论。

    Tips

    • 基于个人理解分类,只是为了更好的理解锁的本质与特性,欢迎指正;
    • 共享部分的划分并不准确,因为共享锁只与读操作共享,与写操作互斥;
    • 特性是在本质的基础上添加的,它们的关系像是车与改装车的关系。

    读锁,写锁和读写锁

    锁是为了保证并发访问的互斥,但所有的场景都需要互斥吗?

    有时候,临界区只有读操作,使用互斥锁的话就很呆。因此诞生了共享锁,允许多个线程同时申请到共享锁。不过共享锁也限制了线程的操作范围,持有共享锁的线程只允许读取数据

    读写锁

    实际上,单纯使用共享锁没有太多意义,因为读取操作不产生并发安全问题。但是对只有读取操作的临界区使用互斥锁,有点“大材小用”,因此结合两者产生了“共享-互斥锁”,通常称呼为读写锁

    读写锁的特点:

    • 允许多个线程申请读锁
    • 如果读锁已经被申请,需要等待读锁释放后才能申请写锁
    • 如果写锁已经被申请,需要等待写锁释放后才能申请读锁

    总结一下:

    读写锁.png

    换句话说,读写锁中只有两种情况多读或一写

    Tips:Java中提供了读写锁ReentrantReadWriteLock,我们后面慢慢聊。

    读写锁的优缺点

    相较于单纯的互斥锁,读写锁保证了读取的并发量,提高了程序的性能。但它真的那么好吗?

    陈硕老师在《Linux多线程服务端编程》 中提到了慎用读写锁,并说道:

    读写锁(Readers-Writer lock,简写为rwlock)是个看上去很美的抽象。

    并给出了4点理由:

    1. 开发过程中容易犯在持有read lock时修改数据的错误;
    2. 读写锁的实现比互斥锁复杂,如果控制粒度极小,互斥锁可能更快;
    3. 如果读锁不允许升级为写锁,会和non-recursive mutex一样,造成死锁;
    4. 读写锁会引起写饥饿。

    Tips

    • recursive mutex和non-recursive mutex是POSIX规范的称呼,我们通常称为ReentrantLok和NonreentrantLock;
    • 《Linux多线程服务端编程》的Keyword:Linux多线程服务端编程

    第1点和第2点比较容易理解,不过多解释,第3点在ReentrantReadWriteLock的部分和大家解释,我们今天来看第4点,读写锁引起的写饥饿。

    如下,由于不断的获取读锁,导致线程t2虽然很早申请写锁,但要等到所有读线程都执行后才能获取到写锁,这就是写饥饿

    读写锁的缺点.png

    TipsReentrantReadWriteLock存在写饥饿的情况,Java 8虽然进行了增强,但不是对ReentrantReadWriteLock增强。

    公平锁与非公平锁

    接下来是按照特性分类了,先来看最容易理解的功能--公平性。不知道咋回事,想起来张麻子了~~

    并发环境中,大量线程是瞬间涌入的,当执行到临界区时,开始尝试获取互斥锁,虽然看似是同时请求,但实际上还是有一丢丢时间差距。

    公平锁维护等待队列,当线程尝试获取锁时,如果等待队列为空,或当前线程位于队首,那么线程就持有锁,否则添加到队尾,按照FIFO的顺序出队

    非公平锁,线程直接尝试获取锁,失败后再进入等待队列。

    Tips

    • 不熟悉队列的可以看我写的另一个系列:数据结构:栈和队列
    • Java中ReentrantLock的“公平模式”和“非公平模式”的都借助了AQS。

    公平锁与非公平锁的比较

    公平锁严格按照申请顺序获取锁,每个线程都有机会获取锁;非公平锁允许直接抢占,无需判断等待队列是否有等待线程。

    对于非公平锁来说,如果就是那么“寸”,等待队列队首的线程每次尝试获取锁时,都被其它线程“截胡”了,那么队列中的线程就永远无法获取锁,这就是线程饥饿

    那么非公平锁有优点吗?

    等待队列为空

    以Java中ReentrantLock的公平锁FairSync和非公平锁NonfairSync加锁过程为例:

    公平锁与非公平锁的对比.png

    根据算法复杂度分析,以图中的内容来估算,FairSync的加锁时间是NonfairSync的两倍,加锁速度上非公平锁加锁速度更快

    Tips:如果不熟悉算法复杂度,可以看预备知识:算法的复杂度分析

    等待队列非空

    等待队列非空时,尝试获取公平锁的线程进入等待队列,轮到时唤醒该线程;对于非公平锁来说,如果抢占成功,直接执行程序,无需进入等待队列后等待唤醒,如果抢占失败,则进入等待队列。

    最后,做个总结:

    / 优点 缺点
    公平锁 每个线程都有执行的机会 加锁慢,可能需要额外的唤醒操作
    非公平锁 加锁快,抢占成功无需额外的唤醒操作 线程饥饿

    悲观锁与乐观锁

    我把悲观锁和乐观锁分到了锁的设计类别中,我们先来了解悲观锁和乐观锁,再来看我这么分类的理由。

    什么是悲观锁?

    悲观锁(Pessimistic Locking)认为并发访问共享资源总是会发生修改,因此在进入临界区前进行加锁操作,退出临界区后进行解锁

    根据上面的描述,几乎所有的锁都可以划分到悲观锁的范畴。那么共享锁算不算悲观锁?

    我认为共享锁(读锁,S锁,共享锁)也是悲观锁,有2个理由:

    • 共享锁总是在访问临界区前加锁,退出后解锁
    • 共享锁只与读操作共享,与写操作互斥

    悲观锁是计算机领域最常见的同步机制,数据库中的行锁,表锁,Java中的synchronized等都是悲观锁。

    什么是乐观锁?

    乐观锁(Optimistic Locking)认为并发访问共享资源不会发生修改,因此无需加锁操作,真正发生修改准备提交数据前,会检查该数据是否被修改

    与悲观锁相反,乐观锁认为并发访问不会发生修改,因此允许线程“长驱直入”,如果发生了修改要怎么处理?

    如何实现乐观锁?

    乐观锁(乐观并发控制,Optimistic Concurrency Control)由孔祥重教授(华裔,台湾省出生的美国计算机科学家)提出,并为乐观锁设计了4个阶段:

    • 读取,读取数据,系统派发时间戳;
    • 修改,修改数据,此时修改尚未提交;
    • 校验,校验数据是否被其他读取或写入;
    • 提交/回滚:未发生修改/写入,提交数据,发生修改/写入,即产生冲突时,回滚数据。

    如果按照以上4个步骤实现乐观锁会有什么问题么?

    CAS保证的问题.png

    如果在校验和提交阶段发生线程切换,会导致值的覆盖。通常了为了保证校验和提交操作的原子性,会借助CPU提供的CAS并发原语来保证。

    什么是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个数:

    • V,内存原值
    • A,预期原值
    • B,修改的值

    其过程可以简单描述为:

    • 读取需要修改的内存原值V;
    • 比较内存原值V与预期原值A;
    • 如果V=A,则修改V的值为B,否则不执行任何操作。

    好了,目前解决了原子操作的问题,是不是可以愉快的实现乐观锁了?别急,我们再看另一种情况:

    ABA问题.png

    ABA问题

    线程t1,t2和t3都读取V的值,线程t2和t3先后修改V的值,V的变化轨迹:A \rightarrow B \rightarrow A

    虽然对于线程t1来说,修改的还是A,看起来好像没有问题,但真正的ABA问题可比上面的复杂多了。我们举个例子,假设有单向连表实现的栈A \rightarrow B \rightarrow C \rightarrow D

    ABA的实际问题.png

    解决ABA问题

    最常用的手段是,为数据添加版本,比较数据的同时也要对版本号进行比较,修改数据时,同时更新版本号

    这里举个最常用的通过数据库实现的乐观锁:

    -- 查询库存信息
    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
    • 悲观锁:synchronizedReentrantLock
    • 可重入锁:synchronizedReentrantLock
    • 读写锁: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~~

    相关文章

      网友评论

        本文标题:06.一文看懂并发编程中的锁

        本文链接:https://www.haomeiwen.com/subject/emfcfdtx.html