《Java 并发编程艺术》笔记(下)

线程的状态

Java 线程在运行的生命周期中可能处于表4-1所示的6种不同的状态,在给定的一个时刻,线程只能处于其中的一个状态。

在这里插入图片描述
在这里插入图片描述

Daemon 线程

Daemon 线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为 Daemon 线程。

注意:Daemon 属性需要在启动线程之前设置,不能再线程启动之后设置。

Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行

理解中断

中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的 intercept() 方法对其进行中断操作。

线程通过检查自身是否被中断来进行响应,线程通过方法 isInterrupted() 来进行判断是否被中断,也可以调用静态方法 Thread.interrupted() 对当前线程的中断标识位进行复位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的 isInterrupted() 时依旧会返回 false

从 Java 的 API 中可以看到,许多声明抛出 InterruptedException 的方法(例如Thread.sleep(long millis)方法)这些方法在抛出InterruptedException之前,Java 虚拟机会先将该线程的中断标识位清除,然后抛出 InterruptedException,此时调用 isInterrupted() 方法将会返回 false

安全地终止线程

线程自然终止:run() 方法执行完成或者抛出一个未处理的异常导致线程提前结束。

中断状态是线程的一个标识位,而中断操作是一种简便的线程间交互方式,而这种交互方式最适合用来取消或停止任务。除了中断以外,还可以利用一个boolean变量来控制是否需要停止任务并终止该线程。

对象监视器——管程锁

任意线程对 Object 的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的线程(获得了锁的线程)释放了锁,则该释放操作唤醒在同步队列中的线程,使其重新尝试对监视器的获取。即同一时刻是能有一个线程获得到由synchronized所保护对象的监视器。

在这里插入图片描述

等待 / 通知机制

等待 / 通知机制,是指一个线程 A 调用了对象 O 的 wait() 方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify() 或者 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait() 方法返回,进而执行后续操作。上述两个线程通过对象 O 来完成交互,而对象上的 wait()notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

在这里插入图片描述

使用注意:

  • 1)使用wait()、notify()、notifyAll()方法都需要先对调用对象加锁。(即锁对象应该为调用对象)
  • 2)调用wait()方法后,线程状态由RUNNING(运行)变为WAITTING(等待),并将当前线程放到对象的等待队列
  • 3)notify()notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()notifyAll()的线程释放锁之后,等待线程才有机会从wait()返回。
  • 4)notify()方法将对象的等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的状态由WAITING变为BLOCKED
  • 5)从wait()返回的前提是获得了调用对象的锁。

在这里插入图片描述

在图4-3中,WaitThread首先获取了对象的锁,然后调用对象的 wait() 方法,从而放弃了锁并进入了对象的等待队列 WaitQueue 中,进入等待状态。由于 WaitThread 释放了对象的锁,NotifyThread 随后获取了对象的锁,并调用对象的 notify() 方法,将 WaitThreadWaitQueue 移到 SychronizedQueue 中,此时 WaitThread 的状态变为阻塞状态NotifyThread 释放了锁之后,WaitThread 再次获取锁并从 wait() 方法返回继续执行。

等待 / 通知的经典范式

经典范式可以分为两部分,分别针对:等待方(消费者)通知方(生产者)

等待方遵循如下原则:

  • 1)获取对象的锁
  • 2)如果条件不满足,那么调用对象的 wait() 方法,被通知后仍要检查条件。
  • 3)条件满足执行对应的逻辑

对应的伪代码如下:

synchronized(对象) {
    
    
	while (条件不满足) {
    
    
		对象.wait();
	}
	对应的处理逻辑
}

通知方遵循如下原则:

  • 1)获取对象的锁。
  • 2)改变条件。
  • 3)通知所有等待在对象上的线程。

对应的伪代码如下:

synchronized(对象) {
    
     
	改变条件;
	对象.notifyAll();
}

Thread.join() 的使用

如果一个线程 A 执行了 thread.join(),其含义是:当前线程 A 等待 thread 线程终止后才从 thread.join() 返回。

线程 Thread 除了join()方法外还有join(long millis)join(long millis,int nanos)两个具备超时的方法,这两个方法表示:如果线程 thread 没有在指定时间内停止,那么线程 A 会从该超时方法返回

import java.util.concurrent.TimeUnit;

public class JoinDemo {
    
    

