012_多线程编程

理解并发

并行与并发

最开始的时候,我们手里只有二极管,使用二极管搭建出一大堆的电路,这个电路具备一个能力:从某个地方加载一条一条指令并执行然后将数据写到某个位置。我们叫这个大电路为CPU。让CPU干活的方式就是给它一堆指令然他一条一条执行。这个时候问题来了,比如说你准备了一段代码,估计要运行一天,另外一个哥们儿也抱着一段代码想给CPU跑,预计就跑三分钟。大家都是文明人,肯定是你先跑,中间不停,另一个哥们儿等上一天。虽然这样可以,但是这种事儿一多,另一个哥们儿总有点怨言:难道不能中间停下一会会儿给我用一下?我就三分钟啊?

迫于用户的压力,CPU设计者想了个法子,例如将一小时切成12份,每份5分钟,A哥们儿就只能跑5分钟,就要把CPU短暂停下来给别人用,当然如果你没跑完那就后面再轮到你接着跑。这个单个CPU上跑多个任务的方式就是并发了。

再后来,想用CPU的用户越来越多了,一个CPU不够用了,这好办,增加CPU数量,让用户把任务平均分配到不同CPU上一块儿跑问题就解决了。这种方式我们就称为并行。

可以看出来,并发有诸多好处,自然也会有坏处,假如一台机子只有1个cpu,这台机子要完成三个事情,那就只能是一会儿执行A,一会儿执行B,一会儿执行C,让一个CPU这个干干那个干干,不断切换事儿。中间做切换的时候记住记住当前做到哪里吧,这个"做到哪儿了"我们称为上下文,上下文需要不断记忆,切换,这种切换的事儿一多,资源消耗自然就上去了。因此在一些特定场景下并发不一定是快的。并发同时带来另外一个问题:死锁问题。 主要是因为一个cpu上切换的时候,上一秒的A事情可能占据了很多下一秒的B任务需要的资源,当B启动的时候,发现需要等A把资源放弃了,自己才能干活,但是同时B也占了A的资源,那么问题就来了,A与B互相需要对方退出竞争,放弃资源,但是谁都不放。这样就会造成死锁。并发编程还有一个问题是资源限制问题。一个线程执行的过程中所需要的资源量可能很少,但是如果启动大量线程,那么意味着主机提供的大量资源会被线程消耗,而线程之间又会妨碍彼此,那么就造成整体的执行效率不高。因此要实事求是的根据当前的资源总体大小调整并发度。

线程&进程&协程&超线程

对比维度多进程多线程总结
数据共享、同步数据共享复杂,需要用IPC;数据是分开的,同步简单因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂各有优势
内存、CPU占用内存多,切换复杂,CPU利用率低占用内存少,切换简单,CPU利用率高线程占优
创建销毁、切换创建销毁、切换复杂,速度慢创建销毁、切换简单,速度很快线程占优
编程、调试编程简单,调试简单编程复杂,调试复杂进程占优
可靠性进程间不会互相影响一个线程挂掉将导致整个进程挂掉进程占优
分布式适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单适应于多核分布式进程占优

进程和线程是现代操作系统中两个必不可少的运行模型。进程是操作系统分配资源的最小单元,线程是操作系统调度的最小单元。在操作系统中可以有多个进程,这些进程包括系统进程(由操作系统内部建立的进程)和用户进程(由用户程序建立的进程)。进程和进程之间不共享内存,也就是说系统中的进程是在各自独立的内存空间中运行的。一个进程中线程数量大于等于1,而一个进程中的线程可以共享系统分派给这个进程的内存空间。也正因如此,如果想让进程与进程一起完成一项任务那么就需要进行进程间的协作。协作的方式有很多,但是其核心就是引入第三个角色,类似于信使,一个进程通过信使来告知另外一个进程相关信息。而进程和进程之间可以充当信使的东西无非就是磁盘、内核、和用户空间。

线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程 的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。 线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若 干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它 要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成 是一种特殊的线程同步。线程间的同步方法大体可分为两类:用户模式和内核模式。内核模式指利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态, 而用户模式指不需要切换到内核态,只在用户态完成操作。 用户模式下的方法有:原子操作(例如一个单一的全局变量),临界区。内核模式下的方法有: 事件,信号量,互斥量等,详细如下:

  1. 管道(pipe):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有血缘关系的进程间使用。进程的血缘关系通常指父子进程关系。管道分为pipe(无名管道)和fifo(命名管道)两种,有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间通信。
  2. 信号量(semophore):信号量是个计数器,可以用来控制多个进程对共享资源的访问。它通常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  3. 消息队列(message queue):消息队列是由消息组成的链表,存放在内核中 并由消息队列标识符标识。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。
  4. 信号(signal):信号是一种比较复杂的通信方式,用于通知接收进程某一事件已经发生。
  5. 共享内存(shared memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问,共享内存是最快的IPC方式,它是针对其他进程间的通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。
  6. 套接字(socket):套接口也是一种进程间的通信机制,与其他通信机制不同的是它可以用于不同及其间的进程通信。

与进程相对应的,线程不仅可以共享进程的内存,而且还拥有一个属于自己的内存空间,这段内存空间也叫做线程栈,是在建立线程时由系统分配的,主要用来保存线程内部所使用的数据,如线程执行函数中所定义的变量。线程是我们这篇全文要详细描述的东西,这里就简单的定个基调。

操作系统在线程等待IO的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待IO的过程中,其它线程可以继续执行。当系统线程较少的时候没有什么问题,但是当线程数量非常多的时候,却产生了问题。一来系统线程会占用非常多的内存空间,二来过多的线程切换会占用大量的系统时间。协程刚好可以解决上述的问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。协程在其他语言会有,在java内没有实现,但是JVM的开发者在往这个方向努力。

超线程的机制在计算机组成原理内有涉及,还是相当复杂的,我们把这个事儿说的简单一些。一个CPU核一个时间段只能运行一个线程的代码,对吧,我们一直的认知都是这样,在计算机底层上的视角是,管你啥指令,我都要放在流水线上跑,但是流水线不会真的满满当当的,计算机的底层设计者看到这个情况之后就觉得很可惜,想着怎么填满你的流水线呢?那就把另外一个线程的指令往你的流水线里的空位置塞,超线程的单核双线程就是在一个时间片内的流水线上同时存在两个线程的指令,一块儿刷刷跑。

进程&线程的调度模型

假定计算机只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。轮流获得CPU的使用权可以有多种策略,我们称为调度模型,这里至少有两种调度模型:分时调度模型和抢占式调度模型。

抢占式调度(Preemptive Scheduling)是一种CPU调度技术,它通过将CPU的时隙划分给给定的进程来工作。给定的时间间隔可能能够完成整个过程,也可能无法完成。当进程的区间时间(burst time)大于CPU周期时,它将被放回到就绪队列(ready queue)中,并在下一个时机(chance)执行。当进程切换到就绪状态时,会使用这种调度方式。抢占式调度支持的算法有循环调度(RR)、优先级(priority)调度、SRTF(剩余时间最短优先,shortest remaining time first)。

非抢占调度(Non-preemptive Scheduling)也是一种CPU调度技术,也称分时调度模型,进程获取CPU时间并持有,直到进程终止或推送到等待状态。进程不会被中断,直到它完成,然后处理器切换到另一个进程。基于非抢占式调度的算法具有非抢占式优先(non-preemptive priority)以及最短作业优先级(shortest Job first)。

CPU 调度决策可以在如下四种环境下发生:

  1. 当一个进程从运行状态切换到等待状态(例如,I/O请求,或者调用 wait 等待一个子进程的终止)
  2. 当一个进程从运行状态切换到就绪状态(例如当出现中断时)
  3. 当一个进程从等待状态切换到就绪状态(例如 I/O 完成)
  4. 当一个进程终止时

对于第1和第4两种情况,没有选择只有调度。一个新进程(如果就绪队列中已有一个进程存在)必须被选择执行。不过,对于2和3两种情况,可以进行选择。

而对于Java多线程来说,线程会按优先级分配CPU时间片运行,也就是抢占式调度。JVM规范中规定每个线程都有优先级,且优先级越高越优先执行,但优先级高并不代表能独自占用执行时间片,可能是优先级高得到越多的执行时间片,反之,优先级低的分到的执行时间少但不会分配不到执行时间。线程让出cpu的情况有以下几种:

  1. 当前运行线程主动放弃CPU,JVM暂时放弃CPU操作,例如调用yield()方法,基于时间片轮转调度的JVM操作系统不会让线程永久放弃CPU,只是说放弃本次时间片的执行权。
  2. 当前运行线程因为某些原因进入阻塞状态,例如阻塞在I/O上。
  3. 当前运行线程结束,即运行完run()方法里面的任务。

并发级别

第一级别:阻塞,也就是说在其他线程没有释放资源之前是没有办法继续运行下去的。

第二级别:无饥饿,这个名词的反面就是饥饿,那什么是饥饿呢?饥饿表达的是一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。比如优先度太低,高优先级线程吞噬所有的低优先级线程的CPU时间,又或者是锁的公平/非公平设置导致部分线程始终没有办法得到系统资源进行运行。反过来说无饥饿就意味着线程间没有优先级,锁是公平锁。

第三级别:无障碍,一种最弱的非阻塞调度。所有线程大摇大摆进入临界区资源,如果发现资源已经被修改了就对自己的修改进行回滚。

第四级别:无锁的并行都是无障碍的,无锁的并发必然保证有一个线程能走出,其他都会进行回滚重试。

第五级别:无等待 在无锁的等级上再进一层,要求所有的线程必须在有限步骤内执行完毕

并发编程模型

在并发编程需要处理的两个关键问题是:线程之间如何通信 和 线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存 和 消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

并发基础

线程的创建与启动

继承Thread

我们可以继承Thread的方式制造出一个新的线程:

@Test
public void test0() throws InterruptedException {
  System.out.println("开始运行整体_" + System.currentTimeMillis());
  Thread thread = new Thread() {
    @Override
    public void run() {
      System.out.println("线程开始运行_" + System.currentTimeMillis());
      try {
        System.out.println("线程开始睡眠_" + System.currentTimeMillis());
        Thread.sleep(1000);
        System.out.println("线程结束睡眠_" + System.currentTimeMillis());
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println("线程执行结束_" + System.currentTimeMillis());
    }
  };
  thread.start();
  System.out.println("main开始睡眠_" + System.currentTimeMillis());
  Thread.sleep(2000);
  System.out.println("main结束睡眠_" + System.currentTimeMillis());
}

获得到的输出是:

开始运行整体_1639623765978
main开始睡眠_1639623765978
线程开始运行_1639623765978
线程开始睡眠_1639623765979
线程结束睡眠_1639623766982
线程执行结束_1639623766983
main结束睡眠_1639623767978

线程真正的启动使用的是start方法,而不是调用run方法。run方法就是一个简单的实例方法,不具备真正意义上的线程方法。start方法的源码内部使用start0的native方法,由jvm负责真正创建线程并且开始运行。

public synchronized void start() {
    if (threadStatus != 0){
       throw new IllegalThreadStateException();
    }
    group.add(this);
    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            /* do nothing. If start0 threw a Throwable then
              it will be passed up the call stack */
        }
    }
}
private native void start0();

实现Runable

java是单继承的,因此父类的坑位对于一个类事实上是一个重要资源,使用继承的方式实现线程无形之中就占据了重要资源。那么实现Runable接口的方式实现线程是个好办法。

@Test
public void test1() throws InterruptedException {
    System.out.println("开始运行整体_" + System.currentTimeMillis());
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("线程开始运行_" + System.currentTimeMillis());
            try {
                System.out.println("线程开始睡眠_" + System.currentTimeMillis());
                Thread.sleep(1000);
                System.out.println("线程结束睡眠_" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程执行结束_" + System.currentTimeMillis());
        }
    };
    new Thread(runnable).start();
    System.out.println("main开始睡眠_" + System.currentTimeMillis());
    Thread.sleep(2000);
    System.out.println("main结束睡眠_" + System.currentTimeMillis());
}

得到的输出是:

开始运行整体_1639624612318
main开始睡眠_1639624612319
线程开始运行_1639624612319
线程开始睡眠_1639624612319
线程结束睡眠_1639624613319
线程执行结束_1639624613319
main结束睡眠_1639624614319

可以看出来,我们实现了Runable接口制造出来一个实例,将这个实例当做参数赋予一个Thread实例作为构造参数。同样的,使用start方式将这个线程启动起来。我们还是看下源码:

/* What will be run. */
private Runnable target;

@Override
public void run() {
    if (target != null) {
        target.run();
    }
}

Thread启动,JVM会在合适的时候调用这个线程的run方法,run方法的内部就是判定是否有runnable实例存在,如果有那么就调用Runnable实例的run方法。本质是一样的。如果我们将上面的两种创建线程的方式合并在一起:

@Test
public void test000_001() throws InterruptedException {
    System.out.println("开始运行整体_" + System.currentTimeMillis());
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("线程开始运行_" + System.currentTimeMillis());
            try {
                System.out.println("线程开始睡眠_" + System.currentTimeMillis());
                Thread.sleep(1000);
                System.out.println("线程结束睡眠_" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程执行结束_" + System.currentTimeMillis());
        }
    };
    new Thread(runnable){
        @Override
        public void run() {
            System.out.println("吃饭吃饭吃饭吃饭");
        }
    }.start();
    System.out.println("main开始睡眠_" + System.currentTimeMillis());
    Thread.sleep(2000);
    System.out.println("main结束睡眠_" + System.currentTimeMillis());
}

得到的输出是:

开始运行整体_1639624654217
main开始睡眠_1639624654221
吃饭吃饭吃饭吃饭
main结束睡眠_1639624656222

Thread内部的方法覆盖了runnable的执行逻辑

实现Callable

前面的线程都是造出来,跑起来就完了,不会返回运算结果。要真的想获得到运算结果只能和其他线程共享变量,但是变量什么时候有值呢?不知道,线程的运行是被系统安排的,除非你写while不断去感知这个结果被计算出来了没有。这样给人的感觉太差了。因此为了解决上面描述的各种恶心麻烦的事情,制造出来了Callable实现。

@Test
public void test2() throws InterruptedException, ExecutionException {
  System.out.println("开始运行整体_" + System.currentTimeMillis());
  FutureTask<Long> futureTask = new FutureTask<>(new Callable<Long>() {
    @Override
    public Long call() {
      System.out.println("线程开始运行_" + System.currentTimeMillis());
      try {
        System.out.println("线程开始睡眠_" + System.currentTimeMillis());
        Thread.sleep(1000);
        System.out.println("线程结束睡眠_" + System.currentTimeMillis());
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println("线程执行结束_" + System.currentTimeMillis());
      return System.currentTimeMillis();
    }
  }
                                                );
  new Thread(futureTask).start();
  System.out.println("main开始睡眠_" + System.currentTimeMillis());
  Thread.sleep(2000);
  System.out.println("main结束睡眠_" + System.currentTimeMillis());
  System.out.println("futureTask计算得到的数据为:"+futureTask.get());
}

可以看出,FutureTask把Callable的实现当成构造参数吃掉,FutureTask具备从线程执行结束后获得结果的能力。其中get方法会阻塞,直到结果真正的被线程执行出来。这个实现背后需要依赖队列,使用LockSupport来挂起线程,背后实现和我们后面需要描述的AQS有点像。

线程启动的底层原理

当我们通过new Thread().start()来启动一个线程时,会先在JVM层面创建一个线程,JVM具有跨平台特性,它会根据当前操作系统的类型调用相关指令来创建线程并启动。线程启动后,并不会立刻运行,而是要等到操作系统层面的CPU调度算法,把当前线程分配给某个CPU来执行。线程被分配执行后,会回调线程中的run()方法执行相关指令。如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程相关方法详解

线程的初始化

线程的构造器

Thread类是有很多构造器的,上面新建线程的demo使用的是最简单的一个构造器。

public Thread() {
    init(null, null, "Thread-" + nextThreadNum(), 0);
}

public Thread(Runnable target) {
    init(null, target, "Thread-" + nextThreadNum(), 0);
}

Thread(Runnable target, AccessControlContext acc) {
    init(null, target, "Thread-" + nextThreadNum(), 0, acc, false);
}

public Thread(ThreadGroup group, Runnable target) {
    init(group, target, "Thread-" + nextThreadNum(), 0);
}

public Thread(String name) {
    init(null, null, name, 0);
}

public Thread(ThreadGroup group, String name) {
    init(group, null, name, 0);
}

public Thread(Runnable target, String name) {
    init(null, target, name, 0);
}

public Thread(ThreadGroup group, Runnable target, String name) {
    init(group, target, name, 0);
}

public Thread(ThreadGroup group, Runnable target, String name, long stackSize) {
    init(group, target, name, stackSize);
}

可以看出来,这么多构造事实上都在调用init进行初始化。init私有方法有两个,如下,第一个拥有4个入参的私有方法是上面构造器们用的最多的,第二个init方法就是在第一个init方法的基础上增加了inheritThreadLocals属性。

private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
		init(g, target, name, stackSize, null, true);
}

private void init(ThreadGroup g, 
                    Runnable target, 
                    String name, 
                    long stackSize, 
                    AccessControlContext acc, 
                    boolean inheritThreadLocals)

从init的参数列表上,可以发现一些概念紧密地和线程产生关系。接下来我们详细描述一下他们

设置线程名字

我们在构造现成的时候可以为线程起一个名字,但是我们如果不给线程起名字,那么线程将会以“Thread-”作为前缀与一个自增数字进行组合,这个自增数字在整个JVM进程中将会不断自增,下面是相关的源码,很好理解。

public Thread(ThreadGroup group, Runnable target) {
    init(group, target, "Thread-" + nextThreadNum(), 0);
}
private static synchronized int nextThreadNum() {
		return threadInitNumber++;
}

除了在线程构造器内指明名称之外,在线程运行期间你都可以使用setName的方式动态修改线程名称,下面是源码。

    public final synchronized void setName(String name) {
        checkAccess();
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }
        this.name = name;
        if (threadStatus != 0) {
            setNativeName(name);
        }
    }
    private native void setNativeName(String name);
设置线程组

ThreadGroup类,可以把线程归属到某一个线程组中,线程组中可以有线程对象,也可以有线程组,组中还可以有线程,这样的组织结构有点类似于树的形式。线程组存在的目的也就是管理线程,不过用的很少。在Thread的构造函数中,可以显式地指定线程的Group,也就是ThreadGroup。如果没指定一个线程组,那么子线程将会被加入到父线程所在的线程组。ok,来了一个新的概念,父子线程。事实上,新创建的任何一个线程都会有一个父线程。

private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();//在这里获取当前线程作为父线程
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                g = security.getThreadGroup();
            }

            /* If the security doesn't have a strong opinion of the matter
               use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }

        /* checkAccess regardless of whether or not threadgroup is
           explicitly passed in. */
        g.checkAccess();

        /*
         * Do we have the required permissions?
         */
        if (security != null) {
            if (isCCLOverridden(getClass())) {
                security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
            }
        }

        g.addUnstarted();

        this.group = g;
        this.daemon = parent.isDaemon();
        this.priority = parent.getPriority();
        if (security == null || isCCLOverridden(parent.getClass()))
            this.contextClassLoader = parent.getContextClassLoader();
        else
            this.contextClassLoader = parent.contextClassLoader;
        this.inheritedAccessControlContext =
                acc != null ? acc : AccessController.getContext();
        this.target = target;
        setPriority(priority);
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }

上面的代码中的currentThread()是获取当前线程,在线程的生命周期中,线程的最初状态为NEW,没有执行start方法之前,他只能算是一个Thread的实例,并不意味着一个新的线程被创建,因此currentThread()代表的将会是创建它的那个线程,从源码中我们可以得出以下结论:

  1. 一个线程的创建肯定是由另一个线程完成的
  2. 被创建线程的父线程是创建它的线程

我们都知道main函数所在的线程是由JVM创建的,也就是main线程,那就意味着我们前面创建的所有线程,其父线程都是main线程。

设置线程栈深度大小

Thread的构造器内有个特殊的变量:stacksize。这个事实上是来控制一个线程的方法调用递归的深度的。一般情况下,创建线程的时候不会手动指定栈内存的地址空间字节数组,统一通过xss参数进行设置即可。stacksize越小代表着创建的线程数量越多,这个参数对平台的依赖性比较高,同样的代码,在不同的操作系统,不同的硬件可能运行效率截然不同。

设置守护进程

Java中的线程可以分为两种:守护线程和用户线程。

守护线程是为其他线程提供服务的,使用ThreadDump打印出来的线程信息中,含有 daemon 字样的线程即为守护进程。我们启动下面的代码:

public static void main(String[] args) throws InterruptedException {
	Thread.sleep(1000000L);
}

使用jps指令找到这个进程pid,然后使用kill -3 pid的指令打印线程信息,如下:

"Attach Listener" #11 daemon prio=9 os_prio=31 tid=0x00007fe9c5974000 nid=0x5603 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Service Thread" #10 daemon prio=9 os_prio=31 tid=0x00007fe9c5953800 nid=0x4103 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread3" #9 daemon prio=9 os_prio=31 tid=0x00007fe9c30ed800 nid=0x4303 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread2" #8 daemon prio=9 os_prio=31 tid=0x00007fe9c30ed000 nid=0x4403 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread1" #7 daemon prio=9 os_prio=31 tid=0x00007fe9c3ac4000 nid=0x3d03 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread0" #6 daemon prio=9 os_prio=31 tid=0x00007fe9c4acf800 nid=0x3c03 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Monitor Ctrl-Break" #5 daemon prio=5 os_prio=31 tid=0x00007fe9c3ac3800 nid=0x3b03 runnable [0x00007000096e4000]
   java.lang.Thread.State: RUNNABLE
	at java.net.SocketInputStream.socketRead0(Native Method)
.......略去

"Signal Dispatcher" #4 daemon prio=9 os_prio=31 tid=0x00007fe9c481d000 nid=0x3a03 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Finalizer" #3 daemon prio=8 os_prio=31 tid=0x00007fe9c880f000 nid=0x3303 in Object.wait() [0x00007000093d8000]
   java.lang.Thread.State: WAITING (on object monitor)
.......略去

"Reference Handler" #2 daemon prio=10 os_prio=31 tid=0x00007fe9c880c800 nid=0x3103 in Object.wait() [0x00007000092d5000]
   java.lang.Thread.State: WAITING (on object monitor)
.......略去

"main" #1 prio=5 os_prio=31 tid=0x00007fe9c2810800 nid=0xe03 waiting on condition [0x00007000088b7000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
.......略去

"VM Thread" os_prio=31 tid=0x00007fe9c501b800 nid=0x2f03 runnable

你会发现一个简单的main函数背后有很多守护进程为你保驾护航。如果全部的用户线程已经执行完毕,那么意味着守护线程没有可服务的线程,JVM就可以关闭了。守护线程的制造者可以是JVM也可以是从用户线程变化而来,任何用户侧诞生的线程默认都是用户线程,而作为开发者可以在线程启动前将用户线程设置为守护线程。

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(){
            @Override
            public void run(){
                try {
                    Thread.sleep(1000000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        thread.setDaemon(true);
        thread.start();

        Thread.sleep(1000000L);
    }

thread.setDaemon(true)必须在thread.start()之前调用,否则运行时会抛出IllegalThreadStateException异常。

设置线程优先级

每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个int变量(从1-10),1代表最低优先级,10代表最高优先级。

java中使用Thread#setPriority(int newPriority)方法进行线程优先级级别。如果newPriority小于1或大于10则JDK抛出IllegalArgumentException()的异常,默认优先级是5。如果不主动赋予优先级的值则保持和父线程的优先级一致。

java的线程优先级调度会委托给操作系统去处理,所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级。可以说即使设置了优先级也可能没有作用,因此程序正确性不能依赖线程的优先级高低。

设置异常捕获

线程不允许抛出受检异常,必须自己处理受检异常。但是RuntimeException不可避免,抛出该异常时子线程会结束,但是主线程不会知道,因为主线程无法通过try-catch来捕获子线程异常。

callable的实现可以抛出异常,这也是callable的强大之处

@Test
public void test001(){
    try {
        Thread test = new Thread(() -> {
            throw new RuntimeException("run time exception");
        });
        test.start();
    }
    catch(Exception e) {
        System.out.println("catch thread exception");
    }
}

运行上面的代码,你会发现,没有办法打印出catch thread exception,这样也就证明了我们的线程是没有办法将异常丢到外面去的。那我们怎么做才能在外面捕捉到线程抛出的异常呢?我们可以针对这个线程赋予UncaughtExceptionHandler的实现类:

@Test
public void test001(){
    try {
        Thread test = new Thread(() -> {
            throw new RuntimeException("run time exception");
        });
        // 设置线程默认的异常捕获方法
        test.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println(t.getName() + ": " + e.getMessage());
            }
        });
        test.start();
    }
    catch(Exception e) {
        System.out.println("catch thread exception");
    }
}

如果你在UncaughtExceptionHandler继续抛出异常,很遗憾还是没有办法被最外面的catch给捕捉到。看上去很鸡肋啊,实则不是,用handler的方式可以方式因为某个没有捕获的异常而中断线程的问题,当然你也可以为所有的Thread设置一个默认的UncaughtExceptionHandler,通过调用Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)方法。

设置线程上下文类加载器

类Thread中的getContextCLassLoader()setContextClassLoader(ClassLoader classloader)分别用来获取和设置上下文类加载器。简单地说这两个方法可以改变双亲委托模型。这部分不影响我们对多线程的探究,这与类加载机制相关。后面会详细聊到,在此处就不展开了。

线程协作方法

一个事情如果很简单并且量也不大,一个线程可能从头开始跑就解决了。但是如果事儿越来越复杂,工作量也越来越大,意味着需要更多的线程共同协作才能做到。终极的协作方式是针对关键性资源进行加锁,一个时间段内只能一个线程进行处理这部分资源。同样的java也提供了一些简单的机制来促成线程间的协作机制。

Thread#sleep

我们可以使用Thread类的Sleep()方法让当前线程暂停一段时间。需要注意的是,这并不会让线程终止,一旦从休眠中唤醒线程,线程的状态将会被改变为Runnable,并且根据线程调度,它将得到执行。这个方法不会释放锁,但会释放CPU资源,使得其他线程有机会运行。

Thread#yield

看Thread 的源码可以知道 yield() 为本地方法,也就是说 yield() 是由 C 或 C++ 实现的,yield() 方法表示给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。

public static void main(String[] args) throws InterruptedException {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                System.out.println("线程:" +
                        Thread.currentThread().getName() + " I:" + i);
                if (i == 5) {
                    Thread.yield();
                }
            }
        }
    };
    Thread t1 = new Thread(runnable, "T1");
    Thread t2 = new Thread(runnable, "T2");
    t1.start();
    t2.start();
}

当我们把这段代码执行多次之后会发现,每次执行的结果都不相同,这是因为 yield() 执行非常不稳定,线程调度器不一定会采纳 yield() 出让 CPU 使用权的建议,从而导致了这样的结果。

Thread#join

join()是Thread类的一个方法。根据jdk文档的定义:

public final void join()throws InterruptedException: Waits for this thread to die.

join()方法的作用,是等待这个线程结束,也就是说,t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。

在一个线程中调用 other.join() ,这时候当前线程会让出执行权给 other 线程,直到 other 线程执行完或者过了超时时间之后再继续执行当前线程,join() 源码如下:

