虚拟机类加载过程

Java从诞生时就以平台无关性作为卖点,Java程序步不直接运行在操作系统上的,而是在操作系统上又提供了一层虚拟机。虚拟机为Java程序员提供了一套规范,这套规范与操作系统无关,与操作系统相关的工作就交由Java虚拟机来完成。Sun公司当初在发布Java规范的时候,刻意拆分成《Java语言规范》和《Java虚拟机规范》,以实现让其他语言运行在Java虚拟机上。如今,有一大批运行在Java虚拟机上的语言,如Scala、Jython、Groovy等,Java虚拟机已经开始具有了语言无关性的特点。实现无关性的原因是Java虚拟机在运行程序的时候,只与Class文件打交道。至于这个Class文件的由来,虚拟机并不关心,Class文件可以是javac编译出来的Class文件,或者是groovy编译器编译出来的Class文件,也可以是从网络上获得的。虚拟机在运行程序的时候,首先需要把Class文件加载虚拟机中,这就涉及到虚拟机类加载机制。虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是Java虚拟机的类加载机制。

知道Java虚拟机的类加载机制对与Java程序员还是很有帮助的,尤其是类加载的准备阶段和初始化阶段,是Java程序员必须了解的,这也是本文的重点。

Java语言里面,类型的加载是在运行期动态进行的,只有在使用到该类的时候才会加载。那么,虚拟机会在哪些条件下去加载一个类呢?

类加载的时机

上图表示了一个类在虚拟机的生命周期,其中加载,验证,准备,解析,和初始化就是类的加载过程。虚拟机并没有规定何时将一个类加载到内存里面,但是规定了何时进行类加载的初始化阶段。当”首次主动使用“一个类时,必须对其进行初始化,虚拟机规范严格规定了有且只有5中情况下属于对类的主动使用:

(1)遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果没有对类进行初始化,则需要先出发其初始化。new最常见的Java代码场景:创建一个类的实例;getstatic:调用类的静态变量;putstatic:设置类的静态变量;invokestatic:调用类的静态方法;

(2)对类反射调用是,如果没有进行过初始化,则需要先触发其初始化;

(3)当初始化一个类时,发现其父类尚未初始化时,先触发父类的初始花;

(4)虚拟机启动时被表明为启动类的类(包含main()方法的类);

(5)使用Java7中的动态语言支持时。

除了以上5种情况外,其他的都属于对类的被动使用,不会触发类的初始化。注意,我们这里所说的是类的初始化,不是对象的初始化,不要跟在堆中创建对象的初始化混淆。

类初始化的特例

关于类初始化的场景上看上去很清晰,其实还是会有些不是那么清晰的情况,有些事由于对主动使用的理解不够深入,有些则属于不能理解的场景,通过举几个例子来说明。

静态常量

import java.util.Random;
class FinalTest1 {
	/* 如果x的值是一个常量,也就是说在编译的时候就能确定
	 * 那么在调用这个静态常量时,不会触发该类的初始化
	 */
	public static final int x = 6;
	
	static 
	{
		System.out.println("静态常量不会触发类的初始化");
	}
}

class FinalTest2 {
	/* 如果x的值需要在运行时才能确定,
	 * 在调用这个静态变量时,就会对这个类进行初始化
	 */
	public static final int x = new Random().nextInt(100);
	
	static 
	{
		System.out.println("需要在运行时才能确定值,会触发类的初始化");
	}
}

public class FinalStatic {
	public static void main(String[] args) {
		System.out.println(FinalTest1.x);//不会初始化FinalTest1
		System.out.println(FinalTest2.x);//会初始化FinalTest2
	}

}
final修饰的静态常量在编译阶段会存入调用类的常量池 ,在使用静态常量时,本质上并没有直接引用定义常量的类,所以不会触发类的初始化;如果需要在运行时才能确定其值,则会触发类的初始化。

子类使用父类的静态变量

class Parent {
	public static int a = 3;
	static {
		System.out.println("Parent static block");
	}
}

class Child extends Parent {
	static {
		System.out.println("Child static block");
	}
}

