面试追问地图

主问题必讲关键点下一层追问
Reactor事件分发、EventLoop、Channel 绑定单/多 Reactor、为何减少锁
EventLoopGroup线程管理、chooser 策略、Channel 绑定boss vs worker、runAllTasks
Pipeline入站/出站 Handler 链执行顺序、异常传播、Sharable
ByteBuf读写索引、池化、直接内存引用计数、切片、泄漏检测
零拷贝FileRegion、CompositeByteBuf、wrappedBuffer、sliceOS 层 vs Netty 层、小文件场景
内存池Arena/Chunk/Subpage、伙伴算法线程绑定 Arena 为何减少竞争、小对象为何走 Subpage
粘包拆包字节流、协议边界长度字段偏移、恶意长度
EventLoop 阻塞一个线程管理多个连接业务线程池、任务回写
心跳重连Idle 检测、退避、状态恢复网络抖动、重连风暴
RPC 代理本地调用转远程请求泛化调用、异常映射
服务发现实例缓存、订阅变更注册中心不可用时如何调用
超时重试总超时、幂等、退避重试风暴、对冲请求
序列化性能、兼容、跨语言Schema 演进、安全风险

Netty/RPC 题应沿一条请求从代理入口讲到远端方法执行,再沿响应链返回,不能只列组件名称。


一、Netty 基础

Netty 解决什么问题?

难度 🟡

Netty 是基于 Java NIO 的异步事件驱动网络框架,封装了:

  • 连接建立和生命周期管理。
  • 多路复用和线程模型。
  • ByteBuf 内存管理。
  • 编解码和 Pipeline。
  • TLS、心跳、流量控制等常用能力。

它降低了直接使用 Selector 时处理空轮询、半包、线程安全和资源释放的复杂度。

Netty 的核心组件有哪些?

组件作用
Channel网络连接抽象
EventLoop处理 IO 事件和任务
ChannelPipelineHandler 处理链
ChannelHandler编解码和业务处理
ByteBuf字节容器
ChannelFuture异步操作结果

二、Reactor 与线程模型

Netty 的线程模型是什么?

难度 🔴

服务端常使用主从 Reactor:

Boss EventLoopGroup
  ↓ 接收连接
Worker EventLoopGroup
  ↓ 读写事件
ChannelPipeline
  ↓ 编解码与业务处理

一个 Channel 注册到一个 EventLoop 后,通常由该 EventLoop 串行处理事件,从而减少 Channel 内部状态的锁竞争。

为什么不能在 EventLoop 中执行耗时任务?

EventLoop 线程同时处理多个 Channel。如果某个 Handler 长时间阻塞,会导致同一 EventLoop 上其他连接无法及时读写。

耗时任务应:

  • 异步提交到业务线程池。
  • 设置超时和队列容量。
  • 处理完成后将结果写回 Channel。

ChannelHandler 是否线程安全?

不一定。Handler 是否可共享取决于是否保存可变状态。

标记 @Sharable 前必须确保:

  • 不保存每个连接独立状态。
  • 共享状态使用安全并发结构。
  • 编解码器本身允许复用。

EventLoopGroup 是怎么管理线程的?

频次 ★★★ · 难度 🟡

是什么EventLoopGroup 管理一组 EventLoop(每个 EventLoop 绑定一个线程,线程处理多个 Channel 的 IO 事件)。以 NioEventLoopGroup 为例:

NioEventLoopGroup 构造函数

new ThreadPerTaskExecutor()    ← 线程工厂,每次创建新线程

new NioEventLoop[n] children   ← 创建 n 个 EventLoop 数组

chooser = new DefaultChooser  ← 选择策略,新 Channel 绑定到哪个 EventLoop