public final synchronized void join(long millis)
throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;
    // 超时时间不能小于 0
    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }
    // 等于 0 表示无限等待,直到线程执行完为之
    if (millis == 0) {
        // 判断子线程 (其他线程) 为活跃线程,则一直等待
        while (isAlive()) {
            wait(0);
        }
    } else {
        // 循环判断
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

从源码中可以看出 join() 方法底层还是通过 wait() 方法来实现的。有了wait(),必然有notify(),什么时候才会notify呢?在jvm源码里:

// from /hotspot/src/share/vm/runtime/thread.cpp

void JavaThread::exit(bool destroy_vm, ExitType exit_type) {
    // ...

    // Notify waiters on thread object. This has to be done after exit() is called
    // on the thread (if the thread is the last thread in a daemon ThreadGroup the
    // group should have the destroyed bit set before waiters are notified).
    // 这行起到关键作用
    ensure_join(this);

    // ...
}


static void ensure_join(JavaThread* thread) {
    // We do not need to grap the Threads_lock, since we are operating on ourself.
    Handle threadObj(thread, thread->threadObj());
    assert(threadObj.not_null(), "java thread object must exist");
    ObjectLocker lock(threadObj, thread);
    // Ignore pending exception (ThreadDeath), since we are exiting anyway
    thread->clear_pending_exception();
    // Thread is exiting. So set thread_status field in  java.lang.Thread class to TERMINATED.
    java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
    // Clear the native thread instance - this makes isAlive return false and allows the join()
    // to complete once we've done the notify_all below
    java_lang_Thread::set_thread(threadObj(), NULL);

    // thread就是当前线程
    lock.notify_all(thread);

    // Ignore pending exception (ThreadDeath), since we are exiting anyway
    thread->clear_pending_exception();
}

当子线程threadA执行完毕的时候,jvm会自动唤醒阻塞在threadA对象上的线程,在我们的例子中也就是主线程。至此,threadA线程对象被notifyall了,那么主线程也就能继续跑下去了。

当main线程调用threadA.join时候,main线程会获得线程对象threadA的锁,调用该对象的wait(等待时间),直到该对象唤醒main线程 (也就是子线程threadA执行完毕退出的时候)。join() 是一个synchronized方法, 里面调用了wait(),这个过程的目的是让持有这个同步锁的线程进入等待,那么谁持有了这个同步锁呢?答案是主线程,因为主线程调用了threadA.join()方法,相当于在threadA.join()代码这块写了一个同步代码块,谁去执行了这段代码呢,是主线程,所以主线程被wait()了。然后在子线程threadA执行完毕之后,JVM会调用lock.notify_all(thread);唤醒持有threadA这个对象锁的线程,也就是主线程,会继续执行。

我们来验证一下join的功能

public class JoinTest {
    @Test
    public void test0(){
        Thread th1 = new Thread(()->{
            try {
                System.out.println("第一个线程开始");
                Thread.sleep(1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            System.out.println("第一个线程结束");
        });
        Thread th2 = new Thread(()->{
            try {
                System.out.println("第二个线程开始");
                Thread.sleep(1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            System.out.println("第二个线程结束");
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        try {
            th1.join();
            th2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main函数结束");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

获得到的输出是:

第一个线程开始
第二个线程开始
第二个线程结束
第一个线程结束
main函数结束

可以看到,main线程需要等到线程1和线程2都结束之后才能继续走下去。那么如果没有join的存在的话:

public class JoinTest {
    @Test
    public void test0(){
        Thread th1 = new Thread(()->{
            try {
                System.out.println("第一个线程开始");
                Thread.sleep(1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            System.out.println("第一个线程结束");
        });
        Thread th2 = new Thread(()->{
            try {
                System.out.println("第二个线程开始");
                Thread.sleep(1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            System.out.println("第二个线程结束");
        });
        // 启动两个线程
        th1.start();
        th2.start();
        System.out.println("main函数结束");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

获得到的输出是:

main函数结束
第一个线程开始
第二个线程开始
第一个线程结束
第二个线程结束

可以看到main函数早就执行过去了,而线程1和线程2慢悠悠地执行。

Thread#interrupt

线程在运行过程中,怎么让一个线程主动地停下来?我们可以使用共享变量来实现这个。

public class InterruptTest {
    volatile boolean isStop = false;

    @Test
    public void test0() throws InterruptedException {
        new Thread(() -> {
            while (!isStop){
                System.out.println("运行------");
            }
            System.out.println("运行结束------");
        }).start();
        Thread.sleep(1);
        isStop = true;
        Thread.sleep(1);
        System.out.println("主线程退出------");
    }
}

获得到的输出是:

运行------
运行------
运行------
运行------
运行------
运行------
运行------
运行结束------
主线程退出------

其中isStop使用volatile关键字修饰,目的是为了让主线程的修改可以被线程看见。回头一想,如果我们中断一个线程都要造一个标记位出来,太麻烦了,所以java线程天生会有一个interrupted的判断条件,相当于我们上面说的isStop的状态。然后另一个线程可以调用这个线程interrupt方法,表达中断这个线程。

@Test
public void test1() throws InterruptedException {
    Thread thread = new Thread(){
        @Override
        public void run(){
            while (!isInterrupted()){
                System.out.println("运行------");
            }
            System.out.println("运行结束------");
        }
    };

    thread.start();

    Thread.sleep(1);
    thread.interrupt();
    Thread.sleep(1);

    System.out.println("主线程退出------");
}

获得到的输出是:

运行------
运行------
运行------
运行------
运行------
运行结束------
主线程退出------

线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。isInterrupted仅仅是查询当前线程的中断状态,一旦调用之后,就会清除原状态。也就是说如果一个线程被中断了,第一次调用interrupted则返回true,第二次和后面的就返回false了。

上面的例子是我们主动查询线程的状态来判断是不是被中断了,然后做相应的处理逻辑。那有没有被动通知呢?有,举个简单的例子,睡眠方法就可以在背后监听是不是被中断了。

@Test
public void test2() throws InterruptedException {
    Thread thread = new Thread(){
        @Override
        public void run(){
            try {
                System.out.println("开始睡眠------");
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                System.out.println("监听到中断------");
                e.printStackTrace();
            }
            System.out.println("睡眠结束------");
        }
    };

    thread.start();

    Thread.sleep(1);
    thread.interrupt();
    Thread.sleep(1);

    System.out.println("主线程退出------");
}

获得到的输出是:

开始睡眠------
监听到中断------
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at com.zifang.ex.bust.charpter12.test001.InterruptTest$2.run(InterruptTest.java:50)
睡眠结束------
主线程退出------

可以看出,第一个线程的任务就是睡觉,如果不告诉它该停下来了,它是会继续睡下去的。当主线程调用interrupt方法之后,相当于中断了线程的睡眠过程,并且这个过程会被当做异常被线程内部的try-catch捕获。

Object#wait与Object#notify

前几种线程间合作方式只能在特定的场合上用的上,那如果需要针对一般化场合也需要进行线程间合作怎么办?前面我们提过一嘴,终极的线程合作就是锁机制,一个线程占据了资源的同时告诉别人,你别动,我先处理完再给你。所以这里会有几个概念,一个是锁,什么东西可以充当锁呢?java的设计者认为万物皆锁,锁这种东西应该是一个很常见的东西。也就是说任何一个Object都可以充当锁。第二个概念是,当线程需要处理某个资源,但是这个资源已经被另外一个线程占据,那么我怎么办?我应该等着这个锁被释放。等待锁释放可以有两种方式,一个是不断去看看这个锁是不是已经释放了,另外一个是就等着,当锁释放了会有人过来通知。java设计者决定采用后者,那么意味着一个锁的对象,应该有能力让一个线程等在这个锁上,当锁被释放了,背后有一套机制可以将等待着的线程唤醒。

判定线程是否拥有锁,可以使用Thread的静态方法holdsLock,其入参是锁的实例对象。

说了很多,但是这番描述就可以解释为什么是Object持有wait,notify方法,而不是Thread。我们先造一个demo看看这几个东西是怎么玩儿的。

@Test
public void test1(){
    Object lock = new Object();
    new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程A等待获取lock锁");
            synchronized (lock) {
                try {
                    System.out.println("线程A获取了lock锁");
                    Thread.sleep(1000);
                    System.out.println("线程A将要运行lock.wait()方法进行等待");
                    lock.wait();
                    System.out.println("线程A等待结束");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程B等待获取lock锁");
            synchronized (lock) {
                System.out.println("线程B获取了lock锁");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程B将要运行lock.notify()方法进行通知");
                lock.notify();
            }
        }
    }).start();

    try {
        Thread.sleep(1000000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

我们获得到的输出是:

线程A等待获取lock锁
线程A获取了lock锁
线程B等待获取lock锁
线程A将要运行lock.wait()方法进行等待
线程B获取了lock锁
线程B将要运行lock.notify()方法进行通知
线程A等待结束

wait和notify在调用的时候要求这个线程必须拥有该对象的锁。同样的,当一个线程调用对象的wait()方法时,它会释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以他们只能在同步方法或者同步块中被调用,这也是为什么wait和notify必须配合synchronized关键字,刚好synchronized能帮助当前线程获得锁。

从上面的输出你会发现,线程a执行了lock.wait();之后就没动静了,直到B线程获得到锁并且执行lock.notify()之后才会继续运行。当线程A执行lock.wait();之后,会自动释放锁,进而B线程为什么可以拿到锁资源,执行自己的代码。当B线程执行notify之后,这个方法体也就结束了,结束的刹那将会释放锁。注意,不是lock.notify()语句导致的解锁,而是整个方法体结束导致解锁。

和notify方法类似的还有个notifyAll方法,notify() 方法不能唤醒某个具体的线程,所以只有一个线程在等待的时候它才有用武之地。而notifyAll()唤醒所有线程并允许他们争夺锁确保了至少有一个线程能继续运行。如果没把握,建议notifyAll,防止notify因为信号丢失而造成程序异常。

线程上下文切换

频繁上下文问题

并发过程背后本质是轮换使用CPU执行执行,意味着在轮换过程中需要记忆前一个线程执行到哪里了,包括当前所处的环境变量都需要在某个地方存储起来。而这个过程本身就需要使用CPU资源进行处理,因此在出现大量线程切换的时候,会严重影响CPU的执行,毕竟资源被大量使用了。

上下文切换分类

导致上下文切换的原因有很多,比如通过wait()、sleep()等方法阻塞当前线程,这时CPU不会一直等待,而是重新分配去执行其他线程。当后续CPU重新切换到当前线程时,CPU需要沿着上次执行的指令位置继续运行。因此,每次在CPU切换之前,需要把CPU寄存器和程序计数器保存起来,这些信息会存储到系统内核中,CPU再次调度回来时会从系统内核中加载并继续执行。简而言之,上下文切换,就是CPU把自己的时间片分配给不同的任务执行的过程。根据任务类型的不同,上下文切换又分为三种类型:

1)进程上下文切换。
2)线程上下文切换。
3)中断上下文切换。

进程上下文切换,是指当前进程的CPU时间片分配给其他进程执行,进程切换有以下三种情况:

1)CPU时间片分配。
2)当进程系统资源(如内存)不足时,进程会被挂起。
3)当存在优先级更高的进程运行时,当前进程有可能会被挂起,CPU时间片分配给优先级更高的进程运行。

进程的上下文切换和线程的上下文切换相同,进程切换之后,再恢复执行时,还是需要沿着上一次执行的位置继续运行,但是与线程相比,进程的上下文切换的损耗会更大。原因是进程在做上下文切换时,需要把用户空间中的虚拟内存、栈、全局变量等状态保存起来,还需要保存内核空间的内核堆栈、寄存器等状态(之所以要保存内核态的状态信息,是因为进程的切换只能发生在内核态)。同时在加载下一个进程时,需要再次恢复上下文信息,而这些操作都需要在CPU上运行。每次进程的上下文切换需要几十纳秒或几微秒的CPU时间,从我们的感官上看起来好像不算很长,但是如果进程上下文切换次数非常多,就会导致CPU把大量的时间耗费在寄存器、内核栈、虚拟内存、全局变量等资源的保护和恢复上,使得CPU真正工作的时间很少,这也是为什么我们常说上下文切换过于频繁会影响性能。

线程就是轻量级进程,进程是CPU调度的最小单元,而线程是系统资源分配的基本单元。一个进程中允许创建多个线程,这些线程可以共享同一进程中的资源。线程上下文切换需要注意两点,当两个线程切换属于不同的进程时,由于进程资源不共享,所以线程的切换其实就是进程的切换。当两个线程属于同一个进程时,只需要保存线程的上下文。线程的上下文切换,需要保存上一个线程的私有数据、寄存器等数据,这个过程同样会占用CPU资源,当上下文切换过于频繁时,会使得CPU不断进行切换,无法真正去做计算,最终导致性能下降。

中断上下文切换是指CPU对系统发生的某个中断事件做出反应导致的切换,比如CPU本身故障、程序故障,或者是I/O中断。为了快速响应硬件事件,中断处理会打断当前正常的进程调度和执行过程,此时CPU会调用中断处理程序响应中断事件。而这个被打断的进程在切换之前需要保存该进程当前的运行状态,以便在中断处理结束后,继续恢复执行被打断的进程。这里不涉及用户态中的资源保存,只需要包含内核态中必需的状态保存,如CPU寄存器、内核堆栈等资源。即便如此,中断导致的上下文切换仍然会消耗CPU资源。

减少线程上下文切换策略

既然频繁的上下文切换会影响程序的性能,那么就得想办法减少上下文切换,一般会有以下策略:

1)减少线程数,同一时刻能够运行的线程数是由CPU核数决定的,创建过多的线程,就会造成CPU时间片的频繁切换。
2)采用无锁设计解决线程竞争问题,比如在同步锁场景中,如果存在多线程竞争,那么没抢到锁的线程会被阻塞,这个过程涉及系统调用,而系统调用会产生从用户态到内核态的切换,这个切换过程需要保存上下文信息对性能的影响。如果采用无锁设计就能够解决这类问题。
3)采用CAS做自旋操作,它是一种无锁化编程思想,原理是通过循环重试的方式避免线程的阻塞导致的上下文切换。

总的来说,CPU的切换本意上是为了提高CPU利用率,但是过多的CPU上下文切换,会使CPU把时间都消耗在上下文信息的保存和恢复上,从而使真正的有效执行时间缩短,最终导致整体的运行效率大幅下降。

JVM的线程

JVM本身是一个多线程的程序,和我们编写的java应用程序一样,当JVM启动执行时就是在操作系统中启动了一个JVM进程。我们编写的java单线程或多线程应用进程都是在JVM这个程序中作为一个或多个线程运行。每当使用java命令执行一个带main方法的类时,就会启动JVM(应用程序),实际上就是在操作系统中启动一个JVM进程,JVM启动时,必然会创建以下5个线程:

  1. -main
    主线程,用于执行我们编写的java程序的main方法。
  2. -Reference Handler
    它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题。
  3. -Finalizer
    JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收。
  4. -Signal Dispatcher
    Attach Listener线程的职责是接收外部jvm命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部jvm命令时,进行初始化工作。
  5. -Attach Listener
    该线程是负责接收到外部的命令,执行该命令,并且把结果返回给发送者。通常我们会用一些命令去要求jvm给我们一些反馈信息,如:java -version、jmap、jstack等等。如果该线程在jvm启动的时候没有初始化,那么,则会在用户第一次执行jvm命令时,得到启动。

我们可以使用java.lang.management包下的一些工具类获得到相关信息:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class JVMTest {
    public static void main(String[] args) throws Exception {
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println(threadInfo.getThreadId() + "-" + threadInfo.getThreadName());
        }
    }
}

JVM既可以正常关闭也可以强制关闭,或者说非正常关闭。但是无论正常关闭还是强制关闭,理论上都应该干点什么事儿,好让外面的人感知到这种事情的发生。因此java的设计者们提出了一个叫做Shutdown-Hook的东西,Shutdown-Hook可以在JVM关闭时执行一些特定的操作,譬如可以用于实现服务或应用程序的清理工作。关闭钩子可以在一下几种场景中应用:

  • 程序正常停止,例如代码跑完了,或者代码调用System.exit主动关闭JVM
  • 程序异常退出,例如抛出异常导致代码提前结束,或者代码占据的系统资源太多,直接OutOfMemory了
  • 受到外界影响停止,例如开发者使用Ctrl+C来停止运行中的代码,或者操作系统的用户注销或者关机

而这个东西使用起来还是蛮方便的,直接调用java.lang.Runtime这个类的addShutdownHook(Thread hook)方法即可注册一个Shutdown Hook,然后在Thread中定义需要在systemExit时进行的操作。代码如下:

public class ShutdownHook {
    public static void main(String[] args) {
        System.out.println("JVM-start");

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("jvm-关闭关闭关闭");
        }));
    }
}

线程生命周期详解

就像生物从出生到长大、最终死亡的过程一样,线程也有自己的生命周期,在 Java 中线程的生命周期中一共有 6 种状态。这六种状态在Thread.State这个枚举类内。在线程运行期可以通过Thread#getStatus方法获得到对应的状态枚举值。其源码如下:

public State getState() {
  // get current thread state
  return sun.misc.VM.toThreadState(threadStatus);
}

状态枚举下的值有:New(新创建),Runnable(可运行),Blocked(被阻塞),Waiting(等待),Timed Waiting(计时等待),Terminated(被终止)。

状态还是蛮多的,先上一个全景大图。其中线程的状态是需要按照箭头方向来走的,比如线程从 New 状态是不可以直接进入 Blocked 状态的,它需要先经历 Runnable 状态。线程生命周期不可逆,例如一旦进入 Runnable 状态就不能回到 New 状态;一旦被终止就不可能再有任何状态的变化。所以一个线程只能有一次 New 和 Terminated 状态,只有处于中间状态才可以相互转换。

首先,从图上我们就可以感受到,其主线是三种状态:new,runnable,terminated。这三种状态是很好理解的,创建一个Thread对象,但还未调用start()启动线程时,线程处于初始态。但凡使用start()进行执行之后,就会进入运行态。

在网上你可能还会遇上就绪态的概念,事实上这是一种跳脱于CPU执行之外的视角。就绪态该状态下的线程已经获得执行所需的所有资源,只要CPU分配执行权就能运行。意味着就绪态是指能跑下去,只不过CPU暂时还没有分片给你。这个时候getState自然没有办法执行,但是只要能执行,意味着已经有时间片在跑了,因此在线程内部是没有办法感知到我是不是已经有时间片运行了,这也导致State的枚举值内是没有就绪态的。

当线程运行良好,毫无阻拦地正常运行结束之后,线程也就相当于结束了它的一生,迎接死亡,这也就是终止态了。

再然后,我们就可以在运行态继续细分下去了。线程与线程是可以进行协作执行的,也就是线程的同步问题,线程同步可以使用锁的概念来解决。当一个线程持有锁,其他的线程请求锁资源,但是失败,就会被阻塞,只有当再次主动抢到锁才能从阻塞态的状态切换为运行态。注意,上面是主动去抢占锁的机制,还有是被动地被告知可以去占有锁了,这就是等待的概念了。而被动被通知可以有时间的控制,当时间作用于被动通知那就是超时等待状态。

虽然 BLOCKED 和 WAITING 都有等待的含义,但二者有着本质的区别,首先它们状态形成的调用方法不同,其次 BLOCKED 可以理解为当前线程还处于活跃状态,只是在阻塞等待其他线程使用完某个锁资源;而 WAITING 则是因为自身调用了 Object.wait() 或着是 Thread.join() 又或者是 LockSupport.park() 而进入等待状态,只能等待其他线程执行某个特定的动作才能被继续唤醒,比如当线程因为调用了 Object.wait() 而进入 WAITING 状态之后,则需要等待另一个线程执行 Object.notify() 或 Object.notifyAll() 才能被唤醒。

wait()方法会释放CPU执行权和占有的锁。

yield()方法仅释放CPU执行权,锁仍然占用。

sleep(long)方法仅释放CPU使用权,锁仍然占用,与yield相比,它会使线程较长时间得不到运行。

所有的状态间切换方法在图中已经描述出来,还是蛮清晰的,相信仔细看一遍就能清楚整体是怎么个逻辑了。

关于LookSupport将会在后面详细描述,这个还是蛮重要的。

并发编程矛盾根源与解决方案

并发编程很复杂,其本质原因是底层硬件与操作系统为了获得最大的执行效率而产生的。因此我们把握住一条主线:为了提高执行效率,从而引入一些策略,这些策略又会导致其他问题,为了解决这些问题又会引入其他策略,最终达成平衡。这个过程就是我们理解底层的主线。

CPU运行效率提升策略

流水线技术

流水线技术和并发问题关系不大,但是为了叙述完整也将这部分技术描述一下。完整的表述还是很麻烦的,这里简单聊一下。我们知道CPU执行的是一行一行指令,每一行指令都大致上需要经过以下五个步骤:
1)取指,从内存中捕获需要执行的指令
2)译码,将执行进行分解,打通各个部件的电子通路
3)执行,在这个步骤CPU真正获得输入然后输出
4)访存,在这个步骤将CPU的输出写入寄存器
5)写回,在这个步骤将寄存器上的数据写入内存

image.png
由于一个指令还是蛮耗时的,如果需要一条一条等待执行会让CPU的工作得不到释放,例如取指过程不需要用到CPU。这样依赖我们就将执行指令过程分散为多个步骤,然后在同一个时间不同部件同时处理不一样的步骤,如下图所示:
image.png
这样的安排方式就称为流水线作业,可以看到在同一个时间内上述5个步骤可能同时进行(实际上不行,会有访问冲突,这里只是强调了可以在同一个时间同时执行不同的电子元器件执行)。

多进程技术

相对于CPU,IO 太慢,所以早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行,这个过程我们称为"任务切换",而这个 50 毫秒称为"时间片"。

引入任务切换的机制是会让CPU在有限的时间干更多类型的事儿,比如在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。这里的进程在等待 IO 时会释放 CPU 使用权,CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址。

多线程技术

一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的"任务切换"都是指"线程切换"。Java 并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

指令 2:之后,在寄存器中执行 +1 操作;

指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,注意是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程B也会将0加载到寄存器,线程B执行完三个指令之后将数据刷回主内存后,立刻切换到线程A,注意,这个时候线程 A 获取将会开始执行指令2,直接从寄存器上获得数据进行计算,count的值会成为1,然后将这个值刷回主内存。然后你会发现主内存的值还是1,逻辑上应该是2,但是结果是1。这就是我们口中所说的原子性问题。

指令重排技术

在处理器内核中一般会有多个执行单元,比如算术逻辑单元、位移单元等。在引入并行指令集之前,CPU在每个时钟周期内只能执行单条指令,也就是说只有一个执行单元在工作,其他执行单元处于空闲状态;在引入并行指令集之后,CPU在一个时钟周期内可以同时分配多条指令在不同的执行单元中执行。

假设某一段程序有多条指令,不同指令的执行实现也不同。对于一条从内存中读取数据的指令,CPU的某个执行单元在执行这条指令并等到返回结果之前,按照CPU的执行速度来说它足够处理几百条其他指令,而CPU为了提高执行效率,会根据单元电路的空闲状态和指令能否提前执行的情况进行分析,把那些指令地址顺序靠后的指令提前到读取内存指令之前完成。

实际上,这种优化的本质是通过提前执行其他可执行指令来填补CPU的时间空隙,然后在结束时重新排序运算结果,从而实现指令顺序执行的运行结果。

CPU高速缓存

CPU与内存速度不协调

CPU是计算机最核心的资源,它主要用来解释计算机指令及处理计算机软件中的数据。当程序加载到内存中后,操作系统会把当前进程分配给指定的CPU执行,在获得CPU执行权后,CPU从内存中取出指令进行解码,并执行,然后取出下一个指令解码,再次执行。

CPU在做运算时,需要从内存中读取数据和指令,CPU的运算速度远远高于内存的I/O速度,CPU和内存之间的这个速度瓶颈被称为冯诺依曼瓶颈。虽然计算机在不断地迭代升级,但是这个核心的矛盾无法消除。

CPU在做计算时必须与内存交互,即便是存储在磁盘上的数据,也必须先加载到内存中,CPU才能访问。当CPU向内存发起一个读操作时,在等待内存返回之前,CPU都处于等待状态,直到返回之后CPU继续运行下一个指令,这个过程会导致CPU资源的浪费。为了解决这个问题,开发者在硬件设备、操作系统及编译器层面做了很多优化。而这些优化极大提升CPU运行效率,但是同时给多线程埋下祸根。
image.png

CPU增加高速缓存与伪共享

CPU和内存的I/O操作是无法避免的,为了降低内存的I/O耗时,开发者在CPU中设计了高速缓存,用来存储与内存交互的数据。CPU在做读操作时,会先从高速缓存中读取目标数据,如果目标数据不存在,就会从内存中加载目标数据并保存到高速缓存中,再返回给处理器。
在主流的X86架构的处理器中,CPU高速缓存通常分为L1、L2、L3三级,L1和L2缓存是CPU核内的缓存,是属于CPU私有的。L3是跨CPU核心共享的缓存,其中L1缓存又分为L1D一级数据缓存、L1L一级指令缓存,这三级缓存的大小和缓存的访问速度排列为L1 > L2 > L3。
image.png

  • L1是CPU硬件上的一块缓存,它分为数据缓存和指令缓存(指令缓存用来处理CPU必须要执行的操作信息,数据缓存用来存储CPU要操作的数据),它的容量最小但是速度最快,容量一般在256KB左右,好一点的CPU可以达到1MB以上。
  • L2也是CPU硬件上的一块缓存,相比L1缓存来说,容量会大一些,但是速度相对来说会慢,容量通常在256KB到8MB之间。
  • L3是高速缓存中最大的一块,也是访问速度最慢的缓存,它的容量在4MB到50MB之间,它是所有CPU核心共享的一块缓存。

当CPU读取数据时,会先尝试从L1缓存中查找,如果L1缓存未命中,继续从L2和L3缓存中查找,如果在缓存行中没有命中到目标数据,最终会访问内存。内存中加载的数据会依次从内存流转到L3缓存,再到L2缓存,最后到L1缓存。当后续再次访问存在于缓存行中的数据时,CPU可以不需要访问内存,从而提升CPU的I/O效率。

缓存是有颗粒度的,基于空间局部性原理:如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。在x86架构中,每个缓存行大小为8个字节,CPU每次从内存中加载连续位置的8字节数据作为一个缓存行保存到高速缓存中。

假设第一种情况:在Java中一个long类型是8字节,因此一个缓存行中可以存8个long类型的变量,假设当前访问的是一个long类型数组,当数组中的一个值被加载到缓存中时,也会同步加载另外7个。根据空间局部性原理的大前提,另外7个Long值将很快被使用,因为缓存行已经包含剩下的数据,因此CPU可以减少与内存的交互,这是缓存行的优势。

假设另一种情况:有两个线程,分别访问上述long类型数组的不同的值,比如线程A访问long[1],线程B访问long[4],由于缓存行的机制使得两个CPU的高速缓存会共享同一个缓存行,为了保证缓存的一致性,CPU会不断使缓存行失效,并重新加载到高速缓存。如果这两个线程竞争非常激烈,就会导致缓存频繁失效,这就是典型的伪共享问题。

我们使用这个代码更加直观地理解伪共享问题:

package com.zifang.ex.bust.charpter12;

public class FalseSharingExample implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;
    private static ValueNoPadding[] longs;

    public FalseSharingExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 10; i++) {
            System.gc();
            final long start = System.currentTimeMillis();
            runTest(i);
            System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start));
        }
    }

    private static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        longs = new ValueNoPadding[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValueNoPadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharingExample(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }

    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L;
        }
    }

    public final static class ValuePadding {
        protected long p1, p2, p3, p4, p5, p6, p7; //前置填充
        protected volatile long value = 0L;
        protected long p9, p10, p11, p12, p13, p14, p15; //后置填充    
    }    
    
    public final static class ValueNoPadding {
            protected volatile
            long value = 0L;
        }
    }
}

上述代码的核心功能就是,通过创建多个线程并对共享对象的值进行修改,来模拟伪共享的问题,代码中定义了如下两个静态类。

  • ValuePadding,针对成员变量value做了对齐填充,其中p1、p2、p3、p4、p5、p6、p7作为前置填充,p9、p10、p11、p12、p13、p14、p15作为后置填充。之所以要做前后置填充,就是为了使value不管在哪个位置,都能够保证它处于不同的缓存行中,避免出现伪共享问题。
  • ValueNoPadding,没有做对齐填充。

运行上述代码,执行结果如下:

1 Threads, duration = 297
2 Threads, duration = 170
43 Threads, duration = 180
74 Threads, duration = 186
55 Threads, duration = 291
16 Threads, duration = 329
27 Threads, duration = 302
18 Threads, duration = 306
19 Threads, duration = 256

下面把实例对象改成ValuePadding,代码如下。

private static void runTest(int NUM_THREADS) throws InterruptedException {
  Thread[] threads = new Thread[NUM_THREADS];
  longs = new ValuePadding[NUM_THREADS];
  for (int i = 0; i < longs.length; i++) {
    longs[i] = new ValuePadding();
  }
  for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(new FalseSharingExample(i));
  }
  for (Thread t : threads) {
    t.start();
  }
  for (Thread t : threads) {
    t.join();
  }
}

获得到的输出是:

1 Threads, duration = 307
2 Threads, duration = 317
3 Threads, duration = 375
4 Threads, duration = 365
5 Threads, duration = 346
6 Threads, duration = 411
7 Threads, duration = 443
8 Threads, duration = 454
9 Threads, duration = 46

可以很明显地发现,做了缓存行填充的程序,其运行效率提高了近10倍。

在java中想按照上面展示的策略,利用主动填充参数的方式来避免伪共享问题的话还是比较繁琐的,因此 JDK1.8 提供了@Contended注解,该注解的作用是实现缓存行填充,解决伪共享的问题。@Contended注解可以添加在类上,也可以添加在字段上,当添加在字段上时,可以保证该字段处于一个独立的缓存行中。在使用时,为了确保@Contended注解生效,我们需要配置一个JVM运行时参数:

-XX:-RestrictContende

类级别和字段级别修饰的使用方法如下。

@Contended
public final static class ValuePadding {
  protected volatile long value = 0L;
}
public final static class ValuePadding {
  @Contended    
  protected volatile long value = 0L;
}

@Contended注解还支持一个contention group属性(针对字段级别),同一个group的多个字段在内存上是连续存储的,并且能和其他字段隔离开来。

public final static class ValuePadding {    
  @Contended("group0")    
  protected volatile long value = 0L;    
  @Contended("group0")    
  protected volatile long value1=0L;    
  protected volatile long value2=0L;
}

上述代码就是把value和value1字段放在了同一个group中,这意味着这两个字段会放在同一个缓存行,并且和其他字段进行缓存行隔离。而value2没有做填充,如果对value2进行更新,则仍然会存在伪共享问题。

CPU缓存一致性保障策略

CPU高速缓存的设计极大地提升了CPU的运算性能,但是它存在一个问题:在CPU中的L1和L2缓存是CPU私有的,如果两个线程同时加载同一块数据并保存到高速缓存中,再分别进行修改,那么缓存的一致性是无法得到保障的。如下图:
image.png

两个CPU的高速缓存中都缓存了x=0这个值,其中CPU1将x=0修改成了x=1,这个修改只对本地缓存可见,而当CPU0后续对x再进行运算时,它获取的值仍然是0,这就是缓存不一致的问题。

CPU缓存一致性保障策略

为了解决缓存一致性问题,开发者在CPU层面引入了总线锁定机制和缓存一致性协议机制。至于CPU最终用哪种策略来解决缓存一致性问题,取决于当前CPU是否支持缓存一致性协议,如果不支持,就会采用总线锁。

总线锁定机制

总线锁是在总线上声明一个Lock#信号,这个信号能够确保共享内存只有当前CPU可以访问,其他的处理器请求会被阻塞,这就使得同一时刻只有一个处理能够访问共享内存,从而解决了缓存不一致的问题。

缓存一致性协议机制:

总线锁定将会使CPU的利用率会变得很差,于是CPU开始增加缓存一致性协议机制。

缓存一致性机制指的是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取,不同的CPU类型支持的缓存一致性协议也有区别,比如MSI、MESI、MOSI、MESIF协议等,比较常见的是MESI(Modified Exclusive Shared Or Invalid)协议。

MESI协议是以缓存行的几个状态来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

  • M(Modify)
    表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,缓存的数据和主内存中的数据不一致。
  • E(Exclusive)
    表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改。
  • S(Shared)
    表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致。
  • I(Invalid)
    表示缓存已经失效。

这四种状态会基于CPU对缓存行的操作而产生转移,所以MESI协议针对不同的状态添加了不同的监听任务。

  • 如果一个缓存行处于M状态,则必须监听所有试图读取该缓存行对应的主内存地址的操作,如果监听到有这类操作的发生,则必须在该操作执行之前把缓存行中的数据写回主内存。
  • 如果一个缓存行处于S状态,那么它必须要监听使该缓存行状态设置为Invalid或者对缓存行执行Exclusive操作的请求,如果存在,则必须要把当前缓存行状态设置为Invalid。
  • 如果一个缓存行处于E状态,那么它必须要监听其他试图读取该缓存行对应的主内存地址的操作,一旦有这种操作,那么该缓存行需要设置为Shared。

