JVM原理源码

类加载机制

类加载全过程

当我们用java命令运行某个类的main函数启动程序时,首先需要通过类加载器把主类加载到JVM。

一个普通的main方法

public class Math {

    public static 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) throws ClassNotFoundException {
        Math math = new Math();
        math.compute();
}

的加载过程:

其中loadClass的类加载过程有如下几步:
加载>>验证>>准备>>解析>>初始化>>使用>>卸载

  • 加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的main()方法,new对象等等,在加载阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
  • 验证:校验字节码文件的正确性
  • 准备:给类的静态变量分配内存,并赋予默认值
  • 解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用。
  • 初始化:对类的静态变量初始化为指定的值,执行静态代码块

类被加载到方法区中后主要包含运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息
类加载器的引用:这个类到类加载器实例的引用
对应class实例的引用:类加载器在加载类信息放到方法区中后,会创建一个对应的Class类型的对象实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口和切入点。


注意,主类在运行过程中如果使用到其它类,会逐步加载这些类。
jar包或war包里的类不是一次性全部加载的,是使用到时才加载。

类加载和双亲委派机制

Java里有如下几种类加载器

  • 引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等
  • 扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包
  • 应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类
  • 自定义加载器:负责加载用户自定义路径下的类包

类加载器初始化过程

类加载器初始化过程:
参见类运行加载全过程图可知其中会创建JVM启动器实例sun.misc.Launcher。
sun.misc.Launcher初始化使用了单例模式设计,保证一个JVM虚拟机内只有一sun.misc.Launcher实例。
在Launcher构造方法内部,其创建了两个类加载器,分别sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)。
JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。

双亲委派机制

JVM类加载器是有亲自结构的,如下图

这里类加载其实就有一个双亲委派机制,加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。
比如我们的Math类,最先会找应用程序类加载器加载,应用程序类加载器会先委托扩展类加载器加载,扩展类加载器再委托引导类加载器,顶层引导类加载器在自己的类加载路径里找了半天没找到Math类,则向下退回加载Math类的请求,扩展类加载器收到回复就自己加载,在自己的类加载路径里找了半天也没找到Math类,又向下退回Math类的加载请求给应用程序类加载器,应用程序类加载器于是在自己的类加载路径里找Math类,结果找到了就自己加载了。。
双亲委派机制说简单点就是,先找父亲加载,不行再由儿子自己加载

为什么要设计双亲委派机制?

  • 沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改
  • 避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性

Tomcat的类加载器

Tomcat打破双亲委派机制
以Tomcat类加载为例,Tomcat 如果使用默认的双亲委派类加载机制行不行?
我们思考一下:Tomcat是个web容器,那么它要解决什么问题:
1.一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
2.部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
3.web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
4.web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,web容器需要支持jsp修改后不用重启。

再看看我们的问题:Tomcat 如果使用默认的双亲委派类加载机制行不行?
答案是不行的。为什么?
第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性
第三个问题和第一个问题一样。
我们再看第四个问题,我们想我们要怎么实现jsp文件的热加载,jsp文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的isp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

tomcat的几个主要类加载器:

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;

从图中的委派关系中可以看出:
CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。
WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。

tomcat 这种类加载机制违背了java 推荐的双亲委派模型了吗?答案是:违背了。
很显然,tomcat不是这样实现,tomcat为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递
给父类加载器,打破了双亲委派机制。

JVM内存模型

内存模型:

JVM内存参数设置

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):

java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和-XX:MaxMetaspaceSize=N,对于64位JVM来说,元空间的默认初始大小是21MB,默认的元空间的最大值是无限。

  • XX:MaxMetaspacesize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,默认是21M,达到该值就会触发full gc进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

由于调整元空间的大小需要FullGC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在
JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多

JVM对象创建过程

对象的创建

1.类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。
2.分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
这个步骤有两个问题:
1.如何划分内存。
2.在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
划分内存的方法:

  • "指针碰撞"(Bump the Pointer)(默认用指针碰撞)

如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

  • "空闲列表”(Free List)

如果]ava堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

解决并发问题的方法:

  •  CAS (compare and swap)

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在]ava堆中预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用
TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
3.初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在]ava代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

5.执行<init>方法
执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

什么是java对象的指针压缩
1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:UseCompressedOops

为什么要进行指针压缩?
1.在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
2.为了减少64位平台下内存的消耗,启用指针压缩功能
3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。

对象内存分配

对象栈上分配

我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

public User test1() {
    User user = new User();
    user.setId(1);
    user.setName("zhuge");
    //TODO 保存到数据库
    return user;
}


public void test2() {
    User user = new User();
    user.setId(1);
    user.setName("zhuge");
//TODO保存到数据库
}

很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启
标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

对象在Eden区分配


大多数情况下,对象在新生代中Eden区分配。当Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。我们来进行实际测试一下。
在测试之前我们先来看看Minor GC和Full GC有什么不同呢?

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • ·Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

Eden与Survivor区默认8:1:1
大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的, ,JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数-XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小
会直接进入老年代,不会进入年轻代,这个参数只在Serial 和ParNew两个收集器下有效。
比如设置JVM参数:-XX:PretenureSizeThreshold=1000000(单位是字节)-XX:+UseSerialGC,再执行下上面的第一个程序会发现大对象直接进了老年代
为什么要这样呢?|
为了避免为大对象分配内存时的复制操作而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-xx:MaxTenuringThreshold来设置。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态
年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间
如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象
就会看一个“-XX:-HandlePromotionFailure"(jdk1.8默认就设置了)的参数是否设置了
如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小
如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的
对象就会发生"OOM”
当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的相互引用问题,如下面代码所
示:除了对象objA和objB相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC回收器回收他们。

public class ReferenceCountingGc {
        Object instance = null;

        public static void main(String[] args) {
            ReferenceCountingGc objA = new ReferenceCountingGc();
            ReferenceCountingGc objB = new ReferenceCountingGc();
            objA.instance = objB;
            objB.instance = objA;
            objA = null;
            objB = null;
        }
    }

可达性分析算法

将“GCRoots”对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

常见引用类型
java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用
强引用:普通的变量引用

public static User user = new User();

软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。

public static SoftReference<User> user = new SoftReference<User>(new User());

软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

  1. 如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
  2. 如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用

public static WeakReference<User> user = new WeakReference<User>(new User());

虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用

finalize()方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1.第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,对象将直接被回收。
2.第二次标记
如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后+次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。

示例代码:

public class OOMMTest {
        public static void main(String[] args){
            List<Object> list = new ArrayList<>();
            int i = 0;
            int j = 0;
            while (true) {
                list.add(new User(i++, UUID.randomUUID().toString()));
                new User(j--, UUID.randomUUID().toString());
            }
        }
    }

如何判断一个类是无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?
类需要同时满足下面3个条件才能算是“无用的类”:

  •  该类所有的对象实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • · 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

JVM垃圾回收

垃圾收集算法

分代收集理论

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

标记-复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

标记-清除算法

算法分为“标记”和“清除”阶段:标记存活的对象,统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,比较简单,但是会带来两个明显的问题:

  1. 效率问题(如果需要标记的对象太多,效率不高)
  2. 空间问题(标记清除后会产生大量不连续的碎片)

标记-整理算法

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。(直接把存活对象赋值给可回收对象的内存)

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。

1.1 Serial收集器(-XX:+UseSerialGC-XX:+UseSerialOldGC)

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程("Stop The World”),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法。

虚拟机的设计者们当然知道Stop The World带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。
Serial old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与ParallelScavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

1.2 Parallel Scavenge收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))

Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用复制算法,老年代采用标记-整理算法。

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel
Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。

1.3 ParNew收集器(-XX:+UseParNewGc)

ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。

它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

1.4 CMS收集器(-XX:+UseConcMarkSweepGC(old))

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种“标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记:暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快
  • 并发标记:并发标记阶段就是从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
  • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记
  • 并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
  • 并发重置:重置本次GC过程中的标记数据。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个明显的缺点:

  • 对CPU资源敏感(会和服务抢资源);
  • 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
  • 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure”,此时会进入stop the world,用serial old垃圾收集器来回收

CMS的相关核心参数

  • 1.-XX:+UseConcMarkSweepGC:启用cms
  • 2.-XX:ConcGCThreads:并发的GC线程数
  • 3.-XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
  • 4.-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩-次
  • 5.-XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  • 6.-XX:+UseCMSlnitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
  • 7.-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在标记阶段
  • 8.-XX:+CMSParallellnitiallMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  • 9.-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

