当前位置 博文首页 > 文章内容

    大老费的博客:JVM运行原理及优化

    作者:shunshunshun18 栏目:未分类 时间:2021-12-02 22:33:39

    本站于2023年9月4日。收到“大连君*****咨询有限公司”通知
    说我们IIS7站长博客,有一篇博文用了他们的图片。
    要求我们给他们一张图片6000元。要不然法院告我们

    为避免不必要的麻烦,IIS7站长博客,全站内容图片下架、并积极应诉
    博文内容全部不再显示,请需要相关资讯的站长朋友到必应搜索。谢谢!

    另祝:版权碰瓷诈骗团伙,早日弃暗投明。

    相关新闻:借版权之名、行诈骗之实,周某因犯诈骗罪被判处有期徒刑十一年六个月

    叹!百花齐放的时代,渐行渐远!



     

     

    • 我们写好的代码,是要通过JVM才能运行的
    • JVM 想要执行一个类,首先要加载类,在加载类之前,需要先编译成字节码class 文件
    • 然后就执行类的加载过程,JVM 加载类的话,需要类加载器
    • 类加载器是分层级的,遵循双亲委派机制,
      • 最上层是Bootstrap ClassLoder,加载java的核心类库,加载java安装目录下的lib目录的class文件
      • 第二层是Ext ClassLoder,加载一些java的其他类库,加载java安装目录下的lib/ext目录下的class
      • 第三层是Application ClassLoder ,应该程序类加载器,这个类加载器是加载我们写的类
      • 如果我们自定义类加载器的话,那就是第四层
      • 类加载器遵循双亲委派机制,就是说,如果要加载一个类,先去为他的父类能不能加载,如果父类上面还有父类,就继续问,直到顶层。然后顶层说加载不了,就下派到子类,如果所有父类都加载不了,那就自己加载。这么做的好处是,不会重复加载一个类
    • 然后说一下类加载的过程,分这么几步,加载,验证,准备,解析,初始化。 
      • 加载的话,就是刚才说的类加载器去加载类
      • 验证阶段,主要是验证加载的字节码是否符合JVM规范,不然随便瞎写JVM也执行不了
      • 准备阶段,主要是给对象申请内存,然后给变量设置初始值,该设置0的设置0,该设置null的设置null
      • 解析阶段,主要是给符号引用变成直接引用,就是把一些变量什么temp,直接换成物理地址,不然执行的时候JVM也不认识temp是啥
      • 初始化阶段:主要是给变量赋值,准备阶段只是设置了初始值,这个是核心阶段,执行类的初始化,如果发现这个类的父类没有初始化,会先暂停,然后去初始化父类,也是走类加载的一套流程,直到父类加载完了,再执行子类的初始化
    • 这是类加载的过程,加载的类是放到了JVM的元数据空间,也就是永久代。

     

     

     

    • 永久代的话,我们JVM参数一般会给设置个256M,这个一定要设置,绝对不能不设置JVM参数,使用默认JVM参数可能就给新生代分配一两百兆,永久代分配的可能也很少,一旦并发量上来,系统扛不住,永久代一般就放点类和常量池,一般给256M够了,如果给小了,可能导致频繁的Full GC,因为永久代如果满了,会触发Full GC,这个是很坑的
    • 类加载到永久代后,会把类交给字节码执行引擎去执行,画图
    • 执行这个操作是线程去执行的,每个线程都配有一个程序计数器Java虚拟机栈

     

    • 因为Java是支持多线程的,所以必须要有程序计数器记录这个线程执行到哪了
    • Java虚拟机栈在执行每个方法的时候,都会创建一个栈帧,main方法也一样
    • 局部变量都放到这个栈帧中,如果这个方法执行完了,局部变量也就失效了
    • 这里的栈帧如果没有执行完时,其实都是GC Root,垃圾回收时,就是根据这里的局部变量的引用和永久代的引用来判断对象是否存活
    • 我们设置JVM参数的时候,一般都会给Java虚拟机栈 1M的大小,一个系统运行最多几百个线程,不用设置太大,浪费内存,也不能设置太小,容易溢出,特别是递归调用的时候
    • 然后局部变量保存的都是对象的地址,地址指向了JVM堆内存

     

    • 如果是使用ParNew + CMS 垃圾回收器的话,堆内存分年轻代老年代是很明确的,不像G1
    • 然后说一下ParNew垃圾回收器
      • 这个垃圾回收器是回收年轻代的,使用的是多线程回收,不像之前的Serial回收器使用单线程回收。
      • 然后ParNew使用的是复制清除算法,把年轻代分为Eden区 和两个Survivor,JVM 参数默认的占比是 8:1:1,系统运行会把对象创建到Eden区,每次YoungGC 会标记存活对象复制到Survivor0中,再次YoungGC时,再把存活对象复制到Survivor1中。系统运行期间会保证一直有一个Survivor是空着的
      • Eden区的占比有时是可以调优的,如果条件有限,没有大内存的机器,然后对象创建的还特别频繁,存活的对象比较多,那就建议把Eden区比例调低一些,让Survivor大一点,宁可Young GC多一些,也不要让Survivor触发了动态年龄审核或者放不下存活对象。如果放不下那就把这批对象扔到老年代了,Full GC是很慢的。如果是调低Eden,YoungGC会很频繁,但是YoungGC特别快,我通过jstat 看,回收100M垃圾大概也就1ms,所以,如果内存实在不够,降低Eden去比例也不是不可以。但是如果有条件的话最好的话还是加大新生代内存,毕竟YoungGC也是要Stop the World的。
    • 然后继续说ParNew ,它非常适合回收年轻代内存。因为年轻代一般存活的对象是很少的,大多数都是刚创建出1毫秒就变成了垃圾,所以把极少数存活的对象标记出来,复制成本还是很低的,如果像老年代那样采用标记清除算法,那就太慢了

     

    • 然后说一下老年代的垃圾回收器CMS,这个垃圾回收器是使用的 标记-清除 + 整理 算法,我们一般在JVM参数会指定这算法和整理的频率,JVM参数默认是,标记-清除,5次之后,才会去整理内存空间,让对象整齐排列。但是这个默认参数不太好,这样做会有大量的内存碎片,如果某一次从年轻代晋升一个大对象,老年代居然找不到一块连续的内存,就会触发Full GC,那就坑了。我们会把那个值调成0,就是每次CMS垃圾回收后,都会整理内存,虽然每次的回收时间会多一些,但是不会出现内存碎片。

     

    • CMS 垃圾回收分为4个步骤
      • 第一步是初始标记:初始标记的话,只标记GC root直接引用的对象,只有很少一部分,这个阶段需要STW,但是影响不大,这个过程特别快。这个过程也可以优化,JVM 有个参数是初始标记阶段多线程标记,减少STW时间,正常是单线程标记的。
      • 第二步是并发标记:这个阶段是不需要STW的,是和系统并行的处理,系统继续运行,然后垃圾回收线程去追踪第一步标记的GC root,这一步是很耗时的,但是不影响程序执行。因为在垃圾回收时是允许系统继续创建对象的,所以这个过程会有新的对象进来,也会有标记存活的但是现在变成垃圾,这些有改动的对象JVM都会记下来,等待下一步处理。这一步有一个缺点,并发清理时也有这个问题,就是会占用CPU资源。如果是一个4核的机器,那会占用一个CPU去垃圾回收,公式是(cpu核数 + 3)/4。所以一般CPU资源负载特别高的时候,就俩情况,要不是程序的线程太多了。要不就是频繁FullGC,导致的。
      • 第三步是重新标记:重新标记阶段,会把并发标记阶段有改动的对象重新标记,这一步需要STW,不过也是比较快的,因为改动的对象不会特别多,但是要比第一步慢因为要重新判断找个对象是否GC可达。这里也可以通过JVM参数优化,可以通过参数控制,让CMS在重新标记阶段之前尽量触发一次Young GC(尽量YoungGC是因为可能新生代可能刚刚YoungGC不久,那此时就没必要再一次YoungGC了)这样做的好处是,改动的对象中从存活变为垃圾的那部分,就被清理掉了,缩短STW时间。虽然YoungGC也会造成停顿,但是YoungGC一般频率是比较快的,早晚都要执行,现在执行一举两得。
      • 第四步是并发清理,并发清理是和系统并行的,不需要STW。这个阶段是清理前几个阶段标记好的垃圾。
      • 最后,我们通过JVM参数设置,每次Old GC后都重新整理内存,整理阶段会把老年代零零散散的对象排列到一起,减少内存碎片。
    • 在说ParNew + CMS调优之前,我们先说下JVM的几种GC,Young GC,Old GC,Full GC
      • Young GC 和 Old GC 我上面都已经说过了
      • 再说下 Full GC 吧,Full GC就是全面回收整个堆内存,包括新生代、老年代、永久带。整个过程极其的慢,一定要减少Full GC的次数,一般出现频繁的Full GC有几种情况,我们要避免出现这几种情况
        • 第一种是,内存分配不合理,导致Survivor放不下,或者触发了动态年龄审核机制,频繁的往老年代放对象。
        • 第二种,有内存泄漏问题,导致老年代大部分空间被占用,回收都回收不掉,导致每次新生代晋升一点点对象,就放不下了,触发Full GC
        • 第三种,大对象,一般是代码层面的问题,创建了太多的大对象,大对象是直接放入老年代的, 大对象过多会导致频繁触发Full GC
        • 第四种,永久代满了,触发Full GC,我们JVM参数设置256M基本够了,如果不出现代码层面的bug ,一般不会出现这种情况
        • 第五种,有人在代码里误调用了System.gc(),写了这个方法后,如果有机会,JVM就会发生一次Full GC。不过JVM参数可以禁止这种情况,不允许主动调用,我们要加上
    • 一般什么情况下我们要警觉是不是频繁的Full GC了
      • 第一种情况,CPU负载折线上升,特别高
      • 第二种情况,系统卡死了,或者系统处理请求极慢
      • 第三种情况,如果公司有监控系统,会报警。。
    • 然后再说一下ParNew + CMS 调优的问题吧。如果一个系统需要JVM调优,那其实说白了就是Stop the World 太久了,导致系统太卡了。我们说的调优,其实就是减少STW的时间,让系统没有明显的卡顿现象。
    • 然后分析下,需要STW的有几个地方。YoungGC,和Old GC的两个阶段。但是YoungGC一般STW时间特别短,Old GC时间一般会是Young GC的几倍到几十倍,而且占用CPU资源严重。所以,我们优化的重点是让系统减少Old GC的次数。最好让系统只有YoungGC,没有Old GC,更没有Full GC
    • 所以,优化的重点就是尽量不要让对象进入老年代。如果对象进不去老年代,想Full GC都难。这是JVM调优的重点,对象进入老年代的情况也有几种
      • 第一种,对象经过15次YoungGC,依然是存活的,那晋升老年代
        • 这个其实我们是可以优化一下的,因为如果系统1分钟或者30秒一次YoungGC,那没必要非得让对象存活十几分钟才进入老年代,一般存活个两三分钟,这个对象大概率就是要存活很久的了。所以,我们当时是调低了这个参数的,设置了5。不然这个对象一直存活,然后在两个Survivor里来回复制,如果这个对象小一点还好,如果这个对象挺大的,那容易触发Survivor的动态年龄审核机制,让一大批对象进入老年代。所以,该进入老年代的对象,就让他赶紧进去。
      • 第二种,Young GC后存活的对象大小超过Survivor 的50%,那就会触发动态年龄审核机制,如:1岁、2岁、3岁、4岁的对象加起来大于Survivor 的50%,那大于等于4岁的对象全部进入老年代。
      • 第三种,Young GC后存活的对象大于Survivor的大小,那这一批对象直接全部进入老年代,特别坑。
      • 第四种,大对象直接进入老年代,这个JVM参数里是可以设置的,一般我们都设置1M,大于1M的对象进入老年代,一般很少有1M的对象,一般都是个大数组,或者map。
    • 第一种情况和第四种情况,一般是可控的。所以想要优化的话,主要是要在Survivor的大小这块下功夫。我们要避免动态年龄审核和Survivor放不下的情况。要想保证这点,我们就要知道,我们系统的高峰时期,JVM中每秒有多少对象新增,每次YoungGC存活了多少对象。这就需要用 jstat 了。
    • 首先要使用 jstat -gc PID 1000 1000
      • 找到JVM的PID,然后每秒打印一次JVM的内存情况,如果系统访问量比较小,每秒的增长不是很明显,那就把每次的间隔时间调大一点,比如一分钟打印一次
      • 通过这行命令,我们可以看到当时的内存使用情况,有几个列比较重要的数据
      • S0C:Survivor0 的大小
      • S1C:Survivor1 的大小
      • S0U:Survivor0 使用了多少
      • S1U:Survivor1 使用了多少
      • EC:Eden 区的大小
      • EU:Eden 区使用了多少
      • OC:老年代的大小
      • OU:老年代使用了多少
      • MC:永久代的大小
      • MU:永久代使用了多少
      • YGC:YoungGC次数
      • YGCT:YoungGC的总耗时
      • FGC:Full GC次数
      • FGCT:Full GC的总耗时
    • 一般使用 jstat 优化,重点观察这几个指标
      • Eden 区对象的增长速度
        • 上面的几列,通过一行数据是看不出来Eden 区每秒增长多少数据的,所以我们才每秒打印一次,通过上一秒和下一秒EU的数据就可以推断出每秒增长了多少。这个数据进来多打印几行,取个平均值。
      • Young GC 频率
        • 我们我们知道系统启动时间,用YGC的大小除也能算,但是谁没事记得系统什么时候启动的。而且如果我想看高峰时期某一段时间的呢,就看不了了。看几十天的平均值也没什么意义。所以这个高峰时段YoungGC的频率是通过,Eden的大小,除以Eden区对象的增长速度来算的,Eden区对象增长速度,我们已经知道了。
      • Young GC 耗时
        • 这个YoungGC耗时,我们取平均值就行,用YGCT除以YCG,时间除以次数就是每次的耗时。如果说就像看高峰时段的,因为CPU等使用率比较高,可能会影响回收时间,也可以单独看几次的YoungGC,算出时间。
      • Young GC 后多少对象存活
        • 这个指标还是比较重要的,我们要确定每次存活的对象Survovir到底能不能放得下。我们要保证每次存活的对象要小于Survivor的50%,否则就会触发动态年龄审核机制。
      • 老年代对象增量速度
        • 老年代对象增长速度,决定了Old GC的频率。发生Old GC后,FGC那一列也会增长,FGC那一列其实是FullGC 和Old GC的总和。经过优化后的JVM,每次YoungGC不应该进入太多的对象,不进入或者每次进入几兆是比较好的。这个指标我们也要分多次观察,因为只看一次YoungGC晋升的大小是片面的。我们现在已经知道了YoungGC的频率,如果是3分钟一次,那我们就3分钟打印一次内存情况。jstat -gc PID 180000 100,取多次晋升大小的平均值就行。如果晋升的对象特别多,我们需要分析这些对象为什么会进入老年代,上面我说了有四种情况会晋升老年代,到底是哪种情况。是Survivor不够大,还是大对象太多了,或者有内存泄漏导致对象回收不掉,进入了老年代。这个还要具体分析一下的。如果是Survivor太小,我们很轻易就能看出来,如果每次Young GC后S区都是0,那说明存活的对象太多,S区放不下,都进入了老年代。如果S区不是0,有一部分,但是每次回收进入老年代都很多,就有可能是触发动态年龄审核,这个最好再通过GC日志看一下,通过JVM参数可以让系统打印每次GC的日志。如果出现内存泄漏,数据一般是这样的,发现每次FGC次数加1后,老年代并没有多少数据被回收掉,占用了很多。这就大概率是内存泄漏,导致老年代回收不掉。如果是大对象,数据会这样显示,发现及时没有Young GC,OU也会一直在涨,因为大对象是不用经过年轻代的直接进入老年代。如果内存泄漏和大对象的情况,我们可以用 jmap 打印一份内存快照,用MAT工具分析一下到底是什么对象特别大,通过分析出来的堆栈信息就可以定位到代码的位置。
      • Full GC 频率多高
        • 看这个频率和看YoungGC的频率是一样的,可以看高峰时期某几次的平均值。这个Full GC是很耗时的,Full GC的频率我们最好控制在一天1次或者几天一次的范围。特别是对时效性要求比较高的系统,一定要减少Full GC次数。
      • 一次Full GC 的耗时
        • 这个可以取平均值,也可以取某一段的。我们会发现这个Full GC的耗时是YoungGC的好多倍
    • ParNew + CMS 的原理和优化大概就是这么样的,下面我说一下现在比较流行的G1回收器
    • 有人说G1比ParNew +CMS好,可以全面取代,没必要用ParNew和CMS了,我觉得不是这样的。G1有G1的优点,但是也有缺点。我们选择垃圾回收器还是要根据系统的实际情况来看。但是ParNew+CMS的确有不足的地方,如果某些系统使用的机器是大内存,16G、32G,那每次GC都要等Eden区放满了才执行垃圾回收,一次回收好几G的垃圾,那太慢了,可能停顿时间几十上百毫秒,Full GC甚至要几秒。那就太坑了,不可以接收。这个时候就必须用G1了。
    • 那什么情况下用ParNew+CMS呢,它又什么优点
      • 它的优点就是我们可以优化到极致,极致到没有Full GC只有YoungGC。但是G1不行,我们对G1的优化只能是尽可能的优化预定的停顿时间,其他的我们没法参与太多,因为它什么时候YoungGC我们都不确定。
      • G1的内存使用率是没有ParNew +CMS高的,G1有这么一个机制,如果G1的某一个Region存活对象达到了85%,那就不会去回收这个Region,但是那15%呢。如果是垃圾也回收不掉了。
      • G1的掌控性没有ParNew + CMS好,说白了就是心里没底。我们使用ParNew + CMS可以很确定多久YoungGC,对象增长速度等等等等吧,我们都能看到。但是G1什么时候垃圾回收我们都不知道,如果出现了内存泄漏,如果不是几个G的内存泄漏,我们也很难察觉出来。使用ParNew + CMS可以放心一些,不用搞个活动心惊胆战的。
    • 所以总结来说,如果是4核8G的机器,尽量还是用ParNew + CMS垃圾回收器,如果是大内存机器,就是用G1。
    • 然后说一下G1的原理吧,G1把堆内存平均分成了多个大小相同的Region,我们首先要设置堆内存的大小,然后G1会根据堆大小除以2048,分成2048个大小相同的Region。G1也是有年轻代、老年代的概念,但是只是概念。没有ParNew+CMS分的那么清楚。G1里的年轻代和老年代都是基于Region的,某些Region属于年轻代,某些Region属于老年代,由G1动态控制。但是现在属于年轻代的Region并不永远都是年轻代,如果年轻代的Region被回收了,下次这个Region可能就存放老年代的数据了。所以,G1的年轻代和老年代都是动态的,但是也有个上限。系统刚开始运行时,会给年轻代分配5%的Region来存放对象,年轻代最多可以占用60%的Region,这60%可以通过JVM参数指定,默认是60%,不过这个一般默认就好,如果达到了目标值,就会强制触发YoungGC
    • G1的年轻代也是分Eden和Survivor的,因为G1整体使用的都是复制回收算法。只是某些Region属于Eden,某些Region属于Survivor,系统新创建的对象会被分配到属于Eden的Region,如果垃圾回收就把存活对象复制到Survivor中。
    • G1的一个特点就是我们可以设置一个预期的停顿时间,也就是STW的时间,比如,某个系统的时效性要就特别高,每次GC我只允许STW的5ms,那我们就可以通过JVM参数设置成5ms的停顿,这样G1在垃圾回收时,就会把时间控制在5ms以内
    • G1的垃圾回收不一定是年轻代满了,或者老年代满了才去回收。如果是那样,就和ParNew+CMS没区别了,大内存机器也要STW好久。G1是基于每个Region的性价比去回收的,比如,Region1里有20M对象,回收2ms,Region2里有50兆对象回收要4ms。如果我们设置系统停顿时间为5ms,那G1会在要求的时间内,尽可能回收更多的对象,它会选择Region2,因为性价比更高。所以,我们系统运行,一直往Eden放对象,如果G1觉得,此时回收一下垃圾,差不多要5ms,那可能G1就回去回收,不会等到年轻代占用60%才去回收。
    • G1中年轻代的对象什么情况下会进入老年代
      • 其实和ParNew + CMS整体上是差不多的,只有大对象的处理不一样
      • 1、YoungGC存活的对象Survivor放不下
      • 2、YoungGC存活的对象达到Survivor的50%,触发动态年龄审核
      • 3、对象到达了15岁,进入老年代
      • G1中大对象不会进入老年代,而是专门有一部分Region存放大对象用。如果一个Region放不下大对象,那就会横跨几个Region来存放。
    • G1的Old G也不是我们能控制的,G1会根据自己的判断觉得该回收的时候就会回收,不过也是基于复制算法的
    • G1的混合回收,如果老年代占比45%,就会触发混合回收,回收整个堆内存,但是混合回收也是会控制在我们设置的停顿时间的范围内的,如果时间不够,就会分多次回收。混合回收有点和CMS的回收类似
      • 第一步,初始标记
        • 初始标记需要STW,这一步只标记GC Root直接应用的对象,速度很快
      • 第二步,并发标记
        • 和系统并行,深入的追踪GC Root,标记所有存活的对象,此时系统新创建的对象会被JVM记录,这一步不需要STW
      • 第三步,重新标记,重新标记第二步有改动的对象,要STW。因为只有一小部分改动,速度很快
      • 第四步,混合回收,只有这一步和CMS不一样,CMS这里的回收时和系统并行的。但是G1的混合回收需要STW。混合回收不仅会回收老年代,还会回收新生代和大对象。如果一次性全回收掉,那时间就太久了,可能达不到我们设置的预期停顿时间,所以G1这里是分几批来回收的,回收一次,系统运行一会,然后再回收一次。JVM参数可以设置这个值,分几次去回收,默认值是8次,分8次回收。混合回收还有一个参数我们可以设置,就是空闲的Region达到百分之多少,停止回收,默认是5%
    • G1何时会触发Full GC,其实G1的混合回收就相当于ParNew + CMS的Full GC了,因为回收了所有的区域,只不过回收时间可以控制在我们指定的范围内。但是G1的Full GC就没法控制了,可能要卡顿特别久才能回收完。什么情况下会出现呢,因为G1的整体是基于复制算法的,如果回收的过程中,发现存活对象找不到可以复制的Region,放不下了。那就Full GC,开始单线程标记、清理、整理空闲出一批Region,这个过程很慢
    • 然后说一下G1的优化,G1比较智能,我们可以参与优化的点很少,我们只能合理的设置停顿时间,不要太小也不要太大,太小GC会太频繁,每秒都在GC。太大的话,停顿时间太久了也不好。
    • 平时我们选择垃圾回收器要根据不同的场景具体去分析,该使用那个。没有绝对的好坏。优化也没有一个统一的标准。比如YoungGC和Full GC多久一次好,YoungGC、Full GC耗时多久比较好。这个还是看系统的,只要不影响系统使用,没有卡顿感,我觉得都是好的。而且有些系统内部使用的,即使卡顿一会也无所谓,如果优化的话,用大内存机器成本也在那呢,不用做没必要的优化。

     

     

    • 说说平时工作怎么JVM调优的
      • 如果开发一个新系统,JVM的调优不是一次性就调完的,要分几次去看
        • 第一步,系统开发完需要自己预估一个JVM参数,也就是你预估每秒大概会有多少的对象进入,然后选几台机器,把内存比例设置的合理一些就好了,一般公司都会有一套公司级的通用JVM参数模板,如果是刚开发完,可以直接使用通用模板,反正测试环境还要压测
        • 第二步,测试环境系统压测,使用工具模拟1000人或几千人同时使用,造成每秒几百上千的请求压力,响应时间要控制在200ms。然后压测期间我们需要通过 jstat 去看下内存使用情况,就是我之前说的那些,什么Eden区增长速度,各个GC的频率啊,有没有内存泄漏情况等等吧。如果观察YoungGC和Full GC频率没什么问题,系统没有卡顿现象,就可以上线了。
        • 第三步,如果公司有监控系统,就持续监控,如果没有就每天高峰时期,通过jstat查看一下机器的JVM运行状态,如果需要优化,就继续优化

     

     

     

    cs