자바 동시성 : 스레드와 동기화 성능 - 스레드 풀

스레드 풀 및 ThreadPoolExecutors

당신은 나사 나사 종류의 작업에 직접 프로그램에 사용하지만, 더 자주 일반적으로 클라이언트의 요청을 처리하기 위해 여러 개의 스레드 풀을 사용하여, 특히 자바 EE 애플리케이션 서버, 스레드 풀을 사용할 수 있지만. ThreadPoolExecutor입니다에서 스레드 풀 자바를 지원합니다. 일부 응용 프로그램 서버는 실제로 ThreadPoolExecutor입니다 스레드 풀을 구현하는 데 사용됩니다.

성능 조정 스레드 풀의 경우, 가장 중요한 매개 변수는 스레드 풀의 크기입니다.

어떤 스레드 풀, 그들은 거의 항상 같은 일 :

  1. 작업이 큐 (큐 무기한 수량)에 넣고
  2. 큐에서 만들어진 스레드 작업을 실행할
  3. 스레드가 작업을 완료 한 후, 작업은 큐에서 얻으려고 계속 큐가 비어있는 경우, 스레드는 대기 상태로 전환

스레드 풀 스레드의 최소 및 최대 값을 갖는 경향이있다 :

  1. 작업 큐가 비어있을 때 스레드의 최소 수는, 즉, 작업의 관점에서 그렇게하는 데 필요한 최소를 유지하기 위해 스레드 풀의 스레드 수는 스레드가 새 작업이 대기열에 넣을 때 상대적으로 자원 집약적, 가능한 한 피해야한다이다 생성 항상있을 때 스레드는 즉시 처리 할 수 ​​있습니다.
  2. 스레드 스레드의 최대 수의 최대 수는, 너무 많은 작업을 처리 할 때, 스레드 풀 수 있습니다. 스레드 실행이 너무 많은 스레드, 그것은 성능이 저하됩니다 CPU 자원과 다른 자원에 의존 할 필요가 있기 때문에, 과도한 스레드가 생성되지 않도록하는 것입니다.

ThreadPoolExecutor입니다 및 관련 유형에서, 스레드 풀에서 스레드의 최소 수는 다른 Java 응용 프로그램 서버의 실현에,이 숫자는 최소 수 (MinThreads) 스레드를 호출 할 수있는 핵심 (코어 풀 크기)의 크기를라고, 그러나 그들의된다 개념은 동일합니다.

하지만 스레드 풀의 크기가 변경이 가능한 ThreadPoolExecutor,의 (크기 조정) 때 다른 스레드 풀 어쩌면 매우 다른 존재를 구현한다.

새 작업을 수행 할 필요가있을 때, 모든 스레드가 점유하고있는 전류는 ThreadPoolExecutor입니다 및 다른 구현은 일반적으로 (스레드의 최대 수까지)이 새 작업을 수행 할 수있는 새로운 스레드를 만들 : 가장 간단한 경우 중 하나입니다.

스레드의 최대 수를 설정합니다

다음과 같은 두 가지 측면에 의존하는 방법, 최대 스레드 수 있는지를 결정하는 가장 적합한 :

  1. 특성 작업
  2. 컴퓨터 하드웨어 케이스

논의의 편의를 위해 다음과 같은 네 가지 가능한 JVM의 CPU가 있음을 가정한다. 그래서 작업, 그것은 "압박"자원을 극대화 CPU 활용도를 향상하기 위해 가능한 모든 방법을 동원 할 매우 분명하다.

네 가지 가능한 CPU가 있으므로 그래서 스레드의 최대 수는 네 개의 병렬 작업까지 수행 할 수있는 수단 (4)의 최소로 설정된다. 물론, 가비지 컬렉션 (쓰레기 수거)이 과정에서 일부 영향을 미칠 것이다, 그러나 그들은 종종 전체 CPU를 사용할 필요가 없습니다. CMS를 사용하거나 G1 가비지 컬렉션 알고리즘은, 우리가 쓰레기 수거를 위해 충분한 CPU 리소스를해야하는 경우는 예외입니다.

이 스레드의 많은 수의 그 의지를 설정할 필요가 있습니까? 이 작업의 특성에 따라 달라집니다.

이 작업이 때 연산 집약적 인 작업이 IO 작업이 같은 데이터베이스를 읽기로 등 파일을 읽어 볼 것을 의미하지 않는다 가정, 따라서 그들은 완전히 독립적 인 작업 사이의 동기화의 문제를 포함하지 않습니다. 배치 프로그램을 사용하여 데이터 모의 데이터 소스를 읽고 같은 스레드 풀에서 다른 스레드 수의 성능 시험은 다음 표를 제공하지 않는 것입니다있다 :

위의 일부 결론 :

  1. 스레드의 수가 4, 최적의 성능을 때 CPU 사용률은 스레드 동작 사이의 자원에 대한 경쟁을 증가 CPU 스레드에서 가장 높은 증가를 도달하기 때문에, 스레드의 수와 더 나은 성능을 증가 따라서하지만 성능이 저하됩니다.
  2. CPU 사용률이 가장 높은 기준 비율이 25 % 적합하지 않습니다에 도달 할 경우에도 프로그램이뿐만 아니라 실 전용 응용 프로그램, CPU 자원을 실행하지만 때로는 배경 스레드의 일부가 CPU를 필요로하기 때문에,이입니다 같은 스레드와 GC 시스템과 같은 일부 스레드 및 기타 자원.

계산은 서블릿에 의해 트리거되는 경우, 다음과 같이 성능 데이터 (발전기 (20)가 동시에 요청을 전송 하중) :

위의 표에서는 결론을 내릴 수있다 :

  1. 스레드의 수는 4, 최적의 성능이다. 이러한 유형의 작업은 연산 집약적 만 4 CPU, 그래서 스레드의 수는 4, 최적의 상황이기 때문입니다.
  2. 스레드 성능 저하의 수가 점차 증가 각각 빈번한 스위칭 스레드 실행 콘텍스트 결과 스레드간에 CPU 리소스에 대해 경쟁하고, 이들 스위치만이 폐기 CPU 자원 때문이다.
  3. 감소의 성능 비율은 컴퓨팅 자원이 아닌 성능 병목 현상이 CPU에 의해 제공되는 경우 작업 술의 계산 집약적 인 타입이기 때문에 이것이 분명 아니지만, 등 데이터베이스, 파일 작업, 외부 자원은 다음 스레드의 수를 가져이 증가 할 성능 저하가 더 분명 할 수있다.

여기에서 문제에 대한 클라이언트의 관점에서, 동시 클라이언트 서버 응답 시간의 수의 영향은있을 것이다 무엇인가? 동시 클라이언트의 개수가 증가 할 때 마찬가지로 환경 또는 같이 변경됩니다 응답 시간은 다음과 같다 :

계산 집약적 인 태스크 유형, 동시 클라이언트 1,2,4의 수는, 평균 응답 시간이 최적 인 경우 과도한 4 클라이언트가 발생할 때, 그러나, 성능은 극적으로 증가 클라이언트의 발생률 감소하기 때문 .

클라이언트의 수를 증가하면 스레드의 수는 서버 스레드 풀을 증가시켜 성능을 향상 할 수 있습니다,하지만 CPU를 많이 사용하는 작업의 경우, 그렇게 만 성능이 저하됩니다. 시스템 병목 현상이 CPU 자원이기 때문에, 무모한 행위는 경쟁이 더 강렬하게됩니다 이것에 대한 스레드 풀 자원 스레드의 수를 증가합니다.

그래서, 성능 문제에 직면한다. 첫 번째 단계는 알고 항상 곳이 대상이 될 수 있도록 시스템의 병목 현상. 만용 그 경쟁이 더 강렬 병목 자원 그래서 "튜닝"소위하기 때문에 단지 성능이 더 감소를 가져됩니다. 변화가 경쟁이 자원의 병목 현상을 완화 할 수 있도록하는 경우 반대로, 그것은 일반적으로 성능이 향상됩니다.

가능한 ThreadPoolExecutor의 관점에서 생각하면 위의 시나리오에서, 다음 보류중인 작업 (보류) 상태 (각 클라이언트 요청이 작업에 해당하기 때문에)에서 작업 큐, 모든 사용 가능한 스레드가 있었다 직장에서, CPU는 최대 용량으로 운영하고 있습니다. 스레드 풀에서이 시간을 스레드의 수를 추가의 보류중인 작업의 일부는 다음 무슨 일이 일어날 것입니다받을 이러한 스레드를 추가 할 수 있도록? 그런 다음 성능을 저하 CPU 리소스에 대한 더 치열한 경쟁이있는 경우에만 스레드간에 가져온다.

스레드의 최소 수를 설정

스레드의 최대 수를 설정 한 후, 또한 당신은 스레드의 최소 수를 설정해야합니다. 대부분의 시나리오의 경우, 최대 스레드 수와 그것에는 동일하게 설정.

각 스레드가 자원의 일정 금액, 스택을 스레드에 필요한 특히 리소스를 생성보다 더 많이 소비하기 때문에, 최소 스레드 수는 자원을 절약하도록 설정되어 마음에 스레드의 최대 수 미만. 그러나 시스템 후, 하드웨어 자원과 스레드의 작업을 선택 최대 수의 특성, 그것은 시스템이 항상 이러한 스레드를 활용된다는 것을 의미합니다, 당신은뿐만 아니라 스레드 풀 스레드가 처음에 준비해야 할 수도 있습니다. 그러나 충격은 일반적으로 어떤 다른 인식하지, 아주 작게 설정 스레드의 최소 수를 가져 스레드의 최대 수보다 적은이다.

在批处理程序中,最小线程数是否等于最大线程数并不重要。因为最后线程总是需要被创建出来的,所以程序的运行时间应该几乎相同。对于服务器程序而言,影响也不大,但是一般而言,线程池中的线程在“热身”阶段就应该被创建出来,所以这也是为什么建议将最小线程数设置的等于最大线程数的原因。

在一些场景中,也需要要设置一个不同的最小线程数。比如当一个系统最大需要同时处理2000个任务,而平均任务数量只是20个情况下,就需要将最小线程数设置成20,而不是等于其最大线程数2000。此时如果还是将最小线程数设置的等于最大线程数的话,那么闲置线程(Idle Thread)占用的资源就比较可观了,尤其是当使用了ThreadLocal类型的变量时。

线程池任务数量(Thread Pool Task Sizes)

线程池有一个列表或者队列的数据结构来存放需要被执行的任务。显然,在某些情况下,任务数量的增长速度会大于其被执行的速度。如果这个任务代表的是一个来自Client的请求,那么也就意味着该Client会等待比较长的时间。显然这是不可接受的,尤其对于提供Web服务的服务器程序而言。

所以,线程池会有机制来限制列表/队列中任务的数量。但是,和设置最大线程数一样,并没有一个放之四海而皆准的最优任务数量。这还是要取决于具体的任务类型和不断的进行性能测试。

对于ThreadPoolExecutor而言,当任务数量达到最大时,再尝试增加新的任务就会失败。ThreadPoolExecutor有一个rejectedExecution方法用来拒绝该任务。这会导致应用服务器返回一个HTTP状态码500,当然这种信息最好以更友好的方式传达给Client,比如解释一下为什么你的请求被拒绝了。

定制ThreadPoolExecutor

线程池在同时满足以下三个条件时,就会创建一个新的线程:

  1. 有任务需要被执行
  2. 当前线程池中所有的线程都处于工作状态
  3. 当前线程池的线程数没有达到最大线程数

至于线程池会如何创建这个新的线程,则是根据任务队列的种类:

  1. 任务队列是 SynchronousQueue 这个队列的特点是,它并不能放置任何任务在其队列中,当有任务被提交时,使用SynchronousQueue的线程池会立即为该任务创建一个线程(如果线程数量没有达到最大时,如果达到了最大,那么该任务会被拒绝)。这种队列适合于当任务数量较小时采用。也就是说,在使用这种队列时,未被执行的任务没有一个容器来暂时储存。
  2. 任务队列是 无限队列(Unbound Queue) 无界限的队列可以是诸如LinkedBlockingQueue这种类型,在这种情况下,任何被提交的任务都不会被拒绝。但是线程池会忽略最大线程数这一参数,意味着线程池的最大线程数就变成了设置的最小线程数。所以在使用这种队列时,通常会将最大线程数设置的和最小线程数相等。这就相当于使用了一个固定了线程数量的线程池。
  3. 任务队列是 有限队列(Bounded Queue) 当使用的队列是诸如ArrayBlockingQueue这种有限队列的时候,来决定什么时候创建新线程的算法就相对复杂一些了。比如,最小线程数是4,最大线程数是8,任务队列最多能够容纳10个任务。在这种情况下,当任务逐渐被添加到队列中,直到队列被占满(10个任务),此时线程池中的工作线程仍然只有4个,即最小线程数。只有当仍然有任务希望被放置到队列中的时候,线程池才会新创建一个线程并从队列头部拿走一个任务,以腾出位置来容纳这个最新被提交的任务。

关于如何定制ThreadPoolExecutor,遵循KISS原则(Keep It Simple, Stupid)就好了。比如将最大线程数和最小线程数设置的相等,然后根据情况选择有限队列或者无限队列。

总结

  1. 线程池是对象池的一个有用的例子,它能够节省在创建它们时候的资源开销。并且线程池对系统中的线程数量也起到了很好的限制作用。

  2. 线程池中的线程数量必须仔细的设置,否则冒然增加线程数量只会带来性能的下降。

  3. 在定制ThreadPoolExecutor时,遵循KISS原则,通常情况下会提供最好的性能。

ForkJoinPool

在Java 7中引入了一种新的线程池:ForkJoinPool。

它同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入希望的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。

ForkJoinPool主要用来使用分治法(Divide-and-Conquer Algorithm)来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool需要使用相对少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。

那么到最后,所有的任务加起来会有大概2000000+个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。

所以当使用ThreadPoolExecutor时,使用分治法会存在问题,因为ThreadPoolExecutor中的线程无法像任务队列中再添加一个任务并且在等待该任务完成之后再继续执行。而使用ForkJoinPool时,就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。

比如,我们需要统计一个double数组中小于0.5的元素的个数,那么可以使用ForkJoinPool进行实现如下:

public class ForkJoinTest {
    private double[] d;
    private class ForkJoinTask extends RecursiveTask<Integer> {
        private int first;
        private int last;
        public ForkJoinTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        protected Integer compute() {
            int subCount;
            if (last - first < 10) {
                subCount = 0;
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5)
                                            subCount++;
                }
            } else {
                int mid = (first + last) >>> 1;
                ForkJoinTask left = new ForkJoinTask(first, mid);
                left.fork();
                ForkJoinTask right = new ForkJoinTask(mid + 1, last);
                right.fork();
                subCount = left.join();
                subCount += right.join();
            }
            return subCount;
        }
    }
    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        int n = new ForkJoinPool().invoke(new ForkJoinTask(0, 9999999));
        System.out.println("Found " + n + " values");
    }
}

以上的关键是fork()和join()方法。在ForkJoinPool使用的线程中,会使用一个内部队列来对需要执行的任务以及子任务进行操作来保证它们的执行顺序。

那么使用ThreadPoolExecutor或者ForkJoinPool,会有什么性能的差异呢?

首先,使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用4个线程来完成超过200万个任务。但是,使用ThreadPoolExecutor时,是不可能完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,显然这是不可行的。

当然,在上面的例子中,也可以不使用分治法,因为任务之间的独立性,可以将整个数组划分为几个区域,然后使用ThreadPoolExecutor来解决,这种办法不会创建数量庞大的子任务。代码如下:

public class ThreadPoolTest {
    private double[] d;
    private class ThreadPoolExecutorTask implements Callable<Integer> {
        private int first;
        private int last;
        public ThreadPoolExecutorTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        public Integer call() {
            int subCount = 0;
            for (int i = first; i <= last; i++) {
                if (d[i] < 0.5) {
                    subCount++;
                }
            }
            return subCount;
        }
    }
    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        ThreadPoolExecutor tpe = new ThreadPoolExecutor
        (4, 4, long.MAX_VALUE, TimeUnit.SECONDS, new LinkedBlockingQueue());
        Future[] f = new Future[4];
        int size = d.length / 4;
        for (int i = 0; i < 3; i++) {
            f[i] = tpe.submit(new ThreadPoolExecutorTask(i * size, (i + 1) * size - 1);
        }
        f[3] = tpe.submit(new ThreadPoolExecutorTask(3 * size, d.length - 1);
        int n = 0;
        for (int i = 0; i < 4; i++) {
            n += f.get();
        }
        System.out.println("Found " + n + " values");
    }
}

在分别使用ForkJoinPool和ThreadPoolExecutor时,它们处理这个问题的时间如下:


对执行过程中的GC同样也进行了监控,发现在使用ForkJoinPool时,总的GC时间花去了1.2s,而ThreadPoolExecutor并没有触发任何的GC操作。这是因为在ForkJoinPool的运行过程中,会创建大量的子任务。而当他们执行完毕之后,会被垃圾回收。反之,ThreadPoolExecutor则不会创建任何的子任务,因此不会导致任何的GC操作。

ForkJoinPool的另外一个特性是它能够实现工作窃取(Work Stealing),在该线程池的每个线程中会维护一个队列来存放需要被执行的任务。当线程自身队列中的任务都执行完毕后,它会从别的线程中拿到未被执行的任务并帮助它执行。

可以通过以下的代码来测试ForkJoinPool的Work Stealing特性:

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < d.length - i; j++) {
        for (int k = 0; k < 100; k++) {
            dummy = j * k + i;
            // dummy is volatile, so multiple writes occur
            d[i] = dummy;
        }
    }
}

