Код
#статьи

Потоки в Java: что это такое и как они работают

Выжимаем максимум из процессора и заставляем программы на Java выполнять несколько задач одновременно.

Иллюстрация: Merry Mary для Skillbox Media

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

Но и одноядерную систему можно настроить так, чтобы она работала над несколькими наборами инструкций как бы одновременно — то есть переключалась между ними очень быстро и незаметно для пользователя. При этом за каждую подзадачу будет отвечать своё «виртуальное» ядро, или поток (его ещё называют thread, то есть «нить»). Это и есть многопоточность. Разберёмся, что это такое и как её настроить.

Что такое поток в Java

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

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

Любой процесс имеет минимум один поток, который называют главным. Он запускается в первую очередь, а остальные идут параллельно. Например, при запуске программы на Java процесс — это её среда исполнения (JRE).

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

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

А если программа использует больше тредов, чем есть ядер у компьютера? Вам не стоит беспокоиться: за это отвечает планировщик ОС, который сам распределит ресурсы в угоду производительности.

Как работают потоки в Java

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

public class Main{
    public static void main(String[] args){
        System.out.print("Hello");
        System.out.print(" ");
        System.out.print("World");
    }
}

Команды будут выполняться последовательно: сначала на экране появится слово Hello, затем пробел, а в конце — World. Это обычное поведение программы, но его можно изменить с помощью класса Thread и интерфейса Runnable из стандартной библиотеки java.lang.

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

public class Main{
    public static void main(String[] args){
        System.out.println(Thread.currentThread());
    }
}
Вывод: Thread[main,5,main]

В квадратных скобках первым параметром указано имя потока main, о котором говорилось выше. Второй параметр — это приоритет (по умолчанию он равен 5), третий — имя группы потоков.

Отдельно имя можно получить с помощью метода getName():

    System.out.println(Thread.currentThread().getName());
Вывод: main

Как создать свой поток в Java

Для этого есть класс Thread — это поток, только на уровне кода. Создать их можно сколько угодно, но одновременно будет выполняться столько, сколько поддерживает ваша система.

Интерфейс Runnable — это задача, которую выполняет поток, то есть код. Интерфейс содержит основной метод run() — в нём и находится точка входа и логика исполняемого потока.

Создать поток в Java можно двумя способами.

Первый способ:

  • Определить класс — наследник класса Thread и переопределить метод run().
  • Создать экземпляр своего класса и вызвать метод start().
class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("Hello, I’m " + Thread.currentThread());
    }
}
public class Main{
    public static void main(String[] args){
        MyThread myThread = new MyThread();
        myThread.start();
    }
}
Вывод: Hello, I’m Thread[Thread-0,5,main]

Обратите внимание: если на экземпляре класса Thread вместо метода start() вызвать run(), то код, написанный для другого потока, отлично выполнится, но выполнит его тот же тред, который и вызвал этот метод, а новый запущен не будет! Поэтому нужно пользоваться методом start().

Второй способ:

  • Реализовать интерфейс Runnable и метод run().
  • Создать экземпляр Thread и передать в конструктор свой Runnable (экземпляр класса, реализующий этот интерфейс).
class MyThread implements Runnable{
    @Override
    public void run(){
        System.out.print("Hello, I’m " + Thread.currentThread().getName());
    }
}

public class Main{
    public static void main(String[] args){
        // Первый параметр: экземпляр Runnable
        // Второй параметр: своё имя (необязательно) 
        Thread myThread = new Thread(new MyThread(), "Leo");
        myThread.start();
    }
}
Вывод: Hello, I’m Leo

Второй вариант лучше — он более гибкий. Например, если бы MyThread уже наследовал какой-либо класс, то было бы невозможно пойти первым путём, так как Java не поддерживает множественное наследование.

Практика: изучаем потоки на котиках

Напишем небольшую консольную игру, в которой будут драться… коты. Обещаем: в процессе написания программы ни один кот не пострадает, зато вы увидите, как «нити» конкурируют между собой.

В коде будут новые ключевые слова и класс, которых вы раньше, скорее всего, не встречали:

  • synchronized перед методом означает, что он синхронизирован. Поток, вызвавший синхронизированный метод, запрещает другим нитям к нему обращаться, пока сам не выйдет из метода.
  • volatile нужен, когда одну переменную используют разные потоки, во избежание некорректных результатов.
  • Класс CopyOnWriteArrayList — это тот же ArrayList, только потокобезопасный, то есть оптимизированный под использование нескольких потоков. Он находится в библиотеке java.util.concurrent.

Программа состоит из двух классов — CatFightsConsole и Cat. Вначале создаётся N боевых «котов», а когда они запускаются, они начинают драться друг с другом. Каждый кот стремится первым вызвать метод Cat.attack(), чтобы атаковать случайного из оставшихся в живых конкурентов и отнять у него жизнь. Когда жизнь кота становится равна 0, его поток завершает свою работу. Бой идёт до последнего кота.

