JVM:Java类加载及类加载器

1、Java类加载过程

类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。
JVM:Java类加载及类加载器_第1张图片

1.1、加载

通过一个类的全限定名来获取定义此类的二进制字节流;

  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  • 在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据的访问入口。

简单的说,类加载阶段就是由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的java.lang.Class对象实例(Java虚拟机规范并没有明确要求一定要存储在堆区中,只是hotspot选择将Class对应哪个存储在方法区中),这个Class对象在日后就会作为方法区中该类的各种数据的访问入口。加载阶段由类加载器负责,过程见类加载器;

1.2、连接

连接阶段要做的是将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中,经由验证、准备和解析三个阶段。

1.2.1. 验证

验证类数据信息是否符合JVM规范,是否是一个有效的字节码文件,验证内容涵盖了类数据信息的格式验证、语义分析、操作验证等。

文件格式验证:要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理

  • 是否以魔数0xCAFEBABE开头;
  • 主、次版本号是否在当前Java虚拟机接受范围之内;
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志);
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据;
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

实际上第一阶段的验证点还远不止这些, 上面所列的只是一小部分内容, 该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内, 格式上符合描述一个Java类型信息的要求

元数据检查:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。

字节码检查:通过数据流分析和控制流分析,确定 程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害 虚拟机安全的行为

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似 于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况;
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全 的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的;

延伸:由于数据流分析和控制流分析的高度复杂性,Java虚拟机的设计团队为了避免过多的执行时间消 耗在字节码验证阶段中,在JDK 6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能 多的校验辅助措施挪到Javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名 为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分 的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程 序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验 证的类型推导转变为类型检查,从而节省了大量校验时间。理论上StackMapTable属性也存在错误或被 篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过 虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。 JDK 6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier选项来关闭掉这项优化,或者使用参数- XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了 JDK 7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于50(对应JDK 6)的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型 推导的校验方式

如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法 体通过了字节码验证,也仍然不能保证它一定就是安全的

符号引用验证:最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在 连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类;
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、< package>)是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机 将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等

1.2.2. 准备

准备阶段负责为类中static变量分配空间,并初始化(与程序无关,系统初始化);

  • 被final修饰的静态变量,会直接赋予原值;
  • 类字段的字段属性表中存在ConstantValue属性,则在准备阶段,其值就是ConstantValue的值。

1.2.3. 解析

解析阶段负责将常量池中所有符号引用转换为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法),这个可以在初始化之后再执行。可以认为是一些静态绑定的会被解析,动态绑定则只会在运行时进行解析;静态绑定包括一些final方法(不可以重写),static方法(只会属于当前类),构造器(不会被重写);

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何 形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引 用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规 范》的Class文件格式中;
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能 间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机 的内存中存在;

1.3、初始化

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通 过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器< clinit>()方法的过程。< clinit>()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及 < clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作;

  • < clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问;
  • < clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显 式地调用父类构造器,Java虚拟机会保证在子类的< clinit>()方法执行前,父类的< clinit>()方法已经执行 完毕。因此在Java虚拟机中第一个被执行的< clinit>()方法的类型肯定是java.lang.Object;
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 < clinit>()方法。但接口与类不同的是,执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的< clinit>()方法;
  • Java虚拟机必须保证一个类的< clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的< clinit>()方法,其他线程都需要阻塞等 待,直到活动线程执行完毕< clinit>()方法。如果在一个类的< clinit>()方法中有耗时很长的操作,那就 可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的;
  • 同一个类加载器下,一个类型只会被初始化一次;

2、类加载器

2.1、类和类加载器

类加载器虽然只用于实现类的加载动作, 但它在Java程序中起到的作用却远超类加载阶段。 对于任意一个类, 都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间。 这句话可以表达得更通俗一些: 比较两个类是否“相等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这两个类来源于同一个Class文件, 被同一个Java虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等。

2.2、双亲委派模型

