玩转 Java 线程池(1):线程池到底应该怎么建?

243 阅读5分钟

0 创建线程池的核心问题

根据阿里巴巴的《Java开发规范》里的一条规定,

这条规定指出了,当我们想使用线程池的时候,最好不要偷懒,最好要自己手动创建线程池,那么问题就来了,手动创建线程池到底要如何去创建?

1 我的核心线程数量到底应该创建多少?

1.1 我们设置合适的线程数量是为了什么?

为了榨干硬件的性能,我们知道,一个程序在服务器上去除网络传输的时间,剩下的就是『计算』和『I/O』的时间了。这里的『I/O』既包括和 主存和辅存 交换数据的时间,也包括网络数据传输到服务器,服务器拷贝的内核空间。我们设置合适的线程数量就是为了可以充分利用每个CPU,磁盘的交换数据的效率达到最大。

1.2 根据程序的类型分类讨论

  1. 比如 计算100000个随机数的加法,这个就是实打实的计算密集型的任务。
  2. 比如 文件上传任务,这个就是典型的『I/O』密集型的任务。
  3. 还有第三种『I/O、 计算混合型任务』,也就是目前的大部分程序,都是属于这种,两种耗时的任务都有涉及。

我们逐个讨论

  1. 如果是计算密集型的任务,那么设置的线程数为:服务器CPU数 + 1。 为什么?因为如果是一个任务是计算密集型的,那么最理想的情况就是,所有的CPU都跑满,这样每个CPU的资源都得到了充分的利用。至于为什么要需要在CPU的个数上+1,网上比较流行的解释就是,考虑到即便是CPU密集型的任务其执行线程也可能也有可能在某个时间因为某个原因出现等待(比如说缺页中断等等)。

  2. 如果是IO密集型的任务,那么最好的方式的就是计算IO和计算所花费的时间比。如果 CPU 计算和 I/O 操作的耗时是 1:2,那么合适的线程就是3,至于为什么。。。这里用下图来说明

    极客时间——10 | Java线程(中):创建多少线程才是合适的?
    图片来自 :极客时间——10 | Java线程(中):创建多少线程才是合适的?

    所以,如果是一个CPU,那么合适的线程数量就是

    1 +(IO耗时 / CPU耗时)

    不过现在都是多核的CPU,所以合适的设置的线程数量就是:

    CPU数 * ( 当只有一个CPU合适的线程数量

    当然,我们工作中很难每次都完美的统计到IO和计算所用的时间比,所以,很多前辈们根据自己的工作经验,就有了一个比较通用的线程数量计算,对于I/O 密集型的应用,最佳线程数为:2 * CPU 的核数 + 1

    注意,这里还有一个重点,就是:如果你线程数越多那么切换线程的代价也就越多,所以我们尽量要在比较小线程数量的情况下完成

  3. 如果是混合型任务,那么,我们就把任务拆分成计算型任务和IO型任务,将这些子任务提交给各自的类型的线程池执行就行。

2 用默认的创建线程工厂还是自己实现?

阿里巴巴的《Java开发规范》中有规定:

所以这里重点就是,你需要给创建的线程有意义的名字,这里就直接规定了,你不能使用默认方法来创建线程了。那么我们要怎么创建有意义的线程名称的线程?目前有两种主流的方法。

2.1 用Guava的来创建

@Slf4j
public class ThreadPoolExecutorDemo00 {
	public static void main(String[] args) {
		ThreadFactoryBuilder threadFactoryBuilder = new ThreadFactoryBuilder()
			.setNameFormat("我的线程 %d");
		ThreadPoolExecutor executor = new ThreadPoolExecutor(10,
			10,
			60,
			TimeUnit.MINUTES,
			new ArrayBlockingQueue<>(100),
			threadFactoryBuilder.build());
		IntStream.rangeClosed(1, 1000)
			.forEach(i -> {
				executor.submit(() -> {
					log.info("id: {}", i);
				});
			});
		executor.shutdown();
	}
}

来看看效果:

2.2 自己实现ThreadFactory

public static void main(String[] args) {
	ThreadPoolExecutor executor = new ThreadPoolExecutor(10,
		10,
		60,
        TimeUnit.MINUTES,
		new ArrayBlockingQueue<>(100),
		new MyNewThreadFactory("我的线程池"));
	IntStream.rangeClosed(1, 1000)
		.forEach(i -> {
			executor.submit(() -> {
				log.info("id: {}", i);
			});
		});
	executor.shutdown();
}
public static class MyNewThreadFactory implements ThreadFactory {
	private static final AtomicInteger poolNumber = new AtomicInteger(1);
	private final ThreadGroup group;
	private final String namePrefix;
	MyNewThreadFactory(String whatFeatureOfGroup) {
		SecurityManager s = System.getSecurityManager();
		group = (s != null) ? s.getThreadGroup() :Thread.currentThread().getThreadGroup();
		namePrefix = "From MineNewThreadFactory-" + whatFeatureOfGroup 
		    + "-worker-thread-";
	}

	@Override
	public Thread newThread(Runnable r) {
		String name = namePrefix + poolNumber.getAndIncrement();
		Thread thread = new Thread(group, r,
			name,
			0);
		if (thread.isDaemon()) {
			thread.setDaemon(false);
		}

		if (thread.getPriority() != Thread.NORM_PRIORITY) {
			thread.setPriority(Thread.NORM_PRIORITY);
		}
		return thread;
	}
}

来看看效果:

3 拒绝策略到底用哪个?

3.1 先来看看四个基本的拒绝策略

  • (1) CallerRunsPolicy :任务不给线程池执行,给提交线程的主线程执行,看代码:
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
        r.run();
    }
}
  • (2) AbortPolicy :直接扔出异常,同时这个也是默认的拒绝策略 ,直接看代码:
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
 throw new RejectedExecutionException("Task " + r.toString() +
                                      " rejected from " +
                                      e.toString());
}
  • (3) DiscardPolicy:单纯的拒绝,别的啥也不做,看代码:
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}
  • (4) DiscardOldestPolicy:把最老的任务抛弃,这个其实很好理解,直接看源码理解最好理解了
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
   if (!e.isShutdown()) {
        e.getQueue().poll();
        e.execute(r);
    }
}

