jvm详解

121 阅读24分钟

1.类加载

加载->验证->准备->解析->初始化->使用->卸载

启动类加载器(java安装目录下得lib文件)->扩展类加载器(lib\ext目录)->应用程序类加载器(加载写好的java代码)->自定义加载类(自己定义)

2.内存分布

2.1MetaSpace 元数据空间,存放类相关信息

2.2 字节码执行引擎

2.3 java虚拟机栈

方法里会定义一些方法内的局部变量,jvm必须有一块区域用来存放,这就是虚拟机栈

继续执行

继续执行

isLocalDataCorrupt执行完毕后,出栈

内存分布图

3.jvm内存相关配置

  • -Xms java堆内存大小
  • -Xmx java堆内存最大大小
  • -Xmn java堆内存的新生代大小,扣除新生代剩下的就是老年代的大小了
  • -XX:PermSize: 永久代大小
  • -XX:MaxPermSize:永久代最大大小
  • -Xss:每个线程的栈内存大小

4.模拟估算内存设置

100万订单->高峰期晚上7点到10点3个小时->每秒92个订单,我们以每秒100笔订单计算,部署三台机器,每台机器30个订单

4.1每个支付订单占用的空间

int->4个字节 long-> 8个字节 string->1个汉字两个字节,一个英文一个字节

订单40多个字段,算大概500个字节好了,不到1KB

其他对象,一起大概500字节,总共1KB,30个订单就是30kb每秒

4.2大促期间流量增加百倍

假设每秒上千比订单,每秒使用内存占10M以上,此时流量太大处理请求也要等3秒,导致频繁发送Minor Gc, 到一定次数后,频繁发生FULLGC

5.如果区分jvm内存中的对象是否能被回收

  • 只要你的对象被方法的局部变量,类的静态变量引用了,就不会回收

6.垃圾回收算法

6.1 标记清除

缺点

  • 标记清除算法的效率不算高
  • 在进行 GC 的时候,需要停止整个应用程序,导致用户体验较差
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表

6.2 标记整理

优点

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,标记-整理算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即:STW

6.3 复制算法

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小

6.4 复制算法升级(分代收集)

1个Eden区,2个Survivor区,其中Eden区占80%内存空间,每一块Survivor区各占10%内存空间,比如说Eden区有800MB内存,每一块Survivor区就100MB内存,如下图。

平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于就是有900MB的内存是可以使用的,如下图所示。

但是刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收

此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。接着Eden区就会被清空,然后再次分配新对象到Eden区里,然后就会如上图所示,Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。

如果下次再次Eden区满,那么再次触发Minor GC,就会把Eden区和放着上一次Minor GC后存活对象的Survivor区内的存活对象,转移到另外一块Survivor区去。

因为之前分析了,每次垃圾回收可能存活下来的对象就1%,所以在设计的时候就留了一块100MB的内存空间来存放垃圾回收后转移过来的存活对象

比如Eden区+一块Survivor区有900MB的内存空间都占满了,但是垃圾回收之后,可能就10MB的对象是存活的。

此时就把那10MB的存活对象转移到另外一块Survivor区域就可以,然后再一次性把Eden区和之前使用的Survivor区里的垃圾对象全部回收掉,如下图。

接着新对象继续分配在Eden区和另外那块开始被使用的Survivor区,然后始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域。

这么做最大的好处,就是只有10%的内存空间是被闲置的,90%的内存都被使用上了

7.何时进入老年代?

每次Eden区快满,触发Minor GC,回收Survivor区域,把存活对象都放到另外一块Survivor区域,达到这些情况对象会进入老年代

  • 躲过15次GC后进入老年代,具体多少岁通过参数XX:MaxTenuringThreshold来设置
  • 动态年龄判断,他的大致规则就是,假如说当前放对象的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小的50%,那么此时大于等于这批对象年龄的对象,就可以直接进入老年代了。另外这里要理清楚一个概念,就是实际这个规则运行的时候是如下的逻辑:年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n以上的对象都放入老年代。
  • 大对象直接进入老年代,有一个JVM参数,就是“-XX:PretenureSizeThreshold”,可以把他的值设置为字节数,比如“1048576”字节,就是1MB。他的意思就是,如果你要创建一个大于这个大小的对象,比如一个超大的数组,或者是别的啥东西,此时就直接把这个大对象放到老年代里去。压根儿不会经过新生代。

8.何时会触发FULL GC

8.1老年代空间分配担保规则

第一,判断剩余老年代内存是否大于新生代内存,如果是也可以Minor Gc,为啥检查这个呢?

因为最极端的情况下,可能新生代Minor GC过后,所有对象都存活下来了,那岂不是新生代所有对象全部要进入老年代?如下图。

第二,发现老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个“-XX:-HandlePromotionFailure”的参数是否设置了

下一步判断,就是看看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。

如果上面那个步骤判断失败了,或者是“-XX:-HandlePromotionFailure”参数没设置,此时就会直接触发一次“Full GC”,就是对老年代进行垃圾回收,尽量腾出来一些内存空间,然后再执行Minor GC。

如果上面两个步骤都判断成功了,那么就是说可以冒点风险尝试一下Minor GC。此时进行Minor GC有几种可能。

第二种可能,Minor GC过后,剩余的存活对象的大小,是大于 Survivor区域的大小,但是是小于老年代可用内存大小的,此时就直接进入老年代即可。

第三种可能,很不幸,Minor GC过后,剩余的存活对象的大小,大于了Survivor区域的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生“Handle Promotion Failure”的情况,这个时候就会触发一次“Full GC”。

如果要是Full GC过后,老年代还是没有足够的空间存放Minor GC过后的剩余存活对象,那么

此时就会导致所谓的“OOM”内存溢出了

9.ParNew 垃圾回收器

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

因为现在一般我们部署系统的服务器都是多核CPU的,所以为了在垃圾回收的时候充分利用多核CPU的资源,一旦我们指定了使用ParNew垃圾回收器之后,他默认给自己设置的垃圾回收线程的数量就是跟CPU的核数是一样的。

思考:到底是用单线程垃圾回收好,还是多线程垃圾回收好?到底是Serial垃圾回收器好还是ParNew垃圾回收器好?

“-server”就是服务器模式

“-cilent”就是客户端模式

服务器模式通常运行我们的网站系统、电商系统、业务系统、APP后台系统之类的大型系统,一般都是多核CPU

如果是类似于那种运行在Windows上的客户端程序,建议采用Serial垃圾回收器,单CPU单线程垃圾回收即可,反而效率更高,因为单CPU运行多线程会导致频繁的线上上下文切换,有效率开销

10.CMS垃圾回收器

10.1 执行方式

垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。

10.2 执行流程

  • 初始标记

所谓的“初始标记”,他是说标记出来所有GC Roots直接引用的对象,这是啥意思呢?

在初始标记阶段,仅仅会通过“replicaManager”这个类的静态变量代表的GC Roots,去标记出来他直接引用的ReplicaManager对象,这就是初始标记的过程。

在初始标记阶段,仅仅会通过“replicaManager”这个类的静态变量代表的GC Roots,去标记出来他直接引用的ReplicaManager对象,这就是初始标记的过程。

他不会去管ReplicaFetcher这种对象,因为ReplicaFetcher对象是被ReplicaManager类的“replicaFetcher”实例变量引用的

方法的局部变量和类的静态变量是GC Roots。但是类的实例变量不是GC Roots。

所以第一个阶段,初始标记,虽然说要造成“Stop the World”暂停一切工作线程,但是其实影响不大,因为他的速度很快,仅仅标记GC Roots直接引用的那些对象罢了。

  • 并发标记

在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。

所谓进行GC Roots追踪,意思就是对类似“ReplicaFetcher”之类的全部老年代里的对象,他会去看他被谁引用了?

比如这里是被“ReplicaManager”对象的实例变量引用了,接着会看,“ReplicaManager”对象被谁引用了?会发现被“Kafka”类的静态变量引用了。

那么此时可以认定“ReplicaFetcher”对象是被GC Roots间接引用的,所以此时就不需要回收他。如下图所示。

但是这个过程中,在进行并发标记的时候,系统程序会不停的工作,他可能会各种创建出来新的对象,部分对象可能成为垃圾,如下图所示。