这样就降低了因为对象动态年龄判断原则导致的对象频繁进入老年代的问题,其实很多优化无非就是让短期存活的对象尽量都留在survivor里,不要进入老年代,这样在minor gc的时候这些对象都会被回收,不会进到老年代从而导致full gc 对于对象年龄应该为多少才移懂到老年代比较合适,本例中一次minor gc 要间二三十秒,大多数对象一般在几秒内就会变为垃圾,完全可以将默认的15岁小一点,比如改为5,那么意味着对象要经过5次minor gc才会进入老年代,整个时间也有一两分钟了,如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象,可以移动到老年代,而不是继续一直占用survivor区空间。对于多大的对象直接进入老年代(参数-xx:PretenureSizehreshad),这个一般可以结合你自己系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。可以适当调整JVM参数如下:
 

-Xms3072M -Xmx3072M -Xmn2048M -XS1M -XX:Metaspacesize-256M -XXMaxMetaspacesize-256M 
-XX:SurvivorRatio-8-XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M


对于JDK8认的垃圾回收器是-XX:+UseParalelGC(年轻代-XX:UseParalelidGC老年代),如果内存较大 超过4个G,只是经验值),系统对停物时间比较敏感,我们可以使用ParNew+CMS(-XX:+UseParNewGC-XX:+UseConcMarkSweepGC)
对于老年代CMS的参数如何设置我们可以思考下,首先我们想下当前这个系统有哪些对象可能会长期存活躲过5次以上minor gc最终进入老年代无非就是那些Spring容器里的Bean,线程池对象,一些初始化缓存数据对象等,这些加起来充其量也就几十MB。还有就是某次minor gc完了之后还有超过一两百M的对象存活,那么就会直接进入老年代,比如突然某一秒间要处理五六百单,那么每秒生成的对象可能有一百多M,再加上整个系统可能压力剧增,一个订单要好几秒才能处理完,下一秒可能又有很多订单过来。我们可以估管下大概每隔五六分钟出现-次这样的情况,那么大概半小时到一小时之间就可能因为老年代满了触发一次Full GC,Full GC的触发条件还有我们之前说过的老年代空间分配担保机制,历次的mino gc动到老年代的对象大小肯定是非常小的,所以几乎不会在minor gc发之前由于老年代空间分配担保失败而产生full gc,其实在半小时后发生full gc,这时候已经过了抢购的最高峰期,后续可能几小时才做一次Full GC。对于碎片整理,因为都是1小时或几小时才做一次FulI GC,是可以每做完一次就开始碎片整理,或者两到三次之后再做一次也行。综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如下所示

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction-8

1.5 G1收集器(-XX:+UseG1GC)

G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器.以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征.

G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。
一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize”手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中, JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。

一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

G1收集器一次GC的运作过程大致分为以下几个步骤:

  • 初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快
  • 并发标记(Concurrent Marking):同CMS的并发标记
  • 最终标记(Remark,STW):同CMS的重新标记
  • 筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数-XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。

被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置
的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出
堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发FullGC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

G1垃圾收集分类

YoungGC

YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么增加年轻代的region,
继续给新对象存放I不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMills设定的值,那么就会触发Young GC

MixedGC

不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区(Humongous),正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

G1收集器参数设置

-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个 年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认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%,此时就会立 即停止混合回收,意味着本次混合回收就结束了

G1收集器优化建议

重点核心是-XX:MaxGCPauseMillis停顿时间的设置
如果设置时间过长,导致系统运行很久,Eden区域过多,年轻代占用堆比例过高,触发Young GC时,会有很多存活对象,如果存活对象大于S区的一半,触发动态年龄判定规则,导致一些对象进入老年代。
设置时间过短,导致回收区域数量变少,回收垃圾变少,垃圾对象堆积越来越多,且还需要不断新创建独享,这样就会频繁触发MixedGC;

G1使用场景

  • 50%以上的堆被存活对象占用
  • 对象分配 和晋升速度变化大;
  • 垃圾回收时间特别长
  • 8G以上的堆内存
  • 停顿时间500ms以内;
  • 6.4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC


每秒几十万并发的系统如何优化JVM

Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置-XX:MaxGCPauseMills为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

1.6 ZGC收集器(-XX:+UseG1GC)

参考文章:https://wiki.openjdk.java.net/display/zgc/Main
http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf

ZGC的主要目标有4个:

  • 支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。
  • 最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。
  • 奠定未来GC特性的基础。
  • 最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。

