文章目录
- CMS在并发标记阶段,已经被标记的对象,又被新生代跨带引用,这时JVM会怎么处理?
- 为什么 Minor GC 会发生 STW?
- 有哪些对象是在栈上分配的?
- 对象在 JVM 中的内存结构
- 为什么需要对齐填充?
- JVM 对象分配空间机制
- JVM多线程并发分配对象如何解决堆抢占?
- JVM容量分配实战案例分析
- JVM吞吐量和响应时间?
CMS在并发标记阶段,已经被标记的对象,又被新生代跨带引用,这时JVM会怎么处理?
CMS 垃圾回收器 中使用 “记忆集(Remembered Set)”+“写屏障(Write Barrier)”机制 来解决并发标记期间 跨代引用变更 问题。
为什么需要“记忆集” + “写屏障”?
在 CMS 的 并发标记阶段:
GC 与应用线程并发执行;
这期间应用线程可能让年轻代对象指向老年代对象;
但 CMS 默认只标记老年代;
年轻代未被扫描,容易 遗漏跨代引用;
所以必须 追踪引用变更的位置 —— 这就是“写屏障 + 记忆集”组合机制的作用。
写屏障:屏障是一种插入到对象引用写操作前后的特殊代码逻辑,用来记录引用的变化,在 CMS 里主要用于:
- 记录跨代引用的变化(新生代指向老年代的引用);
- 追踪老年代中对象引用的新增或变化,保证并发标记不遗漏
什么是记忆集?
记忆集是一种 记录哪些区域可能包含跨代引用 的辅助数据结构。
在 CMS 中使用的是:Card Table + Dirty Card Tracking
写屏障的实现:
Card Table 工作机制:
老年代内存被划分为很多小块,每块称为一个 Card(比如 512 Bytes 一块);
JVM 为每个 Card 维护一个字节标记(位于 Card Table 中);
这个字节初始值为 clean(0);
一旦写屏障检测到某个 Card 中的对象引用发生变化,就将对应字节标记为 dirty(1)。
记忆集:
记忆集的本质是一个辅助数据结构,用来记录老年代对象中引用了新生代对象的“引用源地址”。
分代垃圾回收中,Minor GC 只扫描新生代,但可能有老年代对象引用了新生代;
为了避免扫描整个老年代,JVM用记忆集来记录那些引用过新生代的老年代对象;
在 Minor GC 时,只需要扫描记忆集中的那部分老年代对象
为什么 Minor GC 会发生 STW?
- 避免对象引用混乱
Eden 和 Survivor 区中的对象需要移动到 To 区或老年代;
在对象“复制”过程中,如果程序继续运行,会有引用被修改、丢失或读取脏数据;
所以,必须暂停所有线程,保证引用关系不变、对象移动安全。 - 根可达性分析需要一致的对象图
GC 需要做 可达性分析(Root Tracing),从 GC Roots 开始向下遍历;
如果线程在动,分析得到的对象图会是错误的。
有哪些对象是在栈上分配的?
在 JVM 中,“对象在栈上分配” 并不是默认行为,而是一种通过逃逸分析(Escape Analysis)+ 栈上分配优化才有可能实现的高级性能优化机制。默认情况下,Java 中的所有对象都是在堆上分配的,只有局部变量的引用在栈帧中分配。
在栈上分配的条件:
- 对象不会逃逸出当前方法,也就是对象只在方法内被使用,不会被返回、不会赋给其他线程可访问的变量。
- JVM 启用了逃逸分析优化
栈上分配的优点:
3. 分配速度极快 栈是连续内存,分配只需移动指针(非常快);
4. 自动回收 随方法调用结束,局部变量出栈即自动回收;
5. 不会触发 GC 不在堆上,就不会进入 GC 管理范围;
6. 减少内存碎片 不参与堆整理,降低 Full GC 频率;
7. 标量替换优化可能性 JVM 可能将对象拆分为基本类型变量,进一步提升性能;
对象在 JVM 中的内存结构
- 对象头(Header)
包含两部分信息:
(1)Mark Word(标记字段) - 占 8 字节(32位JVM)或 12 字节(64位JVM 非压缩指针)
存储内容因对象状态而异,如:
HashCode(如果调用过 hashCode())
GC 分代年龄
锁信息(轻量级锁、重量级锁、偏向锁)
标记位(是否为偏向锁/是否为垃圾对象等)
(2)Class Pointer(类型指针) - 指向类的元数据
指向方法区中该对象所属类的 class 元信息,用于支持虚方法调用等。 - 实例数据(Instance Data)
真正存放类的 字段(成员变量)值 的地方,包括:
父类继承下来的字段
自身定义的字段
按照 字段声明顺序、类型大小对齐 安排内存布局 - 对齐填充(Padding)
为了满足 8 字节对齐(HotSpot 默认对象起始地址对齐规则),可能会在对象末尾填充一些字节
不参与逻辑数据存储,只是内存对齐需要
为什么需要对齐填充?
- CPU 访问效率
CPU 访问内存时,更快的访问方式是**按固定字节边界对齐(alignment)**访问,比如 4 字节对齐、8 字节对齐。
如果数据没有对齐,CPU 需要分多次访问内存才能读取完整数据,导致性能下降。
对齐保证数据起始地址是特定边界的整数倍,能让 CPU 一次性高效读取。 - 硬件平台的限制
一些处理器架构要求特定类型数据必须对齐访问,不对齐访问会引发硬件异常(bus error),或者不得不做额外处理,降低性能。
例如,64位系统一般要求 8 字节对齐,32位系统一般要求 4 字节对齐。 - 简化内存地址计算
对齐后,硬件和编译器可以更简单更快地计算内存地址和偏移,方便高效的指令执行。
JVM 对象分配空间机制
- 堆内存分配
Java 对象主要在 **堆(Heap)**上分配。
堆又分为 新生代(Young Gen)和 老年代(Old Gen)。
新生代通常使用 Eden 区 + 两个 Survivor 区。 - 对象分配流程
新生代 Eden 区是对象分配的主要场所。
JVM 默认采用 指针碰撞(Pointer Bump) 或 空闲列表(Free List) 分配策略:
指针碰撞:Eden 是连续空间,有个指针指向下一分配位置,分配对象时指针往后移,速度快。
空闲列表:如果 Eden 有碎片,可能采用空闲列表分配。
大对象(如大数组、字符串)可能直接进入老年代,避免在新生代频繁复制。 - 分配失败与垃圾回收
Eden 空间不足时触发 Minor GC,回收无用对象。
Minor GC 后仍空间不足时,可能触发 Full GC 或对象晋升到老年代。
JVM多线程并发分配对象如何解决堆抢占?
- 多线程分配对象时,如果都操作同一内存区域,会产生同步开销,降低性能。
- JVM 采用 线程本地分配缓存(TLAB,Thread Local Allocation Buffer) 来缓解:
每个线程分配一个小的 Eden 子区域(TLAB)。
线程先从自己 TLAB 分配,避免和其他线程竞争堆主区域锁。
只有当 TLAB 空了,才去堆中申请新的 TLAB。 - TLAB 分配流程示意
线程启动时,JVM 为它分配一个初始的 TLAB。
分配新对象时,线程检查 TLAB 剩余空间是否足够。
足够,直接在 TLAB 内分配,调整指针。
不足,从堆中申请新的 TLAB,再分配。
对象生命周期结束后,垃圾回收释放对象,回收空间。
JVM容量分配实战案例分析
每天100w次登陆请求, 8G 内存该如何设置JVM参数,大概可以分为以下几个步骤。
- 任何新的业务在上线之前我们都需要预估其占用的内存大小,而我们分配空间的大小主要来根据以下步骤来判断?
- 计算业务系统每秒钟创建的对象会占用多大的内存空间,然后计算集群下的每个系统每秒的内存占用空间(对象创建速度)
- 设置一个机器配置,估算新生代的空间,比较不同新生代大小之下,多久触发一次MinorGC。
为了避免频繁GC,就可以重新估算需要多少机器配置,部署多少台机器,给JVM多大内存空间,新生代多大空间。 - 根据这套配置,基本可以推算出整个系统的运行模型,每秒创建多少对象,1s以后成为垃圾,系统运行多久新生代会触发一次GC,频率多高。
具体的案例分析:
新增计费业务,预计每天1000万次请求,高峰时期,每秒处理2000笔的并发,一共5台机器内存大小8G,怎么算每台机器分配多大内存能撑住并发?
1.每秒2000笔,分到每台机器上的请求为400笔,加入每个请求所产生的对象大小为300字节,每个请求大概需要10个对象处理,我们暂估3KB,如果算上RPC和DB、写库、写缓存一顿操作下来6KB的数据,每秒大概产生1~2M的数据,如果高峰期,我们分配的内存为6G,分配给新生代的大小,大概是2G,我们算出大概1000秒才会进行一次MinorGC。
JVM吞吐量和响应时间?
吞吐量是指程序用于处理业务的时间与总运行时间的比值。
吞吐量 = (总运行时间 - GC 时间) / 总运行时间
吞吐量越高,说明更多时间用于业务处理,GC 开销更小。
高吞吐通常意味着:
较少 GC 次数
较长 GC 停顿时间
适合批处理、后台计算任务。
响应时间是指系统对单个请求的响应速度,包括平均响应时间和最大响应时间(99%、99.9% 等分位)。
对交互式系统、低延迟系统(如支付系统、API 网关)非常关键。
低响应时间通常意味着:
更频繁的 GC(避免长时间停顿)
GC 停顿更短
适合前台服务、实时交互系统
堆内存大小 和GC 频率和停顿时间之间的关系:
堆大 ⇒ GC 少发生 ⇒ 每次 GC 回收更多对象 ⇒ 吞吐量大
但:每次 GC 停顿时间长 ⇒ 阻塞线程多 ⇒ 响应延迟上升
堆小 ⇒ GC 更频繁发生 ⇒ 每次 GC 停顿短
但:每次处理对象少 ⇒ 更频繁打断业务执行 ⇒ 吞吐下降 ⇒ CPU 被 GC 占用比例升高