Что, если вам нужно подгрузить в свою игру данные прямо во время выполнения? Если вы не будете использовать многопоточность, ваш интерфейс просто зависнет и пользователь подумает, что земля пухом приложению. Да и в целом, есть множество операций, в которых нужно ждать чего-либо. Не подвешивать же ради этого пользовательский интерфейс. Но как?
Между прочим, когда вы пишете программу на Java, у вас в программе уже по-любому есть потоки. Только он один – и это main thread – главный поток, с которого начинается выполнение программы. Давайте научимся создавать и запускать свои потоки.
Содержание:
Процессы и потоки
Различные процессы и потоки нужны, чтобы выполняться параллельно – одновременно друг с другом.
Процесс — совокупность кода и данных, которые объединяются общим виртуальным адресным пространством. Это законченная и независимая единица выполнения. Каждая отдельная программа чаще всего занимает один процесс. Но порой бывает и больше – Google Chrome создаёт каждой вкладке по отдельному процессу. При начале работы программы операционная система формирует процесс, копируя код и данные программы в память. После этого она запускает основной поток этого процесса.
Потоки – это более мелкие крупицы выполнения. В каждом процессе есть как минимум один поток – основной, main thread. В программе на Java мы пишем метод main(), с которого начинается программа. Так запускается главный поток. После этого по нуждам задачи можно прописать запуск и других потоков. Потоки имеют в себе некоторый код, который нужно выполнять. Это может быть как единоразовая задача, так и какой-нибудь цикл while(true), который постоянно крутится, пока его не остановят.
Давайте рассмотрим понятие “параллельно” более подробно. Даже на одноядерном процессоре, где обычно только одна команда может выполняться в каждый момент времени, возможно запускать несколько потоков параллельно. В таком случае процессор периодически переключается между этими потоками, давая каждому из них возможность выполнения. Это называется псевдопараллелизмом. Таким образом, процессор прыгает между потоками, мечась от одного к другому настолько быстро, что вы и заметить не успеваете.
В Java программа завершается после завершения всех её потоков – кроме фоновых задач, так называемых “демонов“. То есть выделяют два типа потоков:
- Обычные потоки – программа ждёт их завершения, чтобы завершиться самой,
- Потоки-демоны – это фоновые задачи. Если все остальные недемонические потоки завершились, а потоки-демоны нет – программа всё равно завершится и собственноручно прибьёт потоки-демоны.
Объявить поток демоном или проверить, является ли он демоническим, можно с помощью методов класса Thread, о которых мы поговорим дальше.
Класс Thread
Для запуска задачи можно создать объект Thread и задать ему действие. По существу, мы лямбда-выражением замещаем создание Runnable для конструктора Thread:
1 2 3 |
Thread thread = new Thread(() -> { System.out.println("I'm blue, da ba dee, da ba daa!"); }); |
Также можно унаследовать свой класс от Thread и записать это действие в переопределении метода run():
1 2 3 4 5 6 |
public class DaBaDeeThread extends Thread { @Override public void run() { System.out.println("I'm blue, da ba dee, da ba daa!"); } } |
Выбирайте на свой вкус и цвет. Но написать создание потока ещё не значит запустить его. У класса Thread есть несколько основных методов для работы:
- start() – запустить выполнение потока.
- stop() – остановить выполнение потока. Однако его не рекомендуют, потому что можно оборвать на полуслове операцию, которая в итоге приведёт к неправильному состоянию объекта, с которым она работала.
- interrupt() – рекомендуют для остановки потока использовать именно этот метод. При вызове метода interrupt(), устанавливается флаг прерывания потока. Это означает, что если поток находится в режиме ожидания (например, в методе sleep(), wait(), или join()), он будет прерван, вызвав исключение InterruptedException, и флаг прерывания сбросится. Причём когда поток не находится в режиме ожидания, то он должен самостоятельно обрабатывать прерывание, т.е. проверять методом isInterrupted() время от времени, не пора ли ему баиньки.
- isInterrupted() – как раз метод для того, чтобы узнать, был ли послано потоку сообщение о необходимости прерваться.
- isAlive() – используется для проверки, выполняется ли ещё поток или уже завершил свою работу. Если поток выполняется, метод возвращает true, если же поток был завершён или ещё не был запущен, то метод возвращает false.
- isDaemon() – проверка на демона (фоновый ли перед нами поток).
- getName()/setName() – получить/поменять название потока.
- getPriority()/setPriority() – установить приоритет потока (int). Чем больше приоритет, тем больше процессорного времени получает поток.
- getState() – возвращает текущее состояние потока (enum State). Это может быть:
- NEW (создан). Поток создан, но еще не запущен методом start().
- RUNNABLE (выполняется). Поток работает.
- BLOCKED (заблокирован). Поток заблокирован, потому что хочет обратиться к ресурсам, которые уже использует другой поток. Он смиренно ждёт, пока ресурсы освободятся.
- WAITING (ждёт). Поток находится в состоянии ожидания (через какие-нибудь Thread.sleep(), Thread.join(), Object.wait() и так далее).
- TIMED_WAITING (ждёт по таймауту). Поток находится в состоянии ожидания с указанным временным интервалом с помощью методов ожидания (как в предыдущем пункте). Но только он закончит ждать, когда истечёт заданное параметром метода время.
- TERMINATED (завершён). Поток завершил свое выполнение – либо успешно, либо с ошибкой.
Отдельно хочу выделить часто используемый метод Thread.sleep(). Когда вам нужно ожидать события, вы можете в цикле крутить Thread.sleep(500). Тогда этот метод будет ждать 500 миллисекунд – 0.5 секунды. И каждые полсекунды делать проверку, наступило ли событие. Либо же вам в целом нужно подождать какое-то время – и вы можете его передать как параметр в этот метод. Как правило, Thread.sleep() действительно часто пригождается в распространённых задачах.
Пример
Давайте напишем небольшой примерчик, чтобы увидеть многопоточность в действии. Для того, чтобы сделать долгую операцию, прикажем классу-наследнику Thread поработать с файлами: записать в один из них 10 000 строк. При этом он будет нам периодически сообщать о своём успехе благодаря выводу в консоль:
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 31 32 33 34 |
public class FileWriteThread extends Thread { private File file; public FileWriteThread(String filename) { file = new File(filename); } @Override public void run() { System.out.println("Поток записи в файл запущен"); try { writeToFile(); } catch (IOException ex) { System.out.println("При записи в файл произошла ошибка"); } finally { System.out.println("Поток записи в файл завершился"); } } private void writeToFile() throws IOException { FileWriter fileWriter = new FileWriter(file); BufferedWriter bufferedWriter = new BufferedWriter(fileWriter); long linesToWrite = 1000000000L; long everyCountOfLinesToNotify = 100000000L; for (int i = 1; i <= linesToWrite; i++) { bufferedWriter.write("Строка №" + i); if (i % everyCountOfLinesToNotify == 0) { System.out.println("Записал в файл уже " + i + " строк"); } } bufferedWriter.close(); } } |
Здесь операция записи в файл окружена конструкцией try-catch-finally. Она позволяет словить исключение, если возникнет ошибка при работе с файлом.
А второй поток создадим другим способом – через лямбда-выражение с Runnable. Перейдём в класс Main и напишем метод создания потока. Он будет проверять знания пользователя в сложении, генерируя случайные примеры и считывая ввод с клавиатуры:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
private static Thread createCalculationThread() { return new Thread(() -> { Random random = new Random(); Scanner scanner = new Scanner(System.in); while (true) { int firstNumber = random.nextInt(100); int secondNumber = random.nextInt(100); System.out.print("Решите пример: " + firstNumber + " + " + secondNumber + " = "); int answer = scanner.nextInt(); if (answer == firstNumber + secondNumber) { System.out.println("Да!"); } else { System.out.println("Нет..."); } } }); } |
Теперь давайте запустим два потока вместе! Должно получиться так, что долгая запись в файл не помешает нам взаимодействовать с программой. По сути, пока в файл пишутся кучи строк, мы спокойно решаем примеры.
1 2 3 4 5 6 7 |
public static void main(String[] args) { Thread calculationThread = createCalculationThread(); Thread fileWriteThread = new FileWriteThread("file.txt"); calculationThread.start(); fileWriteThread.start(); } |
Теперь будем решать примеры, параллельно видя уведомления о записи строк в файл.
Вот так! Задачи выполнялись параллельно в силу того, что мы запустили несколько потоков. А даже если и на одном ядре бы выполнялись, для нас бы это выглядело как параллельно.
Нормальный такой на 17 гигов файлик получился. Миллиард строчек, как-никак!
Мало, конечно, просто начать вставлять потоки направо и налево. Ведь их ещё синхронизировать часто бывает нужно: что, если один поток изменит переменную, к которой другой поток сейчас обращается? Но это уже тема другая. Первоначальное знакомство с потоками мы с вами прошли.