双十一亿级电商系统JVM性能调优实战

(1)JDK体系结构



这个是JDK的体系结构,JDK包含JRE,JRE包含JVM,所以JDK无非就是一些工具集和支持java运行的类库以及java虚拟机
java跨平台就是靠JVM进行的


(2)JVM组成部分
那么JVM由什么部分组成?


由类装载子系统、运行时数据区、字节码执行引擎组成。
先由类装载子系统加载class字节码文件到数据区(内存区)中,再由字节码执行引擎执行内存区中的代码
附上官方文档,这里教一下大家怎么从官网中找到这个链接
首先搜索引擎搜索oracle,然后点击官网那个进行oracle官网

点击Resources,再点Documentation

然后选择java

选择javase 技术文档

选择其他版本

选择jdk8

Java语言和虚拟机规范

找到自己想看版本的jvm文档即可

(3)栈
内存区包含堆、栈、本地方法栈、方法区、程序计数器,它们都是用来放数据的,所以合并叫数据区
那么栈是放什么数据的?
放局部变量的,我们也叫线程栈。在线程运行,jvm会在栈中开辟一块空间作为供线程存放局部变量



方法也是会在当前线程的栈内存中划分一块空间存放方法的局部变量



这个栈是符合FILO(first in last out)的

(4)栈帧
一个线程中的一个方法对应一块栈帧内存区域。
它包含局部变量表、操作数栈、动态链接、方法出口



要了解这四个组成部分,需要先了解操作指令

(5)反汇编



javap -c xxx.class > C:\xxx.txt
从第一个图可以看到,javap就是jdk自带的工具,反汇编后的内容,其实就是.class的内容,只是他们的表现形式不一样

package tuling;

public class Math {

    public static final int initData = 666;
//    public static User user = new User();

    public int compute(){
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        System.out.println("test");
    }

}

得到:

Compiled from "Math.java"
public class tuling.Math {
  public static final int initData;

  public tuling.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class tuling/Math
       3: dup
       4: invokespecial #3                  // Method "":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      16: ldc           #6                  // String test
      18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      21: return
}

然后我们看这部分内容

  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

然后网上搜索一下jvm指令集代码(JVM指令集),就可以知道
iconst_1 就是 将int类型常量1压入操作数栈

istore_1 就是 将int类型值存入局部变量1


这里两步操作就相当于int a=1;
另外提一下,istore_1,istore_2这些可以理解为数组下标,_0就是this变量,但iconst_1就是常量1,iconst_5就是常量5。



最多也是去到5,其他的用其他指令代替


然后iconst_2,istore_2,就是int b=2;,把常量2进操作数栈,然后出栈存入第二个变量b中。

iload_1,它会从局部变量1中装载int类型值,局部变量1就是之前的a,是装载到操作数栈上的,iload_2同样意思。装载的话,变量还是在局部变量表中的,只是把值装载到操作数栈上。



iadd 就从操作数栈的栈顶中弹出两个元素,运算后压回栈中。
bipush 10就是把10压入操作数栈中
imul 就从操作数栈的栈顶中弹出两个元素,运算后压回栈中。
istore_3 将栈顶int型数值存入第4个本地变量(将int类型值存入局部变量3)网上查会有不同的表达,这里我暂时怀疑就是this的原因,第一个本地变量是this,但第一个局部变量是第二个本地变量,这里是我目前的猜想,并未确定
iload_3 把c的值装载到操作数栈
综上,操作数栈就是一块临时的内存空间,要运算的时候把数暂时往里面放,做完了就完事了
ireturn 从当前方法返回int

(6)程序计数器
这里暂时插入一个程序计数器知识点,后面再进行更好的排版。
每一个线程下都有一块空间是程序计算器,用来记录一些行号的
假设准备执行 4: iload_1,这时程序计数器的值就是4,其实应该是存地址的,但可以先这样理解
记录行号的意义是什么?
java是多线程运行,有可能会暂时挂起,回头再继续执行的,这个继续执行,就是利用程序计数器进行的,不然就得重新开始。
它是怎么修改的?
是字节码执行引擎负责修改的。.class是装载到方法区中的,然后字节码执行引擎执行方法区中的方法,在执行的过程中同时修改程序计数器。也只有字节码执行引擎知道执行到的行号。

(7)动态链接
就是调用方法的时候找到该方法在方法区中的入口位置,把符号引用转变为直接引用。


(8)方法出口
就是执行完这个方法之后,知道应该要回到哪一行代码
例如,代码中执行完math.compute(),为什么它能继续执行sout而不是重新开始main方法,就是这个方法出口在记录的

(9)main方法的局部变量表
new出来对象都是放在堆中的,所以代码中的math对象也是放在堆中的,math属于方法的局部变量,所以在局部变量表中会存有math,但存的只是堆中math的地址,是引用的关系


(10)堆跟栈的关系


(11)方法区
在jdk1.8之前,叫永久代或者持久代。后来改为元空间,是不一样的实现方式的。元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中。
一般就是存放常量、静态变量、类信息(字节码装载)

public class Math {

    public static final int initData = 666;
    public static User user = new User();

    public int compute(){
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        System.out.println("test");
    }

}

initData和user都是放在方法区的,并且可以看到user=new User(),对象都是放在堆中的,所以也说明了方法区存的user只是堆中真实对象的地址,方法区和堆的关系也出来了:


(12)本地方法栈
我们平时写的new Thread().start();


带native的就是本地方法,不是由java代码实现的,不管它用什么语言实现,既然我们是要调用的,肯定是有内存空间,就是方法本地方法栈中。
当线程需要调用到本地方法的时候,就会从本地方法栈中分配内存空间给线程

(13)堆



对象要分配到堆中,通常都是分配到Eden区,如果分满了放不下了,就会minor gc。这个minor gc是由字节码执行引擎开启的垃圾收集线程,收集年轻代里面的垃圾对象



垃圾收集的机制,它是怎么收集的?
通过GC Roots(线程栈的本地变量、静态变量、本地方法栈的变量等等),一个一个的GC Roots中出发,找它们引用的对象,继续找对象的成员变量又引用的对象,直到找到最后的对象不再引用其他对象,凡是引用链上的对象都标记为非垃圾,并赋值到Survivor区中,最后还剩下在Eden中的就销毁(未标记的就是垃圾对象)。这个寻找过程也叫可达性分析算法。

如果Eden再次满了,那么minor gc再次执行,注意minor gc是回收整个年轻代的,所以那些非垃圾的继续存到另一个Survivor区(s1,只有两个区,会循环使用),然后minor gc就销毁还在Eden和s0中的对象。还需要记录对象的年龄,例如执行第二次minor gc的时候,有一个对象从Eden移到了s1中,它的年龄就是1,有一个对象是从s0移到s1的,它的年龄就是2,年龄也就是这个对象经历了几次minor gc。



这里提一下对象结构



分代年龄就是放在对象头中。
当一个对象的分代年龄达到了15,还没有被干掉,就移到了老年代。

证明:
使用调优工具jvisualvm,cmd运行jvisualvm
准备一个测试代码,对象无法回收,每一个new出来的都一直被引用着,然后运行

public class HeapTest {

    byte[] a = new byte[1024*1000];

    public static void main(String[] args) throws InterruptedException {
        ArrayList heapTests = new ArrayList<>();
        while(true){
            heapTests.add(new HeapTest());
            Thread.sleep(10);
        }
    }
}

jvisualvm需要先安装插件,第一步,修改插件地址,发现https无法访问的,改为http://visualvm.github.io/uc/8u131/updates.xml.gz


如果jdk版本不同的话,到这个地址找 Plugins Centers,找到之后记得改为http,我也不知道为什么https无法请求(好像是要fq)。然后安装VisualGC

不过安装的话,也是请求https://github.com/visualvm/visualvm.src/releases/download/1.3.9/com-sun-tools-visualvm-modules-visualgc.nbm地址的,也是无法连接,有fq的可以先这样试试,如果是没有的话,只能是离线安装了。
首先从网上下载com-sun-tools-visualvm-modules-visualgc.nbm2.1.2版本,具体自己什么版本会在在线安装的界面可以知道,然后按操作安装即可

