JVM

注意:括号中为八股在每次面试中出现的概率

对象创建的过程了解吗?(392/1759=22.3%)

对象的创建过程是 Java 虚拟机(JVM)中一个非常重要的环节,它主要分为五步。

第一步是进行类加载检查,当程序执行到 new 指令时,JVM 会先检查对应的类是否已经被加载、解析和初始化过。如果类尚未加载,JVM 会按照类加载机制(加载、验证、准备、解析、初始化)完成类的加载过程。这一步确保了类的元信息(如字段、方法等)已经准备好,为后续的对象创建奠定基础。

第二步是进行内存的分配,JVM 会为新对象分配内存空间。对象所需的内存大小在类加载完成后就可以确定,因此分配内存的过程就是从堆中划分一块连续的空间,主要有两种方式:

一种是通过指针碰撞,如果堆中的内存是规整的(已使用和空闲区域之间有明确分界),JVM 可以通过移动指针来分配内存。另一种是通过空闲列表,如果堆中的内存是碎片化的,JVM 会维护一个空闲列表,记录可用的内存块,并从中分配合适的区域。

此外,为了保证多线程环境下的安全性,JVM 还会采用两种策略避免内存分配冲突,一种是通过 CAS 操作尝试更新分配指针,如果失败则重试;另一种是每个线程在堆中预先分配一小块专属区域,避免线程间的竞争。

第三步是将零值初始化,JVM 会对分配的内存空间进行初始化,将其所有字段设置为零值(如 int 为 0,boolean 为 false,引用类型为 null)。这一步确保了对象的实例字段在未显式赋值前有一个默认值,从而避免未初始化的变量被访问。

第四步是设置对象头,其中包含Mark Word、Klass Pointer和数组长度。Mark Word 用于存储对象的哈希码、GC 分代年龄、锁状态标志等信息。Klass Pointer 指向对象所属类的元数据(即 Person.class 的地址)。

第五步是执行构造方法,用<init> 方法完成对象的初始化。构造方法会根据代码逻辑对对象的字段进行赋值,并调用父类的构造方法完成继承链的初始化。这一步完成后,对象才真正可用。

如何记忆:

1.口诀记忆

口诀:

类加载检查先开路,内存分配两方式,零值初始化保安全,对象头设信息全,构造方法来收尾。

解释:

第一句“类加载检查先开路”对应第一步:类加载检查。

第二句“内存分配两方式”对应第二步:指针碰撞和空闲列表。

第三句“零值初始化保安全”对应第三步:字段设置为默认值。

第四句“对象头设信息全”对应第四步:设置 Mark Word 和 Klass Pointer。

第五句“构造方法来收尾”对应第五步:<init> 方法完成初始化。

2.联想记忆

假设你正在建一座房子(对象):

类加载检查 :先检查设计图纸(类)是否齐全,如果没有图纸,就去档案馆(JVM)找一份。

内存分配 :根据图纸规划土地(堆内存),如果土地整齐(规整堆),直接划线分地;如果土地杂乱(碎片化堆),则查看空地清单(空闲列表)。

零值初始化 :给新房子里的所有房间(字段)装上默认家具(如灯关着、水龙头关闭)。

对象头设置 :在门口挂上牌子(对象头),标明房子的编号(哈希码)、使用年限(GC 分代年龄)等信息。

构造方法 :最后装修房子(初始化字段),并邀请家人入住(调用父类构造方法)。

拓展:

1.第二步分配内存后,对象的内存结构图

各部分区域的作用如下:

对象头:包含 Mark Word 和 元数据指针,其中 Mark Word 记录对象的哈希值、锁状态、分代年龄等信息,元数据指针 指向方法区的类元数据。此外,如果对象是数组,还会额外存储数组长度。

实例成员数据:用于存放对象的成员变量值。如果变量是基本类型,直接存储数值;如果是引用类型,则存储指向实际对象的地址。

对齐填充:由于 HotSpot 虚拟机 规定对象的起始地址必须是 8 字节对齐,若对象大小未达到 8 字节 的整数倍,则通过对齐填充补足,以确保高效的内存访问。

注意:其实到内存分配这一步完成,这个对象算已经创建好了,但只是个雏形,还不能使用。

2.Java 创建对象的四种常见方式

(1)new 关键字(最常见)

