跟随黑马面Java-9,JVM篇

第九篇:JVM篇

“求其上,得其中;求其中,得其下,求其下,必败”

新版Java面试专题视频教程,java八股文面试全套真题+深度详解(含大厂高频面试真题)_哔哩哔哩_bilibili

开篇


Java Virtual Machine,Java程序的运行环境(java二进制字节码的运行环境)

  • 好处:

    • 一次编写,到处运行
    • 自动内存管理,垃圾回收机制
    image-20240519095430428
  • JVM组成部分

    image-20240519095602165

JVM组成


image-20240519101224623
什么是程序计数器

JVM程序计数器(Program Counter Register)是一块很小的内存区域,用于存储当前线程所执行的字节码指令地址,即 将要执行的指令代码。它是线程私有的内存区域,每个线程都有自己独立的程序计数器,生命周期与线程保持一致。

  • 你可以使用javap -v xx.class打印堆栈大小,局部变量的数量和方法的参数。

    image-20240519100424773
  • 程序计数器主要是保存线程执行的字节码的行号,记录正在执行的字节码指令的地址

    image-20240519100633759
image-20240519100718881
Java堆

线程共享的区域:主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutofMemoryError异常。

  • Java堆

    image-20240519101224623

    JVM堆是Java虚拟机所管理的内存中最大的一块,用于存储对象实例及数组。根据对象的生命周期不同,JVM堆可分为新生代和老年代。

    • 新生代

      新生代又分为Eden区和两个Survivor区(From Survivor和To Survivor)。当新对象被创建时,会被分配到Eden区。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,对Eden区和两个Survivor区进行垃圾回收。经过垃圾回收后仍存活的对象,将被复制到其中一个Survivor区。

    • 老年代

      老年代存放经过多次Minor GC后仍然存活的对象,以及一些大对象。当老年代内存不足时,虚拟机将发起一次Full GC,对整个堆内存进行垃圾回收。

  • 元空间

    image-20240519102154511

    JVM元空间(Metaspace)是从Java 8开始引入的一个新的内存区域,用于替代永久代(PermGen)。它存储在本地内存中,而不是虚拟机内存。

    • 元空间的作用

      元空间主要用于存储类的元数据信息,包括:

      1. 类信息:类的元数据信息,如类名、访问修饰符、父类、实现的接口、字段、方法等。

      2. 常量:运行时常量池,存储编译期生成的字面量和符号引用。

      3. 编译后的代码:方法字节码,即已被虚拟机加载的类的代码内容。

      4. JIT编译后的代码缓存。

    • 为什么要使用元空间代替永久代

      **主要为了避免OOM内存溢出。**元空间使用本地内存,大小只受本地内存限制,并且垃圾收集更高效。

      1. 永久代的内存空间有限制,容易导致内存溢出。永久代使用的是JVM的内存空间,大小是有限制的,默认为64M。当加载的类信息较多时,很容易导致永久代内存溢出(OutOfMemoryError: PermGen space)。

      2. 永久代的垃圾回收效率低下。永久代的垃圾收集效果比较难令人满意,尤其是类型的卸载条件相当苛刻。主要回收两部分内容:废弃的常量和不再使用的类型。类卸载困难会导致内存泄漏。

      3. 永久代位于堆内存之内,增加了内存占用。永久代虽然是方法区的实现,但它却被设计在堆内存之中,这种设计增加了内存的占用量。

      4. 元空间使用本地内存,受本地内存限制。元空间使用的是本地内存,理论上可以使用的内存空间更大,只受本地内存限制。默认为无限,避免了永久代内存溢出的风险。

      5. 元空间可动态调整内存大小。元空间不在虚拟机内存中,可以动态调整内存大小,无需重启虚拟机。

      6. 元空间和永久代有不同的垃圾回收行为。元空间的垃圾收集更加高效,只使用两级分代收集器,不需要配置永久代。

image-20240519102656017
虚拟机栈
什么是虚拟机栈
  • Virtual Machine Stack

    • 虚拟机栈是每个线程运行时所需要的内存,它先进后出。

      如果有多个线程想要运行,就会创建多个虚拟机栈。所以,栈内存也是线程安全的(线程之间不能相互访问)。

    • 每个栈由多个栈顺(frame)组成,对应看每次方法调用时所占用的内存。

    • 每个线程只能有一个活动栈顿,对应着当前正在执行的那个方法

  • 虚拟机栈的作用

    虚拟机栈描述的是Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 栈帧的结构

    1. 局部变量表(Local Variables)
    2. 操作数栈(Operand Stack)
    3. 动态链接(Dynamic Linking)
    4. 方法返回地址(Return Address)

    其中局部变量表用于存放方法参数和局部变量,操作数栈用于保存计算过程的中间结果。动态链接和方法返回地址是为了支持方法调用和返回。

