JVM是Java高级部分,深入理解程序的运行及原理,面试中也问的比较多。
JVM是Java程序运行的虚拟机环境,实现了“一次编写,到处运行”。它负责将字节码解释或编译为机器码,管理内存和资源,并提供运行时环境,使其不会被不同的底层操作系统和环境影响。
JVM是支持Java成为跨平台语言的基础,Java代码在很多底层,针对不同操作系统做了处理,屏蔽了不同操作系统和处理器CPU的差异,才使得其能够在多个平台运行。
先把大部分知识点理解记熟了,再研究更深的。其中,内存结构和垃圾回收很重要,必问。
一:核心结构
首先,JVM与JRE、JDK的关系,也会经常被问到。使用Java时,要先安装JDK,所以可以记住JDK是包装的最完整的,然后再往前面推。
操作系统 => JVM(虚拟机) => JRE(JVM+基础类库) => JDK(JRE+编译工具)
JVM主要由以下四大模块构成:类加载器、运行时数据区、执行引擎、本地方法接口。其中,每个模块又会有自己的处理和知识点。
下面就按照顺序描述各个模块的知识点和相关问题。
二:类加载器
类加载器(ClassLoader)是JVM的核心组件之一,负责在运行时动态加载类。
我们编写的Java文件,会存储为二进制字节码,等待被使用。之后通过类加载器将二进制字节码编译为机器码,生成完整的类并提供给运行时数据区。
1:类加载机制
一个类完整的生命周期,会经过五个阶段,加载、链接、初始化、使用和卸载(被垃圾回收)。构成了类对象从无到有,以及最后结束的一个过程。
加载、连接、初始化称为类加载机制,链接又分为验证、准备、解析三个阶段。
1:加载
简单来说就是将类的二进制字节流(.class文件)转换为方法区的数据结构,并生成 Class 对象。
方法区就是用来存储类的数据、方法、结构等,加载就是将类数据放入方法区。方法区只是Java的一个概念,在不同厂商,不同版本有相应的实现(HotSpot Java 8使用元空间实现)。具体在内存结构中详解。
注意:即时编译的热点代码不在这个阶段进入方法区。
类加载底层涉及到两个概念:instanceKlass、_java_mirror。两者均在加载阶段完成,且是后续阶段(链接、初始化)的基础。
所以两者的创建都属于类加载机制。但两者的角色和存储位置不同,分别对应 JVM 内部元数据和 Java 层对象。
常见问题:Class对象在初始化阶段生成?
Class
对象在加载阶段就已生成(仅生成对象),但类的静态变量赋值(初始化)在初始化阶段执行,
重要:在加载阶段,JVM会将Class文件加载到内存,并生成对应的Class对象。这时候就会创建运行时常量池,作为方法区的一部分。
运行时常量池此时包含Class文件常量池中的原始数据(字面量、符号引用等),但符号引用(如类、方法、字段的引用)尚未解析为直接引用。
instanceKlass:
HotSpot JVM内部用C++结构 instanceKlass 描述类的元数据,属于JVM内部的元数据存储,用于字节码执行。数据会放入方法区,所以instanceKlass也属于方法区的实现细节,
它的作用是存储类的完整结构信息,例如:
_super:父类的instanceKlass指针;_methods:类的方法列表;_constants:运行时常量池(区别于Class文件中的常量池);_class_loader:加载该类的类加载器引用,以及其他。
特点:instanceKlass实例由JVM直接分配在元空间(Metaspace)中,属于非堆内存。
_java_mirror:
_java_mirror是instanceKlass在Java堆中的镜像对象,对应Java层的java.lang.Class实例。所以其就是我们平时的Class对象。
作用:为Java提供反射入口(Class.forName);存储类的动态信息(如静态变量);以及作为JVM内部元数据(instanceKlass)与Java应用层的桥梁,属于应用层操作。
_java_mirror对象分配在Java堆中,由垃圾回收器管理。
关系:
二者有紧密联系,当调用class.getName时,会先访问Class实例,然后通过JNI调用到instanceKlass中的元数据,最后返回类名。
步骤 1:JVM 解析 .class 文件,生成 instanceKlass(元空间)。
步骤 2:根据 instanceKlass 的信息,创建 _java_mirror(堆)。
步骤 3:instanceKlass 与 _java_mirror 建立双向指针关联。
instanceKlass内部有指针指向_java_mirror。
_java_mirror(即Class对象)通过Klass*指针关联到instanceKlass(在JVM源码中通过oopDesc结构实现)。
所以二者是有一个双向绑定的关系,这是重点。
2:链接
总体来说就是确保字节码符合JVM规范,以及进行默认值处理。其中又分为验证、准备、解析三个阶段。
验证:
验证文件格式是否编写正确,数据是否正确,是否符合规范等。具体有文件格式验证、元数据验证、字节码验证以及符号引用验证等。
说白了就是文件不能有问题,数据不能有问题,为了保证程序的安全性。
另外,验证阶段不修改运行时常量池,仅检查其内容的合法性。
准备:
准备阶段就是为静态类变量分配内存并设置默认值。会根据是否final编译期常量直接赋指定的值。
1:static
变量的赋值仅针对于 static 修饰的静态基本数据类型,static修饰的变量称为类变量;非static修饰的称为实例变量,在此阶段不处理,而是在对象实例化时分配内存及赋值。
2:final
如果是不加final的类变量,基本数据类型会在准备阶段赋各自默认值,在初始化阶段赋实际值;
如果是加了final的类变量,且值为编译期常量(值在编译器就确认了),则会在准备阶段直接赋实际值。如果是运行期才能确定的值,则也同普通类变量,准备阶段赋默认值。
static final int c = new Random().nextInt()。 //赋默认值0
String字符串是特殊的引用类型,当String变量被 static final 修饰时,且赋值为字面量(编译器常量),则也会直接赋值。其他情况下(非static,非final均为引用类型,默认为null)。具体原理可在方法区详解,涉及常量池。
3:基本类型
准备阶段仅针对于静态八大基本类型赋值;如果是引用对象会默认为null(不涉及准备阶段),统一在对象实例化时处理。
详细理解准备阶段赋值,有助于优化代码,例如设置编译器常量,减少初始化开销。以及排查类加载问题等。
解析:
解析阶段就是将常量池中的符号引用转换为直接引用。符号引用就是一组符号来描述目标,直接引用就是直接指向目标的指针,可以用来定位到目标对象。
会将运行时常量池中的符号引用(如java/lang/Object)解析为直接引用(如内存地址或句柄),且缓存到运行时常量池中,避免重复解析。
这期间,运行时常量池会查询字符串常量池是否存在字符串,如果有则直接引用,否则创建并引用。
使用 classLoader.loadClass() 获取Class类对象时,不会触发解析和初始化。
3:初始化
初始化是类加载的最后一个环节,但是注意,区别于之前的环节,初始化阶段是严格按需触发的。只有在首次主动使用时才会触发,不会因类被加载而自动执行,即初始化是惰性的。
初始化赋值:
在Java中,对类变量(static修饰)进行初始化赋值有两种方式:
- 声明类变量时指定初始值
- 使用静态代码块为类变量指定初始值
不加static的变量,称为实例变量,只有在类实例化时才会分配空间并赋值。
注意:在使用静态代码块方式时,必须把静态变量定义在静态代码块的前面,因为两者是按照代码顺序执行的,顺序不一致可能导致问题(空指针)。
如果静态代码块中引用了其他类(例如通过静态方法调用),被引用的类必须已经完成初始化,否则可能导致递归初始化问题。
实现原理:
编译器会将所有静态变量的赋值操作和静态代码块,按代码顺序合并成一个名为<clinit>的类构造器方法。当触发类初始化时,会执行类构造器<clinit>方法,为静态变量赋实际值。原始构造的内容也会存在,但会在最后。
静态代码块中如果抛出未捕获的异常,会导致类初始化失败,后续对该类的任何使用都会抛出ExceptionInInitializerError。
会引发初始化行为:
可将其归纳为主动使用类的场景,就会触发类的初始化。
会初始化场景 | 场景描述 |
---|---|
new关键字 | 通过new创建对象时,类必须初始化。 |
访问类的静态成员 | 访问类需计算的静态变量(非编译期常量),或访问类的静态方法,会导致初始化。 |
反射获取类对象 | 调用 Class.forName() 默认会触发实例化,可通过传参设置为不初始化。 |
类继承关系 | 子类初始化时,父类若没有初始化过,会先初始化父类。 |
接口默认方法 | 如果接口的实现类初始化,且接口包含默认方法,则接口会初始化。 |
包含main方法的类 | 在程序启动时,JVM会初始化包含main方法的类(主启动类)。 |
动态语言支持(做了解) | 通过MethodHandle访问静态成员:获取类的静态字段或方法句柄时触发初始化。 MethodHandles.Lookup lookup = MethodHandles.lookup(); lookup.findStatic(MyClass.class, "staticMethod", MethodType.methodType(void.class)); |
静态变量赋值包装类型时,底层会自动装箱,所以会导致初始化。
以上是会初始化的场景,问到了大致说几个就可以了。另外还有不会触发初始化的场景。
不会触发初始化:
不会初始化场景 | 场景描述 |
---|---|
访问类的编译时常量 | 访问 static final 且值在编译期确定(准备阶段赋值)。 |
数组类型声明 | MyClass[] arr = new MyClass;(数组由JVM动态生成)。 |
反射获取类对象 | 使用不会触发初始化的反射获取类对象,如.class,以及forName设置不初始化,classLoad等。 |
父类已初始化 | 若父类已初始化,子类引用父类的静态字段不会触发子类初始化。 |
集合声明类 | 有点牵强,做一个了解。类作为集合的泛型被声明时,除非主动实例化类对象,否则不会初始化(泛型擦除)。 |
注意,上述反射只是声明时不会初始化,会在第一次使用该类class对象时触发类的初始化。
初始化的唯一性:
JVM会通过锁和状态标记确保类初始化仅执行一次,无论触发操作如何重复或并发。
1:同步锁(<clinit>方法线程安全)
当多个线程同时尝试初始化一个类时,JVM会通过隐式锁确保只有一个线程执行<clinit>方法,其他线程阻塞等待(唤醒后根据状态会跳过初始化)。
2:类标记状态
JVM为每个类维护一个状态(如uninitialized、initializing、initialized)。
一旦类完成初始化,后续操作直接跳过<clinit>方法。
具体使用时才初始化类,减轻了程序启动时的开销,避免了启动程序时大批量初始化类的情况,提高了程序性能。
4:使用及卸载
使用:很好理解,类初始化之后,就可以进行使用了,可以对其属性和方法进行操作。
卸载:JVM中的卸载指的是从JVM中移除Class对象、字节码和静态变量等,卸载并不常见。
因为通常只有 ClassLoader 被回收后,类才有可能被卸载。如果一个类是由系统类加载器加载的,那么它可能很难被卸载,因为系统类加载器通常不会回收(通常与JVM生命周期一致)。所以一般只有自定义类加载器,加载器实例不再被引用时,它加载的类才有可能被卸载。
类卸载条件:
- 所有实例被回收:该类(及其子类)的所有实例都已被垃圾回收。
- 类加载器被回收:加载该类的 ClassLoader 实例已被回收。
- 无活跃引用:该类的 Class 对象(如 MyClass.class)没有被任何地方强引用(例如反射、静态变量等)。
注意:仅仅垃圾回收类的实例, 不意味着类本身被卸载,类卸载必须满足上述条件。
重新创建类:
类卸载后,可以再次重新创建类并使用。但这一行为已经不是简单的初始化了,而是类从字节码开始重新走一遍生命周期。
具体为尝试使用对象时(调用静态方法,实例化等),会由类加载器重新加载字节码,可能需要新的类加载器,随后重新触发 clinit 方法,重新初始化类。
所以,重新加载是类卸载后的新生命周期开始。
public class Test {
public static void main(String[] args) throws Exception {
// 使用自定义类加载器加载类
ClassLoader loader = new CustomClassLoader();
Class<?> clazz = loader.loadClass("MyClass");
// 触发初始化
clazz.getMethod("init").invoke(null);
// 清除引用,触发卸载
clazz = null;
loader = null;
// 强制触发GC(仅示例,实际生产环境慎用)
System.gc();
Thread.sleep(1000);
// 再次加载(需要新的ClassLoader)
loader = new CustomClassLoader();
clazz = loader.loadClass("MyClass");
// 会再次触发初始化
clazz.getMethod("init").invoke(null);
}
}
class CustomClassLoader extends ClassLoader {
// 实现加载类的逻辑(例如从字节码文件读取)
}
5:枚举
在Java中,枚举的本质是一个继承自 java.lang.Enum 的类,其成员变量(枚举实例)会被隐式声明为 public static final,且由 JVM 保证全局唯一性。
枚举的加载遵循 Java 类加载机制,实例会在 cinit 方法中被静态初始化。JVM会保证cinit的线程安全,无需额外同步机制。
枚举的构造器是私有的(由 JVM 强制限制),通过反射调用 Constructor.newInstance() 时会抛出 IllegalArgumentException。
其内部实例也是单例的,跟随初始化创建。
2:类加载器
JVM中有三类核心类加载器,形成双亲委派模型的层次结构。提供三个类加载器的原因是单一职责,分别负责不同的区域。按照由大到小的顺序。
类加载器 | 描述 |
---|---|
Bootstrap ClassLoader(启动类加载器) | 由C++实现,是JVM的一部分,无法直接访问。 加载JAVA_HOME/lib目录的核心类库(如rt.jar)。 处于类加载器层次顶端,无父加载器。 |
Extension ClassLoader(扩展类加载器) | 在Java中实现,Java类sun.misc.Launcher$ExtClassLoader。 加载JAVA_HOME/lib/ext目录或java.ext.dirs系统变量指定的类库。 父加载器为 Bootstrap ClassLoader。 |
Application ClassLoader(应用程序类加载器) | 在Java中实现,Java类sun.misc.Launcher$AppClassLoader。 加载用户类路径(ClassPath)下的类,主要加载我们写的类。 父加载器为 Extension ClassLoader。 |
自定义类加载器 | 自定义路径,父加载器为Application ClassLoader。 |
其中,启动类加载器为最顶级加载器,如果尝试通过 getClassLoader() 获取类加载器时,会打印null。
因为启动类加载器由JVM内部的C++代码实现,没有对应的 ClassLoader 对象,因此返回null,并且这也是Java设计的一种规范,以此作为启动类加载器标识。
其他的类加载器可以返回Java实例,但注意扩展类加载器的getParent()也会返回null,同上。
类的命名空间:由类加载器+包名+类名共同确定唯一类。
不同类加载器加载的同一个类,JVM视为不同类。
1:自定义类加载器
如果有特殊场景需求时,例如需要加载非classpath中的路径文件、想通过接口来实现、同时加载相同的类时,则可以考虑创建并使用自定义类加载器。
步骤:继承ClassLoad类;并重写findClass()方法,读取类字节码;调用defineClass()生成Class对象(遵循双亲委派机制);在使用时通过该自定义类加载器 loadClass 方法获取类对象。
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadClassFromDisk(name); // 自定义加载逻辑
return defineClass(name, bytes, 0, bytes.length);
}
}
自定义类加载器由JVM垃圾回收管理,并且其加载的类在一定条件下会被JVM卸载。
自定义类加载器需谨慎管理生命周期,避免内存泄漏(如Metaspace/PermGen溢出)。
2:双亲委派模型
首先术语,双亲是指的appClassLoader的上面两个父类,所以这样描述。
双亲委派模型是指:类加载请求会首先委派给父加载器,并且一层层往上,父加载器无法完成时,子加载器才尝试加载。
优点:
唯一性:保证类的一致性,同一个类由同一个类加载加载。
安全性:避免重复加载,确保核心类安全(如String类),防止开发者对Java程序类进行篡改。
一般我们说的都是Java 8的双亲委派,其实在Java 9的双亲委派有一些变化,做了相应的优化。
JDK 9引入了模块化概念,大体就是将不同的包指定为一个模块,然后指定该模块的类加载器。当需要加载类时,委派到平台类加载器,会将其直接派给指定的类加载器处理。
会避免无用的委派,优化了类加载性能,提高程序启动速度。
打破双亲委派模型:
有些场景可能需要由子加载器优先加载,不遵循双亲委派机制,该行为称为打破双亲委派。
常见的有:
Tomcat:Web服务器,需要保证每个Web应用使用独立的类加载器(加载各自独立的类,即使同名),加载WEB-INF下的类。
SPI(Service Provider Interface):如JDBC驱动加载,使用线程上下文类加载器(Thread Context ClassLoader)加载厂商实现。
OSGi模块化:每个Bundle有自己的类加载器,形成网状依赖关系。
线程上下文类加载器:
也属于打破双亲委派模型,其作用是解决父加载器需访问子加载器资源的场景(如JDBC加载第三方驱动)。
在线程启动时,会默认把应用程序类加载器放入线程加载器,使用getContentClassLoader可获取当前线程类加载器。
可通过 Thread.currentThread().setContextClassLoader(),设置线程类加载器。
使用 ServiceLoader.load() 通过上下文类加载器加载服务实现。
3:类加载器源码
在 loadClass的源码中,通过方法 findLoaderClass 判断是否加载过,如果有则从缓存中直接取,不会重新加载。
如果这个类没被加载过,返回null。则标识需要加载类,此时会判断一个parent对象,就是我们的类加载器。
如果parent对象不为空,则递归调用 loadClass 方法,递归时也会判断各个类加载是否加载过。直到类加载器为顶级bootstrap时,parent才会为空,并进入方法尝试获取加载类(缓存)。
如果顶级加载器找不到类,会向下继续找,如果所有类加载器都找不到类,会抛出找不到类异常。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded 类是否已加载过,如果存在直接使用
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) { //类加载器不为空,递归向上找
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name); //只有顶级类加载器时,才会为空,并委派顶级父加载器处理
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
......
}
if (resolve) {
resolveClass(c); //如果所有类加载器都无法加载类,会抛出找不到类异常
}
return c;
}
}
三:运行时数据区
运行时数据区主要由五大部分组成:方法区、堆、虚拟机栈、本地方法栈、程序计数器,讲解时也可以按照这个顺序。
这五个部分:方法区、堆、栈会涉及内存溢出;堆是垃圾回收的主要区域,方法区的回收条件严格且效率低,虚拟机找的内存管理不依赖于垃圾回收,而是通过栈帧的弹出释放内存。
栈内存的释放是确定性的(方法结束即释放),而堆和方法区的回收是非确定性的(由 GC 策略决定)。
另外,方法区和堆是线程共享的,所以堆内存的共享变量要考虑线程安全问题。
1:方法区
方法区是一个规范,用于存储类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。它是所有线程共享的内存区域,生命周期与 JVM 一致。
注意方法区作为规范只定义了上述逻辑概念,没有规定具体实现方法,不同的JVM可以用不同的数据结构实现(如Open J9),我们常说的就是 HotSpot,所以在描述方法区时,一定要指定JVM。
1:内存结构
方法区是一个规范,在Hotspot虚拟机中,Java 8对其方法区的实现做了调整和优化。
1.7及之前:
方法区的实现是永久代(PermGen),其位于堆内存中,大小固定,可以通过参数 -XX:PermSize 和 -XX:MaxPermSize 配置。
缺点:受固定内存限制,灵活性不高;易因类加载过多或常量池过大导致 PermGen OOM。
1.8及以后:
1.8修改为元空间(Metaspace)实现,元空间使用本地内存,默认不限制大小,但会受操作系统物理内存限制。
优势为:降低 OOM 风险,内存分配更灵活;没有永久代的内存焦虑问题,可设置元空间内存上限,避免耗尽系统内存;根据类加载的需求动态扩展,减少内存浪费。
2:核心作用
方法区的特点是线程安全的。当方法区无法满足内存分配需求时,会抛出OutOfMemoryError内存溢出。Java 8之后,使用系统内存很难溢出,可以通过设置较小参数,来模拟类加载过多OOM的场景。
1:类元数据存储
在上面解释类加载过程时提过,Hotspot 方法区底层就是使用 instanceKlass 存储类的元数据(类名、字段、方法、类加载器等)。
与堆中的 Class 对象相互关联,并直接分配在本地内存中,而非堆内存。
2:静态变量
类级别的变量(static 修饰),直接存储在方法区中。注意如果是静态引用对象,则方法区仅存储引用地址,实际对象实例存在堆内存中。
3:JIT热点代码
JIT 编译后的热点代码会存储在方法区的“代码缓存”(Code Cache)中。这里简单描述下,具体可以在执行引擎-JIT详细描述(触发条件)。
代码缓存是 JVM 为存储即时编译器(JIT)生成的本地机器码而预留的内存区域,会由JVM单独分配,通常也会在本地内存。
4:垃圾回收
方法区也有垃圾回收机制,但是回收条件较为严格,需满足类卸载条件(引出类加载机制)。主要用于回收废弃常量和无用的类,不可过度依赖,容易发生内存泄漏和内存溢出问题。
这里可能会有问题:描述下方法区的OutOfMemoryError原因和处理?
原因:
- 未合理配置元空间大小。
- 项目中可能存在大量动态生成的类(如 CGLib、反射、JSP)。
- 类加载器未卸载(如频繁热部署应用),或创建大量自定义类加载器。
处理:
- 调整 -XX:MaxMetaspaceSize,元空间内存大小。如果设置默认需考虑物理内存压力。
- 减少动态类生成(如缓存代理对象),以及减少反射动态获取,考虑缓存反射类等。
- 检查代码,去除或减少无用的类加载器,无用的类等,避免内存泄漏。
3:核心参数
描述针对于方法区的参数设置,以及元空间垃圾回收和扩容流程。
元空间参数 | 描述 |
---|---|
-XX:MetaspaceSize | 初始的高水位线,注意不是初始化这么大!初始分配的内存可能较小,然后根据需要动态调整。 当元空间使用量达到此值时,触发Full GC尝试回收无用元数据。若回收后仍不足,则扩容。 |
-XX:MaxMetaspaceSize | 元空间的最大上限,默认无限制(受限于系统内存)。建议生产环境设置此值,防止内存耗尽。 如果达到了MaxMetaspaceSize的限制,就无法继续扩容,导致OOM错误。 |
-XX:MinMetaspaceFreeRatio | 触发元空间扩容的最小空闲比例,默认 40%(注意设置值时无需百分号)。 |
-XX:MaxMetaspaceFreeRatio | 触发元空间缩容的最大空闲比例,默认 70%(内存退回给操作系统)。 |
通过参数可以控制元空间的垃圾回收频率,以及扩容的触发时机。
1:扩容触发
扩容针对的是设置了初始初始高水位线的场景,不设置默认为系统最大内存,生产建议设置避免宕机。
扩容机制:元空间由多个内存块组成,每个块分配给特定的类加载器。当加载新类时,JVM从当前块分配内存。当触发扩容时,JVM会向操作系统申请新的内存块,每次扩容的大小由JVM内部策略决定(通常逐步增加)。达到上限时,不会触发扩容,而是触发GC。
上述的两个参数都可以用来控制扩容:初始高水位线、最小空闲比例。
- 最小空闲比例:当元空间的空闲比例低于这个阈值时,会触发元空间扩容,不会直接触发GC。只会在扩容失败时(达到最大上限),才会触发GC尝试回收数据。
- 初始高水位线:当元空间使用量达到该值时,触发Full GC尝试回收元数据,若GC后内存还不足,则扩容。
- 当两个参数同时设置了,且条件同时满足时。JVM会优先处理【最小空闲比例】的扩容需求,扩容成功后直接分配,扩容失败则GC。后续会检查【初始高水位线】,在首次使用量达到时,如果上个参数已扩容,则此时不会触发GC,而是将高水位线自动更新为扩容后的容量。如果上个参数未扩容,则按原逻辑触发GC再尝试扩容。
所以,两个参数对于扩容和GC顺序不同,不会产生冲突,能够协同确保内存分配的效率与稳定性,从性能方面看,肯定是先扩容好,因为GC成本太高。
优化初始值:避免初始值过小导致频繁 GC。
优化最小空闲比例:如果元空间增长过快,降低该值以减少扩容频率;如果内存充足,提高该值以保留更多空闲内存,减少GC风险。
对于缩容,元空间将内存释放给操作系统的条件较严格,通常需满足最大空闲比例,且依赖不同的JVM实现。
2:垃圾回收触发
如果元空间的使用达到了设置的最大阈值,分配新内存失败时,会触发Full GC,回收方法区不再使用的元数据,清除满足类卸载条件的数据,条件较为严格,无用类无法被回收即为内存泄漏。
Full GC:表示全局垃圾回收,暂停所有线程STW。这里的Full GC与堆内存的Full GC是同一个过程,只是触发的条件不同。都是由垃圾回收器执行。
因此,元空间触发的Full GC实际上会触发整个堆的回收,而堆触发的Full GC同样可能影响元空间。
如果垃圾回收后内存还是不够,则会抛出内存溢出,程序终止。
4:运行时常量池
运行时常量池其实也是方法区的核心部分,比较重要,这里单独描述。在描述之前,需要先理解三个术语以及他们的关系。
1:常量池
常量池指的是Class文件结构里的一部分,里面存放了各种类编译时期生成的各种字面量和符号引用,如类接口名、字段、方法等信息。
这部分信息是在编译时生成的,存在于Class文件中。每个类都有自己的常量池。
2:运行时常量池
运行时常量池是方法区的一部分,每个类或接口在JVM中加载后,其Class文件中的常量池会被解析并加载到运行时常量池。其跟随Hotspot方法区实现,1.8之前在永久代,1.8及之后在元空间。
运行时常量池创建发生在类的加载阶段,符号引用替换发生在解析阶段。
每个类或接口都有自己的运行时常量池(同Class常量池)。
在加载时,运行时常量池存的是符号引用,在解析阶段,会替换为字符串常量池的引用。
两者关系:Class文件中的常量池是运行时常量池的“静态快照”,运行时常量池是其在JVM中的运行时形态。
3:字符串常量池
可称为String Pool / String Table,字符串常量池是JVM中全局共享的字符串缓存池,用于存储字符串对象的引用(避免重复创建相同字符串)。
在Java中,字符串是不可变的,所以JVM为了优化,会有一个全局的字符串常量池。当用双引号直接创建字符串时,JVM会检查字符串常量池中是否存在该字符串,如果有就直接返回引用,否则创建并放入池中。
在Java 7之前位于永久代(方法区),Java 7及之后被移到堆内存(Heap)。
字符串常量池是一个JVM内部实现的哈希表结构(不是集合框架中的),在Java7存放在永久代时,大小固定不可扩容,易导致内存问题。Java 7及之后移动到堆内存后,大小可通过 JVM 参数调整(例如 -XX:StringTableSize=N,默认 60013)。
字符串常量池是惰性加载,按需加载即用到时才加载。实际字符串对象的创建和入池操作发生在首次主动使用该字面量时(如赋值、方法调用)。
如果字符串相加的值在编译期能够确认,则会进行编译期优化,从串池中获取。所以有些面试题会考察字符串相加判断,根据其是否从串池获取,以及存储位置是否一样判断。
与运行时常量池关系:运行时常量池可能包含字符串常量池中的符号引用。
比如在类加载阶段,Class创建并加载到运行时常量池后。
随后会在解析符号阶段,查询字符串常量池,如果存在则引用指向已有的对象,否则创建新的字符串对象并放入字符串常量池。
所以运行时常量池中的字符串实际上是引用到字符串常量池中的对象。
5:String.intern()
String提供的一个方法,调用 intern() 时,JVM 会检查字符串常量池(StringTable)中是否存在与当前字符串内容相同的对象,如果有则直接返回;如果不存在,会将当前字符串对象的引用添加到字符串常量池,并返回该引用。
intern() 直接操作的是 字符串常量池(StringTable),而非运行时常量池。运行时常量池中的符号引用在类加载时已被解析为字符串常量池中的对象引用,不会修改其内容。
伴随着Java版本对方法区的调整,intern方法对串池的处理也不同(妥协),所以经常会有字符串比对的相关问题。
Java 6:
如果一个堆内存字符串对象,调用intern方法,会判断字符串常量池是否存在。
如果不存在,则会将堆中的字符串内容拷贝到永久代,生成一个新的字符串对象,加入池中,并返回永久代的引用。如果存在,则直接返回永久代的引用。
结果:堆中的对象和池中的对象是两个独立的对象,地址不同。会导致==判断失败。
String s1 = new String("abc"); // 堆中对象
String s2 = s1.intern(); // 永久代对象(拷贝生成)
System.out.println(s1 == s2); // false(地址不同)
Java 7+:
从 Java 7 开始,字符串常量池被移至堆内存,与普通对象共存。
所以对象调用intern方法时,如果不存在,则会将将堆中该字符串对象的引用直接加入池中(无需拷贝),如果存在,则直接返回池中的引用。
结果:池中存储的是堆中对象的引用,地址相同。
String s1 = new String("abc"); // 堆中对象
String s2 = s1.intern(); // 池中引用指向堆中的 s1 对象
System.out.println(s1 == s2); // true(地址相同)
但是不能确保一定返回堆中的引用,具体要看 intern 执行的时机。
如果字符串常量池中已有相同内容的字符串(例如通过字面量 "abc" 提前加载),则 intern() 直接返回池中引用,与调用时机无关。此时再和堆内存对象判断地址,会比较不成功。
String s0 = "abc"; // 池中已加载 "abc"
String s1 = new String("abc");
String s2 = s1.intern();
System.out.println(s2 == s0); // true(s2 指向池中已有的 "abc")
System.out.println(s1 == s2); // false(s1 是堆中的新对象)
equals() 与 == 的区别:
这里就更加清晰两者的区别了,很经典的面试题,以及建议字符串用equlse判断值的原因。
无论 Java 6 还是 Java 7+,只要字符串内容相同,equals() 始终会返回 true(判断内容相等)。
但 == 的结果取决于对象地址:
Java 6 中,intern() 后的对象地址与堆对象地址不同(拷贝到永久代),会比较失败。
Java 7+ 中,intern() 后的对象地址可能与堆对象地址相同(引用复用),会比对成功,但是需要注意 intern 方法的执行时机(串池中没有提前字面量加载)。
2:堆内存
堆内存是JVM管理的内存区域中最重要的一部分,用于存储对象实例和数组。它是所有线程共享的内存空间,也是垃圾回收的主要区域。
堆内存存储的数据:
- new 创建的对象和数组。
- 存储类级别的静态实例对象,存储实例变量的值(基本类型)或堆对象引用。
- Java 7及之后存储字符串常量池,作为全局字符串缓冲池。
- 存储线程私有的分配缓冲区,JVM 为每个线程在堆内存中分配一小块私有区域(TLAB),用于快速分配对象,避免多线程竞争。
- 存储逃逸分析优化失败的对象,JVM 的逃逸分析会尝试将未逃逸的对象分配在栈上(栈上分配),但若分析失败或未启用优化,对象仍分配在堆中(栈章节中详细描述)。
- 垃圾回收相关的元数据,如标记信息,分代年龄等数据。
静态变量不在堆中的原因:
静态变量属于类,由方法区管理,与堆内存隔离。这种设计避免了静态变量被垃圾回收(除非类卸载),同时减少堆内存压力。
1:内存结构
为了优化垃圾回收效率,Java将堆内存划分为不同区域,称之为不同的代,且随着JDK版本做了相关优化。
上图是Java 8堆内存详细的划分,在之前还会存在一个永久代内存区域。所以堆内存最大的改动就是在Java 8对方法区的实现。
新生代:
新生代用于存放新创建的对象,其中又分为了 Eden 区和 Survivor 区,存储不同时期对象。新生代垃圾回收称为 Minor GC,理解为代价小一点的GC。
Eden区:对象首次分配的区域。在Minor GC时会被清空,用于分配新对象。
Survivor区:存储并处理垃圾回收时的对象,其中又分为 Survivor From 和 Survivor To 区。进入From区和To区的对象,会在两者之间移动,不会回到Eden区。直到年龄足够晋升到老年代,或被回收。
这里涉及到垃圾回收算法的标记整理,用来清除无用对象,保留存活对象(具体可以在GC篇描述)。
老年代:
用来存放长期存活的对象,如直接晋升老年代或经过多次 Minor GC 后依然存活的晋升对象。老年代的垃圾回收称为 Migor GC,Full GC表示全局垃圾回收,一般会认为是老年代垃圾回收导致的全局回收,会造成STD,速度较慢,代价比较大(具体可以在GC篇详细描述)。
老年代就是一块完整的内存区域,没有更具体的区域划分(Java 8)。
永久代:
这里就能理解为什么Java 8之前的方法区叫永久代,因为1.8版本前方法区实现在堆中,所以也遵循了堆中的分代规则,起了永久代的名字,并划分一块内存区域,内存固定容易造成OOM。
1.8及之后版本,将永久代从堆内存移除,改为元空间使用本地内存,避免了永久代导致的内存溢出问题。
Java 9:
回答问题时可以提一下,在Java 9中,G1默认称为垃圾回收器,G1 将堆划分为多个大小相等的 Region(区域),不再严格物理分代。
逻辑上我们还会称为分代,但物理上是动态 Region 分配。其优势是提供更可控的停顿时间,适合大内存应用。
2:核心参数
通过 JVM 参数可以配置堆内存的大小和分代比例,从而进行我们常说的JVM调优和GC调优,以及内存溢出等相关问题的故障排查。
堆内存的相关参数大致分为四个部分:堆内存基础参数、分代内存参数、高级调优参数。
使用的话就是加在程序的启动参数中,中途加入不会生效,程序需重启。
堆内存基础设置:
-Xms:初始堆大小,建议与 -Xmx
相同,避免堆动态扩容导致的性能波动。
-Xmx:最大堆大小,堆内存上限,超过会触发 OutOfMemoryError。
-XX:+UseCompressedOops:启用压缩指针,默认为开启,可优化对象头大小,节省内存。替换为减号即为关闭。
使用以上参数可简单设置堆内存大小,结合本地内存容量,减少内存不足的问题,提高系统稳定性。
-Xmx 至少为系统可用内存的 1/4,但不超过 80%,避免系统崩溃。
分代内存参数(年轻代/老年代):
分代参数 | 描述 |
---|---|
-XX:NewSize | 年轻代初始大小,设置年轻代的初始值。 |
-XX:MaxNewSize | 年轻代最大大小,结合堆初始/最大内存使用。 |
-XX:OldSize | 老年代初始大小,结合堆初始/最大内存使用。 |
-XX:NewRatio | 老年代与年轻代的比例,默认值 JDK8 为 2。 使用:-XX:NewRatio=3,表示老年代:年轻代=3:1。 |
-XX:SurvivorRatio | Eden 区与 Survivor 区的比例,默认为8。 使用:-XX:SurvivorRatio=8,表示Eden区和survivor区(From和To区)比例8:1:1。 |
-XX:MaxTenuringThreshold | 对象晋升老年代的年龄阈值,默认15,设置0表示直接进入年代。 使用:-XX:MaxTenuringThreshold=15。 |
一般情况下,直接增大堆内存可以简单快速的解决内存问题,但如果有内存紧缺的场景,例如项目体量过大、机器本地内存限制等,则只能最大程度的分配堆内存中的分代内存。
新生代内存不足,频繁触发垃圾回收时,可考虑提高新生代大小,或减小晋升阈值,让对象进入老年代。
老年代内存不足,可考虑提高老年代比例。或提高晋升阈值,使对象保留在新生代,但要注意可能需要提高Survivor区的比例,因为进入Survivor区的对象不会回到Eden区。
高级调优参数:
高级参数 | 描述 |
---|---|
-XX:+UseTLAB | 启用线程本地分配缓冲(默认开启),可加速对象分配,减少竞争。 |
-XX:TLABSize | 设置 TLAB 大小(每个线程在堆内存分配的一小块区域),使用时根据线程数调整优化。 |
-XX:+AlwaysPreTouch | 启动时预分配物理内存,避免运行时内存分配延迟,但会延长启动时间。 |
-XX:+EliminateAllocations | 启用逃逸分析优化,自动将未逃逸对象分配在栈上(默认开启)。 |
生产环境中,一般是多个参数结合使用,根据场景,选择构建高吞吐量、低延迟、大内存的的程序,提高系统性能,减少内存问题。
3:内存溢出
当发生内存泄漏或堆内存不足时(设置较小或对象较多),会触发垃圾回收机制,当回收后内存还不够时,就会抛出内存溢出OutOfMemoryError(OOM),程序终止。
堆内存时垃圾回收的主要区域,一般也称Full GC为堆内存不足,虽然方法区也可引发Full GC,但和堆相比概率较低。
常见的堆内存溢出原因有:内存泄漏、堆内存设置较小。
优化策略是:排查代码有没有内存泄漏问题;设置较大堆内存;优化对象生命周期,调整分代内存结构;使用工具分析堆内存使用情况(VisualVM / JConsole/图形化查看堆内存使用)。
内存泄漏和内存溢出区别:
总结:内存泄漏是程序中的错误,导致对象无法被回收,可用内存逐渐减少;而内存溢出是程序需要的内存超过了可用的内存,可能由泄漏引起,也可能是其他原因。
两者的关系是内存泄漏可能导致内存溢出,但溢出不一定由泄漏引起,也可能是正常的内存不足。
内存泄漏:
对象已经不再被程序使用,但由于错误被意外保留在内存中,导致垃圾回收器(GC)无法回收它们。例如:静态集合长期持有对象引用、ThreadLocal的引用、资源泄漏(连接池或IO未关闭)、类无法卸载、Hash值改变等。
处理:使用工具(如MAT、VisualVM)分析堆转储,找到未被回收的对象引用链,删除或优化代码。注意内存泄漏不是单纯加内存能解决的,问题的源头还是代码问题。
内存溢出:
申请内存时,JVM的堆或方法区没有足够空间分配对象,就会抛出OOM,程序终止。可能的原因为:内存泄漏长期累计、JVM方法区或堆内存设置较小、程序体积或数据量过大等。
处理:检查是否存在泄漏,设置JVM参数调整,检查程序逻辑是否处理了大数据场景,修改或优化代码。
两者在生产环境都是比较严重的问题:内存泄漏是温水煮青蛙,加内存也只是缓解一时,需要解决代码的源头问题,不好排查;内存溢出会使程序终止,可能造成业务操作中断、用户数据丢失等,问题很严重。
3:虚拟机栈
虚拟机栈是Java虚拟机内存模型运行时的重要部分,用于支持方法调用和执行过程中的数据管理,栈内存是线程私有的,不涉及垃圾回收的区域。
线程私有:每个线程在创建时都会分配一个独立的虚拟机栈,生命周期与线程相同。
每个栈只能有一个活动栈帧,对应着当前正在执行的那个方法。
1:内存结构
栈帧:每个方法的执行对应一个栈帧的入栈(方法开始)和出栈(方法结束),栈帧中存储方法的局部变量、操作数栈、动态链接、返回地址等信息。
后进先出(LIFO):方法调用链的栈帧按调用顺序压入和弹出。
栈帧内包含几个重要部分,主要用来存储局部变量,以及对数据进行操作。
全局变量需要考虑线程安全。看一个栈帧内的变量是否线程安全,可以看其是否被其他方法引用,或当作返回值被引用(逃离方法)。
局部变量表:存储方法参数和局部变量,包括基本数据类型、对象引用和返回地址。以变量槽(Slot)为最小单位,32位类型(如int)占1个槽,64位类型(如long、double)占2个槽,对象通常占用一个槽,具体由JVM实现。局部变量表大小在编译期确定,不会在运行时改变。
操作数栈:执行字节码指令时的工作区,用于存放计算中间结果和调用参数。提供给编译器通过指令获取计算。操作数栈大小也是编译时确定,写入方法的Code属性中。
动态链接:将符号引用转换为直接引用(方法在内存中的实际地址)。每个栈帧持有指向运行时常量池的引用,支持动态绑定。
方法返回地址:记录方法正常退出或异常中断后应返回的位置。
其他还有例如多线程中,存储对象头进行锁获取等。
在方法中发生未捕获的异常时,栈帧会被弹出,直到找到合适的异常处理器。可能涉及多个栈帧的销毁,同时异常信息会被传递到调用者栈帧中。
2:栈内存溢出
当线程请求的栈深度超过虚拟机允许的最大值时,会抛出StackOverflowError,例如无限递归,或设置了较小的栈内存。
当线程扩展栈时无法申请足够内存时,可能会抛出OutOfMemoryError,不过通常栈溢出更常见于StackOverflowError。
可以通过-Xss设置栈大小(如-Xss1M),默认值依赖JVM实现和操作系统。
异常抛出时,JVM通过栈帧中的信息生成堆栈轨迹,可使用JConsole、VisualVM或jstack命令查看线程栈信息。
4:程序计数器
程序计数器是用来保存当前线程正在执行的字节码指令地址,是线程私有的,且唯一不会内存溢出的区。
线程私有:每个线程独立拥有一个程序计数器,互不影响。
部分JVM实现可能将程序计数器映射到CPU的寄存器,以提升执行效率。
简单来说就是记住下一条JVM指令的执行地址,提供并指导执行引擎完成计算。是虚拟机栈和执行引擎之间的桥梁。
5:本地方法栈
Java虚拟机用于管理本地方法的调用,而本地方法栈就用来管理本地方法的调用,即代码中使用native修饰的方法,底层可能是C或C++编写的本地接口。
需注意不是所有的JVM都支持本地方法,本地方法栈也是线程私有的。
常见的有例如Object类的clone方法,就是调用本地方法。
对一个运行中的Java程序而言,当其某个线程调用本地方法时,它就进入了一个全新的不受虚拟机管理的模块,本地方法可以通过本地方法接口来访问虚拟机中的运行时数据区,以及其他数据。
四:执行引擎
执行引擎时负责将JVM中字节码,转换为底层操作系统能够执行的机器指令,并实际运行程序。
执行引擎主要有三个功能点:解释器、即时编译器、垃圾回收器。
1:解释器
逐条读取并解释字节码指令,将字节码解释为机器码,即使下次读取是相同的,仍会重复解释。
优点是启动速度快,内存占用较低;缺点是执行效率较低,每次运行会重复解释。
2:即时编译器
一般称为JIT,其功能是将热点代码编译为本地机器码,后续执行直接运行机器码。
优点是显著提升长期运行程序的性能(如服务器应用)。
1:编译器实现
- C1编译器(Client Compiler):轻量级编译器,优化启动速度,适用于客户端程序。
- C2编译器(Server Compiler):深度优化编译器,牺牲编译时间换取更高的执行效率。
- 分层编译(Tiered Compilation):JDK 7+默认启用,结合C1和C2的优势,先由C1编译,再对热点代码由C2深度优化。
热点代码探测:
可通过-XX:CompileThreshold(默认 10000),当方法调用次数超过阈值时会触发编译。
此时JIT 编译器(如 C1/C2)将字节码编译为机器码,存入代码缓存。
编译优化策略:
方法内联:将小方法调用替换为方法体代码,减少调用开销,例如A(1*1),并可结合常量折叠进一步优化。
逃逸分析:判断对象是否逃逸出方法或线程,决定是否进行栈上分配或锁消除。
2:代码缓存
代码缓存是 JVM 为存储即时编译器(JIT)生成的本地机器码而预留的内存区域。
在 HotSpot 中,代码缓存是方法区的一部分,由JVM单独分配,通常也在本地内存。
可通过 -XX:ReservedCodeCacheSize 和 -XX:InitialCodeCacheSize 参数配置大小。
当代码缓存占满时,JIT 编译会停止,导致程序退化为解释执行(性能下降)。可通过添加参数 -XX:+UseCodeCacheFlushing 允许在缓存不足时回收无用代码(如已卸载类的方法代码)。
后续调用可直接执行代码缓存中的机器码,大幅提升性能。
3:其他描述
JVM基于栈:字节码指令通过操作数栈进行运算,指令更紧凑,跨平台性好,但效率略低。
物理CPU基于寄存器:直接操作寄存器,效率更高,但依赖硬件架构,不符合Java跨平台运行。
垃圾回收器属于内存管理模块,但执行引擎需要与GC协作,在安全点(Safepoint)暂停线程以执行垃圾回收。
Graal编译器:JDK 10+引入的可插拔编译器,支持更多激进优化,未来可能替代C2。
AOT编译:JDK 9+通过jaotc工具将字节码预先编译为机器码,减少启动时间。
五:垃圾回收
JVM的垃圾回收是自动管理内存的核心机制,负责回收不再使用的对象以释放内存,避免内存泄漏。吸取了C++的经验,改为自动管理,避免手动管理垃圾回收导致的一些问题。
主要从几个方面描述:回收条件、回收算法、垃圾回收器、回收操作。
1:回收条件
回收算法的目的是判断对象是否可进行回收,并自动进行垃圾回收,释放资源,节省内存空间。
1:引用计数法
引用计数法并未在Java中使用,而是作为一个概念理解,在其他语言中有使用到。
原理:每个对象维护一个计数器,记录被引用的次数。当引用计数归零时,对象被回收。
缺点:两个对象互相引用,引用计数无法归零,导致内存泄漏;另外需要频繁更新计数器,并且在多线程环境下还需要考虑线程安全问题,造成较大性能开销。
2:可达性分析算法
Java中使用的是根搜索算法,也称为可达性分析算法。
原理:从GC Roots出发,遍历对象引用链。若对象不可达(例如将属性或对象赋值为null),则标记为可回收。
GC Root主要有四种对象:线程栈帧中的局部变量或参数、引用类型静态变量、字符串常量池引用、同步锁持有的对象。
在可达和不可达的状态中间,还有一个可复活状态:对象被标记为可回收,但在finalize()方法中重新与引用链关联。
3:引用类型
JVM提供了四种引用类型,用来在发生垃圾回收时,判断特定的对象是否可被回收释放内存。
强引用 | 平时用到的所有引用都属于强引用,例如new对象,对象间赋值等。 对象只要被一个强引用关联,GC 永远不会回收。强引用链断裂时可被回收。 |
软引用(Soft) | 适用于内存敏感型缓存,通过 SoftReference 类包装使用。 当没有强引用直接引用时,且即使发生垃圾回收内存也不够时,回收软引用对象。 |
弱引用(Weak) | 适用于临时缓存、监听器注册,通过WeakReference 类包装使用。 当没有强引用直接引用时,无论内存是否充足,发生GC就会被回收。 |
虚引用(Phantom) | 适用于精准控制资源释放,必须与 ReferenceQueue 结合使用,当对象被回收时,虚引用会被加入队列。 虚引用对象无法通过 get() 获取实际对象,仅用于跟踪对象被回收的时机。 |
软弱引用还可以搭配队列使用,因为其本身也是对象,会占用空间。使用队列后,当软弱引用的对象被回收后,会进入引用队列进一步处理。
// 创建软引用对象
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
// 尝试获取对象
byte[] data = softRef.get(); // 若内存充足,返回字节数组;若内存不足,返回 null
// 创建弱引用对象
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// 手动触发 GC(仅示例,实际开发中避免调用 System.gc())
System.gc();
// 检查对象是否存活
if (weakRef.get() == null) {
System.out.println("对象已被回收");
}
// 创建引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 创建虚引用对象
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
// 检查对象是否被回收
Reference<?> ref = queue.poll(); // 若对象被回收,返回虚引用对象
if (ref != null) {
System.out.println("对象已被回收,可执行清理操作");
}
2:回收算法
堆内存是垃圾回收的主要区域,JVM提供了不同的算法用于堆内存的垃圾回收。
为什么标记存活对象,而不是标记失效对象?
- 效率问题:垃圾回收的目的是回收内存,而 JVM 中大部分对象是“朝生夕死”的。直接标记存活对象可以避免遍历大量无效对象,提高效率。
- 安全性问题:如果误删存活对象会导致程序崩溃,因此必须明确标记存活对象后再清理未标记的部分。
注意:对于标记对象,网上有的说是标记失效对象,这个一定要明确是标记存活对象。在《深入理解Java虚拟机》的书中,确实描述标记需要回收的对象,但实际实现是标记存活对象反向推导的。
1:标记清除
分为两个步骤,核心思想是通过“标记存活对象”和“清除未标记对象”两个阶段来回收内存。
1:沿GC Root引用链,标记所有存活对象。若需并发标记(如CMS收集器),需通过“三色标记”等机制处理应用线程与GC线程的并发修改。
2:线性遍历整个堆内存,识别未标记的对象。将未标记对象占用的内存标记为空闲,供后续分配使用。
优点是速度快,只需做一个标记;且实现逻辑简单,无需移动对象,适合处理存活率高的老年代对象。
缺点是清除后会产生大量不连续的内存碎片,可能导致大对象分配失败;会造成两次停顿,降低响应时间;清除阶段需遍历整个堆,时间复杂度为O(n)。
2:标记整理
是对于标记清除算法,产生内存碎片的优化方案。
其步骤为标记存活对象后,将其移动到内存一端,清除边界外的空间。解决了内存碎片问题。
缺点是效率低、速度慢,整理需要移动对象造成开销,且对象中的局部/引用变量也需要改变引用地址。
同标记清除,适用于老年代,对象存活率高的场景。
3:复制算法
将内存分为两块区域,称为From区和To区,To区域始终保持空区域状态。核心思想是通过空间划分和对象复制来高效管理内存,避免内存碎片化问题。
步骤:从GC Root根对象触发,标记所有可达对象。将From区的可达对象按顺序复制到To区,随后直接清空From区的所有对象。最后From区和To区互换角色,为下一次回收做准备。
优点:由于保持顺序分配,所以没有内存碎片问题。
缺点:传统复制算法需要预留 50% 的闲置内存(From 和 To 区各占一半),利用率较低。
4:分代回收机制
上面都是JVM提供的三种回收算法,但实际上虚拟机不会单纯使用某种具体的算法,在Hotspot虚拟机中,是采用三种算法结合的方式,称为分代机制。
具体可以参考堆内存篇结构描述。新生代老年代采用不同算法,新生代使用复制算法并优化了传统复制算法的双倍内存问题。老年代采用标记清除或标记整理算法。
具体流程为:
创建对象时,优先分配在 Eden 区;当触发 Minor GC 时,将 Eden + From Survivor 中的存活对象 标记并复制到 To Survivor(年龄+1),清空无用对象。当存活对象年龄达到阈值(默认 15)后晋升到老年代。
3:垃圾收集器
JVM提供了多种垃圾收集器,适用于不同场景,可以从两个方面对其进行分类。
1:从设计目标区分
单线程型 | 串行执行,如 Serial,仅用于特定场景(小内存或客户端应用)。 |
并行-吞吐量优先型 | 如 Parallel Scavenge,目标是最大化应用运行时间占比(GC时间占比最小化)。 可理解为处理效率高。适用于多线程、注重整体吞吐量的场景。 |
并发-低延迟型 | 如 CMS、G1、ZGC,目标是减少单次GC停顿时间(STW时间短)。 最小化单次停顿时间,适用于大堆内存和对延迟敏感的场景(如Web服务)。 |
简单来说,类似微服务的CAP理论,是性能和响应速度的选择。
2:根据作用区域和算法,可分为两种收集器:分代收集器和全堆收集器。
垃圾回收器并行:并行指的是多个垃圾回收线程一起执行,不能有用户线程。
垃圾回收器并发:用户线程和垃圾回收线程一起执行,能提高响应速度。
1:Serial
Serial是串行的垃圾回收器,属于分代收集器类型。单线程串行工作,通过 -XX:+UseSerialGC 参数显式使用。
针对新生代和老年代,新生代采用复制算法,老年代采用标记-整理算法。
GC时触发STW,暂停所有用户线程,执行垃圾回收线程。
优点为内存占用低,无多线程开销。但其只适用于客户端应用或小内存应用,不适合生产高并发场景。
2:Parallel Scavenge
JDK8默认收集器!
Parallel Scavenge是吞吐量优先的垃圾回收器,属于分代收集器类型,多线程并行工作,可通过两个参数开启。
-XX:+UseParallelGC,表示新生代的垃圾回收器,算法是复制(多线程并行)。
-XX:+UseParallelOldGC,表示老年代的垃圾回收器,算法是标记-整理。
这两个开关在1.8中是默认开启的,且只要有一个开启,另一个也会开启。
核心特点是多线程并行回收,注重最大化吞吐量(单位时间处理请求数)。垃圾回收线程数取决于CPU个数,会执行多个垃圾回收线程,尽快处理。适用于后台计算密集型任务。
提供了核心参数用于动态调整:
-XX:ParallelGCThreads=4 # 并行GC线程数(默认与CPU核数相同)
-XX:GCTimeRatio=99 # GC时间与总时间占比(1/(1+99)=1%)
-XX:MaxGCPauseMillis=200 # 目标最大GC停顿时间(毫秒)
-XX:GCTimeRatio=99,注意垃圾回收时间不能超过工作总时间的百分之一,否则考虑加大堆内存。并且时间占比参数和最大停顿时间互相冲突,需根据场景具体选择。
-XX:GCTimeRatio=99,表示吞吐量,堆越大,则吞吐越大(清理的资源多),所需时间会增加。
-XX:MaxGCPauseMillis=ms,表示时间,设置时间越少,则表示堆越小,吞吐量越小,时间缩短。
3:ParNew
parNew是Serial收集器的多线程版本,属于分代收集器类型,多线程并行工作,通过参数 -XX:+UseParNewGC 启用。
需注意只针对新生代,使用复制算法,多线程并行处理。
需要和CMS搭配使用,在JDK9后逐渐被G1替代。
4:CMS
CMS(Concurrent Mark-Sweep)是并发低延迟的垃圾回收器,属于分代收集器,多线程并发执行,通过参数 -XX:+UseConcMarkSweepGC 启用。
注意:JDK9后标记为废弃(Deprecated),JDK14中移除。
需注意只针对老年代,使用标记-清除算法。CMS只能与ParNew或Serial配合。
特点是采用并发标记和清除,减少STW时间。适用于对延迟敏感的服务(如Web应用)。
缺点是受标记清除算法影响,存在内存碎片问题,可能会触发Full GC进行压缩。由于内存是不连续的,所以对象在分配内存时会采用空闲列表方式。
工作流程:
- 初始标记(STW):标记GC Roots直接关联的对象。
- 并发标记:遍历对象图,无停顿,用户和垃圾回收线程同时运行。
- 重新标记(STW):修正并发标记期间的变动,最后标记,未避免期间再变动,会STW。
- 并发清除:回收垃圾对象,无停顿,和用户线程同时运行。
并发标记阶段使用三色标记,通过写屏障处理并发修改。用户线程只会在打初始标记时短暂阻塞。
优化参数:
-XX:CMSInitiatingOccupancyFraction=75 # 老年代使用率触发CMS的阈值(%)
-XX:+UseCMSCompactAtFullCollection # Full GC时压缩内存
-XX:CMSFullGCsBeforeCompaction=4 # 每4次Full GC后压缩一次
5:G1
G1(Garbage-First)是同时注重吞吐量、低延迟、超大堆内存的垃圾回收器,属于全堆收集器(混合收集),多线程并发执行,通过参数 -XX:+UseG1GC 启用。
G1是在Java 7引入的,Java 8需手动开启,在JDK9+作为默认收集器。
结合了标记-整理算法和分区回收策略,旨在平衡吞吐量和停顿时间,尤其适合现代多核处理器和大内存应用场景。
每个Region的内存较少,且可以单独回收,所以它可以采用标记整理方式,避免内存碎片。并且内存规整后,对象在 Region 分配内存时,会使用指针碰撞的方式,最大限度使用空间。
核心思想是将堆划分为多个等大的Region(默认2048个),每个region都可以是以下类型之一,在GC过程中动态变化,无需固定分区比例。
Eden Region:存放新对象。
Survivor Region:存放存活对象(Minor GC后晋升)。
Old Region:存放长期存活对象。
Humongous Region:存储超大对象(大小 ≥ Region的50%)。
可通过 -XX:MaxGCPauseMillis 设定目标最大停顿时间(默认200ms),G1会结合历史GC数据动态调整策略,动态选择回收价值最高的Region以达成停顿目标。
1:分阶段回收
- Young GC:回收Eden和Survivor区(类似传统新生代GC)。
- Mixed GC:同时回收Young和部分Old Region(减少老年代碎片)。
- Full GC(后备方案):当并发回收速度跟不上对象分配速度时触发(需避免)。
工作流程:
初始标记 | 通常由新生代触发,在STW阶段,标记GC Roots直接关联的对象。 耗时极短,仅标记根对象。 |
并发标记 | 通常发生在老年代,当老年代占用堆空间达到阈值时,进行并发标记。 也是标记存活对象,不会造成STW,不会暂停用户线程。通过SATB算法处理漏标记(标记期间对象变化)。 |
最终标记 | 处理剩余的SATB记录,修正标记结果(相比CMS处理的更少)。 会STW,耗时较短,取决于并发标记期间的对象变动量。 |
筛选回收 | 最终标记完成后,根据停顿目标,选择回收价值高的Region,将其存活对象复制到空闲Region(复制算法)。 也称为混合收集,会造成STW,耗时主要取决于存活对象数量和region选择策略。 |
并发标记阶段采用三色标记,结合SATB(Snapshot-At-The-Beginning)算法,通过写屏障记录引用变化。
注意并不是操作所有老年代数据复制,而是为了达成停顿目标,动态选择效率最高的老年代对象处理。如果对象不多,又可以满足最大停顿时间,那么它会把所有老年代复制。
SATB:
目的是解决并发标记期间的对象漏标问题。
在初始化时对整个堆建立快照;在并发标记期间,对发生修改的对象记录到SATB队列;最终标记阶段处理SATB队列,确保标记完整性。
Card Table(卡表):
一种底层数据结构,用于粗略跟踪跨代引用。早期的跨代引用优化处理。
将堆内存划分为固定大小的卡页,每个卡页对应卡表中的一个条目。当老年代对象引用新生代对象时,标记对应卡页为脏页(Dirty Card),表示需扫描该区域。
在垃圾回收时,避免全堆扫描,仅扫描脏页以找到跨代引用。
维护成本较低(仅标记脏页),但扫描脏页时需遍历卡页内所有对象。
Remembered Set(记忆集):
目的是解决跨Region引用问题(避免全堆扫描)。
一般称为RSet,是一种高级抽象结构,用于精确记录跨Region引用。基于卡表实现,但功能更精确。避免跨Region引用扫描,直接读取RSet中的引用,扫描效率更高。
所以Rset和卡表是协作关系,Rset在其基础上做了升级优化。
实现:在G1中,每个Region维护一个RSet,记录其他Region中指向本Region的指针位置。
1:写屏障。当对象A(Region X)引用对象B(Region Y)时,触发写屏障,标记对象A所在卡页为脏页。
2:并发优化线程。定期处理脏页,解析其中的跨Region引用,并将具体引用地址记录到目标Region的RSet中。
3:GC阶段使用Rset。回收Region时,直接查询其RSet,仅扫描相关卡页中的引用,避免遍历全堆。
优点:写屏障的轻量化(仅标记卡表)保证应用线程性能;Refinement线程的并发处理避免GC停顿过长。
场景:
适用于堆内存较大(>=4G,建议6G+),要求低延迟业务场景(页面,实时系统),以及对象生命周期分布不均匀场景。
不适用于堆内存较小(<2G),堆内存小使用Serial或Parallel更高效;不适用超大堆内存(≥16GB)。
6:ZGC
ZGC(Z Garbage Collector)是并发低延迟的垃圾回收器,属于全堆收集器,多线程并发执行,通过参数 -XX:+UseZGC 启用。
在JDK 11引入初始版本,JDK11至JDK14使用需解锁实验室选项开启,JDK15+正式启用。
ZGC的设计目标是低延迟、高吞吐量、停顿时间与堆大小无关、支持动态调整堆大小。其专为超大堆内存设计,核心理念是全程并发。
算法是染色指针 + 读屏障。并发阶段采用三色标记结合染色指针(Colored Pointers),通过读屏障处理并发标记。
染色指针:
核心特性之一,在指针中嵌入元数据(元空间与对象地址分离)。
通过指针标记对象状态(存活、可回收等),无需对象头。可快速实现并发标记与转移,无需STW暂停。
读屏障:
当应用线程从堆中读取对象引用时触发。
检查指针的染色位,若对象正在被转移,触发自愈机制,自动更新引用到新地址。以保证应用线程始终访问有效对象,无需暂停。
内存多重映射:
原理是将同一物理内存映射到多个虚拟地址空间(如Marked0和Marked1视图)。
目的是支持染色指针快速切换标记状态,无需复制内存。
适用于堆内存 ≥ 8GB(最佳实践为16GB+)、要求极致低延迟(金融、大规模微服务)、堆内存动态变化频繁等场景。
优点:ZGC通过染色指针和全程并发设计,在超大堆场景下实现了亚毫秒级停顿,是Java应对现代高并发、低延迟需求的终极方案。
缺点:
内存开销:染色指针和元数据占用约3%~5%额外内存。
CPU开销:读屏障和并发处理需更多CPU资源(建议多核环境)。
7:Shenandoah
Shenandoah(读音深圳do啊)是并发低延迟垃圾回收器,属于全堆收集器,多线程并发执行,通过参数 -XX:+UseShenandoahGC 启用。
算法是并发复制 + 读屏障。在JDK12中正式引入,JDK15优化性能。
与ZGC类似,但实现方式不同(无染色指针,依赖Brooks指针)。并发标记与整理均依赖三色标记,使用读/写屏障实现高并发。
同样适合大堆内存、低延迟需求。有两个关键技术。
并发压缩:在对象存活时移动并更新引用。
Brooks指针:每个对象头存储转发指针,指向复制后的新地址。
在堆内存 ≥16GB时,可选择ZGC或Shenandoah(追求亚毫秒级停顿)。如果时 TB 级别的堆内存,优先使用JDK 15版本的ZGC,生产已验证。
4:垃圾回收
垃圾回收具体处理,及GC调优。
1:Minor GC
新生代(Eden区)空间不足时触发。
当new一个新对象,默认会放到 Eden 区,Eden区内存不足时,触发垃圾回收。
不同的垃圾回收器有不同处理,以Java 8的Par为例,第一次GC会标记Eden存活对象,复制到To区,且对象寿命加1。第二次GC会标记Eden区和From区存活对象,复制到To区。对象寿命达到阈值后,晋升至老年代。
频繁发生Minor GC时,可能是新生代内存设置较小,或有无用对象无法回收。
2:Mijor GC
老年代内存不足时处理,对整个堆进行回收,通常伴随长时间STW,通常也称其为Full GC。
以Java 8的Par处理器为例,会使用标记-整理算法清除无用对象,GC后内存还不足时,抛出内存溢出,程序终止。
频繁Mijor GC时,可能是老年代分配过小,或整个堆内存较小。
问题排查:创建了很多大对象,全部晋升到了老年代;发生了内存泄漏问题;代码bug等(手动gc)。
3:Full GC
一般情况下指的就是Mijor GC老年代垃圾回收,但不全面,因为方法区也会导致Full GC。
方法区动态加在类过多或内存泄漏,触发的Full GC和堆内存触发的是同一个,都是由垃圾回收器执行全面的垃圾回收,包含方法区和堆内存。
通常会伴随长时间STW,是比较严重的问题。不同的垃圾回收器会有不同的优化方案。
4:GC调优
GC调优时,首先检查导致GC的问题,以及想要实现的目标(高吞吐量或低延迟等)。
调优方法大致有垃圾回收器选型、内存、锁竞争、CPU占用、响应时间等。
使用 jstat、jmap、jconsole、VisualVM 等工具分析内存和 GC 日志。
1:新生代调优
新生代的对象特点是内存分配廉价,对象操作频繁,大部分对象是用完就消除。
Minor GC时间短,所以一般调优会从新生代开始。
新生代内存不能太大或太小,取一个中间的合理值。
太小会导致创建对象内存不够用(频繁minor gc);太大会导致内存空闲,老年代内存紧张,从而导致Full GC更严重。
建议设置 25 < 新生代 < 50 的堆内存总量,足够对象的日常创建销毁。
并发场景下,考虑最大并发量时,占用的内存是否会超过新生代内存,如果不超过,则可能不会或较少的触发垃圾回收。
新生代中的幸存区需要能够保留当前活跃对象及需要晋升的对象。如果幸存区内存过小,JVM会动态调整晋升阈值,可能会导致部分不活跃对象提前晋升到老年代。
对象提前晋升到老年代后,只有等到Full GC的时候才能被回收,等于是延长了存活短对象的生存周期,从而占用内存空间。
另一方面,有时候可能又需要将一些对象提前晋升老年代,如果有大量存活对象未被晋升,那么会留在幸存区中不停的复制移动,浪费系统性能。
所以,实际中还是根据业务场景,项目体量具体考虑优化方案。
2:老年代调优
一般不涉及老年代调优,理论上是越大越好,取决于系统内存。
如果频繁Full GC的话,考虑增加老年代内存,或减少对象晋升(搭配新生代),以及排查内存泄漏问题。或考虑使用并发吞吐量或低延迟垃圾回收器。
六:面试问题
结合上述所有JVM结构等知识,进一步扩展的JVM面试相关问题,大致回答清晰即可。
1:对象创建过程
通过 new 创建一个对象,JVM都做了什么?
1:首先会进入类的加载过程,通过类加载器及双亲委派模型,尝试从缓存中(常量池中)获取已加载过的类,如果找不到,则重新加载并实例化类,在加载阶段分配堆内存空间并生成初始Class对象。
2:获取到已经加载过的类之后,会在堆内存分配空间。根据垃圾回收机制不同,有两种内存分配方式:指针碰撞(serial、ParNew、G1)和空闲列表(CMS)。
3:分配内存后,会将对象存入新生代的Eden区,这个过程如果是多线程并发情况下,可能会发生JVM内存的抢占(另一个面试题,可引出)。
4:对象存入Eden区之后,会进入类加载的准备阶段,为类对象设置默认值(可引出final修饰)。
5:对象头设置,对象头包括GC的分代年龄、锁升级标识、HashCode。
6:最后触发类的初始化,执行 cinit 方法,随后执行 init 方法。init也是自动生成的一个方法,用来对非静态变量赋值,在cinit之后执行。
2:对象内存分配方式
注意不要讲成对象的内存布局,尽量精准回答。
类加载阶段,获取到缓存中的类之后,会在堆中分配内存。根据内存是否规整区分为两种分配方式。
堆内存是否规整是由垃圾收集器是否带有压缩整理功能决定的。
1:内存规整
堆内存规整的情况下,已使用的内存在一边,未使用内存在一边。对应标记-整理算法。
该场景下,使用指针碰撞方式分配内存。
已使用和未使用的内存中间存放一个分界指示器,分配内存时,指针会向未使用内存移动待分配对象大小的位数,用来存放对象。
这种方式内存使用充分,但是开销较大,在垃圾回收后,需要整理压缩内存便于后续使用。
2:内存不规整
内存不规整时,JVM内部维护了一个记录可用内存块的列表。在分配对象时,找一块足够容纳待分配对象的空间,划分给对象实例。
该方式称为空闲列表方式,对应标记清除-方式。
该方式性能较好,垃圾回收后无需整理。但可能导致内存浪费,使用不充分(大内存存小对象)。
3:内存抢占问题
多线程并发环境下,无论通过哪个内存分配方式,多个对象可能会指向同一块内存地址,即发生内存抢占问题。
JVM针对内存线程安全问题提供了两种解决方式:CAS 和 TLAB,这两种方式可以共同使用,协作处理。
TLAB:
线程本地分配缓冲区,JDK 8默认开启。默认占Eden区的1%,可通过参数调整。
每个线程在堆内存中预先分配一小块内存,当线程要分配内存时,优先在本地缓冲区分配。本地缓冲区用完之后,会重新申请新的缓冲区。
CAS:
当本地缓冲区分配失败,或对象大小超过设置的缓冲区大小时,会通过CAS分配内存。性能相比TLAB较差。
4:对象的内存布局
一个对象在JVM中是如何存储的,或者如何计算对象大小。
在Hotspot虚拟机中,对象在Java内存中的存储布局可分为三块,三块数据相加就是对象的大小。
1:对象头区域
Mark Word:锁升级的状态、对象的HashCode、GC的分代年龄、是否偏向锁和锁标志位。
类型指针:存在方法区的 KlassInstance 中,通过类型指针可以获取到类的信息并实例化。
数组长度:只有数组对象才会有。
2:实例数据区域
实例数据是指我们代码中定义的字段内容,例如属性、字段等。
3:对齐填充区域
添加占位符,起占位作用,保证对象的大小必须是8字节的整数倍,因为Hotspot要求对象的起始地址必须是8字节的整数倍,且对象头部分正好是8字节的倍数。
如果不对齐填充,会导致对象头中有空位,从而导致其他对象可能存进来,数据错乱。并且8的倍数也会提高运行速度。
5:三色标记算法
在传统的GC过程中,通过可达性分析算法标记存活对象,再执行垃圾回收,此时程序会STW,暂停用户线程,只执行垃圾回收线程,效率低且用户体验不好。
三色标记算法是在原有的垃圾回收器上升级,将STW变为并发标记,减少停顿时间。程序一边运行,一边标记垃圾。并且做了优化,避免重复扫描对象,提升标记阶段的效率。
在传统标记中,对象引用不会发生改变,不会有问题;但是在并发标记时,对象间的引用可能发生改变,可能会出现错标和漏标的情况。
用到三色标记的垃圾回收器:CMS、G1、ZGC、Shenandoah。
1:三色
三色标记算法也是根据可达性分析,从GC Roots开始进行遍历访问,只是在遍历对象过程中做了优化,按照【是否检查过】这个条件将对象标记成三种颜色。
- 白色:该对象没有被标记,表示垃圾对象。
- 灰色:该对象已经被标记过了,但该对象下的属性(如A引用B)没有全被标记完,GC会继续向下寻找垃圾。
- 黑色:该对象已被标记过,且该对象下所有属性也都被标记过了。
可以理解为三种颜色是三个集合,分别将对象放入不同集合。初始都是白色,发生垃圾回收且找完之后,就会清空白色。灰色可以理解为中转站,最后结束时灰色一定是空的。
2:浮动垃圾
在一个GC的并发标记过程中,一个对象已经被标记为黑色或灰色,但并发修改导致变成了垃圾(白色)。但此时不会对标记过的对象重新扫描,所以不会发现。
那么此时这个对象即不会被清除,也不会被重新找到,就称为浮动垃圾。
浮动垃圾对系统的影响不大,在下一次GC时会被同样处理。
3:对象漏标问题
简答来说就是需要用的对象被回收。也是由于并发导致,可使用写屏障技术记录引用变化解决。
在一个GC的并发标记的过程中,一个业务线程将一个白色对象引用断开置为垃圾,同时有一个黑色对象引用这个对象(新增引用),这两个顺序可以互换,也可以先引用再断开。
此时GC Roots不会再去黑色节点下面找,但又因为其是白色标记。所以会导致出现:既被需要使用,又会被垃圾回收。导致系统出现问题。
CMS和G1垃圾回收器,都针对该问题做了应对,CMS增加引用环节确认标记,G1增加SATB算法确认并发期间修改的标记。
6:垃圾回收器选择
问到项目线上使用什么垃圾回收器,可以先大概描述下常见的回收器,然后说有缺陷。
新生代收集器:Serial、ParNew、Parallel Scavenge。
老年代收集器:Serial Old、CMS、Parallel Old。
全堆收集器:G1、ZGC、Shenandoah。
在实际使用时,会根据项目的JDK版本,以及业务的类型综合考虑垃圾回收器。
JDK8常用的有两种组合,ParNew + CMS,Parallel Scavenge + Parallel Old。这两种组合的新生代处理基本一样,但是对于老年代,CMS组合使用标记清除方式,性能及响应速度会更好一点。
所以,如果我的项目是ToB的,或者堆内存是4G以下的,可以选择JDK8默认的Parallel组合。
如果项目是ToC的(用户),或者堆内存是4-8G更大一点,可以选择CMS方式。
然后G1是JDK 9的默认回收器,Java 8需要显式开启使用。G1回收器对于内存大且需要更快的响应速度时,是很好的选择。
最后对于ZGC、Shenandoah这两个垃圾回收器,要确认自己的项目体积内存已达到十几G或T级别,并且追求毫秒级的停顿时,可以考虑。一般用到这两个回收器的都是中大型项目了,如果体量下,用了可能反而不如其他回收器。
7:逃逸分析
还有另一种提问方式,对象一定分配在堆中吗?这个一定要回答不一定,然后描述逃逸分析。
逃逸分析:
编译期间,JIT做的优化功能,用于减少堆内存分配压力。
简单来说就是在方法中创建的对象,其指针有可能被返回或者被全局引用,然后被其他方法或线程引用,这种现象就称为引用的逃逸。
JVM 的逃逸分析会尝试将未逃逸的对象分配在栈上,即方法中创建的对象,没有传递出去。
但是如果创建的对象过大,使得无法分配在栈上时(栈内存不够),会正常存储到堆中。
好处:
栈上分配。未逃逸的对象会随着栈帧出栈而销毁,减轻垃圾回收的压力。
同步消除。如果确定一个变量不会方法逃逸,那么它在多线程环境下是安全的,不需要考虑变量的线程安全问题,避免同步开销。
标量替换。未方法逃逸的变量,栈可以使用内存碎片进行存储,将其变量恢复为原始类型访问,提升速度和性能。