JVM

类加载器

类加载器只负责类的加载,不关心能否允许,能否运行由执行引擎决定,将加载的信息放到方法区中

JVM(Java Virtual Machine)是Java编程语言的核心部分,它负责将Java源代码编译后的字节码转换为可在各种操作系统上执行的机器码。在JVM中,类加载器(ClassLoader)负责将类文件加载到内存中,并在运行时创建Java类的定义。JVM的类加载器子系统是管理和协调这些类加载器的一组组件。

JVM的类加载器子系统由以下几种类加载器组成:

  1. Bootstrap ClassLoader(引导类加载器):这是JVM的内置类加载器,负责加载Java的核心类库,如java.lang包中的类。它通常由JVM实现提供,以本地代码的形式存在,无法直接在Java代码中获取。

  2. Extension ClassLoader(扩展类加载器):也称为ext类加载器,它负责加载Java扩展库,位于JRE的lib/ext目录下。它用于加载一些扩展的标准库,不过现在不推荐过多使用扩展机制。

  3. Application ClassLoader(应用程序类加载器):也称为系统类加载器,它负责加载应用程序classpath(类路径)下的类文件。它是Java开发者最常接触到的类加载器,它可以通过ClassLoader.getSystemClassLoader()方法来获取。

此外,还可以通过编写自定义的类加载器,来实现自己的类加载逻辑。这可以用于实现一些特殊的加载需求,比如从网络上加载类、加密类文件等。

类的加载过程:

  1. 类的加载过程是指将类的字节码从磁盘加载到内存,并在JVM中创建一个对应的java.lang.Class对象的过程。类加载过程包括加载、连接和初始化三个阶段。

    1. 加载(Loading):在这个阶段,类加载器通过类的全限定名(包括包名和类名)来定位类的字节码文件,然后将字节码加载到内存中。加载并不仅仅是简单地将字节码(二进制流)读入内存,它还会执行一些额外的操作,比如验证字节码的格式、检查访问权限等。类加载过程中,会生成一个对应的java.lang.Class对象,用于在JVM中表示这个类。

    2. 连接(Linking):连接阶段包括三个子阶段:验证、准备和解析。

      • 验证(Verification):在这个阶段,类加载器会对类的字节码进行验证,以确保字节码的格式正确,没有安全问题,且满足JVM的要求。
      • 准备(Preparation):在这个阶段,类加载器会为类的静态变量分配内存,并设置默认的初始值
        • 将类变量(成员变量)设置为零值(对于数值类型)、null(对于引用类型)等。
      • 解析(Resolution):在这个阶段,类加载器会将类中的符号引用转换为直接引用,以便于访问其他类。这个过程可能涉及到在运行时将常量池中的符号引用替换为实际的内存地址。
    3. 初始化(Initialization):这是类加载过程的最后一个阶段。在这个阶段,JVM会执行类的初始化代码,包括执行静态初始化块和静态字段的赋值。类初始化可能涉及到对其他类的加载和初始化操作。这个阶段是类加载的最终阶段,只有在需要使用类时才会触发。

    类初始化相关的 <clinit>(class initialization) 方法,这个方法负责处理类的静态字段赋值静态代码块,会将显式初始化和static代码块合并在一起

    在 Java 编译器编译 Java 源代码生成字节码时,如果类中包含了静态字段的赋值或者静态初始化块,编译器会为类生成一个名为 <clinit> 的方法。这个方法会在类初始化的过程中被执行,负责执行静态字段的赋值和静态初始化块的内容。

    这个 <clinit> 方法由编译器自动创建,它的特点包括:

    • 方法名为 <clinit>
    • 没有参数
    • 无需在代码中显式调用,会在类初始化的过程中自动执行
    • 它是一个特殊的方法,不需要程序员手动编写,会根据静态字段的赋值和静态初始化块的内容自动生成

    举个例子,假设有以下 Java 类:

    public class MyClass {
        static int myStaticField = 42;
        
        static {
            System.out.println("Static initializer block");
        }
    }
    

    编译器会为 MyClass 自动生成一个 <clinit> 方法,类似于以下伪代码:

        // <clinit> 方法,会在类初始化时被执行
        static void <clinit>() {
            myStaticField = 42;
            System.out.println("Static initializer block");
        }
    

    在类初始化时,JVM 会调用 <clinit> 方法来执行静态字段的赋值和静态初始化块中的代码,确保类的静态状态正确初始化。

以下代码是合法的,不会报编译错误:

public class Main {
    static {
        a = 999;
    }
    public static int a = 100;
}

原因:

  • 链接阶段有一个准备的子阶段,这个阶段就已经将这个变量初始化了
  • 值的变化过程:0->100->999

类初始化的情况

类的初始化是在以下情况下触发的:

  1. 创建类的实例(对象):当通过new关键字创建一个类的实例时,会触发该类的初始化过程。例如:

    MyClass myObject = new MyClass();
    
  2. 访问类的静态成员:当访问类的静态字段(静态变量)或者调用静态方法时,会触发类的初始化。例如:

    int value = MyClass.myStaticField;  // 访问静态字段
    MyClass.staticMethod();             // 调用静态方法
    
  3. 使用反射操作类:通过反射机制调用Class.forName()Class.getDeclaredConstructor().newInstance()等方法加载和实例化类时,会触发类的初始化。例如:

    Class<?> clazz = Class.forName("com.example.MyClass");
    
  4. 运行时初始化时机:如果类包含静态初始化块(static块),这些块会在类加载时按照代码顺序执行,从而触发初始化。例如:

    public class MyClass {
        static {
            // 静态初始化块
        }
    }
    
  5. 调用main方法:当直接调用main方法启动Java程序时,首先会初始化包含main方法的类。

  6. 初始化一个类的子类

同步加锁

每个类只会被加载一次,并且加载过程是同步加锁的

public class Main {

    public static void main(String[] args) throws ClassNotFoundException {
        Runnable r = () -> {
            Test test = new Test();
        };
        new Thread(r, "线程1").start();
        new Thread(r, "线程2").start();
        Class<?> aClass = Class.forName("Test");
    }
}

