2_jvm篇



JVM组成部分

在这里插入图片描述

整个JVM的组成部分如上图所示整体由两个子系统(类加载子系统、执行引擎)和两个组件(运行时数据区、本地接口)组成。

  • 类加载子系统(Class loader):根据类的全限定名来装载class文件数据到运行时数据区的方法区中。
  • 执行引擎(Execution engine):用于执行字节码中的指令。
  • 运行时数据区(Runtime data area):这就是我们常说的JVM内存,里面存储的Java代码运行时的数据。
  • 本地接口(Native interface):与本地方法库交互,是Java和其他编程语言交互的接口。

JVM的整体作用:先通过编译器将Java代码编译成字节码文件,再由类加载器将字节码文件加载到内存中,将其放在运行时数据区的方法区中,而字节码文件只是JVM的一套指令集规范,底层的操作系统并不能够识别字节码指令,因此不能直接交给操作系统去执行,需要由特定的解释器执行引擎将字节码翻译成底层操作系统能够执行的系统指令,再交由CPU去执行,而这个过程中需要使用本地接口去调用其它编程语言写的代码来实现。

运行时数据区

Java虚拟机在执行Java程序的过程中会将它所管理的内存区域划分为几个不同的数据区域。这些区域各自有各自的用途以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

在这里插入图片描述

如图所示:其中jdk1.7和jdk1.8的主要有以下差异

  • jdk1.7有永久代,但是已经将字符串常量池和静态变量放在了堆上,逐步减少对永久代的使用。
  • jdk1.8没有永久代,使用元数据区替代方法区,运行时常量池、类常量都保存在元数据区,但是字符串常量仍然放在堆内存中,元数据区也就是我们说的元空间(Mate Space)。

JVM内存各区域介绍

堆(Heap)

在这里插入图片描述

堆内存是JVM运行时内存中最大的一块,同时也是GC主要工作的地方,是被所有线程共享的,几乎所有的对象实例都在堆上分配内存,但是随着JIT编译器的的发展和逃逸分析技术的逐渐成熟(从JDK1.7开始就已经默认开启逃逸分析了),如果发现未逃逸的对象则可以通过同步消除标量替换栈上分配的优化方式进行优化。堆内存中又被细分为两个部分:

1、新生代(Young Generation):新生代占整个堆内存的1/3,它又被细分为以下几个部分

  • Eden:伊甸区,几乎所有的新对象首先都会在Eden空间中分配内存,Eden区默认占整个新生代区域的8/10(可调)。
  • Survivor Space:当Eden区满了之后,会触发一次Minor GC,这个时候还存活的对象会被移动到其中一个Survivor区(通常是Form Survivor)。
  • Survivor空间包括Form Survivor和To Survivor两部分,每经历一次GC都会当前Survivor区将存活的对象复制到另一个Survivor区,Form Survivor和To Survivor默认各占整个新生代区域的1/10(可调)。

2、老年代(Old Generation):老年代占整个堆内存的2/3,老年代用于存放长时间存活的对象,通常是在新生代经历多次(默认15次,可调)垃圾回收后仍然存活的对象会被移动到老年代,或者通过担保机制的大对象会直接被分配在老年代。

逃逸分析:逃逸分析是一种编译器优化技术,用于确定对象的生命周期和作用域。通过逃逸分析,编译器可以确定对象是否会被其他线程所引用,或者是否会在当前线程中逃逸到堆上,进而决定是否将该对象分配在栈上或堆上。逃逸分析分为以下两种情况:

  • 方法逃逸:方法逃逸指的是在方法中创建的对象在方法结束后仍然被其他代码引用或持有。这种情况下,对象的引用可能会被返回给调用者,或者传递给其他方法作为参数,使得对象的生命周期延长到了创建该对象的方法的作用域之外。因此,编译器无法将该对象分配在栈上,而必须将其分配在堆上,以确保对象在方法结束后仍然可以被访问到。
  • 线程逃逸:线程逃逸指的是在一个线程中创建的对象被其他线程所引用或持有。在多线程环境下,如果一个对象的引用逃逸到了其他线程,那么该对象就不能安全地分配在栈上,因为栈上的数据是线程私有的,其他线程无法访问。因此,对象必须在堆上进行分配,以确保所有线程都能够访问到该对象。

同步消除:线程同步本身比较耗性能,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁。

标量替换:经过逃逸分析之后如果发现有未逃逸的对象,那么可以通过标量替换优化技术将对象拆分成其各个成员变量的形式,比如将一个对象拆分成多个基本类型变量。这样一来,这些基本类型变量就可以分别分配在栈上,而不需要将整个对象分配在堆上。

栈上分配:故名思议就是在栈上分配对象,其实目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替
换。

方法区(Method Area)/元空间(Mate Space)

在jdk1.7中的方法区用于存放运行时常量池和类常量池等,在HotSpot虚拟机中方法区的实现也被称为永久代(在其他虚拟机中可能并不存在永久代的概念,永久代只是作为HotSpot虚拟机堆方法区的一种具体实现),永久代的内存是固定的,所以比较容易出现OOM(Out Of Memory)。

所以在jdk1.8的时候使用元空间替代了原本的方法区,在jdk1.8之后运行时常量池和类常量池等信息便被存放在了元空间,元空间与方法区最大的不同便是由JVM内存中移到了本地内存中,默认情况下,元空间的大小仅受本地内存的限制,说白了也就是以后不会因为永久代空间不够而抛出OOM异常出现了。但是也可以通过相关配置进行手动分配元空间的内存大小。-XX:MetaspaceSize=512M用于设置初始的元空间大小,即在JVM启动时分配给元空间的内存大小,例如我这里为元空间初始分配512M内存。-XX:MaxMetaspaceSize=1024M用于设置元空间的最大大小,即元空间允许占用的最大内存大小,例如我这里设置元空间的最大内存为1024M。如果元空间内存不足时也会抛出OOM。

Java虚拟机栈(JVM Stack)

Java虚拟机栈其实就是使用的物理意义上的栈结构,每一个方法在执行的同时都会创建一个栈帧(stack frame),用于存放局部变量表、操作数栈、动态链接、方法出口等信息。在方法从调用到执行完成,都对应着栈帧从虚拟机中入栈和出栈的过程。最终,栈帧会随着方法的创建到结束而销毁。

在这里插入图片描述

  • 局部变量表:局部变量表也被称为局部变量数组或本地变量表,定义为一个数组,主要用于存储方法参数和定义在方法体内的局部变量。局部变量表以变量槽(Variable Slot)为最小单位,每一个变量槽都能存储一个各类基本数据类型、对象引用(reference)、以及returnAddress类型的数据,并且局部变量表在class文件中就已经定义好了。
  • 操作数栈:操作数栈也常被称为操作栈,它是一个先进后出(FIFO)的栈,当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。
  • 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另一部分将在每一次运行期间都转换为直接引用,这部分就被称为动态链接。
  • 方法出口:当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(正常调用完成);另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理(异常调用完成)。方法出口就是记录的在方法退出后的位置,正常退出和异常退出有不同的处理方式。

接下来我们通过一个代码案例来模拟一下整个虚拟机栈的运行流程,首先编写一个测试类:

public class VMStackTest {
    public static void main(String[] args) {
        int num = a();
        System.out.println(num);
    }

    public static int a() {
        return b();
    }

    private static int b() {
        int x = 10;
        int y = 20;
        return x + y;
    }
}

当main方法执行后,会依次执行两个方法a() -> b() -> 返回,我们使用javap -v命令将以上代码进行反编译:

Classfile /D:/IDEA/idea_workspace/practice-demo/target/classes/com/practice/jvm/VMStackTest.class
  Last modified 2023-11-19; size 761 bytes
  MD5 checksum aaea7eaa184b46bbc0633b896238c0ae
  Compiled from "VMStackTest.java"
public class com.practice.jvm.VMStackTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#29         // java/lang/Object."<init>":()V
   #2 = Methodref          #6.#30         // com/practice/jvm/VMStackTest.a:()I
   #3 = Fieldref           #31.#32        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #33.#34        // java/io/PrintStream.println:(I)V
   #5 = Methodref          #6.#35         // com/practice/jvm/VMStackTest.b:()I
   #6 = Class              #36            // com/practice/jvm/VMStackTest
   #7 = Class              #37            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/practice/jvm/VMStackTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               num
  #20 = Utf8               I
  #21 = Utf8               MethodParameters
  #22 = Utf8               a
  #23 = Utf8               ()I
  #24 = Utf8               b
  #25 = Utf8               x
  #26 = Utf8               y
  #27 = Utf8               SourceFile
  #28 = Utf8               VMStackTest.java
  #29 = NameAndType        #8:#9          // "<init>":()V
  #30 = NameAndType        #22:#23        // a:()I
  #31 = Class              #38            // java/lang/System
  #32 = NameAndType        #39:#40        // out:Ljava/io/PrintStream;
  #33 = Class              #41            // java/io/PrintStream
  #34 = NameAndType        #42:#43        // println:(I)V
  #35 = NameAndType        #24:#23        // b:()I
  #36 = Utf8               com/practice/jvm/VMStackTest
  #37 = Utf8               java/lang/Object
  #38 = Utf8               java/lang/System
  #39 = Utf8               out
  #40 = Utf8               Ljava/io/PrintStream;
  #41 = Utf8               java/io/PrintStream
  #42 = Utf8               println
  #43 = Utf8               (I)V
{
  public com.practice.jvm.VMStackTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/practice/jvm/VMStackTest;

  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: invokestatic  #2                  // Method a:()I
         3: istore_1
         4: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: iload_1
         8: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        11: return
      LineNumberTable:
        line 5: 0
        line 6: 4
        line 7: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  args   [Ljava/lang/String;
            4       8     1   num   I
    MethodParameters:
      Name                           Flags
      args

  public static int a();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: invokestatic  #5                  // Method b:()I
         3: ireturn
      LineNumberTable:
        line 10: 0

  public static int b();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: bipush        10
         2: istore_0
         3: bipush        20
         5: istore_1
         6: iload_0
         7: iload_1
         8: iadd
         9: ireturn
      LineNumberTable:
        line 14: 0
        line 15: 3
        line 16: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            3       7     0     x   I
            6       4     1     y   I
}
SourceFile: "VMStackTest.java"

可以看到反编译后我们整个方法的最大操作数栈、局部变量表、方法的符号引用等信息都是已经确定好的,当我们的程序开始执行时,会将这些信息封装为对应的栈帧,我们从main方法开始看起:

在这里插入图片描述

首先由main方法的栈帧入栈,执行到指令0: invokestatic #2 // Method a:()I时,需要调用a()方法,这个时候main方法就不会往下执行了,而是去执行a()方法,那么同样的,执行a()方法的同时也就意味着a()方法的栈帧应该入栈,进入栈顶位置,main方法的栈帧就会被压下去:

在这里插入图片描述

此时执行到a()方法的0: invokestatic #5 // Method b:()I指令时,又会去调用b()方法,接着b()方法的栈帧入栈,也是进入栈顶的位置,将之前的栈帧继续向下压:

在这里插入图片描述

现在依次执行b()方法中的指令,最后返回x+y的值,在方法b()返回之后,也就意味着方法b()执行结束,这个时候b()方法对应的栈帧3就会出栈,a()方法的栈帧2就会得到b()方法的返回结果,然后就会继续执行a()方法还未执行完的指令,由于这里a()方法的下一条指令就是直接返回结果,所以a()方法的栈帧2执行完毕之后也会出栈并将结果继续交给下一个栈帧,这里也就是我们main方法的栈帧1,这个时候main方法就会接着执行后续的指令,最后输出得到的结果。

在这里插入图片描述

本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈基本差不多,唯一不同的就是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。另外,与 Java 虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常,关于本地方法到这里就不多介绍了。

程序计数器(Program Counter Register)

程序计数器是一块较小的内存区域,是线程私有的,如果执行的是Java方法,那么程序计数器通过记录当前线程所执行字节码指令的行号,来判断线程的分支、循环、跳转、异常、线程切换和恢复等操作。如果是执行的本地方法则为空。并且这是JVM内存中唯一没有OOM的内存区域。

类加载系统

Java中的所有类,都需要编译之后由类加载器将class文件装载到JVM中才能运行,当然类加载器也是一个类,并且类加载器本身也是由其他类加载器加载的,这里可能会让人有点混乱,后续会进行解释说明。

Java的类加载机制遵循着“按需加载”的原则,也就是说,只有在需要用到某个类的时候,才会将这个类的相关信息加载到内存中,这是为了节省内存开销。

在Java中类的装载方式大概可以分为两种:

  • 隐式加载:程序在运行过程中当碰到通过new 等方式生成对象时,如果这个类还未加载到内存中,那么会隐式的调用类装载器加载对应的类到jvm中
  • 显示加载:通过Class.forName()等方法,显式加载需要的类

类的生命周期和加载过程

在这里插入图片描述

JVM 类加载过程分为加载链接初始化使用卸载这五个阶段,在链接中又包括:验证准备解析

  • 加载:加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制class file格式的字节流,简单点说就是找到文件系统中、jar 包中、网络下载中、运行时生成的、或存在于任何地方的class 文件。 如果找不到二进制表示形式,则会抛出 NoClassDefFound 错误。

    装载阶段并不会检查class file的语法和格式。 类加载的整个过程主要由 JVM 和 Java 的类加载系统共同完成, 当然具体到loading阶段则是由 JVM 与具体的某一个类加载器(java.lang.classLoader)协作完成的。

  • 链接:包括了三个阶段;

    • 验证:确保被加载类的正确性,验证字节流是否符合 class 文件规范,例如验证魔数(magic number),以及版本号等。
    • 准备:为类的静态变量分配内存并设置变量初始值(例如null或者0值)等,但是如果是常量,在这个阶段就会被赋予最终值。
    • 解析:解析出常量池数据和属性表等信息,将符号引用替换为直接引用(符号引用可以理解为一个标识,而直接引用直接指向内存中的地址),这里会包括 ConstantPool 结构体以及 AttributeInfo 接口等。
  • 初始化:类加载完成的最后一步就是初始化,目的就是为static标记的字段赋值,以及执行 <clinit> 方法的过程。JVM虚拟机通过锁的方式确保 clinit 仅被执行一次.

  • 使用:程序代码执行使用阶段。

  • 卸载:程序代码退出、异常、结束等。

类加载器和类加载机制(双亲委派)

类加载过程可以描述为通过一个类的全限定名来获取描述此类的 Class 对象,这个过程由“类加载器(ClassLoader)”来完成。系统自带的类加载器分为三种:

  • 启动类加载器(BootstrapClassLoader)
  • 扩展类加载器(ExtClassLoader)
  • 应用类加载器(AppClassLoader)

一般启动类加载器是由 JVM 内部实现的,并且不是由 Java 代码编写的。它是由 C/C++ 等语言编写的,因此启动类加载器可以直接访问底层操作系统的资源,如文件系统、网络等。启动类加载器在 Java 的 API 里无法拿到,但是我们可以侧面看到和影响它(后面的内容会演示)。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在sun.misc.Launcher中定义的,扩展类加载器和应用类加载器一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。

在这里插入图片描述

1、启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib下rt.jar、resources.jar、cahrset.jar等jar包里所有的class)。它可以看做是 JVM 自带的,我们在代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。

