【闲聊杂谈】一篇给你讲清楚JVM调优的本质

1、什么是JVM

JVM的定义

在说JVM之前,首先要知道什么是JVM。JVM是JavaVirtualMachine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

为什么要在程序和操作系统中间添加一个JVM? Java是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性。这些特性直接在操作系统上实现是不太可能的,所以就需要JVM进行一番转换。有了JVM这个抽象层之后,Java就可以实现跨平台了。JVM只需要保证能够正确执行.class文件,就可以运行在诸如Linux、Windows、MacOS等平台上了。 而Java跨平台的意义在于一次编译,处处运行,能够做到这一点JVM功不可没。比如我们在Maven仓库下载同一版本的jar包就可以到处运行,不需要在每个平台上再编译一次。 现在的一些JVM的扩展语言,比如Clojure、JRuby、Groovy等,编译到最后都是.class文件,Java语言的维护者,只需要控制好JVM这个解析器,就可以将这些扩展语言无缝的运行在JVM之上了。

如果说Java是跨平台的语言,那么JVM就是跨语言的平台。目前不完全统计,已经有一百多种语言可以直接运行在JVM上。当然JVM本身是一种规范(具体规范定义了什么,感兴趣可以自己去Oracle官网看一看:https://docs.oracle.com/javase/specs/index.html),在不同的操作系统上有不同的具体实现。

为什么这些语言都可以在JVM上运行呢?因为这些语言写成的文件都可以编译成class文件,class文件可以直接丢在JVM中运行。从JVM的角度来说,不管你是什么语言,只要能变成class文件,那我就能给你运行起来。

常见的JVM实现

Hotspot

Oracle官方实现,其实也是从Sun收购过来的。不出意外,这也是大家最常用的虚拟机。安装完以后,命令行输入java -version就可以看到相关信息。

最底下一行说明了,当前的JVM使用的是Hotspot的64位Serverv版。值得一提的是,mixed mode指的是当前使用的是解释执行和编译执行的混合版。经常有人回Java到底是解释执行的语言,还是编译执行的语言。默认的情况是是二者结合的混合执行,官放在这个界面已经告诉你了。所谓混合模式,是在起始阶段采用解释执行,如果检测遇到热点代码(比如多次被调用的方法,或者多次被调用的循环等),就会进行编译。当然,这个完全可以使用参数进行指定:

-Xmixed:默认的混合模式

-Xint:使用解释模式,启动快,执行慢

-Xcomp:使用纯编译模式,执行快,启动慢

Jrockit

BEA公司的实现,曾经号称是世界上最快的JVM,后被Oracle收购,合并于Hotspot。

J9

IBM的实现。

Microsoft VM

微软的实现。

TaobaoVM

相当于是Hotspot的深度定制版,像阿里这种大厂当然得定制才符合身份。

LiquidVM

直接针对硬件的虚拟机,没有再跟操作系统进行对接,而是直接去对接硬件。这个运行起来的效率直接飞起。

azul zing

号称最新垃圾回收的业界标杆,据说特别贵。不过人收那么多钱,服务也不会差。根据官网给出的介绍,说是1ms之内可以实现垃圾回收,简直不可思议。不过azul的垃圾回收的算法后来被Hotspot吸收优化,诞生了后来的ZGC。

2、认识class文件

生成class文件

使用Java创建一个类

编译完之后会产生一个class文件,将这个class文件使用IDEA的BinEd插件打开,就可以看到一个十六进制的文件内容

这是什么玩意儿?? 其实class文件本质上是一个二进制字节流,它的数据类型有:u1、u2、u4、u8和_info(_info的来源是Hotspot源码中的写法)。u1代表一个字节,u2代表两个字节.....其实这几种数据类型也是逻辑上分的,本身二进制的类型不是0就是1,最终被JVM解释运行的就是这些二进制的字节流。

class文件的构成

magic的作用就是告诉JVM,这是一个Java语言的class文件;

minor versionmajor version用来表示class文件的版本号。这里的34是十六进制,换算成十进制是52,JDK7默认是51,JDK8默认是52;

constant_pool_count指的是常量池中的常量数;

access_flag指的是修饰符,public、final.....

interface_count指的是接口的数量;

filds_count指的是成员变量的数量;

.....

具体每一项代表的含义看下图

使用工具查看class文件内容

JDK自带的工具中也提供了 javap 的命令进行class文件的内容解析

进入到class文件所在的目录下,对class文件进行解析

除此之外,我个人感觉最好用的还是IDEA中提供的JClassLib插件,它会将class文件中的内容非常详细的分析出来

3、认识类加载器

上面我们已经知道了class文件中有什么,那么接下来,class文件又是怎么被加载到类加载器中的呢?这个类加载器又是个什么东西?

class文件加载过程

根据官方给出的规范定义,总的来说加载的过程分为三大步:loading、linking、initializing。

loading

就是将class文件加载到内存中。

linking

verification 的过程是校验,class文件是否符合定义的标准。譬如,文件开始的4个字节不是CAFE BABE,那说明这不是一个标准的class文件,校验出错。

preparation 的过程是赋默认值,而不是初始值。比如说int a = 8,这一步是给a赋默认值0,而不是初始值8。

resolution 的过程是将类、方法、属性等符号引用解析为直接引用。常量池中的各种符号引用解析为指针、偏移量等内存地址的直接引用。

initializing

这一步才是赋初始值,调用静态代码块。

类加载器

在上面所说的loading步骤,将class文件加载到内存中,就得使用类加载器来进行加载,也就是ClassLoader。当一个class文件加载到内存中以后,会产生两块内容。第一块内容是将二进制的东西扔进内存中占了一块,与此同时生成了一个class的对象,而这个对象指向第一块的内容。

而ClassLoader自身也是分层级的,不同的类会使用不同的ClassLoader进行加载。

当使用getClassLoader()方法获取到空的时候,那就说明已经到最顶级的Bootstrap类加载器。这个Bootstrap是用C++来实现的,Java中并没有对应的类,所以只能返回null。

双亲委派机制

那么这么多类型的类加载器,是怎么协同工作的呢?

1)一般一个类开始加载的时候,会先去自定义的加载器中寻找,看看有没有将这个类已经加载过了。如果已经加载过了,则不需要再加载;如果没有加载过,赶紧的给加载进来;

2)自定义加载器发现自身没有加载,但不会立马自己就跑去加载这个类,而是先去问一下自己的父级加载器,也就是App ClassLoader,看它有没有加载。如果有加载则返回已加载,如果没有加载,再继续像它的父级类加载器继续寻找是否已经加载;

3)直到最顶级的Bootstrap类加载器,还没有加载过的记录。那么此时Bootstrap会看这个类是否该属于自己去加载,如果不属于自己加载的类,则反向委托自己下一级的类加载器,也就是Extension ClassLoader进行加载,如果也不属于Extension ClassLoader进行加载,则继续委托下一级App ClassLoader进行加载,直至找到符合的类加载器将这个类进行加载;

4)最终经过了转圈圈之后,这个类才算完整的被加载到内存中。如果到最后也没有加载完成,则会报ClassNotFoundException异常;

这就是所谓的双亲委派机制,双亲委派是一个孩子向父亲方向,然后父亲向孩子方向的双亲委派过程。那为什么要搞得这么复杂,直接加载不就完了,还委托来委托去的。

根本原因还是为了安全。如果不采取双亲委派机制,而是随便一个自定义的类加载器可以任意加载的话。那么完全可以自己写一个String类型,将JDK提供的String类型覆盖掉。那么这个String是自定义的,想干嘛干嘛,别人使用你的这个String类型的时候,就会被你的代码肆意妄为。当然次要的也是可以节省资源,对于已经加载完的文件,无需重复加载,找找就行了。

自定义类加载器

