Java—并发编程指南(下)

366 阅读17分钟

五、并发容器

由于ArrayList、HashMap等容器不支持并发,早期JDK提供了Vector、Hashtable等一系列同步容器,它们实现的方法是对所有公有方法进行同步,使得同一时刻只有一个线程能访问容器的状态。通过Collections.synchronizedXxx()方法,可以创建各个基础容器的同步容器。

即使Vector这样的容器对所有方法都进行了同步,但它还是不安全的,这种情况出现在对容器进行复合操作时。以下面的程序为例,假设A线程执行getLast()方法,在step1处挂起,随后B线程执行完了整个deleteLast()方法,A线程继续执行时size的值已经过时了,再执行vector.get(size - 1)就会出错。

    public Object getLast(Vector vector) {
        int size = vector.size();
        // step1
        return vector.get(size - 1);
    }

    public void deleteLast(Vector vector) {
        int size = vector.size();
        vector.remove(size - 1);
    }

这种“先判断后执行”的操作称为竞态条件,再以Hashtable为例,如果有2个线程执行如下putIfNotExist(Object key, Object value)方法,可能会导致覆盖插入。要想解决这个问题,需要将整个复合操作进行同步。

    public void putIfNotExist(Object key, Object value) {
        if (!hashtable.contains(key)) {
            hashtable.put(key, value);
        }
    }

由于同步容器的效率不高且具备竞态条件这样的隐患,Java推出了并发容器来改进同步容器的性能,不同的并发容器对应不同的场景,并且ConcurrentHashMap这样的并发容器还封装了常见的复合操作。

5.1 CopyOnWriteArrayList

当通过Iterator对容器进行迭代时,如果有别的线程修改了容器,那么正在迭代的线程会抛出ConcurrentModificationException,这是一种“及时失败”的机制,用于通知用户该处代码存在隐患,想要避免该异常,需要在迭代过程中加锁。但是如果容器数量很大或者每个元素的操作时间很长,那么其余线程就会等待很久。一种解决方案是在容器发生修改时克隆该容器,并在副本上进行写操作,期间的迭代操作都在原容器上进行。

上述方法被称为写时拷贝CopyOnWrite(COW),CopyOnWriteArrayList中通过变量array保存实际的数组,当容器即将发生变化(add, remove...)时克隆当前容器,并在副本上进行增删操作,操作之后再将结果赋值给array,以add(E e)方法为例,相关代码如下所示。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    private transient volatile Object[] array;

    final void setArray(Object[] a) {
        array = a;
    }

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

    ......
}

当对CopyOnWriteArrayList迭代时,通过iterator()方法新建迭代器,该迭代器遍历的是当前array的快照。迭代器中已经保存了引用,即使别的线程通过setArray(newElements)方法修改了array的引用也不会出现异常。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }

    static final class COWIterator<E> implements ListIterator<E> {
        /** Snapshot of the array */
        private final Object[] snapshot;
        /** Index of element to be returned by subsequent call to next.  */
        private int cursor;

        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }
        ......
    }

    ......
}

根据CopyOnWriteArrayList的实现,可以发现该容器具有“弱一致性”,因为迭代时容器的内容可能会发生变化,如果一个场景能容忍短暂的数据不一致性,才适合使用该容器。例如通过CopyOnWriteArrayList保存监听器,该场景对时效性的要求不高,而且遍历监听器并执行是一个非常耗时的操作。

5.2 ConcurrentHashMap

ConcurrentHashMap与HashMap一样是一个基于散列的Map,JDK1.8中它采用CAS+Node锁的同步方式来提供更高的并发性与伸缩性。ConcurrentHashMap的使用方法与HashMap相同,并且封装了一些常见的复合操作,如下所示。

// 当前key没有对应值时插入
public V putIfAbsent(K key, V value)

// 仅当key对应的值为value时才移除
public boolean remove(Object key, Object value)

// 仅当key对应的值为oldValue时才替换为newValue
public boolean replace(K key, V oldValue, V newValue)

