Клиент-сервер на Java: сокеты, эхо-сервер и многопоточный сервер

Клиент-сервер на Java: сокеты, эхо-сервер и многопоточный сервер Полезное

Клиент-серверное приложение на Java — это две программы, которые общаются по сети: сервер открывает ServerSocket на порту и ждет подключений, а клиент создает Socket к адресу и порту сервера. После подключения у каждой стороны есть свой Socket, и обе обмениваются байтами через его потоки ввода и вывода (по протоколу TCP).

Ниже — рабочий эхо-сервер и клиент, объяснение, почему такой сервер обслуживает только одного клиента за раз, многопоточная версия на пуле потоков и разбор ошибок, которые встречаются чаще всего. Весь код проверен на OpenJDK 25.0.4 (Java 25 LTS, Linux) 24.09.2026.

Словарь: что здесь легко перепутать

В статьях о сокетах одно слово часто означает разные вещи. Разведем их сразу:

Термин Что это в Java Чем не является
Сервер Программа, которая слушает порт (ServerSocket) и отвечает Не обязательно мощная отдельная машина: сервер и клиент могут работать на одном ноутбуке
ServerSocket «Приемная»: ждет подключений и выдает на каждое новое соединение отдельный Socket Сам данные не передает
Socket Один конец установленного соединения; есть и у клиента, и у сервера Не порт и не адрес
Поток данных (stream) InputStream / OutputStream сокета — через них идут байты Не поток выполнения
Поток выполнения (thread) Thread: параллельная ветка программы, нужен, чтобы обслуживать нескольких клиентов сразу Не канал передачи данных

Порядок работы всегда один: сервер создает ServerSocket и вызывает accept(), клиент подключается, accept() возвращает серверный Socket, дальше обе стороны пишут и читают данные, пока одна не закроет соединение.

Минимальный рабочий пример: эхо-сервер и клиент

Эхо-сервер возвращает клиенту каждую присланную строку с приставкой echo:, а на bye прощается и закрывает соединение. Сохраните два файла в одну папку.

EchoServer.java:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;

public class EchoServer {
    public static void main(String[] args) throws IOException {
        int port = 5050;
        // Слушаем только 127.0.0.1: с других машин сервер недоступен
        try (ServerSocket server = new ServerSocket(port, 50, InetAddress.getLoopbackAddress())) {
            System.out.println("Сервер слушает " + server.getLocalSocketAddress());
            while (true) {
                try (Socket client = server.accept();
                     BufferedReader in = new BufferedReader(new InputStreamReader(
                             client.getInputStream(), StandardCharsets.UTF_8));
                     PrintWriter out = new PrintWriter(new OutputStreamWriter(
                             client.getOutputStream(), StandardCharsets.UTF_8), true)) {
                    client.setSoTimeout(30_000); // молчание дольше 30 с = разрыв
                    System.out.println("Подключился клиент " + client.getRemoteSocketAddress());
                    String line;
                    while ((line = in.readLine()) != null) {
                        if (line.equals("bye")) {
                            out.println("До свидания");
                            break;
                        }
                        out.println("echo: " + line);
                    }
                    System.out.println("Клиент отключился");
                } catch (SocketTimeoutException e) {
                    System.out.println("Клиент молчал 30 с, соединение закрыто");
                } catch (IOException e) {
                    // обрыв одного клиента (Connection reset) не должен ронять весь сервер
                    System.out.println("Соединение оборвано: " + e.getMessage());
                }
            }
        }
    }
}

EchoClient.java:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;

public class EchoClient {
    public static void main(String[] args) throws IOException {
        String[] messages = args.length > 0 ? args : new String[] {"привет", "сервер на Java", "bye"};
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress("127.0.0.1", 5050), 3_000); // таймаут подключения
            socket.setSoTimeout(5_000);                                      // таймаут ответа
            BufferedReader in = new BufferedReader(new InputStreamReader(
                    socket.getInputStream(), StandardCharsets.UTF_8));
            PrintWriter out = new PrintWriter(new OutputStreamWriter(
                    socket.getOutputStream(), StandardCharsets.UTF_8), true);
            for (String msg : messages) {
                out.println(msg);                          // println + autoflush = строка ушла в сеть
                System.out.println("Ответ: " + in.readLine());
            }
        }
    }
}

