Tomcat 如何扩展Java线程池

如果大家觉得文章有错误内容,欢迎留言或者私信讨论~

  在开发中我们就碰到各种“池”的概念,比如线程池、连接池、常量池等。实际上我们运行程序的本质就是利用计算机的系统资源(比如CPU、内存、磁盘等)来完成信息的处理。比如在 JVM 中我们创建一个对象就需要用到 CPU、内存资源,如果你的程序涉及到了大量的对象创建并且这些对象的存活时间短,这就导致 JVM 频繁的 GC,这部分就可能会成为性能的瓶颈。

  “池”就是用来解决这种问题的。简单来说,就是把需要用到的对象存储起来,下次用到再从“池”中取出来。在 Java 中万物皆对象,线程也是一个对象,Java 线程是对操作系统线程的封装,创建 Java 线程也需要消耗系统资源,因此就有了线程池。

  那就先让我们来看一下 JDK 原生的线程池。

Java 线程池

  Java 的线程池就是内部维护一个线程数组和一个线程队列,当任务处理不过来,就放到队列里慢慢“消化”。

ThreadPoolExecutor

  我们先来看看 Java 线程池核心类的构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

  Java 线程池的工作流程如下:

  1. 当线程池被创建开始接受任务之后,如果corePoolSize还未满,线程池就会创建新的线程来执行。
  2. 当核心线程池数达到corePoolSize之后,后续的任务会被丢到workQueue中等待处理。
  3. workQueue也已经到达顶峰,那么线程池就会创建额外线程,让线程池达到maximumPoolSize
  4. 最后当最大线程数也达到顶峰,那么线程池就会转而执行拒绝策略 handler,比如抛出异常或者由调用者线程来执行任务等。
  5. 当高峰过去,额外线程使用 poll(keepAliveTime, unit)方法从工作队列中拉活干,请注意 poll 方法设置了超时时间,如果超时了仍然两手空空没拉到活,表明它太闲了,这个线程会被销毁回收。

FixedThreadPool/CachedThreadPool

  Java 提供了一些默认的线程池实现,比如 FixedThreadPool 和 CachedThreadPool,它们的本质就是给 ThreadPoolExecutor 设置了不同的参数,是定制版的 ThreadPoolExecutor。

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

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

  从上面的代码可以看到:

  • FixedThreadPool有固定的线程长度,忙不过来时会把任务放到无限长的队列里,这是因为 LinkedBlockingQueue 默认是一个无界队列。
  • CachedThreadPool 的 maximumPoolSize 参数是 Integer.MAX_VALUE, 对线程数不做限制,但它的任务队列是 SynchronousQueue,表明队列长度为 0。

Tomcat 线程池

  跟 FixedThreadPool 和 CachedThreadPool 一样,Tomcat 的 Executor 组件也是定制版。Tomcat 对于是否限制线程数是否限制队列长度 都做了处理,也就是说对高并发进行控制。因此 Tomcat 传入的参数是这样的:

//定制版的任务队列
taskqueue = new TaskQueue(maxQueueSize);

//定制版的线程工厂
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());

//定制版的线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);

  你可以看到两个关键点:

  • Tomcat 有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。
  • Tomcat 对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。

  除了资源限制以外,Tomcat 线程池还定制自己的任务处理流程。我们知道 Java 原生线程池的任务处理逻辑比较简单:

  • 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
  • 后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
  • 如果总线程数达到 maximumPoolSize,执行拒绝策略。

  Tomcat 线程池通过重写 execute 方法实现了自己的任务处理逻辑:

  • 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
  • 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
  • 如果总线程数达到 maximumPoolSize,则继续尝试把任务添加到任务队列中去。
  • 如果缓冲队列也满了,插入失败,执行拒绝策略。

  观察 Tomcat 线程池和 Java 原生线程池的区别,其实就是在第 3 步,Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。那具体如何实现呢,其实很简单,我们来看一下 Tomcat 线程池的 execute 方法的核心代码。

public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
  
  ...
  
  public void execute(Runnable command, long timeout, TimeUnit unit) {
      submittedCount.incrementAndGet();
      try {
          //调用Java原生线程池的execute去执行任务
          super.execute(command);
      } catch (RejectedExecutionException rx) {
         //如果总线程数达到maximumPoolSize,Java原生线程池执行拒绝策略
          if (super.getQueue() instanceof TaskQueue) {
              final TaskQueue queue = (TaskQueue)super.getQueue();
              try {
                  //继续尝试把任务放到任务队列中去
                  if (!queue.force(command, timeout, unit)) {
                      submittedCount.decrementAndGet();
                      //如果缓冲队列也满了,插入失败,执行拒绝策略。
                      throw new RejectedExecutionException("...");
                  }
              } 
          }
      }
}

  从这个方法你可以看到,Tomcat 线程池的 execute 方法会调用 Java 原生线程池的 execute 去执行任务,如果总线程数达到 maximumPoolSize,Java 原生线程池的 execute 方法会抛出 RejectedExecutionException 异常,但是这个异常会被 Tomcat 线程池的 execute 方法捕获到,并继续尝试把这个任务放到任务队列中去;如果任务队列也满了,再执行拒绝策略。

定制版的任务队列

  细心的你有没有发现,在 Tomcat 线程池的 execute 方法最开始有这么一行:

submittedCount.incrementAndGet();

  当任务执行失败,抛出拒绝异常时,将这个原子变量减一:

submittedCount.decrementAndGet();

  这个变量主要跟 Tomcat 的定制的任务队列有关系,Tomcat 的任务队列 TaskQueue 扩展了 Java 中的 LinkedBlockingQueue,我们知道 LinkedBlockingQueue 默认情况下长度是没有限制的,除非给它一个 capacity。因此 Tomcat 给了它一个 capacity,TaskQueue 的构造函数中有个整型的参数 capacity,TaskQueue 将 capacity 传给父类 LinkedBlockingQueue 的构造函数:

public class TaskQueue extends LinkedBlockingQueue<Runnable> {

  public TaskQueue(int capacity) {
      super(capacity);
  }
  ...
}

  这个 capacity 参数是通过 Tomcat 的 maxQueueSize 参数来设置的,但问题是默认情况下 maxQueueSize 的值是Integer.MAX_VALUE,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。

  为了解决这个问题,TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程:

public class TaskQueue extends LinkedBlockingQueue<Runnable> {

  ...
   @Override
  //线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
  public boolean offer(Runnable o) {

      //如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
      if (parent.getPoolSize() == parent.getMaximumPoolSize()) 
          return super.offer(o);
          
      //执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
      //表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
      
      //1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
      if (parent.getSubmittedCount()<=(parent.getPoolSize())) 
          return super.offer(o);
          
      //2. 如果已提交的任务数大于当前线程数,线程不够用了,返回false去创建新线程
      if (parent.getPoolSize()<parent.getMaximumPoolSize()) 
          return false;
          
      //默认情况下总是把任务添加到任务队列
      return super.offer(o);
  }
  
}

  从上面的代码我们看到,只有当前线程数大于核心线程数、小于最大线程数,并且已提交的任务个数大于当前线程数时,也就是说线程不够用了,但是线程数又没达到极限,才会去创建新的线程。这就是为什么 Tomcat 需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。

  当然默认情况下 Tomcat 的任务队列是没有限制的,你可以通过设置 maxQueueSize 参数来限制任务队列的长度。

你可能感兴趣的:(Tomcat,java,tomcat,jvm)