没有为 skinppwtl.dll 加载的符号文件_JVM专题(一)类加载器子系统

前言:

JVM一直是我们工作学习中的一道坎, 在此我们就一起系统的学习下JVM相关知识吧. 我们知道JVM一共分以下四个主要部分:

类加载器子系统

运行时数据区(内存结构)

执行引擎

垃圾回收器

我们将在本文中学习第一个部分类加载子系统

类加载过程

klass模型

java中的每个类, 在jvm中都有对应的klass模型与之对应, 存储类的元信息如:常量池, 属性信息, 方法信息等…

看下klass模型类的继承结构:909882a22c44405e7fc33c5b7de4a5d0.png
从继承关系上可以看出, 类的元信息是存储在元空间上的.
我们思考一个问题, 当我们一个启动一个java程序, 类加载器将.class文件加载到系统解析后是如何存储的呢? 其实答案就是上面的klass类, 我们通过HSDB工具可以查看.有些人可能会有些不理解klass类到底是什么?和我们的.class有什么区别呢?
首先.class也就是我们自己编写的java代码编译后产生的文件, 也就是我们自己的java类, 而klass实际上就是java类被加载到jvm中存在形式(c++代码),下面我们来详细看下klass模型类都对应我们java中的哪些对象:

  1. InstanseKlass:普通的Java类在JVM中对应的是instanceKlass类的实例,再来说下它的三个字类
    ● InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象,实际上就是这个C++类的实例,存储在堆区,学名镜像类
    ● InstanceRefKlass:用于表示java/lang/ref/Reference类的子类
    ● InstanceClassLoaderKlass:用于遍历某个加载器加载的类

  2. ArrayKlass:见名知意, 用于表示数组类型的对象元信息,Java中的数组不是静态数据类型,是动态数据类型,即是运行期生成的,Java数组的元信息用ArrayKlass的子类来表示:
    ● TypeArrayKlass:用于表示基本类型的数组
    ● ObjArrayKlass:用于表示引用类型的数组

类加载过程

我们在网上搜索类加载过程总能看到下面这张图片09cb5e7dd3c2141605f3d50009d7d8b9.png
从图中可以看出, 类加载过程一共分为七个步骤, 其实这么说是不准确的, 我们可以说这么说: 类的生命周期有七个步骤, 但是类的加载过程只有前5个阶段. 下面我们来详细看下类的加载过程每个阶段都做了什么

加载Loading
  1. 通过类的全限定名获取存储该类的class文件(没有指明必须从哪获取)

  2. 解析成运行时数据,即instanceKlass实例,存放在方法区

  3. 在堆区生成该类的Class对象,即instanceMirrorKlass实例

那么何时加载呢?
  1. 主动使用时
     ● new、getstatic、putstatic、invokestatic
     ● 反射
     ● 初始化一个类的子类会去加载其父类
     ● 启动类(main函数所在类)
     ● 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化

  2. 预加载
    包装类、String、Thread

从哪加载呢?

因为没有指明必须从哪获取class文件,脑洞大开的工程师们开发了这些
 ● 从压缩包中读取,如jar、war
 ● 从网络中获取,如Web Applet
 ● 动态生成,如动态代理、CGLIB
 ● 由其他文件生成,如JSP
 ● 从数据库读取
 ● 从加密文件中读取

验证Verification
1. 文件格式验证

第一阶段主要是验证字节流是否符合Class文件格式的规范, 并且能被当前版本的虚拟机处理.具体对一下验证点进行了验证
 ● 是否以魔数 0xCAFEBABE 开头
 ● 主、次版本号是否在当前虚拟机处理范围之内
 ● 常量池的长两种是否有不被支持的常量类型(检查常量 tag 标志)
 ● 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
 ● CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
 ● Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息

2.元数据验证

第二阶段对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段可能包括的验证点如下:
 ● 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
 ● 这个类的父类是否有继承了不允许被继承的类(被 final 修饰的类)
 ● 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
 ● 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 fianl 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
第二阶段的主要目的是对类的元数据信息进行语义检验,保证不存在不符合 Java 语言规范的元数据信息。

3. 字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段对类的方法体进行校验分析,保证被叫眼泪的方法在运行时不会做出危害虚拟机安全的时间,例如:
 ● 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中
 ● 保证跳转指令不会跳转到方法体以外的字节码指令上
 ● 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这样是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给它毫无继承关系、毫不相关的一个数据类型,则是危险和不合法的

4. 符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段—解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要检验下列内容
 ● 符号引用中通过字符串描述的全限定名是否能找到对应的类
 ● 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
 ● 符号引用中的类、字段、方法的访问性(private、default、protected、public)是否可被当前类访问
符号应用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。

准备Preparation

准备阶段一般情况下是为静态变量分配内存并赋初值
实例变量是在创建对象的时候完成赋值的,没有赋初值一说
基本数据类型的初值如下:827571be42ef4b68a6c9e8d78faf57fe.png
赋初值上面也说是一般情况下, 那么什么情况下不会为静态变量赋初值呢? 那就是被final修饰的静态变量, 在编译的时候会给属性添加ConstantValue属性, 在准备阶段直接给变量赋值, 就没有赋初值这一步, 例如:

// 准备阶段, i被赋初值为0, 在初始化阶段, 才会把i赋值10

static int i = 10;

// 在编译时, 被final修饰, 在编译的时候会给j添加ConstantValue属性ConstantValue指定的属性为20, 准备阶段, 会直接给j赋值=> j = ConstantValue指定的属性 = 20

static final int j = 20;

解析Resolution

解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程, 我们先看下什么是符号引用和直接引用
 ● 符号引用:以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中

 ● 直接引用:可以使直接指向目标的指针、相对偏移量或是一个能直接定位到目标的句柄。直接引用适合虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在

我的个人理解: 符号引用就是在编译阶段, 每个类的会被编译成一个class文件, 但是在编译时它并不知道所引用类或接口(字段, 方法, 接口等)的内存地址, 此时就用一个符号来代替, 这个符号就是符号引用, 而解析阶段, 就是引用类符号地址转化为实际内存地址的一个阶段

何时解析?
 openjdk采用思路是在使用的时候去解析, 在使用以下16个字节码命令的时候会进行解析:
anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield


初始化Initialization

执行静态代码块,完成静态变量的赋值, 之前在准备阶段被赋初值的对象, 会在这个阶段赋实际的值
那么在这里思考一个问题, 为什么静态代码块和静态字段的赋值会在类加载之前完成?
其实通过字节码可以看到,静态字段、静态代码段,字节码层面会生成clinit方法(被final修饰且初始化语句是编译时常量表达式时不会生成clinit方法)0874c19e7255eefc4c3ba1fc9e15492e.png方法(类构造器)是在初始化阶段执行的, 该方法只能被jvm调用, 专门承担类变量的初始化工作,它里面包含所有的类变量初始化语句和类型的静态初始化器, 方法是在类加载过程中执行的,而是在对象实例化执行的,所以一定比先执行, 所以静态代码块和静态字段会在类加载之前完,并且方法中语句的先后顺序与代码的编写顺序相关


类加载器


JVM中有两种类型的类加载器,由C++编写的及由Java编写的。除了启动类加载器(Bootstrap Class Loader)是由C++编写的,其他都是由Java编写的。由Java编写的类加载器都继承自类java.lang.ClassLoader。我们也可以通过继承java.lang.ClassLoader来创建一个自定义类加载器
各种类加载器之间存在着逻辑上的父子关系,但不是真正意义上的父子关系,因为它们直接没有从属关系。7abcb76a37b365d611e2ee65069b2429.png

启动类加载器(BootstrapClassLoader)

因为启动类加载器是由C++编写的, 通过Java程序去查看显示的是null, 因此, 启动类加载器无法被Java程序调用, 启动类加载器不像其他类加载器有实体, 它是没有实体的, JVM将C++处理类加载的一套逻辑定义为启动类加载器, 我们通过代码来查看一下启动类加载器加载类的路径:

// 获取启动类加载器加载类的路径

URL[] urLs = Launcher.getBootstrapClassPath().getURLs();

for (URL urL : urLs) {

System.out.println(urL);

}

// 返回结果

file:/D:/rj/jdk/jdk8/jre/lib/resources.jar

file:/D:/rj/jdk/jdk8/jre/lib/rt.jar

file:/D:/rj/jdk/jdk8/jre/lib/sunrsasign.jar

file:/D:/rj/jdk/jdk8/jre/lib/jsse.jar

file:/D:/rj/jdk/jdk8/jre/lib/jce.jar

file:/D:/rj/jdk/jdk8/jre/lib/charsets.jar

file:/D:/rj/jdk/jdk8/jre/lib/jfr.jar

file:/D:/rj/jdk/jdk8/jre/classes

扩展类加载器(ExtensionClassLoader)

该加载器器是用JAVA编写,且它的父类加载器是Bootstrap,是由sun.misc.Launcher$ExtClassLoader实现的,主要加载JAVA_HOME/lib/ext目录中的类库。开发者可以自己使用扩展类加载器。

// 获取扩展类加载器加载路径

ClassLoader classLoader = ClassLoader.getSystemClassLoader().getParent();

URLClassLoader urlClassLoader = (URLClassLoader) classLoader;

URL[] urls = urlClassLoader.getURLs();

for (URL url : urls) {

System.out.println(url);

}

// 执行结果

file:/D:/rj/jdk/jdk8/jre/lib/ext/access-bridge-64.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/cldrdata.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/dnsns.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/jaccess.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/jfxrt.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/localedata.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/nashorn.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/sunec.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/sunjce_provider.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/sunmscapi.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/sunpkcs11.jar

file:/D:/rj/jdk/jdk8/jre/lib/ext/zipfs.jar

可以通过java.ext.dirs指定扩展类加载器加载路径

应用程序类加载器(ApplicationClassLoader)

应用程序类加载器, 也是由java编写,负责加载应用程序classpath目录下的所有jar和class文件。它的父加载器为Ext ClassLoader。也就是我们编写的代码一般都是AppClassLoader加载的

自定义类加载器(UserClassLoader)

除了以上jdk自带的三种类加载器外, 开发者还可以通过继承ClassLoader类来实现自定义类加载器, 只要重写findClass方法就行, 我们先看下findClass源码:

protected Class> findClass(String name) throws ClassNotFoundException {

throw new ClassNotFoundException(name);

}

可以看到, 这里的findClass直接就抛出了一个异常, 我们可以通过重写这个方法来实现我们自己的加载逻辑, 下面是一个自定义类加载器的例子:

public class MyClassLoader extends ClassLoader {

private static final String SUFFIX = ".class";

@Override

protected Class> findClass(String className) throws ClassNotFoundException {

System.out.println("MyClassLoader findClass");

byte[] data = getData(className.replace('.', '/'));

return defineClass(className, data, 0, data.length);

}

private byte[] getData(String name) {

InputStream inputStream = null;

ByteArrayOutputStream outputStream = null;

File file = new File(name + SUFFIX);

if (!file.exists()) return null;

try {

inputStream = new FileInputStream(file);

outputStream = new ByteArrayOutputStream();

int size = 0;

byte[] buffer = new byte[1024];

while ((size = inputStream.read(buffer)) != -1) {

outputStream.write(buffer, 0, size);

}

return outputStream.toByteArray();

} catch (FileNotFoundException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

} finally {

try {

inputStream.close();

outputStream.close();

} catch (Exception ex) {

ex.printStackTrace();

}

}

return null;

}

}

我们用自定义加载的类加载器来加载本身试下, 代码如下:

public static void main(String[] args) {

MyClassLoader classloader = new MyClassLoader();

try {

Class> clazz = classloader.loadClass(MyClassLoader.class.getName());

System.out.println(clazz);

System.out.println(clazz.getClassLoader());

} catch (ClassNotFoundException e) {

e.printStackTrace();

}

}

// 执行结果

class main.java.paodan.test.MyClassLoader

sun.misc.Launcher$AppClassLoader@18b4aac2

从上面的结果, 我们可以看到, 虽然类被加载成功了, 但是使用的类加载器却是AppClassLoader, 而并不是我们的自定义类加载器, 为什么呢? 因为双亲委派模型, 自定义类加载器的父类能够加载到这个类, 所以就由AppClassLoader去加载了. 所以在不打破双亲委派的前提下, 我们自定义加载器只能加载三种父类加载器以外的类, 接下来我们就来讲下什么是双亲委派模型

双亲委派模型

如果一个类加载器收到了加载某个类的请求,则该类加载器并不会去加载该类,而是把这个请求委派给父类加载器,每一个层次的类加载器都是如此,因此所有的类加载请求最终都会传送到顶端的启动类加载器;只有当父类加载器在其搜索范围内无法找到所需的类,并将该结果反馈给子类加载器,子类加载器会尝试去自己加载。09c230e8c0857294648d2c6102965a40.png
双亲委派源代码如下:

protected Class> loadClass(String name, boolean resolve)

throws ClassNotFoundException

{

synchronized (getClassLoadingLock(name)) {

// First, check if the class has already been loaded 首先, 检查类是否已经被加载

Class> 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. 如果父类没有加载, 就调用findClass()自己去加载

long t1 = System.nanoTime();

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;

}

}

打破双亲委派

因为在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派。
类似这样的情况, 就叫做打破双亲委派
我们可以通过重写loadClass方法来打破双亲委派, 话不多说, 上代码:

@Override

public Class> loadClass(String name)

throws ClassNotFoundException

{

// 如果是指定路径, 就自己去加载, 否则调用父类去加载

if (name.startsWith("main.java.paodan.test")){

return findClass(name);

}

return super.loadClass(name);

}

来看下执行结果:

MyClassLoader findClass

class main.java.paodan.test.MyClassLoader

main.java.paodan.test.MyClassLoader@1f89ab83

打破双亲委派, 通过自定义类加载器加载成功.

那么为什么要有双亲委派模型呢?

任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
也就是说,判断2个类是否“相等”,只有在这2个类是由同一个类加载器加载的前提下才有意义,否则即使这2个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这2个类必定不相等。

基于双亲委派模型设计,那么Java中基础的类,Object类似Object类重复多次的问题就不会存在了,因为经过层层传递,加载请求最终都会被Bootstrap ClassLoader所响应。加载的Object类也会只有一个,否则如果用户自己编写了一个java.lang.Object类,并把它放到了ClassPath中,会出现很多个Object类,这样Java类型体系中最最基础的行为都无法保证,应用程序也将一片混乱。


除了双亲委派, 类加载还有以下两个机制, 由于比较好理解, 我们就不在此过多阐述

全盘负责:当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也由该类加载器负责载入,除非显示使用另一个类加载器来载入。缓存机制:缓存机制会将已经加载的class缓存起来,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存中不存在该Class时,系统才会读取该类的二进制数据,并将其转换为Class对象,存入缓存中。这就是为什么更改了class后,需要重启JVM才生效的原因。


最后让我们用一个详细的类加载流程图来作为这篇文章的结尾吧721027f9db9d56a5c8c629f956bc5e97.png

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值