这个监听过程是基于CPU中的Snoopy嗅探协议来完成的,该协议要求每个CPU缓存都可以监听到总线上的数据事件并做出相应的反应。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

所有CPU都会监听地址总线上的事件,当某个处理器发出请求时,其他CPU会监听到地址总线的请求,根据当前缓存行的状态及监听的请求类型对缓存行状态进行更新。

CPU缓存一致性的优化策略

CPU通过引入高速缓存来提升其利用率,并且基于缓存一致性协议来保证缓存的一致性,但是缓存一致性协议会影响CPU的使用率。

假设存在一个S状态的缓存行,如果CPU0对这个缓存进行修改,那么CPU0需要发送一个Invalidate消息到CPU1,在等待CPU1返回Acknowledgement消息之前,CPU0一直处于空闲状态。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

为了减少这种缓存一致性协议带来的CPU闲置问题,开发者在CPU层面设计了一个Store Buffers。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在CPU中引入Store Buffers的设计后,CPU0会先发送一个Invalidate消息给其他包含该缓存行的CPU1,并把当前修改的数据写入Store Buffers中,然后继续执行后续的指令。等收到CPU1的Acknowledgement消息后,CPU0再把Store Buffers移到缓存行中。

这种优化的思想有点类似于我们在实际项目开发中的异步化思维方式,Store Buffers就像一个流量削峰的异步队列,CPU可以把指令直接放在该队列中继续往后执行,从而减少缓存同步导致的CPU性能损耗。但是这种优化方式存在问题,我们来看下面这段代码:

a = 1;
b = a + 1;
assert (b == 2);

从理论上来说,assert(b==2)断言的结果一定是true,但是根据逻辑推演,实际上可能会得到一个false的结果,我们基于上面的图来分析一下产生的原因。

假设a变量的缓存状态是Shared,并且缓存在CPU1及其他CPU核心上,此时CPU0开始执行a=1指令,具体流程分析如下。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. CPU0执行a=1指令,此时a不存在于CPU0的缓存中,但是在其他CPU缓存中它是Shared状态,所以CPU0会发送一个MESI协议消息read invalidate给CPU1,试图从其他缓存了该变量的CPU中读取a的值,并且使得其他CPU中a的缓存行失效。
  2. CPU0把a=1写入Store Buffers。
  3. CPU1收到read invalidate消息后,返回Read Response(在CPU1缓存行中的值a=0)和Invalidate Acknowledge(让CPU1中a=0的缓存行失效变成Invalid状态)。
  4. 由于Store Buffers的存在,CPU0在等待CPU1返回之前,继续往下执行b=a+1指令。此时Cache Line中还没有加载b,于是发出read invalidate消息,从内存加载b=0。
  5. CPU0收到Read Resposne,更新CPU0的缓存行(a=0),接着CPU0从缓存行中加载 a=0 的值,完成b=a+1的计算,此时b = 0
  6. CPU0将Store Buffers中a=1的值同步到缓存行中。
  7. CPU0最后执行assert(b==2),断言失败。

导致问题的根本原因在于这里的a变量,同时存在于缓存行及Store Buffers中,这两个位置的值不同使得最终运行的结果产生了问题。

因此硬件工程师们继续优化,引入了Store Forwarding机制。Store Forwarding是指每个CPU在加载数据之前,会先引用当前CPU的Store Buffers,也就是说支持将CPU存入Store Buffers的数据传递给后续的加载操作,而不需要经过Cache。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

CPU0在计算b=a+1指令时,会从缓存行中加载a的值,在引入Store Forwarding机制之后,CPU0会直接从Store Buffers中加载数据,而在Store Buffers中a=1,所以最终能够保证结果是2。这个方法似乎完美地解决了CPU利用率及可见性问题,但实际上并非如此,我们继续来看一个例子。

int a=0,b=0;
executeToCPU0(){
  a=1;    
  b=1;
}
executeToCPU1(){    
  while(b==1){
    assert(a==1);    
  }
}

这段代码初始化了两个变量a和b,初始值都为0,假设CPU0执行executeToCPU0()方法,CPU1执行executeToCPU1()方法,并且a存在于CPU1的高速缓存中,b存在于CPU0的高速缓存中,a和b都是Execution状态。上述程序可能会出现b==1返回true的结果,但是断言失败。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

具体流程分析如下。

  1. CPU0执行a=1指令,a是独占状态且a不存在于CPU0的缓存行中,因此CPU0把a=1写入Store Buffers中并发送MESI协议消息read invalidate给CPU1。
  2. 接着CPU1执行while(b==1),同样CPU1的缓存行中没有b变量的缓存,所以CPU1发出一个MESI协议消息read invalidate给CPU0
  3. CPU0执行b=1指令,而b变量存在于CPU0的缓存行中,也就是说缓存行处于modified或exclusive状态。因此直接把b=1的值写入缓存行中。
  4. 此时,CPU0收到CPU1发来的read invalidate消息,将缓存行中的b=1返回给CPU1,并修改该缓存行状态为Shared。
  5. CPU1收到包含b的缓存行,将其保存到CPU的高速缓存中,状态为Shared。
  6. 获取b=1的值之后,CPU1可以继续执行assert(a==1)指令,此时CPU1的缓存行中包含a=0的值,所以断言返回为false。
  7. CPU1收到CPU0的read invalidate消息,把包含a=0的缓存行返回给CPU0,并且让当前缓存行设置为Invalid状态,但是这个过程比前面的异步步骤执行更晚,已经导致了可见性问题。
  8. 最后CPU0收到包含a的缓存行后,把Store Buffers中a=1的结果同步到CPU0的缓存行中。

出现这个问题的原因是CPU不知道a和b之间的数据依赖,CPU0对a的写入需要和其他CPU通信,因此有延迟,而对b的写入直接修改本地缓存行就行,因此b比a先在缓存行中生效,导致当CPU1读到b=1时,a还存在于Store Buffers中。

从代码的角度来看,executeToCPU0()方法似乎变成了这个样子:

executeToCPU0(){
    b=1;    
    a=1;
}

这就是Store Buffers导致Read操作的指令重排序问题,Read操作重排序之后,在多线程环境下就会产生可见性问题。

Store Buffers的存在确实更进一步提升了CPU的利用率,但是Store Buffers本身的存储容量是有限的,在当前CPU的所有写入操作都存在缓存未命中的情况时,就会导致Store Buffers很容易被填充满。被填满之后,必须要等到CPU返回Invalidate Acknowledge消息,Store Buffers中对应的指令才能被清理,而这个过程CPU必须要等待,无论该CPU中后续指令是否存在缓存未命中的情况。

当前CPU之所以要等待Invalidate Acknowledge返回后才去清理指令,是因为CPU必须要确保缓存的一致性。但是如果收到Invalidate消息的CPU此时处于繁忙状态,那么会导致Invalidate Acknowledge消息返回延迟。我们发现,该CPU在发送Invalidate Acknowledge消息之前,并不需要立刻使缓存行失效,反过来,我们也可以按照Store Buffers的设计理念,增加一个Invalidate Queues,用于存储让缓存行失效的消息。也就是说,CPU收到Invalidate消息时,把让该缓存行失效的消息放入Invalidate Queues,然后同步返回一个Invalidate Acknowledge消息。这样就大大缩短了响应的时间,

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

增加Invalidate Queues的优化之后,CPU发出的Invalidate消息能够很快得到其他CPU发送的Invalidate Acknowledge消息,从而加快了Store Buffers中指令的处理效率,减少了CPU因此导致的阻塞问题。但是,Invalidate Queues存在会导致CPU内存系统的Write操作的重排序问题,下面我们来分析一种可能存在的情况,代码如下。

int a=0,b=0;
executeToCPU0(){
	a=1;    
  b=1;
}
executeToCPU1(){    
  while(b==1){
    assert(a==1);    
  }
}

仍然假设a、b的初始值为0,a在CPU0、CPU1中均为Shared状态,b在CPU0中属于Exclusive状态,CPU0执行executeToCPU0()方法,CPU1执行executeToCPU1()方法,最终的结果仍然是assert(a==1)返回false。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

具体流程分析如下:

  1. CPU0执行a=1,而a的缓存行处于共享状态,所以CPU0需要把a=1的指令保存到Store Buffers中,并且发送一个Invalidate的MESI协议消息给CPU1。
  2. CPU1执行while(b==1)指令,但是b并不存在于CPU1的缓存行中,因此发送一个Read的MESI协议消息。
  3. CPU1收到CPU0的invalidate消息,并把该消息放入Invalidate Queue中,然后返回一个Invalidate Acknowledge。
  4. CPU0收到CPU1的返回消息,把a=1放到CPU0的缓存行中。
  5. CPU0执行b=1,而b此时是独占状态,并且存在于CPU0的高速缓存中,所以直接修改缓存行中b的值,此时在CPU0的缓存行中b=1。
  6. CPU0收到CPU1的Read消息,于是从缓存行中把b=1的结果返回给CPU1,CPU1收到结果后把b=1保存到缓存行中,并且变更b的状态为Shared。
  7. 此时CPU1中b1成立,继续执行assert(a1)指令,由于a=1这个指令修改后,CPU1收到Invalidate消息并没有立即处理,而是放入InvalidateQueues中,因此,此时CPU1读取的a仍然是旧的值0,导致断言失败。
  8. CPU1这时才处理Invalid Queues中的消息,把包含a的缓存行设置为Invalid状态。

总的来说,断言失败的根本原因是:CPU1在读取a的缓存行时,没有先处理Invalidate Queues中的缓存行的失效操作。Invalidate Queues的优化和StoreBuffers的优化会分别带来Store和Load指令的内存系统重排序,最终导致可见性问题。

CPU内存屏障

多核情况下,缓存一致性协议无法彻底解决问题。因此CPU设计者们提供了一个内存屏障指令,开发者可以在合适的位置插入内存屏障指令,相当于告诉CPU指令之间的关系,避免CPU内存系统重排序问题的发生。大多数处理器都会提供以下内存屏障指令,在x86指令中的内存屏障如下:

  • lfence,读屏障指令
    使validate Queues中的指令立即处理,并且强制读取CPU的缓存行。执行lfence指令之后的读操作不会被重排序到执行lfence指令之前,这意味着其他CPU暴露出来的缓存行状态对当前CPU可见。
  • sfence,写屏障指令
    将Store Buffers中的修改刷新到本地缓存中,使得其他CPU能够看到这些修改,而且在执行sfence指令之后的写操作不会被重排序到执行sfence指令之前,这意味着执行sfence指令之前的写操作一定要全局可见(内存可见性及禁止重排序)。
  • mfence,读写屏障指令
    相当于lfence和sfence的混合体,保证mfence指令执行前后的读写操作的顺序,同时要求执行mfence指令之后的写操作的结果全局可见,执行mfence指令之前的写操作结果全局可见。

任何带有lock前缀的指令都会有内存屏障的作用。

在不同的应用中,为了防止CPU的指令重排序,必然会使用到CPU提供的内存屏障指令。在Linux系统的内核中,这三种指令分别封装成smp_mb()、smp_rmb()和smp_wmb()方法,以下是最新的Linux 5.12.9内核代码中barrier.h文件中的关于内存屏障的定义。

#define dma_rmb()  barrier()
#define dma_wmb()  barrier()
#ifdef CONFIG_X86_32
#define __smp_mb() asm volatile("lock; addl $0,-4(%%esp)" ::: "memory", "cc")
#else
#define __smp_mb() asm volatile("lock; addl $0,-4(%%rsp)" ::: "memory", "cc")
#endif
#define __smp_rmb() dma_rmb()
#define __smp_wmb() barrier()

上述方法表示在多处理器环境下可以调用内存屏障方法。

smp_mb() 是全屏障,基于lock指令来实现,该lock指令和前文提到的lock指令是同一个,它有两个作用:

  • 声明lock指令后,在多处理器环境下,通过总线锁/缓存锁机制来保证执行指令的原子指令。
  • ock指令隐含了一个内存屏障的语义,也就是说,修饰了lock指令的数据能够避免CPU内存重排序问题。

smp_wmb() 是通过barrier()方法实现写屏障方法的,它是一个编译器重排序的宏定义,实现代码如下。其中barrier()方法只约束gcc编译器防止编译重排序,而不约束CPU的行为,它会告诉编译器内存的变量值发生了变化,之前存储在寄存器及缓存中的变量副本无效,需要通过内存访问保证数据的实时性,也就是说它能够保证 smp_wmb() 屏障之前的指令全局可见,避免写操作的指令重排序问题。

#define barrier() __asm__ __volatile__("":::"memory")

smp_rmb() 是一个读屏障指令封装方法,它也是通过barrier()方法来实现的。在barrier.h文件中,开发者还提供了多处理器和单处理器都适用的内存屏障方法,代码如下:

#ifdef CONFIG_X86_32
  #define mb() 
    asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "mfence", X86_FEATURE_XMM2) ::: "memory", "cc")
  #define rmb() 
    asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "lfence", X86_FEATURE_XMM2) ::: "memory", "cc")
  #define wmb() 
    asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "sfence", X86_FEATURE_XMM2) ::: "memory", "cc")
#else
  #define mb()
    asm volatile("mfence":::"memory")
  #define rmb()   
    asm volatile("lfence":::"memory")
  #define wmb()   
    asm volatile("sfence" ::: "memory")
#endif

CONFIG_X86_32的意思是,这可能是一个32位的x86架构的系统,也可能是一个64位的X86架构的系统,根据32位或者64位分别定义了内存屏障方法mb()、rmb()和wmb()。可以看到在64位的系统中,针对这三个内存屏障方法,分别用到了CPU提供的内存屏障指令mfence、lfence和sfence。

有了内存屏障指令之后,继续回到前面的问题上,我们在1、2两个位置分别添加smp_wmb()和smp_rmb()方法,代码如下:

int a=0,b=0;
executeToCPU0(){    
	a=1;    
	smp_wmb(); //1    
	b=1;
}
executeToCPU1(){    
	while(b==1){  
 		smp_rmb(); //2       
 		assert(a==1);   
  }
}

smp_wmb()方法,触发一个写屏障指令,a=1的写入操作必须在b=1的写入操作之前完成,相当于把Store Buffers中的数据刷新到CPU本地缓存,这是写屏障的作用。

smp_rmb()方法,触发一个读屏障指令,CPU执行smp_rmb()方法时,会先把当前Invalidate Queues中的数据处理掉,再执行屏障后的读取操作,以保证a读取的值是最新的。

通过引入这两个屏障,使得使用者在存在数据关系的位置建立顺序关系,从而解决了Store Buffers和Invalidate Queues导致的问题。

JVM视角的并发编程核心问题

前面使用大量篇幅描述了计算机效率提升的各种策略,这些策略将导致在多线程运行的场景下出现的各种问题。JVM是C语言实现的,可以说我们的所有代码事实上都是JVM在背后执行的。我们前面也探究过java内发起一个线程事实上是委托JVM制造一个新的线程。本质上和在C语言上发起多线程所面临的多线程问题是一致的。只不过在JVM上,JVM专门制造出了一层处理多线程的机制与工具,并屏蔽各种底层硬件,让开发者享受便利。

总而言之,在JVM视角上,同样面临三个复杂问题:

  1. 可见性问题
  2. 重排序问题
  3. 原子性问题

三个问题事实上都是互相牵涉的,无法完全分开。在前面描述的一步一步优化计算机执行效率的问题上,我们可以感受到这一点。JVM面对可见性问题与硬件层面面对的可见性问题一致,但是JVM还需要处理的事情是统一化硬件。也就是说不通的CPU型号对可见性问题的处理不太一样,因此JVM制造了JMM来屏蔽这个事儿。我们来看一个可见性问题的经典例子:

public class Test {
    private static long count = 0;
    private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            count += 1;
        }
    }
    public static long calc() throws InterruptedException {
        Test test = new Test();
        // 创建两个线程,执行add()操作
        Thread th1 = new Thread(()->{
            test.add10K();
        });
        Thread th2 = new Thread(()->{
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calc());
    }
}

线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

但是,仔细思考,事实上原子性问题也同样会导致这个问题。也就是说上面的结果的根因很可能不是纯粹的可见性问题导致的。假设count在执行自增操作的中途,CPU时间片切换,也就是说线程发生上下文切换,而没有可见性保证的情况下,会导致两个线程的执行是覆盖式执行,这样的行为将会导致自增量丢失。单核不会出现可见性问题,但是在多核的场景下,且没有可见性的保证下,时间片切换将导致的数据不一致。因此我们需要保证原子性,也就是保证一个事儿必须做完,不能被打断,并且必须要求保证可见性。Java领域里,原子性保障依旧需要JVM出面与底层打通,换言之,JVM必须抽象出来,让Java开发者可以很方便的解决这个问题。

可见性问题往往都伴随着而可见性问题往往都伴随着排序性问题,这是由于CPU需要提高效率导致。但是JVM在这个基础之上再增加了一个排序性问题,那就是编译时指令重排。同样,JVM的目的是在更高维度上优化我们的Java代码。我们看这个例子:

public class VolatileExample {
    public static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int i=0;
            while (!stop){
                i++;
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
}}

代码的逻辑很简单,首先t1线程通过stop变量来判断是否应该终止循环,然后在main线程中通过修改stop变量的值来破坏t1线程的循环条件从而退出循环。但是,实际情况是t1线程并没有按照期望的结果执行,该线程一直处于运行状态。这个程序的问题只有在HotSpot的Server模式中才会出现,在HotSpot虚拟机中内置了两个即时编译器,分别是C1编译器和C2编译器,程序使用哪个编译器取决于JVM虚拟机的运行模式。C2编译器是专门面向服务器端的、充分优化过的高级编译器。它有一些比较典型的优化功能,例如:

  1. 无用代码消除(Dead CodeElimination)
  2. 循环展开(Loop Unrolling)
  3. 循环表达式外提(Loop Expression Hoisting)
  4. 消除公共子表达式(Common SubexpressionElimination)

在上面的例子中,执行时默认启用C2编译器的,C2编译器中的循环表达式外提策略将发挥作用,优化后代码会变成这样:

Thread t1=new Thread(()-> {    
  int i=0;    
  if(!stop){        
    while(true){
      i++;
    }  
  }
});

从上面代码中我们发现,被优化的代码对stop变量不具备变化的能力,因此会导致当其他线程修改stop的值时,该线程无法读取。为了防止因优化而产生问题,我们可以增加一个JVM参数:

-Djava.compiler=NONE

再次运行VolatileExample程序,发现能够正常执行结束。但是通过JVM参数来禁止JIT优化是全局的操作,会影响整个程序的优化,代价很大。

JVM的统一策略

JMM模型

在多线程环境中导致可见性问题的根本原因是CPU的高速缓存及指令重排序,虽然CPU层面提供了内存屏障及锁的机制来保证有序性,然而在不同的CPU类型中,又存在不同的内存屏障指令。Java作为一个跨平台语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,针对不同的底层操作系统和硬件也要提供统一的线程安全性保障,因此JVM需要一个逻辑模型来屏蔽,而Java Memory Mode就是这样一个模型。

对于Java而言, 定义出 JMM这一套读写数据的规范之后,我们就不再需要关心底层的复杂性,只需要关心 JMM 抽象出来的主内存和工作内存的概念。JMM 是一种规范,该规范定义了线程和主内存之间访问规则的抽象关系,JMM有以下规定:

  • 每个线程有一个用来存储数据的工作内存(CPU寄存器/高速缓存的抽象),工作内存保存了主内存(共享内存)中的变量副本,线程对所有变量的操作都是在工作内存中进行的。
  • 每个线程之间的工作内存是相互隔离的,数据的变更需要通过主内存来完成。
  • 所有变量都存储在主内存中。

Java Memory Mode并不像JVM的内存结构一样真实存在,它只是描述了一个线程对共享变量的写操作何时对另外一个线程可见。

as-if-serial语义

前面聊过CPU级别的指令重排序,JVM引入as-if-serial语义,要求不管怎么重排序,程序执行结果不能被改变。

as-if-serial表示所有的程序指令都可以因为优化而被重排序,但是在优化的过程中必须要保证是在单线程环境下,重排序之后的运行结果和程序代码本身预期的执行结果一致,Java编译器、CPU指令重排序都需要保证在单线程环境下的as-if-serial语义是正确的。as-if-serial语义允许重排序,CPU层面的指令优化依然存在。在单线程中,这些优化并不会影响整体的执行结果,在多线程中,重排序会带来可见性问题。另外,为了保证as-if-serial语义是正确的,编译器和处理器不会对存在依赖关系的操作进行指令重排序,因为这样会影响程序的执行结果。我们来看下面这段代码。

public void execute(){
    int x=10;  //1
    int y=5;   //2
    int c=x+y; //3
}

上述代码按照正常的执行顺序应该是1、2、3,在多线程环境下,可能会出现2、1、3这样的执行顺序,但是一定不会出现3、2、1这样的顺序,因为3与1和2存在数据依赖关系,一旦重排序,就无法保证as-if-serial语义是正确的。

JVM的内存屏障指令

虽说每个线程的内存彼此隔离,我们称其为线程的工作内存,但是在计算机的视角,JVM整个都是计算机内存。因此线程修改数据也相当于是修改CPU缓存数据,然后再通过缓存一致性机制来保障多个线程之间数据的可见性。但是纯粹使用CPU级别的缓存一致性是不能解决问题的,因此JVM层面必须要在指令层面增加内存屏障。而JVM的目标是统一计算机系统,计算机底层层面可以实现并不一致。

首先,我们抽象写入与读出的行为成为两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。

那么就会产生出四种可能得行为,针对这四种行为分别制造四种屏障,对应的Java中有四种类型:LoadLoad Barriers、StoreStore Barriers、LoadStore Barriers、 StoreLoad Barriers。如下表所示:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

其中StoreLoad Barriers相当于一个全屏障,相当于实现了前面三种屏障的效果,它可以防止Store1指令在应用到内存之前,Load2指令从其他CPU的缓存行中读取缓存了相同变量的值,保证Load2指令加载数据的准确性。大多数处理器都支持StoreLoad屏障,但同时它也是代价最大的,因为它会把处理器的Store Buffers刷新到内存中,并且使Invalidate Queues生效。

JVM中关于内存屏障方法的定义在orderAccess.hpp文件中,由于内存屏障需要解决在不同系统和CPU类型上都支持一致的可见性问题,所以它针对不同CPU和操作系统提供了不同的实现。在Linux系统的X86架构的CPU中,orderAccess_linux_x86.inline.hpp是对访问顺序的具体实现,代码如下:

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }
inline void OrderAccess::acquire() {    
	volatile intptr_t local_dummy;    
	#ifdef AMD64    
	__asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");    
	#else    
	__asm__ volatile ("movl 0(%%esp),%0" : "=r" (local_dummy) : : "memory");    
	#endif // AMD64
}
inline void OrderAccess::release() {    volatile jint local_dummy = 0;}
// 通过编译器层面的防止重排序指令volatile及CPU提供的内存屏障指令来彻底解决指令重排序导致的可见性问题
inline void OrderAccess::fence() {    
	if (os::is_MP()) { //如果是多核
        #ifdef AMD64
        // __asm__ 用于指示编译器在此插入汇编语句
        // volatile 表示禁止编译器的指令重排序,编译器层面的重排序使用C++层面的volatile解决
    		// lock 解决解决缓存一致性问题,实现内存屏障
        __asm__ volatile ("lock; addl $0,0(%% rsp)" : : : "cc", "memory");        
        #else
        __asm__ volatile ("lock; addl $0,0(%% esp)" : : : "cc", "memory"); 
        #endif
  }
}

除storeload()方法外,其他方法只做了编译器层面的内存屏障实现。比如acquire()方法,通过memory指令来提示编译器,内存数据已经被修改,让CPU重新从内存中加载该数据。

Happen-before规则

在Java Memory Model中,除主动通过volatile、synchronized等关键字来保证可见性外,还定义了happens-before模型。在JSR-133中,happens-before用来描述两个操作指令的顺序关系,如果一个操作和另外一个操作存在happens-before关系,那么意味着第一个操作的执行结果对第二个操作可见。具体来说,假设存在两个指令x和y,如果x happens-before y,那么意味着x的执行结果对y可见。

public class Visibility {

    int x = 0;

    public void write() {
        x = 1;
    }

    public void read() {
        int y = x;
    }
}

代码很简单,类里面有一个 int x 变量 ,初始值为 0,而 write 方法的作用是把 x 的值改写为 1, 而 read 方法的作用则是读取 x 的值。

如果有两个线程,分别执行 write 和 read 方法,那么由于这两个线程之间没有相互配合的机制,所以 write 和 read 方法内的代码不具备 happens-before 关系,其中的变量的可见性无法保证,下面我们用例子说明这个情况。

比如,假设线程 1 已经先执行了 write 方法,修改了共享变量 x 的值,然后线程 2 执行 read 方法去读取 x 的值,此时我们并不能确定线程 2 现在是否能读取到之前线程 1 对 x 所做的修改,线程 2 有可能看到这次修改,所以读到的 x 值是 1,也有可能看不到本次修改,所以读到的 x 值是最初始的 0。既然存在不确定性,那么 write 和 read 方法内的代码就不具备 happens-before 关系。相反,如果第一个操作 happens-before 第二个操作,那么第一个操作对于第二个操作而言一定是可见的。

happens-before 规则如下:
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
5)start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

CAS技术

我们在前面描述过原子性问题,本质上就是因为一般的代码不能保证多个语句被一个线程一口气执行完毕。

为了解决这个原子性问题,JDK提供了CAS机制,CAS 的英文全称是 Compare-And-Swap。

CAS策略的核心思路是,CAS 有三个操作数,分别是内存值 V、预期值 A、要修改的值 B。它可以保证一次的 读-改-写 操作是原子操作。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。在多线程的情况下,各个代码的执行顺序是不能确定的,所以为了保证并发安全,我们可以使用互斥锁。而 CAS 的特点是避免使用互斥锁,当多个线程同时使用 CAS 更新同一个变量时,只有其中一个线程能够操作成功,而其他线程都会更新失败。但是更新失败的线程并不会被阻塞,而是被告知这次由于竞争而导致的操作失败,还可以再次尝试。

从CAS的行为上,我们可以感知到,CAS总是假设锁竞争的情况是很少的,所以不会上锁,只是在更新的时候会判断一下在此期间别人有没有去更新这个数据。我们给这种行为赋予一个名词:乐观锁。可以说,CAS是乐观锁思维下的一个实现方式。除此之外,例如数据库提供的类似于write_condition的机制,也是基于乐观锁的思想来制造的。

于此相对的,就是悲观锁的思路,悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中悲观锁的实现包括 synchronized 关键字和 Lock 相关类等,例如 Lock 的实现类 ReentrantLock,类中的 lock() 等方法就是执行加锁,而 unlock() 方法是执行解锁。处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁。

sun.misc.Unsafe类

接下来我们来看看,CAS机制背后的支撑。CAS背后的具体实现是Unsafe类,这个类能干的事儿很多,和系统底层运行关系密切。Unsafe类是受保护的,因此没有办法直接new出来,但是我们可以使用反射的方式获得到Unsafe实例:

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

获得到Unsafe实例之后,我们就可以使用Unsafe类造实现一下自己的原子操作了。

public class TestSolveAtomic {

    public long count = 0;

    private static long valueOffset;

    private static Unsafe unsafe;
    static {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            unsafe = (Unsafe) f.get(null);
            // 拿到count在TestSolveAtomic内的偏移量,可以利用偏移量来定位cas修改的位置
            valueOffset = unsafe.objectFieldOffset(TestSolveAtomic.class.getDeclaredField("count"));
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            while (!unsafe.compareAndSwapLong(this, valueOffset ,count,count+1)){
                continue;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TestSolveAtomic test = new TestSolveAtomic();
        Thread th1 = new Thread(()->{
            test.add10K();
        });
        Thread th2 = new Thread(()->{
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        System.out.println(test.count);
    }
}

获得到的输出是20000,和使用锁获得到的结果一致,证明我们的原子操作是成功的。

CAS的应用

前面我们说过,当多个线程同时使用 CAS 更新同一个变量时,只有其中一个线程能够操作成功,而其他线程都会更新失败。但是更新失败的线程并不会被阻塞,而是被告知这次由于竞争而导致的操作失败,还可以再次尝试。也就是说,但凡不成功,就再去尝试修改,直到成功,你会发现,这个行为只要套上循环就可以。例如上面的自己实现的原子操作一样,其中有这样的一个语句:

while (!unsafe.compareAndSwapLong(this,valueOffset ,count,count+1)){
	continue;
}

unsafe的方法就是自己在这里不停地循环,直到目标达成。我们给这种行为定义一个名称:自旋锁。这个名字很应景。

自旋锁不会放弃  CPU  时间片,而是持续循环等待锁的释放,直到成功为止。对应的非自旋锁如果发现此时获取不到锁,它就把自己的线程切换状态,让线程休眠,然后 CPU 就可以在这段时间去做很多其他的事情,直到之前持有这把锁的线程释放了锁,于是 CPU 再把之前的线程恢复回来,让这个线程再去尝试获取这把锁。如果再次失败,就再次让线程休眠,如果成功,一样可以成功获取到同步资源的锁。

可以看出,非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。

为什么要使用自旋锁来处理并发呢?肯定是因为有好处。阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。

一个东西肯定有两面性,自旋锁同样存在缺点。它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也就变大了,后期甚至会超过线程切换的开销,得不偿失。

所以我们就要看一下自旋锁的适用场景。首先,自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。

CAS的缺陷

CAS虽然高效地解决了原子操作,但是还是存在一些缺陷的,主要表现在三个方面:

  1. 循环时间太长
  2. 只能保证一个共享变量原子操作
  3. ABA问题

第一个问题:循环时间太长

自旋锁是依靠循环来保证原子更新的,那么自旋CAS长时间地不成功情况将不可避免,如此一来会给CPU带来非常大的开销。因此在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。

第二个问题:只能保证一个共享变量原子操作

从CAS的实现上就可以感知到,CAS只能保证一个共享变量的原子更新。如果需要让多个共享变量看做整体,保证整体的原子性更新就做不到了。在这种场景下大概率要退化成互斥锁来满足诉求。

当然前辈们还有个招来解决这个问题,例如,将多个局部变量整合为一个字段,例如线程池内的ctl变量(int值)高三位表达状态,后29位表达线程数量。

第三个问题:ABA问题

考虑一个场景,A线程将count值从1改成2,再改成1,B线程如果需要观测count值的变化次数,则会发现少跳变了一次。这就是ABA问题。解决方式很简单,我们只需要在原有的基础上增加版本号即可,JDK自带了AtomicStampedReference类来解决这个问题。

public class ABAQuestion{
    private static AtomicInteger atomicInt = new AtomicInteger(100);
    private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100,0);
 
    public static void main(String[] args) throws InterruptedException{
        Thread thread1 = new Thread(new Runnable(){
            @Override
            public void run(){
                atomicInt.compareAndSet(100, 101);
                atomicInt.compareAndSet(101, 100);
            }
        });
 
        Thread thread2 = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(1);
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
                boolean c3 = atomicInt.compareAndSet(100, 101);
                System.out.println(c3);
            }
        });
 
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
 