另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。

ZGC内存布局

ZGC收集器是一款基于Region内存布局的,暂时不设分代的,使用了读屏障、颜色指针等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的Region可以具有如图3-19所示的大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)的,因为复制一个大对象的代价非常高昂。

NUMA-aware

NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory Access Architecture。UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了

服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的

  • 并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新颜色指针(见下面详解)中的Marked 0、Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从I旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。

zGc的颜色指针因为“自愈”(self-Healing)能力,所以只有第一次访问旧对象会变慢,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。

  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

颜色指针

Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。

  • 每个对象有一个64位指针,这64位被分为:
  • 18位:预留给以后使用;
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
  • 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
  • 1位:Marked1标识;
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC
  • 42位:对象的地址(所以它可以支持242=4T内存)

为什么有2个mark标记?

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0,则周期结束所有引用mark标记都会成为01。
GC周期2:使用mark1,则期待的mark标记10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了
(CompressedOops,压缩指针也是32位)。

颜色指针的三大优势:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

读屏障

之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。
在标记和移动对象的阶段,每次 从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。
那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。
那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要 [slow path],修正指针;如果指针是Good Color,那么正常往下执行即可

“这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。”后面3行代码都不需要加读屏障:Object p=o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销

那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。
PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。


ZGC存在的问题

ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。


ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。

解决方案
目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

垃圾收集底层算法实现

三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
这里我们引入“三色标记”来给大家解释下,把Gcroots可达性分析遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
     
			public class ThreeColorRemark {
				public static void main(String[] args) {
					A a = new A();
					//开始做并发标记
					D d = a.b.d;//1.读
					a.b.d = null;//2.写
					a.d = d;//3.写
				}
			}

				class A {
					B b = new B();
					D d = null;
				}

				class B {
					C s = new C();
					D d = new D();
				}

				class C {
				}

				class D {
				}

多标-浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。


漏标-读写屏障

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot AtThe Beginning, SATB).
增量更新就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
原始快照就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色
对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

写屏障

这个写屏障和cpu多线程那边的写屏障是一点关系没有的

给某个对象的成员变量赋值时,其底层代码大概长这样:

			/**
			 *gparam field某对象的成员变量,如 a.b.d
			 *@param new_value 新值. 如 nul1
			 **/
			void oop_field_store (oop * field, oop new_value){
			 *field = new_value;//赋值操作

			}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
void cop_field_store (oop * field, oop new_value){
				pre_write_barrier(field);
			 	*field = new_value;
				//写屏障-写前操作
				post_write_barrier(field, value); //写屏牌-写后操作
			}

写屏障实现增量更新

