Клиент-серверное приложение на Java — это две программы, которые общаются по сети: сервер открывает ServerSocket на порту и ждет подключений, а клиент создает Socket к адресу и порту сервера. После подключения у каждой стороны есть свой Socket, и обе обмениваются байтами через его потоки ввода и вывода (по протоколу TCP).
Содержание
- Словарь: что здесь легко перепутать
- Минимальный рабочий пример: эхо-сервер и клиент
- Разбор кода по шагам
- Почему такой сервер обслуживает одного клиента за раз
- Многопоточный сервер на пуле потоков
- Какую модель обработки выбрать
- Типичные ошибки и что с ними делать
- Что нужно до production
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — рабочий эхо-сервер и клиент, объяснение, почему такой сервер обслуживает только одного клиента за раз, многопоточная версия на пуле потоков и разбор ошибок, которые встречаются чаще всего. Весь код проверен на 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
Клиент отключился
Разбор кода по шагам
new ServerSocket(port, 50, InetAddress.getLoopbackAddress())занимает порт 5050 только на адресе 127.0.0.1. Число 50 — размер очереди ожидающих подключений (backlog); это подсказка, операционная система может ее скорректировать.server.accept()блокирует поток, пока не подключится клиент, и возвращаетSocketэтого соединения.- Байты превращаются в текст с явно указанной кодировкой
StandardCharsets.UTF_8— так клиент и сервер понимают кириллицу одинаково на любой ОС. PrintWriter(..., true)включает автосброс: послеprintlnстрока сразу уходит в сеть. Без этого текст может застрять в буфере, и собеседник будет ждать вечно.readLine()читает до символа перевода строки и возвращаетnull, когда другая сторона закрыла соединение. Значит, протокол здесь такой: одно сообщение — одна строка.setSoTimeoutограничивает ожидание данных: если собеседник молчит дольше заданного, чтение броситSocketTimeoutException, и соединение закроется, а не повиснет навсегда. У клиента отдельно задан таймаут подключения вconnect(..., 3_000).- Второй
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 по умолчанию доступны только администратору.



