跳转到内容

字节跳动 日常实习一面 — Android 开发 面经深度解析

一、Java / 多线程

Q1:多线程有用过吗?多线程的优点和缺点?

考点:多线程理解深度,不是背答案,要能结合实际场景。

答案

优点

优点

说明

提高 CPU 利用率

多核 CPU 并行执行任务,避免单核空闲

提高响应速度

UI 线程不被阻塞,主线程只负责绘制,子线程做耗时操作(Android 强制要求)

提高吞吐量

多任务同时执行,如网络请求 + 数据库读写并发

资源共享

同一进程内线程共享堆内存,通信成本低

简化建模

一个线程处理一个任务/请求,编程模型更直观(如 Tomcat 线程池)

缺点

缺点

说明

线程安全问题

多线程访问共享变量,存在竞态条件、数据不一致

死锁

多个线程互相等待对方释放锁,系统卡死

资源消耗

创建/切换线程有开销(TLS、栈内存约 1MB/线程)

复杂性

调试困难,时序不确定性导致 bug 难以复现

优先级反转

低优先级线程持有高优先级线程需要的锁


Q2:线程安全问题的核心原因是什么(从内存角度分析)?

考点:JMM(Java Memory Model),主内存与工作内存模型。

答案:三大核心原因:

                    ┌─────────────┐
                    │   主内存     │  ← 所有线程共享
                    │ (Main Memory)│    存放实例字段、静态字段、数组元素
                    └──┬───┬───┬──┘
                  ┌────┘   │   └────┐
                  ▼        ▼        ▼
            ┌─────────┐┌─────────┐┌─────────┐
            │工作内存  ││工作内存  ││工作内存  │  ← 线程私有
            │(线程A)  ││(线程B)  ││(线程C)  │     CPU缓存 + 寄存器
            └─────────┘└─────────┘└─────────┘

原因

说明

1. 原子性(Atomicity)

一个操作可能对应多条字节码指令,线程切换可能发生在指令之间。例如 i++ 是三步操作(读→加→写),不保证原子性。

2. 可见性(Visibility)

线程 A 修改了共享变量,线程 B 不能立即看到修改后的值——因为线程把变量缓存在自己的工作内存(CPU 缓存)中,还没有刷新到主内存。

3. 有序性(Ordering)

编译器/CPU 为了性能会进行指令重排序,导致实际执行顺序和代码顺序不一致。单线程下语义一致,多线程下产生问题。

核心:CPU 高速缓存 + 指令重排序 → 各线程看到的变量值不一致 → 线程不安全。


Q3:线程安全相关的关键字或容器?

答案

关键字

关键字

作用

synchronized

互斥锁,保证原子性 + 可见性 + 有序性

volatile

保证可见性 + 有序性(禁止指令重排),不保证原子性

final

不可变,初始化完成后线程安全

JUC 容器(java.util.concurrent)

容器

说明

ConcurrentHashMap

并发安全 HashMap,JDK 8 用 CAS + synchronized

CopyOnWriteArrayList

写时复制,适合读多写少

ConcurrentLinkedQueue

无界非阻塞队列,CAS 实现

BlockingQueue(ArrayBlockingQueue / LinkedBlockingQueue)

阻塞队列,生产者-消费者模式

ConcurrentSkipListMap

并发有序 Map

同步工具CountDownLatchCyclicBarrierSemaphoreReentrantLockReadWriteLock


Q4:volatile 一定能保证线程安全吗?

答案:不能!volatile 只保证可见性和有序性,不保证原子性。

volatile int count = 0;

// 线程 A 和 B 同时执行 10000 次
count++;   // 非原子操作:读 → 加1 → 写

count++ 包含三个步骤,volatile 无法保证这三步不被中断。最终结果小于 20000。

volatile 适用场景

  • 状态标志volatile boolean running = true;(只需可见性)
  • DCL 单例volatile 禁止指令重排序,防止返回未初始化的对象
  • 独立观察:一个线程写,多个线程读

