深入浅出类加载机制

前言

  忽然间思考到一个问题:为什么静态方法、变量不能调用非静态方法、变量?思考一番,忽然想起类加载的顺序,结合之前的基础知识点,就有了今天的这篇文章

1. 类加载机制

在这里插入图片描述
  先贴个图吧,显而易见的静态方法、变量不能调用非静态方法、变量,然后就是我们的类加载机制:
我们知道Java是跨平台的,即 Write Once,Run Anywhere,而我们通过编译Java生成的class文件是与平台无关的。所以我们需要不同的JVM(Liunx版本、Windows版本等)来加载我们编译生成的Class文件。而去虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的这个机制,就是虚拟机的类加载机制。贴个图:
在这里插入图片描述
按照顺序

1.1 加载

  类的加载指的是通过全限定名来获取其定义的二进制字节流将类的 .class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构
加载.class文件的方式

  1. 从本地系统中直接加载
  2. 通过网络下载 .class 文件(URLClassLoader)
  3. 从 zip、jar 等归档文件中加载 .class 文件
  4. 从专有数据库中提取 .class 文件
  5. 将 Java源文件动态编译为 .class 文件
1.2 验证

该阶段的主要作用就是确保被加载的类的正确性,具体可包含:

  1. 文件格式的验证: 验证.class文件字节流是否符合class文件的格式的规则,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面对象头包含的数据信息:Java对象头)。
  2. 元数据验证: 主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求。
  3. 字节码验证: 这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出危害虚拟机安全的事。
  4. 符号引用验证: 它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。
1.3 准备

  此时是为类的静态变量分配内存,并将其初始化为默认值, 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的初始值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

  假设一个类变量的定义为:public static int a= 3;
  那么变量a在准备阶段过后的初始值为0(因为int初始值为0,假如是Integer的话就是null),而不是3,因为这时候尚未开始执行任何Java方法,把a赋值为3的动作将在初始化阶段才会执行
  需要注意的是:对于同时被static和final修饰的常量,必须在声明的时候就为其赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时为其赋值,也可以在类初始化时为其赋值,总之,在使用前必须为其赋值,系统不会为其赋予默认零值。

1.4 解析

  JVM将常量池中的符号引用转化为直接引用的过程,这两者具体区别请看:传送门,简单而言:

  • 符号引用即用一组符号来描述所引用的目标,该符号可以是任意形式的字面量而只要使用时能无歧义地定位到目标即可。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

  当时第一反应是和栈内的句柄访问和指针直接访问记混了,后来想想不对,因为此时类加载尚未完成,JVM内存区域又是运行期内存区域,所以这里这么理解是矛盾的,这里大家顺带复习一下对象访问形式:

  1. 指针直接访问实例数据
    在这种方式中,JVM栈中的栈帧中的本地变量表中所存储的引用地址就是实例数据的地址。通过这个引用就能直接获取到实例数据的地址。
    除此之外,其实引用所指向的对内存中的对象数据有两部分组成,一部分就是这个对象实例本身,另一部分是对象类型在方法区中的地址。
  2. 使用句柄间接访问实例数据
    JVM会在堆中划分一块内存来作为句柄池,JVM栈中的栈帧中的本地变量表中所存储的引用地址是这个对象所对应的句柄地址,而非对象本身的地址。 句柄池中的一个个对象地址有两部分组成,一部分就是对象数据在堆内存中实例池中的地址,另一部分就是对象类型在方法区中的地址。
    在这里插入图片描述

  必须提的是: 虚拟机规范之中并未规定解析阶段发生的具体时间,只要求在诸如:checkcast、getfield、 getstatic、 instanceof、 invokedynamic、等16个指令被执行之前,先对它们所使用的符号引用进行解析。

1.5 初始化

  当一个类被主动使用时,即创建对象、使用静态方法或者字段,反射、初始化子类等等,Java虚拟就会对其初始化,初始化也是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。此时才为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。
而从另一个角度阐述这个阶段:这个价段就是执行类构造器< clinit >()方法的过程。 在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值
  2. 使用静态代码块为类变量指定初始值

当且仅当以下六种情况必须进行初始化:

  1. 创建类的实例,例如:Date date = new Date();
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射,如: Class.forName(“com.hxz.Test”)
  5. 初始化某个类的子类,则其父类也会被初始化
  6. Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

JVM初始化步骤

  1. 如果这个类没有被加载、连接,则程序先加载并连接该类
  2. 如果该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 如果类中有初始化语句,则系统依次执行这些初始化语句
1.5.2 为什么静态方法不能调用非静态方法

  看了面的类加载顺序可以得知:如果我们在静态方法中调用非静态成员变量会超前,因为可能会调用了一个还未初始化的变量(静态变量优先初始化),所以编译器会报错。

1.6 使用

使用

1.7 拆卸

拆卸

2. 类加载器

  Java类的加载是由虚拟机来完成的,虚拟机把描述类的Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成能被java虚拟机直接使用的Java类型,这就是虚拟机的类加载机制.JVM中用来完成上述功能的具体实现就是类加载器.类加载器读取.class字节码文件将其转换成java.lang.Class类的一个实例.每个实例用来表示一个Java类.通过该实例的newInstance()方法可以创建出一个该类的对象.

2.1 类加载器种类
2.1.1 Java虚拟机自带的类加载器
  1. 根类加载器 (Bootstrap classloader) —- 使用c++编写,我们无法在java代码里面获得该类 【从系统属性sun.boot.class.path所指定的目录加载类库,比如java.lang.*】
  2. 扩展类加载器(Extension classloader) —- java编写 【从java.ext.dirs系统属性所指定的目录中加载类库或者从jdk的安装目录的jre\lib\ext子目录下(扩展目录)下加载类库】
  3. 应用程序类加载器(Application ClassLoader) —- java编写 【从环境变量classpath或者系统属性java.class.path所指定的目录下加载类库,用户自定义的类加载器的父类加载器】。该类加载器也称为系统类加载器(System ClassLoader),开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
2.1.2 用户自定义的类加载器

java.lang.ClassLoader的子类,用户可以定制类的加载方式
在这里插入图片描述

2.2 双亲委派机制

JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

public class TestClassLoader {
    public static void main(String[] args) {
        ClassLoader loader = TestClassLoader.class.getClassLoader();
        System.out.println(loader.toString());
        System.out.println(loader.getParent().toString());
        System.out.println(loader.getParent().getParent());
    }
}

输出结果:

sun.misc.Launcher$AppClassLoader@500c05c2
sun.misc.Launcher$ExtClassLoader@454e2c9c
null
2.2.1 为什么要有双亲委派机制
  • 系统类防止内存中出现多份同样的字节码
  • 保证Java程序安全稳定运行
2.2.2 可不可以自定义一个String/Object类?

首先我们自己真正的去写一个试试:

package java.lang;

public class String {
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		System.out.println("string");
	}
}

报错如下所示:

Exception in thread "main" 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)

直接查看抽象类java.lang.ClassLoader的preDefineClass方法代码,示例如下:

private ProtectionDomain preDefineClass(String name, ProtectionDomain pd)
{
if (!checkName(name))
	//注意看这里
	thrownew NoClassDefFoundError("IllegalName: " + name);
if ((name != null) && name.startsWith("java.")) {
	//如果包名为java开头的就抛出SecurityException
	throw new SecurityException("Prohibited package name: " +name.substring(0, name.lastIndexOf('.')));
}
if (pd == null) {
	pd = defaultDomain;
}
if (name != null) checkCerts(name, pd.getCodeSource());
	returnpd;
}

我们知道假设自己去定义一个类加载器,这个类加载器肯定是继承自ClassLoader,而ClassLoader的loadClass()方法里调用了父类的defineClass()方法,并最终调到preDefineClass ()方法,因此我们自定义的类加载器也是不能加载以"java."开头的java类的。
网上也是从好多双亲委派模型去解释的,殊不知这个问题实际是从代码层面就解释的通的

2.3 破坏双亲委派
2.3.1 为何要破坏双亲委派机制?

  首先在某些情况下父类加载器需要委托子类加载器去加载class文件,而受到加载范围的限制,父类加载器无法加载到需要的文件,(因为bootstrap扫描的/lib下的包,拓展类加载器负责加载/lib/ext下的类)
  以驱动器Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL的Driver,SQLserver就是SQLserver的Driver,所以DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,只能通过委托子类来加载Driver实现,从而破坏了双亲委派机制。

2.3.2 如何破坏双亲委派机制?
  1. 自定义类加载器,重写loadClass方法;
  2. 使用线程上下文类加载器,例如JDBC设置Thread ContextClassLoader去加载类

周志明老师也提到了破坏双亲委派的历史,这里就不在列出了,可以看这个前辈写的:传送门

结尾

读完类加载的过程,相信大大家对代码块相应的输出的结果有一个更深刻的理解,譬如:


public class Student {

    static int age = 22;

    public Student(){
        System.out.println("无参构造方法 age = "+ age);
    }
    {
        System.out.println("普通代码块");
    }

    static {
        System.out.println("静态代码块");
    }

    public static void main(String[] args) {
       Student student = new Student(); System.out.println("main方法");
    }
}

静态代码块
普通代码块
无参构造方法
main方法

参考:

周志明《深入理解Java虚拟机》
https://www.cnblogs.com/qlky/p/7643524.html
https://baijiahao.baidu.com/s?id=1636309817155065432&wfr=spider&for=pc

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值