1、JVM运行和类加载过程
一、类的加载机制
类加载机制:JVM把class文件加载到内存,并对数据进行校验,解析和初始化,最终形成JVM 可以直接使用的Java类型的过程。
主要有三步:加载、连接、初始化。其中连接又可以细分为:验证、准备和解析。
加载:类的加载是指把.class文件中的二进制数据读入到内存(方法区)中,将字节流代表的静态存储结构转化成方法区的运行时数据结构,之后在堆区创建一个(java.lang.Class)Class对象,作为访问方法区的类信息的接口。
链接:将Java类的二进制代码合并到JVM的运行时数据的过程
(1)验证
确保加载的类信息符合JVM规范,没有安全方面的问题。
(2)准备
正式为类变量(static 变量)分配内存,并设置类变量的初始值
(3)解析
虚拟机常量池内的符号引用替换为直接引用的过程
初始化:执行类构造器
(重点)类的主动引用和被动引用的区别
类的主动引用(一定会发生类的初始化)
—— new一个类的对象
—— 调用类的静态成员(除了final常量)和静态方法
—— 使用java.lang.reflect包的方法对类进行反射调用
—— 当虚拟机启动,先启动main方法所在的类
—— 当初始化一个类,如果父类没有被初始化,则先初始化它的父类
类的被动引用(不会发生类的初始化)
—— 当访问一个静态域时,只有真正声明这个域的类才会被初始化
通过子类引用父类的静态变量,不会导致子类初始化
—— 通过数组定义类引用,不会触发此类的初始化
—— 引用final常量不会触发此类的初始化(常量在编译阶段就存入类的常量池中)
二、JVM的内存结构
(1)方法区存放一些类的运行时数据:
1、静态变量
2、静态方法
3、静态代码块
4、常规量池
(2)堆存放对象本身
生成java.lang.Class对象,并且可以指向方法区中定义的类的信息
(3)方法栈
将方法的栈帧压入栈中,当方法执行结束后从栈中pop出来。
三、 案例分析
packagecom.oracle.List;
/**
* 类的加载和初始化只执行一次!
* @author zhegao
*/
public class M {
public static void main(String[] args) throws ClassNotFoundException,InstantiationException, IllegalAccessException {
/**类的主动引用(一定会发生类的初始化),
*初始化的顺序:初始化静态信息(先父再子)
*/
//第一种: new一个类的对象
//A a1 = new A();
/*
* 结果:
* Father is very happy
A is so happy!
I am A's father...
A is good
*/
//第二种:调用类的静态成员(除了final常量)和静态方法
// System.out.println(A.age);
//第三种:使用java.lang.reflect包的方法对类进行反射调用
//Class.forName("com.oracle.List.A");
/**
*类的被动引用(不会发生类的初始化)
*/
//第一种:当访问一个静态域时,只有真正声明这个域的类才会被初始化,通过子类引用父类的静态变量,不会导致子类初始化
//System.out.println(B.age); //这里只有A初始化了,而由于静态域age是A的属性,所以B未被初始化!
//第二种:通过数组
A[] as = new A[10];
//第三种:引用常量不会触发此类的初始化
System.out.println(A.height);
/*
*结果
*185
*/
}
}
class B extends A{
public B() {
System.out.println("B is also good.");
}
}
class A extends Father{
public static int age = 27;
public static final intheight = 185;
static {
System.out.println("A is so happy!");
}
public A() {
System.out.println("A is good");
}
}
class Father{
static {
System.out.println("Father is very happy");
}
public Father() {
System.out.println("I am A's father...");
}
}
2、对象的创建过程(这部分参考于博客https://www.cnblogs.com/chenyangyao/p/5296807.html,书籍可以参考《深入理解java虚拟机 第二版》P44-49)
对象的创建过程一般是从new指令(我说的是JVM的层面)开始的(具体请看图1),JVM首先对符号引用进行解析,如果找不到对应的符号引用,那么这个类还没有被加载,因此JVM便会进行类加载过程(具体加载过程可参见我的另一篇博文)。符号引用解析完毕之后,JVM会为对象在堆中分配内存,HotSpot虚拟机实现的JAVA对象包括三个部分:对象头、实例字段和对齐填充字段(具体内容请看图2),其中要注意的是,实例字段包括自身定义的和从父类继承下来的(即使父类的实例字段被子类覆盖或者被private修饰,都照样为其分配内存)。
为对象分配完堆内存之后,JVM会将该内存(除了对象头区域)进行零值初始化,这也就解释了为什么JAVA的属性字段无需显示初始化就可以被使用,而方法的局部变量却必须要显示初始化后才可以访问。最后,JVM会调用对象的构造函数,当然,调用顺序会一直上溯到Object类。
至此,一个对象就被创建完毕,此时,一般会有一个引用指向这个对象。在JAVA中,存在两种数据类型,一种就是诸如int、double等基本类型,另一种就是引用类型,比如类、接口、内部类、枚举类、数组类型的引用等。引用的实现方式一般有两种,具体请看图3。
图1 对象的创建过程
在这里,楼主做了一个问题的思考,对于这段java代码: Instance instance = new Instance(),在单例模式下的线程安全是如何产生的?
答:基于图一,我们可以知道new Instance()主要进行了三个步骤(假设类已经被加载、链接和初始化):
(1)为对象分配内存空间,并初始化为零值 ----> (2) 调用对象的实例化init方法 ----> (3)将内存地址指向instance变量。
(1)(2)(3)这是我们接受的执行顺序,但是由于CPU在执行时做了局部的优化,称之为“重排序”。最终,导致执行的顺序可能是(1)(3)(2)。该顺序在“多例”模式下没有影响,毕竟最终结果都一样,但是在并发环境的“单例”模式下会引发“线程”安全问题,即多个线程新建的对象不一致,或者更严重的是,新建的对象没有被实例化,所以需要“DCL”的双重检查锁并配合volatile关键字使用。具体参考http://blog.csdn.net/qq_29864971/article/details/79321095。
结论:new Instance()并非是一个严格意义的“原子性”操作!,它包含了一些不同的步骤,所以在多线程的“单例”模式下,需要考虑其线程安全问题。这样的问题在一些框架中也会出现,例如:Spring整合Struts1中,原本由Struts控制的action,会兼并到spring容器中(因为struts中,action默认为单例,而兼并到Spring容器后,action的 scope="prototype")
图2 对象的组成结构
图3 对象引用的两种实现方式
通过图3可见,如果通过“直接指针”访问对象,那么对象的布局中,对象头将持有指向“对象类型数据”/类元数据的指针。
但是这两种方式各有优势:
1)方式一(句柄池)的优缺点
优点:栈中的reference存储的是“句柄”地址,它比较稳定不易改变,即使对象被移动或者被垃圾回收器回收,只会改变句柄池中的实例对象的指针,reference本身不会被修改。
缺点:创建句柄池,增加内存的开销。
2)方式二(直接指针)的优缺点
优点:节省内存,并且直接指针的速度更快
缺点:指针移动,reference也会做修改