垃圾回收是否涉及栈内存?

垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放。

栈内存分配越大越好吗?未必,默认的栈内存通常为1024k。

栈顿过大会导致线程数变少,例如,机器总内存为512m,自前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半。

方法内的局部变量是否线程安全?
  • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的

  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

如果想要确保线程安全,就应避免让其他方法调用的影响,而是使用局部变量。在以下示例中,m1()是线程安全的,因为它没有参数也没有返回值;m2()m3()则不是线程安全的。m2()由于有参数传递,而这个参数可能被其他线程修改,导致不确定性。而m3()虽然没有参数传递,但有返回值,这个返回值可能因为多线程环境下的竞态条件而受到影响。

image-20240519103951139
什么情况下会导致栈内存溢出?
  • 栈溢出有两种主要情况:一是栈内存溢出,通常由于过多的递归调用造成;另一种是栈太大导致的溢出。

image-20240519104546965 image-20240519104619189
能不能解释一下方法区

方法区是JVM规范中定义的一个逻辑概念,而永久代和元空间分别是不同JVM实现中方法区的不同实现方式。在JDK7及以前,方法区是永久代,在JVM堆内存中;JDK8及以后,方法区是元空间,使用的是本地内存。

  • 方法区

    • 方法区(Method Area)是各个线程共享的内存区域
    • 方法区的作用
      • 存储类信息:方法区存储每个类的结构信息,如运行时常量池、字段信息、方法信息、构造函数信息、类初始化信息等元数据。
      • 存储静态变量:方法区为类中的静态变量分配内存空间,并在类加载时进行静态变量赋值操作。
      • 存储JIT编译后的代码:JIT(Just-In-Time)编译器在运行时将字节码编译为本地机器码,并存储在方法区中以提高运行效率。
    • 虚拟机启动的时候创建,关闭虚拟机时释放
    • 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError:Metaspace

    这段代码演示了如何通过不断生成类的二进制字节码来导致元空间内存溢出。在Java8中,类的元数据存储在元空间(Metaspace)中,而不是传统的永久代。当我们不断生成新的类,并加载它们时,元空间的内存占用会不断增加。在这段代码中,通过循环生成大量的类,每个类都有自己的二进制字节码,并通过ClassLoader的defineClass方法加载这些类,导致元空间内存不断增长,最终触发OutOfMemoryError: Metaspace异常。这段代码不会导致堆内存溢出的原因是它并没有创建大量的对象实例,而是不断创建类的定义和对应的字节码。这些类定义存储在元空间(Metaspace)中,而不是堆内存中。

    image-20240519110410733
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * 演示元空间内存溢出java.lang.OutOfMemoryError: Metaspace
    * -XX:MaxMetaspaceSize=8m
    */
    public class MetaspaceDemo extends ClassLoader { // 可以用来加载类的二进制字节码
    public static void main(String[] args) {
    MetaspaceDemo test = new MetaspaceDemo();
    for (int i = 0; i < 10000; i++) {
    // classWriter作用是生成类的二进制字节码
    ClassWriter cw = new ClassWriter(i: 0);
    // 版本号,public,类名,包名,父类,接口
    cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "class" + i, null, "java/lang/Object", null);
    // 返回byte[]
    byte[] code = cw.toByteArray();
    // 执行了类的加载
    test.defineClass("class" + i, code, 0, code.length); // class 对象
    }
    }
    }
  • 常量池

    常量池是Class文件中的一部分,用于存储编译期生成的字面量和符号引用。可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

    下图所示的方法中的机器指令需要去常量池中查找对应的信息。

    image-20240519112352214
  • 运行时常量池

    运行时常量池是方法区的一部分,用于存储常量池中的符号引用。在类加载的解析阶段,虚拟机将常量池中的符号引用替换为直接引用,指向运行时内存中的目标实体。常量池包含在*.class文件中,当类被加载时,其常量池信息会被放入运行时常量池,并将其中的符号地址转换为真实地址。

    运行时常量池还会在运行期间动态地向其中添加一些新的常量,例如String.intern()方法会把字符串对象的引用添加到运行时常量池中。

    image-20240519112639042
image-20240519112753532
你听过直接内存吗