class Test {
    static {
        System.out.println(Thread.currentThread().getName() + ", 加载Test类....");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

分类

Java中的类加载器负责将类文件加载到内存中并创建相应的Class对象。类加载器根据不同的来源和加载策略,可以分为以下几种类型:

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

    • 它是JVM内置的类加载器,通常由C++编写,并且不是Java类。

    • 负责加载Java核心库(java.*javax.*等)。

    • 在类加载器层次结构的顶端,无法被Java代码直接引用。

    •         ClassLoader classLoader1 = String.class.getClassLoader();
            		// 结果为null
              System.out.println("classLoader1 = " + classLoader1);
      
  2. 扩展类加载器(Extension ClassLoader)

    • 也叫ext类加载器,负责加载Java的扩展库,位于JRE的lib/ext目录下。
    • 可以将用户编写的jar放到lib/ext目录下,那么将会由此加载器进行加载。
    • 主要加载JRE扩展目录中的JAR文件。
  3. 应用程序类加载器(Application ClassLoader)

    • 也叫系统类加载器,负责加载应用程序classpath下的类。
    • 自己写的普通的类都是由这个加载器进行夹杂的
    • 是大多数开发者直接面对的类加载器,可以通过ClassLoader.getSystemClassLoader()来获取。
  4. 自定义类加载器

    • 开发者可以继承ClassLoader类,实现自己的类加载逻辑,所有继承自ClassLoader类的类加载器都可以看作是自定义类加载器。
    • 适用于特殊的加载需求,如从网络、数据库等加载类。
    • 可以实现类隔离、插件化等功能。

这些类加载器按照类加载的优先级和责任分工形成了一种层次结构,称为“双亲委派模型”(Delegation Model)。根据这个模型,当一个类加载器需要加载类时,它首先会将加载请求委托给它的父加载器。只有当父加载器无法加载时,子加载器才会尝试加载。这种层次结构和委派模型确保了类的一致性和避免了类的重复加载。

这种类加载器的层次结构和双亲委派模型有助于确保Java程序的稳定性、安全性和避免类冲突。

常见方法

java.lang.ClassLoader类是Java中与类加载相关的核心类之一,它提供了一些常用的方法来处理类的加载、查找和定义。以下是一些ClassLoader类中常见的方法及其作用:

  1. loadClass(String className)

    • 加载指定名称的类。这是ClassLoader类最核心的方法之一。
    • 使用双亲委派模型,首先尝试从父类加载器加载类,然后在自己的类路径中查找并加载。
    • 如果需要自定义类加载逻辑,可以在子类中重写这个方法。
  2. findClass(String className)

    • 查找指定名称的类。这个方法在默认情况下是由loadClass方法调用的,用于自定义类的加载逻辑。
    • 在自定义ClassLoader子类中重写这个方法,实现自己的类查找逻辑。
  3. defineClass(String name, byte[] b, int off, int len)

    • 将字节数组转换为类的实例。
    • 这个方法允许自定义加载类的字节码,并创建对应的java.lang.Class对象。
  4. resolveClass(Class<?> c)

    • 解析指定的类。在连接阶段,将类的符号引用解析为直接引用。
    • 这个方法一般由loadClass方法调用,用于确保类被正确连接。
  5. getResource(String name)

    • 查找指定名称的资源文件。可以是类路径中的资源文件,也可以是JAR包中的资源文件。
    • 返回一个URL对象,或者null如果资源未找到。
  6. getSystemClassLoader()

    • 返回系统类加载器(应用程序类加载器)。
    • 这个方法可以用于获取系统类加载器的引用,方便加载类和资源。
  7. getParent()

    • 返回该类加载器的父类加载器。
    • 可以用于在自定义类加载器中使用双亲委派模型,将类加载请求传递给父类加载器。
  8. getResources(String name)

    • 查找指定名称的所有资源文件,返回一个Enumeration<URL>对象。
  9. setClassAssertionStatus(String className, boolean enabled)setPackageAssertionStatus(String packageName, boolean enabled)

    • 设置断言状态,用于类和包的断言检查。
    • 断言可以在开发和调试中用于验证代码假设和逻辑。

获取类加载器

在Java中,可以通过多种方式获取类加载器(ClassLoader)的引用,以便于加载类、资源文件等操作。以下是一些常见的获取类加载器的方法:

  1. 获取当前类的类加载器
    在类中,可以使用getClassLoader()方法获取加载该类的类加载器。这个方法是java.lang.Class类的成员方法。

    ClassLoader classLoader = YourClass.class.getClassLoader();
    
  2. 获取系统类加载器(应用程序类加载器)
    可以使用ClassLoader.getSystemClassLoader()方法获取系统类加载器的引用。

    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
    
  3. 获取线程上下文类加载器
    线程上下文类加载器是通过Thread类的getContextClassLoader()方法获取的,它可以用于加载线程所需的类和资源。

    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
    
  4. 获取类加载器的父加载器
    可以使用getParent()方法获取类加载器的父加载器。

    ClassLoader parentClassLoader = classLoader.getParent();
    
  5. 通过类全限定名加载类并获取类加载器
    可以通过加载一个已知的类,然后获取其类加载器的引用。

    Class<?> loadedClass = Class.forName("com.example.SomeClass");
    ClassLoader classLoader = loadedClass.getClassLoader();
    

注意事项:

  • 类加载器是按照类加载器层次结构的顺序进行查找和加载的,所以不同类加载器加载的类是不同的。如果需要在不同的类加载器之间共享类,需要通过适当的类加载器委派方式来解决。

  • 在不同的应用场景中,选择合适的类加载器非常重要。例如,当你在开发涉及到模块隔离的插件系统时,可能需要自定义类加载器来实现不同模块的隔离加载。

双亲委派机制

双亲委派机制(Parent Delegation Model)是Java类加载器的一种工作机制,用于保证类加载的一致性、避免重复加载以及防止恶意代码的加载。它是Java类加载器层次结构的核心原则之一。

当一个类加载器需要加载类时,它首先不会尝试自己加载,而是将加载请求委托给它的父加载器。这个过程会递归地传递,直到达到顶级的引导类加载器。只有当父加载器无法加载类时,子加载器才会尝试加载。

双亲委派机制的工作过程如下:

  1. 当一个类加载器(子加载器)接收到加载类的请求时,它首先会调用父加载器的loadClass方法,将加载请求传递给父加载器。

  2. 父加载器(如果有的话)会继续将加载请求传递给它的父加载器,直到达到顶级的引导类加载器。

  3. 引导类加载器尝试加载类。如果找到了该类,则返回对应的Class对象,加载结束。

  4. 如果在层次结构的某个级别找不到所需的类,父加载器会将加载请求返回给子加载器。子加载器会尝试自己加载类。

  5. 如果子加载器找到了类,它会将对应的Class对象返回。如果子加载器无法找到类,会将加载请求传递给更高级的父加载器。

通过这种机制,保证了类的一致性,即同一个类只会被加载一次。这在JVM中尤其重要,因为不同的类加载器可能在不同的环境中加载相同的类,如果没有双亲委派机制,可能会导致类的重复加载和冲突。

双亲委派机制还有助于防止恶意代码的加载。例如,如果一个不受信任的类加载器试图加载java.lang.Object,因为双亲委派机制,它会首先委托给父加载器,而不是加载自己的版本。这可以防止恶意代码替换核心类

运行时数据区

方法区
方法区
虚拟机栈
虚拟机栈
本地栈
本地栈
程序计数器
程序计数器
运行时数据区
运行时数据区
Text is not SVG - cannot display

JVM(Java Virtual Machine)在执行Java程序时会将其内部数据结构组织成一些运行时数据区域,这些区域用于存储不同类型的数据和执行不同类型的操作。JVM的运行时数据区域主要包括以下几个部分:

  1. 方法区(Method Area)
    • 也称为永久代(Permanent Generation)(在JDK 7及之前的版本)或元空间(Metaspace)(在JDK 8及之后的版本)。
    • 存储类的结构信息、常量、静态变量、即时编译器编译后的代码等。
    • 在JDK 8及之后的版本中,元空间使用本地内存而不是JVM堆内存。
  2. 堆(Heap)
    • 存储对象实例和数组,是Java程序中动态分配内存的区域。
    • 被所有线程共享,是垃圾回收的主要区域。
    • 可分为新生代(Young Generation)和老年代(Old Generation),新生代进一步分为Eden区、Survivor区。
  3. 虚拟机栈(VM Stack)
    • 每个线程在执行方法时都会创建一个栈帧,用于存储局部变量、方法参数、操作数栈、动态链接等。
    • 栈帧随方法的调用和返回而入栈和出栈。
  4. 本地方法栈(Native Method Stack)
    • 类似虚拟机栈,但用于执行本地(非Java)方法。
    • Hotspot JVM中,本地方法栈和虚拟机栈两者合二为一
  5. 程序计数器(Program Counter Register)
    • 每个线程都有一个程序计数器,用于记录当前线程执行的字节码指令的位置。
    • 线程切换时能够恢复到正确的执行位置。

一个JVM对应一个Runtime对象实例

程序计数器

JVM中的程序计数器(Program Counter Register)是一块较小的内存区域,它是每个线程独有的,并且在线程切换时不会发生上下文切换,用于记录当前线程执行的字节码指令的位置,是对物理程序计数器的模拟

程序计数器在JVM中具有以下特点和作用:

  1. 记录当前执行位置:程序计数器用于记录当前线程正在执行的字节码指令的位置,即JVM中的程序计数器指向当前正在执行的指令。因为线程切换时无需考虑程序计数器的状态,所以能够保证线程切换后能够恢复到正确的执行位置。

  2. 多线程支持:每个线程都有自己的程序计数器,互不影响。这是因为线程切换时可以让程序计数器恢复到线程切换前的状态,从而保证线程独立执行。

  3. 字节码解释器的工作基础:程序计数器在字节码解释器中起到重要作用,它确定了下一条要执行的指令。字节码解释器根据程序计数器的值来获取下一条指令,并将程序计数器递增,以指向下一条指令。

  4. 方法调用和跳转支持:在方法调用和跳转(如条件跳转、循环跳转)时,程序计数器记录了下一条要执行的指令地址。方法返回时,程序计数器将会被恢复到方法调用之前的值,使得程序能够继续执行。

  5. 用于流程控制、分支、循环、跳转、异常处理

需要注意的是,程序计数器是JVM中唯一一个不会出现OutOfMemoryError的区域,也是运行速度最快的一个区域,因为它是线程私有的,且只存储了当前线程的执行位置信息。在执行本地方法时,程序计数器值是未定义的。

虚拟机栈

Java的指令是根据栈来设计的,不同的CPU架构不同,所以不能设计为基于寄存器的,虚拟机栈(VM Stack)是Java虚拟机为每个线程私有创建的一个内存区域,用于存储线程执行方法时的局部变量、操作数栈、方法调用和返回等相关信息。每个方法在执行时都会创建一个栈帧(Stack Frame),访问速度仅次于程序计数器,不存在垃圾回收,栈帧包含了方法的局部变量表、操作数栈、动态链接、方法返回地址等。

虚拟机栈在Java虚拟机运行时数据区域中的作用和特点包括:

  1. 存储方法调用信息:每当一个方法被调用,就会在虚拟机栈中创建一个对应的栈帧,用于存储方法的参数、局部变量等信息。栈帧中的信息会在方法执行时被使用,方法执行完毕后会被弹出。

  2. 支持方法的嵌套调用:如果一个方法在执行过程中又调用了另一个方法,新方法的栈帧会被压入虚拟机栈。这样,支持方法的嵌套调用。

  3. 局部变量表:每个栈帧都包含一个局部变量表,用于存储方法的参数和局部变量。这些变量的生命周期与方法的调用和返回相对应。

  4. 操作数栈:操作数栈用于执行方法中的计算操作,包括方法的参数传递、方法的返回值等。

  5. 方法调用和返回:栈帧中存储了方法的返回地址,使得方法在执行完成后可以正确返回到调用它的位置。

  6. 异常处理:虚拟机栈也参与异常处理机制。当方法抛出异常时,虚拟机会搜索调用栈,查找异常处理代码块。

  7. 线程私有:每个线程都有自己的虚拟机栈,确保了线程之间的隔离。

  8. StackOverflowError:如果递归调用的层次过深,虚拟机栈可能会耗尽,导致StackOverflowError

虚拟机栈的大小是可以设置的,JVM允许栈的大小固定或者动态变化,不过其大小的设置在一定程度上受限于操作系统和硬件。当一个线程的栈空间不足时,可能会引发StackOverflowError,而当栈空间用尽但无法再扩展时,可能会引发OutOfMemoryError

设置栈的大小

java -Xss512k YourMainClass

栈帧

栈帧(Stack Frame)是虚拟机在执行方法调用时创建的数据结构,用于存储一个方法的局部变量、操作数栈、动态链接、方法返回地址等信息。每当一个方法被调用时,虚拟机会为该方法创建一个栈帧,随着方法的执行,栈帧会被推入虚拟机栈,方法执行完毕后会被弹出。

一个典型的栈帧包含以下主要部分:

  1. 局部变量表(Local Variable Table)
    • 被定义为一个数字数组
    • 用于存储方法的参数和局部变量。
    • 包括各种基本数据类型以及对象引用。
    • 在编译期间确定局部变量表的大小。
  2. 操作数栈(Operand Stack)
    • 也被称为表达式栈
    • 用于在方法执行过程中进行计算操作。
    • 是一个后进先出(LIFO)的数据结构。
    • 存储方法执行过程中需要使用的操作数、中间计算结果等。
  3. 动态链接信息(Dynamic Linking)
    • 也被称为指向运行时常量池的方法引用
    • 将符号引用转换为直接引用,以支持方法的调用。
    • 包括调用目标方法的内存地址、方法所在类的运行时常量池索引等。
  4. 方法返回地址(Return Address)
    • 也被称为方法正常退出或者异常退出的定义
    • 记录了方法执行完毕后需要返回的位置,以确保程序继续执行。
    • 根据不同的返回情况,可以有普通返回和异常返回两种方式。

栈帧的生命周期与方法调用的生命周期相对应。在方法调用时,虚拟机会为调用的方法创建一个栈帧,将其推入虚拟机栈。随着方法的执行,局部变量表和操作数栈会被不断地使用和修改。当方法执行完毕后,栈帧会被弹出,将控制权交还给调用方。

局部变量表

局部变量表(Local Variable Table)是栈帧(Stack Frame)中的一个重要部分,用于存储方法的参数和局部变量。每个栈帧都会有一个局部变量表,它是一个固定大小的表格,用于存储方法在执行过程中使用的参数和局部变量。

局部变量表的特点和作用包括:

  1. 存储方法的参数和局部变量:局部变量表用于存储方法的输入参数以及方法内部定义的局部变量。参数和局部变量的值在方法的执行过程中可以被修改。

  2. 基本数据类型和对象引用:局部变量表中可以存储各种基本数据类型(如int、float、boolean等)以及对象引用。对象引用指向堆中的对象实例。

  3. 局部变量表大小在编译期间确定:局部变量表的大小在编译器进行字节码生成时就被确定下来,并且在方法运行期间是不会改变的。

  4. 非静态方法和实例方法的this:对于非静态方法(实例方法),局部变量表的第一个位置通常是this引用,指向当前对象的引用。

  5. 作用域和生命周期:局部变量表中的变量具有作用域和生命周期。作用域通常是从变量定义的位置开始,到其所在方法的结束位置。生命周期是变量从创建到销毁的过程。

  6. 存储引用和值:对于基本数据类型,局部变量表存储的是实际的值。对于对象引用,局部变量表存储的是引用,而实际对象位于堆中。

局部变量表在方法执行过程中起到了存储和管理方法参数和局部变量的重要作用。在栈帧中,局部变量表和操作数栈一起支持方法的执行和计算操作。由于局部变量表在编译期间就被确定了大小,所以它的内存分配是在方法调用时进行的,而不是在运行期间动态分配。

变量槽

局部变量表(Local Variable Table)中的变量槽(Variable Slot)是用于存储方法的参数和局部变量值的存储单元。每个方法在执行时都会创建一个栈帧,栈帧中的局部变量表会根据方法的参数和局部变量进行分配和使用。

变量槽的特点和作用包括:

  1. 存储数据:变量槽用于存储方法的参数和局部变量的值。它可以存储各种基本数据类型(如int、float、boolean等)以及对象引用。

  2. 基本数据类型和引用:变量槽中可以存储基本数据类型的值,也可以存储指向堆中对象实例的引用。

  3. 顺序分配:局部变量表中的变量槽是顺序分配的,它们按照字节码的顺序进行编号,编号从0开始。每个变量槽的大小与数据类型有关。

  4. 方法的参数和局部变量:局部变量表中的变量槽用于存储方法的输入参数和方法内部定义的局部变量。参数会被依次存储在前几个变量槽中,后面的变量槽用于存储方法内部定义的局部变量。

  5. 作用域和生命周期:变量槽的作用域通常是从变量定义的位置开始,到其所在方法的结束位置。变量的生命周期是指从变量分配内存开始,到变量被销毁释放内存的整个过程。

  6. 大小固定:每个变量槽的大小是固定的,32位以内占用一个槽,64位占用两个槽,与数据类型和虚拟机的实现有关。不同的数据类型可能需要不同大小的变量槽。

  7. 每个槽都会分配一个索引,如果一个变量占用多个槽,那么将以起始索引作为这个变量的索引

  8. 非静态的方法的第一个槽(索引为0)为当前对象的引用this

  9. 槽位也会重复利用,如果一个变量的作用域在到达当前位置时已经失效了,那么这个槽位会被重复利用:

    int a = 1;
    {
        int b = 3333;
        b += a;
    }
    int c = 100;
    
    索引 变量
    0 this
    1 a
    2 b
    2 c
操作数栈
        int a = 20;
        int b = 30;
        int c = a + b;
        return c;

字节码:

 // 入栈
 0 bipush 20
 // 出栈并存到第一个位置
 2 istore_1
     
 // 入栈
 3 bipush 30
 // 出栈并存到第二个位置
 5 istore_2
     
 // 入栈
 6 iload_1
 // 入栈
 7 iload_2
 // 出栈两个并把相加后的结果入栈
 8 iadd

 // 出栈并存到第三个位置
 9 istore_3
 // 将第三个元素入栈
10 iload_3
 // 出栈并返回
11 ireturn
动态链接

不具备多态性的方法都不是虚方法,例如构造器、final修饰的方法、静态方法、private权限的方法

动态链接(Dynamic Linking)是栈帧(Stack Frame)中的一个重要组成部分,用于将符号引用(Symbolic Reference)转换为直接引用(Direct Reference),以便支持方法的调用。动态链接是实现方法调用的关键机制之一。

在Java虚拟机的栈帧中,动态链接的作用和原理包括:

  1. 符号引用和直接引用

    • 符号引用是一种在编译期或运行时用于描述方法、字段、类等的符号名称。它不包含具体的内存地址或偏移量。
    • 直接引用是实际的内存地址或偏移量,能够直接指向目标方法、字段或类。
  2. 支持方法调用

    • 在Java中,方法调用时通过动态链接来确定目标方法的具体内存地址。这样,即使目标方法所在的类发生了变化(例如继承、重写等),也能保证方法调用的正确性。
    • 动态链接在方法调用时将符号引用转换为实际的内存地址,以支持方法的正确调用。
  3. 解决编译期无法确定目标地址的问题

    • 在编译期,无法获知方法的最终调用目标,因为在继承、重写等情况下,方法的具体地址可能会发生变化。
    • 动态链接将方法调用的决策推迟到运行时,以便在运行时根据实际情况进行解析。
  4. 支持多态

    • 动态链接允许程序根据实际运行时的对象类型来决定方法的调用目标,实现了多态的特性。
    • 通过动态链接,可以在不同的子类中重写父类方法,并在运行时选择正确的方法实现。

总之,动态链接在栈帧中起到了将符号引用转换为直接引用的重要作用,以支持方法的正确调用和多态特性。这种机制使得Java的方法调用能够适应继承、重写等动态变化的情况,实现了灵活性和可维护性。

虚方法表

虚方法表保存了方法的直接引用,虚方法表(Virtual Method Table,VMT)是一种用于实现多态性的数据结构

在Java中,虚方法表是一种存储在类的元数据中的数据结构,用于支持动态绑定(也称为后期绑定)和实现方法的多态调用。每个类都有一个对应的虚方法表,它存储了该类中的虚方法的地址,以及可能继承自父类的虚方法。

虚方法表的特点和作用包括:

  1. 支持多态性:虚方法表的存在使得子类可以重写父类的方法,并且在运行时根据对象的实际类型来调用正确的方法实现。这实现了多态性,允许不同类型的对象调用相同的方法名称,但根据实际类型执行不同的代码。

  2. 动态绑定:虚方法表的内容在运行时根据对象的实际类型进行动态绑定。这意味着,在调用虚方法时,会根据对象的实际类型来查找并调用正确的方法实现。

  3. 继承关系:子类的虚方法表会包含从父类继承来的虚方法地址。如果子类没有重写某个虚方法,那么在虚方法表中会继续引用父类的虚方法实现。

  4. 方法查找效率:由于虚方法表存储了方法的地址,所以在运行时不需要再通过类层级来查找方法,从而提高了方法调用的效率。

方法返回地址

在栈帧(Stack Frame)中,方法的返回地址是一个重要的部分,它用于指示方法执行完毕后应该继续执行的位置。方法的返回地址通常与方法调用和返回的过程密切相关。

当一个方法被调用时,会创建一个新的栈帧用于存储方法的局部变量、操作数栈等信息。方法的执行过程中,如果遇到了方法调用指令(例如函数调用),会将调用后的返回地址压入当前方法的栈帧中,以便在调用结束后能够正确返回。

方法的返回地址的作用和流程如下:

  1. 方法调用:当一个方法调用另一个方法时,当前方法的返回地址会被保存在被调用方法的栈帧中,以便在被调用方法执行结束后返回到正确的位置。

  2. 返回指令:在方法执行过程中,当遇到返回指令(例如return语句)时,会从栈帧中弹出保存的返回地址,将控制流转移到返回地址指示的位置。

  3. 返回操作:在执行完被调用方法后,会利用保存的返回地址回到调用方法继续执行,继续执行调用方法的下一条指令。

  4. 栈帧的销毁:被调用方法执行结束后,相关的栈帧会被销毁,方法的局部变量、操作数栈等会被清除,控制流返回到调用方法。

需要注意的是,方法的返回地址在栈帧中是一个实际的内存地址或偏移量,它指示了一个指令的位置,该指令会在方法调用结束后执行。方法的返回地址的管理由虚拟机负责,它确保方法调用和返回的过程能够正确执行,从而保证程序的正确性。

在Java虚拟机(JVM)中,堆(Heap)是一块用于存储对象实例的运行时数据区域。它是Java程序中几乎所有对象的分配和回收的地方,也是Java内存管理的核心部分。堆是Java虚拟机管理的内存区域之一,具有以下特点和作用:

  1. 对象存储区域:堆用于存储所有的对象实例,无论是通过关键字new创建的对象还是作为方法参数、局部变量的对象。这些对象在程序运行时动态地分配和销毁。

  2. 堆的特性:堆是一个动态分配和释放内存的区域,它的大小可以随着程序的运行和内存需求而扩展或收缩。

  3. 垃圾回收:堆区域由Java虚拟机的垃圾回收机制管理,它负责对象的分配和回收。垃圾回收器会定期扫描堆中的对象,识别不再被引用的对象并进行回收。

  4. 内存分配策略:堆区域可以采用不同的内存分配策略,例如分代垃圾回收,将堆分为新生代和老年代,以便更有效地管理对象的生命周期。

  5. 堆空间划分:堆内存可以划分为多个区域,包括新生代(Eden区、Survivor区)、老年代等。这些区域有不同的用途和回收策略,用于优化垃圾回收性能。

  6. 堆的调优:开发人员可以通过调整堆的大小、选择垃圾回收算法和调整分代策略等来优化堆的性能和内存使用情况。

Java中的堆内存被划分为不同的代(generations),即分代堆内存管理,这是为了优化内存分配和垃圾回收的效率,以适应不同对象的生命周期和内存使用模式。分代堆内存管理的主要目标是提高内存分配和回收的性能,减少应用程序的停顿时间,以及减轻垃圾回收的负担。

分代堆内存管理的主要原因有以下几点:

  1. 对象生命周期不同:在大多数应用程序中,对象的生命周期并不一致。有些对象很快就会成为垃圾,而有些对象可能会存活较长时间。通过将堆内存划分为不同的代,可以更好地适应不同生命周期的对象,从而减少不必要的垃圾回收。

  2. 优化垃圾回收:分代堆内存管理允许不同的垃圾回收策略适用于不同代。新生代中的对象生命周期较短,因此可以使用效率较高的、基于复制的垃圾回收器。老年代中的对象生命周期较长,因此可以使用适合长期存活对象的垃圾回收策略,如标记-清除、标记-整理等。

  3. 减少停顿时间:通过在新生代中使用基于复制的垃圾回收,可以在短时间内快速回收不再存活的对象,从而减少了短期存活对象的存在时间,减轻了老年代垃圾回收的压力,降低了停顿时间。

  4. 避免内存碎片:分代堆内存管理有助于减轻内存碎片问题。新生代中的对象被频繁地分配和回收,不会在内存中留下大量的碎片。老年代中的对象存活时间较长,内存碎片积累的可能性较小。

  5. 提高分配效率:由于新生代中的对象通常很快就会被回收,因此可以使用简单且高效的内存分配算法,如指针碰撞或空闲列表。而在老年代,可能需要更复杂的内存分配算法,以便更好地处理长期存活的对象。

总的来说,分代堆内存管理是一种在不同生命周期的对象之间进行合理分配和管理内存的方式,有助于优化内存分配和垃圾回收的性能,提高应用程序的执行效率。

堆的大小设置

堆空间在物理上可以是不连续的,但逻辑上必须连续,建议初始内存和最大内存一致,可以减少内存分配次数

-Xms-Xmx 是用于配置Java虚拟机(JVM)堆内存的命令行参数,它们可以在运行Java程序时进行设置。这两个参数用于分别指定堆的初始大小和最大大小。以下是对这两个参数的详细解释:

-X代表运行参数,ms代表memory start

  1. -Xms:这个参数用于设置Java堆的初始大小,即在JVM启动时分配给堆的内存空间大小,默认情况下相当于电脑物理内存 / 64。例如,-Xms512m 表示设置初始堆大小为512兆字节(MB)。

  2. -Xmx:这个参数用于设置Java堆的最大大小,即堆能够分配的最大内存空间大小,默认情况下相当于电脑物理内存 / 4。例如,-Xmx1024m 表示设置最大堆大小为1024兆字节(1GB)。

public static void main(String[] args) throws InterruptedException {
    Runtime runtime = Runtime.*getRuntime*();
    long totalMemory = runtime.totalMemory();
    long maxMemory = runtime.maxMemory();
    System.*out*.println("totalMemory = " + totalMemory);
    System.*out*.println("maxMemory = " + maxMemory);
}

查看垃圾回收过程

-XX:+PrintGCDetails 是Java虚拟机的一项命令行参数,用于在执行垃圾回收(GC)时打印详细的垃圾回收信息。这个参数的作用是帮助开发人员了解垃圾回收器的工作情况,以及程序的内存使用情况。

TLAB

线程本地分配缓冲区(Thread-Local Allocation Buffer,TLAB)是Java虚拟机中的一个优化技术,用于加速对象在堆内存中的分配过程。TLAB是一种线程私有的内存区域,每个线程都有自己的TLAB,用于在堆上分配新创建的对象。TLAB的存在可以减少线程之间的竞争,提高对象分配的效率。

TLAB的工作原理如下:

  1. 分配TLAB:每个线程在Java虚拟机启动时都会分配一个初始大小的TLAB,这个大小可以在运行时进行调整。

  2. 对象分配:当一个线程需要创建新的对象时,它会首先检查自己的TLAB是否有足够的空间。如果有足够的空间,线程可以直接在自己的TLAB上进行对象分配,无需在堆上进行分配。

  3. TLAB耗尽:如果一个线程的TLAB耗尽(没有足够的空间用于分配对象),它会尝试从堆上分配一个更大的TLAB,然后再进行对象分配。

  4. 对象的生命周期:TLAB中分配的对象的生命周期通常比较短暂,因为它们往往是局部变量或者是方法内部的临时对象。一旦对象不再被引用,它就会成为垃圾,随着垃圾回收的进行,TLAB中不再被引用的对象会被回收。

通过使用TLAB,可以减少线程之间在堆上的竞争,提高对象分配的效率。这对于多线程的Java应用程序特别有益,因为在多线程环境中,对象的分配和回收可能成为性能瓶颈。可以通过使用 -XX:+UseTLAB 参数来启用TLAB,虽然它默认是开启的。

JVM堆的内存区域

Java虚拟机(JVM)堆内存是程序运行时用于存储对象实例的一块内存区域,它被分为不同的区域来支持不同类型的对象分配和垃圾回收。JVM堆内存主要分为以下几个区域:

  1. 新生代(Young Generation):新生代是堆内存的一部分,主要用于存放新创建的对象。新生代又被分为三个区域:Eden区(新对象的初始分配区域)、Survivor区(幸存者0区域,用于存放部分经过一次垃圾回收的对象)、Survivor区(幸存者1区域,用于存放从Eden区复制过来的对象)。大部分对象在新生代被创建和销毁,存活较长时间的对象会被晋升到老年代。survivor中文为幸存者,读音为sərˈvīvər,默认情况下占堆空间的三分之一。Eden区、Survivor1区、Survivor0区占用新生代的空间大小为:8 : 1 : 1
  2. 老年代(Old Generation):老年代主要用于存放新生代中经过多次垃圾回收仍然存活的对象。老年代的对象生命周期相对较长,垃圾回收不频繁。通常情况下,老年代的对象存储在新生代晋升过来的对象以及直接在老年代分配的大对象。,默认情况下占堆空间的三分之二
  3. 永久代(Permanent Generation)/ 元空间(Metaspace):永久代在JVM中存在于HotSpot虚拟机中,它用于存储类信息、方法信息、常量池等。在Java 8 之后,永久代被元空间(Metaspace)取代,元空间不再位于堆内存中,而是使用本地内存。元空间是在Java 8 中引入的概念,用于代替传统的永久代。它不再位于堆内存中,而是使用本地内存,动态分配类的元数据信息。元空间可以根据应用程序的需要进行自动扩展和释放,减少了永久代可能引发的内存溢出问题。

要调整新生代和老年代在堆中的占比,你需要通过设置Java虚拟机的命令行参数来指定新生代和老年代的大小比例。这涉及到使用不同的垃圾回收器和参数。

在HotSpot JVM中,可以通过以下参数来调整新生代和老年代的占比:

  1. -XX:NewRatio=n:这个参数用来指定新生代和老年代的比例。例如,如果设置为 -XX:NewRatio=2,则新生代占总堆内存的1/3,而老年代占总堆内存的2/3。默认值通常是8,表示新生代占总堆内存的1/9,老年代占总堆内存的8/9。

  2. -XX:SurvivorRatio=n:如果你想更精确地控制新生代中Eden区和Survivor区的大小比例,可以使用这个参数。例如,-XX:SurvivorRatio=8 表示Eden区和每个Survivor区的大小比例为8:1。

  3. -Xmn=size:这个参数用于直接指定新生代的大小。如果你希望更精确地控制新生代的大小,可以使用这个参数。例如,-Xmn=256m 表示新生代的大小为256兆字节。

对象分配内存

  1. 伊甸园区(Eden)和YGC(Minor GC):伊甸园区是新生代中的一部分,是对象最初分配的地方。当伊甸园区没有足够的空间来容纳新的对象时,会触发一次新生代的垃圾回收,也称为YGC(Young Generation GC)。在YGC中,存活的对象会被复制到幸存者区(Survivor),而未存活的对象会被回收,此时伊甸园区变成空的了。
    • 如果伊甸园区向幸存者区复制存活的对象时,幸存者区放不下,那么将会放入到老年代
    • 如果伊甸园区在GC后仍然无法存放新对象,那么会先看一下老年代能否放下,如果能放下就直接放进去
    • 如果老年代放不下,会进行一次Full GC(Major GC),如果仍然放不下,那么将会直接OOM
  2. 幸存者区(Survivor)和对象年龄:新生代中有两个幸存者区,通常被称为S0和S1。一个幸存者区始终为空,被称为to survivor。在YGC过程中,存活的对象会从伊甸园区或另一个幸存者区移动到to survivor。每次YGC后,幸存下来的对象会被移动到另一个空的幸存者区,同时对象的年龄会增加1。这个机制有助于判断哪些对象在幸存者区存活了多久,以决定是否将其晋升到老年代。
    • 如果幸存者区中相同年龄大小的对象总和大于等于幸存者区空间的一半,那么年龄大于等于这个年龄的对象直接进入老年代,无需达到阈值
  3. 对象晋升到老年代:默认情况下,当一个对象在幸存者区中经历了一定数量的垃圾回收(年龄达到一定阈值,通常是15或16次)时,它将会晋升到老年代。这是为了防止新生代中的对象在多次回收后仍然存活,从而导致过于频繁的YGC。一旦对象晋升到老年代,它会在老年代中进行更长时间的存活,直到老年代触发Full GC(Major GC)。

垃圾回收触发时机

  1. YGC(Young Generation GC,Minor GC)

    • 触发时机:YGC发生在新生代(Young Generation)中,当伊甸园区(Eden)和幸存者区(Survivor)的空间不足以容纳新创建的对象时,会触发YGC。通常,当伊甸园区被填满时,会触发YGC。
    • 回收对象:YGC主要回收新生代中的不再存活的对象,存活的对象会被复制到幸存者区,未存活的对象会被回收。
    • 效果:YGC的目标是迅速回收短期存活的对象,以保持新生代的可用空间。
  2. Major GC(Old Generation GC,部分垃圾回收)

    • 触发时机:Major GC发生在老年代(Old Generation)中,通常是在老年代的空间不足时触发。也可以是由于Minor GC过于频繁,导致老年代空间占用过多,从而触发Major GC。
    • 回收对象:Major GC主要关注老年代中的垃圾回收,清理一部分不再存活的对象,以维护老年代的内存空间。
    • 效果:Major GC有助于减少老年代的内存碎片,保持老年代的健康状态。
  3. Full GC(Full Garbage Collection,完全垃圾回收)

    • 触发时机:Full GC发生在整个堆内存中,包括新生代和老年代。它通常在如下情况触发:老年代空间不足、永久代(在Java 8之前的概念)空间不足、明确调用System.gc()方法、在应用程序中出现内存泄漏等。
    • 回收对象:Full GC会对整个堆内存进行全面的垃圾回收,清理所有不再存活的对象。
    • 效果:Full GC的发生可能导致较长的停顿时间,因为它会影响整个应用程序的执行。它的目标是完全回收内存,解决内存问题。

对象不一定都在堆中分配

在Java中,大多数对象都会在堆上创建,因为堆是存放对象实例的主要内存区域。但并不是所有的对象都会在堆上创建,有一些特殊情况下对象可能不会被直接创建在堆上。

以下是一些情况,对象可能不会在堆上创建:

  1. 栈上分配:某些情况下,编译器可以进行栈上分配(Stack Allocation),即将对象分配到栈上而不是堆上。这通常适用于局部变量,特别是在编译期可以确定对象生命周期的情况下。这些对象在方法调用结束后会自动销毁,无需垃圾回收。

    public String getString() {
        StringBuilder sb = new StringBuilder();
        sb.append("asaaa").append("djafkjdas");
        return sb.toString();
    }
    
    public void abc() {
        User user = new User();
        user.set("aaaa");
        user = null;
    }
    
  2. 逃逸分析:逃逸分析是一种优化技术,通过分析对象是否逃逸到方法外部,来决定是否将对象在堆上创建。如果可以确定对象不会逃逸出方法范围,那么编译器可能会选择在栈上分配或进行其他优化。

  3. 编译器优化:一些情况下,编译器可能会对对象进行优化,例如将短时间存活的对象(例如临时对象)直接内联到方法调用中,而不将其创建在堆上。

  4. 方法区(Metaspace):在Java 8之后,永久代被元数据区(Metaspace)所取代。元数据区主要用于存储类的元数据信息,而不是对象实例。因此,类信息等元数据不会在堆上创建。

虽然存在上述情况,但绝大多数普通的对象仍然会在堆上创建。堆是主要的对象存储区域,由Java虚拟机自动管理对象的分配和回收,使得开发人员无需显式地管理内存。

锁消除

锁消除(Lock Elimination)是一种编译器优化技术,旨在通过在特定条件下消除不必要的同步操作(锁操作),从而提高多线程程序的性能。这种优化技术通过分析代码,判断某些情况下锁对象不会被多个线程同时访问,从而避免了不必要的同步开销和线程竞争。

锁消除的主要思想是在编译时对代码进行分析,发现一些情况下可以安全地消除锁操作,以提高程序的执行效率。一般来说,锁消除通常发生在以下情况:

  1. 局部作用域锁:当锁对象的作用域限定在某个方法内部,并且没有将锁对象传递到其他方法中,编译器可以判断该锁对象不会被其他线程访问,从而消除对锁的使用。

  2. 不可能的并发情况:在某些条件下,编译器可以分析出一些代码段永远不会出现并发访问的情况,因此可以安全地消除锁操作。

  3. 基于逃逸分析:逃逸分析技术用于判断对象是否在方法内逃逸到方法外部,如果对象没有逃逸,编译器可以消除相应的同步操作。

  4. 不可变对象:对于不可变对象,即使在多线程环境下,也不需要进行同步操作,因此可以消除不必要的锁。

方法区

JVM方法区(Method Area),也称为永久代(Permanent Generation,在Java 8及之前的版本)或元数据区(Metaspace,从Java 8开始),是Java虚拟机的一个重要内存区域,用于存储类的元数据信息、静态变量、常量池、方法字节码等,方法区也会出现OOM的问题

User user = new User();

  • 其中User类位于方法区
  • user变量位于栈
  • new User()的对象实例位于堆

以下是关于JVM方法区的一些重要信息:

  1. 存储内容:方法区主要用于存储已加载的类的结构信息,包括类名、访问修饰符、字段信息、方法信息、方法字节码、常量池等。它还存储了类的静态变量,以及在早期的JVM版本中存放运行时常量池。在Java 8及之前的版本,方法区还存储了永久代中的元数据信息,如类的方法、字段、注解等。

  2. 永久代与元数据区:在Java 8及之前的版本,方法区被划分为永久代,用于存储类的元数据信息。然而,永久代有时会导致内存溢出的问题,而且难以进行动态调整。因此,从Java 8开始,方法区被替换为元数据区(Metaspace),元数据区的内存不再受到固定大小的限制,而是直接使用本地内存。

  3. 垃圾回收:虽然方法区存储的是类的元数据和静态数据,但并不意味着方法区不需要进行垃圾回收。在早期的JVM版本中,永久代中的垃圾回收主要针对无用的类和类加载器进行回收。而在元数据区,垃圾回收主要针对被废弃的类的元数据以及无用的类加载器。

  4. 元数据区的特性:元数据区是在本地内存中分配的,因此不会受到Java堆大小的限制。元数据区的大小可以动态调整,从而避免了永久代固定大小的问题。另外,元数据区的回收主要依赖于类的可达性分析。

永久代和元空间

永久代(Permanent Generation)和元空间(Metaspace)都是Java虚拟机的方法区(Method Area)的不同实现方式,用于存储类的元数据信息、常量池、方法字节码等。然而,它们在内存管理、特性和行为方面存在一些重要的区别:

永久代(Permanent Generation):

  1. 适用范围:永久代是在Java 8之前的版本中使用的方法区实现方式。它主要用于存储类的元数据信息、常量池、静态变量等。
  2. 内存管理:永久代的大小是有限制的,一般通过-XX:MaxPermSize参数进行配置。由于永久代的内存大小有限,容易导致内存溢出,特别是在应用程序使用大量动态生成类或字符串的情况下。
  3. 垃圾回收:在永久代中,垃圾回收主要针对类加载器、类的元数据信息等进行,但它并不会像堆内存一样执行完整的垃圾回收过程。
  4. 特性:永久代容易导致内存泄漏或溢出问题,例如由于动态生成的类无法被垃圾回收而导致永久代内存不断增加。
  5. 使用的是虚拟机内存

元空间(Metaspace):

  1. 适用范围:元空间是从Java 8开始引入的方法区实现方式。它与永久代相比更加灵活,不再受固定内存大小的限制。
  2. 内存管理:元空间的大小不再由固定的参数控制,而是使用本地内存,因此不易发生内存溢出。它可以动态地根据应用程序的需要自行扩展。
  3. 垃圾回收:元空间依赖于类的可达性分析来进行垃圾回收,而不是像永久代那样仅仅回收一些特定的元数据。
  4. 特性:元空间避免了永久代中的一些问题,如内存泄漏和溢出。但是,需要注意的是,元空间仍然可以由于类加载频繁而消耗过多内存,因此需要根据应用程序的需要进行适当的调整。
  5. 使用的是本地内存

元空间的空间不足时:Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

被替换原因

永久代(Permanent Generation)是Java虚拟机中的一部分,用于存储类的元数据信息、常量池、静态变量等。然而,永久代在一些应用场景下存在一些问题,导致它被元空间(Metaspace)所取代。以下是一些导致永久代被替代的主要原因:

  1. 内存限制和溢出问题:永久代的大小是由 -XX:MaxPermSize 参数控制的,但它的大小是有限制的。在一些应用中,特别是使用大量动态生成类的场景(如动态代理、大量反射操作),可能会导致永久代溢出(OutOfMemoryError: PermGen)。

  2. 类卸载问题:在永久代中加载的类一般不会被卸载,这可能导致类加载器的内存泄漏问题。对于一些长时间运行的应用,如Web应用服务器,动态部署和卸载应用会使永久代中的类越来越多,最终导致内存问题。

  3. GC效率问题:永久代的垃圾回收效率较低,会导致长时间的Full GC。Full GC 的频繁发生会影响应用的吞吐量和性能。

  4. 调优复杂性:永久代的调优较为复杂,需要根据不同的应用场景设置合适的大小。这需要进行一定的经验和测试,不同的应用可能需要不同的配置。

元空间作为一种取代永久代的解决方案,具有以下优势:

  • 动态内存分配:元空间不再受到固定大小的限制,它可以根据需要动态地分配内存,从而避免了永久代溢出的问题。

  • 类卸载支持:元空间支持类的卸载,减轻了类加载器内存泄漏问题。

  • GC效率提升:由于类的元数据存储在本地内存中,GC 效率得到提升,不再需要执行 Full GC 来清理永久代。

  • 简化调优:元空间的大小可以根据应用的需要进行自动调整,减轻了调优的复杂性。

总之,元空间的引入解决了永久代存在的一些问题,提升了类加载和内存管理的效率,同时减少了调优的复杂性。这是为什么许多JVM实现逐渐将永久代替换为元空间的原因之一。

调整元空间大小

  1. -XX:MetaspaceSize:这个参数可以用来设置元空间的初始大小。默认情况下,它会根据应用程序的需要进行自动调整,但也可以通过这个参数来指定一个初始大小。
  2. -XX:MaxMetaspaceSize:这个参数用来限制元空间的最大大小。虽然元空间不会像永久代那样导致内存溢出,但仍然可以通过这个参数来控制元空间的上限。

元空间的构成

  1. 类的元数据信息:方法区存储了已加载类的元数据信息,包括类的名称、父类、接口、字段、方法等。这些信息描述了类的结构和特性,支持类的加载和解析。
  2. 运行时常量池:方法区中的运行时常量池与类的常量池对应,但它包含了一些在类加载阶段需要解析的符号引用。运行时常量池包含字符串常量、类和接口的符号引用、字段和方法的符号引用等。
  3. 静态常量
  4. 方法字节码:每个类的方法字节码都存储在方法区中。方法字节码是一系列字节码指令,描述了方法的具体实现和逻辑。
  5. 方法信息:方法区存储了类的方法信息,包括方法的访问修饰符、参数列表、返回值类型等。
  6. 异常表:方法区中还存储了异常处理器表,用于在方法抛出异常时进行异常处理。
  7. 符号引用和直接引用:方法区存储了类、字段、方法的符号引用和直接引用,这些引用用于支持类的解析和访问。
  8. 类加载器信息:方法区中包含了加载类的类加载器信息,用于支持类的动态加载和卸载。
  9. JIT即时编译后的缓存

静态变量、字符串常量池存放在堆中

需要注意的是,方法区的具体内部构成在不同的JVM实现中可能有所不同,但总体来说,它是存储类的元数据信息和方法字节码等数据的关键区域,支持Java虚拟机执行类加载、解析、验证和执行等操作。

常量池

常量池是.class文件中的一部分,包含了各种字面量、类型、成员变量/类变量、方法的符号引用

符号引用

直接引用(Direct Reference)是一种指向内存中目标实体的具体指针、偏移量或者句柄。与符号引用不同,直接引用不再是抽象的符号,而是可以直接被虚拟机使用的实际引用。直接引用的作用是将符号引用解析为具体的内存地址或位置,从而能够在运行时直接访问目标实体。

符号引用(Symbolic Reference)是一种编译时的引用,用于在编译阶段定位目标实体,如类、方法、字段等。它在程序代码中使用名称来表示目标实体,而不直接包含目标的内存地址或偏移量。符号引用是一种抽象的引用,它在编译后需要被解析为直接引用(Direct Reference),才能在运行时找到目标实体。

符号引用通常包括以下信息:

  • 类名或字段/方法名:表示目标实体所属的类名或者字段/方法的名称。
  • 描述符(Descriptor):描述目标实体的类型签名,如方法参数列表、返回类型等。
  • 引用类的常量池索引:指向常量池中的位置,用于定位目标实体的描述信息。

在编译阶段,符号引用用于支持类的解析、字段的解析、方法的解析等操作。在类加载和链接阶段,虚拟机会将符号引用解析为直接引用,以便实际定位并访问目标实体。

符号引用就像是一个代号,比如你看到一张地图上标着“餐厅”的标志,它并没有告诉你餐厅具体在哪里,只是一个提示。在编程中,我们也会用类似的方式指代一些东西,比如一个类的名字、方法的名字等,但不直接告诉程序在内存中的位置。

直接引用就像是你亲自去餐厅吃饭,你知道具体的地点,可以直接走过去。在计算机里,直接引用就是告诉程序实际内存中的位置,让它可以直接找到需要的东西。

所以,符号引用是一种间接的引用,类似于标签或名字,它需要在运行时被解析成具体的内存位置,而直接引用则是指向实际内存位置的引用,程序可以直接使用。符号引用就像是一个代号,比如你看到一张地图上标着“餐厅”的标志,它并没有告诉你餐厅具体在哪里,只是一个提示。在编程中,我们也会用类似的方式指代一些东西,比如一个类的名字、方法的名字等,但不直接告诉程序在内存中的位置。

直接引用就像是你亲自去餐厅吃饭,你知道具体的地点,可以直接走过去。在计算机里,直接引用就是告诉程序实际内存中的位置,让它可以直接找到需要的东西。

所以,符号引用是一种间接的引用,类似于标签或名字,它需要在运行时被解析成具体的内存位置,而直接引用则是指向实际内存位置的引用,程序可以直接使用。

字面量

字面量是一种程序中用于表示常量值的符号,直接表示了其自身的值。在编程语言中,字面量通常用于表示数值、字符、字符串、布尔值等常量。

以下是一些常见类型的字面量示例:

  • 整数字面量:如 10-5
  • 浮点数字面量:如 3.14-0.25
  • 字符字面量:如 'A''1'
  • 字符串字面量:如 "Hello, World""Java"
  • 布尔字面量:如 truefalse
  • null 字面量:表示空值,如 null

运行时常量池和常量池的区别

运行时常量池(Runtime Constant Pool)和常量池(Constant Pool)是Java虚拟机中的两个相关概念,它们在类加载和类运行过程中扮演不同的角色。以下是这两者之间的区别:

  1. 定义位置

    • 常量池:常量池是在编译阶段由编译器生成的一部分类文件(.class文件中包括常量池这一部分),包含了类中使用的各种字面常量、符号引用、方法和字段的信息,常量池是字节码文件的一部分。
    • 运行时常量池:运行时常量池是在类加载阶段从编译后的常量池(.class文件中)中复制得到的,它是方法区的一部分,存储了类加载后的常量池信息。
  2. 生命周期

    • 常量池:常量池存在于编译后的类文件中,与类文件一起被加载到JVM中。
    • 运行时常量池:运行时常量池是在类加载后从编译后的常量池中复制得到的,并且在类的生命周期中存在,直到类被卸载。
  3. 内容变化

    • 常量池:常量池内容是在编译时确定的,存储了类中使用的字面常量、符号引用等信息。
    • 运行时常量池:运行时常量池的内容可能会动态生成,例如通过 String.intern() 方法动态添加字符串常量。
  4. 作用

    • 常量池:常量池是类文件的一部分,存储了类的元数据信息、方法、字段的引用等,用于在类加载阶段解析符号引用。
    • 运行时常量池:运行时常量池是方法区的一部分,支持类的解析、动态链接和常量引用的解析。

字符串常量池

字符串常量池从永久代(Permanent Generation)被移动到堆中的主要原因与解决永久代的一些问题有关。以下是为什么字符串常量池从永久代变到了堆中的一些原因:

  1. 内存限制和溢出问题:永久代的大小是有限制的,通过 -XX:MaxPermSize 参数控制。在一些应用中,尤其是包含大量字符串常量的应用,可能会导致永久代溢出。这是因为字符串常量池中的字符串不断增加,而永久代无法容纳所有的字符串。

  2. 类卸载问题:永久代中的字符串常量池的字符串一般不会被卸载,这可能导致内存泄漏问题,尤其是在动态部署和卸载应用的情况下。

  3. GC效率问题:永久代的垃圾回收效率较低,会导致长时间的Full GC。字符串常量池中的字符串对象会变得越来越多,这会增加GC的负担。

  4. 类加载器限制:某些情况下,不同的类加载器可以有自己的字符串常量池,这可能导致类加载器内存泄漏问题。

为了解决这些问题,从Java 7 开始,字符串常量池被移动到了堆内存中。这个变化带来了以下好处:

  • 堆内存管理:字符串常量池现在由垃圾回收器来管理,允许不再使用的字符串对象被垃圾回收。这减轻了永久代的内存管理问题。

  • GC效率提升:字符串常量池中的字符串对象现在与其他堆中对象一样,可以更高效地被垃圾回收。

  • 类加载器独立性:不同类加载器中的字符串常量池现在都在堆中,不再导致内存泄漏问题。

垃圾回收

方法区(Method Area)的内存回收在Java虚拟机中不同于堆内存的垃圾回收。方法区主要存储类的元数据信息、常量池、静态变量、方法字节码等,通常情况下,这些数据在类加载时被加载到方法区,一般不会随着程序的执行而销毁。因此,方法区的内存回收机制与堆内存的垃圾回收机制有一些不同之处。

方法区的内存回收主要包括以下几个方面:

  1. 类的卸载:方法区中的类信息不再被引用,且没有任何地方引用该类的实例,这个类就可以被卸载(Unloaded)。类卸载是方法区内存回收的一部分。需要注意的是,不是所有的类都能被卸载,比如系统类加载器加载的类通常不会被卸载。

  2. 常量池的回收:如果一个常量不再被任何引用持有,那么它可以被回收。这通常发生在字符串常量池,当字符串不再被引用时,可以被垃圾回收。

  3. 静态变量的回收:静态变量通常会在类加载时初始化,如果不再被引用,那么它们占用的内存可以被回收。

  4. 方法字节码的回收:方法区中存储的方法字节码一般不会回收。但是,某些情况下,一些动态生成的类可能会被卸载,导致它们的方法字节码被回收。

需要注意的是,方法区的内存回收通常发生在类卸载时,而不是像堆内存那样频繁进行垃圾回收。方法区的内存管理和回收机制因Java虚拟机的不同实现而异,一些虚拟机可能不支持方法区的内存回收。在Java 8 中,永久代被元空间取代,方法区的内存管理方式也有所不同。

类的卸载的情况比较苛刻:

类卸载是指当一个类不再被引用时,该类的字节码以及与之相关的类信息从方法区(在Java 8及之前的版本是永久代,Java 9及以后是元空间)中被卸载。类卸载是Java虚拟机的内存管理机制之一,用于释放不再需要的类的内存,以避免内存泄漏和减少内存占用。

类卸载发生的情况通常包括以下情形:

  1. 类加载器被回收:当一个类加载器不再被引用,也就是没有任何对象引用该类加载器,且加载器加载的所有类都不再被引用时,这个类加载器加载的类就可以被卸载。这种情况通常出现在动态类加载和卸载的应用中。

  2. 类不再被引用:当一个类的所有实例都不再被引用,且没有任何静态变量或常量引用该类,这个类就可以被卸载。

需要注意的是,不是所有的类都可以被卸载。一些条件限制了类的卸载:

  • 系统类加载器加载的类通常不能被卸载,因为它们在整个应用程序的生命周期内都存在。
  • 基础类库中的类也通常不能被卸载,因为它们对应用程序是必需的。
  • 一些虚拟机实现可能不支持类卸载。

类卸载在某些情况下对于动态加载和卸载类的应用非常有用,例如在Web应用服务器中,当应用被卸载时,相应的类也可以被卸载,从而释放内存资源。类卸载的实现因虚拟机的不同而异,需要虚拟机具备相应的功能来支持它。在Java 8及之前的版本,类卸载通常发生在永久代,而在Java 9及以后的版本,类卸载发生在元空间。

对象

从JVM的角度来看,创建对象的过程通常包括以下步骤:

  1. 类加载:首先,JVM需要加载对象所属的类。如果该类还没有被加载,JVM会根据类的全限定名找到类的字节码文件,并将其加载到内存中。这一步通常包括类加载、链接(验证、准备、解析)以及初始化。

  2. 分配内存:一旦类加载完成,JVM需要为对象分配内存空间。在这个步骤中,JVM需要确定对象的大小,然后在堆内存中分配一块连续的内存空间。JVM可以使用不同的垃圾回收算法来管理堆内存。

  3. 初始化:分配内存后,JVM会对新创建的对象进行初始化。这包括设置对象的字段为默认值,以及执行构造方法(如果有的话)来初始化对象的状态。

  4. 设置对象的头信息:JVM会在对象的头部信息中记录对象的元数据,例如对象的哈希码、类的指针等。

  5. 返回对象引用:最后,JVM会返回一个指向新创建对象的引用,这个引用可以被程序中的变量所持有,从而可以操作和访问对象。

对象在堆中的内存布局

在Java中,对象的内存布局通常包括对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)这三个部分。不同的JVM实现可能在细节上有所不同,但一般都包括这些部分。

