BIGO Java 开发一面(大数据方向):JVM、Flink 与事务原子性
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 请做一下自我介绍。
- Java 线程池的核心参数有哪些?它的任务提交流程是怎样的?
- JVM 里面的垃圾回收器 CMS 和 G1 的原理以及区别是什么?
- 它们使用的垃圾回收算法有什么不同?
- Java 进程内存比较小,只有 3 到 4 个 G,在内存比较紧张的情况下,CMS 和 G1 之间你会选择哪一个?
- 讲一下 Flink 的 Checkpoint 和 Savepoint 有什么区别?
- 它们哪一个是全量快照,哪一个是增量?
- Flink 消费 Kafka 再写到 HDFS 这么一个工作流任务里,怎么保证端到端的精确一次?
- 在这个流程中发现写得很慢、任务很慢,怎么定位具体是哪一个算子、哪一个 Task 上慢?
- 数据库事务的 ACID 是什么?
- Java 代码里要实现对数据库的更新操作,怎么保证这个操作的原子性?
- JDBC 如何实现原子性?
《参考解析》
线程池七个参数与提交流程
核心参数是核心线程数、最大线程数、空闲回收时间与单位、任务队列、线程工厂、拒绝策略。提交一个任务时的顺序要背准:核心线程没满就新建核心线程执行;核心线程满了先进队列排队;队列满了且线程数没到最大值,才新建非核心线程;再满了才走拒绝策略。这里有两个高频追问。一是队列类型对行为的影响:用 LinkedBlockingQueue 这类无界队列,最大线程数实际永远用不到,任务会一直堆在队列里直到内存告急,这也是很多线上事故的来源;用 SynchronousQueue 则不排队、直接扩容到最大线程数。二是拒绝策略的选择:直接抛异常适合不能丢的任务(让上游感知并重试),调用者执行适合可自我限流的场景,丢弃策略只在可容忍丢失时用。线程数怎么定也要能说:CPU 密集按核数加一,IO 密集按「核数 × (1 + 等待时间/计算时间)」估算,最终靠压测和监控校准。
CMS 与 G1 的原理差异
CMS 的目标是最短停顿,工作在老年代,和 ParNew 搭配。流程是初始标记(STW,标记 GC Roots 直接可达)、并发标记(与用户线程并行)、重新标记(STW,修正并发期间的变动,通常用增量更新)、并发清除。它是标记-清除算法,不移动对象,所以两个固有代价:一是产生内存碎片,碎片多了大对象分配不下就会触发 Full GC(并发模式失败,退化成单线程的 Serial Old,停顿很难看);二是并发阶段用户线程还在产生垃圾,只能留到下次回收,也就是浮动垃圾。G1 换的是思路:把堆划分成大小相等的 Region,逻辑上仍分代但物理上不连续,通过 Remembered Set 维护跨 Region 引用,回收时按「回收收益」挑选 Region(Garbage First 的名字来源),整体是标记-整理加 Region 间的复制,因此有压缩效果、基本没有碎片问题。它还提供可预测停顿模型,通过设置期望停顿时间让收集器自己挑选要回收的 Region 数量,代价是更高的内存与 CPU 开销(RSet、卡表、并发线程),以及大对象(Humongous)分配和跨代引用处理上的复杂度。实践结论是 JDK 9 以后 G1 成为默认,绝大多数在线服务直接用 G1 加合理的停顿目标就够。
小堆内存紧张时怎么选
这个问题没有唯一正确答案,但要有判断框架。CMS 的优势是内存开销小、小堆上更容易跑得动(不需要 RSet 和额外的并发线程开销),劣势是碎片和并发模式失败——堆越小、对象晋升越快,碎片导致的 Full GC 越频繁,停顿反而更不可控。G1 的优势是压缩、无碎片、停顿可设目标,劣势是固定开销(约堆的几个百分点)在小堆上占比不低,而且堆太小时 Region 数量少,「挑选回收收益」的空间被压缩,效果不见得比 Parallel 好。所以我的选择会是:堆只有 3 到 4 个 G 且延迟要求不极端、能接受短停顿,优先 G1 并把 -Xms、-Xmx 固定、把停顿目标设成合理值;如果是容器限额很紧、内存几乎不能再留冗余,就考虑 Parallel 收集器换取吞吐,或者干脆降低单机堆压力做水平拆分。回答时把对象存活率、分配速率、停顿目标这几个变量说出来,比直接报一个名字更有说服力。
Checkpoint 与 Savepoint 的区别
两者的机制都是分布式快照,差别在触发方、用途和生命周期。Checkpoint 由 Flink 按间隔自动周期触发,目的是故障恢复,作业失败后从最近一次成功的 checkpoint 重启,默认会被后一次覆盖、随作业生命周期存在,可以在作业取消时删除。Savepoint 由用户手动触发(命令行、REST API 或代码),目的是有计划的运维操作:升级、扩缩容、迁移集群、暂停后恢复,它不会被自动清理、需要自己管理存储,而且要求算子有稳定的 uid 才能正确恢复。全量与增量上:checkpoint 在 RocksDB 状态后端下支持增量快照(只上传变化的 SST 文件),savepoint 默认是全量(新版本提供了增量的实验能力,但主流用法和默认行为仍是全量);增量 checkpoint 快但恢复时要串联多个历史快照,全量则恢复更快、依赖更少。还有一个常见追问是兼容性:savepoint 是为跨版本迁移设计的,配合 uid 与状态迁移器可以调整状态结构,所以正式升级前要先用 savepoint 停机再启新版本,而不是直接改代码重启。
Kafka 到 HDFS 的端到端精确一次
端到端精确一次要三段都对齐。源端:Kafka 消费位点作为状态被 checkpoint 保存,故障恢复时从快照里的位点重放,前提是 Kafka 数据可重放(保留期覆盖重放窗口)。中间状态:Flink 通过 checkpoint 提供一致的状态快照,默认的精确一次模式会做 barrier 对齐,让所有输入在同一个逻辑时间点切分,避免同一次快照里混入不同进度的数据。输出端:必须靠两阶段提交或幂等写,文件类 sink 的做法是——数据先写进 in-progress 的临时文件,checkpoint 触发时把该文件状态记为 pending 写入算子状态,等 checkpoint 全局完成后才把 pending 文件原子重命名成正式文件(HDFS 的 rename 是原子操作),失败恢复后未提交的临时文件会被清理或覆盖,因此不会产生重复可见的数据。这里的关键顺序是:先完成 sink 侧的文件提交,再提交 Kafka 位点,否则可能出现「位点已提交但文件未生成」的数据丢失。实际用的时候还要考虑可见延迟(数据要等下一个 checkpoint 才可见,间隔别设太大)、小文件合并(按时间或大小滚动)、以及文件系统的 rename 语义差异。
慢算子与反压的定位方法
排查顺序是从下游往上游看,因为瓶颈会以反压的形式向上游传播。先在 Web UI 的 Backpressure 面板确认哪个算子处于 high 反压状态,反压的起点就是实际瓶颈。然后看这个算子每个 subtask 的吞吐与耗时分布:如果只有个别 subtask 慢,基本是数据倾斜(keyBy 的热点 key、分区不均),对策是加盐两阶段聚合或改 key 设计;如果所有 subtask 都慢,看具体资源与外部依赖——sink 端写 HDFS 慢通常是文件太多或 NameNode 压力、写入并发过高;CPU 打满就用火焰图或 JFR、async-profiler 抓热点,检查序列化开销、正则与字符串拼接、GC 停顿;等待型慢则看网络缓冲与 checkpoint 对齐时间,checkpoint 太长会把算子卡在 barrier 等待上。列表里还要能说出常用的量化指标:每个算子的 numRecordsIn/Out per second、busy 与 idle 占比、checkpoint 各阶段耗时(对齐、同步、异步)、以及端到端延迟。最后提醒一句:确认问题后要给出可验证的改法(调并行度、改分区键、加缓冲、下沉聚合),而不是只说「优化一下」。
事务 ACID 与 Java 侧的原子性保证
ACID 分别是原子性(一个事务里的操作要么都成功要么都回滚)、一致性(事务前后数据满足约束与业务规则)、隔离性(并发事务之间互不干扰,靠隔离级别与锁、MVCC 实现)、持久性(提交后的修改不丢,靠 redo 日志与刷盘)。在 Java 代码里保证一次更新操作的原子性,落地手段是明确事务边界:把多条 DML 放进同一个事务(setAutoCommit(false),成功后 commit(),异常时 rollback()),并且保证同一事务内的所有操作拿到同一个数据库连接——连接池下最容易出错的就是中途从池里取了另一个连接,框架里通常用 ThreadLocal 绑定连接(@Transactional 与 DataSourceUtils 就是干这个的)。并发场景下还要靠数据库能力收口:唯一索引防重复插入、乐观锁版本号或悲观锁 SELECT ... FOR UPDATE 防丢失更新,跨库才考虑 XA 两阶段提交。JDBC 层面要强调一点:PreparedStatement 只做预编译与参数绑定,它防的是 SQL 注入、带来的是执行计划复用,和原子性没有关系,原子性由连接的事务边界加上数据库的 undo/redo 日志实现;另外在 try-with-resources 或 finally 里必须保证异常路径也回滚,并把 autocommit 还原后再交还连接池,否则下一位使用者会继承一个开着事务的连接。