по мотивам «Java Programming Language»

Введение

Поток - это последовательность шагов, выполняемых по одному в каждый момент времени.

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

Неопределённость параллелизма (или состояние гонки) существует когда два потока, потенциально, могут модифицировать (получение-изменение-присваивание) одну и ту же часть данных конкурентным образом, что может привести к её повреждению.

Для разрешения этой проблемы используются блокировки. Блокировка ассоциируется с объектом, обозначая находится ли объект в пользовании или нет.

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

1. Создание потоков

Создание потока выполнения начинается с создания объекта класса Thread:

Thread worker = new Thread();

После этого его можно сконфигурировать задав начальный приоритет, имя и т.д. После, для запуска потока, вызывается метод Thread::start.

Метод Thread::start порождает новый поток управления на основе данных объекта Thread и возвращает управление вызывающему коду. После этого JVM вызывает метод Thread::run, делая поток активным. Thread::start у объекта потока можно вызвать лишь единожды. Повторный вызов породит исключение IllegalThreadStateException.

Поток завершается тогда, когда происходит возврат управления метода Thread::run.

Стандартная реализация Thread::run не делает ничего. Для того чтобы дать потоку задание нужно либо унаследоваться от класса Thread и переопределить метод Thread::run, либо создать объект класса Runnable и передать его в конструктор класса Thread.

public class PingPong extends Thread {
    private String word;  // what word to print
    private int delay;    // how long to pause

    public PingPong(String whatToSay, int delayTime) {
        word = whatToSay;
        delay = delayTime;
    }

    @Override
    public void run() {
        try {
            for (;;) {
                System.out.print(word + " ");
                Thread.sleep(delay); // wait until next time
            }
        } catch (InterruptedException e) {
            return;                  // end this thread
        }
    }

    public static void main(String[] args) {
        new PingPong("ping",  33).start(); // 1/30 second
        new PingPong("PONG", 100).start(); // 1/10 second
    }
}

Получение объекта класса Thread текущего потока выполняется с помощью вызова статического метода Thread::currentThread.

Метод main выполняется в потоке созданном системой времени выполнения.

2. Использование Runnable

Поток абстрагирует концепцию рабочей единицы (worker) - сущности, которая выполняет какую-то работу. Работа выполняемая потоком упаковывается в метод Thread::run.

Класс Thread также реализует интерфейс Runnable.

Выполнение объекта Runnable в отдельном потоке осуществляется через его передачу конструктору класса Thread. Если объект класса Thread создается с передачей объекта Runnable, реализация Thread::run вызовет метод Runnable::run этого объекта.

Конструкторы класса Thread:

  • Thread(target: Runnable) - создает новый объекта класса Thread, который использует метод run переданного объекта Runnable.

  • Thread(target: Runnable, name: String) - создает новый объекта класса Thread с заданным именем name, который использует метод run переданного объекта Runnable.

  • Thread(group: ThreadGroup, target: Runnable) - создает новый объекта класса Thread в указанной ThreadGroup, который использует метод run переданного объекта Runnable.

  • Thread(group: ThreadGroup, target: Runnable, name: String) - создает новый объекта класса Thread в указанной ThreadGroup, с заданным именем, который использует метод run переданного объекта Runnable.

  • Thread(group: ThreadGroup, target: Runnable, name: String, stackSize: long) - создает новый объекта класса Thread в указанной ThreadGroup, с заданным именем, который использует метод run переданного объекта Runnable. Параметр stackSize позволяет передать желаемый размер стека для потока. Значение соответствующее размеру стека является системно-зависимым и система может проигнорировать переданное значение.

Пример более сложного класса:

class PrintServer implements Runnable {
    private final PrintQueue requests = new PrintQueue();
    public PrintServer() {
        new Thread(this).start();
    }
    public void print(PrintJob job) {
        requests.add(job);
    }
    public void run() {
        for(;;)
            realPrint(requests.remove());
    }
    private void realPrint(PrintJob job) {
        // do the real work of printing
    }
}

Запуск потока в конструкторе может быть рискованным в случае наследования, т.к. поток может получить доступ к полям объекта до того как выполнится конструктор наследника.

При создании объекта класса Thread ссылка на него хранится в соответствующей ThreadGroup, и таким образом предотвращается его удаление сборщиком (GC).

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

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

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

Каждый объект имеет, ассоциированную с ним, блокировку. Эта блокировка может быть захвачена и освобождена с помощью синхронизации методов и выражений. Термин синхронизированный код обозначает любой код внутри синхронизированного метода или выражения.

При вызове потоком синхронизированного (synchronized) метода объекта сначала происходит захват блокировки этого объекта, потом выполняется тело метода, и после этого блокировка освобождается. Другой поток, который вызвал этот метод у того же объекта, будет блокирован до освобождения блокировки.

Синхронизация критической области

Синхронизация обеспечивает взаимоисключающее во времени выполнение потоков.

Захват блокировок выполняется по-поточно, т.е. вызов метода синхронизированного на том же объекте, что и вызывающий, выполнится без блокировки. Освобождение блокировки выполнится на выходе из объемлющего метода.

Освобождение блокировки выполняется как при нормальном завершении (return, конец тела метода), так и при аварийном (исключение).

Thread::holdsLock(obj: Object) - проверка того, владеет ли текущий поток блокировкой данного объекта.

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

Статические методы также могут быть синхронизированы. В этом случае используется блокировка объекта Class, ассоциированного с каждым классом.

Захват объекта класса Class при вызове статического синхронизированного метода не оказывает влияния на объекты этого класса.

Выражение synchronized дает возможность синхронизации произвольного блока кода с захватом блокировки любого объекта.

Синхронизированные методы являются всего лишь сокращенной записью метода, тело которого завернуто в блок synchronized с ссылкой на this.

Для таких объектов как массивы синхронизация на них самих является единственным средством контроля их разделяемого пользования.

Для защиты доступа к статическим данным из не статического кода можно использовать литерал класса этого объекта. Например:

Body() {
    synchronized (Body.class) {
        idNum = nextID++;
    }
}

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

В случае синхронизации вызовов нескольких объектов есть вероятность создания deadlock’а.

class DeadLock {

