跳到主要内容

图解Jvm 23.浅堆深堆与内存泄露

·2042 字·5 分钟

1. 浅堆(Shallow Heap) #

技术定义 #

浅堆(Shallow Heap)指一个对象自身占用的内存空间,包含:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

关键特性 #

  1. 不包含引用对象:仅计算对象自身内存
  2. 类型敏感:String对象的浅堆 ≠ Integer对象的浅堆
  3. 内存对齐:JVM按8字节对齐分配内存

计算示例 #

// 32位JVM中的Integer对象
对象头(8B) + int value(4B) + 对齐填充(0B) = 12B
// 64位JVM关闭压缩指针的HashMap$Node
对象头(16B) + 4个引用字段(32B) + int hash(4B) = 52B  对齐填充后56B

实战意义 #

  • 快速定位「对象膨胀」问题
  • 分析集合类基础内存开销

2. 保留集(Retained Set) #

核心概念 #

保留集指某个对象被GC回收时,会连带回收的所有对象集合

当对象X被回收时:

  • 保留集 = {X,Y,Z,M,N}(所有被X直接或间接独占引用的对象)
  • 排除被其他存活对象引用的对象

3. 深堆(Retained Heap) #

技术定义 #

深堆(Retained Heap)= 保留集中所有对象浅堆之和 - 被其他对象共享的部分

在上例中:

  • 对象A的深堆 = A.shallow + B.shallow + C.shallow
  • 对象D不计入(被外部对象E引用)

内存分析价值 #

  1. 识别真正内存占用大户
  2. 发现「看似小对象实则大内存」的场景
  3. 内存泄漏排查的核心指标

诊断工具对比 #

工具浅堆分析深堆分析支配树
MAT
JProfiler
VisualVM

4. 对象的实际大小 #

与浅堆的差异对比 #

对象的实际大小 = 浅堆 + 所有可达对象的浅堆总和

计算规则详解 #

// Person对象结构
class Person {
    String name;    // 引用类型
    int age;        // 基本类型
    Address addr;   // 引用类型
}

// 计算步骤:
// 1. Person浅堆 = 对象头 + 字段存储空间
// 2. 实际大小 = Person.shallow + name.shallow + addr.shallow + ...

内存快照分析 #

使用MAT工具查看对象内存分布


5. 支配树(Dominator Tree) #

核心算法原理 #

支配树中的节点满足:从GC Roots到该节点的所有路径都必须经过其支配者

MAT实战应用 #

  1. 打开支配树视图
  2. 按Retained Heap排序
  3. 定位可疑对象
// 典型内存泄漏支配树结构
ThreadLocalMap --支配--> Entry --支配--> ValueObject

优化指导价值 #

  • 识别关键内存持有路径
  • 快速定位集合类泄漏

6. 内存泄漏(Memory Leak)VS 内存溢出(OOM) #

本质区别对比 #

维度内存泄漏内存溢出
发生条件对象不再使用但无法回收内存空间不足以创建新对象
持续时间长期累积可能瞬时发生
检测难度需要内存分析工具往往有明确错误日志
典型错误无直接报错java.lang.OutOfMemoryError

关联性图解 #


7. Java中内存泄露的8种情况 #

8.1 静态集合类 #

static List<Object> cache = new ArrayList<>(); // 添加后未移除

8.2 单例模式 #

class Singleton {
    private static final instance = new Singleton();
    private byte[] data = new byte[1024 * 1024]; // 大对象常驻内存
}

8.3 内部类持有外部类 #

class Outer {
    class Inner {
        // 隐式持有Outer.this引用
    }
}

8.4 未关闭连接 #

8.5 变量不合理的作用域 #

public class DataProcessor {
    private byte[] buffer = new byte[1024 * 1024]; // 本应作为局部变量

    public void process() {
        // 长期持有大数组引用
    }
}

内存泄漏原理
https://i.imgur.com/3GjZx4k.png
将本应方法内使用的临时变量提升为成员变量,导致对象生命周期被意外延长


8.6 改变哈希值 #

class Student {
    int id;

    @Override
    public int hashCode() {
        return id;
    }

    void updateId(int newId) {
        this.id = newId; // 修改哈希关键字段
    }
}

HashSet<Student> set = new HashSet<>();
set.add(student);
student.updateId(100);  // 导致无法通过remove()删除

哈希表结构变化

对象存储在旧桶中,但查询时到新桶查找,导致永远无法被访问


8.7 缓存泄露 #

// 错误实现
Map<String,BigObject> cache = new HashMap<>();

// 正确姿势
Map<String,SoftReference<BigObject>> cache = new WeakHashMap<>();

缓存策略对比


8.8 监听器和其他回调 #

public class Service {
    private List<Listener> listeners = new ArrayList<>();

    public void addListener(Listener l) {
        listeners.add(l);
    }

    // 缺少removeListener方法
}

监听器泄漏链


9. 内存泄露案例分析 #

场景描述 #

某电商系统频繁Full GC,监控显示老年代内存持续增长

MAT分析步骤 #

  1. 获取堆转储文件
    <font style="background-color:rgb(252, 252, 252);">jmap -dump:format=b,file=heap.hprof <pid></font>
  2. 支配树定位问题
    https://i.imgur.com/5WQY7vG.png
  3. 发现可疑对象
ConcurrentHashMap$Node[] 
Retained Heap: 1.2GB
  1. 引用链追溯

  1. 根本原因:
    静态缓存使用强引用,未设置过期策略

10. 常见问题与解决方案 #

问题现象分析工具解决方案
频繁Full GCGC日志+MAT检查深堆>1MB的对象
PermGen OOMJVisualVM检查类加载器引用链
堆内存持续增长JProfiler对比两次内存快照
线程数暴涨jstack定位线程创建堆栈

操作指南


11. 高频面试问题与解答 #

Q1:浅堆和深堆的核心区别是什么? #

  • 浅堆测量对象自身内存占用(约几十字节)
  • 深堆计算对象被回收时释放的总内存(可能达GB级)
  • 示例:ArrayList浅堆约40字节,深堆包含所有元素内存

Q2:如何用MAT检测内存泄漏? #

  1. 打开支配树视图
  2. 按Retained Heap降序排序
  3. 检查可疑对象的GC Root路径
  4. 对比多个堆快照的增量变化

Q3:ThreadLocal为什么会导致内存泄漏? #

  • 线程池场景下,线程长期存活导致Entry无法回收
  • 必须显式调用remove()清理条目

Q4:WeakHashMap如何解决缓存泄漏? #

  • Key使用弱引用,不影响GC回收
  • 当Entry的Key被回收时,自动移除整个Entry
  • 需要保证Key是外部对象的唯一引用

全文总结
通过浅堆/深堆分析可精准定位内存问题,结合支配树和引用链分析能快速识别泄漏点。8种典型场景覆盖90%的泄漏情况,MAT等工具是解决问题的利器。建议开发者在以下场景主动检查内存:

  1. 使用全局集合时
  2. 涉及外部资源操作时
  3. 实现回调机制时
  4. 使用缓存组件时