直接内存:并不属于VM中的内存结构,不由VM进行管理。是虚拟机的系统内存,常见于NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高。IO缓冲区在堆内存中分配的。

  • 举例:Java代码完成文件拷贝

    在使用Java代码完成文件拷贝时,我们可以选择常规IO或者NIO两种方式进行操作。但实际上,使用NIO的效率更高。

    image-20240519113430230
    • 常规IO操作:

      Java本身不具备磁盘读写能力,必须调用操作系统的方法(native)。首先会将Java的运行状态由用户态切换到内核态,切换到内核态后,由系统的函数去读取磁盘中的文件。读取出来后在系统内存中划出一块缓冲区,先将文件存入系统缓冲区(分批次)。但是对于此缓冲区,Java代码不能直接访问,会在堆中分配一块内存,这对应我们代码中new了一个byte[]。Java代码要访问刚才读到的数据,必须从系统缓冲区中间接读入到Java缓冲区;读入后再调用输出流的写入操作,进行反复读写,才能将文件复制到一个指定位置。

      image-20240519114627539
    • NIO操作:

      常规IO过程中有两个缓冲区,效率不高。对于直接内存,Java代码能访问它,系统也能访问它。使用直接内存,对比常规IO少了一次缓冲区复制操作,效率自然更高。

      image-20240519114756598
image-20240519114815094

类加载器


什么是类加载器、双亲委派机制
类加载器

类加载器(ClassLoader)是Java虚拟机的一个重要组件,主要负责将class文件加载到JVM中,并为已加载类创建一个java.lang.Class对象实例。

  • 类加载器的作用:

    1. 将class文件加载到内存中,并创建一个java.lang.Class对象实例。
    2. 确保JVM中有且只有一个Class实例。即使多个ClassLoader加载同一个Class文件,内存中只存在一个Class实例。
    3. 实现类的动态加载,如代码热替换、模块化程序设计等。
  • 类加载器的种类:

    1. 启动类加载器(Bootstrap ClassLoader):负责加载Java核心类库,如rt.jar、resources.jar等位于JAVA_HOME/jre/lib下的,无法被Java程序直接引用。
    2. 扩展类加载器(Extension ClassLoader):负责加载Java扩展类库,如%JAVA_HOME%/jre/lib/ext目录下的jar包。
    3. 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上的类库。

除了上述三种,用户还可以自定义类加载器(Customize ClassLoader),以实现特殊需求。

image-20240519125023561
双亲委派机制
  • 双亲委派模型

    双亲委派模型是Java虚拟机的一种类加载机制。它的工作原理是:当一个ClassLoader实例需要加载某个类时,它会先把这个请求委派给父类加载器去完成,每一个层次的ClassLoader实例都是如此,因此所有的类加载请求最终都应该被传递到最顶层的启动类加载器中。

    image-20240519125637347
  • 为什么JVM采用双亲委派模型

    1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。

    2. 防止核心API被篡改。

      如果我们自定义一个String类,它的包名也是java.lang,它其中定义了一个方法,我们尝试运行将会报错。如下:

      image-20240519130043100
  • 打破双亲委派模型

    虽然双亲委派模型是很好的设计,但也存在一些场景需要打破它:

    1. 使用线程上下文类加载器

      这是最常见的一种,比如SPI实现、JNDI服务等会用到线程上下文类加载器。它打破了双亲委派模型的委派规则,可以对指定的类使用当前线程的类加载器进行加载。

    2. 重写loadClass方法

      我们可以自定义一个类加载器,重写其loadClass方法,在该方法中先委派父加载器加载,若父加载器无法加载则使用自身的加载逻辑。这种方式打破了双亲委派模型的父优先原则。

    3. 使用热部署、代码热替换

      这种场景下需要自定义类加载器加载新的类文件,而不能使用双亲委派模型。

    4. 扩展或修改某些核心类

      比如修改java.net.URLConnection等核心类,需要自定义类加载器直接加载这些类。

    5. Tomcat容器打破双亲委派模型

      Tomcat容器会为每个Web应用创建一个独立的类加载器实例,以实现不同Web应用的类加载隔离。

    要打破双亲委派模型,直接重写loadClass方法即可。但是不能破坏启动类加载器的工作,否则会导致程序崩溃。

image-20240519130148380
类装载的执行过程

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。

image-20240519135842384
加载(Loading)
  • 通过类的全名,获取类的二进制数据流。

  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)

  • 创建java.lang.Class类的实例,代表该类。作为方法区这个类的各种数据的访问入口

