网络编程

互联网的本质是什么?两台机器上的进程互相发字节流。这一章我们用 Java 的 Socket API——java.net 包——直接和 TCP/UDP 打交道。这是 HTTP、RPC、数据库连接、消息队列……所有”网络通信”的底层。

为什么学这个?框架(Spring、Netty)把网络通信封装得很优雅,但出问题时——连接挂死、丢包、粘包、半连接——你得能钻到 Socket 层定位。理解 Socket,才算真正”懂网络”。

一、网络基础

1.1 TCP/IP vs UDP

协议特点适用场景
TCP面向连接、可靠、有序、字节流HTTP、数据库、邮件
UDP无连接、不可靠、数据报、快DNS、视频流、游戏、心跳

TCP 像打电话——先拨号建立连接(三次握手),然后双向通话,最后挂断(四次挥手)。中间丢了包会重传,保证对方收到。

UDP 像寄明信片——写好地址投出去就不管了。可能丢、可能乱序、可能重复,但快——没有握手机制。

为什么需要两种?可靠要付出代价:TCP 的握手、确认、重传都很费时。视频通话丢一两帧无所谓,但要低延迟——所以用 UDP。银行转账一字节都不能错——必须 TCP。

1.2 IP 与端口

  • IP 地址 —— 标识网络中的机器(如 192.168.1.100)。
  • 端口 —— 标识机器上的进程(0~65535)。

一台机器只有一个 IP,但可能同时跑 HTTP 服务(80)、数据库(3306)、SSH(22)——靠端口区分。IP 是地址,端口是门牌号

  • 端口 0~1023 是知名端口(well-known),需要管理员权限。
  • 1024~49151 是注册端口。
  • 49152~65535 是动态/临时端口。

1.3 TCP 三次握手与四次挥手

建立连接(三次握手)

客户端                    服务端
  | ---- SYN (seq=x) ----> |   1. 客户端发起
  | <--- SYN+ACK (y, x+1)  |   2. 服务端回应
  | ---- ACK (y+1) ------> |   3. 客户端确认
  |     连接建立           |

断开连接(四次挥手)

客户端                    服务端
  | ---- FIN ---->         |   1. 客户端: 我没数据了
  | <--- ACK ----          |   2. 服务端: 收到
  | <--- FIN ----          |   3. 服务端: 我也没了
  | ---- ACK ------>       |   4. 客户端: 收到, 关

为什么建立是 3 次断开是 4 次?建立连接时服务端的 SYN 和 ACK 可以合并;断开时服务端收到客户端 FIN 后,可能还有数据没发完,所以先 ACK,发完数据再 FIN——拆成两步。

二、InetAddress:地址表示

InetAddress 表示 IP 地址。常用子类 Inet4Address(IPv4)和 Inet6Address(IPv6)。

// 通过主机名获取 (会做 DNS 查询)
InetAddress addr = InetAddress.getByName("www.baidu.com");
System.out.println(addr.getHostAddress());   // IP 地址

// 本机
InetAddress local = InetAddress.getLocalHost();
System.out.println(local.getHostName());     // 主机名
System.out.println(local.getHostAddress());  // IP

// 通过主机名获取多个 (一个域名可能多个 IP)
InetAddress[] all = InetAddress.getAllByName("www.baidu.com");

三、TCP 通信:Socket 与 ServerSocket

Java 的 TCP 通信用两个类:

  • ServerSocket —— 服务端,监听端口,等待连接。
  • Socket —— 客户端,发起连接;服务端 accept 后也拿到一个 Socket。

3.1 服务端

try (ServerSocket server = new ServerSocket(8080)) {   // 监听 8080
    System.out.println("服务端启动, 等待连接...");
    while (true) {
        Socket client = server.accept();    // 阻塞, 等连接
        // 处理 client...
    }
}

accept() 是阻塞的——没有客户端连接时一直等。每来一个连接,返回一个 Socket,对应这个客户端的连接。

3.2 客户端

try (Socket socket = new Socket("127.0.0.1", 8080)) {   // 连接服务端
    OutputStream out = socket.getOutputStream();
    out.write("Hello".getBytes());

    InputStream in = socket.getInputStream();
    byte[] buf = new byte[1024];
    int len = in.read(buf);
    System.out.println("收到: " + new String(buf, 0, len));
}

Socket 的本质:两个流——getInputStream 读对方发来的,getOutputStream 写给对方。网络通信就是流的读写。

3.3 一次完整的 TCP 通信

// === 服务端 ===
try (ServerSocket server = new ServerSocket(8080);
     Socket client = server.accept();
     BufferedReader in = new BufferedReader(
         new InputStreamReader(client.getInputStream()));
     PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {

    String line;
    while ((line = in.readLine()) != null) {   // 读一行
        System.out.println("收到: " + line);
        out.println("Echo: " + line);           // 回一行
        if ("bye".equals(line)) break;
    }
}

// === 客户端 ===
try (Socket socket = new Socket("127.0.0.1", 8080);
     BufferedReader in = new BufferedReader(
         new InputStreamReader(socket.getInputStream()));
     PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) {

    out.println("Hello");
    System.out.println("服务端回: " + in.readLine());
    out.println("bye");
}

BufferedReader/PrintWriter 把字节流包装成字符流,方便按行读写。PrintWritertrue 参数表示自动 flush——否则要手动 flush()

四、UDP 通信:DatagramSocket

UDP 用 DatagramSocketDatagramPacket——没有连接概念,每个包独立发送。

// === UDP 服务端 ===
try (DatagramSocket socket = new DatagramSocket(9090)) {
    byte[] buf = new byte[1024];
    DatagramPacket packet = new DatagramPacket(buf, buf.length);
    socket.receive(packet);    // 阻塞, 等数据报
    String msg = new String(packet.getData(), 0, packet.getLength());
    System.out.println("收到: " + msg + " from " + packet.getAddress());
}

// === UDP 客户端 ===
try (DatagramSocket socket = new DatagramSocket()) {
    byte[] data = "Hello UDP".getBytes();
    DatagramPacket packet = new DatagramPacket(
        data, data.length, InetAddress.getByName("127.0.0.1"), 9090);
    socket.send(packet);
}

UDP 的特点:

  • 无连接——send 前不需要握手,发出去就不管了。
  • 数据报边界保留——发一个包,对方 receive 一次收到完整包(不像 TCP 是字节流,会粘包)。
  • 可能丢包——网络拥塞时路由器会丢 UDP 包,应用层不感知。

五、实战:多线程 Echo 服务器

真实服务端要同时处理多个客户端——单线程的 accept + read 会阻塞,一个慢客户端拖死所有人。解法是每个客户端开一个线程

下面代码在同一进程里启动服务端和多个客户端,演示多线程 TCP 通信。Piston 环境能跑——所有通信都在 127.0.0.1 本地回环。

Java · 在线运行

观察重点

  • 3 个客户端同时连接,服务器用线程池并行处理——单线程下慢客户端会阻塞快客户端。
  • accept() 阻塞——没连接时一直等,所以服务端要单独跑线程。
  • setSoTimeout 防止永久阻塞——读数据设超时,避免恶意客户端不发数据拖死服务。
  • TCP 用 BufferedReader.readLine() 按行读——这是简单的”应用层协议”(行分隔)。
  • UDP 用 DatagramPacket 发/收——无连接,发出去就不管了。

六、网络编程的常见陷阱

6.1 粘包/拆包

TCP 是字节流协议——没有”消息边界”。发 10 次 "abc",对方可能一次收到 "abcabcabc...",也可能收到 30 个 1 字节。这就是粘包/拆包。

解决:自定义应用层协议——

  • 固定长度 —— 每条消息固定 N 字节,不够补齐。
  • 分隔符 —— 用 \n 或特殊字符分隔(上面 Echo 就用了)。
  • 长度前缀 —— 先发 4 字节长度,再发数据(最常用)。
// 长度前缀协议
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
byte[] data = msg.getBytes(StandardCharsets.UTF_8);
out.writeInt(data.length);   // 4 字节长度
out.write(data);             // 数据

6.2 资源泄漏

Socket 不关 = 文件描述符泄漏。Linux 单进程默认 1024 个 fd,泄漏多了 Too many open files。永远 try-with-resources

6.3 阻塞与超时

accept/read 默认无限阻塞。生产环境必须设超时:

server.setSoTimeout(30_000);     // accept 超时
socket.setSoTimeout(10_000);     // read 超时
socket.connect(addr, 3000);      // connect 超时

6.4 字符编码

new String(bytes) 用默认编码——不同机器可能不同!永远显式指定:

new String(data, StandardCharsets.UTF_8);
"hello".getBytes(StandardCharsets.UTF_8);

七、从 Socket 到 Netty

裸 Socket API 写小demo还行,写生产级服务太累——粘包、超时、连接管理、心跳、半关闭……全要自己处理。生产环境几乎都用 Netty——基于 NIO 的异步事件驱动网络框架:

// Netty 服务端 (示意, 需引入 netty 依赖)
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(boss, worker)
     .channel(NioServerSocketChannel.class)
     .childHandler(new ChannelInitializer<SocketChannel>() {
         @Override
         protected void initChannel(SocketChannel ch) {
             ch.pipeline().addLast(new StringDecoder(), new StringEncoder());
             ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
                 @Override
                 protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                     ctx.writeAndFlush("Echo: " + msg);
                 }
             });
         }
     });
    ChannelFuture f = b.bind(8080).sync();
    f.channel().closeFuture().sync();
} finally {
    boss.shutdownGracefully();
    worker.shutdownGracefully();
}

Netty 把”事件循环 + Channel pipeline + Codec”封装好,几行代码就能写高并发服务。Dubbo、gRPC、Spring WebFlux 全靠它。但理解了 Socket 才能看懂 Netty 在做什么。

八、本章小结

概念核心要点
TCP可靠字节流,三次握手建立
UDP不可靠数据报,无连接
InetAddress表示 IP 地址
ServerSocket服务端监听端口
Socket客户端连接 / 服务端 accept 后的套接字
accept()阻塞等连接
getInputStream/getOutputStreamSocket 的两条流
DatagramSocket/DatagramPacketUDP 通信
多客户端每个连接一线程(线程池)

记忆口诀

  • TCP 三次握手,UDP 一发就完——可靠性 vs 速度。
  • Socket = 两条流——InputStream 读,OutputStream 写。
  • accept() 阻塞——多客户端用线程池。
  • TCP 粘包——字节流没边界,要自定义协议。
  • 永远设超时——setSoTimeout 防永久阻塞。
  • 生产用 Netty——别裸 Socket。

结语:从 Socket 到 HTTP

这一章我们用 Socket 写了 Echo 服务器——这是网络通信的最底层。下一章我们看 Java 11+ 内置的 HttpClient——基于 HTTP 协议的高级封装,让你能方便地调用 REST API、抓取网页、做爬虫。