JavaRush /Java блог /Random /Кофе-брейк #254. Демистификация многопоточности в Java: п...

Кофе-брейк #254. Демистификация многопоточности в Java: практическое руководство с примерами

Статья из группы Random
Источник: Medium В этой руководстве раскрывается принцип работы многопоточности в Java и демонстрируются практические примеры кода, которые упрощают понимание этой концепции. Кофе-брейк #254. Демистификация многопоточности в Java: практическое руководство с примерами - 1Многопоточность — это мощная концепция в языке Java, которая позволяет одновременно выполнять несколько задач, что заметно ускоряет работу приложения. Однако многопоточность также может привести и к проблемам. Чтобы их избежать, вам необходимо свободно ориентироваться не только в принципе этой концепции, но и в ее ключевых терминах.
  1. Параллельное выполнение: многопоточность позволяет одновременно выполнять несколько задач, что значительно повышает производительность Java-приложений. Например, в приложении веб-сервера каждый входящий запрос может обрабатываться в отдельном потоке, что позволяет серверу быстрее давать отклик на несколько клиентских запросов.
  2. Использование нескольких ядер ЦП. В современных компьютерах часто используется несколько ядер центрального процессора (ЦП). Многопоточность позволяет приложениям Java использовать эти несколько ядер для параллельного выполнения, эффективно используя доступные аппаратные ресурсы.
  3. Адаптивные пользовательские интерфейсы. В приложениях Java Swing и JavaFX многопоточность имеет решающее значение для обеспечения быстрого отклика пользовательского интерфейса при выполнении трудоемких операций в фоновом режиме. Например, приложение с графическим интерфейсом может использовать отдельный поток для выполнения файлового ввода-вывода без приостановки работы пользовательского интерфейса.
Теперь давайте обсудим некоторые термины, связанные с многопоточностью в Java:
  • Thread: В Java поток (Thread) является наименьшей единицей выполнения задачи внутри программы. Вы можете создавать потоки и управлять ими с помощью класса java.lang.Thread.
  • Runnable: Runnable — это интерфейс в Java, определяющий единственный метод run(). Объекты, реализующие этот интерфейс, могут выполняться потоком. При работе с кодом вы часто создаете объекты Runnable и передаете их экземплярам Thread для выполнения.
  • Synchronization. Синхронизация — это механизм в Java, который обеспечивает контролируемую работу нескольких потоков с общими ресурсами. Он предотвращает состояния гонки (race condition) и повреждение данных, позволяя только одному потоку одновременно получать доступ к синхронизированному блоку или методу. Для применения синхронизации вы можете использовать ключевое слово synchronized.
  • Потокобезопасность (Thread Safety). Обеспечение безопасного доступа нескольких потоков к фрагменту кода или структуре данных без возникновения таких проблем, как повреждение данных или взаимоблокировки (deadlock), называется потокобезопасностью. Обеспечить потокобезопасность в работе вам помогут синхронизация и использование потокобезопасных структур данных.
  • Взаимная блокировка (Deadlock). Взаимная блокировка возникает, когда два или более потоков блокируются, ожидая, что один из них освободит ресурс, необходимый для продолжения работы. Взаимные блокировки могут привести к тому, что программа перестает отвечать на запросы.

Способы создания потоков

1. Расширение класса Thread:

Вы можете создать новый поток, расширив класс Thread и переопределив его метод run(). Этот способ позволяет определить поведение потока непосредственно в подклассе. Вот пример кода:

class MyThread extends Thread {
    public void run() {
        System.out.println("This is a new thread.");
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start(); // Запускаем новый поток
    }
}
В этом примере мы создаем новый поток, расширяя класс Thread и переопределяя метод run(). Когда мы вызываем start(), он вызывает метод run() в новом потоке.

2. Реализация работающего интерфейса:

Альтернативный способ создания потоков состоит в реализации интерфейса Runnable. Такой подход отделяет поведение потока от класса Thread, что способствует лучшей организации кода и его повторному использованию. Давайте посмотрим на пример кода:

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("This is a new thread.");
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable); // Создаем новый поток
        thread.start(); // Запускаем новый поток
    }
}
Здесь мы создали новый поток, создавая экземпляр класса и передавая ему экземпляр Thread нашего класса MyRunnable. Метод run() вызывается, когда мы запускаем поток.

