1. 这不是教科书里的Socket,是我在真实项目里反复调试出来的Java网络通信骨架
“Java Socket编程基础及深入讲解”——看到这个标题,很多人第一反应是大学《计算机网络》课上那个用ServerSocket和Socket写个“Hello World”聊天程序的实验。但我要说,那只是冰山露出水面的尖角。真正让一个Java后端服务能扛住每秒上千连接、不因粘包丢数据、在NAT穿透失败时优雅降级、甚至在JVM频繁GC时仍保持心跳稳定的底层能力,全藏在这套看似简单的API背后。我带过的三个不同规模的项目里,有两次线上故障的根因,最后都回溯到Socket层的一个超时参数设错,或者缓冲区大小没对齐操作系统TCP栈的默认值。这不是理论题,是每天都在发生的工程现实。本文不讲抽象协议分层,不画OSI七层模型图,只聚焦你打开IDE、新建一个.java文件后,从第一行new ServerSocket(8080)开始,到上线前压测发现连接数卡在65535、日志里疯狂打印IOException: Broken pipe为止,中间必须亲手填平的所有坑。适合刚写完Spring Boot接口、想搞懂“为什么加了@Async就变慢了”的中级开发者;也适合正在做IoT设备网关、需要自己封装二进制协议的架构同学。你会看到真实的线程阻塞现场、Wireshark抓包截图里的ACK重传风暴、Linuxss -s命令输出的内存泄漏痕迹——所有这些,我都替你试过了。
2. 从“能连上”到“稳如磐石”:Socket设计思路的本质拆解
2.1 为什么不能直接用ServerSocket.accept()写生产服务?
很多初学者写完一个循环调用accept()、为每个连接起一个新线程的Demo,就以为掌握了Socket。但真实场景下,这种写法在连接数超过200时就会崩。原因不在Java,而在操作系统内核。accept()本质是调用listen()系统调用维护的已完成连接队列(accept queue)。这个队列长度由/proc/sys/net/core/somaxconn控制(Linux默认128),当队列满而新SYN到达时,内核会直接丢弃该SYN包——客户端收不到任何响应,表现为“连接超时”,而非“拒绝连接”。更隐蔽的是,JVM线程与OS线程是一一映射的,每个Java线程至少占用1MB栈空间。起1000个线程,光栈内存就吃掉1GB,还没算堆内存和上下文切换开销。我之前在一个监控告警系统里就踩过这个坑:凌晨三点,设备批量上报心跳,瞬间涌进3000个连接,JVM线程数飙到2900+,GC停顿从10ms拉长到400ms,整个服务雪崩。后来我们把线程模型换成NIO,连接数提升到5万,CPU使用率反而从92%降到35%。这说明,Socket设计的第一道分水岭,不是“能不能通信”,而是“通信规模与资源消耗是否成线性关系”。
2.2 BIO、NIO、AIO:不是版本升级,而是哲学切换
很多人把BIO(Blocking I/O)、NIO(Non-blocking I/O)、AIO(Asynchronous I/O)理解成Java版本迭代带来的新API。这是巨大误解。它们代表三种完全不同的事件处理哲学:
BIO是“人盯人”模式:每个连接配一个专属线程,线程在
read()时挂起,直到数据来;write()时再挂起,直到发完。好处是代码直白,坏处是资源被“占着茅坑不拉屎”。就像餐厅服务员,客人没点菜,服务员就站在桌边干等。NIO是“轮岗制”:一个线程(Selector)同时盯着成千上万个连接的“就绪状态”。它不主动读数据,只问“谁的数据到了?谁的缓冲区空了?”。一旦某个Socket的
OP_READ就绪,才去调用read();OP_WRITE就绪,才去write()。这要求你必须把读写逻辑拆成“状态机”——比如收到半包数据,不能等,得先存起来,等下次OP_READ再拼接。这正是Netty框架的核心思想:把复杂的事件状态管理封装成ChannelHandler链。AIO是“外包制”:你告诉操作系统“等数据来了,通知我”,然后去做别的事。操作系统内核完成读写后,通过回调或
Future通知你。但Java的AIO(AsynchronousSocketChannel)在Linux上实际是用epoll模拟的,并非真正的内核异步;且API设计反直觉,错误处理复杂。我实测过,在高并发小包场景下,AIO性能甚至不如优化后的NIO。所以目前主流框架(Dubbo、RocketMQ)全部基于NIO,而非AIO。
提示:别被名词迷惑。关键不是选哪个IO模型,而是理解你的业务特征:如果单连接流量大、交互少(如文件上传),BIO够用;如果连接多、单连接流量小(如IM消息、IoT心跳),必须上NIO;AIO目前仅建议在Windows平台特定场景尝试。
2.3 Socket选项(SocketOptions)不是可有可无的配置项,而是性能命脉
Socket和ServerSocket提供了一组setSoXXX()方法,比如setSoTimeout()、setTcpNoDelay()、setKeepAlive()。新手常忽略它们,认为“默认值应该最稳妥”。但默认值是为通用场景设计的,不是为你这个具体服务定制的。举几个血泪案例:
SO_TIMEOUT设为0(无限等待):某支付回调服务,上游HTTP请求超时设为5秒,但下游调用银行接口的Socket未设超时。结果一次银行系统抖动,Socket卡死,线程永久阻塞,线程池耗尽,整个支付通道瘫痪。后来强制所有Socket连接设setSoTimeout(3000),配合熔断降级,故障恢复时间从小时级降到秒级。TCP_NODELAY = false(启用Nagle算法):Nagle算法会把小包攒够MSS(最大报文段长度,通常1460字节)再发,减少网络小包数量。但对实时性要求高的场景(如游戏指令、金融行情推送),攒包意味着毫秒级延迟。我们做过对比测试:同一台机器上,TCP_NODELAY = true时,100字节消息平均延迟0.8ms;false时,延迟跳到12ms。因为要等ACK回来才能发下一包,而ACK可能被延迟确认(Delayed ACK)机制再拖200ms。SO_KEEPALIVE = false:这个选项决定TCP是否定期发心跳包探测连接是否存活。默认是false。问题在于,如果客户端异常断电、进程崩溃,服务端不会立刻知道,read()会一直阻塞,直到SO_TIMEOUT触发。而网络设备(如防火墙、负载均衡器)通常有连接空闲超时(如30分钟),超时后静默关闭连接,服务端却还傻等。我们有个设备管理平台,因未开启KEEPALIVE,导致上万设备“假在线”,运维半夜被告警轰炸。开启后,read()在2小时无数据时抛出SocketException: Connection reset,可立即清理连接。
这些选项不是“锦上添花”,而是决定服务生死的“安全阀”。它们的取值必须结合你的业务SLA(比如“99.9%请求响应<200ms”)、网络环境(内网还是公网)、协议特性(文本协议还是二进制协议)来综合设定。
3. 核心细节解析:从创建Socket到稳定通信的12个关键实操点
3.1ServerSocket构造函数的端口绑定陷阱
new ServerSocket(port)看似简单,但第二个参数backlog常被忽略。它的作用是设置已完成连接队列(accept queue)的长度。注意,这不是最大连接数!最大连接数 =backlog+ESTABLISHED状态连接数。当backlog设得太小(如默认50),在突发流量下,队列溢出,新连接SYN被内核丢弃。我们曾将backlog从50调到1024,压测时连接成功率从72%提升到99.98%。但也不能盲目调大:backlog值受/proc/sys/net/core/somaxconn限制,超过该值会被内核截断。所以正确姿势是:先查cat /proc/sys/net/core/somaxconn(Ubuntu默认128,CentOS 7+默认128),再设backlog为该值或略小。代码示例:
// 正确:显式指定backlog,避免依赖默认值 int backlog = 1024; ServerSocket serverSocket = new ServerSocket(port, backlog, InetAddress.getByName("0.0.0.0"));注意:
InetAddress.getByName("0.0.0.0")表示监听所有网卡,而非localhost(127.0.0.1)。后者只响应本机请求,线上部署必用0.0.0.0。
3.2Socket连接超时与读写超时的三重分离
一个Socket连接涉及三个独立超时:
连接超时(connect timeout):
socket.connect(new InetSocketAddress(host, port), 5000)中的5000ms。这是TCP三次握手的最长等待时间。设太短(如100ms),在网络抖动时大量连接失败;设太长(如30s),服务启动慢,健康检查超时。读超时(soTimeout):
socket.setSoTimeout(10000)。这是InputStream.read()的阻塞上限。关键点:它只对read()生效,对write()无效!很多同学误以为设了soTimeout,write()也会超时,结果write()卡死导致线程阻塞。写超时(write timeout):Java原生Socket没有
setWriteTimeout()方法!write()超时由TCP协议栈控制,取决于net.ipv4.tcp_fin_timeout(默认60秒)和net.ipv4.tcp_retries2(默认15次)。这意味着,如果对端突然断网,write()可能卡住一分多钟才抛异常。解决方案只有两个:a) 用NIO的SocketChannel配合Selector实现非阻塞写;b) 启用SO_LINGER(见3.3节)。
我们线上服务的标准配置是:连接超时3s,读超时10s,写操作一律走NIO或加业务层超时兜底。
3.3SO_LINGER:优雅关闭还是自掘坟墓?
socket.setSoLinger(true, lingerTime)用于控制close()行为。当lingerTime > 0时,close()会阻塞,直到发送缓冲区数据发完或超时;lingerTime = 0时,close()立即返回,内核发送RST包强制断开(“暴力关闭”);lingerTime < 0(默认)时,close()立即返回,内核在后台发FIN包(“优雅关闭”)。
表面看,lingerTime > 0最安全。但实测发现,它极易引发线程阻塞。比如对端接收窗口为0(已满),你的数据发不出去,close()就一直等。我们有个日志收集服务,因SO_LINGER=5,在对端宕机时,close()卡住5秒,线程池瞬间打满。后来全部改为lingerTime = 0,配合应用层ACK机制(发送后等待对方回执),既保证数据可靠性,又避免阻塞。
实操心得:除非你100%确定对端一定能及时接收,否则永远不要设
SO_LINGER > 0。用lingerTime = 0+ 应用层重传,比依赖TCP的“优雅”更可靠。
3.4 粘包与半包:二进制协议绕不开的深渊
TCP是字节流协议,不保证“一次write()对应一次read()”。这就是粘包(多个消息粘成一包)和半包(一个消息被拆成多包)。文本协议(如HTTP)靠\r\n\r\n分隔,相对好处理;但二进制协议(如自定义的设备指令)必须自己解决。
常见方案有三种:
固定长度:每个消息固定1024字节。简单但浪费带宽,小消息也要填满。
分隔符:用特殊字节(如
0x00)分隔。风险是消息体里可能真有这个字节,需转义。长度域:消息头第一个4字节存消息体长度。最通用,但需处理“读不够4字节”的半包问题。
我们采用第三种,并封装成工具类:
public class LengthFieldBasedFrameDecoder { private final int lengthFieldOffset = 0; // 长度字段偏移量 private final int lengthFieldLength = 4; // 长度字段长度 private final int lengthAdjustment = 0; // 长度字段值是否包含自身 private final int initialBytesToStrip = 4; // 解码后跳过前4字节 public byte[] decode(InputStream in) throws IOException { // 先读4字节长度 byte[] lenBytes = new byte[4]; int read = in.read(lenBytes); if (read < 4) return null; // 半包,等待下次 int length = ByteBuffer.wrap(lenBytes).getInt(); // 再读length字节消息体 byte[] body = new byte[length]; read = in.read(body); if (read < length) return null; // 半包 return body; } }关键点:read()返回值必须严格校验!read()返回-1表示流结束,返回0表示无数据(非阻塞模式),返回正数才是读到的字节数。忽略返回值是粘包bug的根源。
3.5 缓冲区大小:不是越大越好,而是要匹配
Socket.setReceiveBufferSize()和setSendBufferSize()设置内核缓冲区大小。很多人设成64*1024(64KB)觉得“越大越快”。但实测发现,过大的接收缓冲区会导致read()一次读到过多数据,应用层处理不过来,反而增加延迟。我们的经验公式是:
接收缓冲区= 单次最大消息长度 × 2
(留一倍空间防突发)发送缓冲区= 网络带宽 × RTT(往返时延)
(即BDP,带宽时延积)
例如,内网RTT=0.5ms,带宽1Gbps,则BDP = 10^9 bit/s × 0.0005s ≈ 62.5KB。所以发送缓冲区设64KB合理。但若RTT是50ms(公网),BDP就变成6.25MB,设64KB就严重不足,导致write()频繁阻塞。
实操心得:用
ss -i命令查看真实缓冲区使用情况。ss -i sport = 8080会显示rwnd:65535(接收窗口)和ssthresh:21920(慢启动阈值),据此动态调整。
3.6 字符编码:String.getBytes()背后的雷区
socket.getOutputStream().write("hello".getBytes())——这行代码在中文环境下必炸。getBytes()用平台默认编码(Windows是GBK,Linux是UTF-8),如果两端编码不一致,数据就乱码。必须显式指定:
// 统一用UTF-8,写入前编码 String msg = "你好"; byte[] data = msg.getBytes(StandardCharsets.UTF_8); outputStream.write(data); // 读取后解码 byte[] buffer = new byte[1024]; int len = inputStream.read(buffer); String received = new String(buffer, 0, len, StandardCharsets.UTF_8);更进一步,协议头应包含编码标识。比如HTTP的Content-Type: text/plain; charset=utf-8。我们自定义协议时,在消息头第5字节存编码ID(1=GBK,2=UTF-8),解码前先读这个字节。
3.7 关闭资源:try-with-resources不是万能的
try (Socket socket = new Socket()) { ... }能自动close(),但close()本身可能抛IOException。如果close()失败(如网络已断),异常会吞掉,你根本不知道连接没关干净。更危险的是,Socket的close()会同时关闭InputStream和OutputStream,但如果你先调了inputStream.close(),再socket.close()就会抛SocketException: Socket is closed。
正确姿势是:只调一次socket.close(),且必须捕获其异常:
Socket socket = null; try { socket = new Socket(host, port); // ... 业务逻辑 } catch (IOException e) { log.error("连接失败", e); } finally { if (socket != null && !socket.isClosed()) { try { socket.close(); // 这里可能抛IOException } catch (IOException e) { log.warn("关闭socket失败", e); // 记录警告,不抛出 } } }3.8 多线程安全:Socket对象不是线程安全的
Socket的getInputStream()和getOutputStream()返回的对象,不是线程安全的。如果多个线程同时调用同一个OutputStream.write(),数据会交错,出现乱序。我们曾有个服务,用线程池处理消息,但忘了给每个线程分配独立的OutputStream引用,结果客户端收到的消息是“hel”、“lo wor”、“ld”三段乱序拼接。
解决方案只有两个:a) 每个线程用自己Socket(BIO模式);b) 用锁同步OutputStream(NIO模式下常用ReentrantLock)。我们选b,因为NIO线程复用率高:
private final ReentrantLock writeLock = new ReentrantLock(); public void sendMessage(byte[] data) { writeLock.lock(); try { outputStream.write(data); outputStream.flush(); } finally { writeLock.unlock(); } }3.9 心跳保活:SO_KEEPALIVE只是基础,业务心跳才是核心
SO_KEEPALIVE只能探测TCP连接是否物理存活,但无法判断应用层是否正常。比如服务进程卡在GC,TCP连接还在,KEEPALIVE探针能通,但业务请求已无响应。所以必须加应用层心跳。
我们的心跳协议很简单:客户端每30秒发PING(2字节),服务端立即回PONG(2字节)。服务端用ScheduledExecutorService每10秒扫描一次连接,如果某连接60秒没收到PING,则主动close()。关键点:心跳包必须用独立线程发送,不能和业务线程共用OutputStream,否则业务阻塞会导致心跳发不出。
3.10 异常分类:读懂IOException背后的真相
Socket相关异常不是笼统的IOException,要细看具体子类:
ConnectException:连接被拒(对端ServerSocket没启动,或防火墙拦截)SocketTimeoutException:soTimeout超时(读超时)SocketException: Connection reset:对端强制关闭(RST包)SocketException: Broken pipe:对端已关闭,你还往里write()IOException: No route to host:路由不可达(IP错误或网关故障)
我们用统一异常处理器,根据异常类型做不同动作:ConnectException立即重试;SocketTimeoutException降级返回默认值;Connection reset清理连接;Broken pipe记录并忽略(因为连接已失效)。
3.11 日志埋点:在关键路径加“黑匣子”
Socket问题最难调试,因为现象在客户端,根因在服务端。我们强制在四个位置加日志:
accept()成功后,记录客户端IP和端口;read()读到数据后,记录数据长度(不记内容,防敏感信息);write()发送后,记录发送长度;close()前,记录关闭原因(正常关闭/异常关闭/超时关闭)。
日志格式统一为JSON,方便ELK聚合分析。比如一条典型日志:
{"event":"read","client":"192.168.1.100:54321","length":128,"timestamp":"2023-10-01T08:23:45.123Z"}有了这个,当用户投诉“消息丢失”时,我们能快速定位是read没发生(连接根本没建立),还是read发生了但write没发生(服务端处理失败)。
3.12 性能压测:用wrk和tcpdump代替main方法
别用while(true) { new Thread(...).start(); }压测!这测的是JVM线程创建速度,不是Socket性能。正确方法:
- 工具:用
wrk(HTTP)或自定义netcat脚本(TCP)模拟并发连接; - 监控:
ss -s看memory和TCP:统计;vmstat 1看si/so(swap in/out);dmesg -T | tail看内核OOM日志; - 抓包:
tcpdump -i any port 8080 -w test.pcap,用Wireshark分析重传率、RTT、窗口大小。
我们压测时发现,当连接数到2万时,ss -s显示memory: allocated: 123456(单位页),而limit: 100000,说明内核内存耗尽。此时必须调大/proc/sys/net/core/wmem_max和rmem_max,否则连接数上不去。
4. 实操过程:从零搭建一个抗压10万连接的Echo服务器
4.1 环境准备与参数调优
先确认Linux内核参数,这是高并发的基础。在/etc/sysctl.conf中添加:
# 扩大连接队列 net.core.somaxconn = 65535 # 扩大TIME_WAIT连接复用 net.ipv4.tcp_tw_reuse = 1 # 缩短TIME_WAIT时间(谨慎!) net.ipv4.tcp_fin_timeout = 30 # 增大文件描述符限制(重要!) fs.file-max = 2097152 # 增大TCP缓冲区 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216执行sysctl -p生效。然后检查ulimit -n,确保大于10万。如果不够,修改/etc/security/limits.conf:
* soft nofile 1048576 * hard nofile 1048576重启终端生效。这些不是“玄学配置”,而是让内核能支撑10万连接的物理前提。没调这些,代码写得再好,也卡在65535。
4.2 NIO服务器骨架:Selector、Channel、Buffer三位一体
我们不用Netty,手写NIO核心,只为看清每一行代码的作用。主类EchoServer结构如下:
public class EchoServer { private final Selector selector; private final ByteBuffer readBuffer = ByteBuffer.allocateDirect(8192); private final ByteBuffer writeBuffer = ByteBuffer.allocateDirect(8192); public EchoServer(int port) throws IOException { // 1. 创建Selector this.selector = Selector.open(); // 2. 创建ServerSocketChannel ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(port)); // 3. 注册ACCEPT事件 serverChannel.register(selector, SelectionKey.OP_ACCEPT); } public void start() throws IOException { while (true) { // 阻塞等待事件,最多等1秒 int readyChannels = selector.select(1000); if (readyChannels == 0) continue; // 遍历就绪的key Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); keyIterator.remove(); // 必须手动移除! if (key.isAcceptable()) { handleAccept(key); } else if (key.isReadable()) { handleRead(key); } else if (key.isWritable()) { handleWrite(key); } } } } private void handleAccept(SelectionKey key) throws IOException { ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel(); SocketChannel clientChannel = serverChannel.accept(); clientChannel.configureBlocking(false); // 注册READ事件 clientChannel.register(selector, SelectionKey.OP_READ); log.info("新连接: {}", clientChannel.getRemoteAddress()); } private void handleRead(SelectionKey key) throws IOException { SocketChannel channel = (SocketChannel) key.channel(); readBuffer.clear(); int bytesRead = channel.read(readBuffer); if (bytesRead > 0) { readBuffer.flip(); byte[] data = new byte[readBuffer.remaining()]; readBuffer.get(data); // 回显:把读到的数据写回去 writeBuffer.clear(); writeBuffer.put(data); writeBuffer.flip(); channel.write(writeBuffer); } else if (bytesRead == -1) { // 对端关闭 channel.close(); log.info("连接关闭: {}", channel.getRemoteAddress()); } } private void handleWrite(SelectionKey key) throws IOException { // 当前示例简化,实际需处理write未完成的情况 key.interestOps(SelectionKey.OP_READ); } }关键点解析:
ByteBuffer.allocateDirect():用堆外内存,避免JVM GC影响IO性能。实测比allocate()快15%。keyIterator.remove():必须手动移除,否则下次select()还会返回这个key,导致重复处理。readBuffer.flip():切换读写模式,这是NIO的“门面”操作,不理解它,NIO永远学不会。channel.write(writeBuffer):write()可能只写部分数据(writeBuffer.hasRemaining()为true),需注册OP_WRITE事件,等下次就绪再写。本例简化,实际必须处理。
4.3 粘包处理实战:长度域解码器的完整实现
上面的Echo服务器只处理“整包”,但真实场景必须防粘包。我们扩展handleRead(),加入长度域解码:
// 为每个连接维护一个解码器实例 private final Map<SocketChannel, LengthDecoder> decoderMap = new ConcurrentHashMap<>(); private void handleRead(SelectionKey key) throws IOException { SocketChannel channel = (SocketChannel) key.channel(); readBuffer.clear(); int bytesRead = channel.read(readBuffer); if (bytesRead <= 0) return; readBuffer.flip(); LengthDecoder decoder = decoderMap.computeIfAbsent(channel, k -> new LengthDecoder()); List<byte[]> messages = decoder.decode(readBuffer); for (byte[] msg : messages) { // 处理消息:回显 writeBuffer.clear(); writeBuffer.put(msg); writeBuffer.flip(); channel.write(writeBuffer); } } // 长度域解码器 public class LengthDecoder { private final ByteBuffer headerBuffer = ByteBuffer.allocate(4); private int expectedLength = -1; public List<byte[]> decode(ByteBuffer buffer) { List<byte[]> messages = new ArrayList<>(); while (buffer.hasRemaining()) { if (expectedLength == -1) { // 还没读到长度头 if (buffer.remaining() >= 4) { buffer.get(headerBuffer.array(), 0, 4); headerBuffer.flip(); expectedLength = headerBuffer.getInt(); headerBuffer.clear(); } else { break; // 半包,等下次 } } else { // 已知长度,读消息体 if (buffer.remaining() >= expectedLength) { byte[] body = new byte[expectedLength]; buffer.get(body); messages.add(body); expectedLength = -1; // 重置 } else { break; // 半包 } } } return messages; } }这个解码器能完美处理粘包和半包。测试用例:发送[0,0,0,4,'h','e','l','l','o'](长度4,内容"hell"),再发[0,0,0,1,'o'](长度1,内容"o"),解码器会合并为"hello"。
4.4 压测与调优:从5000到10万连接的实录
用wrk压测(模拟HTTP,但我们的Echo服务是TCP,所以用tcpping和自定义脚本):
# 启动1000个连接,每个连接发100次echo for i in {1..1000}; do (echo "hello"; sleep 0.1; echo "world") | nc localhost 8080 & done初始配置(未调优):连接数卡在5000,ss -s显示memory: allocated: 100000,limit: 100000,说明内存耗尽。按4.1节调优内核参数后,连接数升到5万。但仍有少量连接超时。
用tcpdump抓包发现,大量SYN包发出后,没有SYN-ACK响应。查netstat -s | grep -i "listen",发现listen overflows计数飙升。原因是somaxconn虽设为65535,但/proc/sys/net/core/netdev_max_backlog(默认1000)限制了网卡队列。将其调为5000后,溢出消失。
最终,我们用100台机器(每台1000连接)压测,成功维持10万连接,ss -s显示TCP: inuse 100000,memory: allocated: 850000(单位页,约3.4GB),CPU使用率42%,服务稳定。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨的Bug
5.1 问题速查表:症状、根因、解决方案
| 症状 | 可能根因 | 解决方案 | 实测耗时 |
|---|---|---|---|
java.net.ConnectException: Connection refused | 对端ServerSocket未启动;端口被防火墙拦截;bind()地址错误(用了localhost) | telnet host port测试;检查iptables -L;确认bind()用0.0.0.0 | 5分钟 |
java.net.SocketTimeoutException: Read timed out | soTimeout设太小;网络延迟高;对端处理慢 | 调大soTimeout;用ping测RTT;检查对端服务日志 | 15分钟 |
java.io.IOException: Broken pipe | 对端已关闭连接,你还write();SO_LINGER=0时强制关闭 | 捕获此异常并close()本端;加isConnected()判断 | 10分钟 |
java.net.SocketException: Connection reset | 对端close()时发RST;对端进程崩溃 | 检查对端日志;加SO_KEEPALIVE提前发现 | 20分钟 |
连接数上不去,ss -s显示memory: limit接近 | 内核内存不足;file-max不够;ulimit -n太小 | 调大rmem_max/wmem_max;sysctl -w fs.file-max=2097152;ulimit -n 1048576 | 30分钟 |
read()返回0,但连接没断 | read()在非阻塞模式下,无数据时返回0(非错误) | 检查configureBlocking(false);改用Selector等待OP_READ | 5分钟 |
| 消息乱码,中文变问号 | String.getBytes()用默认编码;两端编码不一致 | 显式用StandardCharsets.UTF_8;协议头加编码标识 | 10分钟 |
5.2 独家避坑技巧:教科书里找不到的经验
技巧1:用
jstack看线程阻塞点
当服务卡住,jstack <pid>输出中找java.net.SocketInputStream.socketRead0,后面跟着的java.lang.Thread.State: RUNNABLE就是阻塞的线程。它卡在read(),说明soTimeout没设或设太大。技巧2:
tcpdump过滤特定连接
不要用tcpdump -i any port 8080抓全量包(太慢)。用tcpdump -i any 'tcp and (src host 192.168.1.100 or dst host 192.168.1.100)' -w debug.pcap,精准抓某客户端的包,Wireshark里用tcp.stream eq 5过滤同一流。技巧3:
strace跟踪系统调用
`strace -p -e trace=network