Класс CatFightsConsole содержит метод main(String[] args), в котором производится начальная настройка программы: создание и настройка объектов, запуск потоков. Далее главный поток натыкается на метод join(), который говорит ему остановиться на этой строчке, пока не завершится поток, на котором был вызван метод. Когда все «кототреды» завершат свою работу, в консоль выведется сообщение о последнем коте — победителе.

// Главный класс
public class CatFightsConsole {
   // Точка входа в программу, Main Thread
   public static void main(String[] args){
       // Title
       System.out.println("Cat Fights Console");

       // Создаём контейнер с котами
       List<Cat> catThreads = new ArrayList<>();
       // Жизни котов
       int life = 9;

       // Создаём и настраиваем классы-потоки котов, добавляя их в контейнер
       Collections.addAll(catThreads,
               new Cat("Tom", life, "Thread Tom"),
               new Cat("Cleocatra", life, "Thread Cleocatra"),
               new Cat("Dupli", life, "Thread Dupli"),
               new Cat("Toodles", life, "Thread Toodles"));

       // Запускаем котов
       for(Cat cat : catThreads)
           cat.getThread().start();

       // Ждём, пока завершатся все, кроме главного
       for(Cat cat : catThreads){
           try{
     // Поток, который вызвал метод join(), приостанавливается на этой строчке
               cat.getThread().join();   
               // Пока поток, на котором вызван метод, не завершит работу, Main ждёт остальных
           }catch (InterruptedException e){
               e.printStackTrace();
           }
       }

       // Последний выживший — первый элемент cats
       System.out.println(String.format("Кот-победитель: %s!!!", Cat.cats.get(0)));
   }
}

У класса Cat есть имя (String name), количество жизней (int life), личный поток и статический список cats со ссылками на все объекты Cat. Он реализует интерфейс Runnable, а значит, основной цикл работы потока происходит в методе run().

При запуске потока, пока объектов Cat более одного и пока у них есть жизни, вызывается синхронизированный статический метод Cat.attack(), который декрементирует переменную life с помощью метода decrementLife() у второго переданного в него объекта. Если после этого значение life равно нулю, то у этого же объекта вызывается метод getThread(), а на нём interrupt() — функция, прерывающая работу потока.

// Это класс «Кот»
class Cat implements Runnable{

   // Статический контейнер всех созданных «кототредов»
   // Класс CopyOnWriteArrayList — тот же ArrayList, только потокобезопасный
   public static final List<Cat> cats = new CopyOnWriteArrayList<>();

   // Имя и количество жизней
   private String name;
   private volatile int life;
   // Личный поток
   private Thread thread;

   // Конструктор: задаём параметры и добавляем объект в статический список
   public Cat(String name, int life, String threadName) {

       this.name = name;           // Имя
       this.life = life;           // Количество жизни
       Cat.cats.add(this);         // Добавляем себя в List<Cat> cats
       thread = new Thread(this, threadName);   // Создаём поток этого кота и передаём ему ссылку на себя

       System.out.println(String.format("Кот %s создан. HP: %d", this.name, this.life));
   }

   // Атака. Принимает текущего кота и кота-противника. Метод синхронизирован
   public static synchronized void attack(Cat thisCat, Cat enemyCat) {

       // Дополнительная проверка жизни — во избежание конфликта (у кота может не быть жизней)
       if (thisCat.getLife() <= 0) { return; }

       // Если противник имеет жизни
       if (enemyCat.getLife() > 0) {
           // Отнимаем жизнь противника
           enemyCat.decrementLife();
           System.out.println(String.format("Кот %s атаковал кота %s. Жизни %<s: %d", thisCat.getName(), enemyCat.getName(), enemyCat.getLife()));

           // Если противник не имеет жизней
           if (enemyCat.getLife() <= 0) {
               // Удаляем противника из списка котов
               Cat.cats.remove(enemyCat);

               System.out.println(String.format("Кот %s покидает бой.", enemyCat.getName()));
               System.out.println(String.format("Оставшиеся коты: %s", Cat.cats));
               System.out.println(String.format("%s завершает свою работу.", enemyCat.getThread().getName()));
               // interrupt() — прервать работу треда
               enemyCat.getThread().interrupt();
           }
       }
   }

   // Точка входа в поток
   @Override
   public void run() {
       System.out.println(String.format("Кот %s идёт в бой.", name));

       // Пока котов больше 1
       while (Cat.cats.size() > 1){
           // Атакуем произвольного кота из оставшихся, кроме себя
           Cat.attack(this, getRandomEnemyCat(this));
       }
   }

   // Возвращает произвольный объект Cat из cats, кроме самого себя
   private Cat getRandomEnemyCat(Cat deleteThisCat) {

       // Создаём лист-копию из основного листа cats
       List<Cat> copyCats = new ArrayList<>(Cat.cats);
       // Удаляем текущего кота, чтобы он не выпал в качестве противника
       copyCats.remove(deleteThisCat);
       // Возвращаем произвольного кота из оставшихся с помощью класса util.java.Random
       return copyCats.get(new Random().nextInt(copyCats.size()));
   }

