2019独角兽企业重金招聘Python工程师标准>>>
相关文章
java虚拟机之初探
前言
在上篇文章java虚拟机之初探中,介绍了java虚拟机的内存分配,对象的创建,判断对象是否存活的方法等相关内容,java虚拟机对内存的分配,使得能够更好的对垃圾对象进行回收,在不同的内存区域采用不同的回收算法而使得垃圾回收更快,更及时。
对于程序计数器,虚拟机栈,本地方法栈,这几个内存区域是线程私有,所以不存在垃圾回收的问题,而对于方法区,java虚拟机规范说过可不要求虚拟机在方法区中进行垃圾回收,而且在方法区中进行垃圾回收性价比较低,方法区中主要回收两部分内容,废弃的常量和无用的类的卸载;所以说java虚拟机的垃圾回收主要是回收堆这部分内存区域。
标记-清除算法
标记-清除算法由标记和清除两部分组成,标记阶段是把所有存活的对象都做上标记(怎么判断存活,参考:java虚拟机之初探);清除阶段,是把那些没有标记的对象进行回收的阶段,通过这两个阶段,就可以令不能利用的内存空间得到重新使用。伪代码如下:
mark_sweep(){
mark_phase()
sweep_phase()
}
标记阶段
mark_phase(){
for(r : $Roots){
mark(*r)
}
}
mark(obj){
if(obj.mark == fasel){ // 存在循环引用,避免标记多次
obj.mark = true;
}
for(child : children(obj)){
mark(*child)
}
}
首先标记通过GC Root 直接引用的对象,之后在递归标记通过指针数组能访问到的对象,当标记完所有的对象之后,标记阶段就结束了;在这过程中,标记所花费的时候是与“存活对象”的个数成正比的,标记过程如下图所示:
清除阶段
在清除阶段,记的对象垃圾收集器会遍历整个堆,回收没有被标,回收到的没有被标记的对象,会把它们放到一个空闲链表中,之后分配的时候,只需从这个空闲链表中分配即可。
分配
在清除阶段完成后,会把回收到的内存放到一个空闲链表中,那么当下次虚拟机在为新对象申请内存的时候,怎样才能找到大小合适的块呢?从空闲链表中分配新块,可以有下面三种分配方式:
首次适应算法(Fisrt-fit)
首次适应算法(Fisrt-fit)就是在遍历空闲链表的时候,一旦发现有大小等于需要的大小之后,就立即把该块分配给对象,并立即返回。
最佳适应算法(Best-fit)
最佳适应算法(Best-fit)就是在遍历空闲链表的时候,返回刚好等于需要大小的块。
最差适应算法(Worst-fit)
最差适应算法(Worst-fit)就是在遍历空闲链表的时候,找出空闲链表中最大的分块,将其分割给申请的对象,其目的就是使得分割后分块的最大化,以便下次好分配,不过这种分配算法很容易产生很多很小的分块,这些分块也不能被使用。
合并
当在空闲链表中对新对象分配内存之后,会产生很多细小的分块,把这些分块形成在一起称为合并。
优点
算法简单,实现容易
与其他GC算法兼容
缺点
碎片化
分配速度慢,标记-清除算法中分块不是连续的,每次分配都会遍历空闲链表,找到足够大的块,
复制算法
基本思想:将可用内存分为大小相等的两块,每次只使用其中的一块,当这一块用完了,就将还存活的对象复制到另外一个快上面,然后再把已使用过的那块一次性清理掉,这样使得每次都对整个半内存区域进行回收,内存分配的时候也不用再考虑碎片化等情况,只需移动堆顶的指针,按顺序分配内存即可,实现简单,高效。
现在的商业虚拟机一般都采用复制算法来回收新生代。
优点
1.优秀的吞吐量,GC标记-清除算法消耗的吞吐量是搜索活动对象(标记阶段)所花费的时间和搜索整个堆(清除阶段)所花费的时间之和,而复制算法只搜索复制存活对象,所以和标记-清除算法相比,能够在较短时间内完成GC。
2.可实现高速分配,复制算法不使用空闲链表,那么在分配的时候,只需要按需移动指针即可。
3.不会产生碎片化
4.与缓存兼容,复制算法中,具有引用关系的对象会被安排在堆里离彼此较近的位置,因此执行速度非常快。
缺点
1.堆使用率低,每次只能使用堆一半的空间
2.递归调用函数,在复制某个对象的时候,还会复制它的子对象,每次递归调用都会消耗栈,可能还会有栈溢出的情况。
标记-压缩算法
标记-压缩算法也分为两个阶段,标记阶段和标记-清除算法的中的标记阶段一致,在压缩阶段,就是把被标记的对象压缩到内存的另一端,之后清除边界以外的内存即可。在老年代中一般使用标记-压缩算法。
在压缩阶段,也有不同的压缩算法,下面将会介绍几种不同的压缩算法:
Lisp2压缩算法
Lisp2算法在对象头里设置了forwarding指针,该指针指向对象的目标地址,如下图所示:
Lisp2算法一共会搜索三次整个堆空间:
1.设定forwarding指针
2.更新指针
3.移动对象
假设堆中对象的初始状态如下:
首先是标记阶段,标记阶段和标记-清除算法中的标记阶段一致,标记之后,堆中的对象如下所示:
接下来是压缩阶段
1.设定forwarding指针
程序首先会搜索整个堆,给活动对象设定forwarding指针,另外,设初始状态下forwarding指针为NULL,伪代码如下:
set_forwarding_ptr(){
scan = new_address = $heap_start;
while(scan < $heap_end){
if(scan.mark ==true){
scan.forwarding = new_address;
new_address += scan.size;
}
}
scan += scan.size;
}
scan是用来搜索堆中对象的指针,new_address是指向目标地址的指针
一旦scan指针找到活动对象,就会将对象的forwarding指针的引用从NULL更新到new_address,将new_address指针按对象长度移动,forwarding指针设定完成后,状态如下所示:
2.更新指针
在这个过程中,会搜索整个堆来更新各个存活对象的指针,伪代码如下:
adjust_ptr(){
// 更新roots指针
for(r : $roots){
*r = (*r).forwarding;
}
//更新存活对象指针
scan = $heap_start;
while(scan < $heap_end){
if(scan.mark == true){
for(child : children(scan)){
*child = (*child).forwarding;
}
}
scan += scan.size;
}
}
3.移动对象
第三次搜索整个堆,将活动对象移动到forwarding指针指向的目标处,移动后,对象如下:
优点
可有效的利用堆空间,不会出现像复制算法只能利用半个堆空间,也不会像标记-清除算法中由于没有压缩而造成的碎片化。
缺点
压缩花费计算成本,在Lisp2压缩算法中,必须对整个堆进行三次搜索,且执行该算法所花费的时间和堆大小成正比的,所以标记-压缩算法的吞吐量要劣于其他算法;在标记-清除算法中,也会对整个中进行搜索,只不过只搜索一次就够了,但标记-压缩算法要搜索三次,相当于花费三陪的时间。
Two-Finger压缩算法
Two-Finger压缩算法是一种高效的算法,只需搜索两次堆即可。在Lisp2算法中,通过执行压缩操作使得活动对象往左移动,而在Two-Finger算法中,通过执行有所操作来让活动对象填补空闲空间,此时为了让对象恰好能填补空闲空间,就必须让对象的大小一致,如图:
值得注意的是,移动的对象都会被保留(图中灰色对象),因为在Two-Finger算法中,我们要利用非活动对象的空间来存放活动对象,这是为了让移动前的对象不会被覆盖掉,所以就能把forwarding指针设定在这个移动前的对象域中,就没有必要多出一个字节。
前提
Two-Finger算法的前提条件是:“必须将所有对象整理成大小一致”,之前的算法都没有这种限制,另外,Two-Finger算法相比于Lisp2而言,没有必要为forwarding指针预留空间,只需要在原对象的域中设定forwarding指针即可。
Two-Finger算法的步骤
1.移动对象
2.更新指针
1.移动对象
首先使用$free和live两个指针,从两端开始向中间搜索,$free指针负责搜索非活动对象,live负责搜索活动对象,堆以及$free和live指针初始状态如下:
当$freee和live指针找到相应的空间后,就会移动对象。
2.更新指针
接下来寻找指向移动前的对象的指针,把它更新,使其指向移动后的对象,
当$free指针和live指针交叉后,表示整个堆已经搜索完毕;当对象移动结束后,$free指针指向分块的开头,$free指针右边地址的指针引用就是移动前的对象。
优点
Lisp2算法事先要确保每个对象都保留一个字用于forwarding指针,这就压迫了堆,而Two-Finger算法能够把forwarding指针设定在移动前的对象域里面,不需要另外的内存,因此内存使用率高。此外,Two-Finger算法之后搜索两次堆,比Lisp2少一次,因此吞吐量较好。
缺点
对象在堆中的排列,具有引用关系的对象被安排在堆中较近的位置,就能通过缓存来提高访问速度,但是Two-Finger算法不考虑对象的引用关系,一律对其压缩,可能会导致对象的顺序压缩之后,发生很大的变化,不能使用缓存来提高访问速度。此外就是必须把对象整理成大小一致,现在能处理这个限制的系统不多,因此限制了Two-Finger算法应用范围。
分代垃圾回收算法
分代垃圾回收中,把对象分为几代,针对不同的代使用不同的垃圾回收算法,一般把对象分为新生代和老年代。新生代中执行GC称为minor GC,新生代中的对象大部分的生命周期很短,只有很少一部分能够存活下来,在新生代中存活了一定次数的对象被晋升到老年代进行处理,在老年代进行垃圾回收称为major GC,老年对象很难称为垃圾,所以老年代执行GC 的频率比新生代的要少。
在Ungar的分代垃圾回收中,堆被划分为新生代和老年代,新生代又分为一个Eden两个survivor区,
刚开始生成的对象,是在Eden去进行分配的,当Eden区满了后,新生代就会发生GC,将Eden区中存活的对象复制对其中一个Survivor区中去,
只有在新生代中经历了数次的GC后依然存活的对象,就会被复制到老年代中去。
新生代GC如下图:
在新生代中,对象的生命周期很短,存活对象不多,所以在新生代中一般采用复制算法的进行垃圾回收,
而在老年代中,对象的生命周期较长,存活对象比较多,所以一般采用标记-压缩算法来进行垃圾回收。
优点
吞吐量得到改善
缺点
如果在程序中对象的声明周期很长,则新生代GC所花费的时间增多,老年代的GC会频繁运行。
垃圾收集器
在上述介绍了虚拟机中常用的垃圾回收算法,接下来就介绍相关的垃圾收集器。
在HotSpot虚拟机中,垃圾收集器如下图所示:
相互联线的收集器可以配合使用;接下来将会介绍每个收集器的特性,基本原理和使用场景等。
Serial收集器
Serial收集器是一个单线程的收集器,但是单线程的意义并不是仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾回收工作,更重要的是在它进行垃圾回收的时候,必须暂停其他所有的工作线程,直到它收集结束。
Serial收集器的运行过程:
Serial收集器在工作的时候,会暂停所有的用户线程,即 Stop The Word (STW),它是不是就是过时了呢?不是的,对现在为止,它依然是虚拟机运行在Client模式下默认的新生代垃圾收集器,简单而高效。对于单个CPU的环境来说,Serial收集器没有线程交互的开销,专心进行垃圾收集,可以获得最高的单线程垃圾收集效率。所以说,Serial收集器对于运行在Clinet模式下的虚拟机来说是一个很好的选择。
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾回收外,其余行为,包括Serial收集器可用的所有控制参数,收集算法,STW,对象分配规则,回收策略等都与Serial的一致。
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。
Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代的垃圾收集器,它是采用复制算法的垃圾收集器,也是并行运行的多线程垃圾收集器。
Parallel Scavenge收集器关注的是达到一个可控制的吞吐量,所谓的吞吐量就是CPU运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),比如虚拟机共运行了100分钟,其中垃圾收集花费1分钟,则吞吐量就是99%。高吞吐量可以高效的利用CPU的时间,尽快完成程序的计算任务,主要适合后台运算而不需要太多交互的任务。
可以使用 -XX:MaxGCPauseMillis参数设置最大垃圾收集停顿时间,使用-XX:GCTimeRatio直接设置吞吐量大小。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器,目前很大一部分的java应用集中在互联网站或B/S系统的服务端上,这类应用非常注重响应速度,希望系统停顿时间尽可能的短,以给用户带来更好的体验,CMS收集器就非常符合这类应用。
CMS收集器是基于标记-清除算法来实现的,它的一个运行过程主要有以下四个步骤:
1.初始标记(CMS initial mark)
2.并发标记(CMS concurrent mark)
3.重新标记(CMS remark)
4.并发清除(CMS concurrent sweep)
其中,初始标记和重新标记这两步仍然需要 Stop The Word,
初始标记仅仅只会标记从GC Roots能直接关联的对象,速度很快。
并发标记就是在进行GC Roots Tracing的过程。
重新标记则是为了修正在并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记,这段时间要比初始标记要长一些,但比并发标记的时间要短得多。
在CMS工作的整个过程中,并发标记和并发清除所花费的时候最长,但是这两个过程的收集线程是可以与用户线程一起工作的。所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS运行过程示意图如下所示:
优点
并发收集
低停顿时间
缺点
CMS收集器对CPU资源敏感,如果CPU的个数较少,在并发标记和并发清除的时候,虽然不会暂停用户线程,但是还是会分出一部分线程资源给收集器,这样会导致应用程序变慢。
CMS收集器无法处理浮动垃圾,由于在并发清除阶段,用户程序还在运行,还会产生垃圾,这部分垃圾出现在标记过后,CMS无法在当次收集它们,只好留到下一次在清除。
由于CMS收集器采用的是标记-清除算法进行垃圾回收,在回收后内存会碎片化,如果内存细小的空间过多,将无法为大对象分配内存,就会出现老年代中还有很多的剩余空间,但是无法找到一个足够大的连续空间来分配给当前的这个大对象,就会不得不提前触发一次 Full GC。为了解决这个问题,CMS收集器提供了两个参数,使用 -XX:+UseCMSCompactAtFullGC开关参数(默认开启),当虚拟机快要进行Full GC的时候,会进行内存碎片化的整理过程,这将导致停顿的时间延长。使用 -XX:CMSFullGCsBeforeCompaction参数,这个参数用于设置执行多少次不压缩的 Full GC后,跟着执行一次带压缩的 Full GC,该参数的默认值为0,表示每次Full GC的时候都会进行内存碎片的整理。
G1收集器
G1(Garbage First)收集器是当前最新的收集器,它是面向服务端应用的垃圾收集器,
G1收集器不再有分代的概念了,它将整个堆划分为多个大小相等的独立区域(Region),新生代和老年代的概念还保留着,当时它们不在是物理隔离的,它们都是一部分Region(不需要连续)的集合。
G1跟踪每个Region里面的垃圾堆积的价值大小(回收所获得的空间以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值较大的Region,保证了G1收集器在有限的时间获取尽可能高的收集效率。
G1收集器大致可分为以下四个步骤:
1.初始标记
2.并发标记
3.最终标记
4.筛选回收
初始标记:仅仅标记能从GC Roots直接关联的对象,Stop-The-Word,但是耗时很短。
并发标记:从GC Roots开始对堆中的对象进行可达性分析,找出存活对象,这段时间耗时较长,但可与用户线程并发执行。
最终标记:为了修正并发标记期间因用户线程继续运行而导致标记产生变化的那一部分标记记录,需要暂停用户线程
筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户期望的GC 停顿时间来指定回收计划。
优点
并行与并发:G1能够利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短 Stop-The-Word的停顿时间。
分代收集:G1收集器不需要其他收集器就能管理整个堆,但它能够采取不同的方式去管理新建的对象,存活一段时间的对象和多次GC后仍然存在的对象。
空间整理了:G1采用的是标记-压缩算法,不会产生碎片。
可预测的停顿:降低停顿时间是CMS和G1的关注点,G1除此之外,还可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒。
总结
内存分配
对象的内存分配,基本上就是在堆上进行分配,对象主要分配在Eden区上,如果启动了本地线程分配缓冲,则按线程优先在TLAB上分配,少数情况下直接在老年代中分配。分配规则如下:
对象优先在Eden去进行分配:大多数情况下,对象在新生代的Eden区中进行分配,当Eden区没有足够的空间进行分配时,虚拟机将触发一次 minior GC。
大对象直接进入老年代:所谓的大对象是指需要大量连续内存空间的java对象,如很长的字符串或数组。
长期存活的对象将进入老年代:java虚拟机给每个对象设置一个年龄计数器,如果对象在Eden区中产生并经过一次Minior GC后仍然存活,并且能够被Survivor区容纳的话,将被移动到Survivor区中去,对象年龄加1,当对象的年龄达到一定的值后(默认为15),就会晋升到老年代进行管理。
垃圾收集器常用参数配置
垃圾收集器常用参数配置
UseSerialGC | 虚拟机与运行在Client模式下的默认值,打开此开关后,使用Serial+Serial-Old组合进行垃圾回收 |
UseParNewGC | 使用ParNew+Serial Old组合进行垃圾回收 |
UseConcMarkSweepGC | u使用ParNew+CMS+Serial Old 进行垃圾回收,Serial Old作为在CMS出现失败后的一个后备收集器 |
UseParallelGC | x虚拟机运行在Server模式下,使用Parallel Scavenge + Serial Old组合回收 |
UseParallelOldGC | 使用Parallel Scavenge + Serial Old组合回收 |
SurvivorRatio | 新生代中Eden和Survivor的容量比值,默认为8:1:1 |
PretenureSizeThreshold | 直接进入老年代的对象大小 |
MaxTenuringThreshold | 晋升到老年代的对象年龄 |
UseAdaptiveSizePolicy | d动态调整java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败 |
ParallelGCThreads | 设置并行GC时进行内存回收的线程数 |
GCTimeRatio | GC 时间占总时间的比率,默认值为99%,即允许1%的GC时间,仅在使用Parallel Scavenge收集器时生效 |
MaxGCPauseMillis | u设置GC最大停顿时间,仅在使用Parallel Scavenge收集器时生效 |
CMSInitiatingOccupancyFraction | u设置CMS收集器在老年代空间被使用多少后触发垃圾收集器,默认值为68%,仅在CMS收集器时生效 |
UseCMSCompactAtFullCollection | u设置CMS收集器在完成收集后是否进行内存碎片整理,仅在CMS收集器有效 |
CMSFullGCsBeforeCompaction | 设置CMS收集器在若干次垃圾回收后再启动一次内存碎片整理,仅在CMS有效 |