    synchronized void foo(DeadLock deadLock) {
        System.out.println("DeadLock::foo() - " 
                + Thread.currentThread().getName());
        try {
            Thread.sleep(100L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        deadLock.bar();
    }

    synchronized void bar() {
        System.out.println("DeadLock::bar() - " 
                + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        DeadLock dl1 = new DeadLock();
        DeadLock dl2 = new DeadLock();
        new Thread(() -> dl1.foo(dl2), "T1").start();
        new Thread(() -> dl2.foo(dl1), "T2").start();
    }
}

4. Методы wait, notifyAll, и notify

Коммуникация между потоками обеспечивается с помощью методов Object::wait, Object::notify и Object::notifyAll. Метод Object::wait дает потоку возможность ожидания наступления какого-либо состояния, а методы Object::notify и Object::notifyAll уведомляют ожидающие потоки о его наступлении. Данные методы применяются для конкретного объекта, также как и блокировки.

Существуют стандартные паттерны, которые следует применять при использовании wait и notifу. В потоке, ожидающем события следует делать что-то вроде:

synchronized void doWhenCondition() {
    while (!condition) {
        wait();
    }
    // … Do what must be done when the condition is true …
}

В этом примере важно отметить следующее:

  • метод является синхронизированным. Иначе состояние объекта было бы нестабильным, например не было бы гарантии в том, что условие будет оставаться истинным и после выражения while - другой поток мог бы его изменить.

  • одним из важных аспектов wait является то, что при остановке потока, он атомарно освобождает блокировку объекта. Под атомарностью приостановки потока и освобождения блокировки понимается их выполнение как одной неделимой операции. Иначе есть вероятность попадания в состояние гонки. После получения уведомления и продолжения работы потока, повторная блокировка захватывается также атомарно.

  • проверка условия всегда должна выполняться в цикле, т.е. не надо менять while на if.

Код уведомления обычно выглядит так:

synchronized void changeCondition() {
    // … change some value used in a condition test …
    notifyAll(); // or notify()
}

Object::notifyAll используется для уведомления всех ожидающих потоков, в то время как Object::notify пробуждает только один поток.

Несколько потоков могут ожидать одного и того же объекта, но, возможно, в различных состояниях. В этом случае всегда следует использовать метод Object::notifyAll. Object::notify является оптимизацией, которая применима только в случае когда:

  • все потоки ожидают одного и того же состояния

  • хотя бы один поток извлечет пользу из данного состояния

  • все это обеспечено контрактом для всех возможных подклассов

Иначе всегда используйте Object::notifyAll.

Использование Object::wait означает приостановку потока до тех пор пока для него не появится работа.

5. Ожидание (waiting) и уведомление (notification) в деталях

Существует три формы ожидания и две формы уведомления:

  • wait(timeout: long): void throws InterruptedException - текущий поток ожидает наступления одного из четырех событий: у объекта вызван метод notify, и данный поток выбран для пробуждения; у объекта вызван notifyAll; истек указанный таймаут; или у потока был вызван метод interrupt. Во время ожидания блокировка объекта освобождается, и, перед завершением, автоматически перезахватывается независимо от причины завершения. InterruptedException возбуждается в случае завершения ожидания из-за прерывания (interrupt) потока.

  • wait(timeout: long, nanos: int): void throws InterruptedException - более точное указание таймаута (timeout - миллисекунды, nanos - наносекунды).

  • wait(): void throws InterruptedException - эквивалентен wait(0). В этом случае время ожидания неограниченно.

  • notifyAll(): void - уведомляет все потоки ожидающие изменения состояния. Потоки пробуждаются от ожидания по мере захвата блокировки.

  • notify(): void - уведомляет по меньшей мере один ожидающий поток. Выбор конкретного потока для уведомления невозможен.

Данные методы должны вызываться только внутри синхронизированного кода, используя блокировку того объекта для которого они вызываются. Вызов может делаться как в самом синхронизированном коде, так и в вызываемых методах. В случае вызова этих методов у объекта без захвата его блокировки будет возбуждено исключение IllegalMonitorStateException.

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

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

6. Диспетчеризация (scheduling) потоков

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

Запущенный поток будет находиться в активном состоянии до выполнения блокирующей операции (такой как sleep, wait или некоторого типа I/O) или пока не будет прерван.

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

В целом рекомендуется задавать более низкий приоритет (Thread.NORM_PRIORITY - 1) непрерывно выполняющимся потокам, чем потокам, которые обрабатывают такие события как, например, пользовательский ввод.

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

  • static sleep(millis: long): void throws InterruptedException - усыпляет текущий поток на, по меньшей мере, указанное количество миллисекунд. При прерывании спящего потока будет возбуждено исключение InterruptedException.

  • static sleep(millis: long, nanos: int): void throws InterruptedException - усыпляет текущий поток на, по меньшей мере, указанное количество милли- и наносекунд.

  • static yield(): void - дает планировщику подсказку о том, что текущему потоку не требуется быть запущенным в настоящее время. Планировщик может как воспользоваться ей, так и проигнорировать.

Следующая программа иллюстрирует то как yield влияет на планирование потоков:

class Babble extends Thread {
    static boolean doYield; // yield to other threads?
    static int howOften;    // how many times to print
    private String word;    // my word

    Babble(String whatToSay) {
        word = whatToSay;
    }

    @Override
    public void run() {
        for (int i = 0; i < howOften; i++) {
            System.out.println(word);
            if (doYield) {
                Thread.yield(); // let other threads run
            }
        }
    }

    public static void main(String[] args) {
        doYield = new Boolean(args[0]).booleanValue();
        howOften = Integer.parseInt(args[1]);
        // create a thread for each word
        for (int i = 2; i < args.length; i++) {
            new Babble(args[i]).start();
        }
    }
}

В случае запуска с параметрами:

Babble false 2 Did DidNot

возможен следующий вывод:

Did
Did
DidNot
DidNot

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

В случае запуска с параметрами:

Babble true 2 Did DidNot

возможен следующий вывод:

Did
DidNot
DidNot
Did

В этом случае вызов yield дает другим потокам шанс быть запущенными.

7. Взаимные блокировки (deadlock)

Взаимную блокировку (deadlock) продемонстрируем на следующем классе:

class Friendly {
    private Friendly partner;
    private String name;

    public Friendly(String name) {
        this.name = name;
    }

    public synchronized void hug() {
        System.out.println(Thread.currentThread().getName() +
              " in " + name + ".hug() trying to invoke " +
              partner.name + ".hugBack()");
        partner.hugBack();
    }

    private synchronized void hugBack() {
        System.out.println(Thread.currentThread().getName() +
              " in " + name + ".hugBack()");
    }

    public void becomeFriend(Friendly partner) {
        this.partner = partner;
    }
}

Этот класс примечателен тем, что в синхронизированном методе hug вызывается, также синхронизированный метод hugBack другого объекта.

Теперь рассмотрим следующий код:

public static void main(String[] args) {
    Friendly jareth = new Friendly("jareth");
    Friendly cory = new Friendly("cory");

    jareth.becomeFriend(cory);
    cory.becomeFriend(jareth);

    new Thread(jareth::hug, "Thread1").start();
    new Thread(cory::hug, "Thread2").start();
}

Возможен следующий сценарий выполнения этого кода:

  • В потоке #1 вызывается синхронизированный метод jareth::hug - поток #1 владеет блокировкой объекта jareth.

  • В потоке #2 вызывается синхронизированый метод cory::hug - поток #2 владеет блокировкой объекта cory.

  • В методе jareth::hug вызывается метод cory::hugBack. Поток #1 теперь блокирован в ожидании блокировки объекта cory, которой, в текущий момент, владеет поток #2.

  • Наконец, в методе cory::hug вызывается синхронизированный метод jareth::hugBack и поток #2 блокируется в ожидании блокировки объекта jareth, которым, в текущий момент, владеет поток #1.

Таким образом, произошла взаимная блокировка - cory::hug не может продолжить выполнение до тех пор пока не получит блокировку объекта jareth, и наоборот.

Одним из распространенных подходов для предотвращение взаимных блокировок является их упорядочивание (lock ordering), которое заключается в обеспечении фиксированного порядка захвата. В этом случае если первый поток захватит первую блокировку, то второй поток будет блокирован в ее ожидании, а первый поток сможет безопасно захватить следующую блокировку и т.д.

8. Завершение работы потоков

Запущенный поток будет оставаться живым (alive) до тех пор пока не завершится по одной из трех причин:

  • нормальный возврат из метода Runnable::run

  • преждевременное завершение работы метода Runnable::run

  • завершение приложения

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

В случае необходимости отменяемости (cancellable) работы потока нужно обеспечить отслеживание и соответствующий ответ на его прерывание (interruption).

Thread th1 = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // do a little work
    }
});
th1.start();
th1.interrupt();

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