当一个类(比如Person类)被类加载器加载后,它会被存入运行时数据区。这个数据区分为方法区(元空间)和堆。在方法区,存储着该类的结构信息,包括构造函数、方法、字段等;而在堆中,存放的是该类的Class对象。当我们创建类的实例时(比如"张三"、“李四”),这些对象的对象头都会指向堆中的Class对象。但是,具体的类数据(比如构造函数、方法、字段等)需要通过方法区才能获取。因此,Person的Class对象可以在方法区中找到Person类的信息,并根据这个信息结构来创建这些实例对象。

image-20240519141353629
链接(Linking)
  • 验证(Verify): 确保加载的类信息符合JVM规范,没有安全方面的问题。

    image-20240519141711813
  • 准备(Prepare): 为类的静态变量分配内存并设置默认初始值。

    • static变量在准备阶段分配空间,基本类型会被设置为默认值(int类型为0),真正赋值在初始化阶段完成。
    • 对于static变量是final的基本类型或字符串常量,其值在准备阶段已确定,直接赋值,而非null。
    • 对于static变量是final的引用类型,同样在准备阶段初始化,实际赋值也在初始化阶段完成。
    1
    2
    3
    4
    5
    6
    public class Application {
    static int b = 10; // 准备阶段:b = 0;初始化阶段:b = 10
    static final int c = 20; // 准备阶段和初始化阶段:c = 20
    static final String d = "hello"; // 准备阶段和初始化阶段:d = "hello"
    static final Object obj = new Object(); // 准备阶段:obj = null;初始化阶段:obj = new Object()
    }
  • 解析(Resolve): 将类的二进制数据中的符号引用转换为直接引用。

    比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。

    下图中,#21这种是符号引用,java/io/PrintStream才是真正的方法

    image-20240519142733920
初始化(Initialization)
  • 对于类的静态变量和静态代码块的初始化操作,遵循以下规则:

    1. 当初始化一个类时,如果其父类尚未初始化,则会优先初始化其父类。

    2. 如果一个类中同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

      • 静态变量赋值:

        • JVM会为类的静态变量分配内存并设置默认初始值(如0、false、null等),然后按照源码中定义的顺序执行显式的静态变量赋值操作。
      • 静态代码块执行:

        • JVM会执行类中的静态代码块,这些代码块是使用static关键字修饰的,位于类中的任何静态方法之外,并按照出现的顺序执行。
      • 静态方法执行:

        • 如果存在显式的静态方法调用,相应的静态方法也会在初始化阶段被执行。
        • 执行顺序为:
          1. 静态变量和静态代码块按照源码顺序执行。
          2. 静态方法执行。
      • 静态变量只会在程序第一次主动使用该类时才会被初始化。

    3. 当子类访问父类的静态变量时,只会触发父类的初始化。

使用(Usage)

JVM 开始从入口方法开始执行用户的程序代码,下述操作将被视为使用此类。

  • 调用静态类成员信息(比如:静态字段、静态方法)

  • 使用new关键字为其创建对象实例

卸载(Uninstall)

当一个类的所有实例对象都已经被回收,并且在运行时常量池中没有其他地方引用这个类的常量时,JVM就可以卸载这个类了。

image-20240519145311809

垃圾回收


对象什么时候可以被垃圾器回收

垃圾回收主要指的是堆中的数据。简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法

  • 引用计数法

    一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。

    • 示例

      下述案例中,demo是个String类型。一开始它是引用到了堆中的一个String(“123”),后面demo又被赋为null;那么栈对堆中对象的引用将会断开,现在的引用次数也就是0。

      image-20240519150436490 image-20240519150154375
      • 当对象间出现了循环引用的话,则引用计数法就会失效

        • 现在的Demo类的成员变量中包含了一个Demo对象instance,现在我们创建两个Demo对象a,b,然后让a的instance指向b,b的instance指向a,它们现在在内存中的状况如左图所示。它们的ref都是2(a:栈中引用和b的引用;b:栈中引用和a的引用)。

          然后再将a,b分别指向null,那么栈中两个变量就不再引用堆中那两个内存了,就会断开。断开后,ref就分别减一,但是它们都不为0。即使它们没有被使用,也不会被回收。如右图所示。出现了这种情况,就是循环引用了,容易引发内存泄漏。

        image-20240519151025365 image-20240519151632709
  • 可达性分析算法:

    现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。

    Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它的工作原理是扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象。如果找不到这样的引用链,表示该对象不再被任何活动对象所引用,可以被回收。

    • 下图中,X-Y节点是可回收的。

      image-20240519152022020
    • 哪些对象可以作为GC Root

      1. 虚拟机栈(栈帧中的本地变量表)中引用的对象:例如,一个方法中定义了一个局部变量,引用了一个对象实例。这个对象实例就可以作为GC Root。例如:

        1
        2
        3
        4
        public void method() {
        Object obj = new Object(); // obj 是局部变量,引用了一个对象实例
        // obj 可以作为GC Root
        }
      2. 方法区中 类的静态属性引用的对象:例如,一个类的静态变量引用了一个对象实例。这个对象实例就可以作为GC Root。例如:

        1
        2
        3
        4
        public class Example {		  // 类
        public static Object obj; // 静态变量引用了一个对象实例
        // obj 可以作为GC Root
        }
      3. 方法区中常量引用的对象:例如,一个常量池中的常量引用了一个对象实例。这个对象实例就可以作为GC Root。例如:

        1
        2
        3
        4
        public class Example {
        public static final Object obj = new Object(); // 常量引用了一个对象实例
        // obj 可以作为GC Root
        }
      4. 本地方法栈中JNI(Native方法)引用的对象:例如,一个Native方法中引用了一个对象实例。这个对象实例也可以作为GC Root。

