关注「Java视界」公众号,获取更多技术干货

悲观锁 和 乐观锁 是啥?

一、悲观锁

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。

悲观锁又叫互斥同步锁,它为了确保结果的正确性,会在每次获取到数据后,都会将其锁住,因此当其他线程也来访问时,就会进入阻塞状态,这样就可以防止其他线程访问该数据,从而保证数据的安全性。

Java中我们常用的 Synchronized  RenntrantLock 都是悲观锁。

在数据库很多地方就用到了这种锁机制,比如行锁,表锁,读锁,写锁等。

一个典型的倚赖数据库的悲观锁调用:select * from style where id='1'  for update 这条 sql 语句锁定了style表中所有符合检索条件(id='1')的记录。这条数据就被当前事务锁定了,其它的事务必须等本次事务提交之后才能执行,也就是其他线程进入阻塞状态。这样可以保证当前的数据不会被其它事务修改。(ps:id字段一定是主键或者唯一索引,不然是锁表,会出事的。)

对于悲观锁来说,只能有一个事务占据资源,其他事务被挂起等待持有资源的事务提交并释放资源。CPU就会将这些得不到资源的线程挂起,挂起的线程也会消耗CPU的资源,尤其是在高井发的请求中。

一旦该线程提交了事务,那么锁就会被释放,这个时候被挂起的线程就会开始竞争资源,那么竞争到的线程就会被 CPU 恢复到运行状态,继续运行。

在高并发的过程中,使用悲观锁就会造成大量的线程被挂起和恢复,这将十分消耗资源,这就是为什么使用悲观锁性能不佳的原因。

有些时候,我们也会把悲观锁称为独占锁,毕竟只有一个线程可以独占这个资源,或者称为阻塞锁,因为它会造成其他线程的阻塞。无论如何它都会造成并发能力的下降,从而导致 CPU 频繁切换线程上下文,造成性能低下。为了克服这个问题,提高并发的能力,避免大量线程因为阻塞导致 CPU 进行大量的上下文切换,程序设计大师们提出了乐观锁机制,乐观锁已经在企业中被大量应用了。

二、乐观锁

乐观锁是一种思想,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。如果一个线程在修改数据时,没有其他线程干扰,那就会正常执行修改;而如果该数据已经被其他线程修改过,那当前线程为了保证数据正确性,就会放弃或报错。

常用的悲观锁例子有原子类并发集合等。

乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,所以也有人把它称为非阻塞锁。

乐观锁使用的是 CAS 原理,所以我们先来讨论 CAS 原理的内容。

2.1 CAS 原理概述

CAS (Compare and Swap) 即比较并替换,实现并发算法时常用到的一种技术。CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该数据已经被其它线程修改了,那么就不再更新数据,可以考虑重试或者放弃。有时候可重试,这样就是一个可重入锁。
CAS操作包含三个操作数——目标数据内存位置、原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果两者相等,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作,一般会自旋重试。
  1. CAS包含比较和转换两个操作,但它属于原子操作,由CPU在硬件层面保证其原子性,
  2. 许多CAS的操作是自旋的,即操作不成功时不断重试,直到操作成功为止。

2.2 ABA 问题解决

但是 CAS 原理会有 一个问题,那就 ABA 问题,下面先来讨论 ABA 问题。举个例子,你看到桌子上有100块钱,然后你去干其他事了,回来之后看到桌子上依然是100块钱,你可能会认为这100块没人动过,其实在你走的那段时间,别人已经拿走了100块,后来又还回来了。你以为钱没被动过,但已经被动过了,这就是ABA问题。

ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

ABA 问题的发生是因为业务逻辑存在回退的可能性, 如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号( version ),对于版本号有一个约定,就是只要修改变量的数据,强制版本号(version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。
例如:我们对数据加一个版本控制字段,只要有人动过这个数据,就把版本进行增加,我们看到桌子上有100块钱版本是1,回来后发现桌子上100没变,但是版本却是2,就立马明白100块有人动过。

2.3 乐观锁思想

可以说乐观锁是由CAS机制+版本机制来实现的。

乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

(1)CAS机制:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可“。

(2)版本机制:CAS机制保证了在更新数据的时候没有被修改为其他数据的同步机制,版本机制就保证了没有被修改过的同步机制,解决了ABA问题。

通过 CA 原理和 ABA 题的讨论,我们更加明确了乐观锁的原理,使用乐观锁有助于提高并发性能,但是由于版本号冲突,乐观锁导致多次请求服务失败的概率大大提高,而我们通过重入(按时间戳或者按次数限定)来提高成功的概率,这样对于乐观锁而现的方式就相对复杂了,其性能也会随着版本号冲突的概率提升而提升,并不稳定。使用乐观锁的弊端在于导致大量的 SQL 被执行,对于数据库的性能要求较高,容易引起数据库性能的瓶颈,而且对于开发还要考虑重入机制,从而导致开发难度加大。

2.4 乐观锁的代码实例

2.4.1 线程不安全实例

先看一个不使用锁,然后多线程访问的实例:

要争夺的资源

public class Number {

    int num = 0;

    public int getNum() {
        return this.num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    public void add() {
        num += 1;
    }

    public void dec() {
        num -= 1;
    }
}

线程一:

/**
 * 线程一 数据做加操作的线程
 */
public class ThreadOne extends Thread {
    Number num;

    public ThreadOne(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int i = 0; i < Test.LOOP; ++i) {
            num.add();
        }
    }
}

线程二:

/**
 * 线程二 数据做减法操作的线程
 */
public class ThreadTwo extends Thread {

    Number num;

    public ThreadTwo(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int j = 0; j < Test.LOOP; j++) {
            num.dec();
        }
    }
}

