线程池基础和实践经验

1,072 阅读10分钟

前言

这种八股文我真的都不好意思写出来,更不好意思去写些哗众取宠的名字。。。但是面试真的会问,如果你参加校招面试那么推荐看《并发编程的艺术》这本,其它的关于多线程的书全都没必要看。这篇博客也就简单过下线程池的基础,主要就是线程池处理任务流程,线程池的使用,如果没兴趣就忽略掉吧(反正我是不喜欢看)。也会分享工作中的一些浅薄的经验,顺便看下开源框架nifty(thrift+netty)和netty是如何使用线程池的。也就是三部分。

  1. 线程池简单介绍
  2. nifty和netty中的线程池
  3. 经验分享

线程池基础

使用线程池的好处

如果程序需要使用到异步或者并发执行那么很多时候我们都会使用线程池,那么使用线程池的好处是什么呢?由于池子里面已经有线程了,所以来任务可以直接执行而不用去创建线程,同时可以持续利用而不需要用完销毁,得到两个好处就是:(1)降低资源开销和(2)提高响应速度,还有点很重要的就是(3)便于监控和调优(八股文,得背)。

相信你们的toC系统十有八九会对线程池进行监控,比如线程数量在你上线后一直上升,那么你这次上线就可能有问题如果不能立即定位问题就得回滚。如果没有监控报警的话那么等耗尽了系统资源导致服务不可用可能就得想怎么写事故报告了。

线程池原理

假设现在提交一个任务给线程池,线程池处理该任务的流程如下

  1. 判断当前线程数是否达到核心线程数corePoolsize。如果没有,则创建线程来执行任务;否则执行步骤 2;
  2. 判断任务队列是否满了。如果没有将任务加入到任务队列;否则进行步骤 3;
  3. 判断当前线程数量是否达到了线程池最大线程数量maxPoolsize。如果没有,则创建线程来执行任务;否则执行拒绝策略。

线程池使用

java线程池使用的ExecutorService,继承关系如下:

我们使用的主要是两个实现类,一个是常规线程池ThreadPoolExecutor,这也是使用最多的;另一个则是用来做定时任务的线程池ScheduledThreadPoolExecutor。我们来看ThreadPoolExecutor.execute方法来处理任务的逻辑(其实就是线程池原理所说的):

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

// TODO 加图

其中步骤2和4需要获取全局锁,这将严重影响性能。所以完成预热后,之后的任务都是放到任务队列,这样避免来获取全局锁。

介绍完处理任务的逻辑后再来看线程池的构造就很简单明了了。

public ThreadPoolExecutor(  int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler) 
  1. corePoolSize:核心线程的数量。任务来了,不管当前有没有空闲线程,只要线程数量没达到该值就会继续创建线程来执行任务,直至线程数量达到该值,也就是上面说的预热;
  2. maximumPoolSize: 线程池允许创建的最大线程数量。当队列满了,其池子里面线程的数量小于该值的时候会继续创建线程来执行任务;
  3. keepAliveTime: 允许空闲线程的活跃时间,超过该时间就销毁,这里空闲线程说的是核心线程外的线程;
  4. unit: keepAliveTime的单位;
  5. workQueue: 阻塞队列,常见的有ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue, PriorityBlockingQueue;
  6. threadFactory: 线程工厂,主要还是用来给线程取个有意义的名字,在介绍nifty的时候有说道这个,使用的是guava的ThreadFactoryBuilder来构建;
  7. handler: 拒绝策略,也就是当线程数达到maximumPoolSize时,无法执行新的任务的时候采取的措施,常见的是 - AbortPolicy:直接抛出异常 - DiscardPolicy:不处理,丢弃掉。

线程池工具类

线程池工具类: Executors

在业务代码中我们创建线程池可能会使用的是Executors工具类, 下面是它的一些常用方法

int num = Runtime.getRuntime().availableProcessors() * 2;

// (1)
Executors.newFixedThreadPool(num);
Executors.newFixedThreadPool(num, new ThreadFactoryBuilder().setNameFormat("fixed").build());

// (2)
Executors.newCachedThreadPool();
Executors.newCachedThreadPool(new ThreadFactoryBuilder().setNameFormat("cache").build());

// (3)
Executors.newSingleThreadExecutor();
Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("single").build());