volatile 不适用场景

  • count++ 等复合操作(需用 synchronizedAtomicInteger
  • 需要多个变量保持一致的场景(转账 A→B)

Q5:有什么关键字能保证原子性?

答案synchronized(JVM 层面)和 JUC 的原子类。

方式

说明

synchronized

互斥锁,代码块/方法级别原子性

AtomicInteger / AtomicLong / AtomicBoolean

CAS(Compare And Swap)无锁原子操作

AtomicReference / AtomicStampedReference

对象引用原子更新

LongAdder / DoubleAdder

高并发场景比 AtomicLong 性能更好(分段累加)

ReentrantLock

显式锁,也能保证原子性


Q6:synchronized 和 volatile 的区别?

考点:高频必考题。

对比维度

synchronized

volatile

保证原子性

✅ 是

❌ 否

保证可见性

✅ 是(解锁时刷回主内存)

✅ 是(每次读取从主内存)

保证有序性

✅ 是(块内禁止重排,块外可能重排到块内)

✅ 是(禁止指令重排)

阻塞

会阻塞线程

不阻塞

性能

较重(JDK 6 后持续优化)

轻量

修饰范围

方法、代码块

变量

实现机制

JVM 指令 monitorenter / monitorexit

内存屏障(Memory Barrier)

锁升级

偏向锁 → 轻量级锁 → 重量级锁


Q7:Java 和 Kotlin 的区别,各自的优势?

考点:Android 开发必问,Kotlin 是 Android 官方首选语言。

对比维度

Java

Kotlin

空安全

无原生支持,运行时 NPE

类型系统区分 TT?,编译期检查

简洁性

样板代码多(getter/setter/构造函数)

语法简洁,data class 一行搞定 POJO

扩展函数

不支持

支持,fun String.isPhone(): Boolean

协程

Thread / ExecutorService,较重量

kotlinx.coroutines,轻量级协程

默认参数

不支持(需重载)

支持 fun foo(a: Int = 1)

Lambda

JDK 8 支持,有局限

原生支持,类型推断更好

when 表达式

switch 有限制

when 更强大(支持任意类型匹配)

数据类

手动写 getter/setter/hashCode/equals

data class 自动生成

属性委托

by lazy, observable, map

项目分工(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 &gt;&gt;&gt; 16) — 高 16 位参与异或,降低碰撞
  • 定位桶(n - 1) & hash,n 为 2 的幂
  • 冲突解决:链表(拉链法)→ 长度 &gt;= 8 且数组 &gt;= 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 变了导致定位到不同桶,因此无法找到。

追问:怎么保证一样?

重写 hashCodeequals,使其与可变属性无关

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 键的对象,参与的 hashCodeequals 的字段应当不可变


二、计算机网络

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

追踪对象分配位置

排查流程

  1. 复现操作(打开→关闭页面,反复几次)
  2. 手动触发 GC,看内存是否回落
  3. 不回落的 → Dump 堆 → 导入 MAT 分析
  4. 根据 GC Root → 最短引用链定位泄漏对象
  5. 找到持有引用的代码位置 → 修复

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。需用 static class + WeakReference&lt;Activity&gt;

单例持有 Context

单例生命周期 = 应用,持有 Activity → 永不可回收。传给单例的 Context 应当用 ApplicationContext

非静态内部类

内部类默认持有外部类引用。改为 static 内部类 + WeakReference

Listener/Observer 未取消注册

注册后未反注册,Listener 集合仍然持有引用。需配对调用 unregister

线程持有 Activity

线程执行时间过长,内部类引用导致。需在 Activity onDestroy 中断线程

关键是切断强引用链,而不是简单加弱引用。

Q6:Android 上内存泄漏的典型场景

场景

原因

解决方案

单例持有 Activity

单例生命周期 &gt; Activity,强引用不释放

ApplicationContext,避免传 Activity

非静态内部类(Handler/Thread)

内部类隐式持有外部类引用

static class + WeakReference

Handler + 延迟消息

MessageQueue → Message → Handler → Activity 链

静态 Handler + WeakRef + removeCallbacksAndMessages(null)

匿名线程 / AsyncTask

线程执行时间长,持有 Activity 引用

线程在 onDestroy 中断;改用 Lifecycle 感知的协程

资源未关闭(File / Cursor / 数据库)

系统资源持有内存缓冲

try-finally 关闭;使用 use {}

Listener/Observer 未反注册

EventBus / RxJava / LiveData 观察者未移除

onDestroy 中反注册

WebView

WebView 独立进程,回调持有 Context

独立进程 + 动态添加 + onDestroy 清理

Dialog / Toast 未 dismiss

持有 Activity 引用,Activity 销毁后仍显示

dialog.dismiss() / toast.cancel()

属性动画未取消

ValueAnimator 持有 View 引用

animator.cancel()


四、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

SharedPreferences / DataStore / KeyChain / KeyStore

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、二叉树非递归遍历

面试特点

  1. 追问链路长:从"volatile 保证线程安全吗"→"那什么能保证原子性"→"synchronized 和 volatile 区别",逐层深入
  2. 理论结合场景:不背八股,用"下载速度为什么由慢到快"考 TCP 慢启动
  3. Android 特色:内存泄漏 + LeakCanary 原理 + Token 存储,Android 岗位特有
  4. 海量数据算法:10MB 内存处理 100MB+ 文件,考分治思想