    public static void main(String[] args) throws InterruptedException {
    
    
        // 获取当前线程信息
        Thread previousThread = Thread.currentThread();

        for (int i = 0; i < 10; i++) {
    
    
            Thread thread = new Thread(new Domino(previousThread));
            thread.start();
            previousThread = thread;
        }

        TimeUnit.SECONDS.sleep(5);
        System.out.println(Thread.currentThread().getName() + " terminate.");
    }

    static class Domino implements Runnable {
    
    
        private Thread thread;

        public Domino(Thread thread) {
    
    
            this.thread = thread;
        }

        @Override
        public void run() {
    
    
            try {
    
    
                thread.join();
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName() + " terminate.");
        }
    }
}

在代码清单4-13所示的例子中,创建了 10 个线程,编号 0~9,每个线程调用前一个线程的 join() 方法,也就是线程 0 结束了,线程 1 才能从 join() 方法中返回,而线程 0 需要等待 main 线程结束。

输出如下:

main terminate.
Thread-0 terminate.
Thread-1 terminate.
Thread-2 terminate.
Thread-3 terminate. 
Thread-4 terminate. 
Thread-5 terminate. 
Thread-6 terminate. 
Thread-7 terminate. 
Thread-8 terminate.
Thread-9 terminate.

从上述输出可以看到,每个线程终止的前提是前驱线程的终止,每个线程等待前驱线程终止后,才从 join() 方法返回,这里涉及了等待/通知机制(等待前驱线程结束,接收前驱线程结束通知)。

在这里插入图片描述

当线程终止时,会调用线程自身的notifyAll()方法,会通知所有等待在该线程对象上的线程。可以看到join()方法的逻辑结构和等待/通知经典范式一致,即加锁循环处理逻辑 3 个步骤。

Lock接口

在这里插入图片描述
在这里插入图片描述

队列同步器(AQS)

队列同步器AbstractQueuedSynchronizer,是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作,并发包的作者期望它能够成为实现大部分同步需求的基础。

同步器的主要使用方式是继承子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的 3 个方法(getState()setState(int newState)compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLockReentrantReadWriteLockCountDownLatch等)。

同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。

队列同步器的接口和示例

