字节跳动 日常实习一面 — Android 开发 面经深度解析
一、Java / 多线程
Q1:多线程有用过吗?多线程的优点和缺点?
考点:多线程理解深度,不是背答案,要能结合实际场景。
答案:
优点:
|
优点 |
说明 |
|---|---|
|
提高 CPU 利用率 |
多核 CPU 并行执行任务,避免单核空闲 |
|
提高响应速度 |
UI 线程不被阻塞,主线程只负责绘制,子线程做耗时操作(Android 强制要求) |
|
提高吞吐量 |
多任务同时执行,如网络请求 + 数据库读写并发 |
|
资源共享 |
同一进程内线程共享堆内存,通信成本低 |
|
简化建模 |
一个线程处理一个任务/请求,编程模型更直观(如 Tomcat 线程池) |
缺点:
|
缺点 |
说明 |
|---|---|
|
线程安全问题 |
多线程访问共享变量,存在竞态条件、数据不一致 |
|
死锁 |
多个线程互相等待对方释放锁,系统卡死 |
|
资源消耗 |
创建/切换线程有开销(TLS、栈内存约 1MB/线程) |
|
复杂性 |
调试困难,时序不确定性导致 bug 难以复现 |
|
优先级反转 |
低优先级线程持有高优先级线程需要的锁 |
Q2:线程安全问题的核心原因是什么(从内存角度分析)?
考点:JMM(Java Memory Model),主内存与工作内存模型。
答案:三大核心原因:
┌─────────────┐
│ 主内存 │ ← 所有线程共享
│ (Main Memory)│ 存放实例字段、静态字段、数组元素
└──┬───┬───┬──┘
┌────┘ │ └────┐
▼ ▼ ▼
┌─────────┐┌─────────┐┌─────────┐
│工作内存 ││工作内存 ││工作内存 │ ← 线程私有
│(线程A) ││(线程B) ││(线程C) │ CPU缓存 + 寄存器
└─────────┘└─────────┘└─────────┘
|
原因 |
说明 |
|---|---|
|
1. 原子性(Atomicity) |
一个操作可能对应多条字节码指令,线程切换可能发生在指令之间。例如 |
|
2. 可见性(Visibility) |
线程 A 修改了共享变量,线程 B 不能立即看到修改后的值——因为线程把变量缓存在自己的工作内存(CPU 缓存)中,还没有刷新到主内存。 |
|
3. 有序性(Ordering) |
编译器/CPU 为了性能会进行指令重排序,导致实际执行顺序和代码顺序不一致。单线程下语义一致,多线程下产生问题。 |
核心:CPU 高速缓存 + 指令重排序 → 各线程看到的变量值不一致 → 线程不安全。
Q3:线程安全相关的关键字或容器?
答案:
关键字:
|
关键字 |
作用 |
|---|---|
|
|
互斥锁,保证原子性 + 可见性 + 有序性 |
|
|
保证可见性 + 有序性(禁止指令重排),不保证原子性 |
|
|
不可变,初始化完成后线程安全 |
JUC 容器(java.util.concurrent):
|
容器 |
说明 |
|---|---|
|
|
并发安全 HashMap,JDK 8 用 CAS + synchronized |
|
|
写时复制,适合读多写少 |
|
|
无界非阻塞队列,CAS 实现 |
|
|
阻塞队列,生产者-消费者模式 |
|
|
并发有序 Map |
同步工具:CountDownLatch、CyclicBarrier、Semaphore、ReentrantLock、ReadWriteLock
Q4:volatile 一定能保证线程安全吗?
答案:不能!volatile 只保证可见性和有序性,不保证原子性。
volatile int count = 0;
// 线程 A 和 B 同时执行 10000 次
count++; // 非原子操作:读 → 加1 → 写
count++ 包含三个步骤,volatile 无法保证这三步不被中断。最终结果小于 20000。
volatile 适用场景:
- 状态标志:
volatile boolean running = true;(只需可见性) - DCL 单例:
volatile禁止指令重排序,防止返回未初始化的对象 - 独立观察:一个线程写,多个线程读
volatile 不适用场景:
count++等复合操作(需用synchronized或AtomicInteger)- 需要多个变量保持一致的场景(转账 A→B)
Q5:有什么关键字能保证原子性?
答案:synchronized(JVM 层面)和 JUC 的原子类。
|
方式 |
说明 |
|---|---|
|
|
互斥锁,代码块/方法级别原子性 |
|
|
CAS(Compare And Swap)无锁原子操作 |
|
|
对象引用原子更新 |
|
|
高并发场景比 AtomicLong 性能更好(分段累加) |
|
|
显式锁,也能保证原子性 |
Q6:synchronized 和 volatile 的区别?
考点:高频必考题。
|
对比维度 |
|
|
|---|---|---|
|
保证原子性 |
✅ 是 |
❌ 否 |
|
保证可见性 |
✅ 是(解锁时刷回主内存) |
✅ 是(每次读取从主内存) |
|
保证有序性 |
✅ 是(块内禁止重排,块外可能重排到块内) |
✅ 是(禁止指令重排) |
|
阻塞 |
会阻塞线程 |
不阻塞 |
|
性能 |
较重(JDK 6 后持续优化) |
轻量 |
|
修饰范围 |
方法、代码块 |
变量 |
|
实现机制 |
JVM 指令 |
内存屏障(Memory Barrier) |
|
锁升级 |
偏向锁 → 轻量级锁 → 重量级锁 |
无 |
Q7:Java 和 Kotlin 的区别,各自的优势?
考点:Android 开发必问,Kotlin 是 Android 官方首选语言。
|
对比维度 |
Java |
Kotlin |
|---|---|---|
|
空安全 |
无原生支持,运行时 NPE |
类型系统区分 |
|
简洁性 |
样板代码多(getter/setter/构造函数) |
语法简洁, |
|
扩展函数 |
不支持 |
支持, |
|
协程 |
|
|
|
默认参数 |
不支持(需重载) |
支持 |
|
Lambda |
JDK 8 支持,有局限 |
原生支持,类型推断更好 |
|
when 表达式 |
|
|
|
数据类 |
手动写 getter/setter/hashCode/equals |
|
|
属性委托 |
无 |
|
项目分工(Android):
- 新代码用 Kotlin:更安全、更简洁、Jetpack Compose 首选
- 旧模块维护 Java:存量代码不强行迁移,Kotlin 和 Java 可以互通
Q8:by lazy 的原理,使用 Java 要怎么实现相似的功能?
考点:Kotlin 委托机制 + DCL(Double Check Locking)单例模式。
Kotlin by lazy 原理:
val heavyData: String by lazy {
println("初始化...")
"计算结果"
}
底层编译为:
// 编译器生成的伪代码
class LazyWrapper {
private var _value: Any? = UNINITIALIZED_VALUE // 初始为哨兵对象
private var initializer: (() -> T)? = { ... }
fun getValue(): T {
val v1 = _value
if (v1 != UNINITIALIZED_VALUE) return v1 as T // 第一次检查(无锁)
synchronized(this) {
val v2 = _value
if (v2 != UNINITIALIZED_VALUE) return v2 as T // 第二次检查(有锁)
val result = initializer!!()
_value = result
initializer = null // 释放闭包引用,防止内存泄漏
return result
}
}
}
本质:懒加载 + 双重检查锁定(DCL)+ volatile + 线程安全。
by lazy 默认 LazyThreadSafetyMode.SYNCHRONIZED,还有 PUBLICATION(多个线程可能同时初始化但不加锁,只用一个结果)和 NONE(无锁,仅单线程用)。
Java 实现相同功能:
public class LazyDelegate<T> {
private volatile T value; // volatile 防止指令重排序
private Supplier<T> initializer; // 不再需要时可置空
private static final Object NONE = new Object();
public LazyDelegate(Supplier<T> init) {
this.initializer = init;
}
@SuppressWarnings("unchecked")
public T get() {
// 第一次检查(无锁,volatile 保证可见性)
if (value != null) return value;
synchronized (this) {
// 第二次检查(有锁)
if (value != null) return value;
T result = initializer.get();
value = result;
initializer = null; // 防止闭包内存泄漏
return result;
}
}
}
// 使用
LazyDelegate<String> lazyData = new LazyDelegate<>(() -> {
System.out.println("初始化...");
return "计算结果";
});
String data = lazyData.get(); // 首次调用才初始化
Q9:HashMap 的实现原理
考点:几乎必问,需回答 JDK 8 的变化。
详答(参考之前面经解析文档中的 HashMap 完整解析,此处要点):
- 数据结构:数组(Node[] table)+ 链表 + 红黑树
- Hash 计算:
(h = key.hashCode()) ^ (h >>> 16)— 高 16 位参与异或,降低碰撞 - 定位桶:
(n - 1) & hash,n 为 2 的幂 - 冲突解决:链表(拉链法)→ 长度 >= 8 且数组 >= 64 转红黑树
- 扩容:容量翻倍,rehash 时分高低两条链表
- put 流程:计算 hash → 桶为空直接放 → 否则遍历链表/树 → key 相同则替换 → 判断扩容
Q10:场景题 — HashMap 用 A 类为键,修改 A 的某个属性后 get 结果一样吗?
答案:通常不一样(取决于 hashCode 实现)!
class A {
int id;
String name;
// hashCode = Objects.hash(id, name) ← 属性相关
}
Map<A, String> map = new HashMap<>();
A key = new A(1, "张三");
map.put(key, "value");
key.name = "李四"; // 修改属性
map.get(key); // 返回 null!因为 hashCode 变了
原因:HashMap 先用 hashCode 定位桶(key 被修改后 hashCode 变了,但数据还在旧桶里),再用 equals 匹配。修改属性后,hashCode 变了导致定位到不同桶,因此无法找到。
追问:怎么保证一样?
重写 hashCode 和 equals,使其与可变属性无关:
class A {
private final String uid; // 唯一标识,不可变
private String name; // 可变属性,不参与 hashCode/equals
private int age;
// hashCode 只用 uid
@Override
public int hashCode() {
return Objects.hash(uid);
}
// equals 只用 uid
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof A)) return false;
return Objects.equals(uid, ((A) obj).uid);
}
}
核心原则:作为 HashMap 键的对象,参与的 hashCode 和 equals 的字段应当不可变。
二、计算机网络
Q1:TCP 和 UDP 的区别
|
对比维度 |
TCP |
UDP |
|---|---|---|
|
连接 |
面向连接(三次握手/四次挥手) |
无连接 |
|
可靠性 |
可靠(确认应答、重传、序号) |
不可靠,尽最大努力交付 |
|
顺序 |
有序(序号保证按序到达) |
无序 |
|
流量控制 |
有(滑动窗口) |
无 |
|
拥塞控制 |
有(慢启动、拥塞避免、快重传、快恢复) |
无 |
|
头部开销 |
20 字节 |
8 字节 |
|
适用场景 |
HTTP、文件传输、邮件 |
DNS、视频直播、语音通话、游戏 |
|
传输单位 |
字节流 |
数据报 |
Q2:TCP 通过哪些方式实现可靠性?
|
机制 |
说明 |
|---|---|
|
序号(Sequence Number) |
每个字节编号,保证按序组装和去重 |
|
确认应答(ACK) |
接收方收到数据后发送 ACK,发送方未收到 ACK 则重传 |
|
超时重传 |
发送方在超时时间内未收到 ACK,自动重传数据包 |
|
校验和 |
检测数据在传输中是否出错,出错则丢弃并触发重传 |
|
流量控制(滑动窗口) |
接收方告知发送方自己能接收的窗口大小,防止发送太快 |
|
拥塞控制 |
慢启动、拥塞避免、快重传、快恢复,防止网络拥堵 |
|
连接管理 |
三次握手建立连接,四次挥手释放连接 |
Q3:下载速度通常由慢到快,背后原理是什么?
答案:TCP 慢启动(Slow Start) + 拥塞避免(Congestion Avoidance)。
拥塞窗口 (cwnd) 的变化:
cwnd
↑
│ 慢启动(指数增长) 拥塞避免(线性增长)
│ ↗↗↗ ↗ ↗ ↗
│ ↗↗
│ ↗↗
│ ↗
│ ↗
─┴──────────────────────────────────→ 传输轮次
ssthresh (慢启动阈值)
|
阶段 |
规则 |
效果 |
|---|---|---|
|
慢启动 |
cwnd = 1 → 每收到一个 ACK,cwnd += 1(指数增长) |
刚开始慢,迅速加速 |
|
拥塞避免 |
cwnd 达到 ssthresh 后,每 RTT 仅 cwnd += 1(线性增长) |
接近容量上限,谨慎加量 |
|
快重传 |
收到 3 个重复 ACK 立刻重传,不等超时 |
更快恢复丢包 |
|
快恢复 |
超时才回到慢启动;3 重复 ACK 则降一半后继续拥塞避免 |
避免无故大幅降速 |
所以下载曲线是:前几秒速度快速攀升(指数增长)→ 达到阈值后缓慢增长(线性增长)→ 遇到瓶颈或丢包后调整。
Q4:HTTP 和 TCP、UDP 的关系
┌─────────────────────────────┐
│ 应用层 │
│ ┌───────────┐ │
│ │ HTTP │ ← 超文本传输协议 │
│ └─────┬─────┘ │
├────────┼────────────────────┤
│ 传输层 │ │
│ ┌─────┴─────┐ ┌──────────┐│
│ │ TCP │ │ UDP ││
│ │ HTTP/1.x │ │ HTTP/3 ││
│ │ HTTP/2 │ │ (QUIC) ││
│ └───────────┘ └──────────┘│
├────────────────────────────┤
│ 网络层 (IP) │
├────────────────────────────┤
│ 数据链路层 + 物理层 │
└─────────────────────────────┘
|
版本 |
传输层协议 |
|---|---|
|
HTTP/1.0 |
TCP |
|
HTTP/1.1 |
TCP |
|
HTTP/2.0 |
TCP |
|
HTTP/3.0 |
QUIC(基于 UDP) |
Q5:HTTP/2.0 和 HTTP/3.0 的区别
|
对比维度 |
HTTP/2.0 |
HTTP/3.0 |
|---|---|---|
|
传输协议 |
TCP |
QUIC(基于 UDP) |
|
连接建立 |
TCP 三次握手 + TLS 1.2 两次握手 ≈ 3 RTT |
QUIC 握手 + TLS 1.3 合并 = 0-1 RTT |
|
多路复用 |
基于 Stream(一个 TCP 连接多 Stream) |
基于 Stream,但无队头阻塞 |
|
队头阻塞 |
TCP 层有队头阻塞(一个 TCP 包丢,阻塞所有 Stream) |
无队头阻塞(丢包只影响该 Stream) |
|
连接迁移 |
IP 变化(切换 Wi-Fi/4G)需重新连接 |
连接 ID 保持不变,无缝切换网络 |
|
拥塞控制 |
内核实现 |
用户态实现,更新更灵活 |
|
加密 |
TLS(可选) |
QUIC 原生加密,默认不可明文 |
|
现状 |
主流,各浏览器/服务器都支持 |
正在推广,大厂逐步升级 |
核心升级:QUIC 把 TCP 的可靠传输搬到 UDP 之上,绕过了 TCP 协议栈的固有缺陷(队头阻塞、连接迁移困难)。
三、内存泄漏
Q1:内存泄漏原理
答案:对象不再被应用使用,但 GC Roots 仍可达,导致 GC 无法回收,内存持续增长。
GC Roots(可达性分析起点):
├── 虚拟机栈(栈帧中的本地变量表)引用的对象
├── 静态属性引用的对象
├── 常量引用的对象
├── JNI(Native 方法)引用的对象
└── 活跃线程引用的对象
如果无用对象被以上 GC Roots 持有引用链 → 不可回收 → 内存泄漏
Q2:怎么排查以及怎么解决?
排查工具:
|
工具 |
用途 |
|---|---|
|
Android Profiler(Android Studio) |
实时监控内存,手动 GC 后看内存是否回落 |
|
LeakCanary |
自动检测 Activity/Fragment 泄漏,生成 hprof |
|
MAT(Memory Analyzer Tool) |
分析 hprof 堆转储,找 GC Root 引用链 |
|
adb dumpsys meminfo |
命令行查看进程内存概况 |
|
Allocation Tracker |
追踪对象分配位置 |
排查流程:
- 复现操作(打开→关闭页面,反复几次)
- 手动触发 GC,看内存是否回落
- 不回落的 → Dump 堆 → 导入 MAT 分析
- 根据 GC Root → 最短引用链定位泄漏对象
- 找到持有引用的代码位置 → 修复
Q3:LeakCanary 转储堆记录了什么?
答案:记录的是 .hprof 格式的 Java 堆转储快照(Heap Dump),包含:
- 所有存活对象的实例及其字段值
- 对象之间的引用关系
- 类信息和类加载器
- GC Roots 集合
- 线程栈信息
LeakCanary 从中提取:泄漏对象 → 最短 GC Root 引用路径。
Q4:LeakCanary 检测内存泄漏的原理是什么?
1. 注册 ActivityLifecycleCallbacks / FragmentLifecycleCallbacks
│
▼
2. 监听 onDestroy() → 用 WeakReference 包装即将销毁的对象
│
▼
3. 立即触发 GC → 如果 WeakReference.get() == null → 正常回收 ✅
│
▼
4. 如果 WeakReference.get() != null → 对象仍被强引用 ❌
│
▼
5. 等待 5 秒(给 GC 更多机会),再次检查
│
▼
6. 仍然存活 → Dump hprof → 分析引用链 → 发出泄漏通知
核心机制:WeakReference + 主动 GC + 引用链分析。
Q5:所有内存泄漏问题弱引用都能解决吗?
答案:不能!
弱引用只能保证被弱引用的对象不阻止 GC,但不能解决根本问题:
|
场景 |
弱引用能解决? |
说明 |
|---|---|---|
|
Handler 内部类持有 Activity |
❌ |
Handler 的 Message 在 MessageQueue 中,持有强引用到 Handler,Handler 是内部类强引用 Activity。需用 |
|
单例持有 Context |
❌ |
单例生命周期 = 应用,持有 Activity → 永不可回收。传给单例的 Context 应当用 |
|
非静态内部类 |
❌ |
内部类默认持有外部类引用。改为 |
|
Listener/Observer 未取消注册 |
❌ |
注册后未反注册,Listener 集合仍然持有引用。需配对调用 unregister |
|
线程持有 Activity |
❌ |
线程执行时间过长,内部类引用导致。需在 Activity onDestroy 中断线程 |
关键是切断强引用链,而不是简单加弱引用。
Q6:Android 上内存泄漏的典型场景
|
场景 |
原因 |
解决方案 |
|---|---|---|
|
单例持有 Activity |
单例生命周期 > Activity,强引用不释放 |
传 |
|
非静态内部类(Handler/Thread) |
内部类隐式持有外部类引用 |
用 |
|
Handler + 延迟消息 |
MessageQueue → Message → Handler → Activity 链 |
静态 Handler + WeakRef + |
|
匿名线程 / AsyncTask |
线程执行时间长,持有 Activity 引用 |
线程在 onDestroy 中断;改用 Lifecycle 感知的协程 |
|
资源未关闭(File / Cursor / 数据库) |
系统资源持有内存缓冲 |
|
|
Listener/Observer 未反注册 |
EventBus / RxJava / LiveData 观察者未移除 |
onDestroy 中反注册 |
|
WebView |
WebView 独立进程,回调持有 Context |
独立进程 + 动态添加 + onDestroy 清理 |
|
Dialog / Toast 未 dismiss |
持有 Activity 引用,Activity 销毁后仍显示 |
|
|
属性动画未取消 |
ValueAnimator 持有 View 引用 |
|
四、Token 认证
Q1:双 Token 的刷新流程,在服务端校验流程
双 Token 机制:Access Token(短期,5-15 分钟)+ Refresh Token(长期,7-30 天)。
客户端 服务端
│ │
│ POST /login {user, pass} │
│─────────────────────────────────────────────→
│ {accessToken, refreshToken} │
│←─────────────────────────────────────────────
│ │
│ GET /api/xxx Header: Bearer accessToken │
│─────────────────────────────────────────────→
│ {data} │
│←───────────────────────────────────────────── ← accessToken 有效
│ │
│ GET /api/xxx Header: Bearer accessToken │
│─────────────────────────────────────────────→
│ 401 (Token expired) │
│←───────────────────────────────────────────── ← accessToken 过期
│ │
│ POST /refresh {refreshToken} │
│─────────────────────────────────────────────→
│ {newAccessToken, newRefreshToken} │ ← 双令牌同时刷新
│←─────────────────────────────────────────────
│ │
│ 用新 accessToken 重试原请求 │
服务端校验流程:
1. 接收请求 → 从 Authorization Header 提取 accessToken
2. 验证 accessToken 签名(JWT)→ 检查是否被篡改
3. 检查 accessToken 是否过期 (exp 字段)
4. 检查 Token 是否在撤销黑名单中(登出/改密码后)
5. 通过 → 返回资源
不通过 → 返回 401
Refresh Token 的安全性:
- Refresh Token 只用一次,刷新时返回新的 Refresh Token(轮转刷新)
- Refresh Token 过期或被吊销 → 要求重新登录
- Refresh Token 存在数据库或 Redis,可服务端控制
Q2:Token 是怎么生成的,保存在哪?
生成(JWT):
JWT = base64(Header).base64(Payload).base64(Signature)
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"userId": 123, "exp": 1735689600, "iat": 1735686000}
Signature: HMAC-SHA256(base64(Header) + "." + base64(Payload), secretKey)
存储位置:
|
平台 |
Access Token |
Refresh Token |
|---|---|---|
|
Web |
内存(变量)或 HttpOnly Cookie |
HttpOnly Secure Cookie(防 XSS,不被 JS 读取) |
|
Android/iOS |
|
Android Keystore / iOS Keychain(硬件级加密) |
不要存在 localStorage(XSS 可读取)。
Q3:Session 和 Token 的区别
|
对比维度 |
Session |
Token(JWT) |
|---|---|---|
|
状态 |
有状态(服务端存 session) |
无状态(服务端不存,靠签名验证) |
|
存储位置 |
服务端内存/Redis,客户端只有 sessionId Cookie |
客户端存储 token,服务端只验证签名 |
|
扩展性 |
差(多服务器需共享 Session) |
好(任何服务器都能独立验证 Token) |
|
安全性 |
CSRF 风险(Cookie 自动发送) |
XSS 风险(JS 可读取 Token),需配合 HttpOnly Cookie |
|
跨域 |
Cookie 受同源策略限制 |
不受同源策略限制(Header 手动携带) |
|
注销 |
服务端销毁 session 即可 |
难以即时失效(需黑名单机制) |
|
适用场景 |
传统 Web 应用、服务端渲染 |
SPA、移动端、分布式微服务 |
五、新技术关注
面试回答策略:不要求精通,但要能说出几个方向 + 了解程度 + 实际尝试。
可能的方向示例:
|
方向 |
了解程度 |
具体 |
|---|---|---|
|
AI 编程助手 |
使用过 |
Claude Code / Copilot 日常辅助编码,能提升效率 |
|
Compose Multiplatform |
了解 + 写过 Demo |
跨平台 UI 框架,Android/iOS/Desktop 共享代码 |
|
Kotlin Multiplatform (KMP) |
了解 |
跨平台共享业务逻辑,平台差异层各自实现 |
|
鸿蒙(HarmonyOS)开发 |
了解原理 |
ArkTS + ArkUI,声明式 UI,与 Android 体系不同 |
|
AI Agent |
关注中 |
OpenClaw / Claude Code Agent 模式,自动化开发趋势 |
六、算法题
Q1:大文件 Top100 高频词(10MB 内存,100MB+ 文件)
考点:海量数据处理,分治思想,Hash 取模 + 堆排序。
详细流程:
文件大小:>100MB 内存限制:10MB 每行:≤100B
目标:找出 Top 100 高频词
┌─────────────────────────────────────────────────────────┐
│ 阶段 1:Hash 分片(分而治之) │
│ │
│ 逐行读取大文件 │
│ → 对每行单词计算 hash(word) % N │
│ → N = ceil(文件大小 / 可用内存) ≈ 100MB / 5MB = 20 │
│ → 写入对应的小文件 chunk_0 ~ chunk_19 │
│ │
│ 保证:相同单词一定落到同一个 chunk 文件 │
├─────────────────────────────────────────────────────────┤
│ 阶段 2:逐个统计(内存计算) │
│ │
│ 对每个 chunk_i: │
│ → 读入内存(每个 chunk 约 5MB,满足 10MB 限制) │
│ → HashMap<String, Integer> 统计频次 │
│ → 用小顶堆(size=100)维护 Top100 │
│ → 输出 chunk_i 的 Top100 列表 │
├─────────────────────────────────────────────────────────┤
│ 阶段 3:合并结果 │
│ │
│ 将 20 个文件的 Top100(共 2000 个词) │
│ → 用 HashMap 或再次堆排序 │
│ → 输出全局 Top 100 │
└─────────────────────────────────────────────────────────┘
伪代码:
// 阶段 1:Hash 分片
int N = 20; // 分片数
BufferedWriter[] writers = new BufferedWriter[N];
for (int i = 0; i < N; i++) {
writers[i] = new BufferedWriter(new FileWriter("chunk_" + i));
}
BufferedReader reader = new BufferedReader(new FileReader("bigfile.txt"));
String line;
while ((line = reader.readLine()) != null) {
int idx = Math.abs(line.hashCode()) % N;
writers[idx].write(line);
writers[idx].newLine();
}
// 关闭所有 writer...
// 阶段 2:每个 chunk 内统计
PriorityQueue<WordCount> globalTop100 = new PriorityQueue<>(100, (a,b) -> a.count - b.count);
for (int i = 0; i < N; i++) {
Map<String, Integer> freq = new HashMap<>();
// 读 chunk_i 到 HashMap 统计
reader = new BufferedReader(new FileReader("chunk_" + i));
while ((line = reader.readLine()) != null) {
freq.merge(line, 1, Integer::sum);
}
// 小顶堆取 Top100
PriorityQueue<WordCount> heap = new PriorityQueue<>(100, (a,b) -> a.count - b.count);
for (Map.Entry<String, Integer> e : freq.entrySet()) {
heap.offer(new WordCount(e.getKey(), e.getValue()));
if (heap.size() > 100) heap.poll();
}
// 合并到全局
for (WordCount wc : heap) {
globalTop100.offer(wc);
if (globalTop100.size() > 100) globalTop100.poll();
}
}
// 输出
while (!globalTop100.isEmpty()) {
WordCount wc = globalTop100.poll();
System.out.println(wc.word + ": " + wc.count);
}
Q2:二叉树的非递归后序遍历
考点:栈模拟递归,后序遍历 = 左 → 右 → 根。
解法一:双栈法(最好理解)
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res;
Stack<TreeNode> s1 = new Stack<>(); // 用于遍历
Stack<TreeNode> s2 = new Stack<>(); // 收集逆序结果
s1.push(root);
while (!s1.isEmpty()) {
TreeNode node = s1.pop();
s2.push(node); // 根先入 s2
if (node.left != null) s1.push(node.left); // 左先入 s1(后出)
if (node.right != null) s1.push(node.right); // 右后入 s1(先出)
}
// s2 顺序:根→右→左,反转即后序遍历:左→右→根
while (!s2.isEmpty()) res.add(s2.pop().val);
return res;
}
解法二:单栈 + prev 标记法
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res;
Stack<TreeNode> stack = new Stack<>();
TreeNode curr = root, prev = null;
while (curr != null || !stack.isEmpty()) {
// 先走到最左
while (curr != null) {
stack.push(curr);
curr = curr.left;
}
curr = stack.peek();
// 如果右子树为空或右子树已被访问,访问当前节点
if (curr.right == null || curr.right == prev) {
res.add(curr.val);
stack.pop();
prev = curr;
curr = null;
} else {
// 转向右子树
curr = curr.right;
}
}
return res;
}
面经总结
考点分布
|
领域 |
题数 |
重点 |
|---|---|---|
|
Java / 多线程 |
10 |
volatile vs synchronized、线程安全原理、HashMap、Kotlin |
|
计算机网络 |
5 |
TCP/UDP、HTTP/2 vs HTTP/3、慢启动 |
|
内存泄漏 |
6 |
Android 典型场景、LeakCanary 原理、排查方法 |
|
认证 |
3 |
双 Token 机制、Session vs Token、JWT |
|
算法 |
2 |
海量数据 Top K、二叉树非递归遍历 |
面试特点
- 追问链路长:从"volatile 保证线程安全吗"→"那什么能保证原子性"→"synchronized 和 volatile 区别",逐层深入
- 理论结合场景:不背八股,用"下载速度为什么由慢到快"考 TCP 慢启动
- Android 特色:内存泄漏 + LeakCanary 原理 + Token 存储,Android 岗位特有
- 海量数据算法:10MB 内存处理 100MB+ 文件,考分治思想