很多人学Java学到集合、IO、并发之后,突然就不知道该往哪个方向使力了。往前走吧,框架源码看不进去;往后退吧,感觉基础也就那样。我通常的建议是:去碰一下网络编程。这个方向能把线程、IO、异常处理、设计模式全部串起来,而且学完马上就能看到东西在跑,正反馈特别强。这篇文章就从一个最简单的Socket通信开始,一路折腾到能扛住并发请求的多线程服务器,把我自己踩过的坑和后来想明白的东西都写清楚。
先交代一下这篇文章适合谁看:Java语法已经过完一遍、知道线程怎么开但没写过完整网络程序的读者,以及那些背了一堆八股文却不知道怎么落地的人。我会把每一段代码为什么这么写、背后的TCP/IP原理是什么、线上会出什么问题都拆开讲,而不是只丢一个能跑的Demo。
1. 内容整体设计与思路拆解
1.1 为什么网络编程是Java进阶绕不开的一关
网络编程在整个Java技术栈里的位置很特殊。它不像集合框架那样天天用,也不像并发那样只在特定场景才露脸,但它是几乎所有后端技术的底座。你用的Tomcat、Netty、Dubbo,底层全是Socket那一套东西,只是披了一层又一层封装。如果你直接把Netty拿来用,遇到连接断了、消息粘包了、线程阻塞了这类问题,大概率一头雾水,因为你根本不知道下面发生了什么。
从面试的角度看,网络编程也是"八股文"重灾区。很多人能背出TCP三次握手、四次挥手的状态流转,但问他"服务端accept之后,客户端断开了,服务端怎么感知"就卡住了。这种问题背是背不出来的,必须亲手写过、调试过才有体感。
所以我给这篇文章定了一个递进式的路线:先理解Socket通信的基本模型,然后写一个能收发消息的单线程程序,再分析它的问题,接着引入多线程解决并发,最后用线程池做资源控制。每一步都对应了真实项目中会遇到的实际问题。
1.2 从Socket到多线程服务器的演进逻辑
很多教程一上来就扔一个多线程服务器的完整代码,新手看了只会复制粘贴,代码稍微变个样就废了。我坚持让读者先写一个"烂"版本——单线程、串行处理、性能极差。只有亲手写出来并压测过,才会明白为什么需要多线程,为什么需要线程池,为什么线程池参数不能随便填。
这个演进过程大概是这样的:
- 单线程服务器:同时只能服务一个客户端,其他连接全部排队等。
- 多线程服务器,每连接一线程:能同时服务多个客户端,但线程开销大,连接多了直接OOM。
- 线程池复用:限制线程数量,连接多了排队,避免资源耗尽。
第二步到第三步之间的思考特别重要。新版JDK引入了虚拟线程之后,第二步的问题被缓解了,但线程池的思路仍然值得学,因为在很多存量系统和中间件里,它还是主流方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 Socket通信的本质:两台机器之间的"电话线"
先把概念说清楚。Socket不是协议,它是操作系统提供的一个网络编程接口,是应用层和传输层之间的桥梁。你用Java写Socket程序,其实是在调用操作系统的能力,Java只是帮你封装了一套跨平台的API。
理解Socket的关键在于,把它想象成一条"电话线":
- 服务端先得装一部电话并开机(创建ServerSocket,绑定端口,监听)。
- 客户端拿起电话拨号(创建Socket,连接服务端的IP和端口)。
- 接通之后,双方通过听筒和话筒说话(InputStream读数据,OutputStream写数据)。
- 谁先挂电话(关闭连接),对方就会听到"嘟嘟嘟"(读到EOF或抛出异常)。
这里有个新手很容易懵的点:ServerSocket和Socket是两个不同的东西。ServerSocket只负责"等待电话进来",它自己不去传数据。一旦有客户端连接进来,accept()方法会返回一个新的Socket对象,这个才是真正用来和客户端聊天的通道。
用代码来表示就是这个样子:
java复制ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept(); // 阻塞在这里等客户端
// 这里是新连接,但通信要用socket,不是serverSocket
}
2.2 TCP三次握手和Socket API的对应关系
我面试别人的时候,特别喜欢问一个问题:你写Socket代码的时候,三次握手发生在哪一步?很多人会愣住,因为他们从来没把八股文和代码对应起来过。
答案是这样的:当客户端执行new Socket(host, port)的时候,三次握手就已经完成了。也就是说,TCP连接在Socket对象返回之前已经建立好了。你拿到的不是一个"正在连接"的Socket,而是一个已经连接成功的Socket。
至于serverSocket.accept(),它做的是从内核的已完成连接队列里取一个连接出来。内核在三次握手完成之后,会把连接放到一个队列里,accept()只是把这个队列里的连接取出来交给应用层处理。
理解了这个对应关系,很多问题就能想通了:
- 为什么accept()会阻塞?因为队列是空的,没有连接完成。
- 为什么连接很多的时候,accept()还是有延迟?因为队列有长度限制(backlog参数),超过之后内核会丢弃SYN包。
backlog这个参数值得多说一句。很多人的代码只写了new ServerSocket(8080),没指定backlog。在Java里,这个参数默认是50。如果你预期有大量并发连接涌入,建议显式指定,比如new ServerSocket(8080, 128)。在某些Linux内核版本里,backlog的实际生效值还受到net.core.somaxconn的系统参数限制,默认是4096或者更小,线上调优的时候要留意。
2.3 流的阻塞特性:网络编程最大的坑
Java的Socket通信用的是流,而流是阻塞的。这是新手写网络程序最容易踩坑的地方,也是理解整个多线程模型的关键。
一个典型的场景:客户端发来一句话,服务端用InputStream.read()去读。如果客户端发完这句话之后不关闭连接,read()会一直阻塞在
