玩转类加载和类加载器

JVM系列文章目录

初识JVM

深入理解JVM内存区域

玩转JVM对象和引用

JVM分代回收机制和垃圾回收算法

细谈JVM垃圾回收与部分底层实现

Class文件结构及深入字节码指令

玩转类加载和类加载器

方法调用的底层实现

Java语法糖及底层实现

GC调优基础知识工具篇之JDK自带工具

GC调优基础知识工具篇之Arthas与动态追踪技术

JVM调优之内存优化与GC优化

JVM调优之预估调优与问题排查

JVM调优之玩转MAT分析内存泄漏

直接内存与JVM源码分析

JVM及时编译器



前言

本文基于JDK1.8,是博主个人的JVM学习记录,欢迎各位指正错误的地方。看这篇博客之前你可能得先看我上一篇博客:Class文件结构及深入字节码指令
,对Class文件结构和字节码指令有一定的了解,才方便你看着一篇博客。


类加载


一个类的生命周期

我们在开发的时候经常谈到这个类被加载了,这个“加载”和类生命周期的“加载”差别还是挺大的,我们一般开发中讨论的加载,指的是从加载 -> 验证 -> 准备 (-> 解析) -> 初始化这么一系列的步骤,这些步骤只是类生命周期的一部分,下面我来介绍一下类的生命周期。
类生命周期的七个阶段


阶段顺序

加载、验证、准备、初始化和卸载这五个的先后顺序是确定的,但是 解析 这个步骤就不一定了,它有可能在类初始化之后才开始(这样做是为了支持java运行时绑定的特性,也叫动态绑定或晚期绑定)。


加载的时机

类到底该在什么时候加载?这个其实在虚拟机规范里没有强制的约束,完全由虚拟机去自由发挥。虽然是自由发挥,但是有三件事是JVM必须做的:

  1. 通过一个类的全限定名获取这个类的二进制流(可以理解为读取类的Class文件,当然也不一定是文件,可能是通过网络或者数据读取)。
  2. 类最终要进方法区,但是如果只是二进制流这种静态数据格式肯定不行,所以要将它转换成方法区的运行时数据结构。
  3. 数据结构转换完成之后就可以正式将它转换成java.lang.Class对象,存储到方法区,作为访问这个类的各种数据的入口。

(简单理解就是:找到类文件,转换成JVM方法区的格式,正式变成Class对象能被访问到。)

验证

东西做出来了,当让要检查它对不对;所以第二部就是检查这个Class文件里到东西是不是符合JVM规格要求到合格产品。验证大致又下面四个阶段:

  1. 文件格式验证:
    这里要结合Class文件格式检查是不是 符合格式规范,比如是不是“cafe babe”开头,版本号是不是在JVM接受到范围之内,常量池会不会有不支持的常量类型…
    只有这一个验证步骤是读取的字节流文件,后面的验证都不是直接读取操作字节流了,毕竟在流水线上要相信上有同事嘛。

  2. 元数据验证
    元数据(用来描述数据的数据)就是诸如:方法、类这种;这一步主要是语义分析,保证不存在不符合《Java语言规范》到元数据信息。

  3. 字节码验证:
    这个阶段最复杂阶段,主要是通过数据流分析和控制流分析,对你写的方法进行分析,保证你的代码别太垃圾把JVM搞炸了,如果这个阶段出了问了,赶紧检查代码吧。

  4. 符号引用验证:
    这一步就是验证符号引用(一种定义)转换成直接引用(在内存中的实际数据)会不会有问题,比如说你代码里引用的类new不出来。

由此可见验证过程很重要,但是也不也不是必须的;为什么这么说呢?因为JVM提供了一个参数-Xverify:none官方文档来关闭大部分类的验证,缩短虚拟机类加载时间;但是我建议你别这么做,你能保证你写的代码百分之一万能不出问题吗?

准备

这个阶段是给类中的静态基本类型变量分配内存并赋零值,如果这个静态变量是对象的话,就不在范围内,下面是零值表:

数据类型零值
int0
long0L
short(short)0
char‘\u0000’
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

解析

这个阶段JVM将常量池内的符号引用转换为直接引用的过程,包括:类或接口的解析、字段解析、类方法解析、接口方法解析。这里我介绍几个常发生的异常:
java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。(字段解析异常) jjava.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。(类或接口的解析异常)
jjava.lang.NoSuchMethodError 找不到相关方法时的错误。(类方法解析、接口方法解析时发生的异常)

初始化

初始化主要是对类中static{}(静态语句块,这一块是线程安全的,JVM保证有多个线程同时初始化的时候,只有一个线程执行这个代码,且这个代码只被执行一次)进行操作(对应字节码的clinit方法,如果一个类没有静态语句块,也没有对变量的赋值操作,就没有clinit方法)

这里特别要注意,JVM对类初始化做了六种严格的规范要求,如果符合这个六种情况之一,要立即初始化:

  1. 遇到new、getstatic、putstatic、invokestatic这四个字节指令,如果类没有初始化,立即初始化,在java代码中常见的场景时:

    • 使用new关键字实例化对象
    • 读取或者设置一个类的静态字段(被final修饰、已在编译期间把结果放 入常量池的静态字段除外,这个限制也不是绝对的,我下面会介绍一个例子,所以还是以字节码指令为准)
    • 调用一个类的静态方法的时候
  2. 使用反射机制对类进行反射调用的时候,如果类没有被实例化,则先将其实例化。

  3. 当初始化一个类的时候,它的父类还没有被初始化,先初始化它的父类。

  4. 当虚拟机启动的时候,需要先启用一个主类(有main方法的类),如果这个类还没有被初始化,则先初始化这个主类。

  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法 句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

  6. 当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前 被初始化。

下面介绍一示例代码,各位来结合着看:
这是主类

/** 
 * @author: abfeathers
 * @date: 2021/3/14
 * @description: 初始化的各种场景
 * 通过VM参数可以观察操作是否会导致子类的加载 -XX:+TraceClassLoading
 */
public class Initialization {
	public static void main(String[]args){
		Initialization initialization = new Initialization();
		initialization.M1();//打印子类的静态字段
//		initialization.M2();//使用数组的方式创建
//		initialization.M3();//打印一个常量
//		initialization.M4();//如果使用常量去引用另外一个常量
	}
	public void M1(){
		//如果通过子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化(但是子类会被加载)
		System.out.println(SubClaszz.value);
	}
	public void M2(){
		//使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)
		SuperClazz[]sca = new SuperClazz[10];
	}
	public void M3(){
		//打印一个常量,不会触发初始化(同样不会触类加载、编译的时候这个常量已经进入了自己class的常量池)
		System.out.println(SuperClazz.HELLO_WORLD);
	}
	public void M4(){
		//如果使用常量去引用另外一个常量
		System.out.println(SuperClazz.WHAT);
	}
}

这是父类

/**
 * @author: abfeathers
 * @date: 2021/3/14
 * @description: 父类
 */
public class SuperClazz {
	static 	{
		System.out.println("父类初始化!");
	}
	public static int value=123;
	public static final String HELLO_WORLD="hello abfeathers";
	public static final int WHAT = value;
}

这是子类

/**
 * @author: abfeathers
 * @date: 2021/3/14
 * @description: 子类
 */
public class SubClaszz extends SuperClazz {
	static{
		System.out.println("子类初始化!");
	}

}

现在我们开始实验:

实验1

执行M1方法
在这里插入图片描述

查看打印结果:
在这里插入图片描述
我们使用参数-XX:+TraceClassLoading来查看类加载情况:父类和子类都被加载了
在这里插入图片描述

我们在结合字节码信息看一下
在这里插入图片描述
这里只有父类被初始化了,这就符合读取类的静态字段(遇到了getstatic指令),这个类如果没被初始化,则将其初始化。

实验2

我们调用M2
在这里插入图片描述
执行结果:这里没有满足6种情况中任何一种,所以没有初始化
在这里插入图片描述
我们使用参数-XX:+TraceClassLoading来查看类加载情况:只有父类被加载了
在这里插入图片描述

实验3

这次我们打印父类的一个常量
在这里插入图片描述
执行结果:没有类被初始化
在这里插入图片描述
我们使用参数-XX:+TraceClassLoading来查看类加载情况:父类子类没有加载信息。

在这里插入图片描述
在这里插入图片描述
再来看看字节码信息:value已经被放入常量池中
在这里插入图片描述

实验4

这次我们调用父类的一个静态常量引用
在这里插入图片描述

执行结果:父类被初始化了
在这里插入图片描述

查看字节码信息:有getstatic指令,被初始化了,这个值在编译阶段无法确定,只能将类初始化了。
在这里插入图片描述

查看类加载情况:只有父类被加载了
在这里插入图片描述


类加载器


JDK提供的三种类加载器


Bootstrap ClassLoader

启动类加载器,这是加载器中的龙头老大,任何类的加载行为,都要经它过问。
它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包 的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
这个加载器是 C++ 编写的,随着 JVM 启动。


Extension ClassLoader

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。
同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
这个加载器是个 Java 类,继承自 URLClassLoader。


Application ClassLoader

这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。
一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码, 会首先尝试使用这个类加载器进行加载。


Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。


类加载器的问题

这里我举个例子,如果你写一个java.lang包,然后改写一些核心类,这经过类加载器编译加载,哦豁完蛋,项目直接GG,可以卷铺盖回家了。
JVM为了解决这个问题,使用 加载该类的类加载器名称+类全限定名组合确定其在JVM中的 唯一性,但是这样还不行,如果一个类被好几个类加载器同时加载,那它就有多个了,所以要限制其只能被一个正确的类加载器去正确的加载,就诞生了双亲委派模型


双亲委派模型

双亲委派模型其实很好理解,打个比方:你的项目团队要做一个新功能,任务派到了你手里,你自己先不干,问你上司有没有干过,干过就让他那源码给你;没有干过你上司又去问他/她的上司,这样一层层问下去最后问到顶级boss,如果最后都没干过,就只能你自己干了。好,我们现在专业点描述一下:双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
在这里插入图片描述
说一千道一万,我们直接上源码瞧一瞧:我们直接找到java.lang.ClassLoader的loadClass方法看它是怎么加载的,代码很简答,我直接在图里简单的解释一下。
在这里插入图片描述


使用双亲委派模型的好处

稳定、唯一 。类加载器有了一定的优先级层级关系。比如java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载。


自定义类加载器

JDK的类加载器有千般好,但是有的时候还是满足不了我们的业务需求,这里我就介绍两种不太一样的自定义类加载器(它们打破了双亲委派模型,别急,这波打破很稳,不影响项目)。


Tomcat中的自定义类加载器

先上Tomcat类加载器的结构图:
在这里插入图片描述
如果你用过Tomcat容器的话,你会发现,就算你同时发布同一个项目工程的好几个不同的版本的jar包上去,只要访问路径不一样,就可以正常运行。这里面肯定有大量同限定名的类啊。

对照上图,我们发现实际上Tomcat并没有打破JDK的双亲委派模型,它只是自己定义的几个类加载器没有遵循,这是为什么呢?

对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。这个类加载器能隔绝不同应用的class文件,使它们互不影响。
我在网上找到了Tomcat的WebAppClassLoader的源码图
在这里插入图片描述
在这里插入图片描述

SPI

SPI这个东西各位在平时项目开发的时候肯定用到过,只是大部分人可能没有注意。SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。比如说我们常用的JDBC驱动,我们在使用这些第三方驱动的时候首先会引入这些依赖,打开这些依赖jar包,我们发现它们都有一个META-INFO文件夹,这个文件下就是一些符合SPI机制的配置信息。这里我以JDBC为例来讲解。
在这里插入图片描述
在这里插入图片描述

SPI打破双亲委派模型

以JDBC为例

为什么说SPI打破了双亲委派模型呢?SPI本身是由JDK规范接口的,DriverManager类和ServiceLoader类都属于rt.jar这个包的,它们都是被BootstrapClassLoader加载的,但是数据库驱动属于业务代码,JDK只提供了接口,实现由第三方厂商去实现,按照类加载逻辑,被引用类要被加载引用类的加载器加载的,但是启动类加载器由没办法加载它。这就只能让ApplicationClassLoader加载去加载了,而这个加载就有意思了,直接不过问上级,自己撸起袖子就干。来我们看看代码实现:

我们先在DriverManager类中找到loadInitialDrivers方法,这个方法是初始化驱动的。
在这里插入图片描述
点进去看,重点就在ServiceLoader.load这里了
在这里插入图片描述
点击,进入ServiceLoader.load方法,再来看一看,好家伙直接取了线程上下文类加载器,根本没有玩双亲委派模型那一套。
在这里插入图片描述
来我们再看看这个上下文线程是什么,我们得先搜索Launcher(这个类在主线程起来的时候就会被加载),然后找一找在哪里设置的当前线程上下文类加载器。它直接取了AppClassLoader设置成了当前线程上下文类加载器
在这里插入图片描述
总结一下这个SPI就是通过直接使用当前线程上下文类加载器破坏了双亲委派模型。


OSGI

这个东西属实比较复杂,而且这个东西也很尴尬,JDK1.9有其他的替代方案(JPMS),曾经流行过,Eclipse的插件系统就是以它为基础实现的。

OSGI规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行,比较霸道。 比如,在一般 Java 应用程序中,classpath 中的所有类都对所有其他类可见,这是毋庸置疑的。但是,OSGI类加载器基于 OSGI 规范和每个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得非常的怪异。但我们不难想象,这种与直觉相违背的加载方式,这些都是由 专用的类加载器来实现的。

OSGI是一个很大技术话题,不是我三言两语能说清楚的,只能大概说一下,有这么个复杂的东西,它实现了模块化,每个模块都可以独立安装、启动、停止、卸载。

各位如果对OSGI有兴趣的话也可以自己花个小一年时间去深入,我这里就不献丑了,对OSGI实在水平有限



上一篇:Class文件结构及深入字节码指令

下一篇:方法调用的底层实现

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值