尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java内存模型入门:理解堆、栈与垃圾回收机制

Java内存模型入门:理解堆、栈与垃圾回收机制 JVM把内存分成几块地盘每块地盘各司其职又彼此暗通款曲。很多人一开始背概念堆放对象栈放引用方法区放类信息但一遇到实际报错就懵——OutOfMemory到底是谁满了StackOverflow又是谁溢出了答案其实藏在“谁在分配、谁在回收、谁在什么时候死”这三个问题里。栈线程的私人记账本栈是每个线程私有的线程一启动JVM就给它划一块LIFO结构。里面装的东西叫栈帧一个方法调用对应一个栈帧。栈帧里存的是局部变量表、操作数栈、动态链接和方法出口。你写int a 1这个a如果是个基本类型值就直接躺在局部变量表里如果是String s abcs这个引用躺在栈里而字符串对象在堆里。栈的生命周期极其残酷方法一执行完栈帧立刻弹出里面的临时变量全部作废不需要任何垃圾回收器介入。这就是为什么局部变量用完就没了而对象的存活却要等GC来裁决。如果你递归调用不设出口栈帧一层层叠上去最终把栈空间耗干抛出StackOverflowError。这好比记账本每一页都写满新交易没法记了。注意这个错误是Error不是Exception因为线程已经彻底废了JVM想救都救不回来。栈的最大价值就是让每个线程拥有绝对的隔离性——线程A的局部变量线程B连碰都碰不到。这也是Java天然适合多线程的一个底层原因。但隔离也带来代价栈上只能放小东西大对象、生命周期不定的对象只能扔到堆里去。堆所有对象的收容所堆是JVM内存里最大的一块也是GC的主要战场。理论上所有new出来的对象都在堆里分配。数组、字符串、集合、你自定义的实体类无一例外。堆是所有线程共享的所以对象可以被多个线程同时访问这也是并发问题的根源之一。堆内部不是铁板一块。为了配合分代回收JVM通常把堆分成新生代和老年代。新生代又细分为Eden区和两个Survivor区S0、S1。大多数对象刚创建时住在Eden区经过几次垃圾回收还活着的会晋升到老年代。这个设计基于一个经验法则绝大多数对象朝生夕死活过几次GC的对象往往能活很久。很多人困惑为什么堆空间看起来总是不够用因为堆里对象生命周期千差万别GC没法像栈那样精准地“弹出帧”只能靠扫描引用关系来判断谁死谁活。堆的内存管理核心就是引用追踪——对象不是被“删除”的而是当没有任何引用指向它时它就成了垃圾等待回收。如果你写了个全局静态Map不断往里面放数据而从不移除那么这些对象永远有引用可达GC永远不敢动它。这就是所谓的内存泄漏——不是真的漏了而是被无意的强引用拴住了。堆溢出的常见原因不是对象太多而是不该活的对象活得比你想象的长。方法区和运行时常量池被低估的元空间JDK 8之后方法区的实现从永久代换成了元空间。它存的是类结构信息、静态变量、常量池、方法字节码等。很多人忽略这块内存但它一样能爆。你动态生成大量类比如反射大量代理类、CGlib增强元空间就会膨胀。常量池里的字符串、数字常量如果在运行时不断往里加String.intern()的字符串也会撑爆内存。元空间最大的特点是它用的是本地内存而不是JVM堆内存——所以它在默认情况下只受系统物理内存限制这既是恩赐也是陷阱。垃圾回收谁来判断对象该死GC的核心步骤就两步找出垃圾回收垃圾。找垃圾主流算法是可达性分析。从一组叫GC Roots的根对象出发沿着引用链往下走能走到的就是活的走不到的就是垃圾。GC Roots包括栈帧中的局部变量引用、静态变量引用、JNI引用、活跃线程等。只要从GC Roots出发无法到达这个对象就算被其他对象互相引用也一样会被判定为垃圾。这里有个经典误区循环引用。两个对象互相引用但外部没有引用指向它们是不是该被回收可达性分析轻松解决——因为从GC Roots出发根本找不到它们所以它们就是垃圾。不是“没人引用你你就该死”而是“从根上找不到你你才该死”。判断出垃圾后回收策略就多了。标记-清除直接标记垃圾并清掉但会产生内存碎片就像磁盘碎片一样大对象来了找不到连续空间。标记-复制把存活对象复制到另一块空区域再把原来的全清掉代价是浪费一部分空间但不会有碎片。标记-整理把存活对象往一端搬然后清理边界外的区域相当于一边压缩一边回收。这三种策略没有绝对优劣JVM会根据不同分代选择不同策略。新生代对象存活率低用标记-复制更划算。老年代对象存活率高用标记-整理或标记-清除更合适。这就是分代垃圾回收器如CMS、G1的设计基础。G1干脆把堆分成许多大小相等的Region不再强制物理上的老年代新生代而是逻辑分代这样能更精细地控制停顿时间。垃圾回收的本质是拿时间换空间而现代GC的目标是把“暂停用户线程”的时间压到极致。一个对象从出生到死亡的完整旅程假设你写new User()这个User对象先落在Eden区。当Eden区快满时发生Minor GC。如果这个对象还被某个栈帧里的局部变量引用着它活下来被复制到S0区年龄加一。下一次Minor GC它又活下来复制到S1区年龄再加一。在两次Survivor区之间来回奔波直到年龄达到阈值默认15它晋升到老年代。如果这时老年代也满了触发Major GC或Full GC。Full GC会回收老年代和新生代还顺带清理元空间停顿时间通常很长所以Full GC次数多了就是性能毒药。问题来了对象什么时候死亡当它的所有引用都被赋值为null、或者引用所在的栈帧弹出、或者引用所属的对象本身变成垃圾时它就不可达了。但注意不可达不等于立即被回收——JVM并不保证一旦不可达就立刻回收。你没办法预测GC什么时候来也无法强制立即回收只能通过System.gc()建议一次但JVM大概率无视。不要依赖finalize()来做资源清理那是被官方明确抛弃的机制。栈和堆的协作逃逸分析改变了一切现代JVM有个优化手段叫逃逸分析。它分析一个对象是否只在线程内部、甚至只在方法内部使用。如果对象没有“逃逸”出方法JVM可能把它分配到栈上而不是堆上。栈上分配的对象随方法结束自动销毁根本不需要GC参与。这就是为什么某些局部小对象的性能开销并没有你想象中那么大。逃逸分析让“对象不在堆里”成为可能也让栈和堆的边界变得比理论上更模糊。还有一种优化叫标量替换如果一个对象可以分解成几个基本类型字段而且不逃逸JVM干脆不创建这个对象直接把字段拆成局部变量。这样连栈上分配都省了直接放在寄存器或栈帧的槽位里。你写的new Point(x, y)可能最终在机器指令里就只是两个int变量的操作。当然这些优化都是在JIT编译阶段完成的你写代码时不需要关心但理解它能帮你明白为什么说“对象一定在堆上”并不总是对的。内存调优的第一课是学会看日志入门者最容易犯的错是上来就调各种GC参数。其实的第一步是开启GC日志观察内存走势。比如-Xlog:gcJDK 9可以打印GC的详细情况。你会看到类似[GC (Allocation Failure) [PSYoungGen: 2048K-256K(6144K)]这样的信息。这告诉你新生代从2048K回收到了256K总堆大小从...。阅读这些日志能直观理解堆的压力在哪里是新生代频繁GC还是老年代持续增长还是元空间告急。没有日志的调优就是闭着眼睛开车。堆大小设置也很有讲究。-Xms是初始堆大小-Xmx是最大堆大小。如果两者设置不一样JVM会动态伸缩堆。频繁伸缩会有额外开销所以生产环境一般建议把两者设成一样。过大的堆不一定好——GC暂停时间会随堆变大而变长尤其是Full GC十几GB的堆一次Full GC可能让服务卡顿数秒。这也是为什么很多系统转向G1甚至ZGC就是为了在几十GB的堆上把停顿控制在几毫秒内。内存模型的终极价值写出更稳的代码理解了堆、栈和GC最大的收益是能预判代码的内存行为。比如当你知道一个集合对象的引用被静态字段持有你就明白它永远不会被回收那么你在设计缓存时必须考虑容量上限和淘汰策略。当你知道大量短生命周期对象会频繁触发Minor GC你可以考虑用对象池复用替代频繁new。但这也不是绝对——对象池本身也会增加代码复杂度和GC压力如果对象很小逃逸分析可能已经把对象优化掉了对象池反而成了累赘。不要为了性能而盲目优化内存先让代码正确再用数据决定要不要优化。更重要的你会明白内存溢出的根源往往不在JVM参数而在你的代码引用了什么不该引用的东西。一个持有Context的静态变量、一个忘记了remove的监听器、一个被缓存下来的Session都是潜在的定时炸弹。GC的使命是回收垃圾而不是回收被你当成宝贝的泄漏源。认清这一点你才算真正入了Java内存模型的门。
返回列表