20210606 Java 并发编程之美

Java 并发编程之美

背景

  • 出版年:2018-10

读后感想

  • 首先很有帮助,让我对 Java 并发编程有了更深、更全面的理解
  • 内容精干无冗余
  • 对 JDK 中并发相关的部分类有源码上的剖析(很重要)
  • 缺少实战内容
  • 读完后对 Java 并发的知识体系仍然还是模糊的,缺少纲领式的内容

内容

并发编程线程基础

什么是线程

  • 线程是进程中的一个实体,线程本身是不会独立存在的
  • 进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位
  • 线程则是进程的一个执行路径, 一个进程中至少有一个线程,进程中的多个线程共享进程资源
  • 操作系统在分配资源时是把资源分配给进程的, 但是 CPU 资源比较特殊,它是被分配到线程的,因为真正要占用 CPU 运行的是线程,所以也说 线程是 CPU 分配的基本单位

img

  • 一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域
  • 程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。之所以要将程序计数器设计为线程私有的,是因为线程是占用 CPU 执行的基本单位,而 CPU 一般是使用时间片轮转方式让线程轮询占用的,所以当前线程的 CPU 时间片用完后,要让出 CPU ,等下次轮到自己的时候再执行。 程序计数器就是为了记录该线程让出 CPU 时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。另外需要注意的是,如果执行的是 native 方法,那么 pc 计数器记录的是 undefined 地址,只有执行的是 Java 代码时 pc 计数器记录的才是下一条指令的地址
  • 每个线程都有自己的栈资源,用于存储该线程的局部变量 ,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧
  • 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new 操作创建的对象实例
  • 方法区则用来存放 JVM 加载的类、常量及静态变量等信息,也是线程共享的

线程创建与运行

参考 java.lang.Thread.State,线程状态图:

img

  • Java 中有三种线程创建方式,分别为实现 Runnable 接口的 run 方法,继承 Thread 并重写 run 方法,实现 Callable 接口的 call 方法
  • 当创建完 thread 对象后该线程并没有被启动执行,直到调用了 start 方法后才真正启动了线程
  • 调用 start 方法后线程并没有马上执行而是处于就绪状态, 这个就绪状态是指线程已经获取了除 CPU 资源外的其他资源,等待获取 CPU 资源后才会真正处于运行状态。一旦 run 方法执行完毕, 该线程就处于终止状态
  • 使用继承方式的好处是 run 方法 获取当前线程直接使用 this 就可以了,无须使用 Thread.currentThread() 方法;不好的地方是 Java 不支持多继承,如果继承了 Thread 类,那么就不能再继承其他类。另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码,而 Runnable 则没有这个限制
  • 使用继承方式的好处是方便传参,你可以在子类里面添加成员变 ,通过 set 方法设置参数或者通过构造函数进行传递,而如果使用 Runnable 方式,则只能使用主线程里面被声明为 final 变量。不好的地方是 Java 支持多继承,如果继承了 Thread 类,那么子 不能再继承其他 ,而 Runnable 则没有这个限制 。前两种方式都没办法拿到任务的返回结果,但是 Callable 方式可以

线程通知与等待

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

    • 线程调用了该共享对象 notify() 或者 notifyAll() 方法
    • 其他线程调用了该线程 interrupt() 方法,该线程抛出 InterruptedException 异常返回
  • 如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,则调用 wait() 方法时调用线程会抛出 IllegalMonitorStateException 异常

  • 一个线程如何才能获取一个共享变量的监视器锁

    • 执行 synchronized 步代码块时,使用该共享变量作为参数

      synchronized (共享变量) {
      	//doSomething
      }
      
    • 调用该共享变量的方法,并且该方法使用了 synchronized 修饰

      synchronized void add (int a, int b) { 
      	//doSomething
      }
      
  • 一个线程可以从挂起状态变为可以运行状态( 就是被唤醒),即使该线程没有被其他线程调用 notifynotifyAll 方法进行通知,或者被中断,或者等待超时,这就是所谓的 虚假唤醒

    // 防止虚假唤醒
    synchronized (obj) { 
        while (条件不满足) {
        	obj.wait();
        }
    }
    
  • 当前线程调用共享变量的 wait() 方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放

  • 当一个线程调用 共享对象 wait() 方法被阻塞挂起后,如果其他线程中断了该线程, 线程会抛出 InterruptedException 异常并返回

  • 一个线程调用共享对象的 notify 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。 一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行

  • 类似 wait 系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的 notify 方法,否则会抛出 IllegalMonitorStateException 异常

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

  • 在共享变量上调用 notifyAll 方法只会唤醒调用这个方法前调用了 wait 系列函数而被放入共享变量等待集合里面的线程。如果调用 notifyAll 方法后一个线程调用了该共享变量的 wait 方法而被放入阻塞集合, 该线程是不会被唤醒的

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

  • Thread 类提供了 join 方法
  • 线程 A 调用线程 B 的 join 方法后会被阻塞,等待线程 B 执行完成后返回,当其他线程调用了线程 A 的 interrupt() 方法中断了线程 时,线程 A 会抛出 InterruptedException 异常而返回

让线程睡眠的 sleep 方法

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

让出 CPU 执行权的 yield 方法

  • Thread 类中有一个静态 yield 方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的 CPU 使用权,当前切换为就绪状态,但是线程调度器可以无条件忽略这个暗示
  • 一般很少使用这个方法,在调试或者测试时这个方法或许可以帮助复现由于并发竞争条件导致的问题,其在设计并发控制时或许会有用途,java.util.concurrent.locks 包里面的锁时会看到该方法的使用
  • sleepyield 方法的区别在于,当线程调用 sleep 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用 yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行

线程中断

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

  • void interrupt() 方法:中断线程,例如,当线程 运行时,线程 B 可以调用线程 A 的 interrupt 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设置标志,线程 A 实际并没有被中断, 会继续往下执行。如果线程 A 因为调用了 wait 系列函数、 join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程 A 的 interrupt 方法,线程 A 会在调用这些方法的地方抛 IntrruptedException 异常而返回。
  • boolean isInterrupted() 方法: 检测当前线程是否被中断,线程的中断状态不受该方法的影响,如果是返回 true 否则返回 false
  • boolean interrupted() 方法: 检测当前线程是否被中断,如果是返回 true ,否则返回 false 。与 islnterrupted 不同的是,该方法如果发现当前线程被中断, 会清除中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用。另外 interrupted() 内部是获取当前调用线程的中断标志而不是调用 interrupted() 方法的实例对象的中断标志
/**
 * interrupted 会清除线程的中断标志
 * isInterrupted 不会
 */
@Slf4j
public class InterruptTest5 {
    @SneakyThrows
    public static void main(String[] args) {

        // 获取中断标志并重置
        // 这里获取的时主线程的中断标志
        log.info("Thread.interrupted 1 :: {}", Thread.interrupted());
        Thread.currentThread().interrupt();
        log.info("Thread.interrupted 2 :: {}", Thread.interrupted());
        log.info("Thread.interrupted 3 :: {}", Thread.interrupted());

        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {

                // while (!Thread.currentThread().isInterrupted()) {
                // 中断标志为 true 时会退出循环,并且清除中断标志
                while (!Thread.currentThread().interrupted()) {

                }

                log.info("thread 1 isInterrupted :: {}", Thread.currentThread().isInterrupted());
            }
        });

        threadOne.start();

        threadOne.interrupt();

        // 获取中断标志
        log.info("threadOne.isInterrupted :: {}", threadOne.isInterrupted());
        
    }
}

理解线程上下文切换

  • 在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的策略 ,也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当前线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换
  • 从当前线程的上下文切换到了其他线程,那么就有一个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场
  • 线程上下文切换时机有:当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程中断时

线程死锁

  • 死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去
  • 死锁的产生必须具备以下四个条件:
    • 互斥条件: 指线程对己经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源
    • 请求并持有条件:指一个线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己经获取的资源
    • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源
    • 环路等待条件:指在发生死锁时,必然存在一个线程与资源的环形链,即线程集合 {T0, T1, T2 ,... , Tn}中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,Tn 在等待己被 T0 占用的资源
  • 要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是学过操作系统的读者应该都知道,目前只有 请求并持有环路等待条件 是可以被破坏的
  • 造成死锁的原因其实和申请资源的顺序有很大关系 使用资源申请的有序性原则就可以避免死锁

守护线程与用户线程

  • Java 中的线程分为两类,分别为 daemon 线程(守护线程〉和 user 线程(用户线程)。在 JVM 启动时会调用 main 函数, main 函数所在的线程就是一个用户线程
  • 当最后一个非守护线程结束时, JVM 正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 退出。言外之意,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出
  • 当父线程结束后,子线程还是可以继续存在的,也就是子线程的生命周期并不受父线程的影响
  • main 线程运行结束后, JVM 会自动启动一个叫作 DestroyJavaVM 的线程, 该线程会等待所有用户线程结束后终止 JVM 进程
  • 如果你希望在主线程结束后 JVM 进程马上结束,那么在创建线程时可以将其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让 JVM 进程结束,那么就将子线程设置为用户线程

ThreadLocal

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

ThreadLocal 相关类的类图结构:

img

  • 由该图可知, Thread 类中有 threadLocalsinheritableThreadLocals ,它们都是 ThreadLocalMap 类型的变量,而 ThreadLocalMap 是一个定制化的 Map
  • 在默认情况下, 每个线程中的这两个变量都为 null ,只有当前线程第一次调用 ThreadLocalset 或者 get 方法时才会创建它们。
  • 其实每个线程的本地变量不是存放在 ThreadLocal 实例里面,而是存放在调用线程的 threadLocals 变量里面。也就是说 ThreadLocal 类型的本地变量存放在具体的线程内存空间中。 ThreadLocal 就是 个工具壳,它通过 set 方法把 value 值放入调用线程的 threadLocals 里面并存放起,当调用线程调用它的 get 方法时,再从当前线程的 threadLocals 变量里面将其拿出来使用。
  • 如果调用线程一直不终止, 那么这个本地变量会一直存放在调用线程的 threadLocals 里面 ,所以当不需要使用本地变量时可以通过调用 ThreadLocal 变量的 remove 方法 ,从当前线程 threadLocals 里面删除该本地变量。
  • Thread 里面的 threadLocals 为何被设计为 map 结构?很明显是因为每个线程可以关联多个 ThreadLocal 变量
  • 在每一个线程内部都有一个名为 threadLocals 的成员变量,该变量的类型为 Map ,其中 key 为我们定义的 ThreadLocal 变量的 this 引用, value 则为我们使用 set 方法设置的值,每个线程的本地变量存放在线程自己的内存变量 threadLocals 中,如果当前线程一直不消亡,那么这些本地变量会一直存在,所以可能会造成内存溢出,因此使用完毕后要记得 ThreadLocal 的 remove 方法删除对应线程 threadLocals 中的本地变量。
  • ThreadLocal 不支持继承性,同一个 ThreadLocal 变量在父线程中被设置值后 在子线程中是获取不到的。
  • InheritableThreadLocal 继承自 ThreadLocal ,其提供了一个特性,就是让子线程可 以访问在父线程中设置的本地变量
  • InheritableThreadLocal 的实现 非常优雅
// ThreadLocal 的用法

public class ThreadLocalTest {
    // 1. print 函数
    static void print(String str, boolean removeFlag) {
        // 1.1. 打印当前线程本地内存中 localVariable
        System.out.println(str + ":" + localVariable.get());
        if (removeFlag) {
            // 1.2. 清除当前线程本地内存中的 localVariable 变量
            localVariable.remove();
        }
    }

    // 2. 创建 ThreadLocal 变量
    static ThreadLocal<String> localVariable = new ThreadLocal<>();

    public static void main(String[] args) {
        // 3 创建线程one
        Thread threadOne = new Thread(new Runnable() {
            public void run() {
                // 3.1. 设置线程 One 中本地变量 localVariable 的值
                localVariable.set("threadOne local variable");
                // 3.2. 调用打印函数
                print("threadOne", true);
                // 3.3. 打印本地变量值
                System.out.println("threadOne remove after" + ":" + localVariable.get());
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            public void run() {
                // 4.1. 设置线程 Two 中本地变量 localVariable 的值
                localVariable.set("threadTwo local variable");
                // 4.2. 调用打印函数
                print("threadTwo", false);
                // 4.3. 打印本地变量值
                System.out.println("threadTwo remove after" + ":" + localVariable.get());
            }
        });

        // 5. 启动线程
        threadOne.start();
        threadTwo.start();


    }
}

/**
 * ThreadLocal 不支持继承性
 * InheritableThreadLocal   支持继承性
 */

@Slf4j
public class ThreadLocalTest2 {
    // public static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    public static ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) {
        threadLocal.set("hello main");
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                log.info("thread :: {}", threadLocal.get());
            }
        });

        thread.start();

        log.info("main :: {}", threadLocal.get());
    }
}

