【转】JVM类装载机制的解析,热更新的探讨
引言
如有错误,请批评指正。
Java是一种动态连接的语言。所谓动态连接,大概可以这么解释。
首先,Java可以大概想象成是编译解释执行的。对于一个*.java的文件,通过javac将会编译成一个*.class文件。而JVM会解释执行*.class文件。但一个软件不可能只有一个class文件,一个项目往往有众多的class文件。这些class存储了足以供JVM执行的字节码,但却只有在连接之后才能执行。
所谓的Java是动态连接的语言,是指class文件并非在编译时连接的,而是在运行时连接的。对于编译器而言,当它编译一个java文件,它就只会编译这个java文件,不同的java文件的编译彼此不会影响。(虽然javac会通过java文件之间的依赖关系搜索并编译其他的与之有依赖的java文件,且在搜索不到的时候报错,但实质上这只是一种主观上的限制罢了。)这也是Java编译器区别于C/C++编译器的地方。
正因为class的连接发生在运行时,因此才需要考虑所谓的加载器,以及令讨论热更新成为可能。
JVM的类加载机制。
JVM加载、连接的基本单位为class文件(或称一个类),值得注意的是,加载并非以java文件为单位。虽然大部分情况下一个java文件对应一个class文件,但是当java文件中定义了匿名类、内部类的时候,一个java文件将生成多个class文件。
类是如何加载的呢?
类的加载大致由这么几个步骤组成:
- (1)找到,并加载类的字节码
- (2)解析字节码,并进行安全性检查
- (3)初始化类
- (?)将符号引用转化为实例引用
JVM的加载机制是很有趣的,以至于我所列出的四项中还出现了一个问号(不是我打错字了哦)。我将采用问答的形式引出这里面的玄机。
问:这四个步骤分别在何时发生。
答:除了第三部“初始化类”,其余的步骤均不知道何时发生。因为JVM只对第三步的执行的时机进行了定义,其他的都是未定义的。这意味着其他三步实现的时机依赖于虚拟机的具体实现。
我们不能假定除了“初始化类”的其他步骤发生的时机!
那么“初始化类”何时发生呢?初始化类,在且只能在第一次发生下列事情的任何一个时进行:类的静态成员变量被使用、类的静态方法被调用、类被实例化、类作为启动类需要被调用main(String[])方法、类的子类需要被初始化、反射操作涉及到该类。(应该就是这几个,没有记漏的话)
值得注意的是,强制类型转化判定、获取类的实例(即Class对象),绝对不会触发初始化类事件,实际上在解析字节码,并进行安全性检查这一步骤的结果就是获得一个Class实例。
因此,假定有一个类叫做com.tzy.A的类,下列语句是不会触发初始化类操作的:
1 Object obj = new Object();
2 com.tzy.A a = (com.tzy.A) obj;
3 Object obj=new Object();
4 //使用反射的方法也不会!
5 //可见不是所有的反射方法都会触发初始化类操作。
6 com.tzy.A.class.isInstance obj);
7 Class.forName("com.tzy.A");
8
9 //而下列语句一定会触发初始化类操作!
10 new com.tzy.A();
11 com.tzy.A.someValue = 5;
12 com.tzy.A.someMethod("helloworld");
13 cmd:Java com.tzy.A
14
15 public class com.tzy.B extends com.tzy.A{
16 ......
17 }
18 new com.tzy.B();//先初始化A,再初始化B。
所谓初始化类,指调用类中的static语句块,将所有静态变量赋初值等。在初始化过程中,不论是static语句块,还是静态变量赋初值调用的方法抛出异常,最终都会抛出一个ExceptionIninitalizerError。
那好,现在我们仅仅知道了初始化类发生的时机,但却对其他三个发生的实际一无所知。现实真是这么悲观吗?也不完全是。
我们知道,如果没有加载类的字节码,虚拟机如何解析并生成一个Class实例呢?如果没有一个Class实例,虚拟机如何初始化它呢?显然前三个步骤是递进的,后一个步骤依赖前一个步骤的完成。因此,对于步骤(1)(2),我们知道,最迟在(3)发生之前发生!
那么步骤(?)何时发生呢?要理解步骤(?)何时发生,首先要理解(?)是个什么东西。
首先,我们知道,单独个class文件的编译过程对于其他class文件是透明的。这意味着其他class文件在编译这个层面上无法印象到该class文件。但是,class文件对其他class文件的依赖关系是客观存在的。例如:
1 public class A{
2 public someMethod(){
3 com.tzy.B b;
4 b = (com.tzy.B)new Object();
5 }
6 }
请看这段代码,如果我调用someMethod()方法,一定会抛出强制类型转换异常。为什么呢?因为Object一定不是com.tzy.B的子类?但是JVM如何知道com.tzy.B不是Object的子类呢?
要知道在class文件中,对于com.tzy.B,存起来可能是像这种形式:
1 "com.tzy.B"
对,就只是一个字符串而已。但是它表示一个类,但是在编译阶段无法验证这个字符串表示到底哪一个类,这个工作只有虚拟机能做,编译器是不管的(除了语法上的验证,但我在后面会说明,有时这个语法上的验证是不应该有的)。
字符串的理解,全凭虚拟机解释。虚拟机会将”com.tzy.B”这个字符串转化成一个Class实例。通过Class实例,可以访问到这个类的声明信息,因此,可以判断Object一定不是它的子类,从而抛出强制类型转换异常。
这个过程就叫做将符号引用转化为实例引用。
这个转化过程在何时发生呢?不知道。它一定发生在第二步之后,但既可能发生在第三步之前,也可能发生在第三步之后。
因此,对于我们的一个应用程序,我们可以这么理解,以做个总结。
- 1、应用程序由许多class文件构成。
- 2、每一个class文件一般会依赖其他class文件,它将依赖的其他class的名字保留下来。所谓class文件的名字,一般是诸如com.tzy.Test、java.lang.String等等形式。
- 3、对于应用程序而言,有且只有一个启动class文件,它是一个包含public static void main(String[])方法的public类。
因此,我们可以构造这么一张“图”。我们假定每一个class文件为一个节点。将启动class文件设为根节点。并定义,如果某一个class文件中出现了另外一个class文件的名字,则用一根单向箭头从这个class文件的节点指向另一个class文件节点。最终,我们得到了一张有一个根节点,且能表示class文件之间依赖关系的“图”。 而从根节点触发,可达的节点集合,则是我们的应用程序会被载入的class文件的最大集合! 之所以是最大集合,是因为虚拟机的机制尽可能的减少加载如内存的class数量。但,好在我们得到了一个最大值。因此,可以安心的假定,离开这个集合的class绝对不会被载入。
类装载器
当某个类对其他类存在依赖关系时,这种依赖关系只会以符号引用的形式存在于class文件中。而这种符号引用所代表的类究竟是什么类,取决于虚拟机如何解释。解释的结果,是获取一段二进制的字节码。一般而言,这段二进制的字节码读取自文件,因此,所谓的将符号引用转化为实例引用,就是将类名定位到*.class文件。
Java通过类加载器来实现二进制字节码的获取。
除了极个别特殊的类,Java中绝大部分的类的实例(Class对象),都是通过某个类加载器的实例获取的。类加载器继承自一个抽象类ClassLoader。
我们可以通过实例方法Class.getClassLoader()方法,来得到某个类的加载器。就像一个孩子只能由一个母亲所生一样,一个类实例只能有一个ClassLoader。
ClassLoader的职能在于,通过类名,生成并返回一个Class实例。我们可以通过主动调用Class类的forName方法,指定使用某个ClassLoader载入Class实例。
Class.forName(String className,boolean init,ClassLoader classLoader)
主动调用这个方法,从而获取Class实例,我个人把这个叫做显式装载。
此外,当虚拟机执行过程中需要获取某个Class实例,手头上却只有该类的符号引用时。虚拟机将隐式的使用类加载器加载这个类,并获取其实例。我把这个叫做隐式装载。
无论装载是显式还是隐式,对于ClassLoader本身而言是一样的,最终会调用到ClassLoader的方法:
protected Class<?> findClass(String className) throws ClassNotFoundException;
这个方法就是用来重写的。
值得注意的是,隐式装载会使用哪一个ClassLoader呢?经过我的实验,隐式装载所使用的符号引用来自哪一个Class,则使用哪一个Class的ClassLoader。
这种机制就会引发一个有趣的现象。还记得我之前说的“图”吗?当使用某一个ClassLoader载入某一个根class节点后,该class将逐步的、递归的、隐式的加载各种各样的class文件。而由此派生出来的Class对象,全部都是由最初的ClassLoader载入的。
此外,不得不说的是,ClassLoader有两个重要的特征。其一是父节点委派机制,其二是符号引用隔离机制。(我自己取的名字,如果找到正确的名字请告诉我。)
其一、父节点委派机制
任何一个ClassLoader实例都可以看做家族树上的一个节点。这意味着你可以给ClassLoader分配一个父亲节点(父亲节点同样是ClassLoader),实际上,不管你愿不愿意,用户自定义的ClassLoader一定会分配一个父亲节点的!
当使用某一个ClassLoader装载一个Class时(不论显式还是隐式),该ClassLoader一定会首先使用用它的父节点装载该Class。如果它的父节点能够装载,则直接返回,此时就没自己什么事了。只有当其父节点抛出ClassNotFoundException时,才调用自己的findClass方法装载。
这就是父节点委派机制。注意,当子节点将装载任务委派给父节点时,父节点同样要遵循父节点委派机制,这样父节点首先还要将加载任务委派给它的父节点。这样变成了一个层层递归委派的过程。
于是,处于家族树最顶端的ClassLoader拥有最高优先权。只有上端ClassLoader宣布它不能载入的Class,下端才能捡漏将其载入。因此,子节点休想越俎代庖,做了本该它父亲该做的事情。
其二、符号引用隔离机制
JVM为每一个ClassLoader实例(注意不是类,是实例)维系了一个个彼此独立的命名空间。这种命名空间仅在虚拟机上表现,在语言层面无法表现。因此,在编写Java程序时,常常无法感受到命名空间的存在。
为了描述命名空间为何物,我想举一个例子:
1 //File:ClassA.java
2
3 public ClassA{
4
5 private static ClassB classB = new ClassB();
6
7 public void setClassB(Object obj){
8 classB = (ClassB)obj;
9 }
10
11 public Object getClassB(){
12 return classB;
13 }
14 }
15
16 //File:ClassB.java
17 public ClassB{
18 @Override
19 public String toString(){
20 return "className:ClassB";
21 }
22 }
将这两个文件使用javac编译以后,存储在某一个文件夹中,如d:\test\ab。
1 //File:MyLoader.java
2
3 import java.io.*;
4 public class MyLoader extends ClassLoader{
5
6 @Override
7 public Class findClass(String name) throws ClassNotFoundException{
8 String className = name;
9 name = name.replace('.','\\')+".class";
10 File file = new File("D:\\test\\ab",name);
11 int length = (int)file.length();
12 byte[] bf = new byte[length];
13 InputStream is = null;
14 try{
15 is = new FileInputStream(file);
16 int start = 0;
17 int len = length;
18 while(true){
19 int i = is.read(bf,start,length);
20 length-=i;
21 start+=i;
22 if(0==i | -1==i){
23 break;
24 }
25 }
26 }catch(Exception e){
27 throw new ClassNotFoundException(e.getMessage());
28 }finally{
29 try{
30 is.close();
31 }catch(Exception e){
32 System.exit(1);
33 }
34 }
35 return defineClass(className, bf, 0, bf.length);
36 }
37 }
该文件编译后的class文件保存在CLASSPATH下。
之后,我们在main方法中执行如下代码。
1 ClassLoader loader1 = new MyClassLoader();
2 ClassLoader loader2 = new MyClassLoader();
3 Class classA1 = Class.forName("ClassA",false,loader1);
4 Class classA2 = Class.forName("ClassA",false,loader2);
在这里,我确保d:\test\ab不会被classpath访问到,因此,我们自定义的MyLoader将主动装载ClassA类,并为之生成Class实例。但现在我构造了两个MyClassLoader的实例,但分别载入一次名为”classA”的类,将其返回值赋给了classA1和classA2变量。
那么,问题是,下列语句将输出什么呢?
1 System.out.println(classA1==classA2);
2 System.out.println(classA1.equels(classA2));
输出结果是:
false
false
这就是类的命名空间机制。JVM为每一个ClassLoader实例(无关他们是否属于同一个ClassLoader的实现类)维系一个命名空间。对于某一个类名,例如”ClassA”,它对应的Class实例到底是哪一个,取决于在哪个命名空间找! 因此,严格来说,类名和类实例是一对多的关系。
当我对虚拟机说,给我找名叫ClassA的类,那么这是一句错误的话,因为在不告诉虚拟机限制在哪个命名空间的前提下,怎么又能唯一确定Class类的实例呢?
上面的例子很明显,我在同一个虚拟机中,构造了两个同名的Class实例。他们彼此不相等!
只不过如果不涉及ClassLoader的编程,我们自定义的类只会用到一个命名空间,那就是CLASSPATH的命名空间(来源于一个AppClassLoader的实例),因此,一般情况下,我们把Class的名字和实例理解为一对一的关系也没什么大问题。
之后,我们尝试执行以下代码:(编译时记得声明throws Exception)
1 Method getter1 = classA1.getMethod("getClassB",new Class[]{});
2 Object obj = getter1.invoke(null,new Object[]{}};
3 Method setter2 = classA2.getMethod("setClassB",new Class[]{Object.class}};
4 try{
5 setter2.invoke(null,new Object[]{obj});
6 }catch(InvocationTargetException e){
7 e.getTargetException().printStackTrace();
8 }
这段代码的意思是,使用反射,从第一个ClassA的实例利用静态方法getClassB()获取ClassB的实例,并使用第二个ClassA的静态方法setClassB(ClassB)将获取的实例赋值。
之所以用反射,是因为在CLASSPATH的命名空间中是无法访问到MyClassLoader创造的命名空间的。可见反射可以跨越命名空间。
其中注意InvocationTargetException 这个异常,它由invoke抛出,用以封装该方法底层抛出的异常。也就是说,当调用Method.invoke时,假如被调用的方法抛出一个NulPointerException,那么invoke方法对应的抛出一个InvocationTargetException ,然后当调用InvocationTargetException.getTargetException()时,将得到NulPointerException。
这里有两个变量,声明都是ClassB classB,此时将一个命名空间中的classB,赋值另外一个命名空间的classB。两者名字都叫ClassB,字节码也完全一样。这种操作可行吗?
这段代码运行的结果是:抛出ClassCastException。
因为虚拟机根本就没有把两个ClassB当做同一种类型,因此彼此根本无法转换。这也是命名空间的妙用,不同的命名空间构造出两个彼此独立的空间,不但允许两个空间自由使用类名而不用担心重名,而且也不用担心重名类彼此转化而引发混乱。
此外,我虽然没有显示地告诉虚拟机,两个ClassB应该分列不同的命名空间,但是虚拟机确实这么做了。那是因为当我们显示地告诉虚拟机用两个ClassLoader分别载入ClassA时后,两个ClassA在将符号引用转化为实例引用时,隐式的分别使用了两个ClassLoader载入ClassB,从而使ClassB也分别属于两个不同的命名空间。
父节点委派 + 符号引用隔离 = 命名空间的共享
父节点委派机制的结果,就是,同一个父节点的命名空间,会且只会被它的所有子节点共享。而子节点的命名空间,对父节点而言是透明的。
这便是JVM的命名空间共享机制。
这个结论源自这么几个推论。当子节点ClassLoader试图翻译某个能被其父节点装载的类名时,这个装载工作在“父节点委派机制”的影响下,一定会被父节点夺走。子节点根本得不到装载的机会,自然不会将该类名添加自自己的命名空间。因此,子节点使用的是父节点命名空间中的类实例。这一条对于所有的子节点都成立。
而父节点一定访问不到子节点命名空间中的类,这可以用反证法得到。因为如果父节点能访问到,那么根据“父节点委派机制”,父节点会夺走所有子节点的这部分工作,最终该类应该属于父节点的命名空间,而不是某个子节点的。
Java提供了三个ClassLoader,它们分别是:Bootstrap、Extension、System(或App)。
其中,Bootstrap是老大,它是一切ClassLoader的父亲节点。当你宣布你的ClassLoader,的父亲节点为null时,那么你的ClassLoader将主动以Bottstrap为父亲节点。它也是Java中唯一一个不是ClassLoader类实例的加载器。当你试图得到Bootstrap时,得到的是null。
如果说Bootstrap的本尊是null的话,那么JVM启动过程就是无极生太极、太极生两仪、两仪生四象、四象生八卦、八卦生万物的过程。
Bootstrap载入%JAVAHOME%\jre\rt.jar 中的类。这里面几乎包含了Java标准库80%的类。
由于Bootstrap是铁打的老大,一切ClassLoader的祖宗,因此,根据“父节点委派机制”,它拥有至高无上的裁决权。因此,任何一个命名空间,一定可以访问到Bootstrap的命名空间,也就是可以访问Java标准库中80%的内容。
同样,假如有一名黑客想要制造一种侵入JVM的病毒,通过在应用程序的命名空间之外加一个病毒加载器,这个病毒加载器替换很多Java标准类库,以达到入侵的目的。这时,这名黑客会发现它的病毒加载器必须以Bootstrap作为父节点,而Bootstrap将粗暴的打断一切病毒加载器企图入侵的行为。
Extension加载器是Bootstrap的子节点。它将载入Java标准库剩余20%的内容。我看了下,有NIO、XML的处理类。是Bootstrap的子节点意味着Extension是可以被替换的,安全性远没有Bootstrap那么高。我个人认为这个加载器没什么好说的。
System加载器又叫做应用加载器,它负责从CLASSPATH路径中加载类,也是Extension的子节点。因为“父节点委派机制”的存在,即便Java标准类库没有出现在CLASSPATH中,应用程序也可以访问到它们。
另外,System加载器也是void main(String[])所在类的加载器。这意味着,除了Java的标准库,应用程序中所有的自定义类全部都是System加载器加载的(不使用显示加载的话)。
【转】JVM类装载机制的解析,热更新的探讨的更多相关文章
- 【转】JVM类装载机制的解析,热更新的探讨(二)
同样,一个Class对象必须知道自己的超类.超级接口.因此,Class对象会引用自己的超类和超级接口的Class对象.这种引用一定是实例引用.(实际上,超类.超级接口的引用也存储在常量池中,但为了区分 ...
- 深入研究Java类装载机制
目录 1.为什么要研究java类装在机制? 2.了解类装载机制,对于我们在项目开发中有什么作用? 3.装载实现细节. 4.总结 一.为什么药研究Java类装载机制 java类加载机制,便于我们使用自定 ...
- JVM的类加载机制全面解析
什么是类加载机制 JVM把描述类的数据从Class文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这就是JVM的类加载机制. 如果你对Class文件的结 ...
- Unity手游之路<十二>手游资源热更新策略探讨
http://blog.csdn.net/janeky/article/details/17666409 上一次我们学习了如何将资源进行打包.这次就可以用上场了,我们来探讨一下手游资源的增量更新策略. ...
- [unity3d]手游资源热更新策略探讨
原地址:http://blog.csdn.net/dingxiaowei2013/article/details/20079683 我们学习了如何将资源进行打包.这次就可以用上场了,我们来探讨一下手游 ...
- [转]Unity手游之路<十二>手游资源热更新策略探讨
最近梳理了下游戏流程.恩,本来想写下,但是,还是看前辈的吧 版权声明: https://blog.csdn.net/janeky/article/details/17666409 上一次我们学习了如何 ...
- 【学习】Unity手游之路<十二>手游资源热更新策略探讨
http://blog.csdn.net/janeky/article/details/17666409 =============================================== ...
- 【原】Android热更新开源项目Tinker源码解析系列之三:so热更新
本系列将从以下三个方面对Tinker进行源码解析: Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Android热更新开源项目Tinker源码解析系列之二:资源文件热更新 A ...
- 游戏服务器之Java热更新
对于运行良好的游戏来说,停服一分就会损失很多收益.因为有些小bug就停服就划不来了.在使用Java开游戏服务器时,JVM给我们提供了一些接口,可以简单做一些热更新.修复一些小Bug而不用重启服务. J ...
随机推荐
- 几个不错的echarts +百度地图 案例
https://echarts.baidu.com/examples/editor.html?c=map-polygon https://echarts.baidu.com/examples/edit ...
- Needham-Schroeder Scyther工具形式化过程
1.Needham-Schroeder Public key Protocol 协议的通信认证的过程 顺序图的 1. A-> S : A, B 2. S->A: {Ks, ...
- ICS2019汇编实验在Linux下使用GDB调试程序
- mysql启动错误:Starting MySQL.. ERROR! The server quit without updating PID file错误
1.原因是因为 mysql-bin.0000*的文件占满系统盘,磁盘空间不足导致无法写入. 解决方法: 如果不需要二进制日志文件,二进制文件可以实现数据库复制以及即时点恢复. 查看日志在配置文件的da ...
- tensorflow 与cuda、cudnn的对应版本关系
来源:https://www.cnblogs.com/zzb-Dream-90Time/p/9688330.html
- spring DefaultListableBeanFactory 概述
有人说,DefaultListableBeanFactory是spring的发动机,其实重要性不为过.TA的整体类图如下: 这里先概述接口部分: BeanFact ...
- 在idea中编写自动拉取、编译、启动springboot项目的shell脚本
idea 开发环境搭建 idea中安装shell开发插件 服务器具备的条件 已经安装 lsof(用于检查端口占用) 已安装 git 安装 maven 有 java 环境 背景 代码提交到仓库后,需要在 ...
- linux下新磁盘创建lvm、扩容lvm
1.首先查看磁盘fdisk -l2.进入磁盘fdisk /dev/sdbn 创建新磁盘p 创建主分区创建分区ID 1-4为主分区根据提示选择磁盘开始位置(默认空格就好)选择结束位置(新增磁盘大小)t ...
- 错误信息: The server cannot or will not process the request due to something that is perceived to be a client error
错误原因:在提交的表单中有 date 类型的数据,也就是不能传输日期类型的数据. 嗯!我知道,去吧!
- 在CentOS7上面搭建GitLab服务器
首先要在CentOS系统上面安装所需的依赖:ssh.防火墙.postfix(用于邮件通知).wegt,以下这些命令也会打开系统防火墙中的HTTP和SSH端口访问. 1.安装SSH协议 安装命令:sud ...