Содержание


Вселенная Java

Часть 2. Выполнение задач в многопоточном режиме

Comments

Серия контента:

Этот контент является частью # из серии # статей: Вселенная Java

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Вселенная Java

Следите за выходом новых статей этой серии.

При разработке ПО часто возникают ситуации, когда необходимо обеспечить одновременное выполнение нескольких задач, например, отправку и получение электронной почты. В корпоративных JEE-приложениях для этого предлагается использовать специальные инфраструктуры и библиотеки, такие как Quartz. Но для JSE-приложений такой подход может не окупиться, так как для внедрения инфраструктуры ресурсов потребуется больше, чем для реализации самих задач.

Поэтому в данной статье рассматриваются два подхода к параллельному выполнению задач в JSE-приложениях: классический способ и новые возможности пакета java.util.concurrent.

Классический подход к запуску задач в многопоточном режиме

Классический подход к запуску задач в многопоточном режиме в JSE предполагает использование класса java.lang.Thread или интерфейса java.lang.Runnable. В первом случае программист создает потомка класса Thread и переопределяет в нем метод run, куда помещается функциональность, которую необходимо выполнить в многопоточном режиме, как показано в листинге 1.

Листинг 1. Запуск задач с помощью класса java.lang.Thread
1 public class ThreadSample extends Thread {
2     @Override
3     public void run() {
4         System.out.println("do some multithreaded task");
5     }
6     public static void main(String[] args) {
7         ThreadSample ts1 = new ThreadSample();
8         ts1.start();
9     }
10 }

Использование интерфейса Runnable основывается на другой парадигме. Сначала программист должен реализовать интерфейс Runnable в собственном классе, а затем поместить объект этого класса в объект типа Thread. Интересующая функциональность также помещается в метод run класса, реализующего интерфейс Runnable, и впоследствии вызывается объектом-контейнером, как показано в листинге 2.

Листинг 2. Запуск задач с помощью интерфейса java.lang.Runnable
1 public class RunnableSample implements Runnable {
2     public void run() {
3         System.out.println("do some multithreaded task");
4     }
5     public static void main(String[] args) {
6         RunnableSample rs1 = new RunnableSample();
7         Thread t1 = new Thread(rs1);
8         t1.start();
9     }
10 }

Как видно в обоих примерах запуск задачи в многопоточном режиме выполняется через вызов метода start объекта типа Thread. Только в первом случае после вызова метода start класса Thread происходит вызов метода run, наследника этого класса (класса ThreadSample), в котором и находится код, относящейся к задаче. При выборе реализации на основе интерфейса Runnable сначала происходит вызов метода start класса Thread, затем обращение к методу run этого же класса, и уже из этого метода вызывается метод run реализации интерфейса Runnable (класса RunnableSample).

Ошибки при использовании классического подхода

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

Во-первых, часто вместо вызова метода start для запуска потока программист сразу вызывает метод run, что приводит к неправильному результату. Если сразу вызвать метод run на строке 8 в листинге 1 или листинге 2, то программа отработает без всяких видимых изменений. Ошибка заключается в том, что при непосредственном вызове метода run задача будет выполняться, но в однопоточном, а не в многопоточном режиме. Поэтому, если такая задача всего одна, программа отработает нормально, хотя и несколько медленнее (но «невооруженным» глазом это будет незаметно), а вот в случае с несколькими задачами падение производительности окажется фатальным.

Другая проблема связана с самим использованием наследования класса Thread вместо реализации интерфейса Runnable. Если при реализации интерфейса требуется обязательное соблюдение сигнатуры при переопределении метода (в данном случае метода run), то в наследовании такого ограничения не существует. Поэтому ошибка в сигнатуре метода run в листинге 1 автоматически меняет состояние этого метода с «переопределенный» на «перегруженный», при этом при компиляции не будет выведено никаких предупреждений или сообщений об ошибках. Однако запуск подобной программы опять приведет к возникновению непредусмотренного результата, а точнее, полному отсутствию такового. Это будет связано с тем, что при отсутствии переопределенной версии метода run будет вызвана реализация этого метода по умолчанию, которая расположена в классе Thread. Эта реализация по умолчанию не содержит никакой функциональности, соответственно поток запустится и тут же остановится, так как никакой работы для него нет.

