• 《Java并发编程之美》学习笔记


    成功上岸,进入华为,之前花5W买的JAVA课程合集,整整420集,拿走不谢,公粮上交,手把手教学,学完即可就业_哔哩哔哩_bilibili

    1. 并发编程基础

    1.1 什么是线程

    线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源

    操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源比较特殊,它是被分配到线程的,因为要真正占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位

    多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器和栈区域

    • 程序计数器是一块内存区域,用来记录线程当前要执行的指令地址
    • 需要注意的是,如果执行的是 native 方法,那么 pc 计数器记录的是 undefined 地址,只有执行的是 Java 代码时 pc 计数器记录的才是下一条指令的地址
    • 每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧
    • 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new 操作创建的对象实例
    • 方法区则用来存放 JVM 加载的类、常量及静态变量等信息,也是线程共享的

    1.2 线程三种创建方式的优缺点

    Java 中有三种线程创建方式,分别为实现 Runnable 接口的 run 方法,继承 Thread 类并重写 run 方法,以及使用 FutureTask 方式

    使用继承方式的好处是方便传参,可以在子类里添加成员变量,通过 set 方法设置参数或者通过构造函数进行传递,而如果使用 Runnable 方式,则只能使用主线程里面被声明为 final 的变量。不好的地方是 Java 不支持多继承,而如果继承了 Thread 类,那么子类不能再继承其他类,而 Runnable 则没有这个限制。前两种方式都没办法拿到任务执行的返回结果,但是 FutureTask 方式可以。

    1.3 线程通知与等待

    Java 中的 Object 类是所有类的父类,鉴于继承机制,Java 把所有类都需要的方法放到了 Object 类里面,其中就包含通知与等待系列的函数

    wait() / wait(long timeout)
    当一个线程调用一个共享变量的 wait() 方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:

    1.其他线程调用了该共享对象的 notify() 或者 notifyAll() 方法
    2.其他线程调用了该线程的 interrupt() 方法,该线程抛出 InterruptedException 异常返回
    3.如果带有超时参数,没有在指定时间的 timeout ms 时间内被其他线程调用该共享变量的 notify() 或者 notifyAll() 方法唤醒,那么该函数还是会因为超时而返回
    4.不加参数的 wait() 方法内部就是调用了 wait(0)

    当线程调用共享对象的 wait() 方法时,当前线程只会释放当前共享对象的锁,当前线程持有的其他共享对象的监视器锁并不会被释放

    虚假唤醒
    一个线程可以从挂起状态变为可以运行状态(也就是被唤醒),即使该线程没有被其他线程调用 notify()、notifyAll() 方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒 。

    虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停的测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用 wait() 方法进行防范。退出循环的条件是满足了唤醒该线程的条件。

    1. synchronized (obj) {
    2. while (条件不满足) {
    3. obj.wait();
    4. }
    5. }

    notify()
    一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。这个被唤醒的线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行

    notifyAll()
    notifyAll() 方法会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程

    1.4 等待线程执行终止的 join 方法

    Thread 类中的 join 方法可以用来等待多个线程全部加载完毕再汇总处理

    线程 A 调用线程 B 的 join 方法后会被阻塞,当其他线程调用了线程 A 的 interrupt() 方法中断了线程 A 时,线程 A 会抛出 InterruptedException 异常而返回

    1.5 让线程睡眠的 sleep 方法

    Thread 类有一个静态的 sleep() 方法,当一个执行中的线程调用了 Thread 的 sleep() 方法后,调用线程会暂时让出指定的执行权,也就是在这期间不参与 CPU 的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与 CPU 的调度,获取到 CPU 的资源后就可以运行了。

    1.6 让出 CPU 执行权的 yield 方法

    当一个线程调用了 Thread 类的静态方法 yield() 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度

    sleep() 和 yield() 方法的区别在于,当线程调用 sleep() 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用 yield() 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

    1.7 线程中断

    Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理

    • void interrupt() : 中断线程,当线程 A 运行时,线程 B 可以调用线程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设置标志,线程 A 实际并没有被中断,它会继续往下执行。
    • boolean isInterrupted() : 检测当前线程是否被中断,如果是返回 true,否则返回 false
    • boolean interrupted() : 检测当前线程是否被中断,如果是返回 true,否则返回 false,如果该方法发现当前线程被中断,则会清除中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用。

    1.8 线程上下文切换

    线程上下文切换时机有:当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程中断时

    1.9 线程死锁

    什么是死锁
    死锁是指两个或两个以上的线程在执行过程,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直等待而无法继续运行下去
    产生死锁的条件。

    死锁的产生必须具备以下四个条件:

    • 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求使用该资源,则请求者只能等待,直至占有资源的线程释放该资源
    • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源
    • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源
    • 环路等待条件:指在发生死锁时,必然存在一个线程一资源的环形链,即线程集合 {T0, T1, T2, ... , Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,......Tn 正在等待已被 T0 占用的资源。

    如何避免线程死锁
    要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是目前只有 请求并持有 和 环路等待 条件是可以被破坏的

    资源的有序分配会避免死锁,因为资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此避免了死锁。

    1.10 守护线程与用户线程

    Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。在 JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程,而垃圾回收线程则是守护线程

    守护线程和用户线程区别之一是当最后一个非守护线程结束时,JVM 会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 的退出。言外之意,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出

    创建守护线程的的方式是,设置线程的 daemon 参数为 true 即可

    总的来说,如果希望在主线程结束后 JVM 进程马上结束,那么在创建线程时可以将其设置为守护线程,如果希望在主线程结束后子线程继续工作,等子线程结束后再让 JVM 进程结束,那么就将子线程设置为用户线程

    1.11 ThreadLocal

    ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

    ThreadLocal 是一个 HashMap 结构,其中 key 就是当前 ThreadLocal 的实例引用,value 是通过 set 方法传递的值。ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。

    2. 并发编程的其他基础知识

    2.1 为什么要进行多线程并发编程

    多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都会高并发编程有着迫切的需求。

    2.2 Java 中的线程安全问题

    • 共享资源:就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源

    线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题

    2.3 Java 中共享变量的内存可见性问题

    当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存

    假如线程 A 和线程 B 使用不同的 CPU 执行,此时由于 Cache 的存在,将会导致内存不可见问题

    2.4 synchronized

    2.4.1 synchronized 关键字介绍

    synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当做一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为 内部锁,也叫做 监视器锁 。

    内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

    另外,由于 Java 中的线程是与操作系统中的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。

    2.4.2 synchronized 的内存语义

    进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存。

    除了可以解决共享变量内存可见性问题外,synchronized 经常被用来实现原子性操作。另外请注意,synchronized 关键字会引起线程上下文切换并带来线程调度开销。

    2.5 volatile

    对于解决内存可见性的问题,Java 还提供了一种弱形式的同步,也就是使用 volatile 关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

    2.6 Java 中的原子性操作

    所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

    线程安全性:即内存可见性和原子性

    2.7 Java 中的 CAS 操作

    CAS 即 Compare and Swap,是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较 -- 更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap 方法。

    1. // 比如说下面这个
    2. boolean compareAndSwapLong(Object obj, long valueOffset, long expect, long update);

    其中 compareAndSwap 的意思是比较并交换。

    CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 的变量值为 expect ,则使用新的值 update 替换旧的值 expect。这是处理器提供的一个原子性指令。

    ABA 问题
    CAS 操作有个经典的 ABA 问题。

    ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B,然后再从 B 到 A 。如果变量的值只能朝着一个方向转换,比如 A 到 B,B 到 C,不构成环形,就不会存在问题。JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。

    2.8 Unsafe 类

    • JDK 的 rt.jar 包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++ 实现库

    2.9 Java 指令重排序

    Java 内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。

    重排序在多线程下会导致非预期的程序执行结果,而使用 volatile 修饰变量就可以避免重排序和内存可见性问题。

    写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

    2.10 伪共享

    2.10.1 什么是伪共享

    为了解决主内存与 CPU 之间运行速度差的问题,会在 CPU 与主内存之间添加一级或多级高速缓冲器(Cache)。这个 Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache 。

    在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。Cache 行是 Cache 与主内存进行数据交换的单位。

    由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每一个变量放到一个缓存行,性能会有所下降,这就是伪共享。

    2.10.2 如何避免伪共享

    在 JDK 8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这就避免了将多个变量存放在同一个缓存行中。

    JDK 8 提供了一个 sun.misc.Contented 注解,用来解决伪共享问题。在默认情况下,@Contented 注解只用于 Java 核心类,比如 rt 包下的类。如果用户类路径下的类需要使用这个注解,则需要添加 JVM 参数:-XX:-RestrictContented 。

    总结来说,在多线程下访问同一个缓存行的多个变量时才会出现伪共享,在单线程下访问一个缓存行里面的多个变量反而会对程序运行起到加速作用

    2.11 锁的概述

    2.11.1 乐观锁与悲观锁

    • 悲观锁是指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态
    • 乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会对数据冲突与否进行检测

    2.11.2 公平锁与非公平锁

    根据线程获取锁的抢占机制,锁可以分为 公平锁 和 非公平锁

    • 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。

    • 非公平锁则在运行时闯入,也就是先来不一定先得。
      ReentrantLock 提供了公平和非公平锁的实现

    • 公平锁:ReentrantLock pairLock = new ReentrantLock(true)

    • 非公平锁:ReentrantLock pairLock = new ReentrantLock(false) ,默认是非公平锁

    在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销

    2.11.3 独占锁与共享锁

    根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为 独占锁 和 共享锁 。

    独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多个线程同时进行读操作。

    • 独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取
    • 共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作

    2.11.4 可重入锁

    • 当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时,如果不被阻塞,那么该锁就是可重入的。
      synchronized 内部锁是可重入锁。

    可重入锁的原理是在锁内部维护了一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器,当计数器值为 0 时说明该锁没有被任何线程占用,当一个线程获取了该锁,计数器值会变为 1,这时其他线程再来获取锁时会发现锁的所有者不是自己而被阻塞挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,计数器值就 + 1,当释放锁后,计数器值 - 1。当计数器值为 0 时,锁里面的线程标示被重置为 null ,这时候被阻塞的线程会被唤醒来竞争获取该锁。

    2.11.5 自旋锁

    由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁失败后,会被切换到用户态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。

    自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。

    由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。

    3. ThreadLocalRandom

    3.1 Random 类及其局限性

    每个 Random 实例里面都有一个原子性的种子变量用来记录当前的种子值,当要生成新的随机数时需要根据当前种子计算新的种子并更新会原子变量。当多线程下使用单个 Random 实例生成随机数时,当多个线程同时计算随机数来计算新的种子时,多个线程会竞争同一个原子变量的更新操作,由于原子变量的更新是 CAS 操作,同时只有一个线程会成功,所以会造成大量线程进行自旋重试,这会降低并发性能,所以 ThreadLocalRandom 应运而生。

    3.2 ThreadLocalRandom

    每个线程都维护一个种子变量,则每个线程生成随机数时都根据自己老的种子计算新的种子,并使用新种子更新老的种子,再根据新种子计算随机数,就不会存在竞争问题了,这会大大提高并发性能。

    ThreadLocalRandom 使用 ThreadLocal 的原理,让每个线程都持有一个本地的种子变量,该种子变量只有在使用随机数时才会被初始化。在多线程下计算新种子时是根据自己线程内维护的种子变量进行更新,从而避免了竞争。

    4. JUC 中的原子操作类

    JUC 包提供了一系列的原子性操作类,这些类都是使用非阻塞算法 CAS 实现的,相比使用锁实现原子性操作这在性能上有很大提高。

    4.1 AtomicLong

    • AtomicLong 是原子性递增或递减类,其内部使用 Unsafe 来实现
      因为 AtomicLong 类是在 rt.jar 包下面的,AtomicLong 类就是通过 BootStarp 类加载器进行加载的,所以其内部实现时可以直接通过 Unsafe.getUnsafe() 方法获取到 Unsafe 类的实例

    在高并发情况下 AtomicLong 还会存在性能问题。JDK 8 提供了一个在高并发下性能更好的 LongAdder 类

    使用 AtomicLong 时,在高并发下大量线程会同时去竞争更新同一个原子变量,但是由于同时只有一个线程的 CAS 操作会成功,这就造成了大量线程竞争失败后,会通过无限循环不断进行自旋尝试 CAS 的操作,而这会白白浪费 CPU 资源。

    4.2 LongAdder

    为了解决高并发下多线程对一个变量 CAS 争夺失败后进行自旋而造成的降低并发性能的问题,LongAdder 在内部维护多个 Cell 元素(一个动态 Cell 数组)来分担对单个变量进行争夺的开销,每个 Cell 里面有一个初始值为 0 的 long 型变量,这样,在同等并发量的情况下,争夺单个变量更新操作的线程量会减少。

    另外,多个线程在争夺同一个 Cell 原子变量时如果失败了,它并不是在当前 Cell 变量上一直自旋 CAS 重试,而是尝试在其他 Cell 的变量上进行 CAS 尝试,这个改变增加了当前线程重试 CAS 成功的可能性。

    最后,在获取 LongAdder 当前值时,是把所有 Cell 变量的 value 值累加后再加上 base 返回的。

    由于 Cells 占用的内存是相对较大的,所以一开始并不创建它,而是在需要时创建,也就是 惰性加载 。
    另外,数组元素 Cell 使用 @sun.misc.Contented 注解进行修饰,这避免了 Cells 数组内多个原子变量被放入同一个缓存行,也就是避免了 伪共享,这对性能也是一个提升。

    LongAccumulator
    LongAdder 类是 LongAccumulator 的一个特例,只是后者提供了更加强大的功能,可以让用户自定义规则。

    5. CopyOnWriteArrayList

    并发包中的并发 list 只有 CopyOnWriteArrayList,它是无界 list 。

    CopyOnWriteArrayList 使用写时复制的策略来保证 list 的一致性,而 获取 - 修改 - 写入 三步操作并不是原子性的,所以在增删改的过程中都使用了独占锁,来保证在某个时间只有一个线程能对 list 数组进行修改。另外 CopyOnWriteArrayList 提供了弱一致性的迭代器,从而保证在获取迭代器后,其他线程对 list 的修改是不可见的,迭代器遍历的数组是一个快照。另外,CopyOnWriteArraySet 的底层就是使用它实现的。

    6. JUC中锁原理

    6.1 LockSupport

    • LockSupport 是个工具类,它的主要作用是挂起和唤醒线程,该工具类是创建锁和其他同步类的基础。
    • LockSupport 类与每个使用它的线程都会关联一个许可证,在默认情况下调用 LockSupport 类的方法的线程是不持有许可证的。LockSupport 是使用 Unsafe 类实现的。

    6.1.1 void park()

    • 如果调用 park 方法的线程已经拿到了与 LockSupport 关联的许可证,则调用 LockSupport.park() 时会马上返回,否则调用线程会被禁止参与线程的调度,也就是会被阻塞挂起。

    6.1.2 void unpark()

    • 当一个线程调用 unpark 时,如果参数 thread 线程没有持有 thread 与 LockSupport 类关联的许可证,则让 thread 线程持有。
    • 如果 thread 之前因调用 park() 而被挂起,则调用 unpark() 后,该线程会被唤醒。
    • 如果 thread 之前没有调用 park(),则调用 unpark 方法后,再调用 park 方法,会立刻返回。

    6.1.3 其他方法

    1.void parkNanos(long nanos)
    2.park(Object blocker)
    3.void parkNanos(Object blocker, long nanos)
    4.void parkUntil(Object blocker, long deadline)

    6.2 AQS

    • AbstractQueuedSynchronizer 抽象同步队列简称 AQS,它是实现同步器的基础组件,并发包中锁的底层就是使用 AQS 实现
    • AQS 是一个 FIFO 的双向队列,其内部通过节点 head 和 tail 记录队首和队尾元素,队列元素的类型为 Node。其中 Node 中的 thread 变量用来存放进入 AQS 队列里的线程
    • 在 AQS 中维持了一个单一的状态信息 state,可以通过 getState、setState、compareAndSetState 函数修改其值。
    • AQS 有个内部类 ConditionObject,用来结合锁实现线程同步。
    • 对于 AQS 来说,线程同步的关键是对状态值 state 进行操作。

    6.2.1 条件变量的支持

    notify 和 wait ,是配合 synchronized 内置锁实现线程间同步的基础设施一样,条件变量的 signal 和 await 方法也是用来配合锁(使用 AQS 实现的锁)实现线程间同步的基础设施。

    它们的不同在于,synchronized 同时只能与一个共享变量的 notify 或 wait 方法实现同步,而 AQS 的一个锁可以对应多个条件变量。

    1. ReentrantLock lock = new ReentrantLock();
    2. Condition notFull = lock.newCondition();
    3. Condition notEmpty = lock.newCondition();

    lock.newCondition() 的作用其实是 new 了一个在 AQS 内部声明的 ConditionObject 对象,ConditionObject 是 AQS 的内部类,可以访问 AQS 内部的变量(例如状态变量 state)和方法。在每个条件变量内部都维护了一个条件队列,用来存放调用条件变量的 await() 方法时被阻塞的线程。注意这个条件队列和 AQS 队列不是一回事。

    注意不要混淆 AQS 阻塞队列与条件变量队列:

    • 当多个线程同时调用 lock.lock() 方法获取锁时,只有一个线程获取到了锁,其他线程会被转换为 Node 节点插入到 lock 锁对应的 AQS 阻塞队列里面,并做自旋 CAS 尝试获取锁。
    • 如果获取到锁的线程又调用了对应的条件变量的 await() 方法,则该线程会释放获取到的锁,并被转换为 Node 节点插入到条件变量对应的条件队列里面。
    • 这时候因为调用 lock.lock() 方法被阻塞到 AQS 队列里面的一个线程会获取到被释放的锁,如果该线程也调用了条件变量的 await() 方法则该线程也会被放入条件变量的条件队列里面。
    • 当另外一个线程调用条件变量的 signal() 或者 signalAll() 方法时,会把条件队列里面的一个或者全部 Node 节点移动到 AQS 的阻塞队列里面,等待时机获取锁。

    也就是说,一个锁对应一个 AQS 阻塞队列,对应多个条件变量,每个条件变量有自己的一个条件队列。

    6.3 独占锁 ReentrantLock

    • ReentrantLock 是可重入的独占锁,同时只能有一个线程可以获取该锁,其他获取该锁的线程会被阻塞而被放入该锁的 AQS 阻塞队列里面。

    6.3.1 获取锁

    void lock()

    • 调用该方法时,如果锁当前没有被其他线程占用并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置 AQS 的状态值 state 为 1,然后直接返回。如果当前线程之前已经获取过该锁,则这次只是简单的把 AQS 的状态值加 1 后返回。如果该锁已经被其他线程持有,则调用该方法的线程会被放入 AQS 队列后阻塞挂起。

    当然还有其他的获取锁的方法

    • void lockInterruptibly():对中断进行响应
    • boolean tryLock():尝试获取锁,如果当前该锁没有被其他线程持有,则当前线程获取该锁并返回 true,否则返回 false。注意,该方法不会引起当前线程阻塞。

    6.3.2 释放锁

    void unlock()

    • 尝试释放锁,如果当前线程持有该锁,则调用该方法会让线程对该线程持有的 AQS 状态值减 1,如果减去 1 后当前状态值为 0 ,则当前线程会释放该锁,否则仅仅减 1 而已。如果当前线程没有持有该锁而调用了该方法则会抛出 IllegalMonitorStateException 异常。

    总的来说,ReentrantLock 的底层是使用 AQS 实现的可重入独占锁。在这里 AQS 状态值为 0 表示当前锁空闲,为大于等于 1 的值则说明该锁已经被占用。该锁内部有公平与非公平实现,默认情况下是非公平的实现。

    6.4 读写锁 ReentrantReadWriteLock

    ReentrantReadWriteLock 的底层是使用 AQS 实现的。ReentrantReadWriteLock 巧妙的使用 AQS 的状态值的高 16 位表示获取到读锁的个数,低 16 位表示获取写锁的线程的可重入次数,并通过 CAS 对其进行操作实现了读写分离,这在读多写少的场景下比较适用。

    6.5 StampedLock

    StampedLock 是并发包里面 JDK8 版本新增的一个类,该锁提供了三种模式的读写控制,当调用获取锁系列函数时,会返回一个 long 型的变量,我们称之为 戳记(stamp),这个戳记代表了锁的状态。其中 try 系列获取锁的函数,当获取锁失败后会返回为 0 的 stamp值。当调用释放锁和转换锁的方法时需要传入获取锁时返回的 stamp 值。

    StampedLock 提供的三种读写模式的锁:

    • 写锁 writeLock:独占锁,不可重入
    • 悲观读锁 readLock:共享锁,不可重入
    • 乐观读锁 tryOptimisticRead:只是使用位操作进行检验,不涉及 CAS 操作,所以效率会高很多

    StampedLock 提供的读写锁与 ReentrantReadWriteLock 类似,只是前者提供的是不可重入锁。但是前者通过提供乐观读锁在多线程多读的情况下提供了更好的性能,这是因为获取乐观读锁时不需要进行 CAS 操作设置锁的状态,而只是简单的测试状态。


     

  • 相关阅读:
    设计模式-抽象工厂模式
    燕之屋通过港交所聆讯:苦战IPO十余年,黄健等人提前精准套现
    室外光缆的规划与维护
    大数据课程K22——Spark的SparkSQL的API调用
    Codeforces Round #802 (Div. 2)
    2024年:如何根据项目具体情况选择合适的CSS技术栈
    高速自动驾驶HMI人机交互
    rollup的使用
    yaml配置
    Selenium Webdriver自动化测试框架
  • 原文地址:https://blog.csdn.net/jmysql/article/details/125527171