Введение в пулы потоков в Java

Введение в потоки потоков в Java

1. Вступление

В этой статье рассматриваются пулы потоков в Java, начиная с различных реализаций в стандартной библиотеке Java, а затем рассматривая библиотеку Guava от Google.

2. Пул потоков

В Java потоки отображаются на потоки системного уровня, которые являются ресурсами операционной системы. Если вы создаете потоки неуправляемо, вы можете быстро исчерпать эти ресурсы.

Переключение контекста между потоками также выполняется операционной системой - чтобы эмулировать параллелизм. Упрощенное представление таково: чем больше потоков вы создаете, тем меньше времени тратит каждый поток на выполнение реальной работы.

Шаблон Thread Pool помогает экономить ресурсы в многопоточном приложении, а также содержать параллелизм в определенных предопределенных пределах.

Когда вы используете пул потоков, выwrite your concurrent code in the form of parallel tasks and submit them for execution to an instance of a thread pool. Этот экземпляр управляет несколькими повторно используемыми потоками для выполнения этих задач. 2016-08-10_10-16-52-1024x572

Шаблон позволяет вамcontrol the number of threads the application is creating, их жизненный цикл, а также планировать выполнение задач и хранить входящие задачи в очереди.

3. Пулы потоков в Java

3.1. Executors,Executor иExecutorService

Вспомогательный классExecutors содержит несколько методов для создания предварительно настроенных экземпляров пула потоков для вас. Эти классы - хорошее место для начала - используйте их, если вам не нужно применять какие-либо специальные настройки.

ИнтерфейсыExecutor иExecutorService используются для работы с различными реализациями пула потоков в Java. Обычно вам следуетkeep your code decoupled from the actual implementation of the thread pool и использовать эти интерфейсы во всем приложении.

ИнтерфейсExecutor имеет единственный методexecute для отправки экземпляровRunnable на выполнение.

Here’s a quick example о том, как вы можете использовать APIExecutors для получения экземпляраExecutor, поддерживаемого одним пулом потоков и неограниченной очередью для последовательного выполнения задач. Здесь мы выполняем единственную задачу, которая просто выводит на экран «Hello World». Задача представлена ​​как лямбда (функция Java 8), которая, как предполагается, равнаRunnable.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

ИнтерфейсExecutorService содержит большое количество методов дляcontrolling the progress of the tasks and managing the termination of the service. Используя этот интерфейс, вы можете отправлять задачи на выполнение, а также контролировать их выполнение с помощью возвращенного экземпляраFuture.

In the following example, мы создаемExecutorService, отправляем задачу и затем используем возвращенный методFuture ’sget, чтобы дождаться завершения отправленной задачи и возврата значения:

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

Конечно, в реальном сценарии вы обычно не хотите сразу вызыватьfuture.get(), а отложите его вызов до тех пор, пока вам действительно не понадобится значение вычисления.

Методsubmit перегружен и принимает либоRunnable, либоCallable, оба из которых являются функциональными интерфейсами и могут передаваться как лямбда-выражения (начиная с Java 8).

Единственный методRunnable не генерирует исключение и не возвращает значение. ИнтерфейсCallable может быть более удобным, поскольку он позволяет генерировать исключение и возвращать значение.

Наконец, чтобы компилятор мог определить типCallable, просто верните значение из лямбда.

Чтобы увидеть больше примеров использования интерфейсаExecutorService и фьючерсов, посмотрите «A Guide to the Java ExecutorService».

3.2. ThreadPoolExecutorс

ThreadPoolExecutor - это реализация расширяемого пула потоков с множеством параметров и ловушек для точной настройки.

Основные параметры конфигурации, которые мы здесь обсудим:corePoolSize,maximumPoolSize иkeepAliveTime.

Пул состоит из фиксированного числа основных потоков, которые все время находятся внутри, и нескольких избыточных потоков, которые могут порождаться и затем прерываться, когда они больше не нужны. ПараметрcorePoolSize - это количество основных потоков, которые будут созданы и сохранены в пуле. Если все основные потоки заняты и отправлено больше задач, то пулу разрешается вырасти доmaximumPoolSize.

ПараметрkeepAliveTime - это интервал времени, в течение которого избыточные потоки (т.е. потокам, экземпляры которых превышаютcorePoolSize), разрешено существовать в состоянии ожидания.

Эти параметры охватывают широкий спектр вариантов использования, кромеthe most typical configurations are predefined in the Executors static methods.

МетодFor example,newFixedThreadPool создаетThreadPoolExecutor с равными значениями параметровcorePoolSize иmaximumPoolSize и нулевымkeepAliveTime.. Это означает, что количество потоков в этот пул потоков всегда один и тот же:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());

В приведенном выше примере мы создаемThreadPoolExecutor с фиксированным количеством потоков 2. Это означает, что если количество одновременно выполняемых задач меньше или равно двум за все время, то они выполняются сразу же. В противном случаеsome of these tasks may be put into a queue to wait for their turn.