   // Декремент жизней
   public synchronized void decrementLife() { life--; }

   // Нужен для корректного вывода 
   @Override
   public String toString() { return name; }

   // Геттеры и сеттеры
   public String getName() { return name; }
   public int getLife() { return life; }
   public Thread getThread() { return thread; }
}
 Вывод:   Оставшиеся коты: [Tom, Dupli, Toodles]
          Cleocatra завершает свою работу.
          Кот Dupli атаковал кота Toodles. Жизни Toodles: 0
          Кот Toodles покидает бой.
          Оставшиеся коты: [Tom, Dupli]
          Toodles завершает свою работу.
          Кот Dupli атаковал кота Tom. Жизни Tom: 2
          Кот Dupli атаковал кота Tom. Жизни Tom: 1
          Кот Dupli атаковал кота Tom. Жизни Tom: 0
          Кот Tom покидает бой.
          Оставшиеся коты: [Dupli]
          Tom завершает свою работу.
          Кот-победитель: Dupli!!!

          Process finished with exit code 0

Изучите код и запустите его на своём компьютере. Из-за постоянной гонки потоков результаты будут различаться при каждом запуске программы.

Посмотрите, как поведут себя потоки, если дать «котам» 1 или 100 000 жизней. Проверьте, не происходит ли ошибок в вычислениях здоровья и успешно ли завершаются треды.

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

Состояния потока и время его жизни

Поток, как и любой другой объект, имеет цикл жизни: он рождается, живёт (готов к выполнению или выполняется), спит (находится в ожидании), и умирает (завершает свою работу).

Получить текущее состояние потока позволяет метод getState(). Он возвращает одно из значений перечисления State, которое содержит набор из 6 констант:

  • ‌NEW — новый, только что созданный поток. Это состояние присваивается, когда выделяется память для объекта.
Thread myThread = new Thread();
  • ‌RUNNABLE — вызывая метод start(), поток становится готовым к выполнению, а затем выполняемым.
  • ‌BLOCKED / WAITING / TIME_WAITING — данные состояния означают, что поток находится в ожидании своего выполнения.
  • ‌TERMINATED — после завершения работы поток уничтожается.

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

class MyThread implements Runnable{
    @Override
    public void run(){
        // Здесь прописана логика объекта
    }
}

public class Main {
    public static void main(String[] args) {
        // При создании объект имеет состояние NEW
        Thread myThread = new Thread(new MyThread());
        System.out.println(myThread.getState());

        // Нить запускается и переходит в состояние RUNNABLE
        myThread.start();
        System.out.println(myThread.getState());

        // main переходит в состояние WAITING
        try{
            myThread.join(); // main на этой строчке приостановится, чтобы подождать, пока myThread завершит свою работу в методе run(), и только потом код будет выполняться дальше
        }catch(InterruptedException e) {
             e.printStackTrace();
        }

        // Объект завершил свою работу и получил статус TERMINATED
        System.out.println(myThread.getState());

        // После выполнения всех инструкций нить main также становится TERMINATED
    }
}
Вывод: NEW
       RUNNABLE
       TERMINATED

Как говорилось ранее, когда запускается новый поток, старый продолжает работу. Вызвав метод join(), главный поток перейдёт на время в состояние WAITING, а затем снова станет RUNNABLE. По завершении работы программы все потоки перейдут в состояние TERMINATED.

Есть ещё метод isAlive(), который позволяет узнать, жив поток или нет. Он возвращает логическое значение true или false.

public class Main{
   public static void main(String[] args){
       // Главный поток сейчас живой и выполняется, поэтому выведет true
       System.out.println("main thread: " + Thread.currentThread().isAlive());

       // Новый поток создан, но ещё не запущен (не живой), поэтому вывод будет false
       System.out.println("new thread: " + new Thread().isAlive());
   }
}
Вывод: main thread: true
       new thread: false

Что в итоге

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

  • ‌Потоки — это виртуальные сущности, которые последовательно выполняют код. Они протекают в процессах, где процесс — это программа, которая выполняется.
  • ‌Поток можно создать двумя способами: унаследовать класс Thread или реализовать интерфейс Runnable.
  • ‌Вся логика нового треда выполняется в методе run(), а запускается он методом start().
  • ‌Поток имеет свой жизненный цикл и шесть состояний, описанных в перечислении ‌State.
  • State — это свойство класса Thread, которое содержит состояния потока, а получить его можно с помощью метода getState().
  • Метод join() переводит в ожидание текущий поток, а interrupt() прерывает его работу.

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

Нейросети для работы и творчества!
Хотите разобраться, как их использовать? Смотрите конференцию: четыре топ-эксперта, кейсы и практика. Онлайн, бесплатно. Кликните для подробностей.
Смотреть программу
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована