JVM学习之虚拟机类装载机制(6)

一 双亲委派机制

 

1 双亲委派模型



 

       双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求,子加载器都会尝试自己去加载. 

        双亲委派模型的实现: ClassLoader.loadClass()方法,先检查需要加载的类是否已被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器,如果父类加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载.

 

2 四类类加载器的作用(启动,扩展,系统,自定义):

(1)启动类加载器: 用C++/C语言实现的,用来加载JAVA_HOME/lib目录或-Xbootclasspath参数所指定的路径的,并且是虚拟机识别的类库(仅按照文件名识别,如rt.jar,其他的名字不符合的类库即使放在lib目录中也不会被加载)

(2)扩展类加载器: 由sun.misc.Launcher$ExtClassLoader实现,负责加载JAVA_HOME/lib/ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用这个类加载器

(3)应用程序类加载器: 由sun.misc.Launcher$AppClassLoader来实现,由于它是ClassLoader中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器,它负责加载用户类路径ClassPath下的类库

(4)自定义类加载器

 

3 类加载器父子关系与树状结构的形成原理

       除了引导类加载器之外,所有的类加载器都有一个父类加载器。通过ClassLoader.getParent()方法可以得到,但由于引导类加载器不是用java语言实现的,所以扩展类加载器的getParent()方法一般会返回null,这个由类加载器具体的实现决定。

        对于系统提供的类加载器来说,系统类加载器的父类加载器是扩展类加载器,而扩展类加载器的父类加载器是引导类加载器;

        对于开发人员编写的类加载器来说,其父类加载器是加载此类加载器 Java 类的类加载器。因为类加载器 Java 类如同其它的 Java 类一样,也是要由类加载器来加载的。一般来说,开发人员编写的类加载器的父类加载器是系统类加载器,也可能增加多个层次的父子关系,但是用户定义的最上层的类加载器的父类加载器还是系统类加载器。类加载器通过这种方式组织起来,形成树状结构。树的根节点就是引导类加载器。

 

4 双亲委派模型的好处

(1)使java类随着它的类加载器一起具备了一种带有优先级的层次关系,例如当java程序启动时,启动类加载器将程序运行的核心类库加载进内存,如果在程序中想要使用自定义的类加载器加载同名类,双亲委派模型使得先由启动类加载器去加载同名类,不会造成二次加载,也不会造成类型冲突破坏程序的正常运行

 

(2)假如在程序中想装载一个java.lang.Virus的类来访问java.lang包中没有暴露出来的,并且有包访问权限的内容,那么Virus类必须和java.lang包中要访问的类被同一个类装载器装载,但这是不可能的,因为类装载的双亲委派机制使得由启动类装载器来装载java.lang包中的类,而Virus并不能被启动类装载器找到,★java虚拟机只把彼此访问的特殊权限授予由同一个类装载器装载到同一个包中的类型(是运行时包与普通包的区别)

 

 5 线程上下文类加载器

 

(1)每个线程都有一个关联的上下文类加载器。如果你使用new Thread()方式生成新的线程,新线程将继承其父线程的上下文类加载器。如果程序对线程上下文类加载器没有任何改动的话,程序中所有的线程将都使用系统类加载器作为上下文类加载器。Web应用和Java企业级应用中,应用服务器经常要使用复杂的类加载器结构来实现JNDI(Java命名和目录接口)、线程池、组件热部署等功能,因此理解这一点尤其重要

 

(2)有时双亲委派模型并不能总是奏效。这通常发生在JVM核心代码必须动态加载由应用程序动态提供的资源时。拿JNDI为例,它的核心是由JRE核心类(rt.jar)实现的。但这些核心JNDI类必须能加载由第三方厂商提供的JNDI实现。这种情况下调用父类加载器(原初类加载器)来加载只有其子类加载器可见的类,这种代理机制就会失效。解决办法就是让核心JNDI类使用线程上下文类加载器,从而有效的打破类加载器层次结构,逆着代理机制的方向使用类加载器。

 

注: Java中所有涉及SPI的加载动作基本上都采用这种方式,如jndi,jdbc,jce,jaxb,jbi等等

 

 

二 类的可见性

 

1 启动类加载器 扩展类加载器 是每个java进程特有的吗

        每用java命令启动一个java应用程序,就会启动一个JVM进程。在同一个JVM进程中,有且只有一个进程,就是它自己。在这个JVM环境中,所有程序代码的运行都是以线程来运行的。JVM找到程序的入口点main(),然后运行main()方法,这样就产生了一个线程,这个线程称之为主线程,所以启动类加载器 扩展类加载器 是每个java进程特有的

 