image-20240519154624651
JVM垃圾回收算法有哪些
垃圾回收算法 描述
标记-清除(Mark-Sweep)算法 标记阶段标记出所有需要回收的对象,清除阶段统一回收被标记的对象。它的缺点是会产生内存碎片。
复制(Copying)算法 将内存分为两块,每次只使用其中一块,当一块用完时将存活对象复制到另一块,然后清理已使用的那一块。
标记-整理(Mark-Compact)算法 先标记存活对象,然后将所有存活对象压缩到内存的一端,之后清理边界外所有空间。避免了内存碎片,但需要移动对象。
分代收集(Generational Collection)算法 根据对象存活周期,将内存划分为新生代和老年代,使用不同的垃圾回收算法。新生代使用复制算法,老年代使用标记-整理或标记-清除算法。
  • 标记清除算法

    将垃圾回收分为两个阶段:标记和清除。

    1. 标记阶段:根据可达性分析算法标记出需要回收的垃圾对象。

    2. 清除阶段:对标记为可回收的对象进行垃圾回收。

    优点:标记和清除速度较快。缺点:碎片化严重,内存不连续。

    image-20240519155028027
  • 标记-整理算法

    标记-整理算法与标记清除算法相似,都包含标记和清除阶段,但标记-整理算法额外增加了整理阶段。这一阶段将碎片化的内存整理到连续的内存空间上,提高了内存的可用性。虽然解决了标记清除算法的碎片化问题,但由于需要对象移动内存位置,其效率受到一定影响。

    image-20240519155421729
  • 复制算法

    复制算法将内存分为两块,在进行垃圾回收时,将存活的对象复制并整理到另一块内存区域,等复制完成,全部清空原来的内存即可。

    优点:在存在大量垃圾对象的情况下,具有较高的效率。清理后,内存无碎片。

    缺点:分配的两块内存空间,在同一时刻只能使用其中一半,导致内存使用率较低。

    image-20240519155632628
image-20240519160117501
JVM中分代回收
  • 分代收集算法

    在Java 8 默认的垃圾回收算法中,堆被分为了两个部分:新生代和老年代,比例为 1:2。在新生代中,又被划分为三个部分:

    • 伊甸园区(Eden):新创建的对象都分配在这里。

    • 幸存者区(Survivor),分成了 from 和 to 区。(from,to只是为了做区分起的逻辑名称而已)

    其中,伊甸园区、from 区和 to 区的比例为 8:1:1。

    image-20240519160417244
  • 分代收集算法-工作机制

    1. 新创建的对象首先被分配到伊甸园区。
    2. 当伊甸园区内存不足时,会标记伊甸园区和 from 区(如果存在)中的存活对象。
    3. 存活对象使用复制算法被复制到 to 区,完成复制后,伊甸园区和 from 区内存都被释放。
    4. 一段时间后,伊甸园区的内存再次不足,会标记伊甸园区和 to 区中的存活对象,将存活对象复制到 from 区。
    5. 当幸存区对象经过多次回收(最多15次)后,会晋升到老年代。若幸存区内存不足或存在大对象,则可能提前晋升。
    image-20240519162046236
  • MinorGC、MixedGC、FullGC的区别是什么

    image-20240519162707504
    类型 描述 特点
    Minor GC 发生在新生代的垃圾回收,暂停时间短(STW) 对新生代进行部分回收
    Mixed GC 新生代+老年代部分区域的垃圾回收,G1 收集器特有 针对新生代和老年代的部分区域进行回收
    Full GC 新生代+老年代完整垃圾回收,暂停时间长(STW) 对整个堆内存进行完整的回收
    • STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成。

