源码阅读高频(HashMap/ConcurrentHashMap/Spring)

共 20 题
📑 题目列表 20 题
#
★★★

1. HashMap 的 treeifyBin 阈值 8 与扩容阈值 0.75 的设计依据分别是什么?

请说明 HashMap 的 treeifyBin 阈值 8 与扩容阈值 0.75 的设计依据?

  • 树化阈值 8 的泊松分布依据
  • 负载因子 0.75 的权衡
  • 树与扩容的触发

树化阈值 8:当链表长度达到 8 且数组长度达到 64 时树化。依据是泊松分布——在理想哈希下,哈希冲突导致链表长度到达 8 的概率极低(约千万分之六),可认为正常场景不会出现,出现 8 说明哈希分布严重异常,此时用红黑树 O(log n) 替代链表 O(n) 更优。负载因子 0.75:在"空间利用率"与"冲突概率"之间权衡——0.75 时,理想情况下节点分布均匀,桶中结点数在 8 以上的概率极小;若负载因子太高(如 1)空间利用率高但冲突多、查找变慢,太低则频繁扩容浪费空间。0.75 是经验上时间与空间的平衡点。

阈值 8 来自泊松分布统计,保证正常哈希下几乎不会树化;0.75 是时间与空间的折中,扩容前桶的期望数量为 0.75*capacity,能较好控制冲突。两者都服务于"哈希分布良好时用链表,异常时用树/扩容"。

// JDK 源码常量
static final int TREEIFY_THRESHOLD = 8;   // 链表树化阈值
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子
#
★★★

2. ConcurrentHashMap 的 size() 为什么用 CounterCell 数组,与 LongAdder 的关系?

请说明 ConcurrentHashMap 的 size() 为什么用 CounterCell 数组,以及它与 LongAdder 的关系?

  • CounterCell 数组分段计数
  • 与 LongAdder 的关系
  • 高并发下 size 的准确性

ConcurrentHashMap 为高并发统计 size 用 CounterCell 数组:每个单元(Cell)独立计数,写入时先尝试更新 base,竞争激烈时把计数分散到自己的 Cell 上(通过线程哈希定位 Cell),减少对单一计数器的竞争。sum() 时累加 base 与所有 Cell。这与 LongAdder 的分段计数思路完全一致——LongAdder 就是"base + Cells"的 Striped64 结构,ConcurrentHashMap 的 CounterCell 来源于同源设计。这样避免高并发写时所有线程争抢一个计数器导致性能下降,size() 是"最终一致"的近似值(弱一致),不保证精确实时。

单一计数器在高并发下 CAS 竞争激烈;分段后每个线程命中自己的 Cell,写竞争大幅降低。size() 是遍历求和,可能有轻微误差,但吞吐远高于单计数器。ConcurrentHashMap 与 LongAdder 共享 Striped64 的分段思想。

// ConcurrentHashMap 简化:CounterCell 分段
class CounterCell { volatile long value; }
// 增加时:先尝试 base,竞争激烈用 cell
// sum():遍历 cell 累加
#
★★★

3. HashMap 1.7 与 1.8 的关键差异,头插改尾插、引入红黑树、扩容时高低位拆分,分别解决了什么问题?

请说明 HashMap 1.7 与 1.8 的关键差异:头插改尾插、引入红黑树、扩容时高低位拆分,分别解决了什么问题?

  • 头插 vs 尾插
  • 红黑树引入
  • 扩容高低位拆分

1.7 在插入冲突时用头插法(新节点插到链表头),扩容迁移时用头插,多线程并发扩容可能形成环形链表导致死循环(CPU 100%);1.8 改为尾插法,避免扩容时链表逆序,降低形成环的概率。引入红黑树:当链表过长(哈希严重冲突)时,查找从 O(n) 降为 O(log n),防止恶意哈希攻击导致链表退化成线性查找。扩容高低位拆分:1.8 扩容后,元素要么留在原索引,要么移到"原索引+旧容量",因为容量是 2 的幂,新索引只取决于最高位(扩容多出的那一位),通过 (e.hash & oldCap) 判断是 0 还是 1,实现高低位拆分,避免重算全部哈希,迁移更高效。

三个改进分别解决:头插改尾插解决并发环形链表/死循环;红黑树解决哈希冲突退化;高低位拆分解决扩容重哈希性能。1.8 仍非线程安全(数据覆盖),但避免了 1.7 的死循环。