三个关键设计

  1. 线程与 EventLoop 一对一:每个 NioEventLoop 构造函数里创建一个 FastThreadLocalThread,启动后不断执行 run() 循环:select()processSelectedKeys()runAllTasks()。线程一旦绑定 EventLoop 就不分裂,直到 EventLoopGroup 关闭。

  2. Channel 绑定一次bossGroup.register(channel) 将一个 Channel 注册到某个 EventLoop 后,该 Channel 的所有 IO 事件都由这一个 EventLoop 的线程串行处理——这是 Netty 减少锁竞争的核心手段(见 并发编程 中线程封闭原则)。

  3. chooser 选择策略:默认 DefaultChooser++idx 取模轮询;PowerOfTwoChooser(可选)在 EventLoop 数量为 2 的幂时用 idx & (n-1) 代替取模。实测性能差异很小,Netty 4.x 默认已统一为 GenericChooser

常见追问

  • EventLoop 的 runAllTasks() 做什么?→ 执行非 IO 任务(用户提交的 channel.eventLoop().execute()),包括注册、绑定、写缓冲区 flush 等。Netty 把”非 IO 任务”和”IO 事件”在同一个线程中串行,就是为了避免跨线程加锁——但这也意味着 IO 任务和普通任务互相阻塞,所以耗时任务应扔到业务线程池
  • bossGroup 和 workerGroup 有什么区别?→ boss 的 EventLoop 处理 accept 事件(接收连接),接收到后把 Channel 注册到 worker 的某个 EventLoop;一个 boss EventLoop 可以服务多个 Channel 的 accept

三、ByteBuf 与内存管理

ByteBuf 相比 ByteBuffer 有什么优势?

  • 独立读写索引,不需要频繁 flip()
  • 支持堆内和直接内存。
  • 支持池化、切片和组合缓冲区。
  • 容量可扩展。

什么是引用计数?为什么会内存泄漏?

池化或直接 ByteBuf 使用引用计数管理生命周期:

  • retain() 增加计数。
  • release() 减少计数。
  • 计数归零后释放或回收到池。

如果 Handler 消费消息后既不向后传播也不释放,就可能泄漏直接内存。

堆内存和直接内存如何选择?

  • 堆内存创建和 GC 管理简单。
  • 直接内存进行 Socket IO 时可减少一次复制。
  • 直接内存分配成本高,通常需要池化。

需要监控 Direct Memory,并区分 Java Heap OOM 与 Direct buffer memory

Netty 内存池是怎么分配的?(PooledByteBufAllocator)

是什么:直接内存申请/释放系统调用开销大(对比 JVM 堆内 new 对象),高并发场景每次 IO 都申请释放会拖垮吞吐。Netty 借鉴 jemalloc 的分级思路做池化,核心结构自顶向下三层:

层级大小说明
PoolArena整个内存池的顶层管理者默认个数 = CPU 核数 × 2,每个线程绑定一个 Arena(无绑定则退化为竞争),减少多线程分配同一块内存的锁竞争
PoolChunk默认 16MBArena 下的大块内存,用伙伴算法(Buddy System)+ 满二叉树管理内部空闲区域
PoolSubpageChunk 切出的小块Chunk 里再切分出的小规格内存,服务 < 8KB 的小对象分配,避免大块内存被切碎产生内碎片

分配流程(按请求大小分级处理)

请求分配 N 字节
  ├─ N ≥ Chunk 大小(16MB): 不进池,直接分配一整块"Huge"对象,用完直接释放
  ├─ N ∈ [8KB, 16MB): "Normal" 规格,在 Chunk 的满二叉树上找一个刚好够大的节点(伙伴算法)
  └─ N < 8KB: "Small/Tiny" 规格,交给 PoolSubpage 按固定规格池(如 16B/32B/...) 分配,避免为几十字节也占一整个 Chunk 节点

伙伴算法在满二叉树上如何工作:PoolChunk 用一棵满二叉树表示 16MB 内存,叶子节点是最小分配单位(8KB),每个非叶子节点表示”两个子节点的合并区域”。分配 N 字节时找层级刚好覆盖 N 的最左空闲节点;释放时检查兄弟节点是否也空闲,空闲则向上合并成更大的空闲块——这就是”伙伴”的含义:分配前先把大块二分,释放时看另一半兄弟是否也能收回合并,减少外碎片。

