【Java虚拟机】对象创建过程与类加载机制、双亲委派模型
对象的创建过程
对象的创建过程
对象的创建过程一般是从 new 指令(JVM层面)开始的,整个创建过程如下:
(1) 首先检查 new 指令的参数是否能在常量池中定位到一个类的符号引用;
(2) 如果没有,说明类还没有被加载,则须先执行相应的类加载、解析和初始化等类加载过程;
(3) 如果有,虚拟机将在堆中为新生对象分配内存。分配内存方式有:指针碰撞和空闲列表,具体选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 指针碰撞:如果Java堆是绝对规整的,所有用过的内存都放在一边,所有没用过的内存存放在另一边,中间存放一个指针作为分界点指示器。分配内存时,将指针从用过的内存区域向空闲内存区域移动等距离区域。
- 空闲列表:如果Java不是规整的,这时,虚拟机就必须维护一张列表,列表上记录了可用的内存块,在分配内存时,从列表上找到一个足够大的连续内存块分配给对象,并更新列表上的记录。
在分配对象内存空间的过程中,需要考虑在并发情况下,线程是否安全的问题。因为创建对象在虚拟机中是非常频繁的行为,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。因此必须要保证线程安全,解决这个问题有两种方案:
- CAS以及失败重试(比较和交换机制):对分配内存空间的操作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。CAS操作需要输入两个数值,一个旧值(操作前期望的值)和一个新值,在操作期间先比较旧值有没有发送变化,如果没有变化,才交换成新值,否则不进行交换。
- TLAB(Thread Local Allocation Buffer,本地线程分配缓存):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲。TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
(4) 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,保证了对象实例的字段在 Java 代码中可以不赋初始值就可以直接使用;
(5) 对对象进行必要的设置,例如是哪个对象的实例、如何才能找到类元信息、对象的哈希码、GC 分代年龄等信息,这些信息存放在对象头中。
(6) 执行 init 方法,把对象按照程序员意愿进行初始化。
至此,一个对象就被创建完毕,同时会在Java栈中分配一个引用指向这个对象,通过栈上面的引用可以访问堆中的具体对象,访问对象主要有两种方式:通过句柄访问对象和直接指针访问对象。
对象的访问方式
(1) 通过句柄访问对象
在Java堆中划出一块内存专门作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。
(2) 通过直接指针访问对象
(3) 优劣对比
① 使用句柄,reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
② 直接指针,速度快,节省一次指定定位的开销。
类加载机制
Java 文件中的代码在编译后,就会生成JVM能够识别的二进制字节流 class 文件,class 文件中描述的各种信息,都需要加载到虚拟机中才能被运行和使用。
类加载机制,就是虚拟机把类的数据从class文件加载到内存,并对数据进行校检,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程。类从加载到虚拟机内存开始,到卸载出内存结束,整个生命周期包括七个阶段,如下图:
加载阶段
这阶段的虚拟机需要完成三件事情:
(1)通过一个类的全限定名来获取定义此类的二进制字节流。
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证阶段
这阶段是为了确保class文件的字节流包含的信息符合当前虚拟机的要求,不会危害虚拟机自身的安全。分为4个校检动作:
(1)文件格式验证:验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理,通过该阶段后,字节流会进入内存的方法区中进行储存。
(2)元数据验证:对字节码描述的信息进行语言分析,对类的元数据信息进行语义校验,确保其描述的信息符合java语言规范要求。
(3)字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法进行校验分析,保证类的方法在运行时不会做出危害虚拟机安全的事件。
(4)符号引用验证:对类自身以外的信息(常量池中各种符号引用)的信息进行校检,确保解析动作能正常执行(该动作发生在解析阶段中)
准备阶段
正式为类变量分配内存空间并设置数据类型零值。这个阶段进行内存分配的仅包括类变量(static修饰),不包括实例变量,实例变量会在对象实例化时随对象一起分配在java堆。
public static int value= 123 ; //变量value在准备阶段过后的初始值是0,不是123.
public static final int value = 123 ; //特殊情况:会生成ConstantValue属性,在准备阶段初始值是123.
final、static、static final修饰的字段赋值的区别:
(1)static修饰的字段在准备阶段被初始化为0或null等默认值,然后在初始化阶段(触发类构造器)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。
(2)static final修饰的字段在Javac时生成ConstantValue属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则Javac时会报错。可以理解为在编译期即把结果放入了常量池中。
(3)final修饰的字段在运行时被初始化(可以直接赋值,也可以在实例构造器中()赋值),一旦赋值便不可更改。
解析阶段
将常量池的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、和调用限定符7类符号引用。
对同一符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机可以对第一次解析的结果进行缓存,从而避免解析动作重复进行。invokedynamic对应的引用称为“动态调用限定符”,必须等到程序实际运行到这条指定的时候,解析动作才能进行。因此,当碰到由前面的invokedynamic指令触发过的解析的符号引用时,并不意味着这个解析结果对其他的invokedynamic指令也同样生效。
(1)符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何字面量,只要使用时无歧义定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以不相同,但是他们能接受的符号引用必须都是一致的,符号引用的字面量形式明确地定义在java虚拟机规范的calss文件格式中。
(2)直接引用:直接引用是可以直接定位到目标的指针、相对偏移量或是一个能间接定位目标的句柄。直接引用是与虚拟机的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
初始化阶段才真正执行类中定义的java代码。执行类构造器()方法的过程,并按程序的设置去初始类变量和其他资源。
类的主动引用
在初始化阶段,有且只有5种场景必须立即对类进行“初始化”,称为主动引用:
(1)遇到new、getstatic、putstatic、invokestatic这4条指定时。对应的场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已经在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
(2)使用java.lang.reflect包的方法对类进行发射调用的时候。
(3)当初始化一个类的时候,如果发现其父类还没进行初始化,则必须对父类进行初始化。(与接口的区别:接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候,才会初始化)
(4)当虚拟机启动时,用户指定的要执行的主类(包含main方法的类)。
(5)java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有进行过初始化,则需要触发其初始化。
类的被动引用
除了主动引用,其他引用类的方式都不会触发初始化,称为被动引用:
(1)对于静态字段,只有直接定义这个字段的类才会被初始化,通过其子类来引用父类中定义的静态字段,只会触发其父类的初始化而不会触发子类的初始化。
(2)通过数组定义来引用类,不会触发此类的初始化。
(3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
类加载器与双亲委派模型
JVM 的类加载器
类加载机制生命周期的第一阶段,即加载阶段需要由类加载器来完成的,类加载器根据一个类的全限定名读取类的二进制字节流到JVM中,然后生成对应的java.lang.Class对象实例。
在虚拟机默认提供了3种类加载器,启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用类加载器(Application ClassLoader),如果有必要还可以加入自己定义的类加载器。
(1)启动类加载器(Bootstrap ClassLoader):负责加载 在\lib目录 和 被-Xbootclasspath参数所指定的路径中的类库
(2)扩展类加载器(Extension ClassLoader):负责加载 \lib\ext目录 和 被java.ext.dirs系统变量所指定的路径中的所有类库
(3)应用程序类加载器(Application ClassLoader):负责加载用户类路径classPath所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
(4)自定义加载器(CustomClassLoader):由应用程序根据自身需要自定义,如 Tomcat、Jboss 都会根据 j2ee 规范自行实现。
任意一个类在 JVM 中的唯一性,是由加载它的类加载器和类的全限定名一共同确定的。因此,比较两个类是否“相等”的前提是这两个类是由同一个类加载器加载的,否则,即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。JVM 的类加载机制,规定一个类有且只有一个类加载器对它进行加载。而如何保证这个只有一个类加载器对它进行加载呢?则是由双亲委派模型来实现的。
双亲委派模型
双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父类加载器。(类加载器之间的父子关系不是以继承的关系实现,而是使用组合关系来复用父加载器的代码)。
双亲委派模型的工作原理
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层级的类加载器都是如此,因此所有请求最终都会被传到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。因此,加载过程可以看成自底向上检查类是否已经加载,然后自顶向下加载类。
双亲委派模型的优点
(1)使用双亲委派模型来组织类加载器之间的关系,Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
(2)避免类的重复加载,当父类加载器已经加载了该类时,子类加载器就没必要再加载一次。
(3)解决各个类加载器的基础类的统一问题,越基础的类由越上层的加载器进行加载。避免Java核心API中的类被随意替换,规避风险,防止核心API库被随意篡改。
例如类 java.lang.Object,它存在在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的 Bootstrap ClassLoader 进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,那系统中将会出现多个不同的 Object 类,程序将混乱。因此,如果开发者尝试编写一个与 rt.jar 类库中重名的 Java 类,可以正常编译,但是永远无法被加载运行。
如何破坏双亲委派模型
双亲委派过程是在java.lang.ClassLoader 的 loadClass() 方法之中实现的,如果想要破坏这种机制,那么就自定义一个类加载器(继承自 ClassLoader),重写其中的 loadClass() 方法,使其不进行双亲委派即可。ClassLoader 中和类加载有关的方法有很多,除了oadClass(),除此之外,还有 findClass() 和 defineClass() 等,那么这几个方法有什么区别呢:
- loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中
- findClass() 根据名称或位置加载 .class 字节码
- definclass() 把字节码转化为 Class 对象
findClass() 是 JDK1.2 之后 ClassLoader 新添加的方法,在 JDK1.2 之后已不提倡用户直接覆盖 loadClass() 方法,而是建议把自己的类加载逻辑实现到 findClass() 方法中,因为在 loadClass() 方法的逻辑里,如果父类加载器加载失败,则会调用自己的 findClass() 方法来完成加载。而同样如果你想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承 ClassLoader,并且在 findClass() 中实现你自己的加载逻辑即可。
对于双亲委派模型的破坏,最经典例子就是 Tomcat 容器的类加载机制了,它实现了自己的类加载器 WebApp ClassLoader,并且打破了双亲委派模型,在每个应用在部署后,都会创建一个唯一的类加载器。
参考: |