简介:这是一份面向计算机网络课程学习者与初阶开发者的Java端口扫描器实践项目,聚焦TCP/UDP协议层探测能力训练,适用于课程设计、工程实训及毕设选题参考。资源包共12个文件,含2个核心Java源码(实现多线程扫描逻辑)、3个编译后class文件、2份Markdown文档(含README说明与使用指南)、1张界面截图PNG,以及Eclipse项目配置文件(.project、.classpath等),整体仅96KB,轻量易部署。已有131人学习下载,体现其作为入门级网络编程案例的实用价值。读者可直接运行复现完整扫描流程:输入目标IP与端口范围、设置线程数(1–200)、实时获取开放端口列表并支持结果保存;代码结构清晰,含图形化界面与模块化功能封装,便于理解Socket通信、线程池调度及Swing UI设计等关键知识点。
1. 这不是玩具级扫描器:一个能跑通 TCP SYN+UDP ICMP 回显、支持线程数精细调控的 Java 端口扫描器(课程设计级但可真实复现)
你试过在 Win10 上用 Java 写一个端口扫描器,结果刚起 5 个线程就卡死、扫到 80 端口却显示“closed”、UDP 扫描永远返回 timeout——不是代码写错了,是缺了三样东西:TCP 连接超时的分级控制逻辑、UDP 响应包的 ICMP 错误码解析机制、以及 Swing 界面线程与扫描线程的真正解耦。这个 PortScan-master 项目,就是把这三块硬骨头啃下来的课程设计成品:它不依赖任何第三方网络库(纯 JDKjava.net+java.nio.channels),完整实现 TCP Connect 扫描(含三次握手状态捕获)、UDP 端口探测(结合 ICMP port unreachable 判定)、多线程并发控制(线程池 + 任务队列 + 扫描进度回调),且所有参数(IP、端口范围、线程数)都在 GUI 实时可调、结果实时刷新。它适合计算机网络课设答辩、Java 多线程实战入门、甚至作为渗透测试原理教学的可视化教具——因为你能看到每一行socket.connect()的阻塞时间、每一条DatagramSocket.receive()的等待逻辑、每一个 SwingSwingUtilities.invokeLater()如何避免界面冻结。别被“课程设计”四个字骗了,它的 TCP 扫描模块已实测通过127.0.0.1:3306(MySQL)、192.168.1.1:80(家用路由器)、localhost:22(SSH);UDP 模块在 Wireshark 下验证过 ICMP Type 3 Code 3(Port Unreachable)的精准捕获。如果你正卡在“Java 怎么做非阻塞 UDP 扫描”或“Swing 更新列表时总抛IllegalStateException”,这份源码就是你的后悔药。
2. 从零拆解核心模块:TCP Connect 扫描器的三次握手状态机与超时分级策略
2.1 TCP 扫描不是简单new Socket(host, port):为什么必须手动控制 connect() 超时?
Java 原生Socket.connect(SocketAddress, timeout)在 timeout 后会抛出SocketTimeoutException,但问题在于:这个 timeout 是整个连接建立过程的总耗时,无法区分“SYN 发出后没回 SYN-ACK”和“SYN-ACK 收到但 ACK 未确认”的状态。而真实网络中,防火墙可能丢弃 SYN 包(无响应)、也可能放行 SYN 但拦截 SYN-ACK(导致客户端一直等)。PortScan-master 的解决方案是:用SocketChannel配合Selector实现非阻塞 connect,并监听 OP_CONNECT 事件——这才是逼近 TCP 协议栈底层的写法。
// src/scan/TcpScanner.java 关键片段 SocketChannel channel = SocketChannel.open(); channel.configureBlocking(false); InetSocketAddress address = new InetSocketAddress(ip, port); SelectionKey key = channel.register(selector, SelectionKey.OP_CONNECT); channel.connect(address); // 非阻塞发起连接 // 在主循环中轮询 selector while (selector.select(100) > 0) { Iterator<SelectionKey> keys = selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey k = keys.next(); keys.remove(); if (k.isConnectable()) { SocketChannel ch = (SocketChannel) k.channel(); try { if (ch.finishConnect()) { // 成功完成三次握手 openPorts.add(port); k.cancel(); ch.close(); } else { // 连接失败(如被拒绝) k.cancel(); ch.close(); } } catch (IOException e) { // Connection refused / No route to host 等明确错误 k.cancel(); ch.close(); } } } }逻辑说明:这段代码绕开了
Socket的黑匣子,直接用 NIO 暴露 TCP 状态。ch.finishConnect()返回 true 表示三次握手完成(SYN→SYN-ACK→ACK 全部成功),此时端口开放;若抛出IOException(如Connection refused),说明目标端口有服务但拒绝连接(仍属开放);若selector.select()超时后finishConnect()仍返回 false,则判定为 filtered(被防火墙过滤)。
参数说明:selector.select(100)中的100是毫秒级轮询间隔,决定了扫描精度——值越小响应越快但 CPU 占用越高;实际项目中建议设为 50~200ms,平衡速度与负载。
2.2 线程池如何避免“创建 100 个 Socket 导致系统资源耗尽”?
课程设计常犯的错误是:为每个端口新建一个Thread,结果线程数一设大就 OOM。PortScan-master 采用ThreadPoolExecutor+LinkedBlockingQueue组合,关键在于队列容量与拒绝策略的协同设计:
// src/scan/PortScanner.java 初始化线程池 private static final int MAX_THREADS = 200; private static final int QUEUE_CAPACITY = 1000; // 队列最大容纳 1000 个待扫描端口任务 private static final ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, // 核心线程数 = 用户设置的线程数(如 50) MAX_THREADS, // 最大线程数 = 200(防突发) 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(QUEUE_CAPACITY), // 有界队列! new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由主线程执行任务 );为什么用有界队列?无界队列(如
new LinkedBlockingQueue<>())会导致任务无限堆积,内存爆满;有界队列强制触发拒绝策略。这里选CallerRunsPolicy是血泪经验:当队列满、线程达上限时,新任务由 Swing 主线程执行——虽然会短暂卡 UI,但绝对不崩溃、不丢任务、不 OOM,比AbortPolicy(直接抛异常)或DiscardPolicy(静默丢弃)更适合课程设计场景。
参数安全边界:corePoolSize来自用户输入(GUI 输入框),但代码中做了校验:Math.max(1, Math.min(200, userThreads)),确保不会传入 0 或超大值。
2.3 Swing 界面如何实时更新扫描结果而不炸掉?
新手常写listModel.addElement(port)在工作线程里,结果抛IllegalStateException: safe to modify only from AWT event dispatch thread。PortScan-master 的解法是:所有 UI 更新必须走SwingUtilities.invokeLater(),且封装成原子操作。
// src/gui/MainFrame.java 中的回调方法 public void addOpenPort(int port) { SwingUtilities.invokeLater(() -> { DefaultListModel<Integer> model = (DefaultListModel<Integer>) listOpenPorts.getModel(); if (!model.contains(port)) { // 避免重复添加 model.addElement(port); // 同步滚动到底部 listOpenPorts.ensureIndexIsVisible(model.size() - 1); } }); } // src/scan/TcpScanner.java 中调用 if (isPortOpen) { mainFrame.addOpenPort(port); // 跨线程安全调用 }关键细节:
invokeLater()是异步投递,不阻塞扫描线程;model.contains(port)防止同一端口因重试被多次添加;ensureIndexIsVisible()让新端口自动滚动可见——这些才是课程设计答辩时老师会追问的“为什么这么写”。
3. UDP 扫描的玄学破解:ICMP 错误码解析与“无响应即开放”的反直觉逻辑
3.1 UDP 扫描为什么不能只看DatagramSocket.receive()是否超时?
TCP 有连接状态,UDP 是无连接的。new DatagramSocket().send(packet)发送后,如果目标端口无服务,操作系统会返回 ICMP Destination Unreachable(Type=3, Code=3);如果有服务,通常不回复任何包。但 Java 的DatagramSocket默认无法捕获 ICMP 错误——它只管 UDP 层。PortScan-master 的破局点是:启用 socket 的SO_TIMEOUT,并在 catchSocketTimeoutException后,结合 ICMP 报文分析做二次判定。
// src/scan/UdpScanner.java 核心逻辑 DatagramSocket socket = new DatagramSocket(); socket.setSoTimeout(2000); // 设置 2 秒超时 try { socket.send(packet); // 关键:尝试接收响应(哪怕服务不回包,OS 可能发 ICMP) byte[] buffer = new byte[1024]; DatagramPacket response = new DatagramPacket(buffer, buffer.length); socket.receive(response); // 此处可能收到应用层响应,也可能被 ICMP 中断 // 收到包 → 端口开放(或有服务) openPorts.add(port); } catch (SocketTimeoutException e) { // 超时 → 两种可能:1. 端口关闭(OS 发 ICMP)2. 端口开放但服务不回包 // 需要抓包验证 ICMP,但课程设计简化处理:标记为 "open|filtered" uncertainPorts.add(port); } catch (IOException e) { // 如果 e.getMessage() 包含 "Connection refused",说明收到 ICMP Port Unreachable if (e.getMessage().toLowerCase().contains("connection refused")) { // 明确判定为 closed closedPorts.add(port); } else { uncertainPorts.add(port); } } finally { socket.close(); }为什么
Connection refused能判断 ICMP?当DatagramSocket尝试向一个无服务的 UDP 端口发送数据时,Linux/Win10 内核会向该 socket 抛出IOException,message 为"Connection refused"——这正是内核将 ICMP Type 3 Code 3 映射到 Java 异常的结果。这是 JDK 对底层 ICMP 的隐式翻译,无需 JNI 或 pcap 库。
参数陷阱:setSoTimeout(2000)的 2000ms 是经验阈值。太短(如 500ms)会误判高延迟网络下的开放端口;太长(如 5000ms)则扫描慢。课程设计推荐 1500~2500ms。
3.2 UDP 扫描结果为何分三级:open / closed / open|filtered?
PortScan-master 的 GUI 结果面板有三列:Open Ports、Closed Ports、Uncertain Ports。这不是偷懒,而是严格遵循 Nmap 的分类逻辑:
| 类型 | 判定条件 | 典型场景 |
|---|---|---|
| Open | receive()成功收到数据包 | DNS(53)、DHCP(67/68)、SNMP(161)等主动响应服务 |
| Closed | IOExceptionmessage 含"Connection refused" | 目标主机存在,但该 UDP 端口无服务监听 |
| Open|Filtered | SocketTimeoutException且无其他异常 | 防火墙丢弃 UDP 包(无 ICMP 返回),无法区分开放或过滤 |
教学价值:这个三级分类恰恰是计算机网络课程的核心考点——它暴露了 UDP 协议的不可靠性本质,也解释了为什么
nmap -sU扫描比-sT慢且结果模糊。在答辩时,你可以指着Uncertain Ports列说:“老师,这正是谢希仁《计算机网络》第 5 版 P189 提到的‘UDP 扫描的固有局限性’。”
3.3 如何验证 UDP 扫描结果的真实性?Wireshark 抓包对照表
光看程序输出不够,必须用 Wireshark 验证。以下是 PortScan-master 扫描127.0.0.1:53(本地 DNS)时的抓包对照:
| 时间 | Wireshark 显示 | PortScan-master 日志 | 逻辑对应 |
|---|---|---|---|
| 0.000s | UDP 127.0.0.1:50234 → 127.0.0.1:53 | Sending UDP probe to port 53 | 程序发出探测包 |
| 0.001s | UDP 127.0.0.1:53 → 127.0.0.1:50234 | Received response on port 53 | receive()成功 →Open |
| 0.005s | ICMP 127.0.0.1 → 127.0.0.1: Destination unreachable (Port unreachable) | IOException: Connection refused on port 54 | Connection refused→Closed |
实操提示:在 Wireshark 过滤栏输入
ip.addr == 127.0.0.1 && (udp || icmp)即可聚焦本地 UDP/ICMP 流量。注意观察UDP包的Length字段(通常为 0,因探测包无 payload)和ICMP的Type/Code字段——这才是判定 closed 的黄金证据。
4. 避坑指南:五个让课程设计当场翻车的致命细节与血泪修复方案
4.1 现象:点击“开始扫描”后界面完全冻结,CPU 占用 100%,5 分钟后才弹出“扫描完毕”
原因:GUI 线程(AWT Event Dispatch Thread)被阻塞。常见于新手把TcpScanner.scan()直接写在button.addActionListener()里,且未用SwingWorker或线程池,导致 Swing 主线程陷入while (port <= endPort)循环。
解决:
- ✅ 正确做法:
button.addActionListener(e -> new ScanTask().execute());,其中ScanTask extends SwingWorker<Void, String>,在doInBackground()中调用扫描逻辑,在process()中更新 UI。 - ❌ 错误写法:
executor.submit(() -> { scan(); });但未用SwingUtilities.invokeLater()更新界面,导致listModel.addElement()抛异常并静默失败。
4.2 现象:扫描192.168.1.1(路由器)时,TCP 扫描显示 22、80 开放,但 UDP 扫描全为Uncertain
原因:家用路由器默认禁用 ICMP Port Unreachable 响应(安全策略),导致 UDP 探测包发出后既无应用层响应,也无 ICMP 错误,全部超时归为Uncertain。
解决:
- ✅ 方案一:改用
nmap -sU --max-retries 3 192.168.1.1对比验证,确认是设备策略而非代码问题; - ✅ 方案二:在代码中增加提示:“UDP 扫描结果受目标主机 ICMP 策略影响,建议结合 TCP 扫描交叉验证”;
- ❌ 不要强行调低
setSoTimeout()到 500ms——只会增加误判率。
4.3 现象:扫描127.0.0.1时,80 端口显示closed,但浏览器能正常访问http://localhost
原因:127.0.0.1的 80 端口服务(如 Apache)可能绑定在::1(IPv6)而非0.0.0.0(IPv4),而 JavaInetSocketAddress默认解析为 IPv4 地址,导致连接被拒绝。
解决:
- ✅ 强制指定 IPv4:
InetAddress.getByName("127.0.0.1")替代InetAddress.getByName("localhost"); - ✅ 或在
SocketChannel.open()后,channel.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), 0))显式绑定任意 IPv4 地址。
4.4 现象:保存结果文件时,中文路径报java.io.FileNotFoundException: ??.txt (系统找不到指定的文件)
原因:FileWriter默认使用平台默认编码(Windows 是 GBK),但JFileChooser返回的路径含中文,若文件名含中文且未指定编码,写入时乱码导致路径无效。
解决:
- ✅ 正确写法:
new FileWriter(file, StandardCharsets.UTF_8); - ✅ 并在保存前校验路径:
if (!file.getParentFile().exists()) file.getParentFile().mkdirs(); - ❌ 不要用
new FileWriter(file)——这是 Windows 下中文路径的隐形炸弹。
4.5 现象:Eclipse 运行时报错Exception in thread "main" java.lang.NoClassDefFoundError: javafx/application/Application
原因:项目.classpath文件引用了 JavaFX 库,但 JDK 11+ 已移除 JavaFX,且 Eclipse 默认 JRE 未配置 JavaFX SDK。
解决:
- ✅ 删除
.classpath中<classpathentry kind="lib" path="lib/jfxrt.jar"/>行; - ✅ 确认
src/gui/MainFrame.java使用的是javax.swing.*(Swing),而非javafx.scene.*(JavaFX); - ✅ 若真需 JavaFX,下载 OpenJFX 并在 Eclipse → Properties → Java Build Path → Libraries → Add Library → User Library 中添加。
5. 进阶技巧:用扫描日志反推网络拓扑,以及三个让答辩加分的实操验证法
5.1 从扫描日志发现隐藏的网络结构:端口分布模式即拓扑指纹
PortScan-master 生成的result.txt不只是端口列表,它是网络设备的“X 光片”。我带学生做课程设计时,让他们对三类目标扫描并对比日志:
| 目标类型 | 典型开放端口模式 | 拓扑含义 | 教学价值 |
|---|---|---|---|
家用路由器(192.168.1.1) | TCP: 22, 80, 5000; UDP: 53, 1900 (UPnP) | LAN 边界网关,提供 NAT、DHCP、Web 管理 | 理解 SOHO 设备协议栈分层 |
Windows 10 主机(192.168.1.100) | TCP: 135, 139, 445, 3389; UDP: 137, 138 | SMB/CIFS 文件共享、远程桌面、NetBIOS | 关联《操作系统》进程通信章节 |
Linux 服务器(192.168.1.200) | TCP: 22, 80, 443, 3306; UDP: 53, 67 | SSH、Web、数据库、DNS、DHCP 服务 | 验证 TCP/IP 协议族服务端口标准分配 |
操作步骤:
- 用 PortScan-master 扫描三台设备(确保在同一局域网);
- 将
result.txt导出,用 Excel 按“目标 IP”分组,统计每类端口出现频次;- 制作热力图:横轴为端口号(0~1024),纵轴为设备类型,色块深浅表示开放概率。
你会发现:135-139端口几乎只出现在 Windows 日志中,3306只在 Linux 服务器出现——这就是协议栈实现差异的实证。
5.2 三个答辩必演的验证实验:让老师当场点头的硬核操作
实验一:TCP 扫描的“三次握手”可视化验证
- 步骤:启动 PortScan-master,设置 IP=
127.0.0.1,端口范围21-23,线程数1; - 同时打开 Wireshark,过滤
tcp.port == 21 || tcp.port == 22 || tcp.port == 23; - 点击扫描,观察 Wireshark 中每个端口的 TCP 流:
21(FTP)应有完整三次握手;22(SSH)同理;23(Telnet)若未开启,则只有 SYN 包发出,无 SYN-ACK 返回。 - 答辩话术:“老师,您看这里——21 端口的
Seq=0, Ack=0, Flags=[S]是 SYN,紧接着Seq=0, Ack=1, Flags=[SA]是 SYN-ACK,最后Seq=1, Ack=1, Flags=[A]是 ACK,三次握手完成,程序判定开放。而 23 端口只有第一个包,证明服务未启用。”
实验二:UDP 扫描的 ICMP 错误码捕获演示
- 步骤:关闭本机 DNS 服务(
services.msc→ Stop “DNS Client”); - 扫描
127.0.0.1:53,观察 PortScan-master 输出Closed Ports中是否包含 53; - Wireshark 过滤
icmp && ip.src == 127.0.0.1,找到对应 ICMP 包,展开Internet Control Message Protocol→Type: 3 (Destination unreachable)→Code: 3 (Port unreachable)。 - 答辩话术:“这个 ICMP Type 3 Code 3 就是 UDP 扫描判定 closed 的依据,它由操作系统内核生成,不是应用层协议,所以 Java 无需解析原始 IP 包——JDK 已帮我们映射到异常消息。”
实验三:线程数对扫描速度的量化影响测试
- 步骤:固定目标
127.0.0.1,端口范围1-1024,分别用线程数1/10/50/100扫描; - 记录每次耗时(程序日志末尾的
Scan completed in X ms); - 绘制折线图:横轴线程数,纵轴耗时(ms),你会看到
1→10速度飙升,10→50增速放缓,50→100几乎持平甚至变慢(线程调度开销反超收益)。 - 答辩话术:“这验证了 Amdahl 定律——并行加速有上限。当线程数超过 CPU 核心数,上下文切换成本吞噬了并发收益。我们的线程数上限设为 200,是为应对 I/O 密集型场景(网络延迟主导),而非 CPU 密集型。”
5.3 从那以后我每次重构网络工具,都强制走一遍“三屏验证法”
所谓三屏验证法,是我带学生做课程设计时定下的铁律:第一屏看代码逻辑(是否符合 RFC)、第二屏看 Wireshark 抓包(是否符合协议行为)、第三屏看终端日志(是否符合预期输出)。比如改 UDP 扫描超时时间,我不只改setSoTimeout(2000),还要:
- 第一屏确认
DatagramSocket创建、发送、接收三步无遗漏; - 第二屏在 Wireshark 看
2000ms内是否真有 ICMP 返回; - 第三屏检查
result.txt中Uncertain Ports数量是否随超时值增大而减少。
这看似繁琐,但能避开 90% 的“代码跑通但协议不对”的玄学 bug。希望帮到你。
本文还有配套的精品资源,点击获取