JVM万字总结

6,716 阅读41分钟

什么是JVM

JVM即Java虚拟机,是一种抽象计算机,它有一个指令集,在运行时操作各种内存区域。虚拟机有很多种,不同厂商提供了不同实现,只要遵循虚拟机规范即可,目前我们所说的虚拟机一般指的是Hot Spot。JVM对Java语言一无所知,只知道一种特定的二进制格式,即类文件格式,我们写好的程序最终交给JVM执行的时候会被编译成二进制格式,JVM只认识二进制格式,所以任何语言只要编译后的格式符合要求,都可以在JVM上运行。

image.png 一个Java类在经过编译好类加载之后,会将加载后的数据放入运行时数据区域,这样我们在运行程序时就可以直接从运行时数据区域中读取信息。

运行时数据区域

程序计数器

程序计数器是线程独有的空间。Java虚拟机可以同时执行多个线程,但在任何一个确定的时刻,一个处理器只会执行一个线程中的一个指令,又因为线程具有随机性,操作系统会一直切换执行不同指令,我们需要把切换时候线程执行的位置存入到PC寄存器中,等切回来的时候能够回到原来的位置继续执行。 任何时候,每个Java虚拟机都在执行单个方法的代码,即该线程的当前方法。如果该方法不是Native方法,即PC寄存器会记录当前正在执行的java虚拟机指令的地址,如果线程当前执行的方法是本地的,那么java虚拟机的PC寄存器的值就是Undefined。 程序计数器是唯一一个不会出现OOM的区域。

堆是java虚拟机管理内存最大的一块,在虚拟机启动时创建,所有线程共享,堆中的对象永远不会被显式释放,必须由GC回收,所以GC也主要回收堆中的对象实例,我们平常讨论的垃圾回收就是回收堆内存。堆可以处于物理上不连续的空间,可以固定大小,也可以动态扩展,通过参数-Xms和-Xmx两个参数控制堆的最小值和最大值。

方法区

方法区也是线程共享的区域,在虚拟机启动时创建,存储每个类的结构,比如:运行时常量池、属性和方法数据,以及方法和构造函数的代码,包括在类和实例初始化以及接口初始化使用的特殊方法,方法区在逻辑上是堆的一部分,但是它又有另一个别名叫非堆,目的是与堆区分开。方法区可以是固定大小,也可以根据计算需要进行扩展。如果方法区的内存无法满足分配请求时也会抛出OutOfMemoryError。

运行时常量池

方法区的一部分,用于存储编译生成的字面量(基本数据类型或被final修饰的常量或字符串)和符号引用,类或接口的运行时常量池是在java虚拟机创建类或接口时创建的。 在jdk1.6以及之前的版本,Java中的字符串是放在方法区中的运行时常量池内,但是在jdk1.7以后将字符串常量池拿出来放在了堆中。

public class GcDemo {

    public static void main(String [] args) {
        String str = new String("lonely")+new String("wolf");
        System.out.println(str == str.intern());
    }
}

这段代码在jdk1.6中打印false,在jdk1.7和jdk1.8中打印true。 关于intern()方法:

  • jdk1.6:调用String.intern()方法,会先去检查常量池中是否存在该字符串,如果不存在,则会在方法区中创建一个字符串,而new String()创建的字符串在堆中,两个字符串的地址当然不相等。
  • jsk1.8:字符串常量池从方法区的运行时常量池移到了堆中,调用String.intern()方法,首先会检查常量池是否存在,如果不存在,那么就会创建一个常量,并将引用指向堆,也就是说不会再重新创建一个字符串对象了,两者都会指向堆中的对象,所以返回true。

有意思的例子

  • 只有一个new String(),产生两个对象
public class GcDemo {

    public static void main(String [] args) {
        String str = new String("lonely");
        System.out.println(str == str.intern());
    }

}

只有一个new String(),在jdk1.7和jdk1.8也会返回false,我们假设一开始字符串常量池没有任何字符串,执行一个new String("lonely")会产生两个对象,一个在堆,一个在字符串常量池。

image.png 这时候String.intern()先检查字符串常量池,发现存在"lonely"的字符串,所以直接返回,这时候两个地址不一样,所以返回false。

  • new String("lonely")+new String("wolf")会产生5个对象,2个在字符串常量池,3个在堆。

image.png 这时候执行String.intern()如果在1.7和1.8中会检查字符串常量池,发现没有lonelywolf的字符串,所以会在字符串常量池创建一个,指向堆中的字符串。 但是在jdk1.6中不会指向堆,会重新创建一个lonelywolf的字符串放到字符串常量池,所以才会产生不同的结果。

jdk1.7和jdk1.8实现方法区的区别

  • jdk1.7之前方法区使用永久代实现,方法区大小可以通过参数-XX:PermSize和-XX:MaxPermSize来控制方法区的大小和所能允许的最大值。
  • jdk1.8移除了永久代,采用元空间实现,所以在jdk1.8中永久代的参数改成-XX:MetaspaceSize和-XX:MaxMetaspaceSize。元空间和永久代的一个很大的区别就是元空间已经不在jvm内了,直接存储到了本地内存。

虚拟机栈

虚拟机栈是线程独有的空间,每个线程都有一个与线程同时创建的私有的虚拟机栈。虚拟机栈中存储栈帧,每个被线程调用的方法都会产生一个栈帧,栈帧中保存一个方法的状态信息,例如局部变量、操作数帧、方法出口等。调用一个方法就是执行一个栈帧的过程,一个方法调用完成,对应的栈帧就会出栈。

image.png