  1. 对象头(Header)
    • 哈希码(HashCode):对象的哈希码,用于支持hashCode()方法和哈希表数据结构。
    • GC年龄
    • 锁状态标志(Lock State Flags):用于支持对象的同步操作,如synchronized关键字。
    • 数组长度或对象的大小(Array Length or Object Size):对于数组对象,存储数组长度;对于普通对象,存储对象的大小。
    • 对象所属类的指针(Class Pointer):指向对象的类的元数据,用于支持动态方法调用等。
  2. 实例数据(Instance Data)
    • 存储对象的实际数据,包括对象的字段值。这些字段可以是类中声明的实例变量,也可以是继承的字段。
  3. 对齐填充(Padding)
    • 由于底层硬件和内存分配的要求,对象的大小通常需要对齐到特定的字节边界。填充字节用于保证对象的起始地址是对齐的,以提高内存访问的效率。

JIT和解释器

Java解释器和即时编译器(JIT Compiler)是两种不同的技术,用于执行Java程序的字节码,它们具有不同的工作方式和性能特点。以下是Java解释器和即时编译器的对比:

Java解释器(Interpreter):

  1. 工作原理

    • 解释器逐条解释字节码指令,并执行相应的操作。每次执行都需要解释一次,不会生成本地机器代码。
    • 每条指令都需要进行解释,因此解释器通常较慢。
  2. 启动速度