只需要继承ClassLoader,然后重写findClass方法将文件加载到内存,再调用defineClass方法转换为class文件就可以了。

package com.feenix.jvm.c2_classloader;

import com.feenix.jvm.Hello;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;

public class T006_MyClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        File f = new File("c:/test/", name.replace(".", "/").concat(".class"));
        try {
            FileInputStream fis = new FileInputStream(f);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int b = 0;

            while ((b = fis.read()) != 0) {
                baos.write(b);
            }

            byte[] bytes = baos.toByteArray();
            baos.close();
            fis.close();

            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            e.printStackTrace();
        }

        return super.findClass(name); //throws ClassNotFoundException
    }

    public static void main(String[] args) throws Exception {
        ClassLoader l = new T006_MyClassLoader();
        Class clazz = l.loadClass("com.feenix.jvm.Hello");
        Class clazz1 = l.loadClass("com.feenix.jvm.Hello");

        System.out.println(clazz == clazz1);

        Hello h = (Hello) clazz.newInstance();
        h.m();

        System.out.println(l.getClass().getClassLoader());
        System.out.println(l.getParent());

        System.out.println(getSystemClassLoader());
    }
}

基本上写框架的时候肯定会少不了自定义ClassLoader,Spring有自己的ClassLoader,Tomcat也有自己的ClassLoader,实现了自定义的ClassLoader,也就是可以实现热部署了。

4、Java内存模型(JMM)

现在的CPU结构越来越复杂,其实就是进一步压榨效率,提高速度,塞进去了越来越多的缓存。CPU的运算速度比内存啊、硬盘啊都不知道快了多少倍。所以像硬盘这么慢吞吞的东西,想要被CPU进行读取计算,是不可能直接跟CPU进行交互的,太拖CPU后腿。所以硬盘中的数据先被读取到内存中,再从内存读取到高速缓存中.....最终读取到寄存器中,被CPU拿来使用计算。

从上图可以看出一个问题,数据从L3缓存读取到L2缓存中后,由于L2缓存是CPU内部的缓存,也就是说一块主板上如果有两个CPU,那么这每个CPU都会有自己的L2缓存,那么从L3读取进来的数据是不共享的。假设两个CPU都将数据进行了修改,就会产生数据不一致的问题。

从硬件上来说,最简单粗暴的方法就是加一个总线锁,直接将L3锁死。当一个CPU访问L3中的数据时,另一个CPU只能等待,直至锁释放后才可以对数据进行访问。这个是老CPU使用的方法,因为效率实在是低,新的CPU早已淘汰了这种同步方式。

MESI Cache一致性协议

现在的CPU对于硬件数据一致的保证,是通过缓存一致性协议来打成。协议的具体实现非常多,intel使用的是MESI协议,所以一般说到这个,都叫它MESI协议。大体上是将缓存的内容做一个标记,分为M、E、S、I四种不同状态,根据不同的状态对数据进行不同的处理。

通过这个协议,对各个CPU中的缓存保证数据的一致性。值得注意的是,MESI并没有完全解决锁总线的问题,现在的CPU并没有完全抛弃总线锁,必要的时候还是会使用。所以现在的CPU的数据一致性实现实际上是通过缓存锁(MESI ...) + 总线锁共同实现。

缓存行的为共享问题及缓存行对齐

当读取硬盘上的数据进内存的时候,每次不可能只读取刚好需要的那一个数据,为了提高读取的效率,会一次性将这个数据所在的一长串连续的内容全部读取进内存中。一长串连续的内容称为一个缓存行(cache line),大多数的缓存行的长度为64字节。

而位于同一缓存行的两个不同数据,被两个不同CPU锁定,就会产生互相影响的伪共享问题。假设x、y位于同一缓存行。第一个CPU只需要用到x,第二个CPU只需要用到y。第一个CPU修改了x的值之后通知第二个CPU缓存行被修改了,第二个CPU就需要重新将缓存行的数据进行加载;同样的,第二个CPU修改了y的值之后通知第一个CPU缓存行被修改了,第一个CPU就需要重新将缓存行的数据进行加载。

伪共享的问题,会造成效率上的严重浪费。理论上来说,只要将两个数据认为的不放在同一个缓存行上,应该是可以提高数据的运算效率。

优化前:

package com.feenix.juc.c_028_FalseSharing;

public class T01_CacheLinePadding {
    private static class T {
        public volatile long x = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
    }
}

优化后:

package com.feenix.juc.c_028_FalseSharing;

public class T02_CacheLinePadding {
    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7;
    }

    private static class T extends Padding {
        public volatile long x = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
    }
}

这种优化方式,叫缓存行对齐。直接将同一个行的空间全部占满,以空间换时间,这种方法已经被很多开源作者使用,在极度追求速度的时候,不失为一个好用的方法。

指令乱序问题

前面说过,代码中的语句会被拆解成多个指令执行。CPU执行指令的速度非常快,CPU为了提高指令执行效率,会在一条指令执行后等待的过程中,去执行另一条指令,前提是,两条指令没有依赖关系。

乱序的问题也可以用代码进行证明:

package com.feenix.jvm.c3_jmm;

public class T04_Disorder {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(new Runnable() {
                public void run() {
                    //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                    shortWait(100000);
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();
            other.start();
            one.join();
            other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                //System.out.println(result);
            }
        }
    }


    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

这个等待的时间会非常久,我自己的电脑运行了一百多万次才得到乱序的结果

指令顺序保证

很多时候程序的运行需要保证指令不可乱序执行,那么这个乱序问题该如何解决呢?

硬件内存屏障

从硬件角度来说,是通过CPU的内存屏障来保证指令的有序性。拿intel的CPU来说,通过三条指令来保证:

1、sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成;

2、lfence:在lfence指令前的读操作当必须在lfence指令后的读操作前完成;

3、mfence:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成;

原子指令

如x86上的“lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。

JVM规范

LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕;

StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见;

LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕;

StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2, 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见;

volatile

对于指令顺序的保证,从代码角度来说,只要加上volatile修饰符即可。那么这个volatile到底做了什么事?从字节码层面,也就是加了一个ACC_VOLATILE标记

这什么也看不出来,所以还是要从JVM层面去看,对于volatile内存区的读写都加上屏障:

StoreStoreBarrier - volatile 写操作 - StoreLoadBarrier

LoadLoadBarrier - volatile 读操作 - LoadStoreBarrier

通过JVM所定义的屏障规范,从而保证指令的有序性。

synchronized

从字节码层面,也是加了一个ACC_SYNCHRONIZED标记,并且增加了monitorenter和monitorexit指令。从JVM层面来说,是C 、C++ 调用了操作系统提供的同步机制。从硬件层面也是通过lock cmpxchg指令来进行锁定。

Java对象模型

对象的创建过程

1、class loading;
2、class linking (verification, preparation, resolution);
3、class initializing;
4、申请对象内存;
5、成员变量赋默认值;
6、调用构造方法<init>:成员变量顺序赋初始值、执行构造方法语句;

对象在内存中的存储布局

普通对象:
1、对象头:markword 8bytes;
2、ClassPointer指针:-XX:+UseCompressedClassPointers 开启为4bytes,不开启为8bytes;
3、实例数据:引用类型 -XX:+UseCompressedOops 开启为4bytes,不开启为8bytes;
4、Padding对齐,8的倍数;

数组对象:
1、对象头:markword 8bytes
2、ClassPointer指针:-XX:+UseCompressedClassPointers 开启为4bytes,不开启为8bytes;
3、数组长度:4bytes;
4、数组数据;
5、Padding对齐,8的倍数;

上面提到的参数是否开启,可以通过java -XX:+PrintCommandLineFlags -version观察虚拟机配置

对象的大小

在Java中想要观察对象的大小比较麻烦,不像C或者C++可以通过sizeof直接拿到。对于Java来说只能通过Agent机制,在class文件加载到JVM的时候,通过拦截从而获取到加载的class文件,拦截到之后,可以对class文件进行任意的修改,那么也就能读取到对象的大小。

package com.feenix.jvm.agent;

import java.lang.instrument.Instrumentation;

public class ObjectSizeAgent {
    private static Instrumentation inst;

