线程池invokeAll方法详解

线上真实案例,多次调用线程池 ThreadPoolExecutor 的 invokeAll() 方法进行数据统计时任务被拒绝,故事从此开始。

本文重在讲述问题的产生、抽象、寻找解决方法的过程,并结合源码对原因进行抽丝剥茧般的分析。bug 千千万万,唯有合理的逻辑推理思维才能让这些 bug 显露原形。

问题起源与抽象

先来看一段简单的代码,定义一个核心线程数5、有界队列5的线程池,然后创建10个任务丢进去执行2次。

按照以前对线程池执行逻辑的理解,创建的10个线程,会先交给核心线程去执行,5个核心线程满了之后,存放到队列中,刚好存储剩下的5个,按理说10个任务都会正常执行完毕。本次只测试固定大小的线程池。

public class InvokeAllTest {

    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5,
            60 * 1000, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5),
            new MyThreadFactory());

    public static void main(String[] args) {
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            tasks.add(new InvokeAllThread());
        }
        System.out.println("第一次任务执行前的executor: " + executor);
        try {
            executor.invokeAll(tasks);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("第一次任务执行完毕后的executor: " + executor);
        System.out.println("==============第一次任务执行完毕,开始第二次任务============");
        try {
            Thread.sleep(1000);
            executor.invokeAll(tasks);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
      	System.out.println("第二次任务执行完毕后的executor:" + executor);
    }
  

  	// 任务执行线程。通过打印线程名称,观察提交的任务被哪个线程执行
    static class InvokeAllThread implements Callable<Void> {
        @Override
        public Void call() throws Exception {
            System.out.println(Thread.currentThread().getName());
            return null;
        }
    }

  	// 给工作线程自定义名字,方便观察提交的任务被哪个线程执行
    static class MyThreadFactory implements ThreadFactory {
        private AtomicInteger threadNum = new AtomicInteger(1);
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r, String.valueOf(threadNum.getAndIncrement()));
            if (thread.getPriority() != Thread.NORM_PRIORITY) {
                thread.setPriority(Thread.NORM_PRIORITY);
            }
            return thread;
        }
    }

运行程序后发现,第一次调用 invokeAll 正常执行,第二次调用报错。多次执行结果相同。

第一次任务执行前的executorjava.util.concurrent.ThreadPoolExecutor@30f39991[Running, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0]
1
2
3
4
4
5
3
2
3
3
第一次任务执行完毕后的executorjava.util.concurrent.ThreadPoolExecutor@30f39991[Running, pool size = 5, active threads = 0, queued tasks = 0, completed tasks = 10]
==============第一次任务执行完毕,开始第二次任务============
2
4
5
2
1
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@3a71f4dd rejected from java.util.concurrent.ThreadPoolExecutor@30f39991[Running, pool size = 5, active threads = 2, queued tasks = 0, completed tasks = 13]
	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 java.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:238)
	at com.aaron.hp.thread.pool.InvokeAllTest.main(InvokeAllTest.java:36)

问题排查与猜测

既然程序出现异常,就该调用 debug 模式进行排查,并遵循"大胆猜测,小心求证"的态度,去解决这个问题。

猜测一:invokeAll 在异步执行后会不会同步等待线程执行完毕获取最终结果

由于 invokeAll 封装的太好,之前只知道最后会同步等待才能获取返回值。那么现在就需要去证实这个概念。

进入 invokeAll 方法后,发现调用了f.get(),那么毫无疑问,这个猜测可以排除掉了。

其实从执行过程的输出内容也可以看出,两次调用 invokeAll 的执行顺序和界限(打印语句) 非常明显。

public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException {
    if (tasks == null)
        throw new NullPointerException();
    ArrayList<Future<T>> futures = new ArrayList<Future<T>>(tasks.size());
    boolean done = false;
    try {
        for (Callable<T> t : tasks) {
            RunnableFuture<T> f = newTaskFor(t);
            futures.add(f);
          	// 任务被添加后的具体执行
            execute(f);
        }
        for (int i = 0, size = futures.size(); i < size; i++) {
            Future<T> f = futures.get(i);
            if (!f.isDone()) {
                try {
                    // 此处同步等待
                    f.get();
                } catch (CancellationException ignore) {
                } catch (ExecutionException ignore) {
                }
            }
        }
        done = true;
        return futures;
    } finally {
        if (!done)
            for (int i = 0, size = futures.size(); i < size; i++)
                futures.get(i).cancel(true);
    }
}
猜测二:队列里面可能存在第一次调用 invokeAll 执行了但没有删掉的任务,所以才会导致第二次放入队列失败

由于未阅读源码,猜测只有当创建的任务执行完毕并且销毁之后,才会从队列中真正移除。

那么就需要查看入队列和出队列的时机。查看 invokeAll 方法中的 execute(f) 方法。