// 1.8 扩容时判断是否迁移到高位
if ((e.hash & oldCap) == 0) { /* 原索引 */ } else { /* 原索引 + oldCap */ }
#
★★★

4. HashMap 为什么线程不安全,JDK 1.7 死循环(环形链表)、1.8 数据覆盖,ConcurrentHashMap 如何避免?

请说明 HashMap 为什么线程不安全,包括 JDK 1.7 死循环(环形链表)与 1.8 数据覆盖,以及 ConcurrentHashMap 如何避免?

  • 1.7 扩容死循环
  • 1.8 数据覆盖
  • ConcurrentHashMap 的并发保证

HashMap 线程不安全:1.7 中并发扩容时,多个线程同时迁移链表,使用头插法可能把链表指针弄成环,后续 get 在环上无限遍历,造成死循环(CPU 100%)和丢数据。1.8 改用尾插和更好的扩容,避免了环形链表,但并发 put 时两个线程可能同时发现某个槽为空,都 CAS 赋值,后写覆盖先写(数据丢失);且 size 修改前非原子,多线程时计数错误。ConcurrentHashMap 如何避免:1.8 用 volatile 数组 + CAS 空槽 + synchronized 锁链表头,保证并发 put 的原子性与可见性;扩容用 ForwardingNode 标记与多线程协助迁移,保证迁移安全;size 用 CounterCell 分段计数。因此并发安全。

1.7 的问题根源是"无同步的并发扩容 + 头插反转链表";1.8 解决了死循环但仍有数据覆盖。ConcurrentHashMap 用 CAS + 锁 + volatile 在每步保证原子与可见,从根本上解决并发问题。

// ConcurrentHashMap 1.8 put 空槽 CAS
if (tabAt(tab, i) == null) {
    if (casTabAt(tab, i, null, new Node<>(hash, key, value))) break; // CAS 防覆盖
}
#
★★★

5. Spring Bean 生命周期中 Aware 接口、BeanPostProcessor 与 InitializingBean 的执行顺序?

请说明 Spring Bean 生命周期中 Aware 接口、BeanPostProcessor 与 InitializingBean 的执行顺序?

  • Bean 生命周期各阶段
  • Aware 回调
  • BeanPostProcessor 与 InitializingBean

Spring Bean 生命周期大致顺序:实例化 → 属性填充 → Aware 回调(BeanNameAware、BeanFactoryAware、ApplicationContextAware)→ BeanPostProcessor 前置处理(postProcessBeforeInitialization)→ 初始化(InitializingBean.afterPropertiesSet / @PostConstruct / init-method)→ BeanPostProcessor 后置处理(postProcessAfterInitialization,AOP 代理在此生成)→ 使用 → 销毁。核心顺序:先 Aware 注入自身感知信息,再 BeanPostProcessor 前后处理,InitializingBean 在 BP 前置之后、后置之前执行。AOP 代理在 postProcessAfterInitialization 生成,所以代理对象是后置处理的结果。

顺序是"容器感知 → 前置增强 → 显式初始化 → 后置增强(代理)"。Aware 用于拿到容器/名称等,InitializingBean 用于自定义初始化,BeanPostProcessor 是扩展点(可增强、可代理)。记住"InitializingBean 在 BP 前后之间"。

public class MyBean implements BeanNameAware, InitializingBean, BeanPostProcessor {
    // 1. BeanNameAware.setBeanName
    // 2. postProcessBeforeInitialization
    // 3. InitializingBean.afterPropertiesSet
    // 4. postProcessAfterInitialization(AOP 代理在此)
}
#
★★★

6. Spring AOP 的代理选择,JDK 代理 vs CGLIB 在什么条件下自动切换?

请说明 Spring AOP 的代理选择:JDK 代理与 CGLIB 在什么条件下自动切换?

  • JDK 代理条件
  • CGLIB 条件
  • proxyTargetClass 配置

Spring AOP 默认基于接口选择代理:若要代理的目标类实现了接口,默认使用 JDK 动态代理;若目标类没有实现接口,则使用 CGLIB 代理。可通过 proxyTargetClass=true 强制使用 CGLIB(即使有接口)。Spring Boot 2.x 起默认 proxyTargetClass=true,即默认优先 CGLIB(基于类代理)。JDK 代理限制:只能代理接口方法,目标必须实现接口;CGLIB 限制:不能代理 final 类/方法。需要代理的类无接口时自动切到 CGLIB。@Configuration 也是通过 CGLIB 代理实现 @Bean 单例。

