文章目录
- 概要
- 加载
- 类加载器分类
- 双亲委派模型
- 自定义加载器
- 验证
- 准备
- 解析
- 初始化
- <cinit>与<init>
概要
jvm运行时的整体结构如下
一个Car
类,类跟Car
对象的转换过程如下:
- 加载后的class类信息存放于方法区;
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定;
- 如果调用构造器实例化对象,则该对象存放在堆区;
其中类的加载总体流程如下:
加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
加载是类加载的第一个阶段。有两种时机会触发类加载
- 预加载
虚拟机启动时加载,加载的是JAVA_HOME/lib/
下的rt.jar
下的.class
文件,这个jar包里面的内容是程序运行时非常常
常用到的,像java.lang.*、java.util.、java.io.
等等,因此随着虚拟机一起加载 - 运行时加载
虚拟机在用到一个.class
文件的时候,会先去内存中查看一下这个.class
文件有没有被加载,如果没有就会按照类的全限定名来加载这个类
那么,加载阶段做了什么,其实加载阶段做了有三件事情:
- 获取.class文件的二进制流
- 将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中
- 在内存中生成一个代表这个.class文件的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的
类加载器分类
jvm提供了3个系统加载器,分别是Bootstrp loader
、ExtClassLoader
、AppClassLoader
这三个加载器互相成父子继承关系
-
Bootstrp loader
: Bootstrp加载器是用C++语言写的,它在Java虚拟机启动后初始化;它主要负责加载以下路径的文件:
%JAVA_HOME%/jre/lib/*.jar
%JAVA_HOME%/jre/classes/*
-Xbootclasspath参数指定的路径
可通过:System.out.println(System.getProperty("sun.boot.class.path"));
打印查看 -
ExtClassLoader
:ExtClassLoader
是用Java写的,具体来说就是sun.misc.Launcher$ExtClassLoader
,其主要加载:
%JAVA_HOME%/jre/lib/ext/
ext下的所有classes目录
java.ext.dirs系统变量指定的路径中类库
可通过:System.getProperty("java.ext.dirs")
打印查看 -
AppClassLoader
:AppClassLoader
也是用Java写成的,它的实现类是sun.misc.Launcher$AppClassLoader
,另外我们知道ClassLoader
中有个getSystemClassLoader
方法,此方法返回的就是它。
负责加载 -classpath 所指定的位置的类或者是jar文档,也是Java程序默认的类加载器
System.getProperty("java.class.path")
双亲委派模型
什么是双亲委派
双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即 ClassNotFoundException
),子加载器才会尝试自己去加载。
为什么需要双亲委派模型?
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的Integer.class ,这样便可以防止核心API库被随意篡改。
双亲委派能否打破?
可以的,比如在tomcat中,tomcat通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则,
看一下tomcat类加载的层次结构如下:
比如:Tomcat的 webappClassLoader
加载web应用下的class文件,不会传递给父类加载器,问题:tomcat的类加载器为什么要打破该模型?
首先一个tomcat启动后是会起一个jvm进程的,它支持多个web应用部署到同一个tomcat里,为此
- 对于不同的web应用中的class和外部jar包,需要相互隔离,不能因为不同的web应用引用了相同的jar或者有相同的class导致一个加载成功了另一个加载不了。
- web容器支持jsp文件修改后不用重启,jsp文件也是要编译成.class文件的,每一个jsp文件对应一个JspClassLoader,它的加载范围仅仅是这个jsp文件所编译出来的那一个.class文件,当Web容器检测到jsp文件被修改时,会替换掉目前JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热部署功能。
如何实现双亲委派模型
双亲委派模型的原理很简单,实现也简单。每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。其实 ClassLoader 类默认的 loadClass 方法已经帮我们写好了,我们无需去写
几个重要的函数
loadClass 默认实现如下:
public Class<?> loadClass(String name) throws ClassNotFoundException {return loadClass(name, false);}
再看看 loadClass(String name, boolean resolve)
函数:
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {if (parent != null) {c = parent.loadClass(name, false);} else {c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}}
从上面代码可以明显看出, loadClass(String, boolean)
函数即实现了双亲委派模型!整个大致过程如下:
- 首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
- 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用
parent.loadClass(name, false);
).或者是调用bootstrap
类加载器来加载 - 如果父加载器及
bootstrap
类加载器都没有找到指定的类,那么调用当前类加载器的findClass
方法来完成类加载。
也就是说,如果要自定义类加载器,就要重写fiindClass
方法。
抽象类 ClassLoader
的 findClass
函数默认是抛出异常的。而前面我们知道, loadClass
在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass
函数,因此我们必须要在 loadClass 这个函数里面实现将一个指定类名称转换为 Class
对象
自定义加载器
除了上面的系统提供的3种loader,jvm允许自己定义类加载器,典型的在tomcat上:
为什么要自定义类加载器:
- 隔离加载类
模块隔离,把类加载到不同的应用选中。比如tomcat这类web应用服务器,内部自定义了好几中类加载器,用于隔离web应用服务器上的不同应用程序。 - 修改类加载方式
除了Bootstrap加载器外,其他的加载并非一定要引入。根据实际情况在某个时间点按需进行动态加载。 - 扩展加载源
比如还可以从数据库、网络、或其他终端上加载 - 防止源码泄露
java代码容易被编译和篡改,可以进行编译加密,类加载需要自定义还原加密字节码
自定义类加载器的加载流程
自定义加载器:
public class MyClassLoader extends ClassLoader {private String codePath;protected MyClassLoader(ClassLoader parent, String path) {super(parent);this.codePath = path;}public MyClassLoader(String classPath) {this.codePath = classPath;}@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {String fileName = codePath + name + ".class";try ( // 输入流BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName));// 输出流ByteArrayOutputStream baos = new ByteArrayOutputStream()) {int len;byte[] data = new byte[1024];while ((len = bis.read(data)) != -1) {baos.write(data, 0, len);}//5.获取内存中字节数组byte[] byteCode = baos.toByteArray();//6.调用defineClass 将字节数组转成Class对象Class<?> defineClass = defineClass(null, byteCode, 0, byteCode.length);return defineClass;} catch (FileNotFoundException e) {e.printStackTrace();} catch (IOException e) {e.printStackTrace();}return null;}
}
有以下注意点:
-
所有用户自定义类加载器都应该继承ClassLoader类
-
在自定义ClassLoader的子类是,我们通常有两种做法:
- 重写loadClass方法(是实现双亲委派逻辑的地方,修改他会破坏双亲委派机制,不推荐)
- 重写findClass方法 (推荐)
验证
连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段主要做以下几方面的工作:
- 文件格式验证:是不是CAFEBABYE开头,主次版本号是否在当前jvm虚拟机可运行的范围内等
- 元数据验证:段主要验证属性、字段、类关系、方法等是否合规
- 字节码验证:这里主要验证class里定义的方法,看方法内部的code是否合法
- 符号引用验证:字节码里有的是直接引用,有的是指向了其他的字节码地址。而符号引用验证的就是,这些引用的对应的内容是否合法
准备
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。关于这点,有两个地方注意一下
- 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
- 这个阶段赋初始值的变量指的是那些不被
final
修饰的static变量,比如public static int value = 123
,value
在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如public static final int value =123;
就不一样了,在准备阶段,虚拟机就会给value
赋值为123。
各个数据类型的零值如下表:
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。来了解一下符号引用和直接引用有什么区别:
符号引用
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
看一段代码:
public class TestMain {private static int i;private double d;public static void print() {}private boolean trueOrFalse() {return false;}
}
反编译后得到:
Constant pool:#1 = Methodref #3.#17 // java/lang/Object."<init>":()V#2 = Class #18 // com/ocean/classloading/TestMain#3 = Class #19 // java/lang/Object#4 = Utf8 i#5 = Utf8 I#6 = Utf8 d#7 = Utf8 D#8 = Utf8 <init>#9 = Utf8 ()V#10 = Utf8 Code#11 = Utf8 LineNumberTable#12 = Utf8 print#13 = Utf8 trueOrFalse#14 = Utf8 ()Z#15 = Utf8 SourceFile#16 = Utf8 TestMain.java#17 = NameAndType #8:#9 // "<init>":()V#18 = Utf8 com/ocean/classloading/TestMain#19 = Utf8 java/lang/Object
可以看到常量池中有22项内容,其中带"Utf8"的就是符号引用。比如#2,它的值是"com/ocean/classloading/TestMain",表示的是这个类的全限定名;又比如#4为i,#5为I,它们是一对的,表示变量时Integer(int)类型的,名字叫做i;#12、#16表示的都是方法的名字。
符号引用就是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。
直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同
解析阶段负责把整个类激活,串成一个可以找到彼此的网。那这个阶段都做了哪些工作呢?大体可以分为:
- 类或接口的解析
- 类方法解析
- 接口方法解析
- 字段解析
初始化
最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收。
前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。而这个阶段,是赋值,才是我们应用程序中编写的有主导权的地方
初始化阶段就是执行类构造器()方法
的过程。 ()
方法并不是程序员在Java代码中直接编写 的方法, 它是Javac编译器的自动生成物,()方法
是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的 语句合并产生的。
()方法
与类的构造函数(即在虚拟机视角中的实例构造器()方法) 不同, 它不需要显 式地调用父类构造器, Java虚拟机会保证在子类的()方法执行前, 父类的()方法
已经执行 完毕。 因此在Java虚拟机中第一个被执行的()方法
的类型肯定是java.lang.Object
。
由于父类的()方法
先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值 操作
()方法
对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对变量的 赋值操作, 那么编译器可以不为这个类生成()方法
。 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 ()方法
。
但接口与类不同的是, 执行接口的()方法
不需要先执行父接口的()方法
, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也 一样不会执行接口的()方法
。
Java虚拟机必须保证一个类的()方法
在多线程环境中被正确地加锁同步, 如果多个线程同 时去初始化一个类, 那么只会有其中一个线程去执行这个类的()方法
, 其他线程都需要阻塞等 待, 直到活动线程执行完毕()方法
。 如果在一个类的()方法
中有耗时很长的操作, 那就 可能造成多个进程阻塞, 在实际应用中这种阻塞往往是很隐蔽的
class TestDeadLoop {static class DeadLoopClass {static {
// 如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”并拒绝编译if (true) {System.out.println(Thread.currentThread() + "init DeadLoopClass");while (true) {}}}}public static void main(String[] args) {Runnable script = new Runnable() {public void run() {System.out.println(Thread.currentThread() + "start");DeadLoopClass dlc = new DeadLoopClass();System.out.println(Thread.currentThread() + " run over");}};Thread thread1 = new Thread(script);Thread thread2 = new Thread(script);thread1.start();thread2.start();}
}
与
上面说的()方法
可以理解为是<cinit>
,对象的初始化方法(构造函数),也就是反编译之后看到方法是<init>
,这两者什么区别呢?
看一段代码
public class ParentA {static {System.out.println("1");}public ParentA() {System.out.println("2");}
}public class SonB extends ParentA {static {System.out.println("a");}public SonB() {System.out.println("b");}public static void main(String[] args) {ParentA ab = new SonB();ab = new SonB();}}
上面的打印结果是:
1
a
2
b
2
b
其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它对应的是 <cinit>
方法。
所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的先后原则,不难分析出正确结果
小结:
方法<cinit>
的执行时期: 类初始化阶段(该方法只能被jvm调用, 专门承担类变量的初始化工作) ,只执行一次
方法 <init>
的执行时期: 对象的初始化阶段