    • 解释器通常具有快速的启动速度,因为它不需要等待编译过程。
  3. 执行效率

    • 解释器的执行效率相对较低,因为它每次执行都需要解释字节码,没有编译后的机器代码来优化执行。
  4. 跨平台性

    • 解释器具有较好的跨平台性,因为它不生成与底层硬件相关的机器代码。

即时编译器(JIT Compiler):

  1. 工作原理

    • JIT编译器将字节码编译成本地机器代码,通常是在程序运行时进行,根据代码的热度和频繁程度进行选择性编译。
    • 编译后的代码会被缓存,以便后续调用可以直接执行本地机器代码,提高了执行速度。
  2. 启动速度

    • JIT编译器的启动速度通常较慢,因为需要进行编译操作。但在一些情况下,它可以选择性地编译热点代码以提高性能。
  3. 执行效率

    • JIT编译器通常提供更高的执行效率,因为它将字节码编译成机器代码,避免了解释执行的开销。
  4. 跨平台性

    • JIT编译器生成与底层硬件相关的机器代码,因此在某些情况下可能会降低跨平台性,但通常性能优势可以弥补这一点。

综上所述,Java解释器适用于快速启动和较小性能要求的情况,而即时编译器适用于需要更高执行效率的情况。在现代JVM中,通常会结合两者的优势,使用解释器来快速启动应用程序,并在运行时使用即时编译器来优化热点代码,从而兼顾了启动速度和执行效率。这种结合的方式通常称为“混合模式”执行。

String

在JDK 9 中,Java平台的设计者引入了一项名为"Compact Strings"的改进,这个改进的目的是减少String对象在内存上的占用空间。具体来说,它将String类的内部表示从char数组改为byte数组,这导致了一些重要的内部结构的变化。以下是为什么这个变化发生的一些原因:

