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

引发的问题

  • 线程的生命周期开销非常高
  • 消耗过多的CPU资源

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

降低稳定性

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

调优策略

1、合理使用线程池

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

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

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

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

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

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

Java 一个线程占多少内存 java线程太多_多线程


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

1.2 设置最小(核心)线程数

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

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

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

1.3 设置额外线程存活时间

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

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

1.4 选择线程池队列
  • SynchronousQueue

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

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

  • 无界队列

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

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

  • 有界队列

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

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

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

1.5 选择合适的饱和策略

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

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

JDK提供了几种不同的RejectedExecutionHandler的饱和策略实现:

  1. AbortPolicy(中止)
  • 该策略是默认的饱和策略;
  • 会抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码;
  1. DiscardPolicy(抛弃)
  • 当提交的任务无法保存到队列中等待执行时,Discard策略会悄悄抛弃该任务。
  1. DiscardOldestPolicy(抛弃最旧)
  • 会抛弃下一个将被执行的任务,然后尝试重新提交的新任务。
  • 如果工作队列是一个优先队列,那么抛弃最旧的策略,可能会抛弃优先级最高的任务,因此最好不要将 抛弃最旧的饱和策略和优先级队列 放在一起使用。
  1. 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();
        }
    }
}
2、避免同步
2.1 使用线程局部变量ThreadLocal

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

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

private static ThreadLocal<Connection> connectionHolder = 
       ThreadLocal.withInitial(() -> DriverManager.getConnecton(DB_URL));

public static Connection getConnection(){
   return connectionHolder.get();
}
2.2 使用基于CAS的替代方案

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

  1. 如果访问的是不存在竞争的资源,那么基于CAS的保护稍快于传统同步(完全不保护会更快);
  2. 如果访问的资源存在轻度或适度的竞争,那么基于CAS的保护要快于传统同步(往往是块的多);
  3. 如果访问的资源竞争特别激烈,这时,传统的同步是更好的选择。
    这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量。类似于在生产者-消费者模式中,可阻塞生产者,它能降低消费者上的工作负载,使消费者的处理速度赶上生产者的处理速度。
3、减少锁竞争

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

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

  • 如果两者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成影响。
  • 如果在锁上的请求量非常高,那么需要获取该锁的线程将被阻塞并等待。
    因此,有3种方式可以降低锁的竞争程度:
3.1 减少锁的持有时间——主要通过缩小锁的范围,快进快出
  • 将一个与锁无关的操作移除同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作。
  • 通过将线程安全性委托给其他线程安全类来进一步提升它的性能。这样就无需使用显式的同步,缩小了锁范围,并降低了将来代码维护无意破坏线程安全性的风险。
  • 尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作必须包含在同一个块中。同步还需要一定的开销,把一个同步代码块分解为多个同步代码块时,反而会对性能产生负面影响。
3.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);
     }
 }
}
  • 在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。

在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;
         }
     }
 }
}

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

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

避免热点区域

在常见的优化措施中,就是将一个反复计算的结果缓存起来,都会引入一些热点区域,而这些热点区域往往会限制可伸缩性。在容器类中,为了获得容器的元素数量,使用了一个共享的计数器来统计size。

在单线程或者采用完全同步的实现中,使用一个独立的计数器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升的可伸缩性,因此每个修改map的操作都要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。

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

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

  1. ReadWriteLock:实现了一种在多个读取操作以及单个写入操作情况下的加锁规则。
    如果多个读取操作都不会修改共享资源,那么这些读操作可以同时访问该共享资源,但是执行写入操作时必须以独占方式来获取锁。
    对于读取占多数的数据结构,ReadWriteLock能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变形可以完全不需要加锁操作。
  2. 原子变量:提供了一种方式来降低更新热点域时的开销。
    静态计数器、序列发生器、或者对链表数据结构中头结点的引用。如果在类中只包含了少量的共享状态,并且这些共享状态不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能够提高可伸缩性。
4、使用偏向锁

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

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

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

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

5、使用自旋锁

自旋锁是采用让当前线程不停地的在循环体内执行实现的,当循环的条件被其他线程改变时才能进入临界区。

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

  • 可以让当前线程进入忙循环,执行一些指令,然后再次检查这个锁;
  • 也可以把这个线程放入一个队列挂起(使得CPU供其他线程可用),在锁可用时通知他。
    如果多个线程竞争的锁被持有时间短,那么自旋锁就是比较好的方案。如果锁被持有时间长,那么让第二个线程等待通知会更好。

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

