《Java 网络编程基础》10.2 SSLEngine 非阻塞握手

阻塞式 SSLSocket 会把握手绑在一条线程上,linkd 的 EventLoop 不能这样等。本节把 SSLEngine 封装成 NonBlockingTlsHandshake:逐轮驱动 wrap/unwrap,处理 BUFFER_OVERFLOW、BUFFER_UNDERFLOW 与 NEED_TASK,最后用 TlsHandshakeHandler 接进 6.2 的事件循环。

本节目标:把 SSLEngine 的 wrap/unwrap 状态机写成一个可以被事件循环反复调用的类,并正确处理欠载、溢出与委托任务。
适用版本:JDK 25(Temurin 25.0.5+8);只使用 javax.net.ssl 与 java.nio。

承接的问题

10.1 的 SSLSocket 很好用:startHandshake() 一调,线程就停在原地等对端。linkd 的 6.2 事件循环不能这样等——一条连接等一个往返,循环里排队的其他连接就一起等这个往返。TLS 握手必须拆成「读一段密文、推进一点状态、写一段密文」的步骤。

SSLEngine 就是为此设计的。它不持有 socket,也不创建线程;调用方把密文喂给 unwrap,把明文交给 wrap,引擎返回这次操作的状态和下一步该做什么。同一个引擎可以被事件循环反复调用,直到握手完成。

代价是调用方要自己管三个缓冲区、四种 Status、六种 HandshakeStatus,还要处理「目标缓冲区不够」和「记录还没收全」。这一节把这些细节收进 NonBlockingTlsHandshake,10.3 再处理会话复用和 ALPN。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:wrap、unwrap 与四种返回状态

SSLEngine 只有两个数据方向:

  • wrap(appData, netOut):把应用明文加密成 TLS 记录,写进 netOut。
  • unwrap(netIn, appIn):把 netIn 里的 TLS 记录解密,写进 appIn。

每次调用返回一个 SSLEngineResult,其中的 Status 有四种:

Status含义调用方动作
OK有进展继续循环,直到没有更多数据
BUFFER_UNDERFLOWnetIn 里的记录不完整保留剩余字节,等下一次读事件
BUFFER_OVERFLOW目标缓冲区不够扩容目标缓冲区,重试同一操作
CLOSED收到或发出了 close_notify停止读写,关闭连接

HandshakeStatus 描述握手下一步:

HandshakeStatus含义调用方动作
NEED_WRAP有握手数据要发用空应用缓冲区调用 wrap
NEED_UNWRAP等对端握手数据从 netIn 解一条记录
NEED_UNWRAP_AGAIN还有已缓冲的记录要解不读新数据,继续 unwrap
NEED_TASK有委托任务(签名、验签)取出任务异步执行,完成后唤醒
FINISHED握手刚完成记录完成标志
NOT_HANDSHAKING当前没有握手已完成时可传应用数据

缓冲区大小不要写死 16384。本机在 TLS 1.3 下打印:

packet=16709
application=16704

packet 是 JSSE 为单条 TLS 记录预留的缓冲区大小,包含 5 字节记录头、1 字节内容类型、16 字节 AEAD tag 和填充;application 是单次 unwrap 最多能吐出的明文字节。握手完成后 JSSE 可能调整这两个值,所以扩容要按 getSession() 的当前值来,不能只信启动时的一次读取。

核心机制二:NonBlockingTlsHandshake 状态机

这个类只做三件事:把 socket 读到的密文攒进 netIn,按 HandshakeStatus 驱动引擎,把产生的密文和明文分别交出去。

package com.plume.linkd.tls;

import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLEngineResult;
import javax.net.ssl.SSLException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

public final class NonBlockingTlsHandshake {

    public enum Status { NEED_READ, NEED_WRITE, NEED_TASK, DONE }

    public record Pump(Status status, List<ByteBuffer> encrypted, List<ByteBuffer> application) { }

    private static final ByteBuffer EMPTY = ByteBuffer.allocate(0);

    private final SSLEngine engine;
    private final Executor tasks;
    private final Runnable wakeup;
    private ByteBuffer netIn;
    private ByteBuffer netOut;
    private ByteBuffer appIn;
    private boolean finished;
    private boolean closed;

    public NonBlockingTlsHandshake(SSLEngine engine, Executor tasks, Runnable wakeup)
            throws SSLException {
        this.engine = engine;
        this.tasks = tasks;
        this.wakeup = wakeup;
        int packet = engine.getSession().getPacketBufferSize();
        this.netIn = ByteBuffer.allocate(packet);
        this.netIn.flip();
        this.netOut = ByteBuffer.allocate(packet);
        this.appIn = ByteBuffer.allocate(engine.getSession().getApplicationBufferSize());
        engine.beginHandshake();
    }

    public void feed(ByteBuffer fromSocket) {
        netIn.compact();
        if (netIn.remaining() < fromSocket.remaining()) {
            ByteBuffer bigger = ByteBuffer.allocate(netIn.position() + fromSocket.remaining());
            netIn.flip();
            bigger.put(netIn);
            netIn = bigger;
        }
        netIn.put(fromSocket);
        netIn.flip();
    }

    public Pump pump() throws SSLException {
        List<ByteBuffer> encrypted = new ArrayList<>();
        List<ByteBuffer> application = new ArrayList<>();
        while (!closed) {
            SSLEngineResult.HandshakeStatus hs = engine.getHandshakeStatus();
            if (hs == SSLEngineResult.HandshakeStatus.FINISHED) {
                finished = true;
                continue;
            }
            switch (hs) {
                case NEED_TASK -> {
                    if (!runTasks()) {
                        return new Pump(Status.NEED_TASK, encrypted, application);
                    }
                }
                case NEED_WRAP -> {
                    if (wrap(EMPTY, encrypted).getStatus() == SSLEngineResult.Status.CLOSED) {
                        closed = true;
                    }
                }
                case NEED_UNWRAP, NEED_UNWRAP_AGAIN -> {
                    if (!netIn.hasRemaining()) {
                        return finish(encrypted, application);
                    }
                    if (unwrap(encrypted, application).getStatus()
                            == SSLEngineResult.Status.BUFFER_UNDERFLOW) {
                        return finish(encrypted, application);
                    }
                }
                case NOT_HANDSHAKING, FINISHED -> {
                    finished = true;
                    if (netIn.hasRemaining()) {
                        if (unwrap(encrypted, application).bytesConsumed() > 0) {
                            continue;
                        }
                    }
                    return finish(encrypted, application);
                }
            }
        }
        return new Pump(Status.DONE, encrypted, application);
    }

    private Pump finish(List<ByteBuffer> encrypted, List<ByteBuffer> application) {
        Status status = !encrypted.isEmpty() ? Status.NEED_WRITE
                : (finished || closed) ? Status.DONE : Status.NEED_READ;
        return new Pump(status, encrypted, application);
    }

    private boolean runTasks() {
        List<Runnable> delegated = new ArrayList<>();
        Runnable task;
        while ((task = engine.getDelegatedTask()) != null) {
            delegated.add(task);
        }
        if (delegated.isEmpty()) {
            return true;
        }
        CompletableFuture<?>[] running = delegated.stream()
                .map(t -> CompletableFuture.runAsync(t, tasks))
                .toArray(CompletableFuture[]::new);
        CompletableFuture.allOf(running).thenRun(wakeup);
        return false;
    }

    private SSLEngineResult wrap(ByteBuffer application, List<ByteBuffer> encrypted)
            throws SSLException {
        while (true) {
            netOut.clear();
            SSLEngineResult result = engine.wrap(application, netOut);
            if (result.getStatus() == SSLEngineResult.Status.BUFFER_OVERFLOW) {
                netOut = ByteBuffer.allocate(netOut.capacity() * 2);
                continue;
            }
            if (netOut.position() > 0) {
                netOut.flip();
                ByteBuffer copy = ByteBuffer.allocate(netOut.remaining());
                copy.put(netOut);
                copy.flip();
                encrypted.add(copy);
            }
            return result;
        }
    }

    private SSLEngineResult unwrap(List<ByteBuffer> encrypted, List<ByteBuffer> application)
            throws SSLException {
        while (true) {
            appIn.clear();
            SSLEngineResult result = engine.unwrap(netIn, appIn);
            if (appIn.position() > 0) {
                appIn.flip();
                ByteBuffer copy = ByteBuffer.allocate(appIn.remaining());
                copy.put(appIn);
                copy.flip();
                application.add(copy);
            }
            if (result.getStatus() == SSLEngineResult.Status.BUFFER_OVERFLOW) {
                appIn = ByteBuffer.allocate(appIn.capacity() * 2);
                continue;
            }
            return result;
        }
    }

    public boolean isHandshakeFinished() {
        return finished;
    }
}

几个关键点:

  • 构造函数里先 flip()。 ByteBuffer.allocate() 出来是填充模式,position=0、limit=capacity,hasRemaining() 为真。不 flip 就调用 unwrap,引擎会把 32KB 零字节当成密文,报 Unrecognized SSL message, plaintext connection?。
  • feed 用 compact 保留半条记录。 BUFFER_UNDERFLOW 时 netIn 里剩的是下一条记录的开头,下一次读要和它拼起来。
  • pump 是循环,不是一次调用。 一次 unwrap 可能只解出一条记录,但 NEED_UNWRAP_AGAIN 要求继续;循环直到 NEED_READ、NEED_WRITE 或 DONE。
  • NOT_HANDSHAKING 也算完成。 JSSE 有时从 NEED_WRAP 直接进入 NOT_HANDSHAKING,不经过一次 FINISHED;只看 FINISHED 会漏掉完成标志。
  • 输出用堆缓冲区拷贝。 netOut 和 appIn 是复用的,直接把它们交给写队列,下一次 wrap/unwrap 会覆盖。拷贝一次换来所有权清晰。

用两个 SSLEngine 在内存里对灌,本机输出:

round 1 client=NEED_WRITE out=1, server=NEED_READ out=0
round 2 client=NEED_READ out=0, server=NEED_TASK out=0
round 3 client=NEED_READ out=0, server=NEED_WRITE out=3
round 4 client=NEED_TASK out=0, server=NEED_READ out=0
round 5 client=NEED_TASK out=1, server=NEED_READ out=0
round 6 client=NEED_WRITE out=1, server=NEED_READ out=0
round 7 client=DONE out=0, server=NEED_WRITE out=1
client finished=true protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
server finished=true protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384

第 1 轮客户端发 ClientHello;第 2 轮服务端解开后出现 NEED_TASK,把签名任务交给委托线程;第 3 轮服务端一次吐出 3 段密文;第 4、5 轮客户端在验证证书链,out=1 说明 Finished 已在任务完成前加密好;第 6 轮客户端发出 Finished;第 7 轮服务端的 out=1 通常是握手后的 NewSessionTicket,10.3 会用到。out 是密文缓冲区个数,不等于握手消息条数。

核心机制三:三种边界状态怎么处理

BUFFER_UNDERFLOW 不是错误。 它只表示 netIn 里的记录还没收全。正确处理是保留 netIn 的剩余字节,返回 NEED_READ,等下一次 onReadable。把 underflow 当异常、或者把剩余字节丢掉,会让下一条记录从中间开始解析,症状是随机握手失败。

BUFFER_OVERFLOW 要扩容后重试同一操作。 wrap 溢出说明 netOut 小于一条记录,unwrap 溢出说明 appIn 小于一条记录的明文。两个方法里都用 while (true) 包住,扩容后 continue,不能直接返回,否则这条记录永远发不出去。

NEED_TASK 不能就地跑。 委托任务包括证书链验证和私钥签名,耗时不可忽略。runTasks 把任务提交给执行器,全部完成后用 wakeup 把事件循环叫回来;pump 先返回 NEED_TASK。在事件循环线程里直接 task.run(),这条连接的握手会拖住所有连接。

在 linkd 里落地:TlsHandshakeHandler

TlsHandshakeHandler 把状态机接到 6.2 的 Handler 上。它只处理握手阶段,握手完成后由 linkd 换成 HTTP 协议处理器。

package com.plume.linkd.tls;

import com.plume.linkd.core.Connection;
import com.plume.linkd.core.EventLoop;
import com.plume.linkd.core.Handler;

import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;
import java.io.IOException;
import java.nio.ByteBuffer;

final class TlsHandshakeHandler implements Handler {

    private final NonBlockingTlsHandshake handshake;
    private final ByteBuffer inbound = ByteBuffer.allocate(16 * 1024);
    private Connection connection;

    TlsHandshakeHandler(SSLEngine engine, EventLoop loop) throws SSLException {
        handshake = new NonBlockingTlsHandshake(engine, loop::execute,
                () -> loop.execute(this::pump));
    }

    @Override
    public void onReadable(Connection c) {
        connection = c;
        try {
            int read = c.channel().read(inbound);
            if (read == -1) { c.close(); return; }
            if (read == 0) return;
            inbound.flip();
            handshake.feed(inbound);
            inbound.compact();
            pump();
        } catch (IOException e) { c.close(); }
    }

    @Override public void onWritable(Connection c) { connection = c; pump(); }

    @Override public void onClosed(Connection c) { }

    private void pump() {
        if (connection == null) return;
        try {
            NonBlockingTlsHandshake.Pump result = handshake.pump();
            for (ByteBuffer out : result.encrypted()) connection.enqueue(out);
            for (ByteBuffer app : result.application()) app.position(app.limit());
        } catch (SSLException e) { connection.close(); }
    }
}

onReadable 做四步:从 Connection.channel() 读到 inbound,flip 后交给 feed,compact 清空已消费部分,调用 pump。onWritable 只调用 pump,因为 6.2 的 Connection.flush() 已经把写队列排空,此时可以继续推进握手。

pump 把 result.encrypted() 逐个 connection.enqueue,由 6.2 的 Connection 打开 OP_WRITE 并负责部分写;result.application() 是握手完成后才出现的明文,这里先消费掉,10.3 之后按 ALPN 交给 HTTP/2 帧解码器或 HTTP/1.1 解析器。

NEED_TASK 的唤醒路径是 loop.execute(this::pump)。任务在业务执行器上跑完后,回到事件循环线程继续 pump;Connection 和 SSLEngine 的可变状态仍然只被一个线程碰。

常见错误与边界

错误一:netIn 分配后忘记 flip。 本机第一次跑就撞上:

javax.net.ssl.SSLException: Unrecognized SSL message, plaintext connection?

allocate 后的缓冲区 limit=capacity,里面全是零字节;unwrap 把零当成 TLS 记录头。构造函数里 netIn.flip() 一行就能避免。

错误二:用 16384 当记录缓冲区。 本机 JSSE 给 TLS 1.3 记录的 packet buffer 是 16709,包含记录头和 AEAD tag,比 16384 大。固定 16384 会在握手证书较大时反复 BUFFER_OVERFLOW,正确做法是 engine.getSession().getPacketBufferSize()。

错误三:只调用一次 unwrap。 一次读事件可能带来多条记录,unwrap 只解一条就返回,剩下的字节留在 netIn 里,握手会停在 NEED_UNWRAP 不动。必须循环到 BUFFER_UNDERFLOW、NEED_TASK 或 CLOSED。

错误四:把 BUFFER_UNDERFLOW 当握手失败。 半条记录是 TCP 流的常态,不是协议错误。只有 SSLException 和 CLOSED 才应该关闭连接。

错误五:在事件循环里执行 NEED_TASK。 证书链验证可能触发磁盘或 OCSP 访问,私钥签名也不便宜;这些任务必须交给业务执行器,完成后 wakeup。

错误六:忘记 close_notify。 直接关 socket 会让对端看到截断的 TLS 流,而不是干净的关闭。优雅关闭要先 engine.closeOutbound(),再 wrap 到 CLOSED,最后关通道;14.3 会把这条路径并进连接排空。

边界:本节只做握手。 不覆盖 0-RTT、客户端证书和握手后的 NewSessionTicket;票据要在 DONE 之后继续 unwrap,10.3 会讲。SSLEngine 默认也不校验主机名,必须显式设置 endpointIdentificationAlgorithm,同样留到 10.3。

小结

  • SSLEngine 不持有 socket,调用方负责 wrap/unwrap、缓冲区和线程。
  • Status 四种:OK 继续,BUFFER_UNDERFLOW 等数据,BUFFER_OVERFLOW 扩容重试,CLOSED 关闭。
  • NEED_TASK 的委托任务要交给执行器,完成后用 wakeup 回到事件循环。
  • 缓冲区按 getPacketBufferSize()/getApplicationBufferSize() 分配,不要写死 16384。
  • netIn 要在构造后 flip,BUFFER_UNDERFLOW 时要保留剩余字节。
  • TlsHandshakeHandler 把密文入 Connection 写队列,把明文留给后续协议处理器。

握手能非阻塞跑通之后,下一步是少握手:会话复用让第二次连接只花一个往返,ALPN 决定这条连接跑 h2 还是 http/1.1,主机名校验决定「证书到底是不是发给这个域名的」。下一节把这三件事收进 TlsSessionPolicy。

阅读导航:上一节:10.1 TLS 1.3 握手与证书链 · 下一节:10.3 会话复用、ALPN 与证书校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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