为什么每线程绑定一个 Arena(还要顺带内存对齐/规格化到 2 的幂次):多线程如果共用同一个 Arena 分配内存,Chunk 上的满二叉树是共享数据结构,并发分配需要加锁;每线程绑定各自的 Arena 后,大部分分配都发生在线程私有的 Arena 上,无需跨线程同步,只有 Arena 数量少于线程数时才会出现竞争(此时靠 PoolThreadCache 做一层线程本地小对象缓存进一步减少 Arena 层面的争抢)。

通用概念:这套”大块内存预先申请、内部再分级切分复用”的思路,和 JVM Buffer Pool 的 16KB 页缓存、MySQL 的 Buffer Pool同一模式——都是用户态/应用层维护一份池子来摊薄操作系统级资源(内存页、系统调用)的申请开销;线程私有 Arena 减少竞争的思路也和线程池 ConcurrentBag 优先复用本线程连接(见MySQL 连接池)同源:把共享资源尽量拆分到线程本地,只在拆分不掉时才同步

常见追问

  • 池化 ByteBuf 用完不 release 会怎样?→ 引用计数归零前不会归还给 Arena,直接内存持续增长直至 Direct buffer memory OOM——这是本节第 2 点内存泄漏的具体后果,池化不解决”忘记 release”的问题,反而因为直接内存不受 GC 管理,问题更隐蔽。
  • 为什么小对象不直接从 Chunk 分配?→ 若干字节的小请求也占用一个 8KB 叶子节点会造成大量内碎片(分配了但用不完的部分浪费掉),PoolSubpage 把叶子节点再按固定规格细分复用,专门优化小对象场景。

Netty 的零拷贝体现在哪?

频次 ★★★★★ · 难度 🔴

是什么:零拷贝的核心是减少数据在内核空间和用户空间之间的复制次数,而不是完全不拷贝。

一、操作系统层的零拷贝

传统 IO 读文件再发送到 socket 需要四次复制:磁盘 → page cache(DMA)→ 用户缓冲(CPU)→ socket 缓冲(CPU)→ 网卡(DMA)。sendfile() / transferTo() 将后两步合并:磁盘 → page cache(DMA)→ 网卡(DMA),完全跳过用户空间,CPU 只参与控制不参与数据搬运。

二、Netty 层的零拷贝(四种场景)

机制做法典型场景
FileRegion + transferTo()封装 JDK FileChannel.transferTo(),文件数据不经过用户堆大文件下载(如 OSS 代理转发)
CompositeByteBuf多个 ByteBuf 逻辑组合为连续视图,无需逐个拷贝到新数组协议头 + 协议体拼接发包
Unpooled.wrappedBuffer()将已有 byte[] / ByteBuffer 包装成 ByteBuf,底层是同一份数据将业务层 byte[] 转换为 Netty 对象发送
ByteBuf.slice() / duplicate()返回共享同一内存区域的切片,写索引独立但数据共享消息分片处理、零拷贝拆包

三、面试答题要点

面试官问”零拷贝”首先要分 OS 层和 Netty 层两层面答,不能混为一谈。OS 层答 sendfile 减少内核态→用户态复制,Netty 层答上面四种机制。追问”Netty 传文件怎么做”→ FileRegion + DefaultFileRegion;追问”零拷贝一定更快吗”→ 小文件场景 wrappedBuffer 零拷贝优势不明显,因为用户态 Copy 本身开销小于系统调用。

通用概念:零拷贝的核心思维是消除不必要的内存副本,同样的思路出现在 Kafka 的 transferTo 消费日志、RocketMQ 的 mmap 写 CommitLog、以及 MySQL 的 Doublewrite Buffer 选择直接写而非先缓存再刷——都是在”数据路径上加副本 vs 不加副本的风险”之间做权衡。


