常见的锁策略详细讲解(悲观锁 vs 乐观锁,轻量锁 vs 重量锁,自旋锁 vs 互斥锁,公平锁 vs 非公平锁,读写锁等)

2024-01-02 21:04:59

悲观锁和乐观锁

所谓悲观和乐观

从人的精神层面来讲,悲观就是:在生活中,人思考问题时,总向着坏的方向思考,乐观就是:在思考问题时,总向着好的方向考

Java中的悲观锁和乐观锁

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

  • 悲观锁:总是假设是最坏的情况,当一个线程获取数据时,都会有其他的线程同时来修改这个数据,所以,当一个线程拿数据时,总会去加锁,这样,当别的线程想要对这个数据进行操作时,就会阻塞等待,换句话讲,就是共享资源在一个时刻间只能一个线程获取,其他线程阻塞等待,直到这个线程释放锁之后,其他线程才能够在尝试获取资源,所以,悲观锁更适合于多个线程对同一个资源进行修改的情况
  • 乐观锁:总是假设是最好的情况,当一个线程获取数据的同时,认为其他的线程不会来对这个数据进行修改,所以,就没必要进行加锁,所以,正因为这个原因,乐观锁更适合于多个线程读取同一个资源的情况。

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

1??版本号机制
2??CAS 算法

版本号机制

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

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

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

CAS(compare and swap) 算法

CAS 算法正如它的名字一样,比较和交换,它有三个操作数CAS(V,A,B),算法如下:

V是需要读写的内存值

A是要和V进行比较的值

B是要写入的新值

当 V 和 A相同时,CAS 算法就认为此时V没有被修改过,就会将 B 赋值给 V,否则,不会进行任何的操作,需要注意的是:这个比较和交换的操作,它是一个原子性的操作,也就是由一条 CPU 指令完成的,所以,CAS 算法也是一种无锁编程,即在不使用锁的情况下实现多线程之间的变量同步,在一般情况下,它是一个自旋的操作,也就是不断的重试。

乐观锁的缺点

1??.ABA问题

ABA 问题是 CAS 操作中的一个大问题,如果一个变量 V 初次读取的时候是 A 值,在准备赋值的时候,检查到它仍然是 A 值,那我们就能说明这个值就没被其他线程改过吗?,答案是:不能,因为,在这段时间内,可能被其他线程改过了,但是又改了过来,那 CAS 操作就会认为它从来没有被修改过。

2??.循环时间长,开销大

因为,在 CAS 操作下,它是一种自旋操作,以及在引入版本号的情况下,它也是一种循环重试的操作,如果长时间不成功,那么就会一直循环重试,进入一种“忙等”的状态,对 CPU 的开销比较大

本篇文章中有些内容借鉴于:https://zhuanlan.zhihu.com/p/40211594

轻量级锁和重量级锁

轻量级锁,锁的开销比较小;

重量级锁,锁的开销比较大;

轻量级锁和重量级锁也是和上面的乐观和悲观有关联的,因为,乐观锁做的工作比较少,所以就会比较轻量,而悲观锁,做的工作比较多,所以就会很重量。

它们只是站在了不同的角度来衡量的,一个是预测锁冲突的概率,一个是实际消耗的开销

所以乐观锁通常就是轻量级锁,悲观锁通常是重量级锁

自旋锁 VS 互斥锁

自旋锁 就属于轻量级锁的典型表现

互斥锁 就属于重量级锁的一种典型表现

对于互斥锁而言,当某一个线程获取锁后,其他线程再尝试获取锁时,就会进行阻塞等待,就暂时不参与 CPU 的调度,暂时就不参与 CPU 的运算了,直到锁释放以后,

互斥锁要借助系统 api 来实现,如果出现锁竞争,就会在内核中触发一系列的动作,比如,让线程进入阻塞状态,暂时不参与cpu的调度,直到锁被释放以后,才参与CPU的调度,这里就涉及了内核态和用户态切换操作,所以开销就比较大,就比较重量

自旋锁 往往是在纯用户态实现,比如使用一个while循环来不停的判定当前锁是否被释放,如果没释放,就继续循环,如果释放了,就获取倒锁,从而结束循环,它就不涉及到阻塞,会一直在CPU上运行,通过“忙等”的方式消耗cpu,换来更快的响应。

