
做Java网络编程这么多年我见过太多人一上来就撸Socket、ServerSocket跑通一个hello world就觉得自己会了。结果一接触真实项目各种诡异问题全冒出来域名解析慢、服务端同时只能服务一个客户端、高并发下线程爆炸、连接数一多CPU就飙到100%。这些问题追根溯源都会回到两个基础点上一是对java.net包里最底层的InetAddress类理解不够二是对多线程网络编程的模型选择没有想清楚。这篇文章我就围绕InetAddress类和多线程网络编程这两条主线把我在实际项目中遇到过的问题、排查思路、最终方案都捋一遍。内容覆盖IP解析底层逻辑、单线程阻塞模型为什么撑不住并发、线程池优雅改造、共享数据与资源释放的经典坑以及一套可以直接参考的压测验证方法。适合刚学完Java基础、准备深入网络编程的读者也适合写过一点Socket但总觉得不扎实的人。1. InetAddress类比你想的重要拿到IP只是第一步很多人对InetAddress的印象停留在“能拿本机IP、能解析域名”这个层面。说实话如果只是写demo这些功能确实够用了。但一旦进入真实环境你很快就会遇到几个问题为什么getHostName()返回的不是我想要的主机名为什么getLocalHost()拿到的IP和同事看到的不一样为什么生产环境域名解析有时候要好几秒这些问题的答案全都藏在InetAddress类的实现细节里。1.1 主机名解析成IP的底层逻辑InetAddress是Java对IP地址的抽象不管IPv4还是IPv6最终都通过它来表示。最常见的用法是InetAddress.getByName(String host)传入主机名、域名或IP字符串。InetAddress address InetAddress.getByName(localhost); System.out.println(IP Address: address.getHostAddress()); System.out.println(Host Name: address.getHostName());这里有个容易被忽略的点getByName传入的参数本身就已经是IP字符串时Java不会走DNS解析直接构造一个地址对象传入主机名或域名时才会触发系统底层的解析流程也就是查本机hosts文件、DNS服务器等一系列操作。还有getHostName()这个方法它默认会做一次反向解析也就是把IP转换回主机名。在某些网络环境里反向DNS记录不存在或者解析很慢这个方法可能会阻塞很长时间。我建议你在业务代码里谨慎调用getHostName()能不用就不用否则一个看似无关紧要的打印日志操作可能会变成线上接口超时的元凶。如果想要控制JVM层面的DNS缓存策略可以通过JVM启动参数设置-Dsun.net.inetaddr.ttl30 -Dnetworkaddress.cache.ttl30这是网上讨论比较多的一种做法。设置之后应用对域名解析结果的缓存时间会被限制住避免因为DNS记录变更而长时间拿到旧IP。我个人的习惯是在需要频繁解析域名、又希望解析结果能及时更新的场景下把TTL设置成30到60秒就够用。1.2 字节数组与“看到的IP”之间的换算InetAddress内部其实不存字符串而是存字节数组。IPv4是4字节IPv6是16字节。getAddress()这个方法返回的正是这个字节数组。举个例子IP地址192.168.1.100对应的字节数组是byte[] bytes new byte[]{(byte) 192, (byte) 168, 1, 100}; InetAddress address InetAddress.getByAddress(bytes); System.out.println(address.getHostAddress());这里有个Java特有的坑byte是有符号类型范围是-128到127。所以IP段里超过127的数字比如192必须强转成(byte)192否则编译都不通过。我第一次写这段代码的时候也懵了一下后来才反应过来是符号位的问题。相反方向如果你想判断一台主机的IP是哪个网段也需要操作字节数组而不是直接拿getHostAddress()返回的字符串去做前缀匹配。字符串匹配看着简单但遇到IPv6的压缩写法比如fe80::1时很容易出错而字节数组判断永远可靠。InetAddress还有一个很容易被忽略的隐藏实现类Inet4Address和Inet6Address。写代码时不需要特意区分它们但你要知道某些极端环境下如果只处理IPv4的逻辑遇到IPv6地址就会出问题。比如限制只允许内网IP访问判断逻辑里最好两个协议栈都覆盖到。1.3 isReachable与本地地址判断的实战姿势isReachable(int timeout)方法可以用来测试一个IP是否可达网络上很多地方叫它“Java版ping”。InetAddress address InetAddress.getByName(192.168.1.100); boolean reachable address.isReachable(3000); System.out.println(reachable: reachable);但这个方法的行为和系统ping命令不完全一样。在有权限的情况下它可能尝试发ICMP回显请求没有权限时它会退化成TCP连接到端口7echo端口或者其他端口。很多网络环境根本不开放echo端口所以这个方法返回false并不代表主机真的不通只能说明“从当前网络环境访问不到”。在实际项目里我更喜欢用判断地址类型的几个方法address.isLoopbackAddress(); address.isSiteLocalAddress(); address.isAnyLocalAddress();isLoopbackAddress()判断是不是回环地址127.0.0.1或::1isSiteLocalAddress()判断是不是内网私有地址比如192.168.x.x、10.x.x.xisAnyLocalAddress()判断是不是通配地址0.0.0.0。在做内网白名单、本机识别、网络环境判断时这几个方法非常实用。还有一类问题网上经常有人问InetAddress.getLocalHost()获取到的IP不是自己想要的网卡IP。比如机器有多个网卡、有虚拟网卡、容器环境返回结果可能是docker网桥IP或者虚拟网卡IP导致外网完全连不通。这是因为getLocalHost()返回的是系统主机名对应的解析结果并不保证是业务网卡的IP。遇到这种情况更稳妥的做法是遍历本机所有网卡筛选出符合条件的那一个网卡IP。EnumerationNetworkInterface interfaces NetworkInterface.getNetworkInterfaces(); while (interfaces.hasMoreElements()) { NetworkInterface networkInterface interfaces.nextElement(); if (networkInterface.isLoopback() || !networkInterface.isUp()) { continue; } EnumerationInetAddress addresses networkInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress addr addresses.nextElement(); if (addr instanceof Inet4Address) { System.out.println(Candidate IP: addr.getHostAddress()); } } }这段代码在容器、多网卡机器上尤其好用。我在某次解决跨网段服务注册问题时就是靠这种方式从一堆虚拟网卡里捞出真正的业务IP才让服务注册中心拿到了正确的地址。2. 单线程网络服务为什么撑不过第二个客户端理解了InetAddress之后下一个核心话题就是服务端模型。服务端只有一个线程的情况看起来像个玩具但很多人确实是从它开始写起然后才意识到并发问题的。我先把这个模型的瓶颈讲透后面引入多线程时你才能理解每一步改造的动机。2.1 一个最简单的回显服务器先看一段最原始的服务端代码。它做的事情很简单监听8080端口每收到一行文本就原样返回。import java.io.*; import java.net.*; public class SingleThreadEchoServer { public static void main(String[] args) throws Exception { ServerSocket server new ServerSocket(8080); System.out.println(Echo server started on port 8080); while (true) { Socket socket server.accept(); System.out.println(Client connected: socket.getInetAddress()); BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter( socket.getOutputStream(), true); String line; while ((line reader.readLine()) ! null) { writer.println(line); if (bye.equalsIgnoreCase(line)) { break; } } writer.close(); reader.close(); socket.close(); } } }这段代码逻辑非常直白但问题也极其致命。2.2 阻塞IO的真相调用线程被卡死上面代码里有两个阻塞点第一个是server.accept()它一直等着新连接进来第二个是reader.readLine()它一直等着客户端发数据。这两个阻塞点共享同一条线程。什么意思呢当第一个客户端连上来之后主线程就停在了readLine()上等待这个客户端发消息。在它断开连接之前accept()根本没有机会执行而后续所有新连接都会在操作系统底层排队连accept()这一步都走不到。我把这个模型比作只有一个窗口的银行第一个客户站在窗口前不管他是在聊天还是在填单子后面排队的人只能干等。窗口前不空出来后面的人连站到窗口前的机会都没有。2.3 多客户端排队场景复现你不信的话可以亲手复现一下。编译启动上面的服务器再开两个终端窗口telnet 127.0.0.1 8080第一个终端连上以后随便输入一行文字服务端正常返回。然后再打开第二个终端连接你会发现连接确实建立了TCP握手已经完成但无论输什么内容都没有回应。原因就是第二个连接对应的socket对象还没有被accept()取出来它的数据根本没有被服务端处理。只有在第一个终端输入bye断开连接后第二个连接才被主线程接住开始正常交互。这是单线程阻塞IO模型最典型的瓶颈并发连接数一多后面的连接全部积压。积压队列有上限一旦超过操作系统默认的backlog值新连接就会被直接拒绝客户端表现就是“Connection refused”。提示不要小看这个问题。很多新手第一次写Socket服务端第一版都是这个模型测试时只拿一个客户端连看起来一切正常一上并发就露馅。3. 多线程网络编程的完整套路用线程池改造ServerSocket既然单线程卡在“一个连接占一个线程”最直接的想法就是让每个连接独立占用一个线程。但“每个连接一个线程”和“每个连接复用池里的一个线程”是两回事。先把这两个概念的差别讲清楚再上完整代码你就知道为什么线程池是更合理的方案。3.1 每个连接一条线程这个方案为什么走不远最简单粗暴的多线程方案长这样while (true) { Socket socket server.accept(); new Thread(() - handleSocket(socket)).start(); }这个方案的优点是代码改动极小每个连接都会在独立线程里运行互不阻塞。但缺点是**连接数等于线程数但线程数不能无限大。**线程过多时CPU时间片都在线程上下文切换上耗掉了真正执行业务逻辑的时间占比反而下降。另外每来一个连接就创建一个线程创建和销毁本身也要开销在高并发、短连接场景下这部分开销甚至可能超过业务处理本身的成本。上次碰到一个项目线上压测并发500连接系统直接卡死jstack一看几百个线程全堆在Socket读写上线程栈密密麻麻。这就是典型的线程爆炸。3.2 ExecutorService加ServerSocket的组合实践正确做法是引入线程池一个固定数量的线程池不断从队列里取任务每个连接被包装成一个任务提交到队列中。核心线程处理不过来时任务先排队队列也满了再按最大线程数扩容实在容不下触发拒绝策略。完整代码我直接贴出来import java.io.*; import java.net.*; import java.util.concurrent.*; public class ThreadPoolEchoServer { private static final int PORT 8080; public static void main(String[] args) throws IOException { ExecutorService pool new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() ); try (ServerSocket server new ServerSocket(PORT)) { System.out.println(ThreadPool echo server started on port PORT); while (true) { Socket socket; try { socket server.accept(); } catch (IOException e) { // 单个连接异常不影响accept主循环 continue; } try { pool.execute(() - handleSocket(socket)); } catch (RejectedExecutionException e) { closeQuietly(socket); } } } } private static void handleSocket(Socket socket) { System.out.println(Handle client: socket.getInetAddress() , thread: Thread.currentThread().getName()); try ( BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter( socket.getOutputStream(), true) ) { String line; while ((line reader.readLine()) ! null) { writer.println(line); if (bye.equalsIgnoreCase(line)) { break; } } } catch (IOException e) { // 客户端异常断开timeout、connection reset等 Thread.currentThread().interrupt(); } finally { closeQuietly(socket); } } private static void closeQuietly(Socket socket) { if (socket ! null) { try { socket.close(); } catch (IOException ignored) { } } } }这套代码的关键点有两个一是accept()主循环不要因为处理任务的异常而挂掉。提交任务时如果线程池拒绝要捕获RejectedExecutionException并把Socket关闭避免资源泄漏。二是每个任务处理完必须在finally里关掉Socket。之前见过有人把close写在try块末尾一旦业务逻辑抛出异常连接就永远不释放直到端口被占满。3.3 线程池的五大参数到底怎么定很多文章都提过线程池参数但落到具体网络服务场景时参数取值经常让人纠结。我直接给出经验参考值corePoolSize核心线程数CPU密集型任务建议设置为CPU核数1IO密集型任务可以设置为CPU核数×21。Socket读写属于典型的IO密集型参考值就是2×N1左右N为物理核数。maximumPoolSize最大线程数一般不要设计得太大过大的最大线程数意味着线程池可以无限膨胀。建议控制在核心线程数的2到4倍以内。keepAliveTime空闲存活时间非核心线程空闲多久后被回收。网络请求池建议设置为60秒太短会导致线程频繁创建销毁。workQueue等待队列ArrayBlockingQueue和LinkedBlockingQueue都可用。不建议用无界队列无界队列会隐藏高并发压力让请求无限堆积内存迟早爆掉。100到1000是比较常见的范围视业务定。RejectedExecutionException拒绝处理默认的AbortPolicy是直接抛异常对网络服务不友好。CallerRunsPolicy更适合接入类服务因为它会让提交任务的线程自己执行任务起到自然限流作用。下面这个表格总结了常见的参数选择跟适用场景方便你对照参数默认值参考设置理由corePoolSize0CPU核数×21Socket读写等待多多给几个核心线程有好处maximumPoolSizeInteger.MAX_VALUE核心线程数的2倍以内防止线程无限膨胀keepAliveTime60s60s兼顾线程复用与空闲回收workQueueLinkedBlockingQueueArrayBlockingQueue(100~1000)有界队列避免内存堆积RejectedExecutionHandlerAbortPolicyCallerRunsPolicy天然限流不轻易丢请求3.4 别急着上NIO阻塞IO加线程池仍是首选看到这里可能有读者会问为什么不直接用NIONIO的Selector模型不是能支撑更高并发吗我的看法是如果你的目标是先把一个网络服务稳定跑起来面对几百上千的并发连接阻塞IO加线程池是最稳妥、最容易维护、也最容易排查问题的方案。NIO的编程模型复杂度高异常处理多调试成本大。线程池方案里的线程栈、阻塞等待、队列排队都是可以直接观测的出了性能问题也很好定位。等你的连接数真的到万级或者需要支撑海量长连接时再考虑引入NIO框架也不迟。这个演进路径我后面还会细说但现阶段先把线程池版本的网络编程吃透收益是最大的。4. 并发场景绕不开的两个坑共享数据与资源释放线程池改造完之后服务端能同时处理多个连接了但并发带来的新问题也开始浮现。最常见的就是共享数据不一致和资源释放不彻底。这两个坑在单线程模型里完全不存在一旦多线程化几乎是必踩。4.1 一个计数器的自增为什么都不准我自己的一个项目里需要统计当前在线连接数。当时第一版代码写得很简单private static int onlineCount 0; // 连接建立时 onlineCount; // 连接断开时 onlineCount--;看着天经地义对不对压测跑到几十个并发连接时计数就开始乱跳有时甚至变成负数。问题出在onlineCount不是原子操作。它实际上分成三步读值、加一、写回。线程A读到的值是10还没来得及写回11线程B也读到10加完写回11结果两次自增只加了一次。解决方案是用java.util.concurrent.atomic包下的AtomicIntegerprivate static AtomicInteger onlineCount new AtomicInteger(0); 在线程池任务里 onlineCount.incrementAndGet(); 任务结束finally里 onlineCount.decrementAndGet();如果你非要用synchronized也不是不行但AtomicInteger在“这类简单的计数器场景”下写起来更简洁性能也更好。这里还要顺带澄清一个网上常见的误解volatile只能保证可见性保证不了原子性。用volatile int修饰计数器多个线程同时自增时依然会丢更新。很多初学者觉得volatile是并发银弹实际上它最适用的场景是“一个线程写、多个线程读”的标志位。4.2 Socket和流的关闭顺序错了就是Connection reset资源释放这块光一个close()的顺序就有门道。网上经常看到有人这么写reader.close(); writer.close(); socket.close();这个顺序本身不是大问题但如果你在关闭writer之后想继续用socket或者反过来关闭socket之后还要flush数据就会踩到Socket closed或Connection reset。正确做法是所有业务逻辑处理完之后直接从最外层的Socket关起。Socket.close()会自动关闭关联的输入输出流不需要手动一个个关。上面线程池版本的代码就是把这个逻辑收敛到了closeQuietly()方法里。还有一个常见场景客户端异常断开服务端还在往输出流写数据。这时会抛SocketException: Connection reset。这种异常本质上属于正常的网络行为不是代码bug但如果你没捕获线程池里的任务会直接终止后续资源释放逻辑也不会执行。所以handleSocket里一定要catch住IOException再在finally里做收尾。4.3 TCP是流协议别忘了消息边界最后再提一个和并发无关、但很多新手会踩的坑TCP数据流没有天然的消息边界。用BufferedReader.readLine()按行读是因为我们约定“一行就是一条消息”本质是自定义了边界。如果你改成用read(byte[])读数据两条消息粘在一起是常有的事这就是所谓的粘包一条消息被拆成两次读到是半包。在网络编程里解决消息边界通常有三种方式按分隔符比如按行适合简单文本协议固定长度每条消息固定字节数适合定长报文长度前缀先读4字节长度再按长度读取内容适合通用协议。做这个回显demo用按行分隔就够但如果后面做正式系统建议直接定一套长度前缀协议。我在项目里就是吃了没设计协议格式的亏导致排查粘包问题花了一整天。5. 压测验证与调参方向让改动有依据多线程改完之后你肯定想知道这个服务端到底能抗多少并发线程池参数是不是最优的空口无凭最好用一个多线程客户端跑一轮压测观察任务处理情况和线程占用情况再决定要不要调参。5.1 一个简单的多线程客户端测试脚本下面这段代码模拟10个客户端同时连接每个客户端连续发送3条消息import java.io.*; import java.net.*; import java.util.concurrent.*; public class MultiClientSimulator { public static void main(String[] args) throws Exception { ExecutorService clientPool Executors.newFixedThreadPool(10); for (int i 0; i 10; i) { final int clientNo i; clientPool.execute(() - { try ( Socket socket new Socket(127.0.0.1, 8080); BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter( socket.getOutputStream(), true) ) { for (int j 0; j 3; j) { String msg client- clientNo -msg- j; writer.println(msg); String response reader.readLine(); System.out.println(Thread.currentThread().getName() send [ msg ], recv [ response ]); TimeUnit.MILLISECONDS.sleep(200); } } catch (IOException | InterruptedException e) { e.printStackTrace(); } }); } clientPool.shutdown(); clientPool.awaitTermination(10, TimeUnit.SECONDS); } }跑完这个脚本正常情况下你能看到服务端日志里多个线程交替处理不同客户端连接每个客户端的消息都得到了正确回显。如果你想测试更高并发把newFixedThreadPool(10)改成50、100循环次数调大就行。但注意别把本机资源打满不然压的就是本地环境了。5.2 根据观察结果调整参数的两个方向压测过程中重点观察服务端的线程池状态核心线程数是否够用、队列积压了多少、有没有触发拒绝策略。如果你的业务以下载、上传这类大流量长连接为主每个请求耗时高队列很容易堆积此时应该调大核心线程数并且把最大线程数控制好让并发处理能力和硬件资源匹配。如果你的业务是短连接、简单请求耗时极低核心线程数不需要太高重点放在队列长度上。队列太小会导致峰值流量进来时频繁拒绝队列太大会让请求排队时间过长表现为客户端拿响应很慢。这种场景下配合CallerRunsPolicy让一定量的请求在提交端线程直接执行反而是更平滑的处理方式。调参不是拍脑袋一定要每次只改一个变量压测一次记录数据再改下一个。我上个月就是这么一点点把某个模拟项目的连接处理能力从300并发调到1200并发的整个过程全凭数据说话。5.3 我踩过的几个坑希望你别再踩写到这里把过去真实项目里踩过的几个和这个主题相关的坑集中列一下每个都是血泪教训第一个坑是accept()循环里直接把IOException向上抛。某个早期版本代码一旦遇到网络抖动整个服务端进程直接退出让运维同事白忙活半天。正确做法是单次accept()异常时catch住并continue保证主循环不中断。第二个坑是线程池拒绝策略没有处理。线程池满了默认抛异常任务提交处没catch结果socket对象被创建了但没人接管连接直接悬空客户端那边表现为“连上了但服务端毫无反应”。后来改成捕获RejectedExecutionException并立即关闭socket问题才消失。第三个坑是忽略了TCP连接断开后的TIME_WAIT状态。压测结束后本机出现大量TIME_WAIT端口占用新连接反而连不上。这不是代码问题而是TCP协议本身的正常状态。遇到时不用慌等超时自然释放即可批量压测时客户端和服务端不要反复创建短连接即可。第四个坑是关于DNS缓存的。之前一个版本里服务启动时会解析外部接口域名并缓存结果结果接口迁移后服务还一直连旧IP折腾了很久才发现是JVM缓存了DNS解析结果。后来在启动参数里配置了TTL才解决。这个问题和前面InetAddress部分的内容是同一个根子上的可见基础类理解不到位最后坑的都是线上服务。说到这再分享一个我个人的收尾经验网络编程出问题时第一反应不要急着查框架或中间件先看看用到的InetAddress、Socket、ServerSocket这几个基础类有没有踩坑。很多所谓疑难杂症往下追一层都是基础概念没吃透。把这篇文章里的要点自己动手复现一遍再遇到线上并发网络服务问题你至少能知道该往哪个方向定位了。