随笔33 内存泄漏问题和垃圾回收机制

时间:2021-09-05 20:56:27
① 内存泄露(Memory Leak)是指一个不再被使用的对象或者变量还在内存中占有存储空间。
在C/C++语言中,内存泄露出现在开发人员忘记释放已分配的内存就会造成内存泄露。
java语言中引入垃圾回收机制,有GC负责进行回收不再使用的对象,释放内存。但是还是会存在内存泄露的问题内存泄露主要有两种情况
  • 1.在堆中申请的空间没有释放
  • 2.对象已不再被使用(注意:这里的不在被使用是指对程序来说没有用处,如数据库连接使用后没有关。但是还是存在着引用),但是仍然在内存中保留着GC机制的引入只能解决第一种情况,对于第2种情况无法保证不再使用的对象会被释放。java语言中的内存泄露主要指第2种情况。

内存泄露的原因

  • 1.静态集合类。如HashMap和Vector。这些容器是静态的,生命周期和程序的生命周期一致,那么在容器中对象的生命周期也和其一样,对象在程序结束之前将不能被释放,从而会造成内存泄露。
  • 2.各种连接如数据库连接,网络连接,IO连接,不再使用时如果连接不释放容易造成内存泄露。
  • 3.监听器,释放对象时往往没有相应的删除监听器,可能会导致内存泄露。

内存溢出(OOM)是指程序在申请内存时没有足够的内存供使用,进而导致程序崩溃这是结果描述。

内存泄露(Memory Leak)最终会导致内存溢出。

② 垃圾回收机制。

垃圾回收主要针对的是堆区的回收,因为栈区的内存是随着线程而释放的
堆区分为三个区:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法区)

年轻代:对象被创建时(new)的对象通常被放在Young(除了一些占据内存比较大的对象),经过一定的Minor GC(针对年轻代的内存回收)还活着的对象会被移动到年老代(一些具体的移动细节省略)。

年老代:就是上述年轻代移动过来的和一些比较大的对象。Minor GC(FullGC)是针对年老代的回收

永久代存储的是final常量,static变量,常量池

str3,str4都是直接new的对象,而substring的源代码其实也是new一个string对象返回,如下图:

随笔33 内存泄漏问题和垃圾回收机制

经过fullgc之后,年老区的内存回收,而年轻区的占了15个,不算PermGen。

 

1.范围:要回收哪些区域?

Java方法栈、本地方法栈以及PC计数器随方法或线程的结束而自然被回收,所以这些区域不需要考虑回收问题。
Java堆和方法区是GC回收的重点区域,因为一个接口的多个实现类需要的内存不一样,一个方法的多个分支需要的内存可能也不一样,而这两个区域又对立于栈可能随时都会有对象不再被引用,因此这部分内存的分配和回收都是动态的。

2.前提:如何判断对象已死?

(1)引用计数法

引用计数法就是通过一个计数器记录该对象被引用的次数,方法简单高效,但是解决不了循环引用的问题。比如对象A包含指向对象B的引用,对象B也包含指向对象A的引用,但没有引用指向A和B,这时当前回收如果采用的是引用计数法,那么对象A和B的被引用次数都为1,都不会被回收。

下面是循环引用的例子,在Hotspot JVM下可以被正常回收,可以证实JVM采用的不是简单的引用计数法。通过-XX:+PrintGCDetails输出GC日志
 1 package com.cdai.jvm.gc;
 2 public class ReferenceCount {
 3     final static int MB = 1024 * 1024;
 4     byte[] size = new byte[2 * MB];
 5     Object ref;
 6     public static void main(String[] args) {
 7         ReferenceCount objA = new ReferenceCount();
 8         ReferenceCount objB = new ReferenceCount();
 9         objA.ref = objB;
10         objB.ref = objA;
11         objA = null;
12         objB = null;
13         System.gc();
14         System.gc();
15     }
16 }
17 [Full GC (System) [Tenured: 2048K->366K(10944K), 0.0046272 secs] 4604K->366K(15872K), [Perm : 154K->154K(12288K)], 0.0046751 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]

(2)根搜索

通过选取一些根对象作为起始点,开始向下搜索,如果一个对象到根对象不可达时,则说明此对象已经没有被引用,是可以被回收的。
可以作为根的对象有:栈中变量引用的对象,类静态属性引用的对象,常量引用的对象等

因为每个线程都有一个栈,所以我们需要选取多个根对象

【附】:对象复活 