Начиная с Java 11 однофайловую программу можно запустить без отдельной компиляции. В первом терминале запустите сервер, во втором — клиент:

java EchoServer.java
java EchoClient.java

Клиент отправит три строки и напечатает ответы:

Ответ: echo: привет
Ответ: echo: сервер на Java
Ответ: До свидания

А сервер запишет в свою консоль (номер порта клиента при каждом запуске свой):

Сервер слушает localhost/127.0.0.1:5050
Подключился клиент /127.0.0.1:37682
Клиент отключился

Разбор кода по шагам

  1. new ServerSocket(port, 50, InetAddress.getLoopbackAddress()) занимает порт 5050 только на адресе 127.0.0.1. Число 50 — размер очереди ожидающих подключений (backlog); это подсказка, операционная система может ее скорректировать.
  2. server.accept() блокирует поток, пока не подключится клиент, и возвращает Socket этого соединения.
  3. Байты превращаются в текст с явно указанной кодировкой StandardCharsets.UTF_8 — так клиент и сервер понимают кириллицу одинаково на любой ОС.
  4. PrintWriter(..., true) включает автосброс: после println строка сразу уходит в сеть. Без этого текст может застрять в буфере, и собеседник будет ждать вечно.
  5. readLine() читает до символа перевода строки и возвращает null, когда другая сторона закрыла соединение. Значит, протокол здесь такой: одно сообщение — одна строка.
  6. setSoTimeout ограничивает ожидание данных: если собеседник молчит дольше заданного, чтение бросит SocketTimeoutException, и соединение закроется, а не повиснет навсегда. У клиента отдельно задан таймаут подключения в connect(..., 3_000).
  7. Второй catch (IOException e) ловит аварийный обрыв клиента. Без него SocketException: Connection reset от одного клиента, который закрылся посреди диалога, вылетает из main и останавливает весь сервер — следующий клиент получает Connection refused.

Почему такой сервер обслуживает одного клиента за раз

В EchoServer один поток выполнения: пока он ведет диалог с первым клиентом, до accept() для второго дело не доходит. Это легко увидеть. Запустите сервер, во втором терминале откройте «молчащее» соединение средствами bash, в третьем — обычный клиент:

exec 3<>/dev/tcp/127.0.0.1/5050
java EchoClient.java привет

Клиент подключится (соединение примет ОС и поставит в очередь backlog), но ответа не дождется и через 5 секунд упадет:

Exception in thread "main" java.net.SocketTimeoutException: Read timed out

Как только первое соединение закроется (exec 3>&-), сервер возьмет следующее из очереди. Для учебного эхо это терпимо, для любого реального сервера — нет.

Многопоточный сервер на пуле потоков

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

PoolServer.java:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;

public class PoolServer {
    static final int PORT = 5050;
    static final int MAX_CLIENTS = 100;  // одновременно обслуживаемых соединений
    static final int MAX_LINE = 1024;    // байт на одну строку запроса

    public static void main(String[] args) throws IOException {
        ExecutorService pool = Executors.newFixedThreadPool(MAX_CLIENTS);
        try (ServerSocket server = new ServerSocket(PORT, 50, InetAddress.getLoopbackAddress())) {
            System.out.println("Сервер слушает " + server.getLocalSocketAddress());
            while (true) {
                Socket client = server.accept();   // главный поток только принимает
                pool.submit(() -> handle(client)); // диалог ведет поток из пула
            }
        } finally {
            pool.shutdown();
        }
    }

    static void handle(Socket client) {
        String worker = Thread.currentThread().getName();
        try (client) {
            client.setSoTimeout(30_000);
            InputStream in = new BufferedInputStream(client.getInputStream());
            PrintWriter out = new PrintWriter(new OutputStreamWriter(
                    client.getOutputStream(), StandardCharsets.UTF_8), true);
            try {
                String line;
                while ((line = readLine(in)) != null) {
                    if (line.equals("bye")) {
                        out.println("До свидания");
                        break;
                    }
                    out.println("echo: " + line);
                    System.out.println(worker + " <- " + line);
                }
            } catch (LineTooLongException e) {
                out.println("ошибка: " + e.getMessage()); // сообщаем клиенту и закрываем
                System.out.println(worker + ": " + e.getMessage());
            }
        } catch (IOException e) {
            System.out.println(worker + ": " + e.getMessage());
        }
    }