Для решения этой проблемы на помощь приходит одна из возможностей, появившихся в JSE 5, - аннотация Override, как показано на строке 8 в листинге 1. Эта аннотация заставляет компилятор выполнить проверку, действительно ли объявленный метод переопределяет какой-нибудь из методов суперкласса. В случае, если метод, отмеченный этой аннотацией, не является переопределением метода из суперкласса, то компилятор выводит сообщение об ошибке. Вообще, аннотацию Override рекомендуется применять во всех случаях, когда выполняется переопределение методов, так как это повышает качество разрабатываемого кода.

Выбор между интерфейсом java.lang.Runnable и классом java.lang.Thread

Как было показано ранее, при необходимости обеспечить параллельное выполнение нескольких задач у программиста есть возможность выбрать, как именно реализовать эти задачи: с помощью класса Thread или интерфейса Runnable. У каждого подхода есть свои преимущества и недостатки.

В качестве основного преимущества при наследовании класса Thread заявляется полный доступ ко всем функциональным возможностям потока на платформе Java. Главным недостатком же считается как раз сам факт наследования, так как в силу того, что в Java применяется только одиночное наследование, то наследование классу Thread автоматически закрывает возможность наследовать другим классам. Для классов, отвечающих за доменную область или бизнес-логику, это может стать серьезной проблемой. Правда негативное воздействие, возникающее из-за невозможности наследования, можно ослабить, если вместо него применить прием делегирования или соответствующие шаблоны проектирования.

Использование интерфейса Runnable по умолчанию лишено этого недостатка, но если реализовать задачу таким способом, то придется потратить дополнительные усилия на ее запуск. Как было показано в листинге 2, для запуска Runnable-задачи все равно потребуется объект Thread, также в этом случае исчезнет возможность прямого управления потоком из задачи. Хотя последнее ограничение можно обойти с помощью статических методов класса Thread (например, метод currentThread() возвращает ссылку на текущий поток).

Поэтому сделать однозначный вывод о превосходстве какого-либо подхода довольно сложно, и чаще всего в приложениях одновременно используются оба варианта, но для решения задач различной направленности. Считается, что наследование класса Thread следует применять только тогда, когда действительно необходимо создать «новый вид потока, который должен дополнить функциональность класса java.lang.Thread», и подобное решение применяется при разработке системного ПО, например, серверов приложений или инфраструктур. Использование интерфейса Runnable показано в случаях, когда просто «необходимо одновременно выполнить несколько задач» и не требуется вносить изменений в сам механизм многопоточности, поэтому в бизнес-ориентированных приложениях в основном используется вариант с интерфейсом Runnable.

Ограничения классического подхода

Когда программист только начинает работать с многопоточными возможностями Java-платформы, то на первых порах он может даже впасть в состояние «эйфории», особенно, если у него уже был негативный опыт по созданию многопоточных приложений в других языках программирования. Действительно, система управления потоками в Java организованна крайне удачно, так как этот компонент платформы был детально проработан еще на стадии проектирования самых первых версий виртуальной Java-машины и языка программирования Java, и сейчас ему по-прежнему уделяется большое внимание. Однако со временем «розовые очки» спадают, и программист начинает замечать «раздражающие» моменты, которые усложняют организацию параллельного исполнения задач в Java.

Первым, что бросается в глаза, оказывается слияние низкоуровневого кода, отвечающего за многопоточное исполнение, и высокоуровневого кода, отвечающего за основную функциональность приложения (так называемый «спагетти-код»). В листинге 1 показано, что бизнес—код и поточный код вообще находятся в одном классе, но даже в более удачном варианте из листинга 2 для выполнения задачи все равно требуется создать объект Thread и запустить его. Подобное перемешивание снижает качество архитектуры приложения и может затруднить его последующее сопровождение.

Но даже если удалось отделить поточный код от основного, то возникает проблема, связанная уже с управлением самими потоками. Потоки в Java запускаются только путем вызова метода start и останавливаются после вызова соответствующих методов или самостоятельно после завершения работы метода run. Также после того, как поток остановился, его нельзя запустить второй раз, что и приводит к следующим негативным моментам:

  • поток занимает относительно много места в куче, так что после его завершения необходимо проследить, чтобы память, занимаемая им, была освобождена (например, присвоить ссылке на поток значение null);
  • для выполнения новой задачи потребуется запустить новый поток, что приведет к увеличению «накладных расходов» на виртуальную машину, так как запуск потока – это одна из самых требовательных к ресурсам операций.