public class ClassExtends {
	static {
		System.out.println("ClassExtends static block");
	}
	/*ClassExtends是启动类,虚拟机会首先触发它的初始化
         *Child类使用的是父类的静态变量,getinstatic只会触发定义该静态字段的类
         */
	public static void main(String[] args) {
		System.out.println(Child.a);
		
	}
}
上面一段程序的输出是:ClassExtends static block

                                            Parent static block

                                            3

反射中的.class语法

package com.ssy.classloader;
public class InitClass {
	static {
		System.out.println("Initclass");
	}
	
	public static void main(String[] args) throws ClassNotFoundException {
		/*.class语法不会触发类的初始化,至于为什么,似乎不可理解 */
		Class<?> clazz = AClass.class;
		System.out.println("--------------");
		clazz = Class.forName("com.ssy.classloader.AClass");
	}
}

class AClass {
	static {
		System.out.println(".class不会触发类的初始化");
	}
}
创建数组
package com.understanding.classloader;

public class NotInitClass2 {
	
	public static void main(String[] args) {
		OneClass[] array = new OneClass[10];
	}
}

class OneClass {
	static {
		System.out.println("static block");
	}
}

运行上面的程序之后会发现,没有输出”static block“语句,说明没有初始化OneClass类。看一下生成的字节码指令:


创建数组的动作由字节码指令newarray触发,它并没有创建10个OneClass对象,而是创建了一个代表数组元素类型为”com.understanding.classloader.OneClass“的一维数组类,也就是”Lcom/understanding/classloader/OneClass“类,这个类由虚拟机生成,继承自Object类。Java数组中的属性和方法(程序员可见的只有length和clone())都封装在这个类中。

注意,在初始化接口时稍有不同:当一个类初始化时,其父类必然已经初始化;但是当初始化一个接口时,并不要求其父接口已经初始化。

上面讲了那么多的初始化,似乎没有提到类加载的加载阶段。因为何时加载一个类,虚拟机规范没有明确说明,由各实现自己确定,但是初始化一个类则是明确了的,在初始化类之前,必然已经加载了该类。

====================================================================================================================================

加载

”加载“是类加载的一个阶段,在加载阶段,虚拟机需要做3件事:

(1)通过类的全额限定名来获取定义此类的二进制字节流;

(2)将字节流所代表的静态存储结构转化为方法去的运行时数据结构;