2、扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。

3、应用程序类加载器(application class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。

此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用程序类加载器作为父加载器。应用程序类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。

类加载机制有三个特点

  • 双亲委派:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去加载,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载此类,如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。使用双亲委派机制可以保证Java程序的稳定运行,可以避免了类的重复加载,保证了Java核心API不可被篡改,如果我们在代码中也创建了一个java.lang.Object的类,再通过双亲委派加载的时候它只会通过BootStrapClassLoader启动类加载器加载到jdk自己的Object类,如果它发现已经加载过Object类了,那么就不会再加载自己写的Object类了。
  • 负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
  • 缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。

自定义类加载器

除了 BootstrapClassLoader是JVM自身的一部分之外,其他所有的类加载器都是在JVM外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。每个ClassLoader可以通过getParent()方法获取其父ClassLoader,如果获取到的ClassLoader为null,那么该类就是通过BootstrapClassLoader加载的。

刚才也说到了除了BootstrapClassLoader其他的类加载器都是继承自ClassLoader抽象类,那么我们如果要自定义类加载器,也是通过继承ClassLoader的方式来实现。

/**
 * 定义一个需要加载的类
 */
public class MyClass {
    static {
        System.out.println("MyClass Initialized Success!");
    }
}


/**
 * 自定义类加载器,加载指定路径下的类
 */
public class MyClassLoader extends ClassLoader {
    private final File classPathFile;
    private static final String PATH = "your calss file path";

    public MyClassLoader(String absolutePath) {
        this.classPathFile = new File(absolutePath);
    }

    @Override
    protected Class<?> findClass(String name) {
        // 获取指定路径下的指定类名
        File file = new File(classPathFile, name.replaceAll("\\.", "\\\\") + ".class");

        // 判断文件是否存在
        if (file.exists()) {
            try {
                // 获取到文件的输入流
                FileInputStream fis = new FileInputStream(file);
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                byte[] buffer = new byte[1024];
                int len;
                // 将流写入到字节数组输出流
                while ((len = fis.read(buffer)) != -1) {
                    out.write(buffer, 0, len);
                }

                // 获取类的字节码文件
                byte[] codeBytes = out.toByteArray();

                // 通过defineClass方法将二进制数组转变为一个Class文件
                return defineClass(name, codeBytes, 0, codeBytes.length);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return null;
    }

    /**
     * 通过自定义的类加载器将字节码文件加载到内存中,并且初始化
     */
    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        // PATH 为字节码存储路径
        Class<?> myClass = new MyClassLoader(PATH).findClass("com.practice.jvm.MyClass");
        // 初始化
        myClass.newInstance();
    }

}



// 控制台输出结果:
// MyClass Initialized Success!

HotSpot虚拟机对象

Java对象模型

Java对象模型是指Java语言中对象的内存布局和访问方式,Java对象在内存中的存储结构及其对应的操作方式。Java对象模型基于Java虚拟机规范,它定义了Java对象在内存中的存储布局和访问方式,以及Java虚拟机如何处理对象的生命周期。Java对象模型包括以下几个方面:

  • 对象头:每个Java对象在内存中都有一个对象头,用来存储运行时对象状态的相关信息、指向该对象所属类的元数据的指针、数组长度(若是数组)。
  • 实例数据:各个属性字段的值,还会包含父类的字段。存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响。
  • 对齐填充:由于jvm中对象的大小被要求向8字节对齐(必须是8字节的整倍数),因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。
对象头

Java对象头由以下几个部分组成

  • 标记字段(Mark Word):Mark Word 是对象运行时的状态信息,8个字节长度,其中包括哈希码、分代年龄、锁状态、偏向锁信息等。对象头中 Mark Word 大小是和机器字长保持一致的,对于 32 位的计算机 Mark Word 长度为 32 bit(4 个字节),64 位计算机 Mark Word 长度为 64 bit(8 个字节),下面就以 64 位虚拟机为例介绍一下 Mark Word 的具体结构:

    在这里插入图片描述

  • 类型指针(Klass Pointer):指向对象类型数据的指针,4个字节长度,因为默认开启指针压缩,否则8个字节长度。只有虚拟机采用直接指针的对象访问定位方式才需要在对象上记录类型指针,而采用句柄的对象访问定位方式则不需要此指针,Hotspot 采用的就是直接指针定位方式,所以需要该指针指向具体的类元信息(Class 对象)。

  • 数组长度(Array Length):普通实例对象的长度可以从类元(Class 对象)信息中推断出来,但是数组类型的实例对象长度是不能提前确定的,只有在创建时才能确定,数组对象创建之后其长度又是固定不变的,所以需要在对象的对象头中专门开辟一块内存空间来记录数组的长度,4个字节长度。

实例数据

普通对象和 Class 对象的实例数据区域是不同的
1、普通对象: 包括当前类声明的实例字段以及父类声明的实例字段,不包括类的静态字段
2、Class 对象: 包括当前类声明的静态字段和方法表等

对齐填充

需要对齐填充的目的是方便数据的存储和读取,如果当前数据长度不足 8 byte 的整数倍,为了方便下个数据存储,需要将数据长度补齐为 8 Byte 的整数倍

对象的创建方式

在Java中创建对象的方式通常有以下几种:

创建方式解释
使用new关键字调用了构造函数
使用Class的newInstance方法调用了构造函数
使用Constructor类的newInstance方法调用了构造函数
使用clone方法没有调用构造函数
使用反序列化没有调用构造函数

对象创建流程

在这里插入图片描述

当虚拟机遇到一条new指令时会经历以下步骤创建对象

1、类加载检测:在方法区,找类的符号引用。检查该类有无被加载过,如果没有则执行类加载过程,否则直接准备为新的对象分配内存。

2、内存分配(两种方式):选用哪种方式取决于堆内存是否规整,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

  • 指针碰撞:内存分配是规整的,一部分是使用过的内存,一部分是没使用的内存,指针作为分界。每次创建对象分配内存的时候,计算并移动指针。
  • 空闲列表:虚拟机维护了一个空闲列表,存储哪些内存是没有被分配的,需要分配空间的时候去查该空闲列表进行分配并对空闲列表做更新。

3、并发处理(两种方式):

  • CAS:冲突失败则自旋挂起,重试直到成功为止。
  • TLAB(本地线程分配缓冲):在堆中为每一个线程分配一小块独立的内存,当一个对象需要在堆内存中分配时,JVM首先会尝试将对象分配到当前线程的TLAB中。TLAB是线程私有的,每个线程都有自己独立的TLAB,用于快速分配对象而无需进行同步操作,这样一来就不存并发问题了。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+UseTLAB参数来设定虚拟机是否使用TLAB。Java 层面与之对应的是 ThreadLocal 类的实现。

4、默认值初始化:为对象的属性设置默认值。

5、设置对象头:将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。

6、执行init方法进行初始化:对JVM,执行init构造方法前,对象的创建已经结束了。但对于java程序来说,构造方法执行完才算结束。init方法包括: 父类变量初始化,父类代码块,父类构造函数。子类变量初始化,子类代码块,子类构造函数。

对象的访问定位

Java程序需要通过 JVM栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM虚拟机的实现。目前主流的访问方式有以下两种:

  • 句柄访问:Java堆中划分出一块内存作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体结构如下图所示:

    在这里插入图片描述

    优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

  • 直接指针:JVM栈内的引用直接指向堆内对象实例的地址,具体结构如下图所示:

    在这里插入图片描述

    优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot虚拟机中采用的就是这种方式。

Java垃圾回收

对象内存分配

对象优先在Eden区分配

大多数情况下对象都是在年轻代的Eden区分配内存,当Eden区没有足够的内存空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

下面我们通过代码来实际测试一下:

/**
 * JVM启动参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 *
 * -Xms20M:指定堆初始化大小为20M
 * -Xmx20M:指定堆最大大小为20M
 * -Xmn10M:指定新生代大小为10M,剩下的10M为老年代大小
 * -XX:+PrintGCDetails:开启GC详细日志
 * -XX:SurvivorRatio=8:设置Eden区和Survivor区的比例为8:1:1
 */
public class GCTest {
    public static void main(String[] args) {
        int _1MB = 1024 * 1024;

        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[5 * _1MB]; // 出现一次Minor GC
    }
}

运行以上代码,出现以下GC日志:

[GC (Allocation Failure) [PSYoungGen: 6630K->888K(9216K)] 6630K->4992K(19456K), 0.0028485 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 8293K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 90% used [0x00000000ff600000,0x00000000ffd3b7e8,0x00000000ffe00000)
  from space 1024K, 86% used [0x00000000ffe00000,0x00000000ffede010,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 4104K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff002020,0x00000000ff600000)
 Metaspace       used 3491K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

通过代码和日志分析可得:在代码中尝试分配三个2M和一个5M大小的对象,由于在运行时通过-Xms20M、 -Xmx20M、 -Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给
新生代,剩下的10MB分配给老年代。 -XX:SurvivorRatio=8 决定了新生代中Eden区与一个Survivor区
的空间比例是8∶1,从输出的结果也可以清晰地看到 eden space 8192K、from space 1024K、to
space 1024K 的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。在执行代码的时候为allocation4对象分配内存是会发生一次MinorGC,因为Eden区总大小为8M,前三个对象已经占用了6M,剩余的空间已经不足以分配allocation4所需的内存了,因此会触发MinorGC,而触发MinorGC会将Eden区的部分对象尝试移动到Survivor区,但是Survivor区的大小不足以放下这些对象,那么这些对象将通过分配担保机制直接进入到老年代。我们可以通过日志ParOldGen total 10240K, used 4104K看到最终老年代的大小使用了百分之40,那么说明有两个2M的对象直接进入了老年代,eden space 8192K, 90% used日志则显示Eden区使用了百分之90,对应的就是未被分配到老年代的其中一个2M的对象和5M的allocation4。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数
组。

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

  • G1 垃圾回收器会根据-XX:G1HeapRegionSize参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent参数设置的阈值,来决定哪些对象会直接进入老年代。
  • Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。
长期存活的对象将进入老年代

为了在内存回收时能识别哪些对象应放在新生代,哪些对象应放在老年代中。虚拟机给每个对象定义了
一个对象年龄(Age)计数器。大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1),对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。但是为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,而是根据动态对象年龄判断来作为进入老年代的年龄阈值。

动态对象年龄判断

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent来设置),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。也就是说如果在Survivor空间中有相同年龄所有对象大小的总和大于Survivor空间的一半,如果这个年龄比MaxTenuringThreshold 的值更小,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保机制

空间分配担保是为了确保在Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间。

JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

对象引用类型

在Java语言中,除了基本数据类型外,其他的都是指向各类对象的对象引用,根据其生命周期的长短,将引用分为4类。

不同的引用类型,主要体现的是对象不同的可达性状态和对垃圾收集的影响。

  • 强引用:最常见的普通对象引用,通过关键字new创建的对象所关联的引用就是强引用,发生 gc 的时候不会被回收。
  • 软引用:软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回
    收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
  • 弱引用:弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
  • 虚引用(幽灵引用/幻象引用):虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态,在这个对象被 gc 时返回一个系统通知。

如何确定垃圾

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有以下两种方式:

  • 引用计数器:为每个对象创建一个引用计数器,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。效率很高但是缺点是不能解决循环引用的问题;
  • 可达性分析:定义一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。但是要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍
    然是可回收对象,则将面临回收。这也是HotSpot虚拟机使用的方式。

可达性分析详解

1、GC Roots对象作为起始点开始向下搜索,搜索路径为引用链。当一个对象到GC Roots没有任何引用链相连,则会被第一次标记。

2、第一次标记后检查对象是否重写了finalize() 和是否已经被调用了finalize()方法。若没有重写finalize()方法或已经被调用,则进行回收。

3、如果对象重写了finalize()方法,且还未执行过,那么会被插入到F-Queue队列中,等待由一个虚拟机自动创建的、低优先级的finalizer线程触发其finalize()方法执行,另外GC为了预防finalize内部的死循环对后续队列的影响,对F-Queue队列中的对象进行的小规模标记。。

4、在第二次标记之前,对象如果执行finalize()方法并重新与GC Root引用链相连,则救赎成功,对象则不会被回收。否则完成第二次标记,进行回收。值得注意的是finalize()方法并不可靠。

哪些对象可以作为GC Roots

GC Roots的对象一般包括以下几种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象

  • 本地方法栈(Native 方法)中引用的对象

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • 所有被同步锁持有的对象

  • JNI(Java Native Interface)引用的对象

垃圾回收算法

标记-清除

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:由于标记的过程需要遍历所有的 GC ROOT,清除的过程也要遍历堆中所有的对象,标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

标记-清除算法的执行的过程如下图所示:

在这里插入图片描述

标记-整理

标记-整理算法(Mark-Compact)算法,与标记-清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清除算法存在的内存碎片问题。

缺点:需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示:

在这里插入图片描述

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将已使用的内存空间一次清理掉。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示:

在这里插入图片描述

分代收集算法

由于对象所占用的内存大小和生命周期不同,适合的垃圾算法也不相同,于是当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般分为新生代老年代,新生代一般采用复制算法,老年代一般采用标记-整理算法。

如图所示:

在这里插入图片描述

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、ParNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

在这里插入图片描述

Serial垃圾收集器(单线程、复制算法)

Serial是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程(Stop The World),直到垃圾收集结束。Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial 垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。

ParNew垃圾收集器(多线程版Serial)

ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。ParNew虽然是除了多线程外和Serial收集器几乎完全一样,但是ParNew垃圾收集器是很多java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。

Parallel Scavenge(多线程、复制算法、高效)

Parallel Scavenge收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU用于运行用户代码的时间 / CPU总消耗时间,即吞量= 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。并且这也是JDK8的默认新生代垃圾收集器。

Serial Old(单线程、标记-整理算法)

Serial Old是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。
Serial Old主要有以下两个用途:

  • 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  • 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

Parallel Old(多线程、标记-整理算法、高效)

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

CMS(多线程、标记-清除算法)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间(Stop The World)为目标的收集器。它非常符合在注重用户体验的应用上使用。并且它是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

  • 初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
  • 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

CMS垃圾收集器的主要优点:并发收集、低停顿

CMS垃圾收起及的主要缺点:对CPU资源敏感、无法处理浮动垃圾、标记-清除算法会造成大量碎片空间

G1(标记-整理算法)

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

G1在JDK7中加入JVM,在JDK9中成为了默认的垃圾收集器,它在物理上不再区分年轻代和老年代,但是在逻辑上还是分代。并且G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域,每一个区域称之为Region。

整体结构图如下图所示:

在这里插入图片描述

G1收集器默认将整个Java堆划分成约2048个大小相同的独立Region块,每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。对于大对象的存储则衍生出 Humongous 的概念。超过 Region 大小一半的对象会被认为是大对象,而超过整个 Region 大小的对象被认为是超级大对象,将会被存储在连续的 N 个 Humongous Region 中。

可预测的停顿时间模型

基于Region的停顿时间模型是G1能够建立可预测的停顿时间模型的前提。G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1收集器会去跟踪各个Region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。

每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是Garbage First名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

回收过程

G1 的回收过程分为以下四个步骤:

  • 初始标记:标记 GC ROOT 能关联到的对象,需要暂停所有工作线程。
  • 并发标记:从 GCRoots 的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象,这个过程比较耗时但是不会暂停工作线程。
  • 最终标记:重新标记那些在并发标记阶段发生变化的对象,需要暂停所有工作线程。
  • 筛选回收:更新 Region 的统计数据,对每个 Region 的回收价值和成本排序,根据用户设置的停顿时间制定回收计划,再把Region 中存活对象复制到空的 Region,同时清理旧的 Region,需要暂停所有工作线程。

总的来说除了并发标记之外,其他几个过程也还是需要短暂的 STW,G1 的目标是在停顿和延迟可控的情况下尽可能提高吞吐量。

Safe Point(安全点)

Safepoint 是指在 Java 虚拟机中,程序执行时的一个安全点。在 Safepoint 处,所有的线程都会被暂停下来,以便进行特定的操作。这些操作包括垃圾回收、线程栈的扫描、安全点同步等。

在 Java 中,Safepoint 是由 JVM 控制的。当 JVM 检测到需要进行安全操作时,它会请求所有线程达到一个安全点,并暂停它们的执行。只有在所有线程都到达安全点后,JVM 才能继续执行特定的操作。

例如在可达性分析算法进行根节点选举时,必须暂停全部的用户线程,我们把这个过程称为“Stop The Word”(此时说的必须暂停全部用户线程是因为GC时必须使全部线程进入安全点)

在HotSpot的解决方案中,是使用一组称为OopMap的数据结构来存放这些对象的引用(OopMap在类加载动作完成时生成)。也就是说当用户线程暂停下来之后,其实并不需要一个不漏的检查完所有的执行上下文和全局的引用位置。而是直接通过OopMap来获取栈上或寄存器里哪里有GC管理的引用指针。

OopMap解决了一部分问题,但没有解决所有的问题。试想一下,对象中的引用关系并非一成不变,如果每次执行一条字节码指令都去生成一个OopMap那就必须消耗大量额外的存储空间。

为了解决这个问题HotSpot并没有让每条指令都生成OopMap,而是只在特定的位置生成OopMap,这个位置就被称为安全点(safe point)

监控与调优

JDK 命令行工具

JDK本身为我们提供了以下一些命令工具,可以用来查看虚拟机参数等信息:

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息。例如:jps -l用于输出正在运行的java程序的主类名。

  • jstat(JVM Statistics Monitoring Tool): 虚拟机统计信息监视工具,监视虚拟机运行时状态信息,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。例如:jstat -gc 7972 250 20用于每隔250ms输出进程id为7972的堆情况。

  • jinfo (Configuration Info for Java) :Java配置信息工具,实时查看和调整虚拟机参数。例如:jinfo -flags 4668用于打印4668进程的常规参数配置。

  • jmap (Memory Map for Java) : Java内存映射工具,生成堆dump文件。例如:jmap -heap 7972用于查看指定进程的堆信息。

  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。例如:jhat C:\Users\Desktop\heap.hprof用于分析指定的dump文件。

  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。例如:jstack 4668用于输出进程4668的快照。

可视化分析工具

  • JConsole:对jvm的内存,线程,类,cpu等进行监视。
  • Jvisualvm:jdk自带全能工具。
  • MAT:基于Eclipse的堆内存分析工具。

GC调优思路

对于 GC 调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput)