因为里层的循环次数(j)是依赖于外层的i的值的,所以这段代码的执行时间依赖于i的值。当i = 0时,执行时间最长,而i = last时执行时间最短。也就意味着任务的工作量是不一样的,当i的值较小时,任务的工作量大,随着i逐渐增加,任务的工作量变小。因此这是一个典型的任务负载不均衡的场景。

这时,选择ThreadPoolExecutor就不合适了,因为它其中的线程并不会关注每个任务之间任务量的差异。当执行任务量最小的任务的线程执行完毕后,它就会处于空闲的状态(Idle),等待任务量最大的任务执行完毕。

而ForkJoinPool的情况就不同了,即使任务的工作量有差别,当某个线程在执行工作量大的任务时,其他的空闲线程会帮助它完成剩下的任务。因此,提高了线程的利用率,从而提高了整体性能。

这两种线程池对于任务工作量不均衡时的执行时间:

 

注意到当线程数量为1时,两者的执行时间差异并不明显。这是因为总的计算量是相同的,而ForkJoinPool慢的那一秒多是因为它创建了非常多的任务,同时也导致了GC的工作量增加。

当线程数量增加到4时,执行时间的区别就较大了,ForkJoinPool的性能比ThreadPoolExecutor好将近50%,可见Work Stealing在应对任务量不均衡的情况下,能够保证资源的利用率。

所以一个结论就是:当任务的任务量均衡时,选择ThreadPoolExecutor往往更好,反之则选择ForkJoinPool。

另外,对于ForkJoinPool,还有一个因素会影响它的性能,就是停止进行任务分割的那个阈值。比如在之前的快速排序中,当剩下的元素数量小于10的时候,就会停止子任务的创建。下表显示了在不同阈值下,ForkJoinPool的性能:

 

可以发现,当阈值不同时,对于性能也会有一定影响。因此,在使用ForkJoinPool时,对此阈值进行测试,使用一个最合适的值也有助于整体性能。

自动并行化(Automatic Parallelization)

在Java 8中,引入了自动并行化的概念。它能够让一部分Java代码自动地以并行的方式执行,前提是使用了ForkJoinPool。

Java 8为ForkJoinPool添加了一个通用线程池,这个线程池用来处理那些没有被显式提交到任何线程池的任务。它是ForkJoinPool类型上的一个静态元素,它拥有的默认线程数量等于运行计算机上的处理器数量。

当调用Arrays类上添加的新方法时,自动并行化就会发生。比如用来排序一个数组的并行快速排序,用来对一个数组中的元素进行并行遍历。自动并行化也被运用在Java 8新添加的Stream API中。

比如下面的代码用来遍历列表中的元素并执行需要的计算:

Stream<Integer> stream = arrayList.parallelStream();
stream.forEach(a -> {
    String symbol = StockPriceUtils.makeSymbol(a);
    StockPriceHistory sph = new StockPriceHistoryImpl(symbol, startDate, endDate, entityManager);
}
);

对于列表中的元素的计算都会以并行的方式执行。forEach方法会为每个元素的计算操作创建一个任务,该任务会被前文中提到的ForkJoinPool中的通用线程池处理。以上的并行计算逻辑当然也可以使用ThreadPoolExecutor完成,但是就代码的可读性和代码量而言,使用ForkJoinPool明显更胜一筹。

对于ForkJoinPool通用线程池的线程数量,通常使用默认值就可以了,即运行时计算机的处理器数量。如果需要调整线程数量,可以通过设置系统属性:-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

下面的一组数据用来比较使用ThreadPoolExecutor和ForkJoinPool中的通用线程池来完成上面简单计算时的性能:

 

注意到当线程数为1,2,4时,性能差异的比较明显。线程数为1的ForkJoinPool通用线程池和线程数为2的ThreadPoolExecutor的性能十分接近。

出现这种现象的原因是,forEach方法用了一些小把戏。它会将执行forEach本身的线程也作为线程池中的一个工作线程。因此,即使将ForkJoinPool的通用线程池的线程数量设置为1,实际上也会有2个工作线程。因此在使用forEach的时候,线程数为1的ForkJoinPool通用线程池和线程数为2的ThreadPoolExecutor是等价的。

所以当ForkJoinPool通用线程池实际需要4个工作线程时,可以将它设置成3,那么在运行时可用的工作线程就是4了。

总结

  1. 当需要处理递归分治算法时,考虑使用ForkJoinPool。
  2. 仔细设置不再进行任务划分的阈值,这个阈值对性能有影响。
  3. Java 8中的一些特性会使用到ForkJoinPool中的通用线程池。在某些场合下,需要调整该线程池的默认的线程数量。
 

————END————

 

추천

출처www.cnblogs.com/Java-no-1/p/11222672.html