面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| 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三个关键设计:
-
线程与 EventLoop 一对一:每个
NioEventLoop构造函数里创建一个FastThreadLocalThread,启动后不断执行run()循环:select()→processSelectedKeys()→runAllTasks()。线程一旦绑定 EventLoop 就不分裂,直到 EventLoopGroup 关闭。 -
Channel 绑定一次:
bossGroup.register(channel)将一个 Channel 注册到某个 EventLoop 后,该 Channel 的所有 IO 事件都由这一个 EventLoop 的线程串行处理——这是 Netty 减少锁竞争的核心手段(见 并发编程 中线程封闭原则)。 -
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 memoryOOM——这是本节第 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 常用:
FixedLengthFrameDecoderDelimiterBasedFrameDecoderLengthFieldBasedFrameDecoder
自定义协议通常包含哪些字段?
魔数 + 版本 + 消息类型 + 序列化方式
+ 压缩方式 + 请求 ID + 消息长度 + 消息体魔数用于快速拒绝非法流量;请求 ID 用于匹配异步请求和响应;长度字段用于拆包。
如何防止非法长度导致内存攻击?
- 限制最大帧长度。
- 在分配消息体前校验长度。
- 对非法魔数、版本和类型立即关闭连接。
- 限制单连接积压和读取速度。
五、Netty 可靠性
如何设计心跳和空闲连接检测?
使用 IdleStateHandler 检测读空闲、写空闲或全部空闲:
- 客户端定期发送 Ping。
- 服务端回复 Pong。
- 连续多个周期未收到响应则关闭并重连。
心跳周期应大于正常网络抖动,并加入随机抖动避免大量客户端同时发送。
Netty 客户端断线如何重连?
- 在连接失败或
channelInactive后调度重连。 - 使用指数退避和最大间隔。
- 防止多个线程同时发起重连。
- 重连后重新认证、订阅和恢复状态。
Netty 如何处理背压?
- 关注 Channel 的可写状态和写缓冲区水位。
- 不可写时暂停生产或降低发送速度。
- 限制业务队列长度。
- 对无法承受的流量快速失败。
只调用 writeAndFlush 不代表数据已经发送到对端。
通用概念:水位线回调是推模式下人为造出的反向通道;拉模式(Kafka consumer)的背压是免费的。
六、RPC 框架
RPC 框架的核心组成?
难度 🔴
动态代理 → 服务发现 → 负载均衡 → 协议编码
→ 网络传输 → 服务调用 → 响应匹配还需要:
- 超时、重试和熔断。
- 序列化与版本兼容。
- 连接池和心跳。
- 限流、监控和链路追踪。
RPC 为什么需要动态代理?
代理将本地接口调用转换为远程请求:
- 获取接口、方法和参数信息。
- 选择服务实例。
- 编码并发送请求。
- 等待或异步接收响应。
- 将远程结果转换为本地返回值或异常。
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。
选型依据包括语言栈、治理能力、性能、协议开放性和团队运维能力。