虚拟机栈可能有以下两种异常:

  • 如果线程执行所需栈深度大于Java虚拟机栈深度,就会抛出StackOverFlowError,其实方法调用的过程就是入栈和出栈的过程,如果一致入栈不出栈,就容易发生异常(递归调用)
  • 如果Java虚拟机栈可以动态扩展,但是扩展大小的时候无法申请到足够的内存,则会抛出OutOfMemoryError。

本机方法栈

本地方法栈类似虚拟机栈,区别就是本地方法栈存储的是Native方法,本地方法栈和虚拟机栈在有的虚拟机是合在一起的,例如Hot Spot虚拟机。

内存异常

内存溢出

概念

JVM内存不够了,,目前无法存放创建的对象

原因

  • JVM分配的内存太小,也可能是服务器本身内存太小,也许是JVM分配的堆内存太小
  • 某段代码死循环,导致疯狂创建对象,但又不会触发GC
  • 创建的对象太大,导致新生代存不下,老年代也存不下,只能OOM了。

解决

  • 增加服务器内存,设置合理的-Xms和-Xmx
  • 通过线程dump和堆dump,分析出现问题的代码
  • 增大JVM内存,及时GC

内存泄露

概念

不会被使用的对象却不能被回收,就是内存泄露

例子

public class Simple{
Object o1;
public void method() {
   o1 = new Object();
   //...其他代码
}
}
在Simple实例被回收之前,o1都不会被回收,因为o1是全局变量

改进:

public class Simple{
Object o1;
public void method() {
   o1 = new Object();
   //...其他代码
   o1 = null; //帮助GC
}
}

对象的内存模型

对象的指向

package com.zwx.jvm;

public class HeapMemory {
    private Object obj1 = new Object();

    public static void main(String[] args) {
        Object obj2 = new Object();
    }
}

我们知道,obj1是类的属性,引用在方法区中,obj2是局部变量,引用存放在虚拟机栈中的栈帧里的局部变量表。所以obj1是方法区指向堆,obj2是经典的虚拟机栈指向堆。 obj1是方法区指向堆:

image.png obj2是虚拟机栈指向堆:

image.png 我们思考一个问题,一个变量指向了堆,而堆内只是存储一个对象的实例,它怎么知道自己属于哪个Class,也就是说这个实例怎么知道自己所对应的类元信息?这就涉及一个Java对象的内存布局。

new一个对象占用几个字节

对象内存中分为三块:对象头(Header),实例数据(Instance Data)和对齐填充。以64位操作系统为例(未开启指针压缩的情况),Java对象布局如下:

image.png 对齐填充不一定有,如果对象头+实例数据正好是8的整数倍,就不需要对齐填充。 Object obj = new Object()占用大小可以分为两种情况:

  • 未开启指针压缩:占用大小=8字节的Mark Word+8字节的Class Pointer=16字节
  • 开启了指针压缩:占用大小=8字节的Mark Word+4字节的Class Pointer+4字节的对齐填充=16字节 我们来验证下: 1、引入pom依赖
<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.10</version>
        </dependency>

2、新建一个demo

