锁与CAS - JUC(二)

853 阅读8分钟

致虚极,守静笃。万物并作,吾以观其复。

目录

  1. 线程池与ThreadLocal - JUC(一)
  2. 锁与CAS - JUC(二)
  3. 并发容器与并发控制 - JUC(三)
  4. AQS - JUC(四)

摘要

  • 我们解决并发问题用synchronized不就好了吗?非要搞一些花里胡哨的操作?
  • 什么是乐观锁?什么叫可重入?什么叫非公平?为什么要有这些东西?
  • CAS的思想我们都知道,它是怎么在java实现的?它有什么问题?

一、为什么synchronized不够用?

我们分别从以下三个方面来讲讲~

  1. 效率低:锁的释放情况少(IO,sleep不会释放锁)、试图获取锁时不能设置超时时间、不能中断一个正在试图获取锁的线程。
  2. 不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的。
  3. 无法知道是否成功获取锁:没有如tryLock的方法。

与之对应的Lock就有如下方法:

  • lock()
  • tryLock()
  • tryLock(long time, TimeUnit unit)
  • lockInterruptibly

注意:Lock不会像synchronized一样,在发生异常的时候自动释放锁,所以需要我们使用finally去主动释放

二、为什么要有乐观锁?

我们先从一个例子理解一下乐观锁的思想

Git:Git僦是乐观锁的典型例子,当我们往远端仓库push的时候,git会检查远端舍库的版本是不是领先于我们现在的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远端代码了,我们的这次提交就失败;如果远端和本地版本号一致,我们就可以顺利提交版本到远端仓库

互斥锁有如下一些劣势:

  1. 阻塞和唤醒带来的性能劣势
  2. 可能会导致永久阻塞
  3. 优先级反转:优先级低的持有锁,优先级高的却一直在等待

乐观锁和悲观锁对比:

  • 开销对比:
    • 悲观锁的原始开销要高于乐观锁,但特点是一劳永逸,临界区的持锁时间就算越来越长,也不会对互斥锁的开销造成影响。
    • 相反,虽然乐观锁的开销一开始比悲观锁小,但是如果自旋时间很长或者不断重试,那么消耗的资源也会越来越多
  • 使用场景对比:
    • 悲观锁:时候并发写入多的情况,适用于临界区持锁时间比较长的情况,这样的话,悲观锁可以避免大量的无用自旋等消耗,典型情况:临界区有IO操作、临界区代码复杂或循环量大、临界区竞争非常激烈。
    • 乐观锁:适合并发写入少,大部分是读取的场景,不加锁可以让读性能大大提高。

三、什么叫锁的可重入?

一句话概括:可重入就是你已经获取了锁A,你还可以在不释放A的情况下继续获取A。

看一下如下代码以及运行结果:

public class GetHoldCount {
    private  static ReentrantLock lock =  new ReentrantLock();

    public static void main(String[] args) {
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
    }
}

那为什么需要可重入锁呢?

其实,最大的作用是避免死锁。包含如下两种场景:

  1. 递归调用
private static void accessResource() {
        lock.lock();
        try {
            System.out.println("已经对资源进行了处理");
            if (lock.getHoldCount()<5) {
                System.out.println(lock.getHoldCount());
                accessResource();
                System.out.println(lock.getHoldCount());
            }
        } finally {
            lock.unlock();
        }
}
  1. 此线程调用同一对象其它synchronized或者有同步锁函数。
    private synchronized void testLock1(){
        
    }
    
    private synchronized void testLock2(){
        testLock1();
    }

下面我们来瞅瞅可重入锁的底层实现~

四、非公平锁的插队会对原队列带来影响吗?

结论:不会。

什么叫非公平锁的插队?

插队行为发生在下一个等待的线程还未唤醒,这时候立即来了一个请求锁的线程,如果是非公平锁,那么这个锁就可能被插队的线程持有。

打个比方:现在有一个非公平锁A被线程1持有,等待队列中有线程2、线程3、线程4

这个时候线程1正准备释放锁,恰好来了线程5,他执行了lock方法,那这个锁很可能就给线程5了。那2、3、4咋办呢?干瞪眼呗。

PS: tryLock()无视公平与非公平的设定,他就是不公平的,如果它发生在那个时刻就会立即获取到

为什么非公平锁的效率高呢?其实该进行唤醒的还是得唤醒,它节省了什么步骤呢?

  1. 描述一下:如果一个线程即将被唤醒,此时另外一个线程来请求锁了,这个时候是很有可能不去唤醒的。当当前拥有锁的线程释放锁之后, 且非公平锁无线程抢占,就开始线程唤醒的流程。通过tryRelease释放锁成功,调用LockSupport.unpark(s.thread); 终止线程阻塞。
  2. 节省了什么?线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

下面我们来瞅瞅非公平锁的底层实现~

五、读写锁是如何实现要么多读要么一写的?

它是两种锁,但放在同一个对象里,通过两个方法分别获取。

正所谓一山不容二虎,读与写这二虎相争,必有一王。当读写并存时,我们只能取一个,取哪一个呢?读写锁使用场景多为读多写少,如果一个写线程进来,而读线程很多,结果必然是写线程将苦逼的一直等待中,它会因得不到资源而产生饥渴。我们要做的,应该是保证请求写操作的线程不会被后来的读线程挤掉。

下面我们来看看读锁插队策略:

  • 公平锁:不允许插队
  • 非公平锁:
    1. 写锁可以随时插队
    2. 读线程仅在等待队列头结点不是想获取写锁的线程的时候才可以插队

最后,还有一些锁优化相关的东西~

  1. JVM虚拟机对锁的优化
    1. 自旋锁与自适应:就是在自旋次数过多以后,JVM会将其转为阻塞。
    2. 锁消除:JVM会对一些没有必要加锁的地方进行锁消除。
    3. 锁粗化:比如两个加相同锁的代码块相隔很近,这个时候JVM可能把这两段加锁代码和中间部分代码全部加锁得了。
  2. 缩小同步代码块、记录不用锁住方法、减少请求锁的次数、避免人为制造“热点”、锁中尽量不要包含锁、选择合适的锁类型和合适的工具类

CAS

六、Unsafe类是什么?

Unsafe类是CAS的核心类。 Java无法访问底层的操作系统,而是通过本地(native)方法来访问。 不过尽管如此,JVM还是开了个后门,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。

我们来看一下AtomicInteger的compareAndSet方法是如何实现原子操作的

他这里调用了unsafe的compareAndSwapInt方法,其中有一个参数 valueOffset 我们来看看:
这一步就是获取value在内存中的偏移地址。

我们继续往下看:

就来到了一个native方法:
这里执行的就是cmpxchg,其中x是目标值,e是原值,addr是值的地址。

七、CAS有些哪些问题?

ABA问题

什么是ABA问题?我们为什么要解决ABA问题?

  1. ABA指的是:

    线程1期望的原值是100,他想修改为50;

    线程2期望的原值是100,他想修改为50;

    线程3期望的原值是50,他想修改为100;

    在并发的环境下:值经历了如下过程:100->50->100->50

    这看起来似乎没什么毛病啊,本来就是要将100改为50啊。

  2. ABA危害

    但是我们想象一个这样的场景:

    小明去提款机取款,我现在余额为100,我想取50,此时提款机出了点问题,他提交了两次-50的请求。按照CAS的想法,应该是有一次更新失败才对。

    此时:

    线程1想改为50,block;

    线程2想改为50,成功;

    突然小明妈妈给他打了50,成功;

    然后线程1恢复执行,发现余额是100,就将它改为50好了,成功;

    理论上应该此时余额为100才对!

  3. 如何解决?加上版本号(JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。)

循环时间长开销大

如果CAS操作失败,就需要循环进行CAS操作(循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成CPU极大的开销。

总结

这次我们知道了可以使用Lock类去解决一些比较灵活的场景,比如需要有实效性的等锁;我们也知道了锁的公平与非公平的区别,知道了非公平快在哪里;还有CAS在JAVA中是如何实现的。

还有,推荐一篇也是讲锁的文章,贼厉害:tech.meituan.com/2018/11/15/…

下一章我们将探索一下并发容器与并发流程的内部,去看看并发包里面的那群老伙计。