简介:这是一份面向计算机网络课程设计的Java版TCP/UDP端口扫描器,适合需要完成课设、毕设或大作业的初、中级学习者。程序基于多线程扫描机制,前台可自由设置目标IP、端口范围及并发线程数,扫描结果会直观显示在主界面中,并支持结果保存;实验环境为Windows 10与Eclipse,代码结构清晰,便于二次修改与功能扩展。压缩包共12个文件,96KB,主要包含Java源码与class字节码文件、Eclipse项目配置文件、Markdown说明文档及界面预览图,可帮助快速理解项目结构并导入开发环境运行。目前已有131人学习/下载,参考性较强。借助源码、配置与界面截图,读者可掌握端口扫描基本原理、多线程任务调度及Socket编程实践方法,也可将完整方案直接用于课程设计答辩或初期项目立项。
1. 用Java写端口扫描器:课程设计里性价比最高的网络编程实战
Java端口扫描器这个组合,几乎成了计算机网络课设的热门固定选题。它要做的事听起来简单——给定一台主机,把TCP/UDP端口逐个探一遍,判断哪些开着、哪些关闭。真正动手时你会发现,它把计算机网络基础、Java网络编程、并发控制和操作系统网络栈全串了起来。想快速完成课设、不愿意选论文型题目的同学,这是最稳的选项;想借课设把Socket练扎实的人,它也有足够的深度可挖。我见过不少同学拿到题就逐个端口connect,扫一台内网机器要几十分钟,问题不在Java,在于没把扫描当成一个有并发、有超时、有资源约束的系统来设计。
2. TCP扫描的核心逻辑:Connect扫描为什么是课程设计的首选
2.1 三次握手与端口状态判定
TCP端口扫描的理论依据是三次握手。客户端向目标端口发送SYN,如果端口处于监听状态,操作系统内核会响应SYN+ACK,握手可以继续完成;如果端口没有服务监听,内核会响应RST,连接立即被拒绝。Java的Socket API不允许应用程序直接构造SYN包——你调用Socket.connect()时,内核已经帮你完成了整个过程,你只看到最终的成功或失败。这种方式叫Connect扫描,也就是教材里说的“全连接扫描”。
为什么课程设计首选Connect扫描,而不是看起来更高级的SYN半开扫描?原因很现实。第一,SYN扫描需要构造原始数据包,这在Linux上通常要root权限,在Windows上更多是系统限制,答辩机器不一定具备这个条件。第二,Java标准库没有暴露构造原始TCP包的能力,绕路第三方库只会增加学习成本和环境依赖。第三,Connect扫描的输出很干净——连上了就是开放,连接被拒就是关闭,超时无响应就是被过滤,三个状态对应三类异常,对课设答辩来说,可读性远比“显得高级”重要。
有个值得注意的细节:关闭端口的RST几乎瞬间返回,所以ConnectException通常比超时快得多。换句话说,扫描一个关闭端口密集的网段时,速度反而快;扫描开放端口多或防火墙过滤严重的网段,速度会被拖慢。这个特性直接影响后文并发方案的设计——你面对的不是“每个端口都等一个timeout”,而是“绝大多数端口几十毫秒内就返回RST,偶尔几个端口卡满timeout”。
2.2 最小可用的TCP端口扫描器:单线程起步
先把核心扫描逻辑写出来,这段代码可以直接运行,也是后续所有并发改造的基础单元。
import java.io.IOException; import java.net.ConnectException; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketTimeoutException; public class TcpProbe { public enum Status { OPEN, CLOSED, FILTERED, UNKNOWN } public record Result(String host, int port, Status status, long costMs) {} public static Result probe(String host, int port, int timeoutMs) { long start = System.nanoTime(); try (Socket socket = new Socket()) { // 注意:必须用无参构造 + 显式 connect // new Socket(host, port) 的超时不可控,可能阻塞很久 socket.connect(new InetSocketAddress(host, port), timeoutMs); long cost = (System.nanoTime() - start) / 1_000_000; return new Result(host, port, Status.OPEN, cost); } catch (SocketTimeoutException e) { // SYN包发出后没有任何回复,多数是防火墙静默丢包 return new Result(host, port, Status.FILTERED, timeoutMs); } catch (ConnectException e) { // 收到RST,端口明确关闭 return new Result(host, port, Status.CLOSED, timeoutMs); } catch (IOException e) { return new Result(host, port, Status.UNKNOWN, timeoutMs); } } }这里最核心的是socket.connect(new InetSocketAddress(host, port), timeoutMs)。第一个参数封装目标IP和端口,第二个参数是建立连接的最大等待时间。我把SocketTimeoutException归类为FILTERED而不是CLOSED——两者语义完全不同:超时意味着包发出去了但没人应答,可能是防火墙丢掉了SYN,也可能是路由不通;关闭则是对方明确告诉你“这里没人监听”。如果混在一起,扫描结果会多出一堆误判的“关闭端口”,答辩时容易被问住。
参数说明:timeoutMs在局域网内建议300到500毫秒,在远程主机上至少1500毫秒。如果设成50毫秒,一次网络抖动就会把开放端口误判成超时——这是新手最容易踩的坑,总想着扫快一点,把超时压得太低。另外,每个线程都要new一个Socket,用try-with-resources关闭是保命做法,漏掉close会在高并发时把文件描述符耗尽。
2.3 扫描参数怎么设:超时、线程数、端口范围
参数在代码里只占三行,但选错了整个程序的表现天差地别。把经验值列成一张表,方便直接照抄:
| 参数 | 本机/局域网 | 远程主机 | 说明 |
|---|---|---|---|
| timeout | 300ms | 1500-3000ms | 低于100ms误报率高,高于3000ms拖慢整体进度 |
| 默认端口范围 | 1-1024 | 1-65535 | 课设建议先扫常用端口,答辩再展示全量扫描 |
| 并发线程数 | 100-200 | 100-300 | 受文件描述符限制,详见第4章 |
| 失败重试 | 0次 | 1次 | TCP的SYN重传由内核负责,应用层无需重试 |
端口范围的选择也有讲究。很多课设要求“扫1到65535”,但答辩现场很少真的扫完整段端口空间。我的习惯是优先扫公认的常用端口——1到1024,再加上8080、3306、5432这类应用端口——把结果展示清楚,再补一句“全端口扫描在代码上同样支持,只是耗时更长”。这不是偷懒,课程设计的时间本来就有限,把精力放在结果分析和代码健壮性上,比干等几万个端口扫描完成划算得多。
还有一点容易被忽略:扫描行为在目标系统上是可见的。连接关闭端口会留下大量RST记录,连接开放端口会留下完整的连接日志。实验环境里这没问题,但如果扫描的不是自己控制的机器,不要用高并发做全端口扫描——这是网络工程师的基本分寸。
3. UDP扫描没有“三次握手”可用:原理与一次有效的探测
3.1 UDP为什么不能照搬TCP的方案
UDP是面向无连接的,发送方丢一个数据报出去,不会有类似TCP握手的确认过程。目标端口开放时,有些服务会回数据——DNS、NTP、SNMP这类协议应答率很高——但绝大多数UDP服务不回任何东西;目标端口关闭时,很多操作系统会回应一个ICMP Port Unreachable。于是UDP扫描的状态判定变成了:收到应用数据,判定开放;收到ICMP Port Unreachable,判定关闭;没有任何反馈,判定未知。
麻烦就在“未知”这两个字上。TCP扫描里超时大概率意味着防火墙过滤,而UDP这边没有防火墙也可能出现超时——一个合法UDP服务收到不认识的数据报,完全可以保持沉默。所以UDP扫描天然有三态,UNKNOWN的比例通常很高。这也是很多课设TCP部分做得很漂亮、一到UDP就摆烂的原因,不是代码难写,是UDP的语义决定了结果天然模糊。
Java要拿到ICMP反馈并不容易,标准库不提供直接解析ICMP的API。但你有不需要解析的办法:把DatagramSocket设置成“已连接”模式,也就是对目标地址调用connect()。当内核收到ICMP Port Unreachable时,会把错误关联到这个socket上,后续的receive()调用就会抛出PortUnreachableException,相当于Java帮你把ICMP错误转成了异常。这是纯标准库能做到的最优解,不需要JNI,也不需要额外权限。
3.2 一次有效的UDP扫描实现
import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetSocketAddress; import java.net.PortUnreachableException; import java.net.SocketTimeoutException; public class UdpProbe { public enum Status { OPEN, CLOSED, UNKNOWN } public record Result(String host, int port, Status status, String detail) {} public static Result probe(String host, int port, int timeoutMs) { DatagramSocket socket = null; try { // 端口0表示由系统分配临时端口 socket = new DatagramSocket(0); // 已连接UDP:只与目标通信,让ICMP错误能映射到这个socket socket.connect(new InetSocketAddress(host, port)); byte[] payload = buildProbePayload(port); DatagramPacket packet = new DatagramPacket(payload, payload.length); socket.send(packet); socket.setSoTimeout(timeoutMs); byte[] buf = new byte[256]; DatagramPacket response = new DatagramPacket(buf, buf.length); socket.receive(response); return new Result(host, port, Status.OPEN, "收到 " + response.getLength() + " 字节响应"); } catch (PortUnreachableException e) { // 收到ICMP Port Unreachable,端口明确关闭 return new Result(host, port, Status.CLOSED, "ICMP端口不可达"); } catch (SocketTimeoutException e) { return new Result(host, port, Status.UNKNOWN, "超时无响应"); } catch (Exception e) { return new Result(host, port, Status.UNKNOWN, e.getClass().getSimpleName()); } finally { if (socket != null) socket.close(); } } private static byte[] buildProbePayload(int port) { if (port == 53) { // 最小DNS查询:12字节固定头 + 1字节QNAME + 4字节类型/类 byte[] dns = new byte[18]; dns[2] = 0x01; // 事务ID高字节 dns[5] = 0x01; // RD=1,期望递归 dns[12] = 0x01; // QNAME长度=1 dns[13] = 0x61; // 'a' dns[14] = 0x00; // QNAME结束 dns[15] = 0x00; // QTYPE高字节 dns[16] = 0x01; // QTYPE=1,A记录 dns[17] = 0x01; // QCLASS=1,IN类 return dns; } return new byte[] { (byte) 0xAA, 0x55 }; // 其他端口发两字节 } }逻辑核心是DatagramSocket.connect(InetSocketAddress)。前面说过,它不是建立真正的连接,而是告诉内核“这个socket只和指定目标交换数据”,这样ICMP错误才能准确传回来。另一个重点是buildProbePayload:UDP扫描与TCP扫描最大的不同在于探测包内容会影响响应率,对53端口发一个合法的DNS查询头,几乎必然收到响应;对未知端口就只能碰运气。
参数说明:timeoutMs建议500到2000毫秒。UDP没有握手机制,响应完全依赖应用层是否愿意回包,时间太短几乎全是UNKNOWN。如果扫描的是53以外的知名UDP端口,最好查一下对应协议的默认载荷格式来构造探测包,能明显提升识别率。
3.3 UDP扫描参数调优:重发、等待与并发
UDP丢包是常态,跨网段尤其明显。第一次发出去的探测包可能根本没到目标,而目标端口本身没有问题。所以UDP探测不能发一次就下结论。常见做法是“逐步重试”:一个端口发2到3次探测包,每次等待时间递增,比如300毫秒、600毫秒、1200毫秒。只要有一次收到响应就判OPEN,只要有一次收到PortUnreachableException就判CLOSED,所有尝试都超时才归入UNKNOWN,这个策略能明显降低误报。
重试逻辑放在哪一层值得想一下。我倾向于放在上层调用方而不是UdpProbe内部,因为重试本身就是“多个探测合并成一个结果”的决策,由调用方管理,结果结构会更干净,UdpProbe保持单次探测语义,也方便单独单元测试。
并发方面,UDP socket比TCP连接轻量,但同样占用文件描述符和临时端口。Windows上大量快速创建和关闭DatagramSocket,会导致临时端口处于“正在释放”状态,短时间内无法复用。如果扫描中途报BindException: Address already in use,多半就是这个原因。缓解办法是控制并发在100以内,或者复用socket、每次重新bind(0)绑定临时端口,后者实现复杂,课设不推荐。实测中还有一个经验:给每个UDP探测之间加极短的间隔(1到2毫秒),能明显减少Windows上的端口冲突概率,这个做法看起来慢,实际体感差别不大。
4. 多线程改造:从串行扫描到快得多的并发探测
4.1 线程池选型:不要无脑用Executors.newFixedThreadPool
扫描器从单线程改成多线程,第一反应通常是Executors.newFixedThreadPool(50),但实际跑一次大范围扫描你很快会发现不对劲。如果某个端口的connect真的等到timeout才返回,这个线程就被占满整个timeout时长,假设timeout是3000ms,50个线程同时只能处理50个端口,其余任务在队列里干等。更本质的问题是,TCP连接是IO阻塞操作,等待网络IO时线程完全闲置,线程池再大也只是表面并发。
我一般会用ThreadPoolExecutor手动构造线程池,下面这段可以直接用:
import java.util.concurrent.*; public class ScannerPool { public static ExecutorService newScanPool(int maxThreads) { return new ThreadPoolExecutor( maxThreads / 2, // 核心线程数 maxThreads, // 最大线程数 30L, TimeUnit.SECONDS, // 空闲线程存活时间 new SynchronousQueue<>(), // 不缓存任务,线程不够直接创建新线程 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时退回调用线程 ); } }SynchronousQueue是关键选择。它不持有任何任务,每个提交的任务必须立刻交给一个线程执行,线程不够就先创建新线程,达到最大线程数后再触发拒绝策略。这个行为对端口扫描很合适——任务数量大、单个任务耗时不确定,队列会放大“任务提交完但还没开始执行”的错觉,而SynchronousQueue天然避免了这种问题。CallerRunsPolicy则是兜底策略:任务太多时让提交线程自己干活,相当于一个自然限流。
maxThreads不要拍脑袋填。Linux下普通用户默认文件描述符上限通常是1024,Windows下每进程可用的临时端口也有限。一次扫描要创建大量socket,线程数超过500很容易触发Too many open files。稳妥做法是先跑ulimit -n看上限,再按上限的一半设置线程数。
4.2 任务分配与结果收集:用CompletionService而不是Future.get()
扫描任务按端口粒度切分后,最自然的做法是提交一个Future,然后按提交顺序get()。但这样会卡——如果第100号端口的任务特别慢,即使其他几百个任务早就完成了,你还是要等它。正确的做法是使用ExecutorCompletionService,它内部维护一个已完成任务队列,你随时take()到的都是“已经完成”的Future。
public class ConcurrentTcpScanner { public static List<Integer> scanAll(String host, int startPort, int endPort, int timeoutMs, ExecutorService pool) throws Exception { int total = endPort - startPort + 1; ExecutorCompletionService<TcpProbe.Result> cs = new ExecutorCompletionService<>(pool); for (int port = startPort; port <= endPort; port++) { final int p = port; cs.submit(() -> TcpProbe.probe(host, p, timeoutMs)); } List<Integer> openPorts = new CopyOnWriteArrayList<>(); for (int i = 0; i < total; i++) { Future<TcpProbe.Result> future = cs.take(); TcpProbe.Result r = future.get(); if (r.status() == TcpProbe.Status.OPEN) { openPorts.add(r.port()); } if ((i + 1) % 500 == 0) { System.out.printf("进度: %d/%d 端口, 已发现 %d 个开放端口%n", i + 1, total, openPorts.size()); } } return openPorts; } }逻辑说明:cs.submit提交的是Callable<TcpProbe.Result>,每个任务只负责扫一个端口。cs.take()拿到的是一个已完成任务的Future,随后的future.get()必然立即返回,不会阻塞。进度打印放在结果收集循环里,每500个端口输出一次,不会干扰采集。
参数说明:线程池由调用方创建和关闭,scanAll内部不shutdown(),重复调用时不会浪费线程池创建的开销。CopyOnWriteArrayList在这里作为并发容器使用,虽然实际只有一个主线程在写,但养成用并发容器的习惯没有坏处,也方便后续你改成多消费者模型。
4.3 扫描速度估算:怎么知道自己的并发够不够
经常有人问“扫1到65535要多久”,其实可以先做个数学估算。假设timeout是300毫秒,多数关闭端口20毫秒内返回RST,少数过滤端口会等满300毫秒。局域网内过滤端口少,串行扫65535个端口大约需要65535×20毫秒,也就是20分钟出头。200线程并发时,理想耗时除以200,约7秒,但实际还受线程调度、socket创建、DNS解析影响,实测1到3分钟属于正常范围。
远程主机情况就不一样了:timeout按1500毫秒算,被防火墙过滤的端口会占满这个时长,如果过滤端口比例高,慢任务会拖住大量线程。这也是为什么要用CompletionService——慢任务永远是少数,快任务能持续返回并推进进度。如果你发现进度条长时间不动,先怀疑的目标不是代码,而是某个网段的过滤率高。
三个提速方向要记牢:第一,收窄timeout,但别低于100毫秒;第二,提高并发线程数,但以不触发文件描述符上限为准;第三,先做主机可达性判断,不可达的主机直接跳过。第三点收益最大——不可达IP的所有端口都会打到timeout上限,一个IP就可能拖慢整轮扫描。
5. 踩坑与排错:端口扫描器常见问题排查
这一章写我在这类课设里反复遇到的五个问题,按“现象-原因-解决”的顺序展开,方便你对照定位。
5.1 现象:扫描程序跑到一半“卡死”,控制台不再输出进度
先怀疑线程池是否被慢任务占满。如果用了Executors.newFixedThreadPool(50)配合Future.get()按提交顺序取结果,那么第51个任务会在队列里等待,前50个任务一旦都撞上timeout上限,所有线程被占满,主线程阻塞在get()上,整个程序看起来像死掉。解决方式是换用第4章的CompletionService方案;如果任务本身没问题,还可以用jstack看线程栈,会发现大量线程阻塞在Socket.connect上——这能帮你确认是任务慢,而不是死循环。
5.2 现象:UDP扫描结果全是“关闭”,但目标端口明明开着
优先怀疑PortUnreachableException的误判。这个异常并不总是代表端口关闭——如果中间设备或目标主机主动丢弃了ICMP报文,某些系统会把错误反馈得不够准确;另一个常见原因是UDP探测包本身丢了。UDP没有重传机制,第一个包丢了,你可能收到一个本机协议栈生成的错误反馈,误以为目标端口关闭。解决办法是先把范围缩到最小:在本机回环地址上启动一个UDP服务,用扫描器扫127.0.0.1,排除所有网络路径干扰,确认代码逻辑本身可靠,再往外网扩展。
5.3 现象:Windows上运行正常,Linux上大量端口超时
这不是玄学,是内核网络栈的差异。最典型的是Socket关闭后的行为:Windows下大量短连接会进入TIME_WAIT状态,拖慢后续建连;而Linux对TCP TIME_WAIT的处理更激进一些。另一个差异在UDP层:PortUnreachableException在Linux上反馈更可靠,Windows上出现“该异常被吞掉或误报”的概率更高。解决的方法是:在Windows上扫描结束后sleep一秒,让系统回收socket资源;在Linux上如果出现大面积超时,先排查iptables是否拦截了出站ICMP,再考虑代码问题。
5.4 现象:扫描结果和nmap对不上
nmap默认的TCP扫描是SYN半开扫描,不完成完整握手,而Java的connect扫描会完整建连。防火墙对这两种扫描的处理策略确实可能不同,导致nmap显示open而connect扫描超时。另外nmap的UDP扫描内置了几十个常见服务的探测载荷,对不同端口发送不同的内容,识别率自然比自己写的两字节探测包高。解决方法是:对比时明确指定nmap模式,TCP用nmap -sT,UDP用nmap -sU,不要拿默认SYN模式直接对比,否则结论没有参考价值。
5.5 现象:扫描结束主线程不退出,程序一直挂着
大概率是线程池没有正确关闭。ExecutorService.shutdown()只是不再接收新任务,已经在跑的任务还会继续执行,必须配合awaitTermination等待结束。另外,非守护线程即使队列清空也不会自动退出,所以显式关闭是必须的。
pool.shutdown(); if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // 强制中断还没跑完的任务 }这段代码放在扫描主逻辑之后。如果awaitTermination返回false,说明有任务没有结束,shutdownNow()会向线程发出中断信号——对阻塞在Socket.connect上的任务,中断会直接抛出SocketException,线程得以退出。别小看这一步:不主动中断的话,某些卡在网络IO上的线程永远不会响应shutdown,程序就那样挂着,看起来像没写完。
6. 进阶:从“能扫”到“能用”的两个小改造
6.1 banner识别:让结果从“端口开放”变成“端口是什么”
课程设计交一个能跑的命令行扫描器通常已经及格,但如果想往90分靠,最先做的应该是banner识别。TCP连接建立后,很多服务会主动发送欢迎信息——FTP的220、SMTP的220、HTTP的响应头——连接成功后用setSoTimeout(500)读一行,把前几十字节记录下来,结果就从“22端口开放”升级成“22端口开放,banner: SSH-2.0-OpenSSH_8.3”。这个信息在答辩时很有价值,直接说明你的扫描器不只是把握手验证做完,还做了应用层识别。要注意的是,部分服务不主动发banner,需要客户端先发送一个探测字符串,课设阶段可以先处理主动发送banner的服务,不需要做太完整的协议交互。
6.2 结果导出:CSV格式与本地验证清单
第二个实用改动是结果导出。我的习惯是输出CSV文件,字段包括IP、端口、协议、状态、耗时、banner,答辩时老师要看原始数据,直接丢一个CSV过去比现场滚动命令行有说服力得多。
| 字段 | 示例 | 说明 |
|---|---|---|
| host | 192.168.1.100 | 目标IP |
| port | 22 | 端口号 |
| protocol | tcp | tcp或udp |
| status | open | open/closed/filtered/unknown |
| cost_ms | 23 | 单次探测耗时 |
| banner | SSH-2.0-OpenSSH_8.3 | 可选 |
写CSV时注意:字段值里如果包含逗号,必须用双引号包裹,banner字段最容易中招;另外不要用奇怪的填充字符,直接逗号分隔就好。
最后说一个我自己的习惯:不管代码写得多急,先在本地把测试用例跑通。我会起一个HTTP服务在8080端口,开一个UDP服务在5353端口,然后用扫描器扫127.0.0.1,确认TCP和UDP两个方向都能准确报出open,再拿去扫别的机器。这个动作能排除“代码本身有bug”的可能,省下一大笔排查时间。这个习惯帮我躲过很多次“扫外网扫不出来,最后发现是timeout参数调太小”的翻车现场,希望帮到你。
本文还有配套的精品资源,点击获取