aepbui
发布于 2023-04-06 / 26 阅读
0
0

JVM

学习JVM相关课程,自己总结的完整笔记分享一下

JVM的体系结构

简介

跨平台的语言

一次编写到处执行

跨语言的平台

java虚拟机不关心运行在其内部的程序到底是哪种变成语言编写,只关心字节码文件,只要其他编程语言的编译结果满足并包含java虚拟机的内部指令集、符号表等,它就是一个有效的字节码文件

执行流程

java源码通过编译器生成字节码文件(在源文件的编译过程中任意一个环节出现错误都无法发生成字节码文件),之后执行引擎逐行的对字节码文件进行解释执行.其中还会将反复执行的热点代码通过JIT编译器编译为机器指令缓存在方法区中.现在的主流的虚拟机都是将解释执行和即时编译二者并存的方式

类加载器

发展历程

Sun Classic VM

Sun公司发布的第一款虚拟机,这个虚拟机只提供解释器,如果想要使用JIT编译器则需要外挂,并且一旦使用了JIT,就会接管虚拟机的执行系统,解释器不再工作

Exact VM

为了解决上一个虚拟机的问题,jdk1.2的时候Sun提供了此虚拟机

此虚拟机可以知道内存中的某个位置的数据具体是什么类型(是对象的地址或是数据)

并且具备了现代虚拟机雏形

  • 热点探测

  • 编译器与解释器的混合工作模式

HotSpot VM

Exact VM只在Solaris下短暂使用就被HotSpot代替

HotSpot目前占据绝对市场地位,JDK6以后的默认虚拟机都是HotSpot

此虚拟机的名称指的就是它的热点代码探测技术

  • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换

  • 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

JRockit

不太注重程序的启动速度,所以不包含解释器的实现,所有代码都靠即时编译器编译后执行

是世界上最快的JVM

Oracle将整合这两个JVM,整合的方式是在HotSpot的基础上移植JRockit的优秀特性

J9

市场定位与HotSpot接近,也号称是世界上最快的JVM,但是在非IBM的设备上运行稳定性较差

IMB在2017年开源了J9 VM

架构模型

Java编译器输入的指令流是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

其中栈指的就是操作数栈

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统

  • 使用零地址指令方式分配

一种不设地址字段的指令,只有操作码,没有操作数(只需要操作栈顶,所以不需要地址)

  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈.指令集更小,编译器容易实现

  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集

  • 指令集架构则完全依赖硬件,可移植性差

  • 性能优秀和执行更高效

  • 花费更少的指令去完成一项操作

  • 基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主

式例

java代码

public class StackTest {
    public static void main(String[] args) {
        int i = 2;
        int j = 3;
        int k = i + j;
    }
}

通过javap -v StackTest.class 反编译,部分截取

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_2
         1: istore_1
         2: iconst_3
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: istore_3
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 4
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
            2       7     1     i   I
            4       5     2     j   I
            8       1     3     k   I
}

分析

0: iconst_2 # 生成常量2
1: istore_1 # 放入操作数栈的索引位置1
2: iconst_3 # 生成常量3
3: istore_2 # 放入操作数栈的索引位置2
4: iload_1  # 常量2出栈
5: iload_2  # 常量3出栈
6: iadd     # 相加
7: istore_3 # 将结果7放入操作数,栈的索引位置3
8: return   # 执行结束

基于寄存器

java目前不支持基于寄存器的执行,此处只是示例

mov eax,2 # 将eax寄存器的值设为1
add eax,3 # 使eax寄存器的值加3

总结

由于跨平台性的设计,java的指令都是根据栈来设计的,不同平台CPU架构不同,所以不能设计为基于寄存器的

优点是跨平台,指令集粒度小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令

生命周期

虚拟机的启动

Java虚拟机的启动是通过引导类加载器创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的

虚拟机的执行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序

  • 程序开始执行时运行,程序结束时停止

  • 执行Java程序的时候,实际上执行的Java虚拟机是一个进程

可以通过jps命令查看所有允许的虚拟机的进程名和pid

虚拟机的退出

  • 程序正常执行结束

  • 程序在执行过程中遇到了异常或错误而异常终止

  • 由于操作系统用现错误而导致Java虚拟机进程终止

  • 某线程调用System.exit方法,且Java安全管理器也允许这个操作

  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java虚拟机的退出

类加载子系统

类加载器是java核心组件,所有Class都由ClassLoader进行加载,ClassLoader负责通过各种方式将类的二进制信息读取到JVM内部,转换为为对应的java.lang,Class的实例对象.之后交给JVM进行链接、初始化等操作

ClassLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎决定

加载的类信息存放于一块称为方法区的内存空间.除了类的信息外,方法区中还会存放运行时常量池信息,比如包括字符串字面量和数字常量

类的加载过程

类的加载过程

加载

通过一个类的全限定定类名获取定义此类的二进制字节流

将这个字节流所代表的静态存储格式结构转化为方法区的运行时数据区

在内存中生成一个代表这个类的Class对象,作为方法区的这个类的各个数据的访问入口

链接

  • 验证

目的在确保class文件的字节流中包含信息符合当前虛拟机要求,保证被加载类的正确性,不会危害虛拟机自身安全,主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证

每个字节码文件的前4个字节被称为魔数0xCAFEBABE

可以通过xxd命令转换为二进制文件

xxd HelloLoader.class
00000000: cafe babe 0000 0034 0022 0a00 0600 1409  .......4."......
00000010: 0015 0016 0800 170a 0018 0019 0700 1a07  ................
00000020: 001b 0100 063c 696e 6974 3e01 0003 2829  .....<init>...()
// 以下省略
  • 准备

为类变量分配内存并且设置该类变量的默认初始值

如果变量被final和static修饰则为常量,准备阶段会显式初始化

不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到堆中

  • 解析

将常量池内的符号引用转换为直接引用的过程

解析操作往往会伴随着JVM在执行完初始化之后再执行

符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在java虛拟机规范的Class文件格式中.直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、 CONSTANT Methodref info等

#29 = Utf8               test/HelloApp
#30 = Utf8               java/lang/Object
#31 = Utf8               java/lang/System
#32 = Utf8               out
#33 = Utf8               Ljava/io/PrintStream;
#34 = Utf8               java/io/PrintStream
#35 = Utf8               println
#36 = Utf8               (I)V

通过javap命令可以查看到一个简单的类加载了很多结构

初始化

  • 初始化阶段就是执行类构造器方法<clinit>执行的的过程

此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来

如果没有类变量的赋值操作和静态代码块就不会生成<clinit>方法

public class ClassInitTest {
    public static int num = 1;

    static {
        num = 2;
    }

    public static void main(String[] args) {
        System.out.println(ClassInitTest.num);
    }
}

可以通过jclasslib查看方法中自动生成的<clinit>

  • 构造器方法中指令,按语句在源文件中出现的顺序执行

public class ClassInitTest {
    public static int num = 1;

    static {
        num = 2;
        number = 20;
    }

    public static int number = 10;

    public static void main(String[] args) {
        System.out.println(ClassInitTest.num);
        System.out.println(ClassInitTest.number);
    }
}

jclasslib

  • <clinit>不同于类的构造器(构造器是虚拟机视角下的<init>)

  • 若该类具有父类, JVM会保证子类的<clinit>执行前,父类的<clinit>已经执行完毕

public class ClassInitTest3 {

    static class Father {
        public static int A = 1;

        static {
            A = 2;
        }
    }

    static class Son extends Father {
        public static int B = A;
    }

    public static void main(String[] args) {
        System.out.println(Son.B);
    }
}

ClassInitTest3&Son.class的 <Clinit>

0 getstatic #2 <test/ClassInitTest3$Son.A : I>
3 putstatic #3 <test/ClassInitTest3$Son.B : I>
6 return
  • 虚拟机必领保证一个类的<clinit>方法在多线程下被同步加锁

因为静态代码块中的死循环和线程锁导致两个线程都卡在new DeadThread()无法打印结束

public class ClassInitTest4 {
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() + "开始");
            DeadThread deadThread = new DeadThread();
            System.out.println(Thread.currentThread().getName() + "结束");
        };
        Thread t1 = new Thread(r, "线程1");
        Thread t2 = new Thread(r, "线程2");

        t1.start();
        t2.start();
    }
} 

class DeadThread {
    static {
        if (true) {
            System.out.println(Thread.currentThread().getName() + "初始化了当前类");
            while (true) {}
        }
    }
}

输出

线程1开始
线程2开始
线程1初始化了当前类

类的加载器

JVM支持两种类型的类加载器,分别分引导类加载器 (Bootstrap ClassLoader)和自定义类加载器 (User-Defined ClassLoader ) 所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

如ExtClassLoader和AppClassLoader都属于自定义类加载器

类的加载器

获取类加载器

public class ClassLoaderTest {
    public static void main(String[] args) {
        // 获取系统类加载器 => sun.misc.Launcher$AppClassLoader@5c647e05
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);
        // 获取上层,扩展类加载器 => sun.misc.Launcher$ExtClassLoader@3d4eac69
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);
        // 再获取上层,获取不到引导类加载器 => null
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);
        // 自定义类的类加载器,默认使用系统类加载器加载 => sun.misc.Launcher$AppClassLoader@5c647e05
        ClassLoader myClassLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(myClassLoader);
        // String类以及所有java核心类库都是使用的是引导类加载器加载的 => null
        ClassLoader strClassLoader = String.class.getClassLoader();
        System.out.println(strClassLoader);
    }
}

获取类加载器的途径

  • 直接获取指定类的类加载器

  • 通过当前线程获取上下文的类加载器

  • 获取系统的类加载器

  • 获取调用者的类加载器

// 通过当前线程上下文获取类加载器
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
System.out.println(contextClassLoader);
// 获取调用者的ClassLoader
// DriverManager.getCallerClassLoader();

JVM加载器

启动类加载器

Bootstrap ClassLoader是c/c++实现,嵌套在JVM内部.只用来加载java的核心类库/jre/lib/rt.jar、resources.jar和sun.boot.class.path路径下的内容,用于提供JVM需要的类

不继承java.lang.ClassLoader,没有父类加载器,处于安全考虑只加载java、javax、sun开头的类

扩展类加载器

Extension ClassLoader由java编写,由sun.misc.Launcher$ExtClassLoader实现

派生与ClassLoader,父类加载器为Bootstrap ClassLoader

从java.ext.dirs系统所指定的目录中加载的类或从/jre/lib/ext下加载的类库

应用程序类加载器

AppClassLoader,由java编写,由sun.misc.Launcher$AppClassLoader实现,并且默认的类加载器

派生与ClassLoader,父类加载器为Extension ClassLoader

它负责加载环境变量或系统属性java.class.path指定路径下的类库

// 可以通过以下方式获取
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
ClassLoader myClassLoader = ClassLoaderTest.class.getClassLoader();
// true
System.out.println(systemClassLoader == myClassLoader);

用户自定义加载器

自定义类加载器的场景

  • 隔离加载类

  • 修改类加载方式

  • 扩展加载源

  • 防止源码泄漏

需要继承java.lang.ClassLoader实现自己的类加载器

在jdk1.2之前需要继承ClassLoader类并重写loaClass方法,但是在1.2之后不推荐覆盖loaClass方法而是建议把相关类加载的逻辑写在findClass方法中

public class CustomerClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] result = getClassFromCustomPath(name);
        try {
            if (result == null)
                throw new FileNotFoundException();
            else
                return defineClass(name, result, 0, result.length);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        throw new ClassNotFoundException(name);

    }

    // 通过FileInputStream等手段获取字节码文件的字节数组 
    private byte[] getClassFromCustomPath(String name) {
        return null;
    }
}

在编写自定义类加载器时,如果没有太过于复杂的需求可以直接继承URLClassLoader类避免自己编写findClass代码以及获取字节码

双亲委派机制

java虚拟机对class文件采用的是按需加载的方式,当需要使用该类的时候才会将它的class文件加载到内存生成class对象.而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

双亲委派机制调用

依次判断,如引导类加载器会判断当前类是否为java、javax、sun的包名

工作原理

一个类加载器收到了类加载请求,会先判断改类是否已经被加载过了,如果有则无需加载.然后把这个请求委托给父类的加载器去执行.如果父类加载器还存在其他父类加载器,则进一步委托,直到最终将达到最顶层的启动类加载器

如果父类加载器可以完成类的加载任务,就会成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制

package java.lang;

public class String {
    static {
        System.out.println("我是自定义的类的静态代码块");
    }
}

这样设计的好处是在一些系统级别的类,如果想要篡改它的实现,在这种机制下已经被启动类加载器加载过了,其他类加载器没有机会加载,防止了危险代码的植入

自定义的java.lang包不可能被使用也没有意义,只会使用引导类加载器加载的java.lang,String

双亲委派机制优势

  • 避免类的重复加载

类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种关系可以避免类的重复加载

当父类已经加载了该类,就没有必要让子加载器再加载一次

  • 保护程序安全,防止核心API被篡改

测试

此时并不会调用自行在项目中创建的java.lang.String中的静态代码块,意味着加载的是启动类加载器的java.lang.String

即使在该类添加main方法也无法执行,因为依次委托,java自带的java.lang.String中并没有main方法

public class String {
    static {
        System.out.println("我是自定义的类的静态代码块");
    }

    public static void main(String[] args) {
    }
}

错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: public static void main(String[] args) 否则 JavaFX 应用程序类必须扩展javafx.application.Application

即使使用了java.lang包下原本没有的类也不允许

package java.lang;

public class MyStart {
    public static void main(String[] args) {
        System.out.println("hello!");
    }
}

Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang

沙箱安全机制

自定义的String类,但是在加载自定义的String类是会率先使用引导类加载器加载,而引导类加载器在加载中才会加载jdk自带的文件rt.jar包中java\lang\String.class.显示没有main方法,其实就是因为加载的是rt.jar包中的String类.这样可以保证对java核心源码的保护,这就是沙箱安全机制

JVM中判断是否用一个类有两个必要条件

  • 类的完整类名必须一致

  • 加载这个类的类的ClassLoader的实例对象必须相同

运行时数据区

运行时数据区

JVM内存布局规定了java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定的运行.不同的JVM对内存的划分方式和管理机制存在着部分差异

Java虚拟机中定义了若干程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁

如堆空间和方法区

另一些则是与线程一一对应,这些数据区会随着线程开始和结束,创建和销毁

如程序计数器、本地方法栈、虚拟机栈

  • 每个线程,独立包含程序计时器、栈、本地栈

  • 线程间共享:堆、堆外内存(永久代、元空间、代码缓存)

线程

线程是一个程序里的运行单元,每个JVM允许一个应用有多个线程并行执行

在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射

当一个java线程执行好以后,此时一个操作系统的本地线程也同时创建

java线程执行终止以后,本地线程也会被回收

操作系统负责所有线程的安排和调度到任何一个可用的CPU上,一旦本地线程初始化完成,他就会被调用执行线程的run方法

如果使用jconsole或其他调试工具,都能看到所有线程(不包括main方法和main方法创建的线程)

Hotspot的后台系统线程主要有以下几个

  • 虚拟机线程

    需要在所有线程都到达安全点(这样堆才不会发生变化),执行"stop the world"的垃圾收集、线程栈收集,线程挂起以及偏向锁撤销

    程序执行时并非所有地方都能停下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点

  • 周期任务线程

    周期性操作的调度执行

  • GC线程

    对JVM里不同种类的垃圾收集行为提供了支持

  • 编译线程

    运行时会将字节码编译为本地代码

  • 信号调度线程

    接受信号并发送给JVM,在内部通过调用适当方法进行处理

程序计数器

JVM的程序计数器Program Counter Register中Register命名源自CPU寄存器,寄存器存储指令相关信息.CPU只有把数据装载到寄存器才能够运行,JVM中的pc寄存器时对物理寄存器的一种抽象模拟.这里并非广义上所指的物理寄存器,所以翻译为程序计数器会更加贴切不容易产生误会

程序计数器是用来存储指向下一条指令的地址的,也将要执行的指令代码,由执行引擎读取下一条指令

程序控制流的指示器,如分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器

任何时间一个线程都只会有一个方法执行,也就是当前方法,程序计数器会存储当前线程正在执行的方法的JVM指令地址,如果执行的是native则是未制定值

实例

可以通过javap命令反编译代码,部分截取

其中左边的部分就是指令地址,右边的部分是操作指令

程序计数器2

执行引擎通过程序计数器找到对应的操作指令,并且将对应的指令翻译为机器指令交给CPU执行,同时操作局部变量表、操作数栈

0: bipush        10
2: istore_1
3: bipush        20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: ldc           #2
12: astore        4

作用

CPU需要在多个时间片中不停的切换各个线程,切换回原线程后就需要知道要从哪里开始继续执行,JVM的字节码解释器需要通过改变程序计时器的值来明确下一条需要执行什么样的字节码指令

看似并行实则并发

程序计数器是线程私有的,CPU会在不同的线程中切换,为了能够准确的记录各个线程正在执行的字节码指令地址,就需要为每个线程都配备一个程序计数器.这样各个线程都会独立计算,而不会出现相互干扰的情况

虚拟机栈

每个线程创建时都会创建一个虚拟机栈,在这个线程上执行的每个方法都对应一个栈帧,并且不同线程中所包含的栈帧是无法相互引用的

栈帧是一个内存区域,维系着方法执行过程中的各种数据信息

栈是一种快速有效的内存分配方式,每个方法的执行都会伴随着压栈和出栈

在一个活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧是有效的,与当前栈帧对应的方法就是当前方法,定义这个方法的类就是当前类

如果在该方法中调用了其他方法,对应新的栈帧就会被创建出来,放在栈的顶部,成为新的栈帧

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得下一个栈帧成为当前栈帧

java有两种方法返回形式,一种是正常的方法返回,即return指令.另一种是出现未被捕获的异常,并以抛出异常的方式处理.不管哪种方式,都会导致栈帧被弹出

内存异常

