【Java 并发】图文解析Java各种锁

目录

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

1、锁的全家福

在这里插入图片描述

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

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

在这里插入图片描述

如上图:

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(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                reentrantLock.unlock();
                System.out.println(Thread.currentThread().getName() + "释放了锁");
            }
        }
    };

    public static void main(String[] args) {
        TestThread testThread = new TestThread();
        testThread.testLock();
    }
}

打印如下:

在这里插入图片描述
可以看出,线程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();
    }
}

打印如下:

在这里插入图片描述

在这里插入图片描述

这俩张图结合来看:

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(),因为atomicInteger1,说明锁被占用了,所以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 是线程间共享变量,需要在并发条件下正确访问,因此需要锁。

上一篇:设计模式(1):介绍


下一篇:macOS Ventura 13.6.6 (22G630) 正式版发布,ISO、IPSW、PKG 下载