        Thread thread3 = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(1);
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
                atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
                atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
            }
        });
 
        Thread thread4 = new Thread(new Runnable(){
            @Override
            public void run() {
                int stamp = atomicStampedRef.getStamp();
                try {
                    TimeUnit.SECONDS.sleep(2);
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
                boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp+1);
                System.out.println(c3);
            }
        });
        thread3.start();
        thread4.start();
    }
}

CAS的底层原理

如果多个线程调用CAS,并且多个线程都去执行预期值与实际值的判断,那么应该还存在原子性问题才对。除非当线程在执行offset偏移量的值和expect进行比较时加锁,保证在同一时刻只允许一个线程来判断。

这是一个很让人困惑的点,我们从源码层面做一个分析,这里的源码指的是JVM层的支持。基于compareAndSwapInt()方法,在JVM源码中的unsafe.cpp文件中找到该方法的定义如下:

//UNSAFE_ENTRY表示一个宏定义
//obj/offset/e/x 分别对应Java中定义的compareAndSwapInt()方法的入参
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))  
  UnsafeWrapper("Unsafe_CompareAndSwapInt");  
  oop p = JNIHandles::resolve(obj);  
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);  
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

代码解读如下:
oop p = JNIHandles::resolve(obj); 这个方法是把Java对象引用转化为JVM中的对象实例。
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); 根据偏移量offset计算value的地址。
(Atomic::cmpxchg(x, addr, e)):比较addr和e是否相等,如果相等就把x赋值到目标字段,该方法会返回修改之前的目标字段的值。

Atomic::cmpxchg()方法的定义在atomic.cpp文件中,代码如下:

unsigned Atomic::cmpxchg(unsigned int exchange_value, volatile unsigned int* dest, unsigned int compare_value) {
    assert(sizeof(unsigned int) == sizeof(jint), "more work to do");  
    return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
                                       (jint)compare_value);
}

该方法并没有定义具体的实现。其实,对于CAS操作,不同的操作系统和CPU架构,其保证原子性的方法可能会不一样,而JVM本身是跨平台的语言,它需要在任何平台和CPU架构下都保证一致性。因此,Atomic::cmpxchg()方法会根据不同的操作系统类型和CPU架构,在预编译阶段确定调用哪个平台下的重载

以Linux系统为例,当定位到到atomic_linux_x86.inline.hpp文件时,Atomic::cmpxchg的具体实现方法如下。

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();  
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                  : "=a" (exchange_value)                    
                  : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)                    
                  : "cc", "memory");  
  return exchange_value;
}

代码说明如下。
mp(multi-processor),os::is_MP()用于判断是否是多核CPU。
asm表示内嵌汇编代码,volatile用于通知编译器对访问该变量的代码不再进行优化。LOCK_IF_MP(%4)表示如果CPU是多核的,则需要为compxchgl指令增加一条Lock指令。具体的执行过程是,先判断寄存器中的compare_value变量值是否和dest地址所存储的值相等,如果相等,就把exchange_value的值写入dest指向的地址。
总的来说,上面代码的功能是基于汇编指令cmpxchgl从主内存中执行比较及替换的操作来实现数据的变更。但是,在多核心CPU的情况下,这种方式仍然不是原子的,所以为了保证多核CPU下执行该指令时的原子性,会增加一个Lock指令。Lock翻译成中文就是锁的意思,按照前面的猜想,CAS底层必然用到了锁的机制,否则无法实现原子性,因此这个猜想被证实是对的。Lock的作用有两个,一是保证指令执行的原子性,二是禁止该指令与其前后的读和写指令重排序。

sychronized关键字

前面说cas机制背后是一种乐观锁的思维方式,与之相对的就是悲观锁的思维方式。synchronized关键字就是悲观锁的经典实现。它解决的是多个线程之间访问资源的同步性,保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。除此之外还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值。

synchronized关键字的使用

synchronized关键字最主要的三种使用方式:

第一种: 修饰实例方法

作用在方法级别,被加锁的对象是当前的对象实例。当多个线程同时访问m1()方法时,同一时刻只有一个线程能执行。

public synchronized void m1(){
   //省略代码
}

第二种: 修饰静态方法

作用在类方法上后,访问静态 synchronized 方法占用的锁是当前类对象的锁。由于所有当前类的实例共享静态方法,因此修饰静态方法会作用于类的所有对象实例。

public static synchronized void m1(){
   //省略代码
}

第三种: 修饰代码块

修饰的代码块儿内可以是一个实例也可以是一个类对象。

public class Lock{ 
    private Lock lock = new Lock();
    
		public void m2(){    
        // 锁住一个实例
				synchronized(lock){
           //省略代码       
  			}
  	}
		public void m2(){    
        // 锁住一个类
				synchronized(Lock.class){
           //省略代码       
  			}
  	}
}

上述三种用法是很简单的,但是需要注意的是,期望两个线程因为锁的存在而产生协作的前提是,这两个线程执行方法时锁住的东西是一个东西。class对象是JVM内保证唯一的,但是实例不是,这里需要特别注意。

synchronized机制产生原理

synchronized 锁住的是实例对象或者类对象,他们都叫做对象,因此从底层上来说,他们是统一的。

我们先从字节码角度入手,表浅地看看 synchronized 是如何发挥作用的。

第一种:代码块执行逻辑

我们先写下这样的代码:

public class SynTest {
    public void synBlock1() {
        synchronized (this) {
            System.out.println("吃饭");
        }
    }
}

利用jdk自带指令获得上面代码的字节码:

 0 aload_0
 1 dup
 2 astore_1
 3 monitorenter
 4 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
 7 ldc #3 <吃饭>
 9 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
12 aload_1
13 monitorexit
14 goto 22 (+8)
17 astore_2
18 aload_1
19 monitorexit
20 aload_2
21 athrow
22 return

第 0-2 是将this引用塞到当前的栈上,第 4 - 12代表的就是 "System.out.println(“吃饭”);"的执行,这几行外侧你会看到 monitorenter与monitorexit的字节码指令将其包裹。

我们利用字节码反推代码真正的逻辑就是:

public class SynTest {
    public void synBlock() {
      lock(this);
      try{
        System.out.println("吃饭");
      } finally{
        unlock(this);
      }
    }
}

lock(this); 对应的就是monitorenter的逻辑,执行monitorenter理解为加锁。

unlock(this) 对应的就是monitorexit的逻辑,执行 monitorexit 理解为释放锁。

monitorenter与monitorexit必然成对出现,上文中19行同样出现monitorexit指令是因为finally保证monitorexit必然执行,保证的机制是将finally的代码拷贝值try语句块的末端。synchronized关键字基于上述两个指令实现了锁的获取和释放过程,解释器执行monitorenter时会进入到InterpreterRuntime.cpp的InterpreterRuntime::monitorenter函数,具体实现如下:
image.png

1、JavaThread thread指向java中的当前线程;

2、BasicObjectLock类型的elem对象包含一个BasicLock类型_lock对象和一个指向Object对象的指针_obj;

class BasicObjectLock {
  BasicLock _lock; 
  // object holds the lock;
  oop  _obj;   
}

3、BasicLock类型_lock对象主要用来保存_obj指向Object对象的对象头数据;

class BasicLock {
    volatile markOop _displaced_header;
}

UseBiasedLocking标识虚拟机是否开启偏向锁功能,如果开启则执行fast_enter逻辑,否则执行slow_enter;

如果synchronized锁住的是类对象呢?

public class SynTest {
    public void synBlock2() {
        synchronized (SynTest.class) {
            System.out.println("吃饭");
        }
    }
}

同样,获得其字节码:

 0 ldc #5 <com/c2f/ace/core/utils/SynTest>
 2 dup
 3 astore_1
 4 monitorenter
 5 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
 8 ldc #3 <吃饭>
10 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
13 aload_1
14 monitorexit
15 goto 23 (+8)
18 astore_2
19 aload_1
20 monitorexit
21 aload_2
22 athrow
23 return

你会看到,与之前相比,0行不一样了,这里是将一个类引用放入操作数里。

第二种:synchronized 方法

对于 synchronized 方法,并不是依靠 monitorenter 和 monitorexit 指令实现的,被 javap 反汇编后可以看到,synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。同步方法的代码如下所示:

public synchronized void synMethod() {}

产生的部分字节码如下:

  public synchronized void synMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 16: 0

可以看出,被 synchronized 修饰的方法会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。其他方面, synchronized 方法和刚才的 synchronized 代码块是很类似的,例如这时如果其他线程来请求执行方法,也会因为无法获得 monitor 锁而被阻塞。

synchronized底层实现原理

Java对象存储结构解析
Java对象存储结构

synchronized是通过锁定一个对象(lock对象)来控制多个线程间的访问互斥性的。因此可以推断出这个lock对象需要记忆住锁的信息。信息存储在jvm所管理的对象上,具体的说就是存储在对象的对象头内。一个Java对象被初始化之后会存储在堆内存中,这个对象在堆内存中可以分为三个部分:对象头,实例数据,对齐填充。其中对象头内分为Mark Word、Klass Pointer、Length。他们之间的关系可以由下图表达:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
接下来,我们主要要了解这些成分的细节。

Mark Word

Mark Word记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时,锁标记和相关信息都是存储在Mark Word中的。而具体的相关存储结构和cpu位数相关。如下图,是32位系统下的MarkWord结构。

以下是64为系统下的MarkWork结构


可以看到不管在32位还是64位系统中,Mark Word中都会包含GC分代年龄、锁状态标记、hashCode、epoch等信息。从图中可以看到一个锁状态的字段,它包含五种状态分别是无锁、偏向锁、轻量级锁、重量级锁、GC标记。Mark Word使用2bit来存储这些锁状态,再额外通过1bit来表达无锁和偏向锁,其中0表示无锁、1表示偏向锁。

Klass Pointer

Klass Pointer表示指向类的指针,JVM通过这个指针来确定对象具体属于哪个类的实例。它的存储长度根据JVM的位数来决定,在32位的虚拟机中占4字节,在64位的虚拟机中占8字节,但是在JDK 1.8中,由于默认开启了指针压缩,所以压缩后在64位系统中只占4字节。如果我们不希望开启压缩指针功能,则可以增加一个JVM参数-XX:-UseCompressedOops。

Length

表示数组长度,只有构建对象数组时才会有数组长度属性。

实例数据

实例数据其实就是类中所有的成员变量。比如,一个对象中包含int、boolean、long等类型的成员变量,这些成员变量就存储在实例数据中。
实例数据占据的存储空间是由成员变量的类型决定的,比如boolean占1字节、int占4字节、long占8字节。如果成员变量是引用类型,那么它的数据大小与虚拟机位数和是否开启压缩指针有关系。

对齐填充

对齐填充本身没有任何含义,其目的是使得当前对象实例占用的存储空间是8字节的倍数,所以如果一个对象的字节大小不是8字节的整数倍,会使用对齐填充来达到这一目的。正因为对象字节大小是8字节的整数倍,因此其引用地址必然是8的倍数,因此存储引用地址的时候,可以抹去后三位地址,达到相同长度地址表示范围扩大的效果。

我们将更多的信息连在一起,来整体理解对象头的存储结构。假定,我们我们写了这样的代码:

public class A {
	private int id;    
	private String name;    
	public static void main(String[] args) {        
		A a = new A();
  }
}

那么在这个代码运行之后,将会形成这样的存储结构。

Java对象存储结构展示工具

为了支持后面的分析,我们需要直观地看到一个对象的内存布局信息。OpenJDK官方提供了一个JOL(Java Object Layout)工具,我们可以使用这个工具获得到一个对象的内存信息。使用步骤如下。
第一步,通过maven依赖引入JOL工具。

<dependency>    
	<groupId>org.openjdk.jol</groupId>    
	<artifactId>jol-core</artifactId>    
	<version>0.9</version>
</dependency>
public class ClassLayoutTest {    
 	public static void main(String[] args) {        
 		ClassLayoutTest example = new ClassLayoutTest();
      	//使用JOL工具打印对象的内存布局        
      	System.out.println(ClassLayout.parseInstance(example).toPrintable());    
    }
}

获得到的打印是:

这里需要对一些字段做简要说明:

  • OFFSET:偏移地址,单位为字节。
  • SIZE:占用的内存大小,单位为字节。
  • TYPE DESCRIPTION:类型描述,其中object header为对象头。
  • VALUE:对应内存中当前存储的值。

对照着字段的解释,针对上面打印出来的内存结构数据可以这样理解:

  • TYPE DESCRIPTION字段对应的部分表示对象头(object header),一共占12字节,前面的8字节对应的是对象头中的Mark Word,最后4字节表示类型指针,它只占4字节是因为默认对指针进行了压缩。
  • TYPE DESCRIPTION字段对应的(loss due to the next object alignment)描述部分,表示对齐填充,这里填充了4字节,从而保证最终的内存大小是8字节的整数倍。最终输出的Instance size: 16 bytes表示当前对象实例占16字节。
  • ClassLayoutTest只是一个空对象定义,因此在打印结果中只有对象头和对齐填充,没有实例数据部分。
Hotspot虚拟机中对象存储的源码

上面我们说得都是概念上的东西,接下来我们直接在JVM源码里面把相关的代码拿出来。我们核心要理解JVM是怎么表达一个对象及其对象头的。当使用new来创建一个普通对象实例的时候,Hotspot虚拟机会创建一个instanceOopDesc对象,而如果对象实例是数组类型,则会创建一个arrayOopDesc对象。

instanceOopDesc对象的定义在instanceOop.hpp文件中,其路径是:hotspot/src/share/vm/oops/instanceOop.hpp,其核心代码如下:

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }

  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedKlassPointers
    // only is true
    return (UseCompressedOops && UseCompressedKlassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }

  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};

#endif // SHARE_VM_OOPS_INSTANCEOOP_HPP

instanceOopDesc继承了oopDesc,oopDesc的定义在oop.hpp文件中,代码如下:

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowOop       _compressed_klass;
  } _metadata;
  // 省略其他代码
}

这种写法给出了C++中的继承关系,在普通实例对象中,oopDesc的定义包含两个成员,分别是_mark和_metadata,Hotspot虚拟机采用OOP-Klass模型来描述Java对象实例,OOP指的是普通对象指针,Klass用来描述对象实例的具体类型。

  • _mark表示对象标记,属于markOop类型,也就是前面提到的Mark Word,它记录了对象和锁有关的信息。
  • metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址。
  • Klass表示普通指针,指向该对象的类元信息,也就是属于哪一个Class实例。
  • _compressed_klass表示压缩指针,默认开启了压缩指针,在开启压缩指针之后,存储中占用的字节数会被压缩。

接着我们重点关注markOop这个对象属性,markOop是一个markOopDesc类型的指针,它的定义在oopsHierarchy.hpp文件中。

typedef class markOopDesc* markOop;

在Hotspot中,markOopDesc这个类的定义在markOop.hpp文件中,代码如下:

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };
  // 省略部分代码 
}

实际上,在markOop.hpp文件的注释中,同样可以看到Mark Word在32位和64位虚拟机上的存储布局。

// The markOop describes the header of an object.
//
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
//
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
sychronized锁类型

在这一部分我们详细聊一聊synchronized都有哪些锁。

在JDK 1.6之前,synchronized只有一种锁,也就是重量级锁。重量级锁依赖于底层操作系统的Mutex Lock来实现,而使用Mutex Lock需要把当前线程挂起,并从用户态切换到内核态来执行,也就是说没有获得锁的线程会通过park方法阻塞,接着被获得锁的线程唤醒后再次抢占锁,直到抢占成功,这种切换带来的性能开销是非常大的。因此在JDK 1.6之后,synchronized做了很多优化,其中针对锁的类型增加了偏向锁和轻量级锁,这两种锁的核心设计理念就是如何让线程在不阻塞的情况下达到线程安全的目的。

偏向锁的原理分析

偏向锁其实可以认为是在没有多线程竞争的情况下访问synchronized修饰的代码块的加锁场景,也就是在单线程执行的情况下。明明假定是单线程,但是还是存在锁的目的是为了防止变成多线程。单线程与多线程是场景控制的,在这个大前提下,增加synchronized关键字是为了防范。

所以偏向锁的作用就是,线程在没有线程竞争的情况下去访问synchronized同步代码块时,会尝试先通过偏向锁来抢占访问资格,这个抢占过程是基于CAS来完成的,如果抢占锁成功,则直接修改对象头中的锁标记。其中,偏向锁标记为1,锁标记为01,以及存储当前获得锁的线程ID。而偏向的意思就是,如果线程X获得了偏向锁,那么当线程X后续再访问这个同步方法时,只需要判断对象头中的线程ID和线程X是否相等即可。如果相等,就不需要再次去抢占锁,直接获得访问资格即可。

例子1

public class BiasedLockExample {
    public static void main(String[] args) {
        BiasedLockExample example = new BiasedLockExample();
        System.out.println("加锁之前");
        System.out.println(ClassLayout.parseInstance(example).toPrintable());
        synchronized (example) {
            System.out.println("加锁之后");
            System.out.println(ClassLayout.parseInstance(example).toPrintable());
        }
    }
}

获得的输出是:

image.png

由于我的电脑存储是小端存储模式,因此上面的value值是需要倒着看的

从上述输出结果中我们发现:

  • 在加锁之前
    对象头中的第一个字节00000001最后三位为[001],低位的两位是[01],表示当前为无锁状态。
  • 在加锁之后
    对象头中的第一个字节10010000最后三位为[000],低位的两位是[00],表示轻量级锁状态。

基于前面的理论分析,在加锁之后逻辑上应该是处在偏向锁状态,但是这里变成了轻量级锁。这里的主要原因是,JVM在启动的时候,有一个启动参数-XX:BiasedLockingStartupDelay,这个参数表示偏向锁延迟开启的时间,默认是4秒,也就是说在我们运行上述程序时,偏向锁还未开启,导致最终只能获得轻量级锁。之所以延迟启动,是因为JVM在启动的时候会有很多线程运行,也就是说会存在线程竞争的场景,那么这时候开启偏向锁的意义不大。

如果我们需要看到偏向锁的实现效果,那么有两种方法:

  1. 添加JVM启动参数-XX:BiasedLockingStartupDelay=0,把延迟启动时间设置为0。
  2. 抢占锁资源之前,先通过Thread.sleep()方法睡眠4秒以上。

例子2

我们把代码改成这样:

public class BiasedLockExample {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000L);
        BiasedLockExample example = new BiasedLockExample();
        System.out.println("加锁之前");
        System.out.println(ClassLayout.parseInstance(example).toPrintable());
        synchronized (example) {
            System.out.println("加锁之后");
            System.out.println(ClassLayout.parseInstance(example).toPrintable());
        }
    }
}

获得到的输出是:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到加锁之后,第一个字节最后的三个位是[101],因此是偏向锁。

轻量级锁的原理分析

前面说到偏向锁能够在不影响性能的前提下获得锁资源,但是同一时刻只允许一个线程获得锁资源,如果突然有多个线程来访问同步方法,那么没有抢占到锁资源的线程就不能使用偏向锁解决了。正常情况下,没有抢占到锁的线程肯定要阻塞等待被唤醒,但是使用重量级锁进行实现又消耗巨大,于是就有了轻量级锁的设计。

轻量级锁,就是没有抢占到锁的线程,进行一定次数的重试,如果在重试的过程中抢占到了锁,那么这个线程就不需要阻塞,这种实现方式我们称为自旋锁。但是自旋也有缺陷,如果持有锁的线程占有锁的时间比较短,而另外的线程不断自旋重试,那么CPU会一直处于运行状态,那么自旋的线程就会浪费CPU资源。因此启用自旋就需要对重试抢占锁的次数必须有一个限制。

在JDK 1.6中默认的自旋次数是10次,我们可以通过-XX:PreBlockSpin参数来调整自旋次数。

除此之外在JDK 1.6中还对自旋锁引入了自适应自旋锁。自适应自旋锁的自旋次数不是固定的,是根据前一次在同一个锁上的自旋次数及锁持有者的状态来决定的。如果在同一个锁对象上,通过自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么JVM会认为此次自旋也有很大的机会获得锁,因此会将这个线程的自旋时间相对延长。反之,如果在一个锁对象中,通过自旋锁获得锁很少成功,那么JVM会缩短自旋次数。

重量级锁的原理分析

轻量级锁能够通过一定次数的重试让没有获得锁的线程有可能抢占到锁资源,但是轻量级锁只有在获得锁的线程持有锁的时间较短的情况下才能起到提升同步锁性能的效果。如果持有锁的线程占用锁资源的时间较长,那么不能让那些没有抢占到锁资源的线程不断自旋,否则会占用过多的CPU资源,这反而是一件得不偿失的事情。
如果没抢占到锁资源的线程通过一定次数的自旋后,发现仍然没有获得锁,就只能阻塞等待了,所以最终会升级到重量级锁,通过系统层面的互斥量来抢占锁资源。

整体来看,我们发现,如果在偏向锁、轻量级锁这些类型中无法让线程获得锁资源,那么这些没获得锁的线程最终的结果仍然是阻塞等待,直到获得锁的线程释放锁之后才能被唤醒。而在整个优化过程中,我们通过乐观锁的机制来保证线程的安全性。

下面这个例子演示了在加锁之前、单个线程抢占锁、多个线程抢占锁的场景中,对象头中的锁的状态变化。

public class HeavyLockExample {
    public static void main(String[] args) throws InterruptedException {
        HeavyLockExample heavy = new HeavyLockExample();
        System.out.println("加锁之前");
        System.out.println(ClassLayout.parseInstance(heavy).toPrintable());
        Thread t1 = new Thread(() -> { 
            synchronized (heavy) {
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();        //确保t1线程已经运行        
        TimeUnit.MILLISECONDS.sleep(500);
        System.out.println("t1线程抢占了锁");
        System.out.println(ClassLayout.parseInstance(heavy).toPrintable());
        synchronized (heavy) {
            System.out.println("main线程来抢占锁");
            System.out.println(ClassLayout.parseInstance(heavy).toPrintable());
        }
    }
}

获得到输出是:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从上述打印结果来看,对象头中的锁状态一共经历了三个类型。

  • 加锁之前,对象头中的第一个字节是00000001,表示无锁状态。
  • 当t1线程去抢占同步锁时,对象头中的第一个字节变成了11011000,表示轻量级锁状态。
  • 接着main线程来抢占同一个对象锁,由于t1线程睡眠了2秒,此时锁还没有被释放,main线程无法通过轻量级锁自旋获得锁,因此它的锁的类型是重量级锁,锁标记为10。

注意,在这个案例演示中,我没有开启偏向锁的参数,如果开启了,那么第一个加锁之后得到的锁状态应该是偏向锁,然后直接到重量级锁(因为t1线程有一个sleep,所以轻量级锁肯定无法获得)。由此可以看到,synchronized同步锁最终的底层加锁机制是JVM层面根据线程的竞争情况逐步升级来实现的,从而达到同步锁性能和安全性平衡的目的,而这个过程并不需要开发者干预。

sychronized锁升级的实现流程

sychronized锁升级的整体过程是:当一个线程访问增加了synchronized关键字的代码块时,如果偏向锁是开启状态,则先尝试通过偏向锁来获得锁资源,这个过程仅仅通过CAS来完成。如果当前已经有其他线程获得了偏向锁,那么抢占锁资源的线程由于无法获得锁,所以会尝试升级到轻量级锁来进行锁资源抢占,轻量级锁就是通过多次CAS来完成的。如果这个线程通过多次自旋仍然无法获得锁资源,那么最终只能升级到重量级锁来实现线程的等待。如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

偏向锁的实现原理

偏向锁使用CAS机制来替换对象头中的Thread Id,如果成功,则获得偏向锁,否则,就会升级到轻量级锁。

偏向锁是在没有线程竞争的情况下实现的一种锁,不能排除存在锁竞争的情况,所以偏向锁的获取有两种情况。

第一种情况:没有锁竞争

在没有锁竞争并且开启了偏向锁的情况下,当线程1访问synchronized(lock)修饰的代码块时:

第一步:从当前线程的栈中找到一个空闲的BasicObjectLock,BasicObjectLock是一个基础的锁对象,它包含以下两个属性:

  • BasicLock,该属性中有一个字段markOop,用于保存指向lock锁对象的对象头数据。
  • oop,指向lock锁对象的指针。

BasicObjectLock也叫LockRecord。

LockRecord是线程私有的数据结构,每个线程都有一个LockRecord列表,每一个LockRecord都会关联到锁对象的MarkWord。

第二步:将BasicObjectLock中的oop指针指向当前的锁对象lock。获得当前锁对象lock的对象头,通过对象头来判断是否可偏向,也就是说锁标记为101,并且Thread Id为空。

  • 如果为可偏向状态,那么判断当前偏向的线程是不是线程1,如果偏向自身,则不需要再抢占锁,直接有资格运行同步代码块。
  • 如果为不可偏向状态,则需要通过轻量级锁来完成锁的抢占过程。

如果对象锁lock偏向其他线程或者当前是匿名偏向状态(也就是没有偏向任何一个线程),则先构建一个匿名偏向的Mark Word,然后通过CAS方法,把一个匿名偏向的Mark Word修改为偏向线程1。如果当前锁对象lock已经偏向了其他线程,那么CAS一定会失败

第二种情况:存在锁竞争

假设线程1获得了偏向锁,此时线程2去执行synchronized(lock)同步代码块,如果访问到同一个对象锁则会触发锁竞争并触发偏向锁撤销,撤销流程如下。

第一步:线程2调用撤销偏向锁方法,尝试撤销lock锁对象的偏向锁。

第二步:撤销偏向锁需要到达全局安全点才会执行,全局安全点就是当前线程运行到的这个位置,线程的状态可以被确定,堆对象的状态也是确定的,在这个位置JVM可以安全地进行GC、偏向锁撤销等动作。当到达全局安全点后,会暂停获得偏向锁的线程1。

第三步:检查获得偏向锁的线程1的状态,这里存在两种状态。

  • 线程1已经执行完同步代码块或者处于非存活状态。在这种情况下,直接把偏向锁撤销恢复成无锁状态,然后线程2升级到轻量级锁,通过轻量级锁抢占锁资源。
  • 线程1还在执行同步代码块中的指令,也就是说没有退出同步代码块。在这种情况下,直接把锁对象lock升级成轻量级锁(由于这里是全局安全点,所以不需要通过CAS来实现),并且指向线程1,表示线程1持有轻量级锁,接着线程1继续执行同步代码块中的代码。

偏向锁的释放

在偏向锁执行完synchronized同步代码块后,会触发偏向锁释放的流程。然而本质上偏向锁本质上并没有释放,因为当前锁对象lock仍然是偏向该线程的。从源码来看,释放的过程只是把Lock Record释放了,也就是说把Lock Record保存的锁对象的Mark Word设置为空。

轻量级锁的实现原理

如果偏向锁存在竞争或者偏向锁未开启,那么当线程访问synchronized(lock)同步代码块时就会采用轻量级锁来抢占锁资源,获得访问资格。

第一步:在线程2进入同步代码块后,JVM会给当前线程分配一个Lock Record,也就是一个BasicObjectLock对象,在它的成员对象BasicLock中有一个成员属性markOop_displaced_header,这个属性专门用来保存锁对象lock的原始Mark Word

第二步:构建一个无锁状态的Mark Word(其实就是lock锁对象的Mark Word,但是锁状态是无锁),把这个无锁状态的Mark Word设置到Lock Record中的_displaced_header字段中。

第三步:通过CAS将lock锁对象的Mark Word替换为指向Lock Record的指针,如果替换成功,就会得到如图2-22所示的结构,表示轻量级锁抢占成功,此时线程2可以执行同步代码块。

第四步:如果CAS失败,则说明当前lock锁对象不是无锁状态,会触发锁膨胀,升级到重量级锁。

相对偏向锁来说,轻量级锁的原理比较简单,它只是通过CAS来修改锁对象中指向Lock Record的指针。从功能层面来说,偏向锁和轻量级锁最大的不同是:

  • 偏向锁只能保证偏向同一个线程,只要有线程获得过偏向锁,那么当其他线程去抢占锁时,只能通过轻量级锁来实现,除非触发了重新偏向(如果获得轻量级锁的线程在后续的20次访问中,发现每次访问锁的线程都是同一个,则会触发重新偏向,20次的定义属性为:XX:BiasedLockingBulkRebiasThreshold =20)
  • 轻量级锁可以灵活释放,也就是说,如果线程1抢占了轻量级锁,那么在锁用完并释放后,线程2可以继续通过轻量级锁来抢占锁资源。

可能有些读者会有疑问,轻量级锁中的CAS操作是先把lock锁对象的Mark Word复制到当前线程栈帧的Lock Record中,然后通过比较lock锁对象的Mark Word和复制到Lock Record中的Mark Word是否相同来决定是否获取锁,那么不是会导致每个线程进来都能CAS成功吗?实际上并非如此,因为每次在CAS之前都会判断锁的状态,只有在无锁状态时才会执行CAS,所以并不会存在多个线程同时获得锁的问题。

轻量级锁的释放

偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。

当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。其他方面, synchronized 方法和刚才的 synchronized 代码块是很类似的,例如这时如果其他线程来请求执行方法,也会因为无法获得 monitor 锁而被阻塞。

第一步:把Lock Record中_displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的Mark Word中,这个过程会采用CAS来完成。

第二步:如果CAS成功,则轻量级锁释放完成。

第三步:假设t1线程获得了轻量级锁,那么当t2线程竞争锁的时候,由于无法获得轻量级锁,所以会触发锁膨胀,在锁膨胀的逻辑中,会判断如果当前的锁状态是轻量级锁,那么t2线程会修改锁对象的Mark Word,将其设置为INFLATING状态(这个过程是采用自旋锁来实现的,当存在多个线程触发膨胀时,只有一个线程去修改锁对象的Mark Word)。如果lock锁对象的Mark Word在锁膨胀的过程中发生了变化,那么持有轻量级锁的线程通过CAS释放时必然会失败,因为存储在当前线程栈帧中的Lock Record的Mark Word和锁对象lock的Mark Word已经不相同了。并且,持有轻量级锁的线程t1在持有锁期间,如果其他线程因为竞争不到锁而升级到重量级锁并且被阻塞,那么线程t1在释放锁时,还需要唤醒处于重量级锁阻塞状态下的线程。因此CAS假如失败,说明释放锁的时候发生了竞争,就会触发锁膨胀,完成锁膨胀之后,再调用重量级锁的释放锁方法,完成锁的释放过程。

偏向锁和轻量级锁的对比

通过对偏向锁和轻量级锁的原理剖析,大家应该对这两种锁的触发场景的认知更加深刻。
偏向锁,就是在一段时间内只由同一个线程来获得和释放锁,加锁的方式是把Thread Id保存到锁对象的Mark Word中。
轻量级锁,存在锁交替竞争的场景,在同一时刻不会有多个线程同时获得锁,它的实现方式是在每个线程的栈帧中分配一个BasicObjectLock对象(Lock Record),然后把锁对象中的Mark Word拷贝到Lock Record中,最后把锁对象的Mark Word的指针指向Lock Record。轻量级锁之所以这样设计,是因为锁对象在竞争的过程中有可能会发生变化,但是每个线程的Lock Record的Mark Word不会受到影响。因此当触发锁膨胀时,能够通过Lock Record和锁对象的Mark Word进行比较来判定在持有轻量级锁的过程中,锁对象是否被其他线程抢占过,如果有,则需要在轻量级锁释放锁的过程中唤醒被阻塞的其他线程。

重量级锁的实现原理

如果线程在运行synchronized(lock)同步代码块时,发现锁状态是轻量级锁并且有其他线程抢占了锁资源,那么该线程就会触发锁膨胀升级到重量级锁。因此,重量级锁是在存在线程竞争的场景中使用的锁类型。

在获取重量级锁之前,会先实现锁膨胀,在锁膨胀的方法中首先创建一个ObjectMonitor对象,然后把ObjectMonitor对象的指针保存到锁对象的Mark Word中,锁膨胀分为四种情况,分别如下。

  • 当前已经是重量级锁的状态,不需要再膨胀,直接从锁对象的Mark Word中获取ObjectMonitor对象的指针返回即可。
  • 如果有其他线程正在进行锁膨胀,那么通过自旋的方式不断重试直到其他线程完成锁膨胀。
  • 如果当前是无锁状态,也就是说之前获得锁资源的线程正好释放了锁,那么当前线程需完成锁膨胀。

以上这四种情况都是在自旋的方式下完成的,避免了线程竞争导致CAS失败的问题。在锁膨胀完成之后,锁lock锁对象的Mark Word会保存指向ObjectMonitor的指针,重量级锁的竞争都是在ObjectMonitor中完成的。在ObjectMonitor中有一些比较重要的字段,解释如下:

  • _owner,保存当前持有锁的线程。
  • _object,保存锁对象的指针。
  • _cxq,存储没有获得锁的线程的队列,它是一个链表结构。
  • _WaitSet,当调用Object.wait()方法阻塞时,被阻塞的线程会保存到该队列中。
  • _recursions,记录重入次数。

重量级锁的获取流程

重量级锁的实现是在ObjectMonitor中完成的,所以锁膨胀的意义就是构建一个ObjectMonitor,在ObjectMonitor中锁的实现过程如下:

首先,判断当前线程是否是重入,如果是则增加重入次数。然后,通过自旋锁来实现锁的抢占(这个自旋锁就是前面我们提到的自适应自旋),这里使用CAS机制来判断ObjectMonitor中的_owner字段是否为空,如果为空就表示重量级锁已释放,当前线程可以获得锁,否则就进行自适应自旋重试。最后,如果通过自旋锁竞争锁失败,则会把当前线程构建成一个ObjectWaiter节点,插入_cxq队列的队首,再使用park方法阻塞当前线程。

重量级锁的释放原理

锁的释放是在synchronized同步代码块结束后触发的,释放的逻辑比较简单。先把ObjectMonitor中持有锁的对象_owner置为null,再从_cxq队列中唤醒一个处于锁阻塞的线程。然后被唤醒的线程会重新竞争重量级锁,需要注意的是,synchronized是非公平锁,因此被唤醒后不一定能够抢占到锁,如果没抢到,则继续等待。

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此早期的synchronized效率低。而在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁消除

经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。例如,我们的 StringBuffer 的 append 方法如下所示:

@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。

但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。

锁粗化

如果我们释放了锁,紧接着什么都没做,又重新获取锁,例如下面这段代码所示:

public void lockCoarsening() {
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
}

那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。不过,我们这样做也有一个副作用,那就是我们会让同步区域变大,这可能会导致其他线程长时间无法获得锁。

锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。

sychronized引发死锁问题

使用了锁之后,很有可能不小心就会造成死锁的情况。什么是死锁呢?死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。产生死锁是需要一些必要条件的:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

你可能还听说过活锁的概念,这是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试、失败、尝试、失败。活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的"活", 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

接下来我们使用sychronized关键字来造一个死锁的情况:

public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();


        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

当你运行这段代码之后,获得到的输出是:

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

然后你会发现,main函数是迟迟不会退出的,因为两个线程没有结束。而他们没有结束的原因在于他们都在等待彼此释放资源。这也就是我们常说的死锁。前面我们介绍了死锁诞生的原因,如果想打破死锁,方式也很简单,只需要打破哪怕其中一个条件即可

  1. 破坏互斥条件
    这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件
    一次性申请所有的资源。
  3. 破坏不剥夺条件
    占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件
    靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

靠着上面的点子,我们把上面线程2的代码改一下:

new Thread(() -> {
    synchronized (resource1) {
        System.out.println(Thread.currentThread() + "get resource1");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread() + "waiting get resource2");
        synchronized (resource2) {
            System.out.println(Thread.currentThread() + "get resource2");
        }
    }
}, "线程 2").start();

获得到的输出是:

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

可以看到main函数正常退出,线程1首先获得到 resource1的锁,这时候线程2就获取不到了。然后线程1再去获取 resource2的锁,可以获取到。然后线程1释放了对resource1、resource2的锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

如果代码出现了问题,你怀疑是死锁,你要怎么判断呢?

第一个方式可以使用jstack命令,它能看到我们 Java 线程的一些相关信息。如果是比较明显的死锁关系,那么这个工具就可以直接检测出来;如果死锁不明显,那么它无法直接检测出来,不过我们也可以借此来分析线程状态,进而就可以发现锁的相互依赖关系,所以这也是很有利于我们找到死锁的方式。

public class MustDeadLock implements Runnable {

    public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();


    public void run() {
        System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程2获得了两把锁");
                }
            }
        }
    }

    public static void main(String[] argv) {
        MustDeadLock r1 = new MustDeadLock();
        MustDeadLock r2 = new MustDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1, "t1");
        Thread t2 = new Thread(r2, "t2");
        t1.start();
        t2.start();
     }
}

第二种方式是在代码中预留MXBean相关的代码进行辅助,例如:

public class DetectDeadLock implements Runnable {

    public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();


    public void run() {
        System.out.println(Thread.currentThread().getName()+" flag = " + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程2获得了两把锁");
                }
            }
        }
    }

    public static void main(String[] argv) throws InterruptedException {
        DetectDeadLock r1 = new DetectDeadLock();
        DetectDeadLock r2 = new DetectDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1,"t1");
        Thread t2 = new Thread(r2,"t2");
        t1.start();
        t2.start();
        Thread.sleep(1000);
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
        if (deadlockedThreads != null && deadlockedThreads.length > 0) {
            for (int i = 0; i < deadlockedThreads.length; i++) {
                ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadlockedThreads[i]);
                System.out.println("线程id为"+threadInfo.getThreadId()+",线程名为" + threadInfo.getThreadName()+"的线程已经发生死锁,需要的锁正被线程"+threadInfo.getLockOwnerName()+"持有。");
            }
        }
    }
}

这个类是在前面 MustDeadLock 类的基础上做了升级,MustDeadLock 类的主要作用就是让线程 1 和线程 2 分别以不同的顺序来获取到 o1 和 o2 这两把锁,并且形成死锁。在 main 函数中,在启动 t1 和 t2 之后的代码,是我们本次新加入的代码,我们用 Thread.sleep(1000) 来确保已经形成死锁,然后利用 ThreadMXBean 来检查死锁。

通过 ThreadMXBean 的 findDeadlockedThreads 方法,可以获取到一个 deadlockedThreads 的数组,然后进行判断,当这个数组不为空且长度大于 0 的时候,我们逐个打印出对应的线程信息。比如我们打印出了线程 id,也打印出了线程名,同时打印出了它所需要的那把锁正被哪个线程所持有,那么这一部分代码的运行结果如下。

t1 flag = 1
t2 flag = 2
线程 id 为 12,线程名为 t2 的线程已经发生死锁,需要的锁正被线程 t1 持有。
线程 id 为 11,线程名为 t1 的线程已经发生死锁,需要的锁正被线程 t2 持有。

一共有四行语句,前两行是“t1 flag = 1“、“t2 flag = 2”,这是发生死锁之前所打印出来的内容;然后的两行语句就是我们检测到的死锁的结果,可以看到,它打印出来的是“线程 id 为 12,线程名为 t2 的线程已经发生了死锁,需要的锁正被线程 t1 持有。”同样的,它也会打印出“线程 id 为 11,线程名为 t1 的线程已经发生死锁,需要的锁正被线程 t2 持有。”

可以看出,ThreadMXBean 也可以帮我们找到并定位死锁,如果我们在业务代码中加入这样的检测,那我们就可以在发生死锁的时候及时地定位,同时进行报警等其他处理,也就增强了我们程序的健壮性。

voilate关键字

voilate关键字是JVM封装出来的东西,这个关键字有两个能力,分别是保证共享变量的可见性,禁止指令重排序。我们使用前面的代码做说明:

public class VolatileExample {
    public static voilate boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int i=0;
            while (!stop){
                i++;
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
}}

首先,JVM要识别出此字段是一个violate修饰的字段,这个信息由字节码提供。任何一个字段被violate修饰都会新增在flag内ACC_VOLATILE标记。这个指令会通过字节码解释器来执行,定位到Hotspot源码的bytecodeInterpreter.cpp文件,找到_putstatic指令的解析代码。静态变量的获取和赋值分别通过getstatic和putstatic指令来实现,非静态变量通过getfield和putfield指令来操作stop字段代码如下。

CASE(_putstatic):
//省略部分代码
int field_offset = cache->f2_as_index();
if(cache->is_volatile()) {    
	if (tos_type == itos) {        
		obj->release_int_field_put(field_offset, STACK_INT(-1));    
  } else if (tos_type == atos) {
    VERIFY_OOP(STACK_OBJECT(-1));        
    obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));        
    OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);    
  }    
  //省略部分代码    
  OrderAccess::storeload();    
  //省略部分代码
}

上面代码表示,如果字段采用volatile修饰,即cache->is_volatile(),则根据当前字段类型调用不同的方法进行赋值。

bool is_volatile() const { return (_flags & JVM_ACC_VOLATILE ) != 0; }

在完成stop字段的赋值之后,代码调用了OrderAccess::storeload()内存屏障方法,会基于lock指令来实现内存屏障。

上述代码中,对stop增加了volatile关键字之后能够保证可见性的原因是:

  • volatile关键字会在JVM层面声明一个C++的volatile,它能够防止JIT层面的指令重排序。
  • 在对修饰了volatile关键字的stop字段赋值后,JVM会调用storeload()内存屏障方法,该方法中声明了lock指令,该指令有两个作用。
    1)在CPU层面,给stop赋值的指令会先存储到Store Buffers中,所以lock指令会使得Store Buffers中的数据刷新到缓存行。
    2)使得其他CPU中缓存了stop的缓存行失效,也就是让存储在Invalidate Queues中的对stop失效的指令立即生效。当其他线程再去读取stop的值时,会从内存中或者其他缓存了stop字段的缓存行中重新加载,使得线程能够获得stop的最新的值。

final关键字

类的final字段在<clinit>字节码指令中初始化,其可见性由JVM的类加载过程保证。如果一个实例的字段被声明为final,则JVM会在初始化final变量后插入一个sfence。sfence禁用了sfence前后对store的重排序,且保证final字段初始化之前的内存更新都是可见的。

编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

final域为引用类型的时候增加了如下规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。final语义在处理器中的实现会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障

上述良好性质被称为“初始化安全性”。它保证,对于被正确构造的对象,所有线程都能看到构造函数给对象的各个final字段设置的正确值,而不管采用何种方式来发布对象。初始化安全性为解决部分初始化问题带来了新的思路:如果待发布对象的所有域都是final修饰的,那么可以防止对对象的初始引用被重排序到构造过程完成之前。于是,单例的实现中的饱汉变种三还可以扔掉volatile,改为借助final的sfence语义:

public class Singleton1_3 {

    private static Singleton1_3 singleton = null;

    public int f1 = 1; // 触发部分初始化问题

    public int f2 = 2;
    private Singleton1_3() {}

    public static Singleton1_3 getInstance() {

        if (singleton == null) {
            synchronized (Singleton1_3.class) {
                // must be a complete instance
                if (singleton == null) {
                    singleton = new Singleton1_3();
                }
            }
        }
        return singleton;
    }
}

Lock/Condition体系

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题synchronized都是能够解决的,但是synchronized不太能满足我们的需求:

第一点:synchronized没有办法处理死锁问题,synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态之后,就嗝屁了,什么都做不了,包括释放线程已经占有的资源。所以我们希望一个锁,在已经占有部分资源的时候,在请求额外数据但是请求不到的时候主动释放锁,这么一来死锁问题就可以解决。

第二点:synchronized的控制能力没有想象中的那么强,它没有办法控制等待获取锁的线程们是否是公平的。如果在极端场合,不公平的锁机制会导致线程饥饿,线程一直都没有资源去执行。

当然synchronized虽然功能较少,但是简单又好用啊,自动就帮你释放锁了,而且随着JDK版本升级,不断优化之后性能也还不错。如果是lock机制的,还需要手动unlock,如果不小心忘了那就很可能出问题。

那么,如果我们重新设计一把互斥锁去解决上面这个问题,可以有以下方案:

  1. 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  2. 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

Lock体系接口

synchronized是jvm自带的同步锁,仅仅使用一个关键字就可以实现对资源的互斥占用。但是开发者往往对此并不满足,其源自于程序员渴望控制一切的欲望。因此Lock体系诞生就诞生了,设计者期望Lock体系可以完全覆盖synchronized的功能,并且增加synchronized做不到的功能。接下来我们就来看看设计者是怎么思考这个东西的。

Lock接口

首先我们来看看设计上对顶级的Lock接口。顾名思义,Lock接口表达的是一个锁所具备的行为,对于 synchronized 的锁而言,只有加锁和解锁的行为。而设计者对Lock赋予了更多的期望,以下是Lock接口的定义。

public interface Lock {
    // 加锁
    void lock();
  	// 加锁,并支持中断
    void lockInterruptibly() throws InterruptedException;
    // 支持非阻塞获取锁的API
    boolean tryLock();
  	// 支持超时的API
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    // 解锁
    void unlock();
    // 获得条件等待对象
    Condition newCondition();
}

Lock 和 synchronized 是两种最常见的锁,用于控制对共享资源的访问,但是在使用上和功能上又有较大的不同。Lock提供了无条件的、可轮询的、定时的、可中断的、可多条件队列的锁操作,Lock的实现类基本都支持非公平锁和公平锁,可以说Lock是synchronized的能力升级版本。但是 Lock 并不是用来代替 synchronized 的,而是当使用 synchronized 不合适或不足以满足要求的时候,Lock 可以用来提供更高级功能的。

接下来,我们一个一个方法来看Lock接口中的方法是怎么用的。

lock()/unlock()方法:最基础的获取/释放锁的方法,可以理解为和synchronized获取/释放锁的方式一致。在线程获取锁时如果锁已被其他线程获取,则进行等待,需要进行释放锁的时候,必须由我们自己调用unlock()方法主动去释放锁,因此最佳实践是执行 lock() 后,首先在 try{} 中操作同步资源,如果有必要就用 catch{} 块捕获异常,然后在 finally{} 中释放锁,以保证发生异常时锁一定被释放,如果不能保证,这段代码将会变得非常危险。逻辑上,示例代码如下所示:

Lock lock = new XxLock();
lock.lock();
try{
    //获取到了被本锁保护的资源,处理任务
    //捕获异常
} finally{
    lock.unlock();   //释放锁
}

再继续思考,由于lock()方法不能被中断,所以可能会导致死锁,我们需要避免这种情况产生,经典的策略如下:

public void tryLock(Lock lock1, Lock lock2) throws InterruptedException {
        while (true) {
            if (lock1.tryLock()) {
                try {
                    if (lock2.tryLock()) {
                        try {
                            System.out.println("获取到了两把锁,完成业务逻辑");
                            return;
                        } finally {
                            lock2.unlock();
                        }
                    }
                } finally {
                    lock1.unlock();
                }
            } else {
                Thread.sleep(new Random().nextInt(1000));
            }
        }
    }

上面的代码看上去和synchronized避免死锁的策略差不多。死锁的发生是因为两个线程互相占用了对方接下去需要占用的锁资源,我们可以在一个线程获取打算获取任意一个锁的时候进行尝试动作,内层的尝试动作失败也会连带着外部的锁的资源释放,这样一来就不用担心死锁问题的产生。

lock() 方法是不能被中断,所以如果一旦陷入死锁,lock() 就会陷入永久等待。上文介绍虽然我们有策略避免死锁产生,但是我们也需要一些更兜底的策略避免死锁,因此针对加锁行为增加了 tryLock() 的策略。tryLock() 表达尝试获取锁,也就是说如果当前锁没有被其他线程占用,则获取成功并返回 true,否则表达获取锁失败并返回 false。相比于 lock(),这样的方法显然功能更强大,我们可以根据是否能获取到锁来决定后续程序的行为。因为该方法会立即返回,即便在拿不到锁时也不会一直等待,所以通常情况下,我们用 if 语句判断 tryLock() 的返回结果,根据是否获取到锁来执行不同的业务逻辑,典型使用方法如下:

Lock lock = new XxLock();
if(lock.tryLock()) {
     try{
         //处理任务
     }finally{
         lock.unlock();   //释放锁
     } 
} else {
    //如果不能获取锁,则做其他事情
}

根据tryLock方法的定义,很显然,tryLock与while配合的情况下就会产生和自旋锁类似的效果:

Lock lock = new XxLock();
while(lock.tryLock()) {
     try{
         // 处理任务
         // 跳出循环
         break; 
     } finally{
         lock.unlock();   //释放锁
     } 
}

tryLock() 有个重载方法是 tryLock(long time, TimeUnit unit),这个方法和 tryLock() 很类似,区别在于后者会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false;如果一开始就获取锁或者等待期间内获取到锁,则返回 true。从方法定义上可以看到,该方法可以抛出InterruptedException异常,这表明在等待的期间,可以中断线程,从而避免死锁的发生。

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

lockInterruptibly方法和lock方法的功能类似,也是主动获取锁,如果这个锁当前是可以获得的,那么这个方法会立刻返回,如果这个锁当前是不能获得的,那么当前线程便会开始等待。但是和lock()方法相比,在等待获得锁的过程中,这个方法支持被中断。如果用另一个视角来看待这个方法,你会发现lockInterruptibly()的行为等价于超时时间是无穷长的 tryLock(long time, TimeUnit unit)。

这个方法的经典用法如下:

public void lockInterruptibly() {
    try {
        lock.lockInterruptibly();
        try {
            System.out.println("操作资源");
        } finally {
            lock.unlock();
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

在这个方法中我们首先执行了 lockInterruptibly 方法,并且对它进行了 try catch 包装,然后同样假设我们能够获取到这把锁,和之前一样,就必须要使用 try-finall 来保障锁的绝对释放。

Condition接口

sychronized关键字可以挂在方法上,自动加锁解锁,lock接口就是来抄袭这个过程的,但是sychronized关键字也可以和object.wait/notify配合,达到更精确的控制。那么后者就是Condiction来控制的。可以看到Lock接口内有这样的一个方法:

Condition newCondition()

可以看出Condition获取的来源是Lock,你可以针对一把锁申请多个Condition。我们打开Condition接口的源码:

public interface Condition {
    void await() throws InterruptedException;
    void awaitUninterruptibly();
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    boolean await(long time, TimeUnit unit) throws InterruptedException;
    boolean awaitUntil(Date deadline) throws InterruptedException;
    void signal();
    void signalAll();
}

可以看到,这些方法就是Object#wait与Object#notify的翻版,只不过多了一些控制的参数。我们可以简单的理解为Condition实例就是Object#await 策略中的Object本身。为了更加清晰得看到Object与Condition间的概念相似性,我们先来看看使用Object.notify机制来实现的简易版阻塞队列代码:

public class MyBlockingQueueForWaitNotify {
 
   private int maxSize;
   private LinkedList<Object> storage;
 
   public MyBlockingQueueForWaitNotify (int size) {
       this.maxSize = size;
       storage = new LinkedList<>();
   }
 
   public synchronized void put() throws InterruptedException {
       while (storage.size() == maxSize) {
           this.wait();
       }
       storage.add(new Object());
       this.notifyAll();
   }
 
   public synchronized void take() throws InterruptedException {
       while (storage.size() == 0) {
           this.wait();
       }
       System.out.println(storage.remove());
       this.notifyAll();
   }
}

那么使用Lock/Condition机制来实现的简易版阻塞队列代码会变成这个样子:

public class MyBlockingQueueForCondition {
 
   private Queue queue;
   private int max = 16;
   private ReentrantLock lock = new ReentrantLock();
   private Condition notEmpty = lock.newCondition();
   private Condition notFull = lock.newCondition();
 
   public MyBlockingQueueForCondition(int size) {
       this.max = size;
       queue = new LinkedList();
   }
 
   public void put(Object o) throws InterruptedException {
       lock.lock();
       try {
           while (queue.size() == max) {
               notFull.await();
           }
           queue.add(o);
           notEmpty.signalAll();
       } finally {
           lock.unlock();
       }
   }
 
   public Object take() throws InterruptedException {
       lock.lock();
       try {
           while (queue.size() == 0) {
               notEmpty.await();
           }
           Object item = queue.remove();
           notFull.signalAll();
           return item;
       } finally {
           lock.unlock();
       }
   }
}

可以看到,两种写法特别相似。如果说 Lock 是用来代替 synchronized 的,那么 Condition 就是用来代替相对应的 Object 的 wait/notify/notifyAll,在用法和性质上几乎都一样。Condition 把 Object 的 wait/notify/notifyAll 转化为了一种相应的对象,其实现的效果基本一样,但是把更复杂的用法,变成了更直观可控的对象方法,是一种升级。await 方法会自动释放持有的 Lock 锁,和 Object 的 wait 一样,不需要自己手动释放锁。另外,调用 await 的时候必须持有锁,否则会抛出异常,这一点和 Object 的 wait 一样。

  • lock.lock() 对应进入 synchronized 方法
  • condition.await() 对应 object.wait()
  • condition.signalAll() 对应 object.notifyAll()
  • lock.unlock() 对应退出 synchronized 方法

AQS框架

synchronized 关键字的锁机制是由JVM内部实现的,程序员无法在表面上感知到加锁过程的逻辑。纯粹的Lock接口,Condition类是无法对外提供锁机制的,你会发现还缺少将他们整合在一起的逻辑。如果每次都需要开发者自行实现将会是一个灾难。因此AQS框架就诞生了,其内部已经实现了Lock与Condition的配合逻辑,只需要开发者使用很少量的代码就可以完成自定义的锁。包括JDK内自带的一些并发工具,其内部基本上都可以看到AQS的身影。

AQS框架的核心设计

AQS是一个抽象类,那么开发者使用AQS的方式是继承AQS,改写部分方法。我们将AQS内可以被覆盖的方法拿出来看看:

// 独占式获取同步状态,获取同步状态成功后,其他线程需要等待该线程释放同步状态才能获取同步状态
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

// 独占式释放同步状态
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

// 共享式获取同步状态,返回值大于等于 0 ,则表示获取成功;否则,获取失败
protected int tryAcquireShared(int arg) {
  throw new UnsupportedOperationException();
}

// 共享式释放同步状态
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}

// 当前同步器是否在独占式模式下被线程占用,一般该方法表示是否被当前线程所独占
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

要理解上面的方法,需要深刻理解AQS的设计。AQS内部最核心的设计分为三大部分,第一个是 state,它是一个数值,在不同的类中表示不同的含义。第二个是一个队列,该队列用来存放线程,这也和sychronized关键字背后存在一个队列的概念一致。第三个是"获取资源/释放资源"的相关方法,需要利用 AQS 的工具类根据自己的逻辑去实现。

第一部分:state状态

AQS源码中使用一个 int 类型的成员变量 state 来表示同步状态。

/**
* The synchronization state.
*/
private volatile int state;

state 的含义并不是一成不变的,它会根据具体实现类的作用不同而表示不同的含义,由开发者自行决定。例如以下几种情况:

  • 在Semphore中,state 表达剩余许可证的数量,相当于是一个内部计数器。
  • 在 CountDownLatch 中,state 表达需要“倒数”的数量,减到 0 的时候就代表这个门闩被放开。
  • 在 ReentrantLock 中,state 表达锁的占有情况。state的值表达某个线程重入这个锁的深度。

于此对应的,在AQS内部提供了三个方法,针对 state 进行操作。

protected final int getState() {
	return state;
}
protected final void setState(int newState) {
	state = newState;
}
protected final boolean compareAndSetState(int expect, int update) {
  // See below for intrinsics setup to support this
  return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

其中setState方法不会产生线程安全问题,当对基本类型的变量进行直接赋值时,如果加了 volatile 就可以保证它的线程安全。而compareAndSetState方法是我们熟知的老朋友了,使用unsafe类进行cas操作,保证原子性。

第二部分 FIFO 同步队列

当多个线程去竞争同一把锁的时候,大部分的线程事实上是抢不到的,那么那些抢不到锁的线程就需要被管理。管理的手段就是利用一个FIFO队列,将暂时抢不到锁的线程穿在一起,当前面的线程释放锁之后,AQS就会从队列中挑选一个合适的线程来尝试抢刚刚释放的那把锁。这里的FIFO队列的数据结构看似简单,但是要想维护成一个线程安全的双向队列却非常复杂,因为要考虑很多的多线程并发问题。在队列中,分别用 head 和 tail 来表示头节点和尾节点,两者在初始化的时候都指向了一个空节点。头节点可以理解为"当前持有锁的线程",而在头节点之后的线程就被阻塞了,它们会等待被唤醒,唤醒也是由 AQS 负责操作的。

第三部分:获取/释放锁方法

获取/释放锁方法是协作工具类的逻辑具体体现,需要每一个协作工具类自己去实现,所以在不同的工具类中,它们的实现和含义各不相同。

获取方法

获取操作通常会依赖 state 变量的值,根据 state 值不同,协作工具类也会有不同的逻辑,并且在获取的时候也经常会阻塞,例如:

  • ReentrantLock 中的 lock 方法就是其中一个“获取方法”,执行时,如果发现 state 不等于 0 且当前线程不是持有锁的线程,那么就代表这个锁已经被其他线程所持有了。这个时候,当然就获取不到锁,于是就让该线程进入阻塞状态。
  • Semaphore 中的 acquire 方法就是其中一个“获取方法”,作用是获取许可证,此时能不能获取到这个许可证也取决于 state 的值。如果 state 值是正数,那么代表还有剩余的许可证,数量足够的话,就可以成功获取;但如果 state 是 0,则代表已经没有更多的空余许可证了,此时这个线程就获取不到许可证,会进入阻塞状态,所以这里同样也是和 state 的值相关的。
  • CountDownLatch 获取方法就是 await 方法(包含重载方法),作用是“等待,直到倒数结束”。执行 await 的时候会判断 state 的值,如果 state 不等于 0,线程就陷入阻塞状态,直到其他线程执行倒数方法把 state 减为 0,此时就代表现在这个门闩放开了,所以之前阻塞的线程就会被唤醒。

“获取方法”在不同的类中代表不同的含义,但往往和 state 值相关,也经常会让线程进入阻塞状态,这也同样证明了 state 状态在 AQS 类中的重要地位。

释放方法

释放方法是获取方法的对立面,和刚才的获取方法配合使用。我们刚才讲的获取方法可能会让线程阻塞,比如说获取不到锁就会让线程进入阻塞状态,但是释放方法通常是不会阻塞线程的。例如:

  • Semaphore 信号量里面,释放就是 release 方法(包含重载方法),release() 方法的作用是去释放一个许可证,会让 state 加 1
  • CountDownLatch 里面,释放就是 countDown 方法,作用是倒数一个数,让 state 减 1。
AQS框架实现自定义锁

如果想使用 AQS 来写一个自己的线程协作工具类,通常而言是分为以下三步,这也是 JDK 里利用 AQS 类的主要步骤。

  1. 新建一个自己的线程协作工具类,在内部写一个 Sync 类,该 Sync 类继承 AbstractQueuedSynchronizer。
  2. 想好设计的线程协作工具类的协作逻辑,在 Sync 类里,根据是否是独占,来重写对应的方法。如果是独占,则重写 tryAcquire 和 tryRelease 等方法;如果是非独占,则重写 tryAcquireShared 和 tryReleaseShared 等方法。
  3. 在自己的线程协作工具类中,实现获取/释放的相关方法,并在里面调用 AQS 对应的方法,如果是独占则调用 acquire 或 release 等方法,非独占则调用 acquireShared 或 releaseShared 或 acquireSharedInterruptibly 等方法。

接下来我们先使用AQS造一个我们自己的锁,看看AQS是怎么玩儿的:

public class MyLockBaseOnAqs {

    // 定义一个同步器,实现AQS类
    private static class Sync extends AbstractQueuedSynchronizer {
        // 实现tryAcquire(acquires)方法
        @Override
        public boolean tryAcquire(int acquires) {
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        // 实现tryRelease(releases)方法
        @Override
        protected boolean tryRelease(int releases) {
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }
    }

    // 声明同步器
    private final Sync sync = new Sync();

    // 加锁
    public void lock() {
        sync.acquire(1);
    }

    // 解锁
    public void unlock() {
        sync.release(1);
    }
  
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        MyLockBaseOnAqs lock = new MyLockBaseOnAqs();

        CountDownLatch countDownLatch = new CountDownLatch(1000);

        IntStream.range(0, 1000).forEach(i -> new Thread(() -> {
            lock.lock();

            try {
                IntStream.range(0, 10000).forEach(j -> {
                    count++;
                });
            } finally {
                lock.unlock();
            }
            countDownLatch.countDown();
        }, "tt-" + i).start());

        countDownLatch.await();

        System.out.println(count);
    }
}

通过结果我们可以看出,这个锁是可以正常工作的。只要简单的套用一下,我们就可以造出符合自己预期的锁工具。

ReentrantLock

我们先把ReentrantLock的类定义拿出来看看:

public class ReentrantLock implements Lock, java.io.Serializable {}

可以看到ReentrantLock的类继承关系及其简单,是Lock接口的直接实现,因此ReentrantLock的行为需要完全符合Lock的定义。当然除此之外,ReentrantLock还提供了其他有意思的能力。

ReentrantLock的使用案例

锁机制的基础使用案例

我们先给出一个最基础的使用案例,也就是覆盖sychronized关键字功能的例子:

private static final Lock lock_test001 = new ReentrantLock();

@Test
public void test001() throws InterruptedException {
    new Thread(()-> test001_innerFunction(),"线程1").start();
    new Thread(()->test001_innerFunction(),"线程2").start();
    Thread.sleep(100000L);
}

public static void test001_innerFunction(){
    try {
        lock_test001.lock();
        System.out.println(Thread.currentThread().getName()+"获得到锁");
        Thread.sleep(1000L);
    } catch (InterruptedException e){
        e.printStackTrace();
    } finally {
        System.out.println(Thread.currentThread().getName()+"释放了锁");
        lock_test001.unlock();
    }
}

获得到的输出是:

线程1获得到锁
线程1释放了锁
线程2获得到锁
线程2释放了锁

可以看到两个线程在同一个时刻只能有一个线程获得一把锁。

公平锁使用案例
private static final Lock lock_test002 = new ReentrantLock(true);

@Test
public void test002() throws InterruptedException {
    new Thread(()->test002_innerFunction(),"线程1").start();
    new Thread(()->test002_innerFunction(),"线程2").start();
    Thread.sleep(100000L);
}

public static void test002_innerFunction(){
    while (true){
        lock_test002.lock();
        try{
            System.out.println(Thread.currentThread().getName() + " get lock");
            Thread.sleep(1000);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock_test002.unlock();
        }
    }
}

获得到的输出是:

线程1 get lock
线程2 get lock
线程1 get lock
线程2 get lock
线程1 get lock
线程2 get lock
线程1 get lock
线程2 get lock
非公平锁使用案例
private static final Lock lock_test003 = new ReentrantLock(false);

@Test
public void test003() throws InterruptedException {
    new Thread(()-> test003_innerFunction(),"线程1").start();
    new Thread(()->test003_innerFunction(),"线程2").start();
    Thread.sleep(100000L);
}

public static void test003_innerFunction(){
    while (true){
        lock_test003.lock();
        try{
            System.out.println(Thread.currentThread().getName() + " get lock");
            Thread.sleep(1000);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock_test003.unlock();
        }
    }
}

获得到的输出是:

线程1 get lock
线程1 get lock
线程2 get lock
线程2 get lock
线程2 get lock
线程1 get lock
线程1 get lock
线程1 get lock

可以看到虽然是按照

响应中断使用案例
@Test
public void test004(){
    Thread thread1 = new Thread(()-> test004_innerFunction(lock_test004_1,lock_test004_2),"线程1");
    Thread thread2 = new Thread(()-> test004_innerFunction(lock_test004_2,lock_test004_1),"线程2");
    thread1.start();
    thread2.start();
    thread1.interrupt();

    try {
        Thread.sleep(10000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

public static void test004_innerFunction(Lock lock1,Lock lock2){
    try {
        lock1.lockInterruptibly();
        Thread.sleep(1000);
        lock2.lockInterruptibly();
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock1.unlock();
        lock2.unlock();
        System.out.println(Thread.currentThread().getName() + "正常执行");
    }
}

获得到的输出是:

java.lang.InterruptedException
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1220)
	at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.test004_innerFunction(ReentratTest.java:97)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.lambda$test004$6(ReentratTest.java:82)
	at java.lang.Thread.run(Thread.java:748)
Exception in thread "线程1" java.lang.IllegalMonitorStateException
	at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
	at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.test004_innerFunction(ReentratTest.java:103)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.lambda$test004$6(ReentratTest.java:82)
	at java.lang.Thread.run(Thread.java:748)
线程2正常执行
限时等待使用案例
private static final Lock lock_test005 = new ReentrantLock();
@Test
public void test005(){
    Thread thread1 = new Thread(()-> test005_innerFunction(lock_test005),"线程1");
    Thread thread2 = new Thread(()-> test005_innerFunction(lock_test005),"线程2");
    thread1.start();
    thread2.start();
    try {
        Thread.sleep(10000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

public static void test005_innerFunction(Lock lock){
    try {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            System.out.println("等待前,"+Thread.currentThread().getName());
            Thread.sleep(3000);
            System.out.println("等待后,"+Thread.currentThread().getName());
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

获得的输出是:

等待前,线程1
Exception in thread "线程2" java.lang.IllegalMonitorStateException
	at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
	at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.test005_innerFunction(ReentratTest.java:135)
	at com.zifang.ex.bust.charpter12.test001.ReentratTest.lambda$test005$9(ReentratTest.java:115)
	at java.lang.Thread.run(Thread.java:748)
等待后,线程1

ReentrantLock与synchronized的比较

ReentrantLock与synchronized都是JDK提供的锁机制,synchronized是jvm层面保证的,

  • synchronized 和 Lock 都是用来保护资源线程安全的。
  • 都可以保证可见性。
  • synchronized 和 ReentrantLock 都拥有可重入的特点。

下面我们来看下 synchronized 和 Lock 的区别,和相同点一样,它们之间也有非常多的区别,这里讲解其中比较大的 7 点不同。

  • 用法区别

synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this),也可以新建一个同步代码块并且自定义 monitor 锁对象;而 Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。

与 Lock 显式的加锁和解锁不同的是 synchronized 的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁,但是 Java 代码中并没有相关的体现。

  • 加解锁顺序不同

对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2,加解锁有一定的灵活度,如代码所示。但是 synchronized 无法做到,synchronized 解锁的顺序和加锁的顺序必须完全相反。那么在这里,顺序就是先对 obj1 加锁,然后对 obj2 加锁,然后对 obj2 解锁,最后解锁 obj1。这是因为 synchronized 加解锁是由 JVM 实现的,在执行完 synchronized 块后会自动解锁,所以会按照 synchronized 的嵌套顺序加解锁,不能自行控制。

  • synchronized 锁不够灵活

一旦 synchronized 锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。如果持有锁的线程持有很长时间才释放,那么整个程序的运行效率就会降低,而且如果持有锁的线程永远不释放锁,那么尝试获取锁的线程只能永远等下去。

相比之下,Lock 类在等锁的过程中,如果使用的是 lockInterruptibly 方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock() 等方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活。

  • synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制

例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized 做不到。

  • 原理区别

synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。 Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的。

  • 是否可以设置公平/非公平

公平锁是指多个线程在等待同一个锁时,根据先来后到的原则依次获得锁。ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置。

  • 性能区别

在 Java 5 以及之前,synchronized 的性能比较低,但是到了 Java 6 以后,发生了变化,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。

讲完了 synchronized 和 Lock 的相同点和区别,最后我们再来看下如何选择它们,在 Java 并发编程实战和 Java 核心技术里都认为:

  1. 如果能不用最好既不使用 Lock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
  2. 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全。
  3. 如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock。

ReentrantReadWriteLock

重入锁 ReentrantLock 是排他锁,排他锁在同一时刻仅有一个线程可以进行访问,但是在大多数场景下,大部分时间都是提供读服务,而写服务占有的时间较少。因此读写分离的次略跃然纸上,ReentrantReadWriteLock的存在就是为了解决这个问题的。

ReentrantReadWriteLock维护一个读锁和一个写锁。通过分离读锁和写锁,可以在同一时间允许多个读线程访问,但是在写线程访问的时候所有读写线程都将会被阻塞。类似于ReentrantLock,ReentrantReadWriteLock也同样支持公平/非公平,重入。这里我们先走一个demo看看这个怎么用:

public class ReadWriteLockDemo{
    static ReadWriteLock lock = new ReentrantReadWriteLock();
    static String text = "hello";
    public static void modify(){
        lock.writeLock().lock();
        try {
            System.err.println(Thread.currentThread().getName()+"开始修改");
            text += " "+Thread.currentThread().getName();
        }catch (Exception e){
            e.printStackTrace();
        }
        finally {
            lock.writeLock().unlock();
        }
    }
    public static void readWithReadLock(){
        lock.readLock().lock();
        try {
            System.err.println(text);
            Thread.sleep(5000);
            System.err.println("5秒过去了");
        }catch (Exception e){
        }finally {
            lock.readLock().unlock();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            readWithReadLock();
        }).start();
        Thread.sleep(1000);
        for (int i=0;i<10;i++) {
            new Thread(() -> {
                modify();
            }).start();
        }
    }
}

读写锁的公平性

ReentrantReadWriteLock 可以设置为公平或者非公平。

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

如果是公平锁,我们就在构造函数的参数中传入 true,如果是非公平锁,就在构造函数的参数中传入 false,默认是非公平锁。在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。

公平锁对于这两个方法的实现如下:

final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

在公平锁的情况下,只要等待队列中有线程在等待,也就是 hasQueuedPredecessors() 返回 true 的时候,那么 writer 和 reader 都会 block,也就是一律不允许插队,都乖乖去排队,这也符合公平锁的思想。

非公平锁对于这两个方法的实现如下:

final boolean writerShouldBlock() {
    return false; // writers can always barge
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}

在 writerShouldBlock() 这个方法中始终返回 false,可以看出,对于想获取写锁的线程而言,由于返回值是 false,所以它是随时可以插队的,这就和我们的 ReentrantLock 的设计思想是一样的,但是读锁却不一样。这里实现的策略很有意思,先让我们来看下面这种场景:假设线程 2 和线程 4 正在同时读取,线程 3 想要写入,但是由于线程 2 和线程 4 已经持有读锁了,所以线程 3 就进入等待队列进行等待。此时,线程 5 突然跑过来想要插队获取读锁,面对这种情况有两种应对策略:

a) 允许插队

由于现在有线程在读,而线程 5 又不会特别增加它们读的负担,因为线程们可以共用这把锁,所以第一种策略就是让线程 5 直接加入到线程 2 和线程 4 一起去读取。

这种策略看上去增加了效率,但是有一个严重的问题,那就是如果想要读取的线程不停地增加,比如线程 6,那么线程  6 也可以插队,这就会导致读锁长时间内不会被释放,导致线程 3 长时间内拿不到写锁,也就是那个需要拿到写锁的线程会陷入“饥饿”状态,它将在长时间内得不到执行。

b) 不允许插队

这种策略认为由于线程 3 已经提前等待了,所以虽然线程 5 如果直接插队成功,可以提高效率,但是我们依然让线程 5 去排队等待,按照这种策略线程 5 会被放入等待队列中,并且排在线程 3 的后面,让线程 3 优先于线程 5 执行,这样可以避免“饥饿”状态,这对于程序的健壮性是很有好处的,直到线程 3 运行完毕,线程 5 才有机会运行,这样谁都不会等待太久的时间。所以我们可以看出,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的,目的是避免“饥饿”。

ReentrantReadWriteLock 的实现选择了策略b,下面我们就用实际的代码来演示一下上面这种场景。

/**
 * 描述:     演示读锁不插队
 */
public class ReadLockJumpQueue {

    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();

    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read(),"Thread-2").start();
        new Thread(() -> read(),"Thread-4").start();
        new Thread(() -> write(),"Thread-3").start();
        new Thread(() -> read(),"Thread-5").start();
    }
}

获得到的输出是:

Thread-2得到读锁,正在读取
Thread-4得到读锁,正在读取
Thread-2释放读锁
Thread-4释放读锁
Thread-3得到写锁,正在写入
Thread-3释放写锁
Thread-5得到读锁,正在读取
Thread-5释放读锁

从这个结果可以看出,ReentrantReadWriteLock 的实现选择了“不允许插队”的策略,这就大大减小了发生“饥饿”的概率。(如果运行结果和课程不一致,可以在每个线程启动后增加 100ms 的睡眠时间,以便保证线程的运行顺序)。

读写锁的升降级

下面我们再来看一下锁的升降级,首先我们看一下这段代码,这段代码演示了在更新缓存的时候,如何利用锁的降级功能。

public class CachedData {
 
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            //在获取写锁之前,必须首先释放读锁。
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
                if (!cacheValid) {
                    data = new Object();
                    cacheValid = true;
                }
                //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
                rwl.readLock().lock();
            } finally {
                //释放了写锁,但是依然持有读锁
                rwl.writeLock().unlock();
            }
        }
 
        try {
            System.out.println(data);
        } finally {
            //释放读锁
            rwl.readLock().unlock();
        }
    }
}

在这段代码中有一个读写锁,最重要的就是中间的 processCachedData 方法,在这个方法中,会首先获取到读锁,也就是rwl.readLock().lock(),它去判断当前的缓存是否有效,如果有效那么就直接跳过整个 if 语句,如果已经失效,代表我们需要更新这个缓存了。由于我们需要更新缓存,所以之前获取到的读锁是不够用的,我们需要获取写锁。

在获取写锁之前,我们首先释放读锁,然后利用 rwl.writeLock().lock() 来获取到写锁,然后是经典的 try finally 语句,在 try 语句中我们首先判断缓存是否有效,因为在刚才释放读锁和获取写锁的过程中,可能有其他线程抢先修改了数据,所以在此我们需要进行二次判断。

如果我们发现缓存是无效的,就用 new Object() 这样的方式来示意,获取到了新的数据内容,并把缓存的标记位设置为 ture,让缓存变得有效。由于我们后续希望打印出 data 的值,所以不能在此处释放掉所有的锁。我们的选择是在不释放写锁的情况下直接获取读锁,也就是rwl.readLock().lock() 这行语句所做的事情,然后,在持有读锁的情况下释放写锁,最后,在最下面的 try 中把 data 的值打印出来。

这就是一个非常典型的利用锁的降级功能的代码。

你可能会想,我为什么要这么麻烦进行降级呢?我一直持有最高等级的写锁不就可以了吗?这样谁都没办法来影响到我自己的工作,永远是线程安全的。如果我们在刚才的方法中,一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也是没有必要的,因为我们只有一处修改数据的代码:

data = new Object();

后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。

如果我们运行下面这段代码,在不释放读锁的情况下直接尝试获取写锁,也就是锁的升级,会让线程直接阻塞,程序是无法运行的。

final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

public static void main(String[] args) {
    upgrade();
}

public static void upgrade() {
    rwl.readLock().lock();
    System.out.println("获取到了读锁");
    rwl.writeLock().lock();
    System.out.println("成功升级");
}

这段代码会打印出“获取到了读锁”,但是却不会打印出“成功升级”,因为 ReentrantReadWriteLock 不支持读锁升级到写锁。

我们知道读写锁的特点是如果线程都申请读锁,是可以多个线程同时持有的,可是如果是写锁,只能有一个线程持有,并且不可能存在读锁和写锁同时持有的情况。正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。假设有 A,B 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移,B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。但是我们考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。

StampedLock

StampedLock是对ReentrantReadWriteLock读写锁的一种改进。ReentrantReadWriteLock中的读和写都是一种悲观锁的体现,而StampedLock的思路是乐观锁,也就是说当乐观读时假定没有其它线程修改数据,读取完成后再检查下版本号有没有变化,没有变化就读取成功了,这种模式更适用于读多写少的场景。

StampedLock有以下3种模式:

1)悲观读锁:与ReadWriteLock的读锁类似,多个线程可以同时获取悲观读锁,悲观读锁是一个共享锁。
2)乐观读锁:直接操作数据,不加任何锁。在操作数据前并没有通过CAS 设置锁的状态,仅仅通过位运算测试。如果当前没有线程持有写锁 ,则简单地返回 一个非 0 的 stamp 版本信息 ,返回0则说明有线程持有写锁。 获取该 stamp 后在具体操作数据前还需要调用validate 方法验证该 stamp 是否己经不可用
3)写锁:与ReadWriteLock的写锁类似,写锁和悲观读锁是互斥的。虽然写锁与乐观读锁不会互斥,但是在数据被更新之后,之前通过乐观读锁获得的数据已经变成了脏数据。是 一 个排它锁或者独占锁,某时只有一个线程可以获取该锁,当二个线程获取该锁后,其他请求读锁和写锁的线程必须等待 ,这类似于ReentrantReadWriteLock 的写锁。

StampedLock 的读写锁都是不可重入锁,所以在获取锁后释放锁前不应该再调用会获取锁的操作,以避免造成调用线程被阻塞。这里我们跑一个测试用例看看StampedLock是怎么用的:

package com.zifang.util.zex.source;

import java.util.Date;
import java.util.HashMap;
import java.util.concurrent.locks.StampedLock;

public class StampedLockTest {
    final static HashMap<String, String> data = new HashMap<>();
    final static StampedLock lock = new StampedLock();


    public static Object write(String key, String value) {
        long stamp = lock.writeLock();
        try {
            System.out.println(new Date() + ": 抢占了写锁,开始写操作");
            return data.put(key, value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock(stamp);
            System.out.println(new Date() + ": 释放了写锁");
        }
        return null;
    }

    /*
     * 对共享数据的悲观读操作
     */
    public static Object pessimisticRead(String key) {
        System.out.println(new Date() + ":  进入过写模式,只能悲观读");
        long stamp = lock.readLock();
        try {
            System.out.println(new Date() + ": 获取了读锁");
            return data.get(key);
        } finally {
            System.out.println(new Date() + ": 释放了读锁");
            lock.unlockRead(stamp);
        }
    }

    /*
     * 对共享数据的乐观读操作
     */
    public static Object optimisticRead(String key) {
        String value = null;

        long stamp = lock.tryOptimisticRead();

        if (stamp != 0) {
            System.out.println(new Date() + ":  乐观锁的印戳值获取成功");
            value = data.get(key);
        } else {
            System.out.println(new Date() + ":  乐观锁的印戳值获取失败,开始使用悲观读");
            return pessimisticRead(key);
        }

        if (!lock.validate(stamp)) {
            System.out.println(new Date() + ":  乐观读的印戳值已经过期");
            return pessimisticRead(key);
        } else {
            System.out.println(new Date() + ":  乐观读的印戳值没有过期");
            return value;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        data.put("initKey", "initValue");

        Thread t1 = new Thread(() -> {
            System.out.println(optimisticRead("initKey"));
        }, "读线程1");

        Thread t2 = new Thread(() -> {
            write("key1", "value1");
        }, "写线程1");
        
        Thread t3 = new Thread(() -> {
            System.out.println(optimisticRead("initKey"));
        }, "读线程2");

        t1.start();
        t1.join();
        t2.start();
        t3.start();
        Thread.sleep(1000);
    }
}

StampedLock也是一种读写锁,但是它不是基于AQS实现的。相较于ReentrantReadWriteLock,StampedLock 多了一种乐观读的模式,以及读锁转化为写锁的方法。StampedLock内部存在state字段,高24位存储的是版本号,写锁的释放会增加其版本号,读锁不会,低7位存储的读锁被获取的次数,第8位存储的是写锁被获取的次数。由于第八位标记写锁是否被获取,不具备重入性质。

整体上看StampedLock中获取锁的过程使用了大量的自旋操作,对于短任务的执行会比较高效,长任务的执行会浪费大量CPU。

并发工具

原子操作类

前面我们聊过了利用CAS来实现原子性操作,JDK内也有对应的实现,那就是原子操作类。简单的列表如下:

原子类类型类名
基本类型原子类AtomicInteger、AtomicLong、AtomicBoolean
数组类型原子类AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
引用类型原子类AtomicReference、AtomicStampedReference、AtomicMarkableReference
升级类型原子类AtomicIntegerfieldupdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
Adder累加器LongAdder、DoubleAdder
Accumulator积累器LongAccumulator、DoubleAccumulator

原子类的作用和锁有类似之处,是为了保证并发情况下线程安全。不过原子类相比于锁,有一定的优势:

  • 粒度更细:原子变量可以把竞争范围缩小到变量级别,通常情况下,锁的粒度都要大于原子变量的粒度。
  • 效率更高:除了高度竞争的情况之外,使用原子类的效率通常会比使用同步互斥锁的效率更高,因为原子类底层利用了 CAS 操作,不会阻塞线程。

原子更新基本类型

基本类型原子类下的 AtomicBoolean,AtomicInteger, AtomicLong 三个类提供的方法类似。我们拿AtomicInteger作为例子,它的常用方法有:

int addAndGet(int delta){};
boolean compareAndSet(int expect, int update){};
int getAndIncrement(){};
void lazySet(int newValue){};
int getAndSet(int newValue){};

其中大多数的方法都是调用compareAndSet方法实现的,譬如getAndIncrement():

public final int getAndIncrement() {
    for (;;) {
      int current = get();
      int next = current + 1;
      if (compareAndSet(current, next))
       return current;
    }
}

public final boolean compareAndSet(int expect, int update) {
	return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

sun.misc.Unsafe只提供三种CAS方法:compareAndSwapObject, compareAndSwapInt和compareAndSwapLong,再看AtomicBoolean源码,发现它是先把Boolean转换成整形,再使用compareAndSwapInt进行CAS,原子更新char,float,double变量也可以用类似的思路来实现。

原子更新数组类型

以AtomicIntegerArray为例,此类主要提供原子的方式更新数组里的整形,常用方法如下:

// 以原子的方式将输入值与数组中索引i的元素相加。
int addAndGet(int i, int delta){};

// 如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值
boolean compareAndSet(int i, int expect, int update){};

例如如下代码:

int value[] = new int[]{1,2,3};
AtomicIntegerArray aia = new AtomicIntegerArray(value);
System.out.println(aia.getAndSet(1, 9));
System.out.println(aia.get(1));
System.out.println(value[1]);

运行结果:2 9 2

原子更新引用类型

之前我们介绍了AtomicInteger,相当于是对Integer的一些封装,其内部使用unsafe执行原子化操作。在这个基础之上提供了+1,-1的针对数字的操作。那么针对一般化对象呢?这个时候我们就能用上AtomicReference作为一般引用的原子操作类了。这个类提供的cas操作对比的是对象的引用。我们把常用的方法拉出来看看:

public final boolean compareAndSet(V expect, V update) {
    return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}

你会发现,和我们之前接触的AtomicInteger没什么大的差别。除此之外引用更新会产生ABA问题。ABA问题,简单的说就是一个线程将引用从A变更到B再变更到A,但是另一个线程并没有感知到这个过程。一般情况下没啥问题,就怕A-B-A的过程中把A内的数据给变更了这将会导致数据错乱。举个例子:A-B-C的链表,Thread1干的事儿是将取出A,将B删除,Thread2将A替换为D,这个时候就有可能变成D-B-C而不是D-C。为了解决上述的问题,JDK也有现成的方案,一个是AtomicMarkableReference,另一个是AtomicStampedReference。AtomicMarkableReference使用布尔值来区别新旧值,而AtomicStampedReference则是使用版本号来控制新旧。

原子更新字段类型

原子更新字段类型是FieldUpdater,例如AtomicIntegerFieldUpdater是针对一个对象下的值域进行变更的原子类。与此实现类似的还有AtomicLongFieldUpdater,AtomicReferenceFieldUpdater。AtomicIntegerFieldUpdater本身是一个抽象类,因此不能进行实例化。但是它提供了实例的构造器:

// AtomicIntegerFieldUpdater
@CallerSensitive
public static <U> AtomicIntegerFieldUpdater<U> newUpdater(Class<U> tclass,
                                                          String fieldName) {
    return new AtomicIntegerFieldUpdaterImpl<U>
        (tclass, fieldName, Reflection.getCallerClass());
}

// AtomicIntegerFieldUpdater.AtomicIntegerFieldUpdaterImpl 内部类
AtomicIntegerFieldUpdaterImpl(final Class<T> tclass,
                                      final String fieldName,
                                      final Class<?> caller) {
            final Field field;
            final int modifiers;
            try {
                // 权限相关
                field = AccessController.doPrivileged(
                    new PrivilegedExceptionAction<Field>() {
                        public Field run() throws NoSuchFieldException {
                            return tclass.getDeclaredField(fieldName);
                        }
                    });
                modifiers = field.getModifiers();
                // 字段权限校验
                sun.reflect.misc.ReflectUtil.ensureMemberAccess(
                    caller, tclass, null, modifiers);
                ClassLoader cl = tclass.getClassLoader();
                ClassLoader ccl = caller.getClassLoader();
                // 类加载器与权限校验
                if ((ccl != null) && (ccl != cl) &&
                    ((cl == null) || !isAncestor(cl, ccl))) {
                    sun.reflect.misc.ReflectUtil.checkPackageAccess(tclass);
                }
            } catch (PrivilegedActionException pae) {
                throw new RuntimeException(pae.getException());
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            }

            // 字段类型校验
            if (field.getType() != int.class)
                throw new IllegalArgumentException("Must be integer type");

            // 字段必须使用violate修饰
            if (!Modifier.isVolatile(modifiers))
                throw new IllegalArgumentException("Must be volatile type");

            this.cclass = (Modifier.isProtected(modifiers) &&
                           tclass.isAssignableFrom(caller) &&
                           !isSamePackage(tclass, caller))
                          ? caller : tclass;
            this.tclass = tclass;
            this.offset = U.objectFieldOffset(field);
        }

可以看到,AtomicIntegerFieldUpdater提供newUpdater方法,接受类与字段名作为初始化参数。最终行为执行在AtomicIntegerFieldUpdaterImpl类中。其他内部实现都是使用cas进行引用替换,与AtomicInteger的执行逻辑大差不差。

Adder累加器

对于 AtomicLong 内部的 value 属性而言,也就是保存当前 AtomicLong 数值的属性,它是被 volatile 修饰的,所以它需要保证自身可见性。这样一来,每一次它的数值有变化的时候,它都需要进行 flush 和 refresh。如果竞争很激烈,那么 flush 和 refresh 操作就会耗费了很多资源,而且 CAS 也会经常失败。因此在 JDK 8 中又新增了 LongAdder 这个类,这是一个针对 Long 类型的操作工具类。我们先写个demo看看LongAdder是怎么用的。

public class LongAdderDemo {
 
   public static void main(String[] args) throws InterruptedException {
       LongAdder counter = new LongAdder();
       ExecutorService service = Executors.newFixedThreadPool(16);
       for (int i = 0; i < 100; i++) {
           service.submit(new Task(counter));
       }
 
       Thread.sleep(2000);
       System.out.println(counter.sum());
   }
   static class Task implements Runnable {
 
       private final LongAdder counter;
 
       public Task(LongAdder counter) {
           this.counter = counter;
       }
 
       @Override
       public void run() {
           counter.increment();
       }
   }
}

代码的运行结果同样是 100,但是运行速度比刚才 AtomicLong 的实现要快。下面我们解释一下,为什么高并发下 LongAdder 比 AtomicLong 效率更高。因为 LongAdder 引入了分段累加的概念,内部一共有两个参数参与计数:第一个叫作 base,它是一个变量,第二个是 Cell[] ,是一个数组。其中的 base 是用在竞争不激烈的情况下的,可以直接把累加结果改到 base 变量上。那么,当竞争激烈的时候,就要用到我们的 Cell[] 数组了。一旦竞争激烈,各个线程会分散累加到自己所对应的那个 Cell[] 数组的某一个对象中,而不会大家共用同一个。这样一来,LongAdder 会把不同线程对应到不同的 Cell 上进行修改,降低了冲突的概率,这是一种分段的理念,提高了并发性,这就和 Java 7 的 ConcurrentHashMap 的 16 个 Segment 的思想类似。

竞争激烈的时候,LongAdder 会通过计算出每个线程的 hash 值来给线程分配到不同的 Cell 上去,每个 Cell 相当于是一个独立的计数器,这样一来就不会和其他的计数器干扰,Cell 之间并不存在竞争关系,所以在自加的过程中,就大大减少了刚才的 flush 和 refresh,以及降低了冲突的概率,这就是为什么 LongAdder 的吞吐量比 AtomicLong 大的原因,本质是空间换时间,因为它有多个计数器同时在工作,所以占用的内存也要相对更大一些。

LongAdder 最终进行计数在最后一步的求和 sum 方法,执行该方法的时候,会把各个线程里的 Cell 累计求和,并加上 base,形成最终的总和。代码如下:

public long sum() {
    Cell[] as = cells; Cell a;
    long sum = base;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

在这个 sum 方法中可以看到,思路非常清晰。先取 base 的值,然后遍历所有 Cell,把每个 Cell 的值都加上去,形成最终的总和。由于在统计的时候并没有进行加锁操作,所以这里得出的 sum 不一定是完全准确的,因为有可能在计算 sum 的过程中 Cell 的值被修改了。

那是不是意味着我们可以使用LongAdder类来替换AtomicInteger?这还是需要看场景,LongAdder 只提供了 add、increment 等简单的方法,适合的是统计求和计数的场景,场景比较单一,而 AtomicLong 还具有 compareAndSet 等高级方法,可以应对除了加减之外的更复杂的需要 CAS 的场景。

Accumulator累积器

Accumulator 和 Adder 非常相似,实际上 Accumulator 就是一个更通用版本的 Adder,比如 LongAccumulator 是 LongAdder 的功能增强版,因为 LongAdder 的 API 只有对数值的加减,而 LongAccumulator 提供了自定义的函数操作。

public class LongAccumulatorDemo {

    public static void main(String[] args) throws InterruptedException {
        LongAccumulator accumulator = new LongAccumulator((x, y) -> x + y, 0);
        ExecutorService executor = Executors.newFixedThreadPool(8);
        IntStream.range(1, 10).forEach(i -> executor.submit(() -> accumulator.accumulate(i)));

        Thread.sleep(2000);
        System.out.println(accumulator.getThenReset());
    }
}

在这段代码中:

  • 首先新建了一个 LongAccumulator,同时给它传入了两个参数;
  • 然后又新建了一个 8 线程的线程池,并且利用整形流也就是 IntStream 往线程池中提交了从 1 ~ 9 这 9 个任务;
  • 之后等待了两秒钟,这两秒钟的作用是等待线程池的任务执行完毕;
  • 最后把 accumulator 的值打印出来。

这段代码的运行结果是 45,代表 0+1+2+3+…+8+9=45 的结果,这个结果怎么理解呢?我们先重点看看新建的 LongAccumulator 的这一行语句:

LongAccumulator accumulator = new LongAccumulator((x, y) -> x + y, 0);

在这个语句中,我们传入了两个参数:LongAccumulator 的构造函数的第一个参数是二元表达式;第二个参数是 x 的初始值,传入的是 0。在二元表达式中,x 是上一次计算的结果(除了第一次的时候需要传入),y 是本次新传入的值。

我们来看一下上面这段代码执行的过程,当执行 accumulator.accumulate(1) 的时候,首先要知道这时候 x 和 y 是什么,第一次执行时, x 是 LongAccumulator 构造函数中的第二个参数,也就是 0,而第一次执行时的 y 值就是本次 accumulator.accumulate(1) 方法所传入的 1;然后根据表达式 x+y,计算出 0+1=1,这个结果会赋值给下一次计算的 x,而下一次计算的 y 值就是 accumulator.accumulate(2) 传入的 2,所以下一次的计算结果是 1+2=3。

我们在 IntStream.range(1, 10).forEach(i -> executor.submit(() -> accumulator.accumulate(i))); 这一行语句中实际上利用了整型流,分别给线程池提交了从 1 ~ 9 这 9 个任务,相当于执行了:

accumulator.accumulate(1);
accumulator.accumulate(2);
accumulator.accumulate(3);
...
accumulator.accumulate(8);
accumulator.accumulate(9);

那么根据上面的这个推演,就可以得出它的内部运行,这也就意味着,LongAccumulator 执行了:

0+1=1;
1+2=3;
3+3=6;
6+4=10;
10+5=15;
15+6=21;
21+7=28;
28+8=36;
36+9=45;

这里需要指出的是,这里的加的顺序是不固定的,并不是说会按照顺序从 1 开始逐步往上累加,它也有可能会变,比如说先加 5、再加 3、再加 6。但总之,由于加法有交换律,所以最终加出来的结果会保证是 45。这就是这个类的一个基本的作用和用法。

我们继续看一下它的功能强大之处。举几个例子,刚才我们给出的表达式是 x + y,其实同样也可以传入 x * y,或者写一个 Math.min(x, y),相当于求 x 和 y 的最小值。同理,也可以去求 Math.max(x, y),相当于求一个最大值。根据业务的需求来选择就可以了。代码如下:

LongAccumulator counter = new LongAccumulator((x, y) -> x + y, 0);
LongAccumulator result = new LongAccumulator((x, y) -> x * y, 0);
LongAccumulator min = new LongAccumulator((x, y) -> Math.min(x, y), 0);
LongAccumulator max = new LongAccumulator((x, y) -> Math.max(x, y), 0);

上述的逻辑用 for 循环也能满足需求,但是用 for 循环的话,它执行的时候是串行,它一定是按照 0+1+2+3+…+8+9 这样的顺序相加的,但是 LongAccumulator 的一大优势就是可以利用线程池来为它工作。一旦使用了线程池,那么多个线程之间是可以并行计算的,效率要比之前的串行高得多。这也是为什么刚才说它加的顺序是不固定的,因为我们并不能保证各个线程之间的执行顺序,所能保证的就是最终的结果是确定的。

接下来我们说一下 LongAccumulator 的适用场景。

第一点需要满足的条件,就是需要大量的计算,并且当需要并行计算的时候,我们可以考虑使用 LongAccumulator。当计算量不大,或者串行计算就可以满足需求的时候,可以使用 for 循环;如果计算量大,需要提高计算的效率时,我们则可以利用线程池,再加上 LongAccumulator 来配合的话,就可以达到并行计算的效果,效率非常高。

第二点需要满足的要求,就是计算的执行顺序并不关键,也就是说它不要求各个计算之间的执行顺序,也就是说线程 1 可能在线程 5 之后执行,也可能在线程 5 之前执行,但是执行的先后并不影响最终的结果。

一些非常典型的满足这个条件的计算,就是类似于加法或者乘法,因为它们是有交换律的。同样,求最大值和最小值对于顺序也是没有要求的,因为最终只会得出所有数字中的最大值或者最小值,无论先提交哪个或后提交哪个,都不会影响到最终的结果。

Semphore

Semaphore,也称信号量,是用来控制同时访问特定资源的线程数量,它协调各个线程,以保证合理的使用公共资源。具体来讲,信号量会维护“许可证”的计数,而线程去访问共享资源前,必须先拿到许可证。线程可以从信号量中去“获取”一个许可证,一旦线程获取之后,信号量持有的许可证就转移过去了,所以信号量手中剩余的许可证要减一。同理,线程也可以“释放”一个许可证,如果线程释放了许可证,这个许可证相当于被归还给信号量了,于是信号量中的许可证的可用数量加一。当信号量拥有的许可证数量减到 0 时,如果下个线程还想要获得许可证,那么这个线程就必须等待,直到之前得到许可证的线程释放,它才能获取。由于线程在没有获取到许可证之前不能进一步去访问被保护的共享资源,所以这就控制了资源的并发访问量,这就是整体思路。

Semaphore有两个构造函数,如下代码

public Semaphore(int permits) {
	sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
	sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

可以看出来,Semphore也是依赖AQS框架来实现的,第一个构造器默认为非公平,第二个构造器可以手动设置为公平的。

我们先来试试Semphore是怎么玩儿的,demo如下。

public class SemaphoreTest {
    private static final int THREAD_COUNT=30;
    private static ExecutorService threadPool = Executors.newFixedThreadPool(30);
    private static Semaphore s = new Semaphore(10);
    
    public static void main(String[] args) {
        for(int i=0;i<THREAD_COUNT;i++){
            final int a = i;
            threadPool.execute(() -> {
                try {
                    s.acquire();
                    System.out.println("do something...."+a);
                    s.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        threadPool.shutdown();
    }
}

从上面的代码可以看出Semaphore的用法非常的简单,首先线程使用Semaphore的acquire()方法获取一个许可证,使用完之后调用release()方法归还许可证。多个线程之间使用许可证来控制协作。

除了上面提到的acquire()方法可以获得到信号量外,acquireUninterruptibly() 方法也可以帮助线程获得到许可证,他们之间的区别是acquire可以支持中断的,而acquireUninterruptibly方法不支持中断,也就是说,线程使用acquire方法获取信号量的时候,这个线程被中断了,那么它就会跳出 acquire() 方法,不再继续尝试获取了。

除此之外Semphore还提供了以下方法,开发者可以根据需要按需使用:

  • boolean acquire(int permits)  一次性获取多个许可证
  • boolean release(int permits) 一次性释放多个许可证
  • boolean tryAcquire() 方法尝试获取许可证,如果有就获取,如果现在获取不到不会阻塞,可以去做别的事
  • boolean tryAcquire(long timeout, TimeUnit unit) 尝试获取并阻塞线程,如果超过超时时间限制,则直接返回 false
  • int availablePermits() 返回此信号量中当前可用的许可证数
  • int getQueueLength() 返回正在等待获取许可证的线程数
  • boolean hasQueuedThreads() 判断是否有线程正在等待获取许可证
  • void reducePermits(int reduction)减少reduction个许可证,是个protected方法
  • Collection<Thread> getQueuedThreads() 返回所有等待获取许可证的线程集合

CyclicBarrier

CyclicBarrier 和 CountDownLatch 确实有一定的相似性,它们都能阻塞一个或者一组线程,直到某种预定的条件达到之后,这些之前在等待的线程才会统一出发,继续向下执行。正因为它们有这个相似点,你可能会认为它们的作用是完全一样的,其实并不是。

CyclicBarrier 可以构造出一个集结点,当某一个线程执行 await() 的时候,它就会到这个集结点开始等待,等待这个栅栏被撤销。直到预定数量的线程都到了这个集结点之后,这个栅栏就会被撤销,之前等待的线程就在此刻统一出发,继续去执行剩下的任务。

举一个生活中的例子。假设我们班级春游去公园里玩,并且会租借三人自行车,每个人都可以骑,但由于这辆自行车是三人的,所以要凑齐三个人才能骑一辆,而且从公园大门走到自行车驿站需要一段时间。那么我们模拟这个场景,写出如下代码:

public class CyclicBarrierDemo {

    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
        for (int i = 0; i < 6; i++) {
            new Thread(new Task(i + 1, cyclicBarrier)).start();
        }
    }

    static class Task implements Runnable {

        private int id;
        private CyclicBarrier cyclicBarrier;

        public Task(int id, CyclicBarrier cyclicBarrier) {
            this.id = id;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            System.out.println("同学" + id + "现在从大门出发,前往自行车驿站");
            try {
                Thread.sleep((long) (Math.random() * 10000));
                System.out.println("同学" + id + "到了自行车驿站,开始等待其他人到达");
                cyclicBarrier.await();
                System.out.println("同学" + id + "开始骑车");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
        }
    }
}

在这段代码中可以看到,首先建了一个参数为 3 的 CyclicBarrier,参数为 3 的意思是需要等待 3 个线程到达这个集结点才统一放行;然后我们又在 for 循环中去开启了 6 个线程,每个线程中执行的 Runnable 对象就在下方的 Task 类中,直接看到它的 run 方法,它首先会打印出"同学某某现在从大门出发,前往自行车驿站",然后是一个随机时间的睡眠,这就代表着从大门开始步行走到自行车驿站的时间,由于每个同学的步行速度不一样,所以时间用随机值来模拟。

当同学们都到了驿站之后,比如某一个同学到了驿站,首先会打印出“同学某某到了自行车驿站,开始等待其他人到达”的消息,然后去调用 CyclicBarrier 的 await() 方法。一旦它调用了这个方法,它就会陷入等待,直到三个人凑齐,才会继续往下执行,一旦开始继续往下执行,就意味着 3 个同学开始一起骑车了,所以打印出“某某开始骑车”这个语句。

接下来我们运行一下这个程序,结果如下所示:

同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学5到了自行车驿站,开始等待其他人到达
同学2到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学3开始骑车
同学5开始骑车
同学2开始骑车
同学6到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学1到了自行车驿站,开始等待其他人到达
同学1开始骑车
同学6开始骑车
同学4开始骑车

可以看到 6 个同学纷纷从大门出发走到自行车驿站,因为每个人的速度不一样,所以会有 3 个同学先到自行车驿站,不过在这 3 个先到的同学里面,前面 2 个到的都必须等待第 3 个人到齐之后,才可以开始骑车。后面的同学也一样,由于第一辆车已经被骑走了,第二辆车依然也要等待 3 个人凑齐才能统一发车。

要想实现这件事情,如果你不利用 CyclicBarrier 去做的话,逻辑可能会非常复杂,因为你也不清楚哪个同学先到、哪个后到。而用了 CyclicBarrier 之后,可以非常简洁优雅的实现这个逻辑,这就是它的一个非常典型的应用场景。

接下来我们再介绍一下它的一个额外功能,就是执行动作 barrierAction 功能。CyclicBarrier 还有一个构造函数是传入两个参数的,第一个参数依然是 parties,代表需要几个线程到齐;第二个参数是一个 Runnable 对象,它就是我们下面所要介绍的 barrierAction。

当预设数量的线程到达了集结点之后,在出发的时候,便会执行这里所传入的 Runnable 对象,那么假设我们把刚才那个代码的构造函数改成如下这个样子:

CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new Runnable() {
    @Override
    public void run() {
        System.out.println("凑齐3人了,出发!");
    }
});

可以看出,我们传入了第二个参数,它是一个 Runnable 对象,在这里传入了这个 Runnable 之后,这个任务就会在到齐的时候去打印"凑齐3人了,出发!"。上面的代码如果改成这个样子,则执行结果如下所示:

同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学2到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学6到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学6开始骑车
同学2开始骑车
同学4开始骑车
同学1到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学5到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学5开始骑车
同学1开始骑车
同学3开始骑车

可以看出,三个人凑齐了一组之后,就会打印出“凑齐 3 人了,出发!”这样的语句,该语句恰恰是我们在这边传入 Runnable 所执行的结果。值得注意的是,这个语句每个周期只打印一次,不是说你有几个线程在等待就打印几次,而是说这个任务只在“开闸”的时候执行一次。

CountDownLatch

CountDownLatch,它是 JDK 提供的并发流程控制的工具类,它是在 java.util.concurrent 包下,在 JDK1.5 以后加入的。下面举个例子来说明它主要在什么场景下使用。比如我们去游乐园坐激流勇进,有的时候游乐园里人不是那么多,这时,管理员会让你稍等一下,等人坐满了再开船,这样的话可以在一定程度上节约游乐园的成本。座位有多少,就需要等多少人,这就是 CountDownLatch 的核心思想,等到一个设定的数值达到之后,才能出发。

CountDownLatch允许一个或多个线程等待其他线程完成操作。CountDownLatch的构造函数接收一个int类型的参数作为计数器。计数器必须大于等于0,只是等于0的时候,计数器就是零,调用await方法时不会阻塞当前线程。CountDownLatch不可能重新初始化或者修改CountDownLatch对象的内部计数器的值。一个线程调用countDown方法happens-before另一个线程调用的await()方法。

CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置。

下面介绍一下 CountDownLatch 的用法:

用法一:一个线程等待其他多个线程都执行完毕,再继续自己的工作

在实际场景中,很多情况下需要我们初始化一系列的前置条件(比如建立连接、准备数据),在这些准备条件都完成之前,是不能进行下一步工作的,所以这就是利用 CountDownLatch 的一个很好场景,我们可以让应用程序的主线程在其他线程都准备完毕之后再继续执行。

举个生活中的例子,那就是运动员跑步的场景,比如在比赛跑步时有 5 个运动员参赛,终点有一个裁判员,什么时候比赛结束呢?那就是当所有人都跑到终点之后,这相当于裁判员等待 5 个运动员都跑到终点,宣布比赛结束。我们用代码的形式来写出运动员跑步的场景,代码如下:

public class RunDemo1 {

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(5);
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            final int no = i + 1;
            Runnable runnable = new Runnable() {

                @Override
                public void run() {
                    try {
                        Thread.sleep((long) (Math.random() * 10000));
                        System.out.println(no + "号运动员完成了比赛。");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        latch.countDown();
                    }
                }
            };
            service.submit(runnable);
        }
        System.out.println("等待5个运动员都跑完.....");
        latch.await();
        System.out.println("所有人都跑完了,比赛结束。");
    }
}

在这段代码中,我们新建了一个初始值为 5 的 CountDownLatch,然后建立了一个固定 5 线程的线程池,用一个 for 循环往这个线程池中提交 5 个任务,每个任务代表一个运动员,这个运动员会首先随机等待一段时间,代表他在跑步,然后打印出他完成了比赛,在跑完了之后,同样会调用 countDown 方法来把计数减 1。

之后我们再回到主线程,主线程打印完“等待 5 个运动员都跑完”这句话后,会调用 await() 方法,代表让主线程开始等待,在等待之前的那几个子线程都执行完毕后,它才会认为所有人都跑完了比赛。这段程序的运行结果如下所示

等待5个运动员都跑完.....
4号运动员完成了比赛。
3号运动员完成了比赛。
1号运动员完成了比赛。
5号运动员完成了比赛。
2号运动员完成了比赛。
所有人都跑完了,比赛结束。

用法二:多个线程等待某一个线程的信号,同时开始执行

这和第一个用法有点相反,我们再列举一个实际的场景,比如在运动会上,刚才说的是裁判员等运动员,现在是运动员等裁判员。在运动员起跑之前都会等待裁判员发号施令,一声令下运动员统一起跑,我们用代码把这件事情描述出来,如下所示:

public class RunDemo2 {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("运动员有5秒的准备时间");
        CountDownLatch countDownLatch = new CountDownLatch(1);
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            final int no = i + 1;
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    System.out.println(no + "号运动员准备完毕,等待裁判员的发令枪");
                    try {
                        countDownLatch.await();
                        System.out.println(no + "号运动员开始跑步了");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
            service.submit(runnable);
        }
        Thread.sleep(5000);
        System.out.println("5秒准备时间已过,发令枪响,比赛开始!");
        countDownLatch.countDown();
    }
}

在这段代码中,首先打印出了运动员有 5 秒的准备时间,然后新建了一个 CountDownLatch,其倒数值只有 1;接着,同样是一个 5 线程的线程池,并且用 for 循环的方式往里提交 5 个任务,而这 5 个任务在一开始时就让它调用 await() 方法开始等待。

接下来我们再回到主线程。主线程会首先等待 5 秒钟,这意味着裁判员正在做准备工作,比如他会喊“各就各位,预备”这样的话语;然后 5 秒之后,主线程会打印出“5 秒钟准备时间已过,发令枪响,比赛开始”的信号,紧接着会调用 countDown 方法,一旦主线程调用了该方法,那么之前那 5 个已经调用了 await() 方法的线程都会被唤醒,所以这段程序的运行结果如下:

运动员有5秒的准备时间
2号运动员准备完毕,等待裁判员的发令枪
1号运动员准备完毕,等待裁判员的发令枪
3号运动员准备完毕,等待裁判员的发令枪
4号运动员准备完毕,等待裁判员的发令枪
5号运动员准备完毕,等待裁判员的发令枪
5秒准备时间已过,发令枪响,比赛开始!
2号运动员开始跑步了
1号运动员开始跑步了
5号运动员开始跑步了
4号运动员开始跑步了
3号运动员开始跑步了

下面来讲一下 CountDownLatch 的注意点:

  • 刚才讲了两种用法,其实这两种用法并不是孤立的,甚至可以把这两种用法结合起来,比如利用两个 CountDownLatch,第一个初始值为多个,第二个初始值为 1,这样就可以应对更复杂的业务场景了;
  • CountDownLatch 是不能够重用的,比如已经完成了倒数,那可不可以在下一次继续去重新倒数呢?这是做不到的,如果你有这个需求的话,可以考虑使用 CyclicBarrier 或者创建一个新的 CountDownLatch 实例。

Exchanger

Exchanger是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法。当两个线程都到达同步点时,这两个线程就可以交换数据,将本现场生产出来的数据传递给对方。

import java.util.concurrent.Exchanger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExchangerTest {
    private static final Exchanger<String> exchanger = new Exchanger<>();
    private static ExecutorService threadPool = Executors.newFixedThreadPool(2);

    public static void main(String[] args) {
        threadPool.execute(new Runnable(){
            @Override
            public void run() {
                String A = "I'm A!";
                try {
                    String B = exchanger.exchange(A);
                    System.out.println("In 1-"+Thread.currentThread().getName()+": "+B);
                }
                catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
            }
        });

        threadPool.execute(new Runnable(){
            @Override
            public void run() {
                try {
                    String B="I'm B!";
                    String A = exchanger.exchange(B);
                    System.out.println("In 2-"+Thread.currentThread().getName()+": "+A);
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        threadPool.shutdown();
    }
}

如果两个线程有一个没有执行exchange(V x)方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,可以使用exchange(V x, long timeout, TimeUnit unit)设置最大等待时长。

CompletionService

如果想Executor提交了一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的Future,然后反复使用get方法,同事将参数timeout指定为0,从而通过轮询来判断任务是否完成。这种方法虽然可行,但却有些繁琐。幸运的是,还有一种更好的方法:CompletionService。CompletionService将Executor和BlockingQueue的功能融合在一起。你可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这些结果会在完成时被封装为Future。ExecutorCompletionService实现了CompletionService,并将计算部分委托到一个Executor。代码示例如下:

int coreNum = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(coreNum);
CompletionService<Object> completionService = new ExecutorCompletionService<Object>(executor);

for(int i=0;i<coreNum;i++){
    completionService.submit( new Callable<Object>(){
        @Override
        public Object call() throws Exception
        {
            return Thread.currentThread().getName();
        }});
}

for(int i=0;i<coreNum;i++){
    try
        {
            Future<Object> future = completionService.take();
            System.out.println(future.get());
        }
    catch (InterruptedException | ExecutionException e)
        {
            e.printStackTrace();
        }
}

运行结果:

pool-1-thread-1

pool-1-thread-2

pool-1-thread-3

pool-1-thread-4

ThreadLocal

在并发编程中有时候需要让线程互相协作,而协作可以使用共享数据的方式来实现。针对共享数据的操作就需要锁机制来控制并发行为。锁虽好,但是毕竟会在一定程度上让线程之间互相阻塞。前辈们认为在线程需要互相协作的前提下,使用锁是最稳妥的方式。但是如果没有这个前提呢?两个线程没有关系,那当然不用做任何事情。但是如果在这个前提下,两个线程类需要使用同一个field上的数据来干自己的事儿,但是本质上不需要协作怎么办?这就变成被迫要进行共享,被迫进行加锁操作了。

ThreadLocal的出现就是为了解决这个问题,ThreadLocal可以做到每个线程都携带各自的信息,实例的值在各个线程互相不影响。这里我们写个demo看看ThreadLocal是怎么用的:

public class ThreadLocalTest {

    //共享的 ThreadLocal类,里面包裹着线程访问的值
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public void setThreadLocal(String value) {
        threadLocal.set(value);
    }

    // 打印当前的ThreadLocal包裹的数据
    public void getThreadLocal() {
        System.out.println(threadLocal.get());
    }

    public static void main(String[] args) {

        ThreadLocalTest test = new ThreadLocalTest();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.setThreadLocal("1");
                test.getThreadLocal();
            }
        },"t1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.setThreadLocal("2");
                test.getThreadLocal();
            }
        },"t2").start();
    }
}

对于两个线程而言,threadLocal是他们的共享数据,两个线程需要依赖threadLocal进行工作,但是两个线程之间不需要针对这个共享数据进行同步。对于线程t1而言,threadLocal里面的值会跟随这个线程的生命周期一直存在,而不会影响其他的所有线程。相当于对于t1线程而言,threadLocal内的数据是t1私有的。

使用ThreadLocal的时候需要注意下在使用结束之后及时清理,否则可能会造成内存泄漏。内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。因为通常情况下,如果一个对象不再有用,那么我们的垃圾回收器 GC,就应该把这部分内存给清理掉。这样的话,就可以让这部分内存后续重新分配到其他的地方去使用;否则,如果对象没有用,但一直不能被回收,这样的垃圾对象如果积累的越来越多,则会导致我们可用的内存越来越少,最后发生内存不够用的 OOM 错误。我们研究内存泄漏的前提是,这个线程的生命周期十分长。在这个前提下,参考ThreadLocal的存储模型:

我们可能会在业务代码中执行了 ThreadLocal instance = null 操作,想清理掉这个 ThreadLocal 实例,对照图的样子,会变成

你会发现,ThreadLocal还是一个被引用的状态。GC 在垃圾回收的时候会进行可达性分析,它会发现这个 ThreadLocal 对象依然是可达的,所以对于这个 ThreadLocal 对象不会进行垃圾回收,这样的话就造成了内存泄漏的情况。

JDK 开发者考虑到了这一点,所以 ThreadLocalMap 中的 Entry 继承了 WeakReference 弱引用。弱引用的特点是,如果这个对象只被弱引用关联,而没有任何强引用关联,那么这个对象就可以被回收。因此,这个弱引用的机制就避免了 ThreadLocal 的内存泄露问题。

但是于此同时ThreadLocal作为key被回收的之后,对应的value数据还是被强引用联系者,无法被GC回收,这样一来时间一久,就会发生内存泄露。那么JDK的解决方式是在下一次 ThreadLocalMap 调用 set、get、remove 的时候,主动扫描出key是null的entry,然后删除对应的Value。

但是还有一种情况,假设 ThreadLocal 已经不被使用了,那么实际上 set、remove、rehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么刚才的那个调用链就一直存在,也就导致了 value 的内存泄漏。所以最稳的方式是由开发者主动调用ThreadLocal 的 remove 方法进行主动删除。

expungeStaleEntry() 方法是帮助垃圾回收的,根据源码,我们可以发现 get 和set 方法都可能触发清理方法expungeStaleEntry(),所以正常情况下是不会有内存溢出的 但是如果我们没有调用get 和set 的时候就会可能面临着内存溢出,养成好习惯不再使用的时候调用remove(),加快垃圾回收,避免内存溢出

退一步说,就算我们没有调用get 和set 和remove 方法,线程结束的时候,也就没有强引用再指向ThreadLocal 中的ThreadLocalMap了,这样ThreadLocalMap 和里面的元素也会被回收掉,但是有一种危险是,如果线程是线程池的, 在线程执行完代码的时候并没有结束,只是归还给线程池,这个时候ThreadLocalMap 和里面的元素是不会回收掉的

Fork/Join框架

Fork/Join框架是JDK7提供的一个用于并行执行任务的框架,是一个把大任务切分为若干子任务并行的执行,最终汇总每个小任务后得到大任务结果的框架。我们再通过Fork和Join来理解下Fork/Join框架。Fork就是把一个大任务划分成为若干子任务并行的执行,Join就是合并这些子任务的执行结果,最后得到这个大任务的结果。

使用Fork/Join框架时,首先需要创建一个ForkJoin任务,它提供在任务中执行fork()和join操作的机制。通常情况下,我们不需要直接继承ForkJoinTask,只需要继承它的子类,Fork/Join框架提供了两个子类:RecursiveAction用于没有返回结果的任务;RecursiveTask用于有返回结果的任务。ForkJoinTask需要通过ForkJoinPool来执行。

任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务。(工作窃取算法work-stealing)

示例:计算1+2+3+…+100的结果。

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;
 
public class CountTask extends RecursiveTask<Integer>{
    private static final int THRESHOLD = 10;
    private int start;
    private int end;
 
    public CountTask(int start, int end){
        super();
        this.start = start;
        this.end = end;
    }
 
    @Override
    protected Integer compute(){
        int sum = 0;
        boolean canCompute = (end-start) <= THRESHOLD;
        if(canCompute) {
            for(int i=start;i<=end;i++) {
                sum += i;
            }
        } else {
            int middle = (start+end)/2;
            CountTask leftTask = new CountTask(start,middle);
            CountTask rightTask = new CountTask(middle+1,end);
            leftTask.fork();
            rightTask.fork();
            int leftResult = leftTask.join();
            int rightResult = rightTask.join();
            sum = leftResult+rightResult;
        }
        return sum;
    }
 
    public static void main(String[] args) {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        CountTask task = new CountTask(1,100);
        Future<Integer> result = forkJoinPool.submit(task);
        try {
            System.out.println(result.get());
        }
        catch (InterruptedException | ExecutionException e){
            e.printStackTrace();
        }
 
        if(task.isCompletedAbnormally()){
            System.out.println(task.getException());
        }
    }
}

工作窃取算法(work-stealing)

工作窃取算法是指某个线程从其他队列里窃取任务来执行。在生产-消费者设计中,所有消费者有一个共享的工作队列,而在work-stealing设计中,每个消费者都有各自的双端队列,如果一个消费者完成了自己双端队列中的全部任务,那么它可以从其他消费者双端队列末尾秘密地获取工作。

优点:充分利用线程进行并行计算,减少了线程间的竞争。

缺点:在某些情况下还是存在竞争,比如双端队列(Deque)里只有一个任务时。并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列。

阻塞队列

Java 提供的并发队列可以分为阻塞队列和非阻塞队列两大类。

非阻塞并发队列的典型例子是 ConcurrentLinkedQueue,这个类不会让线程阻塞,利用 CAS 保证了线程安全。我们可以根据需要自由选取阻塞队列或者非阻塞队列来满足业务需求。阻塞队列的显著特征就是BlockingQueue接口,他的类定义如下,BlockingQueue 继承了 Queue 接口,是队列的一种。BlockingQueue是阻塞队列的接口定义,其下有非常多的实现:

BlockingQueue接口实现Queue接口,它支持两个附加操作:获取元素时等待队列变为非空,以及存储元素时等待空间变得可用。相对于同一操作他提供了四种机制:抛出异常、返回特殊值、阻塞等待、超时。JDK 8 中提供了七个阻塞队列可供使用:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue :一个由链表结构组成的无界阻塞队列。
  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

阻塞队列的通用方法

我们打开BlockingQueue的源码,看看里面有什么方法。

public interface Queue<E> extends Collection<E> {
    boolean add(E e);
    boolean offer(E e);
    E remove();
    E poll();
    E element();
    E peek();
}

public interface BlockingQueue<E> extends Queue<E> {
    // 以下接口BlockingQueue独有
    void put(E e) throws InterruptedException;
    boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException;
    E take() throws InterruptedException;
    E poll(long timeout, TimeUnit unit) throws InterruptedException;
    int remainingCapacity();
    int drainTo(Collection<? super E> c);
    int drainTo(Collection<? super E> c, int maxElements);

  	// 以下方法覆盖父接口定义
    boolean add(E e);
    boolean offer(E e);
    boolean remove(Object o);
    public boolean contains(Object o);

}

在阻塞队列中有很多方法,而且它们都非常相似,我们将阻塞队列的行为分为三种,分别是插入,移除和检查。针对这三种行为有不一样的配套策略,对应整理如下。

方法\处理方式抛出异常返回特殊值超时退出一直阻塞
插入方法add(e)offer(e)offer(e,time,unit)put(e)
移除方法remove()poll()poll(time,unit)take()
检查方法element()peek()不可用不可用
  • add方法可以往队列中增加一个元素,如果插入失败则抛出异常。
  • offer方法用来插入一个元素,并用返回值来提示插入是否成功。如果添加成功会返回 true,而如果队列已经满了,此时继续调用 offer 方法的话,它不会抛出异常,只会返回一个false。
  • 超时offer方法是offer方法的重载方法,它有三个参数,分别是元素、超时时长和时间单位。通常情况下,这个方法会插入成功并返回 true;如果队列满了导致插入不成功,在调用带超时时间重载方法的 offer 的时候,则会等待指定的超时时间,如果时间到了依然没有插入成功,就会返回 false。
  • put 方法的作用是插入元素。通常在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false 也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。
  • remove 方法的作用是删除元素,如果我们删除的队列是空的,由于里面什么都没有,所以也无法删除任何元素,那么 remove 方法就会抛出异常。
  • poll 方法作用也是移除并返回队列的头节点。但是如果当队列里面是空的,没有任何东西可以移除的时候,便会返回 null 作为提示。正因如此,我们是不允许往队列中插入 null 的,否则我们没有办法区分返回的 null 是一个提示还是一个真正的元素。
  • 超时poll方法和poll方法含义一样,但是多出了时间相关的参数,这个方法表达如果能够移除元素,便会立刻返回这个节点的内容;如果队列是空的就会进行等待,等待时间正是我们指定的时间,直到超时时间到了,如果队列里依然没有元素可供移除,便会返回 null 作为提示。
  • take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
  • element 方法是返回队列的头部节点,但是并不删除。和 remove 方法一样,如果我们用这个方法去操作一个空队列,想获取队列的头结点,可是由于队列是空的,我们什么都获取不到,会抛出和前面 remove 方法一样的异常:NoSuchElementException。
  • peek 方法的意思是返回队列的头元素但并不删除。如果队列里面是空的,它便会返回 null 作为提示。

此外,阻塞队列还有一个非常重要的属性,那就是容量的大小,分为有界和无界两种。无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue 的上限是 Integer.MAX_VALUE,约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量,因为我们几乎无法把这个容量装满。但是有的阻塞队列是有界的,例如 ArrayBlockingQueue 如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。

阻塞队列的并发安全原理

阻塞队列的实现类的并发安全保证是大同小异的,我们以 ArrayBlockingQueue为切入点进行分析。我们首先看一下 ArrayBlockingQueue 的源码,ArrayBlockingQueue 有以下几个重要的属性:

// 用于存放元素的数组
final Object[] items;
// 下一次读取操作的位置
int takeIndex;
// 下一次写入操作的位置
int putIndex;
// 队列中的元素数量
int count;

// 以下3个是控制并发用的工具
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

第一个就是最核心的、用于存储元素的 Object 类型的数组,它会有两个位置变量,分别是 takeIndex 和 putIndex,这两个变量用来标明下一次读取和写入位置,count 用来计数,表达队列中的元素个数。lock和下面两个 Condition 分别是由 ReentrantLock 产生出来的,这三个变量就是我们实现线程安全最核心的工具。

我们把put方法的源码给捞出来:

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
        notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

在 put 方法中,首先用 checkNotNull 方法去检查插入的元素是不是 null。如果不是 null,我们会用 ReentrantLock 上锁,并且上锁方法是 lock.lockInterruptibly()。这个方法在获取锁的同时是可以响应中断的,这也正是我们的阻塞队列在调用 put 方法时,在尝试获取锁但还没拿到锁的期间可以响应中断的底层原因。紧接着 ,是一个非常经典的 try -finally 代码块,finally 中会去解锁,try 中会有一个 while 循环,它会检查当前队列是不是已经满了,也就是 count 是否等于数组的长度。如果等于就代表已经满了,于是我们便会进行等待,直到有空余的时候,我们才会执行下一步操作,调用 enqueue 方法让元素进入队列,最后用 unlock 方法解锁。

综上,我们可以看到ArrayBlockingQueue 实现并发同步的原理就是利用 ReentrantLock 和它的两个 Condition,读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作。进行读操作时如果队列为空,线程就会进入到读线程专属的 notEmpty 的 Condition 的队列中去排队,等待写线程写入新的元素;同理,如果队列已满,这个时候写操作的线程会进入到写线程专属的 notFull 队列中去排队,等待读线程将队列元素移除并腾出空间。

几种常见的阻塞队列

JDK事实上已经提供了很多阻塞队列的实现,我们可以按照我们的需求进行挑选。他们分别是:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

ArrayBlockingQueues

ArrayBlockingQueue 是最典型的有界队列,其内部是用数组存储元素的,利用 ReentrantLock 实现线程安全。我们在创建它的时候就需要指定它的容量,之后也不可以再扩容了,在构造函数中我们同样可以指定是否是公平的,代码如下:

ArrayBlockingQueue(int capacity, boolean fair)

第一个参数是容量,第二个参数是是否公平。正如 ReentrantLock 一样,如果 ArrayBlockingQueue 被设置为非公平的,那么就存在插队的可能;如果设置为公平的,那么等待了最长时间的线程会被优先处理,其他线程不允许插队,不过这样的公平策略同时会带来一定的性能损耗,因为非公平的吞吐量通常会高于公平的情况。

ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。

LinkedBlockingQueue

正如名字所示,这是一个内部用链表实现的 BlockingQueue。如果我们不指定它的初始容量,那么它容量默认就为整型的最大值 Integer.MAX_VALUE,由于这个数非常大,我们通常不可能放入这么多的数据,所以 LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限。

SynchronousQueue

SynchronousQueue 最大的不同之处在于,它的容量为 0,所以没有一个地方来暂存元素,导致每次取数据都要先阻塞,直到有数据被放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。

需要注意的是,SynchronousQueue 的容量不是 1 而是 0,因为 SynchronousQueue 不需要去持有元素,它所做的就是直接传递(direct handoff)。由于每当需要传递的时候,SynchronousQueue 会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。

另外,由于它的容量为 0,所以相比于一般的阻塞队列,SynchronousQueue 的很多方法的实现是很有意思的,我们来举几个例子:

SynchronousQueue 的 peek 方法永远返回 null,代码如下:

public E peek() {
    return null;
}

因为 peek 方法的含义是取出头结点,但是 SynchronousQueue 的容量是 0,所以连头结点都没有,peek 方法也就没有意义,所以始终返回 null。同理,element 始终会抛出 NoSuchElementException 异常。

而 SynchronousQueue 的 size 方法始终返回 0,因为它内部并没有容量,代码如下:

public int size() {
    return 0;
}

直接 return 0,同理,isEmpty 方法始终返回 true:

public boolean isEmpty() {
    return true;
}

因为它始终都是空的。

PriorityBlockingQueue

前面我们所说的 ArrayBlockingQueue 和 LinkedBlockingQueue 都是采用先进先出的顺序进行排序,可是如果有的时候我们需要自定义排序怎么办呢?这时就需要使用 PriorityBlockingQueue。

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。同时,插入队列的对象必须是可比较大小的,也就是 Comparable 的,否则会抛出 ClassCastException 异常。

它的 take 方法在队列为空的时候会阻塞,但是正因为它是无界队列,而且会自动扩容,所以它的队列永远不会满,所以它的 put 方法永远不会阻塞,添加操作始终都会成功,也正因为如此,它的成员变量里只有一个 Condition:

private final Condition notEmpty;

这和之前的 ArrayBlockingQueue 拥有两个 Condition(分别是 notEmpty 和 notFull)形成了鲜明的对比,我们的 PriorityBlockingQueue 不需要 notFull,因为它永远都不会满,真是“有空间就可以任性”。

DelayQueue

DelayQueue 这个队列比较特殊,具有“延迟”的功能。我们可以设定让队列中的任务延迟多久之后执行,比如 10 秒钟之后执行,这在例如“30 分钟后未付款自动取消订单”等需要延迟执行的场景中被大量使用。它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:

public interface Delayed extends Comparable<Delayed> {
    long getDelay(TimeUnit unit);
}

可以看出这个 Delayed 接口继承自 Comparable,里面有一个需要实现的方法,就是  getDelay。这里的 getDelay 方法返回的是“还剩下多长的延迟时间才会被执行”,如果返回 0 或者负数则代表任务已过期。元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。DelayQueue 内部使用了 PriorityQueue 的能力来进行排序,而不是自己从头编写,我们在工作中可以学习这种思想,对已有的功能进行复用,不但可以减少开发量,同时避免了“重复造轮子”,更重要的是,对学到的知识进行合理的运用,让知识变得更灵活,做到触类旁通。

LinkedTransferQueue

相比其他阻塞队列,在继承关系上,LinkedTransferQueue多出了TransferQueue的接口。该接口提供了一整套的transfer接口:

public interface TransferQueue<E> extends BlockingQueue<E> {

    /**
     * 若当前存在一个正在等待获取的消费者线程(使用take()或者poll()函数),使用该方法会即刻转移/传输对象元素e;
     * 若不存在,则返回false,并且不进入队列。这是一个不阻塞的操作
     */
    boolean tryTransfer(E e);

    /**
     * 若当前存在一个正在等待获取的消费者线程,即立刻移交之;
     * 否则,会插入当前元素e到队列尾部,并且等待进入阻塞状态,到有消费者线程取走该元素
     */
    void transfer(E e) throws InterruptedException;

    /**
     * 若当前存在一个正在等待获取的消费者线程,会立即传输给它;否则将插入元素e到队列尾部,并且等待被消费者线程获取消费掉;
     * 若在指定的时间内元素e无法被消费者线程获取,则返回false,同时该元素被移除。
     */
    boolean tryTransfer(E e, long timeout, TimeUnit unit)
    throws InterruptedException;

    /**
     * 判断是否存在消费者线程
     */
    boolean hasWaitingConsumer();

    /**
     * 获取所有等待获取元素的消费线程数量
     */
    int getWaitingConsumerCount();
}

除此之外,在底层实现上来说,其他的阻塞队列对读写操作都是锁上整个队列,在并发高的时候效率总是不太高的。虽然SynchronousQueue虽然不会锁住整个队列,但它是一个没有容量的队列。而LinkedTransferQueue结合了他们的特点,即可以像其他的BlockingQueue一样有容量又可以像SynchronousQueue一样不会锁住整个队列。LinkedTransferQueue采用一种预占模式。也就是说,队列里面有就直接拿走,没有就占着这个位置直到拿到或者超时或者中断。即消费者线程到队列中取元素时,如果发现队列为空,则会生成一个null节点,然后park住等待生产者。后面如果生产者线程入队时发现有一个null元素节点,这时生产者就不会入列了,直接将元素填充到该节点上,唤醒该节点的线程,被唤醒的消费者线程拿东西走人。

LinkedBlockingDeque

LinkedBlockingDeque可以说是在LinkedBlockingQueue的基础上增加了头尾的操作能力,其大部分方法都是通过linkFirst、linkLast、unlinkFirst、unlinkLast这四个方法来实现的。其他都蛮好理解的,问题不大。

组塞队列的选择方案

通常我们可以从以下 5 个角度考虑,来选择合适的阻塞队列:

  • 功能
    比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列。
  • 容量
    是否有存储的要求,还是只需要“直接传递”。在考虑这一点的时候,我们知道前面介绍的那几种阻塞队列,有的是容量固定的,如 ArrayBlockingQueue;有的默认是容量无限的,如 LinkedBlockingQueue;而有的里面没有任何容量,如 SynchronousQueue;而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE。
    所以不同阻塞队列的容量是千差万别的,我们需要根据任务数量来推算出合适的容量,从而去选取合适的 BlockingQueue。
  • 能否扩容
    需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue ,因为它的容量在创建时就确定了,无法扩容。相反,PriorityBlockingQueue 即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。所以我们可以根据是否需要扩容来选取合适的队列。
  • 内存结构
    ArrayBlockingQueue的内部结构是“数组”的形式,和它不同的是,LinkedBlockingQueue 的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。
  • 性能
    比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好。另外,SynchronousQueue 性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue。

并发容器

可能你也听说过同步容器,同步容器经常拿出来和并发容器进行比较。那么什么是同步容器呢?可以简单地理解为通过synchronized来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。比如Vector、Hashtable、Collections.synchronizedSet、SynchronizedList等方法返回的容器。可以通过查看Vector,Hashtable等这些同步容器的实现代码,可以看到这些容器实现线程安全的方式就是将它们的状态封装起来,并在需要同步的方法上加上关键字synchronized。

而并发容器使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性,例如在ConcurrentHashMap中采用了一种粒度更细的加锁机制,可以称为分段锁,在这种锁机制下,允许任意数量的读线程并发地访问map,并且执行读操作的线程和写操作的线程也可以并发的访问map,同时允许一定数量的写操作线程并发地修改map,所以它可以在并发环境下实现更高的吞吐量。

CopyOnWriteArrayList

CopyOnWriteArrayList在设计上来说,在每次修改时,都会创建并重新发布一个新的容器副本,从而实现读写分离。CopyOnWriteArrayList的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写时复制”容器返回的迭代器不会抛出ConcurrentModificationException并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时,仅当迭代操作远远多于修改操作时,才应该使用“写入时赋值”容器。

从上述的实现原理上可以看出,我们在使用CopyOnWriteArrayList的时候需要考虑其场景,例如你的操作大部分是读,且数据量不大的情况下可以使用CopyOnWriteArrayList作为并发容器使用。

CurrentHashMap

CurrentHashMap是HashMap的并发版本,其内部的数据结构与相关操作与HashMap并没有太大的差别,可以简单理解在HashMap的基础上增加了分段锁的能力。所谓分段锁是一种锁的设计,当我们put一个数据的时候,一般做法会将桶的数组全部锁住然后进行执行。这样一来对于频繁操作的场合将会产生大量加锁的过程影响性能,分段锁的玩法就是当你需要访问某个桶的时候进行锁定,其他桶上的数据还是依旧可以访问或者操作的。

ConcurrentHashMap的扩容逻辑相对复杂,这里先将大概逻辑说明白。满足扩容的条件下,会先进行新数组的创建,是旧数组的两倍,使用ForwardingNode进行封装。这个时候需要进行搬迁元素了,如果是单线程的场合下,使用CPU与列表长度计算出搬迁的范围,最小一个线程负担16个桶的搬迁。搬迁从尾部开始,如果列表长度大于16则依靠外部循环不断往前进行搬迁。如果是多线程场合下,会将搬迁任务分散到多个线程上,每个线程搬迁自己负责的部分。当没有轮到搬迁的桶可以继续get/put,如果是正在搬迁,则等待搬迁结束。已经结束的则会请求到ForwardingNode内部,操作新列表。

CurrentSkipListMap

跳表实质是一个可以进行二分查找的有序链表。我们知道使用数组进行二分是很方便的,要求数组内数据是顺序排序即可,但是数组存储对插入移除数据并不友好,需要将数据进行大批量迁移。使用堆的方式存储数据对拆入移除数据的效率也还是可以的,但是不支持快速的查找操作,只能找到最大值或最小值。链表对插入与移除支持友好,但是对查找数据而言需要遍历。红黑树挺好,对查询,插入,移除数据友好,但是其缺点是范围查询支持不高。综上,各种数据结构都有其弱点,而我们的跳表集齐上述数据结构的优点,本质上是使用空间换时间的思路,因此内存消耗会大一些。比如以下链表:

上面说的是跳表的查询,当然少不了结点的插入与删除步骤,我们先说结点插入,如图所示:

比如,我们要向上面这个跳表添加一个元素8。首先,我们先根据随机的方式,确定目标层数,假定定位到了h1这一层,然后,找到8这个元素在下面两层的前置节点。接着,就是链表的插入元素操作了。整个过程比较简单。同样的删除逻辑如下图所示:

首先找到各层中包含元素x的节点。然后使用标准的链表删除元素的方法删除即可

CopyOnWriteArraySet

CopyOnWriteArraySet思路和CopyOnWriteList一致,其内部使用的是CopyOnWriteList进行存储。

ConcurrentLinkedQueue

ConcurrentLinkedQueue是Queue的的并发版本,在队首进行并发控制,为了避免大量锁耗时,其内部实现使用CAS进行操作。实现上还是有点麻烦的。ConcurrentLinkedDeque�则是增强了对首尾的操作能力。

线程池技术

我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。前辈们就想了个招,使用了池化技术,通过对多线程的复用来减少创建线程所带来的效率低下问题。有了线程池的存在,不光可以重用存在的线程,减少对象创建销毁的开销,也可以完成对线程的精细化管理,避免过多资源竞争。

线程池的构造函数

线程池最原始的构造器如下:

ThreadPoolExecutor(int corePoolSize,
                   int maximumPoolSize, 
                   long keepAliveTime, 
                   TimeUnit unit, 
                   BlockingQueue<Runnable> workQueue, 
                   ThreadFactory threadFactory, 
                   RejectedExecutionHandler handler)

线程池的构造函数的参数相当多,我们每一项都描述一遍。

第一项参数:corePoolSize(核心线程数)

当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时,才会根据是否存在空闲线程,来决定是否需要创建新的线程。除了利用提交新任务来创建和启动线程(按需构造),也可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。

第二项参数:maximumPoolSize(线程池线程数最大值)

线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。

第三项参数:keepAliveTime(线程存活保持时间):

默认情况下,当线程池的线程个数多于corePoolSize时,线程的空闲时间超过keepAliveTime则会终止。但只要keepAliveTime大于0,allowCoreThreadTimeOut(boolean) 方法也可将此超时策略应用于核心线程。另外,也可以使用setKeepAliveTime()动态地更改参数。

第四项参数:unit(存活时间的单位):

时间单位,分为7类,从细到粗顺序:NANOSECONDS(纳秒),MICROSECONDS(微妙),MILLISECONDS(毫秒),SECONDS(秒),MINUTES(分),HOURS(小时),DAYS(天);

第五项参数:workQueue(任务队列):

用于传输和保存等待执行任务的阻塞队列。可以使用此队列与线程池进行交互。阻塞队列的选择可以是:ArrayBlockingQueue,LinkedBlockingQueue,SynchronousQueue等等

第六项参数:threadFactory(线程工厂):

threadFactory用于创建新线程。由同一个threadFactory创建的线程,属于同一个ThreadGroup,创建的线程优先级都为Thread.NORM_PRIORITY,以及是非守护进程状态。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。我们也可以自定义一个线程工厂,通过实现 ThreadFactory 接口来完成,这样就可以自定义线程的名称或线程执行的优先级了。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
    // // Executors.defaultThreadFactory() 为默认的线程创建工厂
    this(corePoolSize, 
         maximumPoolSize, 
         keepAliveTime, 
         unit, 
         workQueue, 
         Executors.defaultThreadFactory(), 
         defaultHandler);
}

public static ThreadFactory defaultThreadFactory() {
    return new DefaultThreadFactory();
}

// 默认的线程创建工厂,需要实现 ThreadFactory 接口
static class DefaultThreadFactory implements ThreadFactory {
    private static final AtomicInteger poolNumber = new AtomicInteger(1);
    private final ThreadGroup group;
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    private final String namePrefix;

    DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
        namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-";
    }
    // 创建线程
    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
        if (t.isDaemon()) 
            // 创建一个非守护线程
            t.setDaemon(false); 
        if (t.getPriority() != Thread.NORM_PRIORITY)
            // 线程优先级设置为默认值
            t.setPriority(Thread.NORM_PRIORITY); 
        return t;
    }
}

第七项参数:handler(线程饱和策略)

当线程池和队列都满了,则表明该线程池已达饱和状态。

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1,
    3,
    10,
    TimeUnit.SECONDS, new LinkedBlockingQueue<>(2),
    new ThreadPoolExecutor.AbortPolicy() // 添加 AbortPolicy 拒绝策略
); 

