1. 从一个端口占用报错说起Socket到底是什么前阵子有个朋友在群里贴了一张截图报错内容是error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address他一脸懵地问我这到底在说啥。其实这个报错翻译成人话就是——你想在一个已经被别人占用的端口上再开一个监听操作系统直接把你顶回来了。类似的还有 Windows 上那句经典的由于目标计算机积极拒绝无法连接。(10061)以及 Linux 下 MySQL 启动时那个directory /var/run/mysqld for unix socket file dont exists。这些看起来八竿子打不着的报错背后都指向同一个东西Socket。很多刚接触 Java 后端的人学完语法、集合、IO 之后一到网络编程这章就卡壳因为 Socket 这东西不像ArrayList那样能立刻看到效果它更像是一根看不见的线牵着两台机器上的两个程序。而这根线一旦理解透了你会发现面试里常问的 TCP 三次握手、BIO/NIO 区别、粘包拆包全都是围绕它展开的。这篇文章就是想把 Java Socket 通信从是什么到怎么跑起来再到踩了哪些坑完整讲一遍适合刚学网络编程的新手也适合准备面试时想补一块短板的朋友。我会把可运行的代码、参数计算、排错过程都摊开来讲你照着敲一遍基本就能掌握。先给 Socket 一个不绕弯的定义它是操作系统提供的一套网络通信编程接口是应用层和传输层之间的门。你可以把它想象成一个电话插座——两台机器要通话各自得先在这个插座上插好线然后才能收发数据。这个插座在操作系统里其实由五元组唯一标识源IP、源端口、目的IP、目的端口、协议。只要这五个值确定了一条通信链路就是唯一的。既然如此前面那个bind: only one usage of each socket address就好理解了——你想把同一个地址端口绑定两次等于要在同一个插座上插两根线系统当然不允许。而10061 目标计算机积极拒绝则是你拨号过去对面那个插座根本没插上线没有进程在监听握手的第一步就被拒了。1.1 从 TCP/IP 协议栈看 Socket 的位置要真正搞明白 Socket得把它放回协议栈里看一眼。TCP/IP 分四层应用层、传输层、网络层、链路层。HTTP、FTP、SMTP 这些都属于应用层它们自己并不能把数据发到网线上得往下交给传输层的 TCP 或 UDP。而应用层和传输层之间的那个接口就是 Socket。也就是说HTTP 请求最终也是通过 Socket 发出去的只不过浏览器和 HTTP 库帮你把这层封装了你平时感知不到。Java 的java.net包里Socket和ServerSocket分别对应客户端和服务端。ServerSocket负责在指定端口上监听就像前台接待Socket负责和某个具体连接对话像接待员把你领到的小会议室。很多人一开始分不清这两个类记住一句话就行ServerSocket 是用来等的Socket 是用来聊的。等有新连接进来accept()会返回一个全新的Socket之后所有读写都走这个Socket而不是ServerSocket。这个细节没搞清后面写多线程服务端的时候必然乱套。1.2 为什么学 Java 一定要过 Socket 这一关有人会问现在都 2025 年了谁还手写 Socket不都是 Spring Boot 一套注解搞定吗这话对一半。框架确实把 Socket 包得严严实实但一旦线上出现连接超时、连接池耗尽、半连接队列溢出这些问题你不懂底层 Socket就只能靠猜。比如面试里高频出现的TIME_WAIT 太多怎么办为什么要有三次握手不是两次全都是 Socket 层面的东西。再比如你调 Redis 客户端、Kafka 客户端底层走的都是 Socket 长连接理解它才能理解心跳、重连、粘包这些设计为什么要那样做。另外一个很现实的原因Java 面试里网络编程是硬考点尤其Java 面试八股文里那套 BIO/NIO/AIO 的对比几乎是必问。但光背结论很容易翻车面试官追一句你说的多路复用到底是怎么复用的就露馅了。所以我的建议一直是先动手写一遍最原始的 Socket 通信再去看 NIO、Netty顺序反了会很难受。下面我们就从最朴素的版本开始一步步往上加东西。2. Socket 通信的核心机制拆解在动手写代码之前有几块机制必须提前说清楚不然代码能跑但不知道为什么这么写遇到问题也不会查。2.1 三次握手与 Socket 连接的建立过程当你调用new Socket(127.0.0.1, 8080)的时候底层其实发生了一连串动作。首先是 TCP 的三次握手客户端发一个 SYN 包说我想连你服务端回一个 SYNACK 说收到我准备好了客户端再回一个 ACK 说好开始吧。这三步走完连接才算真正建立。Java 里new Socket(...)这个构造方法如果连不上会直接抛ConnectException它内部其实就是在等你这个握手完成而且是带超时的——默认超时时间跟你操作系统设置有关所以生产代码里一定要显式设connect(SocketAddress, timeout)别用无参版本否则网络一抖线程就卡死在那儿。握手完成后服务端的accept()才会返回。这里有个容易忽略的点三次握手是内核帮你完成的跟你 Java 代码没关系。也就是说哪怕你的服务端线程池满了、还没来得及accept()内核也会先把握手完成把这些连接放进一个叫全连接队列的地方排队。这个队列长度由ServerSocket的 backlog 参数决定默认 50老版本队列满了之后新连接就会被丢弃或拒绝。线上偶发的连接超时十有八九跟 backlog 设置太小、或者 accept 处理太慢有关。这也是为什么高并发服务端要么用 NIO要么把 accept 线程和业务线程彻底分开。2.2 全双工为什么 Socket 能同时收发Socket 建立之后你会发现它既有一个getInputStream()又有一个getOutputStream()两者互不干扰这就是全双工。生活里的类比是打电话你可以边听边说不用等对方说完。技术上是因为 TCP 连接的两端各自维护了发送缓冲区和接收缓冲区数据在两条独立的管道里流动。这里有个特别实用的技巧如果要读写同时进行一定要把读操作放到独立线程里因为read()是阻塞的——只要没有数据到达它会一直停在那儿把主线程活活堵死。这一点是新手最常踩的坑下一篇讲多线程改造时我会细说。还有个小细节值得注意OutputStream的write()只是把数据写进了操作系统缓冲区并不代表对方收到了。想确保真的发出去可以调用flush()但严格意义上 flush 也只是把 Java 层缓冲刷进内核真正到达对方还得靠 TCP 自己的确认机制。所以做可靠通信时通常在应用层设计一个应答协议比如客户端发完请求等服务端回一个 ACK 才认为成功而不是依赖 flush。2.3 BIO、NIO、AIO 的本质区别这三个词面试必问我用一句话给你串起来它们区别的本质是谁来等数据。BIOBlocking IO一线程一连接read()没数据就阻塞那个线程。连接数一多线程数爆炸每个线程还占着栈内存默认 1MB可调几千连接就把内存吃满。NIONon-blocking IO一个线程用 Selector 轮询多个 Channel谁有数据就处理谁。这就是常说的IO 多路复用内核帮忙盯着所有连接有事件了通知你。Netty 就是基于它封装的。AIOAsynchronous IO你发起读请求直接返回内核读完了回调你。听着最香但 Linux 上底层实现io_uring 之前的 epoll其实没那么成熟所以实际生产用得少。有个流传很广的说法是NIO 比 BIO 快这其实不准确。对于连接数少、请求量大的场景BIO 反而更快因为没有 Selector 轮询的开销。NIO 真正的优势是用少量线程撑住海量连接。所以选型要看场景不是无脑上 NIO。理解了这层你再看面试题什么场景用 BIO 什么场景用 NIO就能答到点子上。3. Java 实现 Socket 通信的完整实操理论说够了开始上代码。我会从最简单的一问一答版本开始逐步演进到能处理多连接的服务端每一步都说明为什么这么改。3.1 环境准备与最小可用示例环境没什么特别的JDK 8 以上都行一个能敲代码的 IDEjava.net是标准库不用引任何依赖。这种零依赖正是 Socket 适合练手的原因——你能看清每一行在干什么不用猜框架替你做了啥。先在脑子里定好协议客户端发一行字符串服务端转成大写后回给客户端客户端收到就断开。这是最经典的 Echo 改造版简单到能一眼看懂但已经包含了 Socket 通信的全部要素。服务端代码长这样import java.io.*; import java.net.*; public class SimpleServer { public static void main(String[] args) throws IOException { // 绑定 8080 端口backlog 设为 50 ServerSocket serverSocket new ServerSocket(8080, 50); System.out.println(服务端启动监听 8080...); while (true) { // 阻塞等待连接有连接进来才返回 Socket socket serverSocket.accept(); System.out.println(收到来自 socket.getRemoteSocketAddress() 的连接); // 用 try-with-resources 确保流会被关闭 try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true)) { String line in.readLine(); System.out.println(收到: line); out.println(line.toUpperCase()); // 转大写回发 } socket.close(); } } }这里有几个点要展开说。第一new ServerSocket(8080, 50)的第二个参数是 backlog就是前面说的全连接队列长度。第二try-with-resources包着输入输出流但socket本身没包进去因为流关了 socket 其实基本也废了不过严格来说还是应该显式 close 一下我这里为了演示清楚分开写了。第三PrintWriter的第二个参数true表示自动 flush不加这个println的内容可能还趴在缓冲区里没发出去客户端就一直readLine()阻塞着——这是新手非常容易踩的坑我当年就因为漏了这个true调了半小时以为连接有问题。客户端代码import java.io.*; import java.net.*; public class SimpleClient { public static void main(String[] args) throws IOException { // 显式设置 3 秒连接超时别用无参构造 Socket socket new Socket(); socket.connect(new InetSocketAddress(127.0.0.1, 8080), 3000); try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true)) { out.println(hello socket); String response in.readLine(); System.out.println(服务端返回: response); } socket.close(); } }先启动服务端再跑客户端你会看到服务端打印出收到的连接和消息客户端打印出HELLO SOCKET。到这一步一条完整的 TCP 通信链路就跑通了。别看简单后面所有复杂的东西都是在这个骨架上长出来的。3.2 服务端的关键细节端口、backlog 与超时服务端看起来就几行但每个参数背后都有讲究。先说端口选择1024 以下大多是系统保留端口普通程序绑不了Linux 下非 root 也无权所以练习用 1024 以上随便挑但要注意避开常见服务端口比如 MySQL 的 3306、Redis 的 6379。前面 11434 那个报错多半就是某个服务已经在监听这个端口了。想查端口占用Linux 下用lsof -i:11434或者netstat -tlnp | grep 11434Windows 下用netstat -ano | findstr 11434再配合tasklist找到进程杀掉就行。这里要专门聊聊端口被占用后为什么不能立刻重用。你可能会遇到一种情况程序明明关了重启却报Address already in use。这是因为 TCP 连接关闭后主动关闭方会进入TIME_WAIT状态默认等 2MSL通常 60 秒左右才真正释放端口。这是为了保证最后一个 ACK 能被对方收到是 TCP 的可靠性设计不是 bug。想快速重启可以用serverSocket.setReuseAddress(true)但注意这招在生产环境要慎用官方文档也提醒它可能带来安全风险只在开发调试时图方便用用。超时设置同样关键。ServerSocket的accept()默认无限阻塞如果你希望它隔一段时间能回头干点别的事比如打印心跳、检查关闭标志可以调serverSocket.setSoTimeout(5000)这样超时会抛SocketTimeoutException你 catch 住继续循环就行。而对于客户端Socket读操作也有setSoTimeout()不设置的话服务端要是挂了不响应客户端线程会永远卡在readLine()上这在分布式系统里是灾难性的。所以我的习惯是只要涉及网络 IO超时一定显式设置哪怕设得长一点。3.3 从单连接到并发多线程改造上面那个服务端有个致命问题它一次只能服务一个客户端。为什么因为accept()、读、写、关闭全在一个线程里顺序执行。第一个客户端连上后服务端读它的消息、回它、关掉才会回头处理第二个。如果第一个客户端连上后一直不发数据服务端就卡在readLine()上后面所有客户端全部排队等着体验跟死机一样。这就是典型的 BIO 瓶颈。改造思路很直接每个连接开一个线程处理。import java.io.*; import java.net.*; public class ThreadedServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(多线程服务端启动...); while (true) { final Socket socket serverSocket.accept(); // 每个连接交给独立线程 new Thread(() - handle(socket)).start(); } } private static void handle(Socket socket) { String remote socket.getRemoteSocketAddress().toString(); try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true)) { String line; // 循环读直到对方关闭连接读返回 null while ((line in.readLine()) ! null) { System.out.println(remote 说: line); out.println(line.toUpperCase()); } } catch (IOException e) { System.out.println(remote 异常断开: e.getMessage()); } finally { try { socket.close(); } catch (IOException ignored) {} } } }注意这里我把读改成了while ((line in.readLine()) ! null)这样连接可以一直保持实现真正的会话而不是一问一答就断。这个循环退出的条件是readLine()返回null它发生在对方关闭了连接的写端时。为什么要用final Socket socket因为在 Java 8 的匿名内部类/lambda 里访问局部变量必须是 effectively final不加 final 编译不过。但每个连接开一个线程这个方案撑个几十上百连接还行上千连接就直接爆炸了。因为线程本身很重创建销毁有开销栈内存也占地方。所以生产里的正确做法是用线程池固定几十个工作线程连接来了丢进队列谁空闲谁处理ExecutorService pool Executors.newFixedThreadPool(50); while (true) { final Socket socket serverSocket.accept(); pool.submit(() - handle(socket)); }线程池版比裸线程稳多了但依然是 BIO 模型连接数受线程数限制。真要做到 C10K万级连接得换 NIO。这也是为什么大厂通信框架几乎都是 Netty——它把 NIO 的复杂度藏起来了。不过对大多数人来说线程池 BIO 处理几百连接绰绰有余别过度设计。3.4 双方回环测试与抓包验证代码跑通了不代表真的对。我强烈建议你养成用抓包验证的习惯。最轻量的方式是用telnet或nc冒充一个客户端比如nc 127.0.0.1 8080然后手动敲一行字回车看看服务端有没有反应。这能帮你快速判断问题出在客户端还是服务端。更专业的可以用 Wireshark 抓tcp.port 8080你能亲眼看到三次握手的 SYN、SYN-ACK、ACK以及数据包和 FIN 挥手过程。我第一次抓包看到三次握手的时候那些背了无数遍的八股文才真正活了过来。还有个测试技巧是用Socket的setSoTimeout()配合压力脚本模拟大量短连接快速建立断开观察服务端有没有出现大量TIME_WAIT、有没有连接被拒。这些现象对应的排查方法下一节详细说。4. 常见报错与排查技巧实录Socket 编程的坑大多集中在连接建立和数据读写两个阶段我把平时遇到最多的几类整理出来配排查思路遇到问题可以当速查表用。4.1 三类高频报错的原因与解法报错信息根本原因排查与解决Address already in use/only one usage of each socket address端口已被占用或处于 TIME_WAIT用lsof -i:端口找进程开发期可setReuseAddress(true)Connection refused (10061)/目标计算机积极拒绝目标端口没有进程监听或防火墙拦截确认服务端已启动并监听正确 IP检查netstat与防火墙SocketTimeoutException: Read timed out读超时对方长时间不响应检查对方是否卡死排查网络合理设置setSoTimeoutConnection reset by peer对方强制关闭连接进程崩溃或 RST检查对方日志确认是否有半关闭场景未处理Connection refused这个尤其值得展开。它有两种常见成因一是服务端根本没起来二是起来了但绑定的 IP 不对。注意new ServerSocket(8080)默认绑的是0.0.0.0表示监听所有网卡但如果你写成了new ServerSocket(8080, 50, InetAddress.getByName(127.0.0.1))那它就只听本地回环外部机器连过来必然被拒。线上环境一定要确认绑定地址这也是很多本地能连、远程连不上问题的根源。至于Connection reset通常发生在服务端 close 之后客户端还继续写数据的时候因为连接已经废了内核只能回一个 RST 包客户端就收到这个错误。处理办法是设计协议时约定好关闭流程别一方说关就关。4.2 粘包拆包为什么你发的两条消息变成了一条这是 Socket 编程最经典也最容易懵的问题。假设客户端连续write了两次ABC和DEF服务端read一次可能拿到ABCDEF也可能第一次只拿到AB第二次拿到CDEF。TCP 是面向字节流的它只保证字节顺序不保证消息边界这是很多人误以为 TCP 会保留消息边界导致的困惑。解决粘包拆包主流有三种方案。第一种是固定长度每条消息定长不够补空格简单但浪费带宽。第二种是分隔符比如约定以\n结尾用readLine()就能天然处理——这也是我前面代码里默认用readLine的原因之一。第三种是长度字段先发 4 个字节表示消息体长度再发消息体这是最通用也最可靠的方式Netty 的LengthFieldBasedFrameDecoder就是干这个的。我一般推荐新手从分隔符起步理解了再上长度字段。// 长度字段方案先写 4 字节长度再写内容 DataOutputStream dos new DataOutputStream(socket.getOutputStream()); byte[] body hello.getBytes(StandardCharsets.UTF_8); dos.writeInt(body.length); dos.write(body); dos.flush(); // 读取端 DataInputStream dis new DataInputStream(socket.getInputStream()); int len dis.readInt(); byte[] buf new byte[len]; dis.readFully(buf); // readFully 保证读满不用自己循环 String msg new String(buf, StandardCharsets.UTF_8);注意这里的readFully它能保证把指定长度的字节读满比自己写while循环省心多了。别小看这点手写循环时如果漏了长度累加就会出现读了一半就开始解析的诡异 bug。4.3 编码问题与流包装顺序还有一个隐蔽的坑是字符编码。new InputStreamReader(socket.getInputStream())如果不指定编码会用平台的默认编码Windows 上可能是 GBKLinux 上一般是 UTF-8两边不一致就直接乱码。所以正确写法永远是new InputStreamReader(in, StandardCharsets.UTF_8)。这个坑在跨平台调试时特别折磨人本地好好的一上服务器全是问号。我在实际项目中统一约定所有 Socket 通信一律 UTF-8写进团队规范里省了很多事。流的包装顺序也有讲究。一般是Socket → InputStream → InputStreamReader → BufferedReader一层套一层。有人问能不能直接用InputStream读可以但读中文时按字节读会很痛苦BufferedReader的readLine()能帮你按行处理轻松很多。但要注意readLine()是按\n或\r\n分割的如果你的协议里消息本身包含换行符就会出问题那就得回到长度字段方案。提示调试 Socket 问题时先在两端加上详细的日志把每次收发的字节数和内容都打出来。很多玄学问题一打日志就现原形。5. 从 BIO 到 NIO一次真实的性能演进前面说了那么多 BIO是因为它好懂、好写、够用。但当连接数真的上去BIO 就顶不住了这时候 NIO 该登场了。这一节我不打算把 NIO 讲透那够写一本书而是想让你看清它为什么能扛住更多连接。5.1 NIO 三大件Channel、Buffer、SelectorNIO 的核心是三样东西。Channel可以理解成双向的管道既能读也能写取代了 BIO 里分开的 InputStream/OutputStream。Buffer是数据的中转站读和写都先落到 Buffer 上。Selector是关键一个线程可以把成百上千个 Channel 注册到它上面然后调select()阻塞等待哪个 Channel 有事件可读、可写、连接就绪就返回哪个你只处理有事件的没事件的不用管。这就是多路复用——一个线程复用去照看多个连接。用生活类比BIO 是前台接待员一对一盯着每个客人客人不说话他就干等NIO 是一个接待员拿一个大屏幕上面显示所有客人的状态灯谁亮灯处理谁效率天差地别。这就是为什么 Netty 能用很少的线程扛几万连接。5.2 BIO 与 NIO 的代码对照同样是读一行回一行NIO 的服务端大概长这样Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); // 关键非阻塞 serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞等待事件 IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须手动移除否则重复处理 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 n client.read(buffer); if (n -1) { client.close(); continue; } buffer.flip(); // 处理 buffer 里的数据... } } }看到没代码复杂度一下就上来了而且这里还省略了拆包、写回、缓冲区管理的部分——真写起来要几百行。这就是为什么实际项目不建议手写 NIO直接用 Netty。但你得先看懂这段再去理解 Netty 的 EventLoop 和 Pipeline 才会顺畅。面试里问Netty 为什么快你能从 Selector 多路复用、零拷贝、无锁化设计答起比背结论有说服力得多。需要提醒的是NIO 里read返回0不代表对方关闭只代表暂时没数据返回-1才是对方关了连接。这个和 BIO 里readLine返回null是一个意思别搞混。另外selector.selectedKeys()处理完一定要remove()不然同一个事件会被反复触发这是 NIO 新手最容易犯的错代码看着对就是疯狂重复处理。5.3 什么场景该上 NIO我的经验是给个简单的判断标准如果你的连接数经常超过三五百或者有明显的大量空闲长连接就考虑 NIO 或者依赖 Netty 的框架。比如即时通讯、推送服务、游戏服务器这些都是典型的 NIO 场景。反过来如果就是内部几个服务互相调用、连接数很少BIO 加线程池完全够用上 NIO 纯属给自己找麻烦。技术选型永远看场景不是越新越高级就越好。6. 生产环境里的几条血泪经验折腾了这么多年 Socket有些教训是文档里不会写的我挑几条最实在的分享一下。第一条永远不要相信连接一定成功。网络是不可靠的任何一次write都可能因为对方挂了而失败任何一次read都可能因为网络抖动而超时。所以任何 Socket 相关代码都必须在 try-catch-finally 里保证异常时连接被关掉。我见过太多因为异常路径没 close 导致连接泄漏、最后把连接池耗尽的案例。第二条心跳机制不是可选项。长连接如果长时间没数据中间的路由器、防火墙可能会悄悄把连接断掉但你的程序还以为连着。解决办法是定期发心跳包比如每 30 秒发一次超过几次没收到回应就重连。这就是为什么 Redis 客户端、Kafka 客户端都有 heartbeat 配置本质都是在跟这个问题作斗争。第三条关闭连接要么关到底要么约定好谁关。TCP 有个半关闭状态一方关了写还能读如果两个程序对关闭时机理解不一致就容易出现一方以为断了、另一方还在写结果收到Connection reset。简洁的做法是约定客户端主动关服务端读返回 -1 时清理资源。这个约定写进协议文档能省无数扯皮。第四条多线程环境下共享输出的 Socket 一定要加锁。如果一个 Socket 被多个线程同时write消息可能会交错在一起变成一锅粥。要么每个线程各自用独立连接要么对写操作加同步。这条我在做即时通讯的时候踩过两个线程同时发消息客户端收到的内容拼接错位排查了一整天才定位到。最后分享一个排查小技巧遇到连接相关的疑难杂症先别急着改代码用ss -antp | grep 端口或者netstat -ano看看连接状态分布。如果看到一大堆CLOSE_WAIT说明你程序没正确关闭连接一大堆TIME_WAIT说明短连接太频繁SYN_RECV堆积可能是半连接队列满了。看懂这些状态比盲改代码高效一百倍。这个内容后续还能往深了扩展比如结合 Netty 讲完整的编解码与心跳实现或者讲讲 HTTP 协议是怎么架在 Socket 之上的有机会再单独开一篇细聊。