// 仅当key对应到某个值时才替换为value
public V replace(K key, V value)

ConcurrentHashMap中的检索操作一般不阻塞,因此可能与更新操作重叠,检索操作反映的是最近的更新操作完成的结果,换句话说,更新操作对检索操作来说是可见的(happen-before)。以get()方法为例,代码中并没有进行同步的地方,这是因为Node节点中的val和next字段都是volatile修饰的,因此一个线程的更新操作是对其他线程可见的。

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {

    ......

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode()); // 得到hash值
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                // 槽中的首节点就是要寻找的节点
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            // hash < 0表示正在扩容
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        ......
    }
}

ConcurrentHashMap中的更新操作是通过CAS+Node锁的方式进行同步的,以putVal(K key, V value, boolean onlyIfAbsent)方法为例,其主体逻辑如下。 ① 判断table是否已经初始化,如果没有则调用initTable()方法
② 如果当前槽为空,则调用casTabAt(...)插入数据
③ 如果当前正在扩容,当前线程也去参与扩容(ConcurrentHashMap支持并发扩容)
④ 根据当前是链表还是树插入对应节点

可以发现putVal(...)方法的主体逻辑是位于一个循环中的,这是一种CAS+自旋的思路。假设2个线程同时执行到第②步,成功的线程会退出循环,失败的线程会开始自旋直到插入成功或失败。

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0) // 如果table为空则进行初始化
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                // 当前槽为空,通过cas插入节点,成功则退出循环
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            // 有hash值为MOVED的节点表示正在扩容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) { // 当前槽上是链表
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) { // 当前槽上是树节点
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

扩容Hash表是一个相对缓慢的操作,最好在新建时提供一个预估的大小initialCapacity来构造。

六、线程生命周期

Java中线程的生命周期与操作系统的有所不同,在操作系统中,线程只有在真正获取到CPU使用权时才属于运行状态,如果一个线程可以执行但是没获取到CPU,它则属于就绪状态。而在Java中,如果一个线程在广义上能够运行,它就是运行状态。

Java将线程分为以下6种状态:

  1. NEW(初始化状态):新建了一个线程对象,但是还没有调用线程的start()方法,此时线程只存在于JVM,在操作系统中它还没有被创建。

  2. RUNNABLE(运行状态):Java线程的RUNNABLE状态表达的含义比较宽泛,它对应操作系统中线程的就绪状态、运行状态以及部分休眠状态。 当线程在抢占CPU的使用权时,其对应操作系统中的就绪状态;当线程调用操作系统层面的阻塞式API时(例如I/O),其对应操作系统中的休眠状态。在Java中,这些状态统称为RUNNABLE,JVM并不关心线程是否真的在运行,只要当前线程不在等待锁,也没有被其他线程阻塞,那么它就是运行状态。

  3. BLOCKED(阻塞状态):当线程正在等待进入synchronized代码块时,该线程会从RUNNABLE状态变为BLOCKED状态;当抢占到锁时,再从BLOCKED状态转换到RUNNABLE状态。

  4. WAITING(无时限等待):当获得锁的线程主动调用Object.wait()时,线程会从RUNNABLE状态进入WAITING状态直到被唤醒。还有一种情况是线程调用LockSupport.park()从RUNNABLE状态进入WAITING状态,并发包中的Lock就是依赖LockSupport实现的,如果要将线程恢复到RUNNABLE状态,则需调用LockSupport.unpark(Thread thread)方法。

  5. TIMED_WAITING(有时限等待):TIMED_WAITING状态与WAITING状态唯一的区别是,线程调用方法进入该状态时多了时间参数。例如Thread.sleep(long millis), Object.wait(long timeout), LockSupport.parkNanos(Object blocker, long deadline)等方法。

  6. TERMINATED(终止状态):线程的run()方法执行完后,或者出现未捕获的异常就会进入TERMINATED状态。如果想要主动终止一个线程,可以调用Thread.interrupt()方法通知线程停止,其他类似Thread.stop()这样的方法因为安全性问题已经被弃用了。

如何正确地停止线程? 上面提到通过Thread.interrupt()方法通知线程结束运行,该方法并不会强行停止线程,它只是为线程添加了一个标志位表示该线程应该结束了。为什么这么做呢?因为我们更希望线程收到通知后进行收尾工作再停止(例如释放Lock),这样可以保证共享资源状态的一致性,更加安全,而不是像已被弃用的Thread.stop()方法那样强制停止线程。

下面来看如何处理中断异常(InterruptedException)和停止线程,当对线程调用Thread.interrupt()方法后,RUNNABLE状态下的线程可以通过Thread.currentThread().isInterrupted()检查自己的中断标志位,如果发现自己被中断,则进行退出工作。但是如果线程处于WAITING状态,那么当前线程会抛出异常并清除中断标志位,以下方法可以响应中断并抛出中断异常。

Object.wait() / Object.wait(long) / Object.wait(long,int)
Thread.sleep(long) / Thread.sleep(long,int)
Thread.join() / Thread.join(long) / Thread.join(long,int)
java.util.concurrent.BlockingQueue.take() / put(E)
java.util.concurrent.locks.Lock.lockInterruptibly()
java.util.concurrent.CountDownLatch.await()
java.util.concurrent.CyclicBarrier.await()
java.util.concurrent.Rxchanger.exchange(V)
java.nio.channels.InterruptibleChannel相关方法
java.nio.channels.Selector的相关方法

对于RUNNABLE状态下的线程,可以在循环中检查自己的状态,发现被中断后进行收尾工作。

Thread thread = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 业务逻辑
    }
    // 收尾工作
});

