堆和栈到底有什么区别——我的学习笔记
说在前面上一篇写完 JVM 五大内存区域之后我在评论区看到有人说把堆和栈搞清楚了JVM 内存你就懂了一半。我当时想堆和栈不就是名字不一样吗结果一学发现水还挺深。这篇文章是我从它俩到底差在哪这个角度硬啃下来的笔记。先说一句颠覆我认知的话学之前我一直以为对象在堆里局部变量在栈里。这没错但实际上栈里放的不是对象本身只是对象的引用可以理解为地址或者说指针。对象真正的身子在堆里。MyObjectobjnewMyObject();这一行代码obj这个变量名在栈上它里面存的是一个地址指向堆里那个真正的MyObject对象。用大白话说就是栈上放的是门牌号堆里才是那个房子。这个事儿我一开始没想明白直到后来看了一个比喻栈就像你手边的笔记本记着冰箱里有食物这个信息堆就是冰箱本身食物对象放在里面。你笔记本上那条记录只是一个指向冰箱里食物的线索。堆和栈一张表看懂区别维度栈堆存什么局部变量、方法参数、返回地址对象实例类的实例和数组生命周期确定——方法调用完栈帧就销毁不确定——等 GC 发现没人引用它了才回收存取速度快先进后出操作简单慢分配和回收有额外开销空间大小小每个线程一个用-Xss配置大可以动态扩展谁看得见线程私有其他线程看不到线程共享所有线程都能访问溢出场景递归太深 / 局部变量太多太大大对象太多 / 内存泄漏没回收当时我看到这张表最让我留意的是第一行“栈存引用堆存对象”。这个我前面说了。还有一个让我觉得挺重要的区别——生命周期。栈的生命周期是确定的方法调完就销毁不会拖泥带水。堆就不一样了。一个对象什么时候被回收你没法精确知道。因为垃圾回收器GC要在它认为合适的时候才会动手。这就引出了一个头痛的问题内存泄漏。对象明明不用了但是还有人引用它GC 就认为它还在用就一直不回收堆就越来越满。堆里面其实还分了好几个区我原来以为堆就是一大块内存往里扔对象就行了。后来才知道堆里面是分代的。新生代Young Generation新生代又分三个部分Eden 区大部分新对象刚出生的时候待在这儿。比例上 Eden:S0:S1 8:1:1。Survivor 区S0 和 S1两个大小一样的区域存活过一轮 Minor GC 的对象会移动到其中一个。我刚听到这个比例的时候很困惑为什么 Eden 这么大Survivor 两个加起来才占 20%后来才知道这是统计出来的——大部分对象活不过第一轮 GC比如循环里 new 出来的临时对象所以没必要给 Survivor 太大的空间。Eden 区满了就会触发Minor GC。活下来的对象挪到 Survivor 区在 S0 和 S1 之间来回复制每经过一轮 GC 年龄加 1。等年龄到了某个阈值默认是 15就晋升到老年代。老年代Old Generation存放那些命硬的对象——经过多次 Minor GC 还活着的老家伙。老年代的特点是空间大回收频率低但每次回收Major GC / Full GC的时间更长。因为老年代的对象多而且大多互相引用要找出哪些是垃圾比新生代复杂得多。元空间Metaspace上一篇已经说过了JDK 8 替代永久代用的不是堆内存而是本地内存。存的是类的元数据信息。大对象有特殊待遇这里有一个之前我不知道的事儿——大对象会直接分配到老年代。比如你 new 了一个很大的数组或者很大的对象它不会先去 Eden 区走一圈而是直接去老年代。为什么我理解的原因是这样的新生代太小了大对象放进去很快就把 Eden 塞满了频繁触发 Minor GCMinor GC 要复制对象大对象来回复制的开销非常大容易产生内存碎片——新生代里 big object 分配和回收几次内存就变得零零碎碎后续小对象分配反而找不到连续的空间在 G1 垃圾收集器里超过 Region 一半大小的对象叫Humongous Object直接分配在专门的 Humongous Region 里。传统的 Parallel / CMS 收集器里可以通过-XX:PretenureSizeThreshold参数设置阈值超过这个值就分配到老年代。一句话总结大东西别在窄巷子里折腾直接去大街。栈溢出是什么样的栈溢出我其实碰到过一次。写递归的时候忘记写终止条件了跑起来直接报StackOverflowError。publicvoidrecursiveMethod(){recursiveMethod();// 没有终止条件 → StackOverflowError}我当时看着这个报错还挺懵的——我知道递归会深度调用但我没想到它能把栈撑爆。栈溢出的常见原因无限递归方法一直调自己栈帧一层一层叠上去直到把栈撑破单个栈帧太大一个方法里定义了超级多的局部变量或者很大的数组一个栈帧就把空间占了很多还没递归几层就溢出了怎么解决检查递归逻辑加正确的终止条件或者把递归改成循环迭代调大栈空间通过-Xss参数比如-Xss256k但要注意——每个线程有自己的栈栈设得越大能创建的线程数就越少优化方法本身减少方法里的局部变量别在方法内部搞大数组一个实战例子看到一个案例挺有启发有个系统要遍历树形结构树的层级超过 10 万层用递归实现的结果栈溢出了。解决方法是改成基于栈的迭代遍历——用一个手动维护的数据结构比如 LinkedList 模拟栈来记录访问顺序不用 JVM 的栈帧来叠。这样就不会受虚拟机栈深度的限制了。堆溢出是什么样的堆溢出在上一篇我也提过就是那个眼熟的java.lang.OutOfMemoryError: Java heap space。为什么会堆溢出无非两种大情况真的需要那么多内存——比如一次性加载了超大文件或者缓存了太多数据内存泄漏——对象明明不用了但还有引用在GC 回收不了越积越多怎么排查堆溢出最常用的办法是在 JVM 启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath./heapdump.hprof这样发生堆溢出的时候JVM 会自动生成一个堆的快照文件.hprof。然后用 MATMemory Analyzer Tool或者 JProfiler 打开这个文件看哪些对象占用了大量内存有没有不该被引用的对象还被人拿着怎么解决原因解决思路内存泄漏顺着引用链查找到谁还在引用那些不该引用的对象。比如静态集合里的元素用完没移除、观察者没反注册真的内存不够调大堆内存-Xms2g -Xmx4g前提是物理内存够大代码写得不好分批处理别一次性把所有数据都加载到内存、缓存加过期策略、用完了及时释放又一个实战例子有一个批量处理的程序把每次处理的结果都存到一个静态 List 里结果对象越积越多最后堆溢出了。用 MAT 分析快照后发现这个 List 占用了 80% 的堆内存。解决办法很简单处理一批就写入数据库然后把 List 清空。不让它一直攒着。这个例子给我的启发是很多时候堆溢出不是真的需要那么多内存而是用完了没还。静态集合是个重灾区——因为它的生命周期和 JVM 一样长往里面塞东西不清理迟早出事。最后想说的这一篇比上一篇写起来有意思因为堆和栈的区别是学 JVM 过程中一个特别实在的考点——面试会问、写代码会碰、排查问题会用。我学完之后给自己画了一个简单的判断流程挺有用的分享给你遇到内存问题先看报错如果是StackOverflowError→ 查递归查方法调用深度如果是Java heap space→ 查大对象查内存泄漏如果是Metaspace→ 查类加载太多CGLIB、动态代理等如果是Direct buffer memory→ 查 NIO 使用这一套判断逻辑我现在还没完全内化但至少看到报错不会像以前一样两眼一黑了。