跳转至

Netty与RPC

面试追问地图

主问题 必讲关键点 下一层追问
Reactor 事件分发、EventLoop、Channel 绑定 单/多 Reactor、为何减少锁
EventLoopGroup 线程管理、chooser 策略、Channel 绑定 boss vs worker、runAllTasks
Pipeline 入站/出站 Handler 链 执行顺序、异常传播、Sharable
ByteBuf 读写索引、池化、直接内存 引用计数、切片、泄漏检测
零拷贝 FileRegion、CompositeByteBuf、wrappedBuffer、slice OS 层 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 事件和任务
ChannelPipeline Handler 处理链
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 默认 16MB Arena 下的大块内存,用伙伴算法(Buddy System)+ 满二叉树管理内部空闲区域
PoolSubpage Chunk 切出的小块 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。

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


参考资料