写点什么

太好用了!斩获 3 个大厂 Offer 后,才发现学霸给的 JVM 笔记有多强大

用户头像
飞飞JAva
关注
发布于: 2021 年 05 月 07 日
太好用了!斩获3个大厂Offer后,才发现学霸给的JVM笔记有多强大

Hello,今天给各位童鞋们分享 JVM,赶紧拿出小本子记下来吧!

垃圾回收场景

新生代 GC 场景

在 jvm 内存模型中,新生代的内存分为为 Eden 和两个 Survivor

在系统不停的运行过程中,Eden 区会被塞满,这个时候就会触发 Minor GC,进行垃圾回收有专门的垃圾回收线程,不同的内存区域会有不同的垃圾回收器,相当于垃圾回收线程和垃圾回收器配合起来,使用自己的垃圾回收算法,对指定的内存区域进行垃圾回收,如下图所示:

针对新生代采用 ParNew 垃圾回收器来进行回收,然后 ParNew 垃圾回收器针对新生代采用的就是复制算法来垃圾回收

这个时候垃圾回收器,就会把 Eden 区中的存活对象都标记出来,然后全部转移到 Survivor1 去,接着一次性清空掉 Eden 中的垃圾对象

当 Eden 再次塞满的时候,就又要触发 Minor GC 了,此时已然是垃圾回收线程运行垃圾回收器中的算法逻辑,也就是采用复制算法逻辑,去标记出来 Eden 和 Survivor1 中的存活对象,然后一次性把存活对象转移到 Survivor2 中去,接着把 Eden 和 Survivor1 中的垃圾对象都回收掉

在发生 GC 的时候,我们写好的 JAVA 系统在运行期间还能不能继续在新生代里创建新的对象?

假如在 GC 期间,允许创建新的对象,那么垃圾回收器在把 Eden 和 Survivor1 里的存活对象标记转移到 Survivor2 去,然后还在想办法把 Eden 和 Survivor1 里的垃圾对象都清理掉,结果这个时候系统程序还在不停的在 Eden 里创建新的对象,那么这些新对象很快就成了垃圾对象,有的还有人引用是存活对象,这对垃圾回收器完全乱套,一边回收一边还在创建新的对象。

Stop the World

JVM 最大的痛点,就是垃圾回收的过程,在垃圾回收的时候,尽可能让垃圾回收器专心的工作,不能随便让我们的 Java 应用继续创建对象,所以此时 JVM 会在后台进入“入“Stop the World”状态,也就是说会直接停止我们的 Java 系统的所有工作线程,让我们的代码不再运行

这样的话,就可以让我们的系统暂停运行,然后不再创建新的对象,同时让垃圾回收线程尽快完成垃圾回收的工作,就是标记和转移 Eden 以及 Survivor1 的存活对象到 Survivor2 中去,然后尽快一次性回收掉 Eden 和 Survivor1 中的垃圾对象,等垃圾回收完毕后,继续恢复我们写的 Java 系统的工作线程,然后继续运行我们的代码逻辑,继续在 Eden 区创建新的对象

Stop the World 造成的系统停顿

在运行 GC 的时候会无法创建新的对象,则会造车系统停顿,如果 Minor GC 要运行 50ms,则可能会导致我们的系统在 50ms 内不能接受任何请求,在这 50ms 期间用户发起的所有请求都会出现短暂的卡顿,因为系统的工作线程不在运行,不能处理请求

可能由于内存分配不合理,导致对象频繁进入老年代,平均七八分钟一次 Full GC,而 Full GC 比较慢,一次回收可能需要几秒甚至几十秒,所以一旦频繁的 Full GC,就会造成系统每隔几分钟卡死个几十秒,让用户体验极差

所以说,无论是新生代 GC 还是老年代 GC,都尽量不要让频率过高,也避免持续时间过长,避免影响系统正常运行,这也是使用 JVM 过程中一个最需要优化的地方,也是最大的一个痛点。

不同的垃圾回收器的不同的影响

Serial 垃圾回收器(新生代)

  • 用一个线程进行垃圾回收,然后此时暂停系统工作线程

  • 一般我们在服务器程序中很少用这种方式

