第一次被问“你为什么不用 Java 的 TCP 通信”时,我愣了半天。那时候我手里有一个采集终端,每隔几百毫秒就往电脑上推一批数据,我一直用 U 盘来回倒腾,后来实在受不了了,才开始认真研究 Java 和 TCP 网络通信这条技术线。很多人一上来就找代码贴到自己工程里,结果面对连接建立、端口占用、字节流解析这些细节时照样抓瞎。这篇“Java 实现TCP网络通信(1)”不会直接甩一个能跑的通,再把原理糊弄过去,我会按我自己的学习路径来拆:什么场景该选 TCP、运行环境怎么搭、服务端和客户端如何一步步实现、以及粘包和排错这些绕不开的坎。
这篇内容适合刚接触 Java Socket 的初学者,也适合那些已经能跑通 Demo、但说不清端口绑定和字节流到底怎么回事的人。文章里的代码我都在本机验证过,你可以直接抄走,但更重要的是理解每一步背后的原因。
1. 从TCP和UDP的差别入手,想明白这台机器到底在跟谁说话
1.1 TCP面向连接的含义,不只是“连上了”这么简单
在最开始写代码前,我们先聊一个基础问题:为什么很多网络通信项目非要用 TCP,而不是 UDP。如果你只是想把一个字符串从 A 点送到 B 点,UDP 明显更省事,发出去就不管了。但我在第一次做数据采集时就发现,UDP 丢一个包根本没有任何反馈,终端那边以为数据到了,电脑这边却少了一条关键记录。这种场景下,面向连接的 TCP 才是正确选择。
TCP 的“面向连接”可以类比成打电话:先拨号,对方接听,确认两边都能听到,才开始说话,挂断前还会互相告知一声。UDP 更像寄明信片:写完塞进邮筒,至于中间会不会丢、会不会顺序错乱,发件人完全不关心。打这个比方不是让你们背八股,而是要在选型时有个直觉:若数据丢一个可能就要出事故,就应该上一个可靠协议。
1.2 三次握手为什么必须是三次,两次不行吗
TCP 建立连接时会经历一次“三次握手”,这是网络通信的核心机制,也是面试官特别爱问的点。我用实际发消息的过程来还原一下:
- 客户端发送一个
SYN=1的数据段,附带自己的初始序列号seq=x。相当于“我要跟你建立连接,这是我的序号起点”。 - 服务端收到后回复
SYN=1, ACK=1,附带自己的序列号seq=y,同时确认ack=x+1。相当于“收到你的请求,我的起始序号是 y”。 - 客户端再回一个
ACK=1,确认ack=y+1。这时连接才进入可用状态。
很多初学者会问,为什么要多出第三步,服务端收到客户端第一个 SYN 之后直接建立连接不行吗?如果只握两次手,当客户端因为网络拥塞发出一个迟到的旧连接请求时,服务端无法区分这是一个新请求还是历史请求,就会白白建立一个无效连接,浪费资源。三次握手能够让双方都确认“我的发送能力没问题,你的接收能力也没问题”,从而建立起对称的信任关系。
至于断开连接时要经历“四次挥手”,我记得第一次看抓包结果时被那一串 FIN、ACK 搞蒙,其实逻辑也很简单:因为 TCP 连接是全双工的,两边都能独立地关闭自己的发送方向,所以关闭时需要两组 FIN/ACK 过程。这部分我在后续博文讲自定义协议的长连接时再展开,这里先把三次握手记牢。
1.3 IP、端口和套接字,别把三个概念混在一起
写过代码后你会发现,建立连接时永远离不开 IP 地址、端口号这两个参数。IP 地址定位到某台主机,端口号定位到这台主机上的某个进程。Socket(套接字)就是“IP 地址 + 端口号”这个组合在编程层面的具体表现,用来唯一标识一条连接两端。
一台电脑可以同时运行很多服务,比如 8080 的 Web 服务、3306 的数据库服务,它们彼此不冲突,靠的就是端口号隔离。TCP 通信中服务端必须绑定一个固定端口,客户端则通常由操作系统随机分配一个临时端口。理解这一点,后面遇到端口被占用时才能快速定位问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把Java环境装明白:JDK安装、Path配置、源发行版报错
2.1 JDK 安装路径和主流版本选择
写 TCP 程序不需要特别复杂的 IDE,哪怕是记事本配合命令行都能跑,但 Java 运行环境得先准备好。我建议直接安装 JDK 17 或更高版本,原因很简单:现在很多新项目都已经切到 17 以上的长期支持版本,太旧的 JDK 8 虽然在老系统里还很多,但新学习时没必要绑在旧版本上。
安装时要注意,JDK 的安装路径里最好不要出现中文和空格,否则后续配置环境变量和脚本编译时容易出一些莫名其妙的错误。我碰到过一个同学把 JDK 装在“D:\编程软件\Java”下面,结果 javac 命令始终无法正常解析路径,后来换到纯英文目录就恢复正常了。
2.2 配置 JAVA_HOME 和 PATH 的正确姿势
Windows 下配置环境变量的常规流程是:右键“此电脑”进入属性,选择“高级系统设置”,打开“环境变量”面板。第一步新建一个 JAVA_HOME 变量,值指向 JDK 的实际安装目录,比如 C:\Program Files\Java\jdk-17。第二步找到 Path 变量,在开头添加上两项:%JAVA_HOME%\bin 和 %JAVA_HOME%\jre\bin。
配置完成后,不要急着写代码,先打开一个新的命令行窗口,依次输入下面两条命令验证:
bash复制java -version
javac -version
如果两条命令都能正确输出版本号,说明环境基本没有问题。要注意的是,必须新开一个命令行窗口才会读到新的环境变量,在旧的窗口里反复执行 java -version 是看不到变化的,这个小细节很多人卡了半天才发现。
2.3 常见报错:“源发行版 17 需要目标发行版 17”
我搜索这个话题时总能看到一个高频报错:java: 警告: 源发行版 17 需要目标发行版 17。这个问题的本质是编译器和项目字节码版本设置不一致。比如你电脑装的 JDK 是 17,在 IDEA 里项目的语言级别却设置成了 8,编译时就会出现这种警告或直接报错。
解决办法是在 IDEA 的 Project Structure 里把 Project SDK 和 Project language level 都调成同一版本,同时检查 Maven 或 Gradle 的 sourceCompatibility 配置。这是个环境层面的问题,跟 TCP 本身无关,但如果不处理好,后面连 Socket 类都没法正常编译,所以我把它放在第二章节提前排查掉。
3. ServerSocket+Socket:第一版能跑的客户端和服务端
3.1 服务端代码:绑定端口、阻塞等待、读取一行数据
第一个 Demo 我控制在最小范围:服务端监听 9090 端口,客户端连上来后发送一句文本,服务端读取并打印,随后程序直接退出。这种单次通信虽然简单,却足够把核心 API 的调用链路走通。
java复制import java.io.*;
import java.net.*;
public class TCPServer {
public static void main(String[] args) {
int port = 9090;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务端已启动,监听端口:" + port);
// accept() 会阻塞在这里,直到有客户端连接进来
Socket socket = serverSocket.accept();
System.out.println("客户端已连接:" + socket.getRemoteSocketAddress());
// 读取客户端发来的数据
InputStream in = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in, "UTF-8"));
String line = reader.readLine();
System.out.println("收到消息:" + line);
// try-with-resources 会自动关闭 socket 和 serverSocket
} catch (IOException e) {
e.printStackTrace();
}
}
}
在这段代码里,第一个重点是 ServerSocket(int port) 构造方法,它在底层完成端口绑定和监听队列的创建。一旦该端口已被其他进程占用,这里就会直接抛出异常。第二个重点是 accept() 方法,它会一直阻塞当前线程,等待连接到达。这种阻塞模型在单客户端场景下没有任何问题,但后面要处理多个客户端时,就必须引入多线程,这部分我会放在系列的后续文章里。
3.2 客户端代码:发起连接、输出数据、清理资源
客户端的核心职责就两件事:建立连接,发送数据。这里需要注意,Socket 构造完成后连接未必已经稳定就绪,如果服务端尚未启动或者 IP/端口写错,就会抛出 ConnectException。最稳妥的写法是顺手开启超时控制,我下面的代码先保持最简,超时问题放到第五章节统一讲。
java复制import java.io.*;
import java.net.*;
public class TCPClient {
public static void main(String[] args) {
String host = "127.0.0.1";
int port = 9090;
try (Socket socket = new Socket(host, port);
OutputStream out = socket.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, "UTF-8"))) {
writer.write("你好,TCP通信\n");
writer.flush();
System.out.println("消息已发送");
} catch (IOException e) {
e.printStackTrace();
}
}
}
客户端用 try-with-resources 写法后,Socket、OutputStream 和 BufferedWriter 在代码块结束时都会自动关闭。很多新手写这种 Demo 时不关流,看起来程序也能退出,但如果在长连接应用里不释放资源,文件描述符最终会被耗尽,服务到后面必然出问题。习惯要一开始就养好。
3.3 先启动服务端,再启动客户端,顺序不能反
我强烈建议第一次跑这个 Demo 时,用手动方式在两个命令行窗口分别执行编译和运行,而不是直接点 IDE 的运行按钮。先执行:
bash复制javac TCPServer.java
java TCPServer
看到“服务端已启动,监听端口:9090”后,再开一个窗口执行:
bash复制javac TCPClient.java
java TCPClient
然后你会看到服务端窗口打印出“收到消息:你好,TCP通信”。如果先启动客户端,因为服务端还没监听,客户端会直接报“Connection refused”,这个报错信息说直白点就是“我想连的那个端口此刻没人接待”。把顺序跑对,你才真正体会到监听和连接是一前一后的两个状态。
我自己的习惯是局域网调试时用真实的局域网 IP,比如 192.168.1.100,而不是只用 127.0.0.1 回环地址。用一个回环地址测试只能证明本机协议栈没问题,换到局域网地址才能验证防火墙和路由这些真实网络环境因素。
4. 发几次消息后必然撞见的TCP粘包问题
4.1 什么是粘包,为什么 TCP 会出现这种状况
如果你只跑一次上面的 Demo,大概率不会碰到任何怪事。但当你把通信改成循环发送时,很快就会发现:客户端连续发两条消息,服务端可能一次性读出两条,或者一条消息被拆成两段读出来。前者叫“粘包”,后者叫“拆包”。
出现这个问题的根源,在于 TCP 是一种流式协议。你在应用层看到的是一次 write,但底层 TCP 会把数据看作一个连续的字节流,不保留应用层的消息边界。它可能会把多个小数据块合并成一个大包发送,也可能因为缓冲区限制把一个大数据块拆成多个包。粘包跟 UART 串口通信里的那种“数据粘连”感觉很像,只是表现形式更隐蔽。
4.2 简单有效的解决思路:用长度字段划分消息边界
解决粘包最常用的方案有两种:一种是在消息之间插入特定分隔符,另一种是在消息头部固定几个字节保存消息长度。分隔符法适合文本协议,但如果消息内容里恰好包含分隔符,处理起来就麻烦;所以我在大多数项目里更推荐“长度前缀”方案。
以 Java 为例,发送端可以把消息按 4字节长度 + 内容 的格式发送。接收端先读 4 个字节得到长度 len,再按 len 读取后面的字节,就能准确还原一条消息。具体实现如下。
发送端:
java复制byte[] data = msg.getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(4 + data.length);
buffer.putInt(data.length);
buffer.put(data);
socket.getOutputStream().write(buffer.array());
接收端:
java复制DataInputStream dis = new DataInputStream(socket.getInputStream());
int len = dis.readInt();
byte[] data = new byte[len];
dis.readFully(data);
String msg = new String(data, StandardCharsets.UTF_8);
DataInputStream.readInt() 会按照大端序读取 4 个字节,readFully(byte[]) 保证缓冲区被完全填满才返回,避免了流式读入导致的半包问题。用这套组合拳后,不管底层怎么粘包拆包,应用层都能稳定地把每条消息切分出来。
4.3 做练习时建议加上“循环读取”和“消息计数”
如果你只想验证粘包,可以写一个简单的测试程序:客户端循环发送 100 条同样的消息,服务端循环读取并计数。我第一次这么干时,发现服务端只读了几十条就阻塞了,原因就是多条消息被粘在了一起,第一条长度前缀后面跟着的内容已经超过预期。通过这个实验,你会更加理解为什么 Netty 这类框架要额外设计解码器。
不过,朋友们要注意,这里给出的长度前缀方案只是教学级写法,生产环境中还必须考虑半包和性能:比如 readInt() 如果只读到 2 个字节就会阻塞,所以真正健壮的做法是自己维护接收缓冲区,累计到 4 字节才解析长度。这个话题已经超出本篇文章范围,我在后续“自定义 TCP 协议栈”的章节里会展开细讲。
5. 端口被占用、连接被拒、数据乱码:三次踩坑记录
5.1 端口被占用:BindException 和 “only one usage of each socket address”
我在搜索热词里看到一条典型报错:error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address。翻译过来就是:同一个地址和端口被重复绑定了。这类问题在 Java 里通常表现为 java.net.BindException: Address already in use: JVM_Bind。
最常见的触发原因是你上一个服务端程序没退出,端口还被它占着。尤其是你在 IDE 里重复运行服务端,旧进程没有自动结束,新的进程自然绑不上同一个端口。排查思路很简单,先找出占用端口的进程,再决定是杀掉还是换端口。
Windows 下用这个命令查:
bash复制netstat -ano | findstr 9090
Linux 下用:
bash复制ss -lntp | grep 9090
查出来的 PID 就是占用进程编号,确认无误后按平台结束进程即可。有一点需要提醒:不要一上来就换端口,如果根本问题是“僵尸进程”,换端口只能暂时绕开,过不了多久你还会卡在同一个坑里。
我还碰到过另一个比较意外的情况:Android 开发时 adb 服务报错 daemon not running; starting now at tcp:5037 could not read ok from adb,这个报错同样跟端口有关,5037 是 adb 的默认端口,一旦被其他程序或高版本 adb 抢先占用,启动就会失败。排查方法仍然是用 netstat 看 5037 被谁占着,然后清理冲突进程。端口冲突的排查套路一旦掌握,就能跨平台复用到各类网络工具上。
5.2 连接被拒绝:ConnectException 的三层原因
客户端最常见的报错是 java.net.ConnectException: Connection refused: connect。它通常有三层原因。
第一层:服务端根本没启动,或者服务端启动失败已经退出。这种最简单,先确认服务端进程还在不在。
第二层:服务端启动在其他端口,客户端却连接旧端口。很多同学改了服务端端口,忘了同步改客户端,就会等到一个“拒接”。
第三层:服务端监听的是某个 IP 地址,客户端通过另一个 IP 去连接。如果你调用 new ServerSocket() 时不指定绑定 IP,默认会绑定到所有网卡上,一般不会出问题。但如果你指定绑定了 127.0.0.1,外网 IP 连接自然会被拒。
5.3 中文乱码:字节流和字符流搞混了
TCP 底层传输的都是字节,Java 里如果直接用 InputStream 的 read() 去读文本,很容易出现一边发的是 UTF-8,一边按平台默认编码去解码的问题。例如 Windows 中文系统默认编码是 GBK,客户端用 OutputStreamWriter(out) 没指定编码时,写出去的中文可能被编码成 GBK,服务端却用 UTF-8 去读,打印出来必然是一堆乱码。
解决办法是从一开始就统一两端编码,并且显式声明,不要依赖系统默认值:
java复制new InputStreamReader(in, StandardCharsets.UTF_8)
new OutputStreamWriter(out, StandardCharsets.UTF_8)
Java 的 String.getBytes() 也是一样的道理,只要跨进程传输文本,就必须把字符集钉死。这是一个特别基础却又特别容易翻车的细节,我不止一次在帮人看代码时发现,问题不在 TCP 连接,而在编码转换。
6. 第一阶段学完自检清单
6.1 动手题:把单次通信改成“多次收发”
学完前面的内容,我建议你不要急着看下一篇,先完成一个动手练习:让客户端在建立连接后循环发送 5 条消息,服务端循环接收并打印,最后客户端主动断开,服务端退出循环。这个练习看起来简单,但真实写起来你会发现两个问题:一是服务端如何判定“对端发完了”,二是如果客户端一直不关闭连接,服务端的 readLine() 会怎么表现。
判定对端发完的常见方式有三种:固定长度、消息中携带结束标志、读取到 -1 时认为流已关闭。建议你都试一遍,尤其是最后一种方式:客户端把 Socket 关闭后,服务端的 read() 会返回 -1,这是判断连接关闭的原生信号。
6.2 把客户端延迟和并发连接纳入规划
当你在这套 Demo 上找到手感后,会很自然地萌生一个想法:能不能让服务端同时接待多个客户端?这就触及到 TCP 编程最核心的进化方向:多线程处理多个 Socket。在服务端循环里每次 accept() 拿到新连接后,就丢给一个独立线程去读取数据,主线程立刻回到 accept() 继续等待下一个连接。这个模式是后面所有高并发框架的思维底座。
我个人的建议是,第一阶段先不要碰 Netty 或 Mina 这类框架,把原生的 ServerSocket 多线程版本写明白,再去理解框架里的 EventLoop、Pipeline 设计,否则很容易被一堆抽象概念淹没。
6.3 “Java 实现TCP网络通信(1)”的重点回顾
这一篇的内容围绕 Java TCP 网络通信的最小闭环展开:第一,用 TCP 的场景前提是可靠传输;第二,环境里 JDK、Path、编译器版本要一致;第三,ServerSocket 负责监听和 accept,Socket 负责连接后的读写;第四,TCP 是流协议,必须自己定义消息边界才能解决粘包;第五,端口占用、连接拒绝、编码混乱是三个最常见的排障入口。
下一篇我会专门讲服务端多线程模型,以及如何拆解一个可复用的通信模块。如果你按上面的动手题把多次收发跑通,再来看下一篇文章会非常轻松。
