悲观锁 和 乐观锁 是啥?

一、悲观锁

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

悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁,这样在并发期间一旦一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新了,这就是悲观锁的实现逻辑。

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

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

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

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

二、乐观锁

乐观锁是一种思想,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

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

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

2.1 CAS 原理概述

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

2.2 ABA 问题解决

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

猜你喜欢

转载自blog.csdn.net/weixin_41231928/article/details/106609465
今日推荐