Параллелизм в Java

Материал из Википедии — свободной энциклопедии
Перейти к навигации Перейти к поиску

Язык программирования Java и JVM (Java Virtual Machine) разработаны с поддержкой параллельных вычислений, и все вычисления выполняются в контексте потока. Несколько потоков могут совместно использовать объекты и ресурсы; каждый поток выполняет свои инструкции (код), но потенциально может получить доступ к любому объекту в программе. В обязанности программиста входит координация (или «синхронизация») потоков во время операций чтения и записи разделяемых объектов. Синхронизация потоков нужна для того, чтобы гарантировать, что одновременно к объекту может обращаться только один поток, и чтобы предотвратить обращение потоков к неполностью обновленным объектам в то время, как с ними работает другой поток. В языке Java есть встроенные конструкции поддержки синхронизации потоков.

Процессы и потоки

[править | править код]

Большинство реализаций виртуальной машины Java используют единственный процесс для выполнения программы и в языке программирования Java понятие параллельные вычисления чаще всего связывают с потоками. Потоки иногда называют лёгкими процессами.

Объекты потока

[править | править код]

Потоки разделяют между собой ресурсы процесса, в частности память и открытые файлы. Такой подход ведёт к эффективной, но потенциально проблематичной коммуникации. Каждое приложение имеет хотя бы один выполняющийся поток. Тот поток, с которого начинается выполнение программы, называется главным или основным. Главный поток способен создавать дополнительные потоки в виде объектов Runnable или Callable. (Интерфейс Callable похож на Runnable тем, что оба они разработаны для классов, экземпляры которых будут выполняться в отдельном потоке. Runnable, однако, не возвращает результата и не может выбросить проверяемое исключение.)

Каждый поток может быть запланирован для выполнения на отдельном ядре ЦП, использовать квантование времени на одноядерном процессоре или использовать квантование времени на нескольких процессорах. В последних двух случаях система будет периодически переключаться между потоками, поочередно давая выполняться то одному, то другому потоку. Такая схема называется псевдо-параллелизмом. Нет универсального решения, которое сказало бы как именно потоки Java будут преобразованы в нативные потоки ОС. Это зависит от конкретной реализации JVM.

В языке Java поток представляется в виде объекта-потомка класса Thread. Этот класс инкапсулирует стандартные механизмы работы с потоком. Потоками можно управлять либо напрямую, либо посредством абстрактных механизмов, таких как Executor и коллекции из пакета java.util.concurrent.

Запуск потока

[править | править код]

Запустить новый поток можно двумя способами:

  • Реализацией интерфейса Runnable
public class HelloRunnable implements Runnable {
    public void run() {
        System.out.println("Привет из потока!");
    }
    public static void main(String[] args) {
        (new Thread(new HelloRunnable())).start();
    }
}
  • Наследованием от класса Thread
public class HelloThread extends Thread {
    public void run() {
        System.out.println("Привет из потока!");
    }
    public static void main(String[] args) {
        (new HelloThread()).start();
    }
}

Прерывания

[править | править код]

Прерывание — указание потоку, что он должен прекратить текущую работу и сделать что-то ещё. Поток может послать прерывание вызовом метода interrupt() у объекта Thread, если нужно прервать ассоциированный с ним поток. Механизм прерывания реализован с использованием внутреннего флага interrupt status (флаг прерывания) класса Thread. Вызов Thread.interrupt() поднимает этот флаг. По соглашению, любой метод, завершающийся выбрасыванием InterruptedException, сбрасывает флаг прерывания. Проверить же, установлен ли этот флаг, можно двумя способами. Первый способ — вызвать метод bool isInterrupted() объекта потока, второй — вызвать статический метод bool Thread.interrupted(). Первый метод возвращает состояние флага прерывания и оставляет этот флаг нетронутым. Второй метод возвращает состояние флага и сбрасывает его. Заметьте, что Thread.interrupted() — статический метод класса Thread, и его вызов возвращает значение флага прерывания того потока, из которого он был вызван.

Ожидание завершения

[править | править код]

