基本概念

一个项目的启动运行,是需要通过JVM才能运行的。

JVM想要执行一个类,首先要先加载类,在加载类之前,需要编译成字节码class文件。
然后执行类的加载过程,JVM加载类的话,需要类加载器。

类加载器和双亲委派机制

类加载器是分层级的,遵循双亲委派机制。

  • 启动类加载器(Bootstrap ClassLoader),加载java的核心类库,加载java安装目录下的lib目录的class文件。
  • 扩展类加载器(Ext ClassLoader),加载一些java的其他类库,加载java安装目录下的lib/ext目录的class文件。
  • 应用程序类加载器(Application ClassLoader),这个类加载器是加载我们写的类。
  • 自定义类加载器。

类加载器遵循双亲委派机制。也就是说,如果要加载一个类,先去父类加载器能不能加载,如果父类上面还有父类,就继续往上,直到顶层。如果顶层加载不了,就下派到子类去尝试加载,如果所有父类都加载不了,那就自己加载。 使用双亲委派机制的好处就在于,一个类不会被重复加载,有且只有一个类加载器会加载我们所需要的类。

类加载的过程

过程: 加载 -> 验证 -> 准备 -> 解析 -> 初始化
加载:使用类加载器去加载一个类
验证:验证加载的字节码是否符合JVM规范,不然随便瞎写JVM也执行不了
准备:主要给对象申请内存,然后给变量设置初始值,设置为0或者设置为null
解析:符号引用解析成直接引用,就是把变量直接换成物理地址,代码中的变量名对于JVM来说是无法识别的
初始化:主要给变量赋值(准备阶段只是设置了初始值,并未给变量赋值)执行类的初始化,如果发生这个类的父类没有初始化,会先暂停,然后去初始化父类,也是走类加载的一套流程,知道父类加载完之后,再去执行子类的初始化。

image.png

永久代, 使用 -XXPermSize-XXMaxPermSize 设置内存大小,一般设置256M。关于jvm的内存分配信息,必须要做好设置。否则使用默认的JVM参数,那么可能只会给新生代分配100~200M,永久代分配可能更少,一旦并发量上来,系统扛不住,永久代一般就放点类和常量池,一般给256M就够了。
如果给小了,会造成频繁的Full GC(永久代内存被占用满了,会触发Full GC)。

类加载到永久代后,会把类交给字节码执行引擎去执行。执行这个操作是线程去执行的,每个线程都分配了一个程序计数器和Java虚拟机栈。
image.png

程序计数器主要用来记录每个线程执行的指令位置。
Java虚拟机栈是在执行每个方法时,创建一个栈帧,然后将局部变量存入栈帧里面,按照先进后出的栈规则,调用方法等于入栈,方法执行结束就出栈,对应的栈帧也就失效,里面的局部变量也就失效。
栈帧在没有执行完之前,其实就是GC Root,垃圾回收时,对被GC Root(局部变量或静态变量)引用的对象就是存活的对象,不应该被JVM当作垃圾对象回收。
我们设置JVM参数的时候,一般都会给Java虚拟机栈1M的大小,一个系统运行最多几百个线程,不用设置太大,浪费内存,也不能设置太小,容易溢出,特别是递归调用的时候。
局部变量保存的都是对象的地址,地址执行JVM堆内存。

image.png

垃圾回收器

ParNew + CMS 垃圾回收器。
ParNew主要负责的是新生代垃圾对象的回收。
CMS主要负责老年代垃圾对象的回收。

ParNew垃圾回收器

主要回收新生代的垃圾对象,使用多线程回收。(Serial使用的是单线程回收)。
垃圾回收算法是复制清除算法。将新生代内存区域划分为 Eden区, From Survivor区, To Survivor区,默认比例为8:1:1,系统运行时,会把对象创建到Eden区,每次Young GC会标记存活对象,然后复制到Survivor区,再一次性清除Eden区里的所有对象。再次Young GC时,再把存活对象复制到另一个Survivor区,然后一次性清除Eden区和Survivor区里的对象。系统运行期间会保证一直有一个Survivor区里的内存是没有对象的,是空的。

