大聪明教你学Java | 深入浅出聊乐观锁与悲观锁(synchronized 悲观锁)

5,159 阅读7分钟

前言

这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

“锁”一直是一个老生常谈问题,尤其是在面试的过程中我们常常会被问到“锁”的一些相关的问题,其中就数“悲观锁”和“乐观锁”出现的频率最高,那么今天就和大家聊聊 Java 中“悲观锁”和“乐观锁”的区别和应用。

乐观锁(Optimistic Lock)

本着“繁琐问题必有猥琐解法”的宗旨,这里就直接说一下我对于乐观锁的“猥琐理解”👇

顾名思义,乐观锁是非常乐观的,它很相信别人,每次去数据库中拿数据的时候都认为别人不会修改,但是它也有点自己的“小心思”,它在修改数据的时候还是会进行判断(判断一下在它拿到数据进行修改的这个阶段有没有其他人去更新这条数据)

实现乐观锁的两种方式

最常见的乐观锁实现方式有两种:① 版本号控制 ② CAS算法

咱们先看看利用版本号控制来实现乐观锁,举个小例子~

假设数据库中有一个会员表,表中有三个字段,分别是:id(序号),name(会员名,值为张三),version(版本号,值为 0)。

这时候用户A读取出这条数据,并将会员名修改为“李四”(此时 version = 0);在用户A操作的过程中,用户B又读取出了这条数据,并将会员名修改为“王老五”(此时 version = 0)。用户A完成了修改工作后,将数据版本号( version = 0),连同用户名( name = 李四 )一起提交至数据库进行更新,此时提交进来的版本号等于数据库中存储的版本号,数据被成功更新,同时数据库中的版本号(version)更新为 1;这时候用户B也完成了修改,将数据版本号( version = 0),连同用户名( name = 王老五 )一起提交至数据库进行更新,这时候发现用户B提交进来的版本号和数据库中存储的版本号不一致,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,所以用户B的提交就被判定为无效。这样也就避免了用户A修改后的数据被用户B的提交的数据覆盖的问题。

接下来咱们再看看 CAS 算法 👇

CAS 即 compare and swap(比较与交换),这是一种比较有名的无锁算法。CAS 是在不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

CAS 中涉及到了三个元素,分别是V(存储的原始数据)、A(预期值)、B(需要修改的新值)。它的原理就是:当且仅当预期值 A 和原始值 V 相同时,将原始值 V 修改为 B,否则什么都不做

是不是感觉这个算法很强~ 那这个算法会存在问题吗~

举个例子:线程A从取出了原始数据 V (此时 V 的值是1),因为某些原因导致线程A取出数据后被挂起了,这时候另一个线程B也取出了原始数据 V(此时 V 的值也是1),并且线程B执行了一些操作,将 V 变成了2,紧接着线程 B 又将 V 的数据变成了1,这时候线程A开始执行,进行CAS操作时发现原始数据 A 的值仍然是1,然后线程A操作成功。

看完这个例子是不是就明白了,尽管线程A操作成功了,但是整个过程也是存在问题的,这就是 CAS 中经典的 ABA 问题。解决 ABA 问题也很简单,最简单的办法就是利用版本号控制,每次将变量更新后就把版本号加1,以上面的例子来说,修改的过程是1 => 2 => 1,增加版本号后就变成了 11 => 22 => 31 (前面的1、2、3是版本号,后面是1、2、1是 V 的值)。

除此之外 CAS 机制还存在另外两个问题:

  1. 高并发的情况下,很容易发生并发冲突,如果 CAS 一直失败,那么就会一直重试,浪费CPU资源
  2. 功能限制 CAS 是能保证单个变量的操作是原子性的,在Java中要配合使用 volatile 关键字来保证线程的安全;当涉及到多个变量的时候,CAS 就无能为力了

P.S. 关于 CAS 算法,本人了解的也不多,仅仅了解一个皮毛,这里就不说太多了😅 如果您对 CAS 算法有更深、更独到的理解,欢迎在评论区留言~

悲观锁(Pessimistic Lock)

