《深入理解Java虚拟机》读书笔记2--垃圾收集(GC)与内存分配

垃圾收集(Garbage Collection,GC),其实主要需要完成3件事情:哪些内存需要回收?什么时候回收?如何回收?

对于程序计数器、虚拟机栈以及本地方法栈,这三块内存区域是线程私有的,伴线程生,随线程死,并且每一个栈帧需要的内存在类结构确定后基本就确定了,因此对于这几块区域,内存的分配以及回收具备可确定性,当方法结束或者线程结束时,内存随之回收

但是对于堆以及方法区,运行时才能确定需要创建哪些对象,因此内存分配具备动态性以及不确定性。GC主要考虑的也是这部分区域的内存回收

对象测活

所谓的“对象测活”,也就是要确定哪些对象已经“死亡”,需要被回收

1.堆

对于堆中对象的测活,主要有两种方法:引用计数、可达性分析

#引用计数

主要思想是:给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器+1;当引用失效时,计数器-1;当计数器为0的时候,说明该对象已经没有被引用,可以被回收

大多数时候,该算法足够简单、高效,但是缺点也显而易见:不能处理循环引用的情况。比如对象1引用对象2,对象2也引用对象1,但是1和2已经没有被其他地方引用,因此这两个对象实际上应该被回收。但是由于他们互相持有对方的引用,彼此的引用计数器都不为0,所以不能被回收

#可达性分析

核心思想是:有一系列被称为“GC Roots”的对象,从这些对象开始向下搜索,走过的路径称为引用链。当一个对象没有被任何链路所引用,则判定此对象是不可达的,可以被回收

Java中可以被当作GC Roots的对象包括:虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象

2.引用类型

Java提供了4种类型的引用:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)

之所以提供这么多的引用类型,主要目的是为了满足这类需求:对于一类对象,在内存充足的时候,则可以保存在内存中;但是当GC后内存依然捉襟见肘的时候,则可以抛弃这类对象,释放内存。比较常见的一种使用场景是缓存

#强引用

强引用是最常见,也是使用最频繁的一种引用,通过“Object obj = new Object()”创建的就是强引用,只要引用还存在,对象就不会被回收

#软引用

对于软引用引用的对象,在系统将要OOM之前,会将这类对象纳入回收范围之内进行回收,如果这次回收后依然没有足够内存,才会OOM。Java提供SoftReference类来实现软引用

#弱引用

弱引用的引用强度低于软引用,被弱引用引用的对象,只能存活到下一次GC前。当GC发生时,无论当前内存是否充足,被弱引用引用的对象都会被回收。Java提供WeakReference类来实现弱引用

#虚引用

虚引用是强度最弱的一种引用,既不能通过虚引用来获得对象实例,也不会对其生存时间构成影响。唯一的作用就是在这个对象被回收之前能通收到一个系统通知。Java提供PhantomReference类来实现虚引用

3.回收方法区

方法区(HotSpot虚拟机的永久代)的GC主要是回收两部分内容:废弃的常量和无用的类

#废弃的常量

废弃常量的判定与堆中对象的测活十分相似,只要没有地方再引用到这个常量,这个常量就被判定为“废弃”,是可被回收的

#无用的类

类是否无用,判定条件相对苛刻,需要同时满足3个条件:

1)该类所有的实例已经被回收

2)加载该类的ClassLoader已经被回收

3)该类对应的java.lang.Class对象没有在任何地方被引用,也就是不能通过反射访问

对于大量使用反射、动态代理、CGLib、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景,需要虚拟机具备类卸载功能,以避免方法区(永久代)溢出

GC算法

1.标记清除算法

“标记-清除(Mark-Sweep)”算法是最基础的GC算法,整个过程包括“标记”和“清除”两个阶段。主要不足有两点:

#标记和清除,这两个阶段效率都不高

#产生大量内存碎片,导致为大对象分配内存时,可能会提前触发一次GC

2.复制算法

复制算法可以解决标记清除算法的两个缺点,核心思想是:把内存一分为二(容量相同),每次只使用其中的一块。当一块内存用完后,将仍然存活的对象复制到另一块上,然后再将这块内存空间完整的清理掉

优点:简单高效、无内存碎片

缺点:每次只能使用一半内存,50%的内存被“浪费”

现代的虚拟机采用这种算法回收新生代。新生代的对象大多“朝生夕死”,因此并不需要按照1:1的比例划分内存,而是将一块较大的内存分配个Eden区,另外将两块较小的内存分配给Survivor区

GC时,将Eden和正在使用的一块Survivor中仍然存活的对象复制到另一块Survivor中,然后将Eden和刚才在使用的那块Survivor空间一起回收掉。在复制过程中,如果这块Survivor空间不足,那么这部分对象将通过“分配担保”,直接进入老年代

HotSpot虚拟机默认将Eden和Survivor比例设置为8:1(两块Survivor各占10%),也就是新生代中每次可用内存为90%,只有10%会被“浪费”

3.标记整理算法

复制算法在对象存活率较高时,会进行大量的内存复制,效率会降低。另外还需要额外的空间进行“分配担保”,以应对对象全部存活的极端情况。因此复制算法并不适用于老年代

标记整理算法与标记清除算法在标记阶段一样,但后续并不直接清理内存,而是将存活的对象向一侧移动,之后再清理掉边缘以外的内存

4.分代收集算法

分代收集算法严格来说并不是什么新算法,核心思想是将堆划分为新生代和老年代,根据新生代和老年代不同的特点,采用不同的GC算法

新生代对象朝生夕死,存活率较低,采用复制算法,只需要少量的对象复制,就可以完成垃圾收集;老年代对象存活较久,并且没有额外空间进行分配担保,所以通常采用标记清除或者标记整理算法

HotSpot中GC算法实现

1.枚举根节点

现代应用内存分配较多,如果完全对GC Roots进行枚举往往需要很多时间。另外在此期间,为了保证分析结果的准确性,需要暂停所有用户线程(Stop The World,STW)

目前主流的虚拟机都采用准确式GC,因此并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法得知哪些地方存放着对象引用的。在HotSpot中,使用称为OopMap的数据结构来达到上述目的。在类加载完成的时候,虚拟机会把对象内什么偏移量上是什么数据类型计算出来。这样在GC时就可以得知这些信息了

2.安全点

通过OopMap,HotSpot虚拟机可以快速准确的完成根节点枚举。但是如果为每条指令都生成OopMap,那么GC的空间成本将会大大增加

实际上,只会在称为安全点(Safe Point)的特定位置记录这些信息,也就是说GC并非随时都可运行,只有到达安全点时才可以。安全点太少,会导致GC等待时间太长;过多,会过分增加系统负载。单条指令执行太快,所以不可能在任何指令后都设置安全点,而是要选择“能够让程序长时间运行”的点,符合这种特征的点有:方法调用结束前、循环末尾、可能发生异常的位置等

有了安全点,下面面临的一个问题是,如何在GC发生时让所有线程都跑到安全点,主要有两种方式:抢先式中断和主动式中断

#抢先式中断

抢先式中断,就是在GC发生时,抢先中断所有线程,如果发现有线程不在安全点,那么恢复该线程,让他跑到安全点。但是现在几乎没有虚拟机采用此方式

#主动式中断

主动式中断,就是在GC发生时,不直接对线程进行操作,而是仅仅设置一个标志位,让各个线程主动去轮询,当线程发现这个标志时,把自己中断挂起。轮询标志会被设置在和安全点重合的位置,或者创建对象时需要分配内存的位置,因此线程中断挂起的位置正好在安全点上

3.安全区域

无论采用上述哪种中断方式,都需要线程自己跑到安全点。但是对于例如处于Sleep或者Blocked状态的线程,显然是没有办法自己跑到安全点的,这时候就需要安全区域(Safe Region)

如果在一段代码中,引用关系不会发生变化,也就是说在这个区域内是随时可以开始GC的,那么这段区域就可以视为安全区域

线程在执行到安全区域时,首先会标记自己已经进入安全区域,当GC发生时,系统就不用管这些已经进入安全区域的线程了。当线程需要离开安全区域的时候,会检查系统是否已经完成了根节点枚举甚至完成了整个GC过程,如果没有完成,那么线程就会等待,直到收到可以离开安全区域的信号为止

垃圾收集器

首先需要说明的一点是,没有最好的或者万能的收集器,只应当选择最适合使用场景的收集器

1.Serial及Serial Old收集器

Serial收集器是最基本、最简单的收集器,历史也最为悠久,采用复制算法,用于新生代GC。Serial Old则是其老年代版本,采用标记整理算法

如其名,它是一款单线程收集器,在工作时需要暂停其他所有线程。如果需要扫描的内存很大,STW将是一场灾难

2.ParNew收集器

ParNew收集器实际上就是Serial收集器的多线程版本,使用多线程进行GC。由于多线程切换的开销,因此在单CPU的环境下,ParNew不会比Serial有更好的表现。但是对于多CPU(或者多核)环境来说,ParNew则可以更好的利用CPU资源进行GC。默认情况下,它的GC线程数与CPU数量相同,可通过启动参数-XX:ParallelGCThreads调节

3.Parallel Scavenge及Parallel Old收集器

Parallel Scavenge收集器是一个新生代收集器,采用复制算法,用于新生代GC。Parallel Old则是其老年代版本,采用标记整理算法。

与其他更加关注用户线程停顿时间的收集器(比如CMS收集器)不同,Parallel Scavenge收集器更加注重可控的吞吐量(换句话说就是GC耗费时间的占比)

Parallel Scavenge收集器提供两个参数控制吞吐量:-XX:MaxGCPauseMillis和-XX:GCTimeRatio

#MaxGCPauseMillis

MaxGCPauseMillis参数使收集器尽可能保证GC在设定时间内完成。这个值并不是越小越好,因为这个值设置的小,并不代表GC速度会更快。相反,GC停顿时间缩短是以牺牲吞吐量和新生代空间换取来的。新生代小了当然单次回收会更快,但是GC的频次却会增加

#GCTimeRatio

GCTimeRatio参数设置的是GC时间占比,比如设置为19,那么允许的最大GC时间占比就是1/20,系统默认值是99

由于与吞吐量密切相关,所以Parallel Scavenge收集器也被称为“吞吐量优先”收集器。除了上述两个参数外,-XX:+UseAdaptiveSizePolicy也值得关注。UseAdaptiveSizePolicy是一个开关参数,当开启时,就无需再手动设置新生代大小、Eden和Survivor的比例等细节参数,虚拟机会根据系统的实际运行监控数据,动态的调整这些参数以提供合适的GC提顿时间或者吞吐率

4.CMS收集器

在介绍Parallel Scavenge收集器的时候,我们提到CMS收集器是更加关注用户线程停顿时间的收集器。这种特征更加适用于诸如互联网服务这类尤其重视服务响应速度的需求

CMS全称Concurrent Mark Sweep,可以看出其基于标记清除算法,整体来看分为4个步骤:初始标记、并发标记、重新标记以及并发清除。其中初始标记和重新标记阶段仍然需要STW

#初始标记,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

#并发标记,就是进行根节点枚举,此过程通常较长

#重新标记,是将并发标记过程中因用户线程产生变动的对象进行重新标记记录,比并发标记过程要短

#并发清除,执行清除,可与用户线程并非执行

由于最耗时的并发标记和并发清除阶段可以与用户线程一起并发执行,因此该收集器的执行过程从整体上看是与用户线程一起并发执行的

CMS收集器优点是并发收集、低停顿,但是缺点也很明显:

#对CPU资源敏感。虽然在并发阶段不会导致用户线程停顿,但是会占用一部分线程资源,导致系统处理能力降低

#无法处理浮动垃圾。所谓“浮动垃圾”就是指在并发清除阶段,由于用户线程并发运行所产生的垃圾。这部分垃圾由于是出现在标记过程之后,因此在本次GC中是无法被收集掉的,只能等下次GC时处理。由此可以看出,CMS在运行时需要留给用户线程足够的内存空间正常运行,所以也就不能像其他收集器那样等到内存几乎快满了才开始GC。当CMS运行时,如果预留的内存不能以满足程序需求,将会触发“Concurrent Mode Failure”,这时虚拟机将临时启用Serial Old收集器来进行GC,这个过程停顿就很长了,性能反而下降

#产生内存碎片。基于标记清除算法的收集器都有这个问题。碎片过多时,将给大对象分配带来很大麻烦,有时候老年代还有很大剩余空间,但是由于一个大对象无法分配,不得不提前触发一次GC。CMS提供了相关参数设置内存碎片合并,但合并过程是无法并发的,将导致停顿时间变长

5.G1收集器

G1收集器面向服务器应用,相比其他收集器,具备以下特征:

#并发与并行。G1能够充分利用多CPU(或者多核)的硬件优势来缩短STW时间,部分在其他收集器需要停顿用户线程的操作,G1可通过并发的方式让用户线程继续执行

#分代收集。与其他收集器中分代收集(通过对新生代和老年代使用不同的收集器)不同的是,G1能够独自完成整个堆的GC,它能够对不同年龄的对象采用不同的方式处理