同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。

  • (1) getState():获取当前同步状态。
  • (2) setState(int newState):设置当前同步状态。
  • (3) compareAndSetState(int expect,int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性。

在这里插入图片描述

实现自定义同步组件时,将会调用同步器提供的模板方法,这些模板方法如下

在这里插入图片描述

模板方法基本分为3类:独占式同步状态获取与释放、共享式同步状态获取与释放和查询同步队列中等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

class Mutex implements Lock {
    
    
    // 静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
    
    
        // 是否处于占用状态
        protected boolean isHeldExclusively() {
    
    
            return getState() == 1;
        }

        // 当状态为0的时候获取锁
        public boolean tryAcquire(int acquires) {
    
    
            if (compareAndSetState(0, 1)) {
    
    
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 释放锁,将状态设置为0
        protected boolean tryRelease(int releases) {
    
    
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // 返回一个Condition,每个condition都包含了一个condition队列
        Condition newCondition() {
    
    
            return new ConditionObject();
        }
    }

    // 仅需要将操作代理到Sync上即可
    private final Sync sync = new Sync();

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

    // 尝试非阻塞的获取锁
    public boolean tryLock() {
    
    
        return sync.tryAcquire(1);
    }

    // 释放锁
    public void unlock() {
    
    
        sync.release(1);
    }

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

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

    public boolean hasQueuedThreads() {
    
    
        return sync.hasQueuedThreads();
    }

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

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

上述示例中,独占锁Mutex是一个自定义同步组件,它在同一时刻只允许一条线程占有锁。Mutex定义了一个静态内部类,该内部类继承了同步器并实现了独占式获取和释放同步状态。在tryAcquire(int acquire)方法中,如果经过 CAS 设置成功(同步状态设置为1),则代表获取了同步状态,而在tryRelease(int release)方法中只是将同步状态重置为0。用户使用Mutex时,并不会直接和内部同步器实现打交道。而是调用Mutex提供的方法,在Mutex的实现中,以获取锁的lock()方法为例:只需要在方法实现中调用同步器的模板方法acquire(int args)即可。当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个可靠自定义组件的门槛。

队列同步器的实现分析

同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态

同步队列中的节点(Node):用来保存获取同步状态失败的线程引用等待状态以及前驱和后继节点信息

在这里插入图片描述

节点属性类型和名称以及描述如表5-5所示:

在这里插入图片描述

节点是构成同步队列(即等待队列)的基础,同步器拥有首节点(head)尾节点(tail)没有成功获取同步状态的线程将会成为节点加入队列的尾部

同步队列的结构如下:

在这里插入图片描述

同步器包含两个节点类型的引用,一个指向头节点,另一个一个指向尾节点。线程加入队列的过程必须保证线程安全,同步器提供了一个基于 CAS设置尾节点的方法:compareAndSetTail(Node expect,Node update),保证线程安全。

同步器将节点加入同步队列的过程如图5-2所示。

在这里插入图片描述

同步队列遵循FIFO首节点是获取同步状态成功的节点首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点

该过程如图5-3所示。

在这里插入图片描述

在图5-3中,设置首节点是通过获取同步状态成功的线程完成的由于只有一个线程能够获取到同步状态,因此设置头节点的方法并不需要 CAS 来保障,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的 next 引用即可

节点进入队列后,就进入了一个自旋状态,每个节点(或者说每个线程),都在自省观察,当条件满足,获取到同步状态,就可以从这个自旋过程中退出,否则依旧留在自旋过程中(并会阻塞节点的线程)。

final boolean acquireQueued(final Node node, int arg) {
    
    
    boolean failed = true;
    try {
    
    
        boolean interrupted = false;
        for (;;) {
    
    
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
    
    
                setHead(node);
                p.next = null;
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) {
    
    
                interrupted = true;
            }
        }
    } finally {
    
    
        if (failed) {
    
    
            cancelAcquire(node);
        }
    }
}

acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个,如下。

  • 第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
  • 第二,维护同步队列的FIFO原则。

该方法中, 节点自旋获取同步状态的行为如图5-4所示。

在这里插入图片描述

在图5-4中,由于非首节点线程前驱节点出队或被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态,可以看到节点与及节点之间在循环检查的过程中基本上不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放符合 FIFO,并且对于方便对过早通知进行处理(过早通知指的是前驱节点不是头节点的线程由于中断被唤醒)。

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程,如图5-5所示。

在这里插入图片描述

独占式同步状态获取和释放过程的简单总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

Condition 接口

任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括wait()wait(long timeout)notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。

通过对比Object的监视器方法与Condition接口,可以更详细地了解 Condition 的特性

在这里插入图片描述

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

public void conditionWait() throws InterruptedException {
    
    
    lock.lock();
    try {
    
    
        condition.await();
    } finally {
    
    
        lock.unlock();
    }
}

public void conditionSignal() throws InterruptedException {
    
    
    lock.lock();
    try {
    
    
        condition.signal();
    } finally {
    
    
        lock.unlock();
    }
}

如示例所示,一般都会将Condition对象作为成员变量,调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

ConcurrentHashMap结构

ConcurrentHashMap 是由 Segment 数组 结构和 HashEntry 数组结构组成。Segment 是一种可重入锁(实现了 ReentrantLock)。HashEntry 用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,但是 Segment 的个数一旦初始化就不能改变。

一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

在这里插入图片描述
在这里插入图片描述

什么是阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。

  • 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满,如 put() 方法。
  • 支持阻塞的移除方法:在队列为空时,获取元素的线程会等待队列变为非空,如 take() 方法。

阻塞队列常用于生产者-消费者场景,生产者是往队列里添加元素的线程,消费者是从队列里取元素的线程。BlockingQueue 就是存放生产元素、消费者用来获取元素的容器。

在阻塞队列不可用时,这两个附加操作提供了 4 种处理方式,如表 6-1 所示。

在这里插入图片描述

  • 抛出异常:如果试图的操作无法立即执行则抛出异常。当阻塞队列满时候,再往队列里插入元素,会抛出IllegalStateException(“Queue full”)异常。当队列为空时,从队列里获取元素时会抛出NoSuchElementException 异常 。
  • 返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回 true。如果是移除方法,队列为空时返回 null
  • 一直阻塞:如果试图的操作无法立即执行,则一直阻塞或者响应中断。
  • 超时退出:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功,通常是 true / false

Java 里的阻塞队列

JDK 7 提供了 7 个阻塞队列,如下:

  • ArrayBlockingQueue一个由数组结构组成的有界阻塞队列
  • LinkedBlockingQueue一个由链表结构组成的阻塞队列(可无界、可有界)
  • PriorityBlockingQueue一个支持优先级排序的无界阻塞队列
  • DelayQueue一个使用优先级队列实现的无界阻塞队列
  • SynchronousQueue一个不存储元素的阻塞队列
  • LinkedTransferQueue一个由链表结构组成的无界阻塞队列
  • LinkedBlockingDeque一个由链表结构组成的双向阻塞队列

阻塞队列的实现原理

使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码发现ArrayBlockingQueue使用了Condition来实现,代码如下。

private final Condition notFull;
private final Condition notEmpty;

public ArrayBlockingQueue(int capacity, boolean fair) {
    
    
    // 省略其他代码
    notEmpty = lock.newCondition();
    notFull = lock.newCondition();
}

public void put(E e) throws InterruptedException {
    
    
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
    
    
        while (count == items.length) {
    
    
            notFull.await();
        }
        insert(e);
    } finally {
    
    
        lock.unlock();
    }
}

public E take() throws InterruptedException {
    
    
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
    
    
        while (count == 0) {
    
    
            notEmpty.await();
        }
        return extract();
    } finally {
    
    
        lock.unlock();
    }
}

private void insert(E x) {
    
    
    items[putIndex] = x;
    putIndex = inc(putIndex);
    ++count;
    notFull.signal();
}

当我们往队列里插入一个元素时,如果队列不可用,阻塞生产者主要通过LockSupport.park(this);来实现

线程池的实现原理

当向线程池提交一个任务之后,线程池是如何处理这个任务的呢?

在这里插入图片描述

从图中可以看出,当提交一个新任务到线程池时,线程池的处理流程如下。

  • 1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
  • 2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  • 3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

ThreadPoolExecutor 执行 execute() 方法的示意图,如图 9-2 所示。

在这里插入图片描述

ThreadPoolExecutor 执行 execute 方法分下面 4 种情况。

  • 1)如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(执行这一步骤需要获取全局锁)。
  • 2)如果运行的线程等于或多于 corePoolSize,则将任务加入BlockingQueue
  • 3)如果无法将任务加入 BlockingQueue(队列已满),则创建新的线程来处理任务(执行这一步骤需要获取全局锁)
  • 4)如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。

