Java八股文——JVM「类加载篇」

发布于:2025-06-12 ⋅ 阅读:(26) ⋅ 点赞:(0)

创建对象的过程?

img

面试官您好,当我们在Java代码中写下new MyObject()时,这背后其实是JVM在进行一系列精密而有序的操作。我们可以把这个过程比喻成 “盖一栋精装修的房子”

这个“盖房子”的过程,可以分为以下几个关键步骤:

第一步:规划审批 (类加载检查)
  • JVM的工作:当虚拟机遇到一条new指令时,它首先会像一个规划局,去检查这个“建筑蓝图”(MyObject类)是否合法、是否已经被备案。
    • 它会去运行时常量池中查找,看能否定位到这个类的符号引用。
    • 并检查这个类是否已经被加载、解析和初始化过。
  • 结果:如果没有,JVM就必须先执行完整的类加载过程,把“建筑蓝图”准备好。如果一切手续齐全,就批准“施工”。
第二步:划分地皮 (分配内存)
  • JVM的工作:蓝图确定后,就需要为“房子”划分一块大小合适的地皮。对象所需内存的大小,在类加载完成后就已经完全确定了。JVM会在Java堆中为这个新对象划分出一块内存。
  • 分配方式:划分地皮主要有两种方式,取决于堆内存是否规整:
    • 指针碰撞 (Bump the Pointer):如果Java堆是绝对规整的(比如使用带压缩整理功能的GC算法),JVM只需把一个“已用内存”和“空闲内存”之间的分界指针,向空闲那边移动一段与对象大小相等的距离即可。
    • 空闲列表 (Free List):如果Java堆是不规整的(比如使用标记-清除算法后,会产生内存碎片),JVM就必须维护一个“空闲地块列表”,记录了哪些内存块是可用的。分配时,就需要从这个列表中找到一块足够大的空间划分给新对象。
第三步:毛坯房建设 (初始化零值)
  • JVM的工作:地皮划分好后,就要开始建“毛坯房”了。JVM会将分配到的内存空间(不包括对象头)都初始化为零值(比如int为0, booleanfalse, 引用类型为null)。
  • 作用:这一步至关重要,它保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序访问到的是它们对应数据类型的零值。
第四步:装修与上门牌号 (设置对象头)
  • JVM的工作:毛坯房建好后,需要进行一些基础“装修”和信息登记。JVM会对对象进行必要的设置,这些信息都存放在对象头(Object Header) 中。
  • 内容
    • 这个对象是哪个类的实例(指向其类元数据的指针)。
    • 对象的哈希码
    • 对象的GC分代年龄
    • 锁状态标志(如是否为偏向锁等)。
第五步:精装修与拎包入住 (执行<init>方法)
  • JVM vs. Java的视角:到上一步为止,从JVM的视角看,一个对象已经诞生了。但在Java程序的视角看来,它还只是一个“毛坯房”,不能使用。
  • 执行<init>():接下来,JVM会执行对象的构造方法 (<init>()方法)。这个过程才是真正的“精装修”。
    • 在这里,我们会按照程序员的意图,为对象的成员变量进行显式地赋值、调用父类的构造器、执行构造代码块等。
  • 最终完成:当<init>()方法执行完毕后,一个真正可用的、被完整初始化的对象才算完全创建成功,可以“拎包入住”了。

通过这五个步骤,一个Java对象才算完成了它从无到有的整个创建过程。

对象的生命周期

面试官您好,一个Java对象的生命周期,从它诞生到消亡,可以看作是一段在JVM内存中“旅行”的完整旅程。这个旅程,我通常会把它细分为以下七个阶段

1. 创建阶段 (Creation)
  • 当程序遇到new指令时,对象的生命周期开始。这个阶段包含了我们之前讨论的完整的对象创建过程:类加载检查、内存分配、零值初始化、设置对象头、执行<init>()构造方法。
  • 结果:一个被完整初始化的对象在堆内存中诞生,并且有一个或多个强引用指向它。
2. 应用阶段 (In Use / Reachable)
  • 这是对象生命周期中最主要的阶段。只要对象还存在至少一个强引用链能够从GC Roots(比如虚拟机栈中的引用、静态变量等)触及到它,它就处于可达状态
  • 在这个阶段,对象可以被程序正常地使用,调用其方法、访问其字段。
3. 不可见阶段 (Invisible)
  • 这个阶段比较特殊,指的是对象的作用域已经结束,但在程序逻辑上,它仍然被强引用着。
  • 举例:一个方法内创建的对象,在方法即将返回时,这个对象对于后续代码来说已经是“不可见”和“无用”的了,但它的强引用可能要等到方法完全退出、栈帧销毁时才消失。JIT编译器可能会在这里进行优化,提前让它变得可回收。
