一文让你看懂并发编程中的锁

并发编程中的锁

一文让你看懂并发编程中的锁_第1张图片

计算机中的锁,它到底是什么?

引用维基百科中[锁] 的解释:

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.

可以这么理解:锁用于保证并发环境中对共享资源访问的互斥,限制共享资源访问的同步机制

一文让你看懂并发编程中的锁_第2张图片

锁的分类

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

  • 读锁,S锁,共享锁,写锁,X锁,独占锁,排他锁,读写锁
  • 公平锁,非公平锁
  • 乐观锁,悲观锁
  • 自旋锁,阻塞锁
  • 可重入锁,不可重入锁
  • 偏向锁,轻量级锁,重量级锁
  • 分段锁

没关系,我们透过现象给它们分类,或许能帮助你理解:

本质,指的是互斥与共享的本质;

  1. 互斥:写锁,X锁,独占锁,排他锁;
  2. 共享:读锁,S锁,共享锁;

设计,指的是锁的设计方式;

  1. 乐观锁,悲观锁;
  2. 读写锁;

特性,指的是本质上添加的特性;

  1. 公平锁,非公平锁;
  2. 自旋锁,阻塞锁;
  3. 可重入锁,不可重入锁。

读锁,写锁和读写锁

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

有时候,临界区只有读操作,使用互斥锁的话就很呆。因此诞生了共享锁,允许多个线程同时申请到共享锁。不过共享锁也限制了线程的操作范围,持有共享锁的线程只允许读取数据
实际上,单纯使用共享锁没有太多意义,因为读取操作不产生并发安全问题。但是对只有读取操作的临界区使用互斥锁,有点“大材小用”,因此结合两者产生了“共享-互斥锁”,通常称呼为读写锁

读写锁的特点:

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

读写锁的优缺点

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

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

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

并给出了4点理由:

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

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

公平锁与非公平锁

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

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

公平锁与非公平锁的比较

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

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

加锁速度上非公平锁加锁速度更快

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

Tips

  • Java中ReentrantLock的“公平模式”和“非公平模式”的都借助了[[AQS]]。

悲观锁与乐观锁

什么是悲观锁?

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,

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

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

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

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

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

什么是乐观锁?

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

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

如何实现乐观锁?

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

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

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

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

选择乐观锁还是悲观锁?

通常,我们认为乐观锁的性能优于悲观锁,因为悲观锁的粒度会更粗,而乐观锁的竞争只发生在产生冲突时

一般,会在读多写少的场景使用乐观锁,这样减少加锁/解锁的次数,提高系统的吞吐量;而在写多读少的场景选择悲观锁,如果经常产生冲突,乐观锁需要不断的回滚(或其他方式),反而会降低性能

最后,乐观锁需要自行实现,往往设计逻辑比较复杂,如果本身业务逻辑就已经很复杂了,那么首要保证的是正确的业务逻辑,然后再考虑性能。

Tips:CAS是实现乐观锁的关键技术,但使用CAS并不等于使用乐观锁。例如ReentrantLock中使用了compareAndSet,但它是悲观锁。

自旋锁和阻塞锁

自旋锁(Spin Lock)和阻塞锁都是互斥锁,我们所说的自旋和阻塞是对未抢占到锁的线程来说的:

  • 自旋锁中,线程未获取锁,不会进入休眠,而是不断的尝试获取锁;
    一文让你看懂并发编程中的锁_第3张图片
int  count = 0;
while(!lock.tryLock() && count < 10) {
    count ++;
}
  • 阻塞锁中,线程未获取锁,进入休眠状态。
    一文让你看懂并发编程中的锁_第4张图片

如果临界区执行时间非常短,自旋耗时远小于一次休眠与唤醒,此时使用自旋锁的的代价会比阻塞锁小很多。如果临界区执行时间很长,与其让自旋锁耗尽CPU时间片,倒不如让给其它线程使用。

可重入锁和不可重入锁

可重入锁指的是同一线程可以对其多次加锁,可重入锁的特性和递归很相似,因此POSIX中称这种锁为递归锁。
一文让你看懂并发编程中的锁_第5张图片

不可重入锁会造成死锁?

