JVM系列(一):类的加载机制

java的类加载机制

   我们知道,Java源文件是不能直接在虚拟机上面执行的,java虚拟机不和java在内的任何语言绑定,它只和“Class文件”这种特定的二进制文件有所关联,我们的java语言如果想在虚拟机上面执行,就必须要编译成.class形式的文件,虚拟机会把描述类的数据从class文件加载到内存。
也就是说,一个java源文件如果被执行的话,需要经历以下过程:
                                                                1181802-20191229205620347-718082608.png

   对于虚拟机把的.class文件加载到内存这一过程,如果这个class文件被修改过,如果class文件格式不正确的话,虚拟机还能正常加载吗,如果有同名的类Class文件,该如何加载呢?

   何为虚拟机类加载机制:

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化、最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

   在java语言里,类型的加载、连接、初始化都是在程序运行期间完成的,这个策略是java作为可以动态扩展的语言、为程序提供高度的灵活性的保障。比如说java中多态的体现,一个接口可以等到运行时再指定实际的实现类。

   类从加载到虚拟机主要经历了以下几个阶段:

                                                                1181802-20191229211939092-2064102563.png

一、加载

“加载”是类加载过程的一部分,在加载阶段,虚拟机需要以下三个步骤:

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

   虚拟机的加载阶段的设计是很开放的,它并没有指定我们需要从哪里加载我们的二进制流,因此我们可以从很多地方加载二进制流,例如像以下场景:

  • 从ZIP包中读取,像我们JAR,WAR等形式;
  • 从网络中获取;
  • 例如JSP文件,我们编写的JSP文件会生成对应的Class类;
  • 运行时通过动态代理技术生成,例如Proxy生成的类名会有$Proxy的标记;

二、连接

连接主要分为三个阶段,验证、准备、解析。

1、验证

   Java虚拟机不保证要求其加载的任何文件是由该编译器生成的或格式正确的,如果不对加载的字节流进行验证的话,很有可能会因为加载了有害的字节流导致系统奔溃或者其他的安全性问题。对于这些潜在问题,Java虚拟机需要自行验证这个Class文件是否满足所需的约束。Java虚拟机实现class在链接时验证每个文件是否满足必要的约束。那么又从那几个方面验证呢?根据虚拟机规范来看,验证阶段大概分为四个阶段:文件格式验证、元数据验证、字节码验证、符引用验证。

1) 文件格式验证
这个阶段主要验证字节流是否符合Class文件格式的规范,并且是否能被当前版本的虚拟机处理等。一个Class文件用工具Sublime打开是这样的:

cafe babe 0000 0034 0029 0900 0b00 1b0a
000c 001c 0700 1d0a 0003 001c 0800 1e0a
0003 001f 0900 0b00 200a 0003 0021 0800
220a 0003 0023 0700 2407 0025 0100 046e
616d 6501 0012 4c6a 6176 612f 6c61 6e67
2f53 7472 696e 673b 0100 0361 6765 0100
0667 6574 4167 6501 0014 2829 4c6a 6176
612f 6c61 6e67 2f53 7472 696e 673b 0100
0443 6f64 6501 000f 4c69 6e65 4e75 6d62
6572 5461 626c 6501 0006 7365 7441 6765
0100 1528 4c6a 6176 612f 6c61 6e67 2f53
......

   开头四个字节称为魔数,用来验证这个文件能否被虚拟机接受,紧跟着魔数的后面的四个字节表示主版本号和次版本号,接着版本号的是常量池入口等等,由于这部分内容涉及类的Class文件,这里就不赘述。
该阶段验证内容有以下方面:

  • 是否以魔数0xCAFEBABE开头;
  • 主次版本号是否在当前虚拟机的处理范围之内;
  • 常量池的常量中是否有不被支持的常量类型;
  • 指向常量池的各种索引值中是否有指向不存在的常量或者不符合类型的常量;
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据;
    ......
    只有通过了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储。
2)元数据验证

这个阶段是对字节码描述的信息进行语义分析,看是否有不符合java规范的元数据信息如:

  • 这个类是否有父类;
  • 这个类的父类是否继承了不被允许继承的类;
  • 是否按要求实现了抽象类或者接口中的要求实现的方法;
    ......

① 字节码验证

   这个阶段主要是通过数据流和控制流分析,确定程序的语义是合法的、符合逻辑的,不会对虚拟机产生危害。如:保证不会出现加载到局部变量表的类型和操作数栈的类型不一致,不会将指令跳转到方法体之外的字节码指令上等。

② 符号验证
   虚拟机会将符合引用转化直接引用,这个转化动作在解析阶段执行。通常需要校验是根据符号引用的字符串描述能否找到对应的类、方法、字段、访问属性的限制等。
符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符合引用验证将会抛出如:java.lang.IllegalAccessError、java.lang.NoSuchMthodError、java.lang.NoSuchFiledError等异常

2、准备

   准备阶段是为类变量分配内存,并将其初始化为默认值。准备阶段给变量分配内存仅仅指的是类的变量而不包括实例变量,实例变量将会在实例化的时候随着对象一起分配在堆中。而且并不是将变量进行赋值,只会初始化默认值,例如:static int value = 2;,在准备阶段过后的初始值是其int类型的默认初始值0而不是2.赋值的操作会等到初始化的时候才会操作。

3、解析

   这个阶段是讲符号引用转换为直接引用的过程。那么何为符号引用?一个java类在在编译时,java类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,其实就是类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。jvm在加载Class文件的时候,会把符合引用替换为真实地址的直接引用。这个阶段解析的主要有类或接口的解析、字段解析、方法解析等等

