Многопоточность в Java. Часть 1

Введение

Раньше, чтобы увеличить вычислительную мощность компьютера, пользователь покупал более производительный процессор (в начале они, как правило, были одноядерными) с необходимой тактовой частотой. Однако в какой-то момент частота перестала расти, так как материалы, используемые при производстве процессоров, больше не позволяли её увеличивать.
Существует известный закон (прогноз) Мура, который гласит, что количество транзисторов, размещаемых на кристалле интегральной схемы, удваивается каждые 24 месяца. В прошлом это способствовало увеличению тактовой частоты процессоров, но со временем рост замедлился из-за тепловых и энергетических ограничений, уступив место развитию многоядерных архитектур и другим оптимизациям.
Решением стало увеличение количества ядер в процессоре. Это позволило продолжить рост производительности за счёт выполнения задач в многопоточном режиме.

1. Что такое поток?

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

2. Плюсы многопоточности

Плюсы от использования многопоточности огромны. Представьте веб-приложение, которое обслуживает сотни пользователей одновременно. Как оно это делает? Ответ прост: оно обрабатывает разные запросы в отдельных потоках. Таким образом, каждый пользователь получает поток для выполнения своего запроса.
Точно так же, когда мы печатаем на компьютере в текстовом редакторе, он делает несколько вещей одновременно. Один его поток проверяет орфографию, а другой принимает ввод во время печати. Таким образом, мы можем одновременно решать различные задачи. В противном случае, если текстовый редактор будет однопоточной программой, ему нужно будет сначала прочитать ввод с клавиатуры, а затем только проверить орфографию. Естественно, это будет не очень удобно.

3. Почему программистам нужно знать о потоках?

Мы могли бы придумать еще много подобных примеров, но давайте подумаем о текущей компьютерной архитектуре. Современный компьютер оснащен несколькими ядрами (в рамках одного процессора). Например, рассмотрим ноутбук, который имеет 16 ядер. Это означает, что в определенный момент времени он может выполнять 16 различных задач. Если программа однопоточная, то она задействует только 1 ядро, а остальные 15 будут простаивать. Это не самое оптимальное распределение процессорных ресурсов.
Таким образом, мы должны быть в состоянии написать программу так, чтобы наилучшим образом использовать имеющиеся ресурсы. Если приложение сможет выполнять несколько задач одновременно, то оно станет более отзывчивым. Мы даже можем разделить программу на независимые блоки, а затем выполнять их параллельно, что приведет к более быстрым результатам.

4. Все ли Java-программы выполняются в потоке?

Да, Java была разработана таким образом, чтобы написанная на ней программа выполнялась по умолчанию в одном потоке. Если мы запустим метод main() класса HelloWorld, то программа будет выполняться в потоке, который называется «main Thread» (далее главный поток). Несмотря на то, что главный поток создается автоматически, с ним можно взаимодействовать через класс Thread.
Рассмотрим пример:
class HelloWorld {
    public static void main(String[] args) {
        System.out.println("This program is running on: " + Thread.currentThread());
        System.out.println("Hello world");
    }
}
Примечание: метод Thread.currentThread() возвращает ссылку на экземпляр потока, в котором в данный момент выполняется код.
Если мы запустим эту программу, то получим следующий вывод:
В консоли отобразилась информация о потоке: [имя потока, приоритет потока, имя группы потока].
Важность работы разных потоков может сильно различаться. Для контроля этого процесса был придуман приоритет. Он может иметь числовое значение от 1 до 10. По умолчанию главному потоку выставляется средний приоритет — 5.
Группы потоков — группы, в которые объединяются потоки для повышения уровня управляемости и степени безопасности.

5. Как создавать свои собственные потоки?

Код, который находится в методе main(), выполняется с помощью главного потока. Однако мы можем создать дополнительные потоки для одновременного выполнения нескольких фрагментов кода. Существует два способа создания потоков:
  • Через наследование класса java.lang.Thread
  • Через реализацию интерфейса java.lang.Runnable

5.1 Наследование от класса Thread

