Skip to content

Latest commit

 

History

History
561 lines (467 loc) · 27.8 KB

File metadata and controls

561 lines (467 loc) · 27.8 KB

Занятие 12. Многопоточное программирование

Многопоточность — это способность программы одновременно выполнять несколько задач, используя несколько потоков.

Разработка многопоточных программ сложнее однопоточных из-за риска возникновения конфликтов. Они могут произойти, когда несколько потоков одновременно обращаются к общим ресурсам, таким как данные в памяти или файлы. Это приводит к ошибкам, нестабильности и сбоям в работе приложения. Такая одновременная работа с общим ресурсом называется конкурентностью (от англ. concurrency).

Процессы и потоки исполнения

Процесс в операционной системе (ОС) — это запущенная программа. При запуске Java-программы в ОС стартует новый процесс.

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

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

  1. Создать собственный класс, реализующий интерфейса Runnable.
  2. Реализовать абстрактный метод run() в собственном классе.
  3. Создать поток объект типа Runnable, экземпляр собственного класса.
  4. Создать поток объект класса Thread, передав ему в качестве параметра объект типа Runnable — экземпляр собственного класса.
  5. Вызвать у созданного объекта класса Thread метод start(). После этого новый поток запуститься. При вызове у этого потока метода sleep(millis) он перейдет в режим ожидания на заданное количество миллисекунд millis (т.е. данный поток это время не выполняется).
  6. Запущенный поток исполняется параллельно с другими запущенными патоками.
  7. Поток завершится, когда будет выполнена его последняя инструкция.

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

Создание и запуск потоков

Поток исполнения реализуется на основе объекта класса реализующего интерфейс Runnable.

public class MyThread implements Runnable {
    String name;

    MyThread( String name) {
        this.name = name;
        System.out.println(this.name + ": CREATED");
    }

    public void run() {
        System.out.println(name + ": RUNNING");
        try {
            for(int i = 0; i < 20; i++) {
                System.out.println(name + ": " + i);
                // Усыпить поток
                Thread.sleep(50);
            }
        } catch (InterruptedException e) {
            System.out.println(name + ": INTERRUPTED");
        }
        System.out.println(name + ": FINISHED");
    }
}

Создание и запуск нескольких потоков.

public class DemoApp {
    public static void main(String[] args) {
        // Создать 1-й объект типа Runnable
        MyThread mt1 = new MyThread("Thread-1");
        // Создать 1-й поток
        Thread t1 = new Thread(mt1);
        System.out.println(mt1.name + ": CREATED");
        // Запустить 1-й поток
        t1.start();
        System.out.println(mt1.name + ": STARTED");

        // Создать 2-1 объект типа Runnable
        MyThread mt2 = new MyThread( "Thread-2");
        // Создать 2-й поток
        Thread t2 = new Thread(mt2);
        System.out.println(mt2.name + ": CREATED");
        // Запустить 2-й поток
        t2.start();
        System.out.println(mt2.name + ": STARTED");
    }
}

Собственный класс, реализующий интерфейс Runnable, можно дополнить для удобства методом запуска потока.

public class MyThread implements Runnable {
    Thread t;
    String name;

    MyThread( String name) {
        this.name = name;
        System.out.println(name + ": CREATED");
    }

    public void run() {
        System.out.println(name + ": RUNNING");
        try {
            for(int i = 0; i < 20; i++) {
                System.out.println(name + ": " + i);
                // Усыпить поток
                Thread.sleep(50);
            }
        } catch (InterruptedException e) {
            System.out.println(name + ": INTERRUPTED");
        }
        System.out.println(name + ": FINISHED");
    }

    public void start() {
        System.out.println(name + ": STARTED");
        if (t == null) {
            t = new Thread(this, name);
            t.start ();
        }
    }
}

В этом случае упроститься создание и запуск нескольких потоков.

public class DemoApp {
    public static void main(String[] args) {
        MyThread mt1 = new MyThread( "Thread-1");
        mt1.start();

        MyThread mt2 = new MyThread( "Thread-2");
        mt2.start();
    }
}

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

public class DemoApp {
    public static void main(String[] args) {
        MyThread mt1 = new MyThread( "Thread-1");
        mt1.start();

        MyThread mt2 = new MyThread( "Thread-2");
        mt2.start();

        // Основной поток завершен
        System.out.println("MAIN THREAD FINISHED");
    }
}

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

public class DemoApp {
    public static void main(String[] args) {
        MyThread mt1 = new MyThread( "Thread-1");
        mt1.start();
        MyThread mt2 = new MyThread( "Thread-2");
        mt2.start();
        
        try {
            mt1.t.join(); // Присоединить 1-й поток к основному потоку
            mt2.t.join(); // Присоединить 2-й поток к основному потоку
        } catch (InterruptedException e) {
            // Прерывание основного потока
            System.out.println("MAIN THREAD INTERRUPTED");
        }

        // Теперь основной поток ждет завершения присоединенных к нему потоков
        System.out.println("MAIN THREAD FINISHED");
    }
}

Синхронизация потоков

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

Состояние гонки

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

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

class BankAccount {
    private int balance = 100;
    public  void  withdraw(int amount) { 
        if (this.getBalance() >= amount)
            this.setBalance(this.getBalance() - amount); 
    }
    // другой код
}

Пусть изначально баланс счета составляет 100 рублей и двум потокам необходимо списать 60 и 70 рублей соответственно. Может получиться следующая последовательность действий:

  1. Поток 1 проверяет баланс — доступно списание на 60 рублей.
  2. Поток 2 проверяет баланс — доступно списание на 70 рублей.
  3. Поток 1 устанавливает значение баланса в 40 рублей.
  4. Поток 2 устанавливает значение баланса в 30 рублей.

В результате обе операции успешно завершены: на счету осталось 30 рублей, но значение его баланса некорректно.

Мьютекс и монитор

Мьютекс (англ. mutex) — это механизм синхронизации, т.е. взаимного исключения одновременного обращения к некоторой секции кода или ресурсу. Мьютекс гарантирует, что в определённый момент времени только один поток имеет доступ к секции кода или ресурсу.

Монитор — это реализация мьютекса в Java, которая обеспечивает эксклюзивный доступ к общему ресурсу. Работа с мониторами осуществляется с помощью ключевого слова synchronized.

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

В качестве монитора может выступать любой объект. Монитор привязан именно к объекту, а не к классу.

class BankAccount {
    private int balance = 100;
    public  void  withdraw(int amount) {
        synchronized(this) {
            if (this.getBalance() >= amount)
                this.setBalance(this.getBalance() - amount); 
        }
    }
    // другой код
}

В этом примере объект, на котором вызываются методы, сам выступает в роли монитора.

Явный мьютекс ReentrantLock:

import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

Явный мьютекс Semaphore с 1 разрешением:

import java.util.concurrent.Semaphore;

public class Counter {
    private int count = 0;
    private final Semaphore mutex = new Semaphore(1); // 1 разрешение = мьютекс
    
    public void increment() throws InterruptedException {
        mutex.acquire();
        try {
            count++;
        } finally {
            mutex.release();
        }
    }
}

Синхронизированный блок кода

Синхронизация на объекте:

public class Counter {
    private int count = 0;
    private final Object lock = new Object(); // объект-монитор
    
    public void increment() {
        synchronized(lock) {
            count++; // операция теперь атомарна
        }
    }
    
    public int getCount() {
        synchronized(lock) {
            return count;
        }
    }
}

Синхронизация на текущем объекте:

public class BankAccount {
    private double balance;
    
    public void withdraw(double amount) {
        synchronized(this) { // использование this как монитора
            if (balance >= amount) {
                balance -= amount;
            }
        }
    }
    
    public void deposit(double amount) {
        synchronized(this) {
            balance += amount;
        }
    }
}

Синхронизированный метод

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

public class Speaker extends Thread {
    static Microphone microphone = new Microphone();
    String voice;

    Speaker(String voice) {
        this.voice = voice;
    }

    public void run() {
        microphone.say(voice);
    }
}
class Microphone {
    // Синхронизированный метод
    synchronized void say(String voice) {
        try {
            for (int i = 0; i < 20; i++) {
                System.out.println(voice + " ");
                Thread.sleep(10);
            }
        } catch (InterruptedException e) {
        }
    }
}
public class DemoApp {
    public static void main(String[] args) {
        Speaker cat = new Speaker("meow");
        Speaker dog = new Speaker("woof");
        Speaker cow = new Speaker("moo");
        Speaker pig = new Speaker("oink");

        cat.start();
        dog.start();
        caw.start();
        pig.start();
    }
}

