Review --- JVM

发布于:2025-04-02 ⋅ 阅读:(19) ⋅ 点赞:(0)

JVM

  • jvm是Java的核心,实现了Java的一次编写,到处运行.Java编译器将Java源码编译为二进制Class字节码文件,交给jvm运行处理(解释/编译)为对应平台操作系统可以运行的机器码指令. 每一条java指令,java虚拟机中都有详细定义,如怎么获取操作数,怎么处理操作数,处理结果放在哪里
  • 现在的JVM不仅可以执行java字节码文件,还可以执行其它语言编译后的字节码文件,是一个跨语言平台

jvm由四个部分组成:类加载器,运行时数据区,执行引擎,本地方法接口库

JVM执行

类加载器将字节码文件加载到运行时数据区,至于能不能执行与类加载器无关,执行引擎将字节码文件解释为操作系统可以识别的指令,在这个过程中会用到本地方法(由非Java语言编写)

JVM类加载

类加载分为三个阶段,加载,链接,初始化.

加载: 根据类名(地址)获取此类的二进制字节流,将二进制字节流的静态存储结构转换为方法区的运行时结构,在内存中生成一个代表这个类的java.lang.Class对象,作为这个类的各种数据的访问入口

链接:链接分为三个阶段:验证,准备,解析

                        验证:1)验证字节码文件内容是否以CA FE BA BE开头,主次版本号是否在当前java虚拟机接收范围内;      2)元数据验证   对字节码描述的信息,进行语义分析,以确保其描述的信息符合java语言规范的要求,例如这个类是否有父类;是否继承浏览不允许继承的类(final修饰的类)````

                        准备: 准备阶段则负责为类的静态属性分配内存,并设置默认初始值;

                        解析: 将类中的符号引用替换成直接引用(符号引用是Class文件的逻辑符号,直接引用指向方法区中某一个地址)

初始化: 初始化是为类中的静态属性赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化.初始化阶段就是执行底层类构造器方法<clinit>()的过程

那么类什么时候初始化?

JVM规定,每个类或者接口被首次主动使用时才对其进行初始化.

1.通过new关键字创建对象

2.访问类的讲台变量

3.访问类的静态方法

4.对这个类进行反射相关操作

5.初始化子类时候会导致父类的初始化

6.执行该类的main方法

以下两种情况不会进行类的初始化:

        1)访问类的静态常量,但是也有意外,这里的常量

是指已经指定字面量的常量,对于那些需要一些计算才能得出结果的常量就会导

致类加载,比如:

public final static int NUMBER = 5 ; //不会导致类初始化,被动使用

public final static int RANDOM = new Random().nextInt() ; //会导致类加载

        2)构造这个类的数组时不会导致该类的初始化,

比如:        Person[]  persons = new Person[66];

类加载器分类

  • 站在JVM的角度,类加载器可以分为两种

1.引导类加载器(启动类加载器  Bootstrap ClassLoader).

2.其它所有类加载器,这些类加载器由java语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader.

  • 站在java开发人员角度看,类加载器就会分得更细致些