使用 new 关键字可以直接创建对象,并调用 无参或有参构造方法 进行初始化。

示例:

Person person1 = new Person();
Person person2 = new Person("fsx", 18); 

适用于大部分场景,代码直观,易于理解。

(2)反射(Class 和 Constructor 两种方式)

反射机制可以在运行时动态创建对象,主要通过 Class.newInstance() 和 Constructor.newInstance() 两种方式实现。

暂时无法在飞书文档外展示此内容

Class.newInstance()

通过 Class 对象的 newInstance() 方法创建实例,只能调用无参构造方法。

Person person = Person.class.newInstance();
System.out.println(person);

限制:必须有无参构造方法,否则会抛出异常。

Constructor.newInstance()

Constructor 类提供更灵活的创建方式,可以调用 任意构造方法(包括私有构造方法)。

Constructor<Person> constructor = Person.class.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true); // 允许访问私有构造方法
Person person = constructor.newInstance("fsx", 18);
System.out.println(person);

对比:

Class.newInstance() 只能调用无参构造方法,Constructor.newInstance() 可调用任意构造方法。

Constructor.newInstance() 可以通过 setAccessible(true) 访问私有构造方法。

(3)clone() 方法(对象克隆)

clone() 方法用于创建一个相同内容的新对象,不会调用构造方法。需要实现 Cloneable 接口,并重写 clone() 方法。

public class Person implements Cloneable {
    @Override
    public Person clone() throws CloneNotSupportedException {
        return (Person) super.clone();
    }
}
Person person1 = new Person("fsx", 18);
Person person2 = person1.clone();
System.out.println(person1 == person2); // false

注意:clone() 只进行 浅拷贝,对象内部的引用类型变量仍然指向同一块内存。如果要实现 深拷贝,需要让所有引用类型的成员变量也实现 Cloneable 接口,并重写 clone() 方法。

(4)反序列化(Serializable)

反序列化可以将存储或传输的对象数据恢复成 Java 对象,不会调用构造方法。

Person person1 = new Person("fsx", 18);
byte[] bytes = SerializationUtils.serialize(person1);
Person person2 = (Person) SerializationUtils.deserialize(bytes);
System.out.println(person1 == person2); // false

特点:对象必须实现 Serializable 接口,否则无法序列化;反序列化创建的对象是全新的,与原对象无关;性能开销较大,适用于数据存储或网络传输,而不是频繁对象创建。

类载入过程 JVM 会做什么?(364/1759=20.7%)

类的加载过程确保了类在运行时能够被正确地使用,可以分为五个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)和 初始化(Initialization)。接下来我会详细讲述每个阶段的具体内容和作用。

第一个是加载阶段,在这个阶段,JVM 会完成三件事情,

首先是通过类的全限定名获取定义此类的二进制字节流,这可以通过多种方式实现,例如从本地文件系统加载 .class 文件、从网络下载、或者通过动态代理生成字节码。

然后是将字节流所代表的静态存储结构转化为方法区的运行时数据结构,即将类的元信息(如字段、方法、父类等)存储到方法区中。

最后是在堆中生成一个代表该类的 java.lang.Class 对象,这个对象作为程序访问该类的入口点,所有的反射操作都通过这个对象进行。

第二个是验证阶段,在这个阶段,JVM 会对加载的字节码进行校验,以确保其符合 Java 虚拟机规范,并且不会危害虚拟机的安全,一般会验证四样东西。

首先是文件格式检查字节码文件是否符合 Class 文件格式规范。

然后是元数据检查类的元信息是否符合语法规则,例如父类是否存在、是否继承了 final 类等。

其次是字节码分析字节码指令,确保其不会执行非法操作(如类型转换错误、越界访问等)。

最后是符号引用检查符号引用能否正确解析为直接引用,例如检查类、字段、方法是否存在并且可访问。

第三个是准备阶段,JVM 会为类的静态变量分配内存,并设置默认初始值(零值)。这个阶段并不会执行任何 Java 代码,也不会为实例变量分配内存(实例变量是在对象创建时分配的)。例如,如果类中有一个静态变量 static int value = 123;,在这个阶段,value 会被初始化为 0,而不是 123(赋值操作会在初始化阶段完成)。

