面灵AI→

字节跳动开发一面(算力基础部门):Kotlin 协程、Android 存储与网络协议

轮次
一面
时间
2026-10
来源
牛客网

《面试题目》

  1. 请做一下自我介绍。
  2. 挑一个项目,讲讲里面比较有技术难点的部分。
  3. 在你的项目里,为什么会用到截图?
  4. 说说你做的埋点和你做的实验都会怎么看指标、看哪些指标?
  5. 有什么指标能表明确实提升了用户的体验?
  6. Object 这个基类有哪些默认的方法?
  7. 说一下 Java 的异常体系,以及平时如何使用它。
  8. 我们在做网络交互的时候,常会抛一个 IOException,你觉得它属于什么类型的异常?
  9. Java 线程池的核心参数有哪些?
  10. 什么情况下可以把核心线程回收掉?
  11. 我要做一个短信应用的开发,想构造一个网络请求限制,那线程的核心数一般会怎么选择?
  12. 说一下 Kotlin 的协程,你怎么看待协程和线程的区别?
  13. 协程的核心实现是什么?协程这么高效的原理是什么,也就是说协程是怎么组成的?
  14. 有没有了解过 Continuation 机制和 CPS 变换?
  15. 协程上下文的一些核心组成有哪些?
  16. 说一下 SharedPreferences 的机制。
  17. SharedPreferences 是不是多线程安全的?是不是多进程安全的?
  18. 可以用 ContentProvider 来做多进程数据交互吗?
  19. 能不能用 ContentProvider 封装 SharedPreferences,从而实现 SharedPreferences 的多进程共享?
  20. 用什么方案去做比较重要的、对外的数据存储?
  21. 顶层的 Java 代码怎么和底层的 C++ 代码进行交互?
  22. CopyOnWriteArrayList 这个数据结构有什么特点?
  23. 有一个强一致性的场景,能用它吗?
  24. 比如我要去存钱,那我该用什么数据结构?
  25. 开放性问题:在浏览器里输入了一串 URL,然后回车到渲染完成,从网络协议交互的角度详细阐述一下整个过程。
  26. HTTP/1.1 和 HTTP/2 的区别是什么?
  27. QUIC 解决了什么问题?
  28. QUIC 有什么缺点?
  29. 既然它有缺点,那我们电脑上的浏览器采取了什么样的策略?
  30. 数据库的索引为什么用 B+ 树?
  31. 你知道归并排序吗?
  32. 归并排序是稳定的吗?
  33. 把归并排序实现出来。

《参考解析》

Kotlin 协程:它凭什么比线程轻

线程由操作系统调度,一个线程一份栈(默认 MB 级),切换要陷入内核;协程是运行在线程之上的用户态任务,挂起时只是把当前的续体(Continuation)保存下来、把执行权交还给调度器,不换栈、不进内核,所以单机可以开到几十万个。它的组成可以拆成四块:Continuation 是「剩下的计算」这一闭包(Kotlin 里是 Continuation<T>,每个挂起函数编译后都会多出一个 continuation 参数);状态机是编译器把挂起函数改写成的 label + switch 分段代码;调度器(Dispatchers)决定续体在哪个线程池上恢复;Job/CoroutineContext 负责结构化并发、取消传播与异常处理。CPS 变换就是把「直接返回结果」改写成「把结果交给回调」,协程靠它把异步回调拍平成同步写法,代价是调试栈不直观、取消必须协作(挂起点才会响应取消),以及 CPU 密集任务放错调度器会卡住整个线程——这类任务要挪到 Dispatchers.Default 或独立线程池。

SharedPreferences 与 Android 多进程存储