Eden区的占比有时是可以调优的,如果条件有限,没有大内存的机器,然后对象创建的还特别频繁,存活的对象比较多,那就建议把Eden区的比例调低一些,,让Survivor区大一些,宁可Young GC多一些,也不要让Survivor触发了动态年龄审核或者放不下存活对象,如果放不下就只能把这批对象放入老年代,老年代满了之后,就得触发Full GC,Full GC的过程是很慢的。如果是增加了Survivor区的大小,虽然Eden区的区域小了,可能更频繁地触发了Young GC,但是Young GC的过程是很快的,使用jstat命令可以看到,回收100M的垃圾对象也只需要1ms,所以,如果内存实在不够,降低Eden区的比例也不是不可以。但是如果有条件的话,还是加大新生代内存,毕竟Young GC也是会有Stop the World(系统线程停止执行,只有垃圾回收的线程进行垃圾回收机制 —— 体现就在系统卡顿的现象)

CMS垃圾回收器

主要回收老年代的垃圾对象,使用多线程回收。
垃圾回收算法是标记清除 + 整理算法。
在JVM参数中,可以指定标记清除算法和整理的频率,JVM参数默认是标记清除5次之后,才会去整理内存空间,让对象整齐排列。但是这个默认参数不太好,因为每次标记清除回收垃圾对象后,如果不进行整理的话,会导致老年代中的内存区域存在大量的内存碎片。如果某一次从年轻代晋升了一个大对象,老年代里居然找不到一块连续的内存来存放该大对象,就只能触发一次Full GC。
所以,通常会将整理频率的阈值设置为0,在每次CMS垃圾回收后,都会整理内存,虽然会导致每次回收的时间多一些,但是不会出现内存碎片。

CMS垃圾回收分为四个步骤,分别是: 初始标记, 并发标记 ,重新标记, 并发清理。

  • 初始标记:只标记GC root直接引用的对象,只有很少的一部分,这个阶段需要Stop the World,但是影响不大,这个过程特别快。同时也可以优化,JVM有个参数是初始标记阶段多线程标记,减少Stop the World时间,正常是单线程标记的。
  • 并发标记:这个阶段不需要Stop the World的,是和系统并行的处理。系统继续运行,然后垃圾回收线程去追踪第一步标记的GC root,这一步很耗时,但是不影响程序执行。因为在垃圾回收时是允许系统继续创建对象的,所以这个过程会有新的对象进来,也会有标记存活的但是现在变成垃圾的对象,这些改动的对象JVM都会记录下来,等待下一步处理。但是这个过程会占用CPU资源,如果是一个4核的及其,就会占用一个CPU去垃圾回收,公式是 (cpu核数 + 3)/ 4 ,所以一般CPU资源负载特别高的时候,就两种情况,要么就是程序的线程太多了,要不就是频繁的Full GC导致的。
  • 重新标记:会把并发标记有改动的对象重新标记,这部分需要Stop the World,不过也是比较快的,因为改动的对象不会特别多,但是要比第一步慢因为要重新判断某个对象是否GC可达。这里也可以通过JVM参数优化,可以通过参数控制,让CMS在重新标记阶段之前尽量触发一次Young GC,这样的好处在于,改动的对象中存活变成垃圾的那部分对象,就被清理掉了,缩短Stop the World时间。虽然Young GC也会中造成停顿,但是Young GC一般频率比较快,早晚都要执行,现在执行一举两得。
  • 并发清理:并发清理和系统并行,不需要Stop the World。这个阶段是清理前几个阶段标记好的垃圾。

G1垃圾回收器

JVM GC分类

Young GC: 新生代垃圾对象回收
Old GC:老年代垃圾对象回收
Full GC:全面回收整个堆内存,包括新生代、老年代,永久代。整个过程极其的慢,一定要减少Full GC的次数。
一般出现Full GC的集中情况:

  • 内存分配不合理,导致新生代存活的对象在Survivor区存放不下,或者处罚了动态年龄审核机制,频繁往老年代放对象。
  • 内存泄漏问题。对象在老年代大部分空间一直占用,垃圾回收也回收不掉,导致每次新生代晋升一点点对象到老年代,老年代都放不下,就触发了Full GC。
  • 大对象,一般是代码层面的问题,创建了太多的大对象,大对象直接进入老年代,大对象过多导致频繁触发Full GC。
  • 永久代满了。触发Full GC,一般是代码层面上通过反射机制创建了太多的类导致的。
  • System.gc() 的使用。代码层面上存在这个代码,会导致每次执行会发生一次Full GC。可以通过JVM参数禁用System.gc() 的使用。