ParNew 垃圾回收器(新生代)

  • 常用的新生代垃圾回收器

  • 针对服务器一般都是多核 CPU 做了优化,他是支持多线程个垃圾回收的,可以大幅度提升回收的性能,缩短回收的时间

垃圾回收器

Serial 和 Serial Old 垃圾回收器

  • 分别用来回收新生代和老年代的垃圾对象

  • 工作原理就是单线程运行,垃圾回收的时候会停止我们自己写的系统的其他工作线程,让我们系统直接卡死不动,然后让他们垃圾回收,这个现在一般写后台 Java 系统几乎不用。

ParNew 和 CMS 垃圾回收器

  • ParNew 现在一般都是用在新生代的垃圾回收器,采用的就是复制算法来垃圾回收

  • CMS 是用在老年代的垃圾回收器

  • 都是多线程并发的机制,性能更好,现在一般是线上生产系统的标配组合

ParNew

理论

  • 没有最新的 G1 垃圾回收器的话,通常大家线上系统都是 ParNew 垃圾回收器作为新生代的垃圾回收器当然现在即使有了 G1,其实很多线上系统还是用的 ParNew

  • 通常运行在服务器上 Java 系统,都可以充分利用服务器的多核 CPU 优势,如果对新生代回收的时候,仅仅使用单线程进行垃圾回收,会导致浪费 CPU 的资源

  • 新生代的 ParNew 垃圾回收器主打的就是多线程垃圾回收机制,另外一种 Serial 垃圾回收器主打的是单线程垃圾回收,他们俩都是回收新生代的,唯一的区别就是单线程和多线程的区别,但是垃圾回收算法是完全一样

  • ParNew 垃圾回收器如果一旦在合适的时机执行 Minor GC 的时候,就会把系统程序的工作线程全部停掉,禁止程序继续运行创建新的对象,然后自己就用多个垃圾回收线程去进行垃圾回收,回收的机制和算法都是一样的

参数设置

部署到 Tomcat 时可以在 Tomcat 的 catalina.sh 中设置 Tomcat 的 JVM 参数,使用 Spring Boot 也可以在启动时指定 JVM 参数。

  • 指定使用 ParNew 垃圾回收器

使用“-XX:+UseParNewGC”选项,只要加入这个选项,JVM 启动之后对新生代进行垃圾回收的,就是 ParNew 垃圾回收器

  • ParNew 垃圾回收器默认情况下的线程数量

一旦我们指定了使用 ParNew 垃圾回收器之后,他默认给自己设置的垃圾回收线程的数量就是跟 CPU 的核数是一样的

如果你一定要自己调节 ParNew 的垃圾回收线程数量,也是可以的,使用“-XX:ParallelGCThreads”参数即可,通过他可以设置线程的数量

CMS

理论

  • 老年代选择的垃圾回收器是 CMS,他采用的是标记清理算法

  • 标记清理算法:先通过 GC Roots 的方法,看各个对象是否被 GC Roots 给引用,如果是的话,那就是存活对象,否则就是垃圾对象。先将垃圾对象都标记出来,然后一次性把垃圾对象都回收掉,这种方法最大问题:就是会造成很多内存碎片,这种内存碎片不大不小,可能放不下任何一个对象,则会造成内存浪费

  • CMS 的 STW(Stop the World)问题:如果停止一切工作线程,然后慢慢的执行“标记-清理”算法,会导致系统卡死时间过长,很多响应无法处理。所以 CMS 垃圾回收器采取的是:垃圾回收线程和系统工作线程尽量同时执行的模式来处理

如何实现系统一边工作的同时进行垃圾回收?

CMS 在执行一次垃圾回收的过程共分为 4 个阶段:

  • 初始标记

  • 并发标记

  • 重新标记

并发清理

1、初始标记

CMS 在进行垃圾回收时,会先执行初始标记阶段。这个阶段会让系统的工作线程全部停止,进入“Stop The World”状态,初始标记执行 STW 影响不大,因为他的速度比较快,只是标记出 GC Roots 直接应用的对象

2、并发标记