小总结一波,可以看出(1)、(4) 的拒绝策略,虽然称之为拒绝了,但是仍然会执行任务。但是(2)、(3) 就会直接拒绝任务,使得任务出现丢失。

3.2 自己实现拒绝策略

比如我们想要实现一个拒绝策略,想要我们提交的任务最终提交到队列中,采用阻塞等待的策略来完成,那么我们要怎么写代码?其实根据JDK的代码,我们可以写出自己的拒绝策略,首先要实现 RejectedExecutionHandler

public static class EnqueueByBlockingPolicy implements RejectedExecutionHandler {

	public EnqueueByBlockingPolicy() { }

	@Override
	public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
		if (e.isShutdown()) {
			return;
		}
		try {
			e.getQueue().put(r);
		} catch (InterruptedException interruptedException) {
			interruptedException.printStackTrace();
		}
	}
}

当然在现实中不不建议你这么写,因为这样测量会导致主线程阻塞,而且没有设置超时退出。最好使用 BlockingQueue#offer(E, long, TimeUnit) 这个方法。但是使用 offer 不能保证你肯定会提交任务到队列中。具体要看实际需求。

3.3 总结

如果你需要保证的你提交的任务不丢失,确认执行,那么建议使用 策略 CallerRunsPolicyDiscardOldestPolicy,甚至使用在 3.2 中的这个自己实现的拒绝策略,如果你的任务不重要,保证自己的程序的稳定性比较重要,那么就建议使用DiscardPolicyAbortPolicy