并发编程中常见的锁策略

本文介绍一些常见的锁策略。

锁策略是多线程编程中相对进阶的内容,它不仅仅局限于Java,任何和“锁”相关的话题,都可能会涉及到这些内容;即使是别的语言,只要涉及到“锁”,也都会涉及到锁策略。

锁策略的各个特性主要是给锁的实现者参考的,对于我们来说,了解一些对合理地使用锁也是有帮助的

目录

1、乐观锁&悲观锁

(1)悲观锁

(2)乐观锁

2、轻量级锁&重量级锁

(1)定义

(2)什么是mutex?

3、自旋锁&挂起等待锁

(1)自旋锁(Spin Lock)

(2)挂起等待锁(Suspend-Resume Lock)

4、互斥锁&读写锁

5、可重入锁&不可重入锁

(1)不可重入锁

(2)可重入锁

6、公平锁&非公平锁

7、问答题-锁策略


1、乐观锁&悲观锁

乐观锁和悲观锁不是真正的“锁”,而是两种思想,用于解决并发场景下的数据竞争问题。乐观锁与悲观锁的概念是从程序员的角度进行划分的,锁的实现者预测接下来数据发生并发冲突(也可以说说发生锁冲突)的概率大还是不大,如果预测冲突的概率很,那么这就是悲观锁;如果预测冲突概率不大,那么这就是乐观锁。

(1)悲观锁

悲观锁是从非常悲观保守的角度去考虑和解决问题。

它每次总是假设最坏的情况:每次去拿数据的时候,别的线程也会同时来访问和修改该数据,从而造成结果错误。所以悲观锁为了确保数据的一致性,会在每次获取并修改数据时将数据锁定,让其他线程无法访问该数据。如果别的线程也想要拿到这个数据,就必须阻塞等待,直到它拿到锁。

这也和我们人类中悲观主义者的性格是一样的,悲观主义者做事情之前总是担惊受怕,所以会严防死守,保证别人不能来碰我的东西,这就是悲观锁名字的含义。

举个例子来说明一下这就是悲观锁的操作流程:

1、假设线程 A 和 B 使用的都是悲观锁,所以它们在尝试获取同步资源时,必须要先拿到锁。

并发编程中常见的锁策略_第1张图片


 2、假设线程 A 拿到了锁,并且正在操作同步资源,那么此时线程 B 就必须进行等待。

并发编程中常见的锁策略_第2张图片


 3、而当线程 A 执行完毕后,CPU 才会唤醒正在等待这把锁的线程 B 再次尝试获取锁。

并发编程中常见的锁策略_第3张图片


4、如果线程 B 现在获取到了锁,才可以对同步资源进行自己的操作。

悲观锁要操作数据必须先获取锁的特点可以确保数据的一致性,但也带来了并发性能的下降,因为其他线程需要等待锁的释放。一般而言,悲观锁和乐观锁相比要做的工作更多,效率也会更低。(不绝对)

(2)乐观锁

乐观锁总是假设最好的情况,认为自己在操作资源的时候不会有其他线程来干扰,每次去拿数据的时候别的线程都不会来修改,所以并不会锁住被操作对象。同时,为了确保数据正确性,线程会在更新数据的时候判断一下在自己修改数据这期间,还有没有被别的线程来修改过数据(通过版本号或CAS算法判断):

  • 如果数据没被别的线程修改过,就说明真的只有我自己在操作,那我就可以正常地修改数据;
  • 如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内也修改过数据,那说明我更新迟了一步,所以我会放弃本次修改,并选择报错、重试等策略。

这和我们生活中乐天派的人的性格是一样的,乐观的人并不会担忧还没有发生的事情,相反,他会认为未来是美好的,所以他在修改数据之前,并不会把数据给锁住。当然,乐天派也不会盲目行动,如果他发现事情和他预想的不一样,也会有相应的处理办法,他不会坐以待毙,这就是乐观锁的思想。

同样,举个乐观锁的例子:

1、假设线程 A 此时运用的是乐观锁。那么它去操作同步资源的时候,不需要提前获取到锁,而是可以直接去读取同步资源,并且在自己的线程内进行计算。

并发编程中常见的锁策略_第4张图片


2、当它计算完毕之后、准备更新同步资源之前,会先判断这个资源是否已经被其他线程所修改过。

并发编程中常见的锁策略_第5张图片


3、如果这个时候同步资源没有被其他线程修改更新,也就是说此时的数据和线程 A 最开始拿到的数据是一致的话,那么此时线程 A 就会去更新同步资源,完成修改的过程。

并发编程中常见的锁策略_第6张图片


4、而假设此时的同步资源已经被其他线程修改更新了,线程 A 会发现此时的数据已经和最开始拿到的数据不一致了,那么线程 A 不会继续修改该数据,而是会根据不同的业务逻辑去选择报错或者重试。