测试类:

public class Test {
    final static int LOOP = 1000;

    public static void main(String[] args) throws InterruptedException {
        Number num = new Number();
        Thread addThread = new ThreadOne(num);
        Thread decThread = new ThreadTwo(num);
        addThread.start();
        decThread.start();
        addThread.join();
        decThread.join();
        System.out.println(num.getNum());
    }

}

我们运行3次,结果:

-16
8
293

对同一个数据0执行1000加法,再执行1000次减法,最后数据应该还是0才对,但三次执行结果都不是0,且三次结果都不一样。

也就是多线程更新数据时,数据没有朝着我们期望的结果进行,产生这个结果的原因就是加线程和减线程并没有均匀的抢占资源,若果是均匀抢占那么意味着加和减的操作次数是一样的,最终结果肯定是0;若加线程抢占的次数多了,那结果就是正数;若减操作抢占的次数多,结果就会是负数。

线程安全可以认为是多线程访问同一代码(数据/资源)时,不会产生不确定的结果,那么上面的情况就可以认为是线程不安全的例子,解决的办法就是加锁,加锁就会涉及到悲观锁和乐观锁两种加锁方式。

2.4.2 悲观锁实例 

先看悲观锁的处理方式:

public class Number {

    int num = 0;

    public synchronized void add() {
        num += 1;
    }

    public synchronized void dec() {
        num -= 1;
    }

    public int getNum() {
        return this.num;
    }

    public void setNum(int num) {
        this.num = num;
    }
}

 以上共享资源的加和减操作都加上了锁,相当于Number这个资源被锁定,只有当释放锁以后另一个线程才能访问。

public class ThreadOne extends Thread {
    Number num;

    public ThreadOne(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int i = 0; i < Test.LOOP; ++i) {
            num.add();
        }
    }
}
public class ThreadTwo extends Thread {

    Number num;

    public ThreadTwo(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int j = 0; j < Test.LOOP; j++) {
            num.dec();
        }
    }
}
public class Test {
    final static int LOOP = 1000;
    public static void main(String[] args) throws InterruptedException {
        System.out.println("开始时间:" + new Date().getTime());
        Number num = new Number();
        Thread addThread = new ThreadOne(num);
        Thread decThread = new ThreadTwo(num);
        addThread.start();
        decThread.start();
        addThread.join();
        decThread.join();
        System.out.println(num.getNum());
        System.out.println("结束时间:" + new Date().getTime());
    }
}

结果:

开始时间:1592102861797
0
结束时间:1592102861799

每次执行都是0。

2.4.3 乐观锁实例 

下面用乐观锁思想实现一下,

public class Number {

    //    int num = 0;
    //使用AtomicInteger代替基本数据类型
    AtomicInteger num = new AtomicInteger(0);

    public void add() {
        // num += 1;
        num.addAndGet(1);
    }

    public void dec() {
        // num -= 1;
        num.decrementAndGet();
    }

    public AtomicInteger getNum() {
        return this.num;
    }

}

上面用AtomicInteger代替基本数据类型,其它代码不变:

public class ThreadOne extends Thread {
    Number num;

    public ThreadOne(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int i = 0; i < Test.LOOP; ++i) {
            num.add();
        }
    }
}
public class ThreadTwo extends Thread {

    Number num;

    public ThreadTwo(Number num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int j = 0; j < Test.LOOP; j++) {
            num.dec();
        }
    }
}
public class Test {
    final static int LOOP = 1000;
    public static void main(String[] args) throws InterruptedException {
        System.out.println("开始时间:" + new Date().getTime());
        Number num = new Number();
        Thread addThread = new ThreadOne(num);
        Thread decThread = new ThreadTwo(num);
        addThread.start();
        decThread.start();
        addThread.join();
        decThread.join();
        System.out.println(num.getNum());
        System.out.println("结束时间:" + new Date().getTime());
    }
}
开始时间:1592103599077
0
结束时间:1592103599079