ThreadPoolExecutor 采取上述步骤的总体设计思路,是为了在执行 execute() 方法时,尽可能地避免获取全局锁。在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于corePoolSize),几乎所有的 execute() 方法调用都是执行步骤 2,而步骤 2 不需要获取全局锁。

源码分析:上面的流程分析让我们很直观地了解了线程池的工作原理,让我们再通过源代码来看看是如何实现的,线程池执行任务的方法如下。

在这里插入图片描述

工作线程:线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后,还会循环获取工作队列里的任务来执行。我们可以从Worker类的run()方法里看到这点。

public void run() {
    
    
    try {
    
    
        Runnable task = firstTask;
        firstTask = null;
        while (task != null || (task = getTask()) != null) {
    
    
            runTask(task);
            task = null;
        }
    } finally {
    
    
        workerDone(this);
    }
}

ThreadPoolExecutor中线程执行任务的示意图如图所示。

在这里插入图片描述

线程池中的线程执行任务分两种情况,如下。

1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中 1 的任务后,会反复从 BlockingQueue 获取任务来执行。

向线程池提交任务

可以使用两个方法向线程池提交任务,分别为 execute()submit() 方法。

execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。

通过以下代码可知execute()方法输入的任务是一个Runnable类的实例。

threadsPool.execute(new Runnable() {
    
    
    @Override
    public void run() {
    
    
        // TODO Auto-generated method stub
    }
});

submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future.get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

Future<Object> future = executor.submit(task);
try {
    
    
    Object s = future.get();
} catch (InterruptedException e) {
    
    
    // 处理中断异常
} catch (ExecutionException e) {
    
    
    // 处理无法执行任务异常
} finally {
    
    
    // 关闭线程池
    executor.shutdown();
}

关闭线程池

可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程

只要调用了这两个关闭方法的其中一个,isShutdown 方法就会返回 true。当所有的任务都已关闭后, 才表示线程池关闭成功,这时调用 isTerminaed 方法会返回 true。至于我们应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown 来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow

合理的配置线程池

要想合理的配置线程池,就必须首先分析任务特性,可以从以下几个角度来进行分析:

  • 任务的性质:CPU 密集型任务,IO 密集型任务、混合型任务。
  • 任务的优先级:高,中、低。
  • 任务的执行时间:长,中、短。
  • 任务的依赖性:是否依赖其他系统资源 ,如数据库连接。