К прерыванию потоков относятся следующие методы:

  • Thread::interrupt - отправляет потоку отмену.

  • Thread::isInterrupted - возвращает признак того, что поток был отменен.

  • Thread::interrupted - статический метод, который возвращает признак отмены текущего потока, и после этого сбрасывает состояние отмены. Сброс состояния отмены потока возможен только им самим.

В случае отмены потока во время сна (Thread::sleap) или ожидания (Thread::wait) будет возбуждено исключение InterruptedException. Обработка этого исключения обычно выглядит подобно следующему:

void tick(int count, long pauseTime) {
    try {
        for (int i = 0; i < count; i++) {
            System.out.println('.');
            System.out.flush();
            Thread.sleep(pauseTime);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

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

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

Ожидание завершения потока выполняется с помощью одного из методов Thread::join.

class CalcThread extends Thread {
    private double result;

    @Override
    public void run() {
        result = calculate();
    }

    public double getResult() {
        return result;
    }

    public double calculate() {
        // ... calculate a value for "result"
    }
}

class ShowJoin {
    public static void main(String[] args) {
        CalcThread calc = new CalcThread();
        calc.start();
        doSomethingElse();
        try {
            calc.join();
            System.out.println("result is "
                + calc.getResult());
        } catch (InterruptedException e) {
            System.out.println("No answer: interrupted");
        }
    }
    // ... definition of doSomethingElse ...
}

В приведенном примере происходит запуск потока CalcThread, выполняются какие-то действия и вызывается метод calc::join. Возврат join гарантирует завершение CalcThread.run. Завершение потока не означает удаление его объекта, и join может быть вызван на уже остановленном потоке.

Thread::join имеет следующие три формы:

  • join(millis: long): void throws InterruptedException - ожидает либо завершения данного потока, либо истечения указанного количества миллисекунд. Таймаут равный нулю означает вечное ожидание. При отмене ожидающего потока будет возбуждено исключение InterruptedException.

  • join(millis: long, nanos: int): void throws InterruptedException - ожидает либо завершения данного потока, либо истечения указанного количества милли- и наносекунд.

  • join(): void throws InterruptedException - эквивалентен join(0L).

Внутренне join определен через isAlive и логически может рассматриваться как:

while (isAlive()) {
    wait();
}

с учетом того, что системой времени выполнения, при завершении потока, будет вызван метод notifyAll.

9. Завершение работы приложения

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

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

Начальный поток является обычным пользовательским потоком.

Принудительная остановка приложения выполняется с помощью метода System::exit. Данный метод приостанавливает выполнение JVM, и обрывает все потоки. Однако, можно зарегистрировать особые потоки, которые будут выполняться перед приостановкой.

Многие классы неявно создают потоки в приложении. Некоторые из этих потоков могут быть демонами, а некоторые - нет, и, таким образом, использование этих классов может стать причиной более длительной работы приложения при останове. Завершить такие потоки, в случае необходимости, можно с помощью метода System::exit.

10. Модель памяти: синхронизация и volatile

Доступ к любым разделяемым и изменяемым данным должен выполняться синхронизированно. Однако, это приводит к определённым накладным расходам и не всегда необходимо для предотвращения вмешательства (interference). Платформа гарантирует, что операции записи и чтение переменных всех типов, кроме long и double будут выполняться атомарно, т.е. всегда содержать данные записанные каким-то одним потоком. Это означает, например, что атомарные переменные, значения которых пишутся одним потоком, а читаются - многими, не требуют синхронизации доступа.

Существует несколько факторов влияющих на то, когда значение переменной записанное одним потоком становится видимым другому. Правила, определяющие упорядочивание доступа к памяти и гарантии видимости её содержимого, известны как модель памяти (Java Memory Model).

Действия, выполняемые потоком, определяются семантикой выражений выполняемого метода. Логически эти выражения выполняются в порядке их записи, известном как программный порядок (program order). Однако, значения всех читаемых потоком переменных определяются моделью памяти. Модель памяти определяет набор допустимых значений переменной, которые могут быть возвращены при её чтении. Под переменными, в данном контексте, подразумеваются как статические, так и не статические поля и элементы массивов. С точки зрения программиста этот набор всегда должен состоять из единственного элемента - последнего, записанного каким-либо потоком, значения. Однако, при отсутствии соответствующей синхронизации, реальный набор возможных значений может содержать несколько элементов.

Рассмотрим пример:

public void updateCurrent() {
    currentValue = (int) Math.random();
}

public void showValue() {
    currentValue = 5;
    for (;;) {
        display.showValue(currentValue);
        Thread.sleep(1000);
    }
}

Предположим, что не синхронизированный метод updateCurrent может вызываться произвольными потоками. При первом вхождении в цикл (в случае если updateCurrent ещё не вызывался) единственно возможным значением currentValue является 5. Однако, из-за отсутствия синхронизации при каждом вызове другим потоком метода updateCurrent в набор возможных возвращаемых значений добавляется ещё один элемент. В соответствии с правилами модели памяти при чтении может быть возвращено любое, записанное каким-либо потоком, значение. Таким образом, платформа может посчитать, что переменная currentValue является неизменной и метод showValue всегда будет выводить 5.

Некоторые действия, выполняемые потоком, определяются как действия синхронизации, и последовательность, в которой они выполняются, называется порядком синхронизации (synchronization order) программы. Порядок синхронизации всегда соответствует программному порядку. Эти действия синхронизируют чтение и запись переменных.

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

Существует еще один механизм синхронизации - volatile-переменные. Запись volatile-переменной синхронизирована со всеми последующими чтениями этой переменной. Такие переменные чаще всего используются для простых флагов, или при написании неблокирующих алгоритмов.

Еще одним эффектом использования volatile-переменных является то, что чтение и запись гарантированно будут атомарными, что расширяет базовую гарантию атомарности на типы long и double.

Приведем еще некотрые действия синхронизация, поддерживающие реализацию параллелизма:

  • Запуск нового потока синхронизируется с его первым выполняемым действием. Это гарантирует то, что вновь запущенному потоку будут видны все данные заданные при его создании, включая его собственные поля.

  • Последнее, выполняемое потоком, действие синхронизировано со всеми действиями, фиксирующими его останов, такими как вызов isAlive или join.

  • Прерывание потока синхронизировано со всеми фиксирующими его действиями, такими как возбуждение InterruptedException или вызов в другом потоке метода isInterrupted данного потока.

  • Запись значений переменных по-умолчанию (таких как 0, null или false) синхронизирована с первым действием в любом потоке. Это гарантирует, что даже в некорректно синхронизированной программе поток никогда не увидит произвольных значений переменной - они будут содержать либо значения, записанные каким-то другим потоком, либо значение по-умолчанию.

Существует дополнительные правила модели памяти, которые касаются использование final полей:

  1. Если ссылка на объект сохраняется после его создания, то любой поток, читающий эту ссылку, гарантировано будет видеть проинициализированные значения final-полей этого объекта. Относительно не final-полей, читаемых без синхронизации, такой гарантии нет.

  2. При чтении ссылки из final-поля все не final поля ссылаемого объекта будут содержать значения, по крайней мере, не ранее тех, что они содержали при её записи. Это, например, значит, что если поток создает объект и использует методы этого объекта для установки его значений, то второй поток будет видеть состояние, установленное первым поток, в том случае, если ссылка, используемая для доступа к этому объекту, после сделанных изменений была записана в final поле. Все final поля этого объекта подчиняются первому правилу.

Использование синхронизированных методов, блоков синхронизации и volatile-переменных предоставляет гарантии записи и чтения переменных вне самих таких переменных и блоков. Такие действия синхронизации обеспечивают то, что называется отношением выполняется прежде (happens before). Это отношение является транзитивным и выражение, стоящее перед каким-то другим в программном порядке, выполняется прежде этого второго выражения, что позволяет синхронизировать между потоками не синхронизированные действия. Например, если значение не volatile-переменной было записано перед volatile-переменной, а в другом потоке значение этой же volatile-переменной читается перед чтением не volatile-переменной, тогда запись не volatile-переменной выполняется прежде её чтения, и гарантированно будет возвращенно записанное значение.

Happens before

То же самое справедливо при использовании вместо volatile-переменной синхронизированных методов доступа.

11. Управление потоками, безопасность, и ThreadGroup

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

Потоки организуются в группы потоков (thread groups) для целей управления и безопасности. Группа потоков может содержать другую группу потоков составляя иерархию корнем которой является системная группа. Потоками в рамках группы можно управлять как единым целым, например, одновременно прерывая (interrupting) все потоки в группе, или устанавливая ограничение на максимальный приоритет потока в группе. Группы потоков, также, могут определять области безопасности (security domain). Потоки в группе, в общем, могут изменять другие потоки в этой группе, включая потоки подчиненных групп. В этом контексте изменять означает вызывать методы которые могут повлиять на поведение потока, такие как изменение его приоритета или прерывание. Однако, приложение может установить политику безопасности, запрещающую изменение потоков в группе потоками других групп. Потокам различных групп, также, могут быть настроены различные разрешения на выполнение действий в рамках приложения, таких как I/O.

В общем случае, защищенные (security-sensitive) действия (такие как создание потока, контроль потоков, выполнение I/O операций, или завершение приложения) перед выполнением всегда проверяются установленными менеджерами безопасности. Если действие запрещено политиками безопасности, то возбуждается SecurityException. По-умолчанию менеджеры безопасности не устанавливаются.

Каждый поток принадлежит к какой-то группе потоков. Каждая группа представлена объектом класса ThreadGroup в котором заданы ограничения для потоков этой группы. Задать потоку группу можно передав её объект в конструкторе при его создании. По-умолчанию, если менеджером безопасности не предусмотрено другого, каждый новый поток помещается в группу создавшего его потока. При завершении потока его объект удаляется из группы и, при отсутствии других ссылок, можен быть удален GC.

У класса Thread существует четыре конструктора, позволяющих задать потоку его группу.

Для предотвращения создания потока с произвольной группой, в случае если текущему потоку запрещено размещать потоки в данной группе, эти конструкторы могут возбудить исключение SecurityException.

Заданная потоку группа не может быть изменена после его создания. Получение группы потока выполняется с помощью метода Thread::getThreadGroup. Проверка доступности изменения потока выполняется с помощью метода Thread::checkAccess. Данный метод возбуждает исключение SecurityException если изменение запрещено, и просто возвращает управление в противном случае.

Группа потоков может быть демон-группой. Демон-группа автоматически разрушается (destroyed) когда становится пустой. Указание группе данного признака не влияет на содержащиеся в ней потоки и группы.

В случае задания группе максимального значения приоритета потоков (используя метод Thread::setMaxPriority), все попытки указать потоку более высокий приоритет будут автоматически сброшены до заданного максимума. Данное ограничение применяется и к самой группе - все попытки указать группе более высокое, чем текущее, значение максимального приоритета будут отклонены.

Класс ThreadGroup имеет следующие конструкторы и методы:

  • ThreadGroup(name: String) - создает новый объект класса TreadGroup. Родительской группой, в этом случае, будет группа текущего потока.

  • ThreadGroup(parent: ThreadGroup, name: String) - создает новый объект класса ThreadGroup с заданным именем в указанной группе. Если parent == null возбуждается NullPointerException.

  • getName(): String - возвращает имя данной ThreadGroup.

  • getParent(): ThreadGroup - возвращает родительскую ThreadGroup или null при её отсутствии (что может иметь место только для группы потоков верхнего уровня)

  • setDaemon(daemon: boolean): void - устанавливает статус демона для данной группы потоков.

  • isDaemon(): boolean - возвращает статус демона данной группы потоков.

  • setMaxPriority(maxPriority: int): void - устанавливает максимальный приоритет данной группы потоков.

  • getMaxPriority(): int - возвращает текущий максимальный приоритет данной группы потоков.

  • parentOf(g: ThreadGroup): boolean - проверяет, является ли данная группа потоков родительской по отношению к группе g, или самой этой группой. Более удобно об этом думать как является частью, т.к. группа является частью самой себя.

  • checkAccess(): void - возбуждает исключение SecurityException если текущему потоку не разрешено изменять данную группу потоков. Иначе, просто возвращает управление.

  • destroy(): void - разрушает (destroy) данную группу потоков. Группа потоков не должна содержать потоков, т.к. иначе будет возбуждено исключение IllegalThreadStateException. Если группа содержит другие группы, то они тоже должны не содержать потоков. Вызов этого метода не разрушает потоки в группе.

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

  • activeCount(): int - возвращает приблизительное число активных потоков в этой группе включая потоки содержащиеся во всех подгруппах. Приблизительное потому, что при возврате количества эти данные уже могут устареть. Активный поток - это такой для которого метод Thread::isAlive возвращает true.

  • enumerate(threadsInGroup: Thread[], recurse: boolean): int - заполняет, в пределах размера, массив threadsInGroup ссылками на активные потоки, и возвращает количество сохраненных потоков. Если recurse == false, учитываются только потоки в этой группе. В противном случае включаются все потоки в иерархии групп.

  • enumerate(threadsInGroup: Thread[]): int - эквивалентен enumerate(threadsInGroup, true).

  • int activeGroupCount() - подобен activeCount, но подсчитывает группы, вместо потоков, во всех подгруппах. Активный (active) означает существующий.

  • enumerate(groupsInGroup: ThreadGroup[], recurse: boolean): int - подобен методу enumerate для потоков, но заполняет массив ссылками на объекты ThreadGroup.

  • enumerate(groupsInGroup: ThreadGroup[]): int - эквивалентен enumerate(groupsInGroup, true).

Вызов метода interrupt для группы приводит к вызову interrupt для каждого потока в группе, включая потоки во всех подгруппах.

Класса Thread включает два статических метода для работы с группой текущего потока:

  • activeCount(): int - возвращает число активных потоков в группе текущего потока.

  • enumerate(threadsInGroup: Thread[]): int - эквивалентен вызову метода enumerate(threadsInGroup) объекта группы текущего потока.

Класс ThreadGroup, также, поддерживает метод, который вызывается при завершении потока из-за неперехваченного исключения:

  • uncaughtException(thr: Thread, exc: Throwable): void

12. Потоки и исключения

Исключения всегда возникают в конкретном потоке вследствие его действий. Такие исключения являются синхронными и всегда остаются в самом потоке.

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

uncaughtException(thr: Thread, exc: Throwable): void

который будет вызван если поток thr прерван из-за исключения exc.

Обработчик неперхваченных исключений потока задается используя метод Thread::setUncaughtExceptionHandler.

Метод getUncaughtExceptionHandler вернет явно заданный методом setUncaughtExceptionHandler обработчик, или, если он не был задан, группу потока (ThreadGroup). Если поток был завершен и не имеет группу, getUncaughtExceptionHandler может вернуть null.

Стандартная реализация метода uncaughtException класса ThreadGroup делегирует вызов родительской группе, при её наличии. В противном случае, используя статический метод Thread::getDefaultUncaughtExceptionHandler, у системы запрашивается перехватчик по-умолчанию. Если такой перехватчик существует, то вызывается его метод uncaughtException, иначе у исключения вызывается метод Throwable::printStackTrace. Если исключение является объектом класса ThreadDeath, то ничего не выполняется.

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

  • на системном уровне, заданием обработчика по-умолчанию с помощью статического метода Thread::setDefaultUncaughtExceptionHandler. Эта операция проверяется системой безопасности на возможность выполнения.

  • на уровне групп потоков, расширяя класс ThreadGroup и переопределяя его метод uncaughtException.

  • на уровне потока, с помощью метода Thread::setUncaughtExceptionHandler.

Существует две формы асинхронных исключений: внутренние ошибки JVM и исключения, причиной которых является вызов устаревшего (deprecated) метода Thread::stop.

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

Основной целью метода Thread::stop было завершение потока контролируемым образом. Но эта цель не была достигнута по двум причинам. Во-первых, данное исключение не может гарантировать завершение потока, т.к. может быть перехвачено и проигнорировано. Во-вторых, его возбуждение в момент нахождения потока в критической области (critical section) может привести объект в несогласованное состояние из-за неожиданного освобождения блокировки. Вместо этого следует использовать метод Thread::interrupt.

Вторая форма метода Thread::stop принимает объект целевого исключения в качестве аргумента. Это является еще более хитрым трюком, так как может привести к тому, что проверяемое исключение будет возбуждено методом в котором оно не объявлено.

Для любого живого (alive) потока можно получить его текущий стек-трейс (stack trace). Метод Thread::getStackTrace возвращает массив объектов класса StackTraceElement, где нулевой элемент представляет текущий выполняемый метод.

Стек-трейс всех потоков в системе можно получить используя статический метод Thread::getAllStackTraces, который возвращает отображение (map) каждого потока на соответствующий массив объектов StackTraceElement.

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

13. Класс ThreadLocal

Класс ThreadLocal позволяет иметь одну логическую переменную содержащую, при этом, независимые значения для каждого отдельного потока. Класс ThreadLocal имеет метод set для задания переменной значения в текущем потоке, и метод get для получения её значения в текущем потоке.

Начальное значение может быть задано либо с помощью переопределения метода ThreadLocal::initialValue, который по-умолчанию возвращает null, либо передачей объекта класса Supplier статическому методу ThreadLocal::withInitial.

Сброс значения ThreadLocal-переменной выполняется вызовом метода ThreadLocal::remove. При последующем вызове метода Thread::get, для определения возвращаемого значения, повторно будет вызван метод ThreadLocal::initialValue.

При завершении потока заданное ThreadLocal-переменной значение, в случае остутствия других ссылок, будет уничтожено GC.

При создании нового потока его ThreadLocal-переменные будут содержать значения возвращенные методом initialValue. Для унаследования новым потоком значений переменных текущего потока можно воспользоваться классом InheritableThreadLocal. Класс InheritableThreadLocal является подклассом ThreadLocal и переопределяет метод childValue для получения начальных значений переменных нового потока. Реализация по-умолчанию просто возвращает родительское значение.

При работе с пулами потоков (thread pooling) ThreadLocal-переменные следует использовать осторожно, т.к. в этом случае один и тот же поток может быть задействован более одного раза.

14. Отладка потоков

Для помощи в отладке (debugging) многопоточного приложения класс Thread имеет несколько вспомогательных методов:

  • toString(): String - возвращает строковое представление потока, включая его имя, приоритет, и имя группы потоков.

  • long getId(): long - возвращает положительное значение, однозначно идентифицирующее живой поток.

  • getState(): Thread.State - возвращает текущее состояние потока. Thread.State - это вложенное перечисление (enum), в котором определены следующие константы: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, и TERMINATED. Вновь созданный поток имеет состояние NEW до тех пор пока не будет запущен и перейдет в состояние RUNNABLE, в котором будет находится до завершения, пока не перейдет в TERMINATED. Во время работы до завершения поток может находиться в состоянии RUNNABLE, BLOCKED (будучи заблокированным, например при захвате блокировки монитора), WAITING (в случае вызова wait), или TIMED_WAITING (в случае вызова wait с таймаутом).

  • static dumpStack(): void - печатает стек-трейс (stack trace) текущего потока в System.err.

Для отслеживания состояния группы потоков можно воспользоваться следующими методами класса ThreadGroup:

  • toString(): String - возвращает строковое представление объекта класса ThreadGroup включая его имя и приоритет.

  • list(): void - рекурсивно выводит содержимое (результат toString у групп и потоков) данной группы потоков в System.out.