锁策略以及CAS问题

本文详细介绍了Java并发编程中的锁策略,包括悲观锁与乐观锁、互斥锁与读写锁、重量级锁与轻量级锁、挂起等待锁与自旋锁、公平锁与非公平锁、可重入锁与不可重入锁的概念及应用场景。此外,还深入探讨了CAS(Compare and Swap)操作,阐述了其工作原理、应用以及解决ABA问题的方法。内容涵盖了多线程编程中的关键知识点,有助于理解Java并发编程的实现机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


一、常见的锁策略

1. 悲观锁 VS 乐观锁

1.1 乐观锁

预期冲突的概率很低,多个线程竞争一把锁的概率很低,甚至没有冲突。所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。做的工作比较少,付出的也比较少,效率高。

1.2 悲观锁

假设最坏的情况,预期冲突的概率比较高,多个线程竞争一把锁的概率会比较高。每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到他拿到锁。做的工作比较多,要付出更多成本来解决锁冲突,效率低。

1.3 应用场景

两种锁没有优劣之分,要根据具体的场景来分析。比如,乐观锁适用于读操作多、写操作少,即多读的情况。这样冲突很少发生的时候,可以减少开销。在多写的场景中,冲突也经常发生,使用悲观锁。多个线程对多个数据进行读操作、对同一个数据进行读操作,发生冲突的概率小,但是多个线程对同一个数据进行写操作,会经常容易发生冲突。

2. 普通的互斥锁 VS 读写锁

2.1 普通的互斥锁

对于普通的和互斥锁,只有两个操作:加锁和解锁。只要两个线程对同一个对象加锁,就会产生互斥,出现锁竞争。

2.2 读写锁

多个线程同时读一个变量不会产生线程安全问题,
而且在很多场景中,都是读操作多,写操作少。比如,数据库索引
中,对表的修改操作比较低效,但是读的时候,就可以查的更快。

对于读写锁来说,有三个操作:读锁、写锁、解锁。

  • 加读锁:如果只是进行读操作,加读锁
  • 加写锁:如果只是进行写操作,加写锁
  • 解锁:程序执行完,进行解锁

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock法进 行加锁解锁.

注意: 读锁和读锁之间不存在互斥关系,读锁和写锁、写锁和写锁之间存在互斥关系。

3. 重量级锁 VS 轻量级锁

3.1 重量级锁

在使用锁的过程中,依赖于内核的一些功能来实现的。比如,操作系统提供的mutex接口。操作系统中的锁会在内核中做很多事情,比如让线程阻塞等待。大量的内核态、用户态切换,很容易引起线程的调度。做的工作更多,开销更大。

3.2 轻量级锁

如果是纯用户态实现的,不依赖于操作系统提供的mutex接口。做的工作更少,开销也少。

举个例子理解用户态内核态~

场景:餐厅就餐:自助餐和窗口选菜

  • 自助餐自己打菜,根据爱好选择,是自己在做,这是用户态
  • 去窗口选菜,食堂工作人员打菜,由工作人员完成,这是内核态。
  • 由工作人员完成的操作,这是需要和工作人员进行不断的沟通,如果这个窗口没有喜欢的菜,还需要去别的窗口排队选菜,这时效率是很低的。

4. 挂起等待锁 VS 自旋锁

4.1 挂起等待锁

往往通过一些内核机制来实现的,比较重,重量锁的一种体现。如果线程抢锁失败后,放弃CPU,对应的线程就会在内核中阻塞等待,锁释放之后被操作系统唤醒。

4.2 自旋锁

通过用户态来实现,比较轻,轻量锁的一种体现。在抢锁失败后,不会立即放弃CPU,而是不断询问等待锁的状况。一旦锁被释放,就可以获得锁。

大部分情况下,虽然抢锁失败,但是过不了多久,锁就会被释放。

5. 公平锁 VS 非公平锁

5.1 公平锁

在多个线程等待一把锁的时候,遵循先来后到的原则。
举个例子~
比如,A、B、C三个线程同时请求锁。A先尝试获取锁,获取成功。B再请求锁,失败了,那B就阻塞等待。然后,C也请求锁,失败了,那C也阻塞等待。等到A释放锁之后,B就会先于C获得锁。

5.2 非公平锁

在多个线程等待一把锁的时候,不遵循先来后到的原则。所以,在上述例子中,A释放了锁之后,B、C都有可能获得锁。

注意:

  • 公平锁和非公平锁没有优劣之分,具体看适用场景
  • 操作系统的内部线程调度视为随机的,如果不做任何额外限制,那么,认为是非公平锁。如果要实现公平锁,则需要额外的数据结构进行约束。

6.可重入锁 VS 不可重入锁

6.1 可重入锁

允许同一个线程多次获取同一把锁,不会造成死锁。

6.2 不可重入锁

同一个线程,同一把锁,多次加锁,造成了死锁。