第四个是解析阶段,JVM 会将类中的符号引用替换为直接引用。符号引用是以一组符号描述所引用的目标,例如类的全限定名、字段的名称和描述符等。直接引用是可以直接定位到目标的指针、句柄或偏移量。解析的对象包括类或接口、字段、方法、方法类型、方法句柄和调用点限定符等。

第五个是初始化阶段,在此阶段,JVM 会执行类的初始化代码,包括静态变量赋值和静态代码块的执行。这是类加载过程的最后一个阶段,也是唯一一个会执行用户代码的阶段。初始化的顺序遵循“父类优先”的原则,即先初始化父类,再初始化子类。

如何记忆:

1.口诀记忆

口诀:

加载三件事,字节流转对象;验证四样全,格式元码符;准备静态值,零值默认记;解析符号换,直接引用齐;初始化代码,父先子后行。

解释:

第一句“加载三件事,字节流转对象”对应加载阶段的三个任务:获取字节流、转换为运行时数据结构、生成 Class 对象。

第二句“验证四样全,格式元码符”对应验证阶段的四项内容:文件格式、元数据、字节码、符号引用。

第三句“准备静态值,零值默认记”对应准备阶段:为静态变量分配内存并设置默认值(零值)。

第四句“解析符号换,直接引用齐”对应解析阶段:将符号引用替换为直接引用。

第五句“初始化代码,父先子后行”对应初始化阶段:执行静态代码块和赋值操作,遵循父类优先原则。

2.联想记忆

假设你正在组织一场大型活动(类加载过程):

加载阶段 :你需要准备好活动的所有资料(字节流),然后整理成活动手册(运行时数据结构),最后指定一个负责人(Class 对象)来管理活动。

验证阶段 :在活动开始前,你需要检查资料是否符合规范(文件格式)、活动规则是否合理(元数据)、流程是否安全(字节码)、以及参与人员是否可信(符号引用)。

准备阶段 :你需要提前安排好场地(静态变量分配内存),但暂时不摆放具体物品(设置默认值)。

解析阶段 :你需要将活动计划中的代号(符号引用)替换为具体的地点或人员(直接引用)。

初始化阶段 :活动正式开始,按照计划布置场地(静态变量赋值)并执行开场仪式(静态代码块),先处理主会场(父类),再处理分会场(子类)。

拓展:

1.类加载流程图

2.Java 类的初始化时机

在 Java 虚拟机(JVM)中,只有在特定情况下才会触发类的初始化。根据规范,只有 6 种情况会导致类的初始化:

(1)执行特定字节码指令

当代码涉及以下操作时,JVM 会触发类的初始化:new 关键字创建实例对象时;访问类的 静态变量(getstatic 指令,常量不会触发初始化);对静态变量赋值(putstatic 指令);调用类的 静态方法(invokestatic 指令)。

(2)反射调用

使用 java.lang.reflect 包中的方法(如 Class.forName("类名") 或 newInstance())动态加载类时,会触发类的初始化。

(3)父类未初始化

当一个类需要初始化时,如果其父类尚未初始化,则会先初始化父类。

(4)虚拟机启动

JVM 启动时,会初始化包含 main 方法的主类,确保程序可以正确运行。

(5)使用 MethodHandle 或 VarHandle

MethodHandle 和 VarHandle 主要用于轻量级反射机制。如果想通过它们执行类的静态方法或访问静态变量,需要先对该类进行初始化。

(6) JDK 8 及以上的默认方法

如果接口中定义了默认方法(default 关键字修饰),且该接口的实现类尚未初始化,那么初始化接口时,会确保所有实现类已被初始化。

什么是双亲委派模型?(425/1759=24.2%)

双亲委派模型是 Java 类加载机制中的核心概念,它定义了类加载器之间的层次关系和加载规则。通过这种模型,Java 能够保证类的唯一性和安全性,同时避免重复加载类的问题。接下来我会详细讲述双亲委派模型的定义、层次结构和工作流程。

首先说一下什么是双亲委派模型,它其实是一种类加载机制,其规定了当一个类加载器收到类加载请求时,不会立即尝试自己去加载这个类,而是先将请求委托给父类加载器完成。只有当父类加载器无法加载该类(例如在父类的搜索范围内找不到对应的类)时,子类加载器才会尝试自己加载。这种机制确保了类的加载过程具有层次性,并且优先使用高层级的类加载器来加载核心类库。

接下来说一下类加载器的层次结构,主要分为四层,