3. Лямбда-выражения для Runnable:

Начиная с Java 8, вы можете использовать лямбда-выражения, чтобы упростить создание экземпляров Runnable для выполнения потоков. Этот способ работы считается более кратким и выразительным. Вот пример кода:

public class LambdaExample {
    public static void main(String[] args) {
        Runnable myRunnable = () -> {
            System.out.println("This is a new thread.");
        };

        Thread thread = new Thread(myRunnable); // Создаем новый поток
        thread.start(); // Запускаем новый поток
    }
}
В этом примере мы определяем поведение потока с помощью лямбда-выражения и создаем экземпляр Runnable. Затем мы создаем новый поток и запускаем его как обычно. Все три способа позволяют создавать и запускать потоки на Java, но выбор того, какой из них использовать, зависит от вашего конкретного варианта использования и предпочтений в дизайне. Обычно рекомендуется использовать интерфейс Runnable или лямбда-выражения для Runnable, поскольку это отделяет поведение потока от класса Thread и способствует лучшей организации кода.

Жизненный цикл потока

В течение своего жизненного цикла поток в Java проходит через несколько состояний. Понимание этих состояний и их переходов имеет решающее значение для эффективного многопоточного программирования. Вот все этапы состояния потока в Java:
  1. New: когда вы создаете новый объект Thread, но еще не запустили его с помощью метода start(), поток находится в состоянии “New”. Это означает, что поток был создан, но еще не началось его выполнение.
  2. Runnable: после вызова метода start() в потоке он переходит в состояние “Runnable”. В этом состоянии поток готов к запуску, но в данный момент он может быть не запущен, поскольку планировщик операционной системы еще не запланировал его.
  3. Running: как только поток запланирован операционной системой, он переходит в состояние “Running” (Выполняется). В этом состоянии код потока активно выполняется и потребляет время процессора.
  4. Blocked. Работающий поток может перейти в состояние “Blocked” (Заблокирован), также известное как “Waiting” или “Blocked on I/O”. В этом состоянии он ожидает ресурса, например блокировки или ввода пользователя. Поток не выполняет свой код, но он сразу будет считаться Runnable, как только ресурс станет доступным.
  5. Terminated: поток переходит в состояние “Terminated” (Завершен) или “Dead”, когда он завершит свое выполнение или после генерации исключения, вызывающего внезапное завершение потока. Если поток находится в этом состоянии, его нельзя перезапустить.
Перед вами визуальная демонстрация переходов состояний потока: Кофе-брейк #254. Демистификация многопоточности в Java: практическое руководство с примерами - 2
  • Поток обычно запускается в состоянии “New”.
  • После вызова start() он переходит в состояние “Runnable”, а затем в состояние “Running”, если это запланировано.
  • Если потоку необходимо дождаться ресурса или какого-либо условия, он может перейти в состояние “Blocked”.
  • Как только ресурс или условие удовлетворены, он возвращается в состояние “Runnable”.
  • Когда метод run() завершает работу или возникает необработанное исключение, поток переходит в состояние “Terminated” и не может быть перезапущен.
Понимание этих состояний и переходов жизненно важно для написания правильного и эффективного многопоточного кода, поскольку оно помогает управлять синхронизацией, избегать взаимоблокировок и контролировать поток выполнения вашей программы.

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

Синхронизация — это фундаментальная концепция многопоточного программирования, которая учитывает необходимость координации и контроля доступа к общим ресурсам между несколькими потоками. Она гарантирует, что только один поток может одновременно получить доступ к критическому разделу кода или общему ресурсу. Без синхронизации одновременный доступ нескольких потоков может привести к повреждению данных, состоянию гонки и другому неожиданному поведению. Для создания синхронизированных методов и блоков в Java имеется ключевое слово synchronized. Есть три основных способа использования synchronized:
  1. Синхронизированный метод. Вы можете объявить метод как synchronized. Когда поток вызывает метод synchronized, он блокирует объект, которому принадлежит этот метод. Другие потоки, пытающиеся вызвать синхронизированные методы того же объекта, должны дождаться снятия блокировки.
  2. Синхронизированный блок. Вы можете создать синхронизированный блок кода, явно указав объект для синхронизации. Это позволяет более детально контролировать синхронизацию.
  3. Lock и синхронизированный блок. При использовании синхронизированных блоков вы указываете объект (называемый блокировкой, lock), который необходимо синхронизировать. Несколько потоков могут одновременно использовать разные блокировки, что обеспечивает более детальный контроль над синхронизацией. Вот пример:

public class SynchronizationExample {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void method1() {
        synchronized (lock1) {
            // Синхронизированный блок кода с использованием lock1
        }
    }

    public void method2() {
        synchronized (lock2) {
            // Синхронизированный блок кода с использованием lock2
        }
    }
}
В этом примере method1 и method2 могут вызываться одновременно разными потоками, поскольку они используют разные блокировки (lock1 и lock2). Несмотря на то, что синхронизация — это очень мощный инструмент для обеспечения потокобезопасности и предотвращения повреждения данных в многопоточных приложениях Java, его следует использовать разумно, чтобы избегать ухудшения производительности и взаимоблокировок. Правильные способы синхронизации имеют решающее значение для создания устойчивых и надежных многопоточных программ.

Потокобезопасность

Потокобезопасность (Thread safety) — важнейшая концепция в многопоточном программировании, особенно в таких языках, как Java, где несколько потоков могут выполняться одновременно в одном процессе. Потокобезопасность позволяет программе работать правильно и давать ожидаемые результаты даже при одновременном доступе к нескольким потокам. Обеспечение потокобезопасности необходимо для предотвращения таких проблем, как повреждение данных, состояния гонки и непредсказуемое поведение, которое может возникнуть, когда несколько потоков получают доступ к общим ресурсам без надлежащей синхронизации. Вот несколько способов достижения потокобезопасности в Java:
  1. Использование ключевого слова synchronized. Вы можете использовать ключевое слово synchronized для определения критических разделов кода или методов, к которым одновременно должен обращаться только один поток. Это гарантирует, что несколько потоков не будут мешать доступу друг друга к общим ресурсам.

    
    public synchronized void synchronizedMethod() {
        // Здесь код метода synchronized
    }
    
  2. Использование Lock (блокировок): Java предоставляет более гибкие механизмы синхронизации с использованием явных блокировок из пакета java.util.concurrent.locks. Чаще всего для этой цели используется класс ReentrantLock.

    
    import java.util.concurrent.locks.ReentrantLock;
    
    private final ReentrantLock lock = new ReentrantLock();
    
    public void performTask() {
        lock.lock();
        try {
            // Здесь критический раздел кодаe
        } finally {
            lock.unlock();
        }
    }
    
  3. Неизменяемые структуры данных. Неизменяемые структуры данных (Immutable Data Structures), однажды созданные, не могут быть изменены. Они по своей сути потокобезопасны, поскольку несколько потоков могут читать их одновременно, не вызывая проблем. Чтобы изменить данные, вам нужно лишь создать новый экземпляр. Для объявления полей неизменяемыми чаще всего используется ключевое слово final.

    
    public final class ImmutableData {
        private final int value;
    
        public ImmutableData(int value) {
            this.value = value;
        }
    
        public int getValue() {
            return value;
        }
    }
    
  4. Атомарные операции. Java предоставляет атомарные классы (atomic classes) для выполнения атомарных операций над переменными с помощью пакета java.util.concurrent.atomic. Эти классы гарантируют, что операции чтения-изменения-записи являются атомарными и потокобезопасными.

    
    import java.util.concurrent.atomic.AtomicInteger;
    
    private AtomicInteger counter = new AtomicInteger();
    
    public void incrementCounter() {
        counter.incrementAndGet();
    }
    
  5. Ключевое слово Volatile. Ключевое слово volatile используется для объявления переменной как доступной для изменения. Когда переменная объявлена ​​как volatile, это гарантирует, что любая операция чтения или записи этой переменной является атомарной и видна всем потокам. Однако это не обеспечивает тот же уровень синхронизации, как в случае блокировки или применения синхронизированных блоков. В основном этот способ используется для простых флагов или маркеров, к которым часто обращаются.

    
    private volatile boolean flag = false;
    
  6. Хранилище Thread-Local. Локальное хранилище потока (Thread-Local) позволяет каждому потоку иметь собственную копию данных, изолированную от других потоков. Для этой цели Java предоставляет класс ThreadLocal. Это может быть полезно, когда вам нужно поддерживать состояния конкретных потоков без синхронизации.

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