并发编程的其他基础知识

什么是多线程并发编程

  • 并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,而并行是说在单位时间内多个任务同时在执行。并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行
  • 在多线程编程实践中,线程的个数往往 CPU 的个数,所以一般都称多线程并发编程而不是多线程并行编程
  • 多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海 数据和请求的要求,这些都对高并发编程有着迫切的需求

Java 中的线程安全问题

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

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

  • 如果多个线程都只是读取共享资源,而不去修改,那么就不会存在线程安全问题,只有当至少一个线程修改共享资源时才会存在线程安全问题

  • 最典型的就是计数器类的实现,计数变量 count 本身是一个共享变量,多个线程可以对其进行递增操作,如果不使用同步措施 ,由于递增操作是 获取、计算、保存 三步操作 ,因此可能导致计数不准确

    t1 t2 t3 t4
    线程 A 从内存读取 count 到本线程 递增本线程 count 的值 写回主内存
    线程 B 从内存读取 count 到本线程 递增本线程 count 的值 写回主内存

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

Java 内存模型:

img

Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。Java 内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?

img

图中所示是一个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 共享的二级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 L1 或者 L2 缓存或者 CPU 寄存器。

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

假如线程 A 和线程 B 同时处理一个共享变量,并且不同 CPU 执行,如果当前两级 Cache 都为空,那么这时候由于 Cache 存在,将会导致内存不可见问题

使用 Java 中的 volatile 关键字可以解决内存不可见问题

Java 中的 synchronized 关键字

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

线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源 的 wait 系列方法时释放该内置锁。

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

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

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

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。

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

Java 中的 volatile 关键字

对于解决内存可见性问题, Java 还提供了一种弱形式的同步,也就是使用 volatile 关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。

当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

volatile 的内存语义和 synchronized 有相似之处,具体来说就是,当线程写入了 volatile 值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)

并非在所有情况下使用 volatile 和 synchronized 都是等价的,volatile 虽然提供了可见性保证,但并不保证操作的原子性。

一般在什么时候才使用 volatile 关键字呢?

  • 写入变量不依赖变量的当前值时。因为如果依赖当前值,将是获取、计算、写入三步操作,这三步操作不是原子性的,而 volatile 不保证原子性
  • 读写变量值时没有加锁 。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile

Java 中的原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作全部要么执行,要么全部不执行,不存在只执行其中一部分的情况。在设计计数器时一般都先读取当前值,然后 +1 , 再更新。这个过程是 读、改、写 的过程,如果不能保证这个过程是原子性的,那么就会出现线程安全问题。Java 中简单的一句 ++value 被转换为汇编后就不具有原子性了

Java 中的 CAS 操作

Java 锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开销。

Java 提供了非阻塞的 volatile 关键字来解决共享变量的可见性问题,这在一定程度上弥补了锁带来的开销问题,但是 volatile 只能保 共享变量可见性,不能解决 读、改、写 等的原子性问题。

CAS 即 Compare and Swap ,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较、更新操作的原子性。

JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap* 方法,以 compareAndSwapLong 方法为例进行简单介绍:

boolean compareAndSwapLong (Object obj, long valueOffset, long expect, long update) 方法:其中 compareAndSwap 的意思是比较并交换。 CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值 和 新的值。其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 变量值为 expect ,则使用新的值 update 替换旧的 expect这是处理器提供的一个原子性指令

关于 CAS 操作有个经典的 ABA 问题,具体如下: 线程 1 使用 CAS 修改初始值为 A 的变量 X,那么线程 1 会首先去获取当前变量 X 的值(为 A),然后使用 CAS 操作尝试修改 X 的值为 B,如果使用 CAS 操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程 1 获取变量 X 的值后,在执行 CAS 前,线程 2 使用 CAS 修改了变量 X 的值为 B,然后又使用了 CAS 修改了变量 X 的值为 A。所以虽然线程 1 执行 CAS 时的值是 A, 但是这个 A 己经不是线程 1 获取 时的 A 了。这就是 ABA 问题。

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

Unsafe 类

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

方法声明 描述
long objectFieldOffset(Field field) 返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该 Unsafe 函数中访问指定字段时使用。例如使用 Unsafe 取变量 valueAtomicLong 对象中的内存偏移
int arrayBaseOffset(Class anayClass) 获取数组中第一个元素的地址
int arrayIndexScale(Class arrayClass) 获取数组中一个元素占用的字节
boolean compareAndSwapLong(Object obj, long offset, long expect, long update) 比较对象 obj 中偏移量 offset 的变量的值是否与 expect 相等,相等则使用 update 值更新,然后返回 true ,否则返回 false
public native long getLongvolatile(Object obj, long offset) 获取对象 obj 中偏移量为 offset 的变量对应 volatile 语义的值
void putLongvolatile(Object obj, long offset, long value) 设置 obj 对象中 offset 偏移的类型为 long 的 field 的值为 value ,支持 volatile 语义
void putOrderedLong(Object obj, long offset, long value) 设置 obj 对象中 offset 偏移地址对应的 long 型 field 值为 value 。这是一个有延迟的 putLongvolatile 方法,并且不保证值修改对其他线程 可见。只有在变量使用 volatile 修饰并且预计会被意外修改时才使用该方法
void park(boolean isAbsolute, long time) 阻塞当前线程,其中参数 isAbsolute 等于 false 且 time 等于 0 表示一直阻塞。time 大于 0 表示等待指定 time 后阻塞线程会被唤醒,这个 time 是个相对值,是个增量值,也就是相对当前时间累加 time 后当前线程就会被唤醒。如果 isAbsolute 等于 true 并且 time 大于 0,则表示阻塞的线程到指定的时间点后会被唤醒,这里 time 是个绝对时间, 是将某个时间点换算为 ms 后的值。另外,当其他线程调用了当前阻塞线程的 interrupt 方法而中断了当前线程时, 当前线程也会返回, 而当其他线程调用了 unPark 并且把当前程作为参数时当前线程也会返回
void unpark(Object thread) 唤醒调用 park 后阻塞的线程。
long getAndSetLong(Object obj , long offset, long update) 获取对象 obj 中偏移量为 offset 的变量 volatile 语义的当前值,并设置变量 volatile 语义的值为 update
long getAndAddLong(Object obj, long offset, long addValue) 获取对象 obj 中偏移量为 offset 的变量 volatile 语义的当前值,并设置变量值为 原始值+addValue

使用 Unsafe 类:

/**
 * Unsafe 类在 rt.jar 中,由 Bootstrap 类加载器加载,代码中设置了限制,不能直接使用,可以使用反射的方式获取实例
 */
public class TestUnsafe2 {
    static final Unsafe unsafe;

    static final long stateOffset;

    private volatile long state = 0;

    static {
        try {

            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);

            stateOffset = unsafe.objectFieldOffset(TestUnsafe2.class.getDeclaredField("state"));

            System.out.println("stateOffset == "+stateOffset);

        } catch (Exception e) {
            e.printStackTrace();
            System.out.println(e.getLocalizedMessage());
            throw new Error(e);
        }
    }

    @SneakyThrows
    public static void main(String[] args) {
        TestUnsafe2 testUnsafe = new TestUnsafe2();
        // 设置 state 的值为 1
        boolean success = unsafe.compareAndSwapInt(testUnsafe, stateOffset, 0, 1);
        System.out.println(success);

        System.out.println(unsafe.objectFieldOffset(AtomicLong.class.getDeclaredField("value")));
    }
}

Java 指令重排序

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

/**
 * 指令重排序导致的并发问题
 * <p>
 * num 返回的结果不一定为 4,也可能为 0
 * <p>
 * 使用 volatile 修饰 ready 可避免
 */
@Slf4j
public class CommandReOrderTest {
    public static class ReadThread extends Thread {
        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                if (ready) { // 1
                    log.info("num+num == {}", num + num); // 2,这里可能为 0 或者 4
                }
                log.info("read thread ...");
            }
        }
    }

    public static class WriteThread extends Thread {
        @Override
        public void run() {

            num = 2; // 3
            ready = true;   // 4
            log.info("write thread set over ...");

        }
    }

    private static int num = 0;
    private static boolean ready = false;

    @SneakyThrows
    public static void main(String[] args) {
        ReadThread rt = new ReadThread();
        rt.start();

        WriteThread wt = new WriteThread();
        wt.start();

        Thread.sleep(10);
        rt.interrupt();
        log.info("main exit ..");
    }

    
}

通过把变量声明为 volatile 可以避免指令重排序问题

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

伪共享

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

img

在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。 Cache 行是 Cache 与主内存进行数据交换的单位, Cache 行的大小一般为 2 的幂次数字节。

当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大小的内存复制到 Cache 中。由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是 伪共享

img

在该图中,变量 x 和 y 同时被放到了 CPU 的一级和二级缓存,当线程 1 使用 CPU 1 对变量 x 进行更新时 ,首先会修改 CPU 1 的一级缓存变量 x 所在的缓存行,这时候在缓存一致性协议下, CPU 2 中变量 x 对应的缓存行失效。那么线程 2 在写入变量 x 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了 多个线程不可能同时去修改自己所使用的 CPU 中相同缓存行里面的变量 。更坏的情况是,如果 CPU 只有一级缓存,则会导致频繁地访问主内存。

伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。那么为何多个变量会被放入一个缓存行呢?其实是因为缓存与内存交换数据 的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的的内存放入缓存行。

在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变 时就会
争缓存行,从而降低程序运行性

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

