前言
JVM作为Java进阶的知识,是需要Java程序员不断深度和理解的。
本篇博客介绍JVM的内存模型,对比了1.7和1.8的内存模型的变化;介绍了垃圾回收的语言发展;阐述了定位垃圾的方法,引用计数法和可达性分析发以及垃圾清除算法;然后介绍了Java中的垃圾回收器,由串行、到并行再到并发,最后到G1的演变;最后给出了垃圾回收器的对比和使用指引。
其他相关的JVM博客文章如下:
- Java进阶(1)——JVM的内存分配 & 反射Class类的类对象 & 创建对象的几种方式 & 类加载(何时进入内存JVM)& 注解 & 反射+注解的案例
- Java进阶(4)——结合类加载JVM的过程理解创建对象的几种方式:new,反射Class,克隆clone(拷贝),序列化反序列化
系列文章合集如下:
【合集】Java进阶——Java深入学习的笔记汇总 & 再论面向对象、数据结构和算法、JVM底层、多线程、类加载 …
目录
- 前言
- 引出
- JVM内存模型
- JDK1.7
- JDK1.8
- 垃圾回收基础
- 栈空间和堆空间
- 垃圾回收的语言发展
- 如何定位垃圾
- 引用计数法
- 可达性分析方法
- 垃圾清除算法
- 标记-清除
- 标记-复制
- 标记-压缩
- 垃圾回收器
- 总览
- 串行Serial 和 Serial-Old
- 并行Parallel (jdk1.8默认)
- 并发ParNew,CMS
- 三色标记法
- Garbage FirstGC回收器
- 在jdk1.8用G1
- G1垃圾回收过程
- G1的四大特点
- G1的适用场景
- 垃圾回收器的使用
- G1调优快速使用
- 8种GC回收器的比较
- 怎么选择GC回收器
- 总结
引出
1.JVM的内存模型,对比了1.7和1.8的内存模型的变化;
2.垃圾回收的语言发展;定位垃圾的方法,引用计数法和可达性分析发以及垃圾清除算法;
3.Java中的垃圾回收器,由串行、到并行再到并发,最后到G1的演变;
4.垃圾回收器的对比和使用指引。
JVM内存模型
JDK1.7
Young区(年轻代区):Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用,在Eden区间变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区间
Tenured 年老区:Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区,一般如果系统中用了application级别的缓存,缓存中的对象往往会被转移到这一区间。
Perm 永久区:Perm代主要保存class,method,filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到java.lang.OutOfMemoryError : PermGen space 的错误,造成这个错误的很大原因就有可能是每次都重新部署,但是重新部署后,类的class没有被卸载掉,这样就造成了大量的class对象保存在了perm中,这种情况下,一般重新启动应用服务器可以解决问题。
Virtual区:最大内存和初始内存的差值,就是Virtual区。
JDK1.8
jdk1.8的内存模型是由2部分组成,年轻代 + 年老代。
年轻代:Eden + 2*Survivor
年老代:OldGen
在jdk1.8中变化最大的Perm区,用Metaspace(元数据空间)进行了替换。
需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中,这也是与1.7的永久代最大的区别所在
1.7到1.8的变化
垃圾回收基础
栈空间和堆空间
栈空间:方法结束,自动回收!
堆空间:需要手动管理回收!
程序中最难调试的BUG:野指针(空指针),并发问题
所以,Java为什么不让程序员来手动删除对象,明白不?上述的情况,可能会出现在比如:C语言,C++ 的语言场景中!
垃圾回收的语言发展
在C/C++语言中,没有自动垃圾回收机制,是通过new关键字申请内存资源,通过delete关键字释放内
存资源。如果,程序员在某些位置没有写delete进行释放,那么申请的对象将一直占用内存资源,最终
可能会导致内存溢出。
C / C++ 语言:
- 手动管理
- 忘记释放
- 释放多次
- 开发效率低
Java Python Go语言:
- 自动管理
- 引入GC Garbage Collector
- 大大减低程序员门槛
应用程序只管分配,垃圾回收器负责回收!
如何定位垃圾
垃圾:没有引用指向的对象,就是垃圾对象!
如何找到垃圾?算法:引用计数法,可达性分析方法
引用计数法
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。
Python使用的较为多,但是,这种算法缺陷在于:无法解决循环引用的问题
public class A {public static void main(String[] args) {TestA a = new TestA();TestB b = new TestB();a.b = b;b.a = a;a = null;b = null;}}class TestA {public TestB b;
}
class TestB {public TestA a;
}
优缺点
优点:
实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
在垃圾回收过程中,应用程序无需挂起。如果申请内存时,内存不足,则立刻报out of memery 错误。局部更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
每次对象被引用时,都需要去更新计数器,有一点时间开销。浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。无法解决循环引用问题。(最大的缺点)
可达性分析方法
Java早期使用,但后期更喜欢采用:可达性分析方法
那什么是根呢?JVM线程栈,本地方法栈,运行时常量池,方法区的静态引用,类对象!
main()方法启动的哪些内容,就是根!
垃圾清除算法
标记-清除
标记-复制
标记-压缩
标记-清除
标记-清除算法:
但是这种算法:导致内存碎片化过于严重
优缺点
优点:
可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。
缺点:
效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。
标记-复制
标记-复制算法:将内存一分为二,一份专门用于复制!
但是这种算法:浪费内存
标记-压缩
标记-压缩算法:边清理,边压缩
但是这种算法:效率太低
三种算法都有问题,JVM采用三种的综合运用,产生了各种各样的GC垃圾回收器!
垃圾回收器
总览
随着内存大小不断变化,演变出越来越多的垃圾回收器!
分代模型:新生代1、老年代2
新生代:eden, 幸存区1,幸存区2 比例:8:1
在新生代,至少就可以做到90%的对象,都会被回收掉!
Minor-GC触发的时机:Eden满!
Full-GC触发的时机:Old满!
串行Serial 和 Serial-Old
Serial垃圾回收器:内存在几M - 几十M的场景下
Serial GC是一种单线程的垃圾回收器,它在一个单独的线程上运行,不会使用多个线程来进行垃圾回收,在回收的过程中,会停止所有业务线程(stop-the-world简称:stw),用于Minor-GC!算法:标记-复制算法
Serial-Old垃圾回收器
Serial-Old GC也是一种单线程的垃圾回收器,它在一个单独的线程上运行,不会使用多个线程来进行垃圾回收,在回收的过程中,会停止所有业务线程(stop-the-world简称:stw),用于Full-GC!算法:标记-清除算法、标记-压缩算法
并行Parallel (jdk1.8默认)
并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。
Parallel垃圾回收器:内存在几十M - 几G的场景下
所以区别在于:Serial是单线程的,而Parallel是多线程并行的,房子大了,一个人忙不过来!
JDK1.8 默认采用的就是:Parallel 的组合 ps+po
java -XX:+PrintCommandLineFlags -version #在cmd中查看当前使用的GC回收器
JDK11使用G1
并发ParNew,CMS
当内存继续增大到 几十个G呢?多线程有时也不能解决根本问题了,是否是多线程越多越好呢?
Concurrent GC垃圾回收器:内存在几十G以上的场景下
Serial & Parallel 在GC时,都会打断业务线程,让业务线程停止(STW)
并发标记清除GC:意思是 GC回收线程 & 业务线程 可以并行处理!业务线程在产生垃圾,GC回收线程尽可能的让“业务线程”不需要STW的情况下,就可以及时回收业务线程所产生的垃圾!
尽可能少的STW,可以并行回收,这便是:Concurrent GC垃圾回收器最厉害的地方!
上述的图中,也只有 初始标记 & 重新标记 阶段,是需要STW的!
CMS全称 Concurrent Mark Sweep
,是一款并行的、使用标记-清除算法的老年代垃圾回收器!
可以搭配Serial / ParNew 这些新生代的GC回收器使用!
初始标记,只标记Root上的根&根直接引用的对象,所以尽管采用STW,它的时间也不会太长!
重新标记,毕竟误标的对象,不会太多,所以STW的时间,也不会太长!
三色标记算法,发生在初始标记,并发标记,重新标记阶段,主要由3种颜色组成:黑,灰,白
黑:对象被标记,且它的所有子对象也被标记完
灰:对象被标记,但是它的子对象还没有被标记或标记完
白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉
三色标记法
三色标记法的标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark),
-
初始标记:遍历所有的根对象,将根对象和直接引用的对象标记为灰色。在这个阶段中,垃圾回收器只会扫描被直接或者间接引用的对象,而不会扫描整个堆因此,初始标记阶段的时间比校短。(Stop The World)
-
并发标记:在这个过程中,垃圾回收器会从灰色对像开始遍历整个对象图,将被引用的对像标记为灰色,并将己经遍历过的对象标记为黑色。并发标记过程中,应用程序线程可能会修改对家图,因此垃圾回收器需要便用写屏障(Write Barrier)技术来保证并发标记的正确性,(不需要STW)
-
重新标记:重新标记的主要作用足标记在并发标记阶段中被修改的对象以及未被遍历到的对象。这个过程中,垃圾回收器会从灰色对象重新开始遍历对象图,将被引用的对家标记为灰色,并将已经遍历过的对象标记为黑色。(Stop The World)
在重新标记阶段结束之后,垃圾回收器会执行清除操作,将未被标记为可达对象的对象进行回收,从而释放内存空间,这个过程中,垃圾回收器会将所有未被标记的对象标记为白色(White)
三色标记算法的BUG:可能会产生多标,漏标的问题!漏标是最严重的问题,当然CMS & G1他们分别采用的解决方案也不一样!
多标:产生浮动垃圾,浮动垃圾情况不严重,下次GC线程直接回收就好!
漏标
CMS垃圾回收算法,在JDK1.8之后,就被干掉了!
CMS在增量更新阶段,会根据可达性分析算法,重头到尾扫描重新标记一次堆对象,所以STW时间将会非常的长!
Garbage FirstGC回收器
G1回收器,是当下的最为主流的垃圾回收器!
Garbage First 垃圾优先
G1垃圾回收器,抛弃了传统从物理上进行的分代算法,而采用分区region + 逻辑分代的算法!
G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在DK7就已加入JVM的收集器大家庭中,成为HotSpot重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以 用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。 G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存, 对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以 通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前 一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的 Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且 一个大对象如果太大,可
能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。 Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
G1回收器,同样采用三色标记算法来完成 对象的标记,但是对于漏标的问题,它采用的解决方案是:原始快照
原始快照,GC线程在退出运行时,将所有灰色节点下的所有的引用,拍摄快照到Rset中;GC线程恢复后,直接使用快照数据;
“所有的灰色对家自己引用扫描完成之前册除了对白色对象的明引用”,这个条件如果被破坏了,那么就不会出现漏标的问题。所以:
如果灰色对象在扫描完成前到除了对白色对象的引用,那么我们就在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来在后续「重新标记] 阶段再以这些白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。
但是这种方式可能会把本来真的要取消弱引用的对象给错的活了,从而产生浮垃级,但是就像前面说的,多标的问题是可以忽略的.
在jdk1.8用G1
ZGC(Oracle官方), Shenandoah(Redhat红帽) 这2种是:下一代的垃圾回收器(分页垃圾回收器),Epsilon 是空的回收器,主要用于开发JVM GC回收器的程序员Debug使用!
- 在JDK1.7版本正式启用,移除了Experimental的标识,是JDK9以后的默认垃圾回收器,取代了CMs回收器以及Parallel+Parallel0ld组合。被Oracle官方称为全功能的垃圾收集器”。
- 与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。
- G1在dk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。
如果在jdk1.8用G1则:
java -jar -XX:+UseG1GC 某一个项目.jar
G1垃圾回收过程
G1垃圾回收3种模式:YoungGC、 Mixed GC和Full GC
G1GC的垃圾回收过程主要包括如下三个环节:
- 年轻代GC(Young GC)
- 老年代并发标记过程(Concurrent Marking)
- 混合回收(Mixed GC)
- Full GC(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
顺时针,young gc一>young gc+concurrent mark一>Mixed GC)顺序,进行垃圾回收。
1.应用程序分配内存,当年轻代的Ed区用尽时开始年轻代回收过程:G1的年轻代收集阶段是一个并行的(多个回收线程)独占式(STW收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
2.当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程
3.标记完成马.上开始混合回收过程。对于一个混合回收期,G1GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的.
举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
G1的四大特点
-
并行与并发
- ➢并行性: G1在回收期间,可以有多个Gc线程同时工作,有效利用多核计算能力。此时用户线程STW
- ➢并发性: G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
-
分代收集
- ➢从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
- ➢将堆空间分为若干个区域(Region) ,这些区域中包含了逻辑上的年轻代和老年代。
- ➢和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;
-
空间整合
- ➢CMS:“标记-清除”算法、内存碎片、若干次Gc后进行一次碎片整理
- ➢G1将内存划分为一个个的region。 内存的回收是以region作为基本单位的。Region之间是复制算法。
- 但整体上实际可看作是标记一压缩(Mark一Compact)算法,两种算法都可以避免内存碎片。
- 这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
-
可预测的停顿时间模型
- ➢由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
- ➢G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率。
- ➢相比于CMSGC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况也要好很多。
G1的适用场景
(1)面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
(2)用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
①超过50%的Java堆被活动数据占用;
②对象分配频率或年代提升频率变化很大;
③GC停颅时间过长(长于0.5至1秒)。
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC(线程优先级低)的多线程操作
而G1GC可以采用应用线程承担后台运行的GC工作,即当M的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收
过程。
垃圾回收器的使用
G1调优快速使用
最简单的使用G1调优3大步骤:
- 开启G1GC
- 设置堆的最大内存【防止内存抖动】
- 设置最大的停顿时间【单位毫秒】
简化设置:
java -jar -XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=200 某一个项目.jar
完整设置:
java -jar -XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8 -XX:G1HeapRegionSize=16m -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=40 -XX:TargetSurvivorRatio=50 -XX:MaxTenuringThreshold=15 -XX:InitiatingHeapOccupancyPercent=45 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/root/gc.log 某一个项目.jar
-Xms4g 设置初始堆的大小
-Xmx4g 设置最大堆的大小
-XX:+UnlockExperimentalVMOptions 解锁JVM额外参数
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -Xloggc:/root/gc.log 打印详细的 GC 日志,包括日期和时间戳,并将其写入指定的文件
8种GC回收器的比较
怎么选择GC回收器
怎么选择垃圾收集器?
1.优先调整堆的大小让JVM自适应完成。
2.如果内存小于100M,使用串行收集器
3.如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
4.如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
5.如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
总结
1.JVM的内存模型,对比了1.7和1.8的内存模型的变化;
2.垃圾回收的语言发展;定位垃圾的方法,引用计数法和可达性分析发以及垃圾清除算法;
3.Java中的垃圾回收器,由串行、到并行再到并发,最后到G1的演变;
4.垃圾回收器的对比和使用指引。