Executor组件:Tomcat如何扩展Java线程池?

java 线程池

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

每次提交任务时,如果线程数还没达到核心线程数corePoolSize,线程池就创建新线程来执行。当线程数达到corePoolSize后,新增的任务就放到工作队列workQueue里,而线程池中的线程则努力地从workQueue里拉活来干,也就是调用 poll 方法来获取任务。

如果任务很多,并且workQueue是个有界队列,队列可能会满,此时线程池就会紧急创建新的临时线程来救场,如果总的线程数达到了最大线程数maximumPoolSize,则不能再创建新的临时线程了,转而执行拒绝策略handler,比如抛出异常或者由调用者线程来执行任务等。

如果高峰过去了,线程池比较闲了怎么办?临时线程使用 poll(keepAliveTime, unit)方法从工作队列中拉活干,请注意 poll 方法设置了超时时间,如果超时了仍然两手空空没拉到活,表明它太闲了,这个线程会被销毁回收。

那还有一个参数threadFactory是用来做什么的呢?通过它你可以扩展原生的线程工厂,比如给创建出来的线程取个有意义的名字。

java 默认的线程池实现(不推荐)

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 有固定长度(nThreads)的线程数组,忙不过来时会把任务放到无限长的队列里,这是因为LinkedBlockingQueue 默认是一个无界队列。
  • CachedThreadPool 的 maximumPoolSize 参数值是**<font style="color:rgb(53, 53, 53);">Integer.MAX_VALUE</font>**,因此它对线程个数不做限制,忙不过来时无限创建临时线程,闲下来时再回收。它的任务队列是SynchronousQueue,表明队列长度为 0。

Tomcat 线程池

跟 FixedThreadPool/CachedThreadPool 一样,Tomcat 的线程池也是一个定制版的 ThreadPoolExecutor。

通过比较 FixedThreadPool 和 CachedThreadPool,我们发现它们传给 ThreadPoolExecutor 的参数有两个关键点:

  • 是否限制线程个数。
  • 是否限制队列长度。

对于 Tomcat 来说,这两个资源都需要限制,也就是说要对高并发进行控制,否则 CPU 和内存有资源耗尽的风险。因此 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 原生线程池的任务处理逻辑比较简单:

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

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

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

观察 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 这个原子变量加一,并且在任务执行失败,抛出拒绝异常时,将这个原子变量减一:

submittedCount.decrementAndGet();

其实 Tomcat 线程池是用这个变量 submittedCount 来维护已经提交到了线程池,但是还没有执行完的任务个数。Tomcat 为什么要维护这个变量呢?这跟 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 的值是<font style="color:rgb(53, 53, 53);">Integer.MAX_VALUE</font>,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。

为了解决这个问题,TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程。那什么是合适的时机呢?请看下面 offer 方法的核心源码:

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 的定制版任务队列 TaskQueue 的 offer 方法,它多次调用了 getPoolSize 方法,但是这个方法是有锁的,锁会引起线程上下文切换而损耗性能,请问这段代码可以如何优化呢?

  • 直接读 work.size()即可,因为创建线程和销毁线程的方法都加锁了,而且是同一把锁。
  • 所以,getPoolSize()方法不用额外加锁

对象池

Java 对象,特别是一个比较大、比较复杂的 Java 对象,它们的创建、初始化和 GC 都需要耗费 CPU 和内存资源,为了减少这些开销,Tomcat 和 Jetty 都使用了对象池技术。所谓的对象池技术,就是说一个 Java 对象用完之后把它保存起来,之后再拿出来重复使用,省去了对象创建、初始化和 GC 的过程。对象池技术是典型的以空间换时间的思路。

由于维护对象池本身也需要资源的开销,不是所有场景都适合用对象池。如果你的 Java 对象数量很多并且存在的时间比较短,对象本身又比较大比较复杂,对象初始化的成本比较高,这样的场景就适合用对象池技术。比如 Tomcat 和 Jetty 处理 HTTP 请求的场景就符合这个特征,请求的数量很多,为了处理单个请求需要创建不少的复杂对象(比如 Tomcat 连接器中 SocketWrapper 和 SocketProcessor),而且一般来说请求处理的时间比较短,一旦请求处理完毕,这些对象就需要被销毁,因此这个场景适合对象池技术。

Tomcat 的 SynchronizedStack

Tomcat 用 SynchronizedStack 类来实现对象池:

public class SynchronizedStack<T> {
 
    // 内部维护一个对象数组, 用数组实现栈的功能
    private Object[] stack;
 
    // 这个方法用来归还对象,用 synchronized 进行线程同步
    public synchronized boolean push(T obj) {
        index++;
        if (index == size) {
            if (limit == -1 || size < limit) {
                expand();// 对象不够用了,扩展对象数组
            } else {
                index--;
                return false;
            }
        }
        stack[index] = obj;
        return true;
    }
    
