JVM内存模型和类加载运行机制
目录
一、JVM内存模型
运行一个 Java 应用程序,必须要先安装 JDK 或者 JRE 包。因为 Java 应用在编译后会变成字节码,通过字节码运行在 JVM 中,而 JVM 是 JRE 的核心组成部分。JVM 不仅承担了 Java 字节码的分析和执行,同时也内置了自动内存分配管理机制。这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出风险,使 Java 开发人员不需要关注每个对象的内存分配以及回收,从而更专注于业务本身。
在 Java 中,JVM 内存模型主要分为堆、方法区、程序计数器、虚拟机栈和本地方法栈。其中,堆和方法区被所有线程共享,虚拟机栈、本地方法栈、程序计数器是线程私有的。
1、堆
堆是 JVM 内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。
但需要注意的是,这些区域的划分因不同的垃圾收集器而不同。大部分垃圾收集器都是基于分代收集理论设计的,就会采用这种分代模型。而一些新的垃圾收集器不采用分代设计,比如 G1 收集器就是把堆内存拆分为多个大小相等的 Region。
2、方法区
在 jdk8 之前,HotSopt 虚拟机的方法区又被称为永久代,由于永久代的设计容易导致内存溢出等问题,jdk8 之后就没有永久代了,取而代之的是元空间(MetaSpace)。元空间并没有处于堆内存上,而是直接占用的本地内存,因此元空间的最大大小受本地内存限制。
方法区与堆空间类似,是所有线程共享的。方法区主要是用来存放已被虚拟机加载的类型信息、常量、静态变量等数据。方法区是一个逻辑分区,包含元空间、运行时常量池、字符串常量池,元空间物理上使用的本地内存,运行时常量池和字符串常量池是在堆中开辟的一块特殊内存区域。这样做的好处之一是可以避免运行时动态生成的常量的复制迁移,可以直接使用堆中的引用。要注意的是,字符串常量池在 jvm 中只有一个,而运行时常量池是和类型数据绑定的,每个 Class 一个。
1)类型信息(类或接口):
- 这个类型的全限定名
- 这个类型的直接超类的全限定名(只有 java.lang.Object 没有超类)
- 这个类型的访问修饰符(public、abstract、final)
- 这个类型是接口类型还是类类型
- 任何直接超接口的的全限定名的有序列表
2)运行时常量池:
- Class 文件被装载进虚拟机后,Class 常量池表中的字面量和符号引用都会存放到运行时常量池中,平时我们说的常量池一般指运行时常量池。
- 运行时常量池相比Class常量池具备动态性,运行时可以将新的常量放入池中,比如调用 String.intern() 方法使字符串驻留。
3)字段信息:
- 字段名
- 字段的类型(包括 void)
- 字段的修饰符(public、private、protected、static、final、volatile、transient)
4)方法信息:
- 方法名
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract)
- 方法的字节码
- 操作数栈和该方法的栈帧中的局部变量的大小
- 异常表
5)指向类加载器的引用:
- jvm 使用类加载器来加载一个类,这个类加载器是和这个类型绑定的,因此会在类型信息中存储这个类加载器的引用
6)指向 Class 类的引用:
- 每一个被加载的类型,jvm 都会在堆中创建一个 java.lang.Class 的实例,类型信息中会存储 Class 实例的引用
- 在代码中,可以使用 Class 实例访问方法区保存的信息,如类加载器、类名、接口等
3、虚拟机栈
每当启动一个新的线程,虚拟机都会在虚拟机栈里为它分配一个线程栈,线程栈与线程同生共死。线程栈以 栈帧 为单位保存线程的运行状态,虚拟机只会对线程栈执行两种操作:以栈帧为单位的压栈或出栈。每个方法在执行的同时都会创建一个栈帧,每个方法从调用开始到结束,就对应着一个栈帧在线程栈中压栈和出栈的过程。方法可以通过两种方式结束,一种通过 return 正常返回,一种通过抛出异常而终止。方法返回后,虚拟机都会弹出当前栈帧然后释放掉。
当虚拟机调用一个Java方法时.它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。
栈帧由三部分组成:局部变量区、操作数栈、帧数据区。
1)局部变量区:
- 局部变量区是一个数组结构,主要存放对应方法的参数和局部变量。
- 如果是实例方法,局部变量表第一个参数是一个 reference 引用类型,存放的是当前对象本身 this。
2)操作数栈:
- 操作数栈也是一个数组结构,但并不是通过索引来访问的,而是栈的压栈和出栈操作。
- 操作数栈是虚拟机的工作区,大多数指令都要从这里弹出数据、执行运算、然后把结果压回操作数栈。
3)帧数据区:主要保存常量池入口、异常表、正常方法返回的信息
- 常量池入口引用:某些指令要从常量池取数据,获取类、字段信息等
- 异常表引用:当方法抛出异常时,虚拟机根据异常表来决定如何处理。如果在异常表找到了匹配的 catch 子句,就会把控制权转交给 catch 子句的代码。没有则立即异常中止,然后恢复发起调用的方法的栈帧,然后在发起调用的方法的上下文中重新抛出同样的异常。
- 方法返回信息:方法正常返回时,虚拟机通过这些信息恢复发起调用的方法的栈帧,设置PC寄存器指向发起调用的方法。方法如果有返回值,还会把返回结果压入到发起调用的方法的操作数栈。
4、本地方法栈
本地方法栈与虚拟机栈所发挥的作用是相似的,当线程调用Java方法时,会创建一个栈帧并压入虚拟机栈;而调用本地方法时,虚拟机会保持栈不变,不会压入新的栈帧,虚拟机只是简单的动态链接并直接调用指定的本地方法,使用的是某种本地方法栈。比如某个虚拟机实现的本地方法接口是使用C连接模型,那么它的本地方法栈就是C栈。
本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,它可以做任何他想做的事情,本地方法不受虚拟机控制。
5、程序计数器
每一个运行的线程都会有它的程序计数器(PC寄存器),与线程的生命周期一样。执行某个方法时,PC寄存器的内容总是下一条将被执行的地址,这个地址可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时PC寄存器的值是 undefined。
程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。多线程环境下,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。
二、类加载机制
写好的源代码,需要编译后加载到虚拟机才能运行。java 源文件编译成 class 文件后,jvm 通过类加载器把 class 文件加载到虚拟机,然后经过类连接(类连接又包括验证、准备、解析三个阶段),最后经过初始化,字节码就可以被解释执行了。对于一些热点代码,虚拟机还存在一道即时编译,会把字节码编译成本地平台相关的机器码,以提高热点代码的执行效率。
装载、验证、准备、初始化这几个阶段的顺序是确定的,类型的加载过程必须按照这种顺序开始,而解析阶段可以在初始化阶段之后再开始,一般是在第一次使用到这个对象时才会开始解析。这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段,比如发现引用了另一个类,那么就会先触发另一个类的加载过程。
接下来通过如下类和代码来详细分析下类加载的过程:
三、类编译和Class 文件结构
*.java 文件被编译成 *.class 文件的过程,这个编译一般称为前端编译,主要使用 javac 来完成前端编译。Java class文件是8位字节的二进制流,数据项按顺序存储在class文件中,相邻的项之间没有任何间隔,这样可以使class文件紧凑。class 文件主要包含 版本信息、常量池、类型索引、字段表、方法表、属性表等信息。
将 User 类编译成 class 文件后,再通过 javap 反编译 class 文件,可以看到一个 class 文件大体包含的结构:
我们也可以安装 [jclasslib Bytecode viewer] 插件,就可以在IDEA中清晰地看到 Class 包含的信息:
1、魔数与Class文件信息
魔数唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。
Minior version 是次版本号,Major version 是主版本号。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加 1,所以 jdk1.8 的 Major version 是 52。高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。
Access flags 用于识别类或者接口层次的访问信息,比如这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型 等等。
2、常量池
虚拟机把常量池组织为入口列表,常量池中的许多入口都指向其他的常量池入口(比如引用了其它类),而且 class 文件中的许多条目也会指向常量池中的入口。列表中的第一项索引值为1,第二项索引值为2,以此类推。虽然没有索引值为0的入口,但是 constant_pool_count 会把这一入口也算进去,比如上面的 Constant pool count 为 119,而常量池实际的索引值最大为 118。
常量池主要存放两大类常量:字面量和符号引用。
- 字面量:字面量主要是文本字符串、final 常量值、类名和方法名的常量等。
- 符号引用:符号引用对java动态连接起着非常重要的作用。主要的符号引用有:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。
常量池中每一项都是一个表,常量表主要有如下17种常量类型。
常量池的项目类型:
再理解下符号引用和直接应用:
- 符号引用:java 文件在前端编译期间,class 文件并不知道它引用的那些类、方法、字段的具体地址,不能被class文件中的字节码直接引用。因此使用符号引用来代替,运行时再动态连接到具体引用上。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。在运行时,Java虚拟机从常量池获得符号引用,然后在运行时解析引用项的实际地址。
比如看 sayHello 这个方法,首先要调用 super.sayHello,即父类 Person 的 sayHello 方法,那么第三个指令就会在常量池寻找 [#16] 这个索引,然后可以从常量池找到这个方法的相关信息,再通过 [#29] 找到 Person 类信息。
3、类索引、父类索引与接口索引
Class文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
它们各自指向一个类型为 CONSTANT_Class_info 的常量表,通过 CONSTANT_Class_info 常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
4、字段表
字段表用于描述接口或者类中声明的变量。Java语言中的“字段”包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
描述符:
- descriptor 是描述符,描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
- 对于数组类型,每一维度将使用一个前置的 “[” 字符来描述,如一个定义为 “java.lang.String[][]” 类型的二维数组将被记录成 “[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成 “[I”。
- 用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。
描述符标识字符含义:
比如从构造方法的描述符 <Ljava/lang/String;I)V> 可以看出,方法的参数包括对象类型 java.lang.String、基本类型 int,返回值为 void。
5、方法表
方发表与字段表类似,方发表用于描述方法的访问标志、名称索引、描述符索引、属性表集合、代码指令等
1)异常表:
如果方法表有异常捕获的话,还会有异常表。当方法抛出异常时,就会从异常表查找能处理的异常处理器。
2)重载多出的方法:
如果父类方法在子类中被重写,那方法表中就会包含父类方法的信息,如果重写泛型方法,还会出现编译器自动添加的桥接方法。
因为泛型编译后的实际类型为 Object,如果子类泛型不是 Object,那么编译器会自动在子类中生成一个 Object 类型的桥接方法。桥接方法的内部会先做类型转换检查,然后调用重载的方法。因为我们在声明变量时一般是声明的超类,实际类型为子类,而超类方法的参数是Object类型的,因此就会调用到桥接方法,进而调用子类重载后的方法。
而且,当我们通过反射根据方法名获取方法时,要注意泛型重载可能获取到桥接方法,此时可以通过 method.isBridge() 方法判断是否是桥接方法。
3)类构造器和实例构造器:
方法表还包括实例构造方法 <init> 和类构造方法 <clinit> 。<init> 就是对应的实例构造器。<clinit> 是编译时将类初始化的代码搜集在一起形成的类初始化方法,如静态变量赋值、静态代码块。
初始化阶段会调用类构造器 <clinit> 来初始化类,因此其一定是线程安全的,是由虚拟机来保证的。这种机制我们可以用来实现安全的单例模式,枚举类的初始化也是在 <clinit> 方法中初始化的。
6、属性表
属性表集合主要是为了正确识别Class文件而定义的一些属性,如 Code、Deprecated、ConstantValue、Exceptions、SourceFile 等等。
每一个属性,它的名称都要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示。
四、类加载
1、类初始化的时机
类和接口被加载的时机因不同的虚拟机可能不同,但类初始化的触发时机有且仅有六种情况:
- 当创建某个类的实例,如 new、反射、克隆、反序列化
- 当调用某个类的静态方法时
- 当使用某个接口或类的静态字段,或者赋值时(final 修饰的常量除外,它在编译期把结果放入常量池中了)
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化(接口除外)
- 当虚拟机启动时,会先初始化要执行的主类(包含main()方法的那个类)
这六种情况称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
触发接口初始化的情况:
- 类初始化时并不会触发其实现的接口的初始化,接口初始化时也不会要求父接口初始化
- 在接口所声明的非常量字段被使用时,该接口才会被初始化
- 如果接口定义了 default 方法,那子类重写了这个方法,就会先触发接口的初始化
1)主动初始化:
从输出可以看出,对 final 常量的引用不会触发类的初始化,调用静态方法时触发了类的初始化,同时,一定会先触发父类的初始化,而且类只会被初始化一次。
注意初始化的顺序是按代码的顺序从上到下初始化:
2)被动初始化,如下被动引用不会触发类的初始化:
- 通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化
- 通过数组定义来引用类,不会触发此类的初始化。但是会触发一个 “[com.lyyzoo.jvm.test01.User” 类型的初始化,即一维数组类型
- 引用类的常量不会触发类的初始化。常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
3)不难判断,例子中定义的类的加载顺序如下:
2、加载
在加载阶段,Java虚拟机必须完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
这个二进制流可以从 Class 文件中获取,从JAR包、WAR包中获取,从网络中获取,实时生成、还可以从加密文件中获取,在加载时再解密(防止Class文件被反编译)。这个加载是由类加载器加载进虚拟机的,非数组类型可以使用内置的引导类加载器来加载,也可以使用开发人员自定义的类加载器来加载,我们可以自己控制字节流的获取方式。而数组类型本身不通过类加载器加载,它是由虚拟机直接在内存中构造出来的。
加载阶段会把 Class 常量池中的各项常量存放到运行时常量池中(下图中的常量池只挑选了部分常量来展示)。加载阶段的最终产品就是 Class 类的实例对象,它成为程序与方法区内部数据结构之间的入口,可以通过这个 Class 实例来获得类的信息、方法、字段、类加载器等等。
在装载过程中,虚拟机还会确认装载类的所有超类是否都被装载了,根据 super class 项解析符号引用,这就会导致超类的装载、连接和初始化。
3、验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段会完成下面四个阶段的检验:
- 文件格式验证:保证输入的字节流能正确地解析并存储于方法区之内,通过这个阶段的验证之后,这段字节流会进入Java虚拟机内存的方法区中进行存储,后面的验证就是基于方法区的存储结构而进行了。
- 元数据验证:对类的元数据信息进行语义校验,如这个类是否有父类(除 java.lang.Object 外,所有的类都有父类)、是否继承了 final 的类、实现了 final 的方法等。
- 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
- 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。
4、准备
准备阶段是为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,初始值是指这个数据类型的零值,而赋值的过程是放在 <clinit> 方法中,在初始化阶段执行的。注意实例变量是在创建实例对象时才初始化值的。
基本数据类型的零值:
准备阶段还会为常量字段(final 修饰的常量,即字段表中有 ConstantValue 属性的字段)分配内存并直接赋值为定义的字面值。
User 类经过准备阶段后:
5、解析
解析过程就是根据符号引用查找到实体,再把符号引用替换成一个直接引用的过程。因为所有的符号引用都保存在常量池中,所以这个过程常被称作常量池解析。
1)静态解析与动态连接:
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在运行期间用到时转化为直接引用,这部分称为动态连接。
静态解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法包含 静态方法、私有方法、实例构造器、父类方法以及被 final 修饰的方法,这5种方法调用会在类加载的时候就把符号引用解析为该方法的直接引用(有可能是在初始化的时候去解析的)。
动态连接这个特性给Java带来了更强大的动态扩展能力,比如使用运行时对象类型,因为要到运行期间才能确定具体使用的类型。这也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
2)符号引用解析:
对于符号引用类型如 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等,会查找到对应的类型数据、方法地址、字段地址的直接引用,然后将符号引用替换为直接引用。
对于 CONSTANT_String _info 类型指向的字面量,虚拟机会检查字符串常量池中是否已经有相同字符串的引用,有则替换为这个字符串的引用,否则在堆中创建一个新的字符串对象,并将对象的引用放到字符串常量池中,然后替换常量池中的符号引用。
对于数值类型的常量,如 CONSTANT_Long_info、CONSTANT_Integer_info,并不需要解析,虚拟机会直接使用那些常量值。
6、初始化
直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,初始化阶段就是执行类构造器 <clinit> 方法的过程。
1)<clinit> 方法:
- <clinit> 方法是由编译器自动收集类中的所有类变量的赋值语句和静态代码块合并产生的,代码执行的顺序就是源文件中的顺序。
- Java虚拟机会保证在子类的 <clinit> 方法执行前,父类的 <clinit> 方法会先执行完毕,即先初始化直接超类。
- <clinit> 方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit> 方法。
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit> 方法。
- 执行接口的 <clinit> 方法不需要先执行父接口的 <clinit> 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit> 方法。
- Java虚拟机会保证一个类的 <clinit> 方法在多线程环境中被正确地加锁同步,<clinit> 一定是线程安全的。
2)User 类初始化后:
一个类被装载、连接和初始化完成后,它就随时可以使用了。程序可以访问它的静态字段,调用它的静态方法,或者创建它的实例。
7、即时编译
初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中,这样可以减少解释器的中间损耗,获得更高的执行效率。如果没有即时编译,每次运行相同的代码都会使用解释器编译。
五、类加载器
1、类加载器子系统
在Java虚拟机中,负责查找并装载类型的那部分被称为类加载器子系统。类加载器子系统会负责整个类加载的过程:装载、验证、准备、解析、初始化。
1)Java 虚拟机有两种类加载器,启动类加载器和用户自定义类加载器:
- 启动类加载器:是Java虚拟机实现的一部分,启动类加载器主要用来加载受信任的Java API 的 Class 文件。
- 用户自定义类加载器:是Java程序的一部分,用户自定义的类加载器都是 java.lang.ClassLoader 的子类实例,开发人员可以自己控制字节流的加载方式。
2)类唯一性:
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间,由不同的类加载器加载的类将被放在虚拟机内部的不同命名空间中。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这就是有时候我们测试代码时发现明明是同一个Class,却报强转失败之类的错误。
2、双亲委派模型
Java 1.8 之前采用三层类加载器、双亲委派的类加载架构。三层类加载器包括启动类加载器、扩展类加载器、应用程序类加载器。
1)三层类加载器
- 启动类加载器(Bootstrap ClassLoader):负责将 $JAVA_HOME/lib 或者 -Xbootclasspath 参数指定路径下面的文件(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载) 加载到虚拟机内存中。它用来加载 Java 的核心库,是用原生代码实现的,并不继承自 java.lang.ClassLoader,启动类加载器无法直接被 java 代码引用。
- 扩展类加载器(Extension ClassLoader):负责加载 $JAVA_HOME/lib/ext 目录中的文件,或者 java.ext.dirs 系统变量所指定的路径的类库,它用来加载 Java 的扩展库。
- 应用程序类加载器(Application ClassLoader):一般是系统的默认加载器,也称为系统类加载器,它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般 Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader() 来获取它。
2)双亲委派模型
除了启动类加载器之外,所有的类加载器都有一个父类加载器。应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。一般来说,开发人员自定义的类加载器的父类加载器一般是应用程序类加载器。
双亲委派模型:类加载器在尝试去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,如果父类加载器没有,继续寻找父类加载器,依次类推,如果到启动类加载器都没找到才从自身查找。这个类加载过程就是双亲委派模型。
首先要明白,Java 虚拟机判定两个 Java 类是否相同,不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。不同类加载器加载的类之间是不兼容的。
双亲委派模型就是为了保证 Java 核心库的类型安全的。所有 Java 应用都至少需要引用 java.lang.Object 类,也就是说在运行的时候,java.lang.Object 这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成或者自己定义了一个 java.lang.Object 类的话,很可能就存在多个版本的 java.lang.Object 类,而这些类之间是不兼容的。通过双亲委派模型,对于 Java 核心库的类加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。有了双亲委派模型,就算自己定义了一个 java.lang.Object 类,也不会被加载。
3)ClassLoader
类加载器之间的父子关系一般不是以继承的关系来实现的,通常是使用组合、委托关系来复用父加载器的代码。ClassLoader 中有一个 parent 属性来表示父类加载器,如果 parent 为 null,就会调用本地方法直接使用启动类加载器来加载类。类加载器在成功加载某个类之后,会把得到的 java.lang.Class 类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。
3、线程上下文类加载器
线程上下文类加载器可通过 java.lang.Thread 中的方法 getContextClassLoader() 获得,可以通过 setContextClassLoader(ClassLoader cl) 来设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl) 方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是应用程序类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。线程上线文类加载器使得父类加载器可以去请求子类加载器完成类加载的行为,这在一定程度上是违背了双亲委派模型的原则。
六、对象及其生命周期
1、实例化对象
1)实例化一个类有四种途径:
- 明确地使用 new 操作符
- 调用 Class 或者 java.lang.reflcct.Constructor 对象的 newInstance() 方法
- 调用任何现有对象的 clone() 方法
- 通过 java.io.ObjectInputStream 类的 getObject() 方法反序列化
2)实例化对象的过程:
- 1、当虚拟机要实例化一个对象时,首先从常量池中找到这个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就会触发相应的类加载过程。
- 2、在类加载检查通过后,虚拟机将为新生对象分配内存,为对象分配空间就是把一块确定大小的内存块从Java堆中划分出来。
- 3、内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
- 4、接下来,虚拟机还要对对象进行必要的设置,例如这个对象的类型信息、元数据地址、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 5、最后开始执行对象的构造函数,即Class文件中的 <init> 方法,按照开发人员的意图对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
2、对象的内存布局
在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1)对象头:
对象头主要由两部分组成:Mark Word 和类型指针,如果是数组对象,还会包含一个数组长度。
- Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。synchronized 锁升级就依赖锁标志、偏向线程等锁信息,垃圾回收新生代对象转移到老年代则依赖于GC分代年龄。
- 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 数组长度:有了数组长度,虚拟机就可以通过普通Java对象的元数据信息确定Java对象的大小,如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
这三部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。64 位虚拟机中,为了节约内存可以使用选项 +UseCompressedOops 开启指针压缩,某些数据会由 64位压缩至32位。
2)实例数据:
实例数据部分是对象真正存储的有效信息,即对象的各个字段数据,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
3)对齐填充:
对齐填充仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
4)计算对象占用内存大小:
从上面的内容可以看出,一个对象对内存的占用主要分两部分:对象头和实例数据。在64位机器上,对象头中的 Mark Word 和类型指针各占 64 比特,就是16字节。实例数据部分,可以根据类型来判断,如 int 占 4 个字节,long 占 8 个字节,字符串中文占3个字节、数字或字母占1个字节来计算,就大概能计算出一个对象占用的内存大小。当然,如果是数组、Map、List 之类的对象,就会占用更多的内存。
3、对象访问定位
创建对象后,这个引用变量会压入栈中,即一个 reference,它是一个指向对象的引用,这个引用定位的方式主要有两种:使用句柄访问对象和直接指针访问对象。
1)通过句柄访问对象:
使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
2)通过直接指针访问对象:
如果使用直接指针访问的话,Java堆中对象的内存布局就必须放置访问类型数据的相关信息(Mark Word 中记录了类型指针),reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,HotSpot 虚拟机主要就是使用这种方式进行对象访问。
4、垃圾收集
当对象不再被程序所引用时,它所使用的堆空间就需要被回收,以便被后续的新对象所使用。JVM 的内存分配管理机制会自动帮我们回收无用的对象,它知道如何确定对象不再被引用,什么时候去回收这些垃圾对象,使用什么回收策略来回收更高效,以及如何管理内存,这部分就是JVM的垃圾收集相关的内容了。
参考
本文是学习、参考了如下书籍和课程,再通过自己的总结和实践总结而来。如果想了解更多深入的细节,建议阅读原著。
《深入JAVA虚拟机 第二版》
《深入理解Java虚拟机:JVM高级特性与最佳实践 第三版》
《极客时间:Java性能调优实战》