  1. Unicode编码变化:在Java 9之前,Java中的String使用UTF-16编码来表示Unicode字符。UTF-16编码使用16位(即2字节)来表示一个字符,这对于大多数常见的字符集来说是有效的,但对于辅助平面字符(如Emoji表情符号)来说,它们需要使用4字节来表示。这导致了一些内存浪费。

  2. 内存占用优化:将String的内部表示从char数组改为byte数组,可以节省内存,因为大多数字符在常见情况下只需要一个字节来表示,只有在必要时才使用2字节表示。这可以显著降低String对象的内存占用。

  3. 兼容性:为了确保向后兼容性,Java 9的设计使得String类仍然可以表示所有合法的Unicode字符,包括辅助平面字符。这通过将byte数组中的一些特殊标记值与额外的数据结构一起使用来实现。

底层数据结构

在Java中,字符串常量池的底层数据结构可以理解为一个特殊的散列表(Hash Table)或哈希集合。这个散列表存储了字符串常量的引用,并且确保字符串常量的唯一性。下面是字符串常量池底层数据结构的一些特点:

  1. 哈希表:字符串常量池内部通常使用哈希表来实现。哈希表是一种高效的数据结构,可以根据键(在这里是字符串常量)快速检索对应的值(即字符串引用)。

  2. 哈希函数:为了将字符串映射到哈希表中的位置,字符串常量池使用一个哈希函数。这个哈希函数会计算字符串的哈希码(hash code),并将其转换为哈希表中的索引位置。

