《Java 网络编程基础》7.3 连接状态机与空闲检测

连接卡在 CLOSE_WAIT、空闲连接占着内存,靠 if-else 堆状态迟早漏分支。本节用 linkd 的 ConnectionStateMachine 枚举 CONNECTED、READING、CLOSING、CLOSED 四种状态,配合 lastReadNanos 与 IdleTimeoutHandler 定时扫描,给出半关闭与资源释放顺序。

本节目标:用四个枚举状态约束连接的生命周期,用单调时钟判断空闲,并把状态迁移、PING 和关闭放到正确的事件循环线程上。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、Selector、ScheduledExecutorService。

承接的问题

7.2 解决了「一条消息到哪里结束」,但没有回答「一个连接什么时候结束」。linkd 的连接对象上有好几个布尔值:closed、closing、reading、halfClosed。它们组合出十六种状态,其中大多数是非法的:已经 closed 又 reading、closing 之后又入队、halfClosed 之后又注册 OP_WRITE。

这些非法组合在测试里不常出现,在生产里会以两种形式暴露:

  • CLOSE_WAIT 堆积。 对端发了 FIN,read 返回 -1,但本端没有走关闭流程,socket 一直停在 CLOSE_WAIT,文件描述符和缓冲区都不释放。
  • 空闲连接占内存。 客户端断网、进程被杀、NAT 表项过期,TCP 层没有收到 FIN,连接在服务端看来仍然「活着」。每个连接持有累积缓冲和写队列,几千个僵尸连接就是几百 MB。

这两种问题的共同点是:连接没有明确的状态,代码只能靠零散的 if 猜。本节把状态收成四个枚举值,把迁移规则写成一张表,再用单调时钟和定时扫描把「空闲」变成可执行的判断。

核心机制一:四个状态与合法迁移

ConnectionStateMachine 只定义 CONNECTED、READING、CLOSING、CLOSED 四个状态。迁移表是状态机的核心,非法迁移必须立刻暴露,而不是静默接受:

当前事件下一个动作
CONNECTED可读事件到达READING更新 lastReadNanos
READING本次数据处理完CONNECTED无
CONNECTED / READING主动关闭、超时、EOFCLOSING停止接受新数据
CLOSING写队列排空、通道关闭CLOSED释放缓冲区
任意异常、RSTCLOSED直接释放

注意 CLOSING 不是「立刻 close」。它表示「不再接受新的入队,但已有的写队列还要排空」。7.1 的 Connection.close() 契约是「停止接收新数据、等待写队列排空、关闭通道」,CLOSING 就是这条契约的显式状态。

完整实现:

package com.plume.linkd.core;

import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import java.util.function.LongSupplier;

public final class ConnectionStateMachine {

    public enum State {
        CONNECTED, READING, CLOSING, CLOSED
    }

    private static final Map<State, Set<State>> ALLOWED = Map.of(
            State.CONNECTED, EnumSet.of(State.READING, State.CLOSING, State.CLOSED),
            State.READING, EnumSet.of(State.CONNECTED, State.CLOSING, State.CLOSED),
            State.CLOSING, EnumSet.of(State.CLOSED),
            State.CLOSED, EnumSet.noneOf(State.class));

    private final LongSupplier clock;
    private volatile State state = State.CONNECTED;
    private volatile long lastReadNanos;
    private volatile long lastWriteNanos;

    public ConnectionStateMachine() {
        this(System::nanoTime);
    }

    public ConnectionStateMachine(LongSupplier clock) {
        this.clock = clock;
        long now = clock.getAsLong();
        this.lastReadNanos = now;
        this.lastWriteNanos = now;
    }

    public State state() {
        return state;
    }

    public void onReadable() {
        State current = state;
        if (current == State.CONNECTED) {
            transition(State.READING);
        } else if (current != State.READING) {
            return;
        }
        lastReadNanos = clock.getAsLong();
    }

    public void onDrained() {
        if (state == State.READING) {
            transition(State.CONNECTED);
        }
    }

    public void touchWrite() {
        lastWriteNanos = clock.getAsLong();
    }

    public void beginClose() {
        if (state == State.CLOSED || state == State.CLOSING) {
            return;
        }
        transition(State.CLOSING);
    }

    public void closed() {
        if (state != State.CLOSED) {
            transition(State.CLOSED);
        }
    }

    public long idleNanos(long nowNanos) {
        long last = Math.max(lastReadNanos, lastWriteNanos);
        return Math.max(0, nowNanos - last);
    }

    public long idleMillis(long nowNanos) {
        return idleNanos(nowNanos) / 1_000_000L;
    }

    private void transition(State next) {
        State current = state;
        if (!ALLOWED.get(current).contains(next)) {
            throw new IllegalStateException("illegal transition " + current + " -> " + next);
        }
        state = next;
    }
}