如果线程处于WAITING状态并响应了中断异常,在方法内的话一般选择抛出,让上层处理;如果必须处理的话,则需要重置线程的中断标志,以便让线程正常退出,如下所示。

Thread thread = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 业务逻辑
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            // 捕获中断异常会清除中断标志, 需要重新设置
            Thread.currentThread().interrupt();
            // 收尾工作...
        }
    }
});

七、死锁

7.1 死锁的产生条件

死锁是指两个线程都需要多个资源,但是每个线程只占有了其中一部分并请求另外的资源,此时就会产生循环等待,导致两个线程都无法运行。在《操作系统》这门课中,归纳了4个产生死锁的必要条件:
① 资源互斥:共享资源在同一时刻只能被一个线程占有。
② 占有资源并等待:占有资源的线程不会释放,并请求其余资源。
③ 不可抢占:线程无法抢夺其余线程占有的资源。
④ 循环等待:每个线程都在等待其余线程占有的资源。

死锁示意.png

7.2 死锁的解决方式

7.2.1 使用Lock

使用synchronized关键字进行同步时,线程占有资源后不会释放,如果在申请其他资源时阻塞,就有出现死锁的风险。而使用Lock.tryLock()方法,线程尝试获取资源失败时会直接返回false,开发人员可以使线程释放之前占有的资源。也可以使用Lock.tryLock(long time, TimeUnit unit)方法,让线程尝试在指定时间内获取某个资源,如果失败则返回false,开发人员可以根据返回值进一步处理。

7.2.2 统一资源获取顺序

出现死锁的代码,一定是两个线程循环等待,这是因为两个线程获取资源的顺序不一样,如下所示。线程t1先尝试获取a资源,再尝试获取b资源;而线程t2先尝试获取b资源,再尝试获取a资源,这就有死锁的风险。如果这两个线程获取资源的顺序相等,就不可能发生死锁了。

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (a) {
                    ......
                    synchronized (b) {
                        ......
                    }
                }
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (b) {
                    ......
                    synchronized (a) {
                        ......
                    }
                }
            }
        });

八、线程池

线程池遵循典型的"生产者-消费者"模型,生产者是线程池的使用者,消费者是线程池本身。提到"生产者-消费者"模型,我们很容易联想到阻塞队列,实际上线程池就是依赖阻塞队列实现的。在线程池中,生产者提交的任务会被添加到阻塞队列中,如果阻塞队列已满,则生产者阻塞;消费者会不断从阻塞队列中取出任务执行,如果阻塞队列为空,则消费者阻塞。