(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

每一个Java类都有一个对应的Class对象,它就像一个“镜子”一样反射出类的所有内容,这也是反射的基础,这个Class对象就是在加载阶段生成的。Java虚拟机没有规定是再Java堆中实例化Class对象,但是对于HotSpot而言,Class对象虽然也是对象,但是它存放在方法区(在Java8中,已经去掉了方法区,所以针对的都是Java7及之前版本,后面提到的方法区,也是如此)。

验证

验证阶段对虚拟机而言非常重要,验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机要求,不会危害虚拟机自身的安全。前面,我们已经提到了,虚拟机并不区分这个Class文件的来源,通过javac编译的Class文件一般是不会危害虚拟机安全的,但是Class文件的来源是多种多样的,甚至可以用十六制编辑器直接编辑Class文件。虚拟机会进行4个阶段的校验:

(1)文件格式验证:通过这个阶段的验证后,字节流才会进入内存中;

(2)元数据验证;

(3)字节码验证;

(4)符号引用验证。

验证阶段对虚拟机是重要的,但是如果能确保Class文件是安全和正确的,那么就可以关闭虚拟机的大部分校验,以此缩短类加载时间。

准备

准备阶段是正式为类变量分配内存并设置类变量的初始值,类变量使用的内存在方法去中分配。这里有两点需要强调:

(1)是类变量(被static修饰的变量),而不是为实例变量,实例变量是在创建类的对象时随对象一起分配在Java堆中;

(2)是设置初始值,而不是程序规定的值。

一个类中定义的类变量:

private static int value = 25;

在准备阶段,value = 0;准备阶段给变量设置的零值(也就是各类型的默认值)。在Java中,类的域(包括实例域和静态域)在使用之前,至少会拥有一个值,其中静态域就是通过类加载的准备阶段设置初始值来完成的;实例域是通过在分配对象时,首先将那块内存清零来完成的。

这里还有一个特殊情况,如果类变量是常量,那么在准备阶段,虚拟机就会把该变量设置为常量值:

private static final int value = 25;

在准备阶段,value的值就已经是25了。

解析

解析阶段是虚拟机将常量池内的符号饮用替换为直接引用的过程。解析阶段的内容还是比较丰富的,具体的可以参考《Java虚拟机规范》第三版。

====================================================================================================================================

初始化

初始化阶段就是为类变量赋予程序显式设定的值,而不再是准备阶段赋予的默认初始值。一般而言,给类变量赋值有两种方式:

//在定义处赋值
static int value = 25;

//或者在静态语句块中
int value;
static {
    value = 25;
}

所有的类变量赋值动作和静态语句块中的语句都被Java编译器收集到一起,放到一个特殊的方法中—类构造器<clinit>方法(接口也有这个方法),编译器收集的顺序是由语句在源文件中出现的处顺所决定的。<clinit>方法对程序猿是不可见的,只能被虚拟机调用。换句话说,初始化阶段是执行类构造器<clinit>()方法的过程。初始化一个类包括两个步骤:

(1)如果类存在直接父类,且直接父类还没有初始化,先初始化直接父类;

(2)如果类存在类构造器<clinit>()方法,就执行该方法。

从上面两步来看,我们可以得出<clinit>()方法的特点:

(1)父类总是在子类之前被初始化,这意味着<clinit>()方法不需要显式的调用父类的类构造器,虚拟机会保证子类的<clinit>()方法在执行前,父类的<clinit>()方法已经执行完毕。java.lang.Object是所有类的父类,因此虚拟机中第一个被初始化的<clinit>()方法肯定是Object类的。

(2)如果存在类<clinit>()方法,就执行。也就是说,<clinit>()方法对于类和接口而言并不是必须的的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,编译器就可以不为这个类生成<clinit>()方法。如果一个类定义了类变量,但是没有在定义处或者静态语句块中显式的赋值,编译器也可以不为这个类生成<clinit>()方法。

下面是我在JDK1.7下的测试,第一张图中只定义了静态变量,没有给它显式赋值,javac编译器果然没有给这个类生成<clinit>()方法:

class Clinit {
    static int a;
}
使用javap -verbose Clinit.class发现,编译器没有生成<clinit>()方法:


class Clinit {
    static int a = 1;
}
显式的给类变量a赋初始值,编译器必须要生成<clinit>()方法:



(3)如果类中仅仅定义了静态常量,形如:

static final int value = 123;
我们知道,常量是在编译阶段存入类的常量池中,value字段也没有被当做类变量,任何使用value字段的类都不会引用定义该常量的类,而是会保存value常量值123的本地拷贝。这也就是我们前面讲的,使用类的静态常量不属于对类的主动使用,不会触发类的初始化。如果仅包含静态常量,那么编译器可以不为该类生成<clinit>(0方法。通过一小段代码来说明上面的内容,Java代码如下:
public class Clinit {
	 static  final int value = 123;
	 static final double rand = Math.random();
}
通过使用javap -c Clinit.class得到如下内容:
static {};
Code:
   0: invokestatic #2 //Method java/lang/Math/random:()D
   3: putstatic #3 //Field rand:D
   6: return

其中只有对double类型rand的赋值,并没有对int类型的常量value赋值。


(4)关于接口

a.我们在前面说过,初始化一个接口时并不要求必须初始化其父接口。因此,执行接口的<clinit>()方法也不需要先执行父接口的<clinit>()方法。
b.接口中不能使用静态语句块,且接口中的域都自动是public static final的,如果只包含静态常量,则可以不生成<clinit>()方法。如果域的值不是常量,也就是无法在编译期计算,则必须生成<clinit>()方法。

c.接口中的域并不是接口的一部分,它们的值被存储在该接口的静态存储区域。这句话的意思是说,初始化实现该接口的类时,不要求对接口中的域初始化,也就是说不会执行接口的<clinit>()方法。它们在首次被访问时被初始化。

/**
 * 如果类中只定义了静态变量,但是没有在定义处或者静态语句块中对其显式赋值,编译器可以不生成<clinit>()方法
 * 如果类中值定义了静态常量,编译器可以不生成<clinit>()方法
 */
public class Clinit implements SubInit{
	
	 static  final int value = 123;
	 static final double rand = Math.random();
	
	 public void f() {
		 System.out.println("实现的f()方法");
	 }
	 
	 public static void main(String[] args) {
		 /*初始化接口的实现类时不会执行接口的<clinit>()方法*/
		 System.out.println(rand);
		 new Clinit().f();
		 System.out.println("---------------");
		 /*只有在首次访问域时,才会执行接口的<clinit>()方法*/
		 Print p = SubInit.sub;
		 Print p2 = SubInit.sub;
	 }
}

/**
 * 接口中的域都是static final的,而且必须要显式的赋值,不能使用默认值
 * 如果接口中的域是静态常量,编译器可以不生成<clinit>()方法
 * 接口中不能有static {}
 */
interface SubInit extends BaseInit {	
	/*sub必须在运行时才能创建,所以该接口要生成<clinit>()方法*/
	Print sub = new Print("在SubInit接口中创建的对象");
	void f();
}

/*执行接口的<clinit>()方法,不需要求先执行父接口的<clinit>()方法*/
interface BaseInit {
	Print base = new Print("在BaseInit接口中创建的对象");
}

/*测试类,只打印一句话*/
 class Print {
	public Print(String message) {
		System.out.println(message);
	}
}

程序的输出:
0.3710394328568716
实现的f()方法
---------------
在SubInit接口中创建的对象
通过前两行的输出可以看出,初始化接口的实现类时不会初始化该接口;最后一行输出说明,只有在首次用到时才会对接口进行初始化,也就是对域进行初始化,当然了,也只会初始化一次。输出中并没有来自父接口BaseInit的内容,说明没有执行BaseInit的<clinit>()方法。


(5)虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那么就可能造成多个线程阻塞,这种阻塞往往又很隐蔽,造成调试的困难。周志明老师举的一个例子很好,可以拿出来欣赏一下:

public class ClinitDeadLoop {
	/**
	 * 当有多个线程去初始化一个类,只有一个线程会执行类的<clinit>()方法,其他方法需要阻塞等待
	 */
	public static void main(String[] args) {
		Runnable script = new Runnable() {
			public void run() {
				System.out.println(Thread.currentThread().getName() + "start");
				//对类DeadLoopClass进行初始化,最终会陷入死循环
				DeadLoopClass dlc = new DeadLoopClass();
				System.out.println(Thread.currentThread().getName() + "run over");
			}
		};
		
		Thread thread1 = new Thread(script,"线程1");
		Thread thread2 = new Thread(script,"线程2");
        thread1.start();
        thread2.start();

	}
	
	/*执行该类的<clinit>()方法的代价是无穷大*/
	static class DeadLoopClass {
		static {
			//如果不加这个if语句,编译器将提示:Initializer does not complete normally,并拒绝编译
			if(true) {
				System.out.println(Thread.currentThread().getName() + " 在执行<clinit>()方法时无限循环");
				while(true) {	
				}
			}
		}
	}
}
程序的执行结果:
线程1start
线程2start
线程1 在执行<clinit>()方法时无限循环
我们可以看到,线程1在执行类DeadLoopClass的<clinit>()方法时陷入死循环,线程1永远也没有办法执行完该方法。所有使用DeadLoopClass的线程都必须阻塞等待,在实际应用中必须避免这种情况的发生。

<clinit>()方法是在编译期的语义分析与字节码生成阶段被编译器生成的的。生成<clinit>()方法实际上是一个代码收敛的过程,编译器会把静态语句块(static {})、类变量初始化等操作收敛到<clinit>()方法中,虚拟机会保证父类的<clinit>()方法先执行(对类而言)。


以上的内容着重讲了类加载的初始化阶段,其他阶段太过于抽象和理论化,但是还是必须要理解虚拟机的类加载机制,帮助更好的理解Java中何时执行静态语句块,何时对静态变量赋值等问题。

参考资料:《深入Java虚拟机》第二版,《深入理解Java虚拟机》,《Java虚拟机规范》第三版。转载请注明处处:http://blog.csdn.net/yuhongye111/article/details/30799131

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值