然后继续,在VisualVM中双击我们示例线程,然后点击Visual GC,可以看到就是Eden递增到一定就S0有内容,S0递增到一定就S1有内容,Old越来越多内容,正是验证了上述所说的



当然,还有更仔细的需要看右侧的图表,因为Eden:s0:s1=8:1:1,所以s0或s1是会放不下的以及有一些已达老年,就会看到old呈阶梯递增,可以自己研究一下

那么,当老年代放满的时候,jvm会干什么?
会做一次full gc,会回收整个堆区域,也是由字节码执行引擎开启的垃圾回收线程。但因为现在对象一直被引用着,所以full gc也无法清理,这时就会出现OOM了,内存溢出。可以自己运行一下,看到Old那里满了,程序就退出了。

(14)JVM调优工具
jmap、jstack、jinfo、jvisualvm等等都是可以查看jvm文件的工具,不过现在用得较多的是Arthas,是新技术,阿里巴巴开源的。

(15)Arthas
下载一个bin.zip解压后见到arthas-boot.jar就说明准备好了
直接java -jar arthas-boot.jar运行即可,然后输入序号来表示查看哪个进程的情况


然后输入dashboard可以查看面板信息

如果某个线程你认为有问题,例如占用CPU特别高,占用内存高等,只需要一条命令即可定位问题,thread 线程ID,如

arthas还有这样的一个功能,当一个程序在线上出现问题,然后开发重新修改重新体测完成后,运维重新发布到线上,但发现还有问题,这个时候就不确定是哪一方的问题,但开发至少要先确认运维是否部署成功新的程序,就可以使用arthas的反编译功能,直接反编译线上正在运行的java代码

[arthas@13684]$ jad tuling.Math

就可以知道代码是新的还是旧的

arthas还有一个很强大的功能,例如当程序在线上,例如双十一,有大量访问,但是程序被发现有一个小问题,例如只需要修改一个变量的值,这个时候重新发布程序是不现实的,可以使用arthas来修改线上内存中的变量的值。arthas还有很多强大的功能,值的学习一下。

(16)调优的主要目的
上面说了这么多,难道jvm的调优就是使用工具定位一下,分析一下这样子吗?不是的,调优最重要做的是减少gc次数(特别是full gc),以及缩短gc的时间。原因是当进行gc的时候,就会STW(Stop The World),会停止用户线程运行。

(17)为什么jvm gc要设置STW这个机制--蚂蚁金服面试题
假设gc不停止线程,那么gc执行过程中,已经遍历了部分GC Roots,并标记为非垃圾,然后线程继续执行,执行完了,对象变成垃圾了,那么gc又得重新遍历GC Roots找出垃圾的数据进行回收,这样gc基本就没法结束了。并且对象的在运行过程中的状态变化,对gc来说太复杂了,让线程停止,让gc专心做自己的事情效率会更高。甚至在变化过程中,A对象标记为垃圾,但随着程序运行,A又不是垃圾,就会造成误清理,所以就有STW

(18)如何优化减少full gc次数
假设电商场景,日活500万用户,有10%消费,即50万订单,不在活动时,可能每秒几十个订单,对于多个服务器来说,压力是很少的,但有活动的情况下,可能会在短暂时间内每个服务器都有300个订单产生,假设一个系统是4核8G,然后假设每个订单整个过程占用20kb,也就是300*20kb每秒,但这这段时间,还有一些没下单的仅浏览的也在占用资源,再放大10倍,相当于每秒有60m对象将变为垃圾对象。


假如当前配置是
java -Xms3G -Xmx3G -Xss1M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar microservice-eureka-server.jar

老年代比年轻代默认是2比1,然后当前是分配3G给堆,所以老年代是2G,年轻代是1G,然后Eden比S0比S1默认是8:1:1,所以eden有800M,S0和S1各100M。
每过1秒就有60M放进Eden中,大概14秒就占满Eden,就会进行minor gc。
minor gc也会STW,但时间非常短,我们优先考虑的是full gc。
jvm要挪对象到老年代中有很多种机制,其中一种是对象动态年龄判断。当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
所有这里想说的是,有可能这每秒60M的数据是直接进入老年代的,但这些数据是稍纵即逝的,并不是希望长期存活的,所以就很有快占满了老年代触发full gc。