    // 这个方法用来获取对象
    public synchronized T pop() {
        if (index == -1) {
            return null;
        }
        T result = (T) stack[index];
        stack[index--] = null;
        return result;
    }
    
    // 扩展对象数组长度,以 2 倍大小扩展
    private void expand() {
      int newSize = size * 2;
      if (limit != -1 && newSize > limit) {
          newSize = limit;
      }
      // 扩展策略是创建一个数组长度为原来两倍的新数组
      Object[] newStack = new Object[newSize];
      // 将老数组对象引用复制到新数组
      System.arraycopy(stack, 0, newStack, 0, size);
      // 将 stack 指向新数组,老数组可以被 GC 掉了
      stack = newStack;
      size = newSize;
   }
}

这个代码逻辑比较清晰,主要是 SynchronizedStack 内部维护了一个对象数组,并且用数组来实现栈的接口:push 和 pop 方法,这两个方法分别用来归还对象和获取对象。你可能好奇为什么 Tomcat 使用一个看起来比较简单的 SynchronizedStack 来做对象容器,为什么不使用高级一点的并发容器比如 ConcurrentLinkedQueue 呢?

这是因为 SynchronizedStack 用数组而不是链表来维护对象,可以减少结点维护的内存开销,并且它本身只支持扩容不支持缩容,也就是说数组对象在使用过程中不会被重新赋值,也就不会被 GC。这样设计的目的是用最低的内存和 GC 的代价来实现无界容器,同时 Tomcat 的最大同时请求数是有限制的,因此不需要担心对象的数量会无限膨胀。

对象池的思考

对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销,而不使用对象池则有创建和销毁对象的开销。对于对象池本身的设计来说,需要尽量做到无锁化,比如 Jetty 就使用了 ConcurrentLinkedDeque。如果你的内存足够大,可以考虑用线程本地(ThreadLocal)对象池,这样每个线程都有自己的对象池,线程之间互不干扰。

为了防止对象池的无限膨胀,必须要对池的大小做限制。对象池太小发挥不了作用,对象池太大的话可能有空闲对象,这些空闲对象会一直占用内存,造成内存浪费。这里你需要根据实际情况做一个平衡,因此对象池本身除了应该有自动扩容的功能,还需要考虑自动缩容。

所有的池化技术,包括缓存,都会面临内存泄露的问题,原因是对象池或者缓存的本质是一个 Java 集合类,比如 List 和 Stack,这个集合类持有缓存对象的引用,只要集合类不被 GC,缓存对象也不会被 GC。维持大量的对象也比较占用内存空间,所以必要时我们需要主动清理这些对象。以 Java 的线程池 ThreadPoolExecutor 为例,它提供了 allowCoreThreadTimeOut 和 setKeepAliveTime 两种方法,可以在超时后销毁线程,我们在实际项目中也可以参考这个策略。

另外在使用对象池时,我这里还有一些小贴士供你参考:

  • 对象在用完后,需要调用对象池的方法将对象归还给对象池。
  • 对象池中的对象在再次使用时需要重置,否则会产生脏对象,脏对象可能持有上次使用的引用,导致内存泄漏等问题,并且如果脏对象下一次使用时没有被清理,程序在运行过程中会发生意想不到的问题。
  • 对象一旦归还给对象池,使用者就不能对它做任何操作了。
  • 向对象池请求对象时有可能出现的阻塞、异常或者返回 null 值,这些都需要我们做一些额外的处理,来确保程序的正常运行。

不错的问题

threadLocal中的对象如果用完不清。下次别的请求Tomcat线程池中拿到同个线程,能取到之前请求存入的数据么?

显然是会的,所以 threadLocal 要及时清理。

高效的并发编程

我们知道并发的过程中为了同步多个线程对共享变量的访问,需要加锁来实现。而锁的开销是比较大的,拿锁的过程本身就是个系统调用,如果锁没拿到线程会阻塞,又会发生线程上下文切换,尤其是大量线程同时竞争一把锁时,会浪费大量的系统资源。因此作为程序员,要有意识的尽量避免锁的使用,比如可以使用原子类 CAS 或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。接下来我们来看看 Tomcat 和 Jetty 如何做到高效的并发编程的。

缩小锁的范围

缩小锁的范围,其实就是不直接在方法上加 synchronized,而是使用细粒度的对象锁。

protected void startInternal() throws LifecycleException {



setState(LifecycleState.STARTING);



// 锁 engine 成员变量

if (engine != null) {

    synchronized (engine) {

        engine.start();

    }

}



// 锁 executors 成员变量

synchronized (executors) {

    for (Executor executor: executors) {

        executor.start();

    }

}



mapperListener.start();



// 锁 connectors 成员变量

synchronized (connectorsLock) {

    for (Connector connector: connectors) {

        // If it has already failed, don't try and start it

        if (connector.getState() != LifecycleState.FAILED) {

            connector.start();

        }

    }

}

}
复制代码

