深入理解JVM(虚拟机类加载机制)

1.概述

在class类中的信息需要最终加载到JVM中才能够运行和使用。JVM把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化后,最终形成可以被JVM直接使用的java类型。
与在编译期就进行连接的语言不同,java对Class文件的加载,连接,初始化是在运行时进行的,虽然加大了运行时的一些开销,但是对java的灵活性提供了很大好处。java动态扩展的特性就得益于java的动态加载和动态链接两个特点。java可以运用预定义或者自定义类加载器,把本地的应用程序与网络或其他地方加载进来的class文件进行连接,把它们作为程序的一部分。这种组装的方式在java中有广泛的应用,入applet,jsp等。

2.类加载时机:
类的生命周期包括:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析称为连接。加载,验证,准备,初始化是按顺序进行的,但是解析可能会在初始化之后进行,这是为了支持java的运行时绑定(动态绑定)。
JVM中规定了“有且只有”五种情况必须进行初始化操作(当然在此之前需要加载,验证,准备工作):
a>遇到new, putstatic, getstatic, invokestatic 指令时如果没有初始化需要进行初始化,对于java里的场景是:new一个实例化对象,读取或者设置一个静态变量(被final修饰的对象,在编译期放入到常量池的变量除外),调用静态方法。
b>如果使用reflect包中的方法对类进行反射调用时,如果没有初始化则需要初始化。
c>对一个类初始化时,如果发现其父类没有进行初始化则需要先对其父类进行初始化。
d>当JVM启动时,用户需要先指定一个主类(包括main方法的类)。JVM会对主类进行初始化。
e>使用jdk1.7的动态语言支持时,如果一个MethodHandle实例最后的结果句柄是REF_getstatic,REF_putstatic,REF_invokestatic的方法句柄。并且句柄对应的类没有初始化,则需要对其初始化。
着五种场景中的行为被称为对类的“主动引用”。其他对类的引用方式都不会产生初始化,称为“被动引用”。以下列举几个“被动引用”的程序实例:

public class SuperClass {


public static int value=123;

static {

System.out.println("super class init");

}

}

public class SubClass extends SuperClass {


static {

System.out.println("sub class init");

}

}

public class NotInit {


public static void main(String[] args) {

System.out.println(SubClass.value);

}

}

结果:静态变量的所有者类才会进行初始化。子类调用父类的静态变量不会进行初始化,通过-XX:+TraceClassLoading参数可以看出subClass会进行加载,校验。

public class NotInit {


public static void main(String[] args) {

SubClass[] subs = new SubClass[10];

}

}

结果:最终没有输出初始化,subclass没有进行初始化,但是可以出发一个LSuperClass对象的初始化,该对象表示一维数组。

public class ConstClass {


public static final int value = 123;

static {

System.out.println("const class init");

}

}

public class NotInit {


public static void main(String[] args) {

System.out.println(ConstClass.value);

}

}

结果:没有输出constClass的初始化信息。由于在编译期间,constClass就将value常量放入到NotInit的常量池内。NotInit不再有ConstClass的符号引用入口,两个类在编译期就没有关系了。

接口与类的加载过程有所不同。接口也有初始化过程。接口不能使用static{},但是编译器仍然会给接口创建构造方法,用来初始化变量。接口与类不同之处是5种情况中的第3种,子类初始化时需要所有父类都初始化,而接口不要求接口初始化时所有父接口都初始化,使用到哪个接口就初始化哪个接口。


3.类加载过程

类加载是加载,校验,准备,解析,初始化这5个步骤具体执行的过程。

3.1加载

加载过程虚拟机需要做如下3件事:1.根据类的全限定名加载类的二进制流。2.根据这个流所代表的静态结构转化为方法区内的数据机构。3,在内存中生成一个java.lang.Class对象,作为该类访问方法区数据结构的入口。

通过全限定名加载类二进制流,JVM没有规定这个流一定从Class文件中获取,它可以从网络,压缩包等地方获取。这样虚拟机为该阶段搭建了一个广阔的舞台。很多技术就建立在此基础之上。例如:

a>从zip包过去,这成了日后jar,war等格式的基础

b>从网络中获取,该应用场景为applet。

c>运行时阶段计算生成。应用场景是动态代理,为特定接口生成代理类的二进制流。

d>由其他文件生成,应用场景是由jsp文件生成Class文件。

e>从数据库生成,应用场景较少见。

相对于类加载过程的其他阶段来说,对非数组对象的加载过程是容易控制的。用户即可以用系统的引导类来完成,也可以自定义加载器来完成类的加载。而数组类的类情况不同,数组不是通过类加载器创建的,而是JVM自己创建的,但是它又与类加载器有关系。因为数组里的组件类型最终要靠类加载器去创建。一个数组的创建遵循以下规则:

a>如果是引用类型,那么JVM就递归的采用该节定义的加载过程加载这个类型,然后把该数组类在加载该类型的类加载器的类名空间上进行标记。

b>如果不是引用类型,JVM就把该数组类标记为引导类加载器相关联。

c>数组类的可见性与组建类型的可见性相同,如果不是引用类型,那么数组默认是public。

加载完成后,JVM按照需要的数据格式把二进制流存储到方法区。然后在内存实例化一个Class对象(JVM没有指定该对象是否存储在java堆,HotSpot将它存储在了方法区)作为该类访问方法区数据类型的接口。

加载过程与部分链接过程是交替进行的,加载还没完成,连接可能已经开始。但是它们的开始时间是按先后顺序进行的。

3.2验证

验证是JVM连接的第一步,这一步的目的是确保二进制流的信息符合JVM的要求。并且不会危害JVM的安全。

java语言是安全的,它不会出现访问数组边界以外的数据,将一个类型转换为它没有实现的类型,跳转到不存在的代码之类的问题。如果这样做了编译器将拒绝编译,但是二进制流不一定是java源代码编译生成的,它有可能来自任何途径,而上边问题的语义是可以通过二进制流表达的,所以如果JVM不经过验证加载一些有害的二进制流进来,可能造成程序的崩溃。

验证过程大概会分成4个验证动作:文件格式验证,元数据验证,字节码验证,符号引用验证。

a>文件格式验证:这一阶段验证二进制流是否符合JVM规范,并且是否能被当前版本的虚拟机执行。该阶段可能包括以下验证点:1.是否以魔数开头。2,主次版本号是否在JVM规定范围内。3,常量池中的常量是否有不被支持的类型....第一阶段的验证点有很多,该阶段目的是确保对二进制流能正确解析并且存储到方法区中,这个阶段的验证是针对二进制流进行的,以后的几项验证都是针对方法区中的数据类型进行的。

b>元数据验证:第二阶段对字节码描述的语义进行分析,以确保它描述的语义符合java语言规范。1.这个类是否有父类(除了Object外,其他的类都应该有父类)。2.这个类的父类是否继承了不允许被继承的类(被final修饰的类)。3.如果这个类不是抽象类,是否全部实现了父类或者接口中要求实现的方法。4.类中的字段方法是否与父类产生了冲突(入继承了父类中final的字段,重载的方法与父类中要求的不同)。

c>字节码验证:这个阶段是验证过程中最复杂的一个阶段,通过对数据流和控制流分析,确保程序的语义是合法的,符合逻辑的。第二个阶段对元数据的数据类型进行校验后,将对方法体进行校验。以确保被校验后的方法体不会对虚拟机产生危害。验证点包括:1.任意时刻操作数栈的数据类型与指令序列能配合工作(例如操作栈中放了一个int类型的数据,但是按long类型加载入本地变量表中)。2.确保跳转指令不会跳转到方法体以外的字节码上。3.确保方法体内的类型转换是有效的。例如不能出现父类对象指向子类引用,或者与之完全无关的引用。

