分享

Java多线程引发的性能问题以及调优策略

 LZS2851 2018-07-10



无限制创建线程

Web服务器中,在正常负载情况下,为每个任务分配一个线程,能够提升串行执行条件下的性能。只要请求的到达率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性更高的吞吐率。如果请求的到达速率非常高,且请求的处理过程是轻量级的,那么为每个请求创建一个新线程将消耗大量的计算资源。

引发的问题

  1. 线程的生命周期开销非常高

  2. 消耗过多的CPU资源

    如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争CPU资源时还将产生其他性能的开销。

  3. 降低稳定性

    JVM在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括JVM的启动参数、Thread构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出OutOfMemoryError异常。

调优策略

可以使用线程池,是指管理一组同构工作线程的资源池。

线程池的本质就是:有一个队列,任务会被提交到这个队列中。一定数量的线程会从该队列中取出任务,然后执行。任务的结果可以发回客户端、可以写入数据库、也可以存储到内部数据结构中,等等。但是任务执行完成后,这个线程会返回任务队列,检索另一个任务并执行。

使用线程池可以带来以下的好处:

  1. 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销
  2. 当请求到达时,工作线程已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性
  3. 通过适当调整线程池大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败

线程同步

引发的问题

降低可伸缩性

在有些问题中,如果可用资源越多,那么问题的解决速度就越快。如果使用多线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能够有效地使用这种潜在的并行能力

不过大多数的并发程序都是由一系列的并行工作串行工作组成的。因此Amdhl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速度比,这个值取决于程序中可并行组件(1-F)串行组件(F)所占的比重。

Speedup1F+1FN

  • 当N趋近于无穷大时,最大的加速度比趋近于1/F
    • 如果程序有50%的计算资源需要串行执行,那么最高的加速度比是能是2(而不管有多少个线程可用)。
    • 如果在程序中有10%的计算需要串行执行,那么最高的加速度比将接近10。
  • 如果程序中有10%的部分需要串行执行
    • 在拥有10个处理器的系统中,那么最高的加速度比为5.3(53%的使用率);
    • 在拥有100个处理器的系统中,加速度比可以达到9.2(9%的使用率);

因此,随着F值的增大(也就是说有更多的代码是串行执行的),那么引入多线程带来的优势也随之降低。所以也说明了限制串行块的代码量非常重要。

上下文切换开销

如果主线程是唯一的线程,那么它基本上不会被调度出去。如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,这个过程将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文

那么在上下文切换的时候将导致以下的开销

  1. 在线程调度过程中需要访问由操作系统和JVM共享的数据结构
  2. 应用程序、操作系统以及JVM都使用一组相同的CPU,在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越来越少。
  3. 当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。

这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在执行——它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它将无法获得完整的调度时间片。在程序中发生越来越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此降低吞吐量(无阻塞算法同样有助于减少上下文切换)。