自动切换的核心判断是"目标是否有接口"。有接口用 JDK,无接口用 CGLIB;也可显式配置。Spring 5+/Boot2 默认 CGLIB,兼顾无接口类与完整代理。

// 配置强制 CGLIB
@EnableAspectJAutoProxy(proxyTargetClass = true)
// 目标有接口 -> 默认 JDK 代理;无接口 -> CGLIB
#
★★★

7. ConcurrentHashMap 的 get 为何无锁,volatile 数组引用与 Node 字段的可见性保证,读取期间并发修改的安全性

请说明 ConcurrentHashMap 的 get 为何无锁,包括 volatile 数组引用与 Node 字段的可见性保证,以及读取期间并发修改的安全性?

  • volatile 数组引用
  • Node 字段可见性
  • 无锁读的安全性

ConcurrentHashMap 的 get 无锁:1)数组引用是 volatile,扩容后新数组引用更新对读者可见,读线程能读到最新数组;2)Node 的 val 和 next 字段是 volatile,put 时写入的值对读线程立即可见;3)get 时不修改任何状态,读到的要么是已写入的完整值,要么是 null(通过 CAS 先写引用再写值的顺序保证不会读到半初始化)。读取期间并发修改安全:get 遍历链表基于 volatile next 读取,即使并发 put 修改链表,也能读到一致的数据(可能读到旧值但不会读到损坏数据);扩容时通过 ForwardingNode 指示,读者会转发到新表或用尾插避免读到迁移中不一致。因此 get 无需锁即可安全无锁读。

无锁读依赖 volatile 的可见性:数组引用、Node.val、Node.next 都是 volatile,保证"写先于读可见"。get 只读不写,配合正确的发布顺序,无需锁即可安全。这是"写时锁、读时无锁"的典型。

// Node 的 val 与 next 是 volatile
static class Node<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; }
// get:无需锁,直接 volatile 读取
#
★★★

8. Spring @Configuration 的 CGLIB 代理,full/lite 模式判定,为什么 @Bean 方法互相调用能保持单例

请说明 Spring @Configuration 的 CGLIB 代理,包括 full/lite 模式判定,以及为什么 @Bean 方法互相调用能保持单例?

  • full 模式与 lite 模式
  • CGLIB 代理 @Configuration
  • @Bean 互相调用的单例保证

@Configuration 类被 CGLIB 代理(full 模式),代理拦截 @Bean 方法的调用,先从容器缓存取已有 Bean,若已存在则直接返回缓存实例,从而保证即使 @Bean 方法互相调用,也返回同一个单例实例。lite 模式:类上没标 @Configuration(如只标 @Component 或普通类里的 @Bean 方法),不生成 CGLIB 代理,@Bean 方法互相调用会各自 new 新实例,不能保证单例。判定:类上标注 @Configuration 为 full 模式,否则为 lite 模式。full 模式通过代理保证 @Bean 方法调用的单例语义与生命周期。

full 模式的价值是"代理拦截 @Bean 调用,返回容器中的单例",避免 @Bean 方法内直接调用另一个 @Bean 方法时产生新实例。lite 模式省代理但无此保证。这也解释了为什么 @Configuration 类必须在方法调用中找缓存。

@Configuration // full 模式,CGLIB 代理
public class Config {
    @Bean public A a(){ return new A(); }
    @Bean public B b(){ return new B(a()); } // 调用 a() 返回容器中已有的单例,而非新建
}
#
★★

9. ConcurrentHashMap 1.7 分段锁与 1.8 CAS+synchronized 的演进,锁粒度、扩容并发、size 计数的差异如何?

请说明 ConcurrentHashMap 1.7 分段锁与 1.8 CAS+synchronized 的演进,分析锁粒度、扩容并发、size 计数的差异?

  • 1.7 分段锁与 1.8 CAS+synchronized
  • 锁粒度差异
  • 扩容与 size 计数差异