第一层是启动类加载器(Bootstrap ClassLoader),它负责加载 JVM 核心类库(如 rt.jar 中的类),位于最顶层,通常由本地代码实现。

第二层是扩展类加载器(Extension ClassLoader),它负责加载 $JAVA_HOME/lib/ext 目录下的扩展类库。

第三层是应用程序类加载器(Application ClassLoader),它负责加载用户类路径(ClassPath)上的类,也称为系统类加载器。

第四层是自定义类加载器,开发者可以通过继承 ClassLoader 类实现自己的类加载器,用于加载特定需求的类。

这些类加载器之间形成了一个树状的层次结构,每个类加载器都有一个父加载器。

最后说一下双亲委派模型的工作流程,主要分为四步,

第一步是检查缓存,当前类加载器会先检查是否已经加载过目标类,如果已加载,则直接返回对应的 Class 对象。

第二步是委派父加载器,如果没有加载过,当前类加载器会将加载请求委派给父加载器处理。

第三步是递归向上,父加载器继续将请求委派给它的父加载器,直到到达 Bootstrap ClassLoader。

第四步是尝试加载,如果父加载器无法加载目标类,则子加载器会尝试自己加载。

如何记忆:

1.联想记忆

假设你是一个公司的员工(类加载器),需要完成一项任务(加载类):

(1)双亲委派机制 :当你接到任务时,不会自己直接处理,而是先交给你的上级领导(父加载器)。如果领导也无法完成任务,才会轮到你自己处理。

(2)层次结构 :

启动类加载器 :相当于公司老板,负责最重要的核心任务(JVM 核心类库)。

扩展类加载器 :相当于部门经理,负责一些扩展任务(扩展类库)。

应用程序类加载器 :相当于普通员工,负责日常任务(用户类路径上的类)。

自定义类加载器 :相当于临时工,负责特殊需求的任务。

(3)工作流程 :

先检查是否已经完成过类似任务(缓存检查)。

如果没有,把任务交给上级(委派父加载器)。

上级继续把任务交给更高级别的领导(递归向上)。

如果所有上级都无法完成任务,才由自己亲自处理(子加载器加载)。

拓展:

1.双亲委派模型的优势

类的唯一性:通过双亲委派模型,同一个类只会被一个类加载器加载一次,从而避免了重复加载的问题。

安全性:核心类库(如 java.lang.String)由 Bootstrap ClassLoader 加载,防止用户自定义的恶意类冒充核心类库。

模块化管理:不同层级的类加载器负责加载不同范围的类,便于实现模块化和隔离性。

2.破坏双亲委派模型的场景

热部署:一些应用服务器(如 Tomcat)需要支持动态加载和卸载类,因此会打破双亲委派模型,允许子加载器优先加载某些类。

SPI(Service Provider Interface)机制:JDK 提供了一些 SPI 接口(如 JDBC 驱动),其实现类由线程上下文类加载器(Thread Context ClassLoader)加载,而不是遵循双亲委派模型。

3.双亲委派模型流程图

JVM的内存区域(631/1759=35.9%)

JVM 的内存区域可以分为线程共享和线程私有两部分,每个部分都有明确的职责和作用,保障 Java 程序的高效运行。JDK1.7 和 1.8 时内存结构略有不同,接下来我先讲解一下 JDK1.7 时 JVM 的内存结构,然后再说一下 JDK1.8 时发生了哪些变动。

首先是线程共享的部分,一共有两个,

一个是堆(Heap),所有对象实例和数组都在这里分配内存,垃圾回收器(GC)会管理其中的对象回收。堆中还包含了字符串常量池(String Constant Pool),用于存储字符串字面量和常量。

另一个是方法区(Method Area):用于存储类元信息、静态变量、常量、方法字节码等。其中运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期生成的各种字面量和符号引用。

然后是线程私有的部分,一共有三个,

第一个是虚拟机栈(VM Stack),每个线程启动时都会创建一个虚拟机栈,它存储方法调用过程中产生的栈帧,包括局部变量、操作数栈、方法返回地址等,每个方法调用都会创建一个新的栈帧,方法执行结束后栈帧出栈。

第二个是本地方法栈(Native Method Stack),专门用于存储本地方法(Native Method)的调用信息,与虚拟机栈类似,但用于 JNI(Java Native Interface)调用。

