前言


从本篇文章开始进入JVM的学习,前面我们介绍了JAVA、JVM等等

这篇文章我们从类加载子系统开始进入学习

一、内存结构概述


请先看以下的简图,class Files我们称为字节码,从字节码开始后续操作都需要JVM负责

image.png

第一步:我们要将Class文件加载到内存当中,而类加载需要用到类加载子系统Class Loader来进行加载

同时对应到我们的内存当中,生成一个大的Class对象并且将必要的静态属性进行初始化等等(方法区提现)

第二步:当我们真正去执行字节码指令的时候,就需要执行引擎去发挥作用,按照我们程序的字节码指令去依次执行(涉及到虚拟机栈里去局部变量表取数据,以及操作入栈),若需要创建对象的话还需要用到堆空间

第三步:当程序继续往下走的时候,还会用到程序计数器,若用到本地的C类库,还需要用到本地方法栈

上面是简图,我们可以看以下的详细图

image.png

根据详细图,我们可以看到类加载子系统分三个部分

  • 加载阶段
  • 链接阶段
  • 初始化阶段

如图所知加载阶段分三个环节:引导类、扩展类、系统类等加载器

如图所知链接阶段分三个环节:验证、准备、解析

image.png

紧接着就是静态变量的一个显示初始化,接下来就将每个字节码文件要用到的,在对应的在内存中把类或者接口加载进来

image.png

在内存层面运行时数据区有:PC寄存器(程序计数器)、栈(虚拟机栈)、本地方法栈、堆区、方法区

PC寄存器:每一个线程一份

虚拟机栈:每一个线程一份,每一个线程用的栈里面一个一个结构称为栈桢,栈桢又分为局部变量表、操作数栈、动态链接、方法返回地址等

本地方法栈:涉及到本地方法接口API调用叫本地方法栈

堆区:主要应对Java对象等都放在堆空间中,也是GC重点考虑的一个空间因为堆区会被线程共享的

方法区:主要存放类的信息(常量、方法信息等等)都放在方法区

注意:方法区只有HotSpot虚拟机有,J9,JRockit都没有

image.png

执行引擎又分解释器、即时编译器、垃圾回收器,将我们的指令变成机器指令供CPU去执行,要想和操作系统打交道需要关注执行引擎打交道

若想真正了解一个虚拟机,可以手写一个虚拟机

如果自己想手写一个Java虚拟机的话,主要考虑类加载器、执行引擎结构

二、类加载器与类的加载过程


我们刚刚提到类加载子系统呢分三个阶段:加载、链接、初始化等阶段

image.png

JAVA虚拟机提到说明:任何语言可以考虑用直接的编辑器生成符合Java虚拟机规范的Class文件来在Java虚拟机进行解释运行

类加载器ClassLoader角色

image.png

1.class file(在上图中就是Car.class文件)存在于本地硬盘上,通过类加载器把它加载到内存运行时数据区

2.class file加载到JVM中后会被称为DNA元数据模板放在方法区

3.car.class文件可以调用getClassLoader()方法获取加载此类的加载器,同时可以根据car.class的构造器创在堆空间中创建多个对象

4.对应的对象可以通过getClass()获取到类的本身,知道由那个类创建的对象

而本地磁盘的Class文件是由二进制流的方式加载到内存中,类加载器起到快递员的身份

接下来我们使用一段代码来体会一下加载过程

public class HelloLoader {
    public static void main(String[] args) {
        System.out.println("谢谢ClassLoader加载我....");
        System.out.println("你的大恩大德,我下辈子再报!");
    }
}
//运行结果如下:
谢谢ClassLoader加载我....
你的大恩大德,我下辈子再报!

那么我们的这个HelloLoader类,它的加载过程是怎么样的呢?

image.png

  • 执行 main() 方法(静态方法)就需要先加载main方法所在类 HelloLoader
  • 若HelloLoader没有加载,需要使用相应的ClassLoader进行加载
  • 加载失败则抛出异常
  • 加载成功则进行链接、初始化等操作
  • 加载完成后调用 HelloLoader 类中的静态方法 main

接下来我们对类加载器进行加载、链接、初始化不同阶段进行展开,看看做了哪些事情

三、类加载器中的加载阶段


我们一起看看加载阶段的一些加载说明:

  • 通过一个类的全限定名获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

那么对于一些加载.class文件的方式我们可以进行一些举例说明

  • 从本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从zip压缩包中读取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见
  • 从加密文件中获取,典型的防Class文件被反编译的保护措施

四、类加载器中的链接阶段


前面我们提到过链接阶段分三个环节:验证、准备、解析

对于前面的加载阶段完成后,我们就已经生成了一个比较大的Class对象,第一个验证环节主要做以下几件事情

  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
  • 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证

我们可以将上面举例的HelloLoader类查看它的字节码

使用 BinaryViewer软件查看字节码文件,能被java虚拟机识别的其开头均为 CAFE BABE

image.png

class文件在文件开头有特定的文件标识说的就是这

如果发现你不是一个合法的字节码文件,那么将会验证不通过

刚刚介绍的是验证环节,接下来是链接阶段的准备环节介绍主要以下事情

  • 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化

注意:这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

我们可以举个例子一起来看看static静态变量在准备阶段的初始值

public class HelloApp {

    //prepare:a = 0 ---> initial : a = 1
    private static int a = 1;

    public static void main(String[] args) {
        System.out.println(a);
    }
}

刚刚介绍的是准备环节,接下来是链接阶段的解析环节介绍主要以下事情

  • 将常量池内的符号引用转换为直接引用的过程
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

我们可以反编译 class 文件后可以查看符号引用,下面带# 的就是符号引用

image.png

四、类加载器中的初始化阶段


当执行完加载阶段、链接阶段到达初始化阶段时,就会执行类构造器方法<clinit>()的过程。

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

我们可以举例一个示例代码一起来看看具体的<clinit>()

public class ClassInitTest {
    
    private static int num = 1;
        
    public static void main(String[] args) {
        System.out.println(a);
    }  
}

当然我们运行后会输出:1,这时我们使用 BinaryViewer软件查看字节码文件的<clinit>()方法里有什么

image.png

我们刚刚<clinit>()是对所有类变量的赋值动作和静态代码块中的语句合并而来

若我们没有类变量它会出现吗?

public class ClinitTest {
    
    private int a = 1;
    
    public static void main(String[] args) {
        System.out.println(a);
    }
}

这时我们再使用使用BinaryViewer软件查看字节码文件的<clinit>()方法里有什么

image.png

你就会发现没有,这就说明没有类变量的赋值动作和静态代码块中的语句它就不会有

我们之前说任何一个类声明后,至少存在一个类的构造器,使用看看并且观察一下

public class ClinitTest {
    
    private int a = 1;
    
    public ClinitTest(){
        a =10;
        int d =20;
    }
    
    public static void main(String[] args) {
        System.out.println(a);
    }
}

那么我们使用BinaryViewer软件查看字节码文件init方法里有什么

image.png

构造器方法中指令按语句在源文件中出现的顺序执行。

而<clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())

并且当若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。

我们举例一个示例代码进行说明这种情况并通过字节码观察看看是否这样

public class ClinitTestl {
    
    static class Father {
        
        public static int A = 1 ;
        
        statict{
            A=2;
        }
    }
    
    static class Son extends Father {
        
        public static int B= A;
    }
    
    public static void main (String[] args){
        //加载Father类,其次加载Son类。
        System.out.println(Son.B);//2
    }
}

当我们执行执行 main() 方法需要加载 ClinitTest1 类,再调用另一个类Son的静态变量所以此时需要加载 Son 类(此时执行<Clinet>()方法)但是在此之前需要执行父类的加载,一起来看看字节码是怎么样的

image.png

以及虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

也就是说保证我们的类只加载一次

我们可以使用示例代码来体会一下这个说法

class DeadThread{
    static{
        if(true){
            System.out.println(Thread.currentThread().getName() + "初始化当前类");
            while(true){
            }
        }
    }
}
public class DeadThreadTest {
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() + "开始");
            DeadThread dead = new DeadThread();
            System.out.println(Thread.currentThread().getName() + "结束");
        };

        Thread t1 = new Thread(r,"线程1");
        Thread t2 = new Thread(r,"线程2");

        t1.start();
        t2.start();
    }
}
//运行结果如下:
线程2开始
线程1开始
线程2初始化当前类

//程序卡死了...

当我们的两个线程同时去加载 DeadThread 类,先加载 DeadThread 类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,而另一个线程在等待同步锁的释放

所以无论哪个线程先执行 DeadThread 类的加载,另外一个类也不会继续执行。(一个类只会被加载一次)

使用死循环是模拟虚拟机在加载的时候只执行一次,而其他线程进入阻塞状态

五、几种类加载器的介绍与使用体会


一般JVM支持支持两种类型的类加载器分别为

  • 引导类加载器(Bootstrap ClassLoader)
  • 自定义类加载器(User-Defined ClassLoader)

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器

但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

image.png

我们可以看下面这个图,可以清晰的知道这个继承树的意义

image.png

所以将扩展类加载器、系统类加载器也认为是自定义类加载器

启动类加载器(引导类加载器,Bootstrap ClassLoader)

  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  • 并不继承自java.lang.ClassLoader,没有父加载器
  • 加载扩展类和应用程序类加载器,并作为他们的父类加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

扩展类加载器(Extension ClassLoader)

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

image.png

应用程序类加载器(也称为系统类加载器,AppClassLoader)

  • Java语言编写,由sun.misc.LaunchersAppClassLoader实现
  • 派生于ClassLoader类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器

image.png

我们使用代码来体会一些这几种提到加载器

public class ClassLoaderTest {
    public static void main(String[] args) {

        //获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        //获取系统类加载器其上层:扩展类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

        //获取扩展类加载器其上层:获取不到引导类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);//null

    }
}

那么对于这些加载器分别能加载哪些路径下的文件呢?

public class ClassLoaderTest1 {
    public static void main(String[] args) {
        System.out.println("**********启动类加载器**************");
        //获取BootstrapClassLoader能够加载的api的路径
        URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL element : urLs) {
            System.out.println(element.toExternalForm());
        }
    }
}
//运行结果如下:
**********启动类加载器**************
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/resources.jar
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/rt.jar
file: /D:/developer_tools/Java/jdk1.8.0_131/jre/lib/sunrsasign.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jsse.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jce.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/charsets.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jfr.jar
file: /D:/developer.tools/Java/jdk1.8.0_131/jre/classes

我们可以打开路径下的jsee.jar包里的Class文件反查看加载器是什么

image.png

public class ClassLoaderTest1 {
    public static void main(String[] args) {
        
        //file: /D:/developer.tools/Java/jdk1.8.0_131/jre/lib/jsse.jar
        //从路径中随意选择一个类,来看看他的类加载器是什么:引导类加载器
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);//运行结果:null
    }
}

接下来我们接着看看扩展类的加载器有哪一些

public class ClassLoaderTest1 {
    public static void main(String[] args) {
        
        System.out.println("***********扩展类加载器*************");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) {
            System.out.println(path);
        }
    }
}
//运行结果如下:
***********扩展类加载器**** *** ******
D: \developer_tools\Java\jdk1.8.0_131\jre\lib\ext
C: \Windows\Sun\Java\lib\ext

同理我们打开文件路径通过Class文件反查一下加载器是什么

public class ClassLoaderTest1 {
    public static void main(String[] args) {
        
        //file: D:\developer_tools\Java\jdk1.8.0_131\jre\lib\ext
        ClassLoader classLoader1 = CurveDB.class.getClassLoader();
        System.out.println(classLoader1);
    }
}
//运行结果如下:
sun.misc.Launcher$ExtClassLoader@1540e19d

六、用户自定义加载器


在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时我们还可以自定义类加载器,来定制类的加载方式。

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

  • 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)
  • 修改类加载的方式
  • 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
  • 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)

如何自定义类加载器?

  • 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
  • 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
  • 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
public class CustomClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        try {
            //将路径下的文件以流的形式存入到内存中
            byte[] result = getClassFromCustomPath(name);
            if (result == null) {
                throw new FileNotFoundException();
            } else {
                //defineClass和findClass搭配使用
                return defineClass(name, result, 0, result.length);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        throw new ClassNotFoundException(name);
    }
    //自定义流的获取方式
    private byte[] getClassFromCustomPath(String name) {
        //从自定义路径中加载指定类:细节略
        //如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
        return null;
    }
}

七、关于ClassLoader


ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

以下这些方法都不是抽象方法,可以具体的实现

image.png

关于ClassLoader的途径

image.png

我们可以根据代码示例体会看看一下

public class ClassLoaderTest2 {
    public static void main(String[] args) {
        try {
            //1.
            ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
            System.out.println(classLoader);
            //2.
            ClassLoader classLoader1 = Thread.currentThread().getContextClassLoader();
            System.out.println(classLoader1);

            //3.
            ClassLoader classLoader2 = ClassLoader.getSystemClassLoader().getParent();
            System.out.println(classLoader2);

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
//运行结果如下:
null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d

八、双亲委派机制


Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。

而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

我们使用一个案例引入这个双亲委派机制,我们在自己的src路径下创建自己的java.lang.String类

public class String {
    //
    static{
        System.out.println("我是自定义的String类的静态代码块");
    }
}

这时我们在创建一个新的Test类来引用它,并且看看他的加载器是什么

public class StringTest {

    public static void main(String[] args) {
        java.lang.String str = new java.lang.String();
        System.out.println("hello,atguigu.com");

        StringTest test = new StringTest();
        System.out.println(test.getClass().getClassLoader());
    }
}
//运行结果如下:
hello,atguigu.com
sun.misc.Launcher$AppClassLoader@18b4aac2

我们发现程序并没有输出我们静态代码块中的内容,可见仍然加载的是 JDK 自带的 String 类。

这时我们将代码进行修改一下,再来运行起来看看是怎么样的输出结果

package java.lang;
public class String {
    //
    static{
        System.out.println("我是自定义的String类的静态代码块");
    }
    //错误: 在类 java.lang.String 中找不到 main 方法
    public static void main(String[] args) {
        System.out.println("hello,String");
    }
}
//运行结果如下:
错误:在类java.lang.String中找不到main方法,请将main方法定义为:
public static void main (String[] args)
否则JavaFX 应用程序类必须扩展javafx.application.Application

由于双亲委派机制一直找父类,所以最后找到了Bootstrap ClassLoader,Bootstrap ClassLoader找到的是 JDK 自带的 String 类,在那个String类中并没有 main() 方法,所以就报了上面的错误

双亲委派机制原理

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派机制优势

接下来我们在创建一个示例来java.lang包下看看是否能运行起来

package java.lang;

public class ShkStart {

    public static void main(String[] args) {
        System.out.println("hello!");
    }
}
//运行结果如下:
java.lang.SecurityException: Prohibited package name: java.lang
    at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
    at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
    at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
    at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" 

即使类名没有重复,也禁止使用java.lang这种包名。这是一种保护机制

在比如我们使用加载jdbc.jar 用于实现数据库连接的时候需要用到SPI接口,而SPI接口属于rt.jar包中Java核心api

这个时候我们就要使用双清委派机制,引导类加载器把rt.jar包加载进来针对具体的第三方实现jar包时使用系统类加载器来加载

image.png

从这里面就可以看到SPI核心接口由引导类加载器来加载,SPI具体实现类由系统类加载器来加载

通过上面的例子,我们可以知道,双亲机制可以

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

    • 自定义类:自定义java.lang.String 没有被加载。
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)

沙箱安全机制

当我们运行自定义String类main方法的时候出现了报错,这种其实就是沙箱安全机制,不允许你在程序中破坏核心的源代码程序

九、其他


如何判断两个class对象是否相同?

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的

参考资料


尚硅谷:JVM虚拟机(宋红康老师)


28640
116 声望25 粉丝

心有多大,舞台就有多大