public class HeapMemory {
    public static void main(String [] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

image.png 结果是16字节=8字节的Mark Word+4字节的Class Pointer+4字节的对齐填充,因为默认是开启指针压缩的。

-XX:+UseCompressedOops 开启指针压缩
-Xx:-UseCompressedOops 关闭指针压缩

下面我们手动关闭指针压缩

image.png 再次运行输出结果:

image.png 可以看到这时候的16字节=8字节的Mark Word+8字节的Class Pointer

image.png

对象访问的两种方式

句柄访问

使用句柄访问,Java虚拟机会在堆内划分出一块内存来存储句柄池,那么对象引用当中存储的就是句柄地址,句柄池中才会存储对象实例数据和对象类型的数据地址。

image.png

直接指针访问

直接指针访问,对象引用直接指向的就是对象实例数据

image.png

两种方式对比

使用句柄访问会多一次指针定位,但是也有一个好处就是如果一个对象移动了(地址改变了),那么只需要修改句柄池的指向就可以了,不需要修改reference对象内的指向,如果使用的是直接指针访问,那么就需要到局部变量表内修改reference的指向。

对象年龄

分代年龄

一个对象的分代年龄可以理解为垃圾回收的次数。当一个对象经历一次垃圾回收后还存在,分代年龄+1。在64位操作系统中,分代年龄占了4位,最大值为15(1111),分代年龄默认为0000,随着垃圾回收次数逐渐增加。 Java按照分代年龄划分为Young区和Old区,对象一般先到达Young区,达到一定分代年龄后进入Old区(注意:大对象直接进入Old区)。之所以这么划分是因为如果整个堆只有一个区的话,那么垃圾回收的时候就需要扫描所有对象,浪费性能。其实大部分Java对象生命周期很短,一旦一个对象回收很多次都回收不掉,可以认为下一次回收也可能回收不掉,所以Young区和Old区可以分开回收。只有当Young区在回收之后还没有足够空间才会去触发Old区的垃圾回收。

Young区

现在拆分成了Young区,下面的Young区是经过垃圾回收后的:

image.png 假如现在来了个对象要占用两个对象的大小,会发现放不下,这时候就会触发GC,但是一旦触发GC,对用户线程是有影响的,因为GC过程为了确保对象引用不变,需要停止所有用户线程,这个过程叫做STW:Stop The World。所以一般GC越少越好,实际上上图中至少还可以放下三个Obj,但是由于空间不连续导致对象申请内存失败导致GC,如何解决这种问题呢? 解决思路是把Young区再次划分,分为两个区:Eden区和Survivor区。

image.png 具体操作是:一个对象来了之后,先分配到Eden区,Eden区满了之后触发GC,经过GC之后,为了防止空间不连续,把活下来的对象复制到Survivor区,然后一次性清理掉Eden区(这么做的前提是大部分对象都是生命周期极短的,基本一次垃圾回收就可以把Eden区回收掉)。触发GC的时候Survivor区也会一起回收,并不是说单独只触发Eden区,但是问题来了,Eden区是保证空间基本连续的,但是Survivor有可能产生空间碎片,导致不连续了,所以又把Survivor区一分为二:

image.png

总的流程就是:对象来了,首先在Eden区分配内存,Eden满了后触发GC,GC后把活下来的对象赋值到S0区(S1区是空的),然后继续在Eden区分配对象,再次触发GC之后如果发现S0装不下(产生空间碎片,实际还有空间),那么就把S0活下来的对象复制到S1,这时候S0区就空了,并依次反复操作。假如说S0区和S1区空间对象复制移动之后还是放不下,就说明真的满了,那么就去老年代借点空间(这就是担保机制,老年代需要提供这种空间分配担保),假如老年代空间也不够了,就会触发Full GC,如果还是不够,那就抛出OutOfMemoryError。

Old区

当Young区的对象达到设置的分代年龄后,对象就会进入Old区,Old区满了只有就会触发Full Gc,如果还是空间不够,那就会发生OOM。

对象的生命周期

一般情况下,对象会在Eden区,S0区,S1区和Old区之间流转

image.png

类加载机制

class文件

简介

在java中,每个类文件包含单个类或接口,每个类文件由一个8位字节流组成。所有16位、32位和64位的量都是通过分别读取2个、4个和8个字节来构建的。Java虚拟机规定,Class文件格式使用一种类似C语言的伪结构来存储数据,class文件中只有两种数据类型,无符号数和表。注意:class文件没有对齐填充的说法,所有数据都是按照特定的顺序紧凑的排列在Class文件中。

  • 无符号数:属于数据的基本类型,以u1,u2,u4,u8来表示1个,2个,4个和8个字节。
  • 表:由0个或多个大小可变的项组成,用于多个类文件结构中,也就是说一个类其实就相当于一个表。

结构

一个Class文件大致由以下结构组成:

ClassFile {
    u4             magic;//魔数
    u2             minor_version;//次版本号
    u2             major_version;//主版本号
    u2             constant_pool_count;//常量池数量
    cp_info        constant_pool[constant_pool_count-1];//常量池信息
    u2             access_flags;//访问标志
    u2             this_class;//类索引
    u2             super_class;//父类索引
    u2             interfaces_count;//接口数(2位,所以一个类最多65535个接口)
    u2             interfaces[interfaces_count];//接口索引 
    u2             fields_count;//字段数
    field_info     fields[fields_count];//字段表集合 
    u2             methods_count;//方法数
    method_info    methods[methods_count];//方法集合
    u2             attributes_count;//属性数
    attribute_info attributes[attributes_count];//属性表集合
}

我们任意写一个TestClassFormat.java文件

public class TestClassFormat {

    public static void main(String[] args) {
        System.out.println("Hello JVM");
    }
}

因为Java虚拟机只认class文件,所以必然会对Class文件的格式进行严格的安全性校验。

概述

.java文件经过编译之后,就需要将class文件加载进内存了,并将数据按照分类存储到运行时数据区域。一个类从被加载进内存,再到使用完毕卸载,总共会经过5个步骤(7个阶段):加载->连接->初始化->使用->卸载。其中连接又分为验证、准备和解析。

image.png

加载

加载指的是通过一个完整的类或接口名称来获得其二进制流的形式,并将其按照Java虚拟机规范将数据存储到运行时数据区域,类加载主要做三件事:

  • 通过一个类的全限定名获得定义此类的二进制字节流。
  • 将这个二进制字节流所代表的的静态存储转化为方法区运行时数据结构。
  • 在Java堆中生成一个代表此类的java.lang.Class对象,作为方法区中这些数据的访问入口。 上面第一步在虚拟机规范中并没有说明Class来源于哪里,也没有说明怎么获取,所以就会产生很多的实现方式,下面就是一些常用的实现方式:
  • 最正常的方式:读取本地经过编译的.class文件
  • 从压缩包如zip,jar,war中读取。
  • 从网络中读取
  • 通过动态代理动态生成.class文件
  • 从数据库读取

执行Class的加载需要一个类加载器,而一个良好合格的类加载器需要具有以下两个属性:

  • 对于同一个Class名称,任何时候都应该返回相同的Class对象
  • 如果类加载器L1委派给类加载器L2去加载一个Class对象C,那么以下场景出现的任意类型T,两个类加载器L1和L2都应该返回相同的Class对象: (1)C的直接父类或者父接口类型
    (2)C中的字段类型
    (3)C中方法或者构造函数的参数类型
    (4)C中方法的返回类型
    在Java中类加载器不止一种,对于同一个类用不同的类加载器加载出来的对象是不相等的,那么Java是如何保证上面两点呢?这就是双亲委派模型,Java通过双亲委派模型防止恶意加载,也确保了安全性。

双亲委派模型

概述

定义:当一个类加载器收到加载请求时,自己不去加载,而是交给它的父加载器去加载,以此类推,知道传递到顶层的类加载器,只有当父加载器加载不了这个类,子加载器才会尝试加载这个类。

image.png 上图就是双亲委派模型,顶层加载器使用了虚线表示顶层加载器没有父加载器,从实现上来说,也没有子加载器,是一个独立的加载器,因为扩展类加载器和应用程序加载器从继承关系上来看,是有父子关系的,都继承了URLClassLoader,但是虽然从类的继承关系上启动类加载器没有子加载器,但是逻辑上扩展类加载器还是会将收到的请求优先交给启动类加载器进行优先加载。

  • 启动类加载器:负责加载$JAVA_HOME\lib下的类或者被参数-Xbootclasspath指定的能够被虚拟机识别的类(通过jar名字识别,如rt.jar),启动类加载器由java虚拟机直接控制,开发者不能直接使用启动类加载器。
  • 扩展类加载器:负责加载$JAVA_HOME\lib\ext下的类或者被java.ext.dirs系统变量指定的路径中所有类库,开发者可以直接使用这个类加载器。
  • 应用程序类加载器:负责加载$CLASS_PATH中指定的类库,开发者能直接使用这个类加载器,正常情况下如果我们在应用程序中没有自定义类加载器,一般用的就是这个类加载器。
  • 自定义类加载器:如果需要可以通过java.lang.ClassLoader的子类来定义自己的类加载器,一般我们选择继承URLClassLoader来进行适当改写就行了。

破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型,只是一种推荐的加载模型,也有不遵守这个模型的:比如JNDI,JDBC等相关的SPI动作并没有完全遵守双亲委派模型,破坏双亲委派模型的一个最简单的方式就是:继承ClassLoader类,然后重写其中的loadClass方法(因为双亲委派的逻辑就在loadClass方法中)

常见异常

如果加载过程出现异常,可能抛出以下异常

  • ClassCircularityError:extends或者implements了自己的类或接口
  • ClassFormatError:类或接口的二进制格式不正确
  • NoClassDefFoundError:根据提供的全限定名找不到对应的类或者接口。

ClassNotFoundException和NoClassDefFoundError

  • ClassNotFoundException:当JVM要加载指定文件的字节码到内存时,发现这个文件并不存在,就会抛出这个异常。这个异常一般出现在显式加载,主要有以下三种场景: (1)调用Class.forName() (2)调用ClassLoader的findSystemClass()方法 (3)调用ClassLoader的loadClass()方法 解决方法:手动检查classpath下有没有这个文件
  • NoClassDefFoundError:这个异常一般出现在隐式加载中,出现的情况可能使用了new关键字,或者属性引用了某个类,或者是继承了某个类或接口,或者是方法中的某个参数引用了某个类,这时候就会触发JVM隐式加载,而在加载过程中发现类并不存在,就会抛出这个错误。
    解决方法:确保每个引用的类都在当前classpath下。

连接

连接:获取类或接口的二进制形式并将其结合到java虚拟机的运行时状态以便执行的过程。连接包括三个步骤:验证、准备和解析。

验证

类加载进来需要格式校验,验证以下几个方面:

  • 文件格式验证:比如说是不是以魔数开头,jdk版本号的正确性等等。
  • 元数据验证:比如说类中的字段是否合法,是否有父类,父类是否合法等等。
  • 字节码验证:主要是确定程序的语义和控制流是否符合逻辑。 如果验证失败,会抛出一个VerifyError。

准备

准备:正式开始分配内存地址的一个阶段,主要为类或接口创建静态字段(类常量和常量),并将这些字段初始化为默认值。假设这些字段在常量池已经存在了,则直接赋值。

static final int i=100;

这种被final修饰的会直接赋初始值,不会赋默认值。以下是常用的默认值:

image.png

解析

解析:将常量池中符号引用替换为直接引用的过程。在使用符号引用之前,它必须经过解析,解析过程中会对符号引用的正确性进行检查。

初始化

这个阶段是赋值,会将之前赋的默认值替换成真正的初始值,这一步也是构造方法执行的时候。什么时候需要初始化呢?父类和子类初始化顺序是怎样的呢? Java虚拟机规范中规定了5种情况必须对类初始化,主动触发初始化的行为称为主动引用:

  • 虚拟机启动时,先初始化指定的需要执行的主类(main方法所在的类)
  • 使用new关键字实例化对象时,读取或设置一个类的静态字段(final修饰的除外),以及调用一个类的静态方法。
  • 初始化一个类时,如果其父类还没初始化,先触发父类的初始化(接口不同,当一个接口初始化时,不需要其父接口初始化)
  • 使用反射调用类的时候
  • JDK1.7开始提供的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例解析的结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄对应的类没有被初始化,需要触发其初始化。
public class TestInit1 {
    public static void main(String[] args) {
        System.out.println(new SubClass());//A-先初始化父类,后初始化子类
//        System.out.println(SubClass.value);//B-只初始化父类,因为对于static字段,只会初始化字段所在类
//        System.out.println(SubClass.finalValue);//C-不会初始化任何类,final修饰的数据初始化之前就会放到常量池
//        System.out.println(SubClass.s1);//D-不会初始化任何类,final修饰的数据初始化之前就会放到常量池
//        SubClass[] arr = new SubClass[5];//E-数组不会触发初始化
    }
}

class SuperClass{
  static {
        System.out.println("Init SuperClass");
    }
    static int value = 100;

    final static int finalValue = 200;

    final static String s1 = "Hello JVM";
}

class SubClass extends SuperClass{
    static {
        System.out.println("Init SubClass");
    }
}
  • 语句A的输出结果是:
Init SuperClass Init SubClass com.zwx.jvm.SubClass@xxxxxx
  • 语句B的输出结果是:(调用了类的静态变量虽然是子类调用的,但是静态变量却是父类的,所以只触发父类的初始化,因为静态变量的调用只会触发属性所在的类)
Init SuperClass 100
  • 语句C和语句D的输出结果是:
200
Hello JVM

因为被final修饰的静态变量已经存在于常量池中,在连接的准备阶段已经赋值了,不需要初始化类了。

  • 语句E不会输出任何结果,因为构造数组对象和直接构造对象是通过不同的字节码指令来实现的,创建数组是通过一个单独的newarray指令实现,并不会初始化对象。

使用

经过上面5个步骤,一个完整的对象已经加载到内存了,接下来我们在代码中就可以直接使用了。

卸载

当一个对象不再被使用了,会被垃圾回收掉。

确认对象是否可回收

垃圾收集的第一件事就是怎么确定一个对象是否可回收(是不是垃圾了)。主要方法:引用计数/可达性分析。

引用计数法

这个算法简单效率高,就是给对象添加一个引用计数器,当一个地方引用它时就加1,当引用失效时减一,当计数器的值为0时就表明这个对象不会被使用成为了无用对象,可被回收。这种算法虽然简单,但有一个致命问题:无法解决相互引用的问题。

image.png 上面四个对象相互引用,但是并没有其他对象引用它们,这种对象实际上也是无效对象,但是通过引用计数法它们的计数器的值为1无法被回收。

可达性分析法

可达性分析法就是选择一些称为GC Roots的对象作为起始点,然后从GC Roots开始向下搜索,搜索路径称为引用链,当一个对象不在任何一条引用链上时,就说明此对象是无效对象,可以被回收。可达性分析法解决了引用计数法的对象间相互引用的问题。

image.png

哪些对象可以作为GC Roots呢

  • Java虚拟机栈内栈帧中的局部变量表中的变量
  • 方法区中类静态属性
  • 方法区中的常量
  • 本地方法栈JNI(即Native方法)中的变量

引用的四种分类

强引用>软引用>弱引用>虚引用

强引用

我们写的代码一般都是强引用,如Object obj = new Object()这种就属于强引用,强引用主要还存在就不会回收,空间不够直接抛出OOM

软引用

软引用通过SoftReference类来实现,软引用用来表示一些还有用但又是非必需的对象,系统在即将溢出之前,如果发现有软引用的对象存在,会对其二次回收,回收之后内存还是不够就会抛出OOM

弱引用

弱引用通过WeakRerefence实现,弱引用也是用来表示非必需的对象,但是相比于软引用,弱引用的对象会在第一次垃圾回收时就被回收掉

虚引用

虚引用通过PhantomReference实现,称为幽灵引用或幻影引用,最弱的一种引用,一个对象是否有虚引用对其生存时间没有影响,也无法通过虚引用来取得一个对象实例。设置为虚引用的唯一用处就是当这个对象被回收时可以收到一个系统通知。

image.png

垃圾回收算法

当一个对象被确定为可回收之后,下面就是回收的过程了,回收也有不同的算法。

标记-清除算法

回收前

image.png 1、将堆内存扫描一遍,然后会把灰色区域对象标记一下
2、继续扫描,扫描的同时将被标记的对象统一回收

回收后:

image.png 可以看到,回收之后内存空间不连续,产生了内存碎片。 缺点:

  • 标记和清除两个过程效率都不高。
  • 产生大量不连续的内存碎片

复制算法

复制算法的思想就是把内存区域一分为二,两块内存大小相同,每次只使用其中一块,当其中一块使用完后,将存活的对象复制到另一块,然后一次性清理掉这一块内存。

回收前:

image.png 回收后:

image.png 复制算法的缺点是牺牲了一半的内存空间,有点浪费。复制算法在JVM中的体现就是:java堆内存做了几次划分,Hot Spot虚拟机中Eden和Survivor的比例是Eden:S0:S1=8:1:1,将Survivor分成了两个区域S0和S1来进行赋值,这种做法是为了弥补原始复制算法直接将一半空间作为空闲空间的浪费。IBM公司表示:Young区有98%的对象都是朝生夕死的,生命周期极短,所以说一次GC下来存活的对象很少,所以没必要用一半空间来复制。

标记-整理算法

标记-整理算法就是为了老年代而设计的算法,标记-整理算法和标记-清除算法的区别在最后一步,标记-整理不会直接对对象清理,而是进行移动,将存活对象移动到一端,然后清理掉边界以外的对象。 回收前:

image.png

回收后:

image.png

分代收集算法

目前主流的商业虚拟机都是采用分代收集算法,这种算法就是上面三种算法的结合。新生代采用复制算法,老年代采用标记-整理或标记-清除算法。

垃圾收集器

垃圾收集器就是垃圾收集算法的具体实现,下面是垃圾收集器的汇总

image.png

Serial和Serial Old收集器

Serial收集器是最基本、最早的收集器,Serial收集器是单一线程,就是在GC的时候STW(Stop The World),暂停所有用户线程,如果GC时间过长,用户可以感到卡顿。Serial Old也是单线程,作用于老年代。

image.png 优点:简单高效,拥有很高的单线程收集效率 缺点:需要STW,暂停所有用户线程 算法:Serial采用复制算法,Serial Old采用标记-整理算法

ParNew收集器

ParNew是Serial的多线程版本,实现了并行收集,原理跟Serial一致(并行指的是多个GC线程并行,但是用户线程还是暂停,并发指的是用户线程和GC线程同时执行)。ParNew默认开启和CPU个数相同的线程数进行回收。

image.png

优点:在多CPU时,比Serial的效率高。 缺点:还是需要STW,单CPU时比Serial效率低 算法:复制算法

Parallel Scavenge收集器

新生代收集器,也是复制算法,和ParNew一样并行的多线程收集器,更关注系统的吞吐量(吞吐量=(运行用户代码的时间)/(运行用户代码的时间+GC时间)),Parallel Scavange提供了两个参数用于精确控制吞吐量:

-XX:MaxGCPauseMillis //GC最大停顿毫秒数,必须大于0
-XX:GCTimeRation //设置吞吐量大小,大于0小于100,默认值为99

你会不会觉得把MaxGCPauseMillis设置小点就会让GC速度变快?答案是否定的,如果设置时间过小,Parallel Scavange会牺牲吞吐量和新生代空间来交换,比如新生代400Mb需要GC时间为100ms,设置成50ms了,那么就会把新生代调小为200Mb,这样肯定时间就降下来了,然而这种操作可能会降低吞吐量,原先10s触发一次GC,每次100ms,修改时间后变成5s触发一次GC,每次70ms,那么10ms触发两次GC的时间变成了140ms,吞吐量反而降低。 可以通过参数-XX:+UseAdaptiveSizePolicy开启自适应策略,这样我们不需要手动设置,虚拟机会根据运行情况动态调整。

Parallel Old收集器

是Parallel Scavange的老年代版本,因为Parallel Scavange无法和CMS搭配使用,所以只能和Serial Old。自从Parallel Old出现,就有了Parallel Scavange+Parallel Old的组合,这是JDK1.8使用的,注重吞吐量的一组收集器。

image.png

CMS收集器

这是优化GC停顿时间为目标的收集器,并发回收(仍然需要STW,但是时间很短)。通过-XX:+UseMarkSweepGC启用。CMS基于标记-清除算法实现。整个过程分为四步:

  • 初始标记:需要STW,标记GC Roots对象。
  • 并发标记:这个阶段可以和用户线程一起进行,分为三步: (1)根据第一步找到的GC Roots开始搜索跟GC Roots相连的对象。 (2)预清理:这个阶段是为了处理并发标记之后发生变化的对象。 (3)可被终止的预清理:这个阶段会有一个abort触发条件,该阶段存在的目的是希望能发生一次Young GC,这样就可以减少Young区对象数量,降低重新标记的工作量,因为重新标记会扫描整个堆内空间,可以通过参数-XX:+CMSScavangeBeforeRemark控制在重新标记前发生一次Young GC,默认为false。
  • 重新标记:需要STW,这个阶段是为了修正在阶段2标记之后产生变化的对象。
  • 并发清除:和用户线程同时进行,开始正式清理垃圾,此阶段产生的垃圾留待下次清除。

image.png 优点:并发收集,低停顿 缺点:产生大量碎片,并发阶段会降低吞吐量

G1

G1是以优化GC停顿时间为目标的收集器,它尝试以高概率满足GC停顿时间为目标,同时实现高吞吐量。在G1中,将堆的整个内存布局做了修改,在G1中整个堆划分为多个大小相等的独立区域Region,虽然在逻辑上还保留了新生代和老年代,但是物理上已经隔离了,G1的堆内存布局如下图:

image.png 上图智能柜被划分成一组大小相同的Region,每个Region都是连续的虚拟内存范围,G1可以知道哪个Region区域内大部分是空的,这样就可以在每次允许的收集时间内优先回收价值最大的Region区域(根据回收所获得的空间大小以及回收需要的时间综合考虑)所以这就是G1叫做Garbage-First的原因。G1是jdk1.8默认的垃圾收集器。
G1的工作流程和CMS很相似,区别在最后的步骤。也有四步:

  • 初始标记:需要STW,标记下GC Roots关联的对象,并且修改TAMS(Next Top at Mark Start)的值,使得下一阶段并发运行时,能在正确可用的Region中创建对象。
  • 并发标记:和CMS一样,主要是进行GC Roots的向下搜索,找出存活对象进行标记。
  • 最终标记:需要STW,和CMS一样,这个阶段是修正并发标记期间因用户程序运行而导致变动的对象。
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间制定回收计划。

image.png G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案,这就意味着堆大小约为6G或更大,并且稳定且可预测的暂停时间低于0.5秒。如果应用程序具备以下特性,可以考虑切换到G1收集器:

  • 超过50%的Java堆被实时数据占用
  • 对象分配率或提升率差异很大
  • 当前应用程序GC停顿时间超过0.5秒,又想缩短停顿时间

其他收集器

  • ZGC收集器:Java11提供的一种垃圾收集器
  • Shenandoah:Open JDK包含的收集器

如何选择收集器

  • 串行收集器:Serial和Serial Old单线程收集,适用于内存较小的嵌入式设备。
  • 并行收集器【吞吐量优先】:Parallel Scanvage+Parallel Old,适用于科学计算、后台处理等场景。
  • 并行收集器【GC停顿时间优先】:CMS和G1,适用于对时间有要求的场景,例如Web应用。

JVM调优

JVM参数

所谓JVM调优就是设置一个合理的JVM参数,适合当前系统运行。JVM参数分为三类:标准参数,-X参数和-XX参数。

标准参数

以"-"开头的参数称为标准参数,是任何一个JDK版本都支持的,比较稳定,不会随jdk版本更新和改变。例如-version,-help,-server。

-X参数

以-X开头的参数是在特定版本HotSpot支持的命令,jdk版本变化之后,参数可能变化,这个参数用的较少。

-XX参数

-XX是不稳定的参数,也是主要参数,分为Boolean类型和非Boolean类型。

Boolean型

Boolean型的-XX参数使用格式为:

-XX:[+-]<name>:+或-表示启用或者禁用name属性

例如:

-XX:+UseConcMarkSweepGC:表示启用CMS垃圾收集器
-XX:+UseG1GC:表示启用G1垃圾收集器
-XX:+PrintFlagsFinal:表示打印出所有的JVM参数信息

使用IDEA,选择Run->Edit Configurations,然后点击左边的+号,选择Application,出现如下界面,加入JVM参数

image.png 然后运行main方法,就会打印出所有参数

image.png

非Boolean型

非Boolean型的-XX参数的使用格式为:

-XX<name>=<value>:name表示属性,value表示属性对应的值

例如:

-XX:MaxMetaspaceSize=5M 设置最大永久代空间大小为5M

其他参数

还有一些非常有用的参数,比如-Xms,-Xmx,-Xss,实际上这几种参数也是属于-XX参数,只是简写了。

-Xms1000等价于-XX:InitialHeapSize=1000
-Xmx1000等价于-XX:MaxHeapSize=1000
-Xss1000等价于-XX:ThreadStackSize=1000

常用JVM参数

设置说明
-XX:ClCompilerCount=3最大并行编译数,大于1时可以提高编译速度,但会影响系统稳定性
-XX:InitialHeapSize=100m初始堆大小,可以简写为-Xms100
-XX:MaxHeapSize最大堆大小,可以简写为-Xmx100
-XX:NewSize=20m设置年轻代大小
-XX:MaxNewSize设置年轻代最大值
-XX:OldSize=50m设置老年代大小
-XX:MetaspaceSize=50m设置方法区大小,jdk1.8才有,用元空间代替方法区
-XX:+UseParallelGC设置Parallel Scanvage作为新生代收集器,系统默认会选择Parallel Old作为老年代收集器
-XX:NewRatio新生代和老年代的比值,比如 -XX:NewRatio=4表示新生代:老年代=1:4
-XX:SurvivorRatio表示两个S区和Eden区的比值,比如-XX:SurvivorRatio=8表示(S0+S1):Eden=2:8

命令监控工具

jdk的bin目录下有很多工具监控jvm

jps

jps全称JVM Process Status Tool,一款查看java进程的工具。查看当前环境下运行的java服务的进程id和名称

  • -q:只输出进程id
  • -m:输出虚拟机启动时传递给main()方法的参数
  • -l:输出主类的全名,如果执行的是jar包,输出jar包的路径
  • -v:输出启动虚拟机的参数

image.png

jstat

jstat全称JVM Statistics Monitoring,一款用于监视虚拟机各种运行状态统计信息工具,主要显示如下信息:虚拟机进程的类装载、内存、垃圾收集、JIT编译等。

  • 查看类装载信息
jstat -class PID 1000 10 //查看某个Java进程的类装载信息,每1000毫秒输出一次,共输出10次

image.png

  • 查看垃圾收集信息
jstat -gc PID 1000 10

image.png 上图显示了各个区以及垃圾回收的情况,具体代表含义如下(C代表Capacity,U代表Used已使用大小)

  • S0C和S1C代表Survivor区的S0和S1的大小
  • S0U和S1U表示已使用空间
  • EC表示Eden区大小,EU表示Eden区已使用容量
  • OC表示老年代大小,OU代表老年代已使用容量
  • MC表示方法区大小,MU表示方法区已使用容量
  • CCSC表示压缩类空间大小,CCSU表示压缩类空间已使用大小
  • YGC表示新生代GC次数,YGT表示新生代GC总耗时
  • FGC表示Full GC次数,FGCT表示FULL GC总耗时
  • GCT表示GC总耗时时间

jstat参数常用选项

| 标题 | | | 参数 | 说明 | | -class | 查看类加载/卸载数量和大小,以及所耗费的时间 | | -gc | 统计堆内各个分区的总大小和已使用大小,以及不同分区的GC次数和耗时 | | -gccapacity | 同-gc相似,但是主要统计Java堆各个区域使用到的最大和最小空间 | | -gcutil | 同-gc相似,但主要统计已使用空间占总空间大小 | | -gccause | 同-gcutil一样,只是会额外输出上一次发生GC原因 | | -gcnew | 统计新生代GC情况 | | -gcold | 统计老年代GC情况 |

jstack

Stack Trace for Java,一款用于生成当前时刻的线程状态信息的快照工具,这个对于分析当前线程状态非常有用,比如说是否有哪个线程阻塞了,或者说是否发生了死锁等信息。如:

jstack PID

image.png 可以看到当前线程的状态,线程的名字,所以我们自己创建线程的时候建议采用自定义名称,如果有异常可以很容易知道。

jinfo

Configuration info for Java,一款用于实时查看和修改JVM参数的工具。注意,如果是修改,参数类型是manageable类型才能修改。

jinfo -flag name PID 查看某个java进程的name属性的值
jinfo -flags PID 查看已经复制的JVM参数

jmap

Memory Map for Java,一款用于生成堆转储快照即dump文件的命令

jmap -heap PID //打印出堆内存相关信息

image.png

jmap -dump:format=b,file=/usr/heap.hprof PID //生成dump文件

上面常用参数可以设置,一旦发生OOM之后就会自动生成dump文件

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

jhat

Jvm Heap Analysis Tool,一款用于分析dump文件的工具

jhat heap.hprof

image.png 然后访问地址http://localhoust:7000/, 可以看到这款工具展示的信息比较简单。

可视化监控工具

jconsole工具

Java Monitoring And Management Console,一款JDK自带的可视化监控工具,其实就是上面的jstat,jstack等命令工具可视化了,主要可以查看java应用程序的运行概况,监控堆信息,永久区使用情况,类加载情况等信息。jconsole可以直接在命令行输入命令jconsole,或在jdk安装目录下找到打开。

image.png

VisualVM工具

All-in-one Java TroubleshootingTool,是JDK发布的一款功能强大的运行监控故障处理工具。

  • 监控应用程序的CPU、GC、堆。方法区和线程信息(jstack和jstat的功能)
  • dump文件以及分析(jmap和jhat的功能)
  • 方法级的程序性能分析,可以找出被调用最多,运行时间最长的方法
  • 离线程序快照:收集程序运行时配置、线程dump。内存dump等信息建立一个快照,并可以将快照发送给开发者进行bug反馈。
  • 插件化处理,有无限扩展可能

image.png

分析GC日志调优

什么时候会发生GC

一般有以下几种情况:

  • Eden区不够或S区不够
  • 老年代不够
  • 方法区不够
  • System.gc()

怎么拿到GC日志

打印GC日志可以用命令:

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:/Users/lizhengqiang/Documents/gc.log

image.png 找到gc.log文件,刚开始没有发生GC,所以文件是空的,等到发生GC后打开

image.png

Parallel Scavenge+Parallel Old日志分析

image.png 前三行都比较容易理解:

  • 第一行打印的是当前使用的HotSpot虚拟机以及对应的版本号
  • 第二行打印的是操作系统的内存信息
  • 第三行是设置的JVM参数

第4行开始才是我们的GC日志,下面分析下第4和9行

//第4行
2020-08-23T15:35:30.747+0800: 5.486: [GC (Allocation Failure) [PSYoungGen: 32768K->3799K(37888K)] 32768K->3807K(123904K), 0.1129986 secs] [Times: user=0.02 sys=0.00, real=0.11 secs] 
//第9行
2020-08-23T15:35:34.635+0800: 9.374: [Full GC (Metadata GC Threshold) [PSYoungGen: 5092K->0K(136192K)] [ParOldGen: 12221K->12686K(63488K)] 17314K->12686K(199680K), [Metaspace: 20660K->20660K(1067008K)], 0.0890985 secs] [Times: user=0.25 sys=0.00, real=0.09 secs]
  • 最前面的时间2020-08-23T15:35:30.747+0800表示垃圾回收发生的时间
  • 紧接着一个数字5.486比欧式从java虚拟机启动以来经过的秒数
  • GC (Allocation Failure)表示发生GC的原因,这里代表分配空间失败
  • PSYoungGen,PS是Parallel Scavenge收集器,YoungGen表示年轻代
  • 32768K->3799K(37888K)表示:GC前当前内存区域使用空间->GC后当前内存区域使用空间(当前区域总内存空间)。从这里看出,一次GC之后,年轻代的空间大部分被回收掉了
  • 32768K->3807K(123904K):GC前堆的已使用容量->GC后堆的已使用容量(Java堆的总容量)
  • 0.1129986 secs 表示GC花费的时间,secs表示单位为秒
  • [Times: user=0.02 sys=0.00, real=0.11 secs] 这一部分并不是所有的垃圾收集器都会打印,usr=0.02表示用户态消耗的CPU时间,sys=0.00表示内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间。
  • 第9行Full GC表示发生了Full GC,Full GC=Minor GC+Major GC+Metaspace GC,可以看到,新生代全部都回收掉了,老年代回收了一小部分,方法区一点都没有回收掉,这也体现了三个区域所存对象的区别。

G1日志分析

切换到G1垃圾收集器,然后重启服务,等待垃圾收集之后打开gc.log

-Xms2m -Xmx2m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:/Users/lizhengqiang/Documents/gc.log

image.png 可以看到G1的日志很复杂,我们找到一个完整的日志分析

2020-10-16T14:21:26.579-0800: 0.435: [GC pause (G1 Evacuation Pause) (young), 0.0013216 secs]
   [Parallel Time: 1.0 ms, GC Workers: 4]
      [GC Worker Start (ms): Min: 434.9, Avg: 434.9, Max: 434.9, Diff: 0.0]
      [Ext Root Scanning (ms): Min: 0.2, Avg: 0.5, Max: 0.9, Diff: 0.7, Sum: 1.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.0, Max: 0, Diff: 0, Sum: 0]
      [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.3, Max: 0.5, Diff: 0.5, Sum: 1.3]
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.5]
         [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.1]
      [GC Worker Total (ms): Min: 0.9, Avg: 0.9, Max: 0.9, Diff: 0.0, Sum: 3.8]
      [GC Worker End (ms): Min: 435.8, Avg: 435.8, Max: 435.8, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.0 ms]
   [Other: 0.3 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.2 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.0 ms]
      [Humongous Register: 0.0 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 0.0 ms]
   [Eden: 1024.0K(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->1024.0K Heap: 1024.0K(2048.0K)->464.1K(2048.0K)]
 [Times: user=0.00 sys=0.00, real=0.01 secs] 
  • [GC pause (G1 Evacuation Pause) (young), 0.0013216 secs] 这里表示发生GC的时间是年轻代,后面就是总共发生时间(注意:G1虽然物理上取消了区域划分,但逻辑上还保留着,所以日志中还是会显示young,Full GC会用mixed显示)
  • [Parallel Time: 1.0 ms, GC Workers: 4] 这句表示线程的并行时间和并行的线程数
  • [GC Worker Start (ms): Min: 434.9, Avg: 434.9, Max: 434.9, Diff: 0.0] 这个表示并行的每个线程开始时间最小值,平均值和最大值,以及时间差

image.png