#空间整合。G1从整体上看采用标记整理算法,从局部来看(两个Region之间)采用复制算法。这两种算法可以保证不会产生内存碎片,GC后可提供规整的可用内存

#可预测的停顿。G1提供可预测的停顿时间模型,能够明确指定在一段时间内GC时间不超过指定时间

在使用G1时,虽然仍然保留新生代、老年代的概念,但堆内存已经被划分为大小相同的独立Region。新生代与老年代不再物理隔离,而都是多个Region的集合(Region不要求连续)

G1跟踪各个Region里面垃圾收集的价值(回收空间与回收时间的经验值/性价比),在GC时优先回收收益最高的Region,从而避免对整个堆内存进行回收。这就是G1能够提供可预测的停顿时间模型的原因

G1的原理似乎很简单,但是细节却十分复杂,比如不同Region之间对象的引用问题,如果进行全堆扫描,效率会降低很多。

G1的执行大致分为几个步骤:初始标记、并发标记、最终标记、筛选回收

#初始标记,仅仅只是标记一下GC Roots能直接关联到的对象。虽然需要停顿用户线程,但是速度很快

#并发标记,就是进行根节点枚举。此过程通常较长,但是可以与用户线程并发执行

#最终标记,是将并发标记过程中因用户线程产生变动的对象进行重新标记记录。此过程需要停顿用户线程,但是可以并行执行

#筛选回收,根据设定的期望停顿时间,对各Region回收收益进行权衡,然后回收内存。此过程需要停顿用户线程,但是由于只需要回收一部分Region,时间可控,并且停顿用户线程可以大幅提高回收效率

对象的内存分配

对象内存的分配,从整体上看,是在堆上分配。对象被分配在Eden区,如果启用了TLAB,将分配在TLAB上,少数情况下还可能直接分配在老年代中。规则不是完全固定的,细节取决于使用的GC收集器组合以及JVM中内存相关的参数设置。接下来讲解几条基本的、普遍的分配规则

1.对象优先分配在Eden

大多数情况下,对象优先在Eden上分配,当Eden内存不足时,将触发一次Minor GC

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

大对象,指的是需要很大连续内存空间的对象。大量创建大对象,对虚拟机来说是个坏消息,因为很容易导致虽然还有很大内存,但是却没有连续内存存放大对象,从而提前触发GC,另外也会导致新生代GC过程中在Eden和两个Survivor区之间大量的内存复制操作

虚拟机提供-XX:PretenureSizeThreshold参数来设置需要内存超过一定阈值的大对象将直接分配在老年代

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

哪些对象该进入老年代?这就引入了一个age的概念,虚拟机会给每个对象定义一个对象年龄。如果对象在Eden中经过第一次Minor GC后仍然存活,并且能够被Survivor容纳,那么它将被转移到Survivor中,并且age=1。之后每熬过一次Minor GC,age增加1岁。当age达到一定程度(默认15,可通过-XX:MaxTenuringThreshold设置)后,对象将会被晋升到老年代

但是,对象并非需要严格熬到MaxTenuringThreshold才能晋升到老年代。如果在Survivor空间中相同年龄所有对象占用内存总和大于Survivor空间的一半时,年龄大于等于该年龄的对象就会直接被晋升到老年代

4.空间分配担保

在进行Minor GC之前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象的总空间。如果大于,说明这次Minor GC就是安全的。否则,会检查是否允许担保失败。如果不允许,那么进行Full GC。否则,继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。如果小于,依然进行Full GC。否则将尝试进行Minor GC,但这次Minor GC是有风险的

上面提到了风险,由于新生代采用复制算法进行Minor GC,每次只有一个Survivor区(默认10%)用于空间轮换,自然无法100%保证有足够空间用于轮换,因此就需要老年代为此提供担保

与现实中的担保一样,担保人需要有足够的能力保证被担保人无力偿还贷款时自己有能力代为偿还。老年代也需要保证自己有足够的空间容纳这些对象,但是会有多少对象存活在GC前是无法知道的,因此会取历次晋升到老年代对象的平均大小作为经验值,与老年代最大可用连续空间做比较,再决定是否需要进行Full GC

这种手段是一种概率手段,既然是概率手段,那么终归会有失败的可能。如果失败了,那么就将触发一次Full GC。但是在大多数情况下,Full GC并不会发生。因此从概率上来看,这种方案有助于性能提升

笔记2结束

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值