一般什么情况要警觉是不是频繁Full GC了

  • CPU负载折线上升,特别高
  • 系统卡死,或者系统处理请求极慢
  • 如果公司有监控系统,会报警

JVM调优

jvm调优,说白了,就是减少Stop the World的时间。
Stop the World 存在于 Young GC 和 Old GC两个阶段。但是Young GC一般Stop the World的时间特别短。Old GC Stop the World时间一般会是Young GC的几倍到几十倍,而且占用CPU资源严重。所以优化的重点是让系统减少Old GC的次数。 最好让系统只有Young GC,没有Old GC,更没有Full GC。
所以,优化的重点就是尽量不要让对象进入老年代,如果对象进不去老年代,想Full GC都难。
对象进入老年代的几种情况

  • 对象经过15次Young GC,依然存活,那就晋升到老年代。但是应该进入老年代的对象,比如Spring中的Bean实例,就让他赶紧进去。所以可以通过JVM参数调低对象进入老年代的年龄值。
  • Young GC存活的对象大小超过Survivor的50%,此时就会触发动态年龄审核机制。如:1岁,2岁,3岁,4岁的对象加起来大于Survivor的50%,那么大于等于4岁的对象全部进入老年代。
  • Young GC后存活的对象大于Survivor区的大小,那么这一批对象直接全部进入老年代。
  • 大对象直接进入老年代。可以通过JVM参数设置大对象的大小。一般可以设置为1M,大于1M的对象进入老年代。一般很少有1M的对象,一般都是个大数组,或者map。

第一种情况和第四种情况,都是可控的,所以想要优化的话,主要是在Survivor的大小这块下功夫。我们要避免动态年龄审核和Survivor区放不下的情况。要想保证这点,我们就要知道,我们系统的高峰期里,JVM每秒有多少对象新增,每次Young GC存活了多少对象。可以通过 jstat 命令查看。

  1. jstat -gc PID 1000 1000
  2. S0C: Survivor0的大小
  3. S1CSurvivor1的大小
  4. S0USurvivor0使用了多少
  5. S1USurvivor1使用了多少
  6. EC Eden区的大小
  7. EU Eden区使用了多少
  8. OC 老年代的大小
  9. OU 老年代使用了多少
  10. MC: 永久代大小
  11. MU 永久代使用了多少
  12. YGC Young GC次数
  13. YGCT Young GC的总耗时
  14. FGC Full GC次数
  15. FGCT Full GC的总耗时

一般使用jstat优化,重点观察几个指标

  • Eden区对象的增长速度

通过jstat命令,可以查看每秒的EU的情况,通过这个情况,就能知道Eden区对象的增长速度了。

  • Young GC 频率

可以查看某个高峰时间段内,起点的YGC次数和终点的YGC次数,及对应的YGCT,然后相除,就能知道YoungGC的频率了。

  • Young GC 耗时

可以查看某个高峰时间段内,起点的YGCT和终点的YGCT,相减之后,就能知道YoungGC的耗时了。

  • Young GC 后多少对象存活

这个指标比较重要,我们要确认每次存活的对象Survivor区到底能不能放得下,我们要保证每次存活的对象要小于Survivor区的50%,否则就会触发动态年龄审核机制。

  • 老年代对象的增量速度
  • Full GC 频率

Full GC的频率最好控制在一天1次或者几天一次的范围,特别是对时效性要求比较高的系统,一定要减少Full GC的次数。

  • 一次 Full GCde 耗时

说说平时是怎么JVM调优的

如果开发一个新系统,JVM的调优不是一次性调优完的,要分几次去看。

  • 第一步:系统开发完需要自己预估一个JVM参数信息,也就是预估每秒大概会有多少对象进入,大概会多少时间会触发一次Young GC,然后选择几台机器,把内存比例设置合理一些就行。一般公司会有一套公司级的JVM参数模板,如果是刚开发完,可以直接使用通用模板,反正测试环境还要压测。
  • 第二步:测试环境系统压测,使用工具模拟1000或几千个请求,造成每秒几百上千的请求压力,想用时间要控制在200ms。然后压测期间需要通过jstat去看下内存使用情况,一般查看上面所说的指标信息,或者有没有内存泄漏的问题。如果观察到Young GC和Full GC频率没什么问题,系统没有卡顿现象,就可以上线了。
  • 第三步:如果公司有监控系统,就持续监控,如果没有就每天高峰期间,通过jstat查看一下机器的JVM运行状态,如果需要优化,就继续优化。