JVM系列二(类加载)

Posted 迷茫的码农

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM系列二(类加载)相关的知识,希望对你有一定的参考价值。

JVM系列二(类加载)

我们面试的时候,经常会被问到类加载机制,要想把这个问题很好的回答,需要对类加载体系非常熟悉,希望看了这里的介绍,会对大家有一定的帮助。同样还是建议大家先阅读下《Java字节码解读》,再来看类加载。

我们先用一张图来描述下类的整个生命同期:

加载

加载是类加载过程的第一步,这个加载过程主要就是靠类加载器实现的,包括用户自定义的类加载器。

JVM系列二(类加载)

加载过程

我们都知道类由类加载器加载,在加载的过程中JVM主要做3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(class文件)

    在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。

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

  3. 在内存中创建一个该类的java.lang.Class对象,作为方法区该类的各种数据的访问入口

程序在运行中所有对该类的访问都通过这个类对象,也就是这个Class对象是提供给外界访问该类的接口。这里的外界访问接口,开始我就不明白到底是怎么回事,想更深入了解的同学可以看《Java之Klass-OOP》。

加载源

JVM规范对于加载过程给予了较大的宽松度,一般二进制字节流都是从已经编译好的本地class文件中读取,此外还可以从以下地方读取:

  • Zip包

  • Jar、war、ear等

  • 其它文件生成

  • 由JSP文件中生成对应的Class类

  • 数据库中

    将二进制字节流存储至数据库中,然后在加载时从数据库中读取,有些中间件会这么做,用来实现代码在集群间分发

  • 网络

    从网络中获取二进制字节流,典型就是Applet。

  • 运行时动态生成

    动态代理技术,生成形式为“*Proxy”的代理类的二进制字节流。

类和数组加载的区别

 String[] str = new String[10];

这个数组的数组类型是[Ljava.lang.String,而String只是这个数组的元素类型。数组类和非数组类的加载是不同的。

  • 非数组类:是由类加载器来完成。

  • 数组类:数组类本身不通过类加载器创建,它是由Java虚拟机直接创建,但数组类与类加载器有很密切的关系,因为数组类的元素类型最终要靠类加载器创建。

验证

验证阶段比较耗时,它非常重要但不一定必要(因为对程序运行期没有影响),如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none参数关闭,以缩短类加载时间。

验证的目的

保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

验证的必要性

虽然Java是一门安全的语言,它能确保程序员无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,也就是说Java语言的安全性是通过编译器来保证的。但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的,当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。

虚拟机规范中没有限制二进制字节流的来源,在字节码层面上,上述Java代码无法做到的都是可以实现的,至少语义上是可以表达出来的,为了防止字节流中有安全问题,需要验证。

验证的过程

JVM系列二(类加载)

  • 文件格式验证

    验证字节流是否符合Class文件格式的规范,并且能被当前的虚拟机处理。本验证阶段是基于二进制字节流进行的,只有通过本阶段验证,才被允许存到方法区。后面的三个验证阶段都是基于方法区的存储结构进行,不会再直接操作字节流。

    【加载和验证】是交叉进行的:

    1. 加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区

    2. 文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作

  • 元数据验证

    对字节码描述信息进行语义分析,确保符合Java语法规范。

  • 字节码验证

    本阶段是验证过程的最复杂的一个阶段。本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明它一定安全。

  • 符号引用验证

    发生在JVM将符号引用转化为直接引用的时候,这个转化动作发生在解析阶段,对类自身以外的信息进行匹配校验,确保解析能正常执行。

准备

仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值,这里不包含用final修饰的static,因为final在编译的时候就会分配了(编译器的优化),同时这里也不会为实例变量分配初始化。类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

准备阶段主要完成两件事情:

  1. 为已在方法区中的类的静态成员变量分配内存。

  2. 为静态成员变量设置初始值,初始值为0、false、null等。

    数据类型 默认值
    byte (byte)0
    short (short)0
    int 0
    long 0L
    float 0.0f
    double 0.0d
    char u0000(空)
    boolean false
 public static int x = 1000;

注意:

  • 实际上变量x在准备阶段过后的初始值为0,而不是1000

  • 将x赋值为1000的putstatic指令是程序被编译后,存放于类构造器<clinit>方法之中

但如果声明为:

 public static final int x = 1000;

在编译阶段会为x生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue属性将x赋值为1000.

解析

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info 、CONSTANT_Fieldref_info、ONSTANT_Methodref_info 、CONSTANT_InterfaceMethodref_info四种常量类型。

1、 类或接口的解析

判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

2、 字段解析

对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束(优先从接口来,然后是继承的父类.理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译).

3、 类方法解析

对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

4、 接口方法解析

与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋值过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器方法的过程。

其实初始化过程就是调用类初始化方法的过程,完成对static修饰的类变量的手动赋值还有主动调用静态代码块。

注意:

  • 方法是编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

  • 静态代码块只能访问到出现在静态代码块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

 public class Test {  static { i=0; System.out.println(i);//编译失败:"非法向前引用" }  static int i = 1; }
  • 实例构造器需要调用父类构造函数,而类的不需要调用父类的类构造函数,虚拟机会确保子类的方法执行前已经执行完毕父类的方法,因此在JVM中第一个被执行的方法的类肯定是java.lang.Object

  • 如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会为此类生成方法

  • 接口也需要通过方法为接口中定义的静态成员变量显示初始化

接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成方法,不同的是,执行接口的方法不需要先执行父接口的方法,只有当父接口的静态成员变量被使用到时才执行父接口的方法

虚拟机会保证在多线程环境中一个类的方法被正确的加锁,同步。当多条线程同时去初始化一类时,只会有一个线程去执行该类的方法,其它线程都被阻塞等待,直到活动线程执行方法完毕,其它线程虽会被阻塞,只要有一个方法执行完,其它线程唤醒后不会再进入方法,同一个类加载器下,一个类型只会初始化一次。

静态内部类的单例实现:

public class Student {  private Student() {} /* * 此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全问题 */ private static class SingletonFactory {  private static Student student = new Student();  }  /* 获取实例 */ public static Student getSingletonInstance() { return SingletonFactory.student; }  }

案例分析

静态代码块、构造代码块、构造函数的执行顺序。

 package com.doaredo.test;  import org.junit.Test;  public class ClassLoaderDemo {  /** * 代码块执行顺序 */ @Test public void carryOutOrder(){ Sub sub = new Sub(); System.out.println("===================="); Sub sub1 = new Sub(); }  }  class Super { /*静态代码块*/ static { System.out.println("Super static block code!"); } /*普通代码块*/ { System.out.println("Super block code!"); } /*构造函数*/ public Super(){ System.out.println("Super Constructor code!"); } }  class Sub extends Super { static { System.out.println("---Sub static block code!"); } { System.out.println("---Sub block code!"); } public Sub(){ System.out.println("---Sub Constructor code!"); } }
 Super static block code! ---Sub static block code! Super block code! Super Constructor code! ---Sub block code! ---Sub Constructor code! ==================== Super block code! Super Constructor code! ---Sub block code! ---Sub Constructor code!

从以上结果,我们可以得出结论:

  • 静态代码块只会被执行一次

  • 静态代码块在初始化时完成调用,优先调用父类的静态代码块

  • 使用阶段先执行父类的普通代码块和构造函数,普通代码块先于构造函数执行

 
package com.doaredo.test;  import org.junit.Test;  public class ClassLoaderDemo {  @Test public void singleton(){ System.out.println(Singleton.value); System.out.println(Singleton2.value); }  }  class Singleton { private static Singleton singleton = new Singleton(); public static int value = 1; public Singleton(){ value++; } public static Singleton getInstance(){ return singleton; } }  class Singleton2 { public static int value = 1; private static Singleton2 singleton = new Singleton2(); public Singleton2(){ value++; } public static Singleton2 getInstance(){ return singleton; } }
 1 2

对于Singleton,在准备阶段给value初始为0,虽然通过16行代码通过构造函数自增,但是在执行17行代码的时候又给value赋值为1了。

Singleton2正好相反,先是给value赋值为1,然后通过构造函数自增,所以结果是2。

这个例子展示的是静态修饰执行的顺序,被static修饰的代码都是按照顺序从上往下执行的。

类加载时机

什么时候开始加载,虚拟机规范并没有强制性的约束,对于其它大部分阶段究竟何时开始虚拟机规范也都没有进行规范,这些都是交由虚拟机的具体实现来把握。所以不同的虚拟机它们开始的时机可能是不同的。但是对于初始化却严格的规定了有且只有四种情况必须先对类进行“初始化”(加载,验证,准备自然需要在初始化之前完成):

1、 遇到new、getstatic、putstatic和invokestatic这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。

这四个指令对应到我们Java代码中的场景分别是:

  • New关键字实例化对象的时候

  • 读取或设置一个类的静态字段(读取被final修饰,已在编译器把结果放入常量池的静态字段除外)

  • 调用类的静态方法时

  • 使用java.lang.reflect包方法时对类进行反射调用的时候

  • 初始化一个类的时候发现其父类还没初始化,要先初始化其父类

  • 当虚拟机开始启动时,用户需要指定一个主类,虚拟机会先执行这个主类的初始化

类加载器

启动类加载器

负责加载 JAVA_HOMElib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。由C++实现,不是ClassLoader子类。

扩展类加载器

负责加载 JAVA_HOMElibext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。

应用类加载器

负责加载用户路径(classpath)上的类库。

加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap lassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。

自定义类加载器

1、 继承ClassLoader

2、 重写findClass()方法

3、 调用defineClass()方法

作用:

JVM自带的三个加载只能加载指定路径下的类字节码。如果某个情况下,我们需要加载应用程序之外的类文件呢?比如本地盘下的,或者去加载网络上的某个类文件,这种情况就可以使用自定义加载器了。

 
package com.doaredo.test;  import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.WritableByteChannel;  class MyClassLoader extends ClassLoader {  private String path;// D:\Test.class  public MyClassLoader(String path){ this.path = path; }  @Override protected Class<?> findClass(String name) throws ClassNotFoundException { File file = getClassFile(path); try{ byte[] bytes = getClassBytes(file); Class<?> clazz = this.defineClass(name, bytes, 0, bytes.length); return clazz; } catch (Exception e) { e.printStackTrace(); } return null; }  private File getClassFile(String path){ File file = new File(path); return file; }  private byte[] getClassBytes(File file) throws Exception { FileInputStream fis = new FileInputStream(file); FileChannel fc = fis.getChannel(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); WritableByteChannel wbc = Channels.newChannel(baos); ByteBuffer bb = ByteBuffer.allocate(1024); while(true){ int i = fc.read(bb); if(i == 0 || i == -1) break; bb.flip(); wbc.write(bb); bb.clear(); } fis.close(); return baos.toByteArray(); } }

双亲委派模型

JVM通过双亲委派模式进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器:

  • 当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器。

  • 只有当父类加载器无法完成加载任务时,才会尝试执行加载任务

采用双亲委派的一个好处是:比如加载位于rt.jar包中的类java.lang.Object中,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。

为什么要使用双亲委派这种模型呢

因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

如何判定两个class是相同的

JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。

只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。

为什么需要破坏双亲委派

因为在某些情况下父类加载器需要委托子类加载器去加载class文件(双亲委派模式的话,是子类委托父类加载器去加载class文件)。因为受到加载范围的限制,父类加载器无法加载到需要的文件。

以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如

mysql的就写了MySQL Connector ,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。



以上是关于JVM系列二(类加载)的主要内容,如果未能解决你的问题,请参考以下文章

JVM系列之类加载机制(从类文件到虚拟机)

JVM系列之类加载机制(从类文件到虚拟机)

Java系列文章(全)

深入JVM系列之类加载类加载器双亲委派机制与常见问题

jvm系列-02jvm的类加载子系统以及jclasslib的基本使用

JVM系列之类加载