  3. 碰撞处理:由于哈希函数不一定能够将所有字符串映射到唯一的索引位置,可能会出现哈希冲突(collision),即多个不同的字符串具有相同的哈希码。为了处理碰撞,哈希表使用一种碰撞解决策略,字符串常量池使用拉链法解决hash冲突

  4. 唯一性:哈希表确保相同内容的字符串在常量池中只有一个实例。当试图将一个字符串添加到常量池时,哈希表首先会检查是否已经存在具有相同内容的字符串,如果存在,则返回现有的引用,否则将新的字符串添加到常量池并返回引用。

  5. 不可变性:字符串常量池中的字符串是不可变的。一旦一个字符串被添加到常量池,它就不能被修改。任何对字符串的修改都会创建一个新的字符串对象,而不会修改原始字符串。

调整字符串常量池的大小

-XX:StringTableSize 是一项用于调整字符串常量池的大小的JVM启动参数。字符串常量池是用于存储字符串字面量的特殊区域,它的大小通常会受到JVM的自动管理,但您可以使用 -XX:StringTableSize 参数来手动设置它的大小。

这个参数的语法为:

-XX:StringTableSize=<size>

其中 <size> 是一个正整数,表示您希望设置的字符串常量池的大小。请注意,实际的大小可能会受到JVM实现的限制,因此不一定能够精确设置到指定大小。

使用 -XX:StringTableSize 参数可以在某些情况下优化字符串字面量的存储和查找性能。如果应用程序中有大量的字符串字面量,并且您注意到字符串常量池的效率不够高,您可以尝试增加字符串常量池的大小。这将使得字符串字面量的存储更分散,减少哈希冲突,从而提高查找速度。

但需要注意的是,在大多数情况下,不需要手动设置这个参数,因为JVM通常会自动管理字符串常量池的大小以适应应用程序的需要。手动调整这个参数应该是在特定性能分析和优化情况下才考虑的事情,并且需要谨慎进行。如果设置得不当,可能会导致内存消耗过多或性能下降。

字符串拼接

  1. 常量和常量拼接,结果都在常量池中,编译器期间会优化

            String s1 = "a" + "b" + "c";
            String s2 = "abc";
    		// true
            System.out.println("s1 == s2 = " + (s1 == s2));
    		// true
            System.out.println("s1.equals(s2) = " + s1.equals(s2));
    
  2. 在拼接时,只要有一个是变量,那么结果在堆中

  3. 拼接的结果调用intern()方法,会主动将字符串常量池中没有的字符串放入到常量池中,并返回对象地址,如果有这个字符串,那么将会直接从常量池中返回地址

        final String a = "java";
        String b = "ee";
        
        String s1 = "javaee";
        String s2 = "java" + "ee";

        String s3 = "java" + b;
        String s4 = a + "ee";
        String s5 = a + b;

        String s6 = s3.intern();
        String s7 = s4.intern();
        String s8 = s5.intern();
        // true
        System.out.println("s1 == s2 = " + (s1 == s2));

        System.out.println();

        // false
        System.out.println("s1 == s3 = " + (s1 == s3));
        // true s4左右都为常量
        System.out.println("s1 == s4 = " + (s1 == s4));
		// false
        System.out.println("s1 == s5 = " + (s1 == s5));

        System.out.println();
        // true
        System.out.println("s1 == s6 = " + (s1 == s6));
        // true
        System.out.println("s1 == s7 = " + (s1 == s7));

        System.out.println("s1 == s8 = " + (s1 == s8));

以上代码反编译后的结果:

        String a = "java";
        String b = "ee";
        String s1 = "javaee";
        String s2 = "javaee";
        String s3 = "java" + b;
        String s4 = "javaee";
        String s5 = "java" + b;
        String s6 = s3.intern();
        String s7 = s4.intern();
        String s8 = s5.intern();
        System.out.println("s1 == s2 = " + (s1 == s2));
        System.out.println();
        System.out.println("s1 == s3 = " + (s1 == s3));
        System.out.println("s1 == s4 = " + (s1 == s4));
        System.out.println("s1 == s5 = " + (s1 == s5));
        System.out.println();
        System.out.println("s1 == s6 = " + (s1 == s6));
        System.out.println("s1 == s7 = " + (s1 == s7));
        System.out.println("s1 == s8 = " + (s1 == s8));

        String a = "abc";
        String b = "cde";
        String c = a + b;

		// 相当于

        String a = "abc";
        String b = "cde";
        StringBuilder sb = new StringBuilder();
        sb.append(a);
        sb.append(b);
        String c = sb.toString();

public static void method1() {
    String s = "";
    for (int i = 0; i < 100000; i++) {
        // 相当于 10w个StringBuilder对象 + 10w个String 对象
        // StringBuilder sb = new StringBuilder(s);
        // sb.append("a");
        // s = sb.toString();
        s += "a";
    }
}

public static void method2() {
    StringBuilder s = new StringBuilder();
    for (int i = 0; i < 100000; i++) {
        s.append("a");
    }
}

intern()

String 类中的 intern() 方法是一个用于字符串常量池管理的方法。它的主要作用是:

  1. 将字符串对象添加到字符串常量池中:如果字符串常量池中已经存在一个与调用 intern() 方法的字符串对象内容相同的字符串,那么 intern() 方法将返回常量池中的字符串的引用。如果字符串常量池中没有相同内容的字符串,它将在常量池中创建一个新的字符串,并返回该字符串的引用。

  2. 实现字符串共享intern() 方法允许字符串的共享,即多个字符串对象可以引用相同的常量池中的字符串,从而节省内存。

  3. 用于字符串相等性比较:由于字符串常量池中的字符串是唯一的,可以使用 == 运算符来比较字符串的引用,而不需要比较字符串的内容。这可以提高字符串相等性比较的速度。

以下是 intern() 方法的基本用法示例:

String s1 = new String("Hello");
String s2 = "Hello";

// 使用intern()方法将s1添加到字符串常量池
s1 = s1.intern();

// 比较s1和s2是否引用相同的字符串对象
boolean areEqual = (s1 == s2); // 这将返回true,因为它们引用相同的字符串常量池中的对象

new String()

使用 new String("a") 创建字符串时,将会创建两个对象:

  1. 常量池中的字符串对象:“a” 这个字符串字面量首先会在字符串常量池中创建一个对象。如果字符串常量池中已经存在 “a” 这个字符串,那么将重用现有对象;否则,将创建一个新的字符串对象,并将 “a” 放入字符串常量池。

  2. 堆中的字符串对象:然后,使用 new 关键字创建一个新的字符串对象,该对象在堆内存中分配。这个新的字符串对象的内容是从字符串常量池中复制过来的,因此它的内容也是 “a”。

所以,new String("a") 创建了两个字符串对象,一个在字符串常量池中,一个在堆内存中。通常情况下,我们更倾向于直接使用字符串字面量(例如 "a")而不是显式地使用 new String 来创建字符串对象,因为后者会导致额外的对象创建和内存消耗。

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

以上就将会创建6个对象