并发编程中常见的锁策略_第7张图片

乐观锁通常不会阻塞其他事务,因此并发性能较高,但在发生并发冲突时需要处理冲突的情况。一般而言,它的工作相对少,效率也相对高。(也不绝对)

.

可以用一个生活中的例子来类比乐观锁和悲观锁:同学 A 和 同学 B 请教老师一个问题。

  • 同学 A 认为 “老师是很忙的,我来问问题老师不一定有空解答”。因此同学 A 先给老师发消息:“老师,您忙吗? 我下午两点能来找你问个问题吗?”(这相当于加锁操作)。得到肯定的答复之后(获取锁后),才会真的来问问题。如果得到了否定的答复那他就等一段时间下次再尝试和老师确定时间。这个是悲观锁。
  • 同学 B 则认为 “老师是很较闲的我来问问题老师大概率是有空解答的”。因此同学 B 直接就没有事先询问,直接来找老师(即加锁,直接访问资源)。如果老师确实比较闲那么就能直接问老师问题;但如果发现老师这会确实很忙(发现数据访问有冲突),那么同学 B 也不会打扰老师就下次再来。这个是乐观锁。
乐观锁和悲观锁这两种思路不能说谁优谁劣,而是要看当前的场景是否合适。(就好比如果当前老师确实比较忙,那么使用悲观锁的策略更合适,使用乐观锁会导致 “白跑很多趟”,耗费额外的资源。如果当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。)

synchronized 初始使用乐观锁策略。但当发现锁竞争比较频繁的时候,它就会自动切换成悲观锁策略。


2、轻量级锁&重量级锁

简单来说,轻量级锁是加锁解锁的过程更快更高效的锁策略,而重量级锁是加锁解锁的过程更慢更低效的锁策略。它们和乐观锁悲观锁虽然不是一回事,但有一定的重合:一个乐观锁很可能也是一个轻量级锁。一个悲观锁很可能是一个重量级锁。

(1)定义

重量级锁 加锁机制重度依赖  OS 提供了 mutex(互斥量)。
  • 大量的内核态用户态切换。
  • 很容易引发线程的调度。

这两个操作的成本都比较高,而且一旦涉及到用户态和内核态的切换,效率就低了。

轻量级锁 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成。 实在搞不定了, 再使用 mutex。
  • 少量的内核态用户态切换。
  • 不太容易引发线程调度。

(2)什么是mutex?

Mutex(互斥锁)是一种在多线程编程中使用的同步原语。它是一种低级别的锁机制,由操作系统提供或由编程语言的库支持。使用互斥锁时,只有一个线程能够获取锁,并且其他线程必须等待直到锁被释放。互斥锁是一种不可重入锁,即同一线程在获取锁之后再次尝试获取会导致死锁。

Mutex提供了两个主要的操作:lock(加锁)和unlock(解锁)。当一个线程需要访问共享资源时,它首先尝试去获取Mutex的锁。如果该Mutex没有被其他线程持有,则该线程成功获取锁,可以访问共享资源。如果Mutex已经被其他线程持有,则当前线程会被阻塞,直到Mutex的锁被释放。一旦线程完成对共享资源的访问,它会释放Mutex的锁,允许其他线程去获取锁并进行访问。

通过Mutex可以实现线程间的互斥和同步,避免多个线程同时访问共享资源导致的数据竞争和不一致性。Mutex可以确保在同一时间只有一个线程能够修改共享资源,从而保证了线程安全性。

然而,Mutex也可能引发一些问题,如死锁(Deadlock)和饥饿(Starvation)。死锁指的是多个线程因相互等待对方持有的锁而无法继续执行的情况,而饥饿则是某些线程因无法获得锁资源而一直无法执行的情况。

除了Mutex,还有其他的并发控制机制来控制多线程并发访问共享资源(同步原语 Synchronization primitives),如读写锁(ReadWrite Lock)、信号量(Semaphore)等,可以根据具体需求选择适当的并发控制方式。

可以说互斥锁(Mutex)是一个更一般的概念,而 synchronized 是 Java 语言中特定的关键字用于实现互斥锁的机制。Java 中的 synchronized 可以看作是一种高级封装的互斥锁,提供了更方便的使用方式,并且支持可重入。在 Java 中,synchronized 通常被用来实现线程安全的访问控制,而不需要显式地使用互斥锁。

原理

锁的 “ 原子性”  机制追根溯源是 CPU 这样的硬件设备提供的:
  • CPU 提供了 “原子操作指令”。
  • 操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁。
  • JVM 基于操作系统提供的互斥锁,实现了 synchronized ReentrantLock 等关键字和类。

synchronized 是对 mutex 进行封装(当然,它不仅仅是对mutex的封装。synchronized 内部还做了很多其他的工作)。

并发编程中常见的锁策略_第8张图片

synchronized 开始是一个轻量级锁。如果锁冲突比较严重,就会变成重量级锁。 


3、自旋锁&挂起等待锁

自旋锁是轻量级锁的一种典型实现,而挂起等待锁是重量级锁的一种典型实现。

(1)自旋锁(Spin Lock)

按之前的方式,线程在抢锁失败后即进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,在大部分情况下虽然当前抢锁失败,但过不了很久锁就会被释放,没必要就放弃 CPU。这个时候就可以使用自旋锁来处理这样的问题。

自旋锁是一种忙等待锁的机制。当一个线程需要获取自旋锁时,它会反复地检查锁是否可用,而不是立即被阻塞。如果获取锁失败(锁已经被其他线程占用),当前线程会立即再尝试获取锁,不断自旋(空转)等待锁的释放,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。这样能保证一旦锁被其他线程释放,当前线程能第一时间获取到锁。

自旋锁伪代码

while (抢锁(lock) == 失败) {...}

自旋锁是一种典型的轻量级锁的实现方式,它通常是纯用户态的,不需要经过内核态(时间相对更短)。

  • 优点:没有放弃 CPU,不涉及线程阻塞和调度。一旦锁被释放就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源(忙等),而挂起等待的时候是不消耗 CPU 的。

自旋锁适用于保护临界区较小、锁占用时间短的情况,因为自旋会消耗CPU资源。自旋锁通常使用原子操作或特殊的硬件指令来实现。

(2)挂起等待锁(Suspend-Resume Lock)

挂起等待锁是一种阻塞线程的锁机制。当一个线程需要获取挂起等待锁时,如果锁已被其他线程占用,当前线程会被挂起(暂停执行,不占用CPU资源),放入等待队列中,直到锁可用。一旦锁可用,线程会被唤醒并继续执行。

挂起等待锁是重量级锁的一种典型实现,通过内核的机制来实现挂起等待(时间更长了)。

  • 优点:节省CPU资源,可以避免线程空转等待锁的释放,从而节省了CPU资源。
  • 缺点:如果锁被释放,不能第一时间拿到锁,可能需要过很久才能拿到锁。

挂起等待锁适用于保护临界区较大、锁占用时间较长的情况,因为挂起等待不会占用CPU资源。挂起等待锁通常使用线程的阻塞机制或操作系统提供的同步原语来实现。 

.

举一个生活中的例子来便于理解:

想象去追求一个女神当男生向女神表白后,女神说:你是个好人但是我有男朋友了。

挂起等待锁:先不理女神了,先去干别的。等未来某一天女神分手了,她又想起我,再主动来找我。(注意,在这个很长的时间间隔里,女神可能已经换了好几个男票了)

自旋锁:坚韧不拔锲而不舍,仍然每天持续的和女神说早安晚安……一旦女神哪天和上一任分手,那么就能立刻抓住机会上位~

自旋锁和挂起等待锁的选择取决于具体的应用场景和系统特点。自旋锁适用于锁占用时间短、线程竞争不激烈的情况,可以减少线程切换的开销。而挂起等待锁适用于锁占用时间长、线程竞争激烈的情况,可以防止线程空转,节省CPU资源。在实际使用中,需要根据具体情况进行权衡和选择,以实现最佳的性能和资源利用。

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。


4、互斥锁&读写锁

互斥锁如synchronized只有两个操作:

  1. 进入代码块,加锁。
  2. 出了代码块,解锁。

加锁就只是单纯的加锁,没有更细化的区分了。

而除了这之外,还有一种读写锁,它能够把读和写两种加锁区分开。读写锁有三种操作:

  1. 给读加锁。
  2. 给写加锁。
  3. 解锁。

多线程之间,如果多个线程同时读同一个变量并不会涉及到线程安全问题​但多个线程同时写一个数据,或一个线程读另外一个线程写,是有可能产生线程安全问题的。因此就要求读和读之间不互斥,而写要求与任何人互斥。

注意:只要是涉及到 “互斥”(加互斥锁),就会产生线程的挂起等待。一旦线程挂起,再次被唤醒就不知道隔了多久了。因此尽可能减少 “互斥” 的机会,就是提高效率的重要途径。

读写锁就是把读操作和写操作区分对待了。如果这两种场景下都用同一个锁,就会产生极大的性能损耗。读写锁通过这样的设计,能把锁控制的更加精细。Java标准库中提供了专门的读锁类和写锁类:

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁。
  • ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁;
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁。