Мы создали три задачиCallable, которые имитируют тяжелую работу за счет сна в течение 1000 миллисекунд. Первые две задачи будут выполнены одновременно, а третьему придется ждать в очереди. Мы можем проверить это, вызвав методыgetPoolSize() иgetQueue().size() сразу после отправки задач.

Другой предварительно настроенныйThreadPoolExecutor может быть создан с помощью методаExecutors.newCachedThreadPool(). Этот метод не получает несколько потоков вообще. corePoolSize фактически установлен на 0, аmaximumPoolSize установлен наInteger.MAX_VALUE для этого экземпляра. keepAliveTime составляет 60 секунд для этого.

Эти значения параметров означают, чтоthe cached thread pool may grow without bounds to accommodate any amount of submitted tasks. Но когда потоки больше не нужны, они удаляются через 60 секунд бездействия. Типичный случай использования - это когда в вашем приложении много недолговечных задач.

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());

Размер очереди в приведенном выше примере всегда будет равен нулю, потому что внутри используется экземплярSynchronousQueue. ВSynchronousQueue пары операцийinsert иremove всегда выполняются одновременно, поэтому очередь фактически никогда ничего не содержит.

APIExecutors.newSingleThreadExecutor() создает другую типичную формуThreadPoolExecutor, содержащую один поток. The single thread executor is ideal for creating an event loop. ПараметрыcorePoolSize иmaximumPoolSize равны 1, аkeepAliveTime равно нулю.

Задачи в приведенном выше примере будут выполняться последовательно, поэтому после завершения задачи значение флага будет равно 2:

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});

Кроме того, этотThreadPoolExecutor украшен неизменной оболочкой, поэтому его нельзя перенастроить после создания. Обратите внимание, что также по этой причине мы не можем преобразовать его вThreadPoolExecutor.

3.3. ScheduledThreadPoolExecutorс

ScheduledThreadPoolExecutor расширяет классThreadPoolExecutor, а также реализует интерфейсScheduledExecutorService с несколькими дополнительными методами:

  • Методschedule позволяет выполнить задачу один раз после заданной задержки;

  • МетодscheduleAtFixedRate позволяет выполнить задачу после указанной начальной задержки, а затем выполнять ее повторно с определенным периодом; аргументperiod - это времяmeasured between the starting times of the tasks, поэтому скорость выполнения фиксирована;

  • МетодscheduleWithFixedDelay похож наscheduleAtFixedRate в том, что он повторно выполняет данную задачу, но заданная задержка равнаmeasured between the end of the previous task and the start of the next; скорость выполнения может варьироваться в зависимости от времени, необходимого для выполнения той или иной задачи.

МетодExecutors.newScheduledThreadPool() обычно используется для созданияScheduledThreadPoolExecutor с заданнымcorePoolSize, неограниченнымmaximumPoolSize и нулевымkeepAliveTime. Вот как запланировать выполнение задачи через 500 миллисекунд:

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);

Следующий код показывает, как выполнить задачу с задержкой в ​​500 миллисекунд, а затем повторять ее каждые 100 миллисекунд. После планирования задачи мы ждем, пока она не сработает три раза, используя блокировкуCountDownLatch,, а затем отменяем ее, используя методFuture.cancel().

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);

3.4. ForkJoinPoolс

ForkJoinPool - центральная часть фреймворкаfork/join, представленного в Java 7. Это решает общую проблемуspawning multiple tasks in recursive algorithms. Используя простойThreadPoolExecutor, у вас быстро закончатся потоки, поскольку для каждой задачи или подзадачи требуется свой собственный поток для запуска.

В структуреfork/join любая задача может порождать (fork) несколько подзадач и ждать их завершения с помощью методаjoin. Преимущество фреймворкаfork/join заключается в том, что онdoes not create a new thread for each task or subtask реализует вместо него алгоритм Work Stealing. Этот фреймворк подробно описан в статье «Guide to the Fork/Join Framework in Java».

Давайте посмотрим на простой пример использованияForkJoinPool для обхода дерева узлов и вычисления суммы всех значений листьев. Вот простая реализация дерева, состоящего из узла, значенияint и набора дочерних узлов:

static class TreeNode {

    int value;

    Set children;

    TreeNode(int value, TreeNode... children) {
        this.value = value;
        this.children = Sets.newHashSet(children);
    }
}

Теперь, если мы хотим суммировать все значения в дереве параллельно, нам нужно реализовать интерфейсRecursiveTask<Integer>. Каждая задача получает свой собственный узел и добавляет свое значение к сумме значений своегоchildren. Для вычисления суммы значенийchildren реализация задачи делает следующее:

  • транслирует наборchildren,

  • сопоставляет этот поток, создавая новыйCountingTask для каждого элемента,

  • выполняет каждую подзадачу, разветвляя ее,

  • собирает результаты, вызывая методjoin для каждой разветвленной задачи,

  • суммирует результаты, используя коллекторCollectors.summingInt.

public static class CountingTask extends RecursiveTask {

    private final TreeNode node;

