Java虚拟机Class装在系统初探(一)之类的连接及初始化

    当类加载之后需要进行类的连接,而此步骤包含了三个步骤来确定类的准确性,不会出现错误保证能够正确的初始化,所以这里需要进行下面三个步骤:

验证类:

其包含四个部分:分别为格式检查,语义检查,字节码验证,符号引用验证。

格式检查:必须判断类的二进制数据是否符合格式的要求和规范,比如魔数检查,版本的检查,以及长度的检查,是不是符合Java的文件的规范,检查的版本号是否可以在此虚拟机中运行,以及文件中的数据是否都拥有正确的长度。

语义检查:主要检查我们的类的一些规范问题,如是否final修饰类被继承等,是否有父类,抽象接口以及抽象类等中所有的方法时候都被实现了,除了Object类,其他类都应有父类,是不是存在不兼容的方法,只要是在语义上不符合规范的,虚拟机都不会再让其通过向下执行。

字节码验证:字节码验证也是一个最为复杂的过程,试图通过对字节码的字节码流进行分析,判断字节是否被正确的执行,比如在字节码执行过程中,是否会跳转到一条不存在指令,或者是没有传递正确的类型参数,以及变量的赋值是否给了正确的类型,其中StackMapTasble就是此阶段的执行,但是,完全正确的去确定一段字节码是否可以被安全的执行是无法实现的,因此该过程能够检查出比较明显的问题,若这个阶段不能够通过,同样的也不能够执行到下一步。但是通过也不能够说这个是没有问题的。

符号引用验证:Class文件会在其常量池通过字符串记录自己将要使用的其他类或者是方法,因此在类的验证阶段,虚拟机就会检查这些类或者是这些方法是确实存在的,并且检查其是否有权限访问这些方法,如果一个需要使用的类无法在加载系统中找到,那么将会抛出一个异常:NoClassDefFoundError,一个方法没有被找到将会跑出NoSuchMethodError。

准备阶段:

    当一个类验证通过时,虚拟机就会进入准备阶段,就是我们将要说的这个阶段,这个 阶段将会分配相应的内存空间,并设置初始值,注意这里并不是类的初始化,而更多的指字段的初始化赋值的意思,比如我们定义了一个int类型的字段,但是我们并没有赋予初始值,这个时候,此阶段就完成对此类的字段的初始值的初始化问题。

     需要注意的是,如果类存在常量字段,那么也会在准备工作阶段被赋值,这个赋值属于Java虚拟机的行为,属于变量的初始化,事实上在准备阶段不会有任何的Java代码被执行。如下面的例子:

public static final String constString = "CONST";

那么其对应的Class文件中在字段中就会生成带有ConsantValue属性的字段,这个字段直接存放在常量池中,这个常量是在准备阶段被赋值认为CONST,并不是由Java字节码引起的。

如果没有final的修饰,那么仅仅作为普通变量,此时的定义如下:

public static String constString = "CONST";

此时的constString的赋值在函数<clinit>中发生,这个就属于Java字节码的行为,这个字段上并没有带有任何的数据信息,在这个方法中,通过ldc指令进行压栈,并通过putstatic语句进行赋值。

解析类:

    通过准备工作完成之后就会进入解析的阶段,解析阶段核心的工作就是,将类,接口,字段和方法的符号引用转换为直接引用,符号引用就是一些字面量的引用,和虚拟机内部的数据结构和内存无关,一个较为容易理解的例子就是,Class文件中通过常量池进行了大量的符号引用。

通过一个简单的字节码来分析下解析的过程:

invokevirtual #23 <java/io/PrintStream.println>

假如说这里使用了常量池的第23项的内容,查看对应的常量池可以看到如下的结构:


    分析一下:按照invokevirtual查找之后顺着这层关系得到了上图的内容,对于所有的Class以及NameAndType都是字符串,因此invokevirtual的函数调用通过字面量的引用描述已经表达的较为清晰了,这里就是符号引用。

但是在实际的程序的运行中,仅有符号引用是不够的,当println方法被调用时,系统需要说明并明确该方法的位置,以方法为例,Java虚拟机中为每个类准备了一个方法表,将所有的方法都记录在这个表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以调用这个方法,通过解析操作符号引用就转换为目标方法在类中的方法表中的位置,从而方法被调用成功。

    本质上是通过解析操作,得到字段,方法等在内存中指针的偏移量,如果存在直接引用,那么可以肯定的是系统中存在该类,方法或者是字段,但只有在符号引用中,不能确定系统一定包含这个对象。在Java虚拟机中,其常量池会维护一个字符串形式的拘留表,这个表会保存所有的字符串常量,并且没有重复的项,只要是以CONSTANT_String形式出现的字符串也会保留在这个表中。

关于String.intern()方法的例子:

  首先上例子:

public class InitMain {

	public static void main(String[] args){
		String a = Integer.toString(1) + Integer.toString(2) + Integer.toString(3);
		String b = "123";
		System.out.println(a.equals(b));
		System.out.println(a == b);
		System.out.println(a.intern() == b);
	}
}

运行结果为:


分析下不难发现,a和b通过equals来比较字面量是不是都是“123”,所以equals比较的结果为true,但是可以明显的知道,a和b的引用是不同的,所以 “==”运算的结果为false,第三个可以发现使用了intern函数,因为a在拘留表中的引用就是b常量本身,所以a.intern == b 为true。更为详细的参考此博客。

初始化:

类的初始化是类的装载的最后一个阶段,如果前面的步骤没有问题,那么表示类可以顺利的装载到系统中,此时,Java字节码才开始被执行,初始化阶段最重要的工作就是执行类的初始化方法<clinit>,此方法由静态成员的赋值语句以及static语句块合并产生的。

public class SimpleStatic {

	public static int id = 1;
	public static int number;
	static{
		number = 4;
	}
}

编译器回味这段代码生成如下的<clinit>:

iconst_1
putstatic
iconst_4
putstatic
return

可以看到生成的<clinit>函数中整合了SimpleStatic类中的static赋值语句以及static语句块,先对id和number两个成员变量进行赋值。由于在加载一个类之前虚拟机总是会试图加载该类的父类,因此父类的<clinit>总是在子类的该方法之前调用。

再来一个例子:

public class ChildStatic extends SimpleStatic {

	static{
		number = 2;
	}
	public static void main(String[] args){
		System.out.println(number);
	}
}

这个子类继承了上面的SimpleStatic类,并且在其static语句块中重新定义了number赋值为2.在main()方法中,输出变量number结果为2,ChildStatic类的static语句块覆盖了父类中语句块。但Java编译器并不会为所有的类产生<clinit>初始化函数,如果一个类既没有赋值语句,也没有static语句块,那么生成的<clinit>函数应该为空,因此,编译器就不会为该类插入<clinit>函数。

例如下面这个例子:

public class StaticFinalClass {

	public static final int i =1;
	public static final int j = 2;
}

这个类只有final常量,而final常量在准备阶段就完成了初始化,并不在初始化阶段处理,所以<clinit>函数就没有任务执行,在产生的class文件中,没有该函数的存在。

    注意,对于函数<clinit>调用也就是类的初始化虚拟机会在内部确保其多线程环境中的安全性,也就是说当多个线程试图初始化同一个类的时候,只有一个线程可以进入<clinit>函数,而其他线程必须等待,如果之前的线程成功的加载了类,则等在队列中的线程就没有机会再执行<clinit>函数了,正是因为此函数是线程安全的,因此在多线程换进行初始化类的时候,可以能引起死锁,并且这种死锁是非常难以发现的,因为看起来他们并没有可用的锁信息。

可以参考下面 的例子:

public class StaticA {

	static{
		try{
			Thread.sleep(1000);
			
		}catch(InterruptedException e  ){
			
		}
		try{
			Class.forName("....StaticB");
		}catch(ClassNotFoundException e){
			e.printStackTrace();
		}
		System.out.println("StaticA init OK");
	}
}

StaticB类:

public class StaticB {

	static{
		try{
			Thread.sleep(1000);
			
		}catch(InterruptedException e  ){
			
		}
		try{
			Class.forName("...StaticA");
		}catch(ClassNotFoundException e){
			e.printStackTrace();
		}
		System.out.println("StaticB init OK");
	}
}

测试运行:

public class StaticDeadLockMain extends Thread {

	private char flag;
	public StaticDeadLockMain(char flag){
		this.flag = flag;
		this.setName("Thread" + flag);
	}
	
	@	Override
	public void run(){
		try{
			Class.forName("...Static" + flag);
		}catch(ClassNotFoundException e){
			e.printStackTrace();
		}
	}
	
	public static void main(String[] args){
		StaticDeadLockMain loadA = new StaticDeadLockMain('A');
		loadA.start();
		StaticDeadLockMain loadB = new StaticDeadLockMain('B');
		loadB.start();
	}
}

我们在StaticDeadLockMain中创建了两个线程,在StaticA的初始化过程中,会去尝试初始化StaticB,同样的在初始化StaticA的时候也会尝试去初始化StaticB,这时候就会出现系统死锁了。


所以在实际的工作中一定要注意这种情况的发生,避免由于类的加载而导致的这种难以发现的错误。

猜你喜欢

转载自blog.csdn.net/qq_18870127/article/details/80487905