Если же удалось превратить поток в «многоразовый», то программист сталкивается с проблемой, как понять, что поток уже закончил выполнение задачи и ему можно выдавать следующую. Необходимо еще учитывать тот факт, что выполнение задачи может завершиться неудачно, например, возникновением исключительной ситуации, и подобная ситуация не должна повлиять на выполнение других задач.

Важно сказать, что «среднестатистический» программист будет отнюдь не первым, кто сталкивается с подобными проблемами в многозадачных приложениях. Все эти проблемы были давно проанализированы Java-сообществом и нашли решение в признанных шаблонах проектирования (например, ThreadPool (пул потоков) и WorkerThread (рабочий поток)). Но скорее всего, у рядового программиста, ограниченного временными рамками проекта, просто не будет времени или ресурсов, чтобы подготовить и самое главное протестировать полноценную реализацию данных шаблонов. А неточное или неполное внедрение этих шаблонов (да и вообще любых шаблонов проектирования) может в будущем негативно сказаться на этапе сопровождения продукта.

Новые возможности пакета java.uti.concurrent

Платформа Java постоянно развивается, и поэтому к существующей функциональности все время добавляются новые возможности. Иногда новая функциональность берется из уже существующих сторонних библиотек, при этом речь не идет о банальном копировании, а скорее о переосмыслении и доработке уже существующих решений. Подобным способом в версию Java 5 был добавлен пакет java.util.concurrent, включающий в себя множество уже проверенных и хорошо зарекомендовавших себя приемов для параллельного выполнения задач (этот пакет - только одно из множества важных нововведений, представленных в Java 5).

В рамках этой статьи интерес представляют уже готовые к использованию реализации шаблонов WorkerThread и ThreadPool, а также еще один способ реализации задач для параллельного выполнения, кроме упоминавшихся класса Thread и интерфейса Runnable. Ещё в пакете java.util.concurrent находятся два подпакета: java.util.concurrent.locks и java.util.concurrent.atomic, с которыми тоже стоит ознакомиться, так как они значительно упрощают организацию взаимодействия между потоками и параллельного доступа к данным.

Создание задачи с помощью интерфейса java.util.concurrent.Callable

Интерфейс Callable гораздо больше подходит для создания задач, предназначенных для параллельного выполнения, нежели интерфейс Runnable или тем более класс Thread. При этом стоит отметить, что возможность добавить подобный интерфейс появилась только начиная с версии Java 5, так как ключевая особенность интерфейса Callable – это использование параметризованных типов (generics), как показано в листинге 3.

Листинг 3. Создание задачи с помощью интерфейса Callable
1 import java.util.concurrent.Callable;
2 public class CallableSample implements Callable<String>{
3     public String call() throws Exception {
4         if(какое-то условие) {
5             throw new IOException("error during task processing");
6         }
7         System.out.println("task is processing");
8         return "result ";
9     }
10 }

Сразу необходимо обратить внимание на строку 2, где указано, что интерфейс Callable является параметризованным, и его конкретная реализация – класс CallableSample, зависит от типа String. На строке 3 приведена сигнатура основного метода call в уже параметризованном варианте, так как в качестве типа возвращаемого значения также указан тип String. Фактически это означает, что была создана задача, результатом выполнения которой будет объект типа String (см. строку 8). Точно также можно создать задачу, в результате работы которой в методе call будет создаваться и возвращаться объект любого требуемого типа. Такое решение значительно удобнее по сравнению с методом run в интерфейсе Runnable, который не возвращает ничего (его возвращаемый тип – void) и поэтому приходится изобретать обходные пути, чтобы извлечь результат работы задачи.

Еще одно преимущество интерфейса Callable – это возможность «выбрасывать» исключительные ситуации, не оказывая влияния на другие выполняющиеся задачи. На строке 3 указано, что из метода может быть «выброшена» исключительная ситуация типа Exception, что фактически означает любую исключительную ситуацию, так как все исключения являются потомками java.lang.Exception. На строке 5 эта возможность используется для создания контролируемой (checked) исключительной ситуации типа IOException. Метод run интерфейса Runnable вообще не допускал выбрасывания контролируемых исключительных ситуаций, а выброс неконтролируемой (runtime) исключительной ситуации приводил к остановке потока и всего приложения.

