JVM 系列文章目录
第一篇:内存区域与内存异常
第二篇:对象揭秘与堆内存分配策略
第三篇:垃圾回收
第四篇:类加载机制
第五篇:性能优化(上)
目录
一、类加载机制
类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。
1、加载
“加载” 是 “类加载” 过程的一个阶段,在这个阶段虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中这个类的访问入口。
虚拟机规范的这三个要求并不算具体,因此虚拟机具体实现的灵活度很高。比如上面的第一条并没有明确规定这个二进制流从哪获取,如何获取?所以给开发者留了广阔的空间,许多举足轻重的 Java 技术都建立在这一基础上。例如:
- 从 ZIP 包读取,这很常见,最终称为 JAR、WAR 等格式的基础。
- 运行时生成,比如我们常用的动态代理。
- 由其他文件生成,比如 JSP 就是一个 Serverlet 类。
加载阶段完成,二进制流会根据虚拟机所需的格式存储到方法区中,然后内存中实例化一个 java.lang.Class 类的对象(并没有规定在 Java 堆中),这个对象作为程序访问方法区这个类的外部接口。加载阶段和连接阶段部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这两个阶段仍然保持着固定的先后顺序。
2、验证
验证阶段是为了确保 Class 文件的字节流符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,大致会完成4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
1、文件格式验证
验证字节流是否符合 Class 文件格式要求,比如:
- 是否以魔数 0xCAFEBABE 开头。
- 主次版本是否在当前虚拟机处理范围之内。
- 常量池的常量是否被支持。
- 等等等等…
该阶段验证的主要目的是保证字节流能正确的解析并保存在方法区之中,格式上符合 Java 类型信息的要求。这个阶段是基于二进制流进行的,只有验证通过才能保存在方法区,所以后面的验证阶段都是基于保存在方法区的存储结构进行的。
2、元数据验证
对字节码描述的信息进行语义分析,保证其符合 Java 语言规范。主要验证点如下:
- 这个类是否有父类。
- 这个类的父类是否继承了不允许被继承的类。
- 如果这个类不是抽象类,是否实现了其父类或接口要求实现的所有方法。
- 类中字段、方法是否和父类产生矛盾。
- 等等等等…
3、字节码验证
这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法体进行验证分析,保证方法在运行时不会危害虚拟机安全,例如:
- 保证任意时刻操作数栈的数据类型和指令都能配合工作,例如不会出现类似于这样的情况:操作数栈里放了一个 int 类型的数据,使用时却按 long 类型加载到本地变量表中。
- 保证跳转指令不会跳转到方法体以为的字节码指令上。
- 保证方法体中的类型转换是有效的。
- 等等等等…
4、符号引用验证
这个阶段发生在虚拟机将符号引用转化为直接引用的时候,可以看做是对类自身以外的信息进行匹配性校验,比如外部类、变量、方法是否存在,访问是否合法,通常需要校验以下内容:
- 通过字符串描述的全限定名能否找到对应的类。
- 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用的类、字段、方法的访问性是否可被当前类访问。
- 等等等等…
3、准备
该阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这里有两块需要注意的,一是内存分配仅包括类变量(静态变量),另一个是初始值 “通常情况” 下是数据类型的零值。
// 代码中 VALUE 赋值为 123,但在准备阶段 VALUE 的值为 0
public static int VALUE = 123;
// 准备阶段 VALUE 的值为 123(final 修饰等同于常量)
public final static int VALUE = 123;
4、解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。
- 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量。
- 直接引用:指向存放目标的内存地址的引用。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
5、初始化
初始化是类加载过程的最后一个步骤,就是执行类构造器 clinit() 方法的过程。在此阶段,JVM 会执行执行类中编写的 Java 程序代码,对类的静态变量,静态代码块执行初始化操作。
- clinit() 方法由编译期自动收集类中所有类变量的复制动作和静态语句块的语句产生的,收集顺序由语句在源文件中的出现的顺序决定,所以静态语句块只能访问定义在其之前的静态变量。
- clinit() 方法与类的构造函数 init() 不同,它不需要显示调用父类构造器,虚拟机会保证在子类调用 clinit() 方法之前,父类 clinit() 方法已经执行完毕。
- 由于父类的 clinit() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的赋值操作。
- clinit() 方法对于类或接口并不是必须的,如果没有静态语句块就不需要赋值了。
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口也会生成 clinit() 方法,但接口不需要先执行父类的 clinit() 方法,只有父类接口定义的变量使用时,父类接口才会初始化。
- 多个线程同时区初始化一个类,那么只会有一个线程会执行类的 clinit() 方法,其他线程阻塞等待,直到活动线程执行完毕。
二、类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否 “相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的 “相等”,包括代表类的 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。
三、双亲委派模型
1、类加载器分类
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
从 Java 开发者的角度来看,类加载器的种类可以划分的更细一些。绝大多数 Java 程序都会用到以下三种系统提供的类加载器:
- 启动类加载器(Bootstrap ClassLoader):这个类将器负责将存放在<JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。
- 扩展类加载器(Extension ClassLoader):这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher $App-ClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这 3 种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
2、双亲委派模型
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。双亲委派模型过程:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有个显而易见的好处就是Java类随着它的类加载器一起具备了带有优先级的层次关系,保证 Java 程序稳定运行。
3、打破双亲委派
双亲委派并不是一个强制性的约束模型,而是 Java 设计者提供给开发者的类加载器实现方式。在 Java 世界中绝大多数类加载器都遵循这个模型,但也有例外,到目前位置双亲委派模型总归出现过3次较大规模的 “被破坏” 情况。
- 第一次 “被破坏” 发生在双亲委派模型出现之前,双亲委派在 JDK1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK1.0 就已经存在。面对已经存在的自定义的类加载器实现代码,设计者不得不先前兼容,给 java.lang.ClassLoader 加入一个新的 protected 方法 findClass()。在此之前用户集成 java.lang.ClassLoader 的唯一目的就是重写 loadClass() 方法,因为虚拟机执行类加载器时会调用加载器的私有方法 loadClassInternal(),这个方法的唯一逻辑就是去调用自己的 loadClass() 方法。JDK1.2 之后只需要把类加载逻辑写到 findClass() 方法中,在 loadClass() 方法的逻辑里如果父类加载失败,就会调用 findClass() 来执行加载。
- 第二次 “被破坏” 是由模型自身的缺陷导致的,双亲委派里越基础的类越由上层类加载器进行加载,一般情况下基础类都是被用户代码调用的 API,但是有些情况基础类也会调回用户代码,比如 JDNI、JDBC 等需要加载用户代码的实现类。
- 第三次 “被破坏” 是用户对程序动态性的追求导致的,比如 OSGi 通过给每一个模块一个独立的类加载器来实现 “热部署”。
四、Tomcat类加载机制
1、Tomcat类加载器
当 tomcat 启动时,会创建几种类加载器:
- Bootstrap 引导类加载器,加载 JVM 启动所需的类以及标准扩展的类,位于 jre/lib/ext 下。
- System 系统类加载器,加载 tomcat 启动的类,比如 bootstrap.jar,通常在 catalina.bat 或者 catalina.sh 中指定,位于 CATALINA_HOME/bin 下。
- Common 通用类加载器,加载 tomcat 使用以及应用通用的一些类,位于 CATALINA_HOME/lib 下。
- webapp 应用类加载器,每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib 下的 jar 文件中的 class 和 WEB-INF/classes下的 class 文件。
2、Tomcat类加载过程
当 tomcat 启动应用需要到某个类时,则会按照下面的顺序进行类加载:
- 使用 bootstrap 引导类加载器加载。
- 使用 system 系统类加载器加载。
- 使用 webapp 应用类加载器在 WEB-INF/classes 中加载。
- 使用 webapp 应用类加载器在 WEB-INF/lib 中加载。
- 使用 common 类加载器在 CATALINA_HOME/lib 中加载。
3、tomcat 打破双亲委派模型
从Tomcat的类加载过程中可以看出,它的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个 web 应用的 WebAppClassLoader 类加载器会优先加载,加载不到时再交给 CommonClassLoader 走双亲委托。对于标准类库中的类,会让系统类加载器加载,然后一直委托到启动类加载器,这个过程是没有违背双亲委派的。那么 tomcat 的 WebAppClassLoader 为什么要打破双亲委派模型呢?
- 使用默认加载机制,类加载器只关注全限定类名,无法加载不同应用所依赖的不同版本的相同类库。
- web 容器也有自己的类库,不能和应用程序的类库混淆,需要相互隔离。
- web 容器支持 jsp 文件修改后不用重启,实现热部署功能。