线程池调整真的很重要

知道吗,你的Java web应用实际上是使用线程池来处理请求的。这一实现细节被许多人忽略,可是你早晚都须要理解线程池如何使用,以及如何正确地根据应用调整线程池配置。这篇文章的目的是为了解释线程模型——什么是线程池、以及怎样正确地配置线程池。html

单线程模型

让咱们从一些基础的线程模型开始,而后再随着线程模型的演变进行更深一步的学习。你使用的任何应用服务器或框架,如TomcatDropwizardJetty等,它们的基本原理实际上是相同的。Web服务器的最底层其实是一个socket。这个socket监听并接受到达的TCP链接。一旦一个链接被创建,就能够经过这个新创建的链接读取、解析信息,而后将这些信息包装成一个HTTP请求。这个HTTP请求还将被移交至web应用程序,来完成请求的动做。java

咱们将经过一个简单的服务器程序来展现线程在其中所起到的做用。这个服务器程序展现了大部分应用服务器的底层实现细节。让咱们以一个简单的单线程web服务器程序开始,它的代码像下面这样:node

ServerSocket listener = new ServerSocket(8080);
try {
    while (true) {
        Socket socket = listener.accept();
        try {
            handleRequest(socket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
} finally {
    listener.close();
}

这段代码在8080端口上建立了一个ServerSocket,紧接着经过循环来监听和接受新到达的链接。一旦链接创建,会将socket传递给handleRequest方法。这个方法可能会读取该HTTP请求,处理这个请求,而后写回一个响应。在这个简单的例子中,handleRequest方法从socket中读取简单的一行数据,而后返回一个简短的HTTP响应。可是,handleRequest有可能须要处理一些更复杂的任务,例如读数据库或者执行其它一些IO操做。nginx

final static String response =
    "HTTP/1.0 200 OKrn" +
    "Content-type: text/plainrn" +
    "rn" +
    "Hello Worldrn";
 
public static void handleRequest(Socket socket) throws IOException {
    // Read the input stream, and return "200 OK"
    try {
        BufferedReader in = new BufferedReader(
            new InputStreamReader(socket.getInputStream()));
 
        log.info(in.readLine());
 
        OutputStream out = socket.getOutputStream();
        out.write(response.getBytes(StandardCharsets.UTF_8));
    } finally {
        socket.close();
    }
}

由于只有一个线程处理全部的socket,所以只有在彻底处理好一个请求后,才能再接受下一个请求。在实际的应用中,handleRequest方法可能须要通过100毫秒才能返回,那么这个服务器程序在一秒中,只能按顺序处理10个请求。git

多线程模型

尽管handleRequest可能会被IO操做阻塞,CPU却多是空闲的,它能够处理其它更多请求,但这对单线程模型来讲是不能实现的。所以,经过建立多个线程,可使服务器程序实现并发操做:github

public static class HandleRequestRunnable implements Runnable {
    final Socket socket;
 
    public HandleRequestRunnable(Socket socket) {
        this.socket = socket;
    }
 
    public void run() {
        try {
            handleRequest(socket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
 
// Main loop here
ServerSocket listener = new ServerSocket(8080);
try {
    while (true) {
        Socket socket = listener.accept();
        new Thread( new HandleRequestRunnable(socket) ).start();
    }
} finally {
    listener.close();
}

上面这段代码中,accept()方法仍然是在一个单线程循环中被调用。可是当TCP链接创建,socket建立时,服务器就建立一个新的线程。这个新生的线程将执行和单线程模型中同样的handleRequest方法。web

新线程的创建使调用accept方法的线程可以处理更多的TCP链接,这样服务器就能并发地处理请求了。这一技术被称为“thread per request”(一个线程处理一个请求),也是如今最流行的服务器技术。值得注意的是,还有一些其它的服务器技术,如NGINXNode.js采用的事件驱动异步模型,它们都没有使用线程池。所以,它们都不在本文的讨论范围内。数据库

“thread per request”方式里建立新线程(稍后销毁这个线程)的操做是昂贵的,由于Java虚拟机和操做系统都须要为这一操做分配资源。另外,在上面那段代码的中,能够建立的线程数量是不受限制的。这么作的隐患很大,由于它可能致使服务器资源迅速枯竭。apache

资源枯竭

每一个线程都须要必定的内存空间来做为本身的栈空间。在最近的64位虚拟机版本中,默认的栈空间是1024KB。若是server收到不少请求,或者handleRequest方法的执行时间变得比较长,就会形成服务器产生不少并发线程。若是要维护1000个线程,仅就栈空间而言,虚拟机就必须耗费1GB的RAM空间。另外,为处理请求,每一个线程都会在堆上产生许多对象,这就有可能致使虚拟机的堆空间被迅速占满,给虚拟机的垃圾收集器带来很大压力,形成频繁的垃圾回收,最终致使OutOfMemoryErrors后端

线程消耗的不只是RAM资源,这些线程还可能消耗其它有限的资源,例如文件句柄、数据库链接等。过多地消耗这类资源可能致使一些其它的错误或形成系统崩溃。所以,要防止系统资源被线程耗尽,就必须对服务器产生的线程数量作出限制。

经过使用-Xss参数来调整每一个线程的栈空间,能够在必定程度上解决资源枯竭的问题,但它毫不是灵丹妙药。一个小的栈空间可使得每一个线程占用的内存减少,但这样可能会形成StackOverflowErrors栈溢出错误。栈空间的调整方式不尽相同,可是对许多应用来讲,1024KB过于浪费了,而256KB或512KB会更加合适。Java所容许的最小栈空间的大小是160KB。

线程池

能够经过一个简单的线程池来避免持续地建立新线程,限制最大线程数量。线程池跟踪着全部线程,在线程数量达到上限前,它会建立新的线程,当有空闲线程时,它会使用空闲线程。

ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = Executors.newFixedThreadPool(4);
try {
    while (true) {
        Socket socket = listener.accept();
        executor.submit( new HandleRequestRunnable(socket) );
    }
} finally {
    listener.close();
}

上面这段代码使用了ExecutorService类来提交任务(Runnable)。提交的任务将会被线程池中的线程执行,而不是经过新建立的线程执行。在这个例子中,全部的请求都经过一个线程数量固定为4的线程池来完成。这个线程池限制了并发执行的请求数量,从而限制了系统资源的使用。

除了newFixedThreadPool方法建立的线程池外,Executors类还提供了newCachedThreadPool 方法来建立线程池。这种线程池一样有没法限制线程数量的问题,可是它会优先使用线程池中已建立的空闲线程来处理请求。这种类型的线程池特别适用于执行短时间任务的请求,由于它们不会长时间的阻塞外部资源。

ThreadPoolExecutors 类也能够直接建立,这样就能够对它进行一些个性化的配置。例如能够配置线程池内最小线程数和最大线程数,也能够配置线程建立和销毁的策略。稍后,本文将介绍这样的例子。

工做队列

对于线程数量固定的线程池,善于观察的读者可能会提出这样的一个疑问:当线程池中的线程都在工做时,一个新的请求到达,会发生什么呢?当线程池中的线程都在工做时,ThreadPoolExecutor可能会使用一个队列来组织新到达的请求,直到线程池中有空闲的线程可使用。Executors.nexFixedThreadPool方法会默认建立一个没有长度限制的LinkedList。这个LinkedList也可能会产生系统资源耗尽的问题,虽然这个过程会比较缓慢,由于队列中的请求所占用的资源比线程占用的资源要少得多。可是在咱们的例子中,队列中的每一个请求都保持着一个socket,而每个socket都须要打开一个文件句柄,操做系统对同时打开的文件句柄数量是有限制的,因此队列中保持socket并非一个好的方式,除非必须这么作。所以,限制工做队列的长度也是有意义的。

public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {
    return new ThreadPoolExecutor(nThreads, nThreads,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<Runnable>(capacity),
        new ThreadPoolExecutor.DiscardPolicy());
}
 
public static void boundedThreadPoolServerSocket() throws IOException {
    ServerSocket listener = new ServerSocket(8080);
    ExecutorService executor = newBoundedFixedThreadPool(4, 16);
    try {
        while (true) {
            Socket socket = listener.accept();
            executor.submit( new HandleRequestRunnable(socket) );
        }
    } finally {
        listener.close();
    }
}

咱们再一次建立一个线程池,这一次咱们没有使用Executors.newFixedThreadPool方法,而是自定义了一个ThreadPoolExecutor,在构造方法中传递了一个大小限制为16个元素的LinkedBlockingQueue。一样的,类ArrayBlockingQueue也能够被用来限制队列的长度。

若是全部的线程都在执行任务,并且工做队列也被请求填满了,此时对于新到达请求的处理方式,取决于ThreadPoolExecutor构造方法的最后一个参数。在咱们这个例子中,咱们使用的是DiscardPolicy,这个参数会让线程池丢弃新到达的请求。还有一些其它的处理策略,例如AbortPolicy会让Executor抛出一个异常,CallerRunsPolicy会使任务在它的调用端线程池中执行。CallerRunsPolicy策略提供了一个简单的方式来限制任务提交的速度。可是这样作多是有害的,由于它会阻塞一个本来不该被阻塞的线程。

一个好的默认策略应该是Discard或Abort,它们都会使线程池丢弃新到达的任务。这样服务器就能容易地向客户端响应一个错误,例如HTTP的503错误“Service unavailable”。有的人可能会认为,队列的长度应该是容许增加的,这样全部的任务最终都能被执行。可是用户是不肯意长时间等待的,并且若任务到达的速度超过任务处理的速度,队列将会无限地增加。队列是被用来缓冲忽然爆发的请求,或者处理短时间任务的,一般状况下,队列应该是空的。

多少线程合适呢?

如今,咱们知道了如何建立一个线程池。可是有一个更困难的问题,线程池里应该建立多少个线程呢?咱们已经知道了线程池中的最大线程数量应该被限制,才不会致使系统资源耗尽。这些系统资源包括了内存(堆栈)、打开的文件句柄、打开的TCP链接、打开的数据库链接以及其它有限的系统资源。相反的,若是线程执行的是CPU密集型任务而不是IO密集型任务,服务器的物理内核数就应该被视为是有限的资源,这样建立的线程数就不该该超过系统的内核数。

系统应建立多少线程取决于这个应用执行的任务。开发人员应使用现实的请求来对系统进行负载测试,测试不一样的线程池大小配置对系统的影响。每次测试都增长线程池的大小,直到系统达到崩溃的临界点。这个方法使你能够发现线程池线程数量的上限。超过这个上限,系统的资源将耗尽。在某些状况下,能够谨慎地增长系统的资源,例如分配更多的RAM空间给JVM,或者调整操做系统使其支持同时打开更多的文件句柄。然而,在某些状况下建立的线程数量会达到咱们测试出的理论上限,这很是值得咱们注意。稍后还会看到这方面的内容。

利特尔法则

clipboard.png

排队论,特别的,Little’s Law,能够用来帮助咱们理解线程池的一些特性。简单地说,利特尔法则解释了这三种变量的关系:L—系统里的请求数量、λ—请求到达的速率和W—每一个请求的处理时间。例如,若是每秒10个请求到达,处理一个请求须要1秒,那么系统在每一个时刻都有10个请求在处理。若是处理每一个请求的时间翻倍,那么系统每时刻须要处理的请求数也翻倍为20,所以须要20个线程。

任务的执行时间对于系统中正在处理的请求数量有着很大的影响,一些后端资源的迟延,例如数据库,一般会使得请求的处理时间延长,从而致使线程池中的线程被迅速用尽。所以,理论上测出的线程数上限对于这种状况就不是很合适,这个上限值还应该考虑到线程的执行时间,并结合理论上的上限值。

例如,假设JVM最多能同时处理的请求数为1000。若是咱们预计每一个请求须要耗费的时间不超过30秒,那么,在最坏的状况下咱们每秒能同时处理的请求数不会超过33 ⅓个。可是,若是一切都很顺利,每一个请求只需使用500ms就能够完成,那么经过1000个线程应用每秒就能够处理2000个请求。当系统忽然出现短暂的任务执行迟延的问题时,经过使用一个队列来减缓这一问题是可行的。

为何线程数配置不当会带来麻烦?

若是线程池的线程数量过少,咱们就没法充分利用系统资源,这使得用户须要花费很长时间来等待请求的响应。可是,若是容许建立过多的线程,系统的资源又会被耗尽,这会对系统形成更大的破坏。

不只仅是本地的资源被耗尽,其它一些应用也会受到影响。例如,许多应用都使用同一个后端数据库进行查询等操做。数据库有并发链接数量的限制。若是一个应用不加限制地占用了全部数据库链接,其它获取数据库链接的应用都将被阻塞。这将致使许多应用运行中断。

更糟的是,资源耗尽还会引起一些连锁故障。设想这样一个场景,一个应用有许多个实例,这些实例都运行在一个负载均衡器以后。若是一个实例由于过多的请求而占用了过多内存,JVM就须要花更多的时间进行垃圾收集工做,那么JVM处理请求的时间就减小了。这样一来,这个应用实例处理请求的能力下降了,系统中的其它实例就必须处理更多的请求。其它的实例也会由于请求数过多以及线程池大小没有限制的缘由产生资源枯竭等问题。这些实例用尽了内存资源,致使虚拟机进行频繁地内存收集操做。这样的恶性循环会在这些实例中产生,直到整个系统奔溃。

我见过许多没有进行负载测试的应用,这些应用可以建立任意多的线程。一般状况下,这些应用只要不多数量的线程就能处理好以必定速率到达的请求。可是,若是应用须要使用其它的一些远程服务来处理用户请求,而这个远程服务的处理能力忽然下降了,这将增大W的值(应用处理请求的平均时间)。这样,线程池的线程就会被迅速用尽。若是对应用进行线程数量的负载测试,那么资源枯竭问题就会在测试中显现出来。

多少个线程池合适?

对于微服务架构面向服务的架构(SOA)来讲,它们一般须要请求一些后端服务。线程池的配置很是容易致使程序失败,所以必须谨慎地配置线程池。若是远程服务的性能降低,系统中的线程数量就会迅速达到线程池的上限,其它后续到达的服务就会被丢弃。这些后续的请求也许并非要使用性能出现故障的服务,可是它们都只能被丢弃了。

针对不一样的后端服务请求,设置不一样的线程池能够解决这一问题。在这个模式中,仍然使用同一个线程池来处理用户的请求,可是当用户的请求须要调用一个远程服务时,这个任务就被传递给一个指定的后端线程池。这样处理用户请求的主线程池就不会由于调用后端服务而产生很大的负担。当后端服务出现故障时,只有调用这个服务的线程池才会受到影响。

使用多个线程池还有一个好处,就是它能帮助避免出现死锁问题。若是每一个空闲线程都由于一个还没有处理完毕的请求阻塞,就会发生死锁,没有一个线程能够继续往下执行。若是使用多个线程池,理解好每一个线程池应负责的工做,那么死锁的问题就能在必定程度上避免。

截止时间和一些最佳实践

一个最佳实践是给须要远程调用的请求规定一个截止时间。若是远程服务在规定的时间内没有响应,就丢弃这个请求。这样的技术也能够用在线程池中,若是线程处理某个请求的时间超过了规定时间,那么这个线程就应被中止,为新到达的请求腾出资源,这样也就给W(处理请求的平均时间)规定了上限。虽然这样的作法看起来有些浪费,可是若是一个用户(特别是当用户在使用浏览器时),在等待请求的响应,那么30秒之后,浏览器不管如何也会放弃这个请求,或者更有可能的是:用户不会耐心地等待这个请求响应,而是进行其它操做去了。

快速失败也是一个能够用来处理后端请求的线程池方案。若是后端服务失效了,线程池中的线程数会迅速到达上限,这些线程都在等待没有响应的后端服务。若是使用快速失败机制,当后端服务被标记为失效时,全部的后续请求都会迅速失败,而不是进行没必要要的等待。固然,它也须要一种机制来判断后端什么时候恢复为可用的。

最后,若是一个请求须要独立地调用多个后端服务,那么这个请求就应能并行地调用这些后端服务,而不是顺序地进行。这样就能下降请求的等待时间,但这是以增长线程数为代价的。

幸运的是,有一个很是好的库hystrix,这个库封装了许多很好的线程策略,而后以很是简单和友好的方式将这些接口暴露出来。

总结

我但愿这篇文章能改进你对线程池的理解。一个合适的线程池配置须要理解应用的需求,还须要考虑这几个因素,系统容许的最大线程数、处理用户请求所需的时间。好的线程池配置不只能够避免系统出现连锁故障,还能帮助计划和提供服务。

即便你的应用没有直接地使用一个线程池,它们也间接地经过应用服务器或其它更高级的抽象形式使用了线程池。TomcatJBossUndertowDropwizard 都提供了多种可配置的线程池(这些线程池正是你编写的Servlet运行的地方)。

原文连接: blog.bramp.net 翻译: ImportNew.com - justyoung
译文连接: http://www.importnew.com/1763...[ 转载请保留原文出处、译者和译文连接。]

相关文章
相关标签/搜索