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

Введение
Раньше, чтобы увеличить вычислительную мощность компьютера, пользователь покупал более производительный процессор (в начале они были, как правило, одноядерными) с необходимым ему размером тактовой частоты. Но в один прекрасный момент частота перестала расти, т.к. материалы, используемые при производстве процессоров, больше не позволяли ее увеличивать.
Существует известный закон (прогноз) Мура, который гласит, что тактовая частота процессора должна удваиваться каждые 24 месяца. Это утверждение было верно в течение многих лет, но в последнее время стало неактуальным.
Проблему удалось решить с помощью наращивания количества ядер в рамках одного процессора. Это позволило и дальше повышать его производительность за счет выполнения задач в многопоточном режиме.
Про многопоточность мы как раз и будем говорить на протяжении всего цикла статей по этой теме.
Начнем с базовых вещей — узнаем про потоки.
1. Что такое поток?
Поток (Thread, иногда переводят, как «Нить») — это ход исполнения машинных инструкций программы, выполняемых процессором последовательно (в пределах одного потока) или параллельно, если потоков больше одного.
В первом случае программы называются однопоточными. Во втором, если потоков несколько — многопоточными (когда независимо друг от друга выполняются разные наборы инструкций — разные куски кода).
2. Плюсы многопоточности
Плюсы от использования многопоточности огромны. Представьте веб-приложение, которое обслуживает сотни пользователей одновременно. Как оно это делает? Ответ прост: оно выполняет разные запросы в отдельных потоках. Таким образом, каждый пользователь получает поток для выполнения своего запроса.
Точно так же, когда мы печатаем на компьютере в текстовом редакторе, он делает несколько вещей одновременно. Один его поток проверяет орфографию, а другой принимает ввод во время печати. Таким образом, мы можем одновременно решать различные задачи. В противном случае, если текстовый редактор будет однопоточной программой, ему нужно будет сначала прочитать ввод с клавиатуры, а затем только проверить орфографию. Естественно, это будет не очень удобно.
3. Почему программистам нужно знать о потоках?
Мы могли бы придумать еще много подобных примеров, но давайте подумаем о текущей компьютерной архитектуре. Современный компьютер оснащен несколькими ядрами (в рамках одного процессора). Например, рассмотрим ноутбук, который имеет 16 ядер. Это означает, что в определенный момент времени он может выполнять 16 различных задач. Если программа однопоточная, то она задействует только 1 ядро, а остальные 15 будут простаивать. Это не самое оптимальное распределение процессорных ресурсов.
Таким образом, мы должны быть в состоянии написать программу так, чтобы наилучшим образом использовать имеющиеся ресурсы. Если приложение сможет выполнять несколько задач одновременно, то оно станет более отзывчивым. Мы даже можем разделить программу на независимые блоки, а затем выполнять их параллельно, что приведет к более быстрым результатам.
4. Все ли Java-программы выполняются в потоке?
Да, Java была разработана таким образом, чтобы написанная на ней программа выполнялась по умолчанию в одном потоке. Если мы запустим метод main класса HelloWorld, то программа будет выполняться в потоке, который называется «main Thread» (далее главный поток). Несмотря на то, что главный поток создается автоматически, им можно управлять через объект класса Thread. Для этого нужно вызвать метод currentThread(), после чего можно управлять потоком.
Рассмотрим пример:
public 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
public class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("Executing code from: " + Thread.currentThread());
        System.out.println("Hello world");
    }
}
В коде выше мы создали класс MyThread, унаследованный от Thread, а затем переопределили метод run().
Для использования Thread необходимо создать его экземпляр (в данном случае — экземпляр класса MyThread), а затем вызвать метод start():
public class Playground {
    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 выполняется главным потоком. Этот поток видит код, который создает экземпляр класса Thread. Затем он выполняет метод start() и System.out.println. Поскольку после этого кода больше ничего нет, главный поток завершает свое выполнение. С другой стороны, MyThread, после запуска метода start(), начинает выполнять код, пока не завершит свою работу.
Здесь вы должны иметь в виду, что для запуска потока необходимо вызывать метод start(), а не run(). Это происходит потому, что start() инициализирует Thread, а затем сам вызывает метод run() (но не мы!). Эта информация очень важна для запоминания.
Код выше даст следующий вывод:
Подытожим, перечислив все сделанные шаги:
  • Создали класс, унаследованный от java.lang.Thread
  • Переопределили в нем метод run()
  • Поместили код, который хотим выполнить, в этот метод
  • Создали объект нашего класса, вызвав у него метод start()
5.2 Реализация интерфейса Runnable
Другой способ создания потока — это реализация интерфейса Runnable.
Например:
public class MyRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("Using Runnable from: " +  Thread.currentThread());
        System.out.println("Hello world");
    }
}
Следующие шаги:
  • Создаем экземпляр java.lang.Thread
  • Передаем в его конструктор экземпляр MyRunnable
  • Вызываем метода start()
Код будет следующим:
public class Playground {
    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()
  • Создали экземпляр MyRunnable
  • Создали экземпляр Thread
  • Использовали объект MyRunnable как аргумент для конструктора java.lang.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();
6. Как использовать потоки в наших интересах
Давайте предположим, что мы собираемся создать веб-сервер. Для примера ограничимся единственным вариантом его использования, заключающемся в «прослушивании» любого клиента, который с ним взаимодействует. Если сервер получит от клиента URL какого-нибудь сайта, то в ответ вернет пять наиболее часто используемых на сайте слов.
Хватит слов. Давайте смотреть код (код выглядит сложновато, но в нем достаточно понять, как создавать из однопоточной программы многопоточную)!
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.MalformedURLException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.stream.Collectors;

public class SingleThreadedServer {

    private final MostFrequentWordService mostFrequentWordService = new MostFrequentWordService();

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

    public static void main(String[] args) throws IOException {
        new SingleThreadedServer(2222);
    }

    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 = mostFrequentWordService.mostFrequentWord(line)
                            .stream()
                            .map(counter -> counter.word() + ": " + counter.count())
                            .collect(Collectors.joining("\n"));
                    out.println(wordCount);
                } else if (line.contains("quit")) {
                    out.println("Goodbye!");
                    socket.close();
                } else {
                    out.println("Malformed URL");
                    out.println("Enter url:");
                }
            }
        } catch (IOException e) {
            System.out.println("Was unable to establish or communicate with client socket:" + e.getMessage());
        }
    }

    private static boolean isValid(String url) {
        try {
            new URL(url);
        } catch (MalformedURLException e) {
            System.out.println("invalid url: " + url);
            return false;
        }
        return true;
    }
}
И на случай, если вам интересно, как я написал «MostFrequentWordService», то вот (библиотеку 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 {

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

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

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