三个设计点:

  • ALLOWED 是显式的白名单。 新增状态时必须同步改这张表,漏改会在第一次非法迁移时抛 IllegalStateException,而不是悄悄放行。
  • LongSupplier 让时钟可替换。 生产用 System::nanoTime,测试注入假时钟,就能在毫秒内验证「60 秒 PING、120 秒关闭」,不用真的等两分钟。
  • 字段是 volatile,但迁移只在事件循环线程发生。 volatile 保证定时扫描线程看到最新状态和纳秒值;真正的写操作仍然要投递回事件循环,不能让扫描线程直接改状态。

beginClose() 和 closed() 故意做成幂等:重复调用不抛异常。连接关闭的路径很多(超时、EOF、异常、进程停机),每条路径都可能重复触发关闭,幂等比「保证只调用一次」更可靠。

核心机制二:lastReadNanos 与空闲判定

空闲时间是「距离最后一次有效活动过了多久」。这里有两个时间戳:lastReadNanos 和 lastWriteNanos,取两者最大值:

long last = Math.max(lastReadNanos, lastWriteNanos);
long idle = Math.max(0, nowNanos - last);

只记读会误杀「服务端主动推送」的连接:WebSocket 广播、SSE、消息推送都是只写不读,lastReadNanos 一直停在建立连接那一刻。只记写会误杀「只订阅不发言」的客户端:它周期性发心跳,但服务端没有下行数据。取最大值才表示「两个方向都安静了」。

为什么用 System.nanoTime() 而不是 System.currentTimeMillis():

  • nanoTime 是单调时钟,不受系统时间调整影响;currentTimeMillis 会被 NTP 回拨或手动改时间影响,可能让 idle 变成负数,或者永远到不了阈值。
  • nanoTime 只适合算差值,不能当日期用;日志里要打印墙钟时间,需要另外记录。

Math.max(0, ...) 是对「时钟源被替换」的防御。正常情况下 now 不会小于记录值,但注入假时钟的测试、或者未来换成别的时钟源时,负数会让「空闲超过阈值」永远不成立。

判定阈值有两个:pingAfter 和 closeAfter。linkd 的默认值是 60 秒发 PING、120 秒关闭,约束是 0 < pingAfter < closeAfter。构造函数直接校验,避免配置成「先关闭再 ping」这种不可能的顺序。

核心机制三:定时扫描与 IdleTimeoutHandler

空闲检测不能用 Selector.select(timeout) 代替。select(1000) 的超时只说明「这一秒内没有事件」,不代表连接空闲;一个每 10 秒发一次心跳的连接,在两次心跳之间 select 也会超时。空闲是按连接算的,必须遍历连接。

扫描有两种实现:

  1. 在事件循环里每次 select 返回后检查一次。事件密集时扫描过频,事件稀疏时又依赖超时,节拍不稳定。
  2. 独立的 ScheduledExecutorService 每秒触发一次,把检查任务投递到连接所属的事件循环。节拍稳定,且状态读写仍然发生在事件循环线程。

linkd 用第二种。扫描线程只做两件事:读取连接列表、投递任务;它不碰 SocketChannel,也不改状态机。这样多 Reactor 下每个连接的状态只由自己的循环线程修改,不需要锁。

IdleTimeoutHandler 是包在业务 Handler 外面的装饰器,负责在正确的时机更新时间和执行动作:

package com.plume.linkd.core;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public final class IdleTimeoutHandler implements Handler {

    public enum Action {
        NONE, PING, CLOSE
    }

    private static final byte[] PING_FRAME = "PING\n".getBytes(StandardCharsets.US_ASCII);

    private final Handler next;
    private final ConnectionStateMachine state;
    private final long pingAfterNanos;
    private final long closeAfterNanos;
    private boolean pingSent;

    public IdleTimeoutHandler(Handler next, ConnectionStateMachine state,
                              long pingAfterMillis, long closeAfterMillis) {
        if (pingAfterMillis <= 0 || closeAfterMillis <= pingAfterMillis) {
            throw new IllegalArgumentException("expect 0 < pingAfter < closeAfter");
        }
        this.next = next;
        this.state = state;
        this.pingAfterNanos = pingAfterMillis * 1_000_000L;
        this.closeAfterNanos = closeAfterMillis * 1_000_000L;
    }

    @Override
    public void onReadable(Connection c) {
        state.onReadable();
        pingSent = false;
        next.onReadable(c);
    }

    @Override
    public void onWritable(Connection c) {
        state.touchWrite();
        next.onWritable(c);
    }

    @Override
    public void onClosed(Connection c) {
        state.closed();
        next.onClosed(c);
    }

    public Action checkIdle(Connection c, long nowNanos) {
        ConnectionStateMachine.State current = state.state();
        if (current == ConnectionStateMachine.State.CLOSED
                || current == ConnectionStateMachine.State.CLOSING) {
            return Action.NONE;
        }
        long idle = state.idleNanos(nowNanos);
        if (idle >= closeAfterNanos) {
            state.beginClose();
            c.close();
            return Action.CLOSE;
        }
        if (idle >= pingAfterNanos && !pingSent) {
            pingSent = true;
            c.enqueue(ByteBuffer.wrap(PING_FRAME.clone()));
            state.touchWrite();
            return Action.PING;
        }
        return Action.NONE;
    }

    public void halfClose(Connection c) {
        if (state.state() == ConnectionStateMachine.State.CLOSED
                || state.state() == ConnectionStateMachine.State.CLOSING) {
            return;
        }
        state.beginClose();
        try {
            c.channel().shutdownOutput();
        } catch (IOException ignored) {
            // 对端已复位或通道已关闭,交给 close() 收尾
        }
    }
}

