对象在堆内存中分配空间时,JVM 是如何解决并发安全问题的?
在 Java 中,堆内存(Heap)是所有线程共享的区域。当多个线程同时在堆上分配对象时,如果不进行控制,就会出现两个线程同时使用同一块内存地址的指针碰撞(并发冲突)问题。
为了在保证线程安全的同时最大化分配效率,JVM 主要采用了两种核心机制来解决这个问题:TLAB(本地线程分配缓冲) 和 CAS(Compare-And-Swap)搭配失败重试。
以下是详细的解析:
1. 第一道防线:TLAB(Thread Local Allocation Buffer)
这是 JVM 解决对象分配并发冲突最主要、最高效的手段。
- 核心思想(空间换时间): 把共享的堆内存“私有化”。JVM 在 Eden 区为每个线程预先分配一小块私有的内存区域,这就是 TLAB。
- 如何保证安全: 因为每个线程只能在自己的 TLAB 中分配对象,线程之间互不干扰,所以在这个小区域内分配对象时,完全不需要加锁。
- 分配方式: 采用指针碰撞(Bump the Pointer)。只需要记录 TLAB 的起始地址和当前分配到的指针位置,分配新对象时,把指针往后移动对象大小的距离即可,速度极快。
- 相关配置: TLAB 默认是开启的(可以通过
-XX:+UseTLAB参数控制)。TLAB 的空间通常很小(默认占 Eden 区的 1%)。
2. 第二道防线:CAS(Compare-And-Swap) + 失败重试
当 TLAB 空间不足,或者对象太大无法放入 TLAB 时,对象就必须直接在共享的 Eden 区进行分配。此时,JVM 会采用 CAS 操作来保证线程安全。
- 核心思想(乐观锁): 假设没有冲突去尝试分配,如果发现有冲突就重试,直到成功。
- 如何保证安全: CAS 是一种硬件级别的原子操作(例如 x86 架构下的
lock cmpxchg指令)。- 线程首先读取当前 Eden 区的空闲内存指针位置(假设为
old_ptr)。 - 计算出分配该对象后指针的新位置(假设为
new_ptr = old_ptr + object_size)。 - 利用 CAS 指令尝试将内存指针从
old_ptr更新为new_ptr。 - 成功: 如果此时内存指针仍然是
old_ptr,说明没有其他线程干扰,更新成功,对象分配完毕。 - 失败: 如果指针不再是
old_ptr(说明有其他线程抢先分配了内存),CAS 失败。此时线程会重新读取最新的指针位置,循环重试(Spin),直到分配成功。
- 线程首先读取当前 Eden 区的空闲内存指针位置(假设为
- 应用场景: 除了直接在 Eden 区分配对象外,线程向 JVM 申请新的 TLAB 空间时,由于也是在共享区域划分内存,同样使用的是 CAS + 重试机制。
3. 完整的对象分配并发控制流程
综合以上两点,当你在代码中 new 一个对象时,JVM 在内存分配上的完整流程如下:
- 判断是否开启了栈上分配: 如果经过逃逸分析,发现该对象不会被其他线程访问,JVM 会尽量将对象打散分配在线程栈上(无并发问题)。如果不能,进入下一步。
- 尝试在 TLAB 中分配:
- 判断当前线程的 TLAB 剩余空间是否能容纳该对象。
- 如果能容纳,直接无锁分配(指针移动),结束。
- 如果不能容纳(TLAB 满了),进入下一步。
- 判断对象大小与 TLAB 重新申请逻辑:
- 如果对象比较小,只是当前 TLAB 用完了,线程会通过 CAS操作 向 JVM 申请一块新的 TLAB,然后在新 TLAB 中无锁分配。
- 如果对象比较大(超过了 TLAB 的最大限制),不适合放入 TLAB,进入下一步。
- 直接在 Eden 区分配: JVM 使用 CAS + 失败重试 的机制,直接在共享的 Eden 区为其分配内存。
- 直接进入老年代: 如果是一个超大对象(比如一个巨大的数组),可能会跳过 Eden 区,直接在老年代分配(同样需要同步机制/CAS 保证安全)。
总结
JVM 解决对象分配的并发安全问题,采用的是分层策略:
- 99% 的情况(常规对象): 通过 TLAB 实现无锁的极速分配。
- 1% 的情况(大对象或申请新 TLAB 时): 通过底层的 CAS + 重试 机制保证共享内存更新的原子性。
这种设计既避免了传统重量级锁(如 synchronized)带来的上下文切换和性能损耗,又完美地保证了多线程环境下堆内存分配的绝对安全。
右滑查看面试常问