HashMap为什么会出现内存泄漏问题 以及 Java中引用类型的存储位置

0. 前言

·  最近在研究弱引用的时候,注意到了Java在内存管理时的细节,在这里给大家分享一下。

1. JVM中垃圾处理的机制

·  垃圾处理的算法非常多,本博客只介绍使用最广的Hotspot虚拟机的垃圾回收算法。简单说来,其使用了可达性分析算法 ,通过选出一系列称为GC Roots的对象作为起点,当一个对象没有任何一条强引用链指向GC Roots时,则这个对象会被判定为可回收对象。弱引用不是本章讨论的重点,有兴趣的自行百度。
图1-1
·  在上图中,object5、6、7会被回收。
·  显然,此算法最核心的部分在于GC Roots的选取,所以我们要关注在什么位置的对象才能成为GC Roots,在《深入理解JVM虚拟机》中给出了答案:

  1. 虚拟机栈中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中的常量对象
  4. 本地方法栈中JNI(Native方法)引用的对象

·这里不普及JVM的内存结构。

2. Java中引用类型的存储位置

·  介绍可达性算法主要是为了给我下面的想法做铺垫。
·  注意:以下的内容我没有从任何书籍和博客上看到明确的解答,所以仅是我个人的一些理解。

  • Java中的引用到底存在哪里?
    ·  大部分人都认为,在java中,所有的引用都保存在栈中(虚拟机栈),而引用指向的对象实例则在堆里。我认为这是不准确的,如果一个对象实例中有另一个对象的引用,那么这个引用并非存在栈里,而是存在于堆中
    ·  下面给出我测试用的一段代码:
  • 测试用类B:对象占用内存大小1MB
public class B {
    byte[] b = new byte[1024 * 1024];
}
  • 测试用类A:占用内存大小2MB
public class A {
    public B b = new B();
    byte[] a = new byte[1024 * 1024];
}
  • 测试代码:为了避免不必要的干扰,我在创建实例前先进行了一次垃圾回收。