四个关键行为:

  • onReadable 同时更新状态和 lastReadNanos,并重置 pingSent。 收到任何字节都算「对端还活着」,包括 PONG、业务数据、甚至一个空帧的头部。
  • checkIdle 在 PING 之后调用 touchWrite()。 这一行很容易漏。PING 本身是一次写操作,如果不更新时间,下一次扫描会认为「空闲时间还在增长」,刚发完 PING 就触发关闭。PING 之后重新计时,对端有完整的 closeAfter 窗口来响应。
  • pingSent 保证一个空闲周期只发一次 PING。 如果对端不响应,不会每秒刷一个 PING;到 closeAfter 直接关闭。收到数据后重置,下一个空闲周期可以再发。
  • PING_FRAME.clone() 每次新建数组。 ByteBuffer.wrap 共享同一个 byte[] 没问题,但 ByteBuffer 自身的 position 会被写操作推进;如果把同一个 ByteBuffer 实例反复 enqueue,第二次写出去的是空缓冲区。每次 wrap 一个新实例,或者 clone 数组,都是为了避免这个坑。

用假时钟验证一遍,pingAfter=60s、closeAfter=120s。下面是本机输出,时间轴是注入的假时钟,不是真实等待:

t=0s state=CONNECTED idle=0ms
t=10s after read state=READING idle=0ms
t=20s after drain state=CONNECTED
t=81s action=PING state=CONNECTED events=[enqueue PING]
t=141s action=NONE state=CONNECTED
t=202s action=CLOSE state=CLOSING events=[enqueue PING, close]
after onClosed state=CLOSED action=NONE
  • 第 10 秒收到数据,状态进入 READING 并更新 lastReadNanos;第 20 秒处理完,回到 CONNECTED。
  • 第 81 秒距离最后一次读已 71 秒,超过 60 秒阈值,动作是 PING,状态仍是 CONNECTED。
  • 第 141 秒距离 PING 只过了 60 秒,pingSent 仍为 true,动作是 NONE。
  • 第 202 秒距离 PING 已 121 秒,超过 120 秒阈值,状态转 CLOSING 并调用 close();onClosed 随后推到 CLOSED,再扫描返回 NONE。

在 linkd 里落地:扫描任务、半关闭与资源释放

扫描任务在 LinkdMain 里装配,按「事件循环 → 连接 → 装饰器」两层组织,保证检查任务回到连接所属的循环:

Map<EventLoop, Map<Connection, IdleTimeoutHandler>> tracked = new ConcurrentHashMap<>();
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(
        Thread.ofPlatform().name("linkd-idle").daemon().factory());
timer.scheduleAtFixedRate(() -> tracked.forEach((loop, perLoop) ->
        perLoop.forEach((c, handler) ->
                loop.execute(() -> handler.checkIdle(c, System.nanoTime())))),
        1, 1, TimeUnit.SECONDS);

这段装配有两个约束:

  • 扫描线程不直接调 checkIdle。 checkIdle 会改状态机、enqueue PING、甚至 close(),这些都必须在事件循环线程执行。扫描线程只负责投递,投递本身通过 EventLoop.execute(Runnable)。
  • System.nanoTime() 在任务内部取。 如果在扫描线程取好再传进去,任务排队 200 ms 后执行,用的就是旧时间;虽然偏差不大,但没必要。tracked 也要在连接关闭时移除,否则扫描列表会随历史连接数增长。

关闭顺序比扫描更敏感。一次完整的关闭分五步,顺序不能乱:

  1. beginClose()。 状态进入 CLOSING,拒绝新的 enqueue,后续到达的数据可以选择丢弃或读完。
  2. 排空写队列。 调用 WriteQueue.writeTo 直到返回 true;如果对端不读,最多等一个宽限期,超时转强制关闭。
  3. 半关闭(可选)。 只有在写队列已空、且协议用 EOF 表示结束时,才调用 halfClose(c) 发 FIN。HTTP/1.1 的 keep-alive 连接不能这么做,否则连接无法复用;WebSocket 关闭应该先发 Close 帧,再等对端回 Close。
  4. 释放缓冲区。 WriteQueue.clear() 逐个 pool.release,AccumulatingReader.onClosed 释放累积缓冲区。已经交给内核的字节不归这些缓冲区管。
  5. 关闭通道并摘除登记。 key.cancel()、channel.close()、从 tracked 移除。close() 要幂等,因为超时扫描和异常路径可能同时走到这里。

半关闭用 channel().shutdownOutput() 发 FIN,本端仍然可读,适合「请求已发完、等响应」的协议;Connection.close() 是完整关闭,内部先排空写队列再关通道,适合服务端主动断开。两者之后状态必须是 CLOSING 或 CLOSED,再 enqueue 应被拒绝并记录。

CLOSE_WAIT 是这套状态机最直接的验收指标。对端关闭后,如果本端没有走到第 5 步,netstat -anv -p tcp 里会看到连接停在 CLOSE_WAIT;数量持续上涨,说明有代码路径收到了 -1 却没有关闭连接。macOS 上可以用下面的命令统计:

netstat -anv -p tcp | awk '$6 == "CLOSE_WAIT" {count++} END {print count + 0}'

这里不贴本机输出,因为是否出现 CLOSE_WAIT 取决于当前是否有连接走到该状态;命令本身可以直接复用。

常见错误与边界

错误一:用 currentTimeMillis 算空闲。

// 反例:墙钟被 NTP 回拨后 idle 变成负数
long idle = System.currentTimeMillis() - lastReadMillis;

系统时间向前跳会导致空闲时间突然变大,误杀连接;向后跳会导致空闲时间永远不到阈值,僵尸连接不释放。用 System.nanoTime() 算差值,墙钟只用于日志。

错误二:扫描线程直接改状态。

// 反例:在定时线程里直接写通道
if (idle > timeout) {
    connection.channel().close();
}

SocketChannel 和状态机都不是线程安全的。定时线程关闭通道的同时,事件循环可能正在 read 或 write,结果是 ClosedChannelException、状态错乱,或者写队列里的缓冲区被双重释放。所有操作都要 loop.execute 回事件循环。

错误三:只记录 lastReadNanos。 推送型连接只写不读,60 秒后被判空闲,PING 发不出去就被关闭。反过来只记录 lastWriteNanos,只发心跳不收数据的客户端会被误杀。取两个方向的最大值。

错误四:发完 PING 不更新 lastWriteNanos。 下一次扫描看到的仍是「距离上次业务活动 61 秒」,如果阈值刚好跨过,就会立刻关闭,对端连 PING 都来不及回。PING 是一次写,必须计入活动时间。

错误五:收到 -1 只把状态改成 CLOSED,不关通道。 状态是应用层的,文件描述符不会因为枚举变了就释放。read 返回 -1 后必须走完整关闭流程,否则 socket 停在 CLOSE_WAIT。同理,CLOSING 状态下再 enqueue 会拿到 ClosedChannelException,实现必须拒绝新入队。

边界:TCP keepalive 不能替代应用层空闲检测。 系统级 keepalive 默认通常是 2 小时,且受中间设备、NAT 超时影响,时间不可控。应用层 PING/PONG 的间隔和关闭阈值由自己决定,才能和业务的心跳预算对齐。

小结

  • 连接生命周期收成 CONNECTED、READING、CLOSING、CLOSED 四态,非法迁移用白名单直接抛异常。
  • CLOSING 表示「停止接收新数据、排空写队列」,不是立刻 close。
  • 空闲时间取 lastReadNanos 和 lastWriteNanos 的最大值,用 System.nanoTime() 算差值。
  • IdleTimeoutHandler 在 60 秒无活动时发 PING,PING 后 120 秒仍无响应则关闭;PING 后必须 touchWrite,并重置 pingSent。
  • 定时扫描线程只投递任务,状态和通道操作全部回到连接所属的事件循环;关闭顺序是 beginClose、排空写队列、可选半关闭、释放缓冲区、关闭通道并摘除登记。

到这一章结束,非阻塞读写、半包处理、连接生命周期都有了可运行的实现。下一章换一个方向:把这些字节按 HTTP/1.1 的请求行、头部和消息体解析出来,让 linkd 真正听懂浏览器发来的内容。

阅读导航:上一节:7.2 半包、粘包与缓冲区增长 · 下一节:8.1 请求行、头部与消息体 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发