Java 网络编程套接字入门:从"发一段数据"到"写一个可并发的服务器"
网络编程最核心的目标:让不同主机(或同一主机的不同进程)通过网络传输数据。你在浏览器看视频、刷图片、读文章,本质都是"客户端进程"向"服务端进程"请求网络资源,然后接收响应数据。
先明确请求/响应、客户端/服务端,再落到 UDP/TCP 两条路线,最后讲到端口占用、并发处理、长短连接这些工程级坑点。下面按一条顺滑的学习路径串起来。
1)网络编程到底在编什么:进程之间的数据传输
来看一个关键定义:网络编程就是网络上的主机,通过不同进程,以编程方式实现网络通信(网络数据传输)。只要是不同进程,哪怕在同一台机器上,通过网络协议栈收发数据,也算网络编程。
于是会出现三个高频"角色名词":
- 发送端 / 接收端:一次数据流向里,发送数据的一方叫发送端,接收数据的一方叫接收端(这俩是相对概念)。
- 请求 / 响应:获取网络资源通常要两次传输:先发请求,再回响应。
- 客户端 / 服务端 :提供服务资源的一方是服务端,获取服务的一方是客户端。常见流程是:客户端请求 → 服务端处理业务 → 服务端响应 → 客户端展示结果。

2)Socket 套接字:网络通信的"基本操作单元"
来看 Socket 的定位:它是系统提供的一种网络通信技术,是基于 TCP/IP 的网络通信基本操作单元。基于 Socket 写出来的程序,就是网络编程。

按传输层协议,Socket 主要分三类:
-
流套接字(TCP)
- 有连接、可靠传输、面向字节流
- 有发送缓冲区也有接收缓冲区
- 数据"没有边界":可以多次发送、分多次接收(只要连接不断)
-
数据报套接字(UDP)
- 无连接、不可靠传输、面向数据报
- 有接收缓冲区、通常不强调发送缓冲区
- 数据"有边界":发 100 字节就要一次发完、一次收完
- 单个数据报大小受限(常见上限 64KB)
-
原始套接字(用于自定义传输层协议/读写内核未处理的 IP 数据,了解即可)
3)UDP 编程模型:一次一包,收发都靠 DatagramPacket
来看 UDP 的"脾气":它不建立连接,发送一块数据就必须整体发送,接收也必须整体接收。Java 里主要靠两个类:
DatagramSocket:UDP Socket,用来 send/receive 数据报DatagramPacket:数据报本体(携带字节数组 + 目标/来源地址信息)