三、初始化

   初始化是类加载的最后一步,这个阶段会执行类构造器 < clinit>()方法,为类变量进行真正的赋值。虚拟机会保证父类的< cinit>() 方法优先于子类执行,也就意味着父类中的静态变量的赋值操作会优先于子类。一个类中变量的初始化会按照以下顺序进行:
如果父类还未加载,先执行父类的静态代码块和静态变量初始化,并且静态代码块和静态变量的执行顺序跟代码中出现的顺序有关----》执行子类的静态代码块和静态变量初始化。----》执行父类的实例变量初始化-----》执行父类的构造函数 ----》执行子类的实例变量初始化 ----》执行子类的构造函数。
虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”:

(1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果没有类进行过初始化,则需要先出发其初始化;

(2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先出发其初始化;

(3)当初始化一个类的时候,如果发现其父类还没进行过初始化,则需先触发其父类的初始化;

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

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

类加载器

   前面分析了类的加载的过程,那么需要有将类加载到虚拟机的这么一个操作,执行这个操作的就是类加载器。类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载到JVM中,这个类就不会被再次载入了。那么java如何类实现或者说保证加载同一个类不会被加载两次呢?那就是使用双亲委派模型。

在java中,类加载器可以分为3种:

  • 启动类加载器(BootStrap ClassLoader)
    这个类加载器负责把JAVA_HOME\lib目录下的类库或者Xbootclasspath选项指定的jar包等加载到虚拟机内存中,由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作
  • 扩展类加载器(Extension ClassLoader)
    这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JAVA_HOME\lib\ext目录下或者由系统变量-Djava.ext.dir指定位置中的类库。
  • 应用类加载器(Appliaction ClassLoader)
    应用类加载器也称为系统类加载器,它负责加载用户路径上指定的类库,开发者可以自定义自己的类加载器,如果没有自定义自己的类加载器的话,默认使用该加载器。
类加载的双亲委派机制

   java中使用双亲委派模型来保证一个相同的类不会被加载两次,它的工作原理是:当一个类收到被加载的请求的时候,它不会立即去加载,而是委托给它的父类加载器去加载,如果父类加载器还存在着父加载器,则继续向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

在java中,启动类加载器、扩展类加载器、应用类加载器的关系如下图所示:

                                                                1181802-20191229205707642-1860091880.png

系统类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器

   使用双亲委派模型,无论哪一个类加载器想要加载比如说rt.jar里面的类,最终都会委派到处于顶端的启动类加载器,这样就保证了java类型体系中最基础的行为。大多数情况下,越基础的类由越上层的加载器进行加载。
双亲委派模型的逻辑很清晰,下面一起来分析下这段源码:

 synchronized (getClassLoadingLock(name)) {
            // 检查该类是否已经被加载
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 如果存在父类加载器的话,使用父类加载器加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        // 说明父类是BootStarp加载器,若在bootstrap class loader的查找范围内没有查找到该类,则返回null   
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // 如果依然找不到的话,则调用本身(这个本身包括ext和app)的
                     // findClass(name)来查找类,若自身也加载不了,会产生ClassNotFoundException异常并向上抛出
                    long t1 = System.nanoTime();
                    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(c);
            }
            return c;
        }

   类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass方法不会被重复调用。
   用户可以自己定义类加载器,只需要重写findClass方法就行了。一般尽量不要覆写已有的loadClass(…)方法中的委派逻辑,这样可能由于一些复杂的逻辑导致系统默认的类加载器不能正常工作。真正完成类的加载工作是通过调用defineClass来实现的,它会返回一个Class对象。

下面就来看个自定义类加载器的例子:

public class UserClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {


        File file = new File("G:\\Person.class");

        byte[] bytes = new byte[0];
        try {
            bytes = getClassBytes(file);
        } catch (IOException e) {
            e.printStackTrace();
        }
        //defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class
        Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
        return c;
    }

    private byte[] getClassBytes(File file) throws IOException {
        FileInputStream fis = new FileInputStream(file);
        FileChannel fc = fis.getChannel();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        WritableByteChannel wbc = Channels.newChannel(baos);
        ByteBuffer by = ByteBuffer.allocate(1024);
        while (true){
            int i = fc.read(by);
            if (i == 0 || i == -1)
                break;
            by.flip();
            wbc.write(by);
            by.clear();
        }
        fis.close();
        return baos.toByteArray();
    }
}

一个Person,需要编译成Class文件

public class Person {

    private String name;
    private String age;

    public String getAge() {
        return age;
    }

    public void setAge(String age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age='" + age + '\'' +
                '}';
    }
}
public class ClassLoaderTest {

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        UserClassLoader userClassLoader = new UserClassLoader();
        // 类的全限定名
        Class<?> clazz = Class.forName("classloader.Person", true, userClassLoader);
        Object obj = clazz.newInstance();

        //打印出我们的自定义类加载器
        System.out.println(obj.getClass().getClassLoader());
    }

}

   需要注意的是,即使自定义了自己的类加载器,用definClass()方法去加载一个以“java.lang”开头的类也不会成功的。这是因为在java.lang.ClassLoader里面的preDefineClass方法中,如果类以“java.”开头的话,会抛出SecurityException异常,如下所示部分源代码:

if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }

虚拟机的类加载机制避免了类的重复加载,也避免了java的核心API被篡改,有效的保证了java程序的稳定运行。

参考书籍:

深入理解java虚拟机--周志明 著

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值