    public static void premain(String agentArgs, Instrumentation _inst) {
        inst = _inst;
    }

    public static long sizeOf(Object o) {
        return inst.getObjectSize(o);
    }
}

src目录下创建META-INF/MANIFEST.MF

Manifest-Version: 1.0
Created-By: feenix.com
Premain-Class: com.feenix.jvm.agent.ObjectSizeAgent

ObjectSizeAgent中有一个固定的方法premain,把这个打成一个jar包,在需要使用该Agent Jar的项目中引入该Jar包。然后JVM会自动将这个Instrumentation传递进来,这样直接调用inst.getObjectSize便可拿到对象的大小。

运行时需要该Agent Jar的类,加入参数:
-javaagent:C:\work\ijprojects\ObjectSize\out\artifacts\ObjectSize_jar\ObjectSize.jar

   package com.feenix.jvm.c3_jmm;
   
   import com.feenix.jvm.agent.ObjectSizeAgent;
   
   public class T03_SizeOfAnObject {
       public static void main(String[] args) {
           System.out.println(ObjectSizeAgent.sizeOf(new Object()));
           System.out.println(ObjectSizeAgent.sizeOf(new int[] {}));
           System.out.println(ObjectSizeAgent.sizeOf(new P()));
       }
   
       private static class P {
                           // 8bytes _markword
                           // 4bytes _oop指针
           int id;         // 4bytes
           String name;    // 4bytes
           int age;        // 4bytes
   
           byte b1;        // 1byte
           byte b2;        // 1byte
   
           Object o;       // 4bytes
           byte b3;        // 1byte
       }
   }

可以看到每个成员变量的大小,然后一加总和是31bytes,最后再来个padding得是8的倍数,所以这么一个对象大小总共是32bytes。

对象头具体包括什么

对象脑袋其实非常复杂,而且每个版本的实现还不完全一样,如果想要摸清每个版本具体的实现细节,只能去官网翻原版规范文档,比如JDK8的实现在Hotspot的源码中就是:

这是一个C++写的文件,可以看到32bits是怎么实现的,64bits是怎么实现的,多少位又是不用的(unused),然后每一位代表的又是什么意思。具体看下64bits的实现

为什么GC年龄最大为15?因为使用4bits来记录。最大就是15,这个是不可调的。

关于hashcode的部分:如果对象没有重写hashcode,那么默认是调用os::random产生,可以通过System.identityHashCode获取;另外一旦生成了hashcode,JVM会将其记录在markword中。什么时候会产生hashcode?当然是调用未重写的hashcode方法以及System.identityHashCode的时候。

需要注意下,当调用锁对象的hashcode后会导致该对象的偏向锁或者轻量级锁升级。因为在Java中一个对象的hashcode是在调用者两个方法时才生成的,如果是无锁状态则存在放markword中,如果是重量级锁则存在对应的monitor中,而偏向锁是没有地方能存放该信息的,所以必须升级。

1、句柄池

2、直接指针

这两种方式各有利弊,Hotspot的实现使用的是直接指针的方式。句柄池的方式在垃圾回收的时候效率更高,直接指针的方式在定位的时候效率更高。

5、JVM指令集

当一个class文件被加载到JVM之后,会进入到JVM中的run engine,运行起来就会进入到run-time data area

Program Count 程序计数器:存放指令位置;

while( not end ) {

        取PC中的位置,找到对应位置的指令;

        执行该指令;

        PC ++;

}

JVM Stack:每个线程对应一个栈,每个方法对应一个栈帧,栈帧中包含:

① Local Variable Table
② Operand Stack:对于long的处理(store and load),多数虚拟机的实现都是原子的,jls 17.7,没必要加volatile
③ Dynamic Linking
④ return address:a() -> b(),方法a调用了方法b, b方法的返回值放在什么地方

Direct Memory:JVM可以直接访问的内核空间的内存 ,操作系统直接管理的内存。NIO ,提高效率,实现zero copy;

Heap:JVM中线程共享;

Method Area:方法区被所有的线程所共享。
在JDK1.8之前(不包含1.8),Method Area指的是Perm Space,字符串常量位于Perm Space,FGC不会清理;
在JDK1.8之后(包含1.8),Method Area指的是Meta Space,字符串常量位于Heap,会触发FGC清理;

Run-time Constant Pool:class文件中有一项就是常量池,运行的时候就存放在这里;

JVM Stack

总结下来就是,每个线程都有自的PC、VMS、NMS,Heap和Method Area线程间共享。

好了,有了以上的知识基础之后,来看一道面试题:

    public static void main(String[] args) {
        int i = 8;
        i = i++;
        System.out.println(i);
    }

来看下这个class文件的内部结构,

上面时候的,一个方法对应一个栈帧,栈帧中有局部变量表,对这个main方法来说,使用到的变量有两个:入参传递的args、i,也就是上图中国所圈出来的部分。

而操作栈(Operand Stack)是没有显式的展示,只能通过给出的一条条指令,自己去设想

上图中显式的就是方法中的代码转换成指令后的操作顺序:

bipush 8 就是将8这个数字push到操作栈中,说白了就是压栈;

istore_1 就是将栈顶的值出栈,并存档到下标值为1的局部变量表中的值(根据上上张图,可以看到这个变量就是方法中的i),说白了就是将i赋值为8,对应代码 int i = 8 完成;

iload_1 就是将局部变量表中下标为1的变量的值取出压栈,所以此时8又被压入操作栈中;

iinc 1 by 1 就是将局部变量表中下标为1的变量的值+1,此时局部变量表中的值是9,但栈中的值还是8;

istore_1 将栈顶的8又出栈,赋值给局部变量表中的i,所以上一步 i 的值是9,现在又变成了8;

因此,最终得到的结果 i 就是8。

6、垃圾回收机制

什么是垃圾

没有引用指向的对象,都是垃圾。

C / C++
– 手工管理 malloc free / new delete
– 忘记释放 – memory leak – out of memory
– 释放多次 产生极其难易调试的Bug,一个线程空间莫名其妙被另外一个释放了
– 开发效率很低

Java / Python / Go /Js / Kotlin / Scala
– 方便内存管理的语言
– GC – Garbage Collector – 应用线程只管分配,垃圾回收器负责回收
– 大大减低程序员门槛

Rust
– 运行效率超高
– 不用手工管理内存(没有GC)
– 学习曲线巨高 (ownership)
– 你只要程序语法通过,就不会有bug

如何定位垃圾

reference count 引用计数

在对象头上标记当前对象被引用的数量,当数量为0,则视为垃圾处理掉。这种定位的问题在不能解决对象间的循环引用的问题。三五个对象间相互引用,但是却没有另一个引用指向这三五个对象团,整团对象其实就是垃圾,但是对象头上的标记各自又不是垃圾。

root searching 根可达算法

哪些算是根对象呢?JVM规范中定义:JVM Stack、NMS、run-time constant pool、method area内部用到的引用.....这些都是根对象。基本上可以简单理解为,当一个程序启动之后所需要的必须对象都可以算是根对象。

垃圾清除算法

Mark-Sweep 标记清除

标记清除算法较为简单,就是将那些垃圾找出,然后标记为对象,再对这些标记的垃圾进行清除。在存货对象较多的情况下效率较高,但是需要进行两遍扫描,执行效率偏低,且容易产生碎片。

Copying 拷贝

内存一分为二,将有用的对象拷贝到干净的一半内存中,拷贝完了之后,将原先杂乱的那一半内存全部清除掉。这种算法适用于存活对象较少的情况,只需扫描一次,效率较高,也不会产生碎片。但是问题也很明显,会造成空间使用上的浪费,在移动复制对象的过程中,需要调整对象的引用。

Mark-Compact 标记压缩

有用的对象全部压缩到一起,不会产生碎片,也无需内存减半,方便对象分配。但是问题也有,还是需要扫描两次,还要移动对象,执行效率偏低。

堆内存逻辑分区

Hotspot在老版本的垃圾回收器中使用的分代算法,到G1的时候已经是逻辑分代,物理不分代。而在ZGC中,彻底舍弃了分代的规划。

在分代算法中,将内存分为新生代和老年代,默认情况下是1:3。新生代又分为一个伊甸区(eden)和两个幸存区(survivor),默认情况下是8:1:1;而老年代整体上都是终身区(tenured)。不同的区域使用的垃圾回收算法也不同,譬如eden区一般使用标记清除算法,幸存区一般使用拷贝算法,而老年代一般都使用标记压缩算法来清理垃圾。

1、当一个对象被创建出来之后,首先尝试进行栈上分配,如果栈上分配不下,会进入eden区;

2、在eden区中经过一个垃圾回收之后,会进入survivor1中,survivor1中再经历一次垃圾回收之后,会进入survivor2中,然后反复在survivor1和survivor2中来回移动,每移动一次(或者说是每经历一次垃圾回收)都会在对象头中的年龄记录+1;

3、当这个对象的年龄足够大的时候,就会进入到老年代。

当年轻代空间耗尽时触发的垃圾回收,叫 MinorGC / YongGC,简称YGC;

当老年代无法继续分配空间时触发的垃圾回收,叫 MajorGC / FullGC,简称FGC。当触发FGC时,会将整个内存全部回收。

哪些对象会在栈上分配,哪些对象又在线程本地分配?

▪ 栈上分配
– 线程私有小对象
– 无逃逸
– 支持标量替换
– 无需调整

▪ 线程本地分配TLAB (Thread Local Allocation Buffer)
– 占用eden,默认1%
– 多线程的时候不用竞争eden就可以申请空间,提高效率
– 小对象
– 无需调整

从JDK诞生开始,出现的第一个垃圾回收器就是Serial(意思就是单线程)。后来为了弥补单线程回收效率的低下,又诞生了Parallel Scavenge(意思就是多线程)。JDK1.4版本后期引入CMS,它可以说是里程碑式的GC,它开启了并发回收的过程。但是CMS毛病较多,因此目前任何一个JDK版本默认是CMS 并发垃圾回收是因为无法忍受STW。

常用垃圾回收器

Serial

当Serial进行垃圾回收的时候,程序中所有的工作线程全部停止。

在JDK刚诞生的那会,JVM的内存也没多大,撑死了几十兆,用一个线程来回收垃圾STW的时间也不是很久。不过随着硬件的发展,现在计算机的内存越来越大,试想一下,要是现在用一个单线程去回收几十上百G的空间,那STW的时间没几十个小时打不住。

Parallel Scavenge

一个线程效率太慢,那多个线程效率不就提升上来嘛。所以解决单线程STW时间久的问题,最简单粗暴的方法就是加线程,人多力量大。一直到JDK8,都是采用PS作为默认的垃圾回收器,也就是说上线的时候如果不进行垃圾回收器的特别设置,默认使用Parallel Scavenge。

Parallel New

可以认为是Parallel Scavenge的新版本,和Parallel Scavenge的区别在于它做了一些增强,以便可以和CMS配合使用。

CMS

CMS从功能上来说,可以认为是垃圾回收器的一个里程碑。在CMS之前,所有的垃圾回收器都是在进行垃圾回收的时候,工作线程是必须全部停下等待垃圾回收完成之后才可以进行,在这段STW的时间里,程序除了卡死无响应没有任何办法。而CMS伟大的地方的就在于实现了垃圾回收线程和工作线程同时干活,你做你的工作,我收我的垃圾,真正实现了Concurrent Mark Sweep!但是CMS的问题也很多,以至于没有一个版本默认是CMS,只能手动指定。

CMS从论文发表到第一个版本发行中间经历了特别久的时间,所以这玩意儿写起来是真的不容易,本质上也是采用的标记清除的算法,大概分为这么四步:

1、初始标记。用单线程只标记那些根对象,此时工作线程等待;

2、并发标记。用多线程标记所谓的垃圾对象,工作线程继续无需等待,继续工作;

3、重新标记。用多线程标记在2中工作线程新产生的垃圾,此时工作线程等待;

4、并发清理。对3中标记完成的垃圾进行清理,工作线程继续无需等待,继续工作;

那要这么说下来的话,在1和3中工作线程还是停止了,产生了STW的时间。但是这两步的STW时间非常的短暂,真正等待时间久的STW是在2中,占据了整个时间的80%以上。所以这一大部分的时间已经允许工作线程继续工作,所以其余的少量的STW时间从整体来看可以忽略不计。

前面说了,CMS的问题也很多,比如在4的时候,一边清理一边工作线程也会产生新的垃圾,这些垃圾就叫做浮动垃圾,这些浮动垃圾只能等待下一次垃圾回收的时候再进行处理。当然这不算多么严重的问题,真正难受的点在于:

CMS既然是Mark Sweep(标记清除算法),就一定会有碎片化的问题,碎片到达一定程度,CMS的老年代分配对象分配不下的时候,使用Serial Old(Serial算法用于老年代)进行老年代回收。想象一下,当计算机的内存超级大的时候,比如几十G,几百G,在如此巨大的内存中使用一个单线程进行标记压缩来清理垃圾,STW的时间可想而知!本身CMS就是为了解决大内存中STW的等待时间而被设计出来,结果它自己不停顿则以,一旦产生STW,想象一下一个老奶奶拿着一个笤帚单独在扫天安门广场.....

一般来讲没有什么特别好的解决方案,只能说可以尝试降低触发CMS的阈值,或者保持老年代有足够的空间:–XX:CMSInitiatingOccupancyFraction 92% 可以降低这个值,让CMS保持老年代足够的空间。

有一个问题问的很好:有一个50万的PV的资料类网站(从磁盘提取文档到内存)原服务器32位,1.5G 的堆,用户反馈网站比较缓慢,因此公司决定升级,新的服务器为64位,16G 的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了。这是为什么?

其实很简单,原先内存小的时候,很多用户浏览数据,很多数据load到内存,内存不足,频繁GC,STW长,响应时间变慢。后面升级了硬件,内存越大,FGC时间越长。那要怎么解决这个问题呢?很简单,用G1呗,未来肯定是G1或ZGC的天下。

7、了解JVM调优

JVM常用命令行参数

首先我们要知道Hotspot中对于JVM参数的规定:
1、以 - 开头的是标准参数,所有JVM都应该支持;
2、以 -X 开头的是非标准参数,每个JVM实现可能不同,不是所有版本的JVM都通用;
3、以 -XX 开头的是不稳定参数,下个版本可能取消;

接下来用一段简答的代码来对JVM中参数的设定小试牛刀:

package com.feenix.jvm.c5_gc;

import java.util.LinkedList;
import java.util.List;

public class T01_HelloGC {

