我们知道,编写好的Java源文件必须通过被编译器编译成二进制字节码,即class文件,而class文件要能够运行,必须经过Java虚拟机进行解释。因此,为了使一个Java程序能够被运行,要经历两个阶段:编译阶段、解释阶段。
- 认识class文件
首先认识一下class文件,通过工具可以看到class文件的主要格式,如下图所示:
由图可以看出,class文件具有一套统一的规范,不仅唯一标识了一个Class,还记录了Class的各个组成部分。
- 魔术在class文件中占用了4个字节,表示未0xCAFEBABY;
- 紧跟着的第5、6字节代表的为版本号;
- 版本号后面的是常量池的入口,可以理解为class文件的资源仓库,它是class文件结构中与其他项目关联最多的数据类型,是占用class文件空间最大的数据项目之一。其中,字面常量,记录如字符串、声明为final的常量值等;符号引用则主要包括三类常量:(1)类和接口的全限定名称;2、字段的名称和描述符;3、方法的名称和描述符;
- 常量池结束之后,紧跟着的两个字节代表的是访问标志,用于标识一些类或者接口的访问信息,包括:这个class是类还是接口;是否定义为public;是否定义了abstract;如果为类,是否被定义为了final等;
- 紧跟着的三个索引关系,类索引、父类索引、接口索引集合确定了类的集合关系,分别在访问标识之后;
- 字段表描述接口或者类中声明变量,包括:字段的作用域、static、final、volatile、transient、字段数据类型、字段名称;
- 方发表主要包括:访问标志、名称索引、描述符索引、属性表集合等;
- 等等;
do{
自动计算PC寄存器的值加1;
根据PC寄存器的值,从字节码流中取出操作码;
if(字节码存在操作数,从字节码流中取出操作数){
执行错做码所定义的操作;
}
}while(字节码流长度>0)
Java虚拟机提供了许多的指令集,可以很方便实现Java本身所描述的高级语义,如monitorenter和monnitorexit两条指令来支持synchronized关键字的语义,还有其他如交换、存取的许多指令。
2.class文件加载
类从被加载到内存到被卸载出去位置,它的整个生命周期包括:
其中,类的加载过程可以有三个阶段,即加载、连接和初始化三个阶段;在初始化完成之后,这个类才能被使用,不需要的时候会被卸载。
(1)加载阶段,主要完成了三件事:
- 通过符号引用中记录的类的全限定名称来获取定义此类的二进制字节流;
- 将此二进制字节流代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
(2)连接阶段又主要分为三个阶段:验证、准备、解析。
1)验证
验证的目的是为了确保Class文件的字节流包含的信息是符合虚拟机的要求,并且不会危害到虚拟机的自身安全;如果验证的输入的字节流不符合Class文件的格式约束,虚拟机就会跑出java.lang.VerifyError异常或其子类的异常。验证阶段可以分为四个阶段:文件格式验证、元数据验证、字节码验证、符号用用验证。
- 文件格式验证,主要验证class文件是否已魔数0xCAFEBABE开头、主次版本号是否能被当前虚拟机处理、常量池的常量中是否有不被支持的类型.、常量池里的项是否执行不存在的常量或不符合类型的常量等等。
- 元数据验证,即进行语义分析,以确保class文件描述的类信息符合Java语言的规范,如:是否有父类(除java.lang.Object外,所有类都应有父类)、是否继承了final类、如果不是抽象类是否实现了所有需要实现的方法等;
- 字节码验证,进行数据流和控制流分析,对类的方法体进行校验,确保类的方法在运行时不作出危害虚拟机安全的行为。如:确保跳转指令不会跳到方法体以外的指令上、确保类型转换是合法有效的等;
- 符号引用验证,在“解析”阶段,把常量池内的符号引用转化为直接饮用的时候,要先验证符号引用。如:通过全限定名,是否能找到相应的类;是否能找到指定类中的指定的字段或方法;类、字段或方法的可访问性是否合适等等。如果无法通过符号引用验证,将抛出一个java.lang.IncompatibleClassChangeError的子类。
准备阶段正式为类变量分配内存并设置类变量的默认值,这些变量所用的内存都在方法区中进行分配。如:static int a = 28;此时在准备阶段,就会为变量a在方法区的静态区域中分配4个字节内存,并设置初始化0,而不是28。但是,对于static final类型,是直接赋值的,因为对于final类型的变量是在编译时确定的,如:static final a = 28;则在准备阶段分配的默认值就为4个字节。
Java中常见的类型变量的默认值总结如下:
数据类型 | 默认值 |
boolean | false |
byte | (byte)0 |
char | '\u0000' |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
reference | null |
解析过程就是虚拟机将常量池内的符号引用替换为直接引用的过程。而直接引用指的指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。解析的动作主要针对的是类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用电限定符。
(3)初始化阶段
在此阶段中,程序员通过程序定制的主观计划去初始化类变量和其他资源,也就是执行类构造器<cinit>()方法的过程。<cinit>()方法是由编译器自动收集类中的所有类变量的赋值动作或静态代码块(statc{})中的语句结合产生的。我们无须显示调用父类构造器,会保证在子类的<cinit>()执行之前,父类的<cinit>()方法已经执行。那么什么时候类会被初始化呢,主要存在如下几种情况:
- 创建类的实例,包括:new、反射创建、反序列化方式创建等;
- 访问类的某一个静态方法;
- 访问个类或者接口的静态Field或为该静态Field赋值;
- 使用反射方式来强制创建某个类或者接口的java.lang.Class对象时;
- 初始化某个类的子类;
- 直接使用java.ext
经历了加载、连接、初始化,就可以使用这个类了。
3.加载顺序分析
在Java中,类的加载采用的是双亲委派模型,当接收到类加载的请求时,它首先不是自己去加载,而是交由其父类加载器完成,每一层都是如此,只有父类加载器反馈自己无法完成加载时,子加载器才会尝试自己去加载。如下图所示:
- 启动类加载器,又称根类加载器(Bootstrap ClassLoader),主要加载Java的核心库、核心包,主要位于lib下的jar包;启动类加载器的实现不是用的Java,而是C和C++;
- 扩展类加载器(Extension ClassLoader),主要负责加载JRE的扩展目录(ext)目录下的Jar包;
- 应用程序类加载器,就是应用程序中引用的主要的类;
- 自定义类加载器,通过集成ClassLoader来实现一个自己的类加载器;
(1) 检查此类是否已经被加载过,即在缓存区中查看是否有此Class,如果有,则直接跳到第(8)步;否则,执行第(2)步;
(2)如果父类加载器不存在(如果没有父类加载器,则有两种可能:父类加载器为根类加载器;本身就是根类加载器),直接跳到第(4)步;如果有父类加载器存在,则直接执行步骤(3);
(3)请求父类加载器去载入目标类,如果成功载入则直接跳到步骤(8);否则,执行步骤(5);
(4)请求使用根类加载器去加载目标类,如果成功则跳到(8);否则,执行第(7)步;
(5)当前类加载器尝试寻找Class文件(从与此ClassLoader相关的类路径中寻找),如果找到则进入(6);否则进入(7);
(6)从文件中载入Class,成功载入时进入(8);否则进入(7);
(7)抛出ClassNotFoundException异常;
(8)返回对应的java.lang.Class对象。
注意:第(5)(6)步运行重写ClassLoader的findClass()方法来实现自己的载入策略,甚至重写loadClass()方法实现自己的载入过程。JDK的关于loadClass的实现如下(JDK1.7中的实现):
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
<span style="color:#ff6666;">synchronized</span> (getClassLoadingLock(name)) {
// 此类是否被加载过
Class c = findLoadedClass(name);
if (c == null) {<span style="white-space:pre"> </span>//没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {<span style="white-space:pre"> </span>//是否存在父类加载器
c = parent.loadClass(name, false);<span style="white-space:pre"> </span>//父类加载Class
} else {
c = findBootstrapClassOrNull(name);<span style="white-space:pre"> </span>//根类加载器来加载
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);<span style="white-space:pre"> </span>//<span style="white-space:pre"> 从文件中载入Class</span>
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
值得注意的是,通过ClassLoader来进行加载类,并不会对其进行初始化;而通过Class.forname(***)进行加载,是会对其进行初始化的。
<span style="white-space:pre"> </span>try{
ClassLoader loader = ClassLoader.getSystemClassLoader(); <span style="white-space:pre"> </span><span style="font-family: Arial, Helvetica, sans-serif;">//取得类加载器</span>
Class dogClass = loader.loadClass("Dog");<span style="white-space:pre"> </span><span style="font-family: Arial, Helvetica, sans-serif;">//加载类Dog,但是不对其进行初始化</span><span style="white-space:pre">
</span> Constructor con = dogClass.getConstructor(String.class,double.class);<span style="white-space:pre"> </span><span style="font-family: Arial, Helvetica, sans-serif;">//反射获得Dog的有两个参数的构造器,此时仍然不会对Dog类进行初始化</span>
Dog dog = (Dog)con.newInstance("旺财",9.9);<span style="white-space:pre"> </span>//构造对象,此时会执行初始化操作
dog.sayHello();<span style="white-space:pre"> </span>//调用对象的方法
Class.forName("Dog");<span style="white-space:pre"> </span>//执行Class的惊天方法,将会加载类Dog,并对其进行初始化
}catch(Exception e){
e.printStackTrace();
}
4.类加载并发问题
在JVM的声明周期内,你的应用程序中的Java类只会被加载一次,但有些应用可能会依赖动态加载机制。在JDK1.7中,同一个类不能同时被加载(加载的时候,启动了sychronized),除非这个类标识为支持并发。当多个线程并发的加载某个类,同一个类的加载失败将会导致多线程之间对锁的竞争。现在一些J2EE容器如JBoss等,采用了并发类加载器,这些类加载器在更加细粒度的角度对类加锁,这样同一个类的加载器可以同时加载多个不同的类。JDK1.7支持自定义的并发类加载器,通过它可以放置类加载时出现的死锁。
java.*级别的类的加载由JDK默认的类加载器来进行加载,加载这样的类,可能出现一种情况:当加载的java.*级别的类不存在的时候,可能导致类的加载失败,而引发严重的线程锁的竞争。如下:
<span style="white-space:pre"> </span>String className =”java.lang.WrongClassName”;
<span style="white-space:pre"> </span>Class.forName(className);
在上述代码中,尝试去加载类java.lang.WrongClassName,因其是以java.*开头,将会将加载类的任务委派给JDK默认的类加载器来进行加载,其它的类就不能去加载,只能等待锁,效率就会降低。改变要加载的类,如下:
<span style="white-space:pre"> </span>String className = "org.ph.WrongClassName";
<span style="white-space:pre"> </span>Class.forName(className);
在上述的这样情况下,就不会将加载类的任务委托给JDK默认的类加载器,就没有同步操作,会使用
它会使用ConcurrentClassLoader.performLoadClassUnchecked()方法,也就不会触发对象监视器锁。
5.总结
类的加载时运行程序的一个重要阶段,理解类的加载机制,需要理解JAVA的内存模型、Class的文件格式、加载的详细过程。了解这些,能够帮助我们写出更佳健壮的代码。理解有限,需要在以后的工作和学习中继续深入的领会。
参考文献:
不容忽视的ClassNotFoundException: http://www.uml.org.cn/j2ee/201405291.asp
Java虚拟机 类加载的过程:http://blog.csdn.net/xuefeng0707/article/details/9132339