JavaRush /Java блог /Архив info.javarush /Правильное применение параллелизма.
mbllllb
31 уровень

Правильное применение параллелизма.

Статья из группы Архив info.javarush
Оригинал статьи: http://www.javacodegeeks.com/2015/09/concurrency-best-practices.html Автор: Andrey Redko Эта статья является частью курса “Advanced Java” на JavaCodeGeeks. Данный курс разработан для того, чтобы научить Вас использовать Java наиболее эффективно. В нем обсуждаются сложные темы, включающие такие понятия, как “Создание объекта” (object creation), “Параллелизм” (concurrency), “Сереализация”, “Рефлексия” и многое другое. Он Будет вашим проводником на пути к мастерству в Java! Ознакомьтесь с ним тут!

Содержание

  1. Введение
  2. Нити и группы нитей
  3. Параллелизм, синхронизация и неизменность.
  4. Futures, Executors (исполнители) и Thread Pools (объединение нитей).
  5. Блокировка.
  6. Планировщик Нити

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

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

2. Нити и группы нитей

Нити являются фундаментальными “строительными блоками” многонитиевых приложений в Java. Нити иногда называют “лёгкими процессами” и они позволяют выполнять множество потоков одновременно. Каждое приложение в Java имеет как минимум одну нить, которая называется “главная нить” (main thread). Каждая Java-нить существует только внутри JVM и может не являться нитью операционной системы. Нити в Java являются объектами класса Thread. Как правило, не рекомендуется создавать нити и управлять ими используя непосредственно экземпляры класса Thread (Executors и Thread Pools, рассмотреные в разделе 4, предоставляют лучшие возможности для этого), однако, это очень легко сделать: public static void main(String[] args) { new Thread( new Runnable() { @Override public void run() { // Здесь что-то происходит } } ).start(); } Или тот же пример с использованием lambda-функций в Java 8: public static void main(String[] args) { new Thread( () -> { /* Здесь что-то происходит */ } ).start(); } Несмотря на то, что создание новой нити выглядит очень просто, нить проходит сложный жизненный цикл и может иметь одно из следующих состояний: Состояние нити_____________Описание NEW________________________Нить еще не запущена. RUNNABLE___________________Нить выполняется в JVM. BLOCKED____________________Нить заблокирована монитором. WAITING____________________Нить ожидает, пока другая нить завершит определенное действие. TIMED_WAITING______________Нить ожидает, пока другая нить завершит определенное действие. TERMINATED_________________Нить завершилась //Таблица 1. Не все состояния нити понятны прямо сейчас, но позже, в руководстве, мы пройдемся по большинству из них и обсудим, какие события вызывают у нити то или иное состояние. Нити могут быть собраны в группы. Группа нитей представляет собой множество нитей, которое так же может включать в себя другие группы нитей (таким образом, формируется дерево). Группы нитей создавались как полезный инструмент, однако, в наши дни они не рекомендуются для использования как исполнители и пулы нитей (пожалуйста, посмотрите главу 4)являются намного лучшей альтернативой.

3. Параллелизм, синхронизация и неизменность

В практически каждом Java-приложении, параллельное выполнение нитей требует их взаимодействия между собой при доступе к общим данным. Чтение этих данных не является большой проблемой, однако, их несогласованные изменение - это прямая дорога к катастрофе (так называемое состояние гонки). Это и есть тот самый случай, где необходима синхронизация. Синхронизация - это механизм, обеспечивающий защиту специальных защищенных (синхронизированных - synchronized) блоков кода от одновременного воздействия нескольких параллельно работающих нитей. Если одна из нитей начинает выполнять синхронизированный блок в коде программы, другие нити, пытающиеся выполнить тот же блок, должны будут ждать, пока первая нить не закончит работу с ним. Java поддерживает синхронизацию "из коробки", по ключевому слову synchronized. Это ключевое слово может быть применено к обычным методам класса, статическим методам или использоваться для произвольных блоков кода гарантируя, что нити не смогут работать с ними одновременно. Например: public synchronized void performAction() { // Здесь что-то происходит } public static synchronized void performClassAction() { // Здесь что-то происходит } Или, в качестве альтернативы, пример синхронизации блока кода: public void performActionBlock() { synchronized( this ) { // Здесь что-то происходит } } Существует еще один очень важный эффект ключевого слова synchronized: оно автоматически устанавливает happens-before взаимосвязь (http://en.wikipedia.org/wiki/Happened-before) с любым методом или блоком кода того же объекта, помеченным словом synchronized. Это гарантирует то, что изменения состояний объекта будут видны всем нитям. Пожалуйста, обратите внимание, что конструкторы не могут быть синхронизированы (использование ключевого слова synchronized для конструктора вызовет ошибку компиляции), потому, что только нить создающая экземпляр класса может иметь доступ к нему, пока он (экземпляр) строится. В Java синхронизация основана на внутренней сущности, под названием монитор(monitor) (или внутренний монитор-блокировщик, http://en.wikipedia.org/wiki/Monitor_(synchronization)). Монитор обеспечивает единоличный доступ к состоянию объекта и устанавливает отношение happens-before. Когда какая-нибудь нить вызывает synchronized-метод, она автоматически блокирует монитор (внутренний блокиратор) этого метода (или класса, в случае static-методов) и освобождает его, как только метод завершает работу. Наконец, синхронизация в Java является Реентерабельной: это значит, что нить может заблокировать уже заблокированный код. Реентерабельность существенно упрощает модель прораммирования многонитевых приложений, потому что так у нити меньше шансов заблокировать саму себя. Как видите, параллелизм делает Java-приложение сложным. Однако, есть выход: неизменность(immutability). Мы говорили об этом уже много раз, но это правда очень важно для многонитевых приложений: неизменные объекты не требуют синхронизации, потому что они никогда не будут загружены в более чем одну нить.

4. Futures, Executors (исполнители) и Thread Pools (пулы нитей)

В Java легко создавать новые нити, но управлять ими очень непросто. Стандартная библиотека Java предоставляет чрезвычайно полезные абстракции в виде executors (исполнителей) и thread pools (пулов нитей), ориентированных на упрощение управления нитями. По существу, в своей простейшей реализации thread pool создает и поддерживает список нитей, готовых к немедленному использованию. Приложение, вместо постоянного создания новых нитей, просто берет одно (или столько, сколько необходимо) из пула. Нить, закончившая работу, возвращается обратно в пул и становится доступной для следующих задач. Хотя и возможно работать с пулом нитей непосредственно, стандартная библиотека Java предоставляет шаблоны проектирования, содержащие множество уже готовых методов для создания наиболее распространенных конфигураций пулов нитей. Например, фрагмент кода ниже создает пул нитей с фиксированным количеством нитей(10): ExecutorService executor = Executors.newFixedThreadPool( 10 ); Исполнители (Executors) могут быть использованы для разгрузки любой задачи, т.к. она будет выполняться в отдельной нити из пула нитей (на заметку: это не рекомендуется для использования с задачами, выполнение которых требует много времени). Шаблоны проектирования исполнителей позволяют настраивать поведение, лежащее в основе пулов нитей и поддерживают следующие конфигурации: Метод_______________________________________Описание Executors.newCachedThreadPool_______________Создание пула нитей, который создает новую ____________________________________________нить когда необходимо, при этом используя ранее ____________________________________________созданные нити, если они доступны. Executors.newFixedThreadPool________________Создание пула нитей содержащего фиксированное ____________________________________________число потоков, работающих в общей, ____________________________________________неограниченной очереди. Executors.newScheduledThreadPool____________Создание пула нитей который может управлять ____________________________________________запуском по указанной задержке или выполняться ____________________________________________периодически. Executors.newSingleThreadExecutor___________Создание Исполнителя, который использует ____________________________________________одну работающую нить с неограниченной очередью. Executors.newSingleThreadScheduledExecutor__Создание однонитевого исполнителя ____________________________________________который может управлять запуском по указанной ____________________________________________задержке или выполняться периодически. //Таблица 2 В некоторых случаях результат выполнения не очень важен, тогда исполнитель поддерживает семантику fire-and-forget, например: executor.execute( new Runnable() { @Override public void run() { // Здесь что-то происходит } } ); Эквивалент на Java 8 намного короче: executor.execute( new Runnable() { @Override public void run() { // Здесь что-то происходит } } ); Но если результат выполнения важен, стандартная библиотека Java предоставляет другую абстракцию, отражающую вычисления, которые произойдут в какой-то момент в будущем, под названием Future<Т>. Например: Future< Long > result = executor.submit( new Callable< Long >() { @Override public Long call() throws Exception { // Здесь что-то происходит return ...; } } ); Результат <Future<Т> не может быть доступен немедленно, так что приложение должно получить его используя медоты get(…)-типа. Например: Long value = result.get( 1, TimeUnit.SECONDS ); Если результат вычислений не доступен через указанное время, будет выброшено исключение TimeoutException. Существует перегруженная версия get(), которая может ожидать вечно, но, пожалуйста, используйте используйте ту, с таймаутом. Начиная с релиза Java 8, разработчики получили еще один вариант Future<Т> - CompletableFuture<Т>, который поддерживает дополнительные функции и действия, которые вызываются после её завершения. И не только это. С введением stream'ов, Java 8 ввела очень простой способ выполнять параллельную обработку коллекций, используя метод parallelStream(), например: final Collection< String > strings = new ArrayList<>(); // Some implementation here final int sumOfLengths = strings.parallelStream() .filter( str -> !str.isEmpty() ) .mapToInt( str -> str.length() ) .sum(); Простота, которую исполнители и параллельные потоки привнесли в Java, делают параллелизм и параллельное программирование в Java намного легче. Но есть проблема: неконтролируемое создание пулов нитей и параллельных потоков может свести производительность приложения на нет, поэтому важно управлять ими грамотно.

5. Блокировка

Дополнительно к мониторам, Java поддерживает возвратное взаимное исключение блокировок.
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