Запуск задач с помощью java.util.concurrent.ExecutorService

Облегчив с помощью интерфейса Callable создание задач для параллельного выполнения, пакет java.util.concurrent также берет на себя работу по запуску и остановке потоков. Вместо объекта Thread предлагается использовать объект типа ExecutorService, с помощью которого пользователь может просто поместить задачу в очередь на выполнение и ждать получения результата. Можно сказать, что ExecutorService – это значительно усовершенствованная реализация шаблона WorkerThread.

ExecutorService – это интерфейс, поэтому для выполнения задач используются его конкретные потомки, адаптированные под требования разрабатываемого приложения. Однако программисту нет необходимости создавать собственную реализацию ExecutorService, так как в пакете java.util.concurrent уже присутствуют различные варианты реализации ExecutorService. Доступ к ним можно получить через статические методы служебного класса Executors, метод которого newFixedThreadPool возвращает объект типа ExecutorService со встроенной поддержкой шаблона ThreadPool. Также в классе Executors есть и другие методы для создания объектов ExecutorService с различными свойствами.

Наибольший интерес в ExecutorService представляет метод submit, через который задача ставится в очередь на выполнение. На вход этот метод принимает объект типа Callable или Runnable, а возвращает некий параметризованный объект типа Future. Этот объект можно использовать для доступа к результату выполнения задачи, который будет возвращен из метода call соответствующего Callable-объекта. При этом через объект Future можно проверить, закончено ли уже выполнение задачи – с помощью метода isDone и через метод get получить доступ к результату или исключительной ситуации, если в процессе выполнения задачи произошла ошибка.

Таким образом, при запуске задач с помощью классов из пакета java.util.concurrent не требуется прибегать к низкоуровневой поточной функциональности класса Thread, достаточно создать объект типа ExecutorService с нужными свойствами и передать ему на исполнение задачу типа Callable. Впоследствии можно легко просмотреть результат выполнения этой задачи с помощью объекта Future, как показано в листинге 4.

Листинг 4. Запуск задачи с помощью классов пакета java.util.concurrent
1 public class ExecutorServiceSample {
2     public static void main(String[] args) {
3         //создать ExecutorService на базе пула из пяти потоков
4         ExecutorService es1 = Executors.newFixedThreadPool(5);
5         //поместить задачу в очередь на выполнение
6         Future<String> f1 = es1.submit(new CallableSample());        
7         while(!f1.isDone()) {
8             //подождать пока задача не выполнится
9         }
10        try {
11            //получить результат выполнения задачи
12            System.out.println("task has been completed : " + f1.get());
13        } catch (InterruptedException ie) {           
14            ie.printStackTrace(System.err);
15        } catch (ExecutionException ee) {
16            ee.printStackTrace(System.err);
17        }
18        es1.shutdown();
19    }
20}

Стоит обратить внимание на строку 18, где происходит остановка объекта ExecutorService с помощью метода shutdown. Дело в том, что потоки в объекте ExecutorService не останавливаются сами, как обычно, поэтому их необходимо явно остановить с помощью этого метода, при этом если в ExecutorService находятся невыполненные задачи, то потоки будут остановлены только, когда завершится последняя задача.

Заключение

В этой статье были описаны приемы для параллельного запуска задач в JSE-приложениях с помощью класса Thread и интерфейса Runnable или пакета java.util.concurrent. Несмотря на простоту и известность первого способа, у него есть несколько недостатков, которые становятся заметны по мере «взросления» проекта или программиста, поэтому при разработке новых приложений стоит сразу использовать возможности java.uti.concurrent.

Этот пакет доступен для использования, начиная с версии Java 5, и содержит в себе готовые реализации известных шаблонов проектирования WorkerThread и ThreadPool, а также классы, устраняющие другие недостатки классической модели многопоточного программирования. Поэтому дополнительное время, затраченное на изучение пакета java.util.concurrent, приведет к сокращению затрат на разработку следующих проектов и написанию более качественного кода.


Ресурсы для скачивания


Похожие темы


Комментарии

Войдите или зарегистрируйтесь для того чтобы оставлять комментарии или подписаться на них.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=767420
ArticleTitle=Вселенная Java: Часть 2. Выполнение задач в многопоточном режиме
publish-date=10252011