В Java предусмотрен механизм, позволяющий одному потоку ждать завершения выполнения другого. Для этого используется метод Thread.join().

В Java процесс завершается тогда, когда завершается последний его поток. Даже если метод main() уже завершился, но ещё выполняются порождённые им потоки, система будет ждать их завершения. Однако это правило не относится к особому виду потоков — демонам. Если завершился последний обычный поток процесса, и остались только потоки-демоны, то они будут принудительно завершены и выполнение процесса закончится. Чаще всего потоки-демоны используются для выполнения фоновых задач, обслуживающих процесс в течение его жизни.

Объявить поток демоном достаточно просто — нужно перед запуском потока вызвать его метод setDaemon(true); проверить, является ли поток демоном, можно вызвав его метод boolean isDaemon().

Исключения

[править | править код]

Выброшенное и необработанное исключение приведёт к завершению потока. Главный поток автоматически напечатает исключение в консоль, а потоки, созданные пользователем, могут сделать это только зарегистрировав обработчик.[1][2]

Модель памяти

[править | править код]

Модель памяти Java [1] описывает взаимодействие потоков через память в языке программирования Java. Зачастую на современных компьютерах код ради скорости выполняется не в том порядке, в котором написан. Перестановка выполняется компилятором, процессором и подсистемой памяти. Язык программирования Java не гарантирует атомарность операций и последовательную консистентность при чтении или записи полей разделяемых объектов. Данное решение «развязывает руки» компилятору и позволяет проводить оптимизации (такие как распределение регистров, удаление общих подвыражений и устранение лишних операций чтения), основанные на перестановке операций доступа к памяти.[3]

Синхронизация

[править | править код]

Коммуникация потоков осуществляется посредством разделения доступа к полям и объектам, на которые ссылаются поля. Данная форма коммуникации является предельно эффективной, но делает возможным возникновение ошибок двух разновидностей: вмешательство в поток (thread interference) и ошибки консистентности памяти (memory consistency errors). Для предотвращения их возникновения существует механизм синхронизации.

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

Для синхронизации потоков в Java используются мониторы, которые являются высокоуровневым механизмом, позволяющим единовременно только одному потоку выполнять блок кода, защищённый монитором. Поведение мониторов рассмотрено в терминах блокировок; с каждым объектом ассоциирована одна блокировка.

Синхронизация имеет несколько аспектов. Наиболее хорошо понимаемым является взаимное исключение (mutual exclusion) — только один поток может владеть монитором, таким образом синхронизация на мониторе означает, что как только один поток входит в synchronized-блок, защищённый монитором, никакой другой поток не может войти в блок, защищённый этим монитором пока первый поток не выйдет из synchronized-блока.

Но синхронизация — это больше чем просто взаимное исключение. Синхронизация гарантирует, что данные, записанные в память до или внутри синхронизированного блока, становятся видимыми для других потоков, которые синхронизируются на том же мониторе. После того как мы выходим из синхронизированного блока, мы освобождаем (release) монитор, что имеет эффект сбрасывания (flush) кэша в оперативную память, так что записи, сделанные нашим потоком, могут быть видимыми для других потоков. Прежде чем мы сможем войти в синхронизированный блок, мы захватываем (acquire) монитор, что имеет эффект объявления недействительными данных локального процессорного кэша (invalidating the local processor cache), так что переменные будут загружены из основной памяти. Тогда мы сможем увидеть все записи, сделанные видимыми предыдущим освобождением (release) монитора. (JSR 133)

Чтение-запись в поле является атомарной операцией, если поле объявлено volatile либо защищено уникальной блокировкой, получаемой перед любым чтением-записью.

Блокировки и synchonized-блоки

[править | править код]

Эффект взаимного исключения и синхронизации потоков достигается вхождением в synchronized-блок или метод, неявно получающий блокировку, или получением блокировки явным образом (таким как ReentrantLock из пакета java.util.concurrent.locks). Оба подхода оказывают одинаковое влияние на поведение памяти. Если все попытки доступа к некоторому полю защищены одной и той же блокировкой, то операции чтения-записи этого поля являются атомарными.

Применительно к полям ключевое слово volatile гарантирует:

  1. (Во всех версиях Java) Доступы к volatile-переменной упорядочены глобально. Это означает, что каждый поток, обращающийся к volatile-полю, прочитает его значение перед тем как продолжить вместо того, чтобы (по возможности) использовать закешированное значение. (Доступы к volatile-переменным не могут быть переупорядочены друг с другом, но они могут быть переупорядочены с доступами к обычным переменными. Это сводит на нет полезность volatile-полей как средства передачи сигнала от одного потока к другому.)
  2. (В Java 5 и более поздних) Запись в volatile-поле имеет тот же эффект для памяти, что и освобождение монитора (англ. monitor release), а чтение — тот же, что и захват (англ. monitor acquire). Доступ к volatile-полю устанавливает отношение «Выполняется прежде» (англ. happens before).[4] В сущности, это отношение является гарантией того, что всё, что было видимо для потока A, когда он писал в volatile-поле f, становится видимым для потока B, когда он прочтёт f.

Volatile-поля являются атомарными. Чтение из volatile-поля имеет тот же эффект, что и получение блокировки: данные в рабочей памяти объявляются недействительными, значение volatile-поля заново читается из памяти. Запись в volatile-поле имеет тот же эффект для памяти, что и освобождение блокировки: volatile-поле немедленно записывается в память.

Финальные поля

[править | править код]

Поле, которое объявлено final, называется финальным и не может быть изменено после инициализации. Финальные поля объекта инициализируются в его конструкторе. Если конструктор соответствует определённым простым правилам, то корректное значение финального поля будет видимо для остальных потоков без синхронизации. Простое правило: ссылка this не должна покинуть конструктор до его завершения.

Начиная с JDK 1.2, в Java включен стандартный набор классов-коллекций Java Collections Framework.

Даг Ли, который также участвовал в реализации Java Collections Framework, разработал пакет concurrency, включающий в себя несколько примитивов синхронизации и большое количество классов, относящихся к коллекциям.[5] Работа над ним была продолжена как часть JSR 166[6] под председательством Дага Ли.

Релиз JDK 5.0 включил много дополнений и пояснений к модели параллелизма в Java. Впервые API для работы с параллелизмом разарботанные JSR 166 были включены в JDK. JSR 133 предоставила поддержку для хорошо определённых атомарных операций в многопоточном/многопроцессорном окружении.

И Java SE 6, и Java SE 7 привносят изменения и дополнения в JSR 166 API.

  1. Oracle Interface Thread.UncaughtExceptionHandler. Дата обращения: 10 мая 2014. Архивировано 12 мая 2014 года.
  2. Silent Thread death from unhandled exceptions. literatejava.com. Дата обращения: 10 мая 2014. Архивировано 12 мая 2014 года.
  3. Herlihy, Maurice, and Nir Shavit. «The art of multiprocessor programming.» PODC. Vol. 6. 2006.
  4. Section 17.4.4: Synchronization Order The Java® Language Specification, Java SE 7 Edition. Oracle Corporation (2013). Дата обращения: 12 мая 2013. Архивировано 3 февраля 2021 года.
  5. Даг Ли. Overview of package util.concurrent Release 1.3.4. — «Note: Upon release of J2SE 5.0, this package enters maintenance mode: Only essential corrections will be released. J2SE5 package java.util.concurrent includes improved, more efficient, standardized versions of the main components in this package.». Дата обращения: 1 января 2011. Архивировано 18 декабря 2020 года.
  6. JSR 166: Concurrency Utilities. Дата обращения: 3 ноября 2015. Архивировано из оригинала 3 ноября 2016 года.
  • Goetz, Brian; Joshua Bloch; Joseph Bowbeer; Doug Lea; David Holmes; Tim Peierls. Java Concurrency in Practice (неопр.). — Addison Wesley, 2006. — ISBN 0-321-34960-1.
  • Lea, Doug. Concurrent Programming in Java: Design Principles and Patterns (англ.). — Addison Wesley, 1999. — ISBN 0-201-31009-0.

Ссылки на внешние ресурсы

[править | править код]