【备战阿里】Day8-直击原理-锁(乐观,悲观,共享等...)

230 阅读7分钟

1,概念梳理

我相信大家经常在网上看到各种各样的关于锁文章,想必有些人心里已经乱成一锅粥了,我们先来梳理一下各种锁的区别及分类,先搭建一个框架然后逐一击破!

1-1,首先我们看为什么需要锁

首先,我们要知道,每个线程都有自己的私有空间,当修改一个参数的时候,线程会首先将其值从内存拷贝到自己的私有空间,然后进行修改,最后再将新的值写入到内存

现在,我们假设一个场景:
内存中有一个参数 V 值为 0,我们开启 100000 个线程对其累加,我们想要的结果是 V 的值变为 100000,但是实际结果往往难以预料:

public class Main {
    public int value = 0;

    private void addValue() {
        value++;
    }

    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        for (int i = 0; i < 100000; i++) {
            new Thread(() -> main.addValue()).start();
        }
        //加 10s 延时是为了保证 10w 个线程全部执行完成后再去读取结果
        Thread.sleep(10000);
        System.out.println(main.value);
    }
}

结果:
可以看到结果多次不一致,至于上面为什么用 start 而不用 run,原因是 start 才是真正开辟线程去执行,run 还是在当前线程执行的,所以用 run 的话结果还会是 10000;
我们可以发现问题已经出现了,Why?原因就是每个线程交错执行导致的数据不一致,让我们来看下图
这是一个线程的情况下,如果多个线程呢?那么会发生如下情况 结果就是 Value 的值被更新为 1,但是我们想要的结果是 Value 为 2

1-2,怎么解决这些问题?

我们可以发现,问题的根本在于线程 B 没有拷贝到正确的值,所以我们只需要让线程 B 等线程 A 执行完毕后再去执行,这样的话线程 B 拷贝的值就是 1,那么执行自增后就是 2,最后更新后 Value 的值也就是 2,这就是 悲观锁
那还有一种解决方式,就是线程 B 不用等 A 执行完毕,只需要在最后更新 Value 时检查 Value 的值是否还是拷贝时的值即可,如果不是那么说明有别的线程对 Value 进行了操作,我们可以选择让线程 B 失败或者再次重复一次执行即可,这就是 乐观锁

1-3,具体该怎么做?

这里我们要区分出程序内的锁和数据库内的锁,具体的情形有不同的方案
我先给大家放一个锁的概览

  • 乐观锁和悲观锁只是一种概念,而不是具体的锁!

2,乐观锁(Optimistic Locking)

顾名思义,乐观锁就是保持乐观,不对程序或数据进行加锁控制,认为别人不会修改只会读取数据,那么,问题来了,如果真的别人修改了数据怎么办,按照我们之前的思想,比较一下现在的值和之前拷贝的一不一样然后在做操作就行了

2-1,程序的乐观锁

在 Java 中,使用 CAS(Compare And Set)和自旋来实现乐观锁,CAS 顾名思义就是比较并且设置值,自旋就是不断的循环判断是否可以继续执行,CAS 操作有三个值:

  • V(数据地址)
  • A(数据的旧值)
  • B(数据的新值) 所以整个执行流程为:
  1. 线程拷贝数据地址(V)并获取数据备份(A)
  2. 线程内部修改数据备份
  3. 执行完毕后判断数据地址(V)是否还和拷贝的旧值(A)一样
  4. 如果一样说明数据没有变动,则直接将数据新值(B)更新
  5. 如果不一样说明有变动,那么重新拷贝数据,再次执行操作,再做对比,直到可以更新为止 比如我们要使用 int 的 CAS 操作,就需要使用 AtomicInteger(原子性的 int)
public static void main(String[] args) throws InterruptedException {
        AtomicInteger value = new AtomicInteger(0);
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> value.incrementAndGet()).start();
        }
        //加 1s 延时是为了保证 1w 个线程全部执行完成后再去读取结果
        Thread.sleep(1000);
        System.out.println(value);
}

结果:
那么 AtomicInteger 的 incrementAndGet 是怎么保证原子性的呢?原理就是调用了 CPU 的指令!

public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

自旋的代码:

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        //这里可以看到使用循环不断去执行直到可以更新数据为止,这就是自旋
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

C语言 层面的代码

//native 就表示是 C语言 层面了,具体 C语言 也是调用 CPU 指令
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

2-2,数据库的乐观锁

数据库的乐观锁实现不需要借助数据库锁,而是多采用 version 版本对比,即在每行数据后面加一个 version 标识,数据每次更新 version 都会变化,所以当更新数据时只需要判断数据现在的 version 和获取时的一不一样即可,原理和 CAS 一致

3,悲观锁(Pessimistic Lock)

3-1,程序里的悲观锁

3-1-1,synchronized

private synchronized void addValue() {
        value++;
}

结果:
Synchronized 的缺陷:

  • Synchronized 代码块只有在执行完毕时才会释放锁,当代码块内有耗时的操作,比如 IO 时,别的线程将会被阻塞很久,降低性能
  • 读操作之间是不冲突的,如果每个线程或者大部分线程都是读操作,那么 Synchronized 将会大大降低性能,因为加了 Synchronized,同一时间只有一个线程可以使用代码块
  • Synchronized 释放锁是程序自己释放的,程序员无法操控,也就无法得知锁的状态

3-1-2:Lock 接口

首先我们看一下 Lock 的方法和类关系图

  • lock():上锁,如果锁不是空闲,则等待
  • lockInterruptibly():如果当前线程未被中断,则上锁,可以响应中断
  • tryLock():仅在调用时锁为空闲状态才上锁,可以响应中断
  • tryLock(long time,TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则上锁
  • unlock():释放锁
  • newCondition():返回绑定到此 Lock 实例的新 Condition 实例
    Lock 的主要实现类 ReentranLock(可重入锁),什么叫可重入锁呢?简而言之: 当外部方法已经获得锁的时候,内部方法可以获取相同的锁,而不会产生死锁,可重入锁最常见的应用场景就是递归! ReentranLock 有两种模式:公平锁和非公平锁:
  • 公平锁:谁等待的时间长,谁就优先获得锁
private ReentrantLock lock = new ReentrantLock(true);
private void addValue() {
        lock.lock();
        System.out.println(Thread.currentThread().getId()+"获取锁");
        value++;
        lock.unlock();
}

  • 非公平锁(默认):随机分配,谁运气好,谁获得锁
private ReentrantLock lock = new ReentrantLock(false);

3-1-3:ReadWriteLock 接口

先看一下 ReadWriteLock 的方法和类关系图

  • readLock():只有一个线程可以写数据,其他的只能读
  • writeLock():只有一个线程可以读写数据,其他线程不得操作
    ReadWriteLock 的主要实现类 ReentrantReadWriteLock,其也有公平锁和非公平锁
  • readLock()
private ReadWriteLock lock = new ReentrantReadWriteLock();
private void addValue() {
        lock.readLock().lock();
        try {
            System.out.println(new Date().toString() + "  "+ Thread.currentThread().getId() + "获取锁");
            Thread.sleep(1000);
            value++;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.readLock().unlock();
        }
}


经过测试,同一时间所有线程都获取了锁

  • writeLock()
private void addValue() {
        lock.writeLock().lock();
        try {
            System.out.println(new Date().toString() + "  "+ Thread.currentThread().getId() + "获取锁");
            Thread.sleep(1000);
            value++;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.writeLock().unlock();
        }
    }

经过测试,每个线程都是等待上个线程释放写锁之后才可获得写锁

3-2,数据库的悲观锁

3-2-1,共享锁(Shared Locks)

共享锁,也叫读锁,也就是共享查询锁,即当一个事务正在执行时,对数据加共享锁,这时只允许别的事务进行查询而不能进行修改,知道本事务完成后再释放共享锁。

  • 一个会话给表加读锁并进行查询和修改
  • 其他的会话进行查询和读取

3-2-1,排他锁(Exclusive Locks)

排他锁顾名思义就是排斥其他的事务,也叫写锁,即本事务执行时对数据行加锁,这时不允许其他的事务对该数据进行访问和修改

  • 一个会话给表加写锁并进行查询和修改
  • 其他的会话进行查询和读取