ARTICLE DETAIL

资讯详情

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

JMM与运行时数据区:Java内存模型核心区别与实战调优

JMM与运行时数据区:Java内存模型核心区别与实战调优 1. 先把两个“内存模型”掰开JMM 和运行时数据区1.1 为什么同一个名词能让两拨人吵十年我经常在排查线上问题时听到这样的话“这应该是 JVM 内存模型配置不对。”对方多半是指堆大小没调好、GC 日志刷屏、Metaspace 报错。但是严格来说他口中的“内存模型”和《Java 虚拟机规范》里定义的 Java 内存模型JMM根本不是一回事。《Java 虚拟机规范》里其实有两套和“内存”相关的核心概念一套叫Java 内存模型JMM, Java Memory Model另一套叫运行时数据区Runtime Data Areas。前者解决的是多线程并发下的可见性、原子性、有序性问题属于抽象层面的并发协议后者解决的是JVM 运行期间把内存分成哪些区域、每个区域存什么、谁负责回收属于物理层面的内存规划。很多书为了省事把两套东西都简单翻译成“内存模型”结果读者都在同一个词上互相打架。我这些年做性能排查得出一个粗经验遇到并发 bug优先怀疑 JMM遇到 OOM、GC 频繁、堆溢出基本是运行时数据区的问题。把这两件事分开后面看任何 JVM 分析文章都会顺很多。1.2 JMM 说的“内存”是抽象出来的主内存和工作内存JMM 的核心设定是每个线程有自己的工作内存保存了它最近用到的变量副本所有变量本身的正式存储位置在主内存中。线程不能直接读写主内存里的变量只能先把主内存的变量拷贝到工作内存操作完再刷回主内存。这个模型听起来很简单但它带来的连锁反应是多个线程同时在各自的缓存里操作同一个共享变量可能互相看不到对方的修改。由此引出了经典的三件事原子性一个线程正在改某个变量别的线程不应该看到改了一半的状态。synchronized、Lock基本就是干这个的。可见性线程 A 改了变量线程 B 到底什么时候能看到。volatile保证写操作之后立即对别的线程可见。有序性编译器、JIT 编译器、CPU 都可能调整指令顺序只要不影响单线程语义。但在多线程下重排序可能让你明明写了flag true另一个线程却看到flag还没变。volatile、synchronized以及happens-before规则就是用来约束这种重排序的。JMM 里的“主内存”并不是特指 JVM 堆它只是一个抽象概念对应到物理上可能跨越堆、CPU 缓存、寄存器。所以如果你在调堆大小的时候拿 JMM 解释方向就偏了。1.3 JRE 和 JVM 的关系顺带交代清楚因为总有人在同一个问题里把 JVM、JRE、JDK 混在一起说我在这里补一句最基本的链条JVM 是虚拟机本身负责执行字节码、管理内存和运行时环境JRE 是“JVM Java 核心类库 运行所需工具”只给跑 Java 程序用JDK 又是“JRE 编译器和开发调试工具”给开发者用。真正说到内存模型我们最终观察的东西都在 JVM 内部——堆、栈、方法区、程序计数器这些运行时数据区以及 JMM 定义的线程间通信规则。搞清楚这层关系再学习后面的内容就有了一个稳定的框架。2. 运行时数据区全景图哪些区域在默默工作2.1 六个区域的一句话职责在 Java 8 及之后的常见实现里运行时数据区可以以下面的表格快速对齐到脑海区域线程归属大体存什么可能的异常程序计数器线程私有当前线程执行的字节码行号基本没有Java 虚拟机栈线程私有栈帧局部变量表、操作数栈、动态链接、返回地址StackOverflowError、OutOfMemoryError本地方法栈线程私有本地方法native 方法调用的栈线程过多时OutOfMemoryErrorJava 堆线程共享几乎所有对象实例和数组OutOfMemoryError: Java heap space方法区元空间线程共享类元数据、运行时常量池、静态变量等OutOfMemoryError: Metaspace直接内存线程共享NIO 使用DirectByteBuffer分配的堆外内存OutOfMemoryError: Direct buffer memory注意一个细节程序计数器是唯一一个在《Java 虚拟机规范》里没有规定OutOfMemoryError的区域。因为它存的只是一个数值和线程生命周期绑定线程挂了它就跟着没了。2.2 为什么堆总是大家议论的焦点堆是 JVM 管理内存时“压力最大”的区域。绝大多数 Java 对象都出生在堆里绝大多数 GC 动作也发生在堆里所以排查性能问题时90% 的情况先看堆都合理。在正式版的 Oracle HotSpot JVM 里堆又被分成了新生代和老年代。新生代里继续分为 Eden 区和两个 Survivor 区。很多新手第一次看到-Xms、-Xmx、-XX:NewRatio就头大其实只要抓住了“对象先在 Eden 出生活过一轮 Minor GC 后进入 Survivor继续活下去最终晋升到老年代”这条主线后面的具体参数都只是这一条主线的调整旋钮。堆以外第二容易被忽略的是直接内存。NIO 的ByteBuffer.allocateDirect()会在堆外申请内存它不受-Xmx限制但受操作系统可用内存限制。如果你用 Netty又开了很大的 Buffer 池且不做回收控制最后可能堆还很健康进程却因为“Direct buffer memory”挂掉了。3. 堆的完整故事对象从出生到回收的全路径3.1 一个对象到底是怎么“进入”堆的大多数人的直观理解是写一行new User()对象就放到堆里。这话没错但在 HotSpot JVM 的实现层面路径比这句话复杂得多。首先JIT 编译器会做逃逸分析。如果确定对象不会逃逸出方法或线程就可能做栈上分配甚至标量替换——也就是对象直接在栈帧里拆成几个基础字段分配不再进入堆。这是编译器优化的一部分也是为什么“对象一定在堆”这句话在现代 JVM 里并不完全精确。如果对象确实要进堆在多线程环境下直接每次都用 CAS 去堆里抢内存会很费劲。于是 JVM 给每个线程划分了一块TLABThread Local Allocation Buffer。线程创建对象时优先在自己的 TLAB 里分配TLAB 不够了再向 Eden 区申请。这也是-XX:UseTLAB默认开启的原因。如果 Eden 区空间也不够了就会触发一次 Minor GC把存活对象移到 Survivor。顺着这条线看你会发现真正的分配路径是栈上分配或 TLAB → Eden → Survivor → 老年代。不是所有对象都直接落在老年代更不是所有对象都老老实实死在 Eden。这里我也顺带回答一个常见热词Java JVM 编译器有几种。主流的说法是 C1客户端编译器、C2服务端编译器以及新兴的 Graal。默认开启分层编译时一般是先用 C1 跑快速编译积累热点后再由 C2 做深度优化很多和内存有关的优化动作比如逃逸分析、锁消除都是在这一层完成的。所以编译器并不直接改变内存模型的规范但它会实际影响对象的分配位置和分配方式。3.2 新生代为什么要分成 Eden 和两个 Survivor新生代采用复制算法处理垃圾。核心逻辑是Eden 区大量对象朝生夕灭Minor GC 时把还活着的对象复制到 Survivor然后一次性清空 Eden 和被回收的 Survivor。为什么要两个 Survivor因为复制算法要求“从一个区域复制到另一个空闲区域”如果只有一个 Survivor下次回收时就没有干净的空闲区可用也就无法整体清空。两个 Survivor 相当于交替使用一个区域存放本世代存活对象另一个作为下一轮的接收区。默认情况下Eden 与两个 Survivor 的比例大约是 8:1:1可以通过-XX:SurvivorRatio调整。这个比例在绝大多数后台服务场景下是合理的Minor GC 之后能活下来的对象很少没必要给 Survivor 留太大空间。调小 Survivor 比例可以扩大 Eden减少 Minor GC 频次但对象存活率一旦偏高很可能出现“Survivor 装不下存活对象直接被送到老年代”的情况导致老年代增速加快。3.3 对象什么时候晋升老年代对象晋升老年代主要看几个条件年龄阈值每熬过一次 Minor GC年龄 1默认到 15 岁晋升老年代。-XX:MaxTenuringThreshold可以调但不是越大越好因为 JVM 还会看 Survivor 区容量做动态判断。Survivor 空间不够存活对象超过 Survivor 可用空间时多余对象直接提前进入老年代。大对象直接进老年代超过-XX:PretenureSizeThreshold的“大对象”为了避免在 Eden 与 Survivor 之间反复拷贝会直接分配进老年代。但调过这个参数的人都有体会它很敏感设得太小会导致老年代被大对象迅速撑满反而容易触发 Full GC。实际操作中我最常用的观察指标是 jstat 里Old区增长曲线。如果老年代增长率异常陡峭先别急着加大堆优先怀疑Survivor 空间过小、大对象过多或缓存对象没设过期时间这三个原因远比单纯的堆空间不足常见。4. 线程私有的栈区平时最安静出事就是大事4.1 Java 虚拟机栈里的一次方法调用Java 虚拟机栈是线程私有的生命周期和线程一致。每当一个方法被执行JVM 就会在栈里压入一个栈帧。一个栈帧里除了局部变量表和操作数栈还包含动态链接、方法返回地址等信息。我举个最直观的例子public int calculate(int a, int b) { int sum a b; return sum; }执行calculate(1, 2)时a、b、sum都会出现在局部变量表里。中间的计算过程a b需要临时存放值这个临时区域就是操作数栈。操作数栈的深度在编译期就已经确定所以别担心它会像业务数据一样无限增长。真正危险的是栈帧数量。每个线程的栈默认大小通常在 1 MB 左右可以通过-Xss调整。如果一个方法递归调用太深每层压一个栈帧栈空间很快被打满抛出的异常是StackOverflowError而不是 OOM。我带过不少新人排查“递归查询菜单导致系统崩溃”的问题数据表里父子层级关系有bug递归永远不结束层次叠到几万层栈直接爆了。这种问题从日志里看往往是StackOverflowError: null你第一反应应该看异常堆栈是不是同一行代码反复出现。4.2 本地方法栈与“无法创建线程”本地方法栈为 native 方法服务。虽然现在直接用 JNI 的普通业务项目不多了但 JVM 本身的某些实现、一些第三方库仍然会用到。当进程遇到OutOfMemoryError: unable to create new native thread很多人以为是栈大小没调对其实通常是因为操作系统对进程的线程数或虚拟内存有限制每个线程默认栈大小太大导致虚拟内存被快速耗尽程序里用线程池无限提交任务连接资源不释放。排查思路应该是先ulimit -u看系统线程数限制再ps -eLf | wc -l看当前线程量最后结合线程转储判断是否存在线程泄漏。直接调-Xss往往只是把问题往后推了一点。4.3 程序计数器唯一没有 OOM 风险的区域程序计数器在 Java 虚拟机和线程私有的区域里都排在最前面。它的作用是记录当前线程执行到的字节码行号。线程切换时CPU 把当前线程的上下文保存下来下次切回来时靠程序计数器恢复执行位置。这块区域不需要 GC也不会 OOM所以在实际调优中可以当它不存在。但理解它有一个好处如果同一个方法在多线程里跑出了不同结果而你怀疑“线程是不是从某一行恢复执行”那多想想 JMM 和栈的上下文比调程序计数器有用得多。5. 方法区和元空间JVM 的“类档案室”5.1 永久代为什么退场了经历过 JDK 7 时代的人一定见过PermGen space溢出。永久代当时是方法区的实现方式放在 Java 堆之外但又受 JVM 本身管理默认空间很小。一旦项目用了大量反射、动态代理、热部署反复加载新类永久代就经常被撑爆然后你只能重启应用。JDK 8 之后永久代被**元空间Metaspace**取代。元空间使用本地内存native memory默认上限取决于操作系统可用内存用得更多也更容易报“好像没上限”的错觉。但“默认无上限”不代表可以无脑使用。类加载器泄漏、动态生成大量代理类依然会把元空间和系统内存拖垮。建议生产环境显式加上-XX:MetaspaceSize和-XX:MaxMetaspaceSize给自己留一个明确的保护边界。5.2 运行时常量池与字符串常量池不是一回事方法区里有一个重要组成运行时常量池负责存放编译器生成的字面量、符号引用、方法引用等。注意JDK 7 之后字符串常量池被移到了 Java 堆里和运行时常量池分了家。很多人把字符串常量池和运行时常量池混着说其实是两个不同的池子。字符串常量池跨到堆里之后和String.intern()配合产生过一个经典误区String s1 hello; String s2 new String(hello); System.out.println(s1 s2); // false System.out.println(s1 s2.intern()); // trues1是编译期常量直接进字符串常量池s2是运行时创建的新对象在堆里单独占一块空间不会自动进池。只有调用intern()时JVM 才会去看池里有没有相同内容的字符串没有则把它加入池中。频繁调用intern()会大量挤占堆空间尤其是把每次请求的 key 都 intern 一遍的场景很容易把堆的打爆。尽量不要在业务热路径里依赖 intern 省内存。5.3 元空间 OOM 和类加载器泄漏最常见的元空间 OOM 场景是容器化部署配合热加载。比如开发环境里用 devtools 热更新每次改代码都会创建新的 ClassLoader旧 ClassLoader 如果没有被释放类元数据就堆积在元空间里。表面上看是“元空间不够”实际是类加载器泄漏。我遇到过一次很典型的生产事故应用每 5 分钟动态生成一批反射类JVM 参数里没有设置-XX:MaxMetaspaceSize结果元空间一路增长到 4 GB最终把整个服务器的可用内存吃光。后来不是简单调大元空间而是抓了类加载器快照确认是动态生成逻辑没有缓存类定义把重复创建的代码修掉元空间曲线才稳定下来。6. GC 和调优内存模型的价值最终体现在这里6.1 GC Roots 和可达性分析和内存区域强绑定的一个概念垃圾回收判断对象是否存活用的是可达性分析。从一系列GC Roots出发沿着引用链查找能被找到的对象算“活着的”找不到的就算可回收。GC Roots 的常见来源包括虚拟机栈中引用的对象局部变量、临时变量方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象活跃线程对象本身。你把 GC Roots 的来源列出来就会发现它们正好分布在前面讲过的各个内存区域里栈里的局部变量、方法区里的静态变量、本地方法栈里的 JNI 引用。所以不理解内存模型就很难真正理解 GC 行为。6.2 典型 OOM 复盘表异常信息对应区域常见原因优先处理方向Java heap spaceJava 堆堆太小、大对象过多、对象泄漏dump 堆快照分析 GC RootsGC overhead limit exceededJava 堆几乎没内存可回收GC 空转检查堆大小和目标分配率Metaspace元空间类加载器泄漏、大量动态代理抓类加载器快照限制-XX:MaxMetaspaceSizeDirect buffer memory直接内存NIO 分配未释放缓冲区太大检查堆外内存使用和 Capability 配置unable to create new native thread本地方法栈/系统层线程数超限、虚拟内存耗尽查线程数、线程池提交量、-XssStackOverflowErrorJava 虚拟机栈递归过深、无限循环查异常栈修业务逻辑这张表最想说明的是每个 OOM 都对应内存模型里的一个具体区域。拿到报错先定位区域再对症下药而不是无脑-Xmx8g -Xms8g。6.3 常用参数和一套可复用的排查路径调优参数里最常用的一套可以这样整理-Xms/-Xmx堆初始大小和最大值。生产环境建议设成相同值避免运行时频繁扩容和缩容。-Xmn或-XX:NewRatio新生代大小或新生代与老年代比例。大多数 Web 服务里新生代可以占比停靠 1/3 左右。-Xss单个线程栈大小。除非确认递归或栈帧很深否则没必要改。-XX:MetaspaceSize/-XX:MaxMetaspaceSize元空间初始值和上限。-XX:SurvivorRatioEden 和 Survivor 的比例。-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPathOOM 时自动导出堆快照这条一定要开。-Xlog:gc*JDK 9或-verbose:gc记录 GC 日志排查问题的最基础动作。排查路径我的习惯是先看 OOM 类型确定是哪个区域再拉 GC 日志确认是分配速率太快还是回收不掉用 jstat 看各区占用曲线找出主要增长点按需 dump 堆或线程分析泄漏对象或线程状态最后才调整参数并保留前后对比记录。这里说个真实的教训。有个服务一直报Java heap space我同事先把-Xmx从 4 GB 调到 8 GB结果问题只是推迟了一周。后来 dump 堆一看内存里全是同一个 HashMap 的 key 没清理每次请求都会往里塞数据。你调大堆等于给泄漏对象提前租了一间更大的房子而不是把租客送走。参数是放大器根因在代码和数据生命周期里。最后再分享一个小技巧生产环境遇到内存问题第一件事不是去看网上流传的各种参数模板而是先确认“当前这个区域的实际占用和增长趋势”。把jstat -gcutil的输出按分钟记录几组再配合一次强制 Full GC 的观察很多问题在改参数之前就已经浮出水面。内存模型学到最后你会发现真正值钱的不是记住每个区域的名称而是清楚每个区域在什么情况下会被撑爆、对应怎么处理。
返回列表