Эта статья является частью нашего Академического курса под названием Advanced Java .
Этот курс призван помочь вам наиболее эффективно использовать Java. В нем обсуждаются сложные темы, включая создание объектов, параллелизм, сериализацию, рефлексию и многое другое. Он проведет вас через ваше путешествие в мастерство Java! Проверьте это здесь !
Содержание
- 1. Введение
- 2. Темы и группы потоков
- 3. Параллельность, синхронизация и неизменность
- 4. Фьючерсы, исполнители и пулы потоков
- 5. Замки
- 6. Планировщики тем
- 7. Атомные операции
- 8. Параллельные Коллекции
- 9. Изучите стандартную библиотеку Java
- 10. Использование синхронизации с умом
- 11. Ждать / Уведомлять
- 12. Устранение проблем параллелизма
- 13. Что дальше
- 14. Скачать
1. Введение
Многопроцессорная и многоядерная аппаратные архитектуры сильно влияют на модель проектирования и исполнения приложений, которые на них работают в настоящее время. Чтобы использовать всю мощь доступных вычислительных блоков, приложения должны быть готовы поддерживать несколько потоков выполнения, которые работают одновременно и конкурируют за ресурсы и память. Параллельное программирование создает множество проблем, связанных с доступом к данным и недетерминированным потоком событий, которые могут привести к неожиданным сбоям и странным сбоям.
В этой части руководства мы рассмотрим, что Java может предложить разработчикам, чтобы помочь им создавать надежные и безопасные приложения в параллельном мире.
2. Темы и группы потоков
Потоки являются основными строительными блоками параллельных приложений в Java. Потоки иногда называют облегченными процессами, и они позволяют нескольким потокам выполнения выполняться одновременно. Каждое отдельное приложение в Java имеет по крайней мере один поток, называемый основным потоком . Каждый поток Java существует только внутри JVM и может не отражать поток операционной системы.
Потоки в Java являются экземплярами класса Thread
. Как правило, не рекомендуется напрямую создавать потоки и управлять ими, используя экземпляры класса Thread (исполнители и пулы потоков, описанные в разделе Futures и Executors, предоставляют лучший способ сделать это), однако это очень легко сделать:
1
2
3
4
5
6
7
8
|
public static void main(String[] args) { new Thread( new Runnable() { @Override public void run() { // Some implementation here } } ).start(); } |
Или тот же пример с использованием лямбда-функций Java 8:
1
2
3
|
public static void main(String[] args) { new Thread( () -> { /* Some implementation here */ } ).start(); } |
Тем не менее создание нового потока в Java выглядит очень просто, потоки имеют сложный жизненный цикл и могут находиться в одном из следующих состояний (поток может находиться только в одном состоянии в данный момент времени).
Не все состояния потока сейчас ясны, но позже в уроке мы рассмотрим большинство из них и обсудим, какие события приводят к тому, что поток находится в том или ином состоянии.
Нити могут быть собраны в группы. Группа потоков представляет набор потоков и может также включать другие группы потоков (таким образом, формируя дерево). Группы потоков, предназначенные, чтобы быть хорошей функцией, однако они не рекомендуются для использования в настоящее время, поскольку исполнители и пулы потоков (см. Futures, Executors и Thread Pools ) являются намного лучшими альтернативами.
3. Параллельность, синхронизация и неизменность
Практически в каждом приложении Java несколько работающих потоков должны взаимодействовать друг с другом и получать доступ к общим данным. Чтение этих данных не является большой проблемой, однако несогласованная их модификация — прямой путь к катастрофе (так называемые гоночные условия). Это момент, когда начинается синхронизация . Синхронизация — это механизм, гарантирующий, что несколько одновременно работающих потоков не выполнят одновременно специально защищенный (синхронизированный) блок кода приложения. Если один из потоков начал выполнять синхронизированный блок кода, любой другой поток, пытающийся выполнить тот же блок, должен дождаться завершения первого из них.
Язык Java имеет встроенную поддержку синхронизации в форме synchronized
ключевого слова. Это ключевое слово может быть применено к методам экземпляра, статическим методам или использоваться вокруг произвольных блоков выполнения и гарантирует, что только один поток за раз сможет вызвать его. Например:
1
2
3
4
5
6
7
|
public synchronized void performAction() { // Some implementation here } public static synchronized void performClassAction() { // Some implementation here } |
Или, альтернативно, пример, который использует синхронизированный с кодом блок:
1
2
3
4
5
|
public void performActionBlock() { synchronized ( this ) { // Some implementation here } } |
Есть еще один очень важный эффект synchronized
ключевого слова: оно автоматически устанавливает отношение « до и после» ( http://en.wikipedia.org/wiki/Happened-before ) с любым вызовом synchronized
метода или блока кода для того же объекта. Это гарантирует, что изменения состояния объекта видны всем потокам.
Обратите внимание, что конструкторы не могут быть синхронизированы (использование ключевого слова synchronized
с конструктором вызывает ошибку компилятора), потому что только поток, который создает экземпляр, имеет к нему доступ во время конструирования экземпляра.
В Java синхронизация строится вокруг внутренней сущности, известной как монитор (или встроенная блокировка / монитор, http://en.wikipedia.org/wiki/Monitor_(synchronization) ). Монитор обеспечивает эксклюзивный доступ к состоянию объекта и устанавливает отношения « до того, как это произойдет» . Когда какой-либо поток вызывает synchronized
метод, он автоматически получает встроенную (мониторную) блокировку для экземпляра этого метода (или класса в случае статических методов) и освобождает его, как только метод возвращается.
И наконец, синхронизация Java является реентерабельной : это означает, что поток может получить блокировку, которой он уже владеет. Reentrancy значительно упрощает модель программирования параллельных приложений, поскольку потоки имеют меньше шансов заблокировать себя.
Как вы можете видеть, параллелизм вносит большую сложность в приложения Java. Тем не менее, есть лекарство: неизменность . Мы уже говорили об этом много раз, но это действительно очень важно для многопоточных приложений, в частности: неизменяемые объекты не нуждаются в синхронизации, поскольку они никогда не обновляются более чем одним потоком.
4. Фьючерсы, исполнители и пулы потоков
Создать новые потоки в Java легко, но управлять ими действительно сложно. Стандартная библиотека Java предоставляет чрезвычайно полезные абстракции в форме исполнителей и пулов потоков, предназначенных для упрощения управления потоками.
По сути, в своей простейшей реализации пул потоков создает и поддерживает список потоков, готовых к немедленному использованию. Приложения вместо того, чтобы каждый раз создавать новый поток, просто заимствуют один (или столько, сколько необходимо) из пула. Как только заимствованный поток завершает свою работу, он возвращается обратно в пул и становится доступным для выполнения следующей задачи.
Хотя пулы потоков можно использовать напрямую, стандартная библиотека Java предоставляет фасад исполнителей, который имеет набор заводских методов для создания часто используемых конфигураций пулов потоков. Например, фрагмент кода ниже создает пул потоков с фиксированным числом потоков (10):
1
|
ExecutorService executor = Executors.newFixedThreadPool( 10 ); |
Исполнители могут использоваться для разгрузки любой задачи, поэтому она будет выполняться в отдельном потоке от пула потоков (как примечание, не рекомендуется использовать исполнители для долгосрочных задач). Фасад исполнителей позволяет настраивать поведение базового пула потоков и поддерживает следующие конфигурации:
В некоторых случаях результат выполнения не очень важен, поэтому исполнители поддерживают семантику « забей и забудь» , например:
1
2
3
4
5
6
|
executor.execute( new Runnable() { @Override public void run() { // Some implementation here } } ); |
Эквивалентный пример Java 8 гораздо более лаконичен:
1
2
3
|
executor.execute( () -> { // Some implementation here } ); |
Но если важен результат выполнения, стандартная библиотека Java предоставляет другую абстракцию для представления вычислений, которая произойдет в какой-то момент в будущем, которая называется Future<T>
. Например:
1
2
3
4
5
6
7
|
Future< Long > result = executor.submit( new Callable< Long >() { @Override public Long call() throws Exception { // Some implementation here return ...; } } ); |
Результат Future<T>
может быть недоступен сразу, поэтому приложение должно ожидать его, используя семейство методов get(…)
. Например:
1
|
Long value = result.get( 1 , TimeUnit.SECONDS ); |
Если результат вычисления недоступен в течение заданного времени ожидания, TimeoutException
исключение TimeoutException
. Существует перегруженная версия get()
которая ждет вечно, но, пожалуйста, предпочтительнее использовать ту с тайм-аутом.
После выпуска Java 8 у разработчиков появилась еще одна версия Future<T>
, CompletableFuture<T>
, которая поддерживает дополнительные функции и действия, которые запускаются после его завершения. Мало того, что с появлением потоков в Java 8 представлен простой и очень простой способ выполнения параллельной обработки коллекции с использованием метода parallelStream()
, например:
1
2
3
4
5
6
7
|
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 поддерживает повторяющиеся блокировки взаимного исключения (с тем же базовым поведением и семантикой, что и у блокировки монитора, но с большими возможностями). Эти блокировки доступны через класс ReentrantLock
из пакета java.util.concurrent.locks
. Вот типичная идиома использования блокировки:
01
02
03
04
05
06
07
08
09
10
11
|
private final ReentrantLock lock = new ReentrantLock(); public void performAction() { lock.lock(); try { // Some implementation here } finally { lock.unlock(); } } |
Обратите внимание, что любая блокировка должна быть явно снята путем вызова метода unlock()
(для synchronized
методов и блоков выполнения компилятор Java изнутри выдает инструкции по снятию блокировки монитора). Если блокировки требуют написания большего количества кода, почему они лучше, чем мониторы? Ну, по нескольким причинам, но самое главное, блокировки могут использовать тайм-ауты в ожидании получения и быстро выходить из строя (мониторы всегда ждут бесконечно и не имеют возможности указать желаемый тайм-аут). Например:
1
2
3
4
5
6
7
8
9
|
public void performActionWithTimeout() throws InterruptedException { if ( lock.tryLock( 1 , TimeUnit.SECONDS ) ) { try { // Some implementation here } finally { lock.unlock(); } } } |
Теперь, когда у нас достаточно знаний о мониторах и блокировках, давайте обсудим, как их использование влияет на состояния потоков.
Когда какой-либо поток ожидает блокировки (полученной другим потоком) с помощью вызова метода lock()
, он находится в состоянии WAITING
. Однако когда какой-либо поток ожидает блокировки (полученной другим потоком) с помощью tryLock()
метода tryLock()
с тайм-аутом, он находится в состоянии TIMED_WAITING
. Напротив, когда какой-либо поток ожидает монитор (полученный другим потоком) с использованием synchronized
метода или исполнительного блока, он находится в состоянии BLOCKED
.
Примеры, которые мы видели до сих пор, довольно просты, но управление блокировками действительно сложно и полно подводных камней. Самый печально известный из них — это тупик: ситуация, когда два или более конкурирующих потока ждут продолжения друг друга и, следовательно, никогда не делают этого. Блокировки обычно возникают, когда задействовано более одной блокировки или блокировки монитора. JVM часто может обнаруживать взаимоблокировки в запущенных приложениях и предупреждать разработчиков (см. Раздел « Устранение неполадок параллелизма »). Канонический пример тупика выглядит так:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
private final ReentrantLock lock1 = new ReentrantLock(); private final ReentrantLock lock2 = new ReentrantLock(); public void performAction() { lock1.lock(); try { // Some implementation here try { lock2.lock(); // Some implementation here } finally { lock2.unlock(); } // Some implementation here } finally { lock1.unlock(); } } public void performAnotherAction() { lock2.lock(); try { // Some implementation here try { lock1.lock(); // Some implementation here } finally { lock1.unlock(); } // Some implementation here } finally { lock2.unlock(); } } |
Метод performAction()
пытается получить lock1
а затем lock2
, в то время как метод performAnotherAction()
делает это в другом порядке, lock2
и затем lock1
. Если в результате выполнения программы эти два метода вызываются в одном и том же экземпляре класса в двух разных потоках, очень вероятно lock2
: первый поток будет бесконечно долго lock2
полученной вторым потоком, а второй поток будет ждать бесконечно для lock1
приобретенного первым.
6. Планировщики тем
В JVM планировщик потока определяет, какой поток должен быть запущен и как долго. Все потоки, созданные Java-приложениями, имеют приоритет, который в основном влияет на алгоритм планирования потоков, когда он принимает решение, когда поток должен быть запланирован и его временной интервал. Однако эта функция имеет репутацию непереносимой (как, в основном, каждая хитрость, которая зависит от конкретного поведения планировщика потока).
Класс Thread
также предоставляет другой способ вмешательства в реализацию планирования потоков с помощью метода yield()
. Он намекает планировщику потока, что текущий поток готов отдать свое текущее использование процессорного времени (а также имеет репутацию непереносимого).
В общем, полагаться на детали реализации планировщика потоков Java не очень хорошая идея. Вот почему исполнители и пулы потоков из стандартной библиотеки Java (см. Раздел « Фьючерсы, исполнители и пулы потоков ») стараются не раскрывать эти непереносимые детали разработчикам (но все же оставляют способ сделать это, если это действительно необходимо). ). Ничто не работает лучше, чем тщательный дизайн, который пытается учесть реальное оборудование, на котором работает приложение (например, количество доступных процессоров и ядер можно легко получить с помощью класса Runtime
).
7. Атомные операции
В многопоточном мире существует особый набор инструкций, называемых сравнением и обменом (CAS). Эти инструкции сравнивают свои значения с заданными и, только если они совпадают, устанавливают новые заданные значения. Это делается как отдельная атомарная операция, которая обычно не блокируется и очень эффективна.
Стандартная библиотека Java имеет большой список классов, поддерживающих атомарные операции, все они находятся в пакете java.util.concurrent.atomic
.
Релиз Java 8 расширяет java.util.concurrent.atomic
новым набором атомарных операций (аккумуляторы и сумматоры).
8. Параллельные Коллекции
Общие коллекции, доступные и изменяемые несколькими потоками, являются скорее правилом, чем исключением. Стандартная библиотека Java предоставляет несколько полезных статических методов в классе Collections
которые делают любую существующую коллекцию поточно-ориентированной. Например:
1
2
3
4
5
|
final Set< String > strings = Collections.synchronizedSet( new HashSet< String >() ); final Map< String, String > keys = Collections.synchronizedMap( new HashMap< String, String >() ); |
Однако возвращаемые обертки коллекции общего назначения являются поточно-ориентированными, что зачастую является не лучшим вариантом, поскольку они обеспечивают довольно посредственную производительность в реальных приложениях. Вот почему стандартная библиотека Java включает в себя богатый набор классов коллекций, настроенных на параллелизм. Ниже приведен список наиболее часто используемых из них, все они размещены в пакете java.util.concurrent
.
Эти классы специально предназначены для использования в многопоточных приложениях. Они используют множество методов, чтобы сделать одновременный доступ к коллекции максимально эффективным, и являются рекомендуемой заменой для synchronized
оболочек коллекции.
9. Изучите стандартную библиотеку Java
Пакеты java.util.concurrent
и java.util.concurrent.locks
являются настоящими жемчужинами для разработчиков Java, которые пишут параллельные приложения. Так как там много классов, в этом разделе мы рассмотрим наиболее полезные из них, но, пожалуйста, не стесняйтесь обращаться к официальной документации Java и исследовать их все.
К сожалению, Java-реализация ReentrantReadWriteLock
была не так ReentrantReadWriteLock
как и в Java 8, есть новый вид блокировки:
10. Использование синхронизации с умом
Блокировка и synchronized
ключевое слово являются мощными инструментами, которые помогают поддерживать согласованность модели данных и состояния программы в многопоточных приложениях. Однако их неразумное использование приводит к конфликту потоков и может значительно снизить производительность приложений. С другой стороны, неиспользование примитивов синхронизации может (и будет) приводить к странному состоянию программы и поврежденным данным, что в конечном итоге приводит к сбою приложения. Так что баланс важен.
Совет состоит в том, чтобы попытаться использовать блокировки и / или synchronized
там, где это действительно необходимо. При этом убедитесь, что блокировки снимаются как можно скорее, а исполнительные блоки, требующие блокировки или синхронизации, остаются минимальными. Эти методы, по крайней мере, должны помочь уменьшить раздор, но не устранят его.
В последние годы появилось много так называемых алгоритмов без блокировки и структуры данных (например, атомарные операции в Java из раздела « Атомные операции »). Они обеспечивают гораздо лучшую производительность по сравнению с эквивалентными реализациями, которые построены с использованием примитивов синхронизации.
Полезно знать, что JVM имеет несколько оптимизаций времени выполнения, чтобы устранить блокировку, когда это может быть необязательно. Наиболее известна предвзятая блокировка : оптимизация, которая повышает производительность незапланированной синхронизации за счет исключения операций, связанных с примитивами синхронизации Java (более подробную информацию см. По адресу http://www.oracle.com/technetwork/java/6-performance-137236. .html # 2.1.1 ).
Тем не менее JVM делает все возможное, устраняя ненужную синхронизацию в приложении — гораздо лучший вариант. Чрезмерное использование синхронизации отрицательно сказывается на производительности приложений, поскольку потоки будут тратить дорогостоящие циклы ЦП, конкурирующие за ресурсы, вместо того, чтобы выполнять реальную работу.
11. Ждать / Уведомлять
До введения утилит параллелизма в стандартной библиотеке Java ( java.util.concurrent
) использование методов wait()/notify()/notifyAll()
было способом установления связи между потоками в Java. Все эти методы должны вызываться, только если поток владеет монитором рассматриваемого объекта . Например:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
private Object lock = new Object(); public void performAction() { synchronized ( lock ) { while ( <condition> ) { // Causes the current thread to wait until // another thread invokes the notify() or notifyAll() methods. lock.wait(); } // Some implementation here } } |
Метод wait()
снимает блокировку монитора, которую удерживает текущий поток, поскольку ожидаемое условие еще не выполнено (метод wait () должен вызываться в цикле и никогда не вызываться вне цикла ). Следовательно, другой поток, ожидающий на том же мониторе, получает шанс на запуск. Когда этот поток завершен, он должен вызвать один из методов notify()/notifyAll()
чтобы разбудить поток (или потоки), ожидающие блокировки монитора. Например:
1
2
3
4
5
6
7
8
|
public void performAnotherAction() { synchronized ( lock ) { // Some implementation here // Wakes up a single thread that is waiting on this object's monitor. lock.notify(); } } |
Разница между notifyAll()
notify()
и notifyAll()
заключается в том, что первый notifyAll()
один поток, а второй — все ожидающие потоки (которые начинают бороться за блокировку монитора).
Идиома wait()/notify()
не рекомендуется использовать в современных Java-приложениях. Это не только сложно, но и требует соблюдения ряда обязательных правил. Таким образом, это может вызвать незначительные ошибки в работающей программе, которые будут очень трудоемкими и длительными для исследования. У java.util.concurrent
есть что предложить, чтобы заменить wait()/notify()
гораздо более простыми альтернативами (которые, скорее всего, будут иметь гораздо лучшую производительность в реальном сценарии).
12. Устранение проблем параллелизма
Много-много чего может пойти не так в многопоточных приложениях. Воспроизведение проблем становится кошмаром. Отладка и устранение неполадок может занять часы и даже дни или недели. Java Development Kit (JDK) включает в себя несколько инструментов, которые, по крайней мере, могут предоставить некоторые подробности о потоках приложения и их состояниях, а также диагностировать условия взаимоблокировки (см. Раздел « Потоки и группы потоков» и « Блокировки» ). Это хорошая точка для начала. Эти инструменты (но не ограничиваются ими):
- JVisualVM ( http://docs.oracle.com/javase/7/docs/technotes/tools/share/jvisualvm.html )
- Java Mission Control ( http://docs.oracle.com/javacomponents/jmc.htm )
- jstack ( https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstack.html )
13. Что дальше
В этой части мы рассмотрели очень важный аспект современных программных и аппаратных платформ — параллелизм. В частности, мы увидели, какие инструменты Java как язык и его стандартная библиотека предлагают разработчикам, чтобы помочь им справиться с параллелизмом и асинхронным выполнением. В следующей части руководства мы рассмотрим методы сериализации в Java.
14. Скачать
Вы можете скачать исходный код этого урока здесь: advanced-java-part-9