2 类加载器的命名空间

        每个类加载器都对应一个类列表,这个列表包含所有以此加载器为初始加载器的类,在这个类加载器命名空间中的类就是这个类列表里的类.

 

3  ★★★类的可见性

(1)每个类加载器都对应一个类列表,这个列表包含所有以此加载器为初始加载器的类,在这个类加载器命名空间中的类就是这个类列表里的类,同一个命名空间内的类是相互可见的

(2)从类加载器的角度, 子类加载器可以看到父类加载器加载的类,而反之则不行

(3)从类加载器加载的类的角度, 子加载器的命名空间包含所有父加载器的命名空间。因此由子加载器加载的类能看见其父加载器加载的类.反之,由父加载器加载的类不能看见子加载器加载的类,因为父加载器的命名空间不包含子加载器的命名空间.

        以这样思考: 判断在一个类A中可不可以使用另一个类B,得先在符号引用解析时通过类A的定义加载器判断其命名空间里包不包含类B,如果不包含,再看能否加载类B

 

注:

(1)每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 getClassLoader()方法就可以获取到此引用

(2)一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类com.example.Inner,则由类 com.example.Outer的定义加载器作为Outer的初始类加载器负责启动 com.example.Inner的加载过程

 

 

三 虚拟机装载类时对class文件字节序列的校验

虚拟机装载类时会在不同阶段对类文件进行四次趟描

(1)第一趟,class文件结构的检查

        每个class文件必须以四个同样的字节开始: 魔数OxCAFEBABE; 

        检验器还要确认在class文件中声明的主版本号和次版本号,确保版本 号在当前java虚拟机实现是可以被支持的;

        虽然不同的class文件有不同的长度,但在class文件中包含的每一个组成部分都声明了它的长度和类型,检验器可以使用组成部分的类型和长度确定整个class文件的正确的总长度

(2)第二趟,类型数据的语义检查

        在连接过程中,在第一趟扫描的基础上,检查了一些Java语言在编译期间应遵守的强制规则.扫描发生在方法区中,主要是对于语义,词法和语法的分析,也就是检查这个类是否能够顺利的编译

(3)第三趟: 字节码验证

       同样是在连接过程中进行.字节码流代表了java的方法,是用于jvm运行引擎执行的"操作码+操作数"的序列.执行字节码时,依次执行每个操作码,于是在jvm内构成了执行线程,每个线程被授予自己的java栈,这个栈由不同的栈桢构成,每一个方法调用将获得一个自己的栈帧(栈帧就是一个内存片断,其中存储产局部变量和计算的中间结果).在一个方法的栈帧中,有局部变量区和操作数栈,这两块内存就是放数据的时机不同,操作数栈就是用来存放字节码指令执行的中间结果,结果或操作数,而局部变量区,就是用来存局部变量形参等

        这个字节码的校验过程校验的就是字节码流的合法过程,也就是校验操作数+操作码的合法性。

 

(4)第四趟: 符号引用的校验

    由于大部分jvm的实现都是延迟加载或者说动态连接的,延迟加载的意思就是,jvm装载某个类A时,如果A类里有引用其他的类B,虚拟机并不会把这个被引用B类也同时装载入内存,而是等到执行到的时候才去装载。

        而这个被引用的B类在引用它的类A中的表现形式主要被登记在了符号表中,当程序运行时需要用到被引用类B的时候,将被引用类B在引用类A的符号引用名改为内存里的直接引用,引用改变前,需要由class文件检验器执行第四趟的符号引用的校验将,例如判断被引用的类,方法,字段是否存在,如果存在,则将符号引用替换为直接引用(不存在则先装载),例如一个指向类、字段或方法的指针,下次再需要用到被引用类的时候直接使用直接引用(loadClass(String name, boolean resolve) 第二个参数就表明了加载一个类时是否需要将类中的符号引用替换为直接引用,最终调用的方法是一个本地方法 resolveClass0)

    注:Class.forName和classLoader的区别是:Class.forName总是承诺连接,即将符号引用替换为直接引用

    第四趟发生的时间是不可预料的,而且发生在方法区中。第四趟是动态连接过程的一部分

 

 

四 类装载子系统装载类的全过程

 

1 过程概览

        类装载器子系统除了要定位导入二进制class文件外,还必须负责验证被导入类的正确性。为类变量分配并初始化内存,以及帮助解析符号引用。这些动作必须严格按以下顺序进行:

 

st1 装载:查找并装载类型的二进制数据

 

st2 连接:执行验证,准备,以及解析(可选,虚拟机的实现可以推迟解析这一步,可以在初始化之后再进行)