Виртуальные потоки

Виртуальные потоки — это легковесные потоки, которые работают поверх платформенных потоков ОС (carrier threads). В отличие от платформенных потоков, которые отображаются один-к-одному на потоки ОС, виртуальные потоки управляются JVM.

Основные отличия виртуальных потоков от платформенных потоков

Характеристика Платформенные потоки Виртуальные потоки
Реализация Потоки операционной системы Легковесные потоки JVM
Соотношение 1:1 с потоками ОС M:N с потоками ОС
Создание Дорогое (системные вызовы) Дешевое (управление JVM)
Масштабирование Ограничено числом потоков ОС (обычно 1000-10000) Миллионы потоков одновременно
Блокировка Блокирует поток ОС Освобождает поток ОС при блокировке
Планирование Планировщик ОС Планировщик JVM
Приоритеты Поддерживают приоритеты Все имеют нормальный приоритет

Создание виртуальных потоков

Самый простой через Thread.startVirtualThread():

// Самый простой способ - для fire-and-forget задач
Thread.startVirtualThread(() -> {
    System.out.println("Виртуальный поток выполняется: " + Thread.currentThread());
});

// С обработкой исключений
Thread.startVirtualThread(() -> {
    try {
        String result = performTask();
        System.out.println("Результат: " + result);
    } catch (Exception e) {
        System.err.println("Ошибка: " + e.getMessage());
    }
});

Настраиваемое создание через Thread.ofVirtual()

// Базовое создание
Thread virtualThread = Thread.ofVirtual()
    .start(() -> {
        System.out.println("Поток запущен");
    });

// С именем и номерами
for (int i = 0; i < 5; i++) {
    Thread virtualThread = Thread.ofVirtual()
        .name("worker-", i)
        .start(() -> {
            System.out.println(Thread.currentThread().getName() + " выполняется");
        });
}

// Создание без запуска (unstarted)
Thread unstartedThread = Thread.ofVirtual()
    .name("pending-thread")
    .unstarted(() -> {
        System.out.println("Этот поток нужно запустить вручную");
    });

unstartedThread.start(); // Запускаем позже

Массовое создание с помощью ThreadFactory:

// Создание фабрики
ThreadFactory factory = Thread.ofVirtual()
    .name("service-thread-", 0)
    .factory();

// Использование фабрики
for (int i = 0; i < 10; i++) {
    Thread thread = factory.newThread(() -> {
        System.out.println(Thread.currentThread().getName() + " работает");
    });
    thread.start();
}

Рекомендуемый способ по умолчанию через Executors.newVirtualThreadPerTaskExecutor():

// Создание ExecutorService с виртуальными потоками
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    
    // Отправка задач
    Future<String> future1 = executor.submit(() -> {
        return "Результат задачи 1";
    });
    
    Future<Integer> future2 = executor.submit(() -> {
        Thread.sleep(1000);
        return 42;
    });
    
    // Получение результатов
    System.out.println(future1.get());
    System.out.println(future2.get());
}
// Executor автоматически закрывается благодаря try-with-resources

Использование виртуальных потоков