Рассмотрим класс MyThread, который наследует Thread и переопределяет его метод run():
class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("Executing code from: " + Thread.currentThread());
        System.out.println("Hello world");
    }
}
Для использования Thread необходимо создать экземпляр MyThread, а затем вызвать у него метод start():
class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Creating a new thread from : " + Thread.currentThread());
        var myThread = new MyThread();
        myThread.start();
        System.out.println("Leaving from: " + Thread.currentThread());
    }
}
Вывод:
В приведённом коде метод main() выполняется главным потоком, который создаёт экземпляр MyThread. Затем он вызывает метод start() и println(). Поскольку после этого в main() нет больше кода, главный поток завершается. В то же время MyThread после вызова start() начинает выполняться в отдельном потоке и продолжает работу, пока не завершит ее.
Подытожим, перечислив выполненные шаги:
  • Создали класс, унаследованный от java.lang.Thread
  • Переопределили в нем метод run()
  • Поместили код, который хотим выполнить, в этот метод
  • Создали объект нашего класса, вызвав у него метод start()
Примечание: Метод run() содержит код, который должен выполняться внутри потока. При создании собственного потока путем наследования от Thread его переопределяют. Однако run() не запускает новый поток, а лишь выполняет код в текущем. Для запуска нового потока необходимо использовать метод start(), который инициализирует Thread, а затем самостоятельно вызывает run().
Продемонстрируем сказанное выше на примере:
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("run() выполняется в потоке: " + Thread.currentThread().getName());
    }
}

class HelloWorld {
    public static void main(String[] args) {
        MyThread thread = new MyThread();

        System.out.println("Вызываем run() напрямую:");
        thread.run();  // Выполнится в main-потоке

        System.out.println("Вызываем start():");
        thread.start();  // Создаст новый поток
    }
}
Вывод:

5.2 Стандартная реализация интерфейса Runnable

Другой способ создания потока — это реализация интерфейса Runnable.
Рассмотрим пример:
class MyRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("Using Runnable from: " +  Thread.currentThread());
        System.out.println("Hello world");
    }
}

class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Creating a new thread from : " + Thread.currentThread());
        var myRunnable = new MyRunnable();
        var thread = new Thread(myRunnable);
        thread.start();
        System.out.println("Leaving from: " + Thread.currentThread());
   }
}
Вывод:
Подытожим, перечислив выполненные шаги:
  • Создали класс MyRunnable, реализующий интерфейс Runnable
  • Поместили нужный нам код в переопределенный метод run()
  • В HelloWorld создали экземпляры MyRunnable и Thread
  • Передали экземпляр MyRunnable в качестве аргумента в конструктор Thread
  • Вызвали метод start() у объекта класса Thread

5.3 Анонимный класс и лямбда-выражения

В качестве альтернативы можно использовать анонимный внутренний класс, разместив его в качестве аргумента для конструктора Thread.
Рассмотрим пример:
var thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Using Runnable from: " + Thread.currentThread());
        System.out.println("Hello world");
    }
});
thread.start();
Или же можно использовать лямбда-выражение, поскольку интерфейс Runnable является функциональным интерфейсом.
Например:
var thread = new Thread(() -> {
    System.out.println("Using Runnable from: " + Thread.currentThread());
    System.out.println("Hello world");
});
thread.start();
Примечание: Функциональный интерфейс в Java — это интерфейс, у которого есть только один абстрактный метод. Он позволяет создавать лаконичный и удобочитаемый код, передавая поведение (метода) как аргумент. Функциональные интерфейсы используются в лямбда-выражениях и ссылках на методы, упрощая работу с анонимными функциями и обработкой данных.

6. Достоинства и недостатки каждого из способов

7. Создание потоков на примере веб-сервера

Рассмотрим более реальный пример использования потоков на примере веб-сервера. Для простоты ограничимся единственным вариантом его применения, заключающемся в ожидании от клиента URL какого-нибудь сайта. В ответ сервер вернет пять наиболее часто используемых на сайте слов.
Перейдем к коду (он выглядит сложновато, но в нем достаточно понять, как создавать из однопоточной программы многопоточную).
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.stream.Collectors;
import java.net.URI;

public class SingleThreadedServer {

    private final MostFrequentWordService wordService = new MostFrequentWordService();

    public SingleThreadedServer(int port) throws IOException {
        try (var serverSocket = new ServerSocket(port)) {
            while (true) {
                try (var socket = serverSocket.accept()) {
                    handle(socket);
                }
            }
        }
    }