java虚拟机规范允许java栈的大小是动态的或着固定的

  • 如果采用固定大小的虚拟机栈,如果线程请求分配的栈内存超过java虚拟机栈允许的最大容量,则会抛出StackOverflowError

  • 如果虚拟机栈可以动态扩展,当尝试扩展时无法申请到足够的内存时,或者新创建的线程没有足够的内存去创建对应的虚拟机栈时则会抛出OutOfMemoryError

可以通过Xss修改栈内存大小

-Xss 2m

内部结构

每个栈帧的结构

  • 局部变量表

  • 操作数栈

  • 动态链接(指向运行时常量池的方法引用)

  • 方法地址

  • 附加信息(栈帧中还运行携带java虚拟机实现的相关的附加信息,例如对程序调试提供支持的信息,不一定存在)

栈帧的内部结构

局部变量表

局部变量表是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用、以及返回地址(returnAddress).对于一个方法而言,它的参数和局部变量越多,局部变量表就越膨胀,栈帧也就越大,进而导致栈能容纳的栈帧就会越少

局部变量表中的变量只会在当前方法中有效,当方法调用完成后,随着栈帧的销毁,局部变量表也随之销毁

局部变量表所需的大小是在编译期就确定下来,可以通过jclaaslib中方法的Code属性的maximum local variables数据项中,在方法运行期间不会被改变

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1 // locals就是局部变量表的最大长度

局部变量表中的变量是重要的垃圾回收的根结点,只要被局部变量表中直接或间接引用的对象都不会被回收

局部变量表3

slot

局部变量表中最基本的存储单元是slot(变量槽),局部变量表中存放编译期可知的基本数据类型、引用数据类型和returnAddress类型的变量

局部变量表中,32位以内的类型占用一个slot(包括returnAddress,byte、short、char、boolean都会被转换为int).64位的类型占用两个slot(long和double)

JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即访问到局部变量表中指定的局部变量值.当访问局部变量表中一个64位的局部变量时,只需前一个索引即可

一个实例方法被调用时,它的方法参数和方法体内定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上

局部变量表slot

当前栈帧是构造方法或实例方法创建的,那么该对象的引用将会存放在下标为0的slot处

非静态方法则没有该对象的引用

private int count = 0;
// this变量不存在于静态方法的局部变量表中
public static void test() {
  // this.count;
}

如果一个方法有返回值,但是没有使用变量接收,那么也不会被存放入局部变量表中

public void test1() {
    // 该方法只有hello存在于局部变量表中
    String name1 = "hello";
    test2(name1);
}

public String test2(String hello) {
    return hello + "world";
}

重复利用

栈帧的局部变量中的slot是可以复用的,如果一个局部变量超过了其作用域,那么在其作用域之后申明的新的局部变量可能就会复用过期的局部变量的slot,从而达到节省资源的目的

public void test4() {
    int a = 0;
    {
        int b = 0;
        b = a + 1;
    }
  	// 变量b的生命周期已结束,c直接占用了b的位置
    int c = a + 1;
}

类变量在类的加载过程中有两次初始化的机会,和类变量初始化不同的是,局部变量表不存在系统初始化过程

也就是说局部变量必须赋值,否则无法使用

public void test() {
    int i;
    // 变量i还没有初始化
    // System.out.println(i);
}

操作数栈

操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中的变量临时存储空间

操作数栈是JVM执行引擎的工作区,当一个方法刚开始执行的时候同时也会创建一个新的操作数栈.每一个操作数栈都会拥有一个明确的栈深度用于存储数值,它所需的最大深度在编译期就确定好了

通过jclaaslib查看,则是保存在code中的Maximum stack size

栈中的任意一个元素和局部变量表相似,32位占用一个栈深度,64位占用两个栈深度

操作数栈虽然是采用数组实现的,但是并没有被设计为可以通过索引访问,而是只能通过入栈和出栈来完成数据的访问

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1 // stack就是操作数栈的最大长度

如果被调用的方法有返回值的话,会调用对应的方法并存入局部变量表中,此处是非静态代码所以是通过aload_0获取了当前类的实例对象后再通过invokevirtual调用方法

aload和iload都是从局部变量表中获取并压入操作数栈中

aload获取引用类型,iload获取int类型

public int getSum() {
    int m = 10;
    int n = 20;
    int k = m + n;
    return k;
}
public void testGetSum() {
    int i = getSum();
    int j = 10;
}

字节码指令

0: aload_0
1: invokevirtual #2                  // Method getSum:()I
4: istore_1
5: bipush        10
7: istore_2
8: return

栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作时必须要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派次数和内存读写次数

由于操作数是存储在内存中的,因此频繁的执行内存读写操作必然会影响执行速度.为了解决这个问题,HotSport JVM设计者提出栈顶缓技术,将栈顶元素全部缓存在CPU寄存器中,以此来降低对内存的读写次数,提升执行引擎的执行效率

动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用.包含这个引用的目的是为了支持当前方法能够实现的动态链接

在java源文件被编译到字节码文件时,所有变量和方法引用都作为符号引用保存在class文件的常量池里

描述一个方法调用了另外的其他方法,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

通过javap 指令可以看到字节码文件中包含常量池.左边的部分是符号引用,右边的是真实结构

Constant pool:
   #1 = Methodref          #9.#23         // java/lang/Object."<init>":()V
   #2 = Fieldref           #8.#24         // test3/DynamicLinkingTest.num:I
   #3 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = String             #27            // methodA()....
   #5 = Methodref          #28.#29        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #6 = String             #30            // methodB()....
   #7 = Methodref          #8.#31         // test3/DynamicLinkingTest.methodA:()V
   #8 = Class              #32            // test3/DynamicLinkingTest
   #9 = Class              #33            // java/lang/Object
  #10 = Utf8               num
  #11 = Utf8               I
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  #14 = Utf8               Code

常量池的作用就是为了提供一些符号和常量,便于指令识别

方法的调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

  • 静态链接 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行时保持不变.这种情况下将调用的方法引用直接转换为直接引用的过程称之为静态链接

  • 动态链接 被调用的方法在编译器无法确定下来,只能在程序运行期将调用方法的引用转换为直接引用

对应的方法绑定机制是早期绑定、晚期绑定.绑定是一个字段、方法或者类在 符号引用被替换为直接引用 的过程,并且仅发生一次

  • 早期绑定 被调用的目标如果在编译期可知,且运行保持不变时,即可将这个方法与所属的类型进行绑定,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态连接的方式将符号引用转换为直接引用

  • 晚期绑定 被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定的相关方法

实例

接口与父类

class Animal {
    public void eat() {
        System.out.println("eat");
    }
}

interface Huntable {
    void hunt();
}

实现类Dog

class Dog extends Animal implements Huntable {

    public void eat() {
        System.out.println("eat of dog");
    }

    public void hunt() {
        System.out.println("hunt of dog");
    }
}

实现类Cat

早期绑定,编译期就可以确定.对应字节码指令invokespecial和invokestatic

调用构造方法、静态方法、显式的调用父类方法都属于非虚方法

class Cat extends Animal implements Huntable {

    public Cat() {
        super(); // 早期绑定,非虚方法
    }

    public Cat(String name) {
        this(); // 早期绑定,非虚方法
    }

    public void eat() {
        super.eat(); // 早期绑定,非虚方法
        System.out.println("eat of cat");
    }

    public void hunt() {
        System.out.println("hunt of cat");
    }
}

调用类

晚期绑定,编译期间无法确定,对应的字节码指令invokevirtual和invokeinterface

public class AnimalTest {
    public void showAnimal(Animal animal) {
        animal.eat();  // 晚期绑定,虚方法
    }

    public void showHunt(Huntable huntable) {
        huntable.hunt(); // 晚期绑定,虚方法
    }
}

java中任意的方法(不包括构造方法)都具备虚函数的特征,相当于c++的virtual关键字修饰的方法

无法被重写,编译期就已经确定的都是非虚方法

静态方法、私有方法、final修饰的方法、实例构造器、显式的调用父类方法都是非虚方法

除此以外都是虚方法

注意 final修饰的是非虚方法,但在编译期无法确定方法的具体调用目标,因此编译器依然会使用invokevirtual指令

class Father {
    public final void showFinal() {
        System.out.println("father show final");
    }
}

public class Son extends Father {
    public void show() {
        showFinal(); // invokevirtual
        super.showFinal(); // invokesepcial
        info(); // invokevirtual
    }
  
    public void info() {
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }
}

上面invokevirtual是子类独有的方法,不过依然是虚方法,因为子类的方法也会被重写,编译器无法确定

动态类型特性

java7中并没有直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码文件生成该指令.直到java8的lambda出现invokedynamic才有直接生成的方式

该指令是为了实现动态类型语言特性的支持而做的改进

动态类型和静态类型的区别就在于对类型的检查是在编译器还是在运行期,前者是静态语言,而后者是动态语言

静态语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特性

@FunctionalInterface
interface Func {
    boolean func(String str);
}

public class Lambda {
    public void lambda(Func func) {
    }

    public static  void main(String[] args) {
        Lambda lambda = new Lambda();
        Func func = s -> true;
        lambda.lambda(func);
    }
}

lambda表达式只有在运行期间才能确定,实现动态语言类似的特性

重写的本质

  • 找到操作数栈顶的第一个元素所知晓的对象的实际类型

  • 如果在该类型中找到与常量中描述符名称相等的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,如果不通过则返回java.lang.IllegalAccessError

试图访问或修改属性或方法,如果没有权限就抛出IllegalAccessError

  • 按照继承关系从下往上依次对该类的各个父类进行第二步的搜索和验证过程

  • 如果最终没有找到合适的方法,则抛出java.lang.AbstractMethodError

没有重写过方法,调用的是抽象方法,抛出AbstractMethodError

虚方法表

在面向对象的编程中会很频繁的使用动态分配,如果每次动态分配的过程的都要在重新在类的方法元数据中搜索合适的目标方法就会大大影响执行效率,因此JVM在类的方法区建立一个虚方法表来实现

每个类都有一个虚方法表,表中存在着各个方法的实际入口

虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始化准备完成后,JVM会把该类的方法也初始化完毕

方法返回地址

方法的结束有两种方式无论是正常执行完成还是出现未处理的异常导致退出,都会返回到该方法调用的位置

  • 方法正常退出时,调用者的程序技术器的值作为下一条的指令地址

在字节码指令中,返回指令包括ireturn(boolean、byte、char、short、int),lreturn(long),freturn(float),

dreturn(double),areturn(引用类型),return(void)

  • 异常退出时,返回地址是要通过异常表来确定(栈帧中不会保存这部分信息),通过异常退出不会给上层调用者产生任何的返回值

执行过程中出现异常,并且这个异常没有在方法中进行处理(本方法的异常表中没有搜索的匹配的处理器),就会导致方法退出,也就是异常完成出口

public class Main {
    public static void main(String[] args) {
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

异常处理表

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: iconst_1
         1: iconst_0
         2: idiv
         3: istore_1
         4: goto          12
         7: astore_1
         8: aload_1
         9: invokevirtual #3                  // Method java/lang/Exception.printStackTrace:()V
        12: return
      Exception table:
         from    to  target type
             0     4     7   Class java/lang/Exception

本质上方法的退出就是当前栈帧出栈的过程.此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置程序计数器值,让调用者的方法继续执行下去

本地方法栈

虚拟机栈用于管理java方法的调用而本地方法栈用于管理本地方法的调用

和虚拟栈一样也是线程私有的,内存异常也可以参照虚拟机栈

本地方法栈

当一个线程调用本地方法时,它会进入本地方法栈中执行本地方法代码,此时该线程的执行不再受虚拟机的限制并且和虚拟机拥有同样的权限

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区

  • 可以直接访问处理器中的寄存器

  • 直接从本地内存的堆中分配任意数量的内存

一个jvm只有一个堆内存,堆也是java内存管理的核心区域,堆在jvm启动时候就被创建,并且大小也被确定了.堆可以处于物理上不连续的内存空间,不过在逻辑上它应该被视为连续的

数组和对象一般情况不会存储在栈上,因为局部变量表中存储的是引用,这个引用指向对象在堆中的位置

方法执行完后,堆中的对象不会立刻移出,仅仅在垃圾收集的时候才会被移出

设置堆空间(年轻代+老年代)的内存大小

  • -Xms 初始堆内存空间,等同于-XX:InitialHeapSize

  • -Xmx 最大堆内存空间,等同于-XX:MaxHeapSize

-X指jvm运行参数,ms指memory start,mx指memory max

默认堆空间大小为物理内存大小的1/64,最大堆空间大小为物理内存大小的1/4

-Xms600m -Xmx600m

获取JVM参数

long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M"); // 575M
System.out.println("-Xmx : " + maxMemory + "M"); // 575M

Thread.sleep(Integer.MAX_VALUE);

jps用于显示当前系统中所有正在运行的Java进程的相关信息

jstat -gc 显示指定pid的JVM的垃圾回收状况

为了方便浏览,此处将多余返回内容去除了

$ jps
27376 HeapSpaceInitial

$ jstat -gc 27376
 S0C    S1C    S0U    S1U      EC       EU        OC         OU 
25600.0 25600.0  0.0    0.0   153600.0 12288.5   409600.0    0.0

jstat命令返回的信息解析

  • OC 老年代容量, OU老年代使用量

  • EC 伊甸园区容量,EU 伊甸园区使用量

  • S0C 幸存者0区容量, S0U 幸存者0区总量

  • S1C 幸存者1区容量, S1U 幸存者1区总量

通过计算将OC、EC、S0C和S1C加起来结果为614400除以1024结果为600m,和-Xmx中配置的结果一致

但是实际上内存中S0C和S1C只有一个会参与存储,所以只有575m

jvisualvm

可以通过这个可视化工具看到Eden Space是150M,Survivor 0 是25M,Old Gen是400M总共575m

分代理论

现代垃圾收集器大部分都基于分代收集理论,堆空间分为

  • 新生代

  • 老年代

  • 元空间(jdk7之前为永久区)

元空间不存储在堆中

分代的唯一理由就是优化GC性能,如果没有分代而是将所有的对象放在一起,这样每次都会将整个堆扫描一遍.而很多对象都是朝生夕死,如果分代把新创建的对象放在一起,当GC时优先对这部分区域进行回收,则会腾出很大的空间

以下命令可以打印GC执行细节

可以查看每次的GC日志,JVM执行结束时还会显示最终的堆内存使用情况

-XX:+PrintGCDetails

执行代码后查看控制台,在jdk1.7以及之前显示的是PSPermGen,而1.8以后是Metaspace

Runtime.getRuntime().totalMemory()一样,此处PSYoungGen的总量为伊甸园区加上幸存者区0区(或1区)

Heap
 PSYoungGen      total 179200K, used 12288K [0x00000007b3800000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 153600K, 8% used [0x00000007b3800000,0x00000007b4400058,0x00000007bce00000)
  from space 25600K, 0% used [0x00000007be700000,0x00000007be700000,0x00000007c0000000)
  to   space 25600K, 0% used [0x00000007bce00000,0x00000007bce00000,0x00000007be700000)
 ParOldGen       total 409600K, used 0K [0x000000079a800000, 0x00000007b3800000, 0x00000007b3800000)
  object space 409600K, 0% used [0x000000079a800000,0x000000079a800000,0x00000007b3800000)
 Metaspace       used 3076K, capacity 4620K, committed 4864K, reserved 1056768K
  class space    used 324K, capacity 392K, committed 512K, reserved 1048576K

新生代和老年代

存储在JVM中的对象分为两类

  • 生命周期较短的瞬时对象,这类对象创建和消亡都较为迅速

  • 生命周期更长,在某些情况下甚至和JVM保持一致

大部分对象都是在Eden中被创建的,除非这个对象被创建时Eden就存储不下了

堆内存中的结构

默认新生代占1,老年代占2,新生代占整个堆的1/3

一般开发中不会修改这个参数,除非项目中有明显大量的生命周期较短或较长的对象需要存储在堆中

可以通过以下参数修改,年轻代占1,老年代占4,年轻代占整个堆的1/5

-XX:NewRatio=4

查看JVM的参数设置

$ jps
42937 EdenSurvivorTest

$ jinfo -flag NewRatio 42937
-XX:NewRatio=2

在HotSpot中Eden和另外两个Survivor所占的默认比例为6:1:1

设置Eden和另外两个Survivor所占的比例为8:1:1

-XX:SurvivorRatio=8

也可以直接设置年轻代的空间,如果和NewRatio冲突时,以-Xmn为准

-Xmn100m

垃圾收集

对象分配详解

  1. 对象的创建一般放入伊甸园区

  2. 当伊甸园区满后如果JVM还需要创建对象,垃圾收集器将会对伊甸园区进行Minor GC(或称为Young GC),将伊甸园区中不再被引用指向的对象销毁,再将新对象放入伊甸园区

只有Eden区满了才会触发Minor GC,当触发Young GC时,会将Eden区和Servivor区一起回收

Minor GC会引发STW,暂停其它用户线程,等待垃圾回收结束,用户线程才会恢复运行

  1. 将这次GC后伊甸园区中的剩余对象转移到幸存者0区

垃圾回收

  1. 如果再次触发GC,将上次幸存下来的对象全部放入幸存者0区(如果幸存者0区有对象则放入幸存者1区)

两个Servivor区一定有一个是空的,那个空的就是to区,另一个就是from区

垃圾回收2

  1. 当一个对象每次GC都不被回收,累计到一定次数将放入老年区

垃圾回收最大晋升到老年代的阈值默认15次,可以通过以下参数设置

-XX:MaxTenuringThreshold=10

有几种情况直接分配到老年代

  • 创建的对象过大在GC后依然无法移入Eden区或Servivor区,则该对象会被直接移入老年代

  • 对象动态年龄判断 Servivor区中相同年龄的对象数量已经达到Survivor空间一半,年龄大于或等于该年龄的直接进入老年代

幸存者区中使用的是复制算法,0区和1区对象互相转移太过于消耗性能,并且已经积累了一定数量,直接将他们晋升到老年代中

  • 内存分配担保 老年代最大的连续空闲空间和年轻代所有对象的内存大小(或历次晋升到老年代的平均对象大小做比较).如果这个条件成立,那么虚拟机可以保证Minor GC 可能是安全的,因为存活的对象可以放入老年代中,而老年代的可用空间足够容纳它们

幸存者区采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代

老年代得有足够空间来容纳这些对象(老年代要进行空间分配担保),但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考

使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间

  1. 当老年代内存不足时会触发Full GC,如果老年代执行了Full GC后内存依然不足,则会产生OOM

java.lang.OutOfMemoryError: Java heap space

通过VisualVM也可以看出

  • Eden区每次GC可以看到明显的波峰

  • Survivor区使用的是复制算法,两个区域内存互相交替

  • 每次GC年轻代的对象都会被移入老年代,所以老年代的占用是逐步上升的,达到顶峰后OOM

  • 元空间存储的是类数据,对象不会被移入,所以比较平稳

GC分类

JVM在进行GC时,并非每次都是对新生代、老年代、元空间(方法区)一起回收

针对HotSpot VM的实现,GC分为部分收集和整堆收集Full GC

  • 部分收集,不是完整收集整个java堆,其中分为

    • Minor GC (或Young GC)新生代收集

    • Mixed GC 混合收集 收集整个新生代以及部分老年代的垃圾收集 (目前只有G1 GC 有混合收集的行为)

    • Major GC 老年代收集 (目前只有CMS GC 有单独收集老年代的行为)

  • 整堆收集Full GC 收集整个java堆和元空间(方法区)的垃圾收集

垃圾收集日志

GC主要参数

-XX:+PrintGC           # 和-verbose:gc类似,输出GC简易日志
-XX:+PrintGCDetails    # 输出GC的详细日志
-XX:+PrintGCTimestamps # GC时打印进程启动到现在经历的时间
-XX:+PrintGCDatestamps # GC时打印当前时间
-XX:+PrintGCApplicationStoppedTime # 输出GC时应用停顿时间
-XX:+PrintGCApplicationConcurrentTime # 输出GC时打印应用执行时间
-XX:+PrintHeapAtGC     # 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log # 日志文件的输出路径(开启该指令可能会导致控制台输出的gc失效)

通过-XX:+PrintGCDetails查看GC日志

[GC (GC原因) [GC区域: 年轻代回收前->年轻代回收后(年轻代总大小)] 年轻代和老年代前->年轻代和老年代后(年轻代和老年代总大小), GC耗时] [Times: user=用户态耗时 sys=系统态耗时, real=实际耗时]

其中年轻代的大小是实际大小的9/10 (复制算法中减去空余的内存1/10)

user 此次gc用户执行的代码执行的cpu时间

sys 此次gc内核执行系统调用或等待系统事件的时间

real gc事件完整的耗时,因为并发的缘故所以user+sys会小于real,如果user+sys大于real则可能io负载过重或cpu不够调度

[GC (Allocation Failure) [PSYoungGen: 3702K->496K(4608K)] 3702K->1830K(15872K), 0.0016111 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 4408K->128K(4608K)] 5742K->3766K(15872K), 0.0031306 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 3275K->0K(4608K)] [ParOldGen: 9782K->6807K(11264K)] 13058K->6807K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0052063 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 3149K->3149K(4608K)] 9957K->13029K(15872K), 0.0003558 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 3149K->0K(4608K)] [ParOldGen: 9879K->9879K(11264K)] 13029K->9879K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0017378 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] 9879K->9879K(15872K), 0.0002345 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] [ParOldGen: 9879K->9820K(11264K)] 9879K->9820K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0038914 secs] [Times: user=0.02 sys=0.01, real=0.00 secs] 
Heap
 PSYoungGen      total 4608K, used 160K [0x00000007bfb00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 4096K, 3% used [0x00000007bfb00000,0x00000007bfb28398,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
  to   space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
 ParOldGen       total 11264K, used 9820K [0x00000007bf000000, 0x00000007bfb00000, 0x00000007bfb00000)
  object space 11264K, 87% used [0x00000007bf000000,0x00000007bf997288,0x00000007bfb00000)
 Metaspace       used 3391K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 386K, committed 512K, reserved 1048576K
java.lang.OutOfMemoryError: Java heap space

详解

[GC (Allocation Failure) [PSYoungGen: 3702K->496K(4608K)] 3702K->1830K(15872K), 0.0016111 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
  • GC (Allocation Failure) 指的是伊甸园区内存分配失败,准备Minor GC

  • [PSYoungGen: 3702K->496K(4608K)] 新生代的内存占用变化和新生代内存总量

  • 3702K->1830K(15872K) 堆内存的内存占用变化和堆内存的总量

  • 0.0016111 secs GC花费的时间

[Full GC (Ergonomics) [PSYoungGen: 3275K->0K(4608K)] [ParOldGen: 9782K->6807K(11264K)] 13058K->6807K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0052063 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
  • Full GC (Ergonomics) 执行一次Full GC

    • Ergonomics 堆内存满了导致Full GC

    • Metadata GC Threshold 方法区满了导致Full GC

  • [ParOldGen: 9782K->6807K(11264K)] 老年代的内存变化情况,老年代的总量

  • [Metaspace: 3360K->3360K(1056768K)] 元空间的内存变化情况,元空间的总量

[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(4608K)] [ParOldGen: 9879K->9820K(11264K)] 9879K->9820K(15872K), [Metaspace: 3360K->3360K(1056768K)], 0.0038914 secs] [Times: user=0.02 sys=0.01, real=0.00 secs] 
  • Full GC (Allocation Failure) 执行一次Full GC内存分配失败,可以看到老年代大小几乎没有变化,OOM

日志分析工具

使用-Xloggc:./logs/gc.log记录日志

可以使用gceasy,通过官网导入gc.log

其他工具gcviewer、GCHisto、HPjmeter

TLAB

堆是线程共享的区域,任何线程都可以访问到堆中的数据,由于对象实例的创建在JVM中十分频繁,因此在并发环境下从堆中划分内存空间是线程不安全的

为避免多个线程同时操作同一地址,需要使用锁等机制,进而影响分配速度的

从内存模型而不是垃圾收集的角度,对Eden区域进行了划分,JVM为每个线程分配了一个私有的缓存区域TLAB(thread lock allocation buffer)

多线程同时分配内存时,使用TLAB可以避免一系列非线程安全问题,同时还能提升内存分配的吞吐量,因此可以将这种内存分配方式称为快速分配策略.尽管不是所有对象都能在TLAB中成功分配内存,但是JVM确实将TLAB作为内存分配的首选

每个线程都会从Eden分配一块空间作为自己的TLAB标识出 eden 里被这个 TLAB 所管理的区域,让eden里的一块空间不让其它线程来这里分配

可以通过以下命令关闭TLAB,TLAB默认开启

-XX:-UseTLAB

TLAB默认情况下占用非常小,仅占整个Eden空间的1%,可以通过以下命令设置Eden空间占用的百分比

-XX:TLABWasteTargetPercent=2

一旦对象在TLAB空间分配内存失败,JVM就会在Eden区中分配内存,并且通过使用指针碰撞来分配内存

指针碰撞 堆内存被一个指针一分为二.指针的左边都被塞满了对象.指针的右变是未使用的区域,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针偏移量将新对象分配在内存空闲位置上

逃逸分析

通过逃逸分析,编译器会在运行时或编译期分析出一个新的对象的引用使用的作用域从而决定是否要执行优化

逃逸分析就是分析对象动态作用于

  • 一个对象在方法中被定义,但却被该方法以外的其他方法使用

  • 一个对象由某个线程在方法中被定义,但却被其他线程访问

jdk7以后HotSpot默认开启逃逸分析,如果使用更早版本则需要以下命令开启

-XX:+DoEscapeAnalysis

可以使用以下命令查看逃逸分析的筛选结果(可能需要dev版本的jvm才可以使用)

VM option 'PrintIdealGraphLevel' is develop and is available only in debug version of VM.

-XX:+PrintEscapeAnalysis

只有服务器端才可以使用逃逸分析

如果使用的是32位jvm需要开启服务器端模式,可以通过java -version查看

-server

缺点

逃逸分析这项技术十分不成熟,根本原因无法保证逃逸分析的性能消耗一定高于优化前的性能,逃逸分析自身也需要进行一系列的复杂分析,这也是一个相对耗时的过程.例如经过逃逸分析后,发现没有对象是不逃逸的,那么逃逸分析的过程就被浪费了

通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,但这取决于JVM设计者的选择,Hotspot虚拟机并未这么做, 也就是说至少Hotspot虚拟机的对象全部是在堆里分配的

栈上分配

注意,Hotspot虚拟机并未实现栈上分配,性能的提高是因为标量替换的实现

在JVM中,对象是在堆中分配内存的是一个普遍常识.但是也有特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话那就可能被优化为栈上分配,这样就无需在堆上分配内存,也无需进行垃圾回收了

开发中能使用局部变量的情况就尽量不要在传递到方法外

// sb逃逸
public StringBuffer create(String s1, String s2) {
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.sppend(s2);
  return sb;
}
// sb未逃逸,作用域完全在方法内
public String create2(String s1, String s2) {
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.sppend(s2);
  return sb.toString();
}

测试

以下代码如果使用了-XX:+DoEscapeAnalysis,则会执行的更快并且在堆内存中只会分配少量User对象,并且不会GC

而-XX:-DoEscapeAnalysis关闭了栈上分配,则运行时间更长,堆内存中分配了大量User对象,并且还会触发GC

public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费时间为: " + (end - start) + " ms ");

        TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
    }

private static void alloc() {
    User user = new User();
}

static class User {}

同步省略

一个对象被发现只能从一个线程被访问,这个对象的操作即可不考虑同步.因为线程同步的代价是相当高,会降低并发性和性能

在动态编译同步块时,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其它线程.如果没有JIT编译器在编译这个同步块时就会取消这部分代码的同步,这样就能提高性能,这个操作就叫同步省略或锁消除

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

以上代码会被优化为

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

并且同步省略是在运行时进行的,所以分析字节码文件依然存在monitorenter和monitorexit关键字

 0 new #6 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init> : ()V>
 7 astore_1
 8 aload_1
 9 dup
10 astore_2
11 monitorenter
12 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
15 aload_1
16 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/Object;)V>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return

标量替换

一个对象可以不需要作为一聚合量可以被访问到,那么该对象的会被拆解成标量并存储在栈中

无法被分解成更小的数据被称为标量,java中基本数据类型就是标量

还可以被分解的被称为聚合量,java对象就是聚合量

可以通过以下命令关闭标量替换,默认开启

-XX:-EliminateAllocations

通过逃逸分析发现一个对象不会被外界访问,那么经过JIT优化,就会把该对象拆解成若干个成员变量来替代,这就是标量替换

public static void alloc() {
  new Point(1, 2);
}

static class Point {
  private int x;
  public int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

以上代码会被优化为

可以看到Point这个聚合量通过逃逸分析分析没有逃逸后,就替换成两个标量了

标量替换为栈上分配提供了很好的基础

public static void alloc() {
  int x = 1;
  int y = 2;
}

方法区

方法区在逻辑上属于堆的一部分,但是可能不会选择进行垃圾回收或压缩,对于Hotspot而言,方法区还有一个别名叫做非堆(Non-Heap)以便区分

方法区在JVM启动时创建,和堆一样是各个线程共享的内存区域,并且和堆一样实际的物理内存可以是不连续的.方法区的大小决定了JVM可以加载多少个类,如果加载了大量的类会导致方法区溢出

永久代

jdk7及之前,习惯将方法区称为永久代

java.lang.OutofMemoryError: PermGen space
# 或者控制台会显示以下信息,虚拟机立刻停止,异常无法被捕捉
A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x00007ff8120a9ce3, pid=78839, tid=7171
# 以下省略

可以通过以下参数设置永久代的可分配内存空间和最大可分配空间

-XX:PermSize=21.75m
-XX:MaxPermSize=82m

元空间

jdk8之后元空间取代了永久代

java.lang.OutofMemoryError: Metaspace
java.lang.OutOfMemoryError: Compressed class space

可以通过以下参数设置元空间的可分配内存空间和最大可分配空间

MetaspaceSize是初始的高水平线,一旦触及这个水平线,Full GC就会被触发并卸载没用的类以及类对应的类加载器,然后高水平线就会被重置,新的高水平线的值取决于GC后释放了多少元空间,如果释放的空间不足应该提高MetaspaceSize,如果释放的过多则应该适当降低该值

-XX:MetaspaceSize=21m
-XX:MaxMetaspaceSize=18446744073709535232

整个永久代有JVM本身设置的固定大小上限无法进行调整,导致JVM更容易OOM.而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小

存储内容

方法区存储已被虚拟机加载的类型信息、常量、静态变量、JIT即时编译器编译后的代码缓存等

有以下代码

public class MethodInnerStructTest extends Object implements Comparable<String>, Serializable {
    public int num = 10;
    private static String str = "测试内部结构";

    public void test1() {
        int count = 20;
        System.out.println("count = " + count);
    }

    public static int test2(int cal) {
        int result = 0;
        try {
            int value = 30;
            result = value / cal;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    @Override
    public int compareTo(String o) {
        return 0;
    }
}

可以通过javap -v -p指令查看字节码文件反编译,并整理出以下信息,整理的代码只是javap命令的片段式例

类型信息

对于加载的类型(class、interface、enum、annotation),JVM必须在方法区存储以下类型信息

  • 该类型的完整有效名称

  • 该类型直接父类的完整有效名

  • 该类型的修饰符(public、abstrat、final等)

  • 该类型的直接接口的有序列表(接口可以有多个)

public class test7.MethodInnerStructTest extends java.lang.Object implements java.lang.Comparable<java.lang.String>, java.io.Serializable
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER

域(Field)信息

JVM在方法区中保存类型的相关信息以及域的声明顺序

域信息包括: 域名称、域类型、域修饰符(public、final、static、volatitle、transient等)

public int num;
descriptor: I
flags: ACC_PUBLIC

private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC

方法信息

JVM在方法区中必须保存方法的以下信息,同域信息一样包含声明顺序

  • 方法名称

  • 方法返回类型(包括void)

  • 方法修饰符(public、final、static、native、abstrat等)

  • 方法的字节码、操作数栈、局部变量表及大小(abstrat、native方法除外)

  • 异常表(abstrat、native方法除外) 每个异常处理的开始位置、结束位置、代码处理在程序计数器的偏移地址、被捕获异常的常量池索引

  public static int test2(int);
    descriptor: (I)I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: bipush        30
         4: istore_2
         5: iload_2
         6: iload_0
         7: idiv
         8: istore_1
         9: goto          17
        12: astore_2
        13: aload_2
        14: invokevirtual #12                 // Method java/lang/Exception.printStackTrace:()V
        17: iload_1
        18: ireturn
      Exception table:
         from    to  target type
             2     9    12   Class java/lang/Exception

运行时常量池

常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息以外,还包含一项常量池,包括各种字面量和对类型、域、方法的符号引用

已以下代码为例

public void test1() {
  int count = 20;
  System.out.println("count = " + count);
}

对应的字节码常量池

常量池可以看作一张表,虚拟机指令根据这张常量池表找到要执行的类名、方法名、参数类型、字面量等类型

Constant pool:
   #1 = Methodref          #18.#52        // java/lang/Object."<init>":()V
   #2 = Fieldref           #17.#53        // test7/MethodInnerStructTest.num:I
   #3 = Fieldref           #54.#55        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Class              #56            // java/lang/StringBuilder
   #5 = Methodref          #4.#52         // java/lang/StringBuilder."<init>":()V
   #6 = String             #57            // count =
   #7 = Methodref          #4.#58         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #8 = Methodref          #4.#59         // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
   #9 = Methodref          #4.#60         // java/lang/StringBuilder.toString:()Ljava/lang/String;
  #10 = Methodref          #61.#62        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #11 = Class              #63            // java/lang/Exception
  #12 = Methodref          #11.#64        // java/lang/Exception.printStackTrace:()V
  #13 = Class              #65            // java/lang/String
  #14 = Methodref          #17.#66        // test7/MethodInnerStructTest.compareTo:(Ljava/lang/String;)I
  #15 = String             #67            // 测试内部结构
  #16 = Fieldref           #17.#68        // test7/MethodInnerStructTest.str:Ljava/lang/String;
  #17 = Class              #69            // test7/MethodInnerStructTest
  #18 = Class              #70            // java/lang/Object
  #19 = Class              #71            // java/lang/Comparable
  #20 = Class              #72            // java/io/Serializable

对应的字节码指令为

0: bipush        20
2: istore_1
3: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
6: new           #4                  // class java/lang/StringBuilder
9: dup
10: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
13: ldc           #6                  // String count =
15: invokevirtual #7                  // Method java/lang/StringBuilder.append: (Ljava/lang/String;)Ljava/lang/StringBuilder
18: iload_1
19: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
22: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: return

具体可以参照动态链接

此图只对偏移地址为3的字节码指令做了图解

有#的,使用的都是在常量池中存放

运行时常量池

运行时常量池是方法区的一部分,而上述常量池表(字节码指令中的Constant Pool Table)是字节码文件的一部分,用于存放编译期生成的各种字面量和符号引用,这些内容将在类加载后存放到方法区中的运行时常量池

运行时常量池

运行时常量池在加载类和接口到虚拟机后就会创建和维护对应的常量池,池中的数据通过索引访问

运行时常量池中包含多种不同常量,包括编译期就已经明确的字面量和运行期解析后才能够获得的方法或者字段引用,此时就不再是常量池中的符号地址(索引),而是真实的内存地址

运行时常量池具备动态性

String str = "intern";
String intern = str.intern();

方法区演进

HotSpot才有永久代,对于JRocket和J9等虚拟机是不存在永久代的概念的.原则上来说如何实现方法区属于虚拟机的实现细节,不受java虚拟机规范约束

Hotspot方法的的变化

jvm版本

改变细节

jdk1.6及之前

方法区实现的完全是永久代,并且静态变量在此时是存放在永久代中

jdk1.7

开始去除永久代,字符串常量池、静态变量移入堆中存储

jdk1.8及之后

完全移除永久代,将方法区实现改为元空间,类型信息、字段、方法、常量保存在元空间中,但是字符串常量池、静态变量依然存储在堆中

只要是new创建的对象全部都是存放在堆里的,但是对于的变量存储的位置不同

  • 非静态成员变量存储在堆中

  • 静态成员变量在1.6及之前存储在永久代中,之后存储在堆中

  • 方法中的变量存储在局部变量表中

public class StaticObjTest {
    static class Test {
        static ObjectHolder objectHolder = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();

        void foo() {
            ObjectHolder localObj = new ObjectHolder();
            System.out.println("done");
        }
    }

    private static class ObjectHolder {}
}

替换原因

jdk1.8取消永久代,并且将其中的数据移动到了与堆不相连的内存区域,也就是元空间

  • 为永久代设置空间大小是很难确定的

使用永久代的默认值往往很容易导致OOM,如果设置了过大很容易导致资源的浪费

由于类的元数据直接分配在内存中,所以元空间的最大可分配空间就是系统最大可分配的内存空间

然而元空间的大小仅受本地内存限制

  • 降低GC复杂度

永久代会为GC带来不必要的复杂性,并且回收效率偏低,在永久代中元数据可能会随着每一次GC发生而进行移动,而hotspot虚拟机每种类型的垃圾回收器都要特殊处理永久代中的元数据,分离出来以后可以简化GC

字符串常量池

jdk7中将StringTable放在了堆空间里(在此之前是放在永久代中),这是因为永久代的回收效率较低,只有在full GC的时候才会触发,这就导致了StringTable的回收效率不高,然而开发中会创建大量的字符串,最终导致OOM

所以放在堆内存中就能及时回收

方法区的垃圾收集

java虚拟机规范对方法区的约束是非常宽松的,可以不要求在方法区中实现垃圾收集,事实上也确实有未实现或者未完整实现方法区类型卸载的收集器存在(如jdk11的zgc就不支持类的卸载)

方法区的回收效果非常难以令人满意,尤其是类型的卸载,条件相当苛刻.但是这个区域的回收又是有必要的,方法区的垃圾收集主要分为两部分

废弃的常量

Hotspot回收方法区中的常量和java堆中的对象非常相似,只要常量池中的常量没有在任何地方被引用就可以被回收

不再使用的类型

如何判断一个类型是否属于不在被使用的类,条件比较苛刻,需要同时满足以下条件

  • 该类和该类的子类所有实例对象都已经被回收

  • 加载该类的类加载器全部被回收,这个条件除非是经过精心设计的可替换类加载器的场景(如OSGI、JSP的重加载),否则通常很难达成

  • 该类的类对象没有被任何地方被引用,不能在任何地方通过反射访问该类的方法

关于是否要对类型进行回收hotspot以下参数

-Xnoclassgc               # 关闭堆类的垃圾回收行为
-verboseLclass            # 查看类型加载和卸载信息
-XX:+TraeClass-Loading
-XX:+TraceClassUnLoading 

在大量通过反射、动态代理,通常都需要jvm有卸载类的能力,以保证不会堆方法区操作过大的内存压力

本地方法接口

本地方法就是一个java调用非java代码的接口.在定义本地方法时,并不需要提供实现体,该方法的实现由非java语言实现

这个特征并非java特有,很多其他语言也有这一机制,例如c++使用extern "C" 告诉c++编译器去调用一个c的函数

JVM支持java语言本身和运行时库,是java程序赖以生存的平台,它由解释器和本地代码库组成,然而它并不是一个完整的系统,通过本地方法,使得java实现了jre与底层系统交互.如果要使用一些java语言本身没有提供的操作系统特性时,也需要使用本地方法

对象的实例化

实例化过程

  1. 判断对象对应的类是否加载、链接、初始化

虚拟机执行new指令,会去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否类元信息是否存在,如果没有,则会在双亲委派机制下使用当前类加载器以ClassLoader加权限定类名的形式找到对应的字节码文件

  1. 分配内存

计算对象占用的内存空间大小,并在堆中划分一块区域给该对象

  • 此时如果内存规整则使用指针碰撞的方式来分配内存

  • 如果内存不规整,则虚拟机还需要维护一个空闲列表来记录哪些内存空间时可用的

选择哪种分配方式由堆是否规整所决定,而堆是否规整由所采用的垃圾收集器是否带有压缩整理决定

  1. 处理并发问题

采用CAS、区域锁来保障更新的原子性,以及为每个线程分配一块TLAB

  1. 初始化分配的内存

初始化对象的默认值,确保对象的实例字段有默认值

  1. 设置对象的对象头

将该类的元数据信息、对象的哈希值、GC相关信息、锁信息等数据存储在对象头中

  1. 执行init方法进行初始化

初始化成员变量、执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量

new指令之后会接着就是执行方法(对应字节码指令是invokespecial),这样一个真正可用的对象才算完成创建出来

实例

public class Customer {
    int id = 998;
    String name;
    Account account;

    {
        name = "test";
    }

    public Customer() {
        account = new Account();
    }
}

class Account {}

对应字节码指令

为初始化分配的内存的对象赋值(id和name)

只有在调用构造方法的时候该对象才被真正创建出来并赋值(Account)

 0 aload_0
 1 invokespecial #1 <java/lang/Object.<init> : ()V>
 4 aload_0
 5 sipush 998
 8 putfield #2 <test8/Customer.id : I>
11 aload_0
12 ldc #3 <test>
14 putfield #4 <test8/Customer.name : Ljava/lang/String;>
17 aload_0
18 new #5 <test8/Account>
21 dup
22 invokespecial #6 <test8/Account.<init> : ()V>
25 putfield #7 <test8/Customer.account : Ltest8/Account;>
28 return

内存布局

对象头

对象头主要包含两部分,分别是运行时元数据和类型指针.如果是数组还会记录数组的长度

运行时元数据包含: 哈希值、GC分代年龄、锁状态标志(同步的时候是否是一个锁)、线程持有的锁、偏向线程ID、偏向时间戳

类型指针 指向元数据InstanceKlass,确定该对象所属的类型

实例数据

实例数据时对象真正存储的有效信息,包括程序代码中定义的各种类型的字段

  • 相同宽度的字段总是被分配在一起的

  • 父类中定义的变量总会出现在子类之前

  • CompactFields参数为true(默认为true)子类的窄变量可能插入到父类变量的空隙

public class Customer {
    int id = 1001;
    String name;
    Account account;

    {
        name = "zhangsan";
    }

    public Customer() {
        account = new Account();
    }
}

class Account {}

测试代码

public static void main(String[] args) {
  Customer cus = new Customer();
}

内存布局

对象的访问定位

由于reference类型在虚拟机规范里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种

  • 使用句柄访问 java堆会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据给自具体的地址信息

使用句柄访问的好处是reference存储的是稳定的句柄地址,在对象移动(例如垃圾收集时移动对象)只需要改变句柄的实例数据指针,而reference本身不需要被修改

  • 使用直接指针 java堆中的对象的内存布局就需要考虑存储类元信息,reference中存储的就是直接的内存地址

由于对象访问在Java中非常频繁,因此这类开销也是一项极为可观的执行成本,所以主流虚拟机大多使用直接指针的方式访问对象

相比于句柄池,如果访问对象的话就无需间接访问带来的开销

内存访问

直接内存

直接内存并不是虚拟机运行时数据区的一部分,更不是java虚拟机规范中定义的内存区域.直接内存实在java堆外、直接向系统申请的内存区域,来源于nio(Non-Blocking IO),通过存在堆中的DirectByteBuffer操作Native内存,访问直接内存的速度会优于堆,即读写性能高

  • 出于性能考虑,读写频繁的场合可能会考虑使用直接内存

  • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

在JDK1.4中加入NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在堆中的DirectBuffer对象作为这块内存的引用进行操作.这样能显著提高性能,因为避免了在Java堆中来回复制数据

内存溢出

直接内存的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值一致

有直接内存的溢出的明显特征就是在Heap Dump文件中不会看见什么明显的异常情况

有些情况直接内存溢出会导致jvm直接关闭,没有任何异常

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory 

直接内存的缺点

  • 分配回收成本较高

  • 不受jvm内存回收管理(此处的直接内存不包括元空间)

指定直接内存大小,如果不指定默认和堆内存最大值一致

-MaxDirectMemorySize

执行引擎

执行引擎是java虚拟机的核心组成部分之一, "虚拟机"是一个相对于"物理机"的概念,他们都要代码执行能力,其区别是物理机的执行是建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是有软件实现的,因此可以不受物理条件制约地定制指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

执行引擎的任务就是将字节码指令解释或者编译为对应平台的本地机器指令

所有jvm的执行引擎输入的都是字节码二进制流,输出的是执行结果

  • 执行引擎在执行过程中究竟需要执行什么样的字节码指令完全依赖程序计数器

  • 每当执行完一项指令后,程序计数器就会更新下一条需要被执行的指令地址

  • 当方法在执行过程中,执行引擎有可能通过存储在局部变量表中的对象引用准确的定位到存储在堆区对象的实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

编译和执行过程

解释器和编译器

  • 解释器 当jvm虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码中的内容翻译为对应平台的本地机器码执行

  • JIT(Just In Time)编译器 虚拟机将源代码直接编译为本地机器平台相关的机器语言

# 完全采用解释器执行
-Xint
# 完全采用即时编译器执行,不过如果后段编译器出现问题,解释器会介入
-Xcomp
# 解释器和即时编译器混合
-Xmixed

jdk1.0时,将java是解释执行的,后来java也发展出可以直接生产本地代码的编译器,jvm在执行代码时通常会将解释执行和编译执行相结合

java的编译期其实是一段不确定的过程

  • 前端编译器 将java文件编译为字节码文件,如javac和ecj(eclipse jdt)

  • 后端运行期编译器JIT 把字节码转换为机器码 (hotsport vm的c1、c2编译器)

  • 静态提前编译器AOT 直接把java文件转换为本地代码的过程(GCJ、Excelsior JET)

字节码

字节码是一种中间状态的二进制代码,它比机器码更加抽象,需要直译器转译后才能成功机器码.主要为了实现特定软件运行和软件环境,与硬件无关

字节码主要的实现方式是通过编译器将源码翻译为字节码,然后特定的虚拟机将字节码翻译为可以执行的指令

java源代码不可以被直接执行而需要翻译为对应的字节码指令(AOT就可以直接将源代码编译为本地代码)

  • 代价更大 相比于执行中间态的字节码,直接执行代价会更大

  • 跨平台性 使得程序在不同的计算机和操作系统上都能够运行

  • 兼容其他语言 jvm号称跨语言的平台,只要符合jvm规范的字节码文件不论他编译前为何种语言,都可以被正确执行

即时编译器

执行分类

hotspot vm是目前市面上高性能虚拟机的代表作之一,它采用解释器和即时编译器共存的架构,在jvm虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力选择最合适的方式来圈很编译本地代码的时间和直接解释执行代码的时间

既然hotspot已经内置了JIT即时编译器了,为何还需要使用解释器来"拖累"程序的执行性能

当程序启动后,解释器可以马上发挥作用,省去编译(字节码编译为本地代码)的时间,立即执行.但是如果编译器想要发挥作用,则需要花一定时间才能将代码编译为本地代码.

所以即使JRockit VM的执行会更加高效,但是程序在启动时间必须花费更长的时间编译.对于那些更看重启动时间的场景来说,或许就需要采用解释器和即时编译器并存的架构来换取平衡点.在这种模式下,jvm启动以后解释器可以立刻发挥作用,而不必等待编译器全部编译完成后才执行,这样可以省去不必要的编译省去时间

之后随着时间的推移,越来越多的热点代码编译为本地代码,程序获得越来越高的执行效率

机器在热机状态可以承受的负载要大于冷机状态,所以热机状态的流量进行切换,可能会使处于冷机状态的服务器因无法承载流量而假死

  • 热机状态 已经运行了一段时间,意味着已经有大量代码已经被编译为本地代码

  • 冷机状态 程序刚启动没多久

某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布.如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在

热点代码和探测方式

JIT编译器将字节码直接编译为本地代码,需要根据代码被调用执行的频率而定,关于需要被编译为本地代码的字节码,被称为热点代码,JIT编译器在运行时会针对频繁调用的热点代码进行优化,将其直接编译为对应平台的本地机器指令,以此提升程序的执行性能

热点代码指的是被多次调用的方法或者一个方法体内循环多次的循环体

由于这种编译方式发生在方法执行过程,因此被称为栈上替换OSR(On Stack Replacement)

目前hotspot采用的热点探测方式是基于计数器的热点探测,hotspot会为每一个方法都建立2种不同类型的计数器

  • 方法调用计数器 统计方法的调用次数

  • 回边计数器 统计循环体执行的循环次数 统计一个方法体内循环体代码执行的次数,在字节码种遇到控制流向后跳转的指令被称为回边,建立回边计数器的统计目的就是为了触发OSR

方法调用计数器和回边调用计数器

阈值在Client下时1500次,在Server模式下是10000次,超过这个阈值就会触发JIT编译

阈值计算方式是方法调用计数器和回边调用计数器之和

可以通过以下参数指定阈值

-XX:CompileThreshold

方法调用计数器统计的并不是方法调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数,当超过一点的时间限度任然不足与提交JIT编译,这个方法的调用次数就会减少一半,这段时间就被称为此方法统计的半衰周期

可以通过以下参数设置半衰周期,单位是秒

-XX:CounterHalfLifeTime

进行热度衰减的动作是虚拟机进行垃圾收集顺便进行的,可以使用以下参数关闭热度衰减

这样方法计数器的方法调用的绝对次数,这样只要系统运行时间足够长,大部分方法都可以编译为本地代码

-XX:-UseCounterDecay

jit即时编译器

JIT分类

Hotspot内置了两个JIT编译器分别为Client Compiler和Server Compiler(有时候会被称为C1和C2编译器)

可以使用参数显示的切换哪一种即时编译器

# 进行简单和可靠的优化,耗时短,可以达到更快的编译速度
-client
# 进行激进优化,但是耗时更长
-server

大部分情况下使用的都是Server Compiler,并且即使显示的切换为Client Compiler也会被JVM忽略

如果使用Server Compiler,这两个编译器会协同运作

Client Compiler的优化有

  • 方法内联 在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段,可以减少栈帧的生成,减少参数传递以及跳转过程

  • 去虚拟化 对唯一的实现类进行内联

  • 冗余消除 在运行期间把一些不会执行的代码折叠掉

Server Compiler的优化策略在逃逸分析为基础,基于逃逸分析有如下几种优化,参照运行时数据区的逃逸分析

  • 标量替换

  • 栈上分配

  • 同步省略

jdk10引入了全新的即时编译器Graal,编译效果追平Server Compile,不过目前带着实验标签,需要使用参数激活才能使用

-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler

AOT编译器

jaotc借助了Graal编译器,可以将java源代码直接转换为机器码,并存放在动态链接库中

即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程.而AOT编译指的则是在程序运行之前,便将字节码转换为机器码的过程

优点

  • Java虚拟机加载已经预编译成二进制库,可以直接执行.不必等待及时编译器的预热,减少Java应用给人带来第一次运行慢的不良体验

缺点

  • 破坏了java一次编译,到处运行的理念,必须为每个不同的硬件、操作系统编译对应的发行包

  • 降低了Java链接过程的动态性,加载的代码在编译器就必须全部已知

  • 目前只支持Linux X64 java base

StringTable

基本特征

java中8种基本数据类型和字符串,为了让他们运行过程更快,更节省内存空间,提供了常量池的概念,常量池属于jvm提供的缓存,String的常量池比较特殊

  • 直接使用双引号声明或使用String的intern方法创建String对象会直接存储的常量池中

  • 常量池中不会存储相同的字符串

  • 当对字符串重新赋值时,会重写指定内存区域赋值,不会使用原有的value进行赋值

通过IDEA的debug可以看到执行到第二个System.out.println();,String和char[]的数量就不在增加了

public static void main(String[] args) {
    System.out.println(); // 1161
    System.out.println("1"); // 1177,此处堆内存中还加载了 Buffer.clear 所以一次性加载了16个
    System.out.println("2"); // 1178
    System.out.println("3"); // 1179

    System.out.println(); // 1180
    System.out.println("1"); // 1180
    System.out.println("2"); // 1180
    System.out.println("3"); // 1180
} // 1180

java中完全相同的字符串字面量应该包含相通的Unicode字符序列并且必须是指向同一个String类实例

数据结构转变

字符串在jdk8以前由char数组 用于存储字符串,jdk9改成byte 官方文档

此前String存储在char数组每个字符占2个字节,字符串是堆空间中使用的主要组成部分,并且大部分String对象仅包含Latin-1字符,char只需要一个字节的存储,因此这些对象内部数组中有一半空间未被使用.所以将使用UTF-16的char数组转换为byte数组加编码标志字段

这样的设计导致了内存占用量显著减少,进而导致GC的次数减少,不过在某些极端下的轻微性能下降

通过coder字段来确定value数组中存储的是何种编码

private final byte[] value;
private final byte coder;

内存分配

字符串常量池是一个固定大小的Hashtable,如果数组长度不够就会导致Hash冲突,导致性能下降

可以使用以下参数设置,StringTable的长度

  • 在jdk1.6长度为1009,固定无法修改

  • 在jdk1.7长度默认为60013

  • 在jdk1.8长度默认为60013,并且无法低于1009

-XX:StringTablesize

位置调整

在jdk1.6之前,字符串常量池在永久代

在jdk1.7之后将字符串常量池的位置移动到堆中.不在分配在永久代,而是和其他对象一样分配在年轻代和老年代中,这样可以让更多的对象存储在堆内存中,这样调优时只需要针对堆内存大小即可

字符串拼接

常量与常量的拼接通过编译期优化,结果直接放在常量池

通过反编译可知String s1 = "a" + "b" + "c"; 等同于 String s1 = "abc";

通过反编译可知String s5 = "1" + s4; 等同于 String s5 = "123";

String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2); // true

String s3 = "123";
final String s4 = "23";
String s5 = "1" + s4;
System.out.println(s4 == s5); // true

字符串中只要有一个是变量,返回的结果就存储在堆中

如果拼接字符串前后出现了变量,则会在堆内存中创建

String s1 = "abc";
String s2 = "def";
String s3 = "abcdef";
String s5 = s1 + "def";
String s6 = "abc" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s6); // false
System.out.println(s3 == s7); // false
System.out.println(s5 == s6); // false
System.out.println(s5 == s7); // false
System.out.println(s6 == s7); // false

通过反编译可以得知,+是java提供的一个语法糖,只要拼接的字符串中存在变量就会是创建StringBuilder并调用append方法

此处的s3就相当于String s3 = new StringBuilder().append(s1).append(s2).toString();

在jdk1.5之前使用的是StringBuffer

String s1 = "a";
String s2 = "b";
String s3 = s1 + s2;

以下代码会创建6个对象

  • new StringBuilder()

  • "a"

  • new String("a")

  • "b"

  • new String("b")

  • StringBuilder的toString()创建的对象

String s4 = new String("a") + new String("b");

通过StringBuilder拼接字符串效率更高

String src = "";
for (int i = 0; i < 100000; i++) {
    src += "a"; // 每次拼接都会创建一个新的StringBuilder和String对象
}

StringBuilder sb = new StringBuilder(100000); // 指定数组长度避免多次扩容
for (int i = 0; i < 100000; i++) {
    sb.append("a"); // 只会往sb中追加,不会创建多余的StringBuilder
}

intern

intern是一个native方法

如果不是通过双引号声明的String对象,可以使用该方法,它会将字符串常量池中返回该字符串,若不存在则将该字符串放入常量池中并返回

public native String intern();

主动调用intern方法,会主动将常量池中还没有的字符串对象放入字符串常量池中,并返回此对象

String s1 = "def";
String s2 = "abc" + s1;
String s3 = s2.intern(); // 放入常量池中

System.out.println(s2 == s3); // true

intern方法就是确保字符串在内存中只有一份拷贝,这样可以节省内存空间,加速字符串操作的执行速度

当s.equals(t) 为真时,s.intern() == t.intern() 一定为真

图例

intern

StringTable存储在堆中,此图只是方便理解才拆分开来

需要使用intern方法时,建议使用该方法返回的字符串

String s1 = new String("abc").intern(); // new String("abc")随时会被回收
String s2 = new String("abc"); // new String("abc")不会被回收,直到超出作用域
String s3 = "abc";

System.out.println(s1 == s3); // true
System.out.println(s1 == s2); // false

不同版本的区别

jdk1.6

  • 如果字符串常量池中已经存在,则直接返回常量池中对象的地址

  • 如果不存在,则直接把该对象复制一份,放入字符串常量池中并返回字符串常量池中的地址

intern_jdk1.6

jdk1.7

  • 如果字符串常量池中已经存在,则直接返回常量池中对象的地址

  • 如果不存在,则会把对象的引用地址复制一份,放入字符串常量池中并返回串池中的引用地址

intern_jdk1.7

String s = new String("a") + new String("b");
s.intern();
String s2 = "ab";
System.out.println(s == s2); // jdk1.6 false jdk1.7以及之后 true

修改调用顺序

new String("a") + new String("b") 返回的"ab"在任何情况下都是存储在堆中的

通过字面量的形式声明的字符串都是存储在字符串常量池中

这时候才调用intern方法就已经没有意义了,因为字符串常量池中已存在

一个存储在堆中一个存储在字符串常量池中,所以内存地址一定不相同

String s = new String("a") + new String("b");
String s2 = "ab";
s.intern();
System.out.println(s == s2);  // 所有版本都为false

垃圾回收

可以通过以下命令查看字符串常量池占用情况

-XX:+PrintStringTableStatistics

输出结果

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :     37101 =    890424 bytes, avg  24.000
Number of literals      :     37101 =   2085776 bytes, avg  56.219
Total footprint         :           =   3456304 bytes
Average bucket size     :     0.618
Variance of bucket size :     0.491
Std. dev. of bucket size:     0.700
Maximum bucket size     :         4

配合-XX:+PrintGCDetails可知字符串常量池是被JVM单独维护起来的一个HashTable

jdk1.7中内部存储的字符串和其他堆内存中存储的引用一样存储在堆内存中的Eden中,随着GC后移入老年代中

这也体现了jdk1.6之前存放在永久代的一些问题

去重

许多大规模java程序遭到了性能瓶颈,在这些程序中,大约25%堆数据被String消耗,而这些String中有一半是重复的.从本质上来讲这是一种浪费,现在G1垃圾收集器中实现自动持续的重复String数据删除,避免内存浪费、减少内存占用

此处的重复显然指的不是字符串常量池中的String,因为字符串常量池中也不会存储重复的String对象

默认关闭,使用以下参数开启

开启后只有底层的char数组会被替换掉,并不会消除重复字符串引用本身

-XX:+UseStringDeduplication

默认是3次GC后才会被列为去重的对象,可以使用此参数指定经历GC次数

-XX:StringDeduplicationAgeThreshold=6

去重是否要在生产环境使用还需要综合考虑,因为有以下缺点

  • 只针对G1垃圾收集器上

  • 要有大量长期字符串,因为不会对短期的对象去重,如果生命周期过短可能还没来得及去重就已经被释放了

  • 只有在java8u20以后的版本才有此特性

  • 每次去重是在GC的时候进行的,所以会增加GC停顿时间

实现方式

  • 在进行垃圾收集的时候,会访问对堆上存活的对象并且会检查是否是候选的要去重的String对象

  • 如果符合则把这个对象的引用插入到等待队列中,一个去重的线程会在后台运行处理这个队列,处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象

  • 使用一个hashtable来记录所有的被String对象使用的不重复的char数组.当去重的时候,会查看这个hashtable,来看堆上是否已经存在一个一模一样的char数组

  • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉

  • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了

垃圾回收

相关概述

手动回收

在默认情况下调用System.gc()或者Runtime.getRuntime().gc()会显示的触发FullGC从所有丢弃的空间回收兑现,但是只是提醒jvm进行垃圾回收,无法保证对垃圾收集器的调用

可以通过手动调用触发来决定JVM的GC行为,但是一般情况下垃圾回收应该是自动进行的,无需手动触发.不过在一些特殊情况,例如编写一个性能基准测试,在执行前先触发一次GC,保证之后性能测试结果更精准

日常开发中不要使用System.gc()会导致STW

public void localGC1() {
    byte[] buffer = new byte[10 * 1024 * 1024];
    System.gc(); // buffer不会被释放
}
public void localGC2() {
    byte[] buffer = new byte[10 * 1024 * 1024];
    buffer = null;
    System.gc(); // buffer为null,会被释放
}
public void localGC3() {
    {
        byte[] buffer = new byte[10 * 1024 * 1024];
    }
    System.gc(); // 特殊情况,buffer不会被释放
}
public void localGC4() {
    {
        byte[] buffer = new byte[10 * 1024 * 1024];
    }
    int value = 10;
    System.gc(); // buffer会被释放
}

局部变量表是垃圾回收的根结点,虽然localGC3的buffer已经超过了作用域,但是还存在在局部变量表中所以不会被释放.localGC4的buffer会被释放,那是因为局部变量表的复用机制,变量value覆盖了buffer的slot,导致buffer不存在于局部变量表中,所以导致会被释放

可以通过添加-XX:PrintGCDetails来查看GC执行情况

内存溢出

当应用程序占用的内存增长速度快,造成垃圾回收已经跟不上内存消耗的速度则会造成内存溢出.在大部分情况下,GC会对各个年龄段的内存区域进行回收,直到最后一次独占式的FullGC还是无法申请到足够的内存,则会造成OutOfMemoryError(没有空闲内存并且垃圾收集器也无法提供更多的内存)

导致内存溢出的原因主要有二

  • JVM的内存空间设置不合理,可以通过-Xmx和-Xmx来调整

  • 创建了大量对象并且长时间不能被垃圾回收器收集

内存泄漏

内存中的对象不会被应用程序用到了,但是垃圾收集器又不能回收这些对象的时候就被称为内存泄漏.尽管内存泄漏并不会立刻引起应用程序崩溃,但是一旦发生内存泄漏,可用的内存就会被逐步蚕食,直到耗尽所有内存,最终更有可能发生内存溢出并导致应用程序的崩溃

一些不太好的代码会导致对象生命周期变得过长,也属于宽泛意义上的内存泄漏.如将可以是局部变量的变量写在成员变量上并使用static修饰

垃圾指的是运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

如果不对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法被其它对象使用,还会导致内存泄漏.并且垃圾回收也可以清除内存中的碎片,以便腾出空间分配给新的对象.随着应用程序所应付的业务越来越复杂,没有GC就不能保证程序的正常执行,但是不断GC也会频繁造成STW,所以JVM的开发者才会不断尝试堆GC进行优化

自动内存管理,可以使开发人员无需手动分配和回收内存,降低内存泄漏和溢出的风险,更专注于业务开发.不过如果开发人员过于依赖,则会弱化开发人员定位和解决内存异常问题的能力.所以必须采用一些技术来监控和调节程序

STW

stop-the-world(让整个世界停下来)指在GC发生的过程中会产生的应用程序的停顿,在停顿的产生时整个应用程序都会被暂停并且没有任何响应

通过可达性分析中枚举节点(GC Roots)会导致所有java执行程序的停顿,被STW中断的应用程序将会在完成GC之后恢复,频繁中断会让用户体验下降,所以要减少STW的发生

分析工作必须在一个能确保一致性的快照中进行,整个分析期间整个执行系统就像被冻结在这个时间点,如果出现分析过程中对象的引用关系还在不断的变化,则分析结果的准确性讲无法保证

所有垃圾回收器都有STW,不过现在的垃圾回收器效率越来越高,并且更加注重缩短暂停时间

并行与并发

了解并发需要了解时间片的概念,操作系统会将所有就绪的进程先来先服务的原则排成队列,每次调度时把cpu分配给队首进程并令其执行一个时间片(几十毫秒),当执行的时间片用完时,会有一个计时器中断请求,并且据此信号来暂时停止该进程的执行,然后在分配给队列中新的队首进程,同时也让它执行下一个时间片.这样就可以保证就绪队列中的所有进程,在一给定的时间段内均能获得一时间片的处理机执行时间

  • 并发是指一个处理器同时处理多个任务,只在逻辑上同时发生.在同一时刻只能有一条指令执行,但是多个进程被快速轮换执行,使得在一个时间段(宏观)上具有多个进程同时执行,但是在时间点(微观)上并不是同时执行的

  • 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务,而并行是物理上的同时发生.在同一时刻,有多条指令在多个处理器上执行,在时间段上观察还是在时间点上观察都是同时执行的

  • 串行是指一个处理器一次只执行一个任务,只有执行完当前任务以后才会执行下一个任务

只有在多核CPU或者多个CPU上才存在并行

以上概念对应的垃圾回收器

  • 并发 指用户线程与垃圾收集线程同时执行,垃圾回收线程在执行时不会停顿用户程序的运行

CMS、G1

  • 并行 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

ParNew、Parallel Scavenge、Parallel Old

  • 串行 单线程执行,如果内存不够则程序暂停,启动垃圾回收器进行垃圾回收,回收完再启动程序的线程

Serial、Serial Old

安全点

应用程序的执行并非所有的地方都是和停顿下了进行GC,只有在特定的位置才适合,这些位置被称为安全点

安全点的选择很重要,如果太少可能导致GC等待时间过长,如果太频繁则会导致性能问题.选择一些执行时间较长的指令时更适合作为安全点,如方法调用、循环跳转、异常跳转等,而在一些指令本身就执行的过于短暂的时候作为安全点反而会导致用户体验下降

检查所有线程都处于安全点的方式

  • 抢占式 首先主动过暂停所有线程,如果还有线程不在安全点就恢复线程,直到所有线程跑到安全点

  • 主动式 设置中断标志,各个线程运行到安全点的时候判断该标志,如果为真则将本线程挂起

但当线程执行处于休眠状态或阻塞状态时,此时线程无法响应中断请求,JVM也不可能等待线程被唤醒,此时就需要安全区域.安全区域指的是一段代码片中,对象的引用关系不会发生变化,在这个区域中任何位置进行GC都是安全的,可以把安全区域理解为扩展了的安全点

如果需要发生GC,JVM会忽略此时处于安全区域的线程,并且只有JVM完成GC后才被允许离开安全区域

引用

在jdk1.2之后对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度逐渐减弱

除了强引用外,其它的3种引用都能在java.lang.ref中找到

  • 强引用 代码中普遍存在的引用一般通过new关键字创建,只要强引用关系还存在垃圾回收器就不会回收掉被引用的对象

对于一个强引用的对象只要是可触及的,JVM宁可抛出OOM也不会回收强引用的对象,就不可能被垃圾回收器回收,只有超过了作用域范围或者显示的赋值为null才会被垃圾回收

强引用会导致内存泄漏,也是造成OOM的主要原因

  • 软引用 在虚拟机发生内存溢出之前,将会把这些对象放入回收范围之内进行二次回收(第一次回收指的是回收不可达对象)

软引用通常用来实现高速缓存,如果还存在空闲内存就暂存缓存,不足时再清理,这样保证了使用缓存的同时不会耗尽内存

当垃圾回收器觉得需要回收软可达的对象时,会清除软引用并可选的把引用存放到一个引用队列中(前提准备了引用队列到构造方法中)

// 声明强引用并传递给SoftReference对象
Object obj = new Object();
SoftReference<Object> objectSoftReference = new SoftReference<>(obj);
obj = null; // 最后只保留弱引用
  • 弱引用 被弱引用关联的对象只能存活到下一次垃圾回收之前,只要垃圾回收器工作就会回收弱引用的对象

弱引用和软引用类似,不过弱引用只能存活到下一次垃圾收集发生为止,不管内存是否充足都会将弱引用回收

不过垃圾收集器的线程通常优先级较低,因此并不能很快发现持有弱引用的对象,在这种情况下,弱引用的对象也可以存在较长时间

static Map<Object, Object> weakHashMap = new WeakHashMap<>();

public static void main(String[] args) {
     weakHashMap.put("test", new Object());
     Object test = weakHashMap.get("test");
 }

相比于弱引用,软引用可能会导致内存泄漏,只有在内存不足才会被回收,所以软引用何时回收难以预测,并且jvm需要花费额外的开销来跟踪对象的引用情况.所以在缓存的角度上使用弱引用的场景更多

  • 虚引用 虚引用完全不会对其生存构成影响,也无法通过虚引用获得对象的实例,为一个对象设置虚引用关联的唯一目的就是能在垃圾收集器收集时获得一个系统通知

虚引用完全不会决定对象的什么周期,通过get方法获取对象时永远是null

和其它的Reference的子类不一样的是,创建虚引用必须要传递引用队列.当垃圾收集器准备回收一个对象时,如果发现它还有虚引用就会在回收对象后将这个虚引用加入引用队列,以通知应用程序对象的回收情况

static PhantomReferenceTest obj;
static PhantomReferenceTest obj2;
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;

public static void main(String[] args) {
      phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
      obj = new PhantomReferenceTest();
  		obj2 = new PhantomReferenceTest();

      PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<>(obj, phantomQueue);
  		PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<>(obj2, phantomQueue);
}

每个Reference都有一个带ReferenceQueue的构造函数,可以在外部对这个queue进行监控.如果有对象即将被回收,那么相应的reference对象就会被放到这个queue里

public class CheckRefQueue extends Thread {
    @Override
    public void run() {
        while (true) {
            if (phantomQueue != null) {
                PhantomReference<PhantomReferenceTest> objt = null;
                try {
                    objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (objt != null) {
                    System.out.println("追踪垃圾回收过程,实例被GC了");
                }
            }
        }
    }
}

回收器概述

GC性能指标主要分为以下三种,三者总体表现会随着技术迭代而越来越好,一款优秀的收集器通常最多同时满足其中的两个

  • 吞吐量 运行用户代码的时间占运行时间的比例

  • 暂停时间 执行垃圾收集是程序工作线程被暂停的时间

  • 内存占用 JVM堆区所占用的内存大小

其中暂停时间的重要性日益凸显,因为随着硬件的发展,内存的占用越来越能容忍,硬件性能的提升也有助于降低收集器对应用程序的影响(吞吐量),而内存的扩大对延迟反而是负面效果

吞吐量和低延迟

高吞吐量和低暂停时间是一堆相互矛盾的目标

  • 吞吐量优先,那么必然需要降低内存回收的执行频率,这样会导致GC需要更长的暂停时间来执行内存回收

  • 低延迟优先,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降

每个GC算法只能针对两个目标之一,只专注较大吞吐量或最新暂停时间或者找到两者的折中(最大吞吐量有限的情况下降低停顿时间)

一个交互式的应用程序更会选择低延迟的垃圾回收器

官方文档

垃圾收集器组合

下图中蓝色矩形代表年轻代收集器,红色代表老年代收集器,其中G1GC都支持

使用黑线连接代表他们可以相互搭配使用

其中Serial Old作为CMS出现Concurrent Mode Failure失败的后备预案

红色虚线连接代表它们只有在jdk1.8之前才能搭配使用,之后的版本组合被废弃

绿色虚线代表在jdk14被弃用了该组合,CMS GC在jdk14被整个弃用

垃圾回收组合

java的使用场景很多,所以需要针对不同的场景提供不同的垃圾收集器提高垃圾收集的性能,虽然会对各个收集器进行比较,但并非为挑选一个最好的收集器,没有一个收集器能完美适应所有场景,所以只能选择最适合的垃圾收集器

可以通过以下指令查看当前jvm使用的垃圾收集器

-XX:+PrintCommandLineFlags

也可以使用命令行的方式查看,jdk1.8默认使用UseParallelGC和UseParallelOldGC

jps
$ 81093 GcUseTest
jinfo -flag  UseParallelGC 81093
$ -XX:+UseParallelGC
jinfo -flag  UseParallelOldGC 81093
$ -XX:+UseParallelOldGC

主要垃圾回收器

收集器

分类

作用位置

使用算法

特点

适用场景

Serial

串行运行

作用于新生代

复制算法

响应速度优先

适用于单CPU环境下的client模式

ParNew

并行运行

作用于新生代

复制算法

响应速度优先

多CPU环境Server模式下与CMS配合使用

Parallel

并行运行

作用于新生代

复制算法

吞吐量优先

适用于后台运算而不需要太多交互的场景

Serial Old

串行运行

作用于老年代

标记-压缩算法

响应速度优先

适用于单CPU环境下的Client模式

Parallel Old

并行运行

作用于老年代

标记-压缩算法

吞吐量优先

适用于后台运算而不需要太多交互的场景

CMS

并发运行

作用于老年代

标记-清除算法

响应速度优先

适用于互联网或B/S业务

G1

并发、并行运行

作用于新生代、老年代

标记-压缩算法、复制算法

响应速度优先

面向服务端应用

如何选择

  • 优先调整堆的大小让JVM自适应完成

  • 内存小于100M或是单核并且没有停顿时间要求,使用串行收集器

  • 在多CPU、追求高吞吐量、停顿时间超过1秒,可以选择并行

  • 在多CPU、追求低延迟,需要快速响应,使用并发收集器.官方建议使用G1收集器

Serial

Serial收集器(串行)是最基本也是历史最久的垃圾收集器,jdk1.3之前回收器的唯一选择

对于单核cpu上运行有不错的性能,相对于其它收集器在单线程的使用场景下更高效(没有于其它线程交互的开销),只会使用一个收集线程完成垃圾收集的工作,并且进行垃圾收集器就暂停其他工作线程直到它收集结束

  • 年轻代采用复制算法、串行回收和STW机制的方式执行内存回收

  • 老年代对应的是SerialOld收集器,和Serial的区别是采用标记压缩的算法

Serial收集器

单个CPU并且内存不大(几十到几百MB),并且较短时间内完成垃圾收集(几十毫米),只要不频繁发生回收行为使用该回收器都是可以接受的

以下参数开启Serial和SerialOld

-XX:+UseSerialGC

ParNew

除了采用并行回收的方式执行内存回收外与Serial几乎没有区别

可以利用多CPU或多核的物理硬件资源,可以更快速的完成垃圾收集提升程序吞吐量,但是在系统资源匮乏的场景下,由于不需要频繁的切换上下,Serial效率更高效

ParNew收集器

以下参数开启ParNew,在jdk9以后该收集器被移除

-XX:+USeParNewGC
-XX:+UseConcMarkSweepGC # 与CMS搭配
-XX:ParallelGCThreads # 手动指定收集线程数

Parallel Scavenge

和ParNew类似也是基于并行回收,也采用了复制算法和STW,不过与ParNew不同的是,它的目标是达到可控制的吞吐量,吞吐量优先的收集器,并且拥有自适应调节策略(内存分配情况)

在吞吐量优先的场景下Parallel和Parallel Old的组合内存回收性能不错,java8中默认使用该垃圾收集器组合

高吞吐量可以高效率的运行cpu,尽快完成程序的运算而不需要太多交互的任务,如批量处理,订单处理,工资支付等等

Parallel Old采用了标记压缩、并行回收和STW

相关参数设置

-XX:+UseParallelGC 
-XX:+UseParallelOldGC # 当一个开启另一个也会开启
-XX:ParallelGCThreads # 设置年轻代并行收集器线程数,最好与cpu核心数相等,默认核心数小于8则为8,否则值为3+[5*CPU_COUNT/8]

设置STW时间

-XX:MaxGCPauseMillis # 设置垃圾收集器的最大停顿时间,单位毫秒

为了将时间控制在MaxGCPauseMillis以内,JVM在工作时会调整java堆的大小和其他参数

该参数设置太小会导致GC频率过高

该参数设置需谨慎

设置吞吐量

-XX:GCTimeRatio # 垃圾收集时间占总时间比例 (运行用户代码的时间占运行时间的比例),默认99,也就是回收时间不超过1%

取值0-100,和上一个参数有一定矛盾性

是否开启自适应调节策略

-XX:+UseAdaptiveSizePolicy

年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会自动调整,以达到堆大小、吞吐量、停顿时间之间的比例

默认开启,因为手动设置较为困难,让虚拟机自己完成调优工作

CMS

CMS(Concurrent-Mark-Sweep),是Hotspot虚拟机真正意义上的并发收集器,第一次实现了垃圾收集器与用户线程同时工作,尽可能的缩短垃圾收集时用户线程的停顿时间低延迟优先,良好的响应速度能提升用户的体验

该收集器在jdk14被删除,jdk9被弃用 如果在jdk14使用,则会自动切换到默认的垃圾收集器

CMS使用了标记清除算法,并且也会STW

不过CMS仅能作为老年代的收集器,并且无法与Parallel Scavenge配合工作,所以当选择CMS来收集老年代的时候,新生代只能选择ParNew和Serial中的一个

CMS收集器

CMS的收集较为复杂,分为4个阶段

  • 初始标记 所有用户线程会在此时进行STW(由于直接关联对象比较小,所以速度非常快),此时仅仅只是标记出GC Roots最初关联到的对象,一旦标记完成后就会恢复被暂停的线程

  • 并发标记 从GC Root关联对象开始遍历整个GC Roots,这个阶段耗时较长但是不需要STW,可以和用户线程并发执行

  • 重新标记 由于在并发标记阶段中,收集线程和用户线程交叉运行.为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,所以此时也需要STW(否则永远无法确定存活对象)

  • 并发清除 清理删除掉标记阶段判断的已经死亡的对象,释放内存空间.由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

    用户线程正在执行如果标记整理(移动)的话,用户线程中使用的内存就会产生错误

    CMS的垃圾回收算法是标记清除,所以会产出不连续的内存块,所以分配新对象的时候还需要维护空闲列表

尽管CMS采用的是非独占式的并发回收,但是初始化阶段和再次标记阶段仍然会较短时间的STW

目前所有的垃圾回收器不可能不进行STW,只能尽可能缩短

并且CMS在回收过程中应该确保应用程序有足够的内存(堆内存使用率达到阈值就进行回收),而不能像其它的收集器一样等到内存几乎填满了在收集

因为CMS是并发执行的,在回收阶段用户线程也在执行,如果不预留足够的内存更有可能导致内存溢出

若CMS运行是预留内存也无法满足程序的需求就会导致Concurrent Mode Faiilure,JVM就会暂时使用Serial Old收集器(串行)来回收老年代,这样停顿时间反而会更长

通过上述总结,CMS有并发收集、低延迟的优点,同时为了实现这些优点,同时出现了更多缺点

  • 会产生内存碎片

因为使用了标记清除,所以更容易出现无法分配大对象导致Full GC

  • 对CPU资源非常敏感

垃圾清除线程和用户线程同时并发执行,导致总体程序运行效率降低

  • 无法处理浮动垃圾

在并发标记阶段本身没被标记为垃圾,但是在重新阶段被标记为垃圾(产生的新的垃圾对象),无法对这些对象进行回收,从而只能在下一次执行GC时释放这些内存空间

  • 需要预留更多内存

相关参数设置

设置该老年代收集器

-XX:+UseConcMarkSweepGC # 指定使用CMS收集器,自动打开-XX:+UseParNewGC

设置垃圾回收阈值

-XX:CMSInitiatingOccupanyFraction # 设置堆内存使用率的阈值,达到该阈值便开始进行回收

jdk5及以前版本的默认值为68,jdk6及以上版本默认值为92

如果内存增长缓慢可以设置一个较大的值,可以有效减少垃圾回收的频率

如果内存增长迅速则需要设置一个较小的值,以避免频繁触发老年代串行收集器

内存整理

-XX:+UseCMSCompactAtFullCollection # 开启内存整理,会导致GC时间延长
-XX:CMSFullGCsBeforeCompaction # 设置在执行多少次Full GC后对内存空间进行压缩整理

设置线程数量

-XX:ParallelcMSThreads

CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数

因为CMS对CPU资源非常敏感,当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段大大降低

G1

在应用程序业务越来越庞大越来越复杂,并发量越来越高,所以需要对GC进行不断的优化,G1垃圾收集器实在java7引入的一个新垃圾收集器

G1的设计目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起全功能收集器的期望

G1的侧重点是回收垃圾的最大Region,所以它的名字是垃圾优先Garbage First

Region

G1是一个并行回收器,并且它把堆内存分割成大约2048个独立的Region块,多个Region来表示Eden、Survivor0区、Survivor1区、Old等

新生代和老年代在是物理隔离的,它们都是一个个Region的集合,通过Region的动态分配方式实现逻辑上的连续

所有Region大小相同,每个容量在1-32MB之间,且在JVM生命周期内不会改变

每个Region都是通过指针碰撞来分配空间

一个Region可能属于Eden、Survivor、Old,空白表示未使用的内存空间,G1还增加了新的类型Humongous主要存储大对象

对于堆中的大对象,默认会直接放入老年代,但是如果是一个短期存在的大对象就会对垃圾收集器造成负面影响,为了解决这个问题G1划分了Humongous来专门存储大对象.若一个H区装不下一个大对象,会选择连续的Region来存储.有时为了找到连续的H区不得不启动Full GC.G1大多是行为都把H区作为老年代的一部分看待

如果一个对象的大小已经超过Region大小的50%了就会被放入H区

分区算法

GC时有计划的避免在整个堆中进行全区域的垃圾收集,跟踪各个Region内的价值大小,并且维护一个优先列表,每次根据允许的收集时间,有限回收价值最大的Region

价值指得是回收所获的的空间大小和回收所需时间

优点

  1. 并行并发

  • 在回收期间可以有多个GC同时工作,有效利用多核计算能力

  • 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,并且不会在回收阶段发生完全阻塞的情况

  1. 分代收集

  • 依然属于分代型垃圾回收器,但从对的结构上并不要求各个年轻代、老年代是连续的、固定大小、固定数量

  • 堆空间分为若干个Region

  • 并且和其它收集器不同,同时兼顾年轻代收集和老年代收集

  1. 空间整合

G1将内存划分为若干个的Region,内存的回收是以Region作为基本单位的.Region之间通过复制算法避免内存碎片

这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC

  1. 可预测的停顿时间模型

G1除了追求低延迟以外,还会建立可预测的停顿时间模型(soft real-time尽可能在给定的时间内),使JVM在执行M毫秒的时间段内消耗在垃圾收集上的时间不会超过N毫秒

  • 由于分区Region的原因,G1可以只选取部分区域进行回收,这样减少了回收范围,因此对于全局停顿的情况可以进行很好的控制

  • G1跟踪各个Region的价值大小,每次根据允许的收集时间,优先回收价值最大的Region.确保有限时间内尽可能高的回收效率

在一些场景下可以替换CMS

  • 超过一半的堆空间是活动数据

  • 对象分配频率或年轻代提升频率变化很大

  • GC停顿时间过长

缺点

应用程序运行中时,G1无论是垃圾收集产生的内存占用,还是运行时的额外复杂都比CMS高.在较小的内存上CMS的性能会优于G1,G1更适合大内存上进行运行.内存在6-8G两者差距不大

回收过程

GC回收过程分为以下

  • Young GC

  • 老年代并发标记过程(Concurrent Marking)

  • Mixed GC

  • Full GC (独占式高强度的Full GC是针对GC评估失败的一种失败保护机制)

执行优先级依次为Young GC、Young GC+Concurrent Marking、Mixed GC、Full GC

  • 当Eden区用尽时,开始年轻代回收过程,G1的年轻代收集阶段是一个并行的独占式收集器.在年轻代回收时,会进行STW,启动多线程执行年轻代回收.然后从年轻代区间移动存活对象到Survivor区间或者老年区间

  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程.标记完成后会进行混合回收,GC从老年区间移动存活对象到空闲区,这些空闲区也就成为了老年代的一部分.老年代的G1回收器和其他GC不同.G1的老年代回收器不需要整个老年代被回收,一次只需要扫描和回收一小部分老年代的Region

老年代只回收部分Region,年轻代是完整回收

记忆集

一个Region不是孤立的,一个Region可能被其他Region中的对象引用,也就是说判断对象是否存活需要扫描整个堆内存.特别是年轻代的对象被老年代所引用(跨代引用),只有包含了跨代引用的内存中的老年代对象才会加入到GC Roots扫描中

回收新生代还需要完整扫描老年代.这会导致Minor GC的执行效率降低

记忆集

所有G1(以及其它包含分代的收集器)都是通过记忆集(Rembered Set)来避免全局扫描

  • 每个Region都对应一个记忆集

  • 每个引用类型的数据进行写操作时,都会产生一个写屏障(Write Barrier)来暂时中断操作.并且检查将要写入的引用指向的对象是否和该引用类型的数据在不同的Region.如果不同,则通过卡表(CardTable)把相关引用信息记录到引用指向对象所在的Region对应的记忆集中

Region内相互引用显然没有这个问题

卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等

  • 在卡表中有一个或多个的跨代指针,则将对应的卡表元素标为1,代表变脏.当虚拟机扫描卡表元素为1时,便将对应的卡表内存区域加入到GC ROOT中一并扫描

卡表中的脏元素是先通过脏卡队列(drty card quene)记录记录引用信息的,在年轻代回收的时候G1会对队列中的卡进行处理,更新记忆集,保证真实的引用关系

结果集的处理是线程同步的,为了性能需要事先放入队列中而不是在赋值是就更新记忆集

  • 当进行垃圾回收时,在GC根阶段在枚举范围内加入记忆集,就可以保证不进行全局扫描越不会遗漏

回收过程

G1垃圾回收

  1. YoungGC

  • 扫描根 根节点和记忆集记录的外部引用对象作为扫描存活对象的入口

根节点指静态变量、正在执行的方法中的局部变量等

  • 更新记忆集 处理脏卡队列,更新记忆集

  • 处理记忆集 识别跨代引用并且存活对象

  • 复制对象 Eden中存活的对象会被复制到Survivor中,Survivor满足条件会被晋升到老年代中

具体参照[运行时数据区-垃圾收集]

并且复制过程中的对象都是连续存储的,所以复制过程可以达到内存整理的效果

  • 处理引用 最终Eden区数据为空,GC停止工作

  1. Young GC + Concurrent Marking

  • 初始标记阶段 标记从根节点直接可达的对象,此阶段时STW的,并且会触发YoungGC

  • 根区域扫描 扫描初始标记的存活区中(survivor 区)可达的老年代区域对象并标记可以被引用的对象,这一过程必须在YoungGC之前完成

因为记忆集是不记录从Young Region出发的引用,那么就可能出现一个老年代的存活对象,只被年轻代的对象引用.在一次Young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor Region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分

  • 并发标记 整个对中进行并发标记(和应用程序并发执行),并且可能被YoungGC中断.再次阶段中,若发现Region中的对象全部都是垃圾,那这个区域会被立刻回收.并且此阶段还会判断Region中对象的存活比例

  • 再次标记 由于应用程序并发运行,所以需要通过STW修正上一次标记结果

G1使用了比CMS更快的初始快照算法SATB

  • 独占清理 计算各个区域的存活对象和GC回收比例,并进行排序.识别可以混合回收的区域

此阶段时STW的,并且不会进行垃圾收集

  • 并发清理 识别并清理完全空闲的区域

  1. MixedGC

为了避免堆内存被耗尽,会触发一个Mixed GC,除了回收整个Young Region还可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制

  • 并发标记结束以后,全部是垃圾的Region被回收了,部分为垃圾的Region被计算了出来.默认情况下,这些老年代的内存分段会分8次

可以通过-XX:G1MixedGCCountTarget设置分段次数

  • 混合回收包括年轻代Region和1/8的老年代Region.由于老年代要被分多次回收,所以G1会优先回收垃圾比例多的分段.并且有默认65%的阈值决定是否被回收

可以通过-XX:G1MixedGCLiveThresholdPercent设置Region回收阈值

如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间

  • G1允许整个堆内存默认10%的空间被浪费,如果比例低于该值则不会进行混合回收

可以通过-XX:G1HeapWastePercent设置整堆回收比例

GC会花费很多的时间,但是回收到的内存却很少

  1. FullGC G1设计的初衷就是避免FullGC,如果执行了FullGC,会导致STW,并且应用程序停顿时间会很长,一旦出现FullGC就要考虑进行调整

主要原因有2个

  • 没有足够的内存存储晋升对象

  • 并发处理过程之前内存就耗尽

解决方法

  • 增加JVM的堆内存

  • 避免使用-Xmn-XX:NewRatio等参数显示设置年轻代大小

因为年轻代回收是独占式的,固定年轻代大小会覆盖暂停时间的目标

  • 暂停时间目标不要太过于严苛.暂停时间过小影响到吞吐量,导致垃圾回收开销增大

G1GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间

提高垃圾收集器的最大停顿时间

相关参数设置

开启G1

-XX:+UseG1GC

G1在jdk9中是默认收集器,如果需要在jdk8中使用则需要该指令

设置Region的大小

-XX:G1HeapRegionSize 

默认是堆内存的1/2000.值是2的幂,范围在1MB到32MB之间,根据最小的堆大小划分出约2048个区域

期望达到的最大GC停顿时间

-XX:MaxGCPauseMillis

默认值是200ms

JVM会尽力实现,但不保证达到

STW工作线程数的值

-XX:ParallelGCThread

STW是GC线程数,最多设置为8

设置并发标记的线程数

-XX:ConcGCThreads

设置并行垃圾回收线程数,建议设置在ParallelGCThreads的1/4

设置触发并发GC周期的堆占用率阈值

-XX:InitiatingHeapOccupancyPercent

超过此值触发GC.默认值是45

标记阶段

在堆中存放着所有对象实例,在垃圾回收前需要区分出哪些是存活对象哪些不是,之后被标记已经死亡的对象,GC才会在执行垃圾回收时释放掉其占用的内存,释放掉其占用的内存,这个阶段被称为垃圾标记阶段

引用计数

实现比较简单,对每个对象保存一个整形的引用计数器,用于记录对象被引用的情况

当对象A,只要有任何一个对象引用了A,则对象A的引用计数器就加1,当引用失效时,引用计数器就减1,只要对象A的引用计数器值为0,则说明该对象不再被使用可以被回收

这种算法实现简单,垃圾对象便于辨识效率高,回收没有延迟性(无需等待到内存满了在回收),也用很多缺点

  • 需要单独存储计数器,增加了存储空间的开销

  • 每次赋值需要更新计数器,伴随着加法或减法操作,增加了时间开销

  • 无法处理循环引用的情况

无法处理循环以来是一个严重缺陷,所以主流jvm中都没有使用这类算法

循环引用

虽然java没有使用这种算法,但是一些语言例如python使用,解决方式有两种

  • 手动解除,在合适的时机手动解除引用关系

  • 使用弱引用weakref,弱引用允许引用对象但不增加对象的引用计数

可达性分析

可达性分析不仅支持实现简单和执行高效,更重要的是该算法可以有效的解决在引用计数算法中无法释放循环引用的问题,防止内存泄漏.这种类型的垃圾收集也被称为追踪性垃圾收集

相比之下执行效率比引用计数差一些

可达性分析

可达性分析算法是以根对象集合为起始点,按照从上至下的搜索被根节点所连接的目标对象是否可达

内存中的存活对象都会被根节点直接或间接的连接着,搜索走过的路径被称为引用链

目标对象中没有任何引用链相连则是不可达的垃圾对象需要被标记为垃圾对象.只有被根节点直接或间接连接的对象才是存活对象

GC Root

主要包含以下几种元素

  • 虚拟机栈和本地方法栈中所引用的对象

  • 方法区中的静态属性所引用的对象

  • 常量引用的对象

  • 被同步锁synchronized所持有的对象

  • 基本数据类型所对应的对象,常驻的异常对象,系统类加载器

  • 内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

只要是一个引用,它保存了堆内存中的对象,但它本身又不存放在堆内存里,他就是Root

可达性分析算法来判断内存是否可以被回收,那么分析工作必须在一个能保证一致性的快照中进行,这点无法满足的话分析结果的准确性就无法保证,这也就是GC时必须STW的重要原因

工具使用

首先生成dump文件

  • 可以使用jmap命令

  • 使用visualVM的heap Dump(可以通过save as保存)

  • -XX:+HeapDumpOnOutOfMemoryError 当OOM生成dump文件

  • -XX:+HeapDumpBeforeFullGC 产生fullgc时生成dump文件

  • -XX:HeapDumpPath 可选,当使用以上两个命令时指定生成目录

生成dump文件后可以使用Mat工具分析gc roots

file-open heap dump选择需要导入的dump文件

也可以使用jprofiler,选择对应的attach后

在实时内存中选择需要的对象

进入后选择传入引用,选择需要的对象,点击显示到GC根路径

清除阶段

当成功区分出内存中存活的对象后,之后就是执行垃圾回收,释放掉无用的对象占用的内存空间,目前常用的垃圾收集算法是标记清除、复制算法、标记压缩算法

标记清除

当对中有效内存被耗尽时,就会停止整个程序(STW),然后进行标记和清除

  • 标记 收集器从引用根节点进行遍历标记所有被引用的对象(可达对象)

  • 清除 收集器对堆内存中进行线性遍历,如果发现某个对象没有标记为可达对象则进行回收

使用这种方法有一些明显的缺点标记清除算法效率不算高,执行GC时需要停止整个程序,并且使用这种方式i清理的内存时不连续的需要维护一个空闲列表

清除的对象保存在空闲列表里,当有新的对象创建时直接覆盖在原有的地址

标记清除

复制

将内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正在使用中的内存对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,最后完成垃圾回收,在此之后存储到另一块内存区域中

优点很明显没有标记和清除的过程,实现更简单,运行也更高效,并且复制过后可以保存内存连续性不会出现碎片

缺点也很明显

  • 需要两倍的内存空间,或者说永远会有一半的内存空间无法被使用

  • 并且因为是复制,所以还需要特别维护对象与根节点的引用关系

栈内存指向堆内存地址改变了,并且如果没有使用句柄池而是直接指向所以有这方面的考虑

  • 如果存活对象过多,也会大大影响性能

标记压缩

在标记清除的基础上再进行一次内存碎片整理,现代的垃圾收集器都是用到了标记压缩算法

标记压缩的效果等同于标记清除指向后,在进行一次内存碎片的整理.二者的差异在于标记清除时一种非移动式的回收算法,运动回收是一项优缺点并存的决策,标记存活的对象会被整理按照内存地址依次排列,未被标记的内存会被清理掉,如此以来需要创建新对象时只需要持有一个内存的起始地址即可(指针碰撞),无需维护空闲列表.缺点就是移动对象的同时,如果对象被其他对象引用还需要调整引用地址

标记压缩

标记清除

标记压缩

复制

效率

中等

最慢

最快

空间开销

少,但会堆积碎片

少,堆积碎片

需要至少两倍的空间

移动对象

分代收集

上述的所有算法都具有自己独特的优势,并没有一种算法可以完全替代其他的算法,分代算法应运而生

不同的对象生命周期是不同的,因此不同生命周期的对象可以采用不同的收集算法来提高回收效率,可以根据不同内存区域的特点使用不同的回收算法

目前几乎所有的GC都采用分代收集算法执行垃圾回收的,几乎所有的垃圾回收器都区分新生代和老年代

年轻代

区域相对于老年代较小,对象生命周期短,存活率低,回收频繁.在年轻代使用复制算法回收整理,复制算法效率和当前对象的大小有关,而复制算法内存利用率不高的问题,可以通过survivor的设计得到缓解

以jvm为例,默认新生代与survivor的默认比例是8:1:1,新生代与老年代的比例是1:2,也就是说真正被浪费的内存空间只有1/30

也并非真正意义上的浪费,而是复制算法需要的空闲的内存空间

老年代

区域较大,对象生命周期长、存活率高,回收不频繁.这种情况下在大量存活的对象,复制算法变得不合适,一般由标记清除或者标记压缩的混合实现

  • 标记阶段的开销与存活对象数量成正比

需要遍历出所有和根节点关联的可达对象

  • 清除阶段的开销与所管理区域的大小正相关

需要进行全老年代空间线性的遍历

  • 整理阶段的开销与存活的数据成正比

存活的对象越多,需要整理的对象越多

以Hotspot的CMS回收器为例,CMS是基于标记清除实现的,对于对象的回收效率很高,而对于碎片问题,CMS采用标记压缩算法的Serial Old回收器作为补偿措施.当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理

优化算法

增量收集算法

在上述的算法在垃圾回收过程中都会处于一种STW的状态,在此状态下应用程序所有用户线程都会挂起,直到垃圾回收完成,如果垃圾回收时间过长,那么应用程序就会被挂起很久从而影响用户的使用和系统的稳定性

一次性将所有的垃圾进行回收处理会造成系统的长时间停顿,那么就可以让垃圾收集线程和用户线程交替执行.每次垃圾收集线程只收集一小片区域的内存空间,接着切换到用户现场,依次反复直到垃圾收集完成.增量收集算法的基础任然是传统的标记清除和复制算法,不过增量收集算法是对线程间冲突的妥善处理,允许垃圾收集线程分阶段的方式完成标记清除和复制算法的工作

在垃圾回收过程中间歇性的执行了引用程序代码,所以能减少系统停顿时间,但是因为线程的不断上下文切换,会使得垃圾回收的总体成本下降,造成系统吞吐量下降

分区算法

在相同条件下,堆空间越大,一次GC所耗费的时间就越长,GC的停顿时间也会越长.为了更好的控制停顿时间,将一块大的内存区域氛围若干个小块.根据目标的停顿时间不再回收整个内存空间,而是每次合理的回收若干个小区间,从而减少每次GC所产生的停顿

分代算法是按照对象的生命周期长短划分区域,而分区算法是将整个堆空划分成若干个不同的小区域.每个区域都是独立使用、独立回收

分区算法

finalize

java允许开发人员在对象被销毁之前自定义逻辑,当垃圾回收器发现没有引用指向的对象,在垃圾回收之前,会先调用finalize方法.finalize方法需要在子类进行重写,一般用于资源释放,关闭文件,套接字和数据库连接等

  • finalize可能会导致对象方法复活

  • finalize方法执行时间是没有保障的,他完全有GC线程决定,若不发生GC则该方法没有执行的机会

  • finalize可能会影响GC性能

finalize一般不需要手动调用,而是完全交给JVM

@Deprecated(since="9")
protected void finalize() throws Throwable { }

如果从所有根节点都无发访问某个对象,说明该对象已经不在使用了并且此对象可能需要被回收,并进入缓刑阶段,一个无法触及的对象在某一个条件下可以复活自己

  • 可触及 从根节点开始,可以达到这个对象

  • 可复活 从对象所有引用都被释放,但是对象可能在finalize中复活

  • 不可触及 对象的finalize被调用并且没有复活,那么就会进入不可触及的状态,finalize方法只会被调用一次,所以不可被触及的对象无法复活

判断一个对象是否可以被回收,至少要经历两次标记过程

  • 对象obj到GC Roots没有引用链,则进行一次标记

  • 判断是否有必要执行finalize方法,如若该对象没有重写或者finalize已经被虚拟机调用则会被视为没有必要执行,obj对象会被判为不可触及

  • 如果有必要执行了finalize方法,那么obj会被插入到F-Queue队列中,并由一个由虚拟机自动创建、低优先级的Finalizer线程执行对应的finalize方法

  • 如果obj在执行了finallize方法中引用链进行联系,那么就会被移除即将回收的合集,如果再次出现了没有引用链的情况finalize方法不会被再次调用,对象会直接变成不可触及的状态

finalize方法是跳脱垃圾回收最后的机会,并且每个对象只会被Finalizer线程主动调用一次

public class CanReliveObj {

    private static CanReliveObj obj;

    @Override
    protected void finalize()  {
        obj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        obj = new CanReliveObj();
        obj = null;
        System.gc(); // 手动置null,并且gc
        Thread.sleep(2000); // Finalizer线程优先度较低所以延迟2秒
        if (obj == null) { // alive
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
        
        obj = null;
        System.gc();
        Thread.sleep(2000); // finalize只会被调用一次
        if (obj == null) { // dead
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }
}

通过jdk1.9以后的版本也将finalize弃用,不建议使用

  • 不可控 调用的不可控下导致资源迟迟不被释放而出现异常,既然计算机资源有限,当要释放资源的时候就应该立刻释放

  • 影响性能 内存释放对于JVM来说只需要释放堆内存,而重写了finalize则需要做很多额外工作,如果出现死锁、异常、死循环消耗的成本就更高

  • 异常丢失 如果抛出了异常,这个异常会被舍弃,导致很难精准定位问题

监控调优

调优步骤

性能监控

一种非侵入方式收集或者查看应用运营性能数据的活动.监控通常是一种在生产、质量评估或者开发环境下实施的带有预防或者主动性的活动.当应用的出现性能问题却没有足够多的线索时,首先需要进行性能监控

如GC频繁、cpu负载过高、OOM、内存泄漏、死锁、应用响应时间过长

性能分析

一种侵入方式收集的运行性能数据,它可能会影响到应用的吞吐量或响应性.性能分析是针对性能问题的答复结果,关注点比性能监控更集中.性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤

性能调优

一种改善应用响应性或吞吐量而更改参数]源代码、属性配置,性能调优是在性能监控和性能分析之后的步骤

监控与诊断工具

命令行

对应源码 对应文档

以下命令实例中中,[]中包含的参数代表代表可选参数,<>中包含的参数代表必选参数

JPS

Java Process Status 显示当前操作系统中所有的HotSpot虚拟机的进程信息

  • -q 仅显示JVM的pid

  • -l 输出应用程序的全类名,如果是jar包则输出完整路径

  • -m 输出jvm启动时传递给main方法的参数

  • -v 列出jvm启动时的jvm参数

只显示jvm相关进程,和ps命令类似

如果关闭了默认开启的性能监控(-XX:-UsePerfData),则jps无法检测到对应的jvm

JSTAT

JVM Statistics Monitoring Tool 用于监视虚拟机的各种运行状态信息的命令行工具,可以显示本地或远程虚拟机的类装载、内存、垃圾收集、JIT编译等数据

jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

  • option 查询参数

    • 类装载相关

      • -class 显示ClassLoader相关信息

        类的装载、类的装载总量、类的卸载、类的卸载总量、类装载器消耗时间

    • JIT相关

      • -compiler 显示JIT编译器编译过的方法、耗时等信息

      • -printcompilation 输出已经被JIT编译的方法

    • 垃圾收集相关

      • -gc 显示gc相关的堆信息

      • -gccapacity 和-gc类似,关注堆的使用到的最大最小空间

      • -gcutil 和-gc类似,关注占用堆空间百分比

      • -gccause 和-gcutil类似,关注垃圾回收事件的原因

      • -gcnew 新生代GC状况

      • -gcnewcapacity 新生代内存统计

      • -gcold 老年代gc状况

      • -gcoldcapacity 老年代内存统计

      • -gcmetacapacity 方法区内存统计

# 查看指定端口号jvm的类装载信息
jstat -class 37763
$ Loaded  Bytes  Unloaded  Bytes     Time   
   756  1791.6        0     0.0       0.03
  • -t 进程运行的时间(秒)

Timestamp  GCT   # GC时间占比0.176%
2990.6     76.601
3008.7     76.633

可以通过两次的进程运行的时间和GC时间的增量来计算GC时间占运行时间的比例

如果该比例超过20%则说明堆压力较大,如果比例超过90%则说明堆几乎没有可用空间随时会导致OOM

Timestamp      OU
2989.6      240535.2 
2995.6      240951.2
2998.6      241095.2
3001.6      241247.2
3004.6      241711.3
3007.7      243269.0

运行jstat命令连续获取多行性能数据,并取这几行数据中OU(即已占用的老年代内存)的最小值

每隔一段较长的时间重复一次上述操作,来获得多组OU最小值.如果这些值呈上涨趋势,则说明JVM老年代内存已使用量在不断上涨,也就是无法回收的对象在不断增加,因此很有可能存在内存泄漏

  • -h 每隔指定次数输出一次表头

  • vmid jvm的pid

  • interval 查询间隔(毫秒)

  • count 查询次数

# 查看指定端口号jvm的类装载信息
# 并显示运行时间戳,设置每秒打印一次,一共打印50次,每打印3次打印一次表头
jstat -class -t -h 3 37763 1000 50

JINFO

Configuration Info for Java 查看虚拟机参数配置信息,也可以用于调整虚拟机参数

可以通过jinfo命令查看某个虚拟机参数的默认值

jinfo <option> <pid>

  • -sysprops 查看指定jvm的完整虚拟机参数

和System.getProperties()取得的参数一致

jinfo -sysprops 43560 
  • -flags查看所有JVM曾赋值过的参数

jinfo -flags 46681
  • -flag 查看指定参数

jinfo -flag UseParallelGC 46681
$ -XX:+UseParallelGC

不旦可以查看还能修改,并使之立刻生效.但不是所有参数都支持动态修改,只有被标记为manageable的参数可以被实时修改

可以通过改命令查看可以被修改的参数

java -XX:+PrintFlagsFinal -version | grep manageable

开启指定JVM参数

  • 布尔类型

jinfo -flag PrintGCDetails 47624 
$ -XX:-PrintGCDetails # 未开启
jinfo -flag +PrintGCDetails 47624
jinfo -flag PrintGCDetails 47624 
$ -XX:+PrintGCDetails # 已开启
  • 数值类型

jinfo -flag MaxHeapFreeRatio=90 47624

JMAP

JVM Memory Map 获取JVM的dump文件,获取JVM进程的内存相关信息

由于jmap会访问堆中的所有对象,为了保证在此过程中不被应用现场干扰,jmap需要借助安全点的机制,让所有线程听伦在不改变堆中数据的状态.因此jmap导出的快照都是在安全点的位置,这也导致基于该快照的分析可能存在偏差,例如某个对象在两个安全点之间,那么可能无法探知到这些对象

jinfo <option> <pid>

  • -dump 生成堆转储快照

  • -dump:live 只保存堆中存活对象

实际使用一般更推荐使用-dump:live,因为它容量更小,更重要的是分析堆内存中的存活对象才是更重要的(只有存活对象才会不被回收)

jmap -dump:format=b,file=./Desktop/GCTest.hprof 60408

jmap -dump:live,format=b,file=./Desktop/GCTest4.hprof 60148

除了使用上述方式在生成环境中使用jmap方式生存dump文件,也可以使用在系统OOM时自动生成dump文件

使用场景不一样,自动生成可以第一时间保存导致OOM的快照信息

而使用jmap可以在系统监控异常但是还没有OOM的时候生成快照信息

-XX:+HeapDumpOnOutOfMemoryErro
-XX:+HeapDumpPath=./dump.hprof
  • -heap 生成目前整个堆空间的详细信息

部分命令jmap已经不直接支持,需要使用jhsdb

jhsdb jmap --heap --pid 3255
  • -histo 生成目前整个堆空间对象的统计信息

jmap -histo 13627
  • -F 当-dump没有响应时,添加此参数强制生成

  • -J 传递参数给jmap启动的jvm

JHAT

JVM Heap Anaysis Tool,jdk提供的dump文件分析工具,不过在jdk9之后被删除,官方建议直接使用VisualVM

通过以下命令可以分析指定dump文件,分析完毕以后可以通过127.0.0.1:7000访问简易界面查看

jhat dump.hprof
  • -J 传递参数给jmap启动的jvm

    指定jhat的堆内存大小

jhat -J-mx20g  dump.hprof
  • -port 指定http端口,默认7000

JSTACK

JVM Stack Trace 生成虚拟机当前时刻的线程快照.线程快照就是当前虚拟机内每一条线程正在 执行的方法堆栈快照

线程快照可以定位线程出现长时间停顿的原因

jstack [-l] [-e] <pid>

  • -l 显示锁的附加信息

Locked ownable synchronizers:
		- None

执行jatack打印所有线程状态

如果有死锁会在Found one Java-level deadlock中展示

jstack 41931
  • 死锁 Deadlock*

  • 等待资源 Waiting on condition*

  • 等待获取监视器 Waiting on monitor entry*

  • 阻塞 Blocked*

  • 执行中 Runnable

  • 暂停 Suspended

  • 对象等待中 TIMED_WAITING

  • 停止 Parked

jstack类似以下代码

Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
Set<Map.Entry<Thread, StackTraceElement[]>> entries = all.entrySet();
for (Map.Entry<Thread, StackTraceElement[]> en : entries) {
    Thread t = en.getKey();
    StackTraceElement[] value = en.getValue();
    System.out.println("[Thread name" + t.getName() + "]");
    for (StackTraceElement element : value) {
        System.out.println("\t" + element.toString());
    }
}

JCMD

命令行工具jcmd是一个多功能的工具可以用来实现前面除了jstat之外大部分命令的功能

针对指定进程查看所有支持的命令

jcmd 44930 help

以下指令类似jps -m,查看所有jvm相关进程详细信息

jcmd -l

以下命令类似jstack

jcmd 44930 Thread.print

以下命令类似jmap -histo

jcmd 44930 GC.class_histogram

以下命令类似jmap -dump

jcmd 44930 GC.heap_dump /./Desktop/cmd_dump.hpro

以下命令,让JVM执行GC

jcmd 44930 GC.run

以下命令类似jstat -gc,查看gc执行时间

jcmd 44930 VM.uptime 

以下命令类似jinfo -sysprops,查看虚拟机参数

jcmd 44930 VM.system_properties

以下命令类似jinfo -flags

jcmd 45273 VM.flags

JSTATD

以上命令只能监控本机jvm程序,为了启用远程监控,则需要配合使用statd

图形化界面

使用命令行工具虽然能获取jvm相关信息,但是他们存在一些局限

  • 无法获取方法级别的分析数据,如方法间的调用关系、各方法的调用次数和调用时间等

  • 需要登录到宿主机上

  • 通过终端输出结果展示不够直观

jconsole

在JDK中自带的java监控和管理控制台.用于对JVM中内存、线程和类等的监控,是一个基于JMX的GUI性能监控工具

打开jconsole

jconsole

Visual VM

Visual VM是一个功能强大的多合一故障诊断和性能监控可视化工具,集成了多个jdk命令行工具,可以显示虚拟机进程,及进程的配置和环境信息,监视应用程序的cpu、gc、堆、方法去及线程的信息

visual vm支持插件,可以在此浏览链接,可以直接通过客户端的Tools-Plugins,在Available Plugins中选择自己需要的插件

  • 生成dump文件

可以通过右键Heap Dump生成,或者点击Monitor右侧Heap Dump按钮生成

  • 保存dump文件

  • 读取dump文件

可以通过Select Heap Dump to Compare多个dump文件或者本地服务进行对比

  • 监控线程

  • 抽样器

查看线程的占用情况

MAT

MAT是一款功能强大的dump文件分析器,生成内存泄漏报表,方便定位并分析问题.可以用于查找内存泄漏以及查看内存消耗情况,目前支持主流的hprof和phd格式的文件

  • 导入或着实时监控

通过Acquire Heap Dump获得指定jvm的dump文件

Open Heap Dump直接分析保存的dump文件

mat_dump

  • 生成报告

每次打开dump文件都会弹出一下向导,也可以在总览下面找到

分别是 内存泄漏疑点报告,组件报告,重新打开已经运行的报告

mat_wizard

  • 泄漏疑点报告 Leak Suspects

问题1 该线程所存储的局部变量占据了71.9%,内部有Object数组被cystem class loader加载占用71.9%

  • 直方图 Histogram

右键任意Class Name, Columns-Sort By排序

右键指定Class,Merge Shortest Paths to GC Roots-exclude all phantom/weak/soft etc. references ,该Class的非虚引用软引用的引用情况

点击最右侧按钮Select Baseline,实现多个dump进行对比

点击顶部<regex>正则表达式匹配

其中Shallow Heap 浅堆(该对象消耗的内存)

浅堆指对象本身占用的内存,不包括内部引用对象的大小

Retained Heap 深堆(该对象保留集中所有对象的浅堆之和)

对象的保留集指的是该对象被回收后,可以被回收的所有对象集合(包括该对象本身),即对象的保留集可以被认为是只能通过该对象直接或间接访问的所有对象的集合,也就是被该对象持有的所有对象集合

以下对象显示了一个简单的引用关系,对象A、D的浅堆大小仅包含本身

A的深堆大小是A、C,D的深堆大小为D、E

因为B被两个对象引用,所以无法计算在深堆内

mat_shallow_Retained Heap

  • 线程概述 thread overview

点击第四个按钮查看线程概述

其中包含<local>的代表是局部变量,有内存泄漏的可能,<class>代表该Class所引用到的Class

可以右键任意Object/Stack Fame,List objects查看对象输入(被关联)和输出(关联)的引用对象(引用链),查看是否被其他对象应用导致内存泄漏

elementData代表所关联到的实例(ArrayList内实际存储数据的数组)

  • 支配树 dominator_tree

支配树体现了对象实例之间的支配关系,在对象引用图中,所有对象都指向对象B路径都经过了对象A,则认为对象A支配了对象B.若对象A是离对象B最近的一个支配对象,则认为对象A是对象B的直接支配者

Arthas

以上的一些工具都需要在服务端进行相关的配置,然后才能通过远程连接到项目进程获取相关数据.然而线上的环境大多数情况下都是隔离的

Arthas可以在线排除问题无需重启、动态追踪代码,实时监控jvm状态

  • 使用,也可以直接在后面添加jvm的pid,当显示arthas的logo时说明开启成功

java -jar arthas-boot.jar
[INFO] JAVA_HOME: /Library/Java/JavaVirtualMachines/zulu-11.jdk/Contents/Home
[INFO] arthas-boot version: 3.7.2
[INFO] Process 90216 already using port 3658
[INFO] Process 90216 already using port 8563

成功后也可以选择web界面输入命令

127.0.0.1:3658

如果arthas没有正确退出,重新打开需要连接上一次监听的jvm,然后执行stop指令之后,再监听新的jvm进程

否则会出现异常

  • 查看日志

cat ~/logs/arthas/arthas.log
tail -f  ~/logs/arthas/arthas.log
  • 退出 exit退出当前客户端,stop退出arthas服务端并退出所有客户端

  • 查看帮助文档

java -jar arthas-boot.jar -h

arthas中的基础命令和linux中的类似具体参考文档

或者在命令后面追加-h

  • dashboard 查看jvm的概括信息

# 每0.5秒打印一次,累计4次
dashboard -i 500 -n 4
  • thread 获取jvm的所有线程

# 指定线程id
thread 1
# block状态的线程
thread -b
# 每0.5秒打印一次,累计4次
thread -i 500 -n 4
  • sysprops jvm的属性信息

类似jinfo

  • sysenv jvm的环境变量

  • headdump 导出当前jvm的堆转储文件

heapdump ./dump.hprof
# 指定存活的对象
heapdump --live ./dump.hprof
  • sc 查看已加载的类信息

# 指定包下的类列表
sc java.*
# 该类的详细信息
sc -d java.lang.String
# 并且追加成员变量信息
sc -f -d java.lang.String
  • sm 查看方法

# 查看类所有方法
sm java.lang.String
# 查看指定方法
sm java.lang.String toString
# 查看方法详细信息
sm -d java.lang.String
  • jad 反编译代码

# 反编译指定类
jad java.lang.String
# 反编译指定类下的指定方法
jad java.lang.String toString
  • mc 编译代码

mc ./Desktop/Test.java
  • redefine 热更新代码

将mc命令编译的代码执行redefine

  • retransform 热更新代码

本地编译好的代码通过retransform热更新

  • classloader 类加载器相关信息

# 以树形结构展示类加载器
classloader -t
# 获取类加载器加载类的实例和hash
classloader -l
# 获取指定hash的类加载器的加载情况
classloader -c 764c12b6
  • monitor 方法执行监控

返回时间戳、类名、方法名、调用次数、成功次数、失败次数、平均 RT、失败率

# 每5秒显示一次指定类 构造方法执行情况
monitor -c 5 java.util.ArrayList <init>

# 每5秒显示一次指定类 指定方法的执行情况
monitor -c 5 java.util.ArrayList add
  • watch 观察方法的调用情况

查看方法的调用情况,返回值、抛出异常、入参

# 查看指定类的方法的入参和返回值,结果遍历深度2
watch java.util.ArrayList add "{params, returnObj}" -x 2
  • trace 该方法调用路径,并输出该方法中每个方法的耗时情况

trace java.util.ArrayList add
  • stack 输出方法的完整调用路径

stack java.util.ArrayList add
  • tt 记录同一个方法不同参数传入后的调用时间

tt传递的参数只是引用,如果参数经过后续处理,导致-p可能无法传递准确结果

# 记录
tt -t java.util.ArrayList add

# 查询记录列表
tt -l

# 筛选
tt -s 'method.name=="query"'

# 查看index调用信息
tt -i 1000

# 传递和index一样的参数,再次调用
tt -i 1000 -p

# 指定重载方法
tt -t *Test queryMemberDetailedDTOList params.length==1
tt -t *Test print 'params[1] instanceof Integer'

# 需要释放内存,否则会导致OOM
tt --delete-all
  • profiler 火焰图

# 启动profiler
profiler start

# 采集到的样本数量
profiler getSamples

# profiler状态
profiler status

# 停止采集并导出
# 之后可以通过http://localhost:3658/arthas-output/
profiler stop --format html

OQL

语法和sql类似

  • select查询可以指定字段,添加objects关键字可以将字符串解析为对象

  • from指定查询类

SELECT objects v.elementData FROM java.util.ArrayList v 

查询ArrayList内所有对象的保留集

select as retained set * from java.util.ArrayList

查询指定地址的结构

如果是MAT可以通过Inspetor查看对象地址

用地址的好处是可以区分不同的类加载器加载的同一种类型

SELECT * FROM  0x6c00a9058
  • where添加过滤条件

select * from char[] s where s.@length > 10
select * from java.lang.String s where s.value != null
SELECT toString(f.path.value) FROM java.io.File f 

JVM参数

标准参数

-开头,比较稳定,后续版本基本不会变化

java -version

-X参数

-X开头,功能比较稳定,后续可能会发生变化

# 设置初始堆大小
-Xms
# 设置最大堆大小
-Xmx
# 设置线程堆栈大小
-Xss
# 设置新生代初始值和最大值
-Xmn 
# 解释模式、纯编译模式、混合模式
-Xint -Xcomp -Xmixed

-XX参数选项

以-XX开头,非标准参数,后续会发生变化

# OOM生成dump文件
-XX:+HeapDumpOnOutOfMemoryError 
# fullGC之前生成dump文件
-XX:+HeapDumpBeforeFullGC
# dump文件生成路径
-XX:HeapDumpPath 
# 输出详细GC处理日志
-XX:+PrintGCDetails
# 输出简略GC处理日志
-XX:+PrintGC
-verbose:gc      
# 设置新生代与老年代在堆空间的占比
-XX:NewRatio
# 设置新生代晋升所需最大年龄
-XX:MaxTenuringThreshold     

# 查看所有参数的默认初始值
-XX:+PrintFlagsInitial         
# 查看所有参数的最终值
-XX:+PrintFlagsFinal           
# 查看所有被新值覆盖的值
-XX:+PrintCommandLineFlags    

不建议修改的参数

-XX:+DisableExplicitGC # 禁止手动gc
-XX:ReservedCodeCacheSize # 指定jit代码缓存大小
-XX:+UseCodeCacheFlusing # jvm放弃一些被编译的代码
-XX:+DoEscapeAnalysis # 开启逃逸分析
-XX:+UseBiasedLocking # 开启偏向锁
-XX:+UseLargePages # 开启使用大页面
-XX:+UseTLAB # 使用TLAB
-XX:+PrintTLAB # 打印TLAB使用情况
-XX:+TLABSize # 设置TLAB大小

字节码内部指令

java代码,以下代码在实际文件中,在9行到13行

public static void main(String[] args) {
      LocalVariablesTest test = new LocalVariablesTest();
      int num = 10;
      test.test1();
}

javap -c命令反编译字节码文件

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #3                  // class test3/LocalVariablesTest
         3: dup
         4: invokespecial #4                  // Method "<init>":()V
         7: astore_1
         8: bipush        10
        10: istore_2
        11: aload_1
        12: invokevirtual #5                  // Method test1:()V
        15: return
      LineNumberTable:
        line 10: 0
        line 11: 8
        line 12: 11
        line 13: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  args   [Ljava/lang/String;
            8       8     1  test   Ltest3/LocalVariablesTest;
           11       5     2   num   I

jclasslib概述

方法名称为main方法

方法入参是一个一维字符串数组,L代表是一个引用对象,V代表返回值为void

([Ljava/lang/String;)V 是JNI字段描述符

返回标识是public static 对应的16进制就是0x0009

Bytecode是字节码指令,和javap返回的指令一致

Exception Table 异常表

Misc 杂项

  • Maximum stack size: 最大操作数栈大小

  • Maximum local variables: 局部变量表最大长度

  • Code length: 字节码指令长度(对应字节码指令0到15 )

行号对应表

java代码的行号与程序计数器行号的对应关系

局部变量表

Start PC 对应程序计数器的行号

Length 对应程序计数器指令地址的作用域

可以同过行号对应表和源代码行号对照,Length就是每个变量在源码中的最大范围

Start PC + Line Number均等于Code Length

Index 下标

Name 参数名称

Descriptor 描述

执行解析1

public class OperandStackTest {
    public void testAddOperation() {
        byte i = 15;
        int j = 8;
        int k = i + j;
    }
}

通过javap -v命令将反编译字节码文件

操作数栈最大长度2,局部变量表最大长度4

public void testAddOperation();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        15
         2: istore_1
         3: bipush        8
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: return
  • bipush 15

byte类型会被识别为int,并在操作数栈中压入int类型的15

执行解析

  • istore_1

将操作数栈的15弹出,并放入局部变量表索引1的位置

执行解析2

  • bipush 8

在操作数栈中压入int类型的8

执行解析3

  • istore_2

将操作数栈的8弹出,并放入局部变量表索引2的位置

执行解析4

  • iload_1

获取局部变量表中索引1的位置,并压入操作数栈中

执行解析5

  • iload_2

获取局部变量表中索引2的位置,并压入操作数栈中

执行解析6

  • iadd

操作数栈中的8和15依次出栈,计算为23后压入操作数栈中

执行解析7

  • istore_3

将23出栈,并放入局部变量表中索引3的位置

执行解析8

  • return

方法结束,整个栈帧从虚拟机栈中弹出

执行解析2

public class MethodAreaDemo {
    public static void main(String[] args) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a + b);
    }
}

通过javap指令转换,关键内容截取

// 常量池部分
Constant pool:
   #1 = Methodref          #5.#24         // java/lang/Object."<init>":()V
   #2 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Methodref          #27.#28        // java/io/PrintStream.println:(I)V
   #25 = Class             #31            // java/lang/System
   #26 = NameAndType       #32:#33        // out:Ljava/io/PrintStream;
   #31 = Utf8              java/lang/System
   #32 = Utf8              out
   #33 = Utf8              Ljava/io/PrintStream;
// 方法部分   
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=5, args_size=1
         0: sipush        500
         3: istore_1
         4: bipush        100
         6: istore_2
         7: iload_1
         8: iload_2
         9: idiv
        10: istore_3
        11: bipush        50
        13: istore        4
        15: getstatic     #2          // Field java/lang/System.out:Ljava/io/PrintStream;
        18: iload_3
        19: iload         4
        21: iadd
        22: invokevirtual #3          // Method java/io/PrintStream.println:(I)V
        25: return
  • sipush 500

将500压入操作数栈中

执行解析2_1

  • istore_1

将操作数栈中的500弹出,并放入局部变量表索引1的位置

执行解析2_2

  • bipush 100

将100压入操作数栈中

执行解析2_3

  • istore_2

存入本地变量表中

执行解析2_4

  • iload_1

将局部变量表下标1的位置取出,压入操作数栈中

执行解析2_5

  • iload_2

将局部变量表下标2的位置取出,压入操作数栈中

执行解析2_6

  • idiv

将操作数栈中的数据依次去除,后者除以前者,结果压入操作数栈中

执行解析2_7

  • istore_3

将操作数栈中的5弹出,存入本地变量表中

执行解析2_8

  • bipush 50

将50压入操作数栈中

执行解析2_9

  • istore 4

50弹出操作数栈,放入局部变量表中

执行解析2_10

  • getstatic #2

从常量池中取出#2,压入操作数栈中

执行解析2_11

  • iload_3

将操作数栈下标3的元素去除,压入操作数栈中

执行解析2_12

  • iload 4

将操作数栈下标4的元素压入操作数栈中

执行解析2_13

  • iadd

将操作数栈50和5弹出,相加后压入操作数栈

执行解析2_14

  • invokevirtual #3

弹出#2调用#3(println方法),控制台输出55

执行解析2_15

  • return

执行结束return,操作数栈弹出


评论