JDK 提供了 sun.misc Contended 注解,用来解决伪共享问题,参考 java.lang.Thread#threadLocalRandomSeed ,在默认情况下,@Contended 注解只用于 Java 核心类, 比如 rt 包下的类。如果用户类路径下的类需要使用这个注解, 需要添加 JVM 参数:-XX:-RestrictContended 。填充的宽度默认为 128 ,要自定义宽度则可以设 -XX:ContendedPaddingWidth 参数。

锁的概述

乐观锁与悲观锁

乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想。

悲观锁 指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败, 则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。 如果获取锁成功,则对记录进行操作 ,然后提交事务后释放排它锁。

乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测 。具体来说,根据 update 返回的行数让用户决定如何去做。

乐观锁并不会使用数据库提供的锁机制, 一般在表中添加 version 字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。

公平锁与非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。

ReentrantLock 提供了公平锁和非公平锁的实现:

  • 公平锁: ReentrantLock pairLock =new ReentrantLock(true)
  • 非公平锁: ReentrantLock pairLock =new ReentrantLock(false) ,如果构造函数不传递参数,则 默认是非公平锁

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

独占锁与共享锁

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

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

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

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作

可重入锁

当一个线程再次获取它自己己经获取的锁时,如果不被阻塞,那么该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(严格来说是有限次数)地进入被该锁锁住的代码

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

自旋锁

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

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

Java 并发包中 ThreadLocalRandom 原理剖析

ThreadLocalRandom 类是 JDK 7 在 JUC 包下新增的随机数生成器,它弥补了 Random 类在多线程下的缺陷 。

Random 类及其局限性

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

ThreadLocalRandom

ThreadLocal 通过让每一个线程复制一份变量,使得在每个线程对变量进行操作时实际是操作自己本地内存里面的副本,从而避免了对共享变量进行同步。实际上 ThreadLocalRandom 的实现也是这个原理。

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

/**
 * ThreadLocalRandom    多个线程使用不同的种子,但是种子变化的函数相同,所以多个线程获取到的随机值相同
 * Random   多个线程使用同一个种子,每次获取都会更新种子,所以多个线程获取到的随机值不同
 */
public class ThreadLocalRandomTest2 {
    @SneakyThrows
    public static void main(String[] args) {
        ThreadLocalRandom random = ThreadLocalRandom.current();
        // Random random = new Random(5);

        Map<Long, List<Integer>> map = new HashMap<>();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                List<Integer> integers = map.get(Thread.currentThread().getId());
                if (integers == null) {
                    integers = new ArrayList<>();
                    map.put(Thread.currentThread().getId(), integers);
                }
                for (int i = 0; i < 10; i++) {
                    integers.add(random.nextInt(10));
                }
            }
        };

        for (int i = 0; i < 5; i++) {
            new Thread(runnable).start();
        }

        Thread.sleep(1000);
        map.forEach((k, v) -> {
            System.out.println(k + " :: " + v);
        });
    }
}

源码分析

img

ThreadLocalRandom 类继承了 Random 类并重写了 nextlnt 方法,在 ThreadLocalRandom 中并没有使用继承自 Random 类的原子性种子变量。在 ThreadLocalRandom 中并没有存放具体的种子,具体的种子存放在具体的调用线程 ThreadthreadLocalRandomSeed 变量里面。

ThreadLocalRandom 似于 ThreadLocal ,就是个工具类。当线程调用 ThreadLocalRandomcurrent 方法时, ThreadLocalRandom 负责初始化调用线程的 threadLocalRandomSeed 变量,也就是初始化种子。

当调用 ThreadLocalRandomnextlnt 方法时,实际上是获取当前线程的 threadLocalRandomSeed 作为当前种子来计算新的种子,然后更新新的种子到当前线程的 threadLocalRandomSeed 变量,而后再根据新种子并使用具体算法计算随机数。

变量 instanceThreadLocalRandom 的一个实例,该变量是 static 的。当多线程通过 ThreadLocalRandomcurrent 方法获取 ThreadLocalRandom 的实例时,其实获取的是同一个实例。但是由于具体的种子是存放在线程里面的,所以在 ThreadLocaIRandom 的实例里面只包含与线程无关的通用算法, 所以它是线程安全的。

Java 并发包中原子操作类原理剖析

JUC 包提供了一系列的原子性操作类,这些类都是使用非阻塞算法 CAS 实现的。

原子变量操作类

JUC 并发包中包含有 AtomicIntegerAtomicLongAtomicBoolean 等原子性操作类,它们的原理类似。 AtomicLong 是原子性递增或者递减类,其内部使用 Unsafe 来实现。

AtomicLong 中的 value 被声明为 volatile 的,这是为了在多线程下保证内存可见性,value 具体存放计数的变量。

递增和递减操作:

  • incrementAndGet
  • decrementAndGet
  • getAndIncrement
  • getAndDecrement

比较更新方法:

  • compareAndSet

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

JDK 新增的原子操作类 LongAdder

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

使用 AtomicLong 时,是多个线程同时竞争同一个原子变量( java.util.concurrent.atomic.AtomicLong#value ),使用 LongAdder 时,则是在内部维护一个延迟初始化的原子性更新数组( java.util.concurrent.atomic.Striped64#cells )和一个基值变量( java.util.concurrent.atomic.Striped64#base ),每个 Cell 里面有一个初始值为 0long 型变量。这样,在同等并发量的情况下,争夺单个变量更新操作的线程量会减少,这变相地减少了争夺共享资源的并发量。另外,多个线程在争夺同一个 Cell 原子变量时如果失败了,它并不是在当前 Cell 变量上一直自旋 CAS 重试,而是尝试在其他 Cell 的变量上进行 CAS 尝试 ,这个改变增加了当前线程重试 CAS 成功的可能性。最后,在获取 LongAdder 前值时, 把所有 Cell 变量的 value 值累加后再加上 base 返回的。

LongAdder 类继承自 Striped64 类,在 Striped64 内部维护着三个变量。LongAdder 的真实值其实是 base 的值与 Cell 数组里面所有 Cell 元素中的 value 值的累加,base 是个基础值,默认为 0cellsBusy 用来实现自旋锁,状态值只有 01 ,当创建 Cell 元素、扩容 Cell 数组或者初始化 Cell 数组时,使用 CAS 操作该变量来保证同时只有一个线程可以进行其中之一的操作。

java.util.concurrent.atomic.Striped64.Cell 类为了多线程并发做了很多工作,value 被声明为 volatile 是因为线程操作 value 变量时没有使用锁,为了保证变量的内存可见性这里将其声明为 volatile 。另外 cas 函数通过 CAS 操作,保证了当前线程更新时被分配的 Cell 元素中 value 值的原子性。另外,Cell 类使用 @sun.misc.Contended 修饰是为了避免伪共享。

@sun.misc.Contended 
static final class Cell {
    volatile long value;
    
    Cell(long x) { value = x; }
    
    final boolean cas(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
    }

    // Unsafe mechanics
    private static final sun.misc.Unsafe UNSAFE;
    private static final long valueOffset;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> ak = Cell.class;
            valueOffset = UNSAFE.objectFieldOffset
                (ak.getDeclaredField("value"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

LongAdder 的重要方法:

方法 描述
long sum() 返回当前的值,内部操作是累加所有 Cell 内部的 value 值后再累加 base 。由于计算总和时没有对 Cell 数组进行加锁,所以在累加过程中可能有其他线程对 Cell 的值进行了修改,也有可能对数组进行了扩容,所以 sum 返回的值并不是非常精确的, 其返回值并不是一个调用 sum 方法时的原子快照值
void reset() 重置操作,如果 Cell 数组有元素,则元素值被重置为 0
long sumThenReset() sum 方法的改造版本,在累加对应的 Cell 值后,把当前 Cell 的值重置为 0 ,base 重置为 0 。这样,当多线程调用该方法时会有问题,比如考虑第一个调用线程清空 Cell 的值,则后一个线程调用时累加的都是 0
long longValue() 等价于 sum()
public class LongAddrTest {
    private static LongAdder longAdder = new LongAdder();

    private static Integer[] arrayOne = new Integer[]{0, 1, 2, 3, 0, 5, 6, 0, 56, 0};
    private static Integer[] arrayTwo = new Integer[]{10, 1, 2, 3, 0, 5, 6, 0, 56, 0};

    @SneakyThrows
    public static void main(String[] args) {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                int size = arrayOne.length;
                for (int i = 0; i < size; i++) {
                    if (arrayOne[i].intValue() == 0) {
                        longAdder.increment();
                    }
                }
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                int size = arrayTwo.length;
                for (int i = 0; i < size; i++) {
                    if (arrayTwo[i].intValue() == 0) {
                        longAdder.increment();
                    }
                }
            }
        });

        threadOne.start();
        threadTwo.start();

        // 等待线程执行完毕
        threadOne.join();
        threadTwo.join();

        System.out.println("count 0: " + longAdder.longValue());
    }
}

LongAccumulator 类原理探究

LongAdder 类是 LongAccumulator 的一个特例,LongAccumulatorLongAdder 的功能更强大。

LongAccumulator 相比 LongAdder ,可以为累加器提供非 0 的初始值,后者只能提供默认的 0 值。另外,前者还可以指定累加规则, 如不进行累加而进行相乘,只需要在构造 LongAccumulator 时传入自定义的双目运算器即可,后者则内置累加的规则。

/**
 * LongAdder 是 LongAccumulator 的特例
 */
public class LongAccumulatorTest {
    // 等价于 LongAdder
    private static LongAccumulator longAccumulator = new LongAccumulator((left, right) -> left + right, 0);

    private static Integer[] arrayOne = new Integer[]{0, 1, 2, 3, 0, 5, 6, 0, 56, 0};
    private static Integer[] arrayTwo = new Integer[]{10, 1, 2, 3, 0, 5, 6, 0, 56, 0};

    @SneakyThrows
    public static void main(String[] args) {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                int size = arrayOne.length;
                for (int i = 0; i < size; i++) {
                    if (arrayOne[i].intValue() == 0) {
                        longAccumulator.accumulate(1);
                    }
                }
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                int size = arrayTwo.length;
                for (int i = 0; i < size; i++) {
                    if (arrayTwo[i].intValue() == 0) {
                        longAccumulator.accumulate(1);
                    }
                }
            }
        });

        threadOne.start();
        threadTwo.start();

        // 等待线程执行完毕
        threadOne.join();
        threadTwo.join();

        System.out.println("count 0: " + longAccumulator.longValue());
    }
}

Java 并发包中并发 List 源码剖析

并发包中的并发 List 只有 CopyOnWriteArrayListCopyOnWriteArrayList 是一个线程安全的 ArrayList ,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的,也就是使用了 写时复制策略 。写时复制策略会产生弱一致性问题。

img

CopyOnWriteArrayList无界 List

分析 add 方法,由于加了锁,所以整个 add 过程是个原子性操作。需要注意的是,在添加元素时,首先复制了一个快照,然后在快照上进行添加,而不是直接在原来数组上进行。

修改方法 set ,删除方法 remove ,原理同 add

分析 get 方法,当线程 x 调用 get 方法获取指定位置的元素时,分两步走,首先获取 array 数组(步骤 A ),然后通过下标访访问指定位置的元素 (步骤 B ),这是两步操作,但是在整个过程中并没有进行加锁同步。