为什么要实现锁的可重入呢?假设有不可重入锁lock,我们执行一段递归删除文件夹下文件的逻辑
一文让你看懂并发编程中的锁_第6张图片

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,进入和退出的次数要匹配

重量级锁

重量级锁(Mutex Lock) Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又 是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用 户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。JDK 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。 JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和 “偏向锁”

轻量级锁

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁
锁升级 随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的, 也就是说只能从低到高升级,不会出现锁的降级)

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是, 轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量 级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场 景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀 为重量级锁。

偏向锁

偏向锁 Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线 程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起 来让这个线程得到了偏护。

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级 锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所 以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进 一步提高性能。

分段锁

分段锁也并非一种实际的锁,而是一种思想 ConcurrentHashMap 是学习分段锁的最好实

锁优化

  • 减少锁持有时间

    只用在有线程安全要求的程序上加锁

  • 减小锁粒度

    将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。 降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最最典型的减小锁粒度的案例就是 ConcurrentHashMap

  • 锁分离

    常见的锁分离就是读写锁 ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互 斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能,具体也请查看[高并发 Java 五] JDK 并发包 1。读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如 LinkedBlockingQueue 从头部取出,从尾部放数据

  • 锁粗化

    通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完 公共资源后,应该立即释放锁。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步 和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化

  • 锁消除

    锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这 些对象的锁操作,多数是因为程序员编码不规范引起。

Java中的锁

大部分的锁都能在Java中找到它们的实现:

  • 公平锁:ReentrantLock#FairSync
  • 非公平锁:ReentrantLock#NonfairSync
  • 悲观锁:synchronizedReentrantLock
  • 可重入锁:synchronizedReentrantLock
  • 读写锁:ReentrantReadWriteLock

CAS

什么是CAS?

CAS(Compare And Swap) 指的是比较并替换,虽然是两个操作,但却是一条原子指令。CAS是一种轻量级的同步操作,也是乐观锁的一种实现,它用于实现多线程环境下的并发算法。CAS 操作包含三个操作数:内存位置(或者说是一个变量的引用)、预期的值和新值。如果内存位置的值和预期值相等,那么处理器会自动将该位置的值更新为新值,否则不进行任何操作。

在多线程环境中,CAS 可以实现非阻塞算法,避免了使用锁所带来的上下文切换、调度延迟、死锁等问题,因此被广泛应用于并发编程中。

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问题

线程t1,t2 都读取V的值,线程t1和t2先后修改V的值,V的变化轨迹: A→B→A
一文让你看懂并发编程中的锁_第7张图片

解决ABA问题

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

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

Tips:Java提供了AtomicStampedReference来解决CAS带来的ABA问题。

另外,CAS指令只保证对一个共享变量的原子操作,当需要操作多个共享变量时,无法保证多个CAS操作的原子性。

public class ABADemo {
    private static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        new Thread(() -> {
            int stamp = stampedReference.getStamp();
            System.out.println("当前线程名称:" + Thread.currentThread().getName() + ",版本号为" + stamp + ",值是" + stampedReference.getReference());
            //暂停1秒钟t1线程
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            stampedReference.compareAndSet(100, 101, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println("当前线程名称:" + Thread.currentThread().getName() + ",版本号为" + stampedReference.getStamp() + ",值是" + stampedReference.getReference());
            stampedReference.compareAndSet(101, 100, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println("当前线程名称:" + Thread.currentThread().getName() + ",版本号为" + stampedReference.getStamp() + ",值是" + stampedReference.getReference());
            System.out.println("线程t1已完成1次ABA操作~~~~~");
        }, "t1").start();

        new Thread(() -> {
            int stamp = stampedReference.getStamp();
            System.out.println("当前线程名称:" + Thread.currentThread().getName() + ",版本号为" + stamp + ",值是" + stampedReference.getReference());
            //线程2暂停3秒,保证线程1完成1次ABA
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean result = stampedReference.compareAndSet(100, 6666, stamp, stamp + 1);
            System.out.println("当前线程名称:" + Thread.currentThread().getName() + ",修改成功否:" + result + ",最新版本号" +
                    stampedReference.getStamp() + ",最新的值:" + stampedReference.getReference());
        }, "t2").start();
    }
}

AQS

学习: https://juejin.cn/post/6844903736578408461#heading-0

你可能感兴趣的:(Java开发知识,java,安全)