JVM:Java类加载及类加载器_第2张图片

  • 启动类加载器(Bootstrap Class Loader): 这个类加载器负责加载存放在\lib目录, 或者被-Xbootclasspath参数所指定的路径中存放的, 而且是Java虚拟机能够识别的(按照文件名识别, 如rt.jar、 tools.jar, 名字不符合的类库即使放在lib目录中也不会被加载) 类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用, 用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理, 那直接使用null代替即可;
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\lib\ext目录中, 或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能, 在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。 由于扩展类加载器是由Java代码实现的, 开发者可以直接在程序中使用扩展类加载器来加载Class文件;
  • 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。 由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值, 所以有些场合中也称它为“系统类加载器”。 它负责加载用户类路径(ClassPath) 上所有的类库, 开发者同样可以直接在代码中使用这个类加载器。 如果应用程序中没有自定义过自己的类加载器, 一般情况下这个就是程序中默认的类加载器

双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。

代码实现:ClassLoad类的loadClass方法

 protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    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中的类随着它的类加载器一起具备了一种带有优先级的层次关系。 例如类java.lang.Object, 它存放在rt.jar之中, 无论哪一个类加载器要加载这个类, 最终都是委派给处于模型最顶端的启动类加载器进行加载, 因此Object类在程序的各种类加载器环境中都能够保证是同一个类。 反之, 如果没有使用双亲委派模型, 都由各个类加载器自行去加载的话, 如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中, 那系统中就会出现多个不同的Object类, Java类型体系中最基础的行为也就无从保证, 应用程序将会变得一片混乱。

2.3、破坏双亲委派模型

双亲委派模型并不是一个具有强制性约束的模型, 而是Java设计者推荐给开发者们的类加载器实现方式。

双亲模型有个问题:父加载器无法向下识别子加载器的资源
JVM:Java类加载及类加载器_第3张图片
JDBC 是启动类加载器加载,但 mysql 驱动是应用类加载器。而 JDBC 运行时又需要去访问子类加载器加载的驱动,就破坏了该模型。它是怎么破坏的呢?其实引入了线程上下文来破坏该模型的。
DriverManager部分源码:

private static Connection getConnection(
        String url, java.util.Properties info, Class<?> caller) throws SQLException {
        /*
         * When callerCl is null, we should check the application's
         * (which is invoking this class indirectly)
         * classloader, so that the JDBC driver class outside rt.jar
         * can be loaded from here.
         */
        ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
        synchronized(DriverManager.class) {
            // synchronize loading of the correct classloader.
            if (callerCL == null) {
                callerCL = Thread.currentThread().getContextClassLoader();
            }
        }
}

破坏双亲委派模型:

  • 自定义类加载器:重写loadClass()方法就是破坏双亲委派模型;
  • 线程上下文类加载器:可以通过Thread的setContextClassLoader()设置;
  • 另外一种典型情况就是实现热替换,比如OSGI的模块化热部署,它的类加载器就不再是严格按照双亲委派模型,很多可能就在平级的类加载器中执行了。

Tomcat破坏双亲委派模型

Tomcat是个web容器,可能部署不止一个应用,如果使用默认的类加载机制行不行?肯定是不行的!!

  • 不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离;
  • 部署在同一个web容器中相同的类库相同的版本可以共享;
  • web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来;
  • web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启;

那么Tomcat是怎么做的呢?
JVM:Java类加载及类加载器_第4张图片
我们看到,前面3个类加载和默认的一致,Common ClassLoader、Catalina ClassLoader、Shared ClassLoader和Webapp ClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*、/server/*、/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • Common ClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp(web应用)访问;
  • Catalina ClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • Shared ClassLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • Webapp ClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见。

Common ClassLoader能加载的类都可以被Catalina ClassLoader和Shared ClassLoader使用,从而实现了公有类库的共用,而Catalina ClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离;

WebApp ClassLoader可以使用Shared ClassLoader加载到的类,但各个WebApp ClassLoader实例之间相互隔离;

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperCLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

你可能感兴趣的:(JVM,java)