任务性质不同的任务可以用不同规模的线程池分开处理。CPU 密集型任务配置尽可能小的线程,如配置 Ncpu + 1 个线程的线程池IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如 2 * Ncpu混合型的任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。

优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。

执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。

建议使用有界队列,有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点,比如几千。

线程池的监控

通过线程池提供的参数进行监控。线程池里有一些属性在监控线程池的时候可以使用

  • taskCount:线程池需要执行的任务数量。
  • completedTaskCount:线程池在运行过程中已完成的任务数量。小于或等于 taskCount
  • largestPoolSize:线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
  • getPoolSize: 线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。
  • getActiveCount:获取活动的线程数。

通过扩展线程池进行监控。通过继承线程池并重写线程池的 beforeExecuteafterExecuteterminated 方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。如监控任务的平均执行时间,最大执行时间和最小执行时间等。这几个方法在线程池里是空方法。如:

protected void beforeExecute(Thread t,Runnable r) {
    
     } 

FutureTask 的实现

FutureTask 的实现基于 AbstractQueuedSynchronizer(以下简称为AQS)。java.util.concurrent中的很多可阻塞类都是基于AQS来实现的。AQS是一个同步框架,它提供通用机制来原子性管理同步状态、阻塞和唤醒线程,以及维护被阻塞线程的队列。JDK 6 中 AQS 被广泛使用,基于 AQS 实现的同步器包括:ReentrantLockSemaphoreReentrantReadWriteLockCountDownLatchFutureTask

每一个基于AQS实现的同步器都会包含两种类型的操作,如下:

  • 至少一个acquire操作。这个操作阻塞调用线程,除非/直到AQS的状态允许这个线程继续执行。FutureTaskacquire操作为get()/get(long timeout,TimeUnit unit)方法调用。
  • 至少一个release操作。这个操作改变AQS的状态,改变后的状态可允许一个或多个阻塞线程被解除阻塞。FutureTaskrelease操作包括run()方法和 cancel(…) 方法。

基于“复合优先于继承”的原则,FutureTask声明了一个内部私有类Sync,它是继承于AQS的子类,对FutureTask所有公有方法的调用都会委托给这个内部Sync子类。

在这里插入图片描述

FutureTask.get()方法会调用 AQS.acquireSharedInterruptibly() 方法,这个方法的执行过程如下:

  • 1)调用AQS.acquireSharedInterruptibly(int arg)方法,这个方法首先会回调在子类Sync中实现的tryAcquireShared()方法来判断acquire操作是否可以成功。acquire操作可以成功的条件为:state为执行完成状态RAN或已取消状态CANCELLED,且runner不为null
  • 2)如果成功则get()方法立即返回。如果失败则到线程等待队列中去等待其他线程执行release操作。
  • 3)当其他线程执行release操作(比如FutureTask.run()FutureTask.cancel(…))唤醒当前线程后,当前线程再次执行tryAcquireShared()将返回正值1,当前线程将离开线程等待队列并唤醒它的后继线程。
  • 4)最后返回计算的结果或抛出异常。

FutureTask.run() 的执行过程如下:

  • 1)执行在构造函数中指定的任务(Callable.call())。
  • 2)以原子方式来更新同步状态(调用AQS.compareAndSetState(int expect,int update),设置state为执行完成状态RAN)。如果这个原子操作成功,就设置代表计算结果的变量result的值为Callable.call()的返回值,然后调用AQS.releaseShared(int arg)
  • 3)AQS.releaseShared(int arg)首先会回调在子类Sync中实现的tryReleaseShared(arg)来执行release操作(设置运行任务的线程runnernull,然后返回true);AQS.releaseShared(int arg),然后唤醒线程等待队列中的第一个线程。
  • 4)调用 FutureTask.done()

当执行FutureTask.get()方法时,如果FutureTask不是处于执行完成状态RAN或已取消状态CANCELLED,当前执行线程将到AQS的线程等待队列中等待。当某个线程执行FutureTask.run()方法或FutureTask.cancel(…)方法时,会唤醒线程等待队列的第一个线程。

FutureTask 的使用 Demo 参考如下:

/**
* Callable接口实例 计算累加值大小并返回
*/
public class CallableDemo implements Callable<Integer> {
    
    
    public final static String TAG = CallableDemo.class.getSimpleName();
    private int sum;
    
