Java 与 JVM 基础
即便项目用 Kotlin, 面试官仍常问 Java/JVM: 因为字节码, 并发, GC 是共通的底层. 这一篇覆盖高频考点.
前置知识与示例说明: 先会使用 Java/Kotlin 的类, 集合和异常; Kotlin 语言细节见 Kotlin 语言核心, 协程并发的结构化生命周期见协程与 Flow. 本文 Java/Kotlin 代码为上下文片段, 除明确标注 “可运行示例” 外需补齐工程类型, 线程和 import; HotSpot 实现与 Android ART 必须按文中的版本边界区分.
学习目标: 能用术语准确解释共享内存并发, 集合扩容和类加载, 能为一个线程池任务选择合适的同步工具, 并能识别 ThreadLocal 的生命周期泄漏.
一, Java 对象契约与语言基础
equals / hashCode
equals表示逻辑相等, 必须满足自反, 对称, 传递, 一致和非空.- 重写
equals时必须同时重写hashCode: 相等对象必须有相同哈希值. - 作为
HashMapkey 的对象应保持不可变; 插入后修改参与相等判断的字段会导致对象仍在桶中, 但无法按新 key 找到. - Kotlin
data class会按主构造参数生成equals/hashCode;copy()是浅拷贝, 可变成员仍可能共享.
String 与拷贝
String不可变, 便于常量池复用, 线程安全和作为 Map key.StringBuilder是非同步的可变字符序列, 适合单线程或局部拼接;StringBuffer的多数公开方法带同步, 仅在确有同一缓冲区跨线程共享且无法更好地隔离状态时考虑. 不要因为 “线程安全” 就在每次普通拼接中选择StringBuffer.- 大量拼接应使用
StringBuilder或 Kotlin 的buildString, 不要在循环中反复用+创建中间String. - 字符串字面量可能进入 String Pool,
==在 Java 中比较引用,equals比较内容; Kotlin==会调用equals,===才比较引用. - 浅拷贝只复制对象外壳, 深拷贝还要复制可变引用图.
Parcelable, 序列化和缓存快照都要明确这个边界.
String.length() 返回的是 UTF-16 code unit 数, 不等于用户看到的字符数. 例如以下示意代码中, 基本平面 (BMP) 以外的字符由代理对表示, 因此 length() 为 2:
// 示意代码: 用 codePointCount 统计 Unicode code point, 不等同于所有用户感知字素簇.
String emoji = "\uD83D\uDE00";
int utf16Units = emoji.length(); // 2
int codePoints = emoji.codePointCount(0, emoji.length()); // 1
截断用户名, 计算展示宽度或按用户可见字符计数时, 还要按产品语言规则处理组合字符, 变体选择符和 emoji 序列; 不应只按 length() 截断.
抽象类, 接口与修饰符
抽象类用于表达共享状态或部分实现, 类只能继承一个父类; 接口用于表达能力契约, 一个类可实现多个接口. 接口的 default 方法可在不破坏既有实现类的情况下演进 API, 但多个父接口出现同签名默认方法时, 实现类必须显式消歧. 不要为了复用少量工具方法建立继承层次, 可优先组合或静态工具方法.
| 形式 | 状态与实现 | 何时用 | 不适用边界 |
|---|---|---|---|
| 抽象类 | 可持有实例字段, 构造器和受保护的模板实现 | 子类共享稳定状态与骨架 | 需要跨多个无关类型组合能力 |
| 接口 | 定义契约, 可有 default/static 方法, 不保存每个实现对象的实例状态 | 多实现, 替换实现或对外暴露能力 | 强依赖共享可变状态和构造流程 |
final变量只能赋值一次,final方法不可被覆写,final类不可继承. 它有助于表达不可变和限制扩展, 但不使被引用的可变对象自动线程安全.static成员属于类而非实例, 生命周期通常与类加载器一致. 静态集合或回调若持有Activity,View等短生命周期对象, 会形成长生命周期引用链.synchronized为同一监视器提供互斥, 并在解锁与后续加锁之间建立可见性关系. 它不自动缩短临界区, 也不应包住网络, 磁盘或长时间计算. 线程状态, 等待协作和锁实践见 多线程并发专题.
泛型, 反射与注解
- JVM 泛型通常经过类型擦除, 运行时不能直接得到普通
T的实参; Kotlininline + reified只能在调用点保留有限类型信息. - bridge method 示例: 泛型父类
abstract class Base<T> { abstract T get(); }, 子类class StringBase extends Base<String> { @Override String get() {...} }. 擦除后父类Base.get()返回Object, 子类实际方法签名是()Ljava/lang/String;, 与父类不一致, 编译器自动生成桥方法Object get()转发到String get(), 保证多态可用; 反射getDeclaredMethods()能看到isBridge() == true的额外方法. - 反射的典型流程是从
Class获取类型, 用getDeclaredMethod/getDeclaredConstructor查找成员, 根据访问控制决定是否允许访问, 再以invoke或newInstance调用.getDeclared*能找到本类声明的非公开成员, 但不等于一定有访问权限; Java 模块边界, 安全策略或 Android 混淆配置都可能限制访问. - 以下是反射流程示意代码, 仅说明访问检查和构造调用顺序, 不应直接用于高频业务路径:
// 示意代码: 真实代码需处理参数类型, 异常, 模块/混淆配置和访问边界.
Class<?> type = Class.forName("example.Profile");
Constructor<?> constructor = type.getDeclaredConstructor(String.class);
if (!constructor.canAccess(null)) {
constructor.setAccessible(true); // 仍可能因运行时访问规则失败.
}
Object profile = constructor.newInstance("Ada");
Method method = type.getDeclaredMethod("displayName");
Object value = method.invoke(profile);
- 反射适合框架扩展和低频配置, 代价是查找, 访问检查, 装箱, 启动, 混淆适配和更晚暴露的运行时错误; 高频路径优先使用接口或编译期代码生成. 量级感: 单次
invoke/newInstance相对直接调用常慢一到两个数量级, 且高频反射调用难以被 JIT 内联优化, 具体随 JDK/ART 版本与调用热点变化, 应以测量为准而非套固定数字. - 注解由声明位置, 元素和保留策略组成.
SOURCE仅供源码工具,CLASS写入 class 文件但通常不能反射读取,RUNTIME才能通过反射读取. Room, Hilt, 路由和序列化库经常把注解处理放在编译期; 运行时扫描前应确认确实声明了RetentionPolicy.RUNTIME.
异常与资源
Throwable分为Error与Exception.Error通常表示 JVM 或运行环境难以由业务恢复的问题, 例如OutOfMemoryError;Exception再分为RuntimeException及其子类, 与需要声明或捕获的受检异常.RuntimeException常用于非法参数, 状态不满足或程序错误, 可以传播到统一边界处理, 但不能用它掩盖可预期的业务失败. 受检异常适合调用方确实能采取恢复动作的外部失败; 不要机械地在每层throws Exception或捕获后吞掉上下文.- 不要用
Throwable兜底后吞掉取消, 线程中断或进程终止信号. 捕获InterruptedException时, 要么结束当前操作并向上处理, 要么在无法抛出的边界恢复中断标记Thread.currentThread().interrupt(). - 文件, Cursor, Socket 等资源必须有明确所有权和关闭路径. Kotlin 使用
use, 协程代码还要保证取消时执行清理. - OOM 分类 (风控 SDK 常排查内存): 堆 OOM (
OutOfMemoryError: Java heap space) 多来自对象泄漏或大对象 (采集缓存, Bitmap), 用堆 dump 沿 GC Root 引用链定位; 栈溢出StackOverflowError通常是无界递归或过深调用链 (如 JSON 自引用反序列化), 看异常栈的重复帧定位递归点; 元空间 OOM (Metaspace) 多来自类加载器泄漏或反射 / 动态生成类过多 (如反复生成代理类), 排查加载器存活与类数量.
变量生命周期与 I/O 分类
- 局部变量位于方法执行上下文, 超出作用域后不再由该局部变量引用; 实例字段随对象存活, 只要对象仍能从 GC Roots 可达就不会回收; 类变量随类及其类加载器存活. 因而
static缓存, 监听器和单例是常见的长生命周期持有点. - GC 判断的是对象是否可从 GC Roots 沿强引用链到达, 不是变量名是否还在源码中出现. 将对象赋给局部, 字段或类变量会改变可达性, 但实际回收时机仍由运行时决定.
- Java I/O 可按数据单位分为字节流
InputStream/OutputStream与字符流Reader/Writer; 可按处理层次分为节点流直接连接文件, 内存或 socket, 与缓冲, 编码, 对象序列化等处理流组合包装. 二进制内容按字节处理, 文本按明确字符集的字符流处理, 不要依赖平台默认编码. - 传统阻塞 I/O 在读写未就绪时会阻塞调用线程; NIO 的 ‘Channel’, ‘Buffer’ 与 ‘Selector’ 可支持非阻塞和多路复用, 但也增加状态管理复杂度. 少量顺序文件读写优先清晰的阻塞 I/O; 大量连接或需要事件驱动时再考虑 NIO, 并正确处理半包, 背压和资源关闭.
二, 集合框架
HashMap (必考)
- 结构: 数组 + 链表 + 红黑树 (JDK8).链表长度 ≥ 8 且数组长度 ≥ 64 转红黑树; 退化阈值 6.
- 默认初始容量为 16, 负载因子为 0.75; 桶数组通常在首次
put时才延迟分配, 之后达到阈值时扩容翻倍. 容量保持为 2 的幂, 便于用(n-1) & hash取代取模. - 扰动函数:
hash = h ^ (h >>> 16), 让高位参与运算, 减少碰撞. - JDK 8 扩容会按
hash & oldCap将单链表拆为 low/high 两条链, 并分别保持原相对顺序; 这不使 HashMap 线程安全. JDK 7 并发扩容的头插重排曾可能形成环, 但任何版本都不能用 HashMap 承担并发写入. - 并发问题: 多线程 put 可能丢数据, 扩容期间读到 null.
扩容的数字推演
术语:容量 (capacity) 是桶数组长度; 负载因子 (load factor) 是达到扩容的比例; 阈值 (threshold) 通常是 capacity * loadFactor; 桶 (bin) 是同一索引上的链表或树节点集合. 以下是 JDK 8 HashMap 常见规则的数字示例, 不适用于并发写入或所有替代 Map 实现.
假设首次插入已经分配桶数组为 16, 负载因子为 0.75, 则阈值为 16 * 0.75 = 12. 插入第 13 个, 且触发检查时需要扩容的条目后, 容量扩为 32, 新的阈值为 32 * 0.75 = 24. 容量翻倍时, 一个节点的新索引不是任意重算: 设旧容量 oldCap = 16, 节点 hash 为 19, 则 19 & 16 != 0, 新索引为旧索引加 16; 若 hash & oldCap == 0, 索引保持不变. 原桶链中的节点会进入 low/high 链且各自保持相对顺序. 这就是容量为 2 的幂能用位运算拆分低位/高位的原因.
常见失败是把 HashMap 用于并发写入或把可变 key 插入后修改. 症状是丢值, 读取异常或查不到已插入键; 证据是并发调用栈, key 修改前后的 equals/hashCode 和最小复现; 定位为无同步访问, 扩容竞争或 key 契约破坏; 修复是选择 ConcurrentHashMap/外部同步并令 key 不可变; 验证是在受控并发循环和断言中检查映射完整性. 不要把一次未复现当作线程安全证明.
ConcurrentHashMap
- JDK7: 分段锁 Segment; JDK8:CAS + synchronized 锁单个桶头节点, 粒度更细, 并发度更高.
- size () 用 baseCount + CounterCell 分散统计.
其他
| 接口 | 重复与顺序语义 | 何时用 | 不适用边界 |
|---|---|---|---|
List | 保留元素顺序, 允许重复, 通过索引访问 | 需要有序序列或允许重复项 | 只关心成员关系或按键查询 |
Set | 按相等性去重, 一般不承诺顺序 | 去重, 成员判断 | 需要重复项或位置语义 |
Map | 键唯一, 每个键关联一个值 | 通过稳定键查找值 | 只需无键序列或键本身需要重复 |
ArrayList是连续动态数组, 随机访问通常为 O (1), 尾部追加摊销为 O (1), 中间插入和删除需要移动元素. 它是大多数按索引读取场景的默认选择, 但频繁在中间插入删除大量元素时要测量移动成本.- 扩容发生在追加元素且当前数组已满时. 常见 OpenJDK 实现中, 无参构造会先使用共享空数组, 首次
add再延迟分配通常为 10 的容量; 指定初始容量的构造则按请求容量分配. 之后容量通常按旧容量约 1.5 倍增长, 并至少满足本次所需的最小容量. 扩容会通过Arrays.copyOf分配新数组并复制既有引用, 因而一次扩容是 O (n) 成本, 多次尾部追加的均摊成本才接近 O (1). - 已知要写入大量元素时, 可在写入前以
new ArrayList<>(expectedSize)或ensureCapacity(expectedSize)减少重复扩容和复制. 不要为不确定或过大的上限盲目预分配, 否则会增加堆占用和 GC 压力. 首次容量, 增长公式与内部空数组均是具体 JDK 实现细节, 需按目标 JDK 版本源码或文档核验, 不应视为 Java 语言规范承诺. LinkedList是双向链表, 已定位节点后的插入删除为 O (1), 但按索引随机访问必须遍历, 节点对象也有额外内存和缓存局部性成本. 不要仅因为 “插入快” 就把它用于按索引遍历的热路径.HashSet底层通常基于HashMap;LinkedHashMap额外维护双向链表, 可保持插入顺序, 或在 access-order 模式下将最近访问的节点移到末尾. 后者可作为受控大小 LRU 缓存的构件, 但并不自动解决并发, 过期, 加载抖动或缓存穿透.
Hashtable 与小型映射
Hashtable 自 Java 1.0 即存在, 历史原理是为早期 Java 提供同步键值容器. 其大部分公开方法以对象监视器保护, 因而常形成全表串行访问; 它还禁止 null 键和值. 当前 JDK 仍可使用且未被废弃, 但属于遗留同步容器, 新代码不应将它作为默认 Map. 多读写并发映射优先使用 ConcurrentHashMap; 非并发场景或可由短临界区明确保护的场景, 使用私有 HashMap 加受控外部同步. HashMap 即使由外部同步保护, 仍允许一个 null 键和多个 null 值; ConcurrentHashMap 则禁止 null 键和值, 以避免并发查询时无法区分键不存在与值为 null.
Android 的 ArrayMap 和 SparseArray 采用数组保存键和值或紧凑索引, 用较低对象数量换取查找时的二分, 移动或装箱取舍. 小规模映射, 特别是 Android 框架边界可考虑它们: SparseArray 适合 int 键以避免 Integer 装箱, ArrayMap 适合数量有限的对象键映射. 映射规模大, 频繁变更, 查找极热或需要并发访问时, 数组移动和查找成本可能不合适, 应基于数据规模和 profile 选择 HashMap 或并发容器. Android 组件持有与生命周期语境见 Android 四大组件与基础; 此处只定义容器的 Java 语义.
引用类型与 ReferenceQueue
| 引用类型 | 回收语义 | 适用场景 | 不应用于 |
|---|---|---|---|
| 强引用 | 从 GC Roots 可达时不会因 GC 回收 | 正常对象所有权 | 期待自动释放缓存 |
| 软引用 | 内存紧张时可被清理, 时机不保证 | 有明确内存压力策略的可重建缓存 | 承诺缓存一定保留或替代容量上限 |
| 弱引用 | 下一次 GC 发现仅剩弱引用时可清理 | 不拥有对象的关联, 监听器辅助结构 | 关键业务状态或资源生命周期管理 |
| 虚引用 | get() 始终为 null, 对象进入 phantom reachable 阶段后引用会被处理并入队 | 配合队列跟踪堆外资源回收流程 | 通过引用重新取得对象 |
ReferenceQueue 让程序在引用被 GC 入队后执行后续清理或登记. 虚引用入队表示对象已进入对应的可达性处理阶段, 不表示对象内存已完成回收; 它不提供确定的回收时间, 也不能替代 Closeable.close(), use 或显式资源所有权. 对文件, socket, 数据库游标和 native 资源, 应在不再使用时主动关闭, 不要等待引用队列.
三, 并发
synchronized
- 对象头 Mark Word 记录锁状态, GC 年龄, hashCode 或指向锁记录 / monitor 的指针.
synchronized进入临界区时会围绕 Mark Word 做 CAS 或 monitor 竞争, 所以面试讲锁升级不能只背流程, 要能说清 “对象头里记录了什么”. - 无锁: 对象未被线程持有; 第一次进入同步块时, 运行时会尝试在 Mark Word 中记录当前锁形态或把对象头复制到线程栈上的 Lock Record.
- 偏向锁: 面向 “同一线程反复进入同一把锁” 的场景, Mark Word 记录偏向线程 ID, 后续同线程进入几乎不需要 CAS. 出现其他线程竞争, 调用
hashCode()等需要占用 Mark Word 的信息时, 可能触发偏向撤销或重偏向. 版本边界要说清: JDK 15 起偏向锁被废弃并默认关闭, JDK 18 之后 HotSpot 已移除相关实现, 新版本面试更应把它当历史优化理解. - 轻量级锁: 多线程交替进入但竞争不激烈时, 线程在栈帧创建 Lock Record, 用 CAS 把对象 Mark Word 指向该记录; 失败后可能自旋, 希望持锁线程很快退出, 避免立即阻塞到内核态.
- 重量级锁: 竞争持续, 线程自旋失败或需要阻塞 / 唤醒时, 锁膨胀为 ObjectMonitor, 未抢到锁的线程进入阻塞队列, 由操作系统互斥量参与调度, 吞吐更稳定但上下文切换成本更高.
- 升级边界: 常见路径可概括为无锁 → 偏向锁 → 轻量级锁 → 重量级锁, 但这主要描述 HotSpot 早期到 JDK 14 默认配置下的优化路径; 锁可以膨胀, 退出同步块后不等于立刻恢复到最轻状态, 具体是否偏向, 是否自旋和阈值会受 JDK 版本与 JVM 参数影响.
- 修饰实例方法锁 this, 静态方法锁 Class, 代码块锁指定对象.
volatile
- 对同一变量的 volatile 写与后续读建立 happens-before, 提供可见性和必要的顺序约束; 不要机械解释成 “每次都立即刷主存”.
- 不保证原子性 (如
i++仍不安全). - 经典用途: 双重检查锁单例的实例字段.
CAS 与 AQS
- CAS(Compare-And-Swap): 无锁原子操作,
compareAndSwap(内存值, 期望值, 新值), 失败自旋重试. 底层是 CPU 指令. - ABA 问题: 值从 A→B→A, CAS 误判没变. 用版本号 (AtomicStampedReference) 解决.
- AQS(AbstractQueuedSynchronizer): 用 volatile int state + CLH 队列实现的同步框架, ReentrantLock, CountDownLatch, Semaphore 都基于它.
ReentrantLock 与并发工具怎么选
ReentrantLock 是显式锁: 同一线程可重入, 并可选择公平锁, tryLock 超时 / 可中断获取, 多个 Condition 队列和诊断能力. synchronized 语法更短, 异常退出会自动释放 monitor, 适合简单且作用域固定的临界区. 两者都不应包住网络, 磁盘或长计算.
| 工具 | 解决的问题 | 适用场景 | 不适用边界 |
|---|---|---|---|
synchronized | 互斥与可见性 | 小临界区, 无超时 / 中断需求 | 需要可取消等待或多个条件队列 |
ReentrantLock | 可控互斥 | 限时抢锁, 可中断等待, 复杂条件协调 | 可能忘记 unlock() 的简单场景 |
CountDownLatch | 等待固定次数完成 | 初始化并行任务后一次性汇合 | 需要重复使用的屏障 |
CyclicBarrier | 一组线程重复会合 | 多轮并行计算阶段同步 | 任务数不固定或异步回调链 |
Semaphore | 限制同时访问数量 | 连接池, 并发请求配额 | 需要表达数据依赖而非资源数量 |
BlockingQueue | 生产者 / 消费者交接 | 有界任务队列, 批处理 | 需要非阻塞响应式背压的场景 |
// 上下文片段:必须在 finally 中释放,避免异常路径永久占锁.
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
updateSharedState();
} finally {
lock.unlock();
}
} else {
reportContention();
}
锁竞争的症状是线程长期 BLOCKED/WAITING 或请求超时; 证据可来自线程 dump, 锁等待耗时和临界区日志; 定位持锁时间, 锁顺序和资源泄漏; 修复是缩短临界区, 固定锁顺序, 采用限时/可中断获取或改为消息传递; 验证用受控并发任务确认不会死锁且超时路径可见. 锁/线程池参数不能只套 “核数公式”, 应以任务 CPU/IO 比例, 队列等待, 拒绝次数和端到端延迟测量后调整.
ThreadLocal: 弱键不等于弱值
ThreadLocal 让每个线程在自己的 ThreadLocalMap 中保存一份变量. Map 的 entry 对 key (ThreadLocal 对象) 使用弱引用, 但 value 是强引用: 当 key 被 GC 后, entry 可能变成 key = null, value = largeObject. 在线程池的工作线程长期存活时, 若后续没有触发清理陈旧 entry, value 会继续被线程→ThreadLocalMap→entry 链路持有.
线程池工作线程(长期存活)
-> ThreadLocalMap
-> Entry(key 弱引用已清空,value 强引用仍在)
-> 大对象 / Context / 缓冲区
上下文片段: 每次设置后都在同一任务的 finally 调用 remove(), 不要依赖弱引用或下一次 map 操作的机会性清理.
private static final ThreadLocal<StringBuilder> BUFFER = new ThreadLocal<>();
void handle(Request request) {
try {
BUFFER.set(new StringBuilder());
process(request, BUFFER.get());
} finally {
BUFFER.remove();
}
}
典型症状是线程池空闲后堆中仍保留请求对象或 Activity/Context; 证据是堆 dump 的引用链指向 worker thread 的 ThreadLocalMap; 定位为 set 没有同任务 remove, 或在线程池中存放了不该跨请求复用的数据; 修复为 try/finally remove(), 缩小 value, 改为显式参数或受控对象池; 验证是重复提交任务并在任务结束后重新抓取堆引用链. Android 中也要避免将 Activity, View 等短生命周期对象放进长生命周期线程的 ThreadLocal.
线程池 ThreadPoolExecutor
七个参数: corePoolSize(核心线程), maximumPoolSize(最大), keepAliveTime, unit, workQueue(任务队列), threadFactory, handler(拒绝策略).
执行流程: 核心线程未满 → 创建核心线程; 满了 → 入队; 队列满 → 创建非核心线程到 max; 再满 → 触发拒绝策略 (AbortPolicy 抛异常 / CallerRunsPolicy 调用者执行 / DiscardPolicy 丢弃 / DiscardOldestPolicy 丢最老).
队列选型决定 “先排队还是先开线程”: SynchronousQueue 无缓冲, 任务直接交接给工作线程; 核心线程全忙时 offer 立即失败, 立刻创建非核心线程 (上限 max), 适合短而频繁, 阻塞占比高的 IO 任务: 阻塞线程不占 CPU, 多一线即多一线并发. ArrayBlockingQueue/LinkedBlockingQueue 有界缓冲, 核心线程满后先入队等待再开新线程, 适合 CPU 密集或需要削峰排队, 平滑负载的场景; 队列越大峰值越稳, 但任务滞留越久, 内存越高. 结合 CPU/IO 分类: IO 密集偏 “弹性线程 + 无缓冲” 提升并发, CPU 密集偏 “核数级线程 + 有界队列” 减少上下文切换, 最终仍以队列等待, 拒绝次数和延迟测量为准.
线程数不能用 “CPU 核数 + 1” 或 “IO 核数 × 2” 当固定答案. 先写清目标: 允许的同时在途任务数, 任务 CPU 时间与阻塞时间, 外部服务并发配额, 内存能承受的队列容量, 以及过载时是快速失败, 调用方降速还是可安全丢弃. 以保守 core/max, 有界队列和与业务匹配的拒绝策略开始, 在压测或生产观测中记录队列等待, 活跃线程, 拒绝次数, CPU 利用率, 外部依赖饱和度和端到端 p95/p99 延迟; 再一次只调整一个变量并比较结果. 长阻塞 IO 还应优先考虑超时, 限流和异步客户端, 而不是无限增大线程或队列.
四, Java 内存模型 (JMM)
JMM 是并发语义模型, 不是 “堆, 栈, 方法区” 那张 JVM 运行时区域图. 它回答的是: 一个线程写入共享变量后, 另一个线程在什么条件下必须看到这次写入.
三个核心性质
- 原子性: 操作不可被线程切开观察. 单次引用读写通常是原子的,
i++仍是读, 改, 写三步. - 可见性: 一个线程的写入何时对另一个线程可见.
volatile, 锁释放 / 获取和线程 join 等建立可见性关系. - 有序性: 编译器和 CPU 可以重排没有同步约束的指令.
volatile和锁会提供必要的顺序保证.
happens-before 规则
面试至少能说出以下规则, 并说明它们可以传递:
- 同一线程内, 前面的操作 happens-before 后面的操作.
- 对同一把锁, unlock happens-before 后续 lock.
- 对同一个 volatile 变量, 写 happens-before 后续读.
Thread.start()happens-before 新线程中的操作.- 线程中的全部操作 happens-before 另一个线程成功
join()返回. - 若 A happens-before B 且 B happens-before C, 则 A happens-before C.
private val ready = AtomicBoolean(false)
private var payload: String? = null
fun publish(value: String) {
payload = value
ready.set(true)
}
fun read(): String? = if (ready.get()) payload else null
这里 ready 的 volatile 写读建立了顺序边界, 使读线程在观察到 true 后能看到之前对 payload 的写入. 业务代码更推荐直接发布不可变快照, 避免让多个可变字段组成隐含协议.
安全发布与 Android 边界
- 通过静态初始化, 锁, volatile 引用, 线程安全容器或协程状态流发布对象, 才能避免读到半初始化状态.
final字段在构造完成后有额外的初始化可见性保证, 但对象引用暴露后仍不能把可变成员当成线程安全.- JMM 规定的是语言级可见性和顺序; Android ART, 主线程 Looper, Binder 和协程 Dispatcher 还会叠加各自的调度语义.
五, JVM 内存与 GC
内存区域
- 线程私有: 程序计数器, 虚拟机栈, 本地方法栈.
- 线程共享: 堆 (对象实例, GC 主战场), 方法区 / 元空间 (类信息, JDK8 后用本地内存).
- 注意 Android 用 ART/Dalvik 而非标准 JVM, 但内存模型概念相通.
GC
- 判活: 引用计数 (有循环引用问题) vs 可达性分析 (GC Roots, 主流).
- GC Roots: 虚拟机栈引用, 静态变量, 常量, JNI 引用等.
- 判活与泄漏定位对应: 泄漏对象未被回收, 是因为它仍能从 GC Roots 沿强引用链到达: 本质是被 static 缓存, 单例或长期存活线程等长生命周期对象强引用; 用堆 dump 沿引用链找根, 即可定位 “谁还持有它”.
- 回收算法: 标记 - 清除 (碎片), 复制 (浪费空间, 适合新生代), 标记 - 整理 (适合老年代).
- HotSpot 分代模型: 服务端 JVM 常按新生代/老年代理解, Eden + Survivor, Minor GC, Major/Full GC 是典型面试语言; 但比例 (如 8:1:1) 和具体算法不是 Java 语言规范, 会受 JVM 版本, 收集器和参数影响.
- HotSpot 回收器边界: Serial 适合小堆/单核或客户端场景; Parallel 关注吞吐; CMS 关注低停顿但有碎片和浮动垃圾问题, 已在 JDK 9 标记废弃, JDK 14 移除; G1 将堆划分为 Region, 兼顾可预测停顿; ZGC/Shenandoah 面向大堆低停顿. 回答时应说 “这些是 HotSpot 收集器”, 不要直接套到 Android.
- Android ART 区分: Android App 运行在 ART(早期为 Dalvik) 上, 不是标准 HotSpot Server VM. ART 也有堆, 线程栈, JNI 引用, 可达性分析和并发/分代等 GC 思路; Android Runtime 文档里的 CMS/CC 是 ART 自己的 GC plan, 不等同于 HotSpot CMS/G1/ZGC, 普通应用也不是通过 HotSpot collector 参数来选择它们. Android 面试更关注内存泄漏, 对象分配抖动, Bitmap/native 内存, GC pause 对掉帧的影响.
- 安全表述: 讲 JVM 基础时可用 HotSpot 解释 Serial/Parallel/G1/ZGC; 讲 Android 性能时应切到 ART 语境, 用 “ART 的具体 GC 策略随 Android 版本和设备实现演进” 这类条件措辞, 避免把服务端 JVM 参数经验当作移动端结论.
类加载
- 过程: 加载 → 验证 → 准备 → 解析 → 初始化.
- 父优先委派: 默认
ClassLoader.loadClass通常先检查已加载类, 再请求父加载器, 父无法加载才尝试自身查找; 它帮助保持核心类唯一性, 但具体行为由loadClass实现决定. - 主动引用触发初始化:
new实例, 读写静态字段 (非常量), 调用静态方法, 反射调用, 或初始化某个子类 (父类先初始化) 等场景, 会触发目标类的初始化阶段. - 被动引用不触发: 引用
final static编译期常量 (值在编译期内联进常量池), 通过子类引用父类的静态字段, 或用数组定义引用类, 都不会触发目标类初始化. - 初始化阶段才真正执行: 准备阶段只给静态字段分配默认值 (零值),
static块与静态赋值在初始化阶段才运行. 与类不同, 初始化接口不会自动初始化其父接口 (除非父接口的非编译期常量静态字段被访问).
类加载器层级与术语
在现代 HotSpot 中常见层级为 Bootstrap ClassLoader (由 JVM 实现, 加载核心平台类)→ Platform ClassLoader (平台模块)→ Application/System ClassLoader (应用 classpath).旧资料中的 Extension ClassLoader 对应旧 JDK 机制, 不能直接套到模块化 JDK.
四种容易混淆的情形应分别表述:
- SPI/TCCL: 接口通常由较高层加载器加载;
ServiceLoader或框架可通过线程上下文类加载器 (TCCL) 发现子加载器可见的实现. 这是选择发现实现的加载器, 不等于该加载器普遍 child-first. - 容器 child-first: 为隔离应用依赖, 某些容器的 WebAppClassLoader 对非受保护包可先尝试自身, 再委派父加载器; 核心 / 受保护包仍可能 parent-first. 规则由容器实现和配置决定.
- 自定义
loadClass: 只有覆写loadClass/定义查找顺序的加载器才可能改变默认父优先顺序; 覆写时须处理已加载检查, 并发锁和受保护类, 不能笼统称所有插件都 “打破双亲委派”. - Android
dexElements: ART 的BaseDexClassLoader/DexPathList会按 elements 列表查找 dex 路径; 将补丁 element 前插改变的是该加载器自己的 path 查找优先级, 仍要与其 parent 委派, Android 版本, 加载时机和签名 / 兼容性限制分开讨论.
Android 的 ART/DexPathList 类加载与标准 HotSpot 层级不是同一实现. 谈热修复时应说明其依赖特定 Android 版本, 加载时机, 签名/兼容性与平台限制, 不能将 dexElements 前插说成通用 Java 机制或一律称为 “打破双亲委派”.
HotSpot, ART 与 desugaring 边界
JVM 是 Java 虚拟机规范及其实现类别, HotSpot 只是常见的 JVM 实现, 不能把 JVM 一律等同于 HotSpot. 传统 Java 工具链通常将 Java/Kotlin 源码编译为 class 字节码, HotSpot 以栈式字节码执行并结合解释器和 JIT; 其 JIT, GC 和对象布局结论不能直接等同于 Android Runtime.
Android 早期 Dalvik 以 DEX 为主要执行格式, 使用寄存器式指令集, 与 class 文件及栈式 JVM 字节码不同. Dalvik 早期以解释执行为主, 后续 Android 版本引入 JIT 等优化; 具体策略随系统版本演进, 不应把早期行为当作所有设备结论. ART 在 Android 5.0/API 21 起取代 Dalvik, 先以安装期 AOT 为主要策略, 后续版本逐步演进为结合运行时 JIT, profile 引导 AOT 和安装/空闲期编译的模式. ART 的 GC 和运行时实现也随 Android 版本, 设备和厂商演进, 因此 Android 面试应按目标 API 级别说明, 而不是套用 HotSpot 收集器或参数.
Android 还受 DEX, AOT/JIT profile, 设备系统版本和厂商实现影响. Java 语言/API 能否在 Android 使用还取决于 minSdk, desugaring, AGP/JDK 与 library desugaring 配置; 面试时分别说明 “Java/JMM 规范”, “JVM 实现如 HotSpot” 和 “Dalvik/ART 与 Android 工具链”. 最后核验: 2026-08-08.
高频面试题
Q1: HashMap 为什么线程不安全? ConcurrentHashMap 怎么优化? HashMap 并发 put 会丢数据, JDK7 扩容还会成环死循环. ConcurrentHashMap JDK8 用 CAS + synchronized 锁桶头, 只锁单个桶, 并发度高.
Q2: volatile 能保证原子性吗?
不能. 只保证可见性和有序性. i++ 是读 - 改 - 写三步, volatile 不能保证复合操作原子, 需用 Atomic 类或锁.
Q3: synchronized 锁升级过程? 先讲对象头 Mark Word: 它保存锁标志位, GC 年龄, hashCode 或指向 Lock Record/ObjectMonitor 的指针. 典型 HotSpot 路径是无锁 → 偏向锁 (同一线程反复进入, Mark Word 记录线程 ID)→ 轻量级锁 (栈上 Lock Record + CAS, 少量竞争时自旋)→ 重量级锁 (ObjectMonitor, 竞争线程阻塞/唤醒).边界是: 偏向锁在 JDK 15 起废弃并默认关闭, JDK 18 后 HotSpot 移除; 新版本里不要把偏向锁当成必经阶段. 锁膨胀后通常不会在退出同步块时立刻退回最轻状态, 具体策略受 JVM 版本和参数影响.
Q4: 线程池核心线程会被回收吗?
默认不会. 设置 allowCoreThreadTimeOut(true) 后核心线程空闲超时也回收.
Q5: 为什么用线程池? 核心线程数怎么定? 复用线程降低创建销毁开销, 控制并发并统一治理. 先按目标并发, 任务阻塞比例, 外部依赖配额, 队列内存预算和拒绝语义设保守初值, 再测量队列等待, 拒绝次数, CPU, 外部依赖饱和度及端到端 p95/p99 延迟, 逐项调整. CPU/IO 分类只是输入, 不能推出固定线程数公式.
Q6: 双亲委派的作用? 为什么 Android 热修复要打破它?
父优先的默认查找顺序有助于核心类唯一性和一致性. SPI/TCCL, 容器 child-first, 自定义 loadClass 和 Android dexElements 前插是不同机制: 后者改变的是 DexPathList 中 path element 的查找优先级, 并不自动等于绕过 parent 委派. 热修复方案必须按对应 Android 版本, 加载器, 加载时机, 签名和兼容性说明其实际查找链.
Q7: JMM 和 JVM 内存区域有什么区别? JMM 描述线程之间共享变量的可见性, 顺序和同步规则; JVM 内存区域描述程序计数器, 栈, 堆, 方法区等运行时数据位置. 一个是并发语义, 一个是运行时结构, 不能混为一谈.
Q8: 什么是 happens-before? volatile 为什么能安全发布引用? happens-before 表示前一个操作的结果对后一个操作可见, 且顺序受约束. 对同一 volatile 变量的写 happens-before 后续读, 因此先完成对象初始化再写入 volatile 引用, 读线程观察到该引用后也能看到初始化写入.
Q9: 为什么重写 equals 必须重写 hashCode? HashMap 先用 hash 定位桶, 再用 equals 判断逻辑相等. 如果两个相等对象的 hashCode 不同, 它们可能落入不同桶, 导致查找, 去重和 Set 语义失效.
Q10: 反射和编译期代码生成怎么选? 反射灵活, 但错误更晚暴露, 还会增加启动, 混淆和性能风险; KSP/APT 等代码生成在编译期校验并生成直接调用, 更适合高频和强类型路径. 小规模插件扩展或调试工具仍可使用反射.
易错点 / 追问
- 不要把 volatile 说成 “保证变量操作原子”; 它不能让
i++变成原子操作. - 不要把 JMM 的主内存 / 工作内存抽象直接等同于物理 RAM 和 CPU Cache.
- 不要把
String常量池, 堆和方法区的具体位置说成所有 JDK/ART 版本都固定不变. - HashMap key, StateFlow state 和跨线程快照优先使用不可变对象, 降低发布后被修改的风险.
- Android 使用 ART, 不能把 HotSpot 的 GC 收集器参数和锁实现细节当成 Android 的稳定 API.