查看 ThreadPoolExecutor 类下的 execute 方法源码:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    int c = ctl.get();
  	// 判断工作线程数是否小于核心线程数,如果是则创建 Worker 工作线并返回
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
  	// 判断主线程是否在运行,并判断是否入队列成功
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
  	// 否则重新创建 Worker 线程,创建失败则抛出拒绝策略
    else if (!addWorker(command, false))
        reject(command);
}

此时就会发现入队列的操作在workQueue.offer(command)处完成,而我们提交的任务是由一个叫 Worker 类的实例来执行,addWorker(command, true)创建 Worker 实例。

那么我们就分别进去这两个方法来看下源码:

矮油黑人问号脸。。没想到这个 ThreadPoolExecutor 类的 addWorker 这么长,给核心代码写个注释重点关注,扫一眼然后去看 offer 方法(英文注释是源码中自带的)。前面都是校验,创建核心线程处为new Worker(firstTask)

private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        // Check if queue empty only if necessary.
        if (rs >= SHUTDOWN &&
                ! (rs == SHUTDOWN &&
                        firstTask == null &&
                        ! workQueue.isEmpty()))
            return false;

        for (;;) {
            int wc = workerCountOf(c);
            if (wc >= CAPACITY ||
                    wc >= (core ? corePoolSize : maximumPoolSize))
                return false;
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
            if (runStateOf(c) != rs)
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
      	// 上面一堆都是校验,此处才是 Worker 被创建的地方,注意被传入的 firstTask
        w = new Worker(firstTask);
      // 此处发现 Worker 里面居然还有个 therad 线程,不过想想也是,没有线程怎么异步执行呢。点进 Worker 的构造方法看一眼就会发现,这个线程就是由我们自定义的 threadFactory 来创建的,所以核心线程名称就是我们之前设定好的名字。this.thread = getThreadFactory().newThread(this);
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                // Recheck while holding lock.
                // Back out on ThreadFactory failure or if
                // shut down before lock acquired.
                int rs = runStateOf(ctl.get());

                if (rs < SHUTDOWN ||
                        (rs == SHUTDOWN && firstTask == null)) {
                    if (t.isAlive()) // precheck that t is startable
                        throw new IllegalThreadStateException();
                    workers.add(w);
                    int s = workers.size();
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                    workerAdded = true;
                }
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
              	// worker 实例成功创建后,让它启动起来
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

接着是 ArrayBlockingQueue 类的 offer 方法,在 enqueue(e)处进入队列:

public boolean offer(E e) {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        if (count == items.length)
            return false;
        else {
          	// 进入队列
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}

此时我们先来调试一波,看看入队列时这些方法的执行情况,在三个 if 处分别设置断点,在 addWorker 和 offer 方法靠前的未知打断点,确定是否会进入。

第一次调用 invokeAll:addWorker 进入5次,offer 方法进入5次。

第二次调用 invokeAll:addWorker 进入0次,offer 方法进入10次(可能是5-10次)。

那么发现了新的问题:程序居然没报错!正常执行完成!这不科学!

带着疑惑,重新 debug,居然还没报错!难道之前的异常是偶然吗?

以最快速度连按 F9 debug了几次,有时候报错。。

重新运行 run 了几次,次次报错。。

怀疑人生了。。

此时墨菲定律在我头脑中回响,“偶然事件存在必然的因素”。那么大胆猜测,这个原因极有可能是队列消费速度较慢导致的,去查看消费部分的源码。由于 worker 也是一个线程,那么肯定有类似的 run 方法:

查看 ThreadPoolExecutor 类 的 Worker 这个内部类,找到 run() 方法:

public void run() {
		runWorker(this);
}

而 run 方法调用的是 ThreadPoolExecutor 类里的 runWorker(this)

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
  	// 此处注意,将 worker 里存入的 firstTask 取出来,交给下面的 while 去执行
    Runnable task = w.firstTask;
  	// 将 worker 里的 firstTask 属性置空
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
      	// 如果 task 不为空,即取出的 firstTask 不为空,则执行;否则调用 getTask() 方法获取 task 再执行
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // If pool is stopping, ensure thread is interrupted;
            // if not, ensure thread is not interrupted.  This
            // requires a recheck in second case to deal with
            // shutdownNow race while clearing interrupt
            if ((runStateAtLeast(ctl.get(), STOP) ||
                    (Thread.interrupted() &&
                            runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                wt.interrupt();
            try {
              	// 此处为空实现,可自定义
                beforeExecute(wt, task);
                Throwable thrown = null;
                try {
                  	// 调用 task 的 run 方法执行任务
                    task.run();
                } catch (RuntimeException x) {
                    thrown = x; throw x;
                } catch (Error x) {
                    thrown = x; throw x;
                } catch (Throwable x) {
                    thrown = x; throw new Error(x);
                } finally {
                    afterExecute(task, thrown);
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

查看 ThreadPoolExecutor 类下的 getTask() 方法:

private Runnable getTask() {
    boolean timedOut = false; // Did the last poll() time out?

    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        // Check if queue empty only if necessary.
        if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
            decrementWorkerCount();
            return null;
        }

        int wc = workerCountOf(c);

        // Are workers subject to culling?
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }

        try {
          	// 此处为出队列操作,poll 和 take 的区别在于,poll 会等待指定时间,而 take 是阻塞的,会一直等待
            Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
            if (r != null)
                return r;
            timedOut = true;
        } catch (InterruptedException retry) {
            timedOut = false;
        }
    }
}

看到这里,猜测二也就不攻自破,出队列后任务才会被执行,所以某个任务出队列后,执行成功与否与队列再无瓜葛。(注意这个说法只针对默认代码,如果自定义了拒绝策略是可以将被 interrupt 的线程重新塞回队列里的)

两次猜测失败后的总结

  1. 队列是异步消费的,但入队是同步进行的,如果队列的容量不足以承载要存入队列的任务数,就会被拒绝。(虽然是 ArrayBlockQueue 的特性,但这是通过 debug 以及 run 后观察到的)

  2. 第一次 addWorker 方法执行了5次,offer 执行了5次;第二次则是 0 次,10 次。刚才忽略了这个细节,那么需要重新找到相应的源码阅读。

  3. 任务从队列中移除与任务是否执行完毕无关,先移除,后执行。

  4. 我们创建的任务,是由 worker 核心线程去调用任务的 run 方法来同步执行的,而不是调用任务实例的 start 去异步执行,这也就是为什么 invokeAll 可以获取到返回值的原因所在。

    **备注:**这里有点绕,任务实例指的是我们最开始在 for 循环中创建的10个tasks new InvokeAllThread(),为什么继承了 Callable 明明改写的是 call()方法,但却有 run()方法可以被调用呢?这是因为在 invokeAll()方法执行execute()方法前,通过RunnableFuture<T> f = newTaskFor(t);进行了包装。

复查源码,真相大白

查看 ThreadPoolExecutor 类下的 execute() 方法,创建 worker 前的判断如下:

if (workerCountOf(c) < corePoolSize) { ...}

第一次调用 invokeAll 时,线程池中的核心线程 worker 数为0,小于 corePoolSize,所以前5次会创建 worker 核心线程并返回,此时随着 worker 的创建,我们创建的10个任务中的5个也会随着 worker 的创建作为 firstTask 属性被传进去。后5个任务则被放入 queue 中。

第二次调用 invokeAll 时,线程池中的核心数已经是5,所以10个任务都会被放入 queue 中异步消费,但是我们的 queue 的容量为5。如果消费速度快于入队速度(debug),那么10个任务会正常执行。但是入队速度太快的话(run),前5个肯定可以入队,后面的5个几乎都会被拒绝。

问题解决方案

  1. 对于固定大小的线程池,我们要按照实际情况设置 queue 和 worker 的数量。根据任务类型(IO/CPU)以及机器配置(CPU 核数等)设置 worker 核心线程数;而根据我们的任务多少来设定 queue 的大小,而不是 queue + worker 的总数。
  2. 重写拒绝策略,将被丢弃的任务重新 put 回队列中去,put 是阻塞的。

参考

ThreadPoolExecutor源码分析及阻塞提交任务方法

Thread的中断机制(interrupt)

Java线程池是一种用于管理和复用线程的机制,它可以提高多线程程序的性能和效率。在Java中,线程池由ThreadPoolExecutor类实现,通过设置不同的参数可以对线程池的行为进行调整。 以下是Java线程池的一些常用参数及其解释: 1. corePoolSize(核心线程数):线程池中始终保持的活动线程数,即使它们处于空闲状态。当有新任务提交时,如果活动线程数小于corePoolSize,则会创建新线程来处理任务。 2. maximumPoolSize(最大线程数):线程池中允许存在的最大线程数。当活动线程数达到maximumPoolSize并且工作队列已满时,新任务将会被拒绝。 3. keepAliveTime(线程空闲时间):当线程池中的线程数量超过corePoolSize时,多余的空闲线程在等待新任务到来时的最长等待时间。超过这个时间,空闲线程将被终止。 4. unit(时间单位):keepAliveTime的时间单位,可以是秒、毫秒、微秒等。 5. workQueue(工作队列):用于存储等待执行的任务的阻塞队列。常见的工作队列有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。 6. threadFactory(线程工厂):用于创建新线程的工厂类。可以自定义线程的名称、优先级等属性。 7. handler(拒绝策略):当线程池无法接受新任务时的处理策略。常见的拒绝策略有AbortPolicy(默认,抛出RejectedExecutionException异常)、CallerRunsPolicy(由调用线程执行任务)、DiscardPolicy(直接丢弃任务)和DiscardOldestPolicy(丢弃最旧的任务)。 这些参数可以根据实际需求进行调整,以达到最佳的线程池性能和资源利用率。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值