ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM运行时数据区:从main()执行看五大内存区域协同机制

JVM运行时数据区:从main()执行看五大内存区域协同机制 1. 这不是一张“示意图”而是你每天写代码时 JVM 正在真实运转的战场很多人学 JVM 内存模型第一反应是翻《深入理解 Java 虚拟机》第 2 章对着那张经典的“方法区、堆、栈、本地方法栈、程序计数器”五分区图反复默背——结果面试被问“StringTable 在 JDK 7 和 JDK 8 中到底放在哪”当场卡壳或者线上服务 OOM 后看堆 dump发现 80% 的对象都挤在老年代却完全不知道该去哪个区域查根源又或者调优时盲目加大-Xmx结果 GC 频率不降反升最后发现是元空间Metaspace耗尽触发了 Full GC。这不是理论没学好而是把“内存模型”当成了静态挂图忽略了它本质是一套动态协同的运行时契约每个线程执行字节码时JVM 必须实时分配、追踪、回收、跨区传递数据而这些动作全部受制于五个运行时数据区的边界规则、生命周期约束和线程可见性协议。你写的每一行new Object()、每一个static final String、每一次ThreadLocal.set()都在向不同区域发起精确的内存申请指令GC 线程不是在“清理垃圾”而是在遵守这套契约的前提下对特定区域执行受限的腾挪与压缩。我带过 37 个 Java 初级工程师做性能排查其中 31 人第一次看 GC 日志时连PSYoungGen和ParOldGen分别对应哪个区域都说不清19 人误以为“栈内存溢出 StackOverflowError 就是栈帧太多”却不知道-Xss设置的是每个线程的虚拟机栈容量上限而真正压垮栈的往往是递归深度 局部变量表大小 操作数栈深度三者叠加的结果。这背后全是运行时数据区的底层机制在起作用。所以本文不讲“概念定义”不列“区域对比表格”而是带你回到一个真实场景当你敲下javac HelloWorld.java编译完成再执行java HelloWorld的瞬间JVM 是如何用这五个区域一帧一帧、一字节一字节地把你的代码变成可执行的进程我们会从类加载结束那一刻开始跟踪一个最简main方法的完整生命周期看对象如何诞生、引用如何传递、GC 如何介入、异常如何触发——所有操作都锚定在具体区域上所有参数都有计算依据所有结论都来自 HotSpot 源码级验证。你不需要懂 C但必须清楚-XX:MaxMetaspaceSize256m这个值到底是在限制哪块内存的物理增长ThreadLocalMap的 Entry 数组为什么必须用弱引用包裹 keyString.intern()返回的对象究竟存放在哪个地址空间——这些才是你在面试中能脱口而出、在线上问题里能准确定位的核心能力。2. 类加载完成后的第一帧程序计数器与虚拟机栈的精密协同当java HelloWorld命令被 shell 解析并启动 JVM 进程后JVM 首先初始化类加载子系统将HelloWorld.class加载进方法区JDK 8 为 Metaspace解析常量池验证字节码最后执行clinit方法完成静态初始化。此时整个 JVM 还没有一个用户线程在运行——直到main方法被调用第一个 Java 线程主线程才真正启动。而它的启动直接触发了两个最轻量、最频繁、也最容易被误解的运行时数据区程序计数器Program Counter Register和虚拟机栈Java Virtual Machine Stack。2.1 程序计数器每个线程专属的“下一条指令”指针程序计数器是唯一一个在 JVM 规范中没有规定 OutOfMemoryError 的区域。它存储的是当前线程所执行的字节码指令地址。注意这里说的是“字节码指令地址”不是 Java 源码行号也不是机器码地址。例如HelloWorld.main()编译后的字节码如下public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello World 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return当线程执行到第 0 行getstatic指令时程序计数器的值就是0执行完该指令后自动加 1或根据指令长度跳转变为3指向ldc指令。这个过程由解释器或 JIT 编译器严格控制确保指令流顺序执行。提示为什么它是线程私有的因为多线程环境下每个线程都需要独立记录自己当前执行到哪条指令。如果共享线程切换时就无法恢复现场。这也是为什么synchronized锁住的是对象监视器而非“某条指令”因为指令执行本身由 PC 控制无需锁干预。关键细节在于当线程执行的是本地方法Native Method时程序计数器的值为空Undefined。这是因为本地方法由操作系统直接调度JVM 不参与其指令流管理。这也是Thread.currentThread().getStackTrace()在调用 JNI 方法时会出现Unknown Source的根本原因——PC 没有指向任何 Java 字节码。实操验证你可以用jstack pid查看线程堆栈其中java.lang.Thread.getStackTrace(Native Method)这一行就对应 PC 为空的状态。而后续的HelloWorld.main(HelloWorld.java:5)则明确指向字节码偏移量5即ldc指令所在位置。2.2 虚拟机栈方法调用的“帧式容器”与局部变量生命期虚拟机栈是线程私有的它的基本单位是栈帧Stack Frame。每当一个方法被调用JVM 就为此方法创建一个新的栈帧并压入当前线程的虚拟机栈顶方法返回时该栈帧被弹出。栈帧内部结构固定包含四大部分局部变量表、操作数栈、动态链接、方法返回地址。以main方法为例其栈帧结构如下基于javap -v HelloWorld.class输出区域容量Slot存储内容计算依据局部变量表2argsString[] 类型占 1 slot、this静态方法无 this但编译器预留 slotargs是引用类型JVM 规定引用占 1 slotslot 0 固定为this静态方法中为 null但仍需占位操作数栈最大深度 2执行getstatic时压入System.out引用ldc时压入字符串常量javap -v显示max stack 2即操作数栈最大需要 2 个位置动态链接—指向运行时常量池中该方法的符号引用用于支持方法调用的多态性如invokevirtual方法返回地址—记录调用者方法下一条指令地址此处为return后的退出点确保方法结束后能正确跳转这里有个极易踩坑的点局部变量表的 slot 分配不是按源码声明顺序而是按字节码指令实际使用顺序。比如以下代码public static void main(String[] args) { int a 10; String s hello; int b 20; System.out.println(a s b); }编译后a和b可能复用同一个 slot因为a在s赋值后就不再使用而s占用 slot 1。这导致jmap -histo或jstat看不到“未使用的局部变量”残留因为 slot 已被复用。这也是为什么局部变量作用域结束如{}块内后只要没被新变量覆盖其引用仍可能阻止 GC——因为 slot 里还存着旧引用值。注意-Xss参数设置的是每个线程虚拟机栈的总容量默认 1MB单位是字节。一个栈帧大小 局部变量表大小slot 数 × 4 字节 操作数栈大小slot 数 × 4 字节 其他固定开销约 128 字节。若方法嵌套过深如递归栈帧数量超过Xss / 平均帧大小就会抛出StackOverflowError。实测空方法递归平均帧约 256 字节-Xss512k最多支持约 2000 层递归若方法内声明大量局部变量如int[] arr new int[1000]则单帧膨胀递归深度骤减。2.3 线程私有性的物理实现栈内存如何被 OS 管理虚拟机栈的内存由操作系统分配JVM 仅负责逻辑管理。Linux 下每个 Java 线程对应一个 pthread其栈空间通过mmap()系统调用申请初始只提交少量内存guard page随着栈帧增长OS 动态扩展。-Xss实际限制的是pthread_attr_setstacksize()的参数值。你可以用pstack pid查看 OS 级线程栈会看到类似Thread 1 (LWP 12345): #0 0x00007f8b1a2c3e2d in __nanosleep (...) #1 0x00007f8b1a2c3cbd in usleep (...) #2 0x00007f8b1a5d6a12 in Java_java_lang_Thread_sleep (...) #3 ... // 这里才是 JVM 栈帧对应的 native 调用链而jstack输出的是 JVM 逻辑栈帧两者映射关系是一个 OS 线程栈 JVM 虚拟机栈 本地方法栈 JVM 内部 C 栈帧。因此pstack看到的栈深度远大于jstack因为包含了 JNI 和 JVM 自身的 C 调用。我在一次排查中遇到诡异现象jstack显示某线程只有 3 层 Java 栈帧但pstack显示超过 200 层 native 调用。最终定位到是某个 JDBC 驱动在Connection.close()时触发了长达 10 层的 native 调用链而这些 native 调用并未在 Java 栈中体现——这正是虚拟机栈与本地方法栈分离设计的体现。3. 对象诞生之地堆内存的分代模型与 GC 触发阈值如果说虚拟机栈是“方法执行的舞台”那么堆Heap就是“对象生存的土壤”。它是所有线程共享的区域也是垃圾收集器GC的主要工作场所。HotSpot JVM 采用分代收集Generational Collection策略将堆划分为新生代Young Generation和老年代Old Generation其中新生代又细分为 Eden 区、Survivor 0 区S0、Survivor 1 区S1。这种划分不是凭空设计而是基于“弱代假说Weak Generational Hypothesis”绝大多数对象朝生暮死只有极少数能活过多次 GC。3.1 新生代Eden 区的“快进快出”机制与 Survivor 区的“年龄晋升”当new指令执行时JVM 首先尝试在 Eden 区分配内存。Eden 区采用指针碰撞Bump the Pointer算法维护一个top指针每次分配只需将top向上移动对象大小字节数时间复杂度 O(1)。这是新生代分配如此高效的根本原因。假设 Eden 区大小为 8MB可通过-Xmn或-XX:NewRatio设置当前已使用 7.9MB此时分配一个 200KB 的对象若top 200KB ≤ end直接分配成功否则触发Minor GC年轻代 GC。Minor GC 的核心流程是复制算法Copying扫描 Eden 区 当前使用的 Survivor 区如 S0标记所有存活对象将存活对象复制到另一个空闲 Survivor 区如 S1清空 Eden 区和原 Survivor 区S0交换 S0 与 S1 的角色。这里的关键参数是-XX:MaxTenuringThreshold默认 15它定义了对象在 Survivor 区“熬过”多少次 Minor GC 后晋升老年代。但实际晋升条件更复杂当某次 Minor GC 后S1 中所有相同年龄的对象总大小 Survivor 区总容量的一半时所有年龄 ≥ 该年龄的对象直接晋升。这叫“动态年龄判定”。举个实例S1 容量 1MB某次 GC 后年龄 1 的对象共 300KB年龄 2 的对象共 250KB年龄 3 的对象共 200KB此时年龄 12550KB 500KB1MB/2所以所有年龄 ≥ 2 的对象250KB200KB全部晋升老年代。实操心得很多团队盲目调大-XX:MaxTenuringThreshold15以为能减少晋升结果反而加剧老年代压力。正确做法是用jstat -gc pid观察YGCMinor GC 次数、YGCTMinor GC 总耗时、S0C/S1CSurvivor 容量和S0U/S1USurvivor 使用量。若S0U长期接近S0C说明 Survivor 区太小应调大-XX:SurvivorRatio默认 8即 Eden:S0:S1 8:1:1若EUEden 使用量每次 GC 后都清零但OC老年代容量缓慢上涨说明存在隐性长生命周期对象需检查ThreadLocal或静态集合。3.2 老年代CMS 与 G1 的“空间换时间”博弈老年代存放的是经过多次 Minor GC 仍存活的对象以及大对象如大数组。JVM 对大对象有特殊处理若对象大小 -XX:PretenureSizeThreshold默认 0即禁用则直接分配到老年代避免在新生代反复复制。老年代 GC 更复杂主要有两种策略CMSConcurrent Mark Sweep目标是获取最短停顿时间采用标记-清除算法但会产生内存碎片。当老年代剩余空间 -XX:CMSInitiatingOccupancyFraction默认 92%时触发并发收集。G1Garbage First将堆划分为多个 Region默认 2048 个每个 Region 可扮演 Eden、Survivor 或 Old 角色。G1 通过预测模型选择回收价值最高的 Region 组合实现可控停顿。触发条件是老年代占用率 -XX:InitiatingHeapOccupancyPercent默认 45%。两者的根本差异在于CMS 是“全堆扫描”G1 是“增量式区域回收”。CMS 在并发标记阶段仍需 STWStop-The-World进行初始标记和重新标记G1 的 STW 主要在初始标记和最终标记且时间可控。我在一个 Spark Streaming 任务中遇到典型问题CMS 频繁触发Concurrent Mode Failure并发模式失败日志显示CMS-initial-mark耗时突增。分析发现该任务每批次生成大量临时 RDD导致老年代在 CMS 并发标记期间快速填满被迫退化为 Serial Old GCSTW 达 3 秒以上。解决方案不是调大老年代而是改用 G1并设置-XX:MaxGCPauseMillis200让 G1 自动调整 Region 回收数量最终 GC 停顿稳定在 150ms 内。3.3 堆外内存DirectByteBuffer 与 Off-Heap 的隐形杀手除了堆内存JVM 还允许通过java.nio.DirectByteBuffer分配堆外内存Off-Heap Memory。这部分内存不受 GC 管理由Cleaner机制在对象不可达时释放。但Cleaner是异步执行的若DirectByteBuffer创建速度远超释放速度就会导致OutOfMemoryError: Direct buffer memory。关键参数-XX:MaxDirectMemorySize默认等于-Xmx限制的就是这块内存。实测发现Netty 默认使用PooledByteBufAllocator其内存池会复用DirectByteBuffer但如果业务层频繁alloc()未free()池会不断扩容直至触顶。诊断方法jstat -gc pid中OUOld Gen Used正常但java -XX:PrintGCDetails日志出现java.lang.OutOfMemoryError: Direct buffer memory或用native-memory tracking (NMT)-XX:NativeMemoryTrackingdetail然后jcmd pid VM.native_memory summary查看Internal区域是否异常增长。踩坑记录某支付网关使用 Netty 处理 HTTPS 请求TLS 握手时SSLEngine内部创建大量DirectByteBuffer。因 TLS session 复用率低Cleaner来不及回收-XX:MaxDirectMemorySize1g很快耗尽。最终方案是启用sslContext.createSSLEngine().setUseClientMode(true)并复用SSLEngine实例同时监控BufferPool的usedMemory指标。4. 元数据的家方法区Metaspace的动态扩张与泄漏防控方法区Method Area是各个线程共享的内存区域用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。JDK 8 之后永久代PermGen被彻底移除取而代之的是Metaspace元空间它直接使用本地内存Native Memory而非堆内存。4.1 Metaspace 的内存模型Class Metadata 与 Compressed Class SpaceMetaspace 实际由两部分组成Class Metadata存储类的运行时数据如 vtable、itable、注解、字段偏移量等。这部分内存由mmap()分配无上限除非 OS 内存耗尽。Compressed Class Space当开启压缩指针-XX:UseCompressedOops默认开启时JVM 为类元数据分配一块连续内存默认 1GB用于存储类的 Klass 结构体。这块空间大小由-XX:CompressedClassSpaceSize控制。Metaspace 的初始大小由-XX:MetaspaceSize默认约 20.8MB决定当已使用空间超过此值时触发 Metaspace GCFull GC 的一部分。但 Metaspace GC 不会回收类元数据除非类加载器被回收——这才是真正的泄漏点。4.2 类加载器泄漏Web 应用热部署的“幽灵内存”最常见的 Metaspace 泄漏场景是 Web 容器如 Tomcat热部署。每次部署新 WAR 包Tomcat 会创建新的WebAppClassLoader加载类而旧的WebAppClassLoader若被静态变量、线程、JDBC 驱动等强引用持有就无法被 GC其加载的所有类元数据也无法释放。典型泄漏链路ServletContextListener.contextInitialized()中注册了一个静态TimerTimer创建的TimerThread持有ServletContext的引用ServletContext持有WebAppClassLoader的引用WebAppClassLoader持有所有加载类的Class对象Class对象持有Klass元数据 → Metaspace 无法释放。验证方法jstat -gcmetacapacity pid查看MCMetaspace Capacity、MUMetaspace Usedjmap -clstats pid列出所有类加载器及其加载类数。若发现WebAppClassLoader实例数持续增长且每个加载器加载类数 1000基本可断定泄漏。实操技巧用jcmd pid VM.native_memory summary scaleMB对比两次热部署后的Class区域增长量或用jmap -histo:live pid | grep WebAppClassLoader统计实例数。修复方案不是调大-XX:MaxMetaspaceSize而是检查ServletContextListener、Filter、Servlet的destroy()方法是否释放了所有静态资源特别是Timer、ExecutorService、ThreadLocal。4.3 字符串常量池StringTable 的迁移与 intern() 的代价字符串常量池StringTable在 JDK 7 之前位于永久代JDK 7 移至堆中JDK 8 仍在堆中但实现改为ConcurrentHashMap。String.intern()方法的作用是若常量池中已存在该字符串则返回池中引用否则将该字符串放入池中并返回其引用。关键陷阱在于intern()的性能开销与常量池大小呈非线性增长。因为StringTable是哈希表当元素过多时哈希冲突增加查找时间上升。实测100 万个intern()调用在常量池 10 万字符串时平均耗时 0.05ms当池中达 50 万字符串时平均耗时跃升至 0.3ms。更严重的是内存占用每个String对象在堆中占约 40 字节对象头 12 char[] 引用 4 hash 4 其他 4而StringTable的Entry还需额外 32 字节key 引用 4 value 引用 4 next 指针 4 其他 20。100 万个字符串仅StringTable就额外消耗 32MB。因此intern()应严格限定使用场景仅用于极少的、确定重复率极高的字符串字面量如 HTTP 状态码200、OK绝不能用于用户输入或日志消息。替代方案是使用枚举或静态 final 字符串常量。5. 本地方法的桥梁本地方法栈与 JNI 的内存边界本地方法栈Native Method Stack与虚拟机栈作用相似但服务对象是本地方法Native Method即用 C/C 编写的、通过 JNIJava Native Interface调用的代码。它同样为每个线程私有但其内存分配和管理完全由操作系统和本地代码控制JVM 不干涉。5.1 JNI 的内存模型GlobalRef、LocalRef 与 WeakGlobalRefJNI 提供三类引用类型它们的生命周期和内存归属完全不同LocalRef局部引用在 JNI 函数调用期间有效函数返回时自动释放。它指向 JVM 堆中的对象但本身存储在本地方法栈上。FindClass、GetObjectClass等返回的都是 LocalRef。GlobalRef全局引用手动创建NewGlobalRef手动释放DeleteGlobalRef。它使 JVM 对象不被 GC即使 Java 代码中已无强引用。GlobalRef 存储在 JVM 内部的全局引用表中属于 JVM 管理的内存。WeakGlobalRef弱全局引用类似 GlobalRef但不阻止 GC。当对象被回收后IsSameObject返回 JNI_FALSE。最易出错的是 LocalRef 泄漏。例如JNIEXPORT void JNICALL Java_MyClass_processArray(JNIEnv *env, jobject obj, jintArray arr) { jint *elements (*env)-GetIntArrayElements(env, arr, NULL); // 返回 LocalRef // ... 处理 elements ... // 忘记调用 (*env)-ReleaseIntArrayElements(env, arr, elements, 0); return; }GetIntArrayElements不仅返回数组指针还在 JVM 内部创建 LocalRef 指向该数组。若不调用ReleaseLocalRef 永不释放最终耗尽 JNI 局部引用表默认 64K抛出java.lang.OutOfMemoryError: unable to create new native thread。注意-Xss设置的是 Java 虚拟机栈大小而 JNI 局部引用表大小由-XX:MaxJavaStackTraceDepth影响栈深度和 JVM 内部参数共同决定无法直接配置。解决方法是严格遵循“获取-使用-释放”三步曲或使用PushLocalFrame/PopLocalFrame批量管理。5.2 直接内存与 JNIDirectByteBuffer 的双重身份DirectByteBuffer是连接 Java 堆与本地内存的特殊桥梁。其构造函数内部调用Unsafe.allocateMemory()申请堆外内存并用Cleaner注册释放钩子。但Cleaner的执行依赖ReferenceQueue的轮询而轮询线程Reference Handler优先级较低。当 JNI 代码直接操作DirectByteBuffer的地址时如GetDirectBufferAddress它绕过了Cleaner机制。若 JNI 代码忘记调用free()或 Java 层DirectByteBuffer被 GC 后 JNI 仍持有地址就会导致悬垂指针Dangling Pointer或内存泄漏。实测案例某图像处理库用 JNI 调用 OpenCV传入DirectByteBuffer存储像素数据。OpenCV 内部缓存了该地址但 Java 层DirectByteBuffer被 GC 后OpenCV 仍尝试读写该地址引发SIGSEGV。解决方案是 JNI 层主动调用env-DeleteGlobalRef释放DirectByteBuffer的 GlobalRef并在 Java 层用sun.misc.Cleaner的clean()方法强制释放。6. 运行时数据区的协同全景从 main() 开始的 12 个关键节点追踪现在我们把前面所有区域串联起来以HelloWorld.main()为起点完整追踪一个对象从创建到消亡的全过程。这不是理论推演而是基于jinfo、jstat、jmap、jstack和 HotSpot 调试日志的真实路径。6.1 节点 1-3类加载与 main 栈帧建立方法区 → 程序计数器 → 虚拟机栈java HelloWorld启动JVM 初始化BootstrapClassLoader加载java.lang.Object等核心类到方法区ApplicationClassLoader加载HelloWorld.class解析常量池将main方法字节码存入方法区主线程启动程序计数器PC指向main方法第一条指令0: getstatic虚拟机栈创建第一个栈帧。6.2 节点 4-6对象创建与堆分配堆 → 方法区 → 虚拟机栈执行ldc #3加载字符串常量Hello World常量池中该字符串已存在由String类静态初始化时intern()放入PC 指向常量池索引方法区提供引用执行getstatic #2获取System.outSystem类的静态字段out是PrintStream实例该对象在 JVM 启动时已创建并存于堆其类元数据在方法区执行new指令虽本例无显式 new但println内部会创建StringBuilder在Eden 区分配内存对象头写入Mark Word和Klass Pointer指向方法区中StringBuilder的 Klass 结构。6.3 节点 7-9引用传递与 GC 触发虚拟机栈 → 堆 → MetaspaceStringBuilder实例引用被压入虚拟机栈的操作数栈随后存入局部变量表 slot 1append()方法调用时StringBuilder内部char[]数组在Eden 区分配若 Eden 区满触发Minor GCStringBuilder对象存活复制到S1 区char[]数组若较大可能直接分配到老年代。6.4 节点 10-12方法返回与资源释放虚拟机栈 → 本地方法栈 → DirectMemoryprintln执行完毕StringBuilder栈帧弹出虚拟机栈释放但对象仍在堆中等待 GCPrintStream.write()调用底层FileOutputStream.write()进入本地方法栈通过 JNI 调用 OS write 系统调用若使用FileChannel.map()创建内存映射文件会分配DirectByteBuffer其内存来自本地内存由Cleaner在 GC 后异步释放。这个链条清晰表明没有任何一个运行时数据区是孤立的。堆中的对象依赖方法区的类元数据定位其结构虚拟机栈的引用控制堆对象的可达性程序计数器驱动指令流而指令流又决定哪些区域被访问本地方法栈是 Java 世界与操作系统世界的唯一接口其稳定性直接决定整个 JVM 的健壮性。我在一家电商公司做 JVM 调优时曾遇到一个诡异问题订单服务 CPU 使用率 100%但jstack显示所有线程都在TIMED_WAITING状态。最终用perf top发现热点在pthread_cond_timedwait结合jcmd pid VM.native_memory detail发现Internal区域暴涨定位到是某个自研 RPC 框架的Netty EventLoop在Selector.select()时因DirectByteBuffer泄漏导致本地内存耗尽select()系统调用陷入忙等。这再次印证JVM 内存模型的任何一个环节出问题都会在其他区域显现出连锁反应。7. 面试高频题的底层答案为什么这些“八股文”必须从运行时数据区角度回答网上流传的 JVM 面试题如“StringTable 为什么要从永久代移到堆中”、“CMS 和 G1 的区别是什么”、“ThreadLocal 内存泄漏的原因”如果只背标准答案面试官一句“你能画出它在内存模型中的具体位置吗”就可能露馅。真正高分的回答必须锚定在运行时数据区的物理布局和协作机制上。7.1 “StringTable 为什么要移到堆中”——直击 Metaspace 设计缺陷标准答案“永久代大小固定容易 OOM堆内存更大且可 GC。”底层答案JDK 7 之前StringTable 在永久代而永久代与堆内存隔离intern()的字符串与普通 Java 对象无法统一管理。当大量intern()调用时永久代首先耗尽但堆内存可能还有富余。JDK 7 将其移至堆使得String对象的生命周期与普通对象一致可被 Minor GC 和 Full GC 统一回收。更重要的是堆中的 StringTable 使用ConcurrentHashMap其扩容机制与堆 GC 协同避免了永久代的硬性上限瓶颈。7.2 “CMS 和 G1 的区别”——聚焦 Region 与并发标记的物理实现标准答案“CMS 是标记清除G1 是标记整理CMS 以吞吐量为目标G1 以停顿时间为目标。”底层答案CMS 的并发标记阶段JVM 需要扫描整个老年代标记过程与用户线程并发但标记本身仍需修改对象头Mark Word因此必须在初始标记和重新标记阶段 STW。G1 将堆划分为固定大小的 Region1-32MB每个 Region 独立管理标记时只扫描待回收的 Region 子集且标记信息Remember Set存储在 Region 头部物理上实现了“局部性”和“增量式”这是其可控停顿的硬件基础。7.3 “ThreadLocal 内存泄漏”——锁定 ThreadLocalMap.Entry 的弱引用设计标准答案“ThreadLocalMap 的 Entry 继承 WeakReferencekey 为弱引用value 为强引用若 ThreadLocal 对象被回收key 为 null但 value 仍存在导致泄漏。”底层答案ThreadLocalMap的Entry定义为static class Entry extends WeakReferenceThreadLocal?其referent字段即 key是弱引用
返回列表