(19)能否对JVM调优,让其几乎不发生Full GC--阿里面试题
是可能的(几天、几周一次的意思)。
java -Xms3G -Xmx3G -Xmn2G -Xss1M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar microservice-eureka-server.jar
把年轻代调大为2G,即Eden 1.6g,old 1G,S0=S1=200M。这样约25秒才触发minor gc,这样就会发现并不会触发动态年龄判断机制,60M数据会移动到Survivor区中,由minor gc管理,清理那些早就变为垃圾的垃圾对象。
核心思想是,要在年轻代的时候干掉,而不是变为老年代的时候才干掉,最主要就是minor gc比full gc快很多。

(20)单机几十万并发的系统JVM如何优化
例如Rocket mq、kafka,在好点的机器上基本是可以单机几十万的并发的,这种该怎么优化。
如果还像之前那样配置2G年轻代,几十万的并发假如一个1KB,相当于几百M每秒,几秒钟eden就满了,就会触发minor gc,在触发的时候,还有一部分是还没结束的线程,它们不是垃圾对象,仍然是占用几百M,所以minor gc就会把它们移到Survivor区,但因为放不下,就会进入老年代,那么1G的老年代,几秒就触发一次full gc了。所以小内存的机器是不用想的,根本无法解决,只能使用高内存的机器才有机会解决。
假设64G机器,给eden分配30GB,Survivor去各几GB,这样的话可能就几分钟才一次minor gc,但由于年轻代容量太大,遍历GC Roots,还是需要很长时间的,minor gc还是需要优化

(21)大内存怎么优化
让minor gc只回收一部分,例如几个G,这样就可以有其他对象进入,不会让我们客户端大量的接口超时。分段回收,控制回收不超过1秒,超过1秒就停止回收。这就是G1垃圾收集器

(22)G1垃圾收集器
JDK1.7就开始有G1,不同的垃圾收集器实现方式不一样,不同收集器用于不同场景。G1就是部分收集的垃圾收集器
java -Xms64G -Xmx64G -Xss1M -XX:+UseG1GC -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar microservice-eureka-server.jar

-XX:+UseG1GC 使用 G1 垃圾收集器
-XX:ConcGCThreads=n  并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同.
-XX:G1HeapRegionSize=n  使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为1Mb, 最大值为 32Mb,且必须是2的N次幂,默认将整堆划分为2048个分区.
-XX:MaxGCPauseMillis=200  设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),单位毫秒
-XX:G1NewSizePercent  新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent  新生代内存的最大空间
-XX:TargetSurvivorRatio  Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold=n  提升年老代的最大临界值(tenuring threshold). 默认值为 15.
-XX:InitiatingHeapOccupancyPercent=45  启动并发GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45. 老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了。
-XX:G1MixedGCLiveThresholdPercent  (默认85%)region中存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的意义不大。
-XX:G1MixedGCCountTarget  在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent  (默认5%)gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后对这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
-XX:NewRatio=n  新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为 2.
-XX:SurvivorRatio=n  eden/survivor 空间大小的比例(Ratio). 默认值为 8.
-XX:ParallelGCThreads=n  设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同.
-XX:G1ReservePercent=n  设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10.
....

用-XX:MaxGCPauseMillis就可以实现我们想要的效果
java -Xms64G -Xmx64G -Xss1M -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar microservice-eureka-server.jar 边回收边计算时间,如果达到这个时间,就会停止遍历垃圾,开始回收
但如果对于超大内存的,G1还是没办法解决,或者是说不合适,可以考虑一下ZGC。

(23)ZGC(-XX:+UseZGC)
可能G1用得比较多,但将来可能会是用ZGC。ZGC是JDK11中新加入的具有实验性质的低延迟垃圾收集器,适合超大内存(TB级别)

你可能感兴趣的:(双十一亿级电商系统JVM性能调优实战)