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

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

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

    前言

    线程并发系列文章:

    Java 线程基础
    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

    相关文章

      网友评论

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

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