这个阶段会让系统可以随意创建各种新对象,继续运行,在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行 GC Roots 追踪,但是这个过程中,在进行并发标记的时候,系统程序会不停的工作,他可能会各种创建出来新的对象,部分对象可能成为垃圾

这个阶段就是对老年代所有对象进行 GC Roots 追踪,其实是最耗时的,需要追踪所有对象是否从根源上被 GC Roots 引用了,但是这个最耗时的阶段,是跟系统程序并发运行的,所以其实这个阶段不会对系统运行造成影响。

3、重新标记

因为第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾,所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的。所以此时进入第三阶段,要继续让系统程序停下来,**再次进入“Stop the World”阶段。**然后重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况

这个重新标记的阶段,是速度很快的,他其实就是对在第二阶段中被系统程序运行变动过的少数对象进行标记,所以运行速度很快,接着重新恢复系统程序的运行。

4、并发清理

让系统程序随意运行,然后他来清理掉之前标记为垃圾的对象,这个阶段比较耗时,需要进行对象的清理,但是他是跟着系统程序并发运行的,所以也不影响系统程序的执行

CMS 垃圾回收器问题

1、并发回收导致 CPU 资源紧张

CMS 垃圾回收器有一个最大的问题,虽然能在垃圾回收的同时让系统同时工作,在并发标记和并发清理两个最耗时的阶段,垃圾回收线程和系统工作线程同时工作,会导致有限的 CPU 资源被垃圾回收线程占用了一部分

并发标记的时候,需要对 GC Roots 进行深度追踪,看所有对象里面到底有多少人是存活的但是因为老年代里存活对象是比较多的,这个过程会追踪大量的对象,所以耗时较高。并发清理,又需要把垃圾对象从各种随机的内存位置清理掉,也是比较耗时的

所以在这两个阶段,CMS 的垃圾回收线程是比较耗费 CPU 资源的。CMS 默认启动的垃圾回收线程的数量是(CPU 核数 + 3)/ 4

2、Concurrent Mode Failure 问题

在并发清理阶段,CMS 只不过是回收之前标记好的垃圾对象,但是这个阶段系统一直在运行,可能会随着系统运行让一些对象进入老年代,同时还变成垃圾对象,这种垃圾对象是“浮动垃圾”。因为他虽然成为了垃圾,但是 CMS 只能回收之前标记出来的垃圾对象,不会回收他们,需要等到下一次 GC 的时候才会回收他们。所以为了保证在 CMS 垃圾回收期间,还有一定的内存空间让一些对象可以进入老年代,一般会预留一些空间。CMS 垃圾回收的触发时机,其中有一个就是当老年代内存占用达到一定比例了,就自动执行 GC。

“-XX:CMSInitiatingOccupancyFaction”参数可以用来设置老年代占用多少比例的时候触发 CMS 垃圾回收,JDK 1.6 里面默认的值是 92%

也就是说,老年代占用了 92%空间了,就自动进行 CMS 垃圾回收,预留 8%的空间给并发回收期间,系统程序把一些新对象放入老年代中。

那么如果 CMS 垃圾回收期间,系统程序要放入老年代的对象大于了可用内存空间,此时会如何?

这个时候,会发生 Concurrent Mode Failure,就是说并发垃圾回收失败了,我一边回收,你一边把对象放入老年代,内存都不够

此时就会自动用“Serial Old”垃圾回收器替代 CMS,就是直接强行把系统程序“Stop the World”,重新进行长时间的 GC Roots 追踪,标记出来全部垃圾对象,不允许新的对象产生,然后一次性把垃圾对象都回收掉,完事后再恢复系统线程

3、内存碎片问题

老年代的 CMS 采用“标记-清理”算法,每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生。如果内存碎片太多,会导致后续对象进入老年代找不到可用的连续内存空间了,然后触发 Full GC

所以 CMS 不是完全就仅仅用“标记-清理”算法的,因为太多的内存碎片实际上会导致更加频繁的 Full GC

CMS 有一个参数是“-XX:+UseCMSCompactAtFullCollection”,默认就打开,意思是在 Full GC 之后要再次进行“Stop the World”,停止工作线程,然后进行碎片整理,就是把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片