for (int i = 0; i < 6; i++) {
    executor.execute(() -> {
        System.out.println(Thread.currentThread().getName());
    });
}

获得到的输出是:

pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-3
pool-1-thread-2
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.lagou.interview.ThreadPoolExample$$Lambda$1/1096979270@448139f0 rejected from java.util.concurrent.ThreadPoolExecutor@7cca494b[Running, pool size = 3, active threads = 3, queued tasks = 2, completed tasks = 0]
 at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
 at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
 at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
 at com.lagou.interview.ThreadPoolExample.rejected(ThreadPoolExample.java:35)
 at com.lagou.interview.ThreadPoolExample.main(ThreadPoolExample.java:26)

可以看出当第 6 个任务来的时候,线程池则执行了 AbortPolicy  拒绝策略,抛出了异常。因为队列最多存储 2 个任务,最大可以创建 3 个线程来执行任务(2+3=5),所以当第 6 个任务来的时候,此线程池就忙不过来了,直接抛出异常。当然除了上面提到的ThreadPoolExecutor.AbortPolicy的饱和策略,还有几个饱和策略:
1)ThreadPoolExecutor.AbortPolicy
处理程序遭到拒绝,则直接抛出运行时异常 RejectedExecutionException。(默认策略)
2)ThreadPoolExecutor.CallerRunsPolicy
调用者所在线程来运行该任务,此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。
3)ThreadPoolExecutor.DiscardPolicy
无法执行的任务将被删除。
4)ThreadPoolExecutor.DiscardOldestPolicy
如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重新尝试执行任务(如果再次失败,则重复此过程)。

如果我们发现JDK自带的拒绝策略还是不能满足与自己的需求,我们就可以自己造一个出来,想实现自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可,如下代码所示:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
				1, 
				3, 
				10,
        TimeUnit.SECONDS, 
        new LinkedBlockingQueue<>(2),
        new RejectedExecutionHandler() {  // 添加自定义拒绝策略
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                System.out.println("开始执行自定义拒绝策略");
            }
        });
for (int i = 0; i < 6; i++) {
    executor.execute(() -> {
        System.out.println(Thread.currentThread().getName());
    });
}

线程池的工作原理

线程池的整个生命周期异常复杂,其内部控制的点很多。还是之前的方式,我们把握主线,其他所有的细节我们放在源码解析的部分详细分析。线程池的主线无非是不断往里面添加上任务,那么我们就以这个切入点来描述当你提交一个新的任务的时候会发生的事情。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

按照上面的流程图描述的那样,当提交任务后,线程池首先会检查当前线程数,如果此时线程数小于核心线程数,比如最开始线程数量为 0,则新建线程并执行任务,随着任务的不断增加,线程数会逐渐增加并达到核心线程数,此时如果仍有任务被不断提交,就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。此时,假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maxPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数,如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务,我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize 、workQueue 、maxPoolSize ,如果依然不能满足需求,则会拒绝任务。

线程池的生命周期

分别是RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED;
(4)RUNNING,表示可接受新任务,且可执行队列中的任务;
(5)SHUTDOWN,表示不接受新任务,但可执行队列中的任务;
(6)STOP,表示不接受新任务,且不再执行队列中的任务,且中断正在执行的任务;
(7)TIDYING,所有任务已经中止,且工作线程数量为0,最后变迁到这个状态的线程将要执行terminated()钩子方法,只会有一个线程执行这个方法;
(8)TERMINATED,中止状态,已经执行完terminated()钩子方法;

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程池的重要方法

execute()方法用于提交不需要返回值的任务,因此返回类型是void,它定义在Executor接口中,无法判断任务是否被线程池执行成功。

ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 10, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue(20));
// execute 使用
executor.execute(new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello, execute.");
    }
});

submit()方法用于提交需要返回值的任务,线程池会返回一个Future类型的对象,通过这个对象可以判断任务是否执行成功。它定义在ExecutorService接口中,它扩展了Executor接口,其它线程池类像ThreadPoolExecutor和ScheduledThreadPoolExecutor都有这些方法。

ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 10, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue(20));
// submit 使用
Future<String> future = executor.submit(new Callable<String>() {
    @Override
    public String call() throws Exception {
        System.out.println("Hello, submit.");
        return "Success";
    }
});
System.out.println(future.get());

在线程池运行过程中,我们可能需要制造线程池的运行状况来调整我们的执行策略,线程池内部已经提供了一些参数获取的方法,我们可以使用这些方法进行监控,这些方法如下:

  • getTaskCount():线程池需要执行的任务数量。
  • getCompletedTaskCount():线程池在运行过程中已完成的任务数量,小于或等于taskCount。
  • getLargestPoolSize():线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
  • getPoolSize():线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。
  • getActiveCount():获取活动的线程数。

当我们不需要线程池继续运行的时候,我们可以使用shutdown或者shutdownNow方法来关闭线程池。这里的两种关闭方法是有区别的,需要按照你的需求谨慎选择。

  • shutdown方法
    将执行平缓的关闭过程:不在接收新的任务,同时等待已提交的任务执行完成——包括哪些还未开始执行的任务。
  • shutdownNow方法
    将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。

他们的原理是遍历线程池的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法停止。只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true,当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminated方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。

线程池的扩展方式

继承线程池来自定义线程池,重写线程池的beforeExecute, afterExecute和terminated方法。在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、监视或者统计信息收集的功能。无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被调用。如果任务在完成后带有一个Error,那么就不会调用afterExecute。如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。在线程池完成关闭时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后,terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知、记录日志或者手机finalize统计等操作。

ThreadPoolExecutor 的扩展主要是通过重写它的 beforeExecute() 和 afterExecute() 方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间,如下代码所示:

import java.util.concurrent.*;

public class ThreadPoolExtend {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 线程池扩展调用
        MyThreadPoolExecutor executor = new MyThreadPoolExecutor(
                2, 
                4, 
                10,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue()
        );
        for (int i = 0; i < 3; i++) {
            executor.execute(() -> {
                Thread.currentThread().getName();
            });
        }
    }

    /**
     * 线程池扩展
     */
    static class MyThreadPoolExecutor extends ThreadPoolExecutor {
        // 保存线程执行开始时间
        private final ThreadLocal<Long> localTime = new ThreadLocal<>();

        public MyThreadPoolExecutor(int corePoolSize, 
                                    int maximumPoolSize, 
                                    long keepAliveTime, 
                                    TimeUnit unit, 
                                    BlockingQueue<Runnable> workQueue) {
            super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
        }

        /**
         * 开始执行之前
         *
         * @param t 线程
         * @param r 任务
         */
        @Override
        protected void beforeExecute(Thread t, Runnable r) {
            Long sTime = System.nanoTime(); // 开始时间 (单位:纳秒)
            localTime.set(sTime);
            System.out.println(String.format("%s | before | time=%s", t.getName(), sTime));
            super.beforeExecute(t, r);
        }

        /**
         * 执行完成之后
         *
         * @param r 任务
         * @param t 抛出的异常
         */
        @Override
        protected void afterExecute(Runnable r, Throwable t) {
            Long eTime = System.nanoTime(); // 结束时间 (单位:纳秒)
            Long totalTime = eTime - localTime.get(); // 执行总时间
            System.out.println(String.format("%s | after | time=%s | 耗时:%s 毫秒", 
                                             Thread.currentThread().getName(), eTime, (totalTime / 1000000.0)));
            super.afterExecute(r, t);
        }
    }
}

获得到的输出是:

pool-1-thread-1 | before | time=4570298843700
pool-1-thread-2 | before | time=4570298840000
pool-1-thread-1 | after | time=4570327059500 | 耗时:28.2158 毫秒
pool-1-thread-2 | after | time=4570327138100 | 耗时:28.2981 毫秒
pool-1-thread-1 | before | time=4570328467800
pool-1-thread-1 | after | time=4570328636800 | 耗时:0.169 毫秒

线程池的使用策略

在线程池使用过程中,需要注意以下几点:

1)高并发、任务执行时间短的业务,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换
2)并发不高、任务执行时间长的业务要区分开看:
a)假如是业务时间长集中在IO操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务
b)假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,和 1)一样吧,线程池中的线程数设置得少一些,减少线程上下文的切换
3)并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦。

定时线程池

考虑延时执行或者周期性执行的场合下,我们可以使用Timer类进行处理,但是在JDK5开始就很少使用Timer了,取而代之的可以使用ScheduledThreadPoolExecutor。使用实例如下:

import java.util.concurrent.*;

public class ScheduleThreadPoolTest {
    private static ScheduledExecutorService exec = Executors.newScheduledThreadPool(2);

    public static void method1() {
        exec.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("1");
            }
        }, 2, TimeUnit.SECONDS);
    }

    public static void method2() {
        ScheduledFuture<String> future = exec.schedule(new Callable<String>() {
            @Override
            public String call() throws Exception {
                return "Callable";
            }
        }, 4, TimeUnit.SECONDS);
        try {
            System.out.println(future.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        method1();
        method2();
    }
}

ScheduledThreadPoolExecutor是定时执行线程池,继承自线程池并实现ScheduledExecutorService接口,它表示在未来某个时刻执行,或者未来按照某种规则重复执行的任务执行池。接口定义如下:

// 创建并执行在给定延迟后启用的 ScheduledFuture。
<V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit)

// 创建并执行在给定延迟后启用的一次性操作。
ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit)

// 创建并执行一个在给定初始延迟后首次启用的定期操作,后续操作具有给定的周期;
// 也就是将在 initialDelay 后开始执行,然后在 initialDelay+period 后执行,接着在 initialDelay + 2 * period 后执行,依此类推。
ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)

// 创建并执行一个在给定初始延迟后首次启用的定期操作,随后,在每一次执行终止和下一次执行开始之间都存在给定的延迟。
ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)

可以看到,定时任务总体分为四种:
(1)未来执行一次的任务,无返回值;
(2)未来执行一次的任务,有返回值;
(3)未来按固定频率重复执行的任务;
(4)未来按固定延时重复执行的任务;
这里只需要关注重复与定时两个概念就可以,其背后实现上来说,重复是在执行之后感知到是一个重复任务则丢回到队列,延时执行则是使用其内部的优先级队列来控制定时。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值