基本的调优思路可以总结为:

  • 理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
  • 掌握 JVM 和 GC 的状态,定位具体问题,确定是否有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
  • 接着需要思考选择的 GC 类型是否符合我们的应用特征,具体问题表现在哪里。是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。
  • 通过分析确定具体调整的参数或者软硬件配置。
  • 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复进行分析、调整、验证

常用JVM调优参数

  • -Xms2g:初始化堆大小为 2g;
  • -Xmx2g:堆最大内存为 2g;
  • -Xmn1g:新生代内存大小为1g;-XX:NewSize 新生代大小,-XX:MaxNewSize 新生代最大值,-Xmn 则是相当于同时配置 -XX:NewSize 和 -XX:MaxNewSize 为一样的值;
  • -XX:NewRatio=2:设置新生代的和老年代的内存比例为 1:2,即新生代占堆内存的1/3,老年代占堆内存的2/3;
  • -XX:SurvivorRatio=8:设置新生代 Eden 和 两个Survivor 比例为 8:1:1;
  • –XX:+UseParNewGC:对新生代使用并行垃圾回收器。
  • -XX:+UseParallelOldGC:对老年代并行垃圾回收器。
  • -XX:+UseConcMarkSweepGC:以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。
  • -XX:+PrintGC:开启打印 gc 信息;
  • -XX:+PrintGCDetails:打印 gc 详细信息。