还有一个参数是“-XX:CMSFullGCsBeforeCompaction”,这个意思是执行多少次 Full GC 之后再执行一次内存碎片整理的工作,默认是 0,意思就是每次 Full GC 之后都会进行一次内存整理

触发老年代 GC 的时机

1、老年代可用内存小于新生代全部对象的大小,如果没开启空间担保参数,会直接触发 Full GC,所以一般空间担保参数都会打开;

2、老年代可用内存小于历次新生代 GC 后进入老年代的平均对象大小,此时会提前 Full GC;

3、新生代 Minor GC 后的存活对象大于 Survivor,那么就会进入老年代,此时老年代内存不足;

4、-XX:CMSInitiatingOccupancyFaction:老年代的已用内存大于设定的阀值,就会触发 Full GC;

5、显示调用 System.gc

ParNew + CMS 带给我们的痛点是什么

Stop the World,这个是大家最痛的一个点

无论是新生代垃圾回收,还是老年代垃圾回收,都会或多或少产生“Stop the World”现象,对系统的运行是有一定影响的。所以其实之后对垃圾回收器的优化,都是朝着减少“Stop the World”的目标去做的。

在这个基础之上,G1 垃圾回收器就应运而生了,他可以提供比“ParNew + CMS”组合更好的垃圾回收的性能

G1 垃圾回收器

特点

G1 垃圾回收器是可以同时回收新生代和老年代的对象的,不需要两个垃圾回收器配合起来运作,他一个人就可以搞定所有的垃圾回收。

1、把 Java 堆内存拆分为多个大小相等的 Region

G1 也会有新生代和老年代的概念,但是只不过是**逻辑上的概念**

也就是说新生代可能包含了某些 Region,老年代可能包含了某些 Region。

2、可以设置一个垃圾回收的预期停顿时间

也就是说比如我们可以指定:希望 G1 在垃圾回收的时候,可以保证,在 1 小时内由 G1 垃圾回收导致的“Stop the World”时间,也就是系统停顿的时间,不能超过 1 分钟,这样相当于我们就可以直接控制垃圾回收对系统性能的影响

3、Region 可能属于新生代也可能属于老年代

刚开始 Region 可能谁都不属于,然后接着就分配给了新生代,然后放了很多属于新生代的对象,接着就触发了垃圾回收这个 Region,下一次同一个 Region 可能又被分配了老年代了,用来放老年代的长生存周期的对象,所以其实在 G1 对应的内存模型中,Region 随时会属于新生代也会属于老年代,所以没有所谓新生代给多少内存,老年代给多少内存这一说

实际上新生代和老年代各自的内存区域是不停的变动的,由 G1 自动控制

G1 是如何做到对垃圾回收导致的系统停顿可控的?

其实 G1 如果要做到这一点,他就必须要追踪每个 Region 里的回收价值,啥叫做回收价值呢?

他必须搞清楚每个 Region 里的对象有多少是垃圾,如果对这个 Region 进行垃圾回收,需要耗费多长时间,可以回收掉多少垃圾?G1 通过追踪发现,1 个 Region 中的垃圾对象有 10MB,回收他们需要耗费 1 秒钟,另外一个 Region 中的垃圾对象有 20MB,回收他们需要耗费 200 毫秒。

然后在垃圾回收的时候,G1 会发现在最近一个时间段内,比如 1 小时内,垃圾回收已经导致了几百毫秒的系统停顿了,现在又要执行一次垃圾回收,那么必须是回收上图中那个只需要 200ms 就能回收掉 20MB 垃圾的 Region;于是 G1 触发一次垃圾回收,虽然可能导致系统停顿了 200ms,但是一下子回收了更多的垃圾,就是 20MB 的垃圾

所以简单来说,G1 可以做到让你来设定垃圾回收对系统的影响,他自己通过把内存拆分为大量小 Region,以及追踪每个 Region 中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象。这就是 G1 的核心设计思路

如何设定 G1 对应的内存大小?

G1 对应的是一大堆的 Region 内存区域,每个 Region 的大小是一致的,默认情况下自动计算和设置的,可以给整个堆内存设置一个大小,比如说用“-Xms”和“-Xmx”来设置堆内存的大小