3.1 DatagramSocket 关键用法
new DatagramSocket():绑定本机随机端口(更常见于客户端)new DatagramSocket(port):绑定本机指定端口(更常见于服务端)receive(packet):阻塞等待接收send(packet):发送(通常不阻塞等待)close():关闭套接字
3.2 DatagramPacket 关键用法
- 接收包:
new DatagramPacket(byte[] buf, int length) - 发送包:
new DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)或指定InetAddress + port getAddress()/getPort()/getData():获取对端地址、端口和数据
4)来看 UDP 回显:最短路径理解"请求→处理→响应"
回显(Echo)是网络编程里的"Hello World":客户端发什么,服务端回什么。下面这两段就是完整可运行版本(带注释)。代码来自压缩包。
4.1 UDP Echo Server(服务端)
java
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException{
//指定了一个固定端口号让服务器来使用
socket = new DatagramSocket(port);
//socket对象代表网卡文件,读这个文件等于从网卡收数据,写这个文件等于让网卡发数据
}
public void start() throws IOException {
//启动服务器
System.out.println("服务器启动!");
while(true){
//循环一次,相当于处理一次请求
//处理请求的过程,典型的服务器都分为三个步骤
//1、读取请求并解析
// DatagramPacket表示一个UDP数据报,此处传入的字节数组就保存UDP的载荷部分
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//receive会触发阻塞,直到收到客户端的请求
//把读取到的二进制数据转换成字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
//2、根据请求,计算响应(服务器最关键的逻辑)
String response = process(request);
//3、把响应返回给客户端
//根据response构造DatagramPacket,发送给客户端
//此处不能使用response.length() 这是string中字符的个数 而下面的是String中字节的个数
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
//此处还不能直接发送,UDP协议自身没有保存对方的信息
//需要指定目的ip和端口号
socket.send(responsePacket);
//4.打印一个日志
System.out.printf("[%s:%d] req: %s, resp: %s\n",requestPacket.getAddress().toString(),responsePacket.getPort(),request,response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws SocketException,IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
4.2 UDP Echo Client(客户端)
java
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
//UDP本身不保存对端的信息,给自己的代码中保存一下
private String serverIp;
private int serverPort;
public UdpEchoClient(String serverIp,int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
//一定不能填写serverPort,serverip是目的ip,serverport是目的端口
//源ip所在的客户端的主机Ip,源端口,应该是操作系统随机分配一个端口
//就像学生去食堂吃饭,食堂提供把饭做好了端过去的服务,学生在食堂是随便坐一样
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while(true) {
//1.从控制台读取用户输入的内容
System.out.println("请输入要发送的内容!");
if(!scanner.hasNext()) break;
String request = scanner.next();
//2.把请求发送给服务器,首先构造数据报
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
//3.接收服务器返回的数据报
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
//4.读取服务器的数据
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1",9090);
client.start();
}
}
读代码时重点看三件事:
- UDP 服务端
receive()会阻塞等待;2) UDP 必须在发送包里指定对端地址;3) "一次一包"的边界来自DatagramPacket。
5)把 Echo 改造成"英译汉":核心就是改 process
来看一个非常实用的抽象:服务器主循环基本都一样,差异常常只在"如何处理 request 得到 response"。因此做英译汉字典服务时,核心就是把 process(request) 改成"查表并返回"。
(工程上常见做法是让 process 具备可扩展性:例如改成 protected,再用继承/组合注入不同处理逻辑。)
6)TCP 编程模型:先建立连接,再用字节流持续收发
来看 TCP 的关键区别:它是面向连接 的。通信前要建立连接;建立后双方通过 InputStream/OutputStream 像读写文件一样收发数据。

Java 里 TCP 的两个核心类:
-
ServerSocket:用来创建 TCP 服务端监听套接字new ServerSocket(port):绑定端口accept():阻塞等待客户端连接,返回Socket
-
Socket:客户端 socket;或服务端 accept 后得到的连接 socketnew Socket(host, port):客户端发起连接getInputStream()/getOutputStream():获取读写流
7)来看 TCP 回显:阻塞点、换行协议、flush 都在这里
下面是完整可运行版本(带注释)。代码来自压缩包。
7.1 TCP Echo Server(服务端:线程池并发版)
java
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
private ServerSocket serverSocket = null;
//这里和UDP类似,也是在构造对象的时候绑定端口
public TcpEchoServer(int port) throws IOException{
serverSocket = new ServerSocket(port);
}
public void start() throws IOException{
System.out.println("启动服务器");
//这种情况一般不使用固定线程数的fixedThreadPool
ExecutorService executorService = Executors.newCachedThreadPool();
while (true){
//tcp来说,需要先处理客户端发来的连接
//通过读写clientSocket和客户端进行通信
//如果没有客户端发起连接,此时accept就会阻塞
Socket clientSocket = serverSocket.accept();//每个客户端连接,都会创建一个新的
//每个客户端断开连接,这个对象都可以不要了
//主线程应该复杂进行accept,每次accept到一个客户端就创建一个线程负责处理客户端的请求
// Thread t = new Thread(() ->{
// processConnection(clientSocket);
// });
// t.start(); 这段多线程代码能够正常运行 但还可以再优化 优化原因:客户端多了 频繁的创建销毁线程占比开销大 这时候就该引入线程池
executorService.submit(() ->{
processConnection(clientSocket);
});
//多线程和线程池都意味着一个客户端对应一个线程
}
}
//处理一个客户端的连接
//可能涉及多个客户端的请求和响应
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
//分成三个步骤
while(true){
//1.读取请求并解析
/**byte[] request = new byte[1024];
inputStream.read(request); **/ //这个read的操作会把clientSocket的东西全部写到request数组里
if(!scanner.hasNext()){//如果客户端#1不发请求,那么服务器就会阻塞在此没法回应其他客户端
//连接断开了
break;
}
String request = scanner.nextLine();
//2.根据请求计算响应
String response = process(request);
//3.返回响应到客户端
writer.println(response);//和sout类似
//这个操作只是把数据放到"发送缓冲区"中,还没有真正写入到网卡里
//加上刷新缓冲区操作才是真正发送数据
writer.flush();
//打印日志
System.out.printf("[%s:%d] req:%s,resp:%s\n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);
}
}catch (IOException e){
throw new RuntimeException(e);
}
}
private String process(String request) {
return "this server accept your request = " + request;
}
public static void main(String[] args) throws IOException{
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
7.2 TCP Echo Client(客户端)
java
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp,int serverPort) throws IOException{
//直接把字符串的IP地址设置进来
//127.0.0.1这种字符串
socket = new Socket(serverIp,serverPort);
}
public void start(){
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
//为了使用方便,套壳操作
Scanner scannerNet = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
//从控制台读取请求发送给服务器
while(true){
//1.从控制台读取用户输入
String request = scanner.next();
//2.发送给服务器
writer.println(request);
//这个操作只是把数据放到"发送缓冲区"中,还没有真正写入到网卡里
//加上刷新缓冲区操作才是真正发送数据
writer.flush();
//3.读取服务器返回的响应
String response = scannerNet.nextLine();
//4.打印到控制台
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
读 TCP 这套代码时,盯住三个工程细节:
accept()是阻塞点:没有客户端连上来就会一直等。- 用
println + flush相当于定义了一个很简单的"应用层协议":一行一个消息,否则对端读取边界会很痛苦。 - 服务端要并发就必须"一个连接交给一个线程/任务",否则一个客户端卡住会拖死所有人。
8)Socket 编程注意事项:端口占用、目的地址、以及"别忘了协议"
来看几个真实世界里最常见的坑:
8.1 目的 IP + 目的端口决定"你把数据发给谁"
一次数据传输里,目的 IP + 目的端口唯一标识了对端主机和对端进程。写错了就不是"收不到",而是"发给了别的地方"。
8.2 端口被占用:同一端口只能被一个进程绑定
如果进程 A 已经绑定端口,再让进程 B 绑定同一端口会报错(典型错误:Address already in use)。排查方式:
netstat -ano | findstr 端口号查到 PID- 在任务管理器按 PID 找到并结束进程,或换一个未被占用的端口
8.3 应用层协议不能忽略
就算底层用 TCP/UDP,应用层仍需要约定"数据怎么分隔、怎么解析、字段怎么定义"。否则双方读写会互相折磨。
9)长连接 vs 短连接:什么时候关连接决定系统形态
来看一个经典分叉:TCP 发数据前要先建连接,什么时候关闭连接决定你是短连接还是长连接。
- 短连接:一次请求-响应就关闭,只能收发一次
- 长连接:不关闭连接,双方可多次收发
对比差异:
- 短连接每次都要建连/断连,耗时更高;长连接首次建连后复用,效率更高
- 短连接通常是客户端主动发起;长连接场景里服务端也可能主动推送
- 短连接适合低频请求(比如普通网页浏览);长连接适合高频通信(聊天室、实时游戏等)
还有一个"扩展但很重要"的工程提醒:基于 BIO(同步阻塞 IO)的长连接会长期占用线程资源,并发高时成本非常昂贵;实际高并发长连接更常用 NIO(同步非阻塞 IO)来实现,性能能上一个量级。
10)怎么把代码跑起来:最短运行方式
来看最简单的本地运行方式(同机回环地址):
- 先启动服务端:
UdpEchoServer或TcpEchoServer - 再启动客户端:
UdpEchoClient或TcpEchoClient - 在客户端控制台输入字符串,观察是否收到回显/响应
只要端口一致(示例里都是 9090)且未被占用,就能跑通。
把这些概念和代码吃透之后,你就拥有了写网络程序的"底盘能力":知道什么时候会阻塞、如何定义消息边界、怎么做并发、为什么端口会炸,以及长连接为什么不能傻用 BIO。剩下的就是在这个底盘上继续往上盖:HTTP、RPC、自定义协议、NIO/Netty------都只是更复杂、更工程化的版本而已。