这里最值得探究的就是AtomicInteger,为什么改个数据类型就能实现乐观锁的功能,打开源码:

compareAndSwapInt不是就是CAS么!返回true就可以执行更新操作。

那么你会问CAS有了,ABA在哪呢?Java提供了AtomicStampedReference工具类。通过为引用建立类似版本号(stamp)的方式,来保证CAS的正确性。AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。
AtomicStampedReference主要的方法入下:

我们大致演示一下这个类的使用方法:

    public static void main(String[] args) {

        String str1 = "aaa";
        String str2 = "bbb";
        String str3 = "ccc";

        // 某个位置设置初始值为str1,版本号为1
        AtomicStampedReference<String> reference = new AtomicStampedReference<String>(str1, 1);
        System.out.println("reference.getReference() = " + reference.getReference());
        System.out.println("reference.getStamp() = " + reference.getStamp());
        System.out.println("-----------------------------");
        // 用str2替換str1,替換成功返回true,同时更新版本号到2
        boolean flag = reference.compareAndSet(str1, str2, reference.getStamp(), reference.getStamp() + 1);
        System.out.println("flag: " + flag);
        System.out.println("reference.getReference() = " + reference.getReference());
        System.out.println("reference.getStamp() = " + reference.getStamp());
        System.out.println("-----------------------------");

        // 设置版本号到3
        boolean b = reference.attemptStamp(str2, reference.getStamp() + 1);
        System.out.println("b: " + b);
        System.out.println("reference.getReference() = " + reference.getReference());
        System.out.println("reference.getStamp() = " + reference.getStamp());
        System.out.println("-----------------------------");

//        // 再次用str3替代str2,预期原始版本号为3,新版号为 原版本号+1 成功!
//        boolean c = reference.weakCompareAndSet(str2, str3, 3, reference.getStamp() + 1);
//        System.out.println("c = " + c);
//        System.out.println("reference.getReference() = " + reference.getReference());
//        System.out.println("reference.getStamp() = " + reference.getStamp());
//        System.out.println("-----------------------------");
//
//        // 再次用str1替代str2,预期原始版本号为3,新版号为 原版本号+1  失败!
//        boolean d = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
//        System.out.println("d = " + d);
//        System.out.println("reference.getReference() = " + reference.getReference());
//        System.out.println("reference.getStamp() = " + reference.getStamp());
//        System.out.println("-----------------------------");

        // 再次用str1替代str2,预期原始版本号为4,新版号为 原版本号+1  失败!
        boolean f = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
        System.out.println("f = " + f);
        System.out.println("reference.getReference() = " + reference.getReference());
        System.out.println("reference.getStamp() = " + reference.getStamp());
    }
reference.getReference() = aaa
reference.getStamp() = 1
-----------------------------
flag: true
reference.getReference() = bbb
reference.getStamp() = 2
-----------------------------
b: true
reference.getReference() = bbb
reference.getStamp() = 3
-----------------------------
f = true
reference.getReference() = aaa
reference.getStamp() = 4

可以看到,虽然这个位置的值开始到最终的值都是“aaa”,但是版本号已经由1变成了4,这就是CAS中解决ABA问题的点。

三、MySQL中悲观锁和乐观锁到底是什么?

3.1 乐观锁

乐观锁(Optimistic Locking)认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,可以采用版本号机制或者时间戳机制实现。这个乐观锁是除了CAS外的另一种方式。

  • 乐观锁的版本号机制

在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

  • 乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

3.2 悲观锁

悲观锁(Pessimistic Locking)对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。共享锁、排它锁、行锁、表锁等。

  • 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

  • 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。

补充一:乐观锁缺陷有哪些缺陷?

  • 自旋时间长开销大:如果数据修改不成功,就会一直循环执行直到成功,会给CPU带来非常大的执行开销。
  • 功能限制:CAS只能保证单个变量的原子性,如果我们需要保证一段代码的原子性,乐观锁就不再适合。

补充二:乐观锁和悲观锁哪个好?分别适用什么场景?

悲观锁或乐观锁并不是想要替代对方,也没有优劣之分,只是适用于不同的场景。

  • 对于资源竞争较少(线程冲突较轻)的情况,使用乐观锁更佳。使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,因此可以获得更高的性能。
  • 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的几率会比较大,这样会造成CPU资源浪费,效率低于悲观锁。

补充三:避免死锁的发生

  • 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;

  • 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;

  • 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率 

posted @ 2022-06-25 14:02  沙滩de流沙  阅读(1079)  评论(1编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货