  • 字符串常量池中放入a
  • 堆中放入new String("a")
  • 字符串常量池放入b
  • 堆中放入new String("b")
  • 堆中放入new StringBuilder()
  • StringBuildertoString()将会构造一个新的String对象放入堆中,并不会从常量池加载和放入字符串

        String a = new String("1");
        a.intern();
        String b = "1";
        System.out.println("a == b = " + (a == b));
  • 以上结果为false
  • 字符串常量池中放入1
  • a对象存在于堆中
  • a.intern()在执行时,发现常量池中已经有1了,所以不再放入
  • b将会直接使用字符串常量池中的1
  • 所以结果为false

        String s = new String("1") + new String("1");
        s.intern();
        String s2 = "11";
        System.out.println("s == s2 = " + (s == s2));
  • 以上代码在jdk7之前的结果不一样,因为jdk7将字符串常量池移动到了堆中,jdk7之前在永久代中
  • jdk7之前:
    • 执行s.intern()时,判断出字符串常量池中没有11,那么此时将会直接在字符串常量池中创建一个新的Sting对象值为11
  • jdk7之后:
    • 执行s.intern()时,判断出字符串常量池中没有11s在堆中,字符串常量池也在堆中,所以不会重新创建一个新的对象,而是直接使用s

垃圾回收

在Java虚拟机(JVM)中,“垃圾”(Garbage)通常指的是堆内存中的无用对象或不再被程序引用的对象。JVM的垃圾回收机制负责检测和回收这些垃圾对象,以释放内存资源并确保程序的稳定性和性能。

JVM中的垃圾包括以下情况:

  1. 不再被引用的对象:如果一个对象不再被任何变量、字段、数组元素等引用,它就成为垃圾。这通常发生在对象超出了它的作用域或者引用被重新赋值为null

  2. 循环引用的对象组:有时,一组对象之间相互引用,但这组对象本身已经不再被任何外部引用。即使它们之间相互引用,但由于不再被访问,这些对象也被认为是垃圾。

  3. 临时对象:一些对象仅在某个特定的代码块中使用,超出该代码块后就变得无用,因此被认为是垃圾。

对象存活算法

JVM使用不同的算法来判断对象是否存活,以便进行垃圾回收。以下是常见的判断对象是否存活的算法:

引用计数法

JVM中引用计数算法是一种垃圾收集算法,用于判断对象是否存活。这种算法的核心思想是为每个对象维护一个引用计数器,记录引用该对象的数量。当引用计数器为零时,表示对象不再被引用,可以被回收。

引用计数算法的工作原理

  • 每个对象都有一个关联的引用计数器。
  • 当有新的引用指向对象时,计数器加1。
  • 当引用不再指向对象时,计数器减1。
  • 当计数器降至零时,表示对象不再被引用,可以被回收。

优点

  1. 实时性:引用计数算法能够在对象引用发生变化时迅速判断对象是否存活,无需等待垃圾收集周期。
  2. 简单:这是一种相对简单的算法,易于理解和实现。
  3. 低停顿时间:由于实时性较强,引用计数算法通常具有较低的停顿时间,不会引起长时间的应用程序暂停。

缺点

  1. 循环引用问题:引用计数算法无法处理循环引用的情况。如果一组对象相互引用,但不再被外部引用,它们的计数器永远不会为零,导致内存泄漏。
  2. 计数器更新开销:每次引用发生变化时,都需要更新计数器,这会引入额外的运行时开销。
  3. 难以检测部分死锁:引用计数算法难以检测到部分死锁,即存在一组对象,它们互相引用但没有被外部引用。

对象1
对象1
计数:2
计数:2
对象2
对象2
计数:1
计数:1
对象3
对象3
计数:1
计数:1
1
1
1
1
1
1
P
P
循环引用
循环引用
对象1
对象1
计数:1
计数:1
对象2
对象2
计数:1
计数:1
对象3
对象3
计数:1
计数:1
1
1
1
1
1
1
P
P
内存泄漏
内存泄漏
null
null
Text is not SVG - cannot display

由于循环引用问题和其他限制,引用计数算法在现代JVM中并不常用。相反,JVM更常使用基于可达性分析的算法来判断对象是否存活,因为这种算法可以处理更复杂的情况,例如循环引用,并且可以更好地优化性能和内存回收。

可达性分析算法

JVM中的可达性分析算法是一种常用于判断对象是否存活的垃圾收集算法,它的核心思想是从一组根对象出发,通过追踪对象引用链路,标记所有可达的对象,然后清理未被标记的对象,也就是能够被根对象直接或者间接引用的对象不是垃圾对象。

下图中,对象8/9/10就是垃圾对象,它们不能够被根对象直接或者间接引用

根对象集合
根对象集合
对象2
对象2
对象1
对象1
对象3
对象3
对象4
对象4
对象5
对象5
对象8
对象8
对象6
对象6
对象7
对象7
对象8
对象8
对象9
对象9
对象10
对象10
Text is not SVG - cannot display

可达性分析算法的工作原理

  1. 根对象集合:可达性分析算法从一组称为"根对象"(通常是活动线程的栈帧中的本地变量、静态变量、常量等)出发,这些对象被认为是可达的。

  2. 标记阶段(Marking):从根对象出发,算法递归遍历对象图,标记所有通过引用链路可达的对象。标记的方法可以是直接标记(Mark-Sweep算法)或间接标记(Tracing算法)。

  3. 清理阶段(Sweeping):在清理阶段,未被标记的对象被认定为不可达的垃圾,将其回收,并将内存释放。

优点

  1. 准确性:可达性分析算法非常准确,只有那些确实无法被访问的对象才会被判定为垃圾。
  2. 处理循环引用:这个算法能够有效处理循环引用的情况,只有在引用链上的对象才会被标记为可达。
  3. 自动化:JVM可以自动执行垃圾回收,程序员无需手动管理内存释放。

缺点

  1. 效率:可达性分析算法可能需要耗费较多的时间来扫描整个对象图,特别是对于大型堆内存。
  2. 停顿时间:某些垃圾收集器执行可达性分析时,需要停止应用程序的执行(Stop-The-World),这会导致一些暂时的性能问题。
  3. 复杂性:算法本身相对复杂,实现和优化需要一定的技术和资源。

垃圾清除算法

垃圾清除算法是垃圾回收过程中的核心组成部分,它负责识别和清理不再被程序引用的无用对象,从而释放内存以供其他对象使用。以下是一些常见的垃圾清除算法:

  1. 标记-清除算法(Mark and Sweep)
    • 标记阶段:采用可达性分析算法,从根对象(如程序中的栈、静态变量)出发,通过引用链标记所有可达的对象,标记的是不需要被清除的对象
    • 分配内存时可以采用指针碰撞
    • 清除阶段:扫描整个堆内存,清理所有未被标记的对象,即垃圾对象。
    • 优点:简单,可以处理循环引用。
    • 缺点:可能产生不连续的内存碎片,导致内存分配效率下降。
  2. 复制算法(Copying)
    • 将堆内存分为两块,一半用于存活对象,一半用于垃圾对象
    • 在垃圾回收时,将存活对象复制到另一半内存中,然后清理原始内存,在复制的过程中可以实现内存为连续的。幸存者0区和幸存者1区就是采用此算法
    • 分配内存时可以采用空闲列表
    • 优点:无内存碎片,分配速度快。
    • 缺点:只能使用一半的堆内存,不适用于大型对象。
  3. 标记-整理/压缩算法(Mark and Compact)
    • 类似标记-清除算法,但在清除阶段后,会将存活对象紧凑地移到一侧,消除内存碎片
    • 分配内存时可以采用指针碰撞
    • 优点:无内存碎片,可使用整个堆内存。
    • 缺点:需要额外的复制和移动操作,效率较低。
  4. 分代垃圾回收算法
    • 将堆内存划分为不同代(如新生代和老年代),根据对象的生命周期将对象分配到不同代,HotSpots采用此方式。
    • 针对不同代使用不同的垃圾回收算法,如新生代通常使用复制算法,老年代使用标记-清除或标记-整理算法。
    • 优点:根据对象生命周期分别优化,提高垃圾回收效率。
  5. 增量式垃圾回收算法
    • 在多次小步骤中执行垃圾回收,逐渐释放内存,每次只回收某一小块区域的垃圾。
    • 通过将回收操作分解为多个步骤,减少了单次回收的停顿时间
    • 优点:降低了应用程序停顿时间,提高了响应性。
    • 缺点:可能导致垃圾回收的总体时间变长。
特点 标记-清除算法 复制算法 标记-整理算法
标记阶段速度 中等
清除阶段速度 无需清除 中等
空间开销 无额外内存开销 50%额外内存开销 无额外内存开销
内存分布 可能导致内存碎片 不会产生内存碎片 不会产生内存碎片
适用于新生代
适用于老年代
适用于长时间存活对象
适用于短时间存活对象

Stop The World

“Stop-the-world” 是指在垃圾回收过程中,JVM 暂停应用程序的执行以进行垃圾回收操作。这种暂停是为了确保在清理内存和对象回收期间,不会有并发的线程修改或访问堆内存,以防止数据一致性问题和垃圾回收器与应用程序之间的竞态条件。

Stop-the-world 情况通常发生在以下几个时刻:

  1. 新生代垃圾回收:在新生代中执行垃圾回收操作时,通常使用复制算法,这会导致应用程序停顿,因为所有存活的对象都要被复制到另一块内存区域。

  2. 老年代垃圾回收:老年代的垃圾回收器在执行标记-清除或标记-整理操作时也会导致停顿,因为这些操作需要全局性地分析整个堆内存。

  3. Full GC(Full Garbage Collection):在进行 Full GC 时,所有的应用程序线程都会停止,包括新生代和老年代的垃圾回收。

  4. 应用程序手动触发的垃圾回收:有时,应用程序会显式调用 System.gc() 方法或 Runtime.getRuntime().gc() 方法来触发垃圾回收,这也可能导致 stop-the-world 情况。

Stop-the-world 事件的主要目标是确保垃圾回收器可以安全地操作内存而不会与应用程序产生竞态条件。然而,停顿时间过长可能会对应用程序的性能和响应性产生负面影响。因此,JVM 的垃圾回收器一直在努力减少 stop-the-world 的时间,例如使用并发垃圾回收器、增量垃圾回收器和压缩垃圾回收器等技术。

对于实时性要求高的应用程序,减少 stop-the-world 时间是至关重要的,因为较长的停顿时间可能会导致应用程序无法满足性能要求。在选择垃圾回收器时,应根据应用程序的需求和硬件特性权衡停顿时间和垃圾回收器的性能。

System.gc()

System.gc() 是 Java 中用于请求垃圾回收的方法。它向 Java 虚拟机发出一条提示,建议进行垃圾回收操作,但不能确保垃圾回收会立即执行,因为具体的垃圾回收行为是由虚拟机决定的。

以下是关于 System.gc() 方法的一些要点:

  1. 垃圾回收提示System.gc() 方法用于向虚拟机发送垃圾回收提示。这是一个标准的 Java API,但虚拟机可以选择是否执行垃圾回收以及何时执行。

  2. 不一定触发垃圾回收:虽然调用 System.gc() 可以建议虚拟机执行垃圾回收,但并不保证垃圾回收会立即发生。虚拟机可能会忽略这个提示,或者在某些情况下,它可能会在一个不确定的时间执行垃圾回收。

  3. 依赖于虚拟机实现System.gc() 的行为是虚拟机特定的。不同的虚拟机实现可能对这个方法有不同的响应。一些虚拟机可能会忽略它,而另一些虚拟机可能会执行垃圾回收。

  4. 谨慎使用:通常情况下,不建议频繁调用 System.gc(),因为过多的垃圾回收操作可能会影响应用程序的性能。虚拟机通常有自己的垃圾回收策略和触发机制,可以在大多数情况下自动管理内存。