    // readLine с лимитом: без него клиент может прислать гигабайт без перевода строки
    static String readLine(InputStream in) throws IOException {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        int b;
        while ((b = in.read()) != -1 && b != '\n') {
            if (buf.size() >= MAX_LINE) {
                throw new LineTooLongException("строка длиннее " + MAX_LINE + " байт");
            }
            buf.write(b);
        }
        if (b == -1 && buf.size() == 0) {
            return null; // клиент закрыл соединение
        }
        String s = buf.toString(StandardCharsets.UTF_8);
        return s.endsWith("\r") ? s.substring(0, s.length() - 1) : s;
    }

    static class LineTooLongException extends IOException {
        LineTooLongException(String message) { super(message); }
    }
}

Запуск тот же: java PoolServer.java, клиент прежний. Повторим опыт с молчащим соединением из bash и тремя клиентами, запущенными одновременно, — теперь все получают ответы сразу. Журнал сервера:

Сервер слушает localhost/127.0.0.1:5050
pool-1-thread-2 <- привет
pool-1-thread-3 <- клиент B
pool-1-thread-4 <- клиент C
pool-1-thread-5 <- клиент A
pool-1-thread-6: строка длиннее 1024 байт
pool-1-thread-1: Read timed out

Поток pool-1-thread-1 занят молчащим соединением и не мешает остальным; через 30 секунд таймаут его освобождает. Клиенты A, B и C обслужены разными потоками, порядок строк от прогона к прогону меняется. Строка из 5000 символов отклонена: клиент получил ошибка: строка длиннее 1024 байт, соединение закрыто.

Лимит считается в байтах, а не в символах: латиница в UTF-8 занимает 1 байт, кириллица — 2, поэтому в 1024 байта помещается 512 русских букв.

Границы этого решения стоит знать. newFixedThreadPool(100) одновременно обслуживает до 100 клиентов, а 101-й будет принят и встанет в очередь пула, пока кто-то не освободит поток. Очередь пула не ограничена, так что при наплыве подключений она растет; в нагруженном сервере ее ограничивают или отклоняют лишние соединения.

Какую модель обработки выбрать

Модель Как устроена Когда подходит
Один поток accept() и диалог в одном цикле Учебный пример, утилита на одного клиента
Поток на клиента new Thread(...) на каждое подключение Почти никогда: нет верхней границы числа потоков
Пул потоков Executors.newFixedThreadPool(n) Сотни одновременных соединений, понятный лимит нагрузки
Виртуальные потоки Executors.newVirtualThreadPerTaskExecutor(), Java 21+ Тысячи соединений с блокирующим кодом; лимит одновременных клиентов задают отдельно, например Semaphore
Неблокирующий ввод-вывод java.nio.channels.Selector или готовый фреймворк (Netty) Много соединений и свой протокол, когда нужен полный контроль

Для HTTP сокеты вручную обычно не пишут: протокол и TLS берут на себя готовые серверы (Tomcat, Jetty, Netty, встроенный в Spring Boot). Ручные сокеты нужны для своего протокола поверх TCP и для понимания, что происходит под фреймворками.

Типичные ошибки и что с ними делать

Частая ошибка в старых примерах — конструктор с именем, отличным от класса:

import java.net.ServerSocket;

public class ServerSide {
    private ServerSocket server = null;

    public Server(int port) {   // имя не совпадает с именем класса
        // ...
    }
}

javac не принимает такой код:

ServerSide.java:6: error: invalid method declaration; return type required
    public Server(int port) {   // имя не совпадает с именем класса
           ^
1 error

Исправление: конструктор называется ровно как класс (public ServerSide(int port)) или класс переименовывается вместе с файлом.

Остальные ошибки проявляются уже при запуске. Таблица — по симптому, как его обычно и ищут:

Симптом Причина Что сделать
java.net.ConnectException: Connection refused у клиента Сервер не запущен, другой порт или адрес Сначала запустить сервер, сверить порт; сервер на 127.0.0.1 не виден с другой машины
java.net.BindException: Address already in use у сервера Порт занят — часто вторым экземпляром того же сервера Остановить старый процесс или выбрать другой порт
Клиент висит и ничего не получает Сообщение без перевода строки, не сброшен буфер или сервер занят другим клиентом println и автосброс PrintWriter; многопоточный сервер; таймауты на обеих сторонах
SocketTimeoutException: Read timed out Ответ не пришел за время setSoTimeout Проверить, что сервер отвечает на каждую строку; увеличить таймаут осознанно
java.net.SocketException: Connection reset Собеседник закрыл соединение аварийно, не дочитав данные Обрабатывать IOException на каждом соединении, не роняя весь сервер
Кириллица превращается в знаки вопроса или «кракозябры» Стороны читают и пишут в разных кодировках Явно указывать StandardCharsets.UTF_8 на обеих сторонах

Еще одна ловушка из старых примеров: сервер читает DataInputStream.readUTF(), а клиент пишет текст обычным println. readUTF понимает только формат writeUTF (двухбайтовая длина и модифицированный UTF-8), поэтому обе стороны должны использовать одну пару методов.

Что нужно до production

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

  • шифрование: SSLServerSocket из javax.net.ssl или TLS на обратном прокси, иначе данные идут открытым текстом;
  • аутентификация: сейчас ответ получит любой, кто достучался до порта;
  • сетевые ограничения: слушать 0.0.0.0 только осознанно, порт закрыть фаерволом для посторонних;
  • общие лимиты: соединения с одного адреса, объем данных, длина очереди;
  • явный протокол: формат и максимальный размер сообщения, коды ошибок, поведение при обрыве.

Выводы

  • Сервер на Java — это программа с ServerSocket, который принимает подключения и выдает на каждое свой Socket; клиент создает Socket к адресу и порту сервера.
  • Текстовый обмен надежнее вести строками с явной кодировкой UTF-8 и автосбросом PrintWriter, иначе сообщения теряются в буфере или портят кириллицу.
  • Однопоточный сервер обслуживает одного клиента за раз; для нескольких нужен пул потоков или виртуальные потоки с отдельным лимитом.
  • Таймауты, лимит длины сообщения и прослушивание 127.0.0.1 — минимальная защита учебного сервера; для работы в сети нужны еще TLS, аутентификация и сетевые ограничения.

Где применяется / связь с практикой

Освойте тему на практике

Сокеты лежат под любым сетевым кодом на Java: JDBC-драйверами, HTTP-клиентами, брокерами сообщений. Понимание accept(), блокирующего чтения и пулов потоков помогает разбираться с зависшими соединениями и таймаутами даже в сервисах на Spring. Системно пройти язык, от синтаксиса до многопоточности, можно на курсе «Java разработчик. Базовый уровень». Разобрать отдельные темы с преподавателями можно на открытых уроках Otus.

Смежная тема: клиент-серверная модель в Python и сокеты — та же архитектура на другом языке.

FAQ

Как сделать то же самое по UDP?
Для UDP в Java есть DatagramSocket и DatagramPacket: соединения нет, каждое сообщение — отдельный пакет, доставка и порядок не гарантируются, поэтому подтверждения и повторы приходится делать самому.

Можно ли запустить сервер и клиент на одном компьютере?
Да, так и сделано в примерах: сервер слушает 127.0.0.1, клиент подключается к тому же адресу. Чтобы подключаться с другой машины, сервер должен слушать внешний адрес, а порт — быть открыт в фаерволе.

Почему в примерах порт 5050, а не 5000?
Порт 5000 на новых версиях macOS часто занят системным приемником AirPlay, и сервер падает с BindException. Подойдет любой свободный порт выше 1024: на Linux порты ниже 1024 по умолчанию доступны только администратору.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)