image-20240519162809950
JVM有哪些垃圾回收器

在JDK8中,默认的垃圾回收器是Parallel Scavenge(新生代)与Parallel Old(老年代)的组合。ParNew 通常是与CMS并发标记扫描垃圾收集器搭配使用的年轻代收集器。

在 JVM 中,实现了多种垃圾收集器,包括:

  • 串行垃圾收集器

  • 并行垃圾收集器

  • CMS(并发)垃圾收集器

  • G1 垃圾收集器

串行垃圾收集器

在 JVM 中,Serial 和 SerialOld 串行垃圾收集器使用单线程进行垃圾回收。它们适用于堆内存较小的场景,比如个人电脑。Serial 收集器作用于新生代,采用复制算法;而 SerialOld 收集器作用于老年代,采用标记-整理算法。在垃圾回收时,只有一个线程在工作,并且 Java 应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

当程序运行到某个安全点时,就会触发垃圾回收。这时将会阻塞所有线程,而有一个单独的线程区进行垃圾回收。当此垃圾回收器运行结束,将恢复原来阻塞的线程。

image-20240519163356088
并行垃圾回收器

ParallelNew 和 ParallelOld 是并行垃圾回收器,是 JDK 8 默认使用的垃圾回收器。

  • ParallelNew 作用于新生代,采用复制算法。

  • ParallelOld 作用于老年代,采用标记-整理算法。

在垃圾回收时,多个线程同时工作,并且 Java 应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

image-20240519163827947
CMS(并发)垃圾回收器

CMS全称Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。

image-20240519164258259

在CMS垃圾回收过程中,首先进行初始标记,仅标记与GC Root相关联的对象。然后,在并发标记阶段,追踪GC Root的引用链,并标记沿途的对象为存活对象。接着是重新标记阶段,因为在并发标记完成后,原先未标记为存活对象的对象被重新引用(与GC Root树关联),或者原先被标记为存活对象的对象的引用为0,需要将其作为垃圾对象回收。完成重新标记后,进行并发清理阶段,清理时其他线程也可以继续正常运行。

image-20240519164925046 image-20240519165317309
G1垃圾回收器

在JDK9及以后的版本中,默认使用G1垃圾回收器,它适用于新生代和老年代。G1将堆内存划分为多个区域,每个区域都可以充当Eden、Survivor、Old、以及专为大对象准备的Humongous。该回收器采用复制算法,并兼顾了响应时间和吞吐量。G1的垃圾回收过程分为三个阶段:新生代回收、并发标记和混合收集。如果并发失败(即回收速度赶不上创建新对象速度),会触发FulIGC。

image-20240519165733630
  • Young Collection (年轻代垃圾回收)

    初始时,所有区域都处于空闲状态。随着对象的创建,一些空闲区域被选为伊甸园区存储这些对象。当伊甸园区需要垃圾回收时,会选择一个空闲区域作为幸存区,并使用复制算法复制存活对象。这个过程需要暂停用户线程。

    下图中,表示整个堆空间被划分为大小相等的区域,每个区域都可用作伊甸园、幸存者、老年代。随着对象增加,伊甸园区被填满,触发新生代的垃圾回收。G1中,伊甸园的垃圾回收采用复制算法,标记存活对象后复制到幸存者区域,其余区域释放。尽管复制算法需要暂停,但由于幸存者对象较少,暂停时间较短。

    image-20240519171405507

    随着时间流逝,伊甸园内存再次不足,将伊甸园及之前幸存区中的存活对象采用复制算法,复制到新的幸存区中;较老的对象晋升到老年代。然后,这些伊甸园区和上一次的幸存者区就可以释放了。

    image-20240519171746385
  • Young Collection + Concurrent Mark (年轻代垃圾回收+并发标记)

    • 当老年代内存使用超过默认阈值(通常为45%)时,会触发并发标记。此时不会暂停用户线程。然而,它还会执行一次查漏操作,处理未正确标记的对象,进行重新标记。这个过程需要STW。

    • 完成重新标记后,系统了解了老年代中的存活对象,并进入混合收集(E,S,O区都进行收集)阶段。在这个阶段,不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象较少)的区域。这也是“Gabage First”名称的由来。

      image-20240519172517129
  • Mixed Collection (混合垃圾回收)

    在混合收集阶段,参与复制的区域包括伊甸园Eden、幸存者Survivor和老年代Old。

    在这个阶段,伊甸园区和幸存者区中的存活对象会被复制到一个新的幸存者区(左图中蓝箭头所示)。同时,老年代区域中的存活对象数量较少的区域,以及达到一定阈值的对象,也会被复制到一个新的老年区(左图中红箭头所示)。完成这些复制后,原来的伊甸园、幸存者和老年代区域就可以被释放(右图)。

    image-20240519173207360 image-20240519173334460

    在后续可能进行多次混合收集,将剩下的老年代再重新标记、清理之后,内存也逐渐释放了。当进行到了多次的混合收集,又会进行下一轮的新生代回收、并发标记、混合收集。

    当然,如果垃圾回收速度跟不上分配新对象的速度,那这时候就可能并发失败,这将会触发一次FullGC,导致所有线程阻塞。

    如果一个对象太大了,一个区域装不下,会分配一个连续的区域来存储巨型对象。

    image-20240519174105501
