从头开始学习JVM(四)—— 类加载机制和类加载器

本文详细介绍了JVM的类加载机制,包括加载、验证、准备、解析、初始化五个阶段,以及主动引用和被动引用的概念。还探讨了类加载器的工作原理,如双亲委派模型,并分析了如何破坏这一模型。最后,简述了Java的沙箱安全机制,以保护程序在运行时的安全。
摘要由CSDN通过智能技术生成

JVM(Java Virtual Machine)即Java虚拟机,Java代码都是在JVM上运行的,所以了解JVM是成为Java高手的毕竟之路。

本系列内容将对JVM的知识进行介绍,是从头学习JVM知识的笔记。

本系列内容根据自己的学习和理解的基础上,并参考《深入理解Java虚拟机》一书介绍的知识所写。如果有写的不对的地方,请各位多多提点。



类加载机制

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

Java语言里,类型的加载、连接和初始化过程都是在运行期间完成的,虽然这种策略会令类加载时增加一些性能开销,但是,Java可以动态扩展的语言特性就是依赖运行时加载和动态连接的特点实现的,并且这种方式为Java程序提供了高度的灵活性。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)

其中,验证、准备、解析3个部分统称为连接(Linking)。
类的生命周期


类加载的时机

类的生命周期中,除了解析这一环节,其他环节的执行顺序都是确定的,类的加载过程必须按照顺序按部就班的开始。

解析阶段在某些情况下可以在初始化阶段之后在开始,这就是Java语言的运行时绑定(也叫动态绑定或后绑定)特性。

类加载的第一个阶段,加载阶段,并没有规定在什么时候开始执行,是由虚拟机的具体实现来自由把控的。

主动引用(立即初始化)

虚拟机规范中严格规定了有且只有以下5种情况必须立即对类进行初始化阶段的加载,当然前提是之前的 加载、验证、准备环节都已经完成了。

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,类没初始化过的即触发初始化。使用场景:① 使用 new 关键字实例化对象。 ② 读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)。 ③ 调用一个类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候。
  3. 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
  4. 当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。

以上 5 种情况虚拟机规范中使用了一个很强烈的限定语“有且只有”,它们被称为对类的主动引用,会对类触发立即初始化的场景

被动引用

除了主动引用的情况外,所有引用类的方式都不会触发初始化,称为被动引用。


public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 233;
}
 
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}
 
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world!"
}
 
public class NotInitialization {
    public static void main(String[] args) {
        /**
         * 第一种演示:
         * 通过子类引用父类的静态对象不会导致子类的初始化
         * 只有直接定义这个字段的类才会被初始化
         * 
         *  output : SuperClass init!   
         * 			233
         */
         System.out.println(SubClass.value);
 
        /**
         * 第二种演示:
         * 通过数组定义来引用类不会触发此类的初始化
         * 虚拟机在运行时动态创建了一个数组类
         */
         SuperClass[] sca = new SuperClass[10];
 
        
        /**
         * 第三种演示:
         * 常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类,
         * 因此不会触发定义常量的类的初始化。
         * “hello world” 在编译期常量传播优化时已经存储到 NotInitialization 常量池中了。
         */
         System.out.println(ConstClass.HELLOWORLD);
    }
}


类加载的过程

接下来讲一下类加载的过程,也就是加载、验证、准备、解析、初始化这5个过程的具体动作。
类加载的过程

加载

虚拟机在加载阶段需要完成以下3件事:

  1. 通过一个类的全限定名来获取定义次类的二进制流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法去这个类的各种数据的访问入口。
  4. 内存中实例的 java.lang.Class 对象存在堆中,类的元数据(类的方法代码,变量名,方法名,访问权限,返回值等)存在方法区。作为程序访问方法区中这些类型数据的外部接口。
  5. 加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。

第一步,获取类的二进制流的方式有很多种,许多Java举足轻重的技术都建立在它们的基础上:

  1. 从ZIP包中读取,最终成为JAR、EAR、WAR格式的基础。
  2. 从网络中获取,这种场景最典型的应用就是Applet。
  3. 运行时计算生成,这种场景用的最多的就是动态代理技术。
  4. 由其他文件生成,典型的场景是JSP应用,即由JSP文件生成对应的Class类。
  5. 从数据库中读取,这种场景比较少见。


  • 数组类的特殊性

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

  1. 如果数组的组件类型是引用类型,那就递归采用类加载过程去加载。
  2. 如果数组的组件类型不是引用类型,Java 虚拟机会把数组标记为引导类加载器关联。
  3. 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。