// (4)
Executors.newScheduledThreadPool(num);
Executors.newScheduledThreadPool(num, new ThreadFactoryBuilder().setNameFormat("schedule").build());

(1)、newFixedThreadPool: 固定线程数的线程池,使用的无界阻塞队列LinkedBlockingQueue,由于无界限导致maximumPoolSize、拒绝策略以及keepAliveTime是无效的;

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>());
}

(2)、newCachedThreadPool: 无限数量线程池,最大容量为Integer.MAX_VALUE意味着该线程可以无限创建线程来执行任务了,保活时间为60秒,也就是说线程空闲时间超过60秒就会被销毁。使用了无容量的阻塞队列SynchronousQueue,主线程提交任务后,该线程池处理(execute方法)主要为以下3个步骤:

  1. 首先执行SynchronousQueue.offer(Runnable task)。如果当前maximumPool中有空闲线程正在执行SynchronousQueue.poll(keepAliveTime,TimeUnit.MILLISECONDS),那么主线程执行offer操作与空闲线程执行的poll操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成; 否则执行步骤2。
  2. 当初始maximumPool为空,或者maximumPool中当前没有空闲线程时,将没有线程执行SynchronousQueue.poll(keepAliveTime,TimeUnit.MILLISECONDS)。这种情况下,步骤1 将失败。此时CachedThreadPool会创建一个新线程执行任务,execute()方法执行完成。
  3. 在步骤2)中新创建的线程将任务执行完后,会执行SynchronousQueue. poll(keepAliveTime,TimeUnit.MILLISECONDS)。这个poll操作会让空闲线程最多在SynchronousQueue中等待60秒钟。如果60秒钟内主线程提交了一个新任务(主线程执行步骤1)),那么这个空闲线程将执行主线程提交的新任务;否则,这个空闲线程将终止。由于空闲60秒的空闲线程会被终止,因此长时间保持空闲的CachedThreadPool不会使用任何资源。
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                    60L, TimeUnit.SECONDS,
                                    new SynchronousQueue<Runnable>());
}

(3)、newSingleThreadExecutor:和newFixedThreadPool是一样的,只不过固定线程的数量是1。

(4)、 newScheduledThreadPool: 创建定期执行任务的线程池。功能和Timer类似,但是可以设置多个线程。主题提供了以下三个方法:

public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit);

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit);
                                                 

这两个方法表示在延迟initialDelay后执行任务,后续每隔period时间执行一次

public ScheduledFuture<?> schedule(Runnable command,
                                       long delay, TimeUnit unit);

这个方法表示延迟delay后执行任务,来看个demo

ScheduledExecutorService service = Executors.newScheduledThreadPool(num, new ThreadFactoryBuilder().setNameFormat("schedule").build());
// 3秒后输出"hello world",之后每隔1秒输出一次
service.scheduleWithFixedDelay(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world");
    }
}, 3,1, TimeUnit.SECONDS);

// 3秒后输出"hello world2",之后每隔1秒输出一次
service.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world2");
    }
}, 3,1, TimeUnit.SECONDS);

// 3秒后输出"hello world3",之后不会再输出
service.schedule(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world3");
    }
},1,TimeUnit.SECONDS);

关闭线程池:使用shutdown或者shutdownNow都行,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。

线程池在在开源框架Nifty和Netty中的使用

Nifty中的使用

关于nifty我写过几篇博客,有兴趣可以参考下。

看过几个开源rpc框架,都有用到线程池,有使用Executors创建的,也有定制参数的。拿Nifty(Thrift+Netty)服务端来说,在创建netty的BossPool和WorkerPool的时候使用的线程池如下

acceptorExecutor = newCachedThreadPool(new ThreadFactoryBuilder().setNameFormat("thrift-acceptor-%s").build());
ioExecutor = newCachedThreadPool(new ThreadFactoryBuilder().setNameFormat("thrift-io-%s").build());

这就是Executors提供的线程池,这是给netty用的就不去关心干嘛的了。

NiftyDispatcher是服务端的一个处理器,收到请求后进行处理,这里也使用了线程池,每次收到请求后构建个任务交给线程池去执行。默认使用的就是框架自己创建的线程池

new ThreadPoolExecutor(getWorkerThreads(),
                        getWorkerThreads(),
                        0L,
                        TimeUnit.MILLISECONDS,
                        queue,
                        new ThreadFactoryBuilder().setNameFormat("thrift-worker-%s").build(),
                        new ThreadPoolExecutor.AbortPolicy());

其中,getWorkerThreads和queue是可以自己进行设置的。默认数量是200,使用的是 LinkedBlockingQueue

再看Nifty客户端使用到的线程池, 定义在了AbstractClientChannel这个处理器中,在收到服务端的响应后,会使用线程池来执行回调,返回结果。在程序关闭的时候会关闭线程池。

private static final String SWIFT_CLIENT_POOL_SIZE = "swift.client.pool.size";
private static int poolSize = StringUtils.isEmpty(System.getProperty(SWIFT_CLIENT_POOL_SIZE)) ?
        100 : Integer.parseInt(System.getProperty(SWIFT_CLIENT_POOL_SIZE).trim());
private static ExecutorService executorService = Executors.newFixedThreadPool(poolSize);

static {
    // 关闭线程池
    Runtime.getRuntime().addShutdownHook(
            new Thread(new Runnable() {
                @Override
                public void run() {
                    executorService.shutdown();
                }
            })
    );
}

@Override
public void messageReceived(ChannelHandlerContext ctx, MessageEvent e)
{
    ChannelBuffer response = extractResponse(e.getMessage());
    int sequenceId = extractSequenceId(response);
    onResponseReceived(sequenceId, response);
}

private void onResponseReceived(int sequenceId, final ChannelBuffer response) {
    final Request request = requestMap.remove(sequenceId);
    executorService.execute(new Runnable() {
            @Override
            public void run() {
                fireResponseReceivedCallback(request.getListener(), response);
            }
        });
}

这里使用的就是使用Executors创建固定线程数的线程池。

Netty中的使用

其实这里都不能叫线程池了,netty自己实现了Executor接口而已,execute逻辑完全是自己写的,方便起见就当做线程池吧。

在创建NioEventLoopGroup的时候,可以选用带Executor方法的构造方法。如果我们使用无参构造方法,那么框架会自动创建线程池executor。接下来会使用该线程池来创建NioEventLoop, 最终将其设置为 属性executor。在服务启动的时候执行doStartThread方法的时候会调用executor.execute(runnable),其中runnable中会开启轮询,细节就不说了,知道有这么回事就行。

这里的线程池是自定义的

public final class ThreadPerTaskExecutor implements Executor {
    private final ThreadFactory threadFactory;

    public ThreadPerTaskExecutor(ThreadFactory threadFactory) {
        if (threadFactory == null) {
            throw new NullPointerException("threadFactory");
        }
        this.threadFactory = threadFactory;
    }

    @Override
    public void execute(Runnable command) {
        threadFactory.newThread(command).start();
    }
}

线程工厂也是自定义的,主要是设置名称前缀。执行execute方法的时候使用自定义线程池工厂创建线程然后启动线程。

一些浅薄经验分享

其实我也是天天写业务的直接用到线程池的机会也不多,虽然索性见过两次,但是其实也没有什么深奥的做法。

说下我浅薄的见解吧:

  1. 首先如果是rpc服务,比如我们的thrift服务,使用的线程池设置的线程数量是否过低?如何判断?记得当时我们服务在早晚高峰出现了较多的超时问题,然后抓包发现接收到请求和真正处理时间相差比较大,所以猜可能发生了请求堆积(事实上确实是发生了堆积),我们将线程数量上调了50%解决了这个问题。

  2. 还有一次是redis服务挂了一台,导致我们的服务大量超时,此为同步操作大量占用了请求线程,不断堆积会大量占用系统资源。所以不应该在请求核心线程池同步处理redis请求,优化的时候将接口异步化改造,同时单独分配一个线程池。现在我们的服务基本都是异步化的,主流程里面不允许出现同步IO。

  3. 使用无界阻塞队列需谨慎,基本上也不要去创建线程没有上限的线程池,比如我们使用下面的定制线程池。

new ThreadPoolExecutor(核数*2, 100, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue[Runnable](1000))

当然了我说的是针对一般的情况,具体做法肯定还是得看业务,比如你们的需求处理时间短,任务量大,可以多设置一些线程的数量。