(1)验证:确保被导入类的正确性

(2)准备:为类型分配所需的内存,比如为类变量分配内存,并将其初始化为默认值

(3)解析:把类型中的符号引用(如类的全名)转换直接引用(即指针,对应类数据结构在方法区的地址),做这个步骤前要先进行第四趟class字节码校验,即符号引用的校验

 

st3 初始化 把类变量初始化为正确初始值

 

2 类或接口何时被初始化(类与接口初始化时机有区别)

       在类和接口被装载和连接的时机上,jvm规范给实现提供了一定的灵活性(如可以在类或接口首次主动使用时加载和连接,也可以先行加载和连接),但是它严格地定义了初始化的时机,所有的java虚拟机实现必须在每个类或接口首次主动使用时初始化,下面六种情形符合主动使用的要求:

(1)当创建某个类的新实例时(或者通过在字节码中执行new指令;或者通过不明确的创建(如类被装载后生成的Class实例,字符串连接,或者把方法区中的CONSTANT_String_info入口转换成一个堆中的String实例等)、反射、克隆或者反序列化)。 

(2)当调用某的类的静态方法时(即在字节码中执行invokestatic指令时)。 

(3)当使用某个类或接口的静态字段,或者对该字段赋值时(即在字节码中,执行getstatic或putstatic指令时),编译时常量除外,因为没有为编译时常量分配内存,它的值是确定且固定的.

        类的成员变量在执行期间才能得到值的,访问此成员也可是对此类的主动调用。比如:

                final static double a = Math.random();  

(4)当调用Java api中的某些反射方法时,比如类Class中的方法或者java.lang.reflect包中的类方法:

Class.forName("com.ldy.ClassA");initialize标志位固定为true

 

(5)当初始化某个类的子类时,(某个类初始化时,要求它的超类已经被初始化了,但对于接口来说,只有在某个接口所声明的★非[编译]常量字段被使用时,该接口才会被初始化,而不会因为实现这个接口的子接口或类要初始化而被初始化) 

 

(6)当虚拟机启动某个被表明为启动类的类(即含有main方法的那个类)

 

除上述六种情形外,所有其他使用java类型的方式都是被动使用,不会导致java类型的初始化

 

 

3 装载:

(1)装载过程由三个基本动作组成:

st1:通过类型的完全限定名获取一个代表该类型的二进制数据流

st2:解析这个二进制数据流为方法区内的内部数据结构

st3:创建一个表示该类型的java.lang.Class类的实例,用于访问方法区的数据

 

所以说,装载步骤的最终产品就是产生Class类的实例对象(对应着方法区的信息).

 

4 验证

 

(1)验证阶段包括虚拟机装载字节码时候的一些必要验证,如检查魔数,确保每一个部分的位置与长度的正确,确保装载一个类时该类的所有超类已经被装载等等;

(2)验证阶段也包括正式的连接验证阶段,一般是基于方法区类数据的相关验证,如java语法检查,字节码检查等等

(3)验证阶段还包括发生在正式验证阶段之后的检查,即符号引用验证

(4)需要注意的是,超类需要在子类初始化前被初始化,当实现了父接口的类被初始化时,不需要初始化父接口;但是,当实现了父接口的子类(或是扩展了父接口的子接口)被装载时,父接口也必须被装载.(需要区别初始化与装载的概念)

 

5 准备

 

(1)在准备阶段,java虚拟机为类变量分配内存(★为类变量分配内存是在方法区分配的),设置默认初始值,但在到达初始化阶段之前,类变量都没有初初始化为真正的初始值,因为在准备阶段是不会执行java代码的

 

(2)在准备阶段,Java虚拟机实现可能也为一些数据结构分配内存,目的是提高运行程序的性能.例如方法表,它包含指向类中每一个方法(包括从超类继承的方法)的指针,可以使得继承的方法执行时不需要搜索超类

 

(3)★注意编译时常量这种在类中定义的"类变量"是不用作为方法区的类变量而在方法区分配内存的

 

6 解析

解析过程就是在类型的常量池中寻找类,接口,字段和方法的符号引用,把这些符号引用替换成直接引用的过程.

 

7 初始化

 

(1)为了准备让一个类或者接口被首次主动使用,最后一个步骤就是初始化,也就是为类变量赋予正确的初始值

(2)在java代码中,一个正确的初始值是通过类变量初始化语句或者静态初始化语句给出的.一个类变量初始化语句是变量声明后面的等号和表达式;静态初始化语句是一个以static关键字开头的程序块.

 

