Java多线程(八):常见的锁策略

2年前 (2022) 程序员胖胖胖虎阿
210 0 0

目录

1. 乐观锁&悲观锁

1.1 乐观锁

1.1.1 乐观锁定义

1.1.2 乐观锁实现(CAS)

1.1.3 CAS应用:AtomicInteger :

 1.1.4 CAS缺点(ABA问题)

1.2 悲观锁

1.2.1 悲观锁定义

1.2.2 悲观锁应用

2. 公平锁&非公平锁

3. 读写锁

3.1 读写锁的定义

3.2 读写锁示例

3.3 独占锁 

3.4 共享锁

4. 可重入锁

5. 自旋锁&挂起等待锁


1. 乐观锁&悲观锁

1.1 乐观锁

1.1.1 乐观锁定义

        乐观锁 认为一般情况下不会出现冲突,所以只会在更新数据的时候才对冲突进行检测,如果发生没有发生冲突直接进行修改,如果发生冲突了不做任何修改,然后把结果返回给用户,让用户自行决定处理。

1.1.2 乐观锁实现(CAS)

        CAS(Compare And Swap)比较并替换,CAS 比较并替换的流程是这样的:CAS 中包含了三个操作单位:V(内存值)、A(预期的旧值)、B(新值),比较 V 值和 A 是否相等,如果相等的话则将 V 的值更换成 B,否则就提示用户修改失败,从而实现了 CAS 的机制。

两个线程进行 CAS 操作:

  1. 内存地址V中,存储着变量10;
    Java多线程(八):常见的锁策略
  2. 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11;
    Java多线程(八):常见的锁策略
  3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
    Java多线程(八):常见的锁策略
  4. 线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值(10 != 11),提交失败。
    Java多线程(八):常见的锁策略
  5. 线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋;
    Java多线程(八):常见的锁策略
  6. 这一次没有其他线程修改V的值,线程1进行比较(Compare),发现 A == V(11 == 11);
    Java多线程(八):常见的锁策略
  7. 线程1进行交换(Swap),将V的值修改为B,V=12。
    Java多线程(八):常见的锁策略

 

1.1.3 CAS应用:AtomicInteger :

非线程安全代码:

public class CASDemo {
    private static int number = 0;
    private static final int MAX_COUNT = 100000;

    public static void main(String[] args) throws InterruptedException {
        // ++
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                number++;
            }
        });
        t1.start();

        // --
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                number--;
            }
        });
        t2.start();

        t1.join();
        t2.join();
        System.out.println("最终结果:" + number);
    }
}

 运行结果:

Java多线程(八):常见的锁策略

结果并不是预期的0,是线程不安全的。

 线程安全代码(AtomicInteger):

public class CASDemo2 {
    private static final int MAX_COUNT = 100000;
    private static final AtomicInteger atomicInteger = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        // ++
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                atomicInteger.getAndIncrement();
            }
        });
        t1.start();

        // --
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                atomicInteger.getAndDecrement();
            }
        });
        t2.start();

        t1.join();
        t2.join();
        System.out.println("最终结果:" + atomicInteger.get());
    }
}

 运行结果:

Java多线程(八):常见的锁策略

 结果为0,证明使用了AtomicInteger是线程安全的。

 1.1.4 CAS缺点(ABA问题)

        ABA 转账问题,X 给 Y 转账,系统卡顿点击了两次转账按钮,X 原来是 300,正常是转完账(100元) 还剩下200,第⼀次转账成功之后变成了 200,此时 Z 给 X 转了 100 元,余额又变回了 300,第⼆次CAS 判断(300,300,200)成功,于是又扣了X 100 元,X直接亏了100元。

Java多线程(八):常见的锁策略

 ABA代码演示:

public class ABADemo1 {
    private static final AtomicInteger money = new AtomicInteger(300);

    public static void main(String[] args) throws InterruptedException {
        // 第一次转账点击按钮(-100)
        Thread t1 = new Thread(() -> {
            // 先得到余额
            int oldMoney = money.get();
            // 执行耗时 2s
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money.compareAndSet(oldMoney, oldMoney - 100);
        });
        t1.start();

        // 第二次点击按钮(-100)(不小心点击的,因为第一次点击完没反应,所以又点了一次)
        Thread t2 = new Thread(() -> {
            int oldMoney = money.get();
            money.compareAndSet(oldMoney, oldMoney - 100);
        });
        t2.start();

        // 给账户 +100
        Thread t3 = new Thread(() -> {
            int oldMoney = money.get();
            // 执行耗时 1s
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money.compareAndSet(oldMoney, oldMoney + 100);
        });
        t3.start();

        t1.join();
        t2.join();
        t3.join();
        System.out.println("最终余额:" + money.get());
    }
}

 运行结果:

Java多线程(八):常见的锁策略

 可以看到,这波属实亏大了!

 解决方案(AtomicStampedReference:引入了一个版本号,每次操作后让版本号+1,执行的时候判断版本号和值。

public class ABADemo2 {
    private static final AtomicStampedReference<Integer> money = new AtomicStampedReference<>(300, 0);

    public static void main(String[] args) throws InterruptedException {
        // 第一次转账点击按钮(-100)
        Thread t1 = new Thread(() -> {
            // 先得到余额
            int oldMoney = money.getReference();
            // 得到版本号
            int version = money.getStamp();
            // 执行耗时 2s
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money.compareAndSet(oldMoney, oldMoney - 100, version, version + 1);
        });
        t1.start();

        // 第二次点击按钮(-100)(不小心点击的,因为第一次点击完没反应,所以又点了一次)
        Thread t2 = new Thread(() -> {
            int oldMoney = money.getReference();
            int version = money.getStamp();
            money.compareAndSet(oldMoney, oldMoney - 100, version, version + 1);
        });
        t2.start();

        // 给账户 +100
        Thread t3 = new Thread(() -> {
            int oldMoney = money.getReference();
            int version = money.getStamp();
            // 执行耗时 1s
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money.compareAndSet(oldMoney, oldMoney + 100, version, version + 1);
        });
        t3.start();

        t1.join();
        t2.join();
        t3.join();
        System.out.println("最终余额:" + money.getReference());
    }
}

运行结果:

Java多线程(八):常见的锁策略 

成功解决ABA问题。

 

1.2 悲观锁

1.2.1 悲观锁定义

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

1.2.2 悲观锁应用

        之前介绍过的 synchronizedLock 都是悲观锁。

详情请看:Java多线程(三):线程安全问题与解决方法_澄白易的博客-CSDN博客

2. 公平锁&非公平锁

        假设三个线程 A, B, C。 A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢? 

  • 公平锁: 遵守 "先来后到"。B 比 C 先来的,当 A 释放锁的之后, B 就能先于 C 获取到锁;
  • 非公平锁: 不遵守 "先来后到"。 B 和 C 都有可能获取到锁。

注意

  1. 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序;
  2. 公平锁非公平锁没有好坏之分, 关键还是看适用场景。

3. 读写锁

3.1 读写锁的定义

        读写锁(Readers-Writer Lock)顾名思义就是⼀把锁分为两部分:读锁和写锁,其中读锁允许多个线程同时获得,因为读操作本身是线程安全的,而写锁则是互斥锁,不允许多个线程同时获得(写锁),并且写操作和读操作也是互斥的,总结来说,读写锁的特点是:读读不互斥、读写互斥、写写互斥。

        Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:

  • ReentrantReadWriteLock.ReadLock 类表示⼀个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁;
  • ReentrantReadWriteLock.WriteLock 类表示⼀个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.

3.2 读写锁示例

public class ReadWriteLockDemo {
    public static void main(String[] args) {
        // 创建读写锁
        final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
        // 创建读锁
        final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        // 创建写锁
        final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
        // 线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(100));
        // 启动新线程执行读任务
        executor.submit(() -> {
            // 加锁操作
            readLock.lock();
            try {
                System.out.println("执行读锁1:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                readLock.unlock();
            }
        });

        // 启动新线程执行读任务2
        executor.submit(() -> {
            readLock.lock();
            try {
                System.out.println("执行读锁2:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                readLock.unlock();
            }
        });

        // 启动新线程执行写操作
        executor.submit(() -> {
            writeLock.lock();
            try {
                System.out.println("执行写锁1:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                writeLock.unlock();
            }
        });

        // 启动新线程执行写操作2
        executor.submit(() -> {
            writeLock.lock();
            try {
                System.out.println("执行写锁2:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                writeLock.unlock();
            }
        });
        executor.shutdown();
    }
}

运行结果:

Java多线程(八):常见的锁策略 

可以观察到读写锁的特点:读读不互斥、读写互斥、写写互斥。

3.3 独占锁 

        独占锁是指任何时候都只有⼀个线程能执行资源操作,synchronizedLock都是独占锁。

3.4 共享锁

        共享锁指定是可以同时被多个线程读取,但只能被一个线程修改。比如 Java 中的 ReentrantReadWriteLock 就是共享锁的实现方式,它允许⼀个线程进行写操作,允许多个线程读操作。

4. 可重入锁

       可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

注意Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

 

5. 自旋锁&挂起等待锁

        想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~ 

Java多线程(八):常见的锁策略

  • 挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
  • 自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位。

        自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。

自旋锁是一种典型的  轻量级锁  的实现方式.

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

Java多线程(八):常见的锁策略

 

版权声明:程序员胖胖胖虎阿 发表于 2022年10月6日 上午10:08。
转载请注明:Java多线程(八):常见的锁策略 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...