二、CAS

1.什么是CAS?

全称是Compare and swap,意思为:比较并交换

作用:通俗理解,拿着寄存器 / 内存 里面的值A和另一个内存中的值B进行比较。如果值一样,就把当前寄存器 / 内存中的值A和这个内存的值B进行交换。这里的交换,可以理解为赋值。

简化理解:

  • 比较:比较A与B是否相等
  • 交换:如果值相等,交换
  • 返回操作是否成功

伪代码:

  • address:待比较的内存地址
  • expectValue: 预期内存中的值
  • swapValue:把内存中的值改为新的值
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false; }

但是,这段代码是线程不安全的。因为涉及了读写操作,读写还不是原子的。

此处的CAS是指:CPU提供了一个单独的CSA指令,通过这条指令就可以完成上述伪代码描述的过程。一条指令就可以完成操作,相当于是原子的了。CPU上的指令就是一条一条执行的,指令已经是不可分割的最小单位了。也就是说,这个指令已经封装好了,直接拿来用,不用管内部的实现过程。

2. CAS的应用

2.1 实现原子类

Java标准库中提供了java.util.concurrent.atomic 包,里面的类都是基于CAS来实现的。典型的就是AtomicInteger类,getAndIncrement();相当于++操作。

举个例子,多线程实现数字的累加。代码是线程安全的,所以输出结果为200。


import java.util.concurrent.atomic.AtomicInteger;

public class Demo27 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                // 这个方法就相当于 num++
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                // 这个方法就相当于 num++
                num.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 通过 get 方法得到 原子类 内部的数值.
        System.out.println(num.get());
    }
}

伪代码:

class AtomicInteger {
    private int value; //原始的值,也就是内存中的值
    public int getAndIncrement() {
        //读取原始的值为oldValue,相当于把内存中的值读到寄存器中
        int oldValue = value; 
        //把内存中的值和寄存器中的值进行比较,如果相等,oldValue+1并且返回true,循环结束。如果不等,不做任何操作,返回false,继续下次循环。(重新读取value的值,再进行CAS)
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

上述实现++操作安全的原因:CAS不涉及锁竞争、阻塞等待,保证的原子性。

画图理解

  • 执行顺序
    在这里插入图片描述

  • 两个线程都执行load操作
    在这里插入图片描述

  • t1先执行CAS操作,value和oldValue值相等,把value的值设为oldValue+1、oldValue = value
    在这里插入图片描述

  • t2执行CAS操作,此时value和oldValue值不相等,不能进行+1、赋值操作。要重新进入循环,并且重新读取内存中value的值到寄存器oldValue中。
    在这里插入图片描述

  • t2再次执行CAS操作,value和oldValue值相等,把value的值设为oldValue+1、oldValue = value
    在这里插入图片描述

  • 线程t1和t2返回各自的oldValue

2.2 实现自旋锁

通过 CAS 看当前锁是否被某个线程持有。
如果这个锁已经被别的线程持有, 那么就自旋等待。
如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程。

public class SpinLock {
    //表示当前未上锁
    private Thread owner = null;
    public void lock(){
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

3. CAS的ABA问题

3.1 什么是ABA问题?

简单来说,就是当前值和旧值相等,有可能是中间没发生任何变化,也有可能是中间变了又变回来了。

举个例子:银行取款
账户余额:100 取款:50

伪代码:

int oldValue = 100;
CAS(value,oldValue,oldValue-50)
  • t1和t2将内存中的数据加载到寄存器上
  • t1执行CAS操作后,value变为50,oldValue变为50
  • 此时t2执行CAS操作,由于value = 50,oldValue = 100,不相等,如果没有t3这个线程,就直接结束了。
  • 但是t3在t2执行CAS之前进行了转账50的操作,此时value = 100。因为值相等。由于ABA问题,产生误判,会接着进行一次操作。意味着,再取出来50。

整个过程的变化就是100 --> 50 --> 100 (ABA)

在这里插入图片描述

3.2 解决方案

引入版本号,版本号只会变大不会变小。修改变量的同时,也修改版本号。版本号的初试值设为1

继续银行取款的例子 ~
在这里插入图片描述

  • 我们期望的是,t1进行一次取款50的操作后,t2取款50失败
  • 解决CAS的ABA问题,设置版本号,初始值为1

1)t1和t2将内存中的值读到寄存器中
在这里插入图片描述

2)t1执行CAS操作,value变为50,版本号变为2,oldValue也变为50。
在这里插入图片描述
3)t3执行转账50的操作,value变为100,版本号变为3
在这里插入图片描述

4)t2执行CAS操作,value = oldValue,但是版本号不相同,t2取款50操作失败

如果不加版本号,之间用变量比较,就容易出现ABA问题。加版本号之后,每次修改变量会变化,版本号增加,这个时候就不会有ABA问题了。

评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值