类卸载是Java虚拟机(JVM)垃圾收集机制的一个高级特性。它指的是JVM在特定条件下,将不再被使用的Java类(Class对象)及其相关的元数据(metadata)从内存(具体来说是方法区或元空间)中彻底移除并回收其占用的内存空间的过程。
要理解类卸载,需要先理解以下几个关键点:
1. 类的生命周期:一个类从被加载到虚拟机内存开始,到被卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。卸载是生命周期的最后一步。
2. 谁需要被卸载?不是任何一个类都可以被卸载。通常只有那些被“用户自定义类加载器”(User-Defined Class Loader)加载的类才有可能被卸载。由Java虚拟机自带的三大类加载器(Bootstrap ClassLoader, Extension ClassLoader, System/Application ClassLoader)加载的类,在JVM的整个生命周期中通常始终是存活的,因此几乎不会被卸载。
3. 类卸载发生的必要条件(必须同时满足):
该类的所有实例都已被垃圾回收: 即Java堆中不存在任何该类的实例对象。
加载该类的ClassLoader实例已被垃圾回收: 这是最关键的一个条件。类与其类加载器之间有着强关联性,一个类必须由其类加载器来定义和加载。只有当这个类加载器实例本身不再被引用,成为了垃圾对象并被回收时,它加载的所有类才“有可能”被卸载。
该类对应的java.lang.Class对象没有任何地方被引用: 不能通过任何地方的反射等方法访问到这个Class对象。
4. 类卸载发生的地方:类卸载发生在JVM的方法区(Method Area)。在JDK 8及之后的版本中,方法区的具体实现叫做“元空间”(Metaspace)。类卸载的目的就是为了回收元空间中不再使用的类元数据,以防止元空间无限膨胀,最终导致内存溢出(OutOfMemoryError: Metaspace)。
5. 一个典型的例子:Java中的热部署(Hot Deployment)机制,例如在应用服务器(如Tomcat)或IDE(如IntelliJ IDEA)中,当你修改了代码并重新部署或调试时,服务器或IDE会创建一个新的类加载器来加载新版本的类。而老的类加载器以及由它加载的所有旧版类,在满足了上述三个条件后,就会被JVM卸载,从而完成类的更新和内存的回收。
总结来说:类卸载是JVM一种优化内存使用的机制,它允许在严格的条件满足时,清理掉不再需要的类定义信息。这个过程完全由JVM自动管理,对开发者是透明的。它的核心触发条件是加载该类的类加载器实例本身被垃圾回收。理解类卸载有助于我们更好地管理应用的内存,特别是在需要动态加载和卸载类的复杂应用(如OSGi、应用服务器)中。
类初始化是执行类构造器 <clinit>()
方法的过程,该方法由编译器自动收集类中的所有类变量(static变量)的赋值动作和**静态代码块(static {} 块)**中的语句合并产生。触发类初始化的场景可以归纳为以下几类:
1. 主动引用(必然触发初始化)当一个类被“主动使用”时,JVM 必须对其进行初始化。主动使用包括以下具体情况:
创建类的实例:使用
new
关键字创建一个新对象。访问类的静态变量:读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)。
调用类的静态方法:使用
invokestatic
指令调用类的静态方法。反射调用:使用
java.lang.reflect
包的方法对类进行反射调用时(例如Class.forName("com.example.Test")
)。初始化子类:当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
作为程序入口:当一个类含有
main()
方法并被指定为程序运行的入口点时。
2. 补充说明(不触发初始化的特殊情况)了解哪些情况不会触发初始化同样重要:
通过子类引用父类的静态字段:对于静态字段,只有直接定义这个字段的类才会被初始化。通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
访问编译期常量:访问一个类中定义为
static final
的常量(编译期常量),并不会触发该类的初始化,因为这个值在编译阶段就已经被放入调用类的常量池中了。数组定义:通过数组定义来引用类(例如
SomeClass[] sca = new SomeClass[10];
),不会触发该类的初始化。类加载:类加载(Class Loading)和类初始化(Initialization)是两个不同的阶段。仅仅通过类加载器(ClassLoader)加载一个类,并不会触发其初始化。
希望以上清晰的分类和解释能帮助你准确地理解类初始化的触发时机。
自定义类加载器通常需要继承 java.lang.ClassLoader
类,并重写其关键方法。核心步骤如下:
第一步:继承 ClassLoader 类
创建一个新的类,并让它继承 java.lang.ClassLoader
。
第二步:重写关键方法
你需要重写一个或多个 findClass
方法来定义类的查找逻辑。这是推荐的做法,因为它遵循了双亲委派模型。在重写的方法中,你需要完成以下步骤:
根据自定义的路径或方式(例如,从网络、加密文件、数据库中)读取类的字节码。
调用
defineClass
方法(从父类继承而来)将字节数组转换为Class
对象。如果找不到类,则抛出
ClassNotFoundException
。
一个简单的自定义类加载器示例
假设我们要创建一个从特定磁盘目录(而非 classpath)加载类的类加载器。
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class MyClassLoader extends ClassLoader {
// 指定自定义的类加载路径
private String classPath;
public MyClassLoader(String classPath) {
// 默认的父加载器是系统类加载器
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 将类名转换为文件系统路径
String fileName = convertClassNameToPath(name);
File classFile = new File(fileName);
// 2. 如果文件不存在,直接抛出异常
if (!classFile.exists()) {
throw new ClassNotFoundException("Class " + name + " not found in path: " + classPath);
}
// 3. 读取类的字节码
byte[] classBytes = loadClassBytes(classFile);
if (classBytes == null || classBytes.length == 0) {
throw new ClassNotFoundException("Failed to load class bytes for: " + name);
}
// 4. 调用 defineClass 方法将字节数组转换为 Class 对象
// 参数:类名,字节码数组,起始位置,长度
return defineClass(name, classBytes, 0, classBytes.length);
}
/**
* 将类名转换为完整的文件路径
* 例如:com.example.Test -> /my_classes/com/example/Test.class
*/
private String convertClassNameToPath(String className) {
return classPath + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
/**
* 从文件中读取字节码数据
*/
private byte[] loadClassBytes(File file) {
try (FileInputStream fis = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
}
如何使用这个自定义类加载器
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
// 指定类文件所在的根目录,例如 /my_classes
String customClassPath = "/my_classes";
MyClassLoader myClassLoader = new MyClassLoader(customClassPath);
// 使用自定义加载器加载指定类
// 注意:这个类不能在应用类路径下,否则会被系统类加载器优先加载(双亲委派)
Class<?> loadedClass = myClassLoader.loadClass("com.example.MyClass");
// 创建实例并调用方法
Object instance = loadedClass.getDeclaredConstructor().newInstance();
// 假设 MyClass 有一个名为 'show' 的方法
Method method = loadedClass.getMethod("show");
method.invoke(instance);
}
}
关键注意事项
双亲委派模型:
ClassLoader
的loadClass
方法默认实现了双亲委派机制。它会先委托父加载器尝试加载,只有在父加载器无法完成时,才会调用自己的findClass
方法。上面的例子通过重写findClass
而不是loadClass
,很好地遵守了这一模型。如果你需要破坏双亲委派,则需要重写loadClass
方法(但通常不推荐)。命名空间:每个类加载器都有自己的命名空间。由不同的类加载器加载的同一个类(
Class
文件相同),在 JVM 看来也是两个不同的、不兼容的类。这会导致类型转换异常(ClassCastException
)。defineClass
方法:这是一个final
的本地方法,由 JVM 实现。它负责将字节数组解析成 JVM 内部的Class
对象。你无法重写它,只能调用。findLoadedClass
方法:在自定义loadClass
逻辑时,可以先调用此方法检查类是否已被当前类加载器加载过,避免重复加载。应用场景:自定义类加载器常用于热部署、代码加密解密、从非标准来源(如网络、数据库)加载类、以及实现容器隔离(如 Tomcat、OSGi)等场景。
通过以上步骤和示例,你就可以创建出一个功能完整的自定义类加载器。核心是重写 findClass
方法,定义如何找到类的字节码,并最终调用 defineClass
完成加载。
双亲委派模型是Java类加载器所采用的一种工作模型,其核心思想可以概括为“向上委托,向下查找”。它规定了类加载器在加载一个类时所应遵循的步骤和原则,目的是为了保证Java核心库的类型安全,避免类的重复加载。
这个模型的“双亲”一词,并非指父母两个人,而是指父级(Parent)的类加载器,其本质是一种组合关系。
工作过程可以分为以下三个步骤:
检查是否已加载:当一个类加载器接收到一个类加载的请求时,它首先不会自己去尝试加载这个类,而是会先检查这个类是否已经被加载过。如果已经加载,则直接返回已加载的类。
委托给父加载器:如果当前类加载器在自己的缓存中没有找到这个类,它不会立即自己查找,而是会将这个加载请求委托给它的父类加载器(也就是
parent
属性指向的那个加载器)去完成。父加载器无法完成时才自己加载:这个委托的过程会一直向上进行,直到传递到最顶层的启动类加载器(Bootstrap ClassLoader)。如果父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类),子加载器才会尝试调用自己的
findClass
方法,在自己的类路径中去查找并加载这个类。
举个例子来说明:假设你的应用程序中有一个名为com.example.MyClass
的类。
应用程序类加载器(App ClassLoader)首先收到加载这个类的请求。
它不会自己先找,而是委托给它的父加载器——扩展类加载器(Extension ClassLoader)。
扩展类加载器收到委托后,同样也不会自己找,而是继续委托给它的父加载器——启动类加载器(Bootstrap ClassLoader)。
启动类加载器在自己的加载路径(JRE/lib/rt.jar等)中查找
com.example.MyClass
,显然找不到,于是返回失败。扩展类加载器得知父加载器失败后,就在自己的加载路径(JRE/lib/ext目录)中查找,同样也找不到,于是返回失败。
应用程序类加载器得知父加载器也失败后,这才开始在自己的加载路径(程序的classpath)中查找,成功找到并加载了这个类。
双亲委派模型的主要优势:
安全性:最重要的作用是保证了Java核心API的安全性。例如,用户无法自定义一个
java.lang.String
类来替换掉核心库中的String类。因为即使你自定义了,在委托过程中,启动类加载器会先在核心库中找到并加载标准的java.lang.String
,你的类根本没有机会被加载。避免重复加载:可以确保一个类在JVM中只存在一份,避免了类的重复加载。当父加载器已经加载了某个类时,子加载器就没有必要再加载一次。
总结来说,双亲委派模型就是一种层次化的、以“优先委派给父加载器”为原则的类加载机制。它通过这种自底向上再向下的委托链,有效地保障了Java程序的稳定运行和核心库的安全。
1. 内存位置与实现方式PermGen 是 Java 虚拟机(JVM)堆内存的一部分,用于存储类元数据、静态变量和常量池等数据,其大小受堆内存配置参数(如 -XX:MaxPermSize)限制。而 Metaspace 是 Java 8 引入的替代方案,它使用本地内存(Native Memory)而非堆内存,其容量受系统可用物理内存限制(默认无上限),可通过 -XX:MaxMetaspaceSize 参数设置上限。
2. 内存管理机制PermGen 的内存管理由 JVM 的垃圾回收器负责,但回收效率较低,容易因类加载器泄漏或过多类信息导致内存溢出(OutOfMemoryError: PermGen space)。Metaspace 的内存管理由本地内存系统处理,并引入了更高效的元数据垃圾回收机制(与类加载器关联),当类加载器不再存活时会自动回收其元数据,减少了内存泄漏风险。
3. 默认大小与调优参数PermGen 有固定的初始大小和最大大小(默认依赖于 JVM 实现),需通过 -XX:PermSize 和 -XX:MaxPermSize 手动调整。Metaspace 默认不设上限(仅受系统内存限制),但可通过 -XX:MetaspaceSize(初始大小)和 -XX:MaxMetaspaceSize(最大大小)控制,避免过度占用系统内存。
4. 出现版本与兼容性PermGen 存在于 Java 7 及早期版本中,在 Java 8 被彻底移除。Metaspace 从 Java 8 开始引入,完全替代了 PermGen 的功能,同时优化了动态类加载和卸载的性能。
5. 内存溢出表现PermGen 内存不足时会抛出 "java.lang.OutOfMemoryError: PermGen space"。Metaspace 内存不足时则抛出 "java.lang.OutOfMemoryError: Metaspace",且通常与未限制本地内存或类加载器泄漏相关。
总结:Metaspace 通过使用本地内存、改进垃圾回收机制和自动扩展能力,解决了 PermGen 容易内存溢出、调优困难的问题,提升了 JVM 对元数据管理的灵活性和可靠性。
-Xmx
和 -Xms
都是 Java 虚拟机(JVM)用来配置堆内存(Heap Memory)大小的关键参数。
-Xmx 的作用:-Xmx
参数用来设置 JVM 堆内存的最大可用大小。它的值是堆内存的上限,JVM 在运行过程中使用的堆内存总量不能超过这个设定值。例如,使用 -Xmx2g
表示将最大堆内存设置为 2 GB。如果应用程序需要更多内存而超出了这个限制,JVM 就会抛出 OutOfMemoryError
错误。
-Xms 的作用:-Xms
参数用来设置 JVM 堆内存的初始大小,也就是 JVM 启动时立即分配的堆内存量。例如,使用 -Xms512m
表示 JVM 一开始就会分配 512 MB 的堆内存。设置一个合理的初始值可以减少 JVM 在运行时动态调整堆大小所带来的性能开销。
两者的主要区别与联系:简单来说,-Xms
是堆内存的“起步价”或初始容量,而 -Xmx
是堆内存的“最大容量”或上限。在程序运行过程中,JVM 的垃圾回收机制会在这两个值之间动态调整堆的实际大小,以适应程序的内存需求。将 -Xms
和 -Xmx
设置为相同的值可以避免运行时动态调整,从而在某些场景下减少性能波动,适用于对性能稳定性要求较高的系统。
总结:-Xmx
决定了你的应用程序最多能使用多少内存,而 -Xms
决定了启动时立即获得多少内存。合理配置这两个参数对于优化 Java 应用的性能和稳定性非常重要。
常见的 OutOfMemoryError 类型主要有以下几种:
第一种是 Java heap space,也就是 Java 堆空间溢出。这是最常见的一种,发生在应用程序创建了太多新对象,而垃圾回收器又无法回收足够内存来容纳新对象时。通常由内存泄漏或堆大小设置不合理导致。
第二种是 Metaspace,元空间溢出。在 Java 8 及以后版本中,它取代了永久代(PermGen),用于存储类的元数据信息。当加载的类数量过多,或者动态生成大量类时,就可能耗尽元空间分配的内存。
第三种是 Unable to create new native thread,无法创建新的本地线程。这通常发生在应用进程试图创建过多线程,而超出了操作系统对单个进程线程数的限制,或者系统内存本身不足时。
第四种是 GC overhead limit exceeded,GC 开销超过限制。这是一种特殊的错误,表示虚拟机花费了绝大部分时间在进行垃圾回收,但只能回收很少的堆空间,几乎等于内存泄漏。
第五种是 Requested array size exceeds VM limit,申请的数组大小超过虚拟机限制。当程序尝试分配一个大于堆大小的数组,或者接近平台规定的最大正整数限制时,就会抛出此错误。
第六种是 Direct buffer memory,直接内存溢出。在使用 NIO 的 DirectByteBuffer 分配堆外内存时,如果分配的量过大且得不到及时回收,就会引发此错误。
第七种是 Kill process or sacrifice child。这并非标准的 JVM 错误,而是一些基于 Linux 的操作系统在内存极度紧张时,通过 OOM Killer 机制终止进程的行为。
这些是主要的 OutOfMemoryError 类型,每种都对应着不同的内存区域和产生原因。
内存溢出,即 OutOfMemoryError,是 Java 程序运行时可能遇到的一种严重错误。它属于 java.lang.Error
的子类,表示应用程序已经耗尽了其可用的内存资源,并且垃圾回收器也无法再释放出更多的可用内存,导致 Java 虚拟机(JVM)无法继续执行程序。
简单来说,你可以把 JVM 的内存空间想象成一个固定大小的水杯,而程序运行中创建的对象就是不断倒入杯中的水。当水(对象)倒得太多,超过了水杯(内存空间)的容量,水就会溢出来。这个“水溢出来”的现象,在程序中就是发生了 OutOfMemoryError。
导致内存溢出的核心原因主要有以下几点:
内存泄漏(Memory Leak): 这是最常见的原因。指程序中某些对象已经不再被使用(即程序逻辑上不会再访问它们),但由于代码编写问题(例如,被静态集合错误地引用、未关闭的连接等),垃圾回收器(Garbage Collector, GC)无法识别并回收这些对象。这些无用对象会持续占用内存空间,久而久之,可用内存越来越少,最终导致内存溢出。
内存设置过小: 分配给 JVM 的堆内存(-Xmx 参数)本身设置得太小,不足以支撑应用程序的正常运行。例如,一个需要处理大量数据的程序,只分配了 256MB 的堆内存,就很容易因为内存不足而溢出。
处理的数据量过大: 应用程序在某一时刻需要加载或处理非常庞大的数据量(例如,一次从数据库查询出百万条记录并全部加载到内存中的集合里),即使没有内存泄漏,也可能瞬间耗尽所有内存。
代码问题: 编写了无限循环创建对象的代码,或者存在导致对象数量急剧增长的递归调用等。
发生内存溢出时,程序会立即终止(Crash),因为它是一个无法由应用程序处理的严重错误。
要解决和排查内存溢出问题,通常需要结合错误日志(它会指出是哪种内存区域发生了溢出,如 Java heap space)、使用内存分析工具(如 Eclipse MAT, jvisualvm)来生成和分析堆转储文件(Heap Dump),从而找到那些占用内存最多且不再使用的对象,定位到导致问题的源代码。
总而言之,内存溢出就是程序申请的内存超过了 JVM 能提供的最大限制,导致程序无法继续运行而崩溃的错误。
什么是内存泄漏?
内存泄漏(Memory Leak)指的是在程序运行过程中,由于某些原因,程序中已动态分配的内存(在Java中即对象占用的堆内存)未能被释放或无法被释放,造成系统内存的浪费。
通俗地讲,可以把它想象成:
你的程序向系统“借”了一块内存来存放一个对象。
当你不再需要这个对象时,你“忘记”告诉系统可以收回这块内存了。
于是,这块内存一直被这个毫无用处的对象占着,导致可用内存越来越少。
如果这种情况持续发生,最终可能会耗尽所有可用内存,导致程序性能下降,甚至抛出
OutOfMemoryError
错误,使程序崩溃。
需要特别注意的是,Java拥有垃圾回收(Garbage Collection, GC) 机制,它会自动回收不再被引用的对象。因此,Java中的内存泄漏并非指对象完全无法被回收,而更常见的是指对象在逻辑上已经不再使用,但由于意外的引用存在,导致垃圾回收器无法回收它们。这是一种更隐蔽的“无意识的对象保持”。
Java 中如何出现内存泄漏?
尽管有垃圾回收机制,Java中仍然可能因为程序设计不当而发生内存泄漏。以下是几种常见的情况:
第一,静态集合类引起的内存泄漏。静态变量的生命周期与应用程序的生命周期一致,它们所引用的所有对象在整个程序运行期间都不会被释放。如果你向一个静态的集合类(如HashMap, ArrayList等)不断添加对象,并且在使用完后没有相应地移除它们,这些对象就会一直占用内存,造成泄漏。这是最常见的一种情况。
第二,未关闭的资源连接。数据库连接(Connection)、网络连接(Socket)、文件流(FileStream)等资源不仅占用内存,还占用系统资源。如果在使用后没有显式地调用 close()
方法将其关闭,这些连接对象就不会被及时回收,从而导致内存泄漏甚至系统资源耗尽。
第三,监听器和回调未注销。在很多图形界面程序(如Swing)或事件驱动框架中,你需要注册监听器(Listener)。如果你在释放一个对象时,没有注销它之前注册的监听器,那么事件源会一直持有该监听器的引用,导致这个对象无法被垃圾回收。
第四,内部类持有外部类的引用。非静态内部类(包括匿名内部类)会隐式地持有其外部类的引用。如果你创建了一个内部类对象(如一个线程或一个监听器)并将其长期存在(例如交给一个静态变量或另一个长生命周期线程),那么即使外部类实例本身已经不再需要,它也会因为这个内部类的持有而无法被回收。
第五,修改对象的哈希值后仍将其存放在HashSet或HashMap中。如果一个对象被存入基于哈希的集合(如HashSet)后,程序修改了参与计算哈希值(hashCode)的字段,那么后续调用 map.get(key)
或 set.contains(obj)
方法时将无法再找到这个对象。因为这个对象被存放在了一个基于旧哈希值计算出的位置,而查找时使用的是新哈希值。然而,这个对象却真实地存在于集合中,集合一直持有它的引用,导致它无法被回收,同时你也无法访问和移除它。这会造成严重的内存泄漏。
第六,ThreadLocal使用不当。ThreadLocal变量为每个线程提供了独立的变量副本。但如果使用线程池,线程是会复用的,其生命周期很长。如果你将一个大对象set进ThreadLocal后没有及时调用 remove()
方法进行清理,那么这个大对象就会一直与该线程关联,只要线程存活(即使任务早已完成),该对象就不会被回收,造成内存泄漏。
综上所述,Java中的内存泄漏根源通常在于长生命周期对象无意中持有了短生命周期对象的引用,阻止了垃圾回收器对后者的正常工作。避免内存泄漏的关键在于养成良好的编程习惯,例如及时释放资源、注销监听器、在适当的时候将对象引用置为null、以及谨慎使用长生命周期的集合等。
Java 中的可达性分析算法是垃圾收集器(Garbage Collector, GC)用来判断对象是否存活的核心算法。其核心思想是:通过一系列称为“GC Roots”的根对象作为起始点,从这些根节点开始向下搜索,所走过的路径称为“引用链”(Reference Chain)。如果一个对象到 GC Roots 之间没有任何引用链相连,或者说从 GC Roots 到这个对象不可达时,则证明此对象是不再被使用的,可以被判定为可回收的内存垃圾。
这个算法的关键在于两个部分:GC Roots 和 遍历搜索过程。
一、 GC Roots(根对象集合)
GC Roots 是一些绝对不能被当做垃圾回收的、非常稳定的对象,它们通常包括以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象:例如,当前正在运行的各个线程方法中的参数、局部变量、临时变量等所引用的对象。
方法区中静态属性引用的对象:也就是 Java 类的静态变量(被 static 修饰的变量)。
方法区中常量引用的对象:比如字符串常量池(String Table)里的引用,或者被 final 修饰的常量。
本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
Java 虚拟机内部的引用:如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError),还有系统类加载器。
所有被同步锁(synchronized 关键字)持有的对象。
反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
二、 遍历搜索过程
垃圾收集器会从这些 GC Roots 开始,逐个遍历它们所引用的所有对象。每找到一个对象,就将其标记为“存活”。然后,再以这些新找到的存活对象为新的起点,继续遍历它们所引用的对象。这个过程会一直重复,直到所有从 GC Roots 可达的对象都被访问并标记完毕。最终,那些在整个遍历过程中都没有被标记到的对象,就是不可达的对象,也就是可以被安全回收的垃圾。
一个简单的比喻:想象 GC Roots 是一棵大树的几棵主根。从这些主根开始,可以长出很多侧根和须根(引用链),最终连接到树叶(对象)。可达性分析算法就是检查每一片树叶是否至少有一条路径能回溯到主根。如果某片树叶和任何主根都断了联系(没有路径相连),那么这片树叶就已经枯萎死亡,可以被清理掉了。
重要补充:对象的不同可达性级别
Java 还定义了不同级别的可达性,这影响了对象被回收的优先级,从强到弱依次是:
强可达(Strongly Reachable):对象可以直接从 GC Roots 到达,这是最常见的状态。
软可达(Softly Reachable):对象不是强可达的,但可以通过软引用(SoftReference)找到。这类对象只有在内存不足时(即将发生 OutOfMemoryError 之前)才会被垃圾收集器回收。
弱可达(Weakly Reachable):对象不是强可达也不是软可达,但可以通过弱引用(WeakReference)找到。无论当前内存是否充足,下一次垃圾收集发生时,弱引用对象都会被回收。
虚可达(Phantomly Reachable):对象不是以上任何一种,并且已经被标记为可回收的最终状态,只能通过虚引用(PhantomReference)找到。虚引用的唯一作用是在对象被回收时收到一个系统通知。
不可达(Unreachable):意味着对象可以被回收了。
总结来说,可达性分析算法是 Java 自动内存管理的基石。它通过建立以 GC Roots 为起点的引用链图,高效且准确地识别出所有存活对象,从而确保不再使用的内存空间能够被安全地回收,避免了内存泄漏。