Thread Pools (Пулы потоков)

Thread Pools (Пулы потоков) — это фундаментальная концепция многопоточного программирования, которая используется для эффективного управления потоками и их повторного использования. Пул потоков — это набор предварительно инициализированных рабочих потоков, готовых одновременно выполнять задачи. Их применение устраняет накладные расходы на создание и удаление потоков для каждой задачи, что может быть довольно дорогостоящей процедурой. Для управления пулами потоков и обеспечения абстракции высокого уровня в Java используется платформа Executor. Она имеет набор интерфейсов и классов для одновременного выполнения задач с использованием пулов потоков. Основным интерфейсом в платформе Executor является одноименный интерфейс Executor, и его наиболее распространенная реализация — ThreadPoolExecutor. Вот некоторые ключевые аспекты пулов потоков и платформы Executor:

Введение в пулы потоков:

  • Worker Threads (Рабочие потоки). Пул потоков содержит фиксированное или переменное количество рабочих потоков. Эти потоки создаются и запускаются при инициализации пула потоков.
  • Task Queue (Очередь задач). Задачи отправляются в очередь задач. Рабочие потоки постоянно отслеживают эту очередь на наличие ожидающих задач.
  • Task Submission (Отправка задач). Вы отправляете задачи в пул потоков, и пул назначает их доступным рабочим потокам. Если доступных потоков нет, задачи ждут в очереди, пока поток не освободится.
  • Reuse (Повторное использование). После того, как рабочий поток завершит задачу, его можно повторно использовать для другой задачи, устраняя накладные расходы на создание и уничтожение потока.

Платформа Executor:

Java предоставляет набор интерфейсов и классов для работы с пулами потоков через платформу Executor. Вот ее некоторые ключевые компоненты:
  • Executor: это корневой интерфейс платформы Executor. Он определяет единственный метод execute(Runnable command) для отправки задач на выполнение.
  • ExecutorService: это подинтерфейс Executor, который добавляет такие функции, как отправка задачи с возвращаемыми значениями с помощью submit(), отмена задачи и многое другое.
  • ThreadPoolExecutor: это наиболее часто используемая реализация интерфейса ExecutorService. Она позволяет создавать и настраивать пулы потоков с указанным количеством ядер и максимальным количеством потоков, а также различными другими настройками.
  • Executors: это служебный класс, который предоставляет фабричные методы для создания различных типов экземпляров ExecutorService, таких как пулы потоков фиксированного размера, кэшированные пулы потоков и запланированные пулы потоков.

Преимущества и использование:

  • Эффективное управление ресурсами. Пулы потоков управляют созданием, повторным использованием и прекращением использования потоков, обеспечивая более эффективное использование системных ресурсов.
  • Контроль над количеством потоков. Вы можете контролировать количество потоков в пуле, что помогает предотвратить истощение ресурсов в системе.
  • Поддержка приоритета задач. Пулы потоков часто поддерживают приоритет задач, что позволяет назначать задачам разные приоритеты.
  • Абстракция задач. Пулы потоков абстрагируют детали потоковой обработки, позволяя вам сосредоточиться на задачах, которые необходимо выполнять одновременно.
  • Улучшенная скорость реагирования. Позволяя избегать накладных расходов на создание потоков, пулы потоков могут улучшить скорость реагирования приложений.

Распространенные ошибки в многопоточности

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

Deadlock (Тупик, Взаимная блокировка)