1.7 用 Segment 分段锁:数组分成若干段,每段一把锁,并发度 = 段数(默认 16),锁粒度是段级。1.8 抛弃 Segment,用 CAS(空槽)+ synchronized(锁桶链表头),锁粒度降到桶级,并发度更高。扩容:1.7 扩容要锁住整个段,其他段可并发读写,单段内扩容不可并发;1.8 扩容是多线程协助迁移(ForwardingNode + helpTransfer),多个线程可并发迁移不同桶,扩容更快。size 计数:1.7 用每个段的 count 累加,统计时可能加锁重试;1.8 用 CounterCell 分段计数(LongAdder 思路),高并发下更高效。整体上 1.8 锁粒度更细、扩容并发、size 计数更优。

演进是"锁粒度从段级到桶级 + CAS 无锁读 + 并发扩容 + 分段计数"。1.8 用 synchronized 锁局部桶头,比 Segment 更细,且 CAS 空槽减少锁的使用。这是 1.8 并发性能更优的根本。

// 1.7: Segment 分段,锁粒度 = 段
// 1.8: CAS 空槽 + synchronized 锁桶头 + helpTransfer 协助扩容 + CounterCell 计数
#
★★

10. ThreadLocal 源码,ThreadLocalMap 的弱引用 Key 与内存泄漏,为什么建议用完 remove?

请说明 ThreadLocal 源码,ThreadLocalMap 的弱引用 Key 与内存泄漏,以及为什么建议用完 remove?

  • ThreadLocalMap 弱引用 Entry
  • 内存泄漏机制
  • remove 的必要性

ThreadLocalMap 的 Entry 用弱引用做 key(static class Entry extends WeakReference<ThreadLocal>),value 是强引用。当外部 ThreadLocal 强引用消失后,key 被 GC 变为 null,但 value 仍被 Entry 强引用,无法回收。若线程是线程池中的长生命周期线程,这些"key=null 的过期 Entry"会一直累积,value 泄漏。虽然 ThreadLocalMap 在 get/set 时惰性清理过期 Entry,但若该 key 再也不被访问,就不会被清理。因此建议用完显式 remove(),彻底删除该 Entry 及 value,避免泄漏。这是线程池场景下必须 remove 的原因。

弱引用 key 是为了让 ThreadLocal 不阻止 GC,但 value 强引用残留导致泄漏。remove 是最可靠的清理手段;线程池线程复用放大了泄漏风险。

ThreadLocal<String> tl = new ThreadLocal<>();
try {
    tl.set("value");
    // 使用
} finally {
    tl.remove(); // 显式删除 Entry,防止 value 泄漏
}
#
★★

11. String 源码,hashCode 缓存、equals 优化与 compareTo 的字节比较

请说明 String 源码,包括 hashCode 缓存、equals 优化与 compareTo 的字节比较?

  • hashCode 缓存
  • equals 优化
  • compareTo 字节比较

String 的 hashCode 使用缓存:首次计算 hash 后存入 int hash 字段,之后重复调用直接返回缓存值(因为 String 不可变,hash 不变)。equals 优化:先比较引用(== 快速判断同一对象),再判断是否 String 类型,再比较长度(长度不同直接 false),最后逐字符比较;若内部是 byte[] 数组(JDK9+ 用 byte 数组,含 latin1/utf16 两种编码),按编码比较。compareTo 按字典序逐字符比较,比较的是字符的编码值(UTF-16 码元),找到第一个不同字符的差值即返回。这些优化都建立在 String 不可变的基础上。

不可变性让 hashCode 缓存合法、equals 可用长度快速判断。compareTo 逐字节/逐字符比较编码值,实现字典序。JDK9 用 byte[] 压缩存字符,减少内存。

// hashCode 缓存
private int hash; // 默认 0
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) { h = 31*h + value[i]; ...; hash = h; }
    return h;
}
#
★★

12. ReentrantLock 源码,AQS 的入队/唤醒(unparkSuccessor)与公平/非公平锁的差异

请说明 ReentrantLock 源码,AQS 的入队/唤醒(unparkSuccessor)与公平/非公平锁的差异?

  • AQS 入队与唤醒
  • unparkSuccessor 唤醒后继
  • 公平/非公平差异

