美文网首页Android技术知识JavaAndroid开发
最详细的图文解析Java各种锁(终极篇)

最详细的图文解析Java各种锁(终极篇)

作者: 小鱼人爱编程 | 来源:发表于2021-03-21 21:13 被阅读0次

    前言

    线程并发系列文章:

    Java 线程基础
    Java “优雅”地中断线程
    Java 线程状态
    真正理解Java Volatile的妙用
    Java ThreadLocal你之前了解的可能有误
    Java Unsafe/CAS/LockSupport 应用与原理
    Java 并发"锁"的本质(一步步实现锁)
    Java Synchronized实现互斥之应用与源码初探
    Java 对象头分析与使用(Synchronized相关)
    Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
    Java Synchronized 重量级锁原理深入剖析上(互斥篇)
    Java Synchronized 重量级锁原理深入剖析下(同步篇)
    Java并发之 AQS 深入解析(上)
    Java并发之 AQS 深入解析(下)
    Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
    Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
    Java 并发之 ReentrantReadWriteLock 深入分析
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
    最详细的图文解析Java各种锁(终极篇)

    前面的十几篇文章都是从源码的角度分析线程并发涉及到的知识点,本篇将重点总结、归纳、提炼知识点,尽量少贴代码。遇到有疑惑的点,请查看对应文章的分析。
    通过本篇文章,你将了解到:

    1、锁的全家福
    2、如何验证公平/非公平锁
    3、底层如何获取锁/释放锁
    4、自旋锁与自适应自旋
    5、为什么需要等待/通知机制

    1、锁的全家福

    image.png

    2、如何验证公平/非公平锁

    公平与非公平区别之处在于获取锁时的策略。


    image.png

    如上图:

    1、线程1持有锁。
    2、线程2、线程3、线程4 在同步队列里排队等候锁。

    这时线程5也想要获取锁,根据公平与否分为两种不同策略。

    公平锁

    线程5先判断同步队列是是否有线程在等待,明显地此时同步队列里有线程在等待,于是线程5加入到同步队列的尾部等待。

    非公平锁

    1、线程5不管同步队列是否有线程等待,管他三七二十一先去抢锁再说。若是运气好就能直接捡到便宜获取了锁,若是失败再去排队。
    2、线程5还是有机会捡便宜的,若是此时线程1刚好释放了锁,并唤醒线程2,线程2醒过来后去获取锁。若在线程2获取锁之前线程5就去抢锁了,那么它会成功。它的成功对于线程2、线程3、线程4来说是不公平的。

    我们知道ReentrantLock 可实现公平/非公平锁,来验证一下。

    先来验证公平锁:

    public class TestThread {
        private ReentrantLock reentrantLock = new ReentrantLock(true);
        private void testLock() {
            for (int i = 0; i < 5; i++) {
                Thread thread = new Thread(runnable);
                thread.setName("线程" + (i + 1));
                thread.start();
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        private Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println(Thread.currentThread().getName() + " 启动了,准备获取锁");
                    reentrantLock.lock();
                    System.out.println(Thread.currentThread().getName() + " 获取了锁");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    reentrantLock.unlock();
                }
            }
        };
    
        public static void main(String args[]) {
            TestThread testThread = new TestThread();
            testThread.testLock();
        }
    }
    

    打印如下:


    image.png

    可以看出,线程2、3、4、5 按顺序获取锁,实际上拿到锁也是按照这顺序的。
    因此,符合先到先得,是公平的。

    再来验证非公平锁

    public class TestThread {
        private ReentrantLock reentrantLock = new ReentrantLock(false);
        private void testLock() {
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(runnable);
                thread.setName("线程" + (i + 1));
                thread.start();
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        private void testUnfair() {
            try {
                Thread.sleep(500);
                while (true) {
                    System.out.println("+++++++我抢...+++++++");
                    boolean isLock = reentrantLock.tryLock();
                    if (isLock) {
                        System.out.println("========我抢到锁了!!!===========");
                        reentrantLock.unlock();
                        return;
                    }
                    Thread.sleep(10);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        private Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println(Thread.currentThread().getName() + " 启动了,准备获取锁");
                    reentrantLock.lock();
                    System.out.println(Thread.currentThread().getName() + " 获取了锁");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    reentrantLock.unlock();
                }
            }
        };
    
        public static void main(String args[]) {
            TestThread testThread = new TestThread();
            testThread.testLock();
            testThread.testUnfair();
        }
    }
    

    打印如下:


    image.png
    image.png

    这俩张图结合来看:

    1、第一张图:线程1~线程10 依次调用lock抢锁,然后主线程开始抢锁。
    2、只要有一次能够证明主线成比线程1~线程10之间的某个线程先获得锁,那么就证明该锁为非公平锁。
    3、第二张图:主线程比线程4~线程10 先获得了锁,说明过程是非公平的。

    值得注意的是:

    此处使用tryLock()抢占锁,tryLock()和lock(非公平模式)核心逻辑是一样的。

    3、底层如何获取锁/释放锁

    一直在提线程获取了锁,线程释放了锁,到底这个逻辑如何实现的呢?
    从第一张全家福的图,可以看出锁的基本数据结构包含:

    共享锁变量、volatile、CAS、同步队列。

    假设设定共享变量为:volatile int threadId。

    threadId == 0表示当前没有线程获取锁,thread !=0 表示有线程占有了锁。

    获取锁

    1、线程调用 CAS(threadId, 0, 1),预期threadId == 0, 若是符合预期,则将threadId设置为1,CAS成功说明成功获取了锁。
    2、若是CAS失败,说明threadId != 0,进而说明有已经有别的线程修改了threadId,因此线程获取锁失败,然后加入到同步队列。

    释放锁

    1、持有锁的线程不需要锁后要释放锁,假设是独占锁(互斥),因为同时只有一个线程能获取锁,因此释放锁时修改threadId不需要CAS,直接threadId == 0,说明释放锁成功。
    2、成功后,唤醒在同步队列里等待的线程。

    synchronized 和 AQS 获取/释放锁核心思想就是上面几步,只是控制得更复杂,精细,考虑得更全面。

    注:CAS(threadId, xx, xx)是伪代码

    4、自旋锁与自适应自旋

    很多文章说CAS是自旋锁,这说法是有问题的,本质上没有完全理解CAS功能和锁。

    1、CAS 全称是比较与交换,若是内存值与期望值一致,说明没有其它线程更改目标变量,因此可以放心地将目标变量修改为新值。
    2、CAS 是原子操作,底层是CPU指令。
    3、CAS 只是一次尝试修改目标变量的操作,结果要么成功,要么失败,最后调用都会返回。

    通过上个小结的分析,我们知道synchronized、AQS底层获取/释放锁都是依赖CAS的,难道说synchronized、AQS 也是自旋锁,显然不是。

    自旋锁是不会阻塞的,而CAS也不会阻塞,因此可以利用CAS实现自旋锁:

        class MyLock {
            AtomicInteger atomicInteger = new AtomicInteger(0);
            private void lock() {
                boolean suc = false;
                do {
                    //底层是CAS
                    suc = atomicInteger.compareAndSet(0, 1);
                } while (!suc);
            }   
        }
    

    如上所示,自定义锁MyLock,线程1,线程2分别调用lock()上锁。

    1、线程1调用lock(),因为atomicInteger== 0,所以suc == true,线程1成功获取锁。
    2、此时线程2也调用lock(),因为atomicInteger==1,说明锁被占用了,所以suc==false,然而线程2并不阻塞,一直循环去修改。只要线程1不释放锁,那么线程2永远获取不了锁。

    以上就是自旋锁的实现,可以看出:

    1、自旋锁最大限度避免了线程挂起/与唤醒,避免上下文切换,但是无限制的自旋也会徒劳占用CPU资源。
    2、因此自选锁适用于线程执行临界区比较快的场景,也就是获得锁后,快速释放了锁。

    既想要自旋,又要避免无限制自旋,因此引入了自适应自旋:

        class MyLock {
            AtomicInteger atomicInteger = new AtomicInteger(0);
            //最大自旋次数
            final int MAX_COUNT = 10;
            int count = 0;
            private void lock() {
                boolean suc = false;
                while (!suc && count <= MAX_COUNT) {
                    //底层是CAS
                    suc = atomicInteger.compareAndSet(0, 1);
                    if (!suc)
                        Thread.yield();
                    count++;
                }
            }
        }
    

    可以看出,给自旋设置了最大自旋次数,若还是没能获取到锁,则退出死循环。

    实际上synchronized、ReentrantReadWriteLock 等的实现里,同样为了尽量避免线程挂起/唤醒,在抢占锁的过程中也是采用了自旋(自适应自旋)的思想,但这只是它们锁实现的以小部分,它们并不是自旋锁。

    5、为什么需要等待/通知机制

    先看独占锁的伪代码:

        //Thread1
        myLock.lock();
        {
            //临界区代码
        }
        myLock.unLock();
    
        //Thread2
        myLock.lock();
        {
            //临界区代码
        }
        myLock.unLock();
    

    Thread1、Thread2 互斥拿到锁后各干各的,互不干涉,相安无事。
    若是现在Thread1、Thread2 需要配合做事,如:

        //Thread1
        myLock.lock();
        {
            //临界区代码
            while (flag == false)
                wait();
            //继续做事
        }
        myLock.unLock();
    
        //Thread2
        myLock.lock();
        {
            //临界区代码
            flag = true;
            notify();
            //继续做事
        }
        myLock.unLock();
    

    如上代码,Thread1需要判断flag == true才会往下运行,而这个值需要Thread2来修改,Thread1、Thread2 两者间有协作关系。于是Thread1需要调用wait 释放锁,并阻塞等待。Thread2在Thread1释放锁后拿到锁,修改flag,然后notify 唤醒Thread1(唤醒时机在Thread2执行完临界区代码并释放锁后)。Thread1 被唤醒后继续抢锁,然后判断flag==true,继续做事。
    于是,Thread1、Thread2愉快配合完成工作。
    为啥wait/notify 需要先获取锁呢?flag 是线程间共享变量,需要在并发条件下正确访问,因此需要锁。

    至此,线程并发系列文章暂时告一段落了。大家对这系列文章有疑惑,请评论留言。

    本文基于jdk 1.8。

    您若喜欢,请点赞、关注,您的鼓励是我前进的动力

    持续更新中,和我一起步步为营系统、深入学习Android/Java

    1、Android各种Context的前世今生
    2、Android DecorView 一窥全貌(上)
    3、Android DecorView 一窥全貌(下)
    4、Window/WindowManager 不可不知之事
    5、View Measure/Layout/Draw 真明白了
    6、Android事件分发全套服务
    7、Android invalidate/postInvalidate/requestLayout 彻底厘清
    8、Android Window 如何确定大小/onMeasure()多次执行原因
    9、Android事件驱动Handler-Message-Looper解析
    10、Android 键盘一招搞定
    11、Android 各种坐标彻底明了
    12、Android Activity/Window/View 的background
    13、Android IPC 之Service 还可以这么理解
    14、Android IPC 之Binder基础
    15、Android IPC 之Binder应用
    16、Android IPC 之AIDL应用(上)
    17、Android IPC 之AIDL应用(下)
    18、Android IPC 之Messenger 原理及应用
    19、Android IPC 之获取服务(IBinder)
    20、Android 存储基础
    21、Android 10、11 存储完全适配(上)
    22、Android 10、11 存储完全适配(下)
    23、Java 并发系列不再疑惑

    相关文章

      网友评论

        本文标题:最详细的图文解析Java各种锁(终极篇)

        本文链接:https://www.haomeiwen.com/subject/hzpgcltx.html