d>符号引用验证:最后一个阶段的校验出现在将符号引用转换为直接饮用的时候,这个转化动作出现在JVM链接的第三个阶段--解析中发生。符号引用验证可以看中是对类以外的信息进行匹配性验证。需要验证点包括:1.符号引用通过字符串描述的全限定名能否找到对应的类。2.符号引用的类,方法,字段的访问性能否被当前类所访问。。。。符号引用的目的是确保解析动作能正常执行。如果不能通过符号引用验证讲抛出nosuchmethodError, noshchFiledError等异常。

对于JVM来说验证是一个非常重要但是非必需的过程,如果一个类被实践执行过很多次,可以通过参数-Xverify:none关闭验证阶段,加快类加载速度。

3.3准备

准备阶段是正式为变量分配内存,并赋初始值的阶段。这些变量的内存都在方法区内进行分配。这里分配内存的变量仅是类变量(被static修饰的变量),不包括实例变量,实例变量是在java堆上分配的。这里所说的初始化“通常情况”是数据类型的零值。还有一种特殊情况是被final修饰的变量,这种变量会赋予初始化的值放入常量池中。

3.4解析

解析阶段是JVM把常量池中的符号引用转化成直接引用的阶段。符号引用是指在Class文件中以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型出现的常量。1.符号引用:以一组符号来描述引用目标,符号引用可以是任何形式的字面值,只要使用时无歧异的定位到引用目标即可。符号引用与虚拟机的内存布局无关,引用目标不一定加载到内存中。个虚拟机的内存布局不同,但是它们可以接受的符号引用必需是一致的。2.直接引用可以是指向目标的指针,偏移量或者间接指向目标的句柄。直接引用和内存布局相关,不同虚拟机翻译过来的直接引用不同。如果使用了直接内存,那引用目标必定存在于内存中了。

只要是执行了操作符号引用的指令之前,先对它们使用的符号引用进行解析。对同一符号引用进行多次解析请求是很常见的,除invokedynamic指令外,JVM实现了可以对第一次解析结果进行缓存(在运行时常量池中记录直接以引用,并且把常量置为已解析),从而避免重复解析。JVM需要保证在同一个实体中,第一次符号解析成功,那么以后的解析请求也应该成功。如果第一次解析失败,那么以后的解析应该返回同样的异常。而invokedynamic指令不行,该指令的目的是用来支持动态语言,只有执行到这条指令时才进行解析。所以之前的解析结果对本次解析无效。

解析的动作主要针对类或接口,字段,方法,接口方法等7种符号类型进行,以下介绍其中前四中:

<此处不理解暂且略过>

a>类或接口的解析:假设当前所在的代码是类D,如果要把还没解析的符号引用N解析为一个类或接口C的直接引用需要以下几个步骤:1.如果C不是一个数组类型,那么JVM把代表N的全限定名交给D的类加载器。在加载过程中由于进行元数据验证,字节码验证可能会触发其他类加载器的动作,例如加载这个类的父类或接口。在加载过程中出现如何异常,解析宣告失败。2.如果C是一个数组类型,并且数字的元素类型为对象。那么会按照低一点的方式加载数组类型。接着由JVM生成一个代表此数组维度和类型的数组对象。3.如果以上步骤都没异常,那么C在JVM中已经是一个有效的类或者接口了,接下来还要确认D对C的访问权限。如果不具备访问权限,会抛出IllegalAccessError异常。

</此处不理解暂且略过>

3.5初始化:

类初始化阶段是类加载过程的最后一步,前面加载时用户可以自定义类加载器进行加载外,其他阶段都是JVM主导完成的。到了初始化阶段才开始真正执行java代码(字节码)。

