笔记|并发编程中常见的锁策略<包含详细介绍CAS机制和ABA问题>


常见锁策略

    • 乐观锁、悲观锁
        • 悲观锁
        • 乐观锁
        • CAS
          • CAS的优势
          • CAS的缺点
            • CPU开销过大
            • ABA问题
            • ABA解决方案AtomicStampedReference
    • 公平锁与非公平锁
    • 独占锁与共享锁
    • 可重入锁
    • 自旋锁

乐观锁、悲观锁 乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入类似的思想。
悲观锁 悲观锁指对数据被外界修改保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排他锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
应用:synchronized、Lock 都是悲观锁。
乐观锁 乐观锁是相对于悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
这里想要介绍一下乐观锁的实现,了解一下CAS
CAS CAS是乐观锁的一种实现,CAS全称是比较和替换,CAS的操作主要是由以下几个步骤组成
  1. 先查询原始值
  2. 操作时比较原始值是否修改
  3. 如果修改,则操作失败,禁止更新操作,如果没有发生修改,则更新为新值。
上述三个步骤是一个原子性操作,不可以被拆分执行。
CAS的优势 CAS是一种无锁操作,不需要加锁,避免了线程切换的开销。
CAS的缺点 【笔记|并发编程中常见的锁策略<包含详细介绍CAS机制和ABA问题>】CAS虽然在低并发量的情况下可以减少系统的开销,但是CAS也有一些问题:
  • CPU开销过大问题
  • ABA问题
  • 只能针对一个共享变量
笔记|并发编程中常见的锁策略<包含详细介绍CAS机制和ABA问题>
文章图片

上图是我们在使用CAS的一个基本的操作流程。
CAS中包含了三个操作单位:V(内存值)、A(预期的旧值)、B(此操作要修改的目标值)。
在我们多线程执行操作前,V的值就等于A的值,起初所有的并发线程的这两个值都是一样的。在进行任务操作时,相当于竞争机制,如果一个线程修改了这个内存中的值,这个操作完成后,把V从A修改成了B,这个时候也就有效的避免了其他线程来一起进行操作,所以,任务得不到执行的线程会循环往复的自旋,回去重新读取内存的新数据,直到获取到了相同的值,才能执行自己的任务。
CPU开销过大 在我们使用CAS时,如果并发量过大,我们的程序有可能会一直自旋,长时间占用CPU资源。
ABA问题 所谓ABA问题,就是比较并交换的循环,存在?个时间差,而这个时间差可能带来意想不到的问题。比如线程1和线程2同时也从内存取出A,线程T1将值从A改为B,然后?从B改为A。线程T2看到的最终值还是A,经过与预估值的比较,二者相等,可以更新,此时尽管线程T2的CAS操作成功,但不代表就没有问题。
举一个生活中的例子:
?家火锅店为了生意推出了?个特别活动,凡是在五?期间的老用户凡是卡?余额小于20的,赠送20元,但是这种活动每人只可享受?次。
假设有个线程A去判断账户里的钱此时是15,有个人给他转账,直接+20,这时候卡里余额是35。但是此时不巧,正好在连锁店里,这个客?正在消费,又消费了20,此时卡里余额又为15,线程B去执行扫描账户的时候,发现它又小于20,??过cas给它加了20,这样的话就相当于加了两次,这样循环往复肯定把?板的钱就坑没了!
本质:
ABA问题的根本在于cas在修改变量的时候,无法记录变量的状态,比如修改的次数,否修改过这个变量。这样就很容易在?个线程将A修改成B时,另一个线程又会把B修改成A,造成cas多次执行的问题。
ABA解决方案AtomicStampedReference 引入版本号,每次操作之后让版本号 +1,执行的时候判断版本号的值,就可以解决ABA问题。
import java.util.concurrent.atomic.AtomicStampedReference; /** * ABA 问题演示 */ public class ABADemo1 { private static AtomicStampedReference money = // 引入版本号 new AtomicStampedReference<>(100, 0); public static void main(String[] args) throws InterruptedException {// 第 1 次点击转账按钮(-50) Thread t1 = new Thread(() -> { int old_money = money.getReference(); // 先得到余额 int version = money.getStamp(); // 得到版本号 // 执行花费 2s try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } money.compareAndSet(old_money, old_money - 50, version, version + 1); }); t1.start(); // 第 2 次点击转账按钮(-50)【不小心点击的,因为第一次点击之后没反应,所以不小心又点了一次】 Thread t2 = new Thread(() -> { int old_money = money.getReference(); // 先得到余额 int version = money.getStamp(); // 得到版本号 money.compareAndSet(old_money, old_money - 50, // 版本号+1 version, version + 1); }); t2.start(); // 给账户 +50 元 Thread t3 = new Thread(() -> { // 执行花费 1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } int old_money = money.getReference(); int version = money.getStamp(); money.compareAndSet(old_money, old_money + 50, version, version + 1); }); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println("最终账号余额:" + money.getReference()); } }

乐观锁并不会使用数据库和JDK提供的锁机制,一般在表中添加 version 字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。
公平锁与非公平锁 根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。
ReentrantLock提供了公平锁和非公平锁得实现
  • 公平锁:ReentrantLock lock = new ReentrantLock(true);
  • 非公平锁:ReentrantLock lock = new ReentrantLock(false);
    如果构造函数不传递参数,则默认是非公平锁。
例如,假设线程A已经持有了锁,这时候线程B请求该锁其将会被挂起。当线程A释放锁后,假如当前有线程C也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程B和线程C两者之一都有可能获取到该锁,这时候不需要任何其他干涉,而如果使用公平锁则需要把C挂起,让B获取当前锁。
在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
独占锁与共享锁 根据锁只能被单个线程持有或者是能被多个线程持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占锁方式实现的。共享锁可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
锁一直是围绕线程安全来实现的,比如独占锁,它在内存里面的操作是怎么样的
这个地方涉及到一个概念,内存模型(这个和jvm不要混淆),是JVM用来区别线程栈和堆的内存方式,每个线程在运行的时候,所操作的数据存储空间有两个,一个是主内存一个是工作内存,主内存其实就是jvm中堆,工作内存就是线程的栈,每次的数据操作,都是从主内存中把数据读到工作内存中,然后在工作内存中进行各种处理,如果进行了修改,会把数据回写到主内存,然后其他线程又进行同样的操作,就这样数据在工作内存和主内存,进进出出,不亦乐乎,在多次的情况下,就是因为进进出出的顺序乱了,不是按照线程预期的访问顺序,就出现了数据不一致的问题,导致了多线程的不安全性;整个操作过程还牵涉到CPU,高速缓存等概念…
可重入锁 当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们就说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数地进入该锁锁住的代码。
在面例子展示可重入锁地情况:
/** * synchronized 可重入性测试 */ public class ThreadSynchronized { public static void main(String[] args) { synchronized (ThreadSynchronized.class) { System.out.println("当前主线程已经得到了锁"); synchronized (ThreadSynchronized.class) {//可以进入第二层 System.out.println("当前主线程再次得到了锁"); } } } }

笔记|并发编程中常见的锁策略<包含详细介绍CAS机制和ABA问题>
文章图片

实际上,synchronized 内部锁是可重入锁。可重入锁地原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
但是当获取了该锁的线程再次获取锁时发现拥有者是自己,就会把计数器值加 +1,当释放锁后计数器 -1。当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
自旋锁 由于 Java 中的线程是与操作系统的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取锁时有需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,他不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取,很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。

    推荐阅读