`````````

双亲委派机制

        java对class字节码文件采用的是按需加载方式,当需要这个类的字节码文件时才会将它的字节码文件加载到内存中生成Class对象.  在加载这个类的class文件时,JVM采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

工作原理:

  • 如果一个类加载器收到了类加载请求,它不会自己先取加载,而是将请求委托给父类的加载器取加载执行.
  • 如果父类加载器还存在其父类加载器,则继续进一步向上委托,请求最终将到达顶层的启动类加载器.
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制

如果这些类加载器都不能处理这个类加载请求,就会抛出ClassNotFoundException  异常

思考:  我们自己创建一个名为java.lang 的包,再创建一个名为String类,当我们new String()时候,会加载创建java核心类库中的String对象, 还是我们自己创建的类String的对象?

双亲委派机制优点:可以避免用户创建编写的类替代java原本的核心类,比如java.lang.String

如何打破双亲委派机制

  • 默认情况下,ClassLoader 的 loadClass(String name) 方法实现了双亲委派机制的逻辑。如果需要打破双亲委派,可以直接重写 loadClass 方法,改变其加载逻辑。
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 检查类是否已经被加载
    Class<?> clazz = findLoadedClass(name);
    if (clazz == null) {
        try {
            // 尝试自己加载类,而不是先交给父类加载器
            clazz = findClass(name);
        } catch (ClassNotFoundException e) {
            // 如果自己无法加载,则委托给父类加载器
            clazz = super.loadClass(name, resolve);
        }
    }
    if (resolve) {
        resolveClass(clazz);
    }
    return clazz;
}

在这段代码中,我们改变了默认的加载顺序:优先尝试自己加载类,只有在失败时才委托给父类加载器。这种行为打破了双亲委派机制。

  • 另一种方式是重写 findClass(String name) 方法。findClass 是 ClassLoader 提供的一个空方法,用于子类实现自己的类加载逻辑。
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    // 自定义类加载逻辑
    byte[] classData = loadClassData(name); // 假设从某个地方加载字节码
    if (classData == null) {
        throw new ClassNotFoundException();
    }
    return defineClass(name, classData, 0, classData.length);
}

private byte[] loadClassData(String className) {
    // 根据类名加载字节码数据(可以从文件、网络等地方获取)
    String path = className.replace('.', '/') + ".class";
    try (InputStream input = new FileInputStream(path);
         ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
        int data;
        while ((data = input.read()) != -1) {
            buffer.write(data);
        }
        return buffer.toByteArray();
    } catch (IOException e) {
        e.printStackTrace();
        return null;
    }
}

在这种实现中,我们完全绕过了父类加载器,直接由子类加载器负责加载类。这种方式也打破了双亲委派机制。

JVM运行时数据区

运行时数据区组成概述:

        JVM的运行时数据区,不同虚拟机实现可能略微有所不同,但都会遵从Java虚拟机规范,Java8 虚拟机规范规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

1)程序计数器

2)本地方法栈

3)虚拟机栈

4)堆

5)方法区

程序计数器

        JVM中的程序计数寄存器(Program Counter Register)这里译为程序计数器更容易理解.

程序计数器用来存储下一条指令的地址,也就是说,它指向的是即将执行的那条指令的内存位置,而不是指令本身。执行引擎会根据程序计数器提供的地址去获取对应的指令,然后执行这个指令。执行完后,通常程序计数器会被更新为下一条指令的地址(除非发生了跳转、分支等改变控制流的操作)。

  • 它是一块很少的内存空间,也就是运行速度最快的存储区域.
  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,声明周期与线程生命周期保持一致.
  • 程序计数器会存储当前线程正在执行的Java方法的JVM指令地址.
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域.

Java虚拟机栈

“Java 虚拟机栈(Java Virtual Machine Stack),早期也称为 Java 栈,是每个线程在创建时都会创建的一个线程私有数据结构。它的主要作用是支持线程的执行,具体来说,它通过一系列栈帧(Stack Frame)来管理方法调用的过程。每个栈帧对应着一次方法调用,包含该方法的局部变量(包括8种基本数据类型和对象的引用)、操作数栈(用于存储中间结果)、动态链接以及方法返回地址等信息。

Java 虚拟机栈不仅负责存储局部变量和中间结果,还参与了方法的调用和返回过程。当一个方法被调用时,一个新的栈帧会被压入栈中;当方法执行完毕后,相应的栈帧会被弹出,控制权返回给调用者。由于每个线程都有自己的虚拟机栈,因此不同线程之间的栈帧是相互独立的。”

  • JVM 直接对 java 栈的操作只有两个:调用方法入栈.执行结束后出栈.
  • 对于栈来说不存在垃圾回收问题.
  • 栈中会出现异常,当线程请求的栈深度超过虚拟机所允许的深度时,会抛出StackOverflowError.
  • 不同线程中所包含的栈帧(方法)是不允许存在相互引用的,即不可能在一个栈中引用另一个线程的栈帧.
  • 如果当前栈帧调用了其它栈帧,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧.
  • Java方法有两种返回方式,一种是正常的返回,即return 结果.另一种是抛出异常,不管哪种方式都会导致栈帧被弹出

栈帧的内部结构:

  •         局部变量表(Local Variables)

        局部变量表是一组变量值存储空间,用于存放方法参数和内部定义的局部变量. 对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用.

  •         操作数栈(Operand Stack)/表达式栈

        栈最典型的一个应用就是用来对表达式求值. 在一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根结底就是进行计算的过程. 因此可以这么说,程序中的所有计算过程都是在借助于操作数栈完成的.

  •         方法返回地址(Return Address)

        当一个方法执行完毕后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址.

本地方法栈

  • Java虚拟机管理Java方法的调用,而本地方法栈用于管理本地方法的调用.
  • 本地方法栈也是线程私有的.
  • 允许被实现成固定或者是可动态扩展的内存大小.内存溢出方面也是相同的.

如果线程请求分配的栈容量超过本地方法栈允许的最大容量,抛出StackOverflowError.

  • 本地方法是用C语言写的.
  • 它的具体做法是在Native Method Stack 中登记native 方法,在Execution Engine 执行时加载本地方法库.

Java堆内存

  •         《Java虚拟机规范》中对Java堆的描述是:所有的对象实例都应当在运行时分配在堆上。
  • 堆也是Java内存管理的核心区域,是JVM管理的最大一块内存空间
  • 堆内存的大小是可以调节的. 
    例如: -Xms:10m(堆起始大小) -Xmx:30m(堆最大内存大小)
  • 所有的线程共享Java堆
  • 堆中的对象不会马上被移除,仅仅在收集的时候才会被移除.
  • 堆事GC(Garbage Collection -- 垃圾回收器)执行垃圾回收的重点区域
        堆空间区域划分:
Java8及之后堆内存分为:新生区(新生代) + 老年区(老年代)
新生区分为Eden(伊甸园)区和Survivor(幸存者)区

为什么分区(代)?

将对象根据存活概率进行分类, 对存活时间长的对象, 放到固定区, 从而减少扫描垃圾时间及GC频率. 针对分类进行不同的垃圾回收算法, 实现算法的扬长避短.

对象创建内存分配过程

  1. 对象分配与伊甸园区:新创建的对象通常首先被分配在年轻代的伊甸园区。如果伊甸园区的空间不足以分配新的对象,将会触发一次Minor GC。
  2. 垃圾回收与对象移动:当伊甸园区发生垃圾回收时,未被引用的对象将被清除,存活下来的对象会被移动到其中一个幸存者区(S0或S1)。两个幸存者区交替使用,确保每次垃圾回收后总有一个幸存者区为空。
  3. 对象晋升老年代:在多次垃圾回收过程中幸存下来的对象(默认情况下经过15次GC),将会被移动到老年代。这个阈值可以通过参数-XX:MaxTenuringThreshold=<N>进行调整,但考虑到对象头中用于存储年龄的位数限制,最大值为15。
  4. 老年代的垃圾回收:老年代的垃圾回收(Major GC/Full GC)不像年轻代那样频繁。它通常在老年代空间不足时触发。若清理后仍无法满足对象存储需求,则可能会尝试扩展堆大小(如果配置允许),或者最终抛出OutOfMemoryError(OOM)异常。
  5. 整堆GC:在某些情况下,比如老年代也满了并且无法通过GC释放足够的空间,这时会触发对整个堆(包括年轻代和老年代)的垃圾回收。如果这次GC之后仍然没有足够的空间,同样会导致OOM错误。

堆空间的参数设置

官网地址:
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:+PrintFlagsInitial 查看所有参数的默认初始值
-Xms:初始堆空间内存
-Xmx:最大堆空间内存
-Xmn:设置新生代的大小
-XX:MaxTenuringTreshold:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails 输出详细的 GC 处理日志

方法区

        方法区和堆一样是线程共享的内存区域。其中主要存储加载的类字节码、class/method/field等数据、static final 常量、static 变量、即时编译器编译后的代码等数据。

        《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但对于HotSpotJVM而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。

所以,方法区被看作是一块独立于Java堆的内存空间。

  • 方法区在JVM启动时被创建,并且它的实际的物理内存空间中 和 Java堆一样是可以不连续的。
  • 方法区的大小跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,JVM同样会抛出内存溢出的错误
  • 关闭JVM 就会释放这个区域的内存

方法区大小设置

Java方法区的大小不是固定的,JVM 可以根据应用的需要进行动态调整。

  • 元数据大小可以使用参数-XX:MetaspaceSize 指定
  • 方法区一旦占满 就会触发 Full GC(整堆收集)
  • 因此为了减少FullGC 那么这个-XX:MetaspaceSize 可以设置一个较高的值

方法区的垃圾回收

  1. 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,这是不对的。实际上只是《Java虚拟机规范》对方法区的约束非常宽松,提到过可以不要求虚拟机在方法区中实现垃圾收集
  2. 一般来说这个区域的回收效果难以令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时候又是必要的。

类型的卸载

判定一个常量是否不再被使用相对简单,而判定一个类型是否不再被使用就比较苛刻了。需要同时满足三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及任何派生子类的实例
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi,JSP的重加载,否则是很难达成的
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类

本地方法接口

什么是本地方法

Native Method 的底层实现由非Java语言实现,比如C。这个特征并非java特有,很多其它的编程语言都有这一机制。

在定义一个native method 时,并不提供实现体(像定义一个Java interface),因为其实现体是由非Java语言在外面实现的。

关键字 native 可以与其它Java标识符连用,但是abstract除外。

为什么要使用 Native Method

与Java环境外交互

有时 java 应用需要与 java 外面的环境交互,这是本地方法存在的主要原因。 你
可以想想 java 需要与一些底层系统,如某些硬件交换信息时的情况。本地方法
正是这样的一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需
去了解 java 应用之外的繁琐细节。

执行引擎

  • 执行引擎是Java虚拟机核心的组成部分之一。
  • JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其它辅助信息。
  • 那么如果想让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

区分概念:

  1. 前端编译:指的是将.java文件编译为.class文件的过程,这是在开发阶段完成的。
  2. 后端编译(即JIT编译):是在程序运行期间,由JVM根据需要将部分字节码动态编译成本地机器码以提高执行效率的过程。

什么是解释器?什么是JIT编译器?

解释器:当Java虚拟机启动时会根据预定义的规范堆字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。

为什么Java是半编译半解释型语言?

Java 的设计初衷是为了实现“一次编写,到处运行”的跨平台特性。为了实现这一目标,Java 采用了字节码(Bytecode)作为中间层,并结合了编译和解释两种执行方式,因此被称为“半编译半解释型语言”。

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。

JIT 编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的 JIT 代码缓存中(执行效率更高了)。

是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。

JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其

直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。

一个被多次调用的方法,或者是一-个方法体内部循环次数较多的循环体都可以被称之为“热点代码”。

目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测。

为什么需要解释器与 JIT 编译器共存?
解释器的作用
1.快速启动:
解释器可以直接逐行解释字节码并执行,无需等待编译完成。
这种方式适合程序启动阶段或执行频率较低的代码。
2.节省资源:
对于不常执行的代码,使用解释器可以避免编译带来的额外开销。
JIT 编译器的作用
1.提高执行效率:
对于频繁执行的热点代码,JIT 编译器将其编译为本地机器代码并缓存,后续直接执行本地代码,显著提升性能。
2.动态优化:
JIT 编译器可以根据运行时的信息对代码进行深度优化,例如内联方法、分支预测等。

平衡点
解释器和 JIT 编译器各有优劣,单独使用其中一种都无法满足所有场景的需求。
因此,现代 JVM(如 HotSpot VM)采用了解释器与 JIT 编译器并存的架构,根据代码的执行频率动态选择最合适的执行方式,达到性能与资源消耗之间的平衡。

垃圾回收

垃圾是指在 运行程序中没有任何引用指向的对象 ,这个对象就是需要被回收的垃
圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存 溢出

早期垃圾回收

在早期的 C/C++时代,垃圾回收基本上是手工进行的。
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,
垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
现在,除了 Java 以外,C#、Python、Ruby 等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和来及回收方式已经成为了现代开发语言必备的标准。
应该关心哪些区域的回收?

垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中,Java堆是垃圾收集器的工作重点

从次数上讲:

  • 频繁回收Young区
  • 较少收集Old区
  • 基本不收集元空间(方法区)

垃圾回收相关算法

  1. 垃圾标记阶段算法
    垃圾标记阶段:主要是为了判断对象是否是垃圾对象
    比如在堆中存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是有用对象,哪些是垃圾对象。只有被标记为已经是垃圾对象,GC才会在执行垃圾回收时释放其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

    在JVM中判断是否为垃圾对象一般有两种方式:引用计数算法和可达性分析算法。
    引用计数算法:
    1.引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型
    引用计数器属性。用于记录对象被引用的情况。
    2.对于一个对象 A,只要有任何一个引用指向了对象 A,则对象 A 的引用计数器
    就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,
    即表示对象 A 不可能再被使用,可进行回收。
    3.优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
    4.缺点:
    1.它需要单独的字段存储计数器,这样的做法 增加了存储空间的开销。
    2.每次赋值都需要更新计数器,伴随着加法和减法操作,这 增加了时间开销。
    3.引用计数器有一个严重的问题,即 无法处理循环引用 的情况。这是一条致命
    缺陷,导致在.Java 的 垃圾回收器中没有使用这类算法

    可达性分析算法:
    可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集
    1.相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效
    等特点,更重要的是该算法可以有效地 解决在引用计数算法中循环引用的问题
    防止内存泄漏的发生。
    2.相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的
    垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)
    可达性分析实现思路
    所谓"GCRoots”根就是一组必须活跃的引用
    其基本思路如下:
    1.可达性分析算法是以根(GCRoots)为起始点,按照从上至下的方式搜索被根
    对象所连接的目标对象是否可达。
    2.使用可达性分析算法后,内存中的存活对象都会被根直接或间接连接着,搜索
    所走过的路径称为引用链(Reference Chain)
    3.如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,
    可以标记为垃圾对象。
     
    GC Roots 可以是哪些元素?
    1.虚拟机栈中引用的对象
    比如:各个线程被调用的方法中使用到的参数、局部变量等。
    2.方法区中类静态属性引用的对象,比如:Java 类的引用类型静态变量
    3.所有被同步锁 synchronized 持有的对象
    4.Java 虚拟机内部的引用。
    基 本 数 据 类 型 对 应 的 Class 对 象 , 一 些 常 驻 的 异 常 对 象 ( 如 :
    NullPointerException、OutofMemoryError),系统类加载器。

     
    对象的finalization机制
    finalize()方法
    Object 类中 finalize()源码
            protected void finalize() throws Throwable { }
    对象销毁前的回调方法:finalize();
    Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁
    之前的自定义处理逻辑。
    当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调
    用这个对象的 finalize()方法,一个对象的 finalize()方法只被调用一次。
    finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。 永远不要主动调用某个对象的 finalize()方法,应该交给垃圾回收机制调用。理
    由包括下面三点:
    1.在 finalize()时可能会导致对象复活。
    2.finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,
    若不发生 GC,则 finalize()方法将没有执行机会。
    3.一个糟糕的 finalize()会严重影响 GC 的性能。比如 finalize 是个死循环。

    生存还是死亡?
    由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。
    如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来
    说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时
    处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,
    如果这样,那么对它立即进行回收就是不合理的。为此,定义虚拟机中的对象可
    能的 三种状态 。如下:
    可触及的: 从根节点开始,可以到达这个对象。
    可复活的: 对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
    不可触及的: 对象的 finalize()被调用,并且没有复活,那么就会进入不可触及
    状态。
    以上 3 种状态中,是由于 finalize()方法的存在,进行的区分。只有在对象不可
    触及时才可以被回收。

     
  2. 垃圾回收阶段算法

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便于有足够的可用内存空间为新对象分配内存.目前在JVM中比较常见的三种垃圾收集算法是:

  • 标记-复制算法(Copying)
  • 标记-清除算法(Mark-Sweep)
  • 标记压缩算法(Mark-Compact)

  • 标记-复制算法

它将可用内存按容量划分为两块,每次只使用其中的一块.在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存中的角色,最后完成垃圾回收.

复制算法适合存活对象少,垃圾对象多,特别适合于新生代.

  • 标记-清除算法

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里.下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放(也就是覆盖掉原有地址)

  • 标记-压缩算法
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。
如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记-清除算法的确可以应用在老年代中,但是在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进

标记压缩算法执行过程
将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。


标记-压缩算法与标记清除算法的比较
标记-压缩的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法.
二者的本质差异在于移动和非移动.是否移动回收后的存活对象是一项优缺点并存的风险决策.



分代收集
        前面所有这些算法中,并没有一种算法可以完全替代其它算法,它们都具有自己独特的优势和特点。分代收集应运而生。
        分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关:
        比如 Http 请求中的 Session 对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。
        但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的GC都采用分代收集算法执行垃圾回收,在HotSpot 中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。


年轻代(Young Gen)
特点:
区域相对老年代较小,对象生命周期短、存活率低、回收频繁。
这种情况适合用标记-复制算法,速度是最快的。复制算法的效率只和当前存货对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过HostSpot中的两个 survivor 的设计得到缓解。

老年代(Tenured Gen)
特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
这种对象大量存活的情况下,复制算法明显变得不合适。一般是由标记-清除或者是标记清除-标记压缩的混合实现。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

 垃圾回收相关概念

内存溢出与内存泄漏
内存溢出
        内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了了,系统会提示内存溢出。
内存泄漏
        内存泄漏也称作“存储泄漏”。严格来说,只有 对象不会再被程序用到了,但是GC又不能回收它们的情况,才叫内存泄漏。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步 蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃.
常见案例:
单例模式
单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的发生.
一些提供close() 的资源未关闭导致内存泄漏
数据库连接 dataSource.getConnection(),网络连接 socket 和 io 连接必须手动close,否则是不能被回收的.
Stop the World
        简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
        可达性分析算法中枚举根节点(GC Roots) 会导致所有Java执行线程停顿,为什么需要停顿所有Java执行线程呢?
  1. 分析工作必须在一个能确保一致性的快照中进行
  2. 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  3. 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证,会出现漏标、错标问题
  4. 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生
  5. 随着垃圾回收(GC)技术的发展和优化,现代的垃圾收集器在执行 GC 操作时,尤其是涉及到 Stop-the-World (STW) 事件时,变得越来越高效。这种进步旨在尽可能减少应用程序由于 GC 操作而必须暂停的时间。

        STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。


垃圾回收器

概述:如果说垃圾收集算法是内存回收的方法论,那么收集器就是内存回收的实践者.
        垃圾收集器没有在Java虚拟机规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
        由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的垃圾回收器。从不同角度分析垃圾收集器,可以将GC分为不同的类型。实际使用时,可以根据实际的使用场景选择不同的垃圾回收器,这也是JVM调优的重要部分。
垃圾回收器分类
按线程数可以分为 单线程(串行)垃圾回收器和 多线程(并行)垃圾回收器
单线程垃圾回收器(Serial)
只有一个线程进行垃圾回收,适用于小型简单的使用场景,垃圾回收时。其它用户线程会暂停。
 
多线程垃圾回收器(Parallel)
多线程垃圾回收器内部提供多个线程进行垃圾回收,在多cpu情况下大大提升垃圾回收效率,但同样也是会暂停其它用户线程.
按照工作模式分,可以分为 独占式并发式垃圾回收器.
按工作的内存空间分,又可分为 年轻代垃圾回收器老年代垃圾回收器
GC性能指标
吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)
暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
内存占用:Java堆区所占的内存大小。
HotSpot垃圾收集器
图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们之间可以搭配使用。
Serial,Serial Old,ParNew,Parallel Scavenge,Parallel Old,CMS,G1

 

CMS(Concurrent Mark-Sweep)垃圾回收器
概述:

        CMS 是一种专注于减少停顿时间的垃圾回收器,特别适用于那些对响应时间敏感的应用程序。它通过并发执行大部分的垃圾回收工作来尽量减少应用程序的停顿时间。

垃圾回收过程:

初始标记(Initial Mark, STW):短暂暂停所有用户线程,标记直接从GC Roots可达的对象。
并发标记(Concurrent Mark):与用户线程并发进行,遍历对象图以标记可到达的对象。
重新标记(Remark, STW):再次暂停所有用户线程,处理在并发标记阶段中发生变化的对象引用,确保没有遗漏任何可达对象。
并发清除(Concurrent Sweep):与用户线程并发执行,清理不再使用的对象并释放它们占用的空间。
优点:

减少了“Stop-the-World”的停顿时间,适合需要低延迟的应用场景。
并发模式减少了GC对应用性能的影响。
缺点:

无法压缩堆内存,可能导致内存碎片化问题,进而影响分配大对象时的性能。
在高负载下,可能无法跟上老年代对象产生的速度,导致频繁的Full GC,这会显著增加停顿时间。
相对于其他收集器,配置和调优较为复杂。


G1(Garbage First)垃圾回收器
概述:

G1是一种服务器级别的垃圾收集器,设计目标是替代CMS,提供更高效的内存管理和更低的停顿时间。G1将堆划分为多个大小相等的区域(Region),然后根据各个区域的垃圾堆积密度动态决定哪些区域最值得优先回收。

垃圾回收过程:

年轻代GC(Young GC):类似于传统的分代垃圾收集器,但使用了G1特有的算法优化,回收年轻代中的垃圾。
并发标记周期(Concurrent Marking Cycle):
初始标记(Initial Mark)
根区扫描(Root Region Scanning)
并发标记(Concurrent Marking)
最终标记(Final Mark)
清理(Cleanup)


混合收集(Mixed Collection):在标记完成后,G1会尝试同时回收一些年轻代和老年代的区域,选择垃圾最多的区域优先回收。
Full GC:当G1无法管理堆内存时触发,这是一个单线程的过程,会导致较长的停顿时间。
优点:

可预测的最大停顿时间控制,允许用户设定期望的最大GC停顿时间。
区域化的堆管理方式有助于减少内存碎片,并提高了垃圾回收效率。
支持并发的全局标记清除过程,减少了STW的时间。
更好的堆利用率,能够有效地处理大型堆。


缺点:

相比于CMS,G1的实现更为复杂,可能会消耗更多的CPU资源用于后台操作。
在某些情况下,尤其是当堆较大且对象分布不均匀时,可能不如CMS高效。


总结
CMS 主要关注于降低GC停顿时间,适用于那些对延迟敏感的应用程序,但它不能解决内存碎片化的问题,并且在面对高负载时表现不佳。
G1 提供了一种更加现代化的方法来管理堆内存,通过分区的方式实现了更灵活的垃圾回收策略,支持设置最大停顿时间的目标,并试图最小化停顿时间的同时提高整体吞吐量。

JVM调优基础

        在Java应用开发中,了解和掌握JVM调优技巧是至关重要的。正确的JVM配置不仅能提高程序的运行效率,还能减少内存泄漏、避免频繁的垃圾回收(GC)导致的应用卡顿等问题。
  1. 堆内存大小调整
            堆内存是JVM用于存储对象实例的地方,分为年轻代(Young Generation)、老年代(Old Generation)。合理设置堆内存大小对应用性能有着直接影响。
     
  • -Xms:设置JVM启动时分配的初始堆内存大小,默认值取决于平台。对于长期运行的服务端应用,建议设置为与-Xmx相同,以减少堆内存扩展带来的开销。

  • -Xmx:指定JVM允许使用的最大堆内存大小。应根据应用的实际需求进行设置,过小会导致频繁的GC,过大则可能增加Full GC的时间。

  • -XX:NewRatio:设置年轻代与老年代的比例。例如,-XX:NewRatio=2表示年轻代占整个堆的1/3。

  • -Xmn:直接设置年轻代的大小。使用这个选项时需谨慎,因为它会覆盖由-XX:NewRatio设定的比例。

     2. 垃圾收集器选择
      
不同的垃圾收集器适用于不同类型的应用场景:

  • Serial GC:适合单核处理器或小型应用,简单但停顿时间较长。
  • Parallel GC:利用多线程加速GC过程,适合高吞吐量需求的应用。
  • CMS GC:专注于缩短停顿时间,适用于需要快速响应的应用。
  • G1 GC:分区管理堆内存,目标是预测可控的最大停顿时间,适合大堆内存环境下的应用。
  • ZGC/Shenandoah:新一代低延迟垃圾收集器,支持非常大的堆内存,并致力于最小化GC停顿时间。

选择合适的垃圾收集器可以通过以下参数实现:

-XX:+UseSerialGC   # 使用Serial GC
-XX:+UseParallelGC # 使用Parallel GC
-XX:+UseConcMarkSweepGC # 使用CMS GC
-XX:+UseG1GC       # 使用G1 GC

    3. 其它重要参数

-XX:MaxMetaspaceSize:设置元空间的最大容量,防止内存溢出。
-XX:+HeapDumpOnOutOfMemoryError:当发生OutOfMemoryError时生成堆转储文件,便于后续分析问题原因。
-XX:HeapDumpPath:指定堆转储文件的保存路径。
-XX:+PrintGCDetails:打印详细的GC日志信息,有助于监控和调优。
-XX:SurvivorRatio:设置Eden区与Survivor区的比例。

   4. 实践

  • 监测和调整:定期使用工具如JVisualVM, JConsole或专业的APM工具来监测JVM的运行状态,特别是GC活动。

  • 测试环境验证:任何调整都应在测试环境中充分验证其效果,确保不会引入新的问题。
  • 持续优化:随着业务的发展,应用的需求可能会发生变化,因此需要持续关注并适时调整JVM配置。

想要实现观测GC的图示化界面,需要安装插件  -- 菜单栏处选择工具,单机插件,下载Visual GC


网站公告

今日签到

点亮在社区的每一天
去签到