SharedPreferences 是「首次加载全量进内存 + 编辑写内存 + apply() 异步落盘 / commit() 同步落盘」的键值存储,XML 文件整体重写。它是线程安全的(内部有锁,apply 还会串行排队),但不是多进程安全的:每个进程各有一份内存缓存,跨进程写会互相覆盖,MODE_MULTI_PROCESS 在新版本上早已废弃。用 ContentProvider 封装 SharedPreferences 是经典做法——ContentProvider 是 Android 四大组件里唯一被系统保证「同一时刻只有一个实例、由系统统一调度跨进程访问」的组件,让所有进程都通过 provider 的 call/query 读写,就能串行化并共享同一份数据。但要注意 provider 的调用是跨进程 IPC,读也会有一次 binder 开销,高频读场景不合适;现在更常见的选择是 MMKV(mmap + 共享内存 + 进程锁)或 DataStore(后者面向单进程协作,多进程仍需另配方案)。至于「重要的对外数据」,判断依据是数据敏感度、一致性要求和访问频率:需要跨进程高频共享用小文件 mmap 方案,需要事务和查询落 SQLite/Room,敏感字段加密后再落盘,密钥放 Keystore 而不是硬编码。

线程池:核心参数与核心线程回收

七个核心参数是核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略;关键理解是「先占满核心线程,再入队,队列满了才扩到最大线程数,仍然拒绝才走拒绝策略」——所以用无界队列时最大线程数形同虚设。核心线程默认即使空闲也常驻(allowCoreThreadTimeOut(true) 才能回收,回收阈值同样走 keepAliveTime;另外 prestartAllCoreThreads 是明确的预热手段)。核心线程数的估算看任务类型:CPU 密集型取核数 ±1,IO 密集型按 核数 × (1 + 等待时间/计算时间) 估,再按实际吞吐压测微调;像「短信应用里给网络请求限并发」这种场景,本质是给下游定一个并发上限,数量应该由对方能承受的 QPS 和单次请求耗时倒推(QPS × 平均耗时 ≈ 并发数),而不是拍一个核数倍数,同时配一个有界队列 + 合适的拒绝策略(降级/排队/快速失败要明确),否则限流会变成内存里堆任务。

CopyOnWriteArrayList 与强一致场景

CopyOnWriteArrayList 的语义是「写时复制整个数组 + 读不加锁」,所以读性能极好、读到的永远是某个完整快照;代价是每次写都要复制(内存与 GC 压力大)、写之间要加锁、写完之后其他线程读到的仍是旧快照——它给的是最终一致而不是强一致,也不能用来做「读出来再改」的复合操作。所以「存钱/扣余额」这种必须先判余额再扣减的场景绝不能用它:并发下两个线程都读到余额充足就会超扣。正确选择是:单机计数用 AtomicLong / LongAdder(CAS + 重试,保证原子读改写),复合校验用 synchronized/ReentrantLock(能锁住「检查—更新」整个临界区),跨机或要持久一致就落数据库——用行锁 select ... for update、乐观锁版本号、或 update balance = balance - ? where id = ? and balance >= ? 这种带条件的原子更新,配合事务与幂等键。

Java 与 C++ 交互、以及 Object 与异常体系

Android 上 Java 调 C++ 走 JNI:native 声明方法,C++ 侧按命名约定(或用 RegisterNatives 显式注册,性能和可读性都更好)实现,注意 JNIEnv 线程绑定、局部/全局引用的生命周期、jstring 与 UTF-8 转换,以及异常要在原生侧检查并抛回 Java 层;跨语言边界传大数据的拷贝开销常常是性能瓶颈,能传指针/直接缓冲区就别来回序列化。Object 的默认方法一共这些:getClass、hashCode、equals、clone、toString、notify、notifyAll、wait(三个重载)、finalize——equals 与 hashCode 必须成对重写,wait/notify 必须持有监视器锁。Java 异常体系以 Throwable 为根,分 Error(JVM 级严重错误,不该捕获)与 Exception;Exception 再分受检异常(编译期强制处理,如 IOException)与非受检异常(RuntimeException 及其子类,如 NPE、越界)。IOException 属于受检异常,编译器要求你 try-catch 或向上声明——设计上受检异常用于「调用方有合理恢复手段」的情况,业务错误用自定义非受检异常 + 全局处理器收敛更常见。

