JVM架构图「原|简」
JAVA中虚拟机的讲解,涉及「类加载机制,运行时区域,执行引擎,垃圾回收等」及对voliate, synchronized的JVM层面实现机制等。持续更新中…。 最新文章公众号持续更新中… 欢迎骚扰,分享技术,探讨生活。
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,本文主要讲解的就是架构原图的Class Loader SubSystem 「类装载系统」阶段,当然肯定涵盖常说的双亲委派模型。
在 Java 语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。
加载时机
以下五种情况必须对类进行初始化(而加载、验证、准备自然需要在此之前完成):
-
遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时没初始化触发初始化。也就是对应的使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
-
使用 java.lang.reflect 包的方法对类进行反射调用的时候。
-
当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化,先父到子,从上到下。「Main静态块 -> 父类静态块–>子类静态块–>父类构造块–>父类构造函数–>子类构造块–>子类构造函数」
-
当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
-
通过子类引用父类的静态对象不会导致子类的初始化 ,只有直接定义这个字段的类才会被初始化
public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world!" } // main方法中使用常量 System.out.println(ConstClass.HELLOWORLD);
-
常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类, 因此不会触发定义常量的类的初始化。
加载过程
-
通过一个类的全限定名来获取定义次类的二进制流。
-
将这个字节流所代表的静态存储结构转化为方法区「元空间」的运行时数据结构。
-
在内存「为什么会在内存中呢,因为任何程序都需要加载都内存才能和CPU交流」中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
注:加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。
数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:
- 如果数组的组件类型是引用类型,那就递归采用类加载加载。
- 如果数组的组件类型不是引用类型,Java 虚拟机会把数组标记为引导类加载器关联。
- 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。
JVM加载Class文件的原理机制
Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
类装载方式,有两种 :
- 1.隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,
- 2.显式装载, 通过class.forname()等方法,显式加载需要的类
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
连接过程「验证、准备、解析」
验证
是连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。
- 文件格式验证
- 是否以魔数 0xCAFEBABE 开头
- 主、次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持常量的类型(检查常量 tag 标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
- Class 文件中各个部分集文件本身是否有被删除的附加的其他信息
只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面 3 个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。
-
元数据验证「重点」
- 这个类是否有父类(除 java.lang.Object 之外)
- 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(覆盖父类 final 字段、出现不符合规范的重载)
这一阶段主要是对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
-
字节码验证
- 保证任意时刻操作数栈的数据类型与指令代码序列都配合工作(不会出现按照 long 类型读一个 int 型数据)
- 保证跳转指令不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)
- ……
这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。
- 符号引用验证
- 符号引用中通过字符创描述的全限定名是否能找到对应的类
- 在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
- ……
最后一个阶段的校验发生在将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,还有以上提及的内容。
符号引用的目的是确保解析动作能正常执行,如果无法通过符号引用验证将抛出一个 java.lang.IncompatibleClass
.ChangeError
异常的子类。如 java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
2.准备
这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含 static 修饰的变量不含实例变量
public static int value = 1127;
这句代码在初始值设置之后为 0,因为这时候尚未开始执行任何 Java 方法。而把 value 赋值为 1127 的 putstatic 指令是程序被编译后,存放于 clinit() 方法中,所以初始化阶段才会对 value 进行赋值。
基本数据类型的零值
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
Int | 0 | boolean | False |
long | 0L | Float | 0.0f |
short | (short)0 | double | 0.0d |
char | ‘\u0000’ | Reference | null |
byte | (byte)0 |
特殊情况:如果类字段的字段属性表中存在 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1127。
3.解析
这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
-
符号引用
符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。 -
直接引用
直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关常量池中符号引用0x1110111指向内存地址与虚拟机无关 转换直接引用的过程 (也就是转化为虚拟机内部能直接引用到的东西「直接指向目标的指针」)
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。
初始化
前面过程都是以虚拟机主导,而初始化阶段开始执行类中的 Java 代码。
-
初始化阶段是执行类构造器
<clinit>
() 方法的过程。类构造器<clinit>
()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生,代码从上往下执行。
-
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
-
虚拟机会保证一个类的
<clinit>
() 方法在多线程环境中被正确加锁和同步1)初始化类的五种条件(对类的主动引用)『上边的加载时机,加载 连接那些是在初始化之前』
JVM初始化主类,含有main方法的类
new getstatic putstatic invokestatic 这几条指令时
初始化子类时,父类未被初始化,则会先初始化父类
使用Class.forName(String className)加载类时
使用java.lang.reflect.*的方法对类进行反射调用时
2)被动引用类时,不会初始化类
子类调用父类的静态变量,子类不会被初始化,父类会被初始化,但是不会调用构造函数
创建类的引用数组,该类不会被初始化,eg Main[] list = new Main[10];
调用类的final静态常量,不会初始化该类
ClassLoader的loadClass方法只会加载类,不会初始化类
3)初始化就是执行类的 (){……}方法。
把静态变量的赋值和静态代码块等操作顺序串连成一个方法。
初始化的总结就是:初始化是为类的静态变量赋予正确的初始值
类加载器
通过一个类的全限定名来获取描述此类的二进制字节流。
加载这个过程,Java特意把这一步抽出来用类加载器来实现
- 启动类加载器
加载<JAVA_HOME>\lib
目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件 - 扩展类加载器
主要负责加载<JAVA_HOME>\lib\ext
目录中或被java.ext.dirs系统变量所指定的路径的类库 - 引用程序类加载器
主要负责加载用户类路径(classPath)上的类库 - 自定义类加载器,继承ClassLoader
public class TestClassLoader extends ClassLoader {
// 自定义的class扫描路径
private String classPath;
public TestClassLoader(String classPath) {
this.classPath = classPath;
}
/**
* 1.JVM在加载一个class时会先调用classloader的loadClassInternal方法
* 2.loadClassInternal方法就是调用了loadClass
* 2.1 先查看这个类是否已经被自己加载了 / 加载直接返回
* 2.2 没有加载, 如果有父类加载器,先委派给父类加载器来加载 parent.loadClass(name, false);
* 2.3 如果父类加载器为null,说明ExtClassLoader也没有找到目标类,则调用BootstrapClassLoader来 查找 findBootstrapClassOrNull(name);
* 2.4 如果都没有找到,调用findClass方法,尝试自己加载这个类(也就是我们这里重写的findClass)
*
* @param name
* @return
* @throws ClassNotFoundException
*/
// 覆写ClassLoader的findClass方法 findClass方法解析class字节流,并实例化class对象
protected Class<?> findClass(String name) throws ClassNotFoundException {
// getDate方法会根据自定义的路径扫描class,并返回class的字节
byte[] classData = getDate(name);
System.out.println(classData.toString());
if (classData == null) {
System.out.println("ClassNotFoundException");
throw new ClassNotFoundException();
} else {
// 生成class实例
return defineClass(name, classData, 0, classData.length);
}
}
/**
* 指定路径加载二进制字节流的过程 / 比如安全领域可以对字节码加密或OSGi、代码热部署
* @param name
* @return
*/
private byte[] getDate(String name) {
// 拼接目标class文件路径
String path = classPath + File.separatorChar + name.replace('.', File.separatorChar) + ".class";
System.out.println(path);
try {
InputStream is = new FileInputStream(path);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
byte[] buffer = new byte[2048];
int num = 0;
while ((num = is.read(buffer)) != -1) {
stream.write(buffer, 0 ,num);
}
return stream.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
双亲委派模型
自定义类加载器代码中已经简单分析了源码的双亲委派流程
{
// 1.JVM在加载一个class时会先调用classloader的loadClassInternal方法
// 2.loadClassInternal方法就是调用了loadClass,下边就是具体loadClass源码
// 先判断class是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 找父类加载器加载
c = parent.loadClass(name, false);
} else {
// 没父类加载器说明是顶层了,就用Bootstrap ClassLoader去加载
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();
// 最后如果没找到,那就自己找 自定义的加载器「继承classLoader实现findClass」
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
除顶层启动类加载器之外,其他都有自己的父类加载器。
工作过程「源码loadClass执行流程」:如果一个类加载器收到一个类加载的请求,它首先不会自己加载,而是把这个请求委派给父类加载器。只有父类无法完成时子类才会尝试加载。
为什么要双亲委派?
它使得类有了层次的划分。拿java.lang.Object
来说,加载它经过一层层委托最终是由Bootstrap ClassLoader
来加载的,最终都是由Bootstrap ClassLoader
去找<JAVA_HOME>\lib
中rt.jar里面的java.lang.Object
加载到JVM中。
这样如果有人想自己造了个java.lang.Object
,里面嵌了不太好的代码,如果我们是按照双亲委派模型来实现的话,最终加载到JVM中的只会是我们rt.jar里面的东西,也就是这些核心的基础类代码得到了保护。因为这个机制使得系统中只会出现一个java.lang.Object
。不会乱套,你想想如果我们JVM里面有两个Object 岂不是完蛋了。
所以很重要一点就是,两个类是否相等要看全限定名,是不是一个class文件和类加载器是否都一致,两个类是否相等,取决于他们是否由统一个类加载器来加载。如果他们来自不同的类加载器,哪么就算这两个类来自同一Class文件,他们也是不相等的。
一定是双亲委派吗?
SPI(Service Provider Interface),和API不一样,它是面向拓展的,我定义了这个SPI,具体如何实现由扩展者实现。我就是定了个规矩
例如JDBC,在rt.jar里面定义了这个SPI,那mysql有mysql的jdbc实现,oracle有oracle的jdbc实现,java不管你内部如何实现的,反正数据库厂商都得统一按这个来,这样java开发者才能容易的调用数据库操作。所以因为这样那就不得不违反这个约束,Bootstrap ClassLoader
就得委托子类来加载数据库厂商们提供的具体实现。因为它的手只能摸到<JAVA_HOME>\lib
中,其他的它无能为力。这就违反了自下而上的委托机制了。
怎么破坏双亲委派模型?
1.继承CLassLoade后重写loadClass方法
2.利用线程上下文加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承 一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序 类加载器。
3.为了实现热插拔,热部署,模块化,意思是添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。
本文参考《深入理解java虚拟机》