深入理解JVM(第三章 垃圾回收器与内存分配策略)

学习《深入理解JVM》做的笔记,有的用自己的话记录的,如果不准确请参考原文。

一,概述
在虚拟机内存的分配与回收中,虚拟机栈,本地方法栈和程序计数器的生命周期与线程相同。栈中的栈帧会随着方法的进入和完成而入栈出栈。分配的空间也在类结构确定下来后(即编译期)就知道了,所以这些区域不需要考虑垃圾回收的问题,因为方法或者线程结束内存也随着回收。而java方法区和堆不一样。一个接口的不同实现类内存不一样,方法里不同分支的内存也不一样。所以只有在运行时才能确定会创建哪些对象,这些对象的内存是多少。这部分的内存的分配和回收都是动态的,所以垃圾回收主要是针对这部分区域进行。

二,对象已死吗
在垃圾回收器回收之前需要确定对象是活着还是死了(不在被任何途径调用)。
1. 使用判断对象是否死了的方法有:
 a>.引用计数算法:给对象添加一个引用计数器,当对象被引用时,计数器加一。当引用实效时,计数器减一。当任何时刻计数器为0时,就认为对象已死。这种方法方便,高效。但是有一个问题,当两个对象相互应用对方,但是不在有外界调用它们时。这两个对象的程序计数器都不为0,所以这两个对象不会被垃圾回收器回收。例如下面程序:

public class ReferenceCountingGC {


public Object instance = null;

private static final int _1MB = 1024*1024;

private byte[] bigSize = new byte[2 * _1MB];

public static void main(String[] args) {

ReferenceCountingGC gc1 = new ReferenceCountingGC();

ReferenceCountingGC gc2 = new ReferenceCountingGC();

gc1.instance = gc2;

gc2.instance = gc1;

gc1 = null;

gc2 = null;

System.gc();

}

}

从结果上看这两块内存已经被回收,说明java虚拟机不是用的该种方法。

b>. 可达性分析算法:该算法的思路是通过一系列通过称为GC Root的对象开始向下搜索,所走的搜索路径称为引用链。当从GC Root到对象没有一条可达的引用链时就说明该对象已死。可作为GC Root的对象有:虚拟机栈中引用的对象,本地方法栈中引用的对象,方法区中静态属性引用的对象,方法区中常量引用的对象。


2.在谈引用

jdk1.2之前对引用的定义:如果一块内存中存储着一个对象的地址那么这块内存称为一个引用。这种引用只有引用和不引用两种状态,对于那些不必须的对象就无能为力了。jdk1.2 之后引入了4种新的引用类型:1.强引用 2.软引用 3.弱引用 4.虚引用

a> 强引用:程序中普遍存在的,类似Object o = new Object(),只要有强引用对象就不能被回收。

b>软引用:有些对象还有用但非必需的,这些对象使用软引用。当内存溢出之前会将这些对象进行回收,如果内存还不满足则抛出异常。

c>弱引用:有些对象是非必需的,这些对象使用弱引用。当垃圾回收器工作时,无论内存是否足够都会回收这些对象。

d>虚引用,这些引用的对象对于回收没有任何影响。只是这种对象被回收时会收到一个系统通知。


3.生存还是死亡

即使通过可达性分析算法,某个对象没有可达的引用链,也不能说明该对象已经死亡。垃圾回收器在回收一个对象时会对其进行两次标记。第一次标记后会对它们进行一次筛选,执行筛选的条件是该对象是否覆盖了finalize()方法或者虚拟机是否已经调了finalize()方法。这两种情况虚拟机都视为“没有必要执行”。

如果对象执行了finalize()方法,那么该对象会被放倒一个F-Queue队列中。稍后虚拟机会创建一个低优先级的finalize线程去执行finalize()方法,但不能保证方法执行完成。因为这个方法执行速度很慢,如果都执行完成会影响垃圾回收的效率。稍后GC会小范围内进行第二次标记,如果这时对象有到GC Root的引用链时该对象会移除被回收的集合,否则会被回收。

一次对象自我拯救的示例:

public class FinalizeEscapeGC {


public static FinalizeEscapeGC SAVE_HOOK = null;

@Override

protected void finalize() throws Throwable {

super.finalize();

System.out.println("finalize exec!");

SAVE_HOOK = this;

}

public static void isAlive() {

System.out.println("i am is alive");

}

public static void main(String[] argsthrows InterruptedException {

SAVE_HOOK = new FinalizeEscapeGC();

SAVE_HOOK = null;

System.gc();

Thread.sleep(500);

if(SAVE_HOOK == null) {

System.out.print("i am dead");

}else {

SAVE_HOOK.isAlive();

}

SAVE_HOOK = null;

System.gc();

Thread.sleep(500);

if(SAVE_HOOK == null) {

System.out.print("i am dead");

}else {

SAVE_HOOK.isAlive();

}

}

}

第一次执行gc时,该对象被标记,但是覆盖了finalize()方法,所以被放到F-Queue队列不会被回收。第二次执行SAVE_HOOK = null后gc后由于finalize()不在执行,所以对象脱离了引用链将被回收。由于finalize()方法执行代价昂贵,所以不要使用。


4.回收方法区:

虚拟机规范中规定可以不对方法区(或永久代)进行回收。在堆中尤其新生代中回收率为70%到95%。而在方法区中回收效率要低的多。

永久带中的垃圾回收包括两部分:对常量的回收和无用类的回收。对常量的回收与堆上的对象类似,如果常量池中的一个字符串,没有任何对象引用它,那么该字符串可以被回收。对类的回收复杂的多,要符合以下几个条件:1.该类的所有对象都已经被回收。2该类的classloader被回收。3该类的Class对象任何地方都不会使用到。具备了以上条件,类仅仅是可以被回收。类不跟对象一样一定需要回收。还取决于虚拟机中配置的一些参数。在大量使用动态代理,反射,cglib,生成大量jsp文件的系统中,需要提供类的卸载功能。保证方法区不会内存溢出。


5.垃圾回收算法:

a>标记-清除算法(mark-sweep):首先把需要清除的所有对象全部标记出来,然后一次性清除。该方法是后续所有方法的基础,但存在问题:1,标记清除两个过程效率都不高。2,清除后会造成大量的空间碎片,导致需要分配较大空间时不能满足,使得提前需要再一次垃圾回收。

b>复制算法(copying):将内存分为两部分。先将对象放到一块内存里,内存放满后,将其中活着的对象,复制到另一块内存中然后把剩下的所有对象一块清除。此种方法不用考虑空间碎片问题,而且效率高只需将对象的指针移动位置,按顺序分配内存即可。缺点时使用内存只有原来的一半。浪费空间较大。

现在虚拟机大多采用这种方法来回收新生代。据研究新生代98%的对象“朝生夕死”,所以采用1:1的比例浪费空间。而是将内存分为一块较大的Eden空间和两个较小的Survivor空间,它们的比例是8:1:1,首先将Eden空间和一块Survivor空间用来存放对象。当垃圾回收时,先将次两块空间活着的对象复制到另一块Survivor空间里,然后将这两块空间的对象清除掉。这样只会“浪费”10%的空间。

但是如果10%的Survivor空间不能容纳复制过来的对象时,会将老年代的空间作为担保,把容纳不开的对象放入老年代。

c>标记-整理算法(mark-compact):对于对象存活率比较高的情况,采用复制算法,效率会变低,所以老年代不适合此种方法。老年代所使用的方法叫标记-整理算法。标记后不对可回收对象进行回收,而是将存活的对象向一边移动,然后把边界以外的内存全部清除掉。

d>分带回收算法:该算法是以上算法的综合应用。java堆将分为新生代和老年代。新生代由于对象死亡率较高,所以使用“复制”算法进行回收。老年代的对象死亡率小,所以使用“标记-清除”或者“标记-整理”进行回收。


6.hotSpot的算法实现(判断对象死活的算法实现):

6.1枚举根节点:

从可达性分析中从GC Root节点找引用链的操作为例,GC Root主要存在于全局引用(常量或者静态属性变量)和执行上下文(栈帧中的局部变量表)中。如果逐个检查这里边的引用会非常消耗时间。令外,还存在GC的停顿问题,因为为确保检查结果的准确性,分析时需要把所有的线程停止(stop the world)。

在当前主流虚拟机中,当线程都停止后,不需要逐个去检查执行上下文和全局引用位置。虚拟机能够准确的定位到引用的位置,在HotSpot虚拟机中当类加载后,会在OopMap中记录下内存中什么偏移量是什么数据类型。在JIT编译过程中也会在某些地方记录下引用的位置。这样虚拟机就能快速定位到引用的位置。

6.2安全点

在OopMap的帮助下,虚拟机可以准确,快速的轮询完GC Roots,但是问题随之而来,程序运行时引用快速变化,OopMap的内容也迅速增加,虚拟机不能每条指令都产生对应的OopMap,这样会占用大量的内存。

事实上,虚拟机在特定的位置记录这些信息。这些特定位置称为“安全点”。程序不能在随意的地方停下来进行GC,而需要在安全点上停下来进行GC,安全点不能设置的太少,以至于等待GC的时间过长。也不能设置的太多,以至于运行时的负荷太重。安全点的选取标准是“程序是否长时间运行”。由于指令运行速度很快,所以较长的指令流不会消耗太多时间。长时间运行比较明显的特点是指令重复执行,比如循环,方法调用等。

对于safepoint另一个问题是:当GC时如何让所有的线程都停下来跑到最近的“安全点”上。有两种方法:“抢先式中断”和“主动式中断”。1.抢先式中断:当GC时把所有的线程全部中断,当有线程不在安全点时恢复线程,让线程跑到最近的安全点上。几乎所有虚拟机都不会采用这种方式。2.主动式中断:当GC发生时不直接对线程操作,而是设置一个标志各线程主动轮询该标志,当标志为true时中断此线程。标示的轮询的地方与安全点重合。

6.3安全区域:

safepoint解决了程序在在运行时间不长可以进入“安全点”的问题。但是当线程不执行时,例如线程sleep,blocked,或者cup没有分配执行时间时。线程不能响应JVM的中断请求。这种情况就需要安全区域来解决。

安全区域是在一段代码片段内引用的状态不会变化。这时候在任意时刻进行GC都是没问题的。我没可以把safeRegion看作safepoint的扩展。

当线程执行到安全区域时就把自己标示为“安全区域”状态。当发生GC时不用管该状态的线程。当此线程要离开安全区域继续执行时,先看是否完成了GC Roots跟节点枚举或者整个GC是否完成。如果已经完成则继续执行,否则直到收到可以离开安全区域信号为止。


7.垃圾收集器:

7.1:Serial收集器:

Serial是最基本,历史最悠久的收集器。该收集器顾名思义是一个但线程收集器,但这不仅仅意味着它只使用一个CPU或者一条垃圾回收线程工作。更重要的是当它工作时需要所有的线程停止工作。这种体验对用户来说非常不好。但它的回收效率非常高效。由于client模式下分配内存较小,收集耗时较少可以接受线程暂停的情况。所以可以应用于Client模式下的虚拟机。

7.2ParNew收集器:

ParNew收集器是Serial收集器的多线程版本,出了多线程进行垃圾回收外,其他特点与Serial完全一样。它是运行在Server模式下的虚拟机首选的新生代垃圾收集器。其原因不是由于性能,而是除了Serial外它是唯一能与CMS配合工作的收集器。CMS收集器是一款真正意义上实现了用户线程与回收线程并行工作的收集器。

不幸的是CMS作为老年代收集器不能与Parallel Scavenge配合工作,它只能与Serial和PerNew其中一个配合使用。PerNew收集器在但CPU环境下由于线程间交互产生的开销,使得其性能不能超越Serial收集器。而现在随着CPU数目的增多,ParNew收集器对GC资源的使用效率还是很有好处的。默认情况下他会开启与CPU相同数目的线程。

在此澄清两个概念并行和并发。并行:多个回收线程共同工作,但是用户线程不工作。并发:回收线程与用户线程共同工作,用户线程继续执行,回收线程工作在另一个CPU上。

7.3Parallel Scavenge收集器

该收集器也是一款新生代回收器,采用复制算法进行回收。又是并行的垃圾回收器。看起来与NewPar相似,它有什么特别之处呢。

该收集器与其他收集器的关注点不同,其他收集器关注的是尽量缩短用户线程的停止时间。但是该收集器关注的是达到一个可控制的吞吐量。吞吐量的概念是这样的:用户线程的执行时间/(用户线程的执行时间+垃圾回收时间)。

停顿时间越短的线程越适合交互越频繁的程序以提升用户的体验。而高吞吐量能提高CPU的使用效率,适合交互少计算多的后台线程。

Parallel Scavenge回收器设置了两个参数来控制吞吐量:设置最大停顿时间的参数:-XX:MaxGCPauseMillis,和直接控制吞吐量的参数:-XX:GCTimeRatio.

-XX:MaxGCPauseMillis参数是一个大于0的毫秒数。虚拟机的停顿时间小于设置的这个数值。但是不要认为这个值设置越小越好。停顿时间变小是以牺牲新生代的大小和吞吐量为代价的。如何把新生带的大小从500M调小到300M,JVM的停顿时间会变短,但也导致垃圾回收的频率增加。以前10S进行一次垃圾回收,停顿时间100ms,现在5s进行一个垃圾回收,停顿时间70ms。系统的吞吐量减少。

-XX:GCTimeRatio:该值是一个0到100的整数。表示GC时间占总时间的比率。相当于吞吐量的倒数。如果此只设置为99,那么允许的最大GC时间:99/(99+1)为1%。

除了这两个参数,还有一个-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当打开该参数后,就不需要手动设置新生代的大小,Endo区和Survior区的比例,以及晋升为老年代的年龄等细节。JVM会根据性能监控信息动态调整这些参数以适应最合适的停顿时间和吞吐量。这种调节方式叫做GC自适应调节策略。如果对虚拟机原理不了解的话,打开此参数。设置好基本的内存参数-XX:Xmx(最大的堆内存)。-XX:MaxGCPauseMillis(更关注停顿时间)或 -XX:GCTimeRatio(更关注吞吐量)设置一个优化目标,其他细节会由JVM完成。自适应调节策略也是与ParNew的一个重要区别。

7.4Serial Old收集器:

该收集器是Serial的老年代收集器版本,也是采用单线程工作。使用“标记-整理”算法。该收集器也是主要在Client模式虚拟机下进行。它运用在Server模式虚拟机下有两个作用。一是在jdk1.5 之前,与parallel scavenge收集器配合使用。二是作为CMS的备用方案。在并发收集出现“concurrent mode failure”时使用。

7.5Parallel Old收集器

该收集器是Parallel Scavenge的老年代收集版本,采用“标记-整理”算法。这个收集器在jdk1.6才开始出现。Parallel Scavenge比较尴尬,原因是选择了它作为新生代收集器后,只能选择Serial Old作为老年代收集器。因为它不能与CMS配合使用。由于Serial Old但线程的限制,使得整体上不能达到最大吞吐量的效果。可能还不如ParNew加CMS组合给力。

直到Parallel Old收集器的出现。使得Parallel Scavenge+Parallel Old组合,在注重吞吐量和CPU使用效率的场景下是一个非常强力的组合。

7.6CMS收集器

CMS(concurrent mark sweep)是一款以尽量缩短停顿时间为目标的收集器,目前应用于互联网,B/S等交互性比较强的应用上。从名字上可以看出它是使用“标记-清除”算法实现的。它的运行过程包括四个步骤:1.初始标记。2.并发标记。3,重新标记。4并发清除。初始标记是对GC Roots关联的对象进行快速标记,速度很快。并发标记是执行“GC Roots Tracing”的过程,于用户线程一块执行耗时长。重新标记是对上个步骤中用户线程产生的GC Roots记录变化进行修正。并发清除是把标记的对象进行清除。于用户线程并发进行。其中并发标记与并发清除耗时最长。

CMS的优点是并发收集,低停顿。但是它也有缺点:

a>CMS对CPU资源非常敏感。其实任何的并发程序都对CUP资源敏感。在并发阶段,虽然它不会使用户线程中断。但是收集线程占去的CPU的一部分资源,使用户线程执行效率变慢,吞吐量降低。CMS启用的回收线程数默认是:(CPU数量+3)/4.当CUP数量少时,垃圾收集线程会对用户线程产生非常大的影响。为了解决这种情况,虚拟机提供了一种称为“增量式并发收集器( i-CMS)”。这种收集器使用抢占式模式来模拟并发问题,并发标记和清除时使GC线程与用户线程轮流使用CPU,这样整个垃圾回收时间会变的更长。实践证明改收集器效果不好,已被声明为“deprecated”。

b>CMS无法收集浮动垃圾,产生“concurrent mode failure”,导致产生一个Full GC。所谓浮动垃圾就是在并发清除时,用户线程还在执行。新产生的垃圾由于没有被标记不能清除。只能等到下一次GC。还由于垃圾回收阶段用户线程还在执行,虚拟机尚需要预留一部分内存。所以需要老年区内存没有满之前就进行GC。-XXCMSInitiatingOccupancyFraction该参数可以设置老年代内存到达百分之多少时进行GC。如果该值设置过高,有可能用户线程产生的对象将内存耗尽,产生“concurrent mode failure”,失败。这是虚拟机使用备用方案,采用Serial Old收集器进行收集。这样收集时间会变的很慢。

c>由于该收集器使用“标记-清除”算法,所以会使老年区产生很多内存碎片,当有大对象进行分配时,由于不能满足分配条件而失败。不得不提前进行一次Full GC。为解决这个问题。JVM提供了一个参数:-XX:UseCMSCompactAtFullCollection.开启这个参数时,当CMS收集器顶不住要进行一个Full GC时,开启碎片整合过程。碎片整理过程是不能进行并发的,会加长垃圾回收的时间。JVM还设计了另外一个参数:-XX:CMSCullGCBeforeCompact,执行多少次不压缩的GC后执行一个压缩的GC。(默认为0,表示每次Full GC都会进行压缩)。

7.7G1收集器

G1是一款面向服务器应用的垃圾回收器,它有如下特点:

a>并发与并行,G1可以使用多CPU资源来缩短停顿时间,并且可以与用户线程并发执行。

b>分带执行,它与其他收集器一样也有分带的概念。它可以不与其他收集器配合使用就能完成所有的GC工作。它能用不同的方式去处理新创建的对象,存活了很久的对象和熬过了很多个GC的对象。

c>空间整合:与CMS使用“标记-清除”不同,G1使用的是“标记-整理”。收集后内存不会产生很多碎片。

d>可预测的停顿:这是G1与CMS相比又一个优势,缩短停顿时间是CMS和G1共同的目标,但G1除了能够缩短停顿时间还能够建立可以测停顿时间模型。G1可以在M毫秒内制定停顿时间不超狗M毫秒。

在G1之前的收集器都是对整个新生代和老年代进行收集。但是G1不是这样。使用G1时内存布局与其他收集器不同,它把整个内存划分为若干块相同的区域。虽然仍然保留新生代,老年代的概念。但是新生代和老年代不再是物理上隔离(可以是不连续的区域)。它们是一部分区域(region)的集合。

G1能够建立预测停顿时间的模型是因为它避免了所有堆内存的回收,G1跟踪每个区的垃圾堆积的价值(回收内存大小和所用时间),在后台维护一个优先级列表。收集时根据所给时间选定一个回收价值最大的Region,保证了在有限的时间内尽可能的提高收集效率。

G1“化整为零”的思想很容易理解,但是也存在着问题:不能保证该Region的对象就一定被本Region的对象引用。它可能别堆内任意对象引用。所以使用可达性算法判断时,就需要扫描整个堆。这是势必会降低收集效率。其实不是只有G1有这个问题。在其他收集器中新生代对象也能会引用老年代的对象。也存在这个问题。

在G1回收器中,不同Region之间的对象引用或者新生代,老年代之间的对象引用,都是通过使用Remembered set来避免全堆扫描的。每个Region都有一个Remembered set,当JVM发现程序对reference对象进行写操作时,会产生一个write Barrier终止写操作。判断该对象与所写的对象是否在同一个Region中,如果不是会通过CardTable将被引用的对象信息加入到Re membered set中。当进行GC跟节点枚举时将Remembered set加入进去,不扫描全堆也不会遗漏了。

如果不考虑Remembered set操作,G1回收器步骤如下:1.初始标记。2.并发标记。3,最终标记。4,筛选回收。最初标记:把可与GC Roots直接关联的对象进行标记。此过程需要停顿但耗时很短。修改TAMS的值保证在下一步骤里,用户线程有可用空间创建新对象。并发标记:对对象进行可达性算法分析,标记出所有活着的对象。此步骤耗时较长。但是与用户线程并发执行。最终标记:把上一步骤用户线程执行时,对象引用变动的那一部分标记记录下来。虚拟机把并发标记阶段对象的变化记录到Remembered set logs里面。最终标记阶段把Remembered set logs合并到Remembered set 里,此阶段可以并行执行。筛选回收:对各个Region里的回收价值和成本进行排序,根据用户指定的时间进行回收。


8.内存分配与回收策略:

8.1对象优先在Eden分配:

大多数情况下,对象在新生代Eden区分配,当空间不足时发生一次minor GC(小GC)。虚拟机提供了一个参数:-XX:+PrintGCDetails用于输出GC日志,程序实例如下:

VM args: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 分配20M的堆内存,可以打印GC日志。新生代 Eden:Survivor=8:1,新生代10M,老年代10M,新生代Eden8M+Survivor1M创建对象。1MSurvivor用来复制对象。

public class EdenTest {


private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {

byte[] a1a2a3a4;

a1 = new byte[2 * _1MB];

a2 = new byte[2 * _1MB];

a3 = new byte[2 * _1MB];

a4 = new byte[4 * _1MB];

}

}

从GC日志日志可以看出,当分配到a4时,由于空间不够分配,所以进行一次minor GC。此过程中由于a1,a2,a3 全部存活所以将他们全部转移到Survivor,由于Survivor空间不足,所以转移到老年区的担保区域。最后新生代Eden中有4M,老年代中有6M。

在此澄清一个概念:新生代GC(minor GC):发生在新生代上的GC,由于新生代的对象大多是朝生夕死,所以minor GC的频率很高。老年代GC(Major GC/ Full GC):发生在老年代上的GC。Full GC往往伴随着一次minor GC。

8.2大对象直接进入老年代

所谓大对象是占用大量连续内存的对象,入很长的字符串以及数组,大对象对于内存分配来说是个坏消息,往往还有很多内存,但是没有连续内存满足大对象要求时,使JVM提前进入GC。虚拟机中提供了一个参数:-XX:PretenureSizeThreshold参数,当对象超过该参数大小时,直接进入老年区。只有Serial和ParNew有该参数,Parallel scavenge不认识该参数,如果需要使用该参数,请使用ParNew+CMS组合。

8.3长期存活的对象进入老年代

JVM里每个对象都有一个年龄计数器,对象在Eden中刚创建时年龄为0,当发生一次GC时对象被Survivor容纳此时年龄加1,对象在Survivor中每熬过一个GC,年龄就加1.直到年龄为15时进入到老年代。可以通过参数-XX:MaxTenuringThreshold设置。

8.4动态对象年龄判定:

在Survivor区域中的对象并不是非要等到对象年龄到达-XX:MaxTenuringThreshold参数时对象才进入老年代。如果同年龄的对象空间总和大于Survivor空间的一半时,就将大于等于该年龄的所有对象晋升到老年区。

8.5空间分配担保:

在minor GC之前先检查老年区连续空间的大小是否大于新生代的空间总大小,如果大于则此次GC是安全的。如果不大于则检查HandlePromotionFailur设置是否允许担保失败,如果允许则检查老年代连续空间是否大于历次晋升空间的平均值,如果大于则尝试进行minor GC,此次GC是有风险的。

jdk6 update 24之后,HandlePromotionFailur不在作为判断的条件。而是判断老年代连续空间是否大于新生代总空间或者历次晋升空间的平均值,如果大于则进行minor GC,否则进行Full GC。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值