面试官问:简单描述一下一个类被加载,创建对象,到调用对象方法被执行的流程。
背八股文的都知道:知识点是类的生命周期,以及对象的创建过程🐴。
但是你有真正理解基于JVM的内存结构,一个类的生命周期吗?这次让你不再只听过没见过了,只有见过了才会记忆犹新😁。
为了不让回答太干吧,首先脑回路可以想一下思路:
- jvm 内存结构
- 类的生命周期
- 对象的创建过程
- 额外补充创建对象的几种方式。
一、JVM内存结构
JVM 内存结构也是java的一种规范,需要注意的是要和JMM(java内存模型)区分。JVM的内存结构,也叫运行时区域,如图所示,主要有以下几个部分:
- 方法区(Method Area): 也称为类区,这里存储了每个类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容以及一些特别方法(如初始化器)的字节码内容。在JVM规范中,方法区也经常被称为非堆(Non-Heap)的一部分。
- 堆(Heap): 这是JVM内存中的主要工作区域,它被所有线程共享。堆主要用于存放对象实例和数组。堆的大小可以动态调整,GC(垃圾收集器)主要作用的区域就是堆。堆内存进一步可以分为年轻代(Young Generation)、老年代(Old or Tenured Generation)以及在某些情况下的持久代(Permanent Generation,但在HotSpot JVM中已被元空间Metaspace替代)。
- 虚拟机栈(Stack Area): 每个线程运行时都会创建自己的线程栈。这块区域存放局部变量、操作数栈、方法调用和返回地址。每个方法调用都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接信息和方法返回时需要的信息。栈内存是线程私有的。
- 程序计数器(Program Counter Register): 这是一小块内存区域,它可以看作是当前线程所执行的字节码的行号指示器。在任意时刻,每个线程都要执行一个方法,即使是一个本地的(Native)方法,程序计数器会正常运行,每个线程都有自己独立的程序计数器,称为"线程隔离"(Thread-Local Storage)。
- 本地方法栈(Native Method Stack): 和Java栈类似,本地方法栈服务于本地方法调用(即非Java方法调用)。本地方法一般指的是用其他语言(如C或者C++)编写的方法。当某个线程调用一个本地方法时,它会在本地方法栈中登记该方法的状态。
一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其类加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成汇编代码(操作系统相关的函数)。执行引擎在需要执行某个方法的时候,将对应方法区的函数指令放到CPU的高速缓存,CPU直接读取然后运行指令。程序计数器会记录执行指令的地址的行号。
过程如下:Java 文件->编译器>字节码->JVM->机器码->CPU。
JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。 我们平常说的JVM其实更多说的是HotSpot,还有其他的TaobaoVM(淘宝有自己的VM,它实际上是Hotspot的定制版),LiquidVM(针对硬件的虚拟机,它下面是没有操作系统的,运行效率比较高),毕昇JDK(华为内部OpenJDK定制版Huawei JDK的开源版本(gitee.com/openeuler/b...)。
二、类的生命周期
字节码转换为机器码是Java虚拟机(JVM)的执行引擎(Execution Engine)的功能之一,即解释(AOT)执行或者即时编译(JIT)执行,此外还支持类加载(Class Loading),指令优化,垃圾回收等。类的生命周期也贯穿其中,而Java类的生命周期指的是从类被加载到Java虚拟机(JVM)中开始,到类被卸载出虚拟机为止的过程。这个生命周期包括以下几个主要阶段:
1.类加载
类加载过程也就是JVM执行引擎通过类加载器读取字节码文件的过程。通常类加载器有如下图所示:
在Android的源码中art/runtime/class_linker.cc,我看看类怎么加载的。源码链接
这个klass
对象是什么?可以理解为加载一个类以后,生成这个类信息的对象,每个类的字节码对应一个。当示例一个对象的时候,这个类信息也会一直存在在这个对象中。
2.链接
类加载完成以后开始链接阶段,这阶段包含验证,准备,解析:
- 验证(Verification): 确保加载的类符合JVM规范,不会造成安全失败。
- 准备(Preparation): 为类变量分配内存,并设置默认初始值。
- 解析(Resolution): 将类、方法、属性的符号引用转换为直接引用。 在上面DefineClass方法里,LoadClass执行完成就有一个LinkClass方法。
3.初始化
- 执行类构造器
clinit()
方法的过程,这个方法由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}
块)中的语句合并产生。 - 父类会先于子类初始化,静态变量按照声明顺序初始化。
- 一个类只会被初始化一次。
执行引擎会调用klass的初始化方法,openjdk源码链接
new 关键字对应的方法,会先找到Klass,然后对类进行类的初始化。
这个方法包含以下几个步骤:
- 分配类变量并进行默认初始化;
- 执行静态语句块中的语句,包括赋值和执行方法调用;
- 解析和链接类中的所有相关的符号引用;
- 把符号引用转换为直接引用;
- 对类变量进行显示初始化;
- 最后执行其他的类的
<clinit>()
方法
4.使用
创建类的实例对象、调用类的方法、或访问类的变量等活动。这个日常开发写的代码都是怎么使用类。就不赘述了。
5.卸载
当满足一个类的所有实例对象都已经被回收,并且class对象klass没有被引用的地方条件下,类才会被卸载,比如该类的ClassLoader被回收,并且此类的所有实例都已经被回收等情况下,JVM会认为该类已经不再需要了,类会被卸载。不过这个条件相对比较难以达到。
三、对象中的类信息klass
当我们实例化一个类的对象的时候,会给对象添加一个klass对象的引用(Android SDK 21以上),klass携带类的全部信息。如图所示:
如图是一个没有成员变量和方法的类,展开发现是一些threadId,classLoader,classsize,objectsize等状态信息。此时发现classSize的大小是224(可能不同Android版本会有不同的大小)
当我加一个方法的时候,增加到232,说明一个方法引用大小为8个字节。 此外我们还发现了一个shadow$_monitor_的字段。我们查看Object的源码
看注释的说明已经很明白了,klass
是Class类信息,monitor_
是关于Monitor和hashcode信息,Object的java源码中的只有在hashCode有使用shadow$_monitor_
,如图:
identityHashCodeNative是一个native方法,我们查看Android源码,结合java代码和c代码,hashcode应该有5种条件生成,所以获取对象的hashCode()
的时候,明白他不代表对象的地址了。
如果想知道Monitor是什么?关注我,我马上安排 @~@ 😄😄😄。
四、从字节码看一个对象的创建
下面方法是简单的创一个对象:
java
public static void fun2() {
Object object = new Object();
}
转换成java字节码指令集:
yaml
0: new #3 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_0
8: return
new 表示方法调用,dup表示复制到作用句柄,invokespecial表示调用构造方法 执行引擎会调用klass的初始化方法,源码链接
new 关键字对应的方法,会先找到Klass,然后对类进行类的初始化,然后申请堆内存实例对象。
Android中字节码是dex的字节码,同样转换成指令集看看:
dart
0000: new-instance v0, java.lang.Object // type@0003
0002: invoke-direct {v0}, java.lang.Object.<init>:()V // method@0005
0005: return-void
new-instance创建一个新的实例,invoke-direct直接调用,我们找到art执行引擎运行时的对应方法源码
所以简单来看,一个对象的创建,首先需要判断类是否被加载,然后申请内存,不够的话会触发GC,如果内存申请成功,就调用init()
,即构造方法处理初始化类的成员信息。这需要区别类的初始化,类的初始化只有一次,<clinit>()
并不是程序员在Java代码中直接编写的方法,而是Javac编译器的自动生成物。
五、对象被实例化的方式
对象被实例化的方式有哪些:
-
使用new关键字
-
使用反射
javaClass<?> clazz = Class.forName("com.demo.MyDemo"); MyDemo myObject = (MyDemo) clazz.newInstance(); //构造函数反射 Class<?> clazz = Class.forName("com.demo.MyDemo"); Constructor c = clazz.getConstructor(null); MyDemo myObject = (MyDemo)c.newInstance();
-
使用Java 8+的构造函数引用
java
class MyDemo{
MyDemo(String s){
}
}
Function<String, MyDemo> constructorReference = MyDemo::new;
MyDemo myObject = constructorReference.apply("some parameter");
- 克隆clone() 类实现接口
java
public class MyDemo implements Cloneable{
@Override
protected MyDemo clone() throws CloneNotSupportedException {
// 调用super.clone()来执行浅拷贝
MyDemo cloned = (MyDemo) super.clone();
return cloned;
}
}
- 序列化 先通过ObjectOutputStream进行序列化,然后通过ObjectInputStream进行反序列化,可以实现创建一个对象。
六、总结
前文从源码层面加深理解类加载和对象的创建过程,再来丰富我们的对开头面试官的回答。
答 : 首先jvm内存结构有方法区,堆,虚拟机栈(程序计数器,本地方法栈),类通过类加载器加载,链接这两个阶段会生成一个klass的类对象存储在类表中,然后类的初始化阶段会将将常量和静态变量初始化存储,方法代码存在方法区,当使用关键字new
实例化一个对象时候,先判断类是否被初始化,然后申请内存,可能会触发GC,内存申请成功将对象存在堆区。然后调用对象的构造方法初始化对象的成员变量,当调用一个对象的方法时,方法被执行的时候,通过klass的类信息中的method方法表,找到对应的方法地址,然后执行引擎将方法区的指令加载到虚拟机栈开始执行。方法区的指令会封装成一个栈帧,每个线程都有一个虚拟栈维护这些方法栈,最终放到CPU高速缓存被CPU执行,程序计数器也会一直记录方法指令执行到哪一个行。最终直到方法指令被执行完,该方法出栈,继续下一个方法。