ReentrantLock 基于 AQS。获取锁失败时,当前线程被包装成 Node 入队(CLH 队列),通过 CAS 追加到队尾。释放锁时,unparkSuccessor 负责唤醒队首的第一个有效(非取消)后继节点:从队尾向前找最新的非取消节点,用 LockSupport.unpark 唤醒该线程。公平/非公平差异:非公平模式在 tryAcquire 时直接 CAS 抢锁,不检查队列,可能插队(新到的线程抢到锁);公平模式先调用 hasQueuedPredecessors 检查队列中是否有等待者,有则必须排队,先到先得。非公平吞吐更高(减少唤醒开销),公平避免饥饿。

入队用 CAS 保证并发安全;唤醒用 unparkSuccessor 从队尾向前找非取消节点。公平与非公平只在"获取锁前是否检查队列"上有差异,是 ReentrantLock 构造参数控制。

// 非公平:直接 CAS 抢
if (compareAndSetState(0, acquires)) return true;
// 公平:先检查队列
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) return true;
#
★★

13. Spring 循环依赖的解决,三级缓存(singletonObjects/earlySingletonObjects/singletonFactories)为何需要第三级?

请说明 Spring 循环依赖的解决,三级缓存(singletonObjects/earlySingletonObjects/singletonFactories)为何需要第三级?

  • 三级缓存结构
  • 第三级 ObjectFactory 的作用
  • 无法解决的循环依赖

三级缓存:singletonObjects(一级,完整单例)、earlySingletonObjects(二级,已创建未完全初始化的早期引用)、singletonFactories(三级,ObjectFactory 工厂)。处理循环依赖:创建 A 时,A 实例化后放入三级缓存(提供 ObjectFactory);注入 B 时发现 B 依赖 A,从三级缓存通过 ObjectFactory 拿到 A 的早期引用(若需 AOP 则在此返回代理),注入 B;B 创建完成后再回头完善 A。为何需要第三级而不只是二级:二级缓存能拿到早期引用,但三级缓存加入 ObjectFactory,是为了在 A 创建期间、AOP 后置处理尚未执行时,也能让早期引用经过代理(提前应用 AOP),保证注入给 B 的引用与最终 A 一致。若没有三级,A 的代理无法在循环依赖场景正确生成。构造器注入无法解决循环依赖(实例化就需要依赖)。

三级缓存的价值在于"支持在早期引用阶段就应用 AOP/代理"。二级缓存只能返回原始对象,无法在此时生成代理。第三级 ObjectFactory 延迟到需要时才生成代理,保证一致性。核心依赖是"提前暴露半成品 + 工厂支持代理"。

Map<String, Object> singletonObjects;      // 一级
Map<String, Object> earlySingletonObjects; // 二级
Map<String, ObjectFactory<?>> singletonFactories; // 三级
// 三级缓存:getSingleton 时先从一级、二级,最后通过三级工厂获取
#
★★

14. ConcurrentHashMap 的扩容,多线程协助迁移与 sizeCtl 的作用如何?

请说明 ConcurrentHashMap 的扩容,多线程协助迁移与 sizeCtl 的作用?

  • 扩容触发
  • 多线程协助迁移
  • sizeCtl 控制

扩容时,ConcurrentHashMap 用 sizeCtl 作为控制状态:初始为容量阈值,扩容时置为负数(高 16 位是扩容标识戳,低 16 位是参与迁移的线程数 +1)。一个线程触发扩容后,其他线程可通过 helpTransfer 检测到 ForwardingNode(已迁移的桶标记)并协助迁移,多个线程各自负责一部分桶,加快扩容。迁移时每个桶迁移后置为 ForwardingNode,读线程遇到 ForwardingNode 会转发到新表。sizeCtl 用 CAS 更新,保证并发下扩容状态一致;扩容完成后 sizeCtl 置为新阈值。这实现了多线程协作扩容,避免单线程迁移瓶颈。

sizeCtl 是一个"多用途状态字段":正数表示阈值,负数表示扩容中(含线程数信息)。多线程通过 CAS 修改 sizeCtl 参与扩容,ForwardingNode 标记已迁移桶并指导读取。这是 1.8 并发扩容的关键。

// sizeCtl: 正数=阈值,负数=扩容中(低位为参与线程数+1)
// helpTransfer: 检测到 ForwardingNode 时协助迁移
// 迁移后桶置为 ForwardingNode,读线程转发到新表
#
★★

15. HashMap 的扰动函数(hash 高低位异或)与 tableSizeFor 容量计算