  5. 用途:有时,在一些特殊情况下,比如需要在某个时间点强制执行垃圾回收以便腾出内存,或者在测试或性能分析时,可以考虑使用 System.gc()。但在正常应用程序中,不应该依赖于它来管理内存。

总的来说,System.gc() 方法提供了一种向虚拟机建议执行垃圾回收的途径,但它不应该被滥用,应该在必要时慎重使用。大多数情况下,虚拟机可以自动管理内存,无需手动触发垃圾回收。

内存泄漏

在Java中,内存泄漏(Memory Leak)是指应用程序中的某些对象或数据占用了内存,但由于某些原因无法被及时释放,导致内存不断积累,最终耗尽了可用内存资源,从而影响应用程序的性能甚至导致程序崩溃。内存泄漏通常是由于程序中的错误或不良的内存管理实践引起的。

以下是一些常见的导致内存泄漏的情况:

  1. 未关闭资源:如果应用程序使用了文件、网络连接、数据库连接或其他资源,但没有在使用完毕后关闭这些资源,会导致资源未释放,从而占用内存。
  2. 长期引用:如果应用程序中的对象持有对其他对象的引用,并且这些引用永远不会被释放,那么这些对象就无法被垃圾回收,从而导致内存泄漏。
    • 例如单例模式下,某个单例的实例对象持有了不再使用的外部对象,那么这个外部对象不会被回收
  3. 缓存:缓存是一种常见的内存泄漏来源。如果缓存中的对象不定地进行清理或过期处理,那么缓存中的对象可能会一直存在于内存中。
  4. 监听器和回调:如果应用程序中的对象注册了监听器或回调函数,但在不再需要时没有取消注册,这些对象可能会一直存在于内存中,无法被回收。
  5. 循环引用:两个或多个对象之间的循环引用可能导致垃圾回收器无法正确地识别和回收这些对象。

为避免内存泄漏,开发者应采取以下措施:

  • 明确关闭资源:确保在使用完文件、连接、流等资源后,及时关闭它们,通常使用 try-with-resources 或手动关闭。
  • 及时释放对象引用:确保不再需要的对象的引用被设置为 null,以便垃圾回收器可以识别并回收这些对象。
  • 清理缓存:对于缓存,实施合适的清理策略,包括定期清理、LRU(最近最少使用)策略等。
  • 小心处理监听器和回调:确保注册的监听器和回调在不再需要时被取消注册。
  • 避免循环引用:审查代码以确保没有循环引用的情况发生。

引用

在Java中,引用是用于访问和操作对象的一种机制。Java中的引用类型包括以下几种:

  1. 强引用(Strong Reference)

    • 强引用是最常见的引用类型,也是默认的引用类型。
    • 当一个对象被强引用引用时,垃圾回收器不会回收这个对象,即使内存不足也不会回收,只有当没有任何强引用指向该对象时,垃圾回收器才会将其回收。
  2. 软引用(Soft Reference)

    • 软引用用于描述一些还有用但并非必需的对象。
    • 当内存不足时,垃圾回收器会回收软引用引用的对象。这使得软引用非常适合用于实现内存敏感的缓存。
  3. 弱引用(Weak Reference)

    • Weak中文为虚弱的
    • 弱引用用于描述非必需的对象,但它的生命周期更短暂。
    • 当垃圾回收器运行时,无论内存是否足够,都会回收弱引用引用的对象。
  4. 虚引用(Phantom Reference)

    • phantom中文为幻影,读音为ˈfan(t)əm
    • 通过get()获取到的值永远为null
    • 虚引用的作用是帮助对象跟踪被垃圾回收的状态,而不是引用对象本身。
    • 虚引用通常与引用队列(Reference Queue)一起使用,当虚引用引用的对象被回收时,会将其放入引用队列,以便程序可以获取有关回收对象的通知。

引用类型的选择取决于对象的生命周期和应用程序的需求。强引用是最常用的,而软引用、弱引用和虚引用通常用于处理内存管理、缓存、对象生命周期跟踪等特殊情况。引用类型的正确使用可以帮助避免内存泄漏和提高程序的性能。

测试弱引用:

        WeakReference<List<String>> listSoftReference = new WeakReference<>(new ArrayList<>());
        System.out.println("listSoftReference.get() = " + listSoftReference.get());
        System.gc();
        System.out.println("listSoftReference.get() = " + listSoftReference.get());

注意:不要用变量接收get()方法后的返回值!!!!

测试软引用:

        SoftReference<Object> softReference = new SoftReference<>(new Object());
        System.out.println("softReference.get() = " + softReference.get());
        try {
            byte[] bytes = new byte[1024 * 1024 * 1024];
        } catch (Throwable e) {

        } finally {
            System.out.println("softReference.get() = " + softReference.get());
        }

垃圾回收器

Java虚拟机(JVM)中的垃圾回收器是用于管理和回收内存中不再使用的对象的组件。JVM中的垃圾回收器有多种不同的实现,每种实现都有其自己的特点和适用场景。JVM中的垃圾回收器按照其工作方式可以分为三大类:串行、并行和并发垃圾回收器。以下是按照这三大类进行排序的一些常见垃圾回收器:

串行垃圾回收
串行垃圾回收
并行垃圾回收
并行垃圾回收
并发垃圾回收
并发垃圾回收
Text is not SVG - cannot display

上图中虚线会垃圾回收线程,实线为用户线程

-XX:+PrintCommandLineFlags 来查看JVM的默认参数,其中包括垃圾回收器的选择

串行垃圾回收器(Serial Garbage Collector)

  1. Serial Garbage Collector(串行垃圾回收器):

    -XX:+UseSerialGC
    
    • 串行垃圾回收器是最简单的垃圾回收器之一。
    • 在新生代采用复制算法、串行回收,在老年代采用使用标记-整理(Mark and Compact)算法来回收内存。
    • 存在stw问题
    • 它使用单个线程来执行垃圾回收操作,适用于单线程环境,主要用于客户端应用或小型服务器。

并行垃圾回收器(Parallel Garbage Collector)

  1. ParNew(Parallel New)

    • -XX:+UseParNewGC -XX:ParallelGCThreads=8
      

      第一个设置项为使用此垃圾回收器,第二个设置项为设置使用的线程数

      已被移除

    • par是Parallel 缩写,new代表新生代,是Java虚拟机的一种新生代垃圾回收器
    • 相当于Serial 的并行版本,它是基于并行处理的垃圾回收器,也有STW问题
    • ParNew通常与CMS(Concurrent Mark-Sweep)垃圾回收器一起使用,用于回收新生代内存,而CMS负责老年代内存的垃圾回收。
    • ParNew回收器使用复制(Copying)算法来回收新生代内存
    • ParNew垃圾回收器适用于多核CPU的服务器环境,特别是需要降低新生代垃圾回收暂停时间的应用程序。它的目标是减少新生代内存的回收停顿时间,以提高应用程序的吞吐量。
  2. Parallel Garbage Collector(并行垃圾回收器):

    -XX:+UseParallelGC
    
    • Parallel读音为ˈperəˌlel

    • 也被称为吞吐量垃圾回收器,用于多核CPU的服务器环境,可以用于回收新生代和老年代。

    • 与串行垃圾回收器类似,但会利用多个线程并行进行垃圾回收,以提高吞吐量

  3. Parallel Old Garbage Collector(并行老年代垃圾回收器):

    • 主要用于老年代的垃圾回收,与Parallel垃圾回收器配合使用。

并发垃圾回收器(Concurrent Garbage Collector)

  1. Concurrent Mark-Sweep (CMS) Collector(并发标记-清除垃圾回收器):

    • 采用标记-清除算法,适用于需要低延迟的应用,如Web应用。

    • 它使用并发线程来标记和清除垃圾,以减少应用暂停时间,主要用于老年代的垃圾回收。

    • CMS回收器的核心特点是它的并发性,回收的过程中用户线程也在运行,所以不能采用标记-压缩

    • 缺点:

      1. 内存碎片问题:CMS回收器采用标记-清除算法,这可能导致内存碎片问题。进而触发Full GC(全垃圾回收),这会引发较长的停顿时间。

      2. CPU资源消耗:CMS回收器的标记和清除阶段是并发执行的,但它需要一些额外的CPU资源。在高负载的情况下,这可能会导致应用程序的吞吐量略有下降。

      3. 无法处理浮动垃圾:CMS回收器无法处理在垃圾回收期间产生的新垃圾,这被称为浮动垃圾。

      4. 停顿时间不可控:虽然CMS回收器的目标是减小停顿时间,但它无法保证完全消除停顿。在某些情况下,由于并发执行时的竞争或其他因素,停顿时间仍然可能变得较长。

      5. 无法处理永久代:CMS回收器主要关注新生代和老年代,不处理永久代的垃圾回收。在某些应用中,永久代的垃圾回收仍然可能导致停顿时间较长。

      6. 过度并发:在高度多线程的应用程序中,CMS回收器的过度并发可能会导致过多的线程竞争,从而降低了性能。

  2. G1 Garbage Collector(G1垃圾回收器):

    • 适用于大堆内存和低延迟要求的应用。
    • 使用分代和区域划分的方式来管理内存,可以更精细地控制垃圾回收的行为。
  3. Z Garbage Collector(Z垃圾回收器):

    • 引入了低延迟的垃圾回收机制,适用于需要非常低暂停时间的应用。
    • 使用了读屏障和写屏障等技术,以减少应用暂停时间。
  4. Shenandoah Garbage Collector(Shenandoah垃圾回收器):

    • 也是为了低延迟而设计的垃圾回收器,适用于大堆内存和低延迟要求的应用。
    • 使用了一些先进的技术,如并发标记和并发整理。

每种垃圾回收器都有其适用的场景和性能特点,选择合适的垃圾回收器取决于应用程序的需求,包括吞吐量、延迟、内存大小等。不同的应用场景可能需要不同的垃圾回收器配置。

不同分代中的垃圾回收器

在JVM中,不同分代的垃圾回收器主要用于优化垃圾回收的效率和降低暂停时间。以下是垃圾回收器在不同分代中的应用:

  1. 新生代(Young Generation)

    • Serial Garbage Collector:用于串行处理新生代的垃圾回收。它适用于单线程环境或小型应用。
    • Parallel Garbage Collector:也被称为吞吐量垃圾回收器,使用多线程并行处理新生代垃圾。适用于多核CPU的服务器环境。
    • G1 Garbage Collector:新生代可以选择使用G1的回收策略来处理。G1的年轻代回收算法可以有效处理新生代的垃圾。
  2. 老年代(Old Generation)

    • Serial Old Garbage Collector:用于串行处理老年代的垃圾回收。
    • Parallel Old Garbage Collector:与Parallel垃圾回收器一起用于老年代的垃圾回收。
    • CMS Garbage Collector:并发标记-清除垃圾回收器,用于低延迟应用。
    • G1 Garbage Collector:G1垃圾回收器也可以用于老年代的垃圾回收,它通过区域划分来管理老年代内存。

以下是常见的Java垃圾收集器的对比表格,包括了串行/并行、作用于新生代/老年代、使用的算法、特点以及适用的场景:

垃圾收集器 类型 作用代 使用算法 特点 适用场景
Serial GC 串行 新生代 复制 单线程执行,简单高效,停顿时间相对较长,适用于单核处理器或小型应用。 客户端应用、单核服务器
ParNew GC 并行 新生代 复制 多线程执行,停顿时间相对短,适用于多核处理器,与CMS一起用于新生代。 多核服务器、需要低停顿时间的应用
Parallel GC 并行 新生代 复制 多线程执行,停顿时间相对短,吞吐量高,适用于多核处理器,主要用于新生代。 吞吐量优先的应用、后台处理任务
CMS GC 并发 老年代 标记-清除 最小化停顿时间,多线程执行,适用于要求低停顿时间的应用,但可能产生内存碎片。 Web应用、需要低停顿时间的应用
G1 GC 并发 新生代、老年代 标记-整理、混合收集 混合收集模式,可预测的停顿时间,适用于大堆内存、低停顿时间的应用,能有效处理内存碎片。 大内存应用、需要可预测停顿时间的应用
ZGC 并发 新生代、老年代 标记-整理、分代收集 极低停顿时间(毫秒级),适用于需要极低停顿时间的大堆内存应用,但性能相对较低。 大内存、对停顿时间敏感的应用
Shenandoah GC 并发 新生代、老年代 标记-整理、分代收集 极低停顿时间(毫秒级),适用于需要极低停顿时间的大堆内存应用,性能相对较高。 大内存、对停顿时间敏感的应用

Q.E.D.


念念不忘,必有回响。