您的位置:首页 > 其它

【JVM.2】垃圾收集器与内存分配策略

2018-05-03 16:36 162 查看

垃圾收集器需要完成的3件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?  

  在前一节中介绍了java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个帧栈中分配多少内存基本上是在类结构确定下来时已经确定下来时及已知,因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑回收问题,因为方法结束或者线程结束时,内存自然就跟着随着回收。而java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的主要是这部分内存。

 

一.对象已死吗?

  在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是确定这些对象之中那些还“存活”着,哪些已经‘死去’(即不可能再被任何途径使用的对象)。

1.  引用计数算法(Reference Counting)

  很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是没有再被使用的。

  客观说引用计数算法的实现简单,判断效率也高。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中主要原因是它很难解决对象之间相互循环引用的问题。

看个栗子:

package jvm.gc;

public class ReferenceCountingGC {
public Object instance = null;
private static final int _1M = 1024*1024;
private byte[] bigSize = new byte[2 * _1M]; //占点内存
public static void main(String[] args) {
ReferenceCountingGC obja = new ReferenceCountingGC();
ReferenceCountingGC objb = new ReferenceCountingGC();
obja.instance = objb;   //互相引用
objb.instance = obja;
obja = null;
objb = null;
//调用GC,查看obja,objb是否GC
System.gc();
}
}

运行结果:

