1. 先把这个报错本身彻底拆明白
1.1 JSch 和 SSH 握手过程中到底发生了什么
JSch 是 Java 生态里使用频率很高的 SSH2 客户端库,很多老项目做 SFTP 文件上传下载、远程命令执行,底层用的就是它。我之前接手过好几个文件同步模块,日志里都出现过com.jcraft.jsch.JSchException: connection is closed by foreign host。第一次看到这个英文提示的人,十有八九会以为foreign host是指某个国外主机或者境外地址,但实际上完全不是这个意思。这个报错的信息很直白:你的 JSch 客户端和远程服务器之间的 TCP 连接是通的,SSH 协议层也建立了会话,然后远程那一端主动把连接断开了,而且断开之前还按照 SSH 协议规范发送了一条 Disconnect 消息。
要理解这句话,得先弄清楚 JSch 的完整连接链条。JSch 做一次 SSH 连接,大致分成两个阶段。第一阶段是 TCP 连接建立,JSch 用普通 Socket 去连目标服务器的 22 端口,这个阶段和 SSH 协议没有任何关系,只要目标端口能通、防火墙放行,TCP 就能建立成功。第二阶段是 SSH 协议协商,包括版本字符串交换、密钥交换算法协商、加密算法协商、认证方式协商,比如密码认证或者公钥认证。认证通过之后,JSch 还要再申请一个 Channel,可能是 SFTP Channel,也可能是 Exec Channel 或者 Shell Channel。只有这个 Channel 成功打开,后续的sftp.get()、sftp.put()、channel.getInputStream()这些操作才能正常工作。
connection is closed by foreign host这个报错,几乎都是发生在第二阶段以及之后的运行过程中。也就是说,你的网络层是通的,但是 SSH 协议层的对端主动结束了会话。JSch 收到对方发来的 SSH_MSG_DISCONNECT 消息后,如果发现自己不是主动断开的那一方,就会把这句英文作为异常信息抛出来。所以这个报错的本质不是“连不上”,而是“连上了之后被对方踢了”。明白了这一层,排查方向才不会跑偏。
1.2 “foreign host” 指的是哪一方
这里必须把概念掰扯清楚。SSH 协议里,连接的两端分别叫 client 和 server。对于 JSch 客户端来说,对端的那台机器就是 foreign host,翻译成“对端主机”或者“远端主机”更准确,跟你所处的地理位置没有任何关系。当服务端决定断开连接时,可以发送一条 SSH_MSG_DISCONNECT 消息,里面带着 reason code 和一段描述文本。JSch 拿到这条消息之后,如果发现自己只是被动接收方,就会把它翻译成connection is closed by foreign host。
换句话说,报错里说的“foreign host 关闭了连接”,指的是对端主动断连。但主动断连的“对端”不一定就是那台真正的 SSH 服务器。它会可能是 sshd 进程本身,也可能是链路里的网关、负载均衡设备、安全设备。虽然最终客户端看到的都是同一句报错,但触发断开的底层原因千差万别。服务端主动断开连接的常见原因包括:sshd 配置了存活探测参数,客户端长时间没有心跳,被判定为死连接;sshd 的并发连接数达到上限,为保护资源主动断开新连接;用户认证成功之后,登录 shell 不可执行、强制命令执行失败、SFTP 子系统路径错误;服务器上的 TCP 包装、防火墙规则或云安全策略主动介入了会话;中间 NAT 设备或负载均衡器在一段时间没有流量后回收了 TCP 会话。这几类原因在后续内容里会逐个拆开讲,但先要树立一个意识:这是一句“结果型”报错,不是一个“原因型”报错。
1.3 别把几个相似报错混为一谈
实际排查中,几个名字相近的报错特别容易混淆。connection is closed by foreign host、Connection reset by peer、Read timed out看起来都像“连接断了”,但本质完全不同。如果混在一起,排查思路会从一开始就走偏。
| 报错文本 | 本质 | 优先排查方向 |
|---|---|---|
| connection is closed by foreign host | SSH 协议层收到了对端的 Disconnect 消息,连接属于协议层面的“礼貌关闭” | 服务端 sshd 配置、账号限制、连接数上限、保活策略 |
| Connection reset by peer | TCP 层收到 RST 包,通常意味着进程崩溃、防火墙强杀或端口被中间设备干预 | 进程存活状态、防火墙规则、安全组、内核参数 |
| Read timed out | 客户端在等待数据时超过了自身设置的超时时间,不一定是对方主动断开 | 网络稳定性、服务端处理耗时、代码里的 timeout 参数 |
| Connection refused | 服务端端口没有监听,或网络根本无法到达 | 端口状态、服务进程、网络连通性 |
当你看到connection is closed by foreign host时,优先去服务端日志、sshd 配置里找原因;当你看到Connection reset by peer时,就要优先查防火墙、安全组、进程存活状态。这个区分看起来基础,但能省掉大把无效排查时间。
2. 导致连接被对端关闭的几类主要原因
2.1 服务端会话保活策略与资源限制
多数生产环境里,这个报错来自服务端主动断开。为了不让空闲 SSH 连接长期占用资源,sshd 会配置保活和超时策略,核心参数是ClientAliveInterval和ClientAliveCountMax。ClientAliveInterval表示服务器每隔多少秒向客户端发送一次加密的存活探测消息,ClientAliveCountMax表示连续多少次没有收到客户端响应,就判定连接失效并主动断开。举个例子,如果这两个参数分别配置为 300 和 3,那么一个客户端连续 15 分钟没有任何响应,就会被服务器踢掉。你的程序如果在做一个长时间任务,期间一直没有交互数据,超过这个阈值后,服务器主动断开,客户端下一次读数据时就会收到connection is closed by foreign host。
除了保活参数,连接数限制也是常见的触发点。MaxStartups控制未认证连接的数量,MaxSessions控制每个连接上可以同时打开的 Session 数量。如果一台服务器并发量很大,而MaxStartups设置得很小,新连接可能在 SSH 协议协商阶段就被拒绝。有些时候 sshd 会先收下 TCP 连接,再在协议层回一条 busy 相关的 disconnect 消息,在客户端看来就是“foreign host 关闭了连接”。这类问题有一个明显的特征:报错集中在高并发时段,或者集中在用完某个连接池之后,而且换个时段测试往往又正常了。
ClientAliveInterval和ClientAliveCountMax在服务端的配置位置通常是/etc/ssh/sshd_config。需要注意的是,这两个参数控制的是服务端主动探测客户端的策略,跟客户端自己设置的心跳是两个方向的东西。服务端探测不到客户端存活,就会发 disconnect;客户端探测不到服务端响应,也会主动断开。
2.2 网络链路中的空闲回收
这一块最容易被忽略,但实际出现频率极高。很多内网环境里存在 NAT 网关、负载均衡器、安全审计设备,这些网络设备会维护一张 TCP 连接映射表。当一条 TCP 连接在一段时间内没有任何数据流动,网络设备会认为这条连接已经不再活跃,主动把它从映射表里清理掉,清理动作可能表现为向某一端发送 RST,也可能表现为静默丢弃后续数据包。
对 JSch 这种长连接场景来说,问题尤其明显。假设你写了一个定时任务,每天凌晨通过 SFTP 连接服务器拉取文件,任务里每处理一个文件之间有几秒钟甚至更长的等待时间。这些间隙里连接是空闲的,如果网络设备对空闲 TCP 的回收时间比你的任务间隔还要短,那么下一次读写时,连接已经被网络设备静默丢弃了。此时 JSch 的行为取决于底层 Socket 的状态:如果设备发了 RST,会报Connection reset by peer;如果设备静默丢弃,客户端会一直等待,直到触发客户端的超时逻辑,表现为Read timed out。但如果你恰好收到的是服务端在清理前发出的 disconnet 消息,就可能直接看到connection is closed by foreign host。
这类问题有个很典型的特征:本地直连服务器怎么测都正常,一旦通过跳板机、堡垒机或者公网网关之后,问题就会出现。原因是多了一段网络设备链路,多了更多空闲会话回收策略。对付它最直接的办法就是让连接保持活跃,定时产生流量,让 NAT 表项不会过期。JSch 里对应的机制是ServerAliveInterval,这个后面会单独讲。
2.3 客户端 Session 生命周期管理不当
代码层面同样容易埋雷。很多人写 JSch 时,习惯把 Session 对象存在全局变量或者缓存里,多个业务线程共用同一个 Session,或者一个 Session 反复打开 Channel 却从不关闭。JSch 的 Session 底层对应一条 SSH 连接,多个线程并发读写同一个 Session,状态管理很容易出问题。某些情况下,服务端因为协议层异常主动断开,客户端随后抛出connection is closed by foreign host,但真正原因却在代码结构上。
更常见的场景是“迟到的报错”。一个 Session 长时间不用,服务端判定为闲置连接并踢掉,但客户端对象还认为自己连着。等到下一次真正发数据时,JSch 才发现连接已经失效,于是抛异常。从时间上看,这个异常发生在你发起新操作的那一刻,特别容易让你误以为是这次操作出了问题,但根因其实是上一个 Session 早就被服务端清理了。我在不少项目里见过类似情况,排错时把新代码翻来覆去看,最后才发现是旧的全局 Session 一直在“装死”。
一个比较稳妥的实践是:每个业务线程尽量使用独立的 Session,或者用连接池统一管理;操作完成之后,在 finally 块里同时关闭 Channel 和 Session;不要把 Channel 挂到全局静态变量上;长连接场景一定要设置心跳参数,让连接在闲置期间也有协议层数据流动,避免被服务端误判为死连接。
2.4 认证完成后的 shell 限制、子系统与强制命令问题
还有一种情况比较隐蔽:认证明明通过了,但服务器端仍然在几毫秒到几秒内断开连接。比如某个账号的登录 shell 被设置为/usr/sbin/nologin或者/bin/false。当你用这个账号建立 SSH 会话时,sshd 在认证通过后会尝试启动用户 shell,结果发现 shell 不可执行,于是立刻发送 disconnect。如果 JSch 代码里只做 Session 建立和 SFTP 传输,没有主动检查认证后的状态,就会收到这个报错。
对于 SFTP 场景,还有一个容易踩的坑:/etc/ssh/sshd_config中配置了Subsystem sftp internal-sftp或Subsystem sftp /usr/libexec/sftp-server。如果子系统路径不存在、权限不对,或者 sftp 子系统被注释掉了,客户端打开 SFTP Channel 时会失败,有一定概率表现成对端直接断开。尤其是你新建了一个高权限账号,只给了密钥,却没有确认它的 shell 和子系统权限,这类问题非常常见。
另外,如果服务端配置了强制命令,比如ForceCommand指定了某个脚本,而脚本运行失败,连接也会被主动断开。这一类问题单靠客户端代码永远修不好,必须去检查服务器的账号配置、shell 路径、子系统配置和目录权限。
3. 排查与定位:从现象走到根因
3.1 第一步:确认报错出现的阶段和执行上下文
看到报错之后,不要急着改代码,先做一件事:把完整的堆栈信息打出来,确认这个异常是在session.connect()阶段出现,还是在channel.connect()阶段出现,又或者是在某个sftp.get()/sftp.put()执行过程中出现。不同阶段对应完全不同的排查方向。
如果异常出现在 Session 连接阶段,说明服务端在 SSH 协议协商阶段就把你断开了,优先检查 sshd 的认证限制、连接数限制、IP 白名单、账号 shell。如果异常出现在文件传输过程中,那是另一种情况,大概率是空闲超时、网络设备回收,或者客户端的重连机制没有写好。如果异常出现在sftp.get()过程中,但文件大小也不大、传输时间也不长,那还要考虑服务端磁盘文件系统异常、磁盘紧凑等,不过这不是主角。
建议在代码里把异常封装成带业务上下文的错误信息,至少包含:发生异常的阶段(getSession、connect、openChannel、put、get),目标服务器地址与端口,执行时间和线程名,完整的 JSch 异常堆栈。这样拿到生产问题时,不需要翻半天日志才能定位入口。
3.2 第二步:开启 JSch 内部调试日志
JSch 自带一套日志接口,但默认没有实现。很多人在排查时给项目加了 debug 参数,却发现 JSch 什么日志都不输出,原因就是没有给 JSch 设置 Logger。通过下面这段代码可以开启:
import com.jcraft.jsch.JSch; import com.jcraft.jsch.Logger; JSch.setLogger(new Logger() { @Override public boolean isEnabled(int level) { return true; } @Override public void log(int level, String message) { System.out.println("[JSch Log] level=" + level + ", message=" + message); } });开启之后重新执行一次连接,日志里会输出 SSH 版本交换、算法协商、认证方式、密钥交换的过程。通过日志可以非常直观地看到连接是在哪一步被中断的。比如日志停在kex: server->client ...之后突然没有任何输出,最终抛错,那很可能是在算法协商阶段出了问题。再比如日志停在某个认证方法的位置,那就优先看认证阶段。
日志里需要关注几个关键信息:是否出现SSH_MSG_DISCONNECT以及对应的 reason code;服务端返回的 disconnect 描述文本;协商出来的加密算法和版本号。有了这些,再对照服务端配置,基本能锁定一半以上的问题。
3.3 第三步:结合服务端日志,确认断开方是谁
客户端日志只能证明“对端关了我”,要确认服务端为什么关,还是要看服务端日志。以常见的 OpenSSH 服务端为例,主要看这几个位置:/var/log/secure或/var/log/auth.log,里面会有 sshd 的认证记录;sshd 配置里如果打开了LogLevel VERBOSE,可以看到更细的断开原因;如果服务端用了 systemd 管理,可以通过journalctl -u sshd查看最近的日志。
有一种很隐蔽的服务端行为,就是服务器本身的资源监控脚本或安全巡检脚本,会定期清理长时间空闲或者超出服务等级的 SSH 会话。这类清理动作在服务端日志里往往只留下一句session closed,没有更多细节。如果只盯着客户端看,很难想到根因在服务端的巡检策略上。这种场景下,需要结合实际业务时间点去对照:是不是每次空转到某个固定时长,就准时报错?如果是,服务端保活或清理策略的可能性就非常大了。
3.4 第四步:抓包确认断开方式
如果日志对不上,有条件的话可以做一次抓包。在客户端机器上通过 tcpdump 或 Wireshark 抓 22 端口流量,复现一次断连现象,观察断开前有没有收到 TCP RST,还是收到了 SSH 层的 disconnect 消息。不同结果指向的问题完全不同。
抓包时的几个判断依据:如果断开前收到SSH_MSG_DISCONNECT,说明对端按协议规范正常关闭,优先看对端服务器和链路设备的配置;如果收到 TCP RST,说明连接被异常终止,查防火墙、安全组、进程是否被重启;如果长时间什么都没收到,客户端干等,然后报超时,说明连接被网络设备静默丢弃,重点解决心跳保活问题。抓包看起来复杂,但一次就能把方向定死,比盲目改配置高效得多。
4. 代码层面可落地的解决方案
4.1 连接超时参数:setTimeout 和 connect(timeout) 的区别
JSch 提供了两个容易混淆的超时配置,一个是Session#setTimeout(int),一个是Session#connect(int)。它们的语义完全不同,我见过一些项目把两个都设成了很大的值,结果超时参数等于没设。
session.setTimeout(ms)设置的是 SSH 协议层的 I/O 超时,也就是读写 Socket 数据时的最长等待时间。设置为 0 表示不超时。注意,这个参数只影响协议层的读和写,影响的是每次 I/O 操作的等待上限,不是总时长。session.connect(ms)设置的是建立 SSH 会话的等待超时,超过这个时间还没完成握手,连接方法就会抛异常。
实际配置时,connect(timeout)应该设置成合理的值,比如 10 秒。如果网络状况差,可以放宽到 20 秒,但不要设置成 0 或者超大值。setTimeout要谨慎:如果你要传输大文件,SSH 底层数据流只要还在动,timeout 就不会触发;但如果你把 timeout 设置成 3 秒,服务器在一个慢速磁盘上返回大目录列表,客户端就会在读数据时超时,产生误报。一个比较稳妥的起点是:
session.setTimeout(0); session.connect(10000);也就是说,握手最多等 10 秒,建立连接之后,I/O 不主动超时,把你自己的业务超时逻辑放到外层去控制。这样能减少很多因为服务端处理慢而产生的假性断连报错。
4.2 ServerAlive 心跳机制,解决空闲被回收的核心手段
JSch 中和“心跳”直接相关的方法有两个:session.setServerAliveInterval(int interval)和session.setServerAliveCountMax(int max)。前者表示每隔多少毫秒发送一个 SSH 协议层的保活请求,后者表示连续多少个保活请求没有得到回复后,认为连接已经失效。
这个心跳是 SSH 协议层的机制,不是操作系统底层的 TCP KeepAlive。它有几个实际作用:让 NAT 网关、负载均衡器看到连接仍然活跃,不会回收映射表项;让服务端明确知道客户端存活,不会触发服务端的ClientAliveCountMax断开逻辑;当网络真的断开时,客户端可以快速失败,而不是让任务一直挂着。
参考配置:
session.setServerAliveInterval(30000); session.setServerAliveCountMax(3);含义是每 30 秒发送一次心跳,连续 3 次心跳都没有收到服务端响应,JSch 就判定连接失效并抛异常。这样既能保证 NAT 表项不过期,又能在网络真正断开时快速感知问题。心跳消息本身会消耗少量带宽和 CPU,如果一台机器上有上千个连接,interval 不要设置得太小,30 到 60 秒是比较常见的区间。如果链路质量较差,可以把 countMax 调大一点,避免偶发丢包就误判。另外要注意,ServerAliveInterval只对已经建立的会话有效,握手过程中的等待还需要靠connect(timeout)来控制。
4.3 重试机制与连接池的取舍
做了心跳之后,网络抖动、服务端重启仍然可能让连接断开,因此客户端必须配合重试机制。但很多项目的重试写法比较粗糙,是同一个 Channel 上反复重发命令,这在 SSH 场景下并不合适,因为连接已经失效时,Channel 对象也无法复用。重试的正确逻辑是:捕获到连接级异常后,先主动关闭当前 Session,再重新创建 Session 和 Channel,完成重新认证,然后重试传输操作。同时要限制最大重试次数,不能无限重试,否则会把故障服务器的压力放大。
我个人的习惯是:最多重试 3 次,每次间隔用递增策略,比如 1 秒、2 秒、4 秒。如果第一次重连就成功了,后面的延时就不需要等待。这样既保证了短时网络波动的自愈能力,又防止在真正故障时把日志刷爆。如果有大量并发任务,连接池的设计也很重要。连接池要维护空闲连接的存活状态,不能把超过空闲阈值的连接继续给业务线程使用。最简单的做法是:每次从池里取连接前,先做一次isConnected()检查,但这个检查并不能保证下一秒连接就一定正常,所以业务操作里仍然要带重试逻辑。
4.4 完整示例:带心跳、带重试的 SFTP 工具类骨架
这里给出一个简化的示例,把超时参数、心跳参数、异常关闭和重试逻辑串起来,方便理解整个配置的用法。
import com.jcraft.jsch.*; public class SftpTemplate { private static final int CONNECT_TIMEOUT = 10000; private static final int SERVER_ALIVE_INTERVAL = 30000; private static final int SERVER_ALIVE_COUNT_MAX = 3; private static final int MAX_RETRY = 3; public byte[] download(String host, String user, String privateKeyPath, String remotePath) throws Exception { Exception lastErr = null; for (int i = 0; i < MAX_RETRY; i++) { JSch jSch = new JSch(); Session session = null; try { jSch.addIdentity(privateKeyPath); session = jSch.getSession(user, host, 22); session.setConfig("StrictHostKeyChecking", "no"); session.setTimeout(0); session.setServerAliveInterval(SERVER_ALIVE_INTERVAL); session.setServerAliveCountMax(SERVER_ALIVE_COUNT_MAX); session.connect(CONNECT_TIMEOUT); ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp"); sftp.connect(CONNECT_TIMEOUT); byte[] data; try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { sftp.get(remotePath, out); data = out.toByteArray(); } sftp.disconnect(); return data; } catch (Exception e) { lastErr = e; if (session != null && session.isConnected()) { session.disconnect(); } Thread.sleep(1000L * (i + 1)); } } throw lastErr == null ? new RuntimeException("download failed") : lastErr; } }这段代码里把StrictHostKeyChecking设置成了no,这是示例环境为了省事,生产环境不要直接照抄。严格的主机密钥校验可以防止中间人攻击,正确做法是把目标服务器的公钥指纹放到 known_hosts 文件里,然后把严格检查设为yes。这段代码的核心价值在于展示了:连接参数如何配置、异常时如何关闭旧 Session、重试时如何重建连接。
4.5 核心参数速查
| 参数 | 作用 | 建议值 |
|---|---|---|
| session.connect(timeout) | SSH 握手超时,只影响 connect 过程 | 10~30 秒 |
| session.setTimeout(ms) | SSH 协议层 I/O 读等待超时 | 长任务建议 0,交给外层控制 |
| session.setServerAliveInterval(ms) | 心跳间隔,保证连接活跃 | 30~60 秒 |
| session.setServerAliveCountMax(n) | 心跳失败容忍次数 | 2~5 次 |
| StrictHostKeyChecking | 主机密钥校验策略 | 生产环境使用 yes |
5. 常见问题与排查技巧速查
5.1 报错组合速查表
| 现象 | 优先排查方向 |
|---|---|
| session.connect 时直接报 closed by foreign host | sshd 的 MaxStartups、MaxSessions、IP 限制、账号 shell |
| 长任务执行中途报错 | 服务端 ClientAliveInterval、NAT 空闲回收、客户端心跳未配置 |
| 服务器重启后批量报错 | 网络设备残留 TCP 状态、服务端未完全启动 |
| 只有特定服务器报错 | 该服务器的 sshd 配置、安全组、防火墙策略 |
| 并发执行时高频报错 | Session 对象被多个线程共用、连接数超限 |
| 刚连上立刻断开 | 登录 shell 异常、子系统路径错误、服务端强制命令失败 |
5.2 容易被忽略的三个细节
第一,DNS 解析。如果代码里每次创建 JSch 对象都用域名,而域名在断连期间解析结果变化了,重连逻辑可能连到另一台服务器。客户端看到的目标变了,但服务端看到的用户没变,这种情况很难排查。所以生产环境尽量用固定内网 IP,或者把 DNS 解析结果缓存下来。
第二,服务器时间。SSH 协议本身并没有强制客户端和服务端时钟一致,但在一些堡垒机或审计环境中,会话时长、登录时间如果对不上,安全策略会介入,表现为连接被对端关闭。出现无规律断连时,可以先检查双方时钟偏差。
第三,批量任务里的连接复用。如果在 for 循环里复用同一个 Session 开多个 Channel,一定要确认前一个 Channel 是否真正关闭。很多奇怪的报错来自资源没有释放,导致服务端认为客户端行为异常,主动断开。Channel 的关闭不能只靠disconnect(),还要注意输入输出流也要关闭。
5.3 一次生产事故复盘
之前处理过一次文件归集任务报错,半夜开始大面积出现connection is closed by foreign host。一开始怀疑是服务端维护,检查 sshd 日志却发现一切正常。后来发现是服务端前面的网络设备对空闲 TCP 连接的回收时间只有 15 分钟,而任务里每个文件的处理间隔刚好超过了 15 分钟。代码里没有配置任何心跳,连接在网络设备处被静默清理,客户端在下一次写数据时才发现异常。
解决思路并不复杂:把ServerAliveInterval设置为 60 秒,断连前最多容忍 3 次丢包,同时增加项目自己的重试机制。上线之后这个报错基本消失。这个问题最坑的地方在于,服务端日志完全正常,因为你根本没有真正触达服务端,连接在网络链路就被切断了。如果当时只盯着服务端的 sshd 日志,可能永远找不到原因。
5.4 一些额外经验
如果你用的是较老的 JSch 版本,建议先升级到最新版。老版本在密钥交换算法、主机密钥类型的支持上比较有限,某些情况下连接被对端关闭,本质上是因为服务端拒绝了老旧的算法协商结果。升级之后,很多兼容性问题会自然消失。
还有一点,很多项目会先用ChannelExec执行命令,再在同一个 Session 上开ChannelSftp传文件。同一个 SSH 连接可以开多个 Channel,但部分服务端会对同时打开的 Channel 数量有限制。如果发现命令和文件传输组合操作时出现异常,可以尝试把命令执行和 SFTP 传输拆到两个独立 Session 里。多一层隔离,排障会更清晰。
最后想说,不建议把connection is closed by foreign host单纯当作“网络断了”来处理。我见过有团队把这类异常统一 catch 后重试 5 次,结果真正原因是账号 shell 不允许登录,重试只会把日志刷爆,没有任何意义。先定位,再修复,比盲目重试有价值得多。根据我个人的排查经验,遇到这个报错,先开 JSch 日志,再看服务端 sshd 配置,然后补上心跳和重试,这一套组合拳能覆盖绝大多数情况。如果还不行,就去抓包看一眼断开方式,基本不会再被它卡住。