Deadlock возникает, когда два или более потоков застревают в состоянии, при котором каждый из них ожидает ресурса, удерживаемого другим, что приводит к остановке, в которой ни один поток не может продолжить работу. Взаимные блокировки могут быть сложными для обнаружения и устранения. Например, рассмотрим два потока, A и B, каждому из которых необходим доступ к двум ресурсам, X и Y. Если поток A получает ресурс X, а поток B получает ресурс Y одновременно, то они не смогут продолжить работу, поскольку каждый из них ожидает ресурс, принадлежащий другому. Чтобы предотвратить появление взаимоблокировок требуется тщательное проектирование, например постоянное получение ресурсов в последовательном порядке, а также использование таких механизмов, как тайм-ауты, которые позволяют выходить из потенциальных взаимоблокировок.

Race Condition (Состояние гонки)

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

Starvation (Голодание)

“Голодное” состояние возникает, когда потоку постоянно отказывают в доступе к ресурсам или процессору, что препятствует его прогрессу. Это может произойти, если другие потоки имеют более высокий приоритет или постоянно конкурируют за ресурсы. Например, если поток с более низким приоритетом часто конкурирует за время процессора с потоком с более высоким приоритетом, он может никогда не получить шанс запуститься, что приведет к “голоданию”. Чтобы ее смягчить, вы можете использовать настройки приоритета потока (хотя они не всегда гарантируют надежность) или рассмотрите механизмы справедливого планирования, которые гарантируют, что все потоки имеют равные шансы на выполнение.

Ошибки параллелизма

Ошибки параллелизма — это тонкие проблемы, возникающие при взаимодействии нескольких потоков и приводящие к неожиданному поведению программы. Эти ошибки может быть сложно воспроизвести и отладить. Примеры ошибок параллелизма включают гонки данных (data races), нарушения атомарности и проблемы с упорядочением. Для их выявления и устранения часто требуется глубокое понимание принципов многопоточности и тщательное тестирование.

Чрезмерное переключение контекста

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

Практические примеры

Пример 1. Создание и запуск потоков:


public class ThreadExample {
    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            final int taskNumber = i;
            Thread thread = new Thread(() -> {
                System.out.println("Thread " + taskNumber + " is running.");
            });
            thread.start();
        }
    }
}
В этом коде мы создаем десять потоков с помощью цикла. Каждый поток печатает сообщение, и мы начинаем каждый поток с метода start().

Пример 2. Синхронизация доступа к общим ресурсам:


public class SynchronizationExample {
    private static int counter = 0;

    public static synchronized void incrementCounter() {
        counter++;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    incrementCounter();
                }
            });
            thread.start();
        }

        // Ждем завершения всех потоков
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter);
    }
}
Этот пример демонстрирует необходимость синхронизации, когда несколько потоков обращаются к общему ресурсу, в данном случае к общему счетчику. У нас есть общий счетчик, и несколько потоков увеличивают его в цикле. Мы используем ключевое слово synchronized, чтобы гарантировать, что только один поток может изменять счетчик одновременно. Без синхронизации результат, скорее всего, будет неверным из-за условий гонки.

Пример 3. Использование пулов потоков для параллельных задач:


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // Создаем пул потоков с тремя потоками
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 10; i++) {
            final int taskNumber = i;
            executor.execute(() -> {
                System.out.println("Task " + taskNumber + " is running on thread " + Thread.currentThread().getName());
            });
        }

        // Завершение работы пула потоков
        executor.shutdown();
    }
}
В этом примере показано, как использовать пул потоков для одновременного выполнения нескольких задач. Здесь мы создаем пул потоков фиксированного размера с тремя потоками, используя Executors.newFixedThreadPool(3). Затем мы отправляем на выполнение десять задач. Пул потоков эффективно управляет выполнением этих задач, и вы можете контролировать количество потоков в пуле в соответствии с доступными ресурсами. Надеюсь, эти примеры продемонстрировали вам основные концепции создания потоков, синхронизации доступа к общим ресурсам и использования пулов потоков для одновременного выполнения задач в Java. Включение многопоточности в ваши приложения Java позволяет им эффективно справляться с одновременными задачами, делая ваше программное обеспечение более отзывчивым и функциональным. Благодаря этим основополагающим знаниям и практическим примерам мы теперь хорошо подготовлены к использованию потенциала многопоточности в вашем путешествии по разработке Java.
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