公平锁 VS 非公平锁

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

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

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

读写锁

在多线程下,进行读操作时,是不会产生线程安全问题的,在写操作时,非常容易出现线程安全问题,所以,就可以使用加锁产生互斥效果来解决线程安全问题,而在多个线程进行读操作时,既然不会产生线程安全问题,那么也就不用再进行互斥操作了因为只要涉及到互斥操作,就要阻塞等待,阻塞等待后,就不知道什么时候能够被唤醒了,而且,阻塞等待是内核态+用户态完成的,所以,效率就比较低,而为了提高效率,减少互斥就是一种重要的手段,所以直接并发读就可以了,只有在写操作时,进行互斥操作,所以,就有了读写锁策略。

读写锁的特性:

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

在 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();
            }
        });
    }
}

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

在这里插入图片描述

读写锁的插队策略

插队策略:为了防止“线程饥饿”,读锁不能插队

举个例子:

假设在非公平的ReentrantReadWriteLock场景下:有4个线程,线程1 和 线程2 是同时读取,所以可以同时获取到锁,线程3 想要写入,此时就会阻塞等待(读加锁和写加锁互斥),进入等待队列,此时,线程4没有在队列中,但是,线程4想要进行读取操作,线程4能否有先有线程3执行呢?

针对上述场景,就有两种策略:

  • 策略一:允许线程4优先于线程3执行,因为,线程3是写锁,线程4是读锁,让线程4先读取,是不会对线程3的写操作有任何影响的,也可以提高一定的效率,但是,这个策略有一个弊端:如果在线程4之后又有 n 个线程也进行读操作,都进行插队的话,就会造成“线程饥饿”;
  • 策略二:不允许插队,就是,线程4的读操作必须放在线程3的写操作之后,放入队列中,排在线程3的后面,这样就能避免线程饥饿;

而事实上,ReentrantReadWriteLock 在非公平情况下,采用的是策略2,允许写锁插队,也允许读锁插队,但是,读锁插队的请提示,队列的第一个元素不能是想获取写锁的线程。

读写锁的升级策略

读锁 变成 写锁为升级策略

写锁 变成 读锁为降级策略

代码示例:

public class Main3{
    //创建 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() {
            try {
                //线程获取到读锁
                readLock.lock();
                System.out.println(Thread.currentThread().getName() + "获取读锁,开始执行");
                System.out.println(Thread.currentThread().getName() + "尝试将读锁升级成写锁");
                writeLock.lock();//升级失败,不会执行到下面的代码
                System.out.println(Thread.currentThread().getName() + "读锁升级成写锁成功");
                //睡眠3秒
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                //释放读锁
                readLock.unlock();
                System.out.println(Thread.currentThread().getName() + "释放锁,执行完成");
            }
    }
    //获取写锁的方法
    public static void write() {
            try {
                //线程获取到写锁
                writeLock.lock();
                System.out.println(Thread.currentThread().getName() + "获取写锁,开始执行");
                System.out.println(Thread.currentThread().getName() + "尝试将写锁降级为读锁");
                readLock.lock();
                System.out.println(Thread.currentThread().getName() + "写锁降级成功");
                //睡眠3秒
                Thread.sleep(2000);
            } 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();
            }
        });
    }
}

在这里插入图片描述

在这里插入图片描述

ReentrantReadWriteLock 不支持升级为写锁是因为:为了避免死锁,如果多个线程同时进行升级的话,就会造成死锁,比如,假设线程A和线程B都是读锁,如果两个线程都想升级,那么,线程A升级时,就要等线程B释放了锁,而线程B想要升级时,就要等才能成A释放了锁,此时,就会互相等待,构成死锁。

使用场合:读写锁(ReentrantReadWriteLock)适合于读多写少的场合,可以提高并发效率

可重入锁 VS 不可重入锁

可重入锁:一个线程针对同一把锁连续加锁多次,如果不会死锁,就时可重入锁,反之就是不可重入锁。例如,synchronized就是可重入锁

文章来源:https://blog.csdn.net/Shine0115/article/details/135342254
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。