image-20240519174132244
强引用、软引用、弱引用、虚引用的区别
  • 强引用(Strong Reference)

    这是我们最常见的普通对象引用。只要GC Root有强引用指向一个对象,该对象就不会被垃圾回收器回收。强引用是造成内存泄漏的主要原因之一。

    image-20240519185453734
  • 软引用(SoftReference)

    如果一个对象只具有软引用,在垃圾回收后,内存仍不足时将会再次触发回收内存。

    image-20240519185504513
  • 弱引用(WeakReference)

    弱引用的生命周期比软引用短。只要垃圾回收器线程扫描到它,不管当前内存空间足够与否,都会回收这个弱引用指向的对象。弱引用同样适用于内存敏感的缓存,比软引用更加激进。

    image-20240519185658849
  • 虚引用(PhantomReference)

    虚引用是所有引用中最弱的一种,无法通过它访问对象的任何属性或函数。它仅仅是提供了一种确保对象被finalize以后,做某些事情的机制。虚引用必须和引用队列(ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象内存之前,将这个虚引用加入引用队列。虚引用主要用于跟踪对象被垃圾回收的活动。

    image-20240519190603301
image-20240519190634645

JVM实践


JVM调优的参数可以在哪里设置参数值
  • war包部署在tomcat中设置

    修改%CATALINA_HOME%/bin/catalina.sh文件

    image-20240519190925999
  • jar包部署在启动参数设置

    通常在linux系统下直接加参数启动springboot项目

    1
    nohup java -Xms512m -Xmx1024m -jar xxx.jar --spring.profiles.active=prod &
    • nohup:用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行

    • 参数&:让命令在后台执行,终端退出后命令仍旧执行

  • jar包在Docker中运行

    1. 在DockerFile中编辑ENTRYPOINT

      1
      2
      3
      FROM openjdk:8-jdk-alpine
      COPY target/myapp.jar /app.jar
      ENTRYPOINT ["java", "-Xms512m", "-Xmx1g", "-jar", "/app.jar"]
    2. 使用CMD指令

      1
      2
      3
      FROM openjdk:8-jdk-alpine 
      COPY target/myapp.jar /app.jar
      CMD ["java", "-Xms512m", "-Xmx1g", "-jar", "/app.jar"]
    3. 在docker run时传递参数

      1
      docker run -e "JAVA_OPTS=-Xms512m -Xmx1g" myapp

      这里的-e参数用于设置容器的环境变量JAVA_OPTS。在容器内部,你需要从代码或者ENTRYPOINT/CMD中读取该环境变量的值作为JVM启动参数。


JVM调优的参数都有哪些?

对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。

你可以通过访问Java HotSpot VM Options (oracle.com)来查看VM参数。

设置项 VM指令 举例 说明
堆空间大小 -Xms<size> -Xmx<size> -Xms512m -Xmx1024m 不指定单位默认为字节;指定单位,按照指定的单位设置。可用单位:k,m,g
虚拟机栈设置 -Xss<size> -Xss256k 默认1m,一般256k就够
年轻代中Eden区和两个Survivor区的大小比例 -XX:SurvivorRatio=<value> -XX:SurvivorRatio=6 表示Eden区的大小占比,默认为8:1:1
年轻代晋升老年代阈值 -XX:MaxTenuringThreshold=<value> -XX:MaxTenuringThreshold=10 表示经过多少次挪动会晋升成为老年代,0-15,默认15
垃圾回收收集器设置 -XX:+Use<CollectorName> -XX:+UseG1GC 表示使用什么样的垃圾回收器
设置项 VM指令 说明
设置heapdump目录 -XX:HeapDumpPath=${目录} 表示设置heapdump的生成目录
设置heapdump在OOM时生成 -XX:+HeapDumpOnOutOfMemoryError 表示配置当程序发生内存溢出时自动生成 dump 文件
  • 设置堆空间大小

    设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。

    image-20240519192605401

    设置多少合适:

    • 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64。

    • 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生stw,暂停用户线程。

    • 堆内存大肯定是好的。但也存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长

  • 虚拟机栈的设置

    虚拟机栈的设置:每个线程默认会开启1M的内存,用于存放栈顿、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

    image-20240519192809473
  • 年轻代中Eden区和两个Survivor区的大小比例

    设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。

    image-20240519193124283
  • 年轻代晋升老年代阈值

    image-20240519194904457
    • 默认为15

    • 取值范围0-15

  • 设置垃圾回收收集器

    通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器

    image-20240519195217518
image-20240519195528619
JVM调试的工具
工具 描述 类型 使用 说明 示例
jps 查看进程状态信息 命令行工具 在目录下运行jps命令 image-20240519200340761
jstack 查看Java进程内线程的堆栈信息 命令行工具 jstack [option] <pid> image-20240519200514732
jmap 查看堆转储信息 命令行工具 jmap -heap pid显示Java堆的信息
jmap -dump:format=b,file=heap.hprof pid
-XX:+HeapDumpOnOutOfMemoryError-XX:+HeapDumpPath=/home/app/dumps/
format=b表示以hprof二进制格式转储Java堆的内存
file=<filename>用于指定快照dump文件的文件名。
image-20240519201101673
jhat 堆转储快照分析工具 命令行工具
jstat JVM统计监测工具 命令行工具 jstat -gcutil pid总结垃圾回收统计
jstat -gc pid垃圾回收统计
是VM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等 image-20240519201326408image-20240519201338958
jconsole 用于对JVM的内存,线程,类的监控 可视化工具 java 安装目录 bin目录下直接启动jconsole.exe 用于对jvm的内存,线程,类的监控,是一个基于 jmx 的 GUI 性能监控工具 image-20240519201526454
VisualVM 能够监控线程,内存情况 可视化工具 java 安装目录 bin目录下 直接启动jvisualvm.exe就行 能够监控线程,内存情况,查看方法的CPU时间和内存中的对象,已被GC的对象,反向查看分配的堆栈 image-20240519201746129
image-20240519201951153
Java内存泄漏的排查思路

在Java程序运行时,有三块内存可能会导致内存泄漏或溢出的问题,分别是虚拟机栈、方法区、堆空间。其报错分别是StackOverflowError,OutOfMemoryError:Metaspace,OutOfMemoryError:java heap space。其可能的原因分别是递归造成的问题;动态加载的类过多;垃圾处理器来不及回收。

image-20240519202522821
  • 但是我们一般是将服务部署在服务器上,而且使用微服务进行部署。每个微服务都有可能导致内存溢出,很有可能这个项目直接闪退了,或者根本没有运行起来,还有运行了一段时间内存不够了,然后就宕机了。我们可以先去获取堆内存快照文件dump,然后用VisualVM去分析dump文件,通过查看堆信息的情况,定位内存溢出的问题。

    在运行参数上指定要记录dump的错误,然后指定输出路径。然后再在JavaVisualVM上查看即可。

    image-20240519203107915 image-20240519203310434
image-20240519203344217
CPU飙高的排查方案与思路
  1. 使用top命令查看CPU情况

    image-20240519203440515

    可以发现,PID为40940的进程CPU占用很高

  2. 使用ps H命令查看线程

    1
    ps H -eo pid,tid,%cpu | grep <PID>

    具体参数解释如下:

    • -H 表示显示层级关系(即进程的线程)。

    • -eo pid,tid,%cpu 指定了要显示的字段,包括进程ID(pid)、线程ID(tid)和CPU利用率(%cpu)。

    • |ps 命令的输出传递给 grep 命令。

    • grep <PID> 表示从 ps 命令的输出中筛选出包含 “<PID>” 的行,即查找进程ID为 “<PID>” 的相关信息。

    image-20240519204034127

    发现TID为2276的线程占用较高。

  3. 使用jstack <PID>来查看其线程的详细情况。

    1
    jstack <PID>
    image-20240519204242854

    然后找到了线程TID为2276的线程(十六进制为8e4)的信息。

  4. 可以直接使用cat命令查看java代码。

image-20240519204444691