内存同步开销

  1. 内存栅栏间接带来的影响

    synchronizedvolatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier),内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道

    内存栅栏可能同样会对性能带来间接的影响,因为他们将抑制一些编译器优化操作。并且在内存栅栏中,大多数操作都是不能被重排序的。

  2. 竞争产生的同步可能需要操作系统的介入,从而增加开销

    在锁上发生竞争的时候,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功),或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待的方式,而如果等待时间较长,则适合采用线程挂起方式。

    某个线程中的同步可能会影响其他线程的性能,同步会增加内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用同步的线程都会受到影响。

  3. 无竞争的同步带来的开销可忽略

    synchronized机制针对无竞争的同步进行了优化,去掉一些不会发生竞争的锁,从而减少不必要的同步开销。所以,不要担心非竞争同步带来的开销,这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。

    • 如果一个对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作

    • 一些完备的JVM能通过逸出分析来找出不会发布到堆的本地对象引用(这些引用是线程本地的)

      getStoogeNames()的执行过程中,至少会将Vector上的锁获取释放4次,每次调用add或toString时都会执行一次。然而,一个智能的运行时编译器通常会分析这些调用,从而使stooges及其内部状态不会逸出,因此可以去掉这4次对锁的获取操作。

      public String getStoogeNames(){
       List<String> stooges = new Vector<>();
       stooges.add("Moe");
       stooges.add("Larry");
       stooges.add("Curly");
       return stooges.toString();
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    • 即使不进行逸出分析,编译器也可以执行锁粒度粗化操作,将临近的同步代码块用同一个锁合并起来。在getStoogeNames中,如果JVM进行锁粒度粗化,那么可能会把3个add和1个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销,同时还能使优化处理更大的代码块,从而可能实现进一步的优化。

调优策略

避免同步

  1. 使用线程局部变量ThreadLocal

    ThreadLocal类能够使线程的某个值保存该值的线程对象关联起来。ThreadLocal提供了getset等方法,这些方法使每个使用该变量的线程都存有一个独立的副本,因此get总是返回由当前执行线程在调用set设置的最新值

    当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

    private static ThreadLocal<Connection> connectionHolder = 
           ThreadLocal.withInitial(() -> DriverManager.getConnecton(DB_URL));
    
    public static Connection getConnection(){
       return connectionHolder.get();
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
  2. 使用基于CAS的替代方案

    在某种意义上,这不是避免同步,而是减少同步带来的性能损失。通常情况下,在基于比较的CAS和传统的同步时,有以下使用原则:

    • 如果访问的是不存在竞争的资源,那么基于CAS的保护稍快于传统同步(完全不保护会更快);

    • 如果访问的资源存在轻度或适度的竞争,那么基于CAS的保护要快于传统同步(往往是块的多);

    • 如果访问的资源竞争特别激烈,这时,传统的同步是更好的选择。

      对于该结论可以这么理解,在其他领域依然成立:当交通拥堵时,交通信号灯能够实现更高的吞吐量,而在低拥堵时,环岛能实现更高的吞吐量。这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量。类似于在生产者-消费者模式中,可阻塞生产者,它能降低消费者上的工作负载,使消费者的处理速度赶上生产者的处理速度。

减少锁竞争

串行操作会降低可伸缩性,在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。在锁上竞争时,将同时导致可伸缩性和上下文切换问题,因此减少锁的竞争能够提高性能和可伸缩性。

在锁上发生竞争的可能性主要由两个因素影响:锁的请求频率每次持有该锁的时间

  • 如果两者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成影响。
  • 如果在锁上的请求量非常高,那么需要获取该锁的线程将被阻塞并等待。

因此,有3种方式可以降低锁的竞争程度:

  1. 减少锁的持有时间——主要通过缩小锁的范围,快进快出

    • 将一个与锁无关的操作移除同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作。
    • 通过将线程安全性委托给其他线程安全类来进一步提升它的性能。这样就无需使用显式的同步,缩小了锁范围,并降低了将来代码维护无意破坏线程安全性的风险。
    • 尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作必须包含在同一个块中。同步还需要一定的开销,把一个同步代码块分解为多个同步代码块时,反而会对性能产生负面影响。
  2. 降低锁的请求频率

    通过锁分解锁分段等技术来实现,将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。也就是说,如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。然而,使用的锁越多,那么发生死锁的风险也就越高。

    • 如果在锁上存在适中而不是激烈的竞争,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

      public class ServerStatus {
       private Set<String> users;
       private Set<String> queries;
      
       public synchronized void addUser(String u) {
           users.add(u);
       }
      
       public synchronized void addQuery(String u) {
           queries.add(u);
       }
      
       public synchronized void removeUser(String u) {
           users.remove(u);
       }
      
       public synchronized void removeQuery(String q) {
           queries.remove(q);
       }
      }
      // 使用锁分解技术
      public class ServerStatus {
       private Set<String> users;
       private Set<String> queries;
      
       public void addUser(String u) {
           synchronized (users) {
               users.add(u);
           }
       }
      
       public void addQuery(String u) {
           synchronized (queries) {
               queries.add(u);
           }
       }
      
       public void removeUser(String u) {
           synchronized (users) {
               users.remove(u);
           }
       }
      
       public void removeQuery(String q) {
           synchronized (queries) {
               queries.remove(q);
           }
       }
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
    • 在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段

      在ConcurrentHashMap的实现中,使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列通有第(N mod 16N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。

      public class StripedMap {
       private static final int N_LOCKS = 16;
       private final Node[] buckets;
       private final Object[] locks;
      
       static class Node<K, V> {
           final int hash;
           final K key;
           V value;
           Node<K, V> next;
      
           public Node(int hash, K key) {
               this.hash = hash;
               this.key = key;
           }
       }
      
       public StripedMap(int capacity) {
           this.buckets = new Node[capacity];
           this.locks = new Object[N_LOCKS];
           for (int i = 0; i < N_LOCKS; i++) {
               locks[i] = new Object();
           }
       }
      
       private final int hash(Object key) {
           return Math.abs(key.hashCode() % buckets.length);
       }
      
       public Object get(Object key) {
           int hash = hash(key);
           synchronized (locks[hash % N_LOCKS]) {
               for (Node n = buckets[hash]; n != null; n = n.next) {
                   if (n.key.equals(key)) {
                       return n.value;
                   }
               }
           }
           return null;
       }
      
       public void clear() {
           for (int i = 0; i < buckets.length; i++) {
               synchronized (locks[i % N_LOCKS]) {
                   buckets[i] = null;
               }
           }
       }
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49

      锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有锁。

    锁分解和锁分段技术都能提高可伸缩性,因为他们都能使不同的线程在不同的数据(或者同一数据的不同部分)上操作,而不会相互干扰。如果程序使用锁分段技术,一定要表现在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。

  3. 避免热点区域

    在常见的优化措施中,就是将一个反复计算的结果缓存起来,都会引入一些热点区域,而这些热点区域往往会限制可伸缩性。在容器类中,为了获得容器的元素数量,使用了一个共享的计数器来统计size。在单线程或者采用完全同步的实现中,使用一个独立的计数器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升的可伸缩性,因此每个修改map的操作都要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。

    为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举,并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个计数,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。

  4. 放弃使用独占锁,使用一种友好并发的方式来管理共享状态

    • ReadWriteLock:实现了一种在多个读取操作以及单个写入操作情况下的加锁规则。

      如果多个读取操作都不会修改共享资源,那么这些读操作可以同时访问该共享资源,但是执行写入操作时必须以独占方式来获取锁。

      对于读取占多数的数据结构,ReadWriteLock能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变形可以完全不需要加锁操作。

    • 原子变量:提供了一种方式来降低更新热点域时的开销。

      静态计数器、序列发生器、或者对链表数据结构中头结点的引用。如果在类中只包含了少量的共享状态,并且这些共享状态不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能够提高可伸缩性。

使用偏向锁

当锁被争用时,JVM可以选择如何分配锁。

  • 锁可以被公平地授予,每个线程以轮转调度方式获得锁;
  • 还有一种方案,即锁可以偏向于对它访问最为频繁的线程

偏向锁的理论依据是,如果一个线程最近用到了某个锁,那么线程下一次执行由同一把锁保护的代码所需的数据可能仍然保存在处理器的缓存中。如果给这个线程优先获得锁的权利,那么缓存命中率就会增加(支持老用户,避免新用户相关的开销)。那么性能就会有所改进,因为避免了新线程在当前处理器创建新的缓存的开销。

但是,如果使用的编程模型是为了不同的线程池由同等机会争用锁,那么禁用偏向锁-XX:-UseBiasedLocking会改进性能。

使用自旋锁

在处理同步锁竞争时,JVM有两种选择。

  • 可以让当前线程进入忙循环,执行一些指令,然后再次检查这个锁;
  • 也可以把这个线程放入一个队列挂起(使得CPU供其他线程可用),在锁可用时通知他。

如果多个线程竞争的锁被持有时间短,那么自旋锁就是比较好的方案。如果锁被持有时间长,那么让第二个线程等待通知会更好。

如果想影响JVM处理自旋锁的方式,唯一合理的方式就是让同步块尽可能的短。

伪共享

引发的问题

在同步可能带来的影响方面,就是伪共享,它的出现跟CPU处理其高速缓存的方式有关。下面举一个极端的例子,有一个DataHolder的类:

public class DataHolder{
  public volatile long l1;
  public volatile long l2;
  public volatile long l3;
  public volatile long l4;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这里的每个long值都保存在毗邻的内存位置。例如,l1可能保存在0xF20位置,l2就会保存在0xF28位置,剩余的以此类推。当程序要操作l2时,会有一大块的内存(包括l2前后)被加载到当前所用的某个CPU核的缓存行(cache line)上。

大多数情况下,这么做是有意义的:如果程序访问了对象的某个特定实例,那么也可能访问邻接的实例变量。如果这些实例变量被加载到当前核的高速缓存中,那么内存访问就会特别快。

那么这种模式的缺点就是:当程序更新本地缓存中的某个值时,当前线程所在的核必须通知其他的所有核——这个内存被修改了。其他核必须作废其缓存行(cache line),并重新从内存中加载。那么随着线程数的增多,对volatile的操作越来越频繁,那么性能会逐渐降低。

Java内存模型要求数据只是在同步原语(包括CAS和volatile构造)结束时必须写入主内存。严格来讲,伪共享不一定会涉及同步(volatile)变量,如果long变量不是volatile,那么编译器会将这些值放到寄存器中,这样性能影响并没有那么大。然而不论何时,CPU缓存中有任何数据被写入,其他保存了同样范围数据的缓存都必须作废

调优策略

很明显这是个极端的例子,但是提出了一个问题,如何检测并纠正伪共享?目前还不能解决伪共享,因为涉及处理器架构相关的专业知识,但是可以从代码入手:

  1. 避免所涉及的变量频繁的写入

    可以使用局部变量代替,只有最终结果才写回到volatile变量。随着写入次数的减少,对缓存行的竞争就会降低。

  2. 填充相关变量,避免其被加载到相同的缓存行中。

    public class DataHolder{
     public volatile long l1;
     public long[] dummy1 = new long[128/8];
     public volatile long l2;
     public long[] dummy2 = new long[128/8];
     public volatile long l3;
     public long[] dummy3 = new long[128/8];
     public volatile long l4; 
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    使用数组来填充变量或许行不通,因为JVM可能会重新安排实例变量的布局,以便使得所有数组挨在一起,于是所有的long变量就仍然紧挨着了。

    如果使用基本类型的值来填充该结构,行之有效的可能性大,但是对于变量的数目不好把控。

    另外,对于填充的大小也很难预测,因为不同的CPU缓存大小也不同,而且填充会增大实例,对垃圾收集影响很大。

    不过,如果没有算法上的改进方案,填充数据有时会具有明显的优势。

线程池

引发的问题

线程饥饿死锁

只要线程池中的任务需要无限期地等待一些必须由池中其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁

因此,每当提交了一个有依赖的Executor任务时,要清楚地知道可能会出现线程饥饿死锁,因此需要在代码或配置Executor的配置文件中记录线程池的大小或配置限制。

如果任务阻塞的时间过长,那即使不出现死锁,线程池的响应性也会变得糟糕。执行时间过长的任务不仅会造成线程池阻塞,甚至还会增加执行时间较短任务的服务时间。

线程池过大对性能有不利的影响

实现线程池有一个非常关键的因素:调节线程池的大小对获得最好的性能至关重要。线程池可以设置最大和最小线程数,池中会有最小线程数目的线程随时待命,如果任务量增长,可以往池中增加线程,最大线程数可以作为线程数的上限,防止运行太多线程反而造成性能的降低。

调优策略

设置最大线程数

线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。同时,设置线程池的大小需要避免“过大”和“过小”这两种极端情况。

  • 如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源。
  • 如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。

因此,要想正确地设置线程池的大小,必须分析计算环境资源预算任务的特性。在部署的系统中有都少个CPU?多大的内存?任务是计算密集型、I/O密集型还是二者皆可?他们是否需要像JDBC连接这样的稀缺资源?如果需要执行不同类别的任务,并且他们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程可以根据各自的工作负载来调整。

要是处理器达到期望的使用率,线程池的最优大小等于:

Nthreads=NcpuUcpu(1+WC)

  • Ncpu:表示处理器数量,可以通过Runtime.getRuntime().avaliableProcessors()获得;
  • Ucpu:CPU的使用率,0Ucpu1
  • WC:等待时间与计算时间的比值;

另外,CPU周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。通过计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果解释线程池大小的上限。

设置最小(核心)线程数

可以将线程数设置为其他某个值,比如1。出发点是防止系统创建太多线程,以节省系统资源。

另外,所设置的系统大小应该能够处理预期的最大吞吐量,而要达到最大吞吐量,系统将需要按照所设置的最大线程数启动所有线程。

另外,指定一个最小线程数的负面影响非常小,即使第一次就有很多任务运行,不过这种一次性成本负面影响不大。

设置额外线程存活时间

当线程数大于核心线程数时,多余空闲线程在终止前等待新任务的最大存活时间。

一般而言,一个新线程一旦创建出来,至少应该留存几分钟,以处理任何负载飙升。如果任务达到率有比较好的模型,可以基于这个模型设置空闲时间。另外,空闲时间应该以分钟计,而且至少在10分钟到30分钟之间。

选择线程池队列

  1. SynchronousQueue

    SynchronousQueue不是一个真正的队列,没法保存任务,它是一种在线程之间进行移交的机制。如果要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素。如果没有线程等待,所有线程都在忙碌,并且池中的线程数尚未达到最大,那么ThreadPoolExecutor将创建一个新的线程。否则根据饱和策略,这个任务将被拒绝。

    使用直接移交将更高效,只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际的价值。在newCachedThreadPool工厂方法中就是用了SynchronousQueue

  2. 无界队列

    如果ThreadPoolExecutor使用的是无界队列,则不会拒绝任何任务。这种情况下,ThreadPoolExecutor最多仅会按最小线程数创建线程,最大线程数被忽略。

    如果最大线程数和最小线程数相同,则这种选择和配置了固定线程数的传统线程池运行机制最为接近,newFixedThreadPoolnewSingleThreadExecutor在默认情况下就是使用的一个无界的LinkedBlockingQueue

  3. 有界队列

    一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的LinkedBlockingQueuePriorityBlockingQueue

    在有界队列填满之前,最多运行的线程数为设置的核心线程数(最小线程数)。如果队列已满,而又有新任务加进来,并且没有达到最大线程数限制,则会为当前新任务启动一个新线程。如果达到了最大线程数限制,则会根据饱和策略来进行处理。

    一般的,如果线程池较小而队列较大,那么有助于减少内存的使用量,降低CPU的使用率,同时还可以减少上下文切换,但付出的代价是会限制吞吐量。

选择合适的饱和策略

当有界队列被填满后,饱和策略将发挥作用,ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。如果某个任务被提交到一个已关闭的Executor,也会用到饱和策略。JDK提供了几种不同的RejectedExecutionHandler的饱和策略实现:

  1. AbortPolicy(中止)
    • 该策略是默认的饱和策略;
    • 会抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码;
  2. DiscardPolicy(抛弃)
    • 当提交的任务无法保存到队列中等待执行时,Discard策略会悄悄抛弃该任务。
  3. DiscardOldestPolicy(抛弃最旧)
    • 会抛弃下一个将被执行的任务,然后尝试重新提交的新任务。
    • 如果工作队列是一个优先队列,那么抛弃最旧的策略,会抛弃优先级最高的任务,因此最好不要将抛弃最旧的饱和策略和优先级队列放在一起使用。
  4. CallerRunsPolicy(调用者运行)
    • 该策略既不会抛弃任务,也不会抛出异常,而是当线程池中的所有线程都被占用后,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行,从而降低新任务的流量。由于执行任务需要一定的时间,因此主线程至少在一定的时间内不能提交任何任务,从而使得工作者线程有时间来处理正在执行的任务。
    • 另一方面,在这期间,主线程不会调用accept,那么到达的请求将被保存在TCP层的队列中而不是在应用程序的队列中。如果持续过载,那么TCP层将最终发现他的请求队列被填满,因此同样会开始抛弃请求。
    • 当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池工作队列应用程序再到TCP层,最终到达客户端,导致服务器在高负载的情况下实现一种平缓的性能降低。

当工作队列被填满后,并没有预定的饱和策略来阻塞execute。因此,可以通过信号量Semaphore来限制任务的到达速率,就可以实现该功能。

public class BoundedExecutor {

    private final Executor executor;
    private final Semaphore semaphore;

    public BoundedExecutor(Executor executor, int bound) {
        this.executor = executor;
        this.semaphore = new Semaphore(bound);
    }

    public void submitTask(final Runnable command) throws InterruptedException {
        semaphore.acquire();
        try {
            executor.execute(command::run);
        } catch (RejectedExecutionException e) {
            semaphore.release();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

选择合适的线程池

  1. newCachedThreadPool工厂方法是一种很好的默认选择,它能够提供比固定大小的线程池更好的排队性能;
  2. 当需要限制当前任务的数量以满足资源管理器需求时,那么可以选择固定大小的线程池,例如在接受网络请求的服务器程序中,如果不进行限制,那么很容易导致过载问题。
  3. 只有当任务相互独立,为线程池设置界限才合理;如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程饥饿死锁问题,那么此时应该使用无界的线程池。
  4. 对于提交任务并等待其结果的任务来说,还有一种配置方法,就是使用有界的线程池,并使用SynchronousQueue作为工作队列,以及调用者运行饱和策略。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多