利用3.2.1中实现的BlockQueue可以实现一个简单的线程池,代码如下所示。在测试时,新建了一个具有5个工作线程的线程池,运行后发现各任务执行的顺序是不确定的,因此线程池一般不用于执行一系列耦合的任务。不过如果将工作线程数量设为1个的话,任务的执行顺序就是确定的,当需要执行一连串耦合的任务时,可以新建只有1个工作线程的线程池进行处理。

public class SampleTreadPool {

    private BlockingQueue<Runnable> mRunnableQueue;
    private List<Worker> mWorkers = new ArrayList<>();

    public SampleTreadPool(int threadSize, BlockingQueue<Runnable> queue) {
        mRunnableQueue = queue;
        for (int i = 0; i < threadSize; i++) {
            Worker worker = new Worker();
            worker.start();
            mWorkers.add(worker);
        }
    }

    public void execute(Runnable r) {
        mRunnableQueue.offer(r);
    }

    private class Worker extends Thread {
        @Override
        public void run() {
            while (true) {
                Runnable r = mRunnableQueue.take();
                r.run();
            }
        }
    }

    // 测试
    public static void main(String[] args) {
        BlockingQueue<Runnable> blockingQueue = new BlockingQueue<>(20);
        SampleTreadPool treadPool = new SampleTreadPool(5, blockingQueue);
        for (int i = 0; i < 50; i++) {
            int t = i;
            treadPool.execute(() -> {
                System.out.println("execute: " + t);
            });
        }
    }
}

上述线程池显然无法投入实际使用,因为它存在很多问题:
① 在阻塞队列已满的情况下提交任务,该线程池会阻塞主线程。虽然可以使用无界的阻塞队列,但是当任务数量过多时,可能会造成OOM。
② 该线程池中工作线程的数量是固定的,我们希望能设置工作线程数量的最大值和最小值,让它能够随系统的运行情况灵活地增加或减少。
③ 当线程运行出现异常时,线程池没有容错机制。
④ 该线程池只能提交Runnable这种无返回值的任务,无法处理Future这类有返回值的任务。

8.1 Java线程池的使用

Java线程池中最核心的实现是ThreadPoolExecutor,它定义了线程池的5种状态如下。

  • RUNNING: 线程池正在运行,接受新任务并处理已经提交的任务。
  • SHUTDOWN: 线程池即将停止,此时不接受新任务,但是会处理已经提交的任务。调用线程池的shutdown()方法会使其状态从RUNNING变为SHUTDOWN。
  • STOP: 线程池即将停止,此时不接受新任务,也不处理已经提交的任务,并会中断正在处理的任务。调用线程池的shutdownNow()方法会使其状态从(RUNNING/SHUTDOWN)变为STOP。
  • TIDYING: 线程池已经停止运行,工作线程为0,此时会执行用户重载的terminated()函数。
  • TERMINATED: terminated()函数运行完毕,线程池彻底停止。
8.1.1 新建线程池

下面来看如何新建一个线程池,ThreadPoolExecutor中重载了4个构造函数,最完整的构造函数有7个参数,如下所示。

    /**
     * @param corePoolSize: 核心线程数量,指线程池中最少的线程数量,即使这些线程空闲
     *        但如果设置了 allowCoreThreadTimeOut, 核心线程也可以回收
     * @param maximumPoolSize: 线程池中允许存在的最大线程数
     * @param keepAliveTime: 当前线程数量大于核心线程数量时
     *        如果等待该时间后仍然没有新任务, 则回收空闲线程
     * @param unit: keepAliveTime参数的单位
     * @param workQueue: 任务队列, 这是一个阻塞队列
     *        用于保存用户提交但尚未执行的 Runnable
     * @param threadFactory: 线程池新建线程时使用的 ThreadFactory
     * @param handler: 任务拒绝策略, 用于在线程数量和任务队列都已满时拒绝新任务
     *        线程池提供了以下四种策略: 
     *        CallerRunsPolicy: 提交任务的线程自己执行
     *        AbortPolicy: 默认策略, 抛出RejectedExecutionException
     *        DiscardPolicy: 静默丢弃任务
     *        DiscardOldestPolicy: 丢弃任务队列中最老的任务并添加新任务
     */
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        ......
    }