[GC (System.gc()) [PSYoungGen: 7374K->776K(37888K)] 7374K->784K(123904K), 0.0344164 secs] [Times: user=0.00 sys=0.00, real=0.05 secs]
[Full GC (System.gc()) [PSYoungGen: 776K->0K(37888K)] [ParOldGen: 8K->662K(86016K)] 784K->662K(123904K), [Metaspace: 3491K->3491K(1056768K)], 0.0052179 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen      total 37888K, used 328K [0x00000000d6000000, 0x00000000d8a00000, 0x0000000100000000)
eden space 32768K, 1% used [0x00000000d6000000,0x00000000d6052030,0x00000000d8000000)
from space 5120K, 0% used [0x00000000d8000000,0x00000000d8000000,0x00000000d8500000)
to   space 5120K, 0% used [0x00000000d8500000,0x00000000d8500000,0x00000000d8a00000)
ParOldGen       total 86016K, used 662K [0x0000000082000000, 0x0000000087400000, 0x00000000d6000000)
object space 86016K, 0% used [0x0000000082000000,0x00000000820a5b50,0x0000000087400000)
Metaspace       used 3498K, capacity 4498K, committed 4864K, reserved 1056768K
class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

可以看出来,发生gc时,这两个相互引用的类还是被回收掉了。

 

2.  可达性分析算法(Reachhability Analysis)

  在主流的商业用程序语言(Java,C#...)的主流现实中,都是称通过可达性分析来判定对象是否存活的。

  这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始往下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

3.  再谈引用

  不管是引用计数算法还是可达性分析算法,判断对象是否存活都与“引用”有关。为了丰富垃圾收集的判断,Java对引用的概念进行了扩充,有如下四种:

  1.   强引用(Strong Reference):程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  2.   软引用(Soft Reference):软引用是用来描述一些还有用但并非必须的对象。当系统要发生内存溢出异常前,将会把这些对象列进回收范围之中进行第二次回收。回收后还没有足够内存才会抛出内出溢出异常。
  3.        弱引用(Weak Reference):软引用也是是用来描述一些还有用但并非必须的对象。当垃圾收集器工作时,无论当前内存是否够用,都会回收掉弱引用的对象。
  4.        虚引用(Phantom Reference):虚引用是最弱的一种引用关系。为一个对象设置虚引用关联的唯一目的技术要在这个对象被收集器回收时收到一个系统通知。

 

4.  生存还是死亡

  即使在可达性分析算法中不可达的对象,也并非“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程;

  垃圾收集器回收之前会判断对象是否覆盖finalize()方法,如果覆盖了就会执行一次,但是下次再可能回收时,不再会执行finalize()方法(说明finalize()只会执行一次)。

看个对象自救的例子:

package jvm;

public class FinalizeGC {
public static FinalizeGC finalizeGC = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
finalizeGC = this;
}

public static void main(String[] args) throws Exception{
finalizeGC = new FinalizeGC();
finalizeGC = null;
//第一次GC会进入重写的finalize中
System.gc();
//因为finalize方法优先级比较低,所以暂停0.5秒等待它
Thread.sleep(500);
if (finalizeGC == null) {
System.out.println("im dead");
} else {
System.out.println("im alive");
}

//与上面代码一样,第二次调用gc
finalizeGC = null;
System.gc();
//因为finalize方法优先级比较低,所以暂停0.5秒等待它
Thread.sleep(500);
if (finalizeGC == null) {
System.out.println("im dead");
} else {
System.out.println("im alive");
}
}

}

执行结果:

finalize method executed
im alive
im dead

执行结果发现,对象只自救了一次,这是因为任何一个对象的finalize()方法只会被系统自动调用一次。(并不鼓励大家使用这种方法来拯救对象。相反,建议大家尽量避免使用它)

 

二.垃圾收集算法

  由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法又各不相同,我们只简单介绍几种算法的思想及其发展过程。

1.  标记-清除算法(Mark-Sweep)

  这个算法执行分为两个阶段:首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。这是最基础的收集算法,之后介绍的收集算法都是对其不足进行改进。

  两个不足:一个效率问题,标记和清除两个过程效率都不高;另一个是空间问题,标记清楚后会产生大量不连续的内存碎片,碎片太多的话可能导致之后在程序运行时需要分配较大对象时,无法找到足够的空间来进行分配。从而提前触发另一次垃圾收集动作。

 

2.  复制算法(Copying)

  为了解决效率问题,复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  这种算法只要按顺序分配内存即可,实现简单,运行高效。但是代价是将内存缩小为了原来的一半,单价太高。

  之后IBM公司专门研究表名,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两个较小的Survivor空间,每次使用Eden和其中一个Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和Survivor的所有空间。默认Eden和Survivor的大小比例为8:1:1。

  当然,98%的对象可回收只是一般场景下的数据,我们没办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

最初复制算法图:

 

优化后复制算法:

 

 3.  标记-整理算法(Mark-Compact)

  复制收集算法在对象存活率较高的情况时,需要进行较多的复制操作,效率将会变的极低。并且所有对象都可能100%的存活的极端情况,所以老年代一般不能直接选用这种算法。

  根据老年代的特点,有人提出另一种“标记-整理”算法:首先标记所有需要回收的对象,之后让所有存活的对象都向一端移动,然后直接清理调端边界以外的内存,

如下图:

 

4.  分代收集算法(Generational Collection)

  当前商业虚拟机的垃圾收集都是采用“分代收集”算法,这种算法根据对象存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最时候的收集算法。

  在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的赋值成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记=清理” 或者 “标记-整理” 算法来进行回收。

 

三.HotSpot的算法实现

1.  枚举根节点

  可以作为GC Roots的节点主要在全局性的引用(例如常量或静态属性)与执行上下文(例如帧栈中的本地变量表)中。

  另外,可达性分析对执行时间的敏感度还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行--这里的“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况。

  虚拟机不需要检查完所有执行上下文和全局的引用位置,在HotSpot中,使用一组成为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在编译期间,就会在特定位置记录下栈和寄存器中哪些位置是应用。

 

2.  安全点

  在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但是OopMap内容变化指令非常多,如果为每一条指令都生成对应的OopMap,将会需要大量的额外空间。

  实际上,HotSpot也没有为每条指令生成OopMap,只是在“特定的位置”记录这些信息,这些位置成为“安全点(SafePoint)”,即程序需要执行时并非在所有地方都停顿下来GC,只要在安全点时才能暂停。

  还有一个需要考虑的问题,如何在GC发生时让所有线程都“跑”到最近的安全点上再停顿下来。有两个方案可供选择:抢先式中断和主动式中断。

 

3.  安全区域

  安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GV都是安全的。我们可以把Safe Refion看做是被扩展的Safepoint。

  为了解决线程属于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,走到安全点去中断挂起。

 

四.垃圾收集器

  如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

 

1.  Serial收集器

  Serial收集器是最基本、发展历史最悠久的收集器。大家看名字就知道这个收集器是单线程的收集器,“单线程”的意义并不仅仅说明它只会使用一个CPU或一天收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(我们称之为 Stop The World)。但是Stop The World带给用户不良的体验感。

 

 

2.  ParNew收集器

   ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余的控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。

 

注意:之后还会接触到几款并发和并行的收集器。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

 

3.  Parallel Scavenge收集器

   Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

   他的一个特点就是 达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。

  GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。

 

4.  CMS收集器(Concurrent Mark Sweep)

  CMS收集器是一种以获取最短回收停顿时间为目标的收集器。

  它的运行过程分为4个步骤:

  1.初始标记(initial mark):初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

  2.并发标记(concurrent mark):并发标记阶段就是进行GC Roots Tracing的过程。

  3.重新标记(remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

  4.并发清除(concurrent sweep):

  其中,第1步和第3步仍然需要“Stop The World”。由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。

  从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

  CMS是一款优秀的收集器,主要优点:并发收集、低停顿。

  缺点:   CMS收集器对CPU资源非常敏感。(虽不会导致用户线程停顿,但是会占用一部分线程而导致应用程序变慢,总吞吐会降低)

        CMS收集器无法处理浮动垃圾。(因为是并发收集,所以伴随程序运行会有新的垃圾不断产生)

        因为是使用“标记-清除”算法,所以在收集结束时会有大量空间碎片产生。

 

5.  G1收集器(Garbage-First)

  G1收集器是当今收集器技术发展的最前沿的成果之一。

  G1的特点:   1.并行与并发、2.分代收集、3.空间整合、4.可预测的停顿

  G1把Java堆划分为多个大小相等的独立区域(Region),虽然保留的新生代和老年代的概念,但不再物理隔离。G1会跟踪各个Region里面的垃圾回收价值大小(回收空间以及回收所需时间的经验值),在后台维护一个优先列表,根据回收价值优先回收。

  

 

五.内存分配与回收策略

  接下来讲解几条普遍的内存分配规则,并通过代码去验证这些规则。

1.  对象优先在Eden分配

  大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

直接上代码:

package jvm.gc;

/**
*              堆20M   堆20M  新生代10M 打印GC日志          Eden和Survivor比例8:1
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public class testAllocation {
private static final int _1M = 1024*1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1M];
allocation2 = new byte[2 * _1M];
allocation3 = new byte[2 * _1M];
allocation4 = new byte[4 * _1M];//出现一次Minor GC
}
}

打印结果:

[GC (Allocation Failure) [PSYoungGen: 6316K->832K(9216K)] 6316K->4936K(19456K), 0.0448287 secs] [Times: user=0.01 sys=0.00, real=0.05 secs]
Heap
PSYoungGen      total 9216K, used 7213K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 77% used [0x00000000ff600000,0x00000000ffc3b5e8,0x00000000ffe00000)
from space 1024K, 81% used [0x00000000ffe00000,0x00000000ffed0030,0x00000000fff00000)
to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen       total 10240K, used 4104K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 40% used [0x00000000fec00000,0x00000000ff002020,0x00000000ff600000)
Metaspace       used 3497K, capacity 4498K, committed 4864K, reserved 1056768K
class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

 

2.  大对象直接进入老年代

  所谓大对象就是需要大量连续内存空间的Java对象。大对象对于内存分配来说是个坏消息(比之更坏的就是遇到一群“朝生夕灭”的“短命大对象”,写程序应该尽量避免) 

看个例子:

/**
*              堆20M   堆20M  新生代10M 打印GC日志        Eden和Survivor比例8:1  新生代最大文件阀值: 3145728k
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
*/
public class PretenureSizeThreshold {
private static final int _1M = 1024*1024;
public static void main(String[] args) {
//被分配至老年代
byte[] threshold = new byte[4 * _1M];
}
}

运行结果:

Heap
PSYoungGen      total 9216K, used 6314K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 77% used [0x00000000ff600000,0x00000000ffc2aa00,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
Metaspace       used 3337K, capacity 4496K, committed 4864K, reserved 1056768K
class space    used 370K, capacity 388K, committed 512K, reserved 1048576K

看的出来,我这里的运行结果与理论不符合,虽然设置的新生代最大阀值,但内存还是存在了新生代。

值得注意的是:-XX:PretenureSizeThreshold参数只对Serial和ParNew两款收集器有用。

插播一个查看虚拟机使用的垃圾回收器的方法:
windows: java -XX:+PrintFlagsFinal -version |FINDSTR /i ":"
Linux:java -XX:+PrintFlagsFinal -version | grep :

 

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

  虚拟机会给每个对象定义一个年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,将被移到Survivor空间中,并且年龄为1。对象每熬过一次Minor GC年龄增长1岁,当年龄到达一定程度时(默认15岁),将会晋升到老年代中。下面来个例子:

/**
*              堆20M   堆20M  新生代10M 打印GC日志        Eden和Survivor比例8:1  设置1岁算成年
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
*/
public class TenuringThreshold {
private static final int _1M = 1024*1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1M / 4];
//什么年龄进入老年代取决于XX:XX:MaxTenuringThreshold设置
allocation2 = new byte[4 * _1M];
allocation3 = new byte[4 * _1M];
allocation3 = null;
allocation3 = new byte[4 * _1M];
}
}

打印结果:

Heap
PSYoungGen      total 9216K, used 6608K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 80% used [0x00000000ff600000,0x00000000ffc74288,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
Metaspace       used 3440K, capacity 4496K, committed 4864K, reserved 1056768K
class space    used 380K, capacity 388K, committed 512K, reserved 1048576K

为什么新生代使用了6608k???

我之后空文件执行了一次发现:

Heap
PSYoungGen      total 9216K, used 2384K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 29% used [0x00000000ff600000,0x00000000ff854268,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
Metaspace       used 3496K, capacity 4498K, committed 4864K, reserved 1056768K
class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

什么代码都没有的情况下就占用了2384k,我觉得可能是系统本地类占用的内存,(但是不是有方法区吗?如果有大神知道为什么,求指教!!!)。

以 -XX:MaxTenuringThreshold=15 来执行。

 

发现一个JVM参数设置的博客,可以参考该博客中的介绍。

 

  

 

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: