多线程篇(基本认识 - 锁机制 - 乐观锁 & 悲观锁)(持续更新迭代)

一、Java层

乐观锁(CAS)和悲观锁(synchronized)

1. 锁

在代码中多个线程需要同时操作共享变量,这时需要给变量上把锁,保证变量值是线程安全的。

锁的种类非常多,比如:互斥锁、自旋锁、重入锁、读写锁、行锁、表锁等这些概念,总结下来

就两种类型,乐观锁和悲观锁。

2. 乐观锁(Optimistic Locking)

乐观锁就是持比较乐观态度的锁。在操作数据时非常乐观,认为别的线程不会同时修改数据,只

有到数据提交的时候才通过一种机制来验证数据是否存在冲突。一般使用CAS算法实现。乐观锁

适用于多读的应用类型,这样可以提高吞吐量。

3. 悲观锁(Pessimistic Concurrency Control)

比较悲观的锁,总是想着最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数

据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

在 Java 中,synchronized从偏向锁、轻量级锁到重量级锁,全是悲观锁。

JDK提供的Lock实现类全是悲观锁。一般用于多写的场景。

4. CAS

CAS 即 Compare and Swap,它体现的一种乐观锁的思想,

比如:多个线程要对一个共享的整型变量执行 +1 操作:

// 需要不断尝试
while (true) {
    int 旧值 = 共享变量; // 比如拿到了当前值 0
    int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1

   /*这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候compareAndSwap 返回 false,重新尝试,
   直到:compareAndSwap 返回 true,表示本线程做修改的同时,别的线程没有干扰*/
    if( compareAndSwap ( 旧值, 结果 )) {
        // 成功,退出循环
    }
}

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。

结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令。

java.util.concurrent中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、

AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。

java的内存模型(可见性,原子性,有序性)详细介绍_傻鱼爱编程的博客-CSDN博客这个里面原

子性问题,用CAS技术解决方案如下:

    // 创建原子整数对象
    private static AtomicInteger i = new AtomicInteger(0);
 
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                i.getAndIncrement();  // 获取并且自增  i++
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                i.getAndDecrement(); // 获取并且自减  i--
            }
        });
 
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

5. synchronized

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。

Mark Word 平时存储这个对象的哈希码 、分代年龄,当加锁时,这些信息就根据情况被替换为

标记位 、 线程锁记录指针 、重量级锁指针 、线程ID 等内容。

JDK5引入了CAS原子操作,从JDK6开始对synchronized的实现机制进行了各种优化,包括使用

JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁

(默认开启偏向锁)这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作

简单、无需手动关闭,推荐在允许的情况下尽量使用此关键字。

JDK6以后锁主要存在四种状态,依次是:无锁状态(对象头中存储01)、偏向锁状态(对象头中存

储线程id)、轻量级锁状态(对象头中存储00)、重量级锁状态(对象头中存储10),锁的升级是单向

偏向锁

Java6 中引入了偏向锁来做优化:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线

程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就

表示没有竞争,不用重新 CAS。

偏向锁的缺点:

  1. 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
  2. 访问对象的 hashCode 也会撤销偏向锁
  3. 撤销偏向和重偏向都是批量进行的,以类为单位,如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的

开启偏向锁(默认开启):-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

关闭偏向锁:-XX:-UseBiasedLocking

举个例子分析一下:

假设有两个方法同步块,利用同一个对象加锁

Object ob = new Object();
public void method1() {
    synchronized(ob) {
        // 同步块 A
        method2();
    }
}
public void method2() {
    synchronized(ob) {
        // 同步块 B
    }
}

解释过程如下:

轻量级锁

若偏向锁失败,它会尝试使用轻量级锁的优化手段,

此时Mark Word 的结构也变为轻量级锁的结构。

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),

如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁竞争的时候,用自旋进行了优化,如果当前线程自旋成功(即这时候持锁线程已经退出

了同步块,释放了锁),这时当前线程就可以避免阻塞。在Java 6之后自旋锁是自适应的,比如

对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,

就少自旋甚至不自旋,总之,比较智能。自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多

核 CPU 自旋才能发挥优势。