public class GCTest {
    public static void main(String[] args) {
        System.gc();
        System.out.println("--------------");
        A a = new A();
        System.gc();
        System.out.println("--------------");
        a = null;
        System.gc();
        System.out.println("--------------");
        while(true){
            try {
                Thread.sleep(5000);
                System.gc();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

·  结果猜测:显然,引用a是存在于栈中的,而a指向的实例A,以及实例A中创建的实例B存在于堆中。现在不确定的点在于:指向实例B的a.b这个引用是存在于栈中,还是堆中呢?众所周知,栈中的数据会随着方法的结束而被释放,如果方法不结束,其数据就不会释放,因此我用while(true){...}使main方法永远不会结束,且每5秒进行一次垃圾回收,看堆中的储存情况。

  1. 如果a.b保存在栈中,其就可作为GC Root存在,那么根据可达性算法,实例B永远不会被回收。
  2. 而如果a.b保存在堆中,一旦引用a被释放了,那么实例A和实例B就会如上图中的object5、6、7一样,被GC回收
  3. 根据以上两种理论,如果a.b保存在栈中,即使实例A被释放了,实例B也不会被释放,堆中至少有1MB的数据。而如果a.b保存在堆中,实例A和实例B都会被释放,堆中的数据至少会小于1MB。
  • 以下是程序的运行结果:堆内存中可粗略地细分为新生代和老年代,这里就不扩展了。
[GC (System.gc()) [PSYoungGen: 3333K->744K(38400K)] 3333K->752K(125952K), 0.0042393 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 744K->0K(38400K)] [ParOldGen: 8K->673K(87552K)] 752K->673K(125952K), [Metaspace: 3479K->3479K(1056768K)], 0.0078916 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
--------------
[GC (System.gc()) [PSYoungGen: 2713K->2176K(38400K)] 3386K->2849K(125952K), 0.0010460 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 2176K->0K(38400K)] [ParOldGen: 673K->2718K(87552K)] 2849K->2718K(125952K), [Metaspace: 3485K->3485K(1056768K)], 0.0079493 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
--------------
[GC (System.gc()) [PSYoungGen: 665K->64K(38400K)] 3384K->2782K(125952K), 0.0003627 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 64K->0K(38400K)] [ParOldGen: 2718K->670K(87552K)] 2782K->670K(125952K), [Metaspace: 3485K->3485K(1056768K)], 0.0039402 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
--------------
[GC (System.gc()) [PSYoungGen: 0K->0K(38400K)] 670K->670K(125952K), 0.0003178 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 0K->0K(38400K)] [ParOldGen: 670K->669K(87552K)] 670K->669K(125952K), [Metaspace: 3485K->3485K(1056768K)], 0.0066122 secs] [Times: user=0.05 sys=0.00, real=0.01 secs] 
  1. 第一次GC时,堆中的内存被清理到只有673K,并且进入了老年代:[ParOldGen: 8K->673K(87552K)]
  2. 第二次GC前,我们创建了对象a,在堆内存中占用了2MB:[PSYoungGen: 2713K->2176K(38400K)](这里不做细微的误差分析,因为我也不清楚gc到底干了什么 )。随后这2MB内存进入了老年代:[ParOldGen: 673K->2718K(87552K)]
  3. 第三次GC前,我们释放了实例A的链接,发现这2MB内存被释放了,老年代中仅剩原先的673K(可能有点误差):[ParOldGen: 2718K->670K(87552K)]
  4. 第四次GC,因为堆中已经没有实例A、B所以此垃圾回收没有释放任何空间。
  5. 总上所述:我认为类成员变量的引用必然在堆内存中。

3. HashMap的内存泄漏问题

·  有了上述问题的铺垫,我们可以正式聊一下HashMap中内存泄漏的问题, 因为之前看ThreadLocal的源码才意识到了这个问题,实在是惭愧,话不多说,我先解释一下内存泄漏的原因:

  1. 首先要明确的一点是,Java中的参数都是按值传递的,即使是引用,在传递时也会生成引用的副本。
  2. 因此,HashMap的对象引用mapput(key,value)时,就会生成key,value引用的副本,我们姑且称为key'value'。根据上面的理论,这个副本应该存在堆内存中。
  3. 当栈中的key引用被释放时,原来指向的实例KEY(姑且这么称呼),与栈中的map引用依然存在一条可达链:map ---- HashMap实例 ---- key’ ---- KEY实例 这条强引用链。因此实例KEY不会被释放,虽然HashMap可以保存键为null的Entry,但是实例KEY我们就不会再用到了,而它迟迟不释放,就会造成内存泄漏的问题。
  • 图示说明:可以很明显看出来KEYVALUE有两条强引用链
    在这里插入图片描述

3.1 代码验证

我们将类A稍作修改,让其不关联类B:

  • 类A
public class A {
//    public B b = new B();
    byte[] a = new byte[1024 * 1024];
}
  • 代码如下
public class GCTest {
    public static void main(String[] args) {
        System.out.println("0.--------------");
        System.gc();
        System.out.println("1.--------------");
        Map<A, B> map = new HashMap<>();
        A a = new A();
        B b = new B();
        System.gc();
        System.out.println("2.--------------");
        map.put(a,b);
        System.gc();
        System.out.println("3.--------------");
        a = null;
        System.gc();
        System.out.println("4.--------------");
        while(true){
            try {
                Thread.sleep(5000);
                System.gc();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 结果如下
0.--------------
[GC (System.gc()) [PSYoungGen: 3333K->712K(38400K)] 3333K->720K(125952K), 0.0016227 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 712K->0K(38400K)] [ParOldGen: 8K->629K(87552K)] 720K->629K(125952K), [Metaspace: 3206K->3206K(1056768K)], 0.0074770 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
1.--------------
[GC (System.gc()) [PSYoungGen: 3379K->2176K(38400K)] 4008K->2805K(125952K), 0.0012001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 2176K->0K(38400K)] [ParOldGen: 629K->2676K(87552K)] 2805K->2676K(125952K), [Metaspace: 3226K->3226K(1056768K)], 0.0076292 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
2.--------------
[GC (System.gc()) [PSYoungGen: 1996K->32K(38400K)] 4672K->2708K(125952K), 0.0005201 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 2676K->2675K(87552K)] 2708K->2675K(125952K), [Metaspace: 3226K->3226K(1056768K)], 0.0076530 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
3.--------------
[GC (System.gc()) [PSYoungGen: 1331K->96K(38400K)] 4007K->2771K(125952K), 0.0003721 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 96K->0K(38400K)] [ParOldGen: 2675K->2677K(87552K)] 2771K->2677K(125952K), [Metaspace: 3229K->3229K(1056768K)], 0.0027659 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
4.--------------
[GC (System.gc()) [PSYoungGen: 1331K->160K(38400K)] 4009K->2837K(125952K), 0.0005946 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 160K->0K(38400K)] [ParOldGen: 2677K->2718K(87552K)] 2837K->2718K(125952K), [Metaspace: 3488K->3488K(1056768K)], 0.0065556 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  • 结果分析:因为FullGC对线程的停顿时间比较长,未被回收的对象会进入老年代,而新生代中会被清0,因此为了方便,我们仅关注老年代的情况
  1. 程序一开始先进行一次清理,避免不必要的干扰,老年代中有628K:[ParOldGen: 8K->629K(87552K)]
  2. 随后,我们创建了HashMap实例,实例A和实例B,在这次的GC中,老年代中多了约2MB内存:[ParOldGen: 629K->2676K(87552K)]
  3. 然后我们将实例A,B放进hashMap对象:map.put(a,b),put操作会在堆内存中会有额外的开销(会新建一些对象用来进行put操作),因此新生代中会有新内存的使用(但很快就被清理掉了):[PSYoungGen: 1996K->32K(38400K)]。我们通过观察老年代:[ParOldGen: 2676K->2675K(87552K)],发现实际上这个put操作并没有为HashMap开辟新的内存空间,因为map仅仅只是把内部的引用指向了实例A, B而已。
  4. 这一步我们将栈中的引用a释放:a = null;,我们希望看到实例A被回收,但是GC并没有这样做,老年代中的值几乎没有变化:[ParOldGen: 2675K->2677K(87552K)]
  5. 为了避免意外,令线程沉睡5秒后,再次清理,发现实例A仍在堆内存中:[ParOldGen: 2677K->2718K(87552K)],因此可以判断这造成了一定的内存泄漏。
  6. 事实上,HashMap中的某一个特定key很难被清理掉,因为用map.clear()会清理掉整个map。但是HashMap的内存泄漏一般不会很严重,因为只有在极少数情况下,我们才想去手动释放一个key。

3.2 再次证明内存泄漏的代码

·  笔者不太会手动去真正释放HashMap的key,因此,我做了一个实验,先把类A关联上类B,但是不实例化这个B。

  • 类A
public class A {
    public B b;
    byte[] a = new byte[1024 * 1024];
}
  • 实验步骤
  1. 实例化A,B得引用a,b,并把a.b = b
  2. b = null, 再令a.b = null,比较内存变化情况
  3. 再令 a = null,查看内存
  • 实验代码
public class GCTest {
    public static void main(String[] args) {
        System.out.println("0.--------------");
        System.gc();
        System.out.println("1.--------------");
        A a = new A();
        B b = new B();
        System.gc();
        System.out.println("2.--------------");
        a.b = b;
        System.gc();
        System.out.println("3.--------------");
        b = null;
        System.gc();
        System.out.println("4.--------------");
        a.b = null;
        System.gc();
        System.out.println("5.--------------");
        a = null;
        System.gc();
        System.out.println("--------------");
        while(true){
            try {
                Thread.sleep(5000);
                System.gc();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 结果如下
0.--------------
[GC (System.gc()) [PSYoungGen: 3333K->776K(38400K)] 3333K->784K(125952K), 0.0013798 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 776K->0K(38400K)] [ParOldGen: 8K->673K(87552K)] 784K->673K(125952K), [Metaspace: 3482K->3482K(1056768K)], 0.0198551 secs] [Times: user=0.05 sys=0.00, real=0.02 secs] 
1.--------------
[GC (System.gc()) [PSYoungGen: 2713K->2144K(38400K)] 3387K->2817K(125952K), 0.0047306 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 2144K->0K(38400K)] [ParOldGen: 673K->2718K(87552K)] 2817K->2718K(125952K), [Metaspace: 3487K->3487K(1056768K)], 0.0091880 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
2.--------------
[GC (System.gc()) [PSYoungGen: 1331K->32K(38400K)] 4050K->2750K(125952K), 0.0003588 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 2718K->2718K(87552K)] 2750K->2718K(125952K), [Metaspace: 3487K->3487K(1056768K)], 0.0080175 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
3.--------------
[GC (System.gc()) [PSYoungGen: 665K->32K(38400K)] 3383K->2750K(125952K), 0.0004018 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 2718K->2718K(87552K)] 2750K->2718K(125952K), [Metaspace: 3487K->3487K(1056768K)], 0.0037897 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
4.--------------
[GC (System.gc()) [PSYoungGen: 665K->32K(38400K)] 3383K->2750K(125952K), 0.0005433 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 2718K->1694K(87552K)] 2750K->1694K(125952K), [Metaspace: 3487K->3487K(1056768K)], 0.0051309 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
5.--------------
[GC (System.gc()) [PSYoungGen: 665K->32K(38400K)] 2359K->1726K(125952K), 0.0045968 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 1694K->670K(87552K)] 1726K->670K(125952K), [Metaspace: 3488K->3488K(1056768K)], 0.0044260 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
--------------
[GC (System.gc()) [PSYoungGen: 665K->32K(38400K)] 1335K->702K(125952K), 0.0003409 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 670K->670K(87552K)] 702K->670K(125952K), [Metaspace: 3488K->3488K(1056768K)], 0.0030132 secs] [Times: user=0.05 sys=0.00, real=0.00 secs] 

  • 结果分析
  1. 程序一开始先进行一次清理,避免不必要的干扰,老年代中有673K:[ParOldGen: 8K->673K(87552K)]
  2. 创建了实例A,B后,老年代中多了2MB:[ParOldGen: 673K->2718K(87552K)]
  3. 将实例A与实例B关联a.b = b后,堆内存无变化:[ParOldGen: 2718K->2718K(87552K)]
  4. 令栈中的引用b=null,发现内存不释放:[ParOldGen: 2718K->2718K(87552K)],这与HashMap的内存泄漏完全一致
  5. 此时,我们释放掉a.b对实例B的引用a.b = null,发现实例B被清理了:[ParOldGen: 2718K->1694K(87552K)],但是实例A仍然占用了1MB的堆内存。
  6. 随后我们再次释放引用a = null,发现此时实例A也被清理了:[ParOldGen: 1694K->670K(87552K)]
  7. 这个实验证明了:GC Root与实例B之间确实有强引用链,而这个强引用链是 类A成员变量类B的引用提供的

4. 总结

·  Java的内存管理机制还是很巧妙的,相比C++方便了很多,越往后面学发现这些底层的东西才是java的灵魂的所在啊。

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值