import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class VirtualThreadsDemo {
    
    public static void main(String[] args) {
        System.out.println("=== Демонстрация виртуальных потоков ===");
        
        // Создание и запуск виртуального потока
        Thread virtualThread = Thread.ofVirtual()
            .name("virtual-thread-1")
            .start(() -> {
                System.out.println("Привет из виртуального потока!");
                System.out.println("Этот поток виртуальный: " + Thread.currentThread().isVirtual());
                System.out.println("Имя потока: " + Thread.currentThread().getName());
            });
        
        try {
            virtualThread.join(); // Ждем завершения потока
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Задания

12-1

  • Разработать многопоточное приложение: генератор случайных последовательностей ДНК.
  • Доработать многопоточное приложение: генератор случайных последовательностей ДНК.
  • Создать 4 потока для генерации текста ДНК, каждый из которых печатает одну из букв ДНК: «T», «C», «G», «A» в общую строку типа StringBuilder. Созданные потоки должны выполняться одновременно.
  • Пользователем должна настраиваться длинна генерируемой последовательности ДНК. Для каждого потока должен автоматически настраиваться лимит итераций печати для буквы, исходя из заданной длинны последовательности ДНК, также случайным образом должна настраиваться задержка итерации в миллисекундах. По завершении порожденных потоков печати букв, текст ДНК должен быть записан в файл из основного потока.
  • Используйте Thread.sleep() для имитации задержки операций.

Примеры онлайн генераторов: https://www.bioinformatics.org/sms2/random_dna.html http://www.faculty.ucr.edu/~mmaduro/random.htm

12-2

  • Реализовать потокобезопасный банковский счет с использованием синхронизации потоков.
  • Создайте класс BankAccount с полем balance (начальный баланс = 1000)
  • Реализуйте методы:
    • deposit(int amount) - пополнение счета
    • withdraw(int amount) - снятие со счета
    • getBalance() - получение текущего баланса
  • Создайте 10 потоков, которые одновременно:
    • 5 потоков выполняют 100 операций пополнения по 100 единиц
    • 5 потоков выполняют 100 операций снятия по 50 единиц
  • Убедитесь, что итоговый баланс корректен (должно быть 1000 + 5×100×100 - 5×100×50 = 1000 + 50000 - 25000 = 26000)
  • Реализуйте два варианта:
    • С использованием synchronized методов
    • С использованием synchronized блока
  • Используйте Thread.sleep() для имитации задержки операций и увеличения вероятности состояния гонки.

12-3

  • Сравнить время создания и выполнения большого количества потоков.
  • Создайте класс ThreadBenchmark для сравнения:
    • Платформенных потоков (Thread)
    • Виртуальных потоков (Thread.ofVirtual())
    • Виртуальных потоков через ExecutorService
  • Реализуйте тест, где каждый поток:
    • Выполняет Thread.sleep(100) (имитация I/O операции)
    • Возвращает свой идентификатор
  • Запустите тест для:
    • 1000 потоков
    • 10000 потоков
    • 100000 потоков (только виртуальные)
  1. Измерьте:
    • Время создания всех потоков
    • Общее время выполнения
    • Использование памяти

Вопросы

  1. Что такое многопоточность?
  2. Чем процесс отличается от потока?
  3. Чем платформенный поток отличается от виртуального?
  4. В каких ситуациях виртуальные потоки более эффективны по сравнению с платформенными?
  5. В каких ситуациях платформенные потоки более эффективны по сравнению с виртуальными?
  6. Есть ли платформенные потоки исполнения в вашей программе?
  7. Есть ли виртуальные потоки исполнения в вашей программе?
  8. Возможна ли состояние гонки в вашей программе?
  9. Используете ли вы мьютекс в вашей программе?
  10. Есть ли синхронизированные методы в вашей программе?
  11. Есть ли синхронизированные блоки кода в вашей программе?
  12. Какие объекты используются как мониторы в вашей программе?
  13. Определение момента завершения потока
  14. Какие приоритеты имеют ваши потоки?
  15. С какой инструкции начинается основной поток вашей программы?
  16. На какой инструкции заканчивается основной поток вашей программы?
  17. Используете ли вы синхронизацию?
  18. Синхронизированные методы
  19. Синхронизированные блоки кода
  20. Используете ли вы ожидание и нотификацию потоков?
  21. Есть ли лямбда-выражения в вашей программе?
  22. Какие параметры имеет то или иное лямбда-выражение в вашей программе?
  23. Какой блок кода составляет тело того или иного лямбда-выражения в вашей программе?
  24. Какой функциональный интерфейс имеет то или иное лямбда-выражение в вашей программе?
  25. Какой возвращаемый тип данных имеет то или иное лямбда-выражение в вашей программе?
  26. Есть ли вашем коде ссылки на методы в вашем коде?
  27. Есть ли аргументы типов в вашем коде?
  28. Есть ли в вашей программе объекты или блоки кода, которые используются несколькими потоками одновременно без синхронизации?
  29. Объясните концепцию мьютекс в контексте многопоточного программирования?
  30. Объясните концепцию монитор в контексте многопоточного программирования?

Дополнительные ресурсы

  1. https://www.tutorialspoint.com/java/java_multithreading.htm