乐观锁聊完了,我们再来看看悲观锁,直接说一下我对于悲观锁的“猥琐理解”👇

顾名思义,悲观锁很悲观,它太小心眼了,总是觉得别人会修改它的数据,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据时就会被阻塞,直到它释放锁后其他人才能操作。

实现悲观锁的方式

先贴上一段代码👇

/**
 * synchronized 悲观锁
 * @description: Demo
 * @author: 庄霸.liziye
 * @create: 2021-12-29 10:20
 **/
public class SynchronizedDemo implements Runnable {

    static int num = 0;

    public void print() {
        for (int i = 0; i < 5000; i++) {
            num++;
        }
    }

    @Override
    public void run() {
        print();
    }

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

        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();

        Thread thread_A = new Thread(synchronizedDemo);
        Thread thread_B = new Thread(synchronizedDemo);
        thread_A.start();
        thread_B.start();
        thread_A.join();
        thread_B.join();
        System.out.println("num = " + num);
    }
}

我们新建了两个线程,希望两个线程同时执行 print() 方法,那么各位小伙伴想想~ 以上面的代码为例,最终 num 的值会是多少呢? 可能很多小伙伴会觉得最输出的 num 值是10000,其实不然👇 在这里插入图片描述 它最终输出的结果是6685(其实每次输出的结果都不一样,也有可能输出10000),这种情况是由于两个线程同时对该变量进行操作而导致的。说的再具体一点,假如当程序执行若干秒后 num 值变成了3000,这时候 thread_A 和 thread_B 同时执行 print() 方法,也就是说此时 thread_A 和 thread_B 都要同时对 num 进行加操作,但是由于这两个线程此时获取的 num 值都是3000,当两个线程对变量进行加操作后 ,num 值由3000变成了3001而不是3002,也就说其中某个线程执行的+1操作是无效的,所以才出现了截图中的结果。

如果我们希望两个线程可以正常执行加操作(即每一次+1都是有效的)该怎么办呢?So easy!给它加个锁就行了👇

/**
 * synchronized 悲观锁
 * @description: Demo
 * @author: 庄霸.liziye
 * @create: 2021-12-29 10:20
 **/
public class SynchronizedDemo implements Runnable {

    static int num = 0;
    
	//给print方法加锁
    public synchronized void print() {
        for (int i = 0; i < 5000; i++) {
            num++;
        }
    }

    @Override
    public void run() {
        print();
    }

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

        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();

        Thread thread_A = new Thread(synchronizedDemo);
        Thread thread_B = new Thread(synchronizedDemo);
        thread_A.start();
        thread_B.start();
        thread_A.join();
        thread_B.join();
        System.out.println("num = " + num);
    }
}

我们在 print() 方法中增加了 synchronized 关键字,也就是给 print() 方法加上了一个悲观锁,这样就保证了无论是哪个线程在执行+1操作的过程中,都不会受到另一个线程的影响,保证了每一次的+1操作都是有效的。

下面我们再改造一下代码~

/**
 * synchronized 悲观锁
 * @description: Demo
 * @author: 庄霸.liziye
 * @create: 2021-12-29 10:20
 **/
public class SynchronizedDemo implements Runnable {

    public final Object object = new Object();

    static int num = 0;

    public void print() {

        /**
         * 其他业务逻辑与耗时操作
         * ...
         * ...
         * 
         **/

        synchronized (object) {
            for (int i = 0; i < 10000; i++) {
                num++;
            }
        }
    }

    @Override
    public void run() {
        print();
    }

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

        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();

        Thread thread_A = new Thread(synchronizedDemo);
        Thread thread_B = new Thread(synchronizedDemo);
        thread_A.start();
        thread_B.start();
        thread_A.join();
        thread_B.join();
        System.out.println("num = " + num);
    }
}

我们对代码进行了一个小小的改造,将锁加到了具体的代码块上,虽然给方法加锁和给代码块加锁都可以保证线程安全,但是后者降低了锁粒度,只锁住了需要保证线程安全的代码,其他无需保证线程安全且耗时的操作可以同步进行,也就增加代码执行效率。

synchronized (object) 和 synchronized (this)

有些小伙伴可能已经发现了,我们用的是 synchronized (object) 而不是 synchronized (this) ,synchronized (this) 中,锁的对象是this;synchronized (object) 中,锁的对象是object,二者都可以帮助我们实现锁,那我们为什么要多创建一个 object 对象呢?这二者又有什么区别呢~? 容小弟喝口水继续为大家讲解☕

大家先看看下面的代码👇

/**
 * synchronized 悲观锁
 * @description: Demo
 * @author: 庄霸.liziye
 * @create: 2021-12-29 10:20
 **/
public class SynchronizedDemo{

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

        PrintTest printTest = new PrintTest();

        Thread thread_A = new Thread(() -> {
            printTest.print();
        });

        Thread thread_B = new Thread(() -> {

            try {
                synchronized (printTest){
                    while (true) {
                        ;
                    }
                }
            } catch (Exception e) {
                System.out.println(e.getMessage());
            }

        });

        thread_A.start();
        thread_B.start();
    }
}

class PrintTest{
    public void print(){
        synchronized (this){
            System.out.println("我在人民广场吃着炸鸡~");
        }
    }
}

大家考虑一下这段代码的执行结果会是什么呢?其实什么都不会输出,因为 synchronized (this) 的锁对象是 this,此时一旦锁对象(实例)被别人获取,别人只要开一个像 thread_B 的线程,那么 thread_A 线程就永远不会执行,从而造成死锁,所以说使用 synchronized (this) 的时候还是存在一定风险的。

我们再改造一下代码👇

/**
 * synchronized 悲观锁
 * @description: Demo
 * @author: 庄霸.liziye
 * @create: 2021-12-29 10:20
 **/
public class SynchronizedDemo{

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

        PrintTest printTest = new PrintTest();

        Thread thread_A = new Thread(() -> {
            printTest.print();
        });

        Thread thread_B = new Thread(() -> {

            try {
                synchronized (printTest){
                    while (true) {
                        ;
                    }
                }
            } catch (Exception e) {
                System.out.println(e.getMessage());
            }

        });

        thread_A.start();
        thread_B.start();
    }
}

class PrintTest{

    private final Object object = new Object();

    public void print(){
        synchronized (object){
            System.out.println("我在人民广场吃着炸鸡~");
        }
    }
}

我们将 synchronized (this) 换成了 synchronized (object),此时锁的对象是object,即便有其他人(thread_B)拿到了锁的对象,也不会影响其他线程执行。

P.S. 所以我们在选择锁对象的时候,一定要考虑到锁对象的安全性,防止出现因为锁对象的暴露而导致的一些线程安全问题。

乐观锁与悲观锁的使用场景

关于两种锁的应用场景,这里就简单说几句:乐观锁适用于多读的场景,也就是冲突很少发生的场景,此时使用乐观锁就可以大大降低了锁的开销,也就使得系统的吞吐量得到提升;但是如果在多写的场景下,写入的过程可能会经常产生冲突,这时候如果使用乐观锁就会导致上层应用会不断的进行重试(写入一次不成就写入两次,两次不成就写入三次...),这样反倒是降低了性能,所以在多写的场景下使用悲观锁就比较合适了。

P.S. 乐观锁与悲观锁没有谁优于谁的说法,我们要根据实际情况去选择合适的锁,否则就可能“偷鸡不成蚀把米”喽~

小结

本人经验有限,有些地方可能讲的没有特别到位,如果您在阅读的时候想到了什么问题,欢迎在评论区留言,我们后续再一一探讨🙇‍

希望各位小伙伴动动自己可爱的小手,来一波点赞+关注 (✿◡‿◡) 让更多小伙伴看到这篇文章~ 蟹蟹呦(●'◡'●)

如果文章中有错误,欢迎大家留言指正;若您有更好、更独到的理解,欢迎您在留言区留下您的宝贵想法。

爱你所爱 行你所行 听从你心 无问东西