字节码与类的加载笔记三(重点)
2024-1-8 00:14:9 Author: 白帽子(查看原文) 阅读量:10 收藏

类的加载概述

在数据类型中可以分为基本数据类型和引用数据类型,基本数据类型是由虚拟机预先定义好的,引用数据类型需要进行类的加载。

类的加载结构图

类加载阶段

简单点就是将Class字节码文件加载到内存中,并在内存中构造出Java类的原形。并构造出一个类模版对象。

类模板对象其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出常量池,类字段,类方法等信息。

这样我们就可以通过类模板对象获取到Java类中的任意信息。

在加载类的时候,通过类的全名获取到二进制数据流,然后解析类的二进制数据流为方法区内的数据结构。

接着创建java.lang.class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口。

二进制流数据获取

1.从系统中读取一个class文件,加载到内存中。

2.读取jar,zip等数据包,提取类文件。

3.事先存储在数据库中的类二进制数据。

4.使用远程HTTP之类的协议通过网络进行传输加载。

5.在运行时生成一段class的二进制信息等。

在获取到类的二进制数据之后,通过java虚拟机的类加载机制,最终生成一个java.lang.class的实例。这个实例对应的就是加载的那个字节码文件。

如果数据不规范,则会抛出ClassFormatError。

类模版以及Class实例的位置

类模板的位置:

加载的类在JVM中创建相应的结构,类结构会存储在方法区。

Class实例的位置:

类将.class文件加载到元空间之后,会在堆中创建一个class对象,用来封装类位于方法区内的数据结构,该class对象是在加载类的过程中创建的,每个类都对应一个Class类型的对象。

如下图:

首先order.class通过类加载器进行加载,加载之后会存储在元空间中,加载到元空间之后,会在堆中创建一个大的Class实例。用来封装元空间内的数据结构。

数组类的加载

数组类本身并不是使用类加载器负责创建的,而是JVM在运行时根据需要直接创建的,但数组的元素仍然需要依靠类加载器进行加载和创建。

如果数组的元素是引用类型,那么就遵循定义的加载过程进行加载和创建数组元素的类型。

JVM使用指定的元素类型和数组维度来创建新的数组类。

如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定,否则数组类的可访问性将默认为public。

类链接阶段

验证阶段

这一阶段说白了就是保证加载的字节码是合法,是遵守JVM虚拟机的规范的。

Java虚拟机会做如下验证:

格式验证会和加载阶段同时执行,格式验证通过之后才会将类的二进制数据信息加载到方法区中。

那么剩下这些语义检查,字节码验证,符号引用验证会在方法区进行验证。

格式检查

格式检查分别会检查魔数检查和版本检查以及长度检查。

魔数检查的话就是看这个字节码是否有CA FE BA BE这个标识。

版本检查的话就是主版本+副版本 = 1.8 或者 jdk1.9。

如果你在高版本下运行时不会出错的,如果你在低版本下运行会报错。

长度检查就是数据中每一项,是否都拥有正确的长度等。

比如说方法引用就是method_ref,他的计数器原本占用2个字节,但是字节码中占用了3个字节,这样的话就是不行的。

语义检查

语义检查就是看我们的语法是不是合法,比如是否所有的类都有父类,我们都知道如果没有显式的继承父类,那么他的父类默认就是Object,所以除了Object都应有父类。

或者说抽象类是否实现了所有的抽象方法等等。

字节码验证

字节码验证就是验证我们的字节码是否合规,判断字节码是否可以正常执行。

在字节码的执行过程中,是否会跳转到一条不存在的指令。

方法调用是否传递了正确的参数等等。

符号引用验证

Class文件在其常量池会通过字符串记录自己将要使用的其他类或方法。

比如说:ldc #12 从常量池中取出索引为12的字符串 然后压入到操作数栈中。如果这个索引不存在那么字节码就是有问题的。

准备阶段

在准备阶段中,虚拟机会为这个类的静态变量分配相应的内存空间,并设置初始值。注意这里是静态变量不是实例变量。

就比如说我们有一个属性为:

private static int i = 10;

那么在准备阶段的时候会给他设置默认初始值为0。

默认初始值表:

注意:

这里不包括使用static final修饰的情况,因为final在编译的时候就已经分配了,准备阶段会显式的赋值。

这里不会为实例变量进行分配初始化,类变量会分配在内存中,而实例变量会随着对象一起分配到Java堆中。