第三个是程序计数器(Program Counter Register),记录当前线程正在执行的字节码指令地址。它是 JVM 运行时最小的内存区域,每个线程都有一个独立的程序计数器。

最后是本地内存

里面包含直接内存(Direct Memory),由 NIO(New Input/Output)直接分配,不受 JVM 堆的管理,通常用于高性能数据传输,如缓冲区(Buffer)。这个内存结构保证了 JVM 在执行 Java 代码时能够高效管理对象、执行方法调用,并支持多线程并发。

JDK 1.8 时 JVM 的内存结构主要有三点不同,

第一点是方法区(Method Area)在 JDK 1.8 被移除,替换为元空间(Metaspace),且元空间使用本地内存,而非 JVM 堆。

第二点是字符串常量池(String Constant Pool)在 JDK 1.7 位于方法区,而在 JDK 1.8 被移动到堆中,降低了方法区的压力。

第三点是运行时常量池(Runtime Constant Pool)在 JDK 1.7 属于方法区的一部分,而在 JDK 1.8 变成元空间的一部分。

如何记忆:

1.联想记忆

假设你正在管理一个大型图书馆(JVM):

(1)线程共享部分 :

堆 :相当于图书馆的主书架,所有书籍(对象实例)都存放在这里,管理员(GC)负责整理和回收旧书。

方法区 :相当于图书馆的档案室,存储书籍的目录(类元信息)、固定资料(静态变量)等。

字符串常量池 :在 JDK 1.7 是档案室的一部分,在 JDK 1.8 搬到了主书架(堆)。

(2)线程私有部分 :

虚拟机栈 :相当于每个读者(线程)都有自己的借阅清单(栈帧),每次借书(方法调用)都会添加一条记录,还书后移除。

本地方法栈 :相当于特殊通道,用于处理外文书籍(JNI 调用)。

程序计数器 :相当于每个读者手中的阅读进度条,记录当前读到哪一页(字节码指令地址)。

(3)直接内存 :相当于图书馆的外部仓库,用于存放临时资料(高性能数据传输)。

拓展:

1.JDK 1.7时内存结构图

2.JDK 1.8时内存结构图

3.元空间中包含运行时常量池,都放在本地内存中,如何进行回收?

运行时常量池与元空间:运行时常量池是JVM用于存储编译期生成的常量(如字符串字面量、方法和字段的符号引用等)的地方。在Java 8之前,常量池是存放在永久代(PermGen)中的,而Java 8之后,永久代被移除,运行时常量池被放到了元空间(Metaspace)中。

元空间的内存管理:元空间使用的是本地内存(Native Memory),而不是堆内存。它的内存分配和回收由操作系统管理,而非JVM的垃圾回收器(GC)。元空间中的内存一般是较难回收的,因为类的元数据信息和运行时常量池数据一旦加载,很少会被卸载,除非对应的类被卸载。

回收问题:虽然元空间的内存管理依赖操作系统,但在某些情况下,内存可能会出现膨胀,导致应用程序占用过多的内存。如果元空间持续增长且没有及时进行类卸载操作,那么内存就会持续被占用。JVM提供了一些选项(如-XX:MaxMetaspaceSize)来限制元空间的大小,以防止内存过度使用。

4.为什么1.8运行时常量池之后移动到内存中,是因为运行时常量池时而很大时而很小,而JVM创建的时候需要指定大小,如果放在JVM中不好控制大小吗?那运行时常量池可以移动出来,那字符串常量池为什么不能移动出来。

(1)运行时常量池的迁移原因

动态大小需求:运行时常量池的大小在运行时可能会变化很大。它需要存储大量的动态信息,如类的符号引用、字符串字面量等。在Java 7及之前,运行时常量池是在永久代(PermGen)中的,永久代的大小是固定的,这导致了内存管理的复杂性,因为在运行时,常量池可能会变得非常大或者非常小,而固定的永久代大小无法很好地适应这种变化。

灵活的内存管理:将运行时常量池移动到元空间后,它使用的是本地内存(Native Memory),不再受限于JVM堆内存的大小。这样可以更灵活地分配和管理内存,操作系统可以根据需求动态调整元空间的大小,从而避免内存不足的问题。

(2)字符串常量池的特殊性