(3)所有的类变量初始化语句和类型的静态初始化器都被java编译器收集放到一个类/接口的初始化方法<clinit>中,初始化一个类包含两个步骤:如果类存在直接超类,且直接超类还没有被初始化,那么就先初始化直接超类(所以第一个被初始化的类永远是Object); 如果类存在一个类初始化方法,就执行此方法.

 

        注意:

 

        初始化接口并不需要初始化它的父接口,因此初始化一个接口只需一步: 如果接口存在一个接口初始化方法的话,就执行此方法.

 

        假如类声明了类变量,但是没有初始化语句,那么不会有<clinit>()方法;如果类仅包含静态final变量的类变量初始化语句,而且这些类变量初始化语句采用编译时常量表达式,类也不会有<clinit>()方法.也就是说,只有那些的确需要执行java代码来赋予类变量正确初始值的类才会有类初始化方法.

 

        此外,java虚拟机必须确保初始化过程被正确地同步.如果多个线程需要初始化一个类,仅允许一个线程来执行初始化,其他的线程需要等待,当活动的线程完成了初始化过程之后,它必须通知其他等待的线程.

 

8 关于编译时常量表达式与编译时常量:

(1)编译时常量表达式: 不用执行java代码,编译器编译时就能计算出表达式的值的表达式;

 

(2)编译时常量:就是那些用final声明(表示不可以再修改,即常量)以及用编译时已知的值初始化的类变量;

 

(3)由于编译时常量的值是已知且固定不变的,虽然是作为static的字段,但是在程序运行时不可能被改变,因此在准备阶段,java虚拟机为类变量分配内存时,不用为编译时常量这种在类中定义的"类变量"在方法区分配内存(即jvm不把它们当作真正的类变量),java编译器也自然不会将"编译时常量表达式"收集到类的初始化方法<clinit>()中 

 

(4)虚拟机不为编译时常量分配内存,它的值是确定且固定的,可以直接嵌入字节码流,也可以放在常量池使用索引引用

 

(5)对于使用编译时常量的类的情形,如下例所示:

    

class Example1d{

static final int angle = 35;

static final int length = angle * 2;

 

}

 

        angle和length字段没有被当做类变量,Java虚拟机在使用它们的任何类的常量池或者字节中直接存放的是它们表示的常量的int值.比如,如果一个类使用了Example1d的angle字段,该类不会在常量池中保存一个指向Example1d类的angle字段的符号引用,而是直接在它们的字节码流中嵌入一个值35.如果angle的常量值超过了short值的限制(-32768-32767),比如是3500,那么类会在它的常量池中保存一个CONSTANT_Integer_info入口(这个入口的索引值会在字节码流中被引用到),值为35000.

 

    关键点是: 编译的类中, 不用符号引用 只是直接嵌入值 或者 在使用类本身保存一个常量

 

9 主动使用和被动使用            

(1)假如在父类或接口中定义的静态字段(前提是非编译常量),用子类或子接口去使用父类的静态字段,那么只会导致父类或父接口的初始化,而不会导致子类或子接口的初始化,因为对直接使用静态字段的子类或子接口来说,属于被动使用

 

(2)使用编译时常量字段,就不是对声明该字段的类的主动使用,不会导致该类的初始化.

 

 

五 ClassLoader解析

(1)defineClass(): 

       每个java虚拟机实现都必须保证ClassLoader类的defineClass()方法能够把新类型导入到方法区中,需要指定类全名及类对应的字节码,可以指定ProtectionDomain,如果不指定,那么使用ClassLoader默认的保护域,一个ClassLoader对应一个默认的保护域实例,一个保护域也只对应一个唯一的ClassLoader实例.入口的defineClass()方法默认的校验标识位参数verify=true

 

(2)findSystemClass(): 需要指定类全名,任何java虚拟机实现都必须保证findSystemClass()方法能够使用系统类装载器来装载指定类型

 

(3)resolveClass(): 接受一个Class实例的引用作为参数,它将对Class实例表示的类型执行连接动作.java虚拟机实现都必须保证resolveClass()方法能够让类装载器子系统执行连接动作(但不一定会解析,即使初始化也不一定能保证解析,因为解析动作可能是当要用到某个符号引用时才会被触发).

 

(4)在Java class文件和虚拟机中,类型名总是以全限定名出现(在java源文件中用点号表示层级关系,而编译后在class文件中是用/代替).每个类装载器又有自己的命名空间,当多个类装载器都装载了同名的类型时,为了唯一标识该类型,要在类型名称前加上装载该类型的类装载器的标识.java虚拟机中的命名空间,其实是解析过程的结果,对于每一个被装载的类型,jvm都会记录装载它的类装载器.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值