4. 不可达阶段 (Unreachable)
  • 没有任何强引用从GC Roots指向这个对象时,它就进入了不可达状态。
  • 此时,它就成为了垃圾回收器(GC)眼中的“垃圾”,成为了被回收的候选者。但注意,此时它还没有被真正回收。
5. 收集阶段 (Collected / Finalization)
  • 当GC开始工作,并扫描到这个不可达的对象时,它会面临一个“命运的审判”。
  • 审判内容:GC会判断这个对象是否需要执行finalize()方法。
    • 如果对象没有重写finalize()方法,或者finalize()已经被调用过一次,那么它就会被直接标记为“待回收”。
    • 如果对象重写了finalize()并且还未被执行过,GC会将这个对象放入一个名为F-Queue的队列中,并由一个低优先级的“Finalizer”线程去稍后调用它的finalize()方法。
  • finalize()的“自我拯救”机会:在finalize()方法中,对象有一次“复活”的机会。比如,它可以在方法里把自己重新赋值给一个静态变量,从而重新建立起与GC Roots的强引用。如果“拯救”成功,它在下一次GC扫描时就会变回可达状态。
  • 注意finalize()已被官方废弃(deprecated),因为它存在性能问题和不确定性,我们应该完全避免使用它。
6. 终结阶段 (Finalized)
  • 如果对象在finalize()方法中没有“自我拯救”,那么它就会被GC正式标记为“待回收”。它已经走到了生命的尽头,等待内存被清理。
7. 销毁阶段 (Deallocated)
  • 当GC执行清理操作时,这个对象的内存空间会被正式回收重用。对象在物理上彻底从内存中消失,其生命周期完全结束。

总结一下,这个七阶段模型,更细致地描绘了一个对象从被new出来,到被使用,再到失去引用,最终被GC一步步判断、标记和清理的全过程。理解这个过程,对于我们编写内存高效的代码、排查内存泄漏问题非常有帮助。


类加载器有哪些?

面试官您好,Java的类加载器(Class Loader)是JVM实现“动态加载类”这一核心功能的基础。它的主要职责就是根据类的全限定名,找到对应的.class文件,并将其字节码加载到内存中,最终转换成JVM内部可以使用的java.lang.Class对象。

在Java中,类加载器主要分为以下几种,它们形成了一个有层次的、类似“父子关系”的结构:

1. 启动类加载器 (Bootstrap Class Loader) —— “JVM的内核”
  • 职责:这是最顶层的、最核心的加载器。它负责加载Java的核心基础库,也就是存放在<JAVA_HOME>\lib目录下的,或者被-Xbootclasspath参数所指定的路径中的类库(比如我们最熟悉的rt.jar,包含了java.lang.*, java.util.*等)。
  • 实现:它不是由Java语言实现的,而是由C++编写,是内嵌在JVM虚拟机自身的一部分。
  • 特殊性:正因为它是JVM的一部分,所以在Java代码中,我们无法直接获取到它的引用。当我们尝试获取它的父加载器时,通常会返回null
2. 扩展类加载器 (Extension Class Loader) —— “官方插件加载器”
  • 职责:它负责加载Java的扩展库,通常是存放在<JAVA_HOME>\lib\ext目录下的,或者被java.ext.dirs系统变量所指定的路径中的类库。
  • 实现:它是由Java语言实现的(sun.misc.Launcher$ExtClassLoader),是ClassLoader的子类。
  • 关系:它的父加载器是启动类加载器
3. 应用程序类加载器 (Application Class Loader) —— “我们最亲密的伙伴”
  • 职责:这个也叫系统类加载器(System Class Loader)。它负责加载我们自己编写的Java应用程序,也就是用户类路径(Classpath) 上所指定的类。我们日常开发中写的绝大多数类,都是由它来加载的。
  • 实现:它也是由Java语言实现的(sun.misc.Launcher$AppClassLoader)。
  • 关系:它的父加载器是扩展类加载器。在Java程序中,我们可以通过ClassLoader.getSystemClassLoader()来获取到它。
4. 自定义类加载器 (Custom Class Loader) —— “强大的扩展能力”
  • 职责:除了JVM自带的这三种,Java还允许开发者通过继承java.lang.ClassLoader,来创建自己的类加载器。
  • 作用:这提供了极大的灵活性,使得我们可以实现一些高级功能,比如:
    • 从网络加载类:实现远程代码的动态执行。
    • 加密与解密:加载加密后的.class文件,在加载时进行解密。
    • 热部署与热加载:实现代码的动态更新和替换,这是很多Web服务器和框架(如Tomcat)实现热部署的基础。

它们之间的协作机制:双亲委派模型 (Parents Delegation Model)

这些加载器并不是各自为战的,它们通过一个非常重要的 “双亲委派模型” 来协同工作。这个模型,其核心思想是 “向上委托,向下查找”

  • 一个生动的比喻
    • 应用程序类加载器接到一个加载任务(比如加载java.lang.String),它就像一个“村干部”,它会先想:“这事儿我能自己干吗?不行,得上报给上级!”
    • 于是它把任务委托给它的父加载器——扩展类加载器(“乡镇干部”)。
    • “乡镇干部”接到任务,也想:“这事儿我先不上手,得上报给市里!” 于是它又把任务委托给它的父加载器——启动类加载器(“市领导”)。
    • 启动类加载器是最高领导,它没法再上报了,于是它就亲自在自己的管辖范围(核心库)里查找。它一看,“哦,java.lang.String是我负责的!” 于是它就加载了,并成功返回。
    • 如果启动类加载器发现这个类不归它管(比如是我们自己写的com.mycompany.MyClass),它就会告诉下级:“这事儿我管不了,你们自己看着办。”
    • 扩展类加载器收到“批示”后,就在自己的管辖范围(扩展库)里查找,发现也找不到,于是也告诉下级:“我也管不了。”
    • 最后,应用程序类加载器收到“批示”,它才会在自己的管辖范围(Classpath)里查找,最终找到了并加载成功。

双亲委派模型的好处

  1. 避免类的重复加载:确保了同一个类只会被一个加载器加载一次。
  2. 保证Java核心库的安全性:防止了核心API被恶意篡改。比如,我们自己写一个java.lang.String类,是永远无法被加载的,因为加载请求最终会委托给启动类加载器,它会加载JDK自带的那个String类,从而保证了核心类的安全。

双亲委派模型的作用

面试官您好,双亲委派模型是Java类加载机制的灵魂,它的设计核心思想是 “优先由上层、可信的加载器来加载,保证核心类的稳定与安全”

这个模型带来的好处是多方面的,但我认为最重要的可以归结为以下两点:

1. 保证Java核心库的绝对安全,防止核心API被篡改

这是双亲委派模型最根本、最重要的作用。

  • 工作机制:它通过一种“向上委托”的机制,保证了所有对Java核心API(如java.lang.String, java.lang.Object)的加载请求,最终都一定会委托给最顶层的启动类加载器(Bootstrap Class Loader) 去完成。
  • 一个经典的“反例”来说明其重要性
    • 假设没有双亲委派模型,那么一个恶意开发者就可以自己编写一个java.lang.String类,并在其中植入恶意代码(比如窃取信息、破坏系统等)。
    • 然后,他可以通过自定义类加载器来加载这个“伪造”的String类。如果成功了,那么整个JVM中所有用到String的地方,都会被这个恶意版本所污染,后果不堪设想。
    • 有了双亲委派模型,这个阴谋就无法得逞。当自定义类加载器收到加载java.lang.String的请求时,它会首先向上委托。这个请求会一路传递到启动类加载器。启动类加载器会在自己的管辖范围(rt.jar)中找到并加载官方的、安全的String类。因为上级已经成功加载了,所以下级的加载器就再也没有机会去加载那个“伪造”的版本了。
  • 结论:双亲委派模型就像一个忠诚的“安保系统”,它确保了无论如何,Java的核心类库都只能由最可信的启动类加载器来加载,从而保护了整个Java生态的根基。
2. 避免类的重复加载,保证类的唯一性
  • 工作机制:由于所有的加载请求都会先尝试由上层加载器处理,这就保证了如果一个类已经被某个父加载器加载过了,子加载器就不会再重复加载一次。
  • 带来的好处
    • 这保证了在JVM中,对于同一个全限定名的类,只会存在一个对应的java.lang.Class对象。
    • 这一点对于Java程序的正确性至关重要。如果同一个类被加载了多次,那么instanceof关键字、类型转换等操作都会因为“类型不匹配”而出错,即使它们的类名完全一样。比如,一个由AppClassLoader加载的MyObject实例,和一个由自定义加载器加载的MyObject实例,在JVM看来是两种完全不同的类型。
补充:为什么要打破双亲委派模型?

虽然双亲委派模型非常重要,但在某些特定的场景下,也需要被“打破”,以实现更灵活的功能。

  • 典型场景
    • JDBC的DriverManager:Java核心库(由启动类加载器加载)需要调用由应用程序(由应用程序类加载器加载)提供的各个数据库厂商的驱动实现。这是一种“由上层调用下层”的场景,与双亲委派的“由下层委托上层”方向相反。它通过线程上下文类加载器(Thread Context ClassLoader) 来打破委派,实现了逆向加载。
    • Tomcat等Web容器:为了实现不同Web应用之间的类隔离,每个Web应用都有自己的类加载器。它们会优先加载自己应用目录下的类,而不是先委托给父加载器,从而打破了委派。
    • OSGi等热部署技术:为了实现模块的热插拔和动态更新,也需要更复杂的、非双亲委派的类加载机制。

总结一下,双亲委派模型通过一种简单而优雅的“向上委托”机制,为Java平台提供了坚实的安全保障和类的唯一性保证。同时,Java也保留了“打破”它的灵活性,以适应更复杂的应用场景。


讲一下类加载过程?

面试官您好,一个Java类从.class文件,到最终被JVM使用并创建出对象,需要经历一个完整的类加载过程。这个过程,根据Java虚拟机规范,主要可以分为以下五个大的阶段加载、验证、准备、解析和初始化

其中,验证、准备、解析这三个阶段又可以统称为链接(Linking)

下面我来详细讲解一下每个阶段都做了什么。

第一阶段:加载 (Loading)

这是整个类加载过程的“开端”。在这个阶段,JVM的主要任务是:

  1. 通过一个类的全限定名(比如com.example.MyClass),找到并获取定义这个类的二进制字节流(通常是从.class文件中读取)。这个动作是由类加载器(Class Loader) 来完成的。
  2. 将这个字节流所代表的静态存储结构,转换成方法区中的运行时数据结构
  3. 在内存中(具体是在中),生成一个代表这个类的java.lang.Class对象。这个Class对象,将作为程序访问方法区中该类各种数据的外部接口。

简单来说,“加载”阶段就是找到.class文件,并把它读到内存里

第二阶段:链接 (Linking)

链接阶段是将加载进来的二进制数据,合并到JVM的运行时状态中去的过程。

2.1 验证 (Verification)

  • 目的:这是为了确保被加载的.class文件的字节流中包含的信息,是符合当前虚拟机要求的,并且不会危害虚拟机自身的安全
  • 做什么:这个阶段会进行非常复杂的校验,比如:
    • 文件格式验证:检查是否以魔数0xCAFEBABE开头,主次版本号是否在当前虚拟机处理范围之内等。
    • 元数据验证:对字节码描述的信息进行语义分析,确保其符合Java语言规范,比如这个类是否有父类(除了Object)、是否继承了final类等。
    • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这是最复杂的一个步骤。
    • 符号引用验证:确保解析行为能正常执行。

2.2 准备 (Preparation)

  • 目的:为类的静态变量(static fields)分配内存,并设置其初始零值
  • 做什么
    • 注意,这里是设置数据类型的零值,而不是代码中显式赋的初始值。比如 private static int value = 123;,在准备阶段,value会被设置为0,而不是123
    • value赋值为123putstatic指令,是存放在类的构造器<clinit>()方法中的,这要到初始化阶段才会执行。
    • 特例:如果一个静态变量是final的常量(public static final int CONSTANT = 123;),并且它的值在编译期就能确定,那么在准备阶段,它就会被直接赋值为123

2.3 解析 (Resolution)

  • 目的:将常量池内的符号引用,替换为直接引用的过程。
  • 什么是符号引用? 以字符串形式表示的、能唯一识别目标的引用,比如类名、方法名、字段名等。
  • 什么是直接引用? 可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
  • 做什么:比如,当代码需要调用一个方法时,它在字节码中只是一个符号引用。在解析阶段,JVM会根据这个符号,找到这个方法在内存中的真实地址,并把符号引用替换成这个地址。
第三阶段:初始化 (Initialization)

这是类加载过程的最后一步,也是真正开始执行我们自己编写的Java代码的阶段。

  • 做什么:这个阶段主要是执行类的构造器方法 <clinit>()

  • <clinit>()方法是什么?

    • 它不是我们手写的,而是由编译器自动收集类中所有静态变量的赋值动作静态代码块(static {}块) 中的语句,合并产生的。
    • 编译器会按照它们在源文件中出现的顺序来收集。
  • 例子

    public class MyClass {
        private static int a = 1; // 赋值动作1
        static {
            b = 20; // 赋值动作2
        }
        private static int b = 2; // 赋值动作3
    }
    

    编译器生成的<clinit>()方法会依次执行:a=1, b=20, b=2。所以最终b的值是2。

  • JVM会保证<clinit>()方法在多线程环境下的同步性。即,一个类的<clinit>()方法只会被一个线程执行一次。

当初始化阶段完成后,这个类才算真正地准备就绪,可以被程序使用了。

总结一下,整个类加载过程就像一个“毛坯房精装修”的过程:加载是把建筑材料(.class文件)运到工地;链接(验证、准备、解析)是搭建框架、铺设水电(分配内存、建立引用);最后的初始化,才是按照我们的设计图(代码逻辑)进行精装修,让这个“房子”可以真正地使用。

参考小林coding和JavaGuide