对老年代所有对象进行GC Roots追踪,其实是最耗时的

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

  • 重新标记

因为第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾

所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的,如下图。

所以此时进入第三阶段,要继续让系统程序停下来,再次进入“Stop the World”阶段。

然后重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况,如下图。

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

  • 并发清理

这个阶段就是让系统程序随意运行,然后他来清理掉之前标记为垃圾的对象即可。

这个阶段其实是很耗时的,因为需要进行对象的清理,但是他也是跟系统程序并发运行的,所以其实也不影响系统程序的执行,如下图。

10.3 缺陷

缺陷1

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

所以其实CMS这个并发垃圾回收的机制,第一个问题就是会消耗CPU资源。

缺陷2

Concurrent Mode Failure问题

在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象

但是这个阶段系统一直在运行,可能会随着系统运行让一些对象进入老年代,同时还变成垃圾对象,这种垃圾对象是“浮动垃圾“

所以为了保证在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追踪,标记出来全部垃圾对象,不允许新的对象产生

10.4内存碎片

之前给大家说过内存碎片的问题,就是老年代的CMS采用“标记-清理”算法,每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生。

如果内存碎片太多,会导致后续对象进入老年代找不到可用的连续内存空间了,然后触发Full GC。

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

CMS有一个参数是“-XX:+UseCMSCompactAtFullCollection”,默认就打开了

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

上图有一个画红圈的地方,就是说在垃圾回收之后,有一些内存碎片,接着会停止工作线程进行碎片整理

11.G1垃圾回收器

11.1 结构

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

他最大的一个特点,就是把Java堆内存拆分为多个大小相等的Region

11.2指定系统停顿时间

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

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

而且G1最大的一个特点,就是可以让我们设置一个垃圾回收的预期停顿时间

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

通过把内存拆分为大量小Region,以及追踪每个Region中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象

大家看下图,G1通过追踪发现,1个Region中的垃圾对象有10MB,回收他们需要耗费1秒钟,另外一个Region中的垃圾对象有20MB,回收他们需要耗费200毫秒。

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

11.4 参数说明

然后JVM启动的时候一旦发现你使用的是G1垃圾回收器,可以使用 “-XX:+UseG1GC”来指定使用G1垃圾回收器,此时会自动用堆大小除以2048

因为JVM最多可以有2048个Region,然后Region的大小必须是2的倍数,比如说1MB、2MB、4MB之类的。

刚开始的时候,默认新生代对堆内存的占比是5%,也就是占据200MB左右的内存,对应大概是100个Region,这个是可以通过“-XX:G1NewSizePercent” 来设置新生代初始占比的,其实维持这个默认值即可。

因为在系统运行中,JVM其实会不停的给新生代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”。

G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过“-XX:MaxGCPauseMills”参数来设定,默认值是200ms

11.5 G1新生代垃圾回收

随着不停的在新生代的Eden对应的Region中放对象,JVM就会不停的给新生代加入更多的Region,直到新生代占据堆大小的最大比例60%。

一旦新生代达到了设定的占据堆内存的最大大小60%,比如都有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还占满了对象,此时如下图所示。

这个时候还是会触发新生代的GC,G1就会用之前说过的复制算法来进行垃圾回收,进入一个“Stop the World”状态

然后把Eden对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象,如下图。

但是这个过程跟之前是有区别的,因为G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过“-XX:MaxGCPauseMills”参数来设定,默认值是200ms。

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

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

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

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

此时就会判断一下,比如年龄为1岁,2岁,3岁,4岁的对象的大小总和超过了Survivor的50%,此时4岁以上的对象全部会进入老年代,这就是动态年龄判定规则

11.7 什么时候触发新生代+老年代的混合垃圾回收?

G1有一个参数,是“-XX:InitiatingHeapOccupancyPercent”,他的默认值是45%

意思就是说,如果老年代占据了堆内存的45%的Region的时候,此时就会尝试触发一个新生代+老年代一起回收的混合回收阶段。

比如按照我们之前说的,堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会开始触发一个混合回收,如下图所示。

11.8 G1垃圾回收的过程

  • 初始标记

如下图,先停止系统程序的运行,然后对各个线程栈内存中的局部变量代表的GC Roots,以及方法区中的类静态变量代表的GC Roots,进行扫描,标记出来他们直接引用的那些对象。

  • 并发标记

这个阶段会允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象,如下图所示

这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象。

但是这个阶段是可以跟系统程序并发运行的,所以对系统程序的影响不太大。

  • 最终标记阶段

这个阶段会进入“Stop the World”,系统程序是禁止运行的,但是会根据并发标记 阶段记录的那些对象修改,最终标记一下有哪些存活对象,有哪些是垃圾对象,如下图所示

  • 混合回收阶段

这个阶段会计算老年代中每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率

接着会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内。

比如说老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收可能只能停顿200毫秒,那么通过之前的计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在我们指定的范围内,如下图。

而且大家需要在这里有一点认识,其实老年代对堆内存占比达到45%的时候,触发的是“混合回收”

也就是说,此时垃圾回收不仅仅是回收老年代,还会回收新生代,还会回收大对象。

到底是回收这些区域的哪些Region呢?

那就要看情况了,因为我们设定了对GC停顿时间的目标,所以说他会从新生代、老年代、大对象里各自挑选一些Region,保证用指定的时间(比如200ms)回收尽可能多的垃圾,这就是所谓的混合回收,如下图。

但是最后一个阶段混合回收的时候,其实会停止所有程序运行,所以说G1是允许执行多次混合回收。

比如先停止工作,执行一次混合回收回收掉 一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。

有一些参数可以控制这个,比如“-XX:G1MixedGCCountTarget”参数,就是在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次

意味着最后一个阶段,先停止系统运行,混合回收一些Region,再恢复系统运行,接着再次禁止系统运行,混合回收一些Region,反复8次。

还有一个参数,就是“-XX:G1HeapWastePercent”,默认值是5%

他的意思就是说,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉

这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会 立即停止混合回收,意味着本次混合回收就结束了。

还有一个参数,“-XX:G1MixedGCLiveThresholdPercent”,他的默认值是85%,意思就是确定要回收的Region的时候,必须是存活对象低于85%的Region才可以进行回收

11.9 核心调优

核心还是在于调节“-XX:MaxGCPauseMills”这个参数的值,在保证他的新生代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc

12 模拟频繁Young GC

12.1 参数设定

比如我们用以下JVM参数来运行代码:

-XX:NewSize=5242880 -XX:MaxNewSize=5242880 -XX:InitialHeapSize=10485760 -XX:MaxHeapSize=10485760 -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=10485760 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC

相当于给堆内存分配10MB内存空间,其中新生代是5MB内存空间,其中Eden区占4MB,每个Survivor区占0.5MB,大对象必须超过10MB才会直接进入老年代,年轻代使用ParNew垃圾回收器,老年代使用CMS垃圾回收器,看下图图示。

12.2 打印GC日志

  • -XX:+PrintGCDetils:打印详细的gc日志
  • -XX:+PrintGCTimeStamps:这个参数可以打印出来每次GC发生的时间
  • -Xloggc:gc.log:这个参数可以设置将gc日志写入一个磁盘文件

加上这个参数之后,jvm参数如下所示:

-XX:NewSize=5242880 -XX:MaxNewSize=5242880 -XX:InitialHeapSize=10485760 -XX:MaxHeapSize=10485760 -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=10485760 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log

13.使用 jstat 摸清线上系统的JVM运行状况

  • jps -l 查询线程pid
  • jstat -gc PID 查看内存与GC情况
  • S0C 这是From Survivor区的大小
  • S1C这是To Survivor区的大小
  • S0U 这是From Survivor区当前使用的内存大小
  • S1U 这是To Survivor区当前使用的内存大小
  • EC 这是Eden区的大小
  • EU 这是Eden区当前使用的内存大小
  • OC 这是老年代的大小
  • OU 这是老年代当前使用的内存大小
  • MC 这是方法区(永久代,元数据区)的大小
  • MU 这是方法区(永久代,元数据区)当前使用的大小
  • YGC 这是系统运行迄今为止的Young GC次数
  • YGCT 这是Young GC 的耗时
  • FGC 这是系统迄今为止的FULL GC次数
  • FGCT 这是FULL GC耗时
  • GCT 这是所有GC的总耗时