Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Java 与 JVM 基础

即便项目用 Kotlin, 面试官仍常问 Java/JVM: 因为字节码, 并发, GC 是共通的底层. 这一篇覆盖高频考点.

前置知识与示例说明: 先会使用 Java/Kotlin 的类, 集合和异常; Kotlin 语言细节见 Kotlin 语言核心, 协程并发的结构化生命周期见协程与 Flow. 本文 Java/Kotlin 代码为上下文片段, 除明确标注 “可运行示例” 外需补齐工程类型, 线程和 import; HotSpot 实现与 Android ART 必须按文中的版本边界区分.

学习目标: 能用术语准确解释共享内存并发, 集合扩容和类加载, 能为一个线程池任务选择合适的同步工具, 并能识别 ThreadLocal 的生命周期泄漏.

一, Java 对象契约与语言基础

equals / hashCode

  • equals 表示逻辑相等, 必须满足自反, 对称, 传递, 一致和非空.
  • 重写 equals 时必须同时重写 hashCode: 相等对象必须有相同哈希值.
  • 作为 HashMap key 的对象应保持不可变; 插入后修改参与相等判断的字段会导致对象仍在桶中, 但无法按新 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 的实参; Kotlin inline + 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 规则

面试至少能说出以下规则, 并说明它们可以传递:

  1. 同一线程内, 前面的操作 happens-before 后面的操作.
  2. 对同一把锁, unlock happens-before 后续 lock.
  3. 对同一个 volatile 变量, 写 happens-before 后续读.
  4. Thread.start() happens-before 新线程中的操作.
  5. 线程中的全部操作 happens-before 另一个线程成功 join() 返回.
  6. 若 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.