    public static void main(String[] args) {
        System.out.println("----- Hello ----- Feenix ----- GC ----->");
        List list = new LinkedList();
        for ( ; ; ) {
            byte[] b = new byte[1024 * 1024];
            list.add(b);
        }
    }

}

java -XX:+PrintCommandLineFlags T01_HelloGC

当什么参数都没有指定的时候,JVM会根据内存的大小算出一个起始的堆大小数据:

-XX:InitialHeapSize=60777536

-XX:MaxHeapSize=972440576

java -Xmn10M -Xms40M -Xmx60M -XX:+PrintCommandLineFlags -XX:+PrintGC T01_HelloGC

这里通过命令,将最小堆设为40M,最大堆设为60M。而一般来说这两个参数设成一样的大小,不要将堆的大小设置成弹性的。内存的占用从最小堆开始,内存不够用了就会往外弹性增大,直至最大堆的大小。这种弹性的增长除了浪费宝贵的系统资源之外没什么特别的意义,直接给它卡死大小就行了,没事别弹来弹去。

java -XX:+UseConcMarkSweepGC -XX:+PrintCommandLineFlags T01_HelloGC

想看下CMS的回收过程,不过我机子上的JDK是11,CMS在1.9的时候已经被移除了。

GC日志输出

现在主流的GC还是PS+PO,那么就以这种垃圾回收器的日志来举个栗子:java -Xmn10M -Xms40M -Xmx60M -XX:+UseParallelOldGC -XX:+PrintCommandLineFlags -XX:+PrintGCDetails T01_HelloGC

.....

-XX:+UseParallelOldGC 使用PS+PO垃圾回收器

-XX:+PrintGCDetauils 打印垃圾回收日志详细信息

常见垃圾回收器参数组合

-XX:+UseSerialGC

这个参数的设定相当于是使用Serial New (DefNew) + Serial Old,一般适用于小型程序。默认情况下不会是这种选项,HotSpot会根据计算及配置和JDK版本自动选择收集器;

-XX:+UseParNewGC

这个参数的设定相当于是使用 ParNew + SerialOld,这个组合已经很少用,在某些版本中已经废弃;

-XX:+UseConcMarkSweepGC(或 -XX:+UseConcurrentMarkSweepGC)

这个参数的设定相当于是使用 ParNew + CMS + Serial Old;

-XX:+UseParallelGC

这个参数的设定相当于是使用 Parallel Scavenge + Parallel Old,这也是JDK1.8默认使用的垃圾回收器组合;

-XX:+UseParallelOldGC

这个参数的设定相当于是使用 Parallel Scavenge + Parallel Old,和 -XX:+UseParallelGC 一样;

-XX:+UseG1GC

这个参数的设定相当于是使用G1;

调优的基础概念

说调优之前先明白两个基础概念:

1、吞吐量:用户代码时间 /(用户代码执行时间 + 垃圾回收时间)

2、响应时间:STW越短,响应时间越好

所谓调优,首先确定,追求啥?吞吐量优先,还是响应时间优先?还是在满足一定的响应时间的情况下,要求达到多大的吞吐量。对于科学计算、数据挖掘类的项目,一般追求的是吞吐量优先,建议使用PS+PO进行垃圾回收;对于网站类的项目,一般追求的是相应时间优先,建议使用G1进行垃圾回收。

其实调优并不是一个多么神秘的事情,说白了就是系统优化,GC的调优基本归为三大块:

1、根据需求进行JVM规划和预调优;

2、优化运行JVM运行环境,譬如慢,卡顿等;

3、解决JVM运行过程中出现的各种问题,譬如OOM等;

调优,从规划开始

预规划可以说是调优中最难的一步,很多领导根本不懂,张口就是要支持百万级别的并发。一般来说百万的并发指的是TPS达到百万级别,对于电商网站来说指的是每秒下上百万个订单。不干这个可能不了解每秒下上百万个订单是什么概念,淘宝每年的双十一差不多是每秒五六十万个订单的样子,据说全国只有12306在春运的时候才能达到这个数字.....

所以说调优一定要从业务场景开始,没有业务场景的调优都是耍流氓。要知道没有最好的垃圾回收器,只有最合适的垃圾回收器。可以参考以下步骤进行调优预估:

1、熟悉业务场景,根据业务场景选择调优追求的是响应时间还是吞吐量;
2、根据调优的目标,选择垃圾回收器组合;
3、计算内存需求;
4、选定CPU,预算范围内越贵越好,性能越强越好;
5、设定不同年代空间大小、升级年龄等参数;
6、设定日志参数。这一步比较复杂,正常来说生产环境的参数比较多:-Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause

调优案例

案例1:垂直电商,最高每日百万订单,处理订单系统需要什么样的服务器配置?

这个问题比较业余,因为很多不同的服务器配置都能支撑。仔细分析下,每天一百万的订单,订单产生的时间肯定会有密集有稀疏,找出订单产生最频繁的时间,找出订单的高峰期,假设最巅峰的时候是1000订单/秒。然后去计算一个订单产生需要多少内存,不同的业务消耗的内存不同,正常来说一个订单占用的空间很少会超过512k。按照512k来算的话,1000订单也就是差不多需要500M内存。当然,更专业的文法是,要求响应时间100ms以内要怎么设计,这样去做压测好了。

案例2:12306遭遇春节大规模抢票应该如何支撑?

12306应该是中国并发量最大的秒杀网站,号称并发量最高能达到100万。如此高的流量已经不能从单台机器性能方面去考虑,一般可以这么考虑:
先从CDN开始,全国不同的地区做不同的CDN缓存,北京的去访问北京的服务器,上海的去访问上海的服务器。CDN下来是一堆的LVS,LVS后面再接一堆的Nginx,Nginx后面才是业务服务器。

假设经过各级分发之后,每台机器需要撑住一千或者一万的并发(单机10k问题),该怎么解决?
普通电商订单:下单 ->订单系统(IO)减库存 -> 等待用户付款;
12306的一种可能的模型:下单 -> 减库存 和 订单(Redis或Kafka)同时异步进行 -> 等付款;

案例3:系统CPU经常100%,如何调优?

下面用一段代码来模拟这种问题,有栗子看着会更直观一些

package com.feenix.jvm.c5_gc;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * 从数据库中读取信用数据,套用模型,并把结果进行记录和传输
 */

public class T15_FullGC_Problem01 {

    private static class CardInfo {
        BigDecimal price = new BigDecimal(0.0);
        String name = "Feenix";
        int age = 5;
        Date birthdate = new Date();

        public void m() {

        }
    }

    private static ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(50,
            new ThreadPoolExecutor.DiscardOldestPolicy());

    public static void main(String[] args) throws Exception {
        executor.setMaximumPoolSize(50);

        for (; ; ) {
            modelFit();
            Thread.sleep(100);
        }
    }

    private static void modelFit() {
        List<CardInfo> taskList = getAllCardInfo();
        taskList.forEach(info -> {
            // do something
            executor.scheduleWithFixedDelay(() -> {
                //do sth with info
                info.m();

            }, 2, 3, TimeUnit.SECONDS);
        });
    }