    public CountingTask(TreeNode node) {
        this.node = node;
    }

    @Override
    protected Integer compute() {
        return node.value + node.children.stream()
          .map(childNode -> new CountingTask(childNode).fork())
          .collect(Collectors.summingInt(ForkJoinTask::join));
    }
}

Код для выполнения вычисления на реальном дереве очень прост:

TreeNode tree = new TreeNode(5,
  new TreeNode(3), new TreeNode(2,
    new TreeNode(2), new TreeNode(8)));

ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));

4. Реализация пула потоков в Guava

Guava - популярная библиотека утилит Google. В нем много полезных классов параллелизма, включая несколько удобных реализацийExecutorService. Реализующие классы недоступны для прямого создания экземпляров или создания подклассов, поэтому единственной точкой входа для создания их экземпляров является вспомогательный классMoreExecutors.

4.1. Добавление Guava в качестве зависимости Maven

Добавьте следующую зависимость в ваш pom-файл Maven, чтобы включить библиотеку Guava в ваш проект. Вы можете найти последнюю версию библиотеки Guava в репозиторииMaven Central:


    com.google.guava
    guava
    19.0

4.2. Служба прямого исполнителя и прямого исполнителя

Иногда вы хотите выполнить задачу либо в текущем потоке, либо в пуле потоков, в зависимости от некоторых условий. Вы бы предпочли использовать один интерфейсExecutor и просто переключить реализацию. Хотя не так сложно придумать реализациюExecutor илиExecutorService, которая выполняет задачи в текущем потоке, все же требуется написать некоторый шаблонный код.

К счастью, Guava предоставляет нам заранее определенные экземпляры.

Here’s an example, демонстрирующий выполнение задачи в том же потоке. Хотя предоставленная задача спит 500 миллисекунд, этоblocks the current thread, и результат доступен сразу после завершения вызоваexecute:

Executor executor = MoreExecutors.directExecutor();

AtomicBoolean executed = new AtomicBoolean();

executor.execute(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    executed.set(true);
});

assertTrue(executed.get());

Экземпляр, возвращаемый методомdirectExecutor(), на самом деле является статическим синглтоном, поэтому использование этого метода вообще не вызывает никаких накладных расходов на создание объекта.

Вы должны предпочесть этот методMoreExecutors.newDirectExecutorService(), потому что этот API создает полноценную реализацию службы исполнителя при каждом вызове.

4.3. Выход из служб исполнителя

Другая распространенная проблема -shutting down the virtual machine, когда пул потоков все еще выполняет свои задачи. Даже при наличии механизма отмены, нет никакой гарантии, что задачи будут работать хорошо и остановят свою работу, когда служба исполнителя завершит работу. Это может привести к зависанию JVM на неопределенное время, пока задачи продолжают выполнять свою работу.

Чтобы решить эту проблему, Гуава представляет семейство существующих служб исполнителей. Они основаны наdaemon threads which terminate together with the JVM.

Эти службы также добавляют ловушку завершения работы с методомRuntime.getRuntime().addShutdownHook() и предотвращают завершение работы виртуальной машины в течение заданного времени, прежде чем отказаться от зависших задач.

В следующем примере мы отправляем задачу, которая содержит бесконечный цикл, но мы используем существующую службу исполнителя с настроенным временем в 100 миллисекунд для ожидания выполнения задач после завершения работы виртуальной машины. БезexitingExecutorService эта задача приведет к зависанию виртуальной машины на неопределенный срок:

ThreadPoolExecutor executor =
  (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService =
  MoreExecutors.getExitingExecutorService(executor,
    100, TimeUnit.MILLISECONDS);

executorService.submit(() -> {
    while (true) {
    }
});

4.4. Прослушивание декораторов

Декораторы прослушивания позволяют вам оборачиватьExecutorService и получать экземплярыListenableFuture при отправке задачи вместо простых экземпляровFuture. ИнтерфейсListenableFuture расширяетFuture и имеет единственный дополнительный методaddListener. Этот метод позволяет добавить слушатель, который вызывается при будущем завершении.

Вы редко хотите использовать методListenableFuture.addListener() напрямую, но этоessential to most of the helper methods in the Futures utility class. Например, с помощью методаFutures.allAsList() вы можете объединить несколько экземпляровListenableFuture в одинListenableFuture, который завершается после успешного завершения всех комбинированных фьючерсов:

ExecutorService executorService = Executors.newCachedThreadPool();
ListeningExecutorService listeningExecutorService =
  MoreExecutors.listeningDecorator(executorService);

ListenableFuture future1 =
  listeningExecutorService.submit(() -> "Hello");
ListenableFuture future2 =
  listeningExecutorService.submit(() -> "World");

String greeting = Futures.allAsList(future1, future2).get()
  .stream()
  .collect(Collectors.joining(" "));
assertEquals("Hello World", greeting);

5. Заключение

В этой статье мы обсудили шаблон пула потоков и его реализации в стандартной библиотеке Java и в библиотеке Google Guava.

Исходный код статьи доступенover on GitHub.