当对象A的成员变量的引用发生变化时,比如新增引用(a.d=d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:

 void post_write_barrier(oop* field, oop new_value) {
  remark_set.add(new_value); // 记录新引用的对象
}


读屏障

oop oop_field_load(oop* field) {
pre_load_barrier(field);//读屏障-读取前操作
return *field;
 }


读屏障是直接针对第一步:Dd=a.b.d,当读取成员变量时,一律记录下来:
 

void pre_load_barrier(oop* field) {
oop old_value = *field;
remark_set.add(old_value);//记录读取到的对象

面试题

为什么G1用SATB?CMS用增量更新?

我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。

记忆集与卡表

在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。
为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、老年代之间才有跨代
引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系,可以类比为Java语言中HashMap与Map的关系。
卡表是使用一个字节数组实现:CARD_TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2A9大小,即512字节

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。

卡表的维护

卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。
Hotspot使用写屏障维护卡表状态。

G1收集器参数设置

-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个 年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认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%,此时就会立 即停止混合回收,意味着本次混合回收就结束了

G1收集器优化建议

重点核心是-XX:MaxGCPauseMillis停顿时间的设置
如果设置时间过长,导致系统运行很久,Eden区域过多,年轻代占用堆比例过高,触发Young GC时,会有很多存活对象,如果存活对象大于S区的一半,触发动态年龄判定规则,导致一些对象进入老年代。
设置时间过短,导致回收区域数量变少,回收垃圾变少,垃圾对象堆积越来越多,且还需要不断新创建独享,这样就会频繁触发MixedGC;

G1使用场景

  1. 50%以上的堆被存活对象占用
  2. 对象分配和晋升速度变化大;
  3. 垃圾回收时间特别长
  4. 8G以上的堆内存
  5. 停顿时间500ms以内;
     

每秒几十万并发的系统如何优化JVM

像Kafka,每秒处理几万到几十万消息,一般部署Kafka需要大内存机器(64G),也即是说可以给年轻代分配30-40个G的内存来支撑并发处理。
对于Eden区的Young GC很快,对于G1之外的收集器(Serial,ParNew,Parallel)来说。但是对于三四十G的内存,每次收集垃圾最快也要几秒钟。让用户等几秒钟是致命的。
而Kafka的并发量放满三十个G的Eden也就一两分钟。
假设一个请求需要1KB内存(真正业务算少了),那么30万请求,那么每秒就有几个G的内存使用,每秒就300M内存,一分钟就18个G,两分钟左右就满了。然后一次YoungGC让用户等几秒,然后再处理新消息,显然不合理。
这使可以使用G1手机器,设置置 -XX:MaxGCPauseMills 为50ms,设置50ms能够回收3-4个G内存,50ms的卡顿是可以完全接受的,用户几乎无感知,那么系统可以一边进行垃圾收集,一边处理业务。

安全点与安全区域

安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。
这些特定的安全点位置主要有以下几种:

  • 1.方法返回之前
  • 2.调用某个方法之后
  • 3.抛出异常的位置
  • 4.循环的末尾

大体实现思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的。

安全区域又是什么?

Safe Point是对正在执行的线程设定的。
如果一个线程处于Sleep 或中断状态,它就不能响应JVM的中断请求,再运行到Safe Point 上。
因此JVM引入了 Safe Region。
Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC都是安全的。

JVM性能调优

前置启动一个web应用程序,用jps查看它的进程id,接着用各种jdk自带命令优化应用

Jmap

-histo  内存实例个数 内存大小

此命令可以查看内存信息实例个数以及内存大小

打开log.txt,文件内容如下

  • num:序号
  • instances:实例数量
  • bytes:占用空间大小
  • class name:类名称,[C is a char[],[S is a short[],[l is a int[],[B is a byte[],[[l is a int[][]

-heap 堆信息

查看堆信息

-dump 堆内存

jmap -dump:format=b,file=eureka.hprof 14660

也可以设置内存溢出自动导出dump文件(内存很大的时候,可能会导不出来)

  • 1. -XX:+HeapDumpOnOutOfMemoryError
  • 2. -XX:HeapDumpPath=./(路径)
public class 00MTest {
    public static List<Object> list = new ArrayList<>();
// JVM设置
// –Xms10M –Xmx10M, –XX:+PrintGCDetails –XX:+HeapDumpOnOutOfMemoryError –XX:HeapDumpPath=D: \jvm. dump
    public static void main (String[] args){
        List<Object> list = new ArrayList<>();
        int i = 0;
        int j = 0;
        while (true) {
            list.add(new User(i++, UUID.randomUUID().toString()));
            new User(j--, UUID.randomUUID().toString());
        }
    }
}

可以使用jvisualvm命令工具导入该dump文件分析

Jstack

用stack加进程id 查找死锁

public class DeadLock {
    private static Object lock1 = new Object();
    private static Object lock2 = new Object();

    public static void main(String[] args) {
        try {
            new Thread(() -> {
                synchronized (lock1) {
                    try {
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                        System.out.println("thread1");
                    }
                }
            }).start();

            new Thread(() -> {
                synchronized (lock2) {
                    try {
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock1) {
                        System.out.println("thread1");
                    }
                }
            }).start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

"Thread-1" 线程名
prio=5 优先级=5
tid=0x000000001fa9e000 线程id
nid=0x2d64 线程对应的本地线程标识nid
java.lang.Thread.State:BLOCKED 线程状态

还可以用jvisualvm自动检测死锁

远程连接jvisualvm

在远程主机上,我们需要启动 Java VisualVM 服务。打开终端或命令行窗口,使用以下命令启动服务:

jvisualvm -J-Djava.rmi.server.hostname=远程主机IP地址 -J-Dcom.sun.management.jmxremote.port=9090 -J-Dcom.sun.management.jmxremote.authenticate=false -J-Dcom.sun.management.jmxremote.ssl=false

以上命令中,我们使用了以下参数

  • -J-Djava.rmi.server.hostname=远程主机IP地址:指定远程主机的 IP 地址。
  • -J-Dcom.sun.management.jmxremote.port=9090:指定远程主机的端口号,可以根据需要更改。
  • -J-Dcom.sun.management.jmxremote.authenticate=false:禁用身份验证。
  • -J-Dcom.sun.management.jmxremote.ssl=false:禁用 SSL。

步骤三:配置远程主机的防火墙
如果远程主机上启用了防火墙,我们需要配置防火墙以允许连接。打开终端或命令行窗口,使用以下命令打开所需的端口:

sudo ufw allow 9090


以上命令中的 9090 是远程主机上启动的 Java VisualVM 服务的端口号,如果您在第二步中使用了不同的端口号,请相应地更改。

步骤四:在本地主机上启动 Java VisualVM
在本地主机上,我们可以直接启动 Java VisualVM。如果您没有安装 Java VisualVM,请先下载并安装它。

启动 Java VisualVM 后,您将看到一个欢迎界面。点击 “Remote” 按钮,在弹出的对话框中输入远程主机的 IP 地址和端口号,然后点击 “Connect”。

步骤五:连接到远程主机
在点击 “Connect” 后,Java VisualVM 将会尝试连接到远程主机。如果一切设置正确,连接将会成功,您将能够在 Java VisualVM 中看到远程主机上运行的 Java 程序。
 

找出cpu最高的线程堆栈信息

1,使用命令top -p <pid>,显示你的java进程的内存情况,pid是你的java进程号,比如19663

2,按H(大写的),获取每个线程的内存情况

3,找到内存和cpu占用最高的线程tid,比如19664
4,转为十六进制得到0x4cd0,此为线程id的十六进制表示
5,执行jstack 19663lgrep-A 104cd0,得到线程堆栈信息中4cd0这个线程所在行的后面10行,从堆栈中可以发现导致cpu飙高的调用方法

6,查看对应的堆栈信息找出可能存在问题的代码

Jinfo

查看正在运行的Java应用程序的扩展参数
查看jvm的参数

查看java系统参数

Jstat

jstat命令可以查看堆内存各部分的使用量,以及加载类的数量。命令的格式如下:
jstat [-命令选项][vmid] [间隔时间(毫秒)][查询次数]
注意:使用的jdk版本是jdk8

-gc pid 评估程序内存使用及GC压力整体情况

  • S0C:第一个幸存区的大小,单位KB
  • S1C:第二个幸存区的大小
  • SOU:第一个幸存区的使用大小
  • S1U:第二个幸存区的使用大小
  • EC:伊甸园区的大小
  • EU:伊甸园区的使用大小
  • OC:老年代大小
  • OU:老年代使用大小
  • MC:方法区大小(元空间)
  • MU:方法区使用大小
  • CCSC:压缩类空间大小
  • CCSU:压缩类空间使用大小
  • YGC:年轻代垃圾回收次数
  • YGCT:年轻代垃圾回收消耗时间,单位s
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间,单位s
  • GCT:垃圾回收消耗总时间,单位s

-gccapacity 堆内存统计

  • NGCMN:新生代最小容量
  • NGCMX:新生代最大容量
  • NGC:当前新生代容量
  • SOC:第一个幸存区大小
  • S1C:第二个幸存区的大小
  • EC:伊甸园区的大小
  • OGCMN:老年代最小容量
  • OGCMX:老年代最大容量
  • OGC:当前老年代大小
  • OC:当前老年代大小
  • MCMN:最小元数据容量
  • MCMX:最大元数据容量
  • MC:当前元数据空间大小
  • CCSMN:最小压缩类空间大小
  • CCSMX:最大压缩类空间大小
  • CCSC:当前压缩类空间大小
  • YGC:年轻代gc次数
  • FGC:老年代GC次数

JVM运行情况预估

用jstat gc-pid命令可以计算出如下一些关键数据,有了这些数据就可以采用之前介绍过的优化思路,先给自己的系统设置一些初始性的JVM参数,比如堆内存大小,年轻代大小,Eden和Survivor的比例,老年代的大小,大对象的阈值,大龄对象进入老年代的阈值等。


年轻代对象增长的速率

可以执行命令jstat -gc pid 1000 10(每隔1秒执行1次命令,共执行10次),通过观察EU(eden区的使用)来估算每秒eden大概新增多少对象,如果系统负载不高,可以把频率1秒换成
1分钟,甚至10分钟来观察整体情况。注意,一般系统可能有高峰期和日常期,所以需要在不同的时间分别估算不同情况下对象增长速率。


Young GC的触发频率和每次耗时

知道年轻代对象增长速率我们就能推根据eden区的大小推算出Young GC大概多久触发一次,Young GC的平均耗时可以通过YGCT/YGC公式算出,根据结果我们大概就能知道系
大概多久会因为Young GC的执行而卡顿多久。


每次Young GC后有多少对象存活和进入老年代

这个因为之前已经大概知道Young GC的频率,假设是每5分钟一次,那么可以执行命令jstat-gc pid 30000010,观察每次结果eden,survivor和老年代使用的变化情况,在每次gc
后eden区使用一般会大幅减少,survivor和老年代都有可能增长,这些增长的对象就是每次Young GC后存活的对象,同时还可以看出每次Young GC后进去老年代大概多少对象,从而可以推算出老年代对象增长速率。

Full GC的触发频率和每次耗时

知道了老年代对象的增长速率就可以推算出FullGC的触发频率了,Full GC的每次耗时可以用公式FGCT/FGC计算得出。


优化思路其实简单来说就是尽量让每次Young GC后的存活对象小于Survivor区域的50%,都留存在年轻代里。尽量别让对象进入老年代。尽量减少Full GC的频率,避免频繁Full

调优案例

系统频繁FULL GC导致系统卡顿

  • 机器配置:2核4G
  • JVM内存大小:2G
  • 系统运行时间:7天
  • 期间发生的Full GC次数和耗时:500多次,200多秒
  • 期间发生的Young GC次数和耗时:1万多次,500多秒


大致算下来每天会发生70多次Full GC,平均每小时3次,每次Full GC在400毫秒左右;
每天会发生1000多次Young GC,每分钟会发生1次,每次Young GC在50毫秒左右。

JVM参数设置如下:

-Xms1536M -Xmx1536M -Xmn512M -Xss256K 
-XX:SurvivorRatio=6 -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC 
-XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly

根据上面的数据 可以算出下面的模型

大家可以结合对象挪动到老年代那些规则推理下我们这个程序可能存在的一些问题
经过分析感觉可能会由于对象动态年龄判断机制导致full gc较为频繁

为了给大家看效果,我模拟了一个示例程序,打印了jstat的结果如下:

jstat -gc 13456 2000 10000

对于对象动态年龄判断机制导致的full gc较为频繁可以先试着优化下JVM参数,把年轻代适当调大点,并且-XX:CMSInitiatingOccupancyFraction=92参数给他调大了(因为该系统高并发场景不高):

-Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=6
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

优化发现没有什么变化,反而full gc的次数比minor gc多了

我们可以推测下full gc比minor gc还多的原因有哪些?

  1. 元空间不够导致的多余full gc
  2. 显示调用System.gc()造成多余的full gc,这种一般线上尽量通过-XX:+DisableExplicitGC参数禁用,如果加上了这个JVM启动参数,那么代码中调用System.gc()没有任何效果
  3. 老年代空间分配担保机制

最快速度分析完这些我们推测的原因以及优化后,我们发现young gc和full gc依然很频繁了,而且看到有大量的对象频繁的被挪动到老年代,这种情况我们可以借助jmap命令大概看下是什么对象

这个USer对象占用过多

后来发现这个User对象有个属性Byte数组每个Byte数组占用100kb大小

修正代码把for循环的i 改成500就恢复了

内存泄露到底是怎么回事

再给大家讲一种情况,一般电商架构可能会使用多级缓存架构,就是redis加上JVM级缓存,大多数同学可能为了图方便对于JVM级缓存就简单使用一个hashmap,于是不断往里面放缓存数据,但是很少考虑这个map的容量问题,结果这个缓存map越来越大,一直占用着
老年代的很多空间,时间长了就会导致full gc非常频繁,这就是一种内存泄漏,对于一些老旧数据没有及时清理导致一直占用着宝贵的内存资源,时间长了除了导致full gc,还有可能导致OOM。
这种情况完全可以考虑采用一些成熟的JVM级缓存框架来解决,比如ehcache等自带一些LRU数据淘汰算法的框架来作为JVM级的缓存。

阿里巴巴Arthas调优工具

# github下载arthas
https://alibaba.github.io/arthas/arthas-boot.jar
#或者Gitee 下载
https://arthas.gitee.io/arthas-boot.iar

# 官方使用文档

简介 | arthasarthas 使用文档icon-default.png?t=N7T8https://arthas.aliyun.com/doc/

用java-jar运行即可,可以识别机器上所有Java进程(我们这里之前已经运行了一个Arthas测试程序,代码见下方

选择进程序号1,进入进程信息操作

输入dashboard可以查看整个进程的运行情况,线程、内存、GC、运行环境信息:

更多详见文档

https://arthas.aliyun.com/doc/

GC日志

对于java应用我们可以通过一些配置把程序运行过程中的gc日志全部打印出来,然后分析gc日志得到关键性指标,分析GC原因,调优JVM参数。打印GC日志方法,在JVM参数里增加参数,%t代表时间

-Xloggc:./gc-%t.log -XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:+PrintGCTimeStamps 
-XX:+PrintGCCause
-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=10 
-XX:GCLogFileSize=100M

Tomcat则慧姐加载JAVA_OPTS变量中

如何分析GC日志

运行程序加上对应gc日志

java -jar -Xloggc:./gc-%t.log -XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:+PrintGCTimeStamps 
-XX:+PrintGCCause
-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=10 
-XX:GCLogFileSize=100M microservice-eureka-server.jar

下图中是我截取的JVM刚启动的一部分GC日志

我们可以看到图中第一行红框,是项目的配置参数。这里不仅配置了打印GC日志,还有相关的VM内存参数。
第二行红框中的是在这个GC时间点发生GC之后相关GC情况。

  1. 对于2.909:这是从jvm启动开始计算到这次GC经过的时间,前面还有具体的发生时间日期。
  2. Full GC(Metadata GC Threshold)指这是一次full gc,括号里是gc的原因,PSYoungGen是年轻代的GC,ParOldGen是老年代的GC,Metaspace是元空间的GC
  3. 6160K->0K(141824K),这三个数字分别对应GC之前占用年轻代的大小,GC之后年轻代占用,以及整个年轻代的大小。
  4. 112K->6056K(95744K),这三个数字分别对应GC之前占用老年代的大小,GC之后老年代占用,以及整个老年代的大小。
  5. 6272K->6056K(237568K),这三个数字分别对应GC之前占用堆内存的大小,GC之后堆内存占用,以及整个堆内存的大小。
  6. 20516K->20516K(1069056K),这三个数字分别对应GC之前占用元空间内存的大小,GC之后元空间内存占用,以及整个元空间内存的大小。
  7. 0.0209707是该时间点GC总耗费时间。

从日志可以发现几次fullgc都是由于元空间不够导致的,所以我们可以将元空间调大点

java -jar -Xloggc:. /gc-adjust-%t. log -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+Print
GCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFil
eSize=100M microservice-eureka-server. jar

调整完我们再看下gc日志发现已经没有因为元空间不够导致的full gc

对于CMS和G1收集器的日志会有一点不一样,也可以试着打印下对应的gc日志分析下,可以发现gc日志里面的gc步骤跟我们之前讲过的步骤是类似的

public class HeapTest {
byte[] a = new byte[1024 * 100]; //100KB
public static void main(String[] args) throws InterruptedException {
ArrayList<HeapTest> heapTests = new ArrayList<>();
while (true) {
heapTests.add(new HeapTest());
Thread.sleep(10); }
}
}

CMS

-Xloggc:d:/gc-cms-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

G1

-Xloggc:d:/gc-g1-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -XX:+UseG1GC

上面的这些参数,能够帮我们查看分析GC的垃圾收集情况。但是如果GC日志很多很多,成干上万行。就算你一目十行,看完了,脑子也是一片空白。所以我们可以借助一些功能来帮助我们分析,这里推荐一个gceasy(https://qceasy.io),可以上传gc文件,然后他会利用可视化的界面来展现GC情况。具体下图所示

上图我们可以看到年轻代,老年代,以及永久代的内存分配,和最大使用情况。

上图我们可以看到堆内存在GC之前和之后的变化,以及其他信息。
这个工具还提供基于机器学习的JVM智能优化建议,当然现在这个功能需要付费

JVM参数汇总查看命令

java -XX:+PrintFlagslnitial 表示打印出所有参数选项的默认值
java-XX:+PrintFlagsFinal表示打印出所有参数选项在运行程序时生效的值

  • 23
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值