JVM 启动的时候,发现使用的是 G1 垃圾回收器(通过:用“-XX:+UseG1GC”来指定使用 G1 垃圾回收器),此时会自动用堆大小除以 2048,JVM 最多可以有 2048 个 Region,然后 Region 的大小必须是 2 的倍数,比如说 1MB、2MB、4MB 之类,可以通过手动方式来指定,则是**“-XX:G1HeapRegionSize“**

刚开始的时候,默认新生代对堆内存的占比是 5%,这个是可以通过“-XX:G1NewSizePercent”来设置新生代初始占比的,其实维持这个默认值即可

在系统运行中,JVM 其实会不停的给新生代增加更多的 Region,但是最多新生代的占比不会超过 60%,可以通过“-XX:G1MaxNewSizePercent”,而且一旦 Region 进行了垃圾回收,此时新生代的 Region 数量还会减少,这些其实都是动态

新生代还有 Eden 和 Survivor 的概念?

  • G1 中虽然把内存划分为很多的 Region,但是其实还是有新生代、老年代的区分,而且新生代里还是有 Eden 和 Survivor 的划分

  • 通过参数,“-XX:SurvivorRatio=8”,可以设置新生代中 80%的 Region 属于 Eden,两个 Survivor 各自占 10%

  • 随着对象不停的在新生代里分配,属于新生代的 Region 会不断增加,Eden 和 Survivor 对应的 Region 也会不断增加

G1 的新生代垃圾回收触发机制?

既然 G1 的新生代也有 Eden 和 Survivor 的区分,那么触发垃圾回收的机制都是类似的,随着不停的在新生代的 Eden 对应的 Region 中放对象,JVM 就会不停的给新生代加入更多的 Region,直到新生代占据堆大小的最大比例 60%。

一旦新生代达到了设定的占据堆内存的最大大小 60%,这个时候还是会触发新生代的 GC,G1 就会用之前说过的复制算法来进行垃圾回收,进入一个“Stop the World”状态,然后把 Eden 对应的 Region 中的存活对象放入 S1 对应的 Region 中,接着回收掉 Eden 对应的 Region 中的垃圾对象,但是这个过程跟之前是有区别的,因为 G1 是可以设定目标 GC 停顿时间的,也就是 G1 执行 GC 的时候最多可以让系统停顿多长时间,可以通过“-XX:MaxGCPauseMills”参数来设定,默认值是 200ms。

那么 G1 就会通过之前说的,对每个 Region 追踪回收他需要多少时间,可以回收多少对象来选择回收一部分的 Region,保证 GC 停顿时间控制在指定范围内,尽可能多的回收掉一些对象。

对象什么时候进入老年代?

可以说跟之前几乎是一样的,还是这么几个条件:

1、对象在新生代躲过了很多次的垃圾回收,达到了一定的年龄了,“-XX:MaxTenuringThreshold”参数可以设置这个年龄,就会进入老年代

2、 动态年龄判定规则,如果一旦发现某次新生代 GC 过后,存活对象超过了 Survivor 的 50%

大对象 Region

在之前,大对象是直接进入老年代,在 G1 的内存模型中,G1 提供了专门的 Region 来存放大对象,而不是让大对象直接进入老年的 Region 中。

在 G1 中,大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%,如果每个 Region 是 2MB,只要一个大对象超过了 1MB,就会被放入大对象专门的 Region 中,而且一个大对象如果太大,可能会横跨多个 Region 来存放在新生代、老年代回收的时候,会顺带带着大对象 Region 一起回收

好啦,今天的文章就到这里,希望能帮助到屏幕前迷茫的你们!

用户头像

飞飞JAva

关注

还未添加个人签名 2021.04.28 加入

分享、普及java相关知识

评论 (1 条评论)

发布
用户头像
要点脸吧。抄袭儒猿技术窝的专栏 http://apppukyptrl1086.pc.xiaoe-tech.com/
2021 年 05 月 08 日 08:53
回复
没有更多了
太好用了!斩获3个大厂Offer后,才发现学霸给的JVM笔记有多强大