6、正确使用并发运行与串行执行

下面列举一个例子说明:
有这样一段代码,根据传递的url列表,并发的去下载url对于的文件内容,原来代码模拟如下:

public class ThreadTest {
    private static final ThreadPoolExecutor EXECUTOR_SERVICE = new ThreadPoolExecutor(8,8,0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>(1));
    public static void main(String[] args) {
        // 1.创建图片列表
        List<String> imageList = new ArrayList<String>();
        for (int i = 0; i < 3; ++i) {
            imageList.add(i + "");
        }

        long start = System.currentTimeMillis();

        // 2.并发处理url
        Map<String, String> resultMap = imageList.parallelStream().collect(Collectors.toMap(url ->  url, url -> {
            try {
                EXECUTOR_SERVICE.submit(() -> {
                    // 2.1模拟同步处理url,并返回结果
                    System.out.println(Thread.currentThread().getName() + " " + url);
                    Thread.sleep(30000);
                    return "" + url;
                }).get();
            } catch (Exception e) {
                e.printStackTrace();
            }
            return "";
        }));

        // 3.打印结果
        long costs = System.currentTimeMillis() - start;
        System.out.println("result:" + costs + " " + JSON.toJSONString(resultMap));
        System.out.println("main is over");
    }
}

如上代码0创建了一个线程个数为8的线程池:

  • 代码1创建了一个图片列表
  • 代码2意为使用并行流把图片url下载任务投递到线程池EXECUTOR_SERVICE后,通过流的collect方法,把url和url处理结果收集起来变成map返回。
  • 代码2.1我们模拟同步根据url下载文件,并返回处理结果。
  • 代码3则使用main线程打印整个处理耗时和处理结果。

这里会导致多个commonPool中的线程处于阻塞状态等待异步任务执行完毕。这里假设imageList中有3个URL,则我们会有3个线程(一个main函数所在调用线程,2个commonpool中的线程)分别把下载图片任务投递到executorService,然后这3个线程各自调用了返回的future的get系列方法等待上传任务的完成,所以这会导致commonPool内的2个线程和调用线程被阻塞。

这里为了等待投递到线程池EXECUTOR_SERVICE中的三个任务执行完毕,耗费了三个线程。其实可以把上面代码改造为如下:

private static final ThreadPoolExecutor EXECUTOR_SERVICE = new ThreadPoolExecutor(8,8,0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>(1));
    public static void main(String[] args) {
        // 1.创建图片列表
        List<String> imageList = new ArrayList<String>();
        for (int i = 0; i < 3; ++i) {
            imageList.add(i + "");
        }

        long start = System.currentTimeMillis();

        // 2.并发执行,并保持url对应的future
        Map<String, Future<String>> futureMap = imageList.stream().collect(Collectors.toMap(url -> url, url -> {
            return EXECUTOR_SERVICE.submit(() -> {
                // 2.1模拟rpc同步处理url,并返回结果
                System.out.println(Thread.currentThread().getName() + " " + url);
                Thread.sleep(300000);
                return "" + url;
            });
        }));

        // 3.调用线程同步等待所有任务执行完毕
        Map<String, String> resultMap = futureMap.entrySet().stream()
                .collect(Collectors.toMap(entry -> entry.getKey(), entry -> {
            try {
                entry.getValue().get();
            } catch (Exception e) {
                e.printStackTrace();
            }
            return "";
        }));

        // 3.打印结果
        long costs = System.currentTimeMillis() - start;
        System.out.println("result:" + costs + " " + JSON.toJSONString(resultMap));
        System.out.println("main is over");
    }
}
  • 如上代码2我们把图片下载任务投递到了线程池EXECUTOR_SERVICE后调用线程马上返回了对于的Future对象,然后我们通过流的collect方法把url和对应的future对象收集到了futureMap中,这个过程是异步的,不会阻塞调用线程。
  • 代码3 main线程则循环获取每个future的执行结果,并且通过流的collect方法把url和对应的future执行结果收集到map.

运行上面代码,我们会发现除了有EXECUTOR_SERVICE中的三个线程在执行文件下载任务外,只有一个main线程在等待全部任务执行完毕,相比原来方法节省了2个commonPool里面的线程。

总结:并发固然好,但是用不对则会起到副作用,本例中原来方法如果url列表很大,则会导致commonpool里面的线程打满,则当前jvm内其它使用commonpool的地方则会自动转换为调用线程来执行了,会起不到预设的效果。