    @Override
    public Integer call() throws Exception {
    
    
        Log.e(TAG, "call: Callable子线程开始计算啦!");
        Thread.sleep(2000); 
        for(int i = 0 ; i < 5000; i++){
    
    
            sum = sum + i;
        }
        Log.e(TAG, "call: Callable子线程计算结束!");
        return sum;
    }
}
public class CallableTest {
    
    

    public static void main(String[] args) {
    
    
        //第一种使用方式
//        //创建线程池
//        ExecutorService es = Executors.newSingleThreadExecutor();
//        //创建Callable对象任务
//        CallableDemo calTask = new CallableDemo();
//        //提交任务并获取执行结果
//        Future<Integer> futureTask = es.submit(calTask);
//        //关闭线程池
//        es.shutdown();

        //第二种使用方式
        //创建线程池
        ExecutorService es = Executors.newSingleThreadExecutor();
        //创建Callable对象任务
        CallableDemo calTask = new CallableDemo();
        //创建FutureTask
        FutureTask<Integer> futureTask = new FutureTask<>(calTask);
        //执行任务
        es.submit(futureTask);
        //关闭线程池
        es.shutdown();
        try {
    
    
            Thread.sleep(2000);
            Log.e(CallableDemo.TAG, "main: 主线程在执行其他任务");
            if (futureTask.get() != null) {
    
    
                //输出获取到的结果
                Log.e(CallableDemo.TAG, "futureTask.get()-->" + futureTask.get());
            } else {
    
    
                Log.e(CallableDemo.TAG, "futureTask.get()未获取到结果");
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        Log.e(CallableDemo.TAG, "主线程执行完成");
    }
}

输出:

在这里插入图片描述

线程池与生产消息者模式

Java 中的线程池类其实就是一种生产者和消费者模式的实现方式,但是我觉得其实现方式更加高明。生产者把任务丢给线程池,线程池创建线程并处理任务,如果将要运行的任务数大于线程池的基本线程数就把任务扔到阻塞队列里,这种做法比只使用一个阻塞队列来实现生产者和消费者模式显然要高明很多,因为消费者能够处理直接就处理掉了,这样速度更快,而生产者先存,消费者再取,这种方式显然慢一些。

我们的系统也可以使用线程池来实现多生产者和消费者模式。例如,创建 N 个不同规模的 Java 线程池来处理不同性质的任务,比如线程池 1 将数据读到内存之后,交给线程池 2 里的线程继承处理压缩数据。线程池 1 主要处理 IO 密集型任务,线程池 2 要处理 CPU 密集型任务

读者可以在平时的工作中思考一下哪些场景可以使用生产者消费者模式,我相信这种场景应该非常多,特别是需要处理任务时间比较长的场景,比如上传附件并处理,用户把文件上传到系统后,系统把文件丢到队列里,然后立刻返回告诉用户上传成功,最后消费者再取队列里取出文件处理。

面试题:线程池创建之后,会立即创建核心线程吗?

  • 不会ThreadPoolExecutor 刚刚创建的时候,并不会立即启动线程池中的线程,而是要等到有任务提交时才会去启动一个 Worker 线程,除非调用了prestartCoreThread/prestartAllCoreThreads 事先启动核心线程(预热)。

线程池预热的话可以调用下面的两个方法:

  • 全部启动:prestartAllCoreThreads()
    在这里插入图片描述
  • 仅启动一个:prestartCoreThread()
    在这里插入图片描述

当工作线程创建成功后,也就是 Worker 对象已经创建好了,这时就需要启动该工作线程,让线程开始干活了,Worker 对象中关联着一个 Thread,所以要启动工作线程的话,只要通过 worker.thread.start() 来启动该线程即可。

启动完了之后,就会执行 Worker 对象的 run 方法,因为 Worker 实现了 Runnable 接口,所以本质上 Worker 也是一个线程。

通过线程 start 开启之后就会调用到 Runnablerun 方法,在 worker 对象的 run 方法中,调用了 runWorker(this) 方法,也就是把当前对象传递给了 runWorker 方法,让他来执行。

问题二:核心线程数会被回收吗?需要什么设置?

  • 核心线程数默认是不会被回收的,如果需要回收核心线程数,需要调用下面的方法:
    在这里插入图片描述
    allowCoreThreadTimeOut 该值默认为 false。
    在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/134842019