字符串常量池的特性:字符串常量池(String Pool)存储的是字符串字面量和intern()方法的结果。它的行为和运行时常量池不同,字符串常量池是用来减少字符串对象的重复,节省内存。由于字符串在Java中被广泛使用,字符串常量池需要在JVM启动时就初始化,并且需要频繁地访问和管理。

在堆内存中管理:在Java 7之前,字符串常量池也是放在永久代中的。Java 7之后,字符串常量池被移动到堆内存中,这是因为堆内存管理更灵活且更适合字符串这种频繁访问和回收的场景。堆内存的垃圾回收器能够有效地管理和回收不再使用的字符串,从而优化内存使用。

垃圾回收算法(673/1759=38.3%)

垃圾回收(Garbage Collection,简称 GC)是 Java 虚拟机(JVM)中自动管理内存的重要机制,它通过一系列算法来识别和回收不再使用的对象,从而释放堆内存。接下来我会详细讲述常见的四种垃圾回收算法及其工作原理。

第一个是标记-清除算法(Mark-Sweep),它是最基础的垃圾回收算法,主要分为两个阶段,一个是标记阶段,从根对象(GC Roots)开始,递归遍历所有可达对象,并标记为“存活”; 另一个是清除阶段,遍历整个堆内存,回收未被标记的对象所占用的空间。

此算法主要存在两个问题,一个是内存碎片化,回收后的内存可能会产生大量不连续的碎片,导致大对象无法分配内存;另一个是效率较低,需要两次遍历堆内存,耗时较长。

第二个是复制算法(Copying),它通过将内存划分为两块(From 和 To),每次只使用其中一块,解决了标记-清除算法的内存碎片化问题,主要分为两个阶段,一个是复制阶段,当一块内存用完时,将存活的对象复制到另一块内存中,并按顺序排列;另一个是清理阶段,直接清空原来的内存块,无需额外的标记或清除操作。

此算法的优点是效率高且不会产生内存碎片,但缺点是需要双倍的内存空间。

第三个是标记-整理算法(Mark-Compact),它是对标记-清除算法的改进,它在标记阶段完成后,会将所有存活对象向一端移动,从而避免内存碎片化。主要分为两个阶段,一个是标记阶段,与标记-清除算法相同,标记所有存活对象;另一个是整理阶段,将存活对象移动到内存的一端,清理边界外的内存。

此算法适合老年代(Old Generation),因为老年代中的对象存活率较高,复制成本较大。

第四个是分代收集算法(Generational Collection),它是目前主流 JVM 的垃圾回收策略,它基于对象的生命周期将堆内存划分为新生代(Young Generation)和老年代(Old Generation)。

对于新生代,大多数对象朝生夕灭,采用复制算法进行垃圾回收。新生代进一步划分为 Eden 区和两个 Survivor 区(From 和 To);对于老年代,存活时间较长的对象存储在此,采用标记-清除或标记-整理算法进行垃圾回收。

这种算法结合了不同算法的优点,针对不同代的特点选择合适的回收策略,从而提升整体性能。

如何记忆:

1.联想记忆

假设你正在整理一个仓库(堆内存):

标记-清除算法 :你先检查所有物品(对象),把有用的物品贴上标签(标记阶段),然后扔掉没有标签的物品(清除阶段)。但问题是,仓库里会留下很多零散的空间(内存碎片),导致大件物品无法存放。

复制算法 :你把仓库分成两个区域(From 和 To),每次只在一个区域放东西。当一个区域满了,就把有用的物品搬到另一个区域,并按顺序排列整齐(复制阶段),然后清空原来的区域(清理阶段)。这样仓库不会乱,但需要双倍的空间。

标记-整理算法 :你先贴标签(标记阶段),然后把所有有用的东西搬到仓库的一侧(整理阶段),最后清理掉剩下的空间。这种方法适合存放长期使用的物品(老年代)。

分代收集算法 :你把仓库分成两个部分,一个是短期存放区(新生代),一个是长期存放区(老年代)。短期存放区经常清理(复制算法),长期存放区偶尔整理(标记-整理算法)。

拓展:

1.如何判断是不是垃圾?

在 Java 中,垃圾回收(GC)主要通过 引用计数法 和 GC Root Tracing(可达性分析) 来判断对象是否可回收。

(1)引用计数法(Reference Counting)

每个对象维护一个 引用计数器,当有新的引用指向该对象时,计数器加一;当引用失效时,计数器减一。如果计数器变为 0,表示该对象不再被使用,可以被回收。

缺点:无法处理 循环引用 的问题(即两个对象相互引用,但不再被外部使用)。

(2)可达性分析(GC Root Tracing)

JVM 采用 可达性分析作为主要的垃圾判断算法,通过一组称为 GC Roots 的对象作为起点,查找所有可达对象。如果某个对象无法从 GC Roots 访问,则认为它是垃圾,需回收。接下来会详细讲述GC Roots、对象的引用类型和生存状态。

第一,GC Roots 主要包括:

JVM 栈中的引用(方法的局部变量、参数等)。

静态变量引用(存储在方法区的类变量)。

运行时常量池中的引用(如字符串常量 String)。

JNI 本地方法引用(即 Native 方法中的引用)。

第二,对象的引用类型(可达性判断)

根据引用强度,Java 将对象引用分为以下四种类型:

强引用(StrongReference):使用 new 关键字创建的对象,不会被回收。

软引用(SoftReference):在内存不足时会被回收,适用于 缓存。

弱引用(WeakReference):不论内存是否足够,只要发生 GC,弱引用的对象都会被回收。

虚引用(PhantomReference):无法通过虚引用访问对象,主要用于 跟踪对象的回收状态。

第三,对象的生存状态

可达:对象能被 GC Roots 访问,说明仍然存活。

可回收(第一次标记):对象已经不可达,但 可能复活(如 finalize() 方法中重新引用 GC Roots)。

不可复活(第二次标记):若对象在 finalize() 之后仍然不可达,则被认定为垃圾,等待回收。

垃圾回收器(659/1759=37.5%)

Java 的垃圾回收机制通过标记垃圾对象和回收无用内存,提升内存利用率,降低程序停顿时间。垃圾回收器是具体实现算法的工具,最常用的两种收集器是 CMS 和 G1 ,分别适用于不同的场景,接下来我会分别进行讲述。

第一个是 CMS 收集器,CMS(Concurrent Mark Sweep)是以最小化停顿时间为目标的垃圾收集器,适用于需要高响应的应用场景(如 Web 应用)。其基于“标记-清除算法”,回收流程包括以下阶段:

首先停止所有用户线程,启用一个GC线程进行初始标记(Stop The World)标记 GC Roots 能直接引用的对象,停顿时间短。

其次由用户线程和 GC 线程并发执行,进行并发标记用户线程和 GC 线程并发执行,完成从 GC Roots 开始的对象引用分析。

然后,启动多个GC 线程进行重新标记(Stop The World),修正并发标记期间用户线程对对象引用的变动,停顿时间稍长但可控。

最后,启动多个用户线程和一个GC 线程,进行并发清除清理不可达对象,清理完成后把GC线程进行重置。

CMS 的优点是以响应时间优先,停顿时间短,但也有两个缺点,一个是由于CMS采用“标记-清除”,会导致内存碎片积累,另一个是由于在并发清理过程中仍有用户线程运行,可能生成新的垃圾对象,需在下次 GC 处理。

第二个是 G1 收集器,G1(Garbage-First)收集器以控制 GC 停顿时间为目标,兼具高吞吐量和低延迟性能,适用于大内存、多核环境。其基于“标记-整理”和“标记-复制算法”,回收流程包括以下阶段:

首先,停止所有用户线程,启用一个GC线程进行初始标记(Stop The World)标记从 GC Roots 可达的对象,时间短。

其次,让用户线程和一个GC 线程并发工作,用GC 线程进行并发标记分析整个堆中对象的存活情况。

然后,停止所有用户线程,让多个GC 线程进行最终标记(Stop The World),修正并发标记阶段产生的引用变动,识别即将被回收的对象。

最后,让多个GC 线程进行筛选回收根据收集时间预算,优先回收回收价值最高的 Region。回收完成后把GC线程进行重置。这是 G1 的核心优化,基于堆分区,将回收工作集中于垃圾最多的区域,避免全堆扫描。

G1 具有三个优点

其一,将堆内存划分为多个 Region,可分别执行标记、回收,提升效率。

第二,采用“标记-整理”和“标记-复制”,实现内存紧凑化。

第三,方便控制停顿时间,通过后台维护的优先队列,动态选择高价值 Region,极大减少了全堆停顿的频率。