请说明 HashMap 的扰动函数(hash 高低位异或)与 tableSizeFor 容量计算?

  • 扰动函数 hash 高低位异或
  • tableSizeFor 计算 2 的幂
  • 作用与原理

扰动函数 h ^ (h >>> 16):把哈希值的高 16 位与低 16 位异或,让低 16 位也携带高位的特征。因为数组取模只用低 n 位(length-1 是低位掩码),若高位不参与,高位不同的哈希会映射到同一桶(冲突),扰动后让高位也影响低位,降低冲突。tableSizeFor:把给定容量向上取整为最近的 2 的幂(如 5→8,13→16),通过一系列无符号右移和或运算把最高位以下的位都填 1,再加 1 得到 2 的幂。保证容量是 2 的幂,使 (n-1)&hash 等价取模且便于扩容高低位拆分。

扰动函数解决"低位相同高位不同导致冲突"的问题;tableSizeFor 保证容量 2 的幂,是取模优化与扩容拆分的前提。两者都提升哈希分布的均匀性与性能。

static int hash(Object key) { int h = key.hashCode(); return h ^ (h >>> 16); } // 扰动

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16;
    return n + 1; // 最近的 2 的幂
}
#
★★

16. ConcurrentHashMap 的 put 流程,空槽 CAS、链表头 synchronized、冲突升级与树化的触发路径

请说明 ConcurrentHashMap 的 put 流程,包括空槽 CAS、链表头 synchronized、冲突升级与树化的触发路径?

  • put 空槽 CAS
  • 链表头 synchronized
  • 树化触发

put 流程:1)计算 hash,若数组为空先初始化;2)若目标槽为空,用 CAS 直接放入新节点(无锁);3)若槽非空且是普通节点,对该槽的链表头加 synchronized 锁,然后遍历链表:key 已存在则更新,否则插入链表尾部;4)若槽是 ForwardingNode(扩容中),先协助扩容再重试;5)若槽是 TreeNode(已树化),加锁后走红黑树插入;6)插入后若链表长度达到 TREEIFY_THRESHOLD=8,且数组长度 >= 64 则树化,否则只扩容;7)size 用 CounterCell 分段计数。冲突升级路径:链表 → 长度8且数组≥64 → 红黑树。

put 的并发控制是"空槽 CAS + 非空槽锁桶头 + 树化"。CAS 减少锁的使用,锁桶头把竞争限制在单个桶,树化应对极端冲突。树化触发需同时满足链表长度 8 与数组长度 64。

// 空槽 CAS
if (tabAt(tab, i) == null) { if (casTabAt(tab, i, null, new Node<>(...))) break; }
// 非空槽:synchronized(table[i]) 后遍历链表/树
// 树化:链表长度>8 且 table.length>=64 时 treeifyBin
#
★★

17. Spring 容器 refresh() 的核心阶段,BeanFactoryPostProcessor 与 BeanPostProcessor 的注册与执行时机

请说明 Spring 容器 refresh() 的核心阶段,BeanFactoryPostProcessor 与 BeanPostProcessor 的注册与执行时机?

  • refresh() 关键阶段
  • BeanFactoryPostProcessor 时机
  • BeanPostProcessor 时机

refresh() 是容器启动的核心方法,关键阶段:prepareRefresh(准备)→ obtainFreshBeanFactory(创建 BeanFactory)→ prepareBeanFactory(配置)→ invokeBeanFactoryPostProcessors(执行 BeanFactoryPostProcessor,可修改 BeanDefinition)→ registerBeanPostProcessors(注册 BeanPostProcessor)→ initMessageSource → initApplicationEventMulticaster → onRefresh → registerListeners → finishBeanFactoryInitialization(实例化所有非懒加载单例 Bean)→ finishRefresh。BeanFactoryPostProcessor 在 Bean 实例化前执行,用于修改 BeanDefinition(如占位符替换);BeanPostProcessor 在 Bean 实例化/初始化时执行(每个 Bean 创建前后调用),用于增强。

关键区别:BeanFactoryPostProcessor 处理"BeanDefinition"(在实例化前、只执行一次),BeanPostProcessor 处理"Bean 实例"(每个 Bean 创建前后都调用)。所以前者先注册先执行,后者在实例化单例阶段触发。