在准备阶段,变量已经赋过一次系统要求的初始值了,而在初始化阶段,可以根据程序猿的主观愿望去给变量赋值。初始化过程是执行<clinit>()方法的过程。1.<clinit>()方法是由类变量的赋值动作和static{}代码块里的语句合并产生的。它们的顺序由语句在源文件中的顺序决定。静态语句块只能访问到定义在它之前的变量,定义在它之后的变量不能访问。2.<clinit>()于<init>()方法不同,它不用显示的调用父类的构造方法,在执行子类<clinit>()方法之前,JVM确保已经执行了它的父类构造方法。所以JVM第一个执行的<clinit>()方法肯定是Object。3.父类的<clinit>()方法优先执行,也就是父类中静态代码块中变量的赋值,优先于子类变量中的赋值操作。4.<client>()方法不是必须的,如果类中没有静态代码块,也没有对变量的赋值操作,那么编译器不对该类产生该方法。5.接口中不能使用静态代码块,但仍然需要赋值操作,因此接口中也需要有<clinit>()方法。但接口和类不同,接口执行<clinit>()之前不需要先执行父接口的该方法,只有父接口定义的变量使用时才执行。另外,实现了某个接口的类执行<clinit>()时,也不会出发该接口执行<clinit>()方法。5.JVM保证一个类的<clinit>()方法在多线程环境中被正确的枷锁,同步。如果一个线程执行该方法时,其他线程都进入阻塞状态,例如以下程序示例:

public class ClinitTest {


static class C1 {

static {

if(true) {

System.out.println(Thread.currentThread() + " init");

while(true) {}

}

}

}

public static void main(String[] args) {

Runnable r = new Runnable() {

@Override

public void run() {

System.out.println(Thread.currentThread() + " start");

C1 c = new C1();

System.out.println(Thread.currentThread() + " end");

}

};

Thread t1 = new Thread(r);

Thread t2 = new Thread(r);

t1.start();

t2.start();

}

}

结果:Thread[Thread-0,5,main] start

Thread[Thread-1,5,main] start

Thread[Thread-1,5,main] init


4.类加载器

虚拟机团队把通过类全限定名来获取此类的二进制流这个动作放倒虚拟机外部实现。以便让程序本身去决定如何去获取所需要的类。实现这个动作的代码块称为“类加载器”。

4.1类与类加载器

对于任意一个类来说需要由加载该类的类加载器和类本身共同确定其在JVM中的唯一性。每一个类加载器都有一个独立的类命名空间。通俗来说就是比较两个类是否相等,只有同一个类加载器加载的前提下才有意义。否则,来源于同一个Class文件,被同一个JVM加载。不是被同一个类加载器加载的话,两个类也一定不同。

这里指的相等。包括Class对象的equals()方法,isAssignableFrom(),isInstance()方法返回的结果。也包括instanceof()关键字对对象所属关系做的判断等。

public class ClassLoaderTest {


public static void main(String[] argsthrows Exception {

ClassLoader cl = new ClassLoader() {

@Override

public Class<?> loadClass(String namethrows ClassNotFoundException {

try {

String fileName = name.substring(name.lastIndexOf(".")+1) + ".class";

InputStream is = getClass().getResourceAsStream(fileName);

if(is == null) {

return super.loadClass(name);

}

byte[] b = new byte[is.available()];

is.read(b);

return defineClass(nameb, 0, b.length);

}catch(IOException e) {

throw new ClassNotFoundException(name);

}

}

};

Object o = cl.loadClass("com.pubukeji.boss.test.ClassLoaderTest");

System.out.println(o.getClass());

System.out.println(o instanceof com.pubukeji.boss.test.ClassLoaderTest);

}

}

结果:instanceof执行结果为false,因为ClassLoaderTest着个类,一个是由系统类加载器加载,另一个是由自定义的类加载器加载。JVM中存在连个类,虽然是由同一个源文件编译而来。

4.2双亲委派

站在虚拟机的角度看,类加载器只有两种:1.启动类加载器(Bootstrap classloader),是由C++实现。属于JVM的一部分2.其他类加载器,是由java实现。独立于JVM外部,并且全部继承与java.lang.ClassLoader抽象类。

从开发人员的角度看,可以分为3种类加载器:

a>启动类加载器(Bootstrap classloader):该类加载器是把<JAVA_HOME>/lib目录下,或者-Xbootclasspath参数所设定的路径中,被JVM识别的类库加载到JVM内存中。启动类加载器无法别java程序直接引用。用户在编写自定义加载器时,如果需要把加载请求委派给双亲,可以用null代替。

b>类扩展加载器(extension classloader):该加载器是由sun.misc.Launcher$ExtClassLoader实现。该加载器加载<JAVA_HOME>/lib/ext目录,或者java.ext.dirs系统变量定义的目录所有类库。该类加载器可以被程序员直接使用。

c>应用程序类加载器(Application ClassLoader):这个加载器由sun.misc.Launcher$AppClassLoader实现。这个加载器时ClassLoader中的getSystemClassLoader的返回值,所以也称为系统类加载器。他负责加载用户路径(classpath)中的类库。开发者可以直接使用这个加载器,如果应用程序中没有定义自定义类加载器,一般会使用该类加载器。

d>用户自定义类加载器,如果需要用户还可加入自定义类加载器。这几个类加载器的关系如图:

781127.png

该模型称为“双亲委派模型”:双亲委派模型要求除了启动类加载器外,其他类加载器都需要有父加载器。子加载器一般不用继承实现,而是用组合的方式复用父加载器代码。

双亲委派模型的工作过程:当加载器收到加载请求时,自己先不处理加载,而是把请求转给自己的父加载器。每层的加载器都是如此。因此最终肯定会传到启动类加载器。只有父加载器无法完成该请求(在搜索范围内找不到该类)。子加载器才自己进行加载。

使用类加载器的好处。例如:加载Object类,所有类加载器加载时都会最终传到启动类加载器进行加载(他存放在rt.jar中)。所以Object在所有类的加载器环境中都是同一个类。双亲委派模型对于保证java的稳定运行很重要,它的代码集中在java.lang.ClassLoader中的loadClass()中:

790902.png

4.3破坏双亲委派模型

双亲委派模型不是一个强制性的约束模型,而是推荐给java开发者的实现方式。目前为止有3次较大规模的“被破坏”,

a>双亲委派出现在jdk1.2之后,而类加载器出现在jdk1.0。为了向前兼容,JVM在1.2之后在ClassLoader中加入了一个新方法findClass()。jdk1.2之后已经不提倡覆盖loadClass()的逻辑了,而是将自己的逻辑写在findClass()中,这样就能够保障双亲委派模型的逻辑了。

b>双亲委派模型,很好的解决了各加载器的基础类的统一问题。基础类一般是作为用户程序调用的API存在。但是基础类需要调用用户程序就实现不了了。这不是不能实现。例如JNDI服务,它的代码是由启动类加载器加载(在rt.jar内)。但是JNDI的目的是对所有资源进行查找,管理。所以需要调用各厂商实现部署在classpath中的JNDI接口提供者代码。但是启动类加载器不认识这些代码。为了解决这个问题,java设计团队引入了一个设计:线程上下文类加载器(Thread context classloader)。这个类加载器可以通过Thread的setContextClassLoader()方法进行设置。如果在启动线程时没有设置就从父线程中继承一个。如果应用程序全局范围内都没有就使用应用程序类加载器。有了线程上下文类加载器后,JDNI就可以就可以去加载所需要的SPI代码了。也就是父类加载器的请求可以委派给子类进行加载。例如JNDI,JDBC都是使用这种方法。

c>第三次破坏是由于追求对程序的动态性而导致的,这里的“动态性”只代码热替换,模块热部署。就像PC的外设一样不用重启,直接插拔就可使用。其中一个典型例子是OSCi(面向Java的动态模型系统)。OSGi实现模块热部署的关键是:他自定义的类加载器机制的实现。每个程序模块(bundle)都有自己的一个类加载器。当替换一个bundle 时,就将该类和模块一同替换掉。


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值