CopyOnWriteArrayList 中迭代器是弱一致性的,所谓弱一致性是指返回迭代器后,其他线程对 list 的增删改对迭代器是不可见的,因为它们操作的是两个不同的数组。

当调用 iterator() 方法获取迭代器时,实际际上会返回一个 COWIterator 对象,COWIterator 对象的 snapshot 变量保存了当前 list 的内容,cursor 是遍历 list 时数据的下标。如果遍历期间其他线程对该 list 进行了增删改,那么 snapshot 就是快照了,因为增删改后 list 里面的数组被新数组替换了,这时候老数组被 snapshot 引用。

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

/**
 * CopyOnWriteArrayList 的弱一致性
 * 写时复制
 */
public class CopyOnWriteArrayListTest {
    private static volatile CopyOnWriteArrayList<String> arrayList = new CopyOnWriteArrayList<>();

    @SneakyThrows
    public static void main(String[] args) {
        arrayList.add("hello");
        arrayList.add("world");
        arrayList.add("welcome");
        arrayList.add("to");
        arrayList.add("beijing");

        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                arrayList.set(1,"test1");
                arrayList.remove(2);
                arrayList.remove(3);
            }
        });

        Iterator<String> itr = arrayList.iterator();

        threadOne.start();

        threadOne.join();
        // Iterator<String> itr = arrayList.iterator();

        while (itr.hasNext()) {
            System.out.println(itr.next());
        }

    }
}

Java 并发包中锁原理剖析

LockSupport 工具类

JDK 中的 rt.jar 里面的 LockSupport 是个工具类,它的主要作用是挂起和唤醒线程,该工具类是创建锁和其他同步类的基础。

LockSupport 类与每个使用它的线程都会关联一个许可证,在默认情况下调用 LockSupport 类的方法的线程是不持有许可证的。 LockSupport 是使用 Unsafe 类实现的。

Thread 类里面有个变量 volatile Object parkBlocker 用来存放 park 方法传递的 blocker 对象,也就是把 blocker 变量存放到了调用 park 方法的线程的成员变量里面。

LockSupport 中的几个主要函数:

方法 描述
void park() 如果调用 park 方法的线程已经拿到了与 LockSupport 关联的许可证,则调用 LockSupport.park() 时会马上返回,否则调用线程会被禁止参与线程的调度, 也就是会被阻塞挂起。
在其他线程调用 unpark(Thread thread) 方法并且将当前线程作为参数时 ,调用 park 方法而被阻塞的线程会返回。另外,如果其他线程调用了阻塞线程的 interrupt() 方法 ,设置了中断标志或者线程被虚假唤醒,则阻塞线程也会返回。所以在调用 park 方法时最好也使用循环条件判断方式。
需要注意的是,因调用 park() 方法而被阻塞的线程被其他线程中断而返回时并不会抛出 InterruptedException 异常。
void unpark(Thread thread) 当一个线程调用 unpark 时,如果参数 thread 线程没有持有 thread 与 LockSupport 类关联的许可证,则让 thread 线程持有。 如果 thread 之前因调用 park() 而被挂起,则调用 unpark 后,该线程会被唤醒。如果 thread 之前没有调用 park ,则 调用 unpark 方法后,再调用 park 方法,其会立刻返回。
void parkNanos(long nanos) park 方法类似,如果调用 park 方法的线程已经拿到了与 LockSupport 关联的许可证,则调用 LockSupport.parkNanos(long nanos) 方法后会马上返回。该方法的不同在于 ,如果没有拿到许可证,则调用线程会被挂起 nanos 时间后修改为自动返回。
void parkUntil(long deadline) 这个方法和 parkNanos 方法的区别是,后者是从当前算等待 nanos 秒时间,而前者是指定一个时间点

JDK 推荐我们使用带有 blocker 参数的 park 方法,并且 blocker 被设置为 this ,这样当在打印线程堆栈( jstack pid )排查问题时就能知道是哪个类被阻塞了

抽象同步队列 AQS 概述

AQS一一锁的底层支持

AbstractQueuedSynchronizer 抽象同步队列简称 AQS ,它是实现同步器的基础组件,并发包中锁的底层就是使用 AQS 实现的

img

由该图可以看到, AQS 是一个 FIFO 的双向队列,其内部通过节点 headtail 记录队首和队尾元素,队列元素的类型为 Node

其中 Node 中的 thread 变量用来存放进入 AQS 队列里面的线程;

  • Node 节点内部的 SHARED 用来标记该线程是获取共享资源时被阻塞挂起后放入 AQS 队列的;
  • EXCLUSIVE 用来标记线程是获取独占资源时被挂起后放入 AQS 队列的;
  • waitStatus 记录当前线程等待状态,可以为 CANCELLED (线程被取消了)、SIGNAL 线程需要被唤醒)、 CONDITION (线程在条件队列里面等待〉、 PROPAGATE (释放共享资源时需要通知其他节点);
  • prev 记录当前节点的前驱节点, next 记录当前节点的后继节点

AQS 维持了 的状态信息 state,可以通过 getStatesetStatecompareAndSetState 函数修改其值。对于 ReentrantLock 来说, state 可以来表示当前线程获取锁的可重入次数 ;对于读写锁 ReentrantReadWriteLock 来说,state 的高 16 位表示读状态,也就是获取该读锁的次数,低 16 位表示获取到写锁的线程的可重入次数;对于 Semaphore 来说, state 用来表示当前可用信号的个数:对于 CountDownlatch 来说,state 用来表示计数器当前的值

AQS 有个内部类 ConditionObject 用来结合锁实现线程同步。ConditionObject 可以直接访问 AQS 内部的变量,比如 state 状态值和 AQS 队列。 ConditionObject 是条件变量,每个条件变量对应一个条件队列 (单向链表队列),其用来存放调用条件变量的 await 方法后被阻塞的线程,如类图所示,这个条件队列的头、尾元素分别为 firstWaiterlastWaiter

对于 AQS 来说,线程同步的关键是对状态值 state 进行操作,根据 state 是否属于一个线程,操作 state 的方式分为独占方式和共享方式。

  • 在独占方式下获取和释放资源使用的方法为:void acquire(int arg)void acquireInterruptibly(int arg)boolean release(int arg)
  • 在共享方式下获取和释放资源的方法为: void acquireShared(int arg)void acquireSharedInterruptibly(int arg)boolean releaseShared(int arg)

使用独占方式获取的资源是与具体线程绑定的,就是说如果一个线程获取到了资源,就会标记是这个线程获取到了,其他线程再尝试操作 state 获取资源时会发现当前该资源不是自己持有的,就会在获取失败后被阻塞。比如独占锁 ReentrantLock 的实现,当一个线程获取了 ReentrantLock 的锁后,在 AQS 部会首先使用 CAS 操作把 state 状态值从 0 变为 1 ,然后设置当前锁的持有者为当前线程,当该线程再次获取锁时发现它就是锁的持有者 ,则会把状态值从 1 变为 2 ,也就是设置可重入次数,而当另外一个线程获取锁时发现自己并不是该锁的持有者就会被放入 AQS 阻塞队列后挂起。

对应共享方式的资源与具体线程是不相关的,当多个线程去请求资源时通过 CAS 方式竞争获取资源,当一个线程获取到了资源后,另外一个线程再次去获取时如果当前资源还能满足它的需要,则当前线程只需要使用 CAS 方式进行获取即可。比如 Semaphore 信号量,当一个线程通过 acquire 方法获取信号量时,会首先看当前信号量个数是否满足需要, 不满足则把当前线程放入阻塞队列,如果满足则通过自旋 CAS 获取信号量。

在独占方式下,获取与释放资源的流程
  1. 当一个线程调用 acquire(int arg) 方法获取独占资源时,会首先使用 tryAcquire 方法尝试获取资源, 具体是设置状态变量 state 的值,成功则直接返回,失败则将当前线程封装为类型为 Node.EXCLUSIVE 的 Node 节点后插入到 AQS 队列的尾部,并调用 LockSupport.park(this) 方法挂起自己

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    
  2. 当一个线程调用 release(int arg) 方法时,尝试使用 tryRelease 操作释放资源,这里是设置状态变量 state 的值,然后调用 LockSupport.unpark(thread)方法激活 AQS 队列里面被阻塞的 个线程(thread) 。被激活的线程则使用 tryAcquire 尝试,看当前状态变量 state 的值是否能满足自己的需要,满足则该线程被激活,然后继续向下运行,否则还是会被放入 AQS 队列并被挂起

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
    

需要注意的是,AQS 类并没有提供可用的 tryAcquiretryRelease 方法,正如 AQS 是锁阻塞和同步器的基础框架一样, tryAcquiretryRelease 需要由具体的子类来实现。子类在实现 tryAcquiretryRelease 要根据具体场景使用 CAS 算法尝试修改 state 状态值,成功则返 true ,否则返回 false 。子类还需要定义,在调用 acquirerelease 方法时 state 状态值的增减代表什么含义。

比如继承自 AQS 实现的独占锁 ReentrantLock,定义当 status 为 0 时表示锁空闲,为 1 时表示锁己经被占用。在重写 tryAcquire 时,在内部需要使用 CAS 算法查看当前 state 是否为 0 ,如果为 0 则使用 CAS 设置为 1 ,并设置当前锁的持有者为当前线程,而后返回 true ,如果 CAS 失败则返回 false 。

比如继承自 AQS 实现的独占锁在实现 tryRelease 时, 在内部需要使用 CAS 算法把当前 state 的值从 1 修改为 0 ,并设置当前锁的持有者为 null ,然后返回 true ,如果 CAS 失败则返回 false

在共享方式下,获取与释放资源的流程
  1. 当线程调用 acquireShared(int arg) 获取共享资源时,会首先使用 tryAcquireShared 尝试获取资源,具体是设置状态变量 state 值,成功则直接返回,失败则将当前线程封装为类型为 Node.SHARED 的 Node 节点后插入 AQS 队列的尾部,并使用 LockSupport.park(this) 方法挂起自己

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
    
  2. 当一个线程调用 releaseShared(int arg) 时会尝试使用 tryReleaseShared 操作释放资源,这里是设置状态变量 state 值,然后使用 LockSupport.unpark(thread) 激活 AQS 队列里面被阻塞的一个线程 (thread) 。被激活的线程则使用 tryReleaseShared 查看当前状态变量 state 的值是否能满足自 己的 需要,满足则该线程被激活,然后继续向下运行,否则还是会被放入 AQS 队列并被挂起

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }
    

同样需要注意的是, AQS 类并没有提供可用的 tryAcquireSharedtryReleaseShared 方法,正如 AQS 是锁阻塞和同步器的基础框架一样, tryAcquireSharedtryReleaseShared 需要由具体的子类来实现。子类在实现 tryAcquireSharedtryReleaseShared 时要根据具体场景使用 CAS 算法尝试修改 state 状态值,成功则返回 true ,否则返回 false 。

比如继承自 AQS 实现的读写锁 ReentrantReadWriteLock 里面的读锁在重写 tryAcquireShared 时,首先查看写锁是否被其他线程持有,如果是则直接返回 false ,否则使用 CAS 递增 state 的高 16 位(在 ReentrantReadWriteLock 中, state 的高 16 位为获取读锁的次数)

比如继承自 AQS 实现的读写锁 ReentrantReadWriteLock 里面的读锁在重写 tryReleaseShared 时,在内部需要使用 CAS 算法把当前 state 值的高 16 位减 1 ,然后返回 true ,如果 CAS 失败则返回 false

基于 AQS 实现的锁除了需要重写上面介绍的方法外,还需要重写 isHeldExclusively 方法,来判断锁是被当前线程独占还是被共享

方法名中是否存在 Interruptibly 关键字的区别:

  • 不带 Interruptibly 关键字的方法的意思是不对中断进行响应,也就是线程在调用不带 Interruptibly 关键字的方法获取资源时或者获取资源失败被挂起时,其他线程中断了该线程,那么该线程不会因为被中断而抛出异常,它还是继续获取资源或者被挂起,也就是说不对中断进行响应,忽略中断
  • Interruptibly 关键字的方法要对中断进行响应,也就是线程在调用带 Interruptibly 关键字的方法获取资源时或者获取资源失败被挂起时,其他线程中断了该线程,那么该线程会抛出 InterruptedException 异常而返回
如何维护 AQS 提供的队列

主要看入队操作:当一个线程获取锁失败后该线程会被转换为 Node 节点,然后就会使用 enq(final Node node) 方法将该节点插入到 AQS 的阻塞队列

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

img

AQS一一条件变量的支持

notifywait ,是配合 synchronized 内置锁实现线程间同步的基础设施,条件变量的 signalawait 方法也是用来配合锁 (使 AQS 实现的锁)实现线程间同步基础设施。它们的不同在于, synchronized 同时只能与一个共享变量的 notifywait 方法实现同步,而 AQS 的一个锁可以对应多个条件变量。在调用共享变量的 notifywait 方法前必须先获取该共享变量的内置锁,同理,在调用条件变量的 signalawait方法前也必须先获取条件变量对应的锁。

ConditionObject 是 AQS 的内部类 ,可以访问 AQS 内部的变量(例如状态变量 state )和方法。在每个条件变量内部都维护了一个条件队列,用来存放调用条件变量的 await 方法时被阻塞的线程。底层使用的是相同对象,但是注意这个条件队列和 AQS 队列不是一回事。

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

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

img

/**
 * 基于 AQS 实现的不可重入的独占锁
 */
public class NonReentrantLock implements Lock, Serializable {

    private static class Sync extends AbstractQueuedSynchronizer {

        /**
         * 是否锁已经被持有
         *
         * @return
         */
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        /**
         * 如果 state 为 0 ,则尝试获取锁
         *
         * @param acquires
         * @return
         */
        @Override
        protected boolean tryAcquire(int acquires) {
            assert acquires == 1;
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        /**
         * 尝试释放锁,设置 state 为 0
         *
         * @param releases
         * @return
         */
        @Override
        protected boolean tryRelease(int releases) {
            assert releases == 1;
            if (getState() == 0) {
                throw new IllegalMonitorStateException();
            }
            setExclusiveOwnerThread(null);
            setState(0);

            return true;
        }

        /**
         * 提供条件变量接口
         *
         * @return
         */
        Condition newCondition() {
            return new ConditionObject();
        }
    }

    // 创建一个 Sync 来做具体的工作
    private final Sync sync = new Sync();

    public boolean isLocked() {
        return sync.isHeldExclusively();
    }

    @Override
    public void lock() {
        sync.acquire(1);
    }


    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

独占锁 ReentrantLock 的原理

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

img

ReentrantLock 最终还是使用 AQS 来实现的,并且根据构造器参数来决定内部是一个公平还是非公平锁,默认是非公平锁

其中 Sync 类直接继承自 AQS,它的子类 NofairSyncFairSync 分别实现了获取锁的非公平与公平策略。

在这里, AQS 的 state 状态值表示线程获取该锁的可重入次数,在默认情况下, state 的值为 0 表示当前锁没有被任何线程持有。当一个线程第一次获取该锁时会尝试使用 CAS 设置 state 的值为 1 ,如果 CAS 成功则当前线程获取了该锁,然后记录该锁的持有者为当前线程。在该线程没有释放锁的情况下第二次获取该锁后 ,状态值被设置为 2 ,这就是可重入次数。在该线程释放该锁时,会尝试使用 CAS 让状态值减 1 ,如果减 1 后状态值为 0 ,则当前线程释放该锁。

tryLock() 方法尝试获取锁,如果当前该锁没有被其他线程持有,则当前线程获取该锁井返回 true ,
否则返回 false 。注意 ,该方法不会引起当前线程阻塞。使用的是非公平策略。

读写锁 ReentrantReadWriteLock 的原理

ReentrantLock 是独占锁,某时只有一个线程可以获取该锁,而实际中会有 写少读多 的场景,显然 ReentrantLock 满足不了这个需求,所以 ReentrantReadWriteLock 应运而生,ReentrantReadWriteLock 采用 读写分离 的策略,允许多个线程可以同时获取读锁。

img

读写锁的内部维护了一个 ReadLock 和一个 WriteLock ,它们依赖 Sync 实现具体功能。而 Sync 继承自 AQS ,并且也提供了公平和非公平的实现。AQS 中只维护了一个 state 状态,而 ReentrantReadWriteLock 需要维护读状态和写状态, 一个 state 怎么表示写和读两种状态呢?ReentrantReadWriteLock 巧妙地使用 state 的高 16 位表示读状态,也就是获取到读锁的次数;使用低 16 位表示获取到写锁的线程的可重入次数

写锁的获取与释放

ReentrantReadWriteLock 写锁使用 WriteLock 来实现

方法 描述
lock() 写锁是个独占锁,同时只有一个线程可以获取该锁。如果当前没有线程获取到读锁和写锁, 则当前线程可以获取到写锁然后返回。如果当前已经有线程获取到读锁和写锁,则当前请求写锁的线程会被阻塞挂起外。另外,写锁是可重入锁,如果当前线程已经获取了该锁,再次获取只是简单地把可重入次数加 1 后直接返回
lockInterruptibly() 类似于 lock 方法,它的不同之处在于,它会对中断进行响应,也就是当其他线程调用了该线程的 interrupt 方法中断了当前线程时,当前线程会抛出异常 InterruptedException 异常。
tryLock() 尝试获取写锁,如果当前没有其他线程持有写锁或者读锁,则当前线程获取写锁会成功,然后返回 true 。如果当前已经有其他线程持有写锁或者读锁则该方法直接返回 false ,且当前线程并不会被阻塞。如果当前线程已经持有了该写锁则简单增加 AQS 的状态值后直接返回 true
这里使用的是非公平策略
tryLock(long timeout, TimeUnit unit) tryAcquire() 的不同之处在于,多了超时时间参数,如果尝试获取写锁失败则会把当前线程挂起指定时间,待超时时间到后当前线程被激活,如果还是没有获取到写锁则返回 false 。另外,该方法会对中断进行响应,也就是当其他线程调用了该线程的 interrupt 方法中断了当前线程时,当前线程会抛出 InterruptedException 异常。
unlock() 尝试释放锁,如果当前线程持有该锁,调用该方法会让该线程对该线程持有的 AQS 状态值减 1 ,如果减去 1 后当前状态值为 0 则当前线程会释放该锁,否则仅仅减 1 而已。如果当前线程没有持有该锁而调用了该方法则会抛出IllegalMonitorStateException 异常
读锁的获取与释放

ReentrantReadWriteLock 中的读锁是使用 ReadLock 来实现的

方法 描述
lock() 获取读锁,如果当前没有其他线程持有写锁,则当前线程可以获取读锁, AQS 的状态值 state 的高 16 位的值会增加 1 ,然后方法返回。否则如果其他一个线程持有写锁,则当前线程会被阻塞。如果当前要获取读锁的线程已经持有了写锁,则也可以获取读锁。但是需要注意,当一个线程先获取了写锁,然后获取了读锁处理事情完毕后,要记得把读锁和写锁都释放掉,不能只释放写锁。
lockInterruptibly() 类似于 lock 方法,它的不同之处在于,它会对中断进行响应,也就是当其他线程调用了该线程的 interrupt 方法中断了当前线程时,当前线程会抛出异常 InterruptedException 异常。
tryLock() 尝试获取读锁,如果当前没有其他线程持有写锁,则当前线程获取读锁会成功,然后返回 true 。如果当前已经有其他线程持有写锁则该方法直接返回 false ,但当前线程并不会被阻塞。如果当前线程已经持有了该读锁则简单增加 AQS 的状态值高 16 位后直接返回 true 。
tryLock(long timeout, TimeUnit unit) tryAcquire() 的不同之处在于,多了超时时间参数,如果尝试获取读锁失败则会把当前线程挂起指定时间,待超时时间到后当前线程被激活,如果此时还是没有获取到读锁则返回 false 。另外,该方法会对中断进行响应,也就是当其他线程调用了该线程的 interrupt 方法中断了当前线程时,当前线程会抛出 InterruptedException 异常。
unlock() 释放锁
案例介绍

读多写少的情况下使用

public class ReentrantReadWriteLockList {
    // 线程不安全的 List
    private ArrayList<String> array = new ArrayList<>();

    // 独占锁
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();

    /**
     * 添加元素
     *
     * @param e
     */
    public void add(String e) {
        writeLock.lock();
        try {
            array.add(e);
        } finally {
            writeLock.unlock();
        }
    }

    /**
     * 删除元素
     *
     * @param e
     */
    public void remove(String e) {
        writeLock.lock();
        try {
            array.remove(e);
        } finally {
            writeLock.unlock();
        }
    }

    /**
     * 获取元素
     *
     * @param index
     */
    public String get(int index) {
        readLock.lock();
        try {
            return array.get(index);
        } finally {
            readLock.unlock();
        }
    }
}

StampedLock 锁

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

StampedLock 提供的三种读写模式的锁分别如下:

描述
写锁 writeLock 是一个排它锁或者独占锁,某时只有一个线程可以获取该锁,当一个线程获取该锁后,其他请求读锁和写锁的线程必须等待,这类似于 ReentrantReadWriteLock 的写锁(不同的是这里的写锁是不可重入锁);当目前没有线程持有读锁或者写锁时才可以获取到该锁。请求该锁成功后会返回一个 stamp 变量用来表示该锁的版本,当释放该锁时需要调用 unlockWrite 方法并传递获取锁时的 stamp 参数。并且它提供了非阻塞的 tryWriteLock 方法。
悲观读锁 readLock 是一个共享锁,在没有线程获取独占写锁的情况下,多个线程可以同时获取该锁。如果已经有线程持有写锁,则其他线程请求获取该读锁会被阻塞,这类似于 ReentrantReadWriteLock 的读锁(不同的是这里的读锁是不可重入锁)。这里说的悲观是指在具体操作数据前其会悲观地认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑。请求该锁成功后会返回一个 stamp 变量用来表示该锁的版本,当释放该锁时需要调用 unlockRead 方法并传递 stamp 参数。并且它提供了非阻塞的 tryReadlock 方法。
乐观读锁 tryOptimisticRead 它是相对于悲观锁来说的,在操作数据前并没有通过 CAS 设置锁的状态,仅仅通过位运算测试。如果当前没有线程持有写锁,则简单地返回一个非 0 的 stamp 版本信息。获取该 stamp 后在具体操作数据前还需要调用 validate 方法验证该 stamp 是否已经不可用,也就是看当调用 tryOptimisticRead 返回 stamp 后到当前时间期间是否有其他线程持有了写锁,如果是则 validate 会返回 0 ,否则就可以使用该 stamp 版本的锁对数据进行操作。由于 tryOptimisticRead并没有使用 CAS 设置锁状态,所以不需要显式地释放该锁。该锁的一个特点是适用于读多写少的场景,因为获取读锁只是使用位操作进行检验,不涉及 CAS 操作,所以效率会高很多,但是同时由于没有使用真正的锁,在保证数据一致性上需要复制份要操作的变量到方法栈,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。

StampedLock 还支持这三种锁在一定条件下进行相互转换。例如 long tryConvertToWriteLock(long stamp) 期望把 stamp 标示的锁升级为写锁,这个函数会在下面几种情况下返回一个有效的 stamp (也就是晋升写锁成功):

  • 当前锁已经是写锁模式了
  • 当前锁处于读锁模式,并且没有其他线程是读锁模式
  • 当前处于乐观读模式,并且当前写锁可用。

另外, StampedLock 的读写锁都是不可重入锁,所以在获取锁后释放锁前不应该再调用会获取锁的操作,以避免造成调用线程被阻塞。当多个线程同时尝试获取读锁和写锁时,谁先获取锁没有一定的规则,完全都是尽力而为,是随机的。并且该锁不是直接实现 LockReadWriteLock 接口,而是其在内部自己维护了一个双向阻塞队列。

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

img

案例介绍
public class StampedLockPoint {
    // 成员变量
    private double x, y;

    // 锁实例
    private StampedLock s1 = new StampedLock();

    // 排他锁 - 写锁
    public void move(double deltaX, double deltaY) {
        long stamp = s1.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            s1.unlockWrite(stamp);
        }
    }

    // 乐观读锁
    public double distanceFromOrigin() {
        long stamp = s1.tryOptimisticRead();
        double currentX = x, currentY = y;
        if (!s1.validate(stamp)) {
            stamp = s1.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                s1.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    // 使用悲观锁获取读锁,并尝试转换为写锁
    public void moveIfAtOrigin(double newX, double newY) {
        long stamp = s1.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                long ws = s1.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    s1.unlockRead(stamp);
                    stamp = s1.writeLock();
                }
            }
        } finally {
            s1.unlock(stamp);
        }
    }
}

Java 并发包中并发队列原理剖析

JDK 中提供了一系列场景的并发安全队列。总的来说,按照实现方式的不同可分为阻塞队列和非阻塞队列,前者使用锁实现,而后者则使用 CAS 非阻塞算法实现。

ConcurrentLinkedQueue 原理探究

ConcurrentLinkedQueue 是线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队和出队操作使用 CAS 来实现线程安全。

ConcurrentLinkedQueue 的底层使用单向链表数据结构来保存队列元素,每个元素被包装成一个 Node 节点。队列是靠头、尾节点来维护的,创建队列时头、尾节点指向一个 itemnull 的哨兵节点。第一次执行 peek 或者 first 操作时会把 head 指向第一个真正的队列元素。由于使用非阻塞 CAS 算法,没有加锁,所以在计算 size 时有可能进行了 offerpoll 或者 remove 操作,导致计算的元素个数不精确,所以在并发情况下 size 函数不是很有用。

如图所示,入队、出队都是操作使用 volatile 修饰的 tailhead 节点,要保证在多线程下出入队线程安全,只需要保证这两个 Node 操作的可见性和原子性即可。由于 volatile 本身可以保证可见性,所以只需要保证对两个变量操作的原子性即可。

img

offer 操作是在 tail 后面添加元素,也就是调用 tail.casNext 方法,而这个方法使用的是 CAS 操作,只有一个线程会成功,然后失败的线程会循环,重新获取 tail ,再执行 casNext 方法。 poll 操作也通过类似 CAS 的算法保证出队时移除节点操作的原子性。

LinkedBlockingQueue 原理探究

使用独占锁实现的阻塞队列,默认队列容量为 Integer.MAX_VALUE ,用户也可以自己指定容量,所以从一定程度上可以说 LinkedBlockingQueue 是有界阻塞队列。

LinkedBlockingQueue 的内部是通过单向链表实现的,使用头、尾节点来进行入队和出队操作,也就是入队操作都是对尾节点进行操作,出队操作都是对头节点进行操作。

如图所示,对头、尾节点的操作分别使用了单独的独占锁从而保证了原子性,所以出队和入队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。

ArrayBlockingQueue 原理探究

使用有界数组方式实现的阻塞队列 ArrayBlockingQueue

如图所示, ArrayBlockingQueue 通过使用全局独占锁实现了同时只能有一个线程进行入队或者出队操作,这个锁的粒度比较大,有点类似于在方法上添加 synchronized 的意思。其中 offerpoll 操作通过简单的加锁进行入队、出队操作,而 puttake 操作则使用条件变量实现了,如果队列满则等待,如果队列空则等待,然后分别在出队和入队操作中发送信号激活等待线程实现同步。另外,相比 LinkedBlockingQueueArrayBlockingQueuesize 操作的结果是精确的,因为计算前加了全局锁。

img

PriorityBlockingQueue 原理探究

PriorityBlockingQueue 是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的 compareTo 方法提供比较规则,如果你需要自定义比较规则则可以自定义 comparators

PriorityBlockingQueue 队列在内部使用二叉树堆维护元素优先级,使用数组作为元素存储的数据结构,这个数组是可扩容的。当当前元素个数 >= 最大容量时会通过 CAS 算法扩容,出队时始终保证出队的元素是堆树的根节点,而不是在队列里面停留时间最长的元素。使用元素的 compareTo 方法提供默认的元素优先级比较规则,用户可以自定义优先级的比较规则。

如图所示, PriorityBlockingQueue 类似于 ArrayBlockingQueue ,在内部使用一个独占锁来控制同时只有一个线程可以进行入队和出队操作。另外,前者只使用了一个 notEmpty 条件变量而没有使用 notFull ,这是因为前者是无界队列,执行 put 操作时永远不会处于 await 状态,所以也不需要被唤醒。而 take 方法是阻塞方法,并且是可被中断的。当需要存放有优先级的元素时该队列比较有用。

img

案例介绍

把具有优先级的任务放入队列,然后从队列里面逐个获取优先级最高的任务来执行

public class TestPriorityBlockingQueue {
    @Data
    static class Task implements Comparable<Task> {
        private int priority = 0;

        private String taskName;


        @Override
        public int compareTo(Task o) {
            if (this.priority >= o.getPriority()) {
                return 1;
            } else {
                return -1;
            }
        }

        public void doSomeThing() {
            System.out.println(taskName + " : " + priority);
        }
    }

    public static void main(String[] args) {
        PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
        Random random = new Random();
        for (int i = 0; i < 10; i++) {
            Task task = new Task();
            task.setPriority(random.nextInt(10));
            task.setTaskName("taskName " + i);
            queue.offer(task);
        }

        while (!queue.isEmpty()) {
            Task task = queue.poll();
            if (null != task) {
                task.doSomeThing();
            }
        }
    }

}

DelayQueue 原理探究

DelayQueue 并发队列是一个无界阻塞延迟队列,队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。

由该图可知, DelayQueue 内部使用 PriorityQueue 存放数据,使用 ReentrantLock 实现线程同步。另外,队列里面的元素要实现 Delayed 接口,由于每个元素都有一个过期时间,所以要实现获知当前元素还剩下多少时间就过期了的接口,由于内部使用优先级队列来实现,所以要实现元素之间相互比较的接口。

img

Java 并发包中线程池 ThreadPoolExecutor 原理探究

线程池主要解决两个问题:一是当执行大量异步任务时线程池能够提供较好的性能在不使用线程池时,每当需要执行异步任务时直接 new 一个线程来运行,而线程的创建和销毁是需要开销的。线程池里面的线程是可复用的,不需要每次执行异步仼务时都重新创建和销毁线程。二是线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。每个 ThreadPoolExecutor 也保留了一些基本的统计数据,比如当前线程池完成的任务数目等。

另外,线程池也提供了许多可调参数和可扩展性接口,以满足不同情境的需要,程序员可以使用更方便的 Executors 的工厂方法,比如 newCachedThreadPool (线程池线程个数最多可达 Integer.MAX_VALUE ,线程自动回收)、 newFixedThreadPool (固定大小的线程池)和 newSingleThreadExecutor (单个线程)等来创建线程池,当然用户还可以自定义。

Executors 其实是个工具类,里面提供了很多静态方法,这些方法根据用户选择返回不同的线程池实例。 ThreadPoolExecutor 继承了 AbstractExecutorService ,成员变量 ctl 是一个 Integer 的原子变量,用来记录 线程池状态线程池中线程个数 ,类似于 ReentrantReadWriteLock 使用一个变量来保存两种信息。

线程池状态含义如下:

  • RUNNING :接受新任务并且处理阻塞队列里的任务
  • SHUTDOWN :拒绝新任务但是处理阻塞队列里的任务
  • STOP :拒绝新任务并且抛弃阻塞队列里的任务,同时会中断正在处理的任务
  • TIDYING :所有任务都执行完(包含阻塞队列里面的任务)后当前线程池活动线程数为 0 ,将要调用 terminated 方法
  • TERMINATED :终止状态。 terminated 方法调用完成以后的状态

线程池状态转换列举如下:

  • RUNNINGSHUTDOWN :显式调用 shutdown 方法,或者隐式调用了 finalize 方法里面的 shutdown 方法
  • RUNNINGSHUTDOWNSTOP :显式调用 shutdownNow 方法时
  • SHUTDOWNTIDYING :当线程池和任务队列都为空时
  • STOPTIDYING :当线程池为空时
  • TIDYINGTERMINATED :当 terminated() hook 方法执行完成时

线程池参数如下:

  1. corePoolSize :线程池核心线程个数。
  2. workQueue :用于保存等待执行的任务的阻塞队列,比如基于数组的有界 ArrayBlockingQueue 、基于链表的无界 LinkedBlockingQueue 、最多只有一个元素的同步队列 SynchronousQueue 及优先级队列 PriorityBlockingQueue
  3. maximunPoolSize :线程池最大线程数量
  4. ThreadFactory :创建线程的工厂
  5. RejectedExecutionHandler :饱和策略,当队列满并且线程个数达到 maximunPoolSize 后采取的策略,比如 AbortPolicy (抛出异常)、 CallerRunsPolicy (使用调用者所在线程来运行任务)、 DiscardOldestPolicy (调用 poll 丢弃一个任务,执行当前任务)及 DiscardPolicy (默默丢弃,不抛出异常)
  6. keeyAliveTime :存活时间。如果当前线程池中的线程数量比核心线程数量多,并且是闲置状态,则这些闲置的线程能存活的最大时间。
  7. TimeUnit :存活时间的时间单位

