锁策略(乐观锁/悲观锁、自旋锁/互斥锁、读写锁、CAS算法、ABA问题)

Java中的悲观锁和乐观锁

Java中的悲观锁和乐观锁是一种锁的思想,并不是一种具体的锁,所以以下的内容,都是在讲解锁的思想,请读者不要和具体的锁产生混乱;

  • 悲观锁:总会认为冲突的概率会很大,当一个线程获取数据时,都会有其他的线程同时来修改这个数据,所以,当一个线程拿数据时,总会去加锁,这样,当别的线程想要对这个数据进行操作时,就会阻塞等待,换句话讲,就是共享资源在一个时刻间只能一个线程获取,其他线程阻塞等待,直到这个线程释放锁之后,其他线程才能够在尝试获取资源,所以,悲观锁更适合于多个线程对同一个资源进行修改的情况
  • 乐观锁:总会认为冲突的概率会很小,当一个线程获取数据的同时,认为其他的线程不会来对这个数据进行修改,所以,就没必要进行加锁,所以,正因为这个原因,乐观锁更适合于多个线程读取同一个资源的情况。虽然乐观锁去除了加锁的操作,但是,一旦发生冲突,重试的概率就会很大,所以,在冲突概率非常小,且加锁成本非常高时,才考虑使用乐观锁。

乐观锁常见的两种实现方式

  • 版本号机制
  • 基于时间戳
  • CAS 算法

版本号机制是要引入一个版本号属性version,来记录数据被修改的次数,

例如:

① 在数据库中,为每一条记录添加一个版本号字段version

② 当一个事务开始时,会先读取记录中当前的版本号,然后再执行对应的修改命令

③ 在事务提交更新之前,检查一下记录中的版本号是否还和当初读出的版本号一样,如果一样,则表示其他事没有进行修改,则更新成功,并将版本号+1

④ 如果版本号已经被修改了,则更新失败,事务进行回滚或者重试。

版本号机制

版本号机制是要引入一个版本号属性version,来记录数据被修改的次数,当数据被修改时,version+1,比如,线程A要更新数据的场景时,在获取这个这个数据的同时,会把version也获取到,当线程A对数据修改了以后,也会将version+1,然后,在提交这个更新后的数据时,如果刚才已经修改后的version值大于当前内存中的version值,更新数据,否则,重试更新操作,直到更新成功。

举个例子:假设有这样一种场景:当前,钱包中有100余额,线程A减了50,在线程A进行减50的过程中,线程B进行了减20的操作,请看下图:

基于时间戳

类似于版本号机制,但是用时间戳字段来跟踪记录的修改时间。

比如:

事务在读取记录时,同时获取到时间戳字段,并在提交事务时,检查记录的时间戳是是否与最初读取时的时间戳一致,如果一致,则更新成功,如果不同,则更新失败

CAS(compare and swap) 算法

比如,现在有一个内存M,有两个寄存器 A,B,假设CAS 相当于是一个函数,M,A,B是函数的三个参数,CAS(M,A,B),如果 M 和 A 相同,就将 B 和 M 进行交换,说是交换,其实也就是一种赋值效果,因为,主要关心的是内存中 M 的值,而没人会关心 B 的值,同时整个操作返回 true,如果 M 和 A 不同,就什么事也不做,同时整个操作返回 false,

以上只是 CAS 的一个逻辑关系,CAS 并不是一个方法,一个CPU 指令完成了比较交换逻辑,这也就说明了CAS它是原子的,用来进一步替代了加锁操作。

所以,基于 CAS 实现线程安全的方式,也成为 “无锁编程”。

所以,CAS 的优点:避免了阻塞,降低了开销。缺点:只能使用于特定场景,不如加锁灵活。

同时呢,因为 CAS 本质上是一个 cpu 指令,后来被操作系统封装,提供成API,然后又被 JVM 封装,也提供成 API,供程序员们使用,如下图:

场景:两个线程对同一个变量count进行++:

public class Main {
    public static void main(String[] args) throws InterruptedException {

        AtomicInteger count = new AtomicInteger();

        Thread thread1 = new Thread(() -> {
            for(int i = 0; i < 100; i++) {

                count.getAndIncrement(); //count++;
                //count.incrementAndGet(); ++count
                //count.getAndDecrement(); count--
                //count.decrementAndGet(); --count;
            }
        });
         Thread thread2 = new Thread(() -> {
             for(int i = 0; i < 100; i++) {
                 count.getAndIncrement();
             }
         });
         thread1.start();
         thread2.start();
         thread1.join();
         thread2.join();
        System.out.println(count.get());
    }
}

CAS 适用场景

CAS 的适用场景

public class SpinLock {
    public AtomicInteger lock;
    public SpinLock() {
        lock = new AtomicInteger(0);
    }

    public void lock() {

        while(true) {
            int curValue = lock.get();//获取当前锁的值,也就相当于CAS(M, A, B) 中M的值
            if(curValue == 0) {// 表示此时锁还没有被获取
                if(lock.compareAndSet(0, 1)) {//尝试将锁的值从0原子的设置成1,也就是判断M和A得值是否相同
                    return; //成功获取到锁
                }
            }
        }

    }
    public void unlock() {
        lock.compareAndSet(1, 0);
    } 
}

ABA 问题

CAS 进行操作的关键是通过值“没有发生变化”来作为“没有其他线程穿插执行”的判定依据。也就是 CAS(M, A, B),判断M有没有发生变化。但是,会出现 ABA 问题。

下面来演示一个例子解释ABA问题:

  1. 初始状态:有一个变量 V,其初始值为 A
  2. 第一个线程(Thread A) 读取 V 的值(A),并准备将其更新为 B
  3. 第二个线程(Thread B) 在 Thread A 读取 V 之后,但在更新之前,读取了 V 的值(A),并将其更新为 C
  4. 第三个线程(Thread C) 在 Thread B 更新 V 之后,读取了 V 的值(C),并将其更新回 A
  5. Thread A 继续执行,使用 CAS 操作试图将 V 的值从 A 更新为 B。由于 V 的值尚未被 Thread B 修改(仍为 A),CAS 操作成功执行。

在这个过程中,尽管 V 的值在 Thread A 首次读取和最终更新之间经历了变化(从 A 变为 C 再变回 A),CAS操作却未能检测到这一变化,因为在 CAS 操作执行时,V 的值看起来没有被修改。这就是 ABA 问题。这就好像我们买手机,买到的是一个二手的翻新机,看起来好像是一个新的,但是实际上是个二手的。

为了解决 ABA 问题,可以采用几种策略,最常见的是使用版本号或时间戳。在每次更新操作时,版本号递增,CAS 操作除了比较值外,还要比较版本号。只有当值和版本号都匹配时,更新才能成功。这样即使值被恢复到原始状态,由于版本号已经改变,CAS 操作能够识别出实际的变化

自旋锁 VS 互斥锁

互斥锁是一种独占锁,比如当线程A成功加锁后,此时,互斥锁就被线程A独占了,只要线程A没有释放手中的锁,线程B就会加锁失败,于是就会释放CPU给其他线程,既然线程B释放掉了CPU,自然线程B的加锁代码就会阻塞。

对于互斥锁这种加锁失败而阻塞的现象,是由操作系统内核实现的,当加锁失败后,这个线程就会进入睡眠状态,等到锁被释放以后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁时,就可以继续执行。

所以,当加锁失败时,会从用户态变成内核态操作,让内核帮我们切换线程,此时就会产生一定的性能上的开销。

当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据和寄存器状态,线程的上下文等不共享的数据。

但是如果能够确定被锁住的代码很短,那么很可能上下文切换的时间比锁住的代码执行的时间还长,就不要用互斥锁了,应该使用自旋锁。

自旋锁是用户态完成加锁和解锁的操作,不会主动产生线程上下文切换,相比互斥锁来说,会更快一点,开销会小一点。

比如,在代码中使用while循环:

import java.util.concurrent.atomic.AtomicInteger;

public class SpinLock {
    private AtomicInteger lock = new AtomicInteger(0);

    public void lock() {
        int expectedValue = 0;
        while (!lock.compareAndSet(expectedValue, 1)) {
            expectedValue = 0; // 重置expectedValue,以准备下一次尝试
        }
    }

    public void unlock() {
        lock.set(0); // 释放锁,设置lock为0
    }

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();
        Thread thread1 = new Thread(() -> {
            spinLock.lock();
            // 执行临界区代码
            System.out.println("Thread 1 is inside critical section.");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 1 is leaving critical section.");
            spinLock.unlock();
        });

        Thread thread2 = new Thread(() -> {
            spinLock.lock();
            // 执行临界区代码
            System.out.println("Thread 2 is inside critical section.");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 2 is leaving critical section.");
            spinLock.unlock();
        });

        thread1.start();
        thread2.start();
    }
}

读写锁

读写锁是由 读锁 和 写锁 两部分构成,如果只是读取共享资源的话,使用 读锁加锁,如果要修改共享资源的话,则用写锁加锁。

读写锁的工作原理就是:

当写锁没有被线程持有时,多个线程能够同时并发的持有读锁,因为读锁是用于读取共享资源的场景,所以,多个线程同时持有读锁也不会破坏共享资源的数据,这也就大大提高了共享资源的访问效率。

当写锁被线程持有后,那么,读操作的线程获取读锁时,就会被阻塞,写操作的线程获取写锁时也会被阻塞。

