其他更多java基础文章:
java基础学习(目录)
通过前一篇JVM学习(一)——内存结构对JVM内存结构的讲解。我们知道程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。栈中的栈帧随着方法的进入和退出就有条不紊的执行者出栈和入栈的操作,每一个栈分配多少个内存基本都是在类结构确定下来的时候就已经确定了,这几个区域内存分配和回收都具有确定性
而堆和方法区则不同,一个接口的实现是多种多样的,多个实现类需要的内存可能不一样,一个方法中多个分支需要的内存也不一样,我们只能在程序运行的期间知道需要创建那些对象,分配多少内存,这部分的内存分配和回收都是动态的。这篇所讲的GC垃圾回收机制就是回收堆和方法区数据的机制。
1.判断对象存活
1.1 引用计数器法
给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
引用计数法有一个重大的漏洞,那便是无法处理循环引用对象。举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活
着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。
1.2 可达性分析算法
通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的 Java语言中GC Roots的对象包括(包括但不限于)下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈JNI(Native方法)引用的对象
虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。
比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引
用设置为 null)或者漏报(将引用设置为未被访问过的对象)。
误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回 收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会 直接导致 Java 虚拟机崩溃。
1.3 Stop-the-world 以及安全点
怎么解决这个问题呢?在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便 是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收 所谓的暂停时间(GC pause)。
Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程 进行独占的工作。
safepoint 安全点顾名思义是指一些特定的位置,当线程运行到这些位置时,线程的一些状态可以被确定(the thread's representation of it's Java machine state is well described),比如记录OopMap的状态,从而确定GC Root的信息,使JVM可以安全的进行一些操作,比如开始GC。
safepoint指的特定位置主要有:
- 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
- 方法返回前
- 调用方法的call之后
- 抛出异常的位置
之所以选择这些位置作为safepoint的插入点,主要的考虑是“避免程序长时间运行而不进入safepoint”,比如GC的时候必须要等到Java线程都进入到safepoint的时候VMThread才能开始执行GC。
2. JVM垃圾回收算法
常见的垃圾回收算法包括:标记-清除算法,复制算法,标记-整理算法,分代收集算法。
2.1 标记—清除算法(Mark-Sweep)
之所以说标记/清除算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。标记/清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;
清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。
不足:
- 标记和清除过程效率都不高
- 会产生大量碎片,内存碎片过多可能导致无法给大对象分配内存。
2.2 复制算法(Copying)
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和 使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间。
不足:
- 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
- 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
2.3 标记—整理算法(Mark-Compact)
标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,因此其不会产生内存碎片。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。
不足:
效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
2.4 分代收集算法(Generational Collection)
分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。
新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。
老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对象需要回收,故采用标记清除或者标记整理算法。
3. JVM中的GC过程
3.1 JVM中的堆分区
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2( 该值可以通过参数 –XX:NewRatio 来指定 )
新生代 ( Young ) 又被划分为
三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
3.1.1 新生代
主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。 新生代又分为 Eden区、ServivorFrom、ServivorTo三个区,默认比例8:1:1。
- Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。
- ServivorTo:保留了一次MinorGC过程中的幸存者。
- ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者。
MinorGC的过程:MinorGC采用复制算法。首先,把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区);然后,清空Eden和ServicorFrom中的对象;最后,ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区。
3.1.2 老年代
老年代的对象比较稳定,所以fullGC不会频繁执行。在进行fullGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次fullGC进行垃圾回收腾出空间。
fullGC根据不同垃圾回收器采用标记—清除算法或标记-整理算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。fullGC的耗时比较长,因为要扫描再回收。fullGC会产生内存碎片,为了减少内存损耗,我们一般需要进行整理或者标记出来方便下次直接分配。
当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。
3.2 JVM对象分配策略
3.2.1 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
3.2.2 大对象直接进入年老代
大对象即需要大量连续内存空间的Java对象,如长字符串及数组。经常出现大对象导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置他们。 虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。 这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。
3.2.3 长期存活的对象将进入年老代
虚拟机给每个对象定义了一个对象年龄计数器,在对象在Eden创建并经过第一次Minor GC后仍然存活,并能被Suivivor容纳的话,将会被移动到Survivor空间,并对象年龄设置为1。每经历过Minor GC,年龄就增加1岁,当到一定程度(默认15岁,可以通过参数-XXMaxTenuringThreshold设置),就将会晋升年老代。
3.2.4 动态对象年龄判定
为了更好地适应不同程序内存状况,虚拟机并不硬性要求对象年龄达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入年老代。
3.2.5 空间分配担保
在发生Minor GC之前,虚拟机会先检查年老代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果条件成立,那么Minor GC可以确保是安全的。
- 如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
- 如果允许,那么会继续检查年老代最大可用连续空间是否大于历次晋升到年老代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的。
- 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时候改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。
与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。
如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。 虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
3.3 图解JVM GC过程
这是我在学习过程中,发现的一个简单易懂的GC过程学习文章,通过图的方式,清晰明了。图解JVM GC过程
4. JVM中的垃圾回收器
4.1 七种垃圾回收器
- 并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
- 并发:指用户线程与垃圾收集线程同时执行(不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行于另一个CPU上
这几篇是我在学习过程中,觉得讲得不错的文章。
我将学习资料中的内容简单概括为下表:
| 名字 | 特点 | 线程 | 回收区域 | 回收算法 |
|---|---|---|---|---|
| Serial收集器 | 最高的单线程收集效率 | 单线程 | 新生代 | 复制 |
| ParNew收集器 | 可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。 | 多线程 | 新生代 | 复制 |
| Parallel Scavenge收集器 | 关注系统吞吐量,目标是达到一个可控制的吞吐量,也经常称为“吞吐量优先”收集器 | 多线程 | 新生代 | 复制 |
| Serial Old收集器 | 年老代收集器,可以和所有的年轻代收集器组合使用(Serial收集器的年老代版本) | 单线程 | 老年代 | 标记-整理 |
| Parallel Old收集器 | Parallel Scavenge收集器的老年代版本,关注吞吐量,这个收集器是在JDK 1.6中才开始提供的。 | 多线程 | 老年代 | 标记-整理 |
| CMS收集器 | 一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器存在3个缺点:1.对CPU资源敏感。一般并发执行的程序对CPU数量都是比较敏感的。2.无法处理浮动垃圾。在并发清理阶段用户线程还在执行,这时产生的垃圾无法清理。3.由于标记-清除算法产生大量的空间碎片 。此外除了CMS的GC,其实其他针对old gen的回收器都会在对old gen回收的同时回收young gen。 |
多线程并发收集 | 老年代 | 标记-清除 |
| G1收集器 | 1.可以像CMS收集器一样,GC操作与应用的线程一起并发执行。2.紧凑的空闲内存区间且没有很长的GC停顿时间(标记整理算法,复制算法)。3.需要可预测的GC暂停耗时。4.不想牺牲太多吞吐量性能。5.启动后不需要请求更大的Java堆。 | 多线程并发收集 | 整个Java堆 | 标记-整理 |
4.2 各垃圾收集参数设置
- -Xmx: 设置堆内存的最大值。
- -Xms: 设置堆内存的初始值。
- -Xmn: 设置新生代的大小。
- -Xss: 设置栈的大小。
- -PretenureSizeThreshold: 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。
- -MaxTenuringThrehold: 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就会加1,当超过这个参数值时就进入老年代。
- -UseAdaptiveSizePolicy: 在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。
- -SurvivorRattio: 新生代Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Suvivor= 8: 1。
- -XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
- -XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
- -XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。