    private void handle(Socket socket) {
        System.out.println("Client connected: " + socket);
        try (var in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
             var out = new PrintWriter(socket.getOutputStream(), true)) {
            out.println("Enter url:");
            String line;
            while ((line = in.readLine()) != null) {
                if (isValid(line)) {
                    var wordCount = wordService.mostFrequentWord(line).stream()
                            .map(counter -> counter.word() + ": " + counter.count())
                            .collect(Collectors.joining(System.lineSeparator()));
                    out.println(wordCount);
                } else if (line.contains("quit")) {
                    out.println("Goodbye!");
                    break;
                } else {
                    out.println("Malformed URL");
                }
                out.println("\nEnter url:");
            }
        } catch (IOException e) {
            System.out.println("Communication error: " + e.getMessage());
        }
    }

    private static boolean isValid(String url) {
        try {
            URI.create(url).toURL();
            return true;
        } catch (Exception e) {
            System.out.println("invalid url: " + url);
            return false;
        }
    }

    public static void main(String[] args) throws IOException {
        new SingleThreadedServer(2222);
    }
}
Для работы следующего класса необходимо скачать библиотеку jsoup, а затем подключить ее к проекту).
import org.jsoup.Jsoup;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

record WordCount(String word, long count) {}

public class MostFrequentWordService {

    List<WordCount> mostFrequentWord(String url) throws IOException {
        return Arrays.stream(getWords(url))
                .filter(word -> word.length() > 3)
                .map(String::toLowerCase)
                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
                .entrySet().stream()
                .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
                .limit(5)
                .map(entry -> new WordCount(entry.getKey(), entry.getValue()))
                .toList();
    }

    private String[] getWords(String url) throws IOException {
        return Jsoup.connect(url).get().body().text().split("\\W+");
    }
}
Пройдемся по коду SingleThreadedServer. В этом классе на порте 2222 запускается ServerSocket и в цикле «прослушивает» его в ожидании подключения клиентов. Метод handle() получает экземпляр Socket'а, а затем «общается» с клиентами. Если клиент отправляет валидный URL, вызывается сервис MostFrequentWordService для поиска наиболее часто встречающихся слов.
Теперь попробуем через Telnet подключиться к нашему серверу и воспользоваться его возможностями.
Необходимо выполнить следующие шаги:
  • Запустите SingleThreadedServer.java. Программа запустит сервер, который будет ждать подключения
  • Проверьте, что Telnet установлен, введя в командной строке telnet. Если его нет, то воспользуйтесь советами из этой статьи
  • Введите в терминале команду telnet localhost 2222
  • Введите URL, например, https://topjava.ru
  • Для выхода напишите quit
Вывод программы на консоль:
Недостатком нашего кода является возможность работы одновременно только с одним клиентом. Поэтому, если попытаться подключить еще одного, то сервер на его запрос ответит только после завершения работы с текущим клиентом.
Конечно, это проблема для веб-сервера. Ведь предполагается, что к нему должны подключаться сотни или тысячи клиентов одновременно.
Мы можем решить ее довольно быстро, если из однопоточной программы сделаем многопоточную. Для этого необходимо для каждого подключившегося клиента создавать новый поток, передавая в него метод handle().
Сделаем это:
public class MultiThreadedServer {

    private final MostFrequentWordService wordService = new MostFrequentWordService();

    public MultiThreadedServer(int port) throws IOException {
        try (var serverSocket = new ServerSocket(port)) {
            while (true) {
                var socket = serverSocket.accept();
                var thread = new Thread(() -> handle(socket));
                thread.start();
            }
        }
    }
    
    // оставшийся код будет без изменений
}
Теперь мы можем подключать сразу несколько клиентов. Для этого запустите несколько терминалов. В каждом из них подключитесь к серверу. В ответ сервер в каждом терминале отобразит сообщение «Enter url:». Это будет для вас знаком, что он работает в многопоточном режиме и может обслуживать одновременно более одного клиента.
Статья написана на основе источников:
«Java Thread Programming (Part 1)»
«Java Thread Programming (Part 2)»
Автор и переводчик: Чимаев Максим
Оцените статью, если она вам понравилась!