在这个阶段并不会像初始化阶段那样会有初始化或者代码执行。这里一定要记住在准备阶段不会有初始化和代码执行的操作。

基本数据类型:

非final修饰的变量,在解析环节会进行默认初始化赋值。

final修饰之后的变量,在解析环节直接显式的赋值。

解析阶段

在解析阶段中会将类,接口,字段和方法的符号引用转换成直接引用。

这里的符号引用转换成直接引用的意思就是:

比如你要做一件事情,你把这件事情分析了一遍,做了一个计划,先干嘛 然后干嘛,这种就是符号引用。

到真实去做的时候。这里就是直接引用。

在比如在Class文件中,通过常量池进行了大量的符号引用,但是在程序实际工作的时候,只有符号引用是不够的,比如println()方法被调用的时候,系统需要知道该方法的位置在哪。

以方法为例,Java虚拟机会为每个类都准备一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法的表中的偏移量就可以直接调用方法,通过解析操作,符号引用就可以转换为目标方法在类中方法表的位置,从而使得方法被调用。

初始化阶段

在初始化阶段中 会为类的静态变量进行显式赋值。

类的初始化是类装载的最后一个阶段,如果前面的步骤都没有任何问题,那么表示类可以顺利的装载到系统中,此时类才会开始执行字节码。

在初始化阶段中会执行类的初始化方法 <clinit>()方法。

这个方法不需要定义,是javac编译器自动收集类中的所有静态类变量的赋值动作和静态代码块中的语句合并而来。

例如如下代码:他会收集类中的所有静态变量的赋值动作和静态代码块中的语句,并合并。

public static void main(String[] args) {        //1.加载B类 并生成B对应的Class对象        //2.连接 num = 0        //3.初始化阶段            //4.依次自动收集类中的所有静态变量的赋值动作和静态代码块中的语句,并合并        /**         * clinit(){         *    System.out  .println("B 静态代码块被执行");         *    //num = 300;         *    num = 100;         * }         * 合并: num = 100         *         */        System.out.println(B.num); //100 这里调用静态变量会导致类加载 总的来说类加载会调用静态代码块中的代码    }

可以看到这个构造器我们是没有定义的,反编译Class文件可以看到确实是存在的。

例1:

package com.jvm.test;
public class InitalizationTest { public static int id = 1; public static int number;
static { number = 2; System.out.println("father static{}"); }}

如下图:

这里的<init>方法表示就是类的默认空参构造器。

<clinit>这个方法里面执行的就是上面的静态变量以及静态代码块中的代码。

比如iconst_1对应的就是id这个静态变量,将1压入到操作数栈中。

在加载一个类之前,虚拟机会首先加载该类的父类,因此父类的<clinit>总是在子类的<clinit>之前被调用。

也就是说 父类的static静态代码块优先级高于子类。

Java编译器不会为所有的类都产生<clinit>初始化方法,那些类在编译为字节码后,字节码中不会包含<clinit>方法?

1.一个类中如果没有声明任何的类变量,也没有静态代码块时。

2.一个类中声明变量,但是没有明确的使用类变量的初始化语句以及静态代码块来执行初始化操作时。

例:

结论:

在链接阶段的准备环节赋值的情况:

1.对于基本类型的字段来说如果使用static final来修饰,则显式赋值(直接赋值常量的方式,而非调用方法的方式)则通常是在链接阶段的准备环节来赋值的。

2.对于String类型来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值则通常是在链接阶段的准备环节来赋值的。

在初始化赋值的情况:

排除上述的在准备环境赋值的情况之外的情况。

最终结论:

使用static final修饰,且显式赋值中,不涉及到方法或者构造器调用的基本数据类型或String类型的显式赋值,是在链接的准备环节进行的。

类的主动使用和被动使用

类的主动使用会调用<clinit>()方法。这里说白了就是类在主动使用的时候才会初始化。

类的被动使用不会调用<clinit>()方法。类在被动使用的时候是不会初始化的。

主动使用

1.当创建一个类的实例的时候,比如new关键字 或者通过反射,克隆,反序列化。

2.当调用类的静态方法时,相当于使用了字节码的invokestatic指令,也会导致类的初始化。

3.当使用类,接口的静态字段时,比如使用getstatic 或 putstatic 会导致类的初始化。

4.当使用java.lang.reflect包中的方法反射类的方法时,比如Class.forName("") 类会被初始化。

5.当初始化子类的时,如果发现父类还没进行初始化,则需要触发其父类的初始化。

添加JVM参数 -XX:TranceClassLoading 可以看到哪些类被加载了,但是加载并不代表会初始化。

6.如果一个接口定义了defalut方法,那么直接实现或者间接实现接口的类的初始化,该接口要在其之前被初始化。

7.当虚拟机启动时,用于需要指定一个要执行的主类(包含main方法的那个类),虚拟机会初始化这个主类。

8.当初次调用MethodHanlder实例时,初始化该MethodHandler只想方法所在的类。

类的被动使用

除了以上的情况,其他情况都属于被动使用。被动使用不会引起类的初始化也就是不会调用<clinit>()方法。

也就是说:并不是代码中出现的类 就一定会被加载或初始化。如果不符合主动使用的条件,类就不会初始化。

1.当访问一个静态字段时,只有真正声明这个字段的类才会初始化。当去访问父类的静态方法时,父类会被初始化而子类并不会被初始化。虽然子类没有被初始化,但是并不代表子类没有被加载。

2.通过数组定义类引用,不会触发类的初始化。但是给数组索引赋值对象引用的时候会进行触发。

3.引用常量不会触发类或接口的初始化。因为常量在链接阶段已经被显式的赋值了。

4.调用ClassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

但是如果使用调用newinstance方法的话,那么就会初始化,因为newinstance方法相当于new了对象。

类的使用

任何一个类型在使用之前都必须经过完整的加载,链接和初始化3个阶段。一但一个类型成功经历过这三个步骤之后,便 "万事具备" 只欠东风。就等着开发者使用了。

开发人员可以在程序中访问和调用它的静态成员信息(比如:静态字段,静态方法) 或者使用new关键字为其创建对象实例。

类的卸载

一个类何时结束声明周期,取决于它的Class对象何时结束声明周期。

类的加载器

类的加载器只和类加载阶段有关系,和类链接以及初始化是没有任何关系的。

ClassLoader的作用

ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,Classloader通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个目标对应的java.lang.Class对象实例,然后交给Java虚拟机进行链接以及初始化操作。

因此ClassLoader在整个加载阶段 只能影响类的加载不能影响类的链接以及初始化行为。至于它能不能运行,则由执行引擎来决定。

类的加载分类
显式加载

显式加载指的是在代码中调用ClassLoader加载Class对象,如直接使用Class.forName(name)或this.getClass().getClassloader.loadClass()加载class对象。

隐式加载

隐式加载则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件,该类的class文件中引用了另一个类的对象。此时额外引用的类将通过JVM自动加载到内存中。

如下例:

package com.jvm.test;
public class UserTotalTest { public static void main(String[] args) { UserTotal userTotal = new UserTotal(); //隐式加载
try { Class<?> cls = Class.forName("com.jvm.test.UserTotal"); //显式加载 ClassLoader.getSystemClassLoader().loadClass("com.jvm.test.UserTotal"); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } }}
命名空间

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性。

每一个类加载器,都拥有一个独立的命名空间:比较两个类是否相等,只有在这两个类由一个类加载器加载的前提下才有意义。

否则即使两个类源自一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,这么着两个类就不是相等的。

简单的来说就是不同的类加载器去加载一个相同的class字节码文件,他们的加载的这个类是不相等的。

类加载器分类

启动类加载器

这个类加载器使用C/C++语言实现的,嵌套在JVM内部。
它用来加载Java核心类库。
并不继承java.lang.ClassLoader 没有父类加载器。
加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
出于安全考虑 BootStrap启动类加载器只加载包名为java,javax,sun等开头的类。

扩展类加载器

java语言编写,由sun.misc.Lanucher$ExClassLoader实现。
派生于ClassLoader类
父类加载器为启动类加载器。
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。
如果用户创建的JAR包放在此目录,也会由扩展类加载器进行加载。
系统类加载器

它负责加载环境变量classpath或系统属性 java.class.path 指路径下的文件。

该类加载是程序中默认的类加载器。一般来说 Java应用都是由他来完成加载。

测试不同类的加载器

ClassLoader与Launcher解析

ClassLoader结构图:

Classloader源码剖析
public final ClassLoader getParent() //返回该类加载器的父类加载器public Class<?> loadClass(String name) throws ClassNotFoundException //加载名称为name的类 返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException 该方法中的逻辑就是双亲委派模型的实现。

loadclass方法剖析:这里他调用了一个重载的方法,第一个参数表示你要加载的字节码文件,第二个参数表示是否进行解析。

这里传入false表示的就是不解析。

跟进loadClass重载的方法。这里resolve表示不需要解析。

首先调用synchronized表示同步操作,也就是说一个类只允许加载一次。紧接着调用findLoadedClass方法在缓存中进行判断是否已经加载过同名的类。可以看到我这里已经加载过了,为什么?因为我在loadClass之前写了一个Class.forName("com.jvm.test.Dog"); 这段代码。表示类已经加载过了。我们去掉这行代码之后然后进入if判断。

此时就变成null了,紧接着判断parent是否等于null,这里的parent就是父类的加载器,如果获取到父类加载器 那么就使用父类加载器进行加载,这里就体现出来了双亲委派机制。此时如果我们的加载器是应用程序类加载器那么他的父类就是扩展类加载器,然后调用扩展类加载器的loadclass方法,此时扩展类加载的父类就是null了,因为引导类加载器也就是bootstarpClassLoader,他是使用c进行编写的,所以获取他的类加载器是一个null,所以就到了else,然后调用findBootstrapClassOrNull,判断我们的类是否在缓存中,如果不存在的话返回null。这里既然返回null了。

上一步返回null之后,这里判断当前类的加载器的父类加载器没有加载此类也就是null。进入if之后,调用findClass方法,跟进去。

来到findClass方法,可以看到调用到了URLClassLoader的方法,首先通过路径的拼接和资源 然后调用defineClass方法返回一个Class实例。

SecurityClassLoader和URLClassLoader

SecurityClassloader扩展了ClassLoader,新增了几个与使用相关的代码源,和权限定义类验证的方法,一般我们不会直接跟这个类打交道,更多是与他的子类URLClassloader有所关联。

双亲委派
工作原理

类编译编译成.class字节码文件之后,通过类加载。

但是在类加载的时候,它(AppClassloader)不会自己先去加载,而是将这个请求委托给父类的加载器去执行加载。

如果父类加载器还存在父类加载器,那么继续向上委托,依次递归,请求最终到达顶层的启动类加载器。

如果父类加载器可以完成类的加载,那么就成功返回,如果父类加载器加载不了,子类才会去尝试加载。

这个说明其实就是上面所说的loadClass方法。

双亲委派的优势和劣势

优势:

避免类的重复加载,确保一个类的全局唯一性

保护程序安全,防止核心API被篡改。这一点其实可以理解为比如说我们创建了一个java.lang这个包 正好也创建了一个String类。

那么类加载加载的是系统的String还是我们自定义的String呢?那肯定是系统类的String。所以保护了核心API被篡改的风险。

双亲委派机制是在java.lang.ClassLoader.loadClass(String boolean)接口中体现。该接口的逻辑如下:

1.先在当前类加载器的缓存中判断有没有目标类,如果有直接返回。

2.判断当前类加载器的父类加载器是否为空,如果不为空那么调用父类加载器的loadClass方法进行加载。

3.如果当前加载器的父类加载器为空,则调用findBootStrapClassOrNull方法 让引导类加载器进行加载。

4.如果前面都没有加载成功的话,那么调用当前类加载器的findClass方法进行加载。最后调用到defineClass方法加载到目标类

双亲委派就在第二步和第三步。

劣势:

类加载的委托过程是单向的,顶层的ClassLoader无法访问底层的Classloader所加载的类。

双亲委派的破坏

第一次破坏双亲委派机制

越基础的类由越上层的类加载器进行加载,基础类型之所以称之为继承,是因为他们总是被作为用户代码继承,调用的API存在,但程序设置往往没有不变的原则,如果由基础类型又要调用回用户的代码呢?

比如说JNDI这个服务,他的代码由启动类加载器进行加载,但是JNDI存在的目的就是堆资源进行查找和集中管理,他需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口,就相当于上层调用下层,但是启动类加载器不可能加载这些下层的代码,因为他根本就不认识这些代码,所以引入了一个线程类加载器也就是Thread Context ClassLoader,这个类加载器通过Java.lang.Thread类的setContextClassloader()方法进行设置的,如果创建线程时还未设置,他会从父类继承一个。如果全局都没有设置过得话,那么这个类加载器就是应用程序类加载器。

有了线程上下文加载器之后,我们就可以通过线程上下文加载器再去调用应用程序类加载器。有了它之后我们就可以去加载底层的这些代码了。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上打通了双亲委派模型的层次结构来逆向使用类加载器。

2.第二次破坏双亲委派机制

原因是由于用户对程序动态型的追求而导致的。如:热部署,模块热部署等。

热替换:

热替换就是在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现在正在运行的系统中。

基本大部分语言都是天生支持热替换的,比如 PHP语言,比如说我们上传一个PHP的shell文件,那么我们可以直接访问他会立即生效。而无需重启web服务器。

但是对于Java来说,他不是天生就支持的,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来重新加载定义这个类。

在Java中实现这个功能的一个可行的办法就是灵活运用ClassLoader。

沙箱安全机制

1.保护程序安全

2.保护Java原生的JDK代码

Java安全模型的核心就是Java沙箱。

沙箱机制就是将Java代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

自定义类加载器

为什么要自定义类加载器?

1.隔离加载类

2.修改类加载的方式

3.扩展加载源

4.防止源码泄露

实现方式:

Java中提供了抽象类ClassLoader,所有用户自定义的类加载器都应该继承Classloader类。

在自定义Classloader的子类时候,我们常见的会有两种做法:

方式一: 重写loadClass方法

方式二:重新findClass方法

自定义类加载器:

package com.jvm.test;
import java.io.*;import java.nio.ByteBuffer;import java.nio.channels.Channels;import java.nio.channels.FileChannel;import java.nio.channels.WritableByteChannel;
/** * @author shkstart * @create 11:03 * <p> * 自定义类的加载器 */public class MyClassLoader extends ClassLoader { private String byteCodePath;

public MyClassLoader(String byteCodePath) { this.byteCodePath = byteCodePath; }
public MyClassLoader(ClassLoader parent, String byteCodePath) { super(parent); this.byteCodePath = byteCodePath; }
@Override protected Class<?> findClass(String className) throws ClassNotFoundException { BufferedInputStream buf = null; ByteArrayOutputStream baos = null; try{ //获取字节码文件的完整路径 String FileName = byteCodePath + className + ".class"; //获取一个输入流 buf = new BufferedInputStream(new FileInputStream(FileName)); //获取一个输出流 baos = new ByteArrayOutputStream(); //具体读入写出的一个过程 int len; byte[] data = new byte[1024]; while ((len = buf.read(data)) != -1){ baos.write(data,0,len); } //获取内存中的完整的字节数组数据 byte[] byteCodes = baos.toByteArray(); //调用defineClass将字节数组的数据转换为Class实例 Class<?> clazz = defineClass(null, byteCodes, 0, byteCodes.length); return clazz; }catch (IOException e){ e.printStackTrace(); }finally {
try { if (baos != null) baos.close(); } catch (IOException e) { throw new RuntimeException(e); } try { if (buf != null) buf.close(); } catch (IOException e) { throw new RuntimeException(e); } }
return null; }}

测试代码:

package com.jvm.test;
import java.lang.reflect.Constructor;
public class MyClassLoaderTest { public static void main(String[] args) throws Exception { MyClassLoader loader = new MyClassLoader("/Users/relay/Downloads/JVM-demo/Jvm-demo001/target/classes/com/jvm/test/"); Class<?> dogClass = loader.loadClass("Dog"); System.out.println("加载此类的加载器为:" + dogClass.getClassLoader()); System.out.println("加载此类的加载器的父类为:" + dogClass.getClassLoader().getParent()); //注意类加载的时候 并不会导致初始化 Constructor<?> declaredConstructor = dogClass.getDeclaredConstructor();//初始化之后才会 declaredConstructor.setAccessible(true); declaredConstructor.newInstance(); }}

到这里字节码和类的加载就告一段落了。

参考:https://www.ngui.cc/el/1815210.html?action=onClick


文章来源: http://mp.weixin.qq.com/s?__biz=MzAwMDQwNTE5MA==&mid=2650247265&idx=1&sn=e762790c0af0bcec8107c0fd1a710e7f&chksm=83a417d0378e13b35f46db2d52dac01fa54477192c4ba258b22afb545a07875b569a775b1e89&scene=0&xtrack=1#rd
如有侵权请联系:admin#unsafe.sh