// refresh() 中的关键顺序
invokeBeanFactoryPostProcessors(beanFactory); // 先执行 BeanFactoryPostProcessor
registerBeanPostProcessors(beanFactory);      // 注册 BeanPostProcessor
finishBeanFactoryInitialization(beanFactory); // 实例化单例,过程中触发 BeanPostProcessor
#

18. ArrayList 的 modCount 与 fail-fast 机制如何与迭代器协作?

请说明 ArrayList 的 modCount 与 fail-fast 机制如何与迭代器协作?

  • modCount 计数
  • 迭代器的 expectedModCount
  • fail-fast 触发

ArrayList 维护 modCount,记录结构性修改次数(add/remove 等改变结构)。迭代器创建时记录 expectedModCount = modCount。每次迭代器 next()(或 remove)时检查 expectedModCount 是否等于当前 modCount,不等则说明迭代期间有其他线程(或本线程其他方式)修改了集合,抛出 ConcurrentModificationException(fail-fast)。这保证迭代器在并发修改时快速失败,而不是静默产生不确定结果。注意 fail-fast 是"尽力而为"的检测,通过单线程内部修改(如迭代器自己的 remove 会同步更新 expectedModCount)也不会误报。

modCount 是"结构版本号",迭代器用 expectedModCount 快照对比,检测进行中的修改。迭代器自身 remove 会同步更新 expectedModCount,所以不会误报。

public E next() {
    checkForComodification(); // 检查 expectedModCount != modCount 则抛异常
    return data[cursor++];
}
final void checkForComodification() {
    if (modCount != expectedModCount) throw new ConcurrentModificationException();
}
#

19. ArrayList 的扩容策略,1.5 倍扩容与 System.arraycopy 的性能影响如何?

请说明 ArrayList 的扩容策略,1.5 倍扩容与 System.arraycopy 的性能影响?

  • 1.5 倍扩容
  • System.arraycopy 性能
  • 扩容的摊还开销

ArrayList 扩容时按 1.5 倍(oldCapacity + oldCapacity>>1)增长,用 System.arraycopy 把旧数组元素复制到新数组。1.5 倍的原因:比 2 倍更省内存(避免过度浪费),又比固定增量(如+10)扩容次数少,摊还后均摊 O(1) 的插入成本。System.arraycopy 是 native 方法,底层用 JVM 内存拷贝(可能用 CPU 指令如 memcpy),比循环逐元素赋值快得多,但仍是 O(n) 的复制开销。扩容时若数组很大,复制成本高,最好预分配容量(new ArrayList<>(expectedSize))减少扩容。

1.5 倍是"内存浪费"与"扩容次数"的平衡。System.arraycopy 高效但本质 O(n),扩容是摊还 O(1)。预分配容量可避免多次扩容的复制开销。

private void grow(int minCapacity) {
    int oldCap = elementData.length;
    int newCap = oldCap + (oldCap >> 1); // 1.5 倍
    elementData = Arrays.copyOf(elementData, newCap); // 内部用 System.arraycopy
}
#

20. HashMap 的树与链表切换,为什么反树化阈值是 6 而非 8(滞回设计),退化时如何重建链表

请说明 HashMap 的树与链表切换,为什么反树化阈值是 6 而非 8(滞回设计),以及退化时如何重建链表?

  • 树化阈值 8 与反树化 6
  • 滞回设计
  • 链表重建

树化阈值是 8,反树化阈值是 6。差异 2 形成"滞回"(hysteresis)设计:如果树化和反树化都用同一个阈值(如 8),那么在 8 附近频繁插入删除会导致链表/树反复切换,浪费性能。用 6 作为反树化阈值,留出缓冲区间,避免在边界反复震荡。退化时重建链表:当红黑树节点通过 remove 减少到阈值 6 以下时,调用 untreeify 把 TreeNode 转换为普通 Node 链表,即遍历树节点,按顺序重新串成单向链表,恢复 O(n) 查找(此时节点少,链表效率更高且省内存)。同时 resize 时若树节点太少也会反树化。

滞回设计避免"阈值附近反复切换"。树化阈值 8 高(缓存友好、多数情况链表够用),反树化 6 低,形成缓冲。退化重建链表是"树无用武之地时释放"。

static final int TREEIFY_THRESHOLD = 8;   // 链表 -> 树
static final int UNTREEIFY_THRESHOLD = 6; // 树 -> 链表
// remove 后节点数 <= 6 时 untreeify 重建链表