但G1缺点是:调优复杂,对硬件资源要求较高。

如何记忆:

1.联想记忆

假设你正在管理一个城市(堆内存):

(1)CMS 收集器 :

初始标记:市长(GC Roots)召集所有部门领导开会(Stop The World),确定哪些部门(对象)是关键部门。

并发标记:市长派调查员(GC 线程)和市民(用户线程)一起工作,分析每个部门是否还在运作。

重新标记:市长再次召集会议(Stop The World),确认调查期间是否有新的变动。

并发清除:清理掉废弃的部门,但有些新成立的部门(新垃圾)暂时保留,等下次清理。

缺点:清理后城市里留下很多零散空间(内存碎片),影响后续规划。

(2)G1 收集器 :

初始标记:市长召集会议(Stop The World),确定关键部门。

并发标记:调查员和市民一起工作,分析整个城市的运作情况。

最终标记:市长再次开会(Stop The World),确认最新变动。

筛选回收:根据预算,优先清理垃圾最多的区域(Region),并重新规划城市布局。

优点:城市被划分为多个小区(Region),每个小区独立管理,提升效率。

拓展:

1.CMS 收集器运行示意图

2.G1 收集器运行示意图

3.除了CMS和G1外,还有哪些垃圾回收器

除了 CMS(并发标记清除)和 G1(Garbage First)之外,JVM 还提供了其他几种垃圾回收器,主要包括 Serial、Parallel、ZGC等。

(1)Serial 垃圾回收器

特点:单线程回收,适用于 单核 CPU 以及 小型 Java 应用。在 GC 过程中会 Stop-The-World(STW),即暂停所有应用线程,影响响应时间。

适用场景:适用于单 CPU 机器,堆内存较小(如 100MB~2GB)。主要用于桌面应用或测试环境。

优缺点: 实现简单,额外开销小;单线程垃圾回收,STW 时间较长,不适用于高并发环境。

使用参数:-XX:+UseSerialGC

(2)Parallel 垃圾回收器(吞吐量优先)

特点:多线程并行进行垃圾回收,提高 GC 效率。吞吐量优先:适用于批量任务处理,GC 期间仍然会 STW。适用于 多核 CPU 环境,可以最大化 CPU 资源利用率。

适用场景:高吞吐量需求的应用(如大数据计算、离线任务)。适用于 堆内存中等(几 GB 级别)的服务器端应用。

优缺点:多线程回收,吞吐量高,适合 批处理任务。GC 期间仍然会导致 较长的暂停时间,不适用于 低延迟应用。

使用参数:-XX:+UseParallelGC

(3)ZGC(低延迟垃圾回收器)

特点:目标是超低延迟,最大 GC 停顿时间 <1ms。支持超大堆内存(最大 16TB),适用于大规模服务。并发执行大部分 GC 任务,减少 STW 时间。

适用场景:适用于低延迟应用(如金融、游戏、交易系统)。大堆内存(数百 GB 级别) 的应用。

优缺点:GC 过程 几乎无感知,适用于 高响应要求场景。相比 G1,CPU 开销稍高,仍在优化中。

使用参数:-XX:+UseZGC

4.如何选择合适的垃圾回收器?

小堆(<2GB)+ 单核 CPU → Serial GC

高吞吐量(批处理任务) → Parallel GC

低延迟应用(Web 服务器、互联网业务) → CMS GC

大堆(>10GB)+ 综合场景 → G1 GC

超大堆(>100GB)+ 低延迟 → ZGC / Shenandoah

高吞吐量 + 可接受一定 GC 停顿 → G1 GC

低延迟 + 高并发 → Shenandoah GC

【神品八股】1759篇面经精华 文章被收录于专栏

神哥引路,稳稳起步!!早鸟特惠,仅剩177名额!晚了就涨到29.9了! 核心亮点: 1.数据驱动,精准高频:基于1759篇面经、24139道八股题,精准提炼真实高频八股。 2.科学记忆,高效掌握:融合科学记忆法和面试表达技巧,记得住,说得出。 3.提升思维,掌握财商:不仅可学习八股,更可教你变现,3个月赚不回购买价,全额退。 适宜人群: 在校生、社招求职者及自学者。

全部评论

相关推荐

评论
3
5
分享

创作者周榜

更多
牛客网
牛客企业服务