本文纯粹为阅读深入理解jvm虚拟机手记文章。
我们知道,jvm分为5部分:程序计数器、虚拟机栈、本地方法栈、方法区、堆。其中,前三部分线程私有,这几个区域不太需要考虑回收问题,因为一般随着线程结束,内存自然就回收了;但方法区跟堆就不一样,需要根据情况具体分析其中对象的创建与回收问题。
如何判断对象已死
1、引用计数算法
就是给对象加一个引用计数器,每当一个地方引用它时,计数器值就加1,引用失效时就减1,引用计数为0的时候就是不能再被使用的。
很多时候面试碰到这个问题,我们都这么回答;但这是错误的,至少主流的jvm都没有采用这种做法,因为很难解决对象循环引用的问题(循环指a.b=b;b.a=a;这类的问题)。当然,AS3,Python等语言的确使用了引用计数算法来进行内存管理也是事实;这种算法优点是实现简单,判定效率也挺高。
2、可达性分析算法
主流的商用程序语言(Java、C#等)中,都是通过可达性分析来判断对象是否存活的。
基本思路就是通过一系列被称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,我们就认为这个对象是不可达的。
java中,通常被作为GC Roots的位置有:
- 虚拟机栈中包含的引用对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
引用的类型
因为缓存的原因,我们希望能有这么一类对象:当内存空间足够的时候,能保留在内存中,如果内存空间在进行gc后仍然非常紧张,则抛弃掉这些对象。实际很多缓存系统都符合类似的应用场景。JDK1.2后,引用分为强引用、软引用、弱引用、虚引用,四者强度依次降低,我们通常所说的引用实际是强引用。
如何判断对象是否该回收
可达性分析认为不可达的对象也并非立即处死的。实际宣告一个对象死亡至少经历两次标记过程;第一次是可达性分析进行标记,第二次是判断对象是否有finalize()方法进行标记。如果没有finalize()方法,或者该方法已被吊用过,则jvm认为可以回收;若finalize()方法没执行,则对象会被放入F-Queue队列,并在稍后由jvm建立一个低优先级的的线程进行执行。注意,jvm会触发这个方法,但并不承诺会等待该线程结束,因为有的对象的finalize()方法可能死循环导致线程永远等待甚至导致内存回收崩溃。so,finalize()方法是对象拯救自己的唯一机会。
方法区回收简介
很多人认为方法区(也就是hotspot的永久代)没有垃圾回收,jvm规范也确实说了不要求必须在方法区实现垃圾回收,实际情况一般还是会有相应的回收机制的。
方法区的gc回收性价比一般较低,新生代一般一次gc可以回收70-90%的内存,方法区远远低于此数值。方法区回收的主要内容是废弃常量跟无用的类。
在大量使用反射、动态代理、CGLib等ByteCode框架,动态生成jsp以及OSG这类频繁自定义ClassLoader的场景,jvm必须具备类卸载的功能,以保证方法区不会内存溢出。
垃圾收集算法
1、标记-清除算法
最基础的算法。后续的收集算法都是基于标记清除的思路并对其不足进改进而得到的。主要有2个不足:效率问题跟空间问题。标记跟清除两个过程效率都不高,而且清除后会产生大量不连续的内存碎片,如果要放一个较大对象而找不到连续内存时,将不得不提前出发另一次垃圾收集动作。显然,该算法不具有压缩功能。
2、复制算法
当前商用虚拟机的新生代算法。理论上均分为2部分进行分别利用,但因新生代朝生夕死的现象,优化为目前的eden(伊甸园)跟survivor(幸存者)分区算法,两者比例为8:1:1,其中survivor区包含相等的2块(这里两者的命名挺有意思,很形象)。多数时候,survivor是可以存下收集后保留下来的对象的,但偶尔也会有survivor空间不足的时候,这时候就需要老年代进行"内存担保"(意思就是如果我不够了,你得来帮我承担,也就是帮我存放对象),这些多余的对象会被直接放到老年代(正常情况要15次回收仍然存活才放到老年代)。
其算法,是对一块进行垃圾收集,幸存的放到另一块儿地方,在另一块儿地方写的时候是挨着写的,所以相当于有压缩功能(这里说的压缩是说把内存整理的都挨着了的意思)。
3、标记-整理算法
复制算法虽然高效,但对于老年代这种存活率较高的区域来说,明显不适合。跟标记-清除类似,标记-整理算法就是进行标记后,进行压缩,将幸存对象移动到一端,剩余的内存全部清空。显而易见,移动过程相对较为繁琐,比标记清除效率要低。当然,有压缩功能(存活的都移动到开头挨着排列了嘛)。
4、分代收集算法
实际不是什么算法,就是当前的商业虚拟机基本都是根据对象存活周期不同,将内存分为新生代跟老年代,进而采用复制算法跟标记-清除或者标记-整理算法的做法。
OopMap、安全点以及安全区域
OopMap
可达性分析中,我们遍历一系列根节点,找到所有引用链跟子节点。这个过程有两个问题需要注意:
1、在查找子节点,确定是否可达的过程中,对象的引用关系不能持续变化,也就是程序必须暂停,否则分析结果就不准确。这就是GC时必须停顿所有java执行线程的原因(sun称之为Stop The World),该过程即使在号称几乎不会发生停顿的CMS收集器中也是要停顿的。
2、我们平常程序的jar包总共可能几十上百M,那么方法区就至少有这么大,要遍历这么大的区域是比较麻烦的。因此,虚拟机应该有办法知道哪些地方存放着对象的引用,在GC的时候直接遍历,而不是遍历方法区跟所有虚拟机栈去找所有对象。jvm使用一个被称为OopMap的数据结构来记录这些信息,在类加载以及JIT编译的时候,就把相关信息记录下来,在GC的时候就可以直接使用了。
安全点
OopMap记录了GC所需要遍历的所有对象,但如果为每一条指令生成一个OopMap对象,明显过于繁琐沉重,所以,我们只在方法调用、循环跳转、异常跳转等地方生成OopMap对象,这些地方就是所谓的安全点。这些点的选择,基本是以“是否具有让程序长时间执行的特征”为标准进行选定的。GC发生的时候,要Stop The World,线程都是运行到最近的下一个安全点才停止的(主动式中断)。实际是线程每到安全点就去访问一个全局的flag,判断是否停止,,当GC发生时,就把这个flag置为true。
安全区域
安全点貌似完美的解决了如何进入GC的问题,实际不是;因为还有sleep或者blocked状态的线程,这时候就需要安全区域来解决。安全区域就是指一段代码片段中,引用关系不会发生变化,这个区域中任意地方开始GC都是安全的。实际上就是线程sleep被唤醒的时候,先判断一下当前是不是在Stop The World,如果是就不能唤醒,直到Stop The World结束。
垃圾收集器
Serial收集器
单线程收集器,只会使用一个cpu或者一条收集线程完成垃圾收集,而且收集过程中必须暂停其他所有的工作线程,直到它收集结束。
最古老的收集器,有缺陷,但却是在client模式下默认的新生代收集器,因为简单高效。------单cpu,无线程交互开销,收集几十兆或者一两百兆的新生代,时间大概几十毫秒。
ParNew收集器
其实是Serial的多线程版本,控制参数,收集算法,回收策略等都跟Serial相同,这俩收集器甚至共用了很多代码;
相对Serial创新不多,是Server模式中首选的新生代收集器;----原因之一是除了Serial之外唯一能跟CMS配合的新生代收集器。
因为多线程交互的开销,单cpu肯定不如Serial,甚至双cpu也不一定比Serial强,当然随着处理器的增多,相对来说还是有优势的。
Parallel Scavenge收集器
也是使用复制算法的新生代收集器(Serial,Parnew都是复制算法的新生代收集器),多线程;
跟ParNew的不同是关注点不同,Parallel Scavenge目标是可控制的吞吐量(cpu用户运行用户代码时间/(运行用户代码时间+垃圾收集时间));
有3个参数用于进行控制:最大垃圾收集停顿时间(此值越小,新生代越小,gc越频繁)、吞吐量大小(数值为1-99,如19表示垃圾收集所占时间为1/(19+1))、自动调节(开启后自动监控性能,调节新生代大小,eden跟survivor比例等)。
Serial Old收集器
serial 收集器的老年代版本,单线程,标记-清除算法,存在意义是主要用于client模式下。
server模式下,1.5之前与Parallel Scavenge搭配使用,另外还可以作为CMS发生Concurrent Mode Failure之后作为预备方案使用。
Parallel Old收集器
Parallel Scavenge的老年代版本,多线程,标记-整理算法。
jdk1.6之后提供,在老年代很大且硬件配置很高的情况下,可以优先考虑Parallel Scavenge + Parallel Old收集器。
CMS收集器
Concurrent Mark Sweep是一种以获得最短回收停顿为目标的收集器,从名字来看,使用标记-清除算法。
java web的互联网应用,一般对服务器响应速度有较高要求,以给用户良好体验,CMS就非常适合这类需求。
运作过程相对较为复杂,分为4个过程:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中1跟3两个阶段需要stop the world,初始标记就是标记一下GC Roots能关联到的对象,速度很快;并发标记就是GC Roots Tracing过程,也就是可达性分析的过程,耗时长,但跟用户线程并行运行;重新标记是为了修正过程2中用户产生的新对象;并发清除不解释;
CMS非常优秀,但不完美,问题有:
1、对cpu资源敏感,默认开启回收线程数为(cup数量+3)/4,取整;那么cup占有率为1/4+3/4n,n为cpu数量,n越大,占有率越低;n<4时使用1个cpu;单cpu下,对程序影响较明显;
2、无法收集浮动垃圾;并发清除阶段,是跟用户线程并行,用户新产生的垃圾需要等待下次回收处理;也因为并行,老年代不能跟其它收集器一样等满了再收集,需要预留空间提供并发收集时程序运作使用,jvm提供了参数,设置老年代使用多少百分比后会进行gc,1.5为68%,jdk1.6后为 92%,当CMS运行期间内存无法满足程序需要,就出现Concurrent Mode Failure失败,需要Serial Old来进行回收,这样的话,花费时间就长了;
3、标记清除算法,因为内存不连续,如果放入较大对象,可能提前触发gc;CMS提供了一个参数,设置多少次不压缩收集后执行一次压缩收集;
G1收集器
1.7出现的一款性能先进的收集器,oracle希望在1.9做为默认收集器。
1、并行与并发;G1能充分利用多CPU,多核的硬件优势来缩短Stop The World的时间,部分其它收集器需要停止用户现场的操作,G1可以通过并发的方式继续运行;
2、分代收集;分代概念仍然保留,能采用不同算法处理不同的对象区域;
3、空间整合;从整体来看,G1是标记-整理算法,从局部来看,是复制算法,这两种方式都避免了CMS产生碎片空间的问题;
4、可预测的停顿;G1相对CMS的另一优势。降低停顿时间是两者共同关注的问题,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度M的时间段内最多停顿N毫秒。(目前极端情况下会有问题)
与其它算法有较大区别,将整个堆分为多个大小相等的独立区域,虽然还保留了新生代跟老年代的概念,但它们已经不是物理隔离的了,它们都是一部分独立区域的集合。G1之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免全堆垃圾收集;G1跟踪各个独立区域里边的垃圾回收效率(回收所得的空间大小跟回收时间的比值),并在后台维护一个列表,每次根据允许的收集时间,优先回收效率最高的区域。
对于如何判断该对象是否被其它独立区域的对象引用的问题,jvm维护了一个Remembered Set来避免全堆扫描,这个set中记录了该对象被谁引用了,gc的时候直接扫描相关区域,不用对整个堆进行扫描。
处理阶段分为:
1、初始标记;标记一下GC Roots能直接关联到的对象,要停顿,时间短;
2、并发标记;从GC Roots开始,对堆中对象进行可达性分析,耗时长,但可跟用户程序并行执行。
3、最终标记;为了解决2中产生的新对象,这些变化都记录在Remembered Set Log中,要停顿。但G1可以多线程运行此过程。
4、筛选回收;可以跟用户程序并行,但停止用户程序会大幅提高效率,因此一般要停顿。这里四个过程说的停顿,指的是stop the world。
目前G1基本能跟CMS效率类似,对于较大的堆,比CMS表现要差;对于60-70G的堆,即使设置期望停顿为50ms,真正停顿时间可能达到30s甚至2分钟。随着oracle对G1的持续改进,应该最终会比CMS要强。
内存分配与回收策略
简单概括为一句话:对象优先在eden区分配,大对象直接进入老年代,长期存活的对象进入老年代;
new产生的新对象优先在eden区分配,如果对象超过设置的参数,则直接进入老年代;默认新生代对象经过15次gc没有回收掉,第16次将进入老年代;但如果Survivor空间相同年龄所有对象大小总和大于Survivor空间一半,则年龄大于等于该年龄的对象直接进入老年代,无需等到jvm设置要求的年龄。