1. 为什么网络编程是Java进阶路上绕不开的一道坎
刚入行那会儿,我对网络编程的理解基本停留在“Socket就是用来聊天的”这种程度。直到有一次需要做一个设备数据采集网关,要求同时对接几十台终端设备,每台设备通过TCP长连接上报心跳和数据,服务端还要支持断线重连、粘包拆包、并发写入。当时我第一反应是用HTTP轮询,结果延迟高得离谱,设备一多服务端直接卡死。后来硬着头皮去啃Socket和多线程,才发现网络编程这块的知识密度远比想象中大得多。
这份学习笔记整理自我系统学习Java网络编程的完整过程,核心参考了B站上那套口碑很好的实战讲解课程,同时结合了我自己在实际项目里踩过的坑和验证过的方案。内容覆盖从最基础的IP、端口、URL概念,到TCP/UDP通信的代码实现,再到BIO、NIO、AIO三种IO模型的演进逻辑,最后落到Netty这样的工业级框架的入门使用。整套笔记的目标很明确:让一个只会写CRUD的Java开发者,能够独立写出一个可用的多人在线聊天服务端,并且理解背后的每一个关键决策。
适合谁来参考?如果你已经掌握了Java基础语法、多线程和集合框架,但一碰到Socket、Channel、Selector这些词就发怵,那这份笔记就是为你准备的。如果你正在准备面试,网络编程几乎是中高级岗位的必考项,TCP三次握手、IO多路复用、零拷贝这些概念,光背八股文没用,得自己动手写过代码才能真正理解。另外,做物联网、即时通讯、游戏服务端、API网关方向的朋友,网络编程是每天都要打交道的核心技能,这份笔记里的实操方案可以直接拿去改改用。
我尽量用“说人话”的方式来讲,每个概念都配上可运行的代码和实际效果验证。遇到复杂的原理,我会用生活化的类比来辅助理解,比如把TCP三次握手比作打电话确认对方在不在。同时,我会重点标注那些“书上不会写但实际开发中一定会遇到”的坑,比如端口占用怎么排查、粘包问题怎么复现和解决、NIO的Selector空轮询bug是怎么回事。这些经验才是真正值钱的部分。
2. 网络编程基础概念快速扫盲
2.1 IP、端口、URL到底该怎么理解
刚接触网络编程时,最容易混淆的就是IP、端口和URL这三个东西。我用一个寄快递的类比来解释:IP地址相当于你家的街道门牌号,它唯一标识了网络中的一台设备;端口号相当于你家具体的房间号,因为一台设备上可能同时运行着多个网络程序,比如你的电脑上既开着浏览器又挂着聊天软件,它们都需要接收网络数据,端口就是用来区分这些不同程序的;URL则是快递单上写的完整地址,它包含了协议、IP、端口、路径等所有信息,快递员(浏览器)拿到URL就知道该把包裹送到哪里、用什么方式送。
在Java里,InetAddress类用来表示IP地址,它封装了IP地址的获取和操作。你可以通过InetAddress.getByName("www.example.com")来获取一个域名对应的IP地址,也可以通过InetAddress.getLocalHost()获取本机地址。这里有个实际开发中经常遇到的坑:getLocalHost()在某些服务器环境下可能返回127.0.0.1而不是真实的网卡IP,导致外部设备无法连接。解决办法是遍历NetworkInterface来获取所有网卡地址,筛选出非回环地址的那个。
端口号的范围是0到65535,其中0到1023是系统保留端口,普通应用程序应该使用1024以上的端口。实际开发中建议选择49152到65535之间的动态端口,避免和其他服务冲突。我习惯在配置文件里把端口号抽出来,而不是硬编码在代码里,这样部署到不同环境时只需要改配置就行。
URL在Java中对应java.net.URL类,它可以解析出协议、主机、端口、路径、查询参数等各个部分。但实际做HTTP请求时,更推荐用HttpURLConnection或者成熟的HTTP客户端库,因为URL类本身只负责解析,不负责发送请求。这一点初学者很容易搞混,以为URL对象就能直接发请求,实际上还需要调用openConnection()方法。
2.2 TCP和UDP的本质区别与选型逻辑
TCP和UDP的区别,面试时谁都能背出“TCP可靠、UDP不可靠”这句话,但实际做技术选型时,光知道这个远远不够。我总结了一个简单的判断标准:如果你的业务场景对数据完整性要求极高,丢一个包就会导致业务逻辑出错,那就选TCP;如果你的业务场景对实时性要求极高,偶尔丢几个包不影响整体体验,那就选UDP。
举个例子,文件传输、网页浏览、邮件发送这些场景必须用TCP,因为少一个字节文件就可能损坏。而视频直播、语音通话、在线游戏这些场景更适合UDP,因为偶尔卡顿一下用户能接受,但如果为了等一个丢失的包而暂停整个画面,体验反而更差。当然,现在很多实时通信场景会在UDP之上自己实现一套可靠性机制,比如WebRTC就是这样做的,兼顾了实时性和一定的可靠性。
TCP的“可靠”体现在几个机制上:三次握手建立连接、数据包确认与重传、流量控制、拥塞控制。三次握手的核心目的是确认双方的发送和接收能力都正常。你可以这样理解:A打电话给B,B接了说“喂”,A说“我能听到你说话”,B说“我也能听到你说话”,这样双方都确认了通信链路是通的。如果只有两次握手,A无法确认B是否能收到自己的消息,因为B的“喂”可能只是自动接听,并不代表B的麦克风是好的。
UDP则简单粗暴得多,它只管把数据包扔出去,不关心对方收没收到。这种“无连接”的特性让UDP的开销极小,传输效率极高。但代价就是不可靠,数据包可能丢失、可能乱序、可能重复。所以用UDP做开发时,应用层必须自己处理这些问题,比如给每个包加序号来解决乱序,加确认机制来解决丢包。
2.3 网络编程中的核心类与API速查
Java网络编程的API主要集中在java.net包下,核心类包括InetAddress、Socket、ServerSocket、DatagramSocket、DatagramPacket、URL、URLConnection等。下面这张表是我整理的高频API速查表,涵盖了最常用的方法和使用场景。
| 类名 | 核心方法 | 使用场景 | 注意事项 |
|---|---|---|---|
| InetAddress | getByName、getLocalHost、getHostAddress | 获取IP地址信息 | getLocalHost可能返回回环地址 |
| Socket | connect、getInputStream、getOutputStream、close | TCP客户端通信 | 必须手动关闭,建议用try-with-resources |
| ServerSocket | bind、accept、close | TCP服务端监听 | accept是阻塞方法,需配合多线程 |
| DatagramSocket | send、receive、close | UDP通信 | receive是阻塞方法,需设置超时 |
| DatagramPacket | getData、getLength、getAddress、getPort | UDP数据包封装 | 接收时要指定缓冲区大小 |
| URL | openConnection、openStream | 解析和访问网络资源 | 只负责解析,发请求需配合Connection |
这里重点说一下Socket的关闭问题。很多初学者写完代码测试没问题,但跑一段时间后发现服务端越来越卡,最后直接无响应。排查半天才发现是Socket没有正确关闭,导致文件描述符耗尽。Java 7之后可以用try-with-resources语法自动关闭,但要注意Socket的close()方法会同时关闭输入流和输出流,如果先关了流再关Socket会抛异常。正确的做法是只关Socket,让它的close方法去处理底层流的关闭。
另外,ServerSocket的accept()方法是阻塞的,这意味着如果没有客户端连接,线程就会一直卡在那里。所以服务端必须为每个连接分配一个独立的线程来处理通信,否则一个客户端连上来之后,其他客户端就无法连接了。这就是经典的BIO模型,后面会详细讲它的局限性和改进方案。
3. TCP通信实战:从单客户端到多客户端并发
3.1 最简TCP通信的代码实现与调试
先来看一个最基础的TCP通信例子,客户端发送一条消息,服务端接收并打印。这个例子虽然简单,但包含了TCP编程的所有核心步骤,理解了它,后面的复杂场景都是在这个基础上做扩展。
服务端代码的核心逻辑是:创建ServerSocket并绑定端口,调用accept()等待客户端连接,连接建立后获取输入流读取数据,最后关闭资源。客户端代码的核心逻辑是:创建Socket并连接服务端地址,获取输出流写入数据,最后关闭资源。
// 服务端 public class TcpServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8888); System.out.println("服务端启动,等待连接..."); Socket socket = serverSocket.accept(); System.out.println("客户端已连接:" + socket.getInetAddress()); InputStream is = socket.getInputStream(); byte[] buffer = new byte[1024]; int len = is.read(buffer); System.out.println("收到消息:" + new String(buffer, 0, len)); socket.close(); serverSocket.close(); } }// 客户端 public class TcpClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 8888); OutputStream os = socket.getOutputStream(); os.write("你好,服务端".getBytes()); os.flush(); socket.close(); } }这段代码跑起来没问题,但有几个细节值得注意。第一,os.flush()必须调用,否则数据可能留在缓冲区里发不出去。第二,服务端的is.read(buffer)返回的是实际读取到的字节数,不能假设它等于buffer的长度。第三,如果客户端发送的中文消息超过1024字节,服务端一次read可能读不完,需要循环读取。这些细节在简单测试中不会暴露问题,但到了生产环境就是致命的。
调试TCP程序时,我习惯用netstat -an | grep 8888来查看端口监听状态,用telnet 127.0.0.1 8888来模拟客户端发送数据。这两个命令能快速判断是服务端没启动、端口没监听,还是网络不通。另外,Wireshark抓包工具在排查TCP层问题时非常有用,可以看到三次握手、数据传输、四次挥手的完整过程。
3.2 多线程改造:让服务端同时处理多个客户端
上面的单客户端版本有个致命问题:accept()一次只能处理一个连接,处理完就退出了。实际场景中,服务端需要同时服务成百上千个客户端,这就必须引入多线程。改造思路很简单:主线程只负责accept(),每接到一个连接就启动一个新线程去处理这个连接的读写。
public class MultiThreadTcpServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8888); System.out.println("多线程服务端启动..."); while (true) { Socket socket = serverSocket.accept(); System.out.println("新客户端连接:" + socket.getInetAddress()); new Thread(new ClientHandler(socket)).start(); } } } class ClientHandler implements Runnable { private Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (InputStream is = socket.getInputStream()) { byte[] buffer = new byte[1024]; int len; while ((len = is.read(buffer)) != -1) { System.out.println("收到:" + new String(buffer, 0, len)); } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { } } } }这个版本能同时处理多个客户端了,但线程模型很粗糙。每个连接创建一个线程,如果有一万个连接,就创建一万个线程,每个线程默认占用1MB栈空间,光线程栈就要吃掉10GB内存,服务器直接崩掉。而且线程上下文切换的开销也很大,CPU大部分时间都花在切换线程上,真正处理业务的时间反而很少。
这就是BIO模型的根本瓶颈:一个连接一个线程,连接数受限于线程数。虽然可以用线程池来复用线程,但线程池的大小是固定的,超出池大小的连接只能排队等待,无法做到真正的并发。所以BIO只适合连接数少、请求频率低的场景,比如内部管理系统。一旦面对高并发场景,就必须换用NIO模型。
3.3 粘包拆包问题的复现与三种解决方案
粘包和拆包是TCP编程中最经典的问题,也是面试高频考点。先解释一下现象:客户端连续发送两条消息“Hello”和“World”,服务端可能一次read读到“HelloWorld”,也可能第一次读到“Hel”,第二次读到“loWorld”。前者叫粘包,后者叫拆包。
产生这个问题的根本原因是TCP是面向字节流的协议,它不关心你发送的消息边界在哪里,只负责把字节按顺序送达。就像往水管里倒水,你倒了两次,但水在水管里是混在一起的,另一端接水的人无法区分哪部分是第一次倒的、哪部分是第二次倒的。
解决粘包拆包的核心思路就是让接收方能够识别消息边界。常用的方案有三种:固定长度、分隔符、长度字段。固定长度就是约定每条消息的长度固定,比如每条消息都是100字节,接收方每读100字节就切分一条消息。这种方式简单但浪费带宽,消息短了要补空格,消息长了要拆分。分隔符就是约定一个特殊字符作为消息结束标志,比如用换行符\n分隔,接收方读到\n就认为一条消息结束了。这种方式适合文本协议,但消息内容本身不能包含分隔符。长度字段是最通用的方案,在消息头部用固定字节数(比如4字节)记录消息体的长度,接收方先读头部拿到长度,再按长度读取消息体。这种方式效率高、无歧义,是大多数二进制协议采用的做法。
// 长度字段方案的服务端读取逻辑 DataInputStream dis = new DataInputStream(socket.getInputStream()); while (true) { int length = dis.readInt(); // 先读4字节长度 byte[] data = new byte[length]; dis.readFully(data); // 再读指定长度的数据 System.out.println("收到完整消息:" + new String(data)); }实际开发中,我推荐直接用Netty这样的框架,它内置了LengthFieldBasedFrameDecoder和DelimiterBasedFrameDecoder等解码器,一行代码就能解决粘包拆包问题,不需要自己手写解析逻辑。但理解底层原理仍然很重要,因为出问题时你需要知道怎么排查。
4. UDP通信与NIO模型深入解析
4.1 UDP通信的代码实现与适用场景
UDP的代码比TCP简单很多,因为不需要建立连接。发送方创建DatagramSocket和DatagramPacket,指定目标地址和端口,调用send()就发出去了。接收方创建DatagramSocket绑定端口,创建DatagramPacket作为缓冲区,调用receive()等待数据到达。
// UDP发送端 DatagramSocket socket = new DatagramSocket(); String msg = "UDP消息"; byte[] data = msg.getBytes(); DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("127.0.0.1"), 9999); socket.send(packet); socket.close(); // UDP接收端 DatagramSocket socket = new DatagramSocket(9999); byte[] buffer = new byte[1024]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); socket.receive(packet); System.out.println("收到:" + new String(packet.getData(), 0, packet.getLength())); socket.close();UDP的receive()也是阻塞的,而且没有超时机制的话会一直等下去。实际使用中建议调用socket.setSoTimeout(5000)设置超时时间,避免线程永久阻塞。另外,UDP的DatagramPacket在接收时如果缓冲区太小,数据会被截断,超出部分直接丢弃,所以缓冲区要设置得足够大。
UDP适合的场景包括:DNS查询、DHCP配置、视频会议、在线游戏、物联网设备上报等。这些场景的共同特点是对实时性要求高,能容忍少量丢包。比如在线游戏中,玩家的位置信息每秒发送几十次,丢一两个包完全不影响体验,下一个包马上就到了。但如果用TCP,为了等一个丢失的包而重传,反而会导致画面卡顿。
4.2 BIO的局限性:为什么高并发场景必须换NIO
BIO的局限性在前面已经提到了一部分,这里再深入分析一下。BIO的全称是Blocking IO,即阻塞式IO。它的“阻塞”体现在两个地方:accept()方法在没有新连接时会阻塞,read()方法在没有数据可读时会阻塞。这意味着每个连接都需要一个独立的线程来处理,线程在等待数据时什么也做不了,纯粹在浪费资源。
假设一个服务端有1000个连接,但同一时刻只有10个连接在活跃地收发数据,其他990个连接处于空闲状态。BIO模型下,这1000个连接对应1000个线程,其中990个线程都在read()上阻塞着,占着内存和CPU调度资源,却没有任何产出。这就是巨大的浪费。
NIO的全称是Non-blocking IO,即非阻塞式IO。它的核心改进是:一个线程可以管理多个连接。通过Selector(多路复用器),线程可以同时监听多个Channel上的事件,哪个Channel有数据可读就去处理哪个,没有数据可读的Channel就注册在Selector上等着,不占用线程。这样,1000个连接只需要1个线程就能管理,资源利用率极高。
用一个生活类比来解释:BIO就像餐厅里每来一桌客人就配一个服务员,服务员全程站在桌边等客人点菜、上菜、结账,其他桌的客人只能干等着。NIO就像一个服务员同时照看十桌客人,哪桌举手就去哪桌,没举手的桌不用管。显然NIO的服务员效率高得多。
4.3 NIO三大核心组件:Channel、Buffer、Selector
NIO的三大核心组件是Channel、Buffer和Selector,理解它们之间的关系是掌握NIO的关键。
Channel是双向的,既可以读也可以写,这点和InputStream/OutputStream不同。常用的Channel有SocketChannel(TCP客户端)、ServerSocketChannel(TCP服务端)、DatagramChannel(UDP)。Channel必须配合Buffer使用,不能直接读写字节。
Buffer是一个内存块,本质是一个数组,但封装了position、limit、capacity三个指针来管理读写状态。写数据时,position记录写到了哪里;读数据时,需要先调用flip()把position重置为0,limit设置为之前写入的位置。这个flip操作是初学者最容易搞混的地方,忘记flip会导致读不到数据。
Selector是多路复用器,它负责监听多个Channel上的事件。Channel注册到Selector上时需要指定感兴趣的事件类型,包括OP_ACCEPT(有新连接)、OP_CONNECT(连接建立)、OP_READ(有数据可读)、OP_WRITE(可以写入)。Selector的select()方法会阻塞直到至少有一个事件发生,然后返回事件数量,通过selectedKeys()获取所有就绪的事件进行处理。
// NIO服务端核心代码 ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8888)); serverChannel.configureBlocking(false); Selector selector = Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> it = keys.iterator(); while (it.hasNext()) { SelectionKey key = it.next(); if (key.isAcceptable()) { SocketChannel client = serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int len = client.read(buffer); if (len > 0) { buffer.flip(); System.out.println("收到:" + new String(buffer.array(), 0, len)); } } it.remove(); } }这段代码看起来比BIO复杂不少,但它的扩展性极强。无论有多少个客户端连接,都只需要这一个线程来处理。当然,实际生产环境中不会只用单线程,而是会用多个Selector配合线程池,形成主从Reactor模式,Netty就是基于这个模式实现的。
4.4 NIO的Selector空轮询问题与解决思路
NIO的Selector有一个著名的空轮询bug,在某些操作系统上,select()方法会在没有任何事件发生的情况下返回0,导致while循环疯狂空转,CPU直接飙到100%。这个问题在JDK 1.6到1.7版本中比较常见,虽然官方一直在修复,但在某些环境下仍然会出现。
解决思路主要有两个:一是记录空轮询的次数,如果连续空轮询超过一定阈值(比如512次),就重建Selector,把原来注册的Channel全部迁移到新的Selector上。二是设置select()的超时时间,比如select(1000),这样即使空轮询,每秒也只会空转一次,不会把CPU跑满。Netty采用的是第一种方案,在NioEventLoop中检测空轮询并重建Selector。
实际开发中,如果你直接用JDK原生的NIO写服务端,一定要处理这个问题。我见过一个线上事故,就是因为空轮询导致CPU持续100%,服务响应极慢,排查了很久才发现是Selector的bug。后来换成Netty之后,这个问题就再也没出现过,因为Netty已经帮我们处理好了。
5. Netty入门与实战:手写一个多人聊天室
5.1 为什么选择Netty而不是原生NIO
原生NIO的代码复杂、API难用、坑多,这是业界公认的。用原生NIO写一个健壮的服务端,需要处理空轮询、粘包拆包、连接管理、心跳检测、编解码、线程模型等一系列问题,代码量巨大且容易出错。Netty的出现就是为了解决这些问题,它把网络编程中所有繁琐的细节都封装好了,开发者只需要关注业务逻辑。
Netty的核心优势可以总结为几点:第一,API简洁,基于ChannelHandler的责任链模式让代码结构非常清晰;第二,性能强悍,采用了主从Reactor多线程模型、零拷贝、内存池等技术;第三,社区活跃,遇到问题容易找到解决方案;第四,经过大规模生产验证,Dubbo、RocketMQ、Elasticsearch等知名项目都在用Netty做底层通信。
学习Netty的路径建议是:先理解BIO和NIO的原理,知道Netty解决了什么问题;然后从官方的Echo示例入手,跑通一个最简单的服务端和客户端;接着学习ChannelHandler、ByteBuf、EventLoop、ChannelPipeline等核心组件;最后通过实战项目(比如聊天室、RPC框架)来巩固。
5.2 Netty服务端与客户端的骨架搭建
Netty服务端的启动流程可以概括为:创建两个EventLoopGroup(bossGroup和workerGroup),创建ServerBootstrap并配置参数,设置ChannelInitializer来添加处理器,最后绑定端口启动。bossGroup负责接收连接,workerGroup负责处理IO读写,这种分工就是主从Reactor模式。
// Netty服务端 public class NettyServer { public static void main(String[] args) throws InterruptedException { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new StringDecoder()) .addLast(new StringEncoder()) .addLast(new ChatServerHandler()); } }); ChannelFuture future = bootstrap.bind(8888).sync(); System.out.println("Netty服务端启动,端口8888"); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }客户端的启动流程类似,只是用Bootstrap而不是ServerBootstrap,用NioSocketChannel而不是NioServerSocketChannel,并且需要指定服务端的地址和端口。客户端还需要一个处理器来读取服务端返回的消息,通常会在channelActive回调里发送一条上线通知。
5.3 ChannelHandler与消息编解码实战
ChannelHandler是Netty中处理IO事件的核心接口,分为ChannelInboundHandler(处理入站事件,如读取数据)和ChannelOutboundHandler(处理出站事件,如写入数据)。实际开发中通常继承ChannelInboundHandlerAdapter或SimpleChannelInboundHandler,重写channelRead、channelActive、exceptionCaught等方法。
消息编解码是网络编程中必须处理的问题。Netty提供了StringDecoder和StringEncoder来处理字符串的编解码,但实际项目中更常用的是自定义协议。一个典型的自定义协议包括魔数(用于快速判断是否是合法数据包)、版本号、序列化算法、消息类型、消息长度、消息体等字段。Netty的LengthFieldBasedFrameDecoder可以基于长度字段自动解决粘包拆包问题,配合自定义的ByteToMessageDecoder就能实现完整的协议解析。
// 自定义解码器示例 public class MyProtocolDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (in.readableBytes() < 8) return; // 头部不够8字节,等待更多数据 in.markReaderIndex(); int magic = in.readInt(); if (magic != 0xCAFEBABE) { ctx.close(); return; } int length = in.readInt(); if (in.readableBytes() < length) { in.resetReaderIndex(); return; } byte[] data = new byte[length]; in.readBytes(data); out.add(new String(data)); } }这里有个关键点:ByteToMessageDecoder的decode方法可能被调用多次,每次传入的ByteBuf可能只包含部分数据。所以必须先判断可读字节数是否足够,不够就return,等下次数据到达时再处理。如果已经读取了部分数据但发现不够,需要调用resetReaderIndex()回退读指针,否则数据就丢了。
5.4 多人聊天室的完整实现与效果验证
聊天室的核心逻辑是:服务端维护一个所有已连接客户端的ChannelGroup,当收到某个客户端发来的消息时,遍历ChannelGroup把消息转发给所有客户端。Netty提供了ChannelGroup接口,可以方便地管理一组Channel,并且支持批量操作。
public class ChatServerHandler extends SimpleChannelInboundHandler<String> { private static final ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); @Override public void channelActive(ChannelHandlerContext ctx) { channels.add(ctx.channel()); channels.writeAndFlush("[系统] " + ctx.channel().remoteAddress() + " 上线了\n"); } @Override public void channelInactive(ChannelHandlerContext ctx) { channels.remove(ctx.channel()); channels.writeAndFlush("[系统] " + ctx.channel().remoteAddress() + " 下线了\n"); } @Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { channels.writeAndFlush("[" + ctx.channel().remoteAddress() + "] " + msg + "\n"); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } }测试时启动服务端,然后启动多个客户端实例,每个客户端输入消息后,所有客户端都能收到。这里要注意writeAndFlush是异步的,返回的是ChannelFuture,如果需要确认消息是否发送成功,可以添加监听器。另外,ChannelGroup的writeAndFlush会自动跳过已关闭的Channel,不需要手动判断。
实际部署时还需要考虑心跳检测,防止连接被防火墙断开。Netty提供了IdleStateHandler,可以设置读空闲、写空闲、读写空闲的超时时间,超时后触发userEventTriggered事件,在事件里发送心跳包或关闭连接。这个功能在长连接场景中几乎是必备的。
6. 常见问题排查与性能优化经验
6.1 端口占用、连接超时、数据乱码的排查套路
端口占用是最常见的问题之一,报错信息通常是java.net.BindException: Address already in use。排查方法是先用netstat -ano | findstr 端口号(Windows)或lsof -i:端口号(Linux/Mac)找到占用端口的进程ID,然后决定是杀掉进程还是换端口。我习惯在服务端启动时捕获这个异常,并打印出友好的提示信息,而不是让程序直接崩溃。
连接超时的报错是java.net.ConnectException: Connection timed out,原因可能是服务端没启动、防火墙拦截、IP地址写错、网络不通。排查顺序是:先用ping命令确认网络可达,再用telnet IP 端口确认端口开放,最后检查服务端日志确认是否正常监听。如果是云服务器,还要检查安全组规则是否放行了对应端口。
数据乱码通常是因为编码不一致。发送方用UTF-8编码,接收方用GBK解码,就会出现乱码。解决办法是双方约定统一的编码格式,推荐统一用UTF-8。在Netty中,StringDecoder和StringEncoder的构造函数可以指定字符集,不指定的话默认是UTF-8。另外,如果消息中包含中文,要确保缓冲区大小足够,否则可能读到半个中文字符导致乱码。
6.2 高并发场景下的参数调优与资源控制
高并发场景下,Netty的默认参数可能不够用,需要根据实际情况调优。NioEventLoopGroup的线程数默认是CPU核心数乘以2,对于IO密集型任务可以适当增加,但不要超过CPU核心数的4倍,否则线程切换开销会抵消收益。SO_BACKLOG参数控制等待队列的大小,默认是128,高并发场景下建议调到1024以上,否则大量连接会被拒绝。
SO_KEEPALIVE参数控制是否开启TCP心跳,默认是false。对于长连接场景建议开启,这样操作系统会自动发送心跳包检测连接是否存活。TCP_NODELAY参数控制是否禁用Nagle算法,默认是false。Nagle算法会把小数据包合并成大数据包发送,减少网络开销,但会增加延迟。对于实时性要求高的场景,建议设置为true禁用Nagle算法。
内存管理方面,Netty的ByteBuf有堆内存和直接内存两种。直接内存的读写性能更好,但分配和释放的开销更大。Netty提供了内存池来复用ByteBuf,减少GC压力。使用完ByteBuf后必须调用release()释放,否则会导致内存泄漏。Netty提供了ResourceLeakDetector来检测内存泄漏,开发阶段可以设置为PARANOID级别,上线后改为SIMPLE级别。
6.3 网络编程学习路线与进阶方向建议
网络编程的学习路线我建议分四个阶段。第一阶段是基础概念和BIO编程,理解IP、端口、TCP、UDP、Socket这些基本概念,能写出简单的客户端和服务端。第二阶段是NIO编程,理解Channel、Buffer、Selector的工作原理,能写出基于NIO的服务端。第三阶段是Netty框架,掌握ChannelHandler、ByteBuf、EventLoop等核心组件,能用Netty搭建完整的网络应用。第四阶段是源码和架构,阅读Netty源码理解其设计思想,学习主从Reactor、零拷贝、内存池等高级技术。
进阶方向主要有几个:一是RPC框架开发,学习Dubbo、gRPC等框架的设计原理,自己动手实现一个简易的RPC框架。二是即时通讯系统,学习消息可靠性投递、离线消息、群组管理、消息同步等机制。三是API网关,学习请求路由、限流熔断、协议转换、安全认证等功能。四是物联网平台,学习MQTT协议、设备管理、数据采集、规则引擎等技术。
我个人在实际操作中的体会是,网络编程这门技术光看书和视频是不够的,必须动手写代码。哪怕只是把教程里的例子敲一遍,跑起来看看效果,也比只看不练强得多。遇到问题不要怕,报错信息就是最好的老师,顺着报错去查资料、去调试,解决一个问题的收获比看十篇文章都大。另外,多看看优秀的开源项目是怎么写的,Netty的官方示例、Dubbo的通信模块、RocketMQ的Remoting模块,都是很好的学习材料。最后再分享一个小技巧:调试网络程序时,在关键节点加上日志输出,把连接建立、数据收发、连接关闭这些事件都记录下来,出问题时一看日志就能定位到是哪一步出了问题。