Java 7 之后不能控制是否开启自旋功能。

举个例子分析一下:

假设有两个方法同步块,利用同一个对象加锁

Object ob = new Object();
public void method1() {
    synchronized(ob) {
        // 同步块 A
        method2();
    }
}
public void method2() {
    synchronized(ob) {
        // 同步块 B
    }
}

解释过程如下: 每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark

Word

重量锁

如果在尝试加轻量级锁的过程中,CAS 操作无法成功(经过自旋),这时一种情况就是有其它

线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

二、数据库层

1. 锁(Lock)

在介绍悲观锁和乐观锁之前,让我们看一下锁。

锁,在我们生活中随处可见,我们的门上有锁,我们存钱的保险柜上有锁,是用来保护我们财产

安全的。

程序中也有锁,当多个线程修改共享变量时,我们可以给修改操作上锁(syncronized)。

当多个用户修改表中同一数据时,我们可以给该行数据上锁(行锁)。

因此,锁其实是在并发下控制多个操作的顺序执行,以此来保证数据安全的变动。

并且,锁是一种保证数据安全的机制和手段,而并不是特定于某项技术的。

2. 悲观锁(Pessimistic Concurrency Control)

第一眼看到它,相信每个人都会想到这是一个悲观的锁。

没错,它就是一个悲观的锁。那这个悲观体现在什么地方呢?

悲观是我们人类一种消极的情绪,对应到锁的悲观情绪,悲观锁认为被它保护的数据是极其不安

全的,每时每刻都有可能变动,一个事务拿到悲观锁后(可以理解为一个用户),其他任何事务

都不能对该数据进行修改,只能等待锁被释放才可以执行。

数据库中的行锁,表锁,读锁,写锁,以及 syncronized 实现的锁均为悲观锁。

这里再介绍一下什么是数据库的表锁和行锁,以免有的同学对后面悲观锁的实现看不明白。

我们经常使用的数据库是 mysql,mysql 中最常用的引擎是 Innodb,Innodb 默认使用的是行

锁。

3. 乐观锁(Optimistic Concurrency Control)

与悲观相对应,乐观是我们人类一种积极的情绪。乐观锁的“乐观情绪”体现在,它认为数据的变

动不会太频繁。因此,它允许多个事务同时对数据进行变动。 但是,乐观不代表不负责,那么

怎么去负责多个事务顺序对数据进行修改呢?乐观锁通常是通过在表中增加一个版本

(version)或时间戳(timestamp)来实现,其中,版本最为常用。

事务在从数据库中取数据时,会将该数据的版本也取出来(v1),当事务对数据变动完毕想要将

其更新到表中时,会将之前取出的版本 v1 与数据中最新的版本 v2 相对比,如果 v1 = v2 ,那么

说明在数据变动期间,没有其他事务对数据进行修改,此时,就允许事务对表中的数据进行修

改,并且修改时 version 会加1,以此来表明数据已被变动。如果,v1 不等于 v2,那么说明数据

变动期间,数据被其他事务改动了,此时不允许数据更新到表中,一般的处理办法是通知用户让

其重新操作。不同于悲观锁,乐观锁是人为控制的。

4. 怎么实现悲观锁,怎么实现乐观锁

经过上面的学习,我们知道悲观锁和乐观锁是用来控制并发下数据的顺序变动问题的。

从表中可以看到猪肉脯目前的数量只有 1 个了。

在不加锁的情况下,如果 A,B 同时下单,就会报错。

4.1. 悲观锁解决

利用悲观锁的解决思路是,A 下单前先给猪肉脯这行数据(id=1)加上悲观锁(行锁)。此时这

行数据只能A 来操作,也就是只有 A 能买。B 想买就必须一直等待。当 A 买好后,B 再想去买的

时候会发现数量已经为 0,那么 B 看到后就会放弃购买。

那么如何给猪肉脯也就是 id=1 这条数据加上悲观锁锁呢?我们可以通过以下语句给 id=1 的这行

数据加上悲观锁。

select num from goods where id = 1 for update;

下面是悲观锁的加锁图解

我们通过开启 mysql 的两个会话,也就是两个命令行来演示。

1、事务 A 执行命令给 id = 1 的数据上悲观锁准备更新数据。

这里之所以要以 begin 开始,是因为 mysql 是自提交的,所以要以 begin 开启事务,否则所有修

改将被mysql 自动提交。

2、事务 B 也去给 id = 1 的数据上悲观锁准备更新数据

我们可以看到此时事务 B 再一直等待 A 释放锁。如果 A 长期不释放锁,那么最终事务B将会报

错,这有兴趣的可以去尝试一下。

3、接着我们让事务 A 执行命令去修改数据,让猪肉脯的数量减一,然后查看修改后的数据,最

后commit,结束事务。

我们可以看到,此时最后一个猪肉脯被 A 买走,只剩 0 个了。

4、当事务 A 执行完第 3 步后,我们看事务 B 中出现了什么

我们看到由于事务 A 释放了锁,事务 B 就结束了等待,拿到了锁,但是数据此时变成了 0,

那么 B 看到后就知道被买走了,就会放弃购买。

通过悲观锁,我们解决了猪肉脯购买的问题。

4.2. 乐观锁解决

下面,我们利用乐观锁来解决该问题。

上面乐观锁的介绍中,我们提到了,乐观锁是通过版本号 version 来实现的。

所以,我们需要给 goods 表加上 version 字段,表变动后的结构如下:

id、name、num、version

1 猪肉脯 1 0

2 牛肉干 1 0

具体的解决思路是,A 和 B 同时将猪肉脯(id = 1 下面都说是 id = 1)的数据查出来,然后 A 先

买,A 将id = 1 和 version = 0 作为条件进行数据更新,即将数量减一,并且将版本号加一。此时

版本号变为1。A 此时就完成了商品的购买。最后 B 开始买,B 也将 id = 1 和 version = 0 作为条

件进行数据更新,但是更新完后,发现更新的数据行数为 0,此时就说明已经有人改动过数据,

此时就应该提示用户重新查看最新数据购买。

下面是乐观锁的加锁图解

我们还是通过开启mysql的两个会话,也就是两个命令行来演示。

1、事务 A 执行查询命令,事务 B 执行查询命令,因为两者查询的结果相同,

所以下面我只列出一个截图。

此时 A 和 B 均获取到相同的数据。

2、事务 A 进行购买更新数据,然后再查询更新后的数据。![image-20230908140737474]

我们可以看到事务 A 成功更新了数据和版本号。

事务 B 再进行购买更新数据,然后我们看影响行数和更新后的数据。

可以看到最终修改行数为 0,数据没有改变。此时就需要我们告知用户重新处理。

5. 乐观锁和悲观锁的优缺点

下面我们介绍下乐观锁和悲观锁的优缺点以便我们分析他们的应用场景,

这里我只分析最重要的优缺点,也是我们要记住的。

悲观锁

  • 优点:悲观锁利用数据库中的锁机制来实现数据变化的顺序执行,这是最有效的办法。
  • 缺点:一个事务用悲观锁对数据加锁之后,其他事务将不能对加锁的数据进行除了查询以外的所有操作,如果该事务执行时间很长,那么其他事务将一直等待,那势必影响我们系统的吞吐量。

乐观锁

  • 优点:乐观锁不在数据库上加锁,任何事务都可以对数据进行操作,在更新时才进行校验,这样就避免了悲观锁造成的吞吐量下降的劣势。
  • 缺点:乐观锁因为时通过我们人为实现的,它仅仅适用于我们自己业务中,如果有外来事务插入,那么就可能发生错误。

2.6 乐观锁和悲观锁的应用场景

悲观锁

对于每一次数据修改都要上锁,如果在DB读取需要比较大的情况下有线程在执行数据修改操作

会导致读操作全部被挂载起来,等修改线程释放了锁才能读到数据,体验极差。所以比较适合用

在DB写大于读的情况。

乐观锁

乐观锁是为弥补悲观锁缺点而诞生。

由于乐观锁的不上锁特性,所以在性能方面要比悲观锁好,比较适合用在DB的读大于写的业务

场景。

  • 28
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wclass-zhengge

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值