JVM-类加载器

1.前置知识

1.1CPU与内存交互图:

2.类加载器ClassLoader

在装载(Load)阶段,其中第(1)步:通过类的全限定名获取其定义的二进制字节流,需要借助类装
载器完成,顾名思义,就是用来装载Class文件的。

2.1什么是类加载器?

负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例的代码模块。
类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。

一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在
的,这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不
同,则仍然不算作是同一个类,无法通过 instanceOf 、equals 等方式的校验。

2.2类加载器分类

1)Bootstrap ClassLoader 负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包

2)Extension ClassLoader 负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.e

3)App ClassLoader 负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。

4)Custom ClassLoader 通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的Class

2.3为什么我们的类加载器要分层?

1.2版本的JVM中,只有一个类加载器,就是现在的"Bootstrap"类加载器。也就是根类加载器。 但是这样会出现一个问题。

假如用户调用他编写的java.lang.String类。理论上该类可以访问和改变java.lang包下其他类的默认 访问修饰符的属性和方法的能力。也就是说,我们其他的类使用String时也会调用这个类,因为只有 一个类加载器,我无法判定到底加载哪个。因为Java语言本身并没有阻止这种行为,所以会出现问题。

这个时候,我们就想到,可不可以使用不同级别的类加载器来对我们的信任级别做一个区分呢?

比如用三种基础的类加载器做为我们的三种不同的信任级别。最可信的级别是java核心API类。然后 是安装的拓展类,最后才是在类路径中的类(属于你本机的类)。

2.3.1验证类加载器

java 复制代码
public class Demo3 {
public static void main(String[] args) {
// App ClassLoader
System.out.println(new Worker().getClass().getClassLoader());
// Ext ClassLoader
System.out.println(new Worker().getClass().getClassLoader().getParent());
// Bootstrap ClassLoader
System.out.println(new Worker().getClass().getClassLoader().getParent().getParent()); System.out.println(new String().getClass().getClassLoader());
}
}

sun.misc.Launcher$AppClassLoader@18b4aac2

sun.misc.Launcher$ExtClassLoader@3a71f4dd

null

null

2.4JVM类加载机制的三种特性:

2.4.1全盘负责

当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class,也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

例如,系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所 依赖的类及引用的类也载入,依此类推。"全盘负责"机制也可称为当前类加载器负责机制。 显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。

以上步骤只是调用了ClassLoader.loadClass(name)方法,并没有真正定义类。真正加载class 字节码文件生成Class对象由"双亲委派"机制完成。

2.4.2父类委托"双亲委派":

是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。

2.4.2.1"双亲委派"机制加载Class的具体过程是:

  1. ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托 给父类加载器。

  2. 父类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则委托给 祖父类加载器。

  3. 依此类推,直到始祖类加载器(引用类加载器)。

  4. 始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试 从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果 载入失败,则委托给始祖类加载器的子类加载器。

  5. 始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。

  6. 依此类推,直到源ClassLoader。

  7. 源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。

2.4.2.1"双亲委派"机制只是Java推荐的机制,并不是强制的机制。

我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应

该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

2.4.3缓存机制:

缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启 JVM,程序的修改才会生效.对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass方法不会被重复调用。

而这里我们JDK8使用的是直接内存,所以我们会用到直接内存进行缓存。这也就是我们的类变量为什么只会被初始化一次的由来。

java 复制代码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First,在虚拟机内存中查找是否已经加载过此类...类缓存的主要问题所在!!!
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.
long t1 = System.nanoTime();
//调用此类加载器所实现的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方法是当字节码加载到内存后进行链接操作,对文件格式和字节码验证,并为 sta resolveClass(c);
}
return c;
}
}

2.4.4打破双亲委派

双亲委派这个模型并不是强制模型,而且会带来一些些的问题。就比如java.sql.Driver这个东西。JDK只能提供一个规范接口,而不能提供实现。提供实现的是实际的数据库提供商。提供商的库总不 能放JDK目录里吧。

所以java想到了几种办法可以用来打破我们的双亲委派。

SPI(service Provider Interface) :

比如Java从1.6搞出了SPI就是为了优雅的解决这类问题------JDK 提供接口,供应商提供服务。编程人员编码时面向接口编程,然后JDK能够自动找到合适的实现,岂不是很爽?

Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心 类库使用。比如JDBC中的DriverManager

OSGI:

比如我们的JAVA程序员更加追求程序的动态性,比如代码热部署,代码热替换。也就是就是机器

不用重启,只要部署上就能用。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实 现。每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一 起,换掉以实现代码的热替换。

2.4.5自定义类加载器

java 复制代码
package com.example.jvmcase.loader;


import java.io.*;


public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
//此方法负责将二进制的字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root +
File.separatorChar + className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}


public String getRoot() {
return root;
}


public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
//        classLoader.setRoot("D:\\codes\\jvm-case\\src\\main\\java");
classLoader.setRoot("D:\\");


Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.example.jvmcase.basic.Test");
testClass = classLoader.loadClass("Test");
System.out.println(testClass);
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:

1、这里传递的文件名需要是类的全限定性名称,即 Test 格式的,因为 defifineClass 方法是按

这种格式进行处理的。

如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的setRoot就是 前缀地址 setRoot + loadClass的路径就是文件的绝对路径

2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。

3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过 我们自定义类加载器来加载。

如果我们把Test放在类路径之下,那么我们将会通过AppClassLoader加载​​​​​​​

打印结果:

class com.example.jvmcase.basic.Test

sun.misc.Launcher$AppClassLoader@18b4aac2

3.常量池分类:

3.1.静态常量池

静态常量池是相对于运行时常量池来说的,属于描述class文件结构的一部分

字面量符号引用组成,在类被加载后会将静态常量池加载到内存中也就是运行时常量池

字面量 :文本,字符串以及Final修饰的内容

符号引用 :类,接口,方法,字段等相关的描述信息。

3.2.运行时常量池

当静态常量池被加载到内存后就会变成运行时常量池。也就是真正的把文件的内容落地到JVM内存了

3.3.字符串常量池

设计理念:字符串作为最常用的数据类型,为减小内存的开销,专门为其开辟了一块内存区域(字符串常量池)用以存放。

JDK1.6及之前版本,字符串常量池是位于永久代(相当于现在的方法区)。

JDK1.7之后,字符串常量池位于Heap堆中

3.3.1面试常问点:

下列三种操作最多产生哪些对象

1.直接赋值

`String a ="aaaa";`

解析:

最多创建一个字符串对象。

首先"aaaa"会被认为字面量,先在字符串常量池中查找(.equals()),如果没有找到,在堆中创建"aaaa"字符串对象,并且将"aaaa"的引用维护到字符串常量池中(实际是一个hashTable结构,存放key-value结构数据),再返回该引用;如果在字符串常量池中已经存在"aaaa"的引用,直接返回该引用。

2.new String()

`String a =new String("aaaa");`

解析:

最多会创建两个对象。

首先"aaaa"会被认为字面量,先在字符串常量池中查找(.equals()),如果没有找到,在堆中创建"aaaa"字符串对象,然后再在堆中创建一个"aaaa"对象,返回后面"aaaa"的引用;

3.intern()

String s1 = new String("yzt");
String s2 = s1.intern();
System.out.println(s1 == s2); //false

解析:

String中的intern方法是一个 native 的方法,当调用 intern方法时,如果常量池已经包含一个等于此String对象的字符串(用equals(object)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将s1 复制到字符串常量池里)

3.4常量池在内存中的布局:

4. 运行时数据区(Run-Time Data Areas)

将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

在Java堆中生成一个代表这个类的java .lang.Class对象,作为对方法区中这些数据的访问入口

在装载阶段的第(2),(3)步可以发现有运行时数据,堆,方法区等名词

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

(3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

说白了就是类文件被类装载器装载进来之后,类中的内容(比如变量,常量,方法,对象等这些数据得要有个去处,也就是要存储起来,存储的位置肯定是在JVM中有对应的空间)

官网概括

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会销毁。其他数据区域是按线程划分的。每个线程的数据区域在创建线程时创建,在线程退出时销毁。

4.1Method Area(方法区)

1.方法区是各个线程共享的内存区域,在虚拟机启动时创建

2.虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来

3.用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

此时回看装载阶段的第2步,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

如果这时候把从Class文件到装载的第(1)和(2)步合并起来理解的话,可以画个图

5.Heap(堆)

1)Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。

2)Java对象实例以及数组都在堆上分配。

此时回看装载阶段的第3步,在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

此时装载(1)(2)(3)的图可以改动一下

6. Java Virtual Machine Stacks(虚拟机栈)

经过上面的分析,类加载机制的装载过程已经完成,后续的链接,初始化也会相应的生效。

假如目前的阶段是初始化完成了,后续做啥呢?肯定是Use使用咯,不用的话这样折腾来折腾去有什么意义?那怎样才能被使用到?换句话说里面内容怎样才能被执行?比如通过主函数main调用其他方法,这种方式实际上是main线程执行之后调用的方法,即要想使用里面的各种内容,得要以线程为单位,执行相应的方法才行。那一个线程执行的状态如何维护?一个线程可以执行多少个方法?这样的关系怎么维护呢?

1)虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。

2)每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。

调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。

java 复制代码
void a(){
b();
}
void b(){
c();
}
void c(){

}

6.1栈帧

栈帧:每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间。

每个栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向运行时常量池的引用(A reference to the run-time constant pool)、方法返回地址(Return Address)和附加信息。

局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中

局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。

操作数栈:以压栈和出栈的方式存储操作数的

动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

方法返回地址:当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

6.1.1结合字节码指令理解栈帧

javap -c Person.class > Person.txt

java 复制代码
Compiled from "Person.java"
class Person {
...     
 public static int calc(int, int);
  Code:
   0: iconst_3   //将int类型常量3压入[操作数栈]
   1: istore_0   //将int类型值存入[局部变量0]
   2: iload_0    //从[局部变量0]中装载int类型值入栈
   3: iload_1    //从[局部变量1]中装载int类型值入栈
   4: iadd     //将栈顶元素弹出栈,执行int类型的加法,结果入栈
   5: istore_2   //将栈顶int类型值保存到[局部变量2]中
   6: iload_2    //从[局部变量2]中装载int类型值入栈
   7: ireturn    //从方法中返回int类型的数据
...
}

index的值是0还是1

在类方法调用时,任何参数都在从局部变量0开始的连续局部变量中传递。在实例方法调用时,总是使用局部变量0向调用实例方法的对象传递引用(在Java编程语言中是这样)。任何参数随后都在从局部变量1开始的连续局部变量中传递。

7.The pc Register(程序计数器)

我们都知道一个JVM进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据CPU调度来的。

假如线程A正在执行到某个地方,突然失去了CPU的执行权,切换到线程B了,然后当线程A再获得CPU执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到的位置。

如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;

如果正在执行的是Native方法,则这个计数器为空。

Java虚拟机可以支持多个线程同时执行(JLS§17)。每个Java虚拟机线程都有自己的pc(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法(§2.6)。如果该方法不是本机的,则pc寄存器包含当前正在执行的Java虚拟机指令的地址。如果线程当前正在执行的方法是本机的,则Java虚拟机的pc寄存器的值是未定义的。Java虚拟机的pc寄存器足够宽,可以保存特定平台上的returnAddress或本机指针。

7.1 Native Method Stacks(本地方法栈)

如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。

那如果在Java方法执行的时候调用native的方法呢?

7.1.1 栈指向堆

如果在栈帧中有一个变量,类型为引用类型,比如Object obj=new Object(),这时候就是典型的栈中元素指向堆中的对象。

7.1.2方法区指向堆

方法区中会存放静态变量,常量等数据。如果是下面这种情况,就是典型的方法区中元素指向堆中的对象。

private static Object obj=new Object();

7.1.3 堆指向方法区

注意,方法区中会包含类的信息,堆中会有对象,那怎么知道对象是哪个类创建的呢?

相关推荐
NEFU AB-IN4 小时前
Prompt Gen Desktop 管理和迭代你的 Prompt!
java·jvm·prompt
唐古乌梁海9 小时前
【Java】JVM 内存区域划分
java·开发语言·jvm
众俗10 小时前
JVM整理
jvm
echoyu.10 小时前
java源代码、字节码、jvm、jit、aot的关系
java·开发语言·jvm·八股
代码栈上的思考1 天前
JVM中内存管理的策略
java·jvm
thginWalker1 天前
深入浅出 Java 虚拟机之进阶部分
jvm
沐浴露z1 天前
【JVM】详解 线程与协程
java·jvm
thginWalker1 天前
深入浅出 Java 虚拟机之实战部分
jvm
程序员卷卷狗3 天前
JVM 调优实战:从线上问题复盘到精细化内存治理
java·开发语言·jvm
Sincerelyplz3 天前
【JDK新特性】分代ZGC到底做了哪些优化?
java·jvm·后端