为了提高效率,原则上非必要不加互斥锁。 既然大家在都读取的情况下并没有线程安全问题,就不加锁了。读写锁适用于一写多读的情况,既能保证线程安全,又能保证数据的准确。

并发编程中常见的锁策略_第9张图片

 synchronized不是读写锁。


5、可重入锁&不可重入锁

可重入锁的字面意思是可以重新进入的锁,即允许同一个线程多次获取同一把锁

简单来说,一个线程针对同一把锁连续加锁两次时,如果出现了死锁,那该锁就是不可重入锁(Non-reentrant Lock),如果没有出现死锁,那么该锁就是可重入锁(Reentrant Lock)。

(1)不可重入锁

不可重入锁是一种不允许同一线程多次获取该锁的锁。当线程第一次获取不可重入锁后,再次尝试获取该锁时会被自己所持有的锁所阻塞。如果同一线程在获取不可重入锁后再次尝试获取该锁,会因“把自己锁死”而导致死锁。 

如何理解“把自己锁死”?

比如滑稽老哥去上厕所,进入厕所之后将门锁上,后来经历了一些神奇的事情他突然被传送到了厕所外。这时他又要进入厕所上厕所,然而再要进入厕所时他必须先解锁,但由于上一把锁也是他自己加的,没人给他解锁,他也无法解锁。

并发编程中常见的锁策略_第10张图片

在代码中即一个线程没有释放锁,却又尝试再次加锁。

如下面这个代码,就是加锁两次的情况,第二次加锁必须等到第一次的锁释放,而第一次锁释放需要执行完外层同步代码块中的代码,这又不得不等待第二次加锁成功。二者相互矛盾,从而导致了死锁:

并发编程中常见的锁策略_第11张图片

在日常开发中,同一个线程对同一个对象加两次锁的代码其实是很常见的:

class BlockingQueue {
    synchronized void put(int elem){
        this.size();
        ...
    }
    
    synchronized int size() {
        ...
    }
}

在上述代码中,size()方法与put()方法都对同一个锁对象this加锁。且在put()中调用了size()方法,这就相当于进入put()方法时候该线程对this对象加了锁,而在put()中调用size()时又对this对象加了锁。但该代码在Java中实际并不会造成死锁,因为synchronized是可重入锁。而前面提到的,操作系统原生提供的 mutex 互斥锁是不可重入锁。,除此之外C++标准库的锁,Python标准库的锁也是不可重入锁。

(2)可重入锁

可重入锁是一种支持同一线程多次获取该锁的锁。当线程第一次获取可重入锁后,可以多次重复获取该锁,而不会被自己所持有的锁所阻塞。

比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入(因为这个原因的可重入锁也叫做递归锁

Java里,只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。可重入锁在加锁时会判定看当前申请锁的线程是否已经是锁的拥有者,如果是,则直接放行。


6、公平锁&非公平锁

遵守先来后到的,就是公平锁;不遵循先来后到的,就是非公平锁。此处定义“先来后到”就是公平。

举个例子:

假设三个线程 A,B,C。 A 先尝试获取锁,获取成功;然后 B 再尝试获取锁,获取失败,阻塞等待;然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待。

当线程 A 释放锁的时候,会发生什么呢?

公平锁策略: 遵守  “ 先来后到”。 B C 先来,所以 A 释放锁的之后, B 就能先于 C 获取到锁。
非公平锁策略 不遵守  “ 先来后到”。  B C 都有可能获取到锁。
操作系统内部的线程调度可以视为是随机的, 如果不做任何额外的限制, 锁就是非公平锁。

想实现公平锁,就需要依赖额外的数据结构(队列)来记录加锁线程们的先后顺序。

公平锁和非公平锁没有好坏之分,关键还是看适用场景。

synchronized 是非公平锁。


7、问答题-锁策略

(1)怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都真正加锁乐观锁认为多个线程访问同一个共享变量冲突的概率不大,因此它并不会真的加锁,而是直接尝试访问数据。在访问的同时识别当前的数据是否出现访问冲突。

实现:悲观锁的实现是先加锁(如借助操作系统提供的 mutex),获取到锁再操作数据,获取不到锁就等待。乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。

2) 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁。读锁和读锁之间不互斥,写锁和写锁之间互斥,写锁和读锁之间互斥。读写锁最主要用在 “频繁读,不频繁写” 的场景中。

3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败后又立即再尝试获取锁,如此无限循环直到获取到锁为止。若第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。

相比于挂起等待锁:

优点: 没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。

缺点:如果锁的持有时间较长,就会浪费 CPU 资源。

4) synchronized 是可重入锁么?

是可重入锁。

可重入锁指的就是连续两次加锁不会导致死锁。实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。 如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

你可能感兴趣的:(#,JavaWeb,java,开发语言,JavaWeb,多线程编程,学习)