四、编解码与 TCP 边界

TCP 粘包/拆包如何解决?

难度 🔴

TCP 是字节流,没有消息边界。常见协议设计:

  • 固定长度。
  • 特殊分隔符。
  • 消息头携带长度。
  • 自描述协议。

Netty 常用:

  • FixedLengthFrameDecoder
  • DelimiterBasedFrameDecoder
  • LengthFieldBasedFrameDecoder

自定义协议通常包含哪些字段?

魔数 + 版本 + 消息类型 + 序列化方式
+ 压缩方式 + 请求 ID + 消息长度 + 消息体

魔数用于快速拒绝非法流量;请求 ID 用于匹配异步请求和响应;长度字段用于拆包。

如何防止非法长度导致内存攻击?

  • 限制最大帧长度。
  • 在分配消息体前校验长度。
  • 对非法魔数、版本和类型立即关闭连接。
  • 限制单连接积压和读取速度。

五、Netty 可靠性

如何设计心跳和空闲连接检测?

使用 IdleStateHandler 检测读空闲、写空闲或全部空闲:

  • 客户端定期发送 Ping。
  • 服务端回复 Pong。
  • 连续多个周期未收到响应则关闭并重连。

心跳周期应大于正常网络抖动,并加入随机抖动避免大量客户端同时发送。

Netty 客户端断线如何重连?

  • 在连接失败或 channelInactive 后调度重连。
  • 使用指数退避和最大间隔。
  • 防止多个线程同时发起重连。
  • 重连后重新认证、订阅和恢复状态。

Netty 如何处理背压?

  • 关注 Channel 的可写状态和写缓冲区水位。
  • 不可写时暂停生产或降低发送速度。
  • 限制业务队列长度。
  • 对无法承受的流量快速失败。

只调用 writeAndFlush 不代表数据已经发送到对端。


通用概念:水位线回调是推模式下人为造出的反向通道;拉模式(Kafka consumer)的背压是免费的。

六、RPC 框架

RPC 框架的核心组成?

难度 🔴

动态代理 → 服务发现 → 负载均衡 → 协议编码
→ 网络传输 → 服务调用 → 响应匹配

还需要:

  • 超时、重试和熔断。
  • 序列化与版本兼容。
  • 连接池和心跳。
  • 限流、监控和链路追踪。

RPC 为什么需要动态代理?

代理将本地接口调用转换为远程请求:

  1. 获取接口、方法和参数信息。
  2. 选择服务实例。
  3. 编码并发送请求。
  4. 等待或异步接收响应。
  5. 将远程结果转换为本地返回值或异常。

RPC 如何匹配异步响应?

客户端为每个请求生成唯一请求 ID,并保存:

requestId → Promise/Future

收到响应后按 requestId 找到等待对象并完成。还需要定时清理超时请求,避免 Map 泄漏。

RPC 重试要注意什么?

  • 只重试网络错误、超时或明确的可重试异常。
  • 写请求必须幂等。
  • 使用退避和抖动。
  • 限制总调用时间,而不只是单次超时。
  • 避免调用链每层都重试。

常见序列化方式如何选择?

方式特点
JSON可读、跨语言,体积和性能一般
Protobuf紧凑、高性能、跨语言,需要 Schema
Hessian跨语言能力有限,Java RPC 常见
Java 原生序列化易用但性能、安全和兼容性较差

协议演进要做到新增字段兼容旧版本,避免复用已删除字段编号。

HTTP、gRPC、Dubbo 如何选择?

  • HTTP/REST:通用、调试方便、生态成熟。
  • gRPC:基于 HTTP/2 和 Protobuf,跨语言、流式通信能力强。
  • Dubbo:Java 生态服务治理能力完整,适合内部高性能 RPC。

选型依据包括语言栈、治理能力、性能、协议开放性和团队运维能力。


参考资料