字节跳动开发一面(算力基础部门):Kotlin 协程、Android 存储与网络协议
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 请做一下自我介绍。
- 挑一个项目,讲讲里面比较有技术难点的部分。
- 在你的项目里,为什么会用到截图?
- 说说你做的埋点和你做的实验都会怎么看指标、看哪些指标?
- 有什么指标能表明确实提升了用户的体验?
- Object 这个基类有哪些默认的方法?
- 说一下 Java 的异常体系,以及平时如何使用它。
- 我们在做网络交互的时候,常会抛一个 IOException,你觉得它属于什么类型的异常?
- Java 线程池的核心参数有哪些?
- 什么情况下可以把核心线程回收掉?
- 我要做一个短信应用的开发,想构造一个网络请求限制,那线程的核心数一般会怎么选择?
- 说一下 Kotlin 的协程,你怎么看待协程和线程的区别?
- 协程的核心实现是什么?协程这么高效的原理是什么,也就是说协程是怎么组成的?
- 有没有了解过 Continuation 机制和 CPS 变换?
- 协程上下文的一些核心组成有哪些?
- 说一下 SharedPreferences 的机制。
- SharedPreferences 是不是多线程安全的?是不是多进程安全的?
- 可以用 ContentProvider 来做多进程数据交互吗?
- 能不能用 ContentProvider 封装 SharedPreferences,从而实现 SharedPreferences 的多进程共享?
- 用什么方案去做比较重要的、对外的数据存储?
- 顶层的 Java 代码怎么和底层的 C++ 代码进行交互?
- CopyOnWriteArrayList 这个数据结构有什么特点?
- 有一个强一致性的场景,能用它吗?
- 比如我要去存钱,那我该用什么数据结构?
- 开放性问题:在浏览器里输入了一串 URL,然后回车到渲染完成,从网络协议交互的角度详细阐述一下整个过程。
- HTTP/1.1 和 HTTP/2 的区别是什么?
- QUIC 解决了什么问题?
- QUIC 有什么缺点?
- 既然它有缺点,那我们电脑上的浏览器采取了什么样的策略?
- 数据库的索引为什么用 B+ 树?
- 你知道归并排序吗?
- 归并排序是稳定的吗?
- 把归并排序实现出来。
《参考解析》
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 捞上来的——说明投递时的部门标注和真实面试团队可能不一致,面试里直接问清「这个岗位归属哪个团队、日常做什么」,比事后纠结强。整场面完面试官态度积极,反馈给得足,属于体验很好的一场。