性能调优总结

1、性能调优要做到有的放矢,根据实际业务系统的特点,以一定时间的JVM日志记录为依据,进行有针对性的调整、比较和观察。

2、性能调优是个无止境的过程,要综合权衡调优成本和更换硬件成本的大小,使用最经济的手段达到最好的效果。

3、性能调优不仅仅包括JVM的调优,还有服务器硬件配置、操作系统参数、中间件、线程池、数据库连接池、数据库本身参数以及具体的数据库表、索引、分区等的调整和优化。

4、通过特定工具检查代码中存在的性能问题并加以修正是一种比较经济快捷的调优方法。

CMS 和 G1 都是更侧重于低延迟的 GC 选项。

  • 通过分析确定具体调整的参数或者软硬件配置。
  • 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复进行分析、调整、验证

常用JVM调优参数

  • -Xms2g:初始化堆大小为 2g;
  • -Xmx2g:堆最大内存为 2g;
  • -Xmn1g:新生代内存大小为1g;-XX:NewSize 新生代大小,-XX:MaxNewSize 新生代最大值,-Xmn 则是相当于同时配置 -XX:NewSize 和 -XX:MaxNewSize 为一样的值;
  • -XX:NewRatio=2:设置新生代的和老年代的内存比例为 1:2,即新生代占堆内存的1/3,老年代占堆内存的2/3;
  • -XX:SurvivorRatio=8:设置新生代 Eden 和 两个Survivor 比例为 8:1:1;
  • –XX:+UseParNewGC:对新生代使用并行垃圾回收器。
  • -XX:+UseParallelOldGC:对老年代并行垃圾回收器。
  • -XX:+UseConcMarkSweepGC:以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。
  • -XX:+PrintGC:开启打印 gc 信息;
  • -XX:+PrintGCDetails:打印 gc 详细信息。

性能调优总结

1、性能调优要做到有的放矢,根据实际业务系统的特点,以一定时间的JVM日志记录为依据,进行有针对性的调整、比较和观察。

2、性能调优是个无止境的过程,要综合权衡调优成本和更换硬件成本的大小,使用最经济的手段达到最好的效果。

3、性能调优不仅仅包括JVM的调优,还有服务器硬件配置、操作系统参数、中间件、线程池、数据库连接池、数据库本身参数以及具体的数据库表、索引、分区等的调整和优化。

4、通过特定工具检查代码中存在的性能问题并加以修正是一种比较经济快捷的调优方法。

  • 16
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值