所以,写锁就是一种独占锁,任何时刻只能被一个线程拥有,读锁就是一种共享锁,可以让多个线程同时拥有。

读写锁的特性:

  • 读加锁 和 读加锁 之间不互斥
  • 写加锁 和 读加锁 之间互斥
  • 写加锁 和 写加锁 之间互斥

所以,读写锁在读操作多,写操作少的场景下,能发会出优势。

而且,读写锁也可以分为 读优先锁 和 写优先锁

读优先锁优先服务读线程,它的工作方式就是:比如,有一个 读线程 A 获取了读锁,写线程 B 在获取写锁时,就会进行阻塞,之后又有一个读线程 C 来获取读锁,此时读线程 C 能够成功的加锁,等到线程A和线程C都释放锁后,线程B才能够再获取到写锁。

但是,如果一直有读线程来获取读锁,那么,写线程就会一直进行阻塞,就造成了写线程饥饿的现象。

写优先锁就是优先服务写线程,比如,有一个读线程 A 获取了读锁,那么,写线程B在获取写锁时就会阻塞,之后又有一个读线程C来获取读锁,此时,线程C就会阻塞,等到 线程A释放锁后,线程B就可以获取 到写锁,而不是 线程C获取到读锁。

但是,写优先锁也会出现线程饥饿的情况,如果一直有写线程加锁,那么读线程就会被饿死。

在 Java 标准库中,提供了 ReentrantReadWriteLock 类,该类是基于读写锁实现的;

在这个类中,又实现了两个内部类,分别表示 读锁 和 写锁:

  • ReentrantReadWriteLock.ReadLock 类表示读锁,这个类提供了 lock() 方法进行加锁 和 unlock() 方法进行解锁
  • ReentrantReadWriteLock.WriteLock 类表示写锁,,这个类提供了 lock() 方法进行加锁 和 unlock() 方法进行解锁

代码示例:

示例一:两个线程都进行读操作。执行结果:可以同时获取锁

public class Main {
    //创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    //创建读锁实例
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    //创建写锁实例
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
    //创建线程池
    private static ExecutorService threadPool = Executors.newCachedThreadPool();

    //获取的读锁方法
    public static void read() {
        //线程获取到读锁
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
        }
    }
    //获取写锁的方法
    public static void write() {
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
        }
    }
    public static void main(String[] args) {
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                read();
            }
        });
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                read();
            }
        });
    }
}

结论:由结果可以看到,多个线程在获取读锁时不会产生阻塞等待

在这里插入图片描述

示例二:一个线程进行读操作,一个线程进行写操作。执行结果:一个可以获取到锁,一个阻塞

public class Main {
    //创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    //创建读锁实例
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    //创建写锁实例
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
    //创建线程池
    private static ExecutorService threadPool = Executors.newCachedThreadPool();

    //获取的读锁方法
    public static void read() {
        //线程获取到读锁
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
        }
    }
    //获取写锁的方法
    public static void write() {
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
        }
    }
    public static void main(String[] args) {
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                read();
            }
        });
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                write();
            }
        });
    }
}

结果:可以看出,获取读锁后时,写锁无法进行加锁,必须等读锁释放后才可以获取写锁

在这里插入图片描述

示例三:两个线程都进行写操作。执行结果:一个可以获取到锁,一个阻塞

public class Main {
    //创建 ReentrantReadWriteLock实例,用于创建读锁和写锁实例
    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    //创建读锁实例
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    //创建写锁实例
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
    //创建线程池
    private static ExecutorService threadPool = Executors.newCachedThreadPool();

    //获取的读锁方法
    public static void read() {
        //线程获取到读锁
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放读锁,执行完成");
        }
    }
    //获取写锁的方法
    public static void write() {
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
        try {
            //睡眠3秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //释放读锁
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁,执行完成");
        }
    }
    public static void main(String[] args) {
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                write();
            }
        });
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                write();
            }
        });
    }
}

结论:由执行结果可以看出,无法同时获取写锁

在这里插入图片描述

公平锁 VS 非公平锁

假设,现在有三个线程 A,B,C 轮流尝试获取同一把锁,此时,线程A获取到锁后,线程B 和 线程C依次阻塞等待,当线程A释放锁后,线程B获取锁,之后 线程C 再获取锁,这样按照“先来后到”的方式,来加锁,此时就是公平锁,反之,线程A释放锁喉,线程B 和 线程C 都有可能获取到锁,此时就是非公平锁

公平锁:按照“先来后到”的方式加锁,此时就是公平锁

非公平锁:不按照“先来后到”的方式,按照“抢占式”的方式,此时就是非公平锁。例如,synchronized 就是非公平锁

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值