线程池类型如下:

  • newFixedThreadPool :创建一个核心线程个数和最大线程个数都为 nThreads 的线程池,并且阻塞队列长度为 Integer.MAX_VALUEkeeyAliveTime = 0 说明只要线程个数比核心线程个数多并且当前空闲则回收。
  • newSingleThreadExecutor :创建一个核心线程个数和最大线程个数都为 1 的线程池并且阻塞队列长度为 Integer.MAX_VALUEkeeyAliveTime = 0 说明只要线程个数比核心线程个数多并且当前空闲则回收。
  • newCachedThreadPool :创建一个按需创建线程的线程池,初始线程个数为 0 ,最多线程个数为 Integer.MAX_VALUE,并且阻塞队列为同步队列。 keeyAliveTime = 60 说明只要当前线程在 60s 内空闲则回收。这个类型的特殊之处在于,加入同步队列的任务会被马上执行,同步队列里面最多只有一个任务。
方法 描述
execute 提交任务到线程池进行执行
shutdown 调用 shutdown 方法后,线程池就不会再接受新的任务了,但是工作队列里面的任务还是要执行的。该方法会立刻返回,并不等待队列任务完成再返回。
shutdownNow 调用 shutdownNow 方法后,线程池就不会再接受新的任务了,并且会丢弃工作队列里面的任务,正在执行的任务会被中断,该方法会立刻返回,并不等待激活的任务执行完成。返回值为这时候队列里面被丢弃的任务列表。
awaitTermination 当线程调用 awaitTermination方法后,当前线程会被阻塞,直到线程池状态变为 TERMINATED 才返回,或者等待时间超时才返回

线程池巧妙地使用一个 Integer 类型的原子变量来记录线程池状态和线程池中的线程个数。通过线程池状态来控制任务的执行,每个 Worker 线程可以处理多个任务。线程池通过线程的复用减少了线程创建和销毁的开销。

Java 并发包中 ScheduledThreadPoolExecutor 原理探究

前面讲解了 Java 中线程池 ThreadPoolExecutor 的原理, ThreadPoolExecutor只是 Executors 工具类的一部分功能。下面来介绍另外一部分功能,也就是 ScheduledThreadPoolExecutor 的实现,这是一个可以在指定一定延迟时间后或者定时进行任务调度执行的线程池。

ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService 接口。线程池队列是 DelayedWorkQueue ,其和 DelayedQueue 类似,是一个延迟队列。

ScheduledFutureTask 是具有返回值的任务,继承自 FutureTaskFutureTask 的内部有个变量 state 用来表示任务的状态,一开始状态为 NEW ,所有状态为:

img

可能的任务状态转换路径为:

img

ScheduledFutureTask 内部还有一个变量 period 用来表示任务的类型,任务类型如下:

  • period = 0 ,说明当前任务是一次性的,执行完毕后就退出了。
  • period 为负数,说明当前任务为 fixed-delay 任务,是固定延迟的定时可重复执行任务。
  • period 为正数,说明当前任务为 fixed-rate 任务,是固定频率的定时可重复执行任务。
方法 描述
schedule(Callable<V> callable, long delay, TimeUnit unit);
schedule(Runnable command, long delay, TimeUnit unit)
该方法的作用是提交一个延迟执行的任务,任务从提交时间算起延迟单位为 unitdelay 时间后开始执行。提交的任务不是周期性任务,任务只会执行一次
scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) 该方法的作用是,当任务执行完毕后,让其延迟固定时间后再次运行( fixed-delay 任务)。其中 initialDelay 表示提交任务后延迟多少时间开始执行任务 commanddelay 表示当任务执行完毕后延长多少时间后再次运行 command 任务, unitinitialDelaydelay 的时间单位。任务会一直重复运行直到任务运行中抛出了异常,被取消了,或者关闭了线程池。
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) 该方法相对起始时间点以固定频率调用指定的任务( fixed-rate 任务)。当把任务提交到线程池并延迟 initialDelay 时间(时间单位为 unit )后开始执行任务 command 。然后从 initialDelay + period 时间点再次执行,而后在 initialDelay + 2 * period 时间点再次执行,循环往复,直到抛出异常或者调用了任务的 cancel 方法取消了任务,或者关闭了线程池。

相对于 fixed-delay 任务来说, fixed-rate 方式执行规则为,时间为 initialDelay+n*period 时启动任务,但是如果当前任务还没有执行完,下一次要执行任务的时间到了则不会并发执行,下次要执行的任务会延迟执行,要等到当前任务执行完毕后再执行。

如图所示,其内部使用 DelayQueue 来存放具体任务。任务分为三种,其中一次性执行任务执行完毕就结束了 fixed-delay 任务保证同一个任务在多次执行之间间隔固定时间, fixed-rate 任务保证按照固定的频率执行。任务类型使用 period 的值来区分。

img

Java 并发包中线程同步器原理剖析

CountDownLatch 原理剖析

在日常开发中经常会遇到需要在主线程中开启多个线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景。在 CountDownLatch 出现之前般都使用线程的 join 方法来实现这一点,但是 join 方法不够灵活,不能够满足不同场景的需要,所以 JDK 开发组提供了 CountDownLatch 这个类。

这里总结下 CountDownLatchjoin 方法的区别。一个区别是,调用一个子线程的 join() 方法后,该线程会一直被阻塞直到子线程运行完毕,而 CountDownLatch 则使用计数器来允许子线程运行完毕或者在运行中递减计数,也就是 CountDownLatch 可以在子线程运行的任何时候让 await 方法返回而不一定必须等到线程结束。另外,使用线程池来管理线程时一般都是直接添加 Runnable 到线程池,这时候就没有办法再调用线程的 join 方法了,就是说 CountDownLatch 相比 join 方法让我们对线程同步有更灵活的控制。

方法 描述
await
await(long timeout, TimeUnit unit)
当线程调用 CountDownLatch 对象的 await 方法后,当前线程会被阻塞,直到下面的情况之一发生才会返回:
当所有线程都调用了 CountDownLatch 对象的 countDown 方法后,也就是计数器的值为 0 时;
设置的 timeout 时间到了,因为超时而返回 false
其他线程调用了当前线程的 interrupt() 方法中断了当前线程,当前线程就会抛出 InterruptedException 异常,然后返回。
countDown 线程调用该方法后,计数器的值递减,递减后如果计数器值为 0 则唤醒所有因调用 await 方法而被阻塞的线程,否则什么都不做。
getCount 获取当前计数器的值,也就是 AQS 的 state 的值,一般在测试时使用该方法。

本节首先介绍了 CountDownLatch 的使用,相比使用 join 方法来实现线程间同步前者更具有灵活性和方便性。另外还介绍了 CountDownLatch 的原理, CountDownLatch 是使用 AQS 实现的。使用 AQS 的状态变量来存放计数器的值。首先在初始化 CountDownLatch 时设置状态值(计数器值),当多个线程调用 countDown 方法时实际是原子性递减 AQS 的状态值。当线程调用 await 方法后当前线程会被放入 AQS 的阻塞队列等待计数器为 0 再返回。其他线程调用 countDown 方法让计数器值递减 1 ,当计数器值变为 0 时,当前线程还要调用 AQS 的 doReleaseShared 方法来激活由于调用 await 方法而被阻塞的线程

public class JoinCountDownLatch2 {
    private static volatile CountDownLatch countDownLatch = new CountDownLatch(2);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }

                System.out.println("child threadOne over!");
            }
        });

        executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }

                System.out.println("child threadTwo over!");
            }
        });


        System.out.println("wait all child thread over");

        // 等待子线程执行完毕,返回
        countDownLatch.await();

        System.out.println("all child thread over!");

        executorService.shutdown();

    }
}

回环屏障 CyclicBarrier 原理探究

上节介绍的 CountDownLatch 在解决多个线程同步方面相对于调用线程的 join 方法已经有了不少优化,但是 CountDownLatch 的计数器是一次性的,也就是等到计数器值变为 0 后,再调用 CountDownLatchawaitcountDown 方法都会立刻返回,这就起不到线程同步的效果了。所以为了满足计数器可以重置的需要, JDK 开发组提供了 CyclicBarrier 类,并且 CyclicBarrier 类的功能并不限于 CountDownLatch 的功能。从字面意思理解, CyclicBarrier 是回环屏障的意思,它可以让一组线程全部达到一个状态后再全部同时执行。这里之所以叫作回环是因为当所有等待线程执行完毕,并重置 CyclicBarrier 的状态后它可以被重用。之所以叫作屏障是因为线程调用 await 方法后就会被阻塞,这个阻塞点就称为屏障点,等所有线程都调用了 await 方法后,线程们就会冲破屏障,继续向下运行。

CyclicBarrier 适用场景:

  • 使用两个线程去执行一个被分解的任务 A ,当两个线程把自己的任务都执行完毕后再对它们的结果进行汇总处理
  • 假设一个任务由阶段 1 、阶段 2 和阶段 3 组成,每个线程要串行地执行阶段 1 、阶段 2 和阶段 3 ,当多个线程执行该任务时,必须要保证所有线程的阶段 1 全部完成后才能进入阶段 2 执行,当所有线程的阶段 2 全部完成后才能进入阶段 3 执行
方法 描述
await
await(long timeout, TimeUnit unit)
当前线程调用 CyclicBarrier 的该方法时会被阻塞,直到满足下面条件之一才会返回:
parties 个线程都调用了 await 方法,也就是线程都到了屏障点,这时候返回 true
设置的超时时间到了后返回 false
其他线程调用当前线程的 interrupt() 方法中断了当前线程,当前线程会抛出 InterruptedException 异常然后返回;
与当前屏障点关联的 Generation 对象的 broken 标志被设置为 true 时,会抛出 BrokenBarrierException 异常,然后返回。

本节首先通过案例说明了 CyclicBarrierCountDownLatch 的不同在于,前者是可以复用的,并且前者特别适合分段任务有序执行的场景。然后分析了 CyclicBarrier ,其通过独占锁 ReentrantLock 实现计数器原子性更新,并使用条件变量队列来实现线程同步。

@Slf4j
public class CycleBarrierTest2 {
    private static CyclicBarrier cyclicBarrier = new CyclicBarrier(2);

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                log.info("1 step 1");
                cyclicBarrier.await();

                log.info("1 step 2");
                cyclicBarrier.await();

                log.info("1 step 3");
            }
        });

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                log.info("2 step 1");
                cyclicBarrier.await();

                log.info("2 step 2");
                cyclicBarrier.await();

                log.info("2 step 3");
            }
        });

        executorService.shutdown();
    }
}

信号量 Semaphore 原理探究

Semaphore 信号量也是 Java 中的一个同步器,与 CountDownLatchCyclicBarrier 不同的是,它内部的计数器是递增的,并且在一开始初始化 Semaphore 时可以指定一个初始值,但是并不需要知道需要同步的线程个数,而是在需要同步的地方调用 acquire 方法时指定需要同步的线程个数。

如果构造 Semaphore 时传递的参数为 M,并在 M 个线程 中调用了该信号量 release 方法,那么在调用 acquire 使 M 个线程同步时传递的参数应该是 M+N

方法 描述
acquire() 当前线程调用该方法的目的是希望获取一个信号量资源。如果当前信号量个数大于 0 ,则当前信号量的计数会减 1 ,然后该方法直接返回。否则如果当前信号量个数等于 0 ,则当前线程会被放入 AQS 的阻塞队列。当其他线程调用了当前线程的 interrupt() 方法中断了当前线程时,则当前线程会抛出 InterruptedException 异常返回。
acquire(int permits) 获取 permits 个信号量资源
acquireUninterruptibly()
acquireUninterruptibly(int permits)
对中断不响应
release()
release(int permits)
该方法的作用是把当前 Semaphore 对象的信号量值增加 1 ( permits ),如果当前有线程因为调用 aquire 方法被阻塞而被放入了 AQS 的阻塞队列,则会根据公平策略选择一个信号量个数能被满足的线程进行激活,激活的线程会尝试获取刚增加的信号量

本节首先通过案例介绍了 Semaphore 的使用方法, Semaphore 完全可以达到 CountDownLatch 的效果,但是 Semaphore 的计数器是不可以自动重置的,不过通过变相地改变 aquire 方法的参数还是可以实现 CycleBarrier 的功能的。 Semaphore 也是使用 AQS 实现的,并且获取信号量时有公平策略和非公平策略之分。

/**
 * Semaphore 模拟 CyclicBarrier 复用
 */
public class SemaphoreTest2 {
    private static Semaphore semaphore = new Semaphore(0);

    @SneakyThrows
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                System.out.println("1 A begin");
                Thread.sleep(1000);
                System.out.println("1 A over");
                semaphore.release();
            }
        });

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                System.out.println("2 A begin");
                Thread.sleep(2000);
                System.out.println("2 A over");
                semaphore.release();
            }
        });

        // 获取到 2 个信号量后返回,信号量归零
        semaphore.acquire(2);

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                System.out.println("1 B begin");
                Thread.sleep(1000);
                System.out.println("1 B over");
                semaphore.release();
            }
        });

        executorService.submit(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                System.out.println("2 B begin");
                Thread.sleep(2000);
                System.out.println("2 B over");
                semaphore.release();
            }
        });

        semaphore.acquire(2);

        System.out.println("main over");


        executorService.shutdown();
    }
}

ArrayBlockingQueue 的使用

Logback 异步日志打印中 ArrayBlockingQueue 的使用。

img

由图可知,其实 Logback 的异步日志模型是一个多生产者-单消费者模型,其通过使用队列把同步日志打印转换为了异步,业务线程只需要通过调用异步 appender 把日志任务放入日志队列,而日志线程则负责使用同步的 appender 进行具体的日志打印。日志打印线程只需要负责生产日志并将其放入队列,而不需要关心消费线程何时把日志具体写入磁盘。

ch.qos.logback.classic.AsyncAppender 是实现异步日志的关键。

Tomcat 的 NioEndPoint 中 ConcurrentLinkedQueue 的使用

Tomcat 使用队列把接受请求与处理请求操作进行解耦,实现异步处理。其实 Tomcat 中 NioEndPoint 中的每个 Poller 里面都维护一个 ConcurrentLinkedQueue ,用来缓存请求任务,其本身也是一个多生产者 - 单消费者模型。

并发组件 ConcurrentHashMap 使用注意事项

putputIfAbsent 方法

/**
 * ConcurrentHashMap#put 使用时可能存在的问题
 */
public class ConcurrentHashMapTest {
    private static ConcurrentHashMap<String, List<String>> map = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                List<String> list1 = new ArrayList<>();
                list1.add("device11");
                list1.add("device12");
                List<String> oldList = map.putIfAbsent("topic1", list1);
                /*if (oldList != null) {
                    oldList.addAll(list1);
                    list1 = oldList;
                }*/
                map.put("topic1", list1);
                System.out.println("1 :: " + map);
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                List<String> list1 = new ArrayList<>();
                list1.add("device21");
                list1.add("device22");
                List<String> oldList = map.putIfAbsent("topic2", list1);
                /*if (oldList != null) {
                    oldList.addAll(list1);
                    list1 = oldList;
                }*/
                map.put("topic2", list1);
                System.out.println("2 :: " + map);
            }
        });


        Thread threadThree = new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                Thread.sleep(100);

                List<String> list1 = new ArrayList<>();
                list1.add("device31");
                list1.add("device32");
                List<String> oldList = map.putIfAbsent("topic1", list1);
                /*if (oldList != null) {
                    oldList.addAll(list1);
                    list1 = oldList;
                }*/
                map.put("topic1", list1);
                System.out.println("3 :: " + map);
            }
        });

        threadOne.start();
        threadTwo.start();
        threadThree.start();

    }
}

SimpleDateFormat 是线程不安全的

可以看到,每个 SimpleDateFormat 实例里面都有一个 Calendar 对象,后面我们就会知道, SimpleDateFormat 之所以是线程不安全的,就是因为 Calendar 是线程不安全的。后者之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如 fields 、 time 等

解决方法:

  • 每次使用都 new 一个 SimpleDateFormat

  • 使用 synchronized 进行同步操作

  • 使用 ThreadLocal

    /**
     * SimpleDateFormat 非线程安全
     * 使用 ThreadLocal 解决线程不安全问题
     */
    @Slf4j
    public class TestSimpleDateFormat1 {
    
        static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>() {
            @Override
            protected DateFormat initialValue() {
                return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            }
        };
    
    
        public static void main(String[] args) {
    
            for (int i = 0; i < 10; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            log.info("{}", safeSdf.get().parse("2020-02-02 22:22:22"));
                        } catch (ParseException e) {
                            e.printStackTrace();
                        } finally {
                            // 使用完毕记得清除,防止内存泄漏
                            safeSdf.remove();
                        }
                    }
                }).start();
            }
        }
    }
    

使用 Timer 时需要注意的事情

当一个 Timer 运行多个 TimerTask 时,只要其中一个 TimerTask 在执行中向 run 方法外抛出了异常,则其他任务也会自动终止。

img

  • TaskQueue 是一个由平衡二叉树堆实现的优先级队列,每个 Timer 对象内部有一个 TaskQueue 队列。用户线程调用 Timerschedule 方法就是把 TimerTask 任务添加到 TaskQueue 队列。在调用 schedule 方法时, long delay 参数用来指明该任务延迟多少时间执行。
  • TimerThread 是具体执行任务的线程,它从 TaskQueue 队列里面获取优先级最高的任务进行执行。需要注意的是,只有执行完了当前的任务才会从队列里获取下一个任务,而不管队列里是否有任务已经到了设置的 delay 时间。一个 Timer 只有一个 TimerThread 线程,所以可知 Timer 的内部实现是一个多生产者-单消费者模型。

当任务在执行过程中抛出 InterruptedException 之外的异常时,唯一的消费线程就会因为抛出异常而终止,那么队列里的其他待执行的任务就会被清除。所以在 TimerTaskrun 方法内最好使用 try-catch 结构捕捉可能的异常,不要把异常抛到 run 方法之外。其实要实现 Timer 功能,使用 ScheduledThreadPoolExecutorschedule 是比较好的选择。如果 ScheduledThreadPoolExecutor 中的一个任务抛出异常,其他任务则不受影响。

之所以 ScheduledThreadPoolExecutor 的其他任务不受抛出异常的任务的影响,是因为在 ScheduledThreadPoolExecutor 中的 ScheduledFutureTask 任务中 catch 掉了异常,但是在线程池任务的 run 方法内使用 catch 捕获异常并打印日志是最佳实践。

ScheduledThreadPoolExecutor 是并发包提供的组件,其提供的功能包含但不限于 TimerTimer 是固定的多线程生产单线程消费,但是 ScheduledThreadPoolExecutor 是可以配置的,既可以是多线程生产单线程消费也可以是多线程生产多线程消费,所以在日常开发中使用定时器功能时应该优先使用ScheduledThreadPoolExecutor

对需要复用但是会被下游修改的参数要进行深复制

创建线程和线程池时要指定与业务相关的名称

java.lang.Thread#Thread(java.lang.String)

java.lang.Thread#Thread(java.lang.Runnable, java.lang.String)

java.lang.Thread#setName

使用线程池的情况下当程序结束时记得调用 shutdown 关闭线程池

使用完线程池后如果不调用 shutdown 关闭线程池,会导致线程池资源一直不被释放。

JVM 退出的条件是当前不存在用户线程,而线程池默认的 ThreadFactory 创建的线程是用户线程。

线程池使用 FutureTask 时需要注意的事情

线程池使用 FutureTask 时如果把拒绝策略设置为 DiscardPolicyDiscardOldestPolicy ,并且在被拒绝的任务的 Future 对象上调用了无参 get 方法,那么调用线程会一直被阻塞。

使用 ThreadLocal 不当可能会导致内存泄漏

ThreadLocal 只是一个工具类,具体存放变量的是线程的 threadLocals 变量。 threadLocals 是一个 ThreadLocalMap 类型的变量

ThreadLocalMap 里面的 keyThreadLocal 对象的弱引用,具体就是 referent 变量引用了 ThreadLocal 对象, value 为具体调用 ThreadLocal 的 set 方法时传递的值。

ThreadLocalMapEntry 中的 key 使用的是对 ThreadLocal 对象的弱引用,这在避免内存泄漏方面是一个进步,因为如果是强引用,即使其他地方没有对 ThreadLocal 对象的引用, ThreadLocalMap 中的 ThreadLocal 对象还是不会被回收,而如果是弱引用则 ThreadLocal 引用是会被回收掉的。但是对应的 value 还是不能被回收,这时候 ThreadLocalMap 里面就会存在 key 为 null 但是 value 不为 nullentry 项,虽然 ThreadLocalMap 提供了 setgetremove 方法,可以在一些时机下对这些 Entry 项进行清理,但是这是不及时的,也不是每次都会执行,所以在一些情况下还是会发生内存漏,因此在使用完毕后及时调用 remove 方法才是解决内存泄漏问题的王道。

关联知识

对 Callable 接口的理解

  • Callable 表示返回结果并且可能 抛出异常 的任务
  • Callable 接口类似于 Runnable,两者都是为那些其实例可能被另一个线程执行的类设计的
  • Callable 相对 Runnable 用到的地方较少,最重要的区别是 Callable 可以返回结果,其次 Runnable 无法抛出经过检查的异常
  • Thread 类有参数为 Runnable 的构造器,但是没有参数为 Callable
  • Executors 类包含一些从其他普通形式转换成 Callable 类的实用方法
  • FutureTask 实现了 Runnable 接口,可以连接 CallableThread
@Slf4j
public class CallableTest {
    public static class MyCallable implements Callable {
        @SneakyThrows
        @Override
        public String call() {

            log.info("MyCallable");
            TimeUnit.SECONDS.sleep(5);
            return "MyCallable";
        }
    }

    @SneakyThrows
    public static void main(String[] args) {
        log.info("main");
        FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
        new Thread(futureTask).start();
        String result = futureTask.get();
        log.info("result::{}", result);
    }
}

参考资料

  • Java 并发编程之美
posted @ 2021-06-06 11:33  流星<。)#)))≦  阅读(108)  评论(0编辑  收藏  举报