在程序世界的大海洋中,类就像是构建一切的基石。它们是构建Java应用的原材料,类加载器则是这个世界的建筑工人。他们负责将构建城市所需的材料搬运到工地(JVM)。了解类加载器的工作原理,就像了解城市建设的过程,能够让我们更好地理解和控制程序的运行
。现在,让我们深入探索JVM的类加载器,解析它的奥秘,开启这趟神奇的旅程吧!
如果说并发编程是指挥交通的艺术,那么了解JVM就是为城市添砖加瓦的艺术。我们来看下究竟是什么样的。
首先,我们编写了一个类:
public class Building {
public Building() {
System.out.println("建筑蓝图已被创建! 我们可以添砖Java了");
}
private static int constructionYear = 2023;
private int floorCount;
public Building(int floors) {
this.floorCount = floors;
}
public void setFloorCount(int floors) {
this.floorCount = floors;
}
public int getFloorCount() {
return this.floorCount;
}
public static int getConstructionYear() {
return constructionYear;
}
public static void main(String[] args) {
new Building();
}
}
类中有一个main()方法的程序入口点。我们运行一下:
Connected to the target VM, address: '127.0.0.1:9888', transport: 'socket'
建筑蓝图已被创建! 我们可以添砖Java了
Disconnected from the target VM, address: '127.0.0.1:9888', transport: 'socket'
Process finished with exit code 0
好,没什么问题。你是否好奇当我们在IDE绿色的小箭头点击run ‘Building.main()’之后,底层到底发什么什么?嗯...有问必答,我们接着看。
我还是结合上面的例子为你讲解,请你仔细思考它们的对应关系。我们开始吧~
当我们在IDE绿色的小箭头点击run ‘Building.main()’ 其实IDE会进行两个步骤:
编译:IDE会先使用javac
编译器,将你的.java
源文件编译成.class
字节码文件。这一步骤通常是在后台进行的,你通常不会看到任何有关编译的消息,除非出现编译错误。(往往这个时候程序员就要挠掉不少头发)
运行:编译完成后,IDE会使用java
命令启动JVM进程,然后载入并执行相应的.class
文件中的main
方法。
搞懂这两个步骤,我们接着往下说。
首先,当你在运行java Building这个指令时,就好比发出了开工命令。JVM就像一位总承包商,控制着一位特殊的工人,也就是Bootstrap类加载器。这位工人的工作是从核心材料库($JAVA_HOME/jre/lib)中取出构建这座大楼所需的基本原材料,这些基本材料包括了Java的核心类库。
Bootstrap类加载器,像一位高级工程师,接下来派遣了另外两位工人,他们是扩展(ext)类加载器和应用(app)类加载器。扩展类加载器的任务是从扩展材料库$JAVA_HOME/jre/lib/ext获取扩展材料。应用类加载器的任务是从建筑工地周围(系统类路径CLASSPATH)收集所需的特定材料。
至此,类加载器加载类前过程已经完成了,我们接着往后看。
当你(雇主)告诉高级工程师(Bootstrap类加载器)你需要一个名为Building 的设计蓝图,这个时候高级工程师就可以派出它的得力助手扩展类加载器了,但是扩展类加载器发现Building不是它的职责范围,于是把活交给应用类加载器,他刚好知道在哪里可以找到Building.class这个特定的建筑蓝图。他会沿着系统类路径,寻找到这个类文件,并将其内容(类的字节码)搬运到JVM中。这个过程就好像是将建筑蓝图放到了JVM的工地上。
当Building类的字节码被搬运到JVM后,总承包商会委托工人们对这些原材料进行处理。他们会检查材料(验证),然后对constructionYear
材料进行预处理先把它设置为0(准备),你看:
private static int constructionYear = 0;
最后将它们组合在一起(解析),把JVM将常量池中的符号引用替换为直接引用。这个过程就好比按照蓝图的要求,将砖块、水泥等材料准备好并组装起来。
紧接着就开始真正的施工了。工人们按照Building类的main方法(也就是建筑的蓝图)开始构建大楼。在这个例子中,它会创建一个新的Building对象。并且静态变量constructionYear
在初始化阶段会被初始化为2023,你看:
private static int constructionYear = 2023;
这就好比工人们按照蓝图上的指示,开始把砖块、水泥等材料搭建起来。
一旦建筑物(也就是Building对象)被创建出来,就可以开始使用了。在这个例子中,当Building对象被创建时,它的构造函数会被调用,打印出”建筑蓝图已被创建! 我们可以添砖Java了“。
当大楼(也就是Building对象)不再被使用,或者建筑工地(也就是JVM)需要关闭时,这座大楼就会被拆除。这个过程由JVM的垃圾回收器负责,它会清理掉不再需要的Building对象。当没有任何类加载器引用这个Building类时,这个类也将被卸载。这就好比当大楼不再需要,或者工地需要关闭时,大楼会被拆除,蓝图(也就是Building.class文件)也会被收回。
不知道你有没有发现,我在描述初始化的过程中并没有提到floorCount
变量,那这个材料就不初始化了吗?还有,为什么一开始高级工程师不直接把活派给应用类加载器而是先给扩展类加载器?还有,为什么写了main()方法,程序就可以运行了?好好好,我一个一个来为你解答
它们会在创建对象的时候(也就是新建Building对象时)被初始化。实例变量floorCount是属于对象的,每个对象都有一份独立的副本,它们的生命周期随着对象的创建和销毁而开始和结束。
因为高级工程师很聪明,他知道有一种双亲委派机制
可以提高效率,怎么提高效率?
例如: 自己写的java.lang.String.class类不会被加载,这样便可以防止核心
API库被随意篡改
当父类加载器已经加载了某个类时,子加载器就不会再加载,避免了重复加载。
当然还有不少优点:防止Java类库的冲突
,节省内存空间
… 这里就不赘述了。
这是由类加载器内部运行机制决定的,你可以看下流程:
在初始化完成后,JVM会查找类中的 main 方法。 main 方法的标准声明应为: public static void main(String[] args)。这个方法是静态的(即与类关联,而不是与对象关联),因此JVM可以在不创建类的实例的情况下调用它。一旦找到 main 方法,JVM就会执行它。程序的执行流程就从 main 方法开始。
上面已经提到,除了引导类加载器(BootStrap)之外,还有扩展类加载器(Ext) 和应用类加载器(App),我们在这里着重再介绍下吧~
引导类加载器是最顶级的类加载器,它主要负责加载Java的核心类库,例如java.lang.*,java.util.*等。这些类库的位置通常在JDK的jre/lib/rt.jar中。引导类加载器是由C++编写的,我们在Java中是无法获取它的引用的。引导类加载器是其他类加载器的父加载器。
扩展类加载器是引导类加载器的子类,它负责加载JDK的扩展类库,这些类库通常位于JDK的jre/lib/ext/目录下或者由系统变量java.ext.dirs指定的目录下。扩展类加载器是由Java编写的,其具体实现类是sun.misc.Launcher$ExtClassLoader
。
应用类加载器是扩展类加载器的子类,也是我们通常接触到的默认的类加载器。它负责加载用户路径(ClassPath)上所指定的类库。这个路径可以通过环境变量CLASSPATH设置,也可以通过java命令的-classpath或者-cp参数设置。应用类加载器的实现类也是sun.misc.Launcher$AppClassLoader
。
每当子类加载器需要加载类时,首先会委托父类加载器进行加载,直到最顶层的引导类加载器。如果父类加载器无法加载该类,才会由子类加载器自己进行加载。
为了你加深对它们的印象,我了一张关于这三个的类加载器树图,你可以暂停看一下:
好,我们来做个总结。作为JVM的开篇,还是老样子,我为你构建一个建筑工地的世界。基于这个世界,我为你讲解了类加载器的工作原理。并且为你解答了一些类加载器过程中遇到的问题,带你重新回顾了一下,本篇文章的三位主人公,它们分别是:引导类加载器,扩展类加载器,应用类加载器。最后我留了几道面试题,不知道你是否都能答上来呢。
既然高级工程师和两位建筑工人已经把事情都划分完了,那么其它工人怎么办?类加载器可以自己定义吗?如何实现? 什么情况下需要使用自定义类加载器?你是否了解ServiceLoader和SPI机制?后面一篇我会回答这些问题,敬请期待。