前言
本文涉及如下知识点:
- HotSpot的垃圾处理机制
- HashMap为什么存在内存泄漏问题
- Java中的引用变量的存储位置
· 最近在研究弱引用的时候,注意到了Java在内存管理时的细节,在这里给大家分享一下。
JVM中垃圾处理的机制
· 垃圾处理的算法非常多,本博客只介绍使用最广的HotSpot虚拟机的垃圾回收算法。简单说来,其使用了可达性分析算法 ,通过选出一系列称为GC Roots的对象作为起点,当一个对象没有任何一条强引用链指向GC Roots时,则这个对象会被判定为可回收对象。弱引用不是本章讨论的重点,有兴趣的自行百度。
· 在上图中,object5、6、7会被回收。
· 显然,此算法最核心的部分在于GC Roots的选取,所以我们要关注在什么位置的对象才能成为GC Roots,在《深入理解JVM虚拟机》中给出了答案:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量对象
- 本地方法栈中JNI(Native方法)引用的对象
·这里不普及JVM的内存结构。
Java中引用类型的存储位置
· 介绍可达性算法主要是为了给我下面的想法做铺垫。
· 注意:以下的内容我没有从任何书籍和博客上看到明确的解答,所以仅是我个人的一些理解。
- Java中的引用到底存在哪里?
· 大部分人都认为,在java中,所有的引用都保存在栈中(虚拟机栈),而引用指向的对象实例则在堆里。我认为这是不准确的,如果一个对象实例中有另一个对象的引用,那么这个引用并非存在栈里,而是存在于堆中。
· 下面给出我测试用的一段代码: - 测试用类B:对象占用内存大小1MB
1 | public class B { |
- 测试用类A:占用内存大小2MB
1 | public class A { |
- 测试代码:为了避免不必要的干扰,我在创建实例前先进行了一次垃圾回收。
1 | public class GCTest { |
· 结果猜测:显然,引用a
是存在于栈中的,而a指向的实例A,以及实例A中创建的实例B存在于堆中。现在不确定的点在于:指向实例B的a.b
这个引用是存在于栈中,还是堆中呢?众所周知,栈中的数据会随着方法的结束而被释放,如果方法不结束,其数据就不会释放,因此我用while(true){...}
使main方法永远不会结束,且每5秒进行一次垃圾回收,看堆中的储存情况。
- 如果
a.b
保存在栈中,其就可作为GC Root存在,那么根据可达性算法,实例B永远不会被回收。 - 而如果
a.b
保存在堆中,一旦引用a
被释放了,那么实例A和实例B就会如上图中的object5、6、7
一样,被GC回收 - 根据以上两种理论,如果
a.b
保存在栈中,即使实例A被释放了,实例B也不会被释放,堆中至少有1MB的数据。而如果a.b
保存在堆中,实例A和实例B都会被释放,堆中的数据至少会小于1MB。
- 以下是程序的运行结果:堆内存中可粗略地细分为新生代和老年代,这里就不扩展了。
1 | [GC (System.gc()) [PSYoungGen: 3333K->744K(38400K)] 3333K->752K(125952K), 0.0042393 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] |
- 第一次GC时,堆中的内存被清理到只有673K,并且进入了老年代:
[ParOldGen: 8K->673K(87552K)]
。 - 第二次GC前,我们创建了对象a,在堆内存中占用了2MB:
[PSYoungGen: 2713K->2176K(38400K)]
(这里不做细微的误差分析,因为我也不清楚gc到底干了什么)。随后这2MB内存进入了老年代:[ParOldGen: 673K->2718K(87552K)]
- 第三次GC前,我们释放了实例A的链接,发现这2MB内存被释放了,老年代中仅剩原先的673K(可能有点误差):
[ParOldGen: 2718K->670K(87552K)]
- 第四次GC,因为堆中已经没有实例A、B所以此垃圾回收没有释放任何空间。
- **总上所述:我认为类成员变量的引用必然在堆内存中。**
HashMap的内存泄漏问题
· 有了上述问题的铺垫,我们可以正式聊一下HashMap中内存泄漏的问题, 因为之前看ThreadLocal的源码才意识到了这个问题,实在是惭愧,话不多说,我先解释一下内存泄漏的原因:
- 首先要明确的一点是,Java中的参数都是按值传递的,即使是引用,在传递时也会生成引用的副本。
- 因此,HashMap的对象引用
map
在put(key,value)
时,就会生成key,value引用的副本,我们姑且称为key'
和value'
。根据上面的理论,这个副本应该存在堆内存中。 - 当栈中的
key引用
被释放时,原来指向的实例KEY
(姑且这么称呼),与栈中的map引用
依然存在一条可达链:map —— HashMap实例 —— key’ —— KEY实例 这条强引用链。因此实例KEY不会被释放,虽然HashMap可以保存键为null的Entry,但是实例KEY我们就不会再用到了,而它迟迟不释放,就会造成内存泄漏的问题。
1 | public class A { |
- 代码如下
1 | public class GCTest { |
- 结果如下
1 | 0.-------------- |
- 结果分析:因为FullGC对线程的停顿时间比较长,未被回收的对象会进入老年代,而新生代中会被清0,因此为了方便,我们仅关注老年代的情况。
- 程序一开始先进行一次清理,避免不必要的干扰,老年代中有628K:
[ParOldGen: 8K->629K(87552K)]
。 - 随后,我们创建了HashMap实例,实例A和实例B,在这次的GC中,老年代中多了约2MB内存:
[ParOldGen: 629K->2676K(87552K)]
。 - 然后我们将实例A,B放进hashMap对象:
map.put(a,b)
,put操作会在堆内存中会有额外的开销(会新建一些对象用来进行put操作),因此新生代中会有新内存的使用(但很快就被清理掉了):[PSYoungGen: 1996K->32K(38400K)]
。我们通过观察老年代:[ParOldGen: 2676K->2675K(87552K)]
,发现实际上这个put操作并没有为HashMap开辟新的内存空间,因为map仅仅只是把内部的引用指向了实例A, B而已。 - 这一步我们将栈中的引用a释放:
a = null;
,我们希望看到实例A被回收,但是GC并没有这样做,老年代中的值几乎没有变化:[ParOldGen: 2675K->2677K(87552K)]
- 为了避免意外,令线程沉睡5秒后,再次清理,发现实例A仍在堆内存中:
[ParOldGen: 2677K->2718K(87552K)]
,因此可以判断这造成了一定的内存泄漏。 - 事实上,HashMap中的某一个特定key很难被清理掉,因为用map.clear()会清理掉整个map。但是HashMap的内存泄漏一般不会很严重,因为只有在极少数情况下,我们才想去手动释放一个key。
再次证明内存泄漏的代码
· 笔者不太会手动去真正释放HashMap的key,因此,我做了一个实验,先把类A关联上类B,但是不实例化这个B。
- 类A
1 | public class A { |
- 实验步骤
- 实例化A,B得
引用a,b
,并把a.b = b
- 令
b = null
, 再令a.b = null
,比较内存变化情况 - 再令
a = null
,查看内存
- 实验代码
1 | public class GCTest { |
- 结果如下
1 | 0.-------------- |
- 结果分析
- 程序一开始先进行一次清理,避免不必要的干扰,老年代中有673K:
[ParOldGen: 8K->673K(87552K)]
。 - 创建了实例A,B后,老年代中多了2MB:
[ParOldGen: 673K->2718K(87552K)]
- 将实例A与实例B关联
a.b = b
后,堆内存无变化:[ParOldGen: 2718K->2718K(87552K)]
- 令栈中的
引用b=null
,发现内存不释放:[ParOldGen: 2718K->2718K(87552K)]
,这与HashMap的内存泄漏完全一致 - 此时,我们释放掉
a.b
对实例B的引用a.b = null
,发现实例B被清理了:[ParOldGen: 2718K->1694K(87552K)]
,但是实例A仍然占用了1MB的堆内存。 - 随后我们再次释放
引用a = null
,发现此时实例A也被清理了:[ParOldGen: 1694K->670K(87552K)]
- 这个实验证明了:GC Root与实例B之间确实有强引用链,而这个强引用链是 类A成员变量类B的引用提供的
总结
· Java的内存管理机制还是很巧妙的,相比C++方便了很多,越往后面学发现这些底层的东西才是java的灵魂的所在啊。
交流
请联系邮箱:chenxingyu@bupt.edu.cn