比如上面的代码是 Tomcat 的 StandardService 组件的启动方法,这个启动方法要启动三种子组件:engine、executors 和 connectors。它没有直接在方法上加锁,而是用了三把细粒度的锁,来分别用来锁三个成员变量。如果直接在方法上加 synchronized,多个线程执行到这个方法时需要排队;而在对象级别上加 synchronized,多个线程可以并行执行这个方法,只是在访问某个成员变量时才需要排队。

用原子变量和 CAS 取代锁

下面的代码是 Jetty 线程池的启动方法,它的主要功能就是根据传入的参数启动相应个数的线程。

private boolean startThreads(int threadsToStart)

{

while (threadsToStart > 0 && isRunning())

{

    // 获取当前已经启动的线程数,如果已经够了就不需要启动了

    int threads = _threadsStarted.get();

    if (threads >= _maxThreads)

        return false;



    // 用 CAS 方法将线程数加一,请注意执行失败走 continue,继续尝试

    if (!_threadsStarted.compareAndSet(threads, threads + 1))

        continue;



    boolean started = false;

    try

        {

            Thread thread = newThread(_runnable);

            thread.setDaemon(isDaemon());

            thread.setPriority(getThreadsPriority());

            thread.setName(_name + "-" + thread.getId());

            _threads.add(thread);//_threads 并发集合

            _lastShrink.set(System.nanoTime());//_lastShrink 是原子变量

            thread.start();

            started = true;

            --threadsToStart;

        }

    finally

        {

            // 如果最终线程启动失败,还需要把线程数减一

            if (!started)

                _threadsStarted.decrementAndGet();

        }

}

return true;

}
复制代码

你可以看到整个函数的实现是一个while 循环,并且是无锁的。<font style="color:rgb(53, 53, 53);">_threadsStarted</font>表示当前线程池已经启动了多少个线程,它是一个原子变量 AtomicInteger,首先通过它的 get 方法拿到值,如果线程数已经达到最大值,直接返回。否则尝试用 CAS 操作将<font style="color:rgb(53, 53, 53);">_threadsStarted</font>的值加一,如果成功了意味着没有其他线程在改这个值,当前线程可以继续往下执行;否则走 continue 分支,也就是继续重试,直到成功为止。在这里当然你也可以使用锁来实现,但是我们的目的是无锁化。

并发容器的使用

CopyOnWriteArrayList 适用于读多写少的场景,比如 Tomcat 用它来“存放”事件监听器,这是因为监听器一般在初始化过程中确定后就基本不会改变,当事件触发时需要遍历这个监听器列表,所以这个场景符合读多写少的特征。

public abstract class LifecycleBase implements Lifecycle {



    // 事件监听器集合

    private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>();



    ...

}
复制代码

volatile 关键字的使用

再拿 Tomcat 中的 LifecycleBase 作为例子,它里面的生命状态就是用 volatile 关键字修饰的。volatile 的目的是为了保证一个线程修改了变量,另一个线程能够读到这种变化。对于生命状态来说,需要在各个线程中保持是最新的值,因此采用了 volatile 修饰。

public abstract class LifecycleBase implements Lifecycle {



    // 当前组件的生命状态,用 volatile 修饰

    private volatile LifecycleState state = LifecycleState.NEW;



}

不错的问题

类别触发 **syscall**
的 Java API
I/O 相关FileInputStream.read()
,Socket.read()
线程/进程Thread.sleep()
,Thread.yield()
,Runtime.exec()
内存管理Unsafe.allocateMemory()
,MappedByteBuffer.force()
时间管理System.currentTimeMillis()
,System.nanoTime()
环境变量System.getenv()
,System.getProperty()

怎么减少系统调用?

✅ 使用用户态缓存
  • **缓存 ****System.currentTimeMillis()**,避免频繁调用(适用于高并发场景)。
  • **使用 **ThreadLocal** 或 ****FastThreadLocal**,减少线程切换时的内存访问。
✅ 避免不必要的 I/O
  • 文件 I/O
    • 使用 **BufferedInputStream**** / ****BufferedOutputStream**,减少 read() / write() 触发的 syscall。
    • 使用 **FileChannel**** + ****MappedByteBuffer** 进行大文件读取,减少 read() 触发的 syscall。
  • 网络 I/O
    • 使用 NIO / Netty,减少阻塞式 read() / write()。
    • 连接池(例如 HttpClient 连接池)减少 connect() 触发的 syscall。
✅ 避免不必要的线程切换
  • 避免 **Thread.sleep()** 频繁调用(导致 syscall)。
  • **尽量使用 **CompletableFuture** 或者 ****ForkJoinPool**,减少线程切换。
  • **使用 **LockSupport.parkNanos()** 代替 ****Thread.sleep()**(减少 CPU 资源浪费)。