    private static List<CardInfo> getAllCardInfo() {
        List<CardInfo> taskList = new ArrayList<>();

        for (int i = 0; i < 100; i++) {
            CardInfo ci = new CardInfo();
            taskList.add(ci);
        }

        return taskList;
    }
}

将这个Java文件丢到Linux环境下,使用javac编译成class文件后,运行:java -Xms200M -Xmx200M -XX:+PrintGCDetails T15_FullGC_Problem01

1、使用【top】命令找出哪个进程占用CPU资源最高;

可以很明显看出,当前系统中 3503 的进程占用资源最多,高达60.8%!

2、使用【top -Hp ${进程ID}】命令找出进程中哪个线程占用CPU资源最高;

3、使用【jstack ${进程ID}】 打印出进程下所有线程的堆栈信息;

jstack打印出的信息,比较关键的是线程的运行状态:java.lang.Thread.State: WAITING (parking)当发现很多线程长时期处于WAITING状态的时候,说明程序本身肯定会有问题。多个线程一直在等待着一个互斥量的释放,可能是锁,可能是资源。在 jstack 导出的信息中查找出被锁住的资源,是哪个线程持有的这把锁,这个线程比较可能的状态是RUNNABLE。

案例4:yml配置文件设置 server.max-http-header-size=100000000

这个参数的意思是Tomcat每建立一个链接,请求头都会占用100000000kb的空间,很快内存就会被占用完。这个案例排查起来目标性很明确,在日志中肯定是http相关的对象(http11OutputBuffer)占用的资源非常高。当请求过多的时候,设置的堆内存不顾大,很容易就会导致OOM问题。

案例5:Lambda表达式导致方法区(MethodArea)溢出问题

Lambda表达式在使用的时候,会动态产生class文件。当在一个循环中不断使用Lambda表达式,就会不断的产生class扔进方法区中,最终会产生方法区溢出问题。关于这个方法区的清理,每个垃圾回收器的逻辑不一样,有的垃圾回收器压根就不清理,有的垃圾回收器在特别苛刻的条件下才会清理。

"C:\Program Files\Java\jdk1.8.0_181\bin\java.exe" -XX:MaxMetaspaceSize=9M -XX:+PrintGCDetails "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2019.1\lib\idea_rt.jar=49316:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2019.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_181\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_181\jre\lib\rt.jar;C:\work\ijprojects\JVM\out\production\JVM;C:\work\ijprojects\ObjectSize\out\artifacts\ObjectSize_jar\ObjectSize.jar" com.mashibing.jvm.gc.LambdaGC
[GC (Metadata GC Threshold) [PSYoungGen: 11341K->1880K(38400K)] 11341K->1888K(125952K), 0.0022190 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Metadata GC Threshold) [PSYoungGen: 1880K->0K(38400K)] [ParOldGen: 8K->1777K(35328K)] 1888K->1777K(73728K), [Metaspace: 8164K->8164K(1056768K)], 0.0100681 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[GC (Last ditch collection) [PSYoungGen: 0K->0K(38400K)] 1777K->1777K(73728K), 0.0005698 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Last ditch collection) [PSYoungGen: 0K->0K(38400K)] [ParOldGen: 1777K->1629K(67584K)] 1777K->1629K(105984K), [Metaspace: 8164K->8156K(1056768K)], 0.0124299 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 
java.lang.reflect.InvocationTargetException
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:388)
	at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411)
Caused by: java.lang.OutOfMemoryError: Compressed class space
	at sun.misc.Unsafe.defineClass(Native Method)
	at sun.reflect.ClassDefiner.defineClass(ClassDefiner.java:63)
	at sun.reflect.MethodAccessorGenerator$1.run(MethodAccessorGenerator.java:399)
	at sun.reflect.MethodAccessorGenerator$1.run(MethodAccessorGenerator.java:394)
	at java.security.AccessController.doPrivileged(Native Method)
	at sun.reflect.MethodAccessorGenerator.generate(MethodAccessorGenerator.java:393)
	at sun.reflect.MethodAccessorGenerator.generateSerializationConstructor(MethodAccessorGenerator.java:112)
	at sun.reflect.ReflectionFactory.generateConstructor(ReflectionFactory.java:398)
	at sun.reflect.ReflectionFactory.newConstructorForSerialization(ReflectionFactory.java:360)
	at java.io.ObjectStreamClass.getSerializableConstructor(ObjectStreamClass.java:1574)
	at java.io.ObjectStreamClass.access$1500(ObjectStreamClass.java:79)
	at java.io.ObjectStreamClass$3.run(ObjectStreamClass.java:519)
	at java.io.ObjectStreamClass$3.run(ObjectStreamClass.java:494)
	at java.security.AccessController.doPrivileged(Native Method)
	at java.io.ObjectStreamClass.<init>(ObjectStreamClass.java:494)
	at java.io.ObjectStreamClass.lookup(ObjectStreamClass.java:391)
	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1134)
	at java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1548)
	at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1509)
	at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1432)
	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1178)
	at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
	at javax.management.remote.rmi.RMIConnectorServer.encodeJRMPStub(RMIConnectorServer.java:727)
	at javax.management.remote.rmi.RMIConnectorServer.encodeStub(RMIConnectorServer.java:719)
	at javax.management.remote.rmi.RMIConnectorServer.encodeStubInAddress(RMIConnectorServer.java:690)
	at javax.management.remote.rmi.RMIConnectorServer.start(RMIConnectorServer.java:439)
	at sun.management.jmxremote.ConnectorBootstrap.startLocalConnectorServer(ConnectorBootstrap.java:550)
	at sun.management.Agent.startLocalManagementAgent(Agent.java:137)

案例6:重写finalize引发频繁GC

C++程序员重写finalize,导致引发频繁。因为C++是手动回收内存资源,需要使用析构函数。对于Java不了解的C++程序员重写了finalize,而finalize耗时比较长,需要200ms左右,导致频繁GC。

案例7:内存一直消耗不超过10%,但是观察GC日志,发现FGC总是频繁产生

显式调用System.gc()方法进行垃圾回收。

调优工具

jconsole

jconsole是JDK自带的工具,电脑上装了JDK就会有,位置在JDK的bin目录下

不过这个工具想要使用的话,在程序启动的时候需要加入相关的参数:java -Djava.rmi.server.hostname=192.168.160.129 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=11111 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Xms200M -Xmx200M -XX:+PrintGCDetails T15_FullGC_Problem01

项目成功启动后,双击 jconsole.exe 运行工具,填入相关信息

成功连接之后,就可以看到对应的运行信息

jconsole作为一个图形管理工具,在Linux系统下肯定用的不多,而且图形也不怎么样,JDK还提供了另外一个工具jvisualvm,位置和jconsole一样,具体使用的方法可以参考:https://www.cnblogs.com/liugh/p/7620336.html

jmap

这是一个Linux系统下的命令,这个命令会将程序中的对象给列举出来,所以一般会加上管道过滤,筛选查看前几十个对象:jmap -histo ${进程ID} | head -20

很明显这些对象的数量很不对劲,可以使用命令: jmap -dump:format=b,file=xxx ${PID} 将异常信息导出成文件进行详细的查看分析。通过产生的这些异常数量的类,找对应的代码去排查问题。这个就需要对业务代码非常的熟悉,一个大型项目中包含的类何止千万,定位非常的困难。

尤其是线上的系统,内存非常的大,jmap -dump执行期间会对程序产生非常大的影响,基本上业务功能就是瘫痪了无响应。所以jmap -dump这命令轻易不要使用,一般是在产生OOM问题之后,通过设定的参数将堆信息导出至文件进行分析。除非有很多服务器备份,其中一台拿来使用对用户没什么影响。

arthas

arthas 是阿里开源的一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。这个工具可以直接在Github上进行下载:https://github.com/alibaba/arthas,还很贴心的准备了中文使用文档:https://github.com/alibaba/arthas/blob/master/README_CN.md

这个工具目前来说应该是我用过的最好的一个Java在线诊断工具,不需要在远程挂载图形界面,极大的节省了宝贵的Linux资源。

从Git下载到本地,解压后可以看到有这些jar包

直接启动 arthas-boot.jar 即可:java -jar arthas-boot.jar

在启动arthas之前,先要确保已经有Java程序在运行,arthas一旦启动之后,会自行寻找所有正在运行的Java程序,并将这些程序以进程号的形式全部列举出来;

现在想要对哪个进程进行监控,直接输入对应的编号。比如4531这个进程在arthas中的编号是[1],直接输入1,敲回车即可;

确定了要监控的进程之后,arthas会将自己挂载到这个进程上:Try to attach process 4531,挂载成功之后:Attach process 4531 success. 就可以通过arthas的命令去观察这个进程。以下介绍一些arthas中较为常用的一些命令:

help  列举出arthas中常用的一些命令

jvm  展示当前进程所在的JVM详细的配置信息

thread  列举出进程中所有的线程信息

thread ${ID}  打印线程中的详细的堆栈信息

dashboard  通过命令行的形式模拟系统资源的使用情况,和Linux的top命令类似

heapdump  导出堆信息文件,可以在后面指定导出文件的位置和文件名

文件导出成功之后,可以通过JDK自带的jvisualvm工具对文件进行分析

jad  反编译

给它一个class文件,通过这个命令可以直接在线反编译出源文件。

可能有些人不是很能理解,源文件就在我自己手上,何必多此一举对class文件进行反编译。其实线上反编译特别的使用:1、对于动态代理生成类的问题定位;2、对于第三方的类用来观察代码;3、确定最新提交的版本是不是已经被使用;

redefine  热替换

对于线上运行的大型项目,热替换的重要性不言而喻。不过这个功能目前有些限制条件,只能改已经运行完成的方法实现,不能改方法名, 不能改属性。

 

T的运行结果非常简单

启动arthas,反编译查看TT现在的代码

直接修改TT.java中的内容,修改完之后,使用redefine命令:redefine /usr/local/app/TT.class

可以看到热替换已经完成,本质上应该是使用ClassLoader进行重新的加载。

8、未来应该是G1的天下

Garbage 1st,其实并不是多么新鲜的垃圾回收器,在 Java 7 update 4 时就已经引入。官方在 ZGC 还没有出现时也推荐使用 G1 来代替选择 CMS。G1 最大的特点是引入分区的思路(取消物理分代,保留逻辑分代),弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器的众多缺陷。目标是用在多核、大内存的机器上,在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。

G1内存模型

在G1之前,内存模型一般都是两块连续的空间,年轻代和老年代。而G1 采用了分区 (Region) 的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1 并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数 -XX:G1HeapRegionSize=n 可指定分区大小 (1MB~32MB,且必须是2的幂),默认将整堆划分为 2048 个分区。

当一个对象的大小达到甚至超过分区大小一半,这种对象称为巨型对象 (Humongous Object)。因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区 (Humongous Region)。G1 内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型 (StartsHumongous),相邻连续分区被标记为连续巨型 (ContinuesHumongous)。由于需要一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

总之,G1 对内存的使用以分区 (Region) 为单位,而对对象的分配则以卡片 (Card) 为单位。当G1进行垃圾回收的时候,会优先将存活对象最少,也就是垃圾对象最多的区域进行清理,所以取名叫垃圾优先(Garbage First)。

G1基本概念

Card Table

上面说了G1的内存模型是分为一个一个大小不同的区域,在每个分区内部又被分成了若干个大小为 512 Byte 卡片 (Card),标识堆内存最小可用粒度。所有分区的卡片,将会记录在全局卡片表 (Global Card Table) 中,分配的对象会占用物理上连续的若干个卡片,当查找分区内对象的引用时,便可通过卡片来查找该引用对象。每次对内存的回收,都是对指定分区的卡片进行处理。

垃圾回收在做YGC的时候,我们都知道把年轻代中的垃圾全部干掉,然后把存活的对象由eden区移到survivor区。但是,想要真正追踪一个活着的对象真的不是一件容易的事情。假设在年轻代和老年代中都有很多的对象,你要怎么确定那些对象是活着的,那些对象是垃圾。对于根可达算法来说,通过根对象去寻找那些活着的对象,但是很有可能根对象指向老年代中的对象,然后这个老年代的对象又指定年轻代的对象,这样就会产生一个很严重的问题:为了分区出年轻代的垃圾对象,居然要去遍历整个老年代的对象,这效率得多低。

引入card这个概念之后,如果有一个card中的对象指回了年轻代的对象,就会将这个card标记为dirty,整个内存中有哪些card被标记为dirty呢,会有一个bitmap(位图)来标记,0代表没脏,1表示脏了。这样只需要扫描那些被标记为脏了的card即可,极大的节省了资源,提升效率。

CSet(Collection Set)

就是一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

RSet(Remembered Set)

记录了其它Region中的对象到本Region的引用。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。这个东西的存在是G1可以实现高效率回收的关键,也是三色标记算法的核心。

动态新老年代比例

一般年轻代的占比在5% - 60%,这个比例不用手工指定,建议也不要手工指定,因为这是G1预测停顿时间的基准。G1会跟踪每一次STW时长,假设设定的STW目标是100ms,但是某一次STW的时长达到了200ms,G1自己会动态去调整这个比例,从而达到预期的STW时间目标。

G1日志

[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0022907 secs]
   [Parallel Time: 0.6 ms, GC Workers: 4]
      [GC Worker Start (ms): Min: 112208.2, Avg: 112208.4, Max: 112208.5, Diff: 0.3]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 0.9]
      [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Processed Buffers: Min: 0, Avg: 0.2, Max: 1, Diff: 1, Sum: 1]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Object Copy (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.2]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 4]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [GC Worker Total (ms): Min: 0.2, Avg: 0.3, Max: 0.4, Diff: 0.3, Sum: 1.2]
      [GC Worker End (ms): Min: 112208.6, Avg: 112208.7, Max: 112208.7, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.3 ms]
   [Other: 1.4 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 1.1 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.2 ms]
      [Humongous Register: 0.0 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 0.0 ms]
   [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 19.5M(20.0M)->19.5M(20.0M)]
 [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0000311 secs]
[GC concurrent-mark-start]
[Full GC (Allocation Failure)  19M->19M(20M), 0.0517014 secs]
   [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 19.5M(20.0M)->19.5M(20.0M)], [Metaspace: 3900K->3900K(1056768K)]
 [Times: user=0.06 sys=0.00, real=0.06 secs] 
[Full GC (Allocation Failure)  19M->19M(20M), 0.0414986 secs]
   [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 19.5M(20.0M)->19.5M(20.0M)], [Metaspace: 3900K->3900K(1056768K)]
 [Times: user=0.06 sys=0.00, real=0.04 secs] 
[GC concurrent-mark-abort]

在这个日志中,将G1垃圾回收的三个阶段全部都打印了出来:

[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0022907 secs] 
年轻代的YGC过程,Evacuation Pause指的是复制存活对象的暂停(不知道谁起的奇奇怪怪的名字)。initial-mark 指的是混合回收的阶段,也就是说看到这个说明YGC混合老年代一起回收,并不是每次年轻代的回收都会存在这个日志信息。

[Parallel Time: 0.6 ms, GC Workers: 4]
GC Workers: 4  说明启了4个GC回收的线程协同工作。

[Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 0.9]
Root Scanning  说明开始从根对象进行搜索

[Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 19.5M(20.0M)->19.5M(20.0M)]
垃圾回收执行完之后,打印各个区垃圾回收的情况

[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0000311 secs]
[GC concurrent-mark-start]

混合回收的其它阶段

[Full GC (Allocation Failure)  19M->19M(20M), 0.0517014 secs]
   [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 19.5M(20.0M)->19.5M(20.0M)], [Metaspace: 3900K->3900K(1056768K)]
 [Times: user=0.06 sys=0.00, real=0.06 secs] 

当无法进行Evacuation的时候,最后只能进行FGC

关于G1的几个问题

G1会不会产生FGC

显而易见是一定会产生FGC的,虽然G1一直是在动态的回收每一个Region区域,但是当对象分配的速度过快,垃圾回收不过来的时候,对象分配不下的时候,就是会产生FGC。在JDK10之前,G1的FGC也是串行的,后面才慢慢优化为并行的FGC。对于G1来说,调优的最终目标就是不会产生FGC。

如果G1产生FGC,应该做什么

