ARTICLE DETAIL

资讯详情

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

JVM对象实例化、内存布局与访问定位全解

JVM对象实例化、内存布局与访问定位全解 天天写new Object()的 Java 程序员大概都有过这样的瞬间代码明明写得很规整线上 JVM 却在某个凌晨突然堆溢出。排查下来对象数量、对象大小、引用指向全都不直观。原因也很简单我们平时接触的并发、集合、框架都太上层了真正决定一次new分配多少内存、引用如何寻址的是 JVM 里的对象实例化、内存布局和访问定位机制。这一章笔记我会沿着这三条线把 Java 对象从诞生到被引用的完整链路拆开来讲顺便把类加载检查、JIT 编译优化、调优工具这些周边知识也串进去。适合正在啃 JVM 的开发者、准备面试的人以及那些想弄明白“为什么这个对象占这么大内存”的实践派读完之后你会对堆内存和对象头有一个非常具体的画面感。先说一句题外话网上经常把“JVM 内存模型”挂在嘴边其实这个词在官方文档里指的是 Java 内存模型JMM讲的是多线程下共享变量的可见性规则而本文要讲的是 JVM 运行时数据区中对象的实际存储形态也就是对象实例化、对象头、字段排列和访问定位。两者一个偏并发语义一个偏内存物理布局别搞混。好了开始正题。1. 对象实例化从 new 指令到堆里那点事1.1 一个对象出生的标准六步HotSpot 虚拟机执行new关键字时并不是简简单单在堆上开一块内存它背后有一个固定的流程。我梳理成六个步骤类加载检查虚拟机遇到new指令先去方法区JDK 8 之后是元空间检查这个指令对应的类符号引用是否能在常量池中定位并且确认这个类已经完成加载、解析和初始化。如果类还没加载就触发了类加载过程。这也是很多人常忽略的地方你new一个对象可能先触发的是类的初始化甚至会先执行静态代码块。分配内存类加载检查通过之后JVM 在堆上为对象分配内存。这里有个细节分配内存要考虑堆是否规整于是引出了指针碰撞和空闲列表两种策略后文我单独展开。初始化零值内存分配完成JVM 会把这块内存区域的全部字节初始化为零值。这一步保证了实例字段即使没有手动赋值也有默认值int是 0boolean是 falseObject引用是 null。设置对象头JVM 把对象头初始化好包括对象的哈希码、GC 分代年龄、锁状态标志以及指向类元信息的类型指针。这些信息平时看不见但几乎控制着 synchronized、hashCode、GC 的行为。执行构造方法此时对象已经是个“半成品”了字节码层面的init方法会按 Java 代码的逻辑给字段赋值完成真正的初始化。返回引用栈帧里存入指向对象的引用后续代码才能通过这个引用访问对象。这六步中前四步是 JVM 底层的分配行为第五步才回到 Java 语义。所以你在构造方法里打断点调试时JVM 早就在底层“凭空造”了一个对象出来构造函数只是给它的字段做初始化而已。1.2 new dup invokespecial字节码层面的实际流程光看步骤还是抽象我用一段最简单的代码配合javap -c来看这样最直观。假设有类public class Order { private long id; private String userName; public Order(long id, String userName) { this.id id; this.userName userName; } }在方法里执行Order order new Order(1001L, hanmeimei);对应字节码大致是0: new #2 // class Order 3: dup 4: ldc2_w #3 // long 1001L 7: ldc #5 // String hanmeimei 9: invokespecial #6 // Method init:(JLjava/lang/String;)V 12: astore_1这里的new指令负责完成我上面说的类加载检查、分配内存、初始化零值和设置对象头。执行完new之后操作数栈上只有一个指向对象内存的引用。dup指令把栈顶的引用复制一份复制出来的这份用于调用构造函数原来的那份在invokespecial执行完后继续留在栈上最终通过astore_1存入局部变量表。很多人不理解为什么需要dup。原因是invokespecial执行构造函数时会把操作数栈顶的引用作为this传进去并且按虚拟机规范它是会消耗这个引用值的。如果不复制一份构造函数调用完之后栈上就没有对象的引用了后面没法赋值给order变量。这就好像你只有一张门禁卡进房间验证身份时保安把卡收走了你还怎么回工位dup相当于提前复印了一张。1.3 分配内存的线程安全问题CAS 与 TLAB堆是线程共享的new对象又是一个高频操作如果每次分配内存都用锁系统性能会很难看。HotSpot 的处理方式有两种第一种使用 CAS 配合失败重试。在指针碰撞模式下分配线程需要移动“分配指针”这个移动动作不是原子操作。JVM 通过 CAS 保证只有抢占成功的线程能移动指针失败就重试。这种方式适合分配压力不大的场景并发高时会空转。第二种使用本地线程分配缓冲也就是 TLABThread Local Allocation Buffer。HotSpot 默认开启-XX:UseTLAB给每个线程在 Eden 区划一小块连续内存线程内分配对象时在自己的 TLAB 里移动指针就行完全不需要和其他线程竞争。当 TLAB 空间不够时才会去堆上申请新的 TLAB或者如果对象太大就直接在堆上分配不走 TLAB。理解 TLAB 对排查问题是很有用的。你可能会发现为什么很多小的、短命的对象分配特别快其实很大程度是 TLAB 的功劳。它也解释了一个现象新生代 Eden 区即使看起来明明还有空间但某个小对象分配时 JVM 还是可能触发一次 Minor GC就是因为线程的 TLAB 剩余空间不够而 Eden 的剩余空间又不足以再申请一块 TLABJVM 会干脆提前做一次新生代回收腾空间。1.4 类加载检查与对象初始化前置条件回到那六步中的第一步。new遇到一个类JVM 首先要看这个类是否已经被加载。所谓“加载”是把 Class 文件二进制流读进元空间解析成方法区中的类元信息。没有加载就按双亲委派模型去加载没有解析就解析没有初始化就得先初始化。这里有个容易忽略的“坑”类的初始化是会执行clinit方法的也就是静态变量赋值和静态代码块。如果类的静态代码块写得很重比如连数据库、读配置文件那么就算你在业务代码里只new了一次也可能承受一次比较明显的耗时。平时我们总说 JRE 是 JVM 加核心类库JVM 启动后我们写的类并不会一股脑全加载到元空间而是按需加载。JVM 里的类加载是按需的new可以说是最典型的触发点之一。还有个相关知识点JDK 8 起类的静态成员和类元信息放在元空间Metaspace它不占用堆内存但动态创建大量代理类会占用元空间那个区域也会 OOM。我们在分析堆溢出时不要下意识只盯堆元空间异常在 jvm 调优里同样常见。1.5 JIT 优化与逃逸分析对象不一定要“出生在堆上”很多人在学习阶段会以为对象一定是堆上分配的这其实不完全对。JVM 中的 JIT 编译器有能力优化掉一部分对象。这里先回答一个高频问题JVM 编译器有几种JITJust-In-Time编译器主要分成 C1客户端编译器编译快优化保守、C2服务端编译器编译慢优化激进以及 JDK 9 之后出现的 Graal JIT 编译器。HotSpot 还支持分层编译也就是先解释执行统计热点再用 C1 快速编译最后用 C2 深度优化。逃逸分析就是一种很典型的 C2 优化。如果 JIT 判断一个对象只在当前方法内部被引用没有“逃逸”到方法外部那么这个对象就不一定真的要在堆上分配可能采用栈上分配甚至被标量替换成多个局部变量。什么场景最常见比如在一个方法里反复new一个只用于算中间值的临时对象编译后这个对象可能直接被拆成几个普通变量完全没有真实的对象实例产生。这也意味着你看到代码里写了new不代表运行时一定在堆上创建了对象。在 JVM 调优和压测时如果某些临时对象大量出现但堆占用没有明显增长先别惊讶很可能是逃逸分析起了作用。当然逃逸分析是 JIT 在运行时做的判断不是编译期静态能保证的所以别把宝都押在它身上代码里该避免无意义的临时对象还是得避免。2. 内存布局对象内部三块地盘2.1 对象头Mark Word 与类型指针一个 Java 对象在堆内存中的布局可以分为三块对象头Header、实例数据Instance Data、对齐填充Padding。对象头是整个布局里信息密度最高的部分。HotSpot 中对象头分为两部分第一块叫 Mark Word用于存放对象自身的运行时数据比如哈希码、GC 分代年龄、锁状态标志第二块是类型指针即对象指向它的类元数据的指针。如果是数组对象对象头里还会额外记录数组长度。Mark Word 在 32 位和 64 位虚拟机中结构不太一样我以 64 位 JVM 为例把常见的锁状态和位内容整理成一张简化表锁状态标记位Mark Word 主要存储内容无锁01对象哈希码、GC 分代年龄、是否偏向锁偏向锁01偏向线程 ID、偏向时间戳、分代年龄轻量级锁00指向栈中锁记录的指针重量级锁10指向管程 Monitor 的指针GC 标记11空信息用于标记对象可回收注意 Mark Word 是一块动态复用的数据结构。同一个对象的哈希码、年龄、锁状态并不会同时全部以原有格式存在一旦加了重量级锁Mark Word 里就只剩指向 Monitor 的指针哈希码等信息会被挪到别处保存。这也是为什么你在覆盖hashCode()时会建议别用Object.hashCode()和偏向锁混在一起研究的原因它们的存储位是互相挤对的。对象头的大小要记牢开启压缩指针的 64 位 JVM 中普通对象头是 12 字节Mark Word 8 字节 类型指针 4 字节不开启压缩指针时是 16 字节。数组对象还会多 4 字节的数组长度整体再对齐到 8 字节边界。2.2 压缩指针和类型指针默认开启但要知道为何64 位 JVM 默认开启-XX:UseCompressedOops也就是压缩普通对象指针。原本一个 8 字节的引用在压缩后变成 4 字节。为什么能这么做因为对象是 8 字节对齐的后 3 位必然是 0这 3 个 0 可以不用在指针里保存因此用 32 位指针就能寻址到 32GB 的堆空间。这个设计在现代服务端很实用毕竟绝大多数 Java 应用堆不超过 32GB把引用从 8 字节砍到 4 字节省下来的内存非常可观。jvm 调优时如果在超大堆大于 32GB上运行压缩指针会失效同样一个对象的内存占用却可能涨一大截。这一点在规划堆大小时要特别留意不是堆越大越划算跨过 32GB 阈值引用从 4 字节变回 8 字节对象占用会明显膨胀小对象缓存这类场景非常吃亏。2.3 实例数据字段重排序有规则实例数据区域是对象真正存储我们定义的字段的地方。很多人以为字段就是按代码书写顺序排列的实际 JVM 会做重排序HotSpot 的字段分配顺序大致遵循“同宽度类型尽量连续放置”的原则long/double优先接下来是int、float再接下来是short/char然后是byte/boolean引用类型放在最后。这个顺序不是绝对的但整体目标是减少内存填充把同宽度的字段排在一起。举个例子一个类定义顺序是class Demo { boolean a; long b; int c; boolean d; String ref; }如果严格按源码顺序排字段之间会有很多空隙内存可能浪费得多。HotSpot 重排后大概率是long b排在前int c和两个boolean靠在一起String ref放在后段。这样排列后对象大小往往更紧凑。平时写业务类不用刻意折腾字段顺序现代 JVM 已经处理得很好。但如果你在做高性能缓存、大量小对象场景把宽度相同的字段聚在一起写多少能减少一点内存碎片。至少养成这个意识排查问题时会少一些困惑。2.4 对齐填充与对象大小计算JVM 要求任何对象的大小都必须是 8 字节的整数倍这是为了配合压缩指针实现低 3 位省略。如果对象头和实例数据加起来不是 8 的倍数就用对齐填充补起来。你可以亲手算一个最简单的Object对象有多大HotSpot 开启压缩指针时对象头 12 字节实例数据 0 字节对齐到 8 的倍数就是 16 字节。所以一个空Object实际占用堆内存 16 字节。这也是为什么大量使用包装类、把简单值拆成很多小对象时内存会爆炸一个Integer在 64 位压缩指针环境下也有 16 字节而一个int只有 4 字节差了整整 4 倍。再看我前面那个Order类字段是long id8 字节加String userName4 字节引用加上对象头 12 字节合计 24 字节正好是 8 的倍数不需要填充。这也是个很常用的估算思路先数对象头再把字段按类型宽度累加最后向上对齐到 8 的倍数。2.5 用 JOL 实际量一量对象占用理论说完强烈建议用 JOLJava Object Layout工具验证一下它是由 OpenJDK 提供的.可以非常清楚地看到对象头的偏移量也可以直接看字段排列。JOL 在 Maven 中就是一个普通依赖也可以下载 jol-cli 垂直走后。如果你用 jol-cli 启动大致命令是这样java -javaagent:jol-cli.jar -jar jol-cli.jar java.lang.String也可以用代码方式打印一个类的布局import org.openjdk.jol.info.ClassLayout; System.out.println(ClassLayout.parseClass(Order.class).toPrintable());输出会展示对象头、每个字段的偏移地址和占用大小。我第一次打印Order时看到id的偏移是 12userName的偏移是 20总大小 24才知道之前脑海里的示意图和真实布局有多大的距离。这种“眼见为实”的经验比背十遍理论都强。3. 访问定位拿到引用之后怎么找到对象3.1 句柄访问与直接指针访问两种路线JVM 规范没有规定栈上的reference数据应该如何找到堆中的对象所以具体实现有两种主流方案句柄访问和直接指针访问。句柄访问的思路是这样的Java 栈中的reference保存一个句柄地址句柄在堆中独立分配一块区域里面维护两个指针一个指向对象实例数据的地址一个指向对象类型数据的地址。访问对象时先通过reference找到句柄再通过句柄里的指针跳到真正的对象数据。直接指针访问就简单直接得多reference里存的就是对象的实际起始地址要访问对象就一步跳过去。两者的优缺点正好相反。句柄访问多一次间接跳转但 GC 移动对象时不需要修改栈上所有reference只要改句柄里的实例指针就行直接指针访问少一次跳转性能更好但 GC 移动对象之后所有持有旧地址的引用都要被修正。3.2 HotSpot 为什么拥抱直接指针HotSpot 虚拟机采用的是直接指针访问。为什么核心原因是访问频率远远高于 GC 移动对象的频率。平时业务代码对对象的读和写非常频繁每一次读字段都少一次指针寻址省下的时间相当可观。GC 搬运对象时修改引用是集中一次性操作即使引用数量多代价也摊到了每次 GC 中。这里还有个很重要的话JIT 编译时会把引用直接内联到机器指令里如果走句柄方案每次都要多一步间接寻址这对即时编译器是不友好的。HotSpot 选择直接指针本质上是“访问时追求快移动时承担一点代价”的取舍在大部分业务场景下这个取舍非常合理。3.3 GC 移动对象时两种方案谁更吃亏你可能会问现在的垃圾收集器不都是会移动对象吗老年代也会做整理新生代更是频繁复制移动对象时修正引用开销不小为什么 HotSpot 还是坚持直接指针原因是 HotSpot 借助了精确式 GC 和 OopMap 数据结构。在安全点虚拟机可以知道栈上哪些位置是对象的引用移动对象后可以精确更新这些引用。所以直接指针访问在 GC 阶段并不是灾难修正引用的过程是可控的。相比之下句柄访问的优点只是简化了 GC 的引用修正但这个优点在现代 GC 里并不是制胜关键。JIT 的性能收益才是大头直接指针完胜。理解这一点你也就明白为什么那么多 JVM 调优文章都在强调-XX:UseCompressedOops因为压缩指针加直接指针在 HotSpot 中组合起来几乎是无损优化。3.4 HSDB 实战看对象头、查引用除了理论HotSpot 也提供了一些工具来观察对象在内存中的真实状态。JDK 9 之后jhsdb里的 HSDB 可以附加到一个 Java 进程上查看对对象头的解读和类信息。比如你可以先启动一个 Java 进程再用jhsdb hsdb --pid pid启动图形化界面通过内存查看器找到对象地址然后观察 Mark Word 的具体二进制内容可以看到锁标志位、分代年龄和哈希码。这个工具在分析死锁、偏向锁引发的奇怪问题时非常有用但使用门槛较高需要先理解对象头各位的含义。初学者可以先学会用 JOL 做静态布局观察HSDB 留到排查疑难问题时再深入研究。4. 常见问题与调优小思路4.1 new 对象的 OOM 究竟发生在哪一步常见的OutOfMemoryError可能在对象实例化的第二步出现也就是堆空间不足分配内存时。但具体形态不一样如果堆整体不足会有Java heap space如果单个请求的大对象超过堆容量也会出现Requested array size exceeds VM limit。排查时最主要的思路是看新生代还是老年代空间不足。大量短命对象堆积会导致新生代不断 Minor GC 又不断晋升最终老年代也满了。一个实用的排查手段是jstat -gcutil pid 1000观察各代使用率或者用jmap -histo看哪个类的实例数量过大。遇到大对象频繁分配时可以考虑调大新生代空间或者检查业务代码是否在循环中反复创建大数组那才是根因。4.2 字段顺序与对象大小优化要克制前面说字段重排序有规则那是不是我们应该拼命调整字段顺序来省钱我的经验是普通业务代码不要过度优化收益通常不大还可能降低可读性。真正值得关注的是两种情况第一种大量小对象场景。比如一个缓存里存了几百万个领域对象你每省 8 字节总内存可能省下几十 MB。这时候值得认真看看 JOL 输出把宽的字段和大引用排好。第二种缓存行与伪共享场景。CPU 缓存行一般是 64 字节如果多个线程同时修改同一个对象里相邻的字段可能产生伪共享False Sharing导致缓存一致性流量暴增。JVM 中的Contended注解就是用来解决这个问题的它会在字段前后填充足够的字节让字段单独占一个缓存行。虽然这是并发层面的问题但根源也是对象内存布局。4.3 synchronized 与对象头的锁状态synchronized 的锁信息是存在 Mark Word 里的所以对象的内存布局直接影响锁的行为。无锁时Mark Word 里存哈希码和年龄偏向锁时存线程 ID升级为轻量级锁后存栈帧中锁记录的地址进一步升级为重量级锁就指向 ObjectMonitor。这是一条写给业务开发的经验偏向锁在 JDK 15 以后已经默认被禁用原因是现代应用并发冲突概率高偏向锁撤销成本反而大于收益。如果你还在看老旧的 JVM 优化文章见到“开启偏向锁”的建议要先确认 JDK 版本别照着抄。4.4 JIT 编译优化与对象生命周期对象内存布局和 JIT 优化是相互影响的。编译器能识别对象的字段是否稳定是否能参与逃逸分析。比如在循环中创建一堆不影响外部状态的对象经过逃逸分析和标量替换运行时可能根本不会发生真正的对象分配。那是不是说我们写代码时可以乱 new 对象不是热点代码还是要靠 JIT 预热和编译才能获得优化冷启动阶段解释执行仍然照常分配对象。线上压测时请给足预热时间否则你测出的“内存占用”可能远高于稳定态这就是很多初学者会踩的坑。4.5 常用观察工具与经验取舍最后整理一下我常用的对象布局和堆内存观察工具工具用途上手难度JOL看类布局、对象头和字段偏移低jmap -histo统计线上实例数量与占用大小低jstat -gcutil观察 GC 和各区使用率低jvisualvm可视化堆、GC 曲线和线程状态中HSDB / jhsdb查看运行中的对象头、堆信息高我的建议是先用 JOL 建立对象大小的直觉再用 jmap 和 jstat 做日常监控HSDB 这类工具等遇到诡异问题时再上手。学习对象实例化和内存布局最忌讳的就是只看概念不验证你会发现实际量出来的数字和想象中差很多。我自己第一次用 JOL 打印一个只有两个字段的简单类时发现它居然占 24 字节当时愣了一下随后马上明白了为什么以前很多“优化”都是在盲猜。后来排查一次接口内存膨胀时正是靠着对象布局的知识定位到包装类型字段过多的问题把那批字段改成基本类型和紧凑数组单条数据内存占用直接降了快一半。JVM 的对象布局不是纯粹的面试题它真的能帮你在关键时刻做出更理性的取舍值得花时间把这篇笔记里的每个细节都亲手验证一遍。
返回列表