在根搜索中得到的不可达对象并不是立即就被标记成可回收的,而是先进行一次标记放入F-Queue等待执行对象的finalize()方法,
执行后GC将进行二次标记,复活的对象之后将不会被回收。因此,使对象复活的唯一办法就是重写finalize()方法,并使对象重新被引用
 1 package com.cdai.jvm.gc;
 2 public class DeadToRebirth {
 3     private static DeadToRebirth hook;
 4     @Override
 5     public void finalize() throws Throwable {
 6         super.finalize();
 7         DeadToRebirth.hook = this;
 8     }
 9     public static void main(String[] args) throws Exception {
10         DeadToRebirth.hook = new DeadToRebirth();
11         DeadToRebirth.hook = null;
12         System.gc();
13         Thread.sleep(500);
14         if (DeadToRebirth.hook != null)
15             System.out.println("Rebirth!");
16         else
17             System.out.println("Dead!");
18         DeadToRebirth.hook = null;
19         System.gc();
20         Thread.sleep(500);
21         if (DeadToRebirth.hook != null)
22         Systemystem.out.println("Rebirth!");
23         else
24             System.out.println("Dead!");
25     }
26 }

要注意的两点是:

第一,finalize()方法只会被执行一次,所以对象只有一次复活的机会

第二,执行GC后,要停顿半秒等待优先级很低的finalize()执行完毕

3.策略:垃圾回收的算法

(1)标记-清除

没错,这里的标记指的就是之前我们介绍过的两次标记过程。标记完成后就可以对标记为垃圾的对象进行回收了。怎么样,简单吧。但是这种策略的缺点很明显,回收后内存碎片很多,如果之后程序运行时申请大内存,可能会又导致一次GC。虽然缺点明显,这种策略却是后两种策略的基础。正因为它的缺点,所以促成了后两种策略的产生。

(2)标记-复制

将内存分为两块,标记完成开始回收时,将一块内存中保留的对象全部复制到另一块空闲内存中。实现起来也很简单,当大部分对象都被回收时这种策略也很高效。但这种策略也有缺点,可用内存变为一半了

怎样解决呢?聪明的程序员们总是办法多过问题的。可以将堆不按1:1的比例分离,

而是按8:1:1分成一块Eden和两小块Survivor区,每次将Eden和Survivor中存活的对象复制到另一块空闲的Survivor中这三块区域并不是堆的全部,而是构成了新生代

为什么不是全部呢?如果回收时,空闲的那一小块Survivor不够用了怎么办?这就是老年代的用处。当不够用时,这些对象将直接通过分配担保机制进入老年代。那么老年代也使用标记-复制策略吧?当然不行!老年代中的对象可不像新生代中的,每次回收都会清除掉大部分。如果贸然采用复制的策略,老年代的回收效率可想而知。

(3)标记-整理

根据老年代的特点,采用回收掉垃圾对象后对内存进行整理的策略再合适不过,将所有存活下来的对象都向一端移动
 
4.实现:虚拟机中的收集器
(1)新生代上的GC实现

Serial单线程的收集器,只使用一个线程进行收集,并且收集时会暂停其他所有工作线程(Stop the world)。它是Client模式下的默认新生代收集器。

ParNewSerial收集器的多线程版本。在单CPU甚至两个CPU的环境下,由于线程交互的开销,无法保证性能超越Serial收集器。

Parallel Scavenge:也是多线程收集器,与ParNew的区别是:
它是吞吐量优先收集器。吞吐量=运行用户代码时间/(运行用户代码+垃圾收集时间)。
另一点区别是配置-XX:+UseAdaptiveSizePolicy后,虚拟机会自动调整Eden/Survivor等参数来提供用户所需的吞吐量。我们需要配置的就是内存大小-Xmx和吞吐量GCTimeRatio。

(2)老年代上的GC实现

Serial Old:Serial收集器的老年代版本。

Parallel Old:Parallel Scavenge的老年代版本。此前,如果新生代采用PS GC的话,老年代只有Serial Old能与之配合。现在有了Parallel Old与之配合,可以在注重吞吐量及CPU资源敏感的场合使用了。

CMS:采用的是标记-清除而非标记-整理,是一款并发低停顿的收集器。但是由于采用标记-清除,内存碎片问题不可避免。可以使用-XX:CMSFullGCsBeforeCompaction设置执行几次CMS回收后,跟着来一次内存碎片整理。

5.触发:何时开始GC?

Minor GC(新生代回收)的触发条件比较简单,Eden空间不足就开始进行Minor GC回收新生代。
而Full GC(老年代回收,一般伴随一次Minor GC)则有几种触发条件

(1)老年代空间不足

(2)PermSpace空间不足

(3)统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间

这里注意一点:PermSpace并不等同于方法区,只不过是Hotspot JVM用PermSpace来实现方法区而已,有些虚拟机没有PermSpace而用其他机制来实现方法区。

6.补充:对象的空间分配和晋升

(1)对象优先在Eden上分配

(2)大对象直接进入老年代

虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个参数值的对象将直接分配到老年代中。因为新生代采用的是标记-复制策略,在Eden中分配大对象将会导致Eden区和两个Survivor区之间大量的内存拷贝。

(3)长期存活的对象将进入老年代

对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会晋升到老年代中。
 
两个概念:新生代和年老代
新生代:初始对象,生命周期短的
永久代:长时间存在的对象
整个java的垃圾回收是新生代和年老代的协作,这种叫做分代回收
P.S:
Serial New收集器是针对新生代的收集器,采用的是复制算法
Parallel New(并行)收集器,新生代采用复制算法,老年代采用标记整理
Parallel Scavenge(并行)收集器,针对新生代,采用复制收集算法
Serial Old(串行)收集器,新生代采用复制,老年代采用标记整理
Parallel Old(并行)收集器,针对老年代,标记整理
CMS收集器,基于标记清理
G1收集器:整体上是基于标记 整理 ,局部采用复制
 
综上:新生代基本采用复制算法,老年代采用标记整理算法。cms采用标记清理。

 ============================================================================================================================================================================================================================================

 

堆内存设置

 

原理

 

JVM堆内存分为2块:Permanent Space 和 Heap Space。

 

  • Permanent 即 持久代(Permanent Generation),主要存放的是Java类定义信息,与垃圾收集器要收集的Java对象关系不大。
  • Heap = { Old + NEW = {Eden, from, to} },Old 即 年老代(Old Generation),New 即 年轻代(Young Generation)。年老代和年轻代的划分对垃圾收集影响比较大。

 

年轻代

所有新生成的对象首先都是放在年轻代。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代一般分3个区,1个Eden区,2个Survivor区(from 和 to)。

大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当一个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当另一个Survivor区也满了的时候,从前一个Survivor区复制过来的并且此时还存活的对象,将可能被复制到年老代。

2个Survivor区是对称的,没有先后关系,所以同一个Survivor区中可能同时存在从Eden区复制过来对象,和从另一个Survivor区复制过来的对象;而复制到年老区的只有从另一个Survivor区过来的对象。而且,因为需要交换的原因,Survivor区至少有一个是空的。特殊的情况下,根据程序需要,Survivor区是可以配置为多个的(多于2个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

针对年轻代的垃圾回收即 Young GC。

年老代

在年轻代中经历了N次(可配置)垃圾回收后仍然存活的对象,就会被复制到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

针对年老代的垃圾回收即 Full GC。

持久代

用于存放静态类型数据,如 Java Class, Method 等。持久代对垃圾回收没有显著影响。但是有些应用可能动态生成或调用一些Class,例如 Hibernate CGLib 等,在这种时候往往需要设置一个比较大的持久代空间来存放这些运行过程中动态增加的类型

所以,当一组对象生成时,内存申请过程如下:

  1. JVM会试图为相关Java对象在年轻代的Eden区中初始化一块内存区域。
  2. 当Eden区空间足够时,内存申请结束。否则执行下一步。
  3. JVM试图释放在Eden区中所有不活跃的对象(Young GC)。释放后若Eden空间仍然不足以放入新对象,JVM则试图将部分Eden区中活跃对象放入Survivor区。
  4. Survivor区被用来作为Eden区及年老代的中间交换区域。当年老代空间足够时,Survivor区中存活了一定次数的对象会被移到年老代。
  5. 当年老代空间不够时,JVM会在年老代进行完全的垃圾回收(Full GC)。
  6. Full GC后,若Survivor区及年老代仍然无法存放从Eden区复制过来的对象,则会导致JVM无法在Eden区为新生成的对象申请内存,即出现“Out of Memory”。

OOM(“Out of Memory”)异常一般主要有如下2种原因

 

1. 年老代溢出,表现为:java.lang.OutOfMemoryError:Javaheapspace

 

这是最常见的情况,产生的原因可能是:设置的内存参数Xmx过小或程序的内存泄露及使用不当问题。

 

例如循环上万次的字符串处理、创建上千万个对象、在一段代码内申请上百M甚至上G的内存。还有的时候虽然不会报内存溢出,却会使系统不间断的垃圾回收,也无法处理其它请求。这种情况下除了检查程序、打印堆内存等方法排查,还可以借助一些内存分析工具,比如MAT就很不错。

 


2. 持久代溢出,表现为:java.lang.OutOfMemoryError:PermGenspace

 

通常由于持久代设置过小,动态加载了大量Java类而导致溢出,解决办法唯有将参数 -XX:MaxPermSize 调大(一般256m能满足绝大多数应用程序需求)。将部分Java类放到容器共享区(例如Tomcat share lib)去加载的办法也是一个思路,但前提是容器里部署了多个应用,且这些应用有大量的共享类库。