1、扩内存;
2、提高CPU性能(回收的快,业务逻辑产生对象的速度固定,垃圾回收越快,内存空间越大);
3、降低MixedGC触发的阈值,让MixedGC提早发生(默认是45%);

默认MixedGC的触发阈值是对象占用整体堆内存空间超过45%,就会启动MixedGC。当然,这个比例可以通过-XX:InitiatingHeapOccupacyPercent来手动指定。

MixedGC可以简单粗暴的认为是一个CMS,也是分为:初始标记STW、并发标记、最终标记STW(重新标记)、筛选回收STW(并行)。最后一步的筛选就是会挑选那些最需要回收的Region,直接将Region存活的对象复制到另一个空的Region中去,复制的同时也进行了压缩,碎片化也就没有CMS那么严重。

G1的缺点

最后,相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比 CMS 要高。从经验上来说,在小内存应用上 CMS 的表现大概率会优于G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间。

而且G1需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。

基于G1设计上的特点,导致存在以下问题:
1、停顿时间过长,通常G1的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是我们知道垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求;

2、内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%~20%左右;

3、支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于100GB的系统中,会因内存过大而导致停顿时间增长;

稍微提一嘴ZGC

ZGC作为新一代的垃圾回收器,在设计之初就定义了三大目标:
1、支持TB级内存;
2、停顿时间控制在10ms之内;
3、对程序吞吐量影响小于15%;

实际上目前ZGC已经满足设计之初定义的目标,最大支持4TB堆空间,据说现在已经可以支持到16T级别。依据实际测试的情况来看,停顿时间通常都在10ms以下,并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。

简单地说,就是ZGC把一切能并发处理的工作都并发执行。ZGC是在G1的基础上发展起来的,我们知道G1中实现了并发标记,所以标记已经不会再影响停顿时间了。G1中的停顿时间主要来自垃圾回收(YGC和混合回收)阶段中的复制算法,在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。

在G1中对象的转移都是在STW中并行执行的,而ZGC就是把对象的转移也并发执行,从而满足停顿时间在10ms以下。我们看到G1只有在MARKING的时候,是并发的。而ZGC 在对象的复制和压缩,复制集的选择,等很多方面都改成了并发(和应用线程同时进行)。这就是它STW时间如此之短的秘诀。

9、并发标记算法

并发标记算法的难点,就是在标记对象的过程之中,对象的引用关系发生改变。

三色标记算法

CMS和G1在并发标记这块都是使用的三色标记法:把对象在逻辑上分为三种颜色,白色代表未被标记的对象;灰色代表自身被标记,成员变量未被标记;黑色代表自身和成员变量均已被标记。

在某些情况下会发生漏标情况:

假设现在对象间的关系如上图所示,A被标记为黑色对象,B被标记为灰色对象,D被标记为白色对象。此时:
1、A对象指向D对象;
2、B对象不再指向D对象;

当上述的1、2条件同时发生之后,因为A已经被标记为黑色对象,不会再对A中的成员变量再次扫描,而此时通过B又找不到D,所以D就会被漏标,从而被误当成是垃圾被回收掉。漏标的问题想要解决的话只要打破上述两个条件其一即可:

1、incremental update(增量更新):当A指向D的时候,将A重新标记为灰色。这样下次再来扫描的时候,看到A是个灰色对象,就会重新再去扫描一遍A的孩子们,这样就可以找到D对象,从而防止漏标;

2、SATB(snapshot at the beginning):当B指向D的引用消失时,要把这个引用推到GC的堆栈,保证D还能被GC扫描到,这样每次从堆栈中就可以取出对象的引用,从而找到漏标的对象;

G1为了解决漏标问题使用的就是SATB算法,从效率上来说SATB比增量更新要快不少。而且G1中有设计了一个RSet(Remembered Set),每次引用改变的时候,通过对RSet的维护,只需对RSet进行遍历即可,无需扫描整个堆去查找指向白色的引用,极大的提高效率,SATB算法和RSet的配合使用简直天衣无缝。

不过RSet对于赋值肯定或多或少会有一些效率上的问题,已由于RSet的存在,每次给对象赋引用的时候,就得做一些额外的操作:在RSet中做一些额外的记录,在GC中被称为写屏障。但是这个写屏障并不是CPU和JVM级别的内存写屏障。

10、GC常用参数

-Xmn:年轻代空间大小 
-Xms:最小堆空间大小 
-Xmx:最大堆空间大小 
-Xss:栈空间空间大小  

-XX:+UseTLAB  使用TLAB,默认打开

-XX:+PrintTLAB  打印TLAB的使用情况

-XX:TLABSize  设置TLAB大小

-XX:+DisableExplictGC  设置System.gc()这句代码不起作用

-XX:+PrintGC  打印GC日志

-XX:+PrintGCDetails  打印GC详细日志

-XX:+PrintHeapAtGC  GC时打印堆栈情况 

-XX:+PrintGCTimeStamps  GC时打印系统时间戳

-XX:+PrintGCApplicationConcurrentTime  GC时打印应用程序时间

-XX:+PrintGCApplicationStoppedTime  GC时打印应用程暂停时长

-XX:+PrintReferenceGC  记录回收了多少种不同引用类型的引用

-verbose:class  类加载详细过程

-XX:+PrintVMOptions  打印JVM运行时参数

-XX:+PrintFlagsInitial / -XX:+PrintFlagsFinal  打印垃圾回收器的指定参数,用法:java -XX:+PrintFlagsFinal -version | grep G1

-Xloggc:opt/log/gc.log  指定GC日志位置和文件名

-XX:MaxTenuringThreshold  升代年龄,最大值15

-XX:PreBlockSpin  锁自旋次数 

-XX:CompileThreshold  热点代码检测参数

Parallel常用参数

-XX:SurvivorRatio

-XX:PreTenureSizeThreshold  指定到底多大的大对象直接分配到老年代

-XX:+ParallelGCThreads  并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同

-XX:+UseAdaptiveSizePolicy  自动选择各区大小比例

CMS常用参数

-XX:+UseConcMarkSweepGC  指定使用CMS垃圾回收器

-XX:ParallelCMSThreads  CMS线程数量

-XX:CMSInitiatingOccupancyFraction  使用多少比例的老年代后开始CMS收集,默认是68%(近似值),如果频繁发生SerialOld卡顿,应该调小(频繁CMS回收)

-XX:+UseCMSCompactAtFullCollection  在FGC时进行压缩

-XX:CMSFullGCsBeforeCompaction  多少次FGC之后进行压缩

-XX:+CMSClassUnloadingEnabled

-XX:CMSInitiatingPermOccupancyFraction  达到什么比例时进行Perm回收(用于JDK1.8之前)

-XX:GCTimeRatio  设置GC时间占用程序运行时间的百分比

-XX:MaxGCPauseMillis  停顿时间,是一个尝试时间,GC会尝试用各种手段达到这个时间,比如减小年轻代

G1常用参数

-XX:+UseG1GC  指定使用G1垃圾回收器

-XX:MaxGCPauseMillis  停顿时间,是一个尝试时间,G1会尝试调整Young区的块数来达到这个值

-XX:GCPauseIntervalMillis  GC的间隔时间

-XX:+G1HeapRegionSize  分区大小,建议逐渐增大该值,1 2 4 8 16 32M,随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长,ZGC对此做了改进

-XX:G1NewSizePercent  新生代最小比例,默认为5%

-XX:G1MaxNewSizePercent  新生代最大比例,默认为60%

-XX:GCTimeRatio  GC时间建议比例,G1会根据这个值调整堆空间

-XX:ConcGCThreads  线程数量

-XX:InitiatingHeapOccupancyPercent  启动G1的堆空间占用比例

猜你喜欢

转载自blog.csdn.net/FeenixOne/article/details/128510771
今日推荐