为了避免OOM,在新建线程池时,强烈建议指定线程池的maximumPoolSize,否则在任务繁忙时会出现线程数暴增的情况;同时也建议使用有界的阻塞队列,不然阻塞队列的无限制增长也会增加OOM的风险。

8.1.2 提交任务

ExecutorService接口中定义了线程池提交任务的方法,如下所示。

// 提交一个Runnable, 该任务无返回值, 也无法查询它的运行情况
void execute(Runnable command);
// 提交一个Callable, 该任务有返回值, 并且可以查询它的运行情况
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
// 提交一个Runnable任务,可以通过返回的Future查询其运行情况,返回null时表示运行完毕
Future<?> submit(Runnable task);
// 执行给定的任务, 当所有任务完成(或超时), 返回一个保存其状态和结果的Future列表
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
                                  long timeout, TimeUnit unit);
// 执行给定的任务,当任一任务完成(或超时), 返回对应结果, 其余任务会被取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks);
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
                    long timeout, TimeUnit unit);

需要注意的是,如果调用Future.get()方法,调用线程会被阻塞,直到Future任务返回结果,因此可以选择Future.get(long timeout, TimeUnit unit)设置最大等待时间,也可以在获取结果前先调用Future.isDone()查询其运行情况。

8.1.3 关闭线程池

在介绍线程池的状态时我们提到了shutdown()shutdownNow()这两个方法,这两个方法都用于关闭线程池,不过处理上有所不同:shutdown()方法会让线程池不再接受新的任务,但是会继续处理已经开始运行的任务;而shutdownNow()方法不仅会让线程池不再接受新的任务,也会去中断当前正在执行任务的线程。

关闭线程池可以使用如下的两段式关闭法,调用shutdown()后等待尚未完成的任务继续运行一段时间,如果等待后还没运行完,则调用shutdownNow()中断这些线程。

这里用到了awaitTermination()方法,它会阻塞一段时间,直到线程池中的任务全部运行完或者等待超时,如果线程池的所有任务运行完则返回true,否则返回false。需要注意的是,该方法会阻塞调用线程

    void shutdownAndAwaitTermination(ExecutorService pool) {
        // 拒绝新任务的提交
        pool.shutdown();
        try {
            // 等待一段时间, 让当前任务继续执行
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                // 尝试中断正在运行的线程
                pool.shutdownNow();
                // 等待一段时间, 让尚未结束任务的线程响应中断
                if (!pool.awaitTermination(60, TimeUnit.SECONDS))
                    System.err.println("Pool did not terminate");
            }
        } catch (InterruptedException ie) {
            // 如果当前线程被中断了, 再次尝试关闭线程池
            pool.shutdownNow();
            // 重新设置中断标志位
            Thread.currentThread().interrupt();
        }
    }

8.2 如何确定线程池的参数

新建ThreadPoolExecutor时需要传入多个参数,包括核心线程数、最大线程数、空闲线程保留时间以及阻塞队列等,实际开发中应该根据具体的业务类型来确定这一系列的参数。

在项目中并不建议将所有的任务都放到一个线程池中去执行,可以根据任务场景新建CPU线程池、IO线程池等,CPU线程池的大小可以固定为(CPU核心数+1),而IO线程池的大小可以预设为(2 * CPU核心数+1),之后再根据IO的吞吐量进行调整。美团的技术博客Java线程池实现原理及其在美团业务中的实践介绍了动态修改线程池参数的实现,可以根据线程池的运行情况不断调整参数,保证系统的吞吐量。

参考

  1. 并发编程实战
  2. 维基百科-CAS