验证

验证是连接阶段的第一步,是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机要求,保证虚拟机自身的安全。验证阶段大致会完成4个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证(部分内容)
  1. 是否以魔数 0xCAFEBABE 开头
  2. 主、次版本号是否在当前虚拟机处理范围之内
  3. 常量池的常量是否有不被支持常量的类型(检查常量 tag 标志)
  4. 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
  5. CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
  6. Class 文件中各个部分集文件本身是否有被删除的附加的其他信息
  7. ……

只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面 3 个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。

  • 元数据验证
  1. 这个类是否有父类(除 java.lang.Object 之外)
  2. 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
  3. 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  4. 类中的字段、方法是否与父类产生矛盾(覆盖父类 final 字段、出现不符合规范的重载)

这一阶段主要是对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。

  • 字节码验证(部分内容)
  1. 保证任意时刻操作数栈的数据类型与指令代码序列都鞥配合工作(例如不会出现按照 long 类型读一个 int 型数据的问题)。
  2. 保证跳转指令不会跳转到方法体以外的字节码指令上。
  3. 保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)。
  4. ……

这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。

  • 符号引用验证
  1. 符号引用中通过字符创描述的全限定名是否能找到对应的类
  2. 在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
  3. 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
  4. ……

最后一个阶段的校验发生在迅疾将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,还有以上提及的内容。

符号引用的目的是确保解析动作能正常执行,如果无法通过符号引用验证将抛出一个 java.lang.IncompatibleClass.ChangeError 异常的子类。如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。

  • 总结

对于虚拟机来说,验证是一个非常重要、但不是一定必要的阶段(因为对程序运行期没有影响)。如果所运行的代码都已经被反反复复验证过了,那么实施阶段可以考虑使用参数 -Xverify:none来关闭验证阶段,缩短类加载时间。

准备

准备阶段时正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存将在方法区中进行分配。

这个阶段进行内存分分配的变量仅包括类变量(含static修饰的变量),而不包括实例变量,实例变量将会在对象实例化阶段随着对象一起分配在堆内存中。且这里说的初始值通常指的是零值。

public static int value = 233;

这句代码在初始值设置之后为 0,因为这时候尚未开始执行任何 Java 方法。而把 value 赋值为 233 的 putstatic 指令是程序被编译后,存放于 clinit() 方法中,所以初始化阶段才会对 value 进行赋值。

基本数据类型的零值

数据类型零值数据类型零值
int0booleanfalse
long0Lfloat0.0f
short(short)0double0.0d
char‘\u0000’referencenull
byte(byte)0


解析

这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic Reference)

符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。

  • 直接引用(Direct Reference)

直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关.


解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。

虚拟机并未规定解析阶段发生的具体时间,虚拟根据现实需要判断在类加载器加载时就对常量池中的符号引用进行解析,也可以等到一个符号引用将要被使用再解析。因此对同一个符号进行多次解析的情况是很常见的。

初始化

类初始化时类加载过程的最后一步,前面过程都是以虚拟机主导,而初始化阶段开始执行类中的 Java 代码,实例变量初始化、实例代码块初始化以及构造函数初始化。初始化阶段时根据程序中的代码规则主观地去初始化类变量和其他资源(包括将零值赋值为定义的值)。

程序执行过程与类的创建

Java程序执行顺序:Java代码 > 解释器 > *.class文件(字节码) > JVM (类加载器加载 > 使用 > 回收 > 卸载)。
程序执行过程与类的创建


类加载器

实现 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作的代码模块叫类加载器。这个定做放到Java虚拟机外部去实现,目的是让应用程序自己去决定如何获取所需的类。类加载器是Java语言流行的重要原因之一。

类与类加载器

每一个类加载器,都拥有一个独立的类名称空间。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。简单来说,就是当两个类是由同一个类加载器加载的前提下,才能比较他们是否“相等”;若加载它们的类加载器不同,那这两个类必定不相等。

类加载器类型

从 Java 虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)。

从程序开发的角度来说,可以分为以下几种类型:

  • 启动类加载器(Bootstrap ClassLoader)

负责加载 <JAVA_HOME>/lib 目录中、或被 -Xbootclasspath 参数指定路径下的类。该加载器无法被Java程序直接引用,对应的文件包为 rt.jar。在自定义类加载器中需要委托给启动类加载器时,直接使用null代替即可。

  • 扩展类加载器(Extension ClassLoader)

由ExtClassLoader实现,负责加载 <JAVA_HOME>/lib/ext 目录中、或者被 java.ext.dirs 系统变量所指定的路径下的类,开发者可以直接使用。

  • 应用程序类加载器(Application ClassLoader)

由ClassLoader实现,是ClassLoader中的getSystemClassLoader()方法的返回值,加载用户路径(ClassPath)上所指定的类库。开发者可以直接使用。

  • 用户自定义类加载器(User ClassLoader)

由用户自行编写的类加载器。

类加载器加载的类目录指定目录参数对应的文件程序可直接使用
启动类加载器(Bootstrap ClassLoader)<JAVA_HOME>/lib-Xbootclasspathrt.jar
扩展类加载器(Extension ClassLoader)<JAVA_HOME>/lib/extjava.ext.dirs
应用程序类加载器(Application ClassLoader)<‘CLASSPATH’>/
用户自定义类加载器(User ClassLoader)自定义


双亲委派模型

双亲委派模型是JDK1.2被引入且广泛应用的类加载模式。它不是强制性的实现,是Java推荐给开发者的一种目前默认的实现模式。

工作过程:如果一个类加载器收到了类加载请求,它会首先把请求委托给父类去实现,每一层次都会 “层层上报”,最终至顶层的启动类加载器。只有当父类加载器无法实现时,才会 “层层下放” 让子类尝试加载。

类加载器的父子关系一般不会以继承(Inheritance)方式实现,而是以组合(Composition)方式实现。类加载器关系图如下:
类加载器关系图

双亲委派模型的好处是避免了类的重复加载、混乱加载。类加载器具备了一种带有优先级的层次关系,很大程度上的保证了类加载器的加载限定,不会出现各种类加载器生成的各种类对象,保证了Java程序的稳定性。

类加载机制特点

  • 全盘负责。加载类的加载器不止加载类对象的实例,还要负责此Class类的依赖和引用,除非显示地置顶使用其他类加载器。
  • 双亲委派模型。优先 “层层上报” 优先交给父类加载器执行,若无法实现才会 “层层下放” 交给子类加载器执行。
  • 缓存机制。加载过的Class类对象会存入缓存区,只有当缓存区不存在该Class对象时,虚拟机才会重新读取二进制流并将其转换成Class对象存入缓存区。因此在JVM中每个类的Class对象是唯一的。也是当开发过程中修改了类程序(Class文件)代码时,必须重启JVM的原因。


破坏双亲委派模型


破坏双亲委派模型有2种方式

  1. 双亲委派的实现是通过java.lang.ClassLoader里的loadClass()方法实现的。因此破坏双亲委派机制的方法即重写loadClass()方法,改变类加载模型。
  2. 通过线程上下文类加载器(Thread Context ClassLoader)。这种加载器是设计来实现基础类调用用户代码的,即父类加载器请求子类加载器去完成类加载动作,这种行为实际上是打通了双亲委派模型的层次来逆向使用类加载器。

打破类加载模型的情况还有以下两种:OSGi环境、Tomcat。

为了实现热部署、代码热替换,在OSGi环境下类加载模型变得比双亲委派模型更复制了,所以它也打破了双亲委派模型。

OSGI对类加载器的使用时值得学习的,弄懂了OSGI的实现,就可以算是掌握了类加载器的精髓。


Tomcat的类加载模型

Tomcat的类加载机制也是打破了双亲委派模型的。因为Tomcat是一种容器,会存在各种App,每个App所需的类的版本不一定完全一致,同一个类加载器不可能生成多个不同版本的某一个类,就会存在以下情况:

  1. 容器和所有app共享的类的类加载器。
  2. 容器本身所需的类加载。
  3. app独占的类所需的类加载器。
  4. app共享的类所需的类加载器。

针对以上情况,Tomcat重新设计了类加载器。如下图
在这里插入图片描述
我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/、/server/、/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见。

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

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

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


沙箱安全机制

额外补充一个知识点。

Java安全模型的核心是Java沙箱(sandbox),在JDK1.1中就存在了。

沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在JVM(Java虚拟机)特定的运行范围中,并且严格限制代码对本地系统资源的访问,通过沙箱机制来保证代码的有效隔离,防止对本地操作系统造成破坏。

所有的Java程序运行都可以指定沙箱,可以定制安全策略,不同级别的沙箱访问本地资源的权限也不同。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值