从输入 URL 到渲染完成,以及 HTTP/1.1、HTTP/2 与 QUIC

完整链路可以按层讲:浏览器先查自身缓存(强缓存命中就直接用,否则带 If-None-Match/If-Modified-Since 走协商缓存)、查 DNS(浏览器/Hosts/系统/本地 DNS 逐级,再到根与权威,缓存按 TTL 生效),拿到 IP 后建立 TCP 连接(三次握手)——HTTPS 还要在其上做 TLS 握手(1.3 是一轮往返,证书校验 + 密钥协商),然后发 HTTP 请求、服务端返回响应,浏览器解析 HTML 构建 DOM、解析 CSS 构建 CSSOM,两者合成渲染树后做布局、绘制、合成;期间遇到 JS 会阻塞解析、遇到图片等子资源再发起并发请求,整条链路受 TCP 慢启动、队头阻塞、以及同域并发连接数限制。HTTP/1.1 相较 1.0 引入了持久连接与管道化(实际很少用),但同一连接上仍严格串行、应用层队头阻塞,浏览器只能靠多开连接绕过。HTTP/2 做了二进制分帧 + 多路复用,一个连接上并发交错多个流、单流阻塞不再拖住其他流,还加了头部压缩(HPACK)、流优先级与服务端推送(后者已被普遍弃用)。但 HTTP/2 跑在 TCP 上,TCP 是有序字节流,一旦丢包重传,所有流都要等——这就是传输层队头阻塞。QUIC 把它搬到 UDP 之上,在用户态实现可靠传输、每个流独立重传,所以彻底解决了队头阻塞,同时把 TLS 1.3 内建到握手里做到 0/1-RTT 建连、支持连接迁移(换网不变连接)。它的缺点是可观测性差(UDP 常被运营商限速或屏蔽、很多中间设备看不懂)、实现都在用户态所以 CPU 开销高于内核 TCP,且需要在服务端做更多调优。浏览器的策略是分而治之:对已知支持 HTTP/3 的站点走 QUIC,失败/超时就自动回退到 HTTP/2 的 TCP 路径(Alt-Svc 缓存、竞速与并行握手),也就是「默认尝试、可随时降级」,而不是一刀切。

B+ 树索引与归并排序

B+ 树的优势在于非叶子节点只存键值,扇出大、树高低(千万级数据三到四层),任何查询都要走到叶子层所以耗时稳定;叶子节点用双向链表串联,范围查询和 order by 只需顺序扫描叶子,不必回上层;相比哈希索引它支持范围与排序,相比赛树它把数据集中在叶子、减少磁盘随机 IO 并靠多路平衡降低树高。归并排序是稳定的——合并两个子序列时,相等元素按「左侧优先」取,相对次序不会改变,这也是 Java 里 Arrays.sort(Object[]) 用 TimSort(归并的变体)的原因:引用类型要求稳定排序,而基本类型用快排则因为稳定性对它们无意义、快排常数更小。实现上写清三段即可:递归分治到单元素、申请临时数组做双指针合并、合并完写回原数组;复杂度稳定在 O(n log n)、空间 O(n),链表场景还能做到 O(1) 额外空间。

这一轮的风格

从题目分布看,面试官在客户端方向挖得很扎实:协程原理、Android 跨进程存储、线程池参数这类问题没有标准答案背书的余地,都要讲出「为什么」和「代价」。有一个插曲值得记下来:JD 上写的是算力基础部门,面试官却说团队做的是协议,还提到简历是 LD 捞上来的——说明投递时的部门标注和真实面试团队可能不一致,面试里直接问清「这个岗位归属哪个团队、日常做什么」,比事后纠结强。整场面完面试官态度积极,反馈给得足,属于体验很好的一场。