Java 类加载机制和运行原理详解

Java 运行原理详解

  • 前言
    • 阅读对象
    • 目标
  • java 运行流程
    • Java启动分析
      • main方法规范
      • 编辑代码
      • 编译代码
      • 虚拟机类加载机制
      • 类加载器
        • 双亲委派模型
          • 启动类加载器
          • 扩展类加载器
          • 应用程序类加载器
          • 自定义类加载器
          • 双亲委派模型类加载流程
          • 破坏双亲委派模型
          • 破坏双亲委派模型案例
          • 破坏双亲委派模型实战
        • 模块化模型
      • 验证
        • 文件格式验证
        • 元数据验证
        • 字节码验证
        • 符号引用验证
      • 准备
      • 解析
      • 初始化
      • 使用
      • 卸载
  • 总结

前言

阅读对象

本文是博主根据自己经验和查阅资料完成,如有不正确之处欢迎指正。本文主要针对Java语言有一定基础且想提高技术深度的人员。如果你还是一个小白推荐Java入门教程。如果你有以下几个疑问欢迎阅读

  • Java文件(xxx.java)是如何转为字节码文件(xxx.class)?
  • 字节码文件内存结构模型?
  • Java虚拟机是如何加载字节码文件?
  • Java虚拟机是如何执行代码?

目标

帮助读者更全面的理解Java语言从《Java规范》到《JVM规范》的运行原理,让你知其然更知其所以然。让性能优化不再是那么高不可攀。

java 运行流程

我相信所有Java开发人员对Java语言的main()方法并不陌生,它是启动一个Java虚拟机进程的入口方法。下面详细说明执行原理,为了方便读者理解使用如下代码示例来分析。

public class TestJava{
  public static void main(String [] args ){
       System.out.println("hello this is java");
  }
}

执行流程
Java 类加载机制和运行原理详解_第1张图片

Java启动分析

main方法规范

main方法是由虚拟机直接调用,所以main方法受到很多的限制

  • 必须是public修饰
  • 返回值必须为void
  • 必须是静态方法(static 修饰)
  • 必须接收String类型的数组参数agrs

格式如下:

 public static void main(String [] args ){ ... }

编辑代码

通过代码编辑器(idea,eclipse等)根据《Java规范》编写代码,该代码文件称为Java源代码文件TestJava.java。

编译代码

将Java代码源文件TestJava.java通过JVM编译工具javac编译生成字节码文件TestJava.class。从开发者的角度由Java源文件到字节码文件仅仅使用Javac一个命令既完成,看似简单的动作实则完成了很复杂的转换。Javac的编译流程如图2
Java 类加载机制和运行原理详解_第2张图片

在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。那么字节码到底是什么样子的呢?如果有这样的疑问请阅读java字节码内存模型

虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、准备、解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。根据执行流程图可知类加载机制是在虚拟机内部执行,但是加载、校验、准备、解析和初始化是在程序运行期间完成的,这给Java极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方加载一个二进制流作为其程序代码的一部分。这种动态组装应用的方式目前已广泛应用于Java项目中。

类加载器

Java虚拟机类加载是由内部完成,但是类加载器确可以在外部扩展。如图1所示类加载器不同的jdk版本实现有所区别,在1.2之前的类加载器没有严格规范加载模型,1.2~1.8使用了双亲委派模型,1.9开始添加了模块化功能所以将双亲委派模型做了扩展修改。

双亲委派模型

根据图1可知双亲委派模型是由启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)和应用程序类加载器(Application Class Loader)组成。通常开发人员可根据业务需求基于应用程序类加载器完成自定义类加载器。除了启动类加载器外其他加载必须存在上级类加载器,也可以通俗的理解为启动类加载器是顶级加载器。

启动类加载器

启动类加载器负责加载JAVA_HOME\lib目录或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。该类加载器是由虚拟机内部实现由c++编写。

扩展类加载器

扩展类加载器负责加载JAVA_HOME\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。该类加载是由Java代码编写sun.misc.Launcher$ExtClassLoader,所以开发人员直接可以扩展该类来自定义类加载器。

应用程序类加载器

应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果程序中没有使用自定义类加载器默认使用该加载器完成类加载。

自定义类加载器

自定义类加载器通常是基于应用程序类加载来完成。主要用来个性化加载类,比如网络加载类,特定路径加载类和破坏双亲委派模型等。

双亲委派模型类加载流程

为了更清楚的描述这里用TestJava.class字节文件来举例(其他类加载流程也是一样),TestJava.class是通过自定义类加载器完成加载,加载流程如下:

  1. 自定义类加载器加载TestJava类时,先委派给上级的应用程序类加载器加载类
  2. 应用程序类加载器加载TestJava类时,先委派给上级的扩展类加载器加载类
  3. 扩展类加载器加载TestJava类时,先委派给上级的启动类加载器加载类
  4. 启动类加载加载TestJava类时,没有找到TestJava文件反向委派给下级扩展类加载器加载类,反之加载完成
  5. 扩展类加载器实现上级委派加载类时,没有找到TestJava文件反向委派给下级应用程序类加载器加载类,反之加载完成
  6. 应用程序类加载器实现上级委派加载类时,没有找到TestJava文件反向委派给下级自定义类加载器加载类,反之加载完成
  7. 自定义类加载器实现上级委派加载类时,没有找到TestJava文件直接抛出未找到类异常,反之加载完成

在Java中是如何实现双亲委派模型?我们通过ClassLoader源码来分析,ClassLoader是一个抽象类,我们通过几个核心方法来分析

package java.lang;
public abstract class ClassLoader {

    // 通过全类名加载类
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    // 双亲委派模型加载类
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    
        // 获取类加载锁,同步加载类
        synchronized (getClassLoadingLock(name)) {
     
            // 通过全类名查询,该类是否已加载
            Class<?> c = findLoadedClass(name);
             
            // 如果该类没有被加载过,加载该类
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 委派给上级类加载器加载类
                        c = parent.loadClass(name, false);
                    } else {
                        // 由启动类加载器加载类
                        c = findBootstrapClassOrNull(name);
                    }
                   // 抛出ClassNotFoundException说明上级加载器没有找到类字节流
                } 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;
        }
    }

    // 执行启动类加载器加载类
    private Class<?> findBootstrapClassOrNull(String name){
        if (!checkName(name)) return null;
        return findBootstrapClass(name);
    }

    // 调用虚拟机启动类加载器加载类
    private native Class<?> findBootstrapClass(String name);
    
    // 当前类加载器加载的加载逻辑由子类来自定义实现,所有我们自定义实现类加载器时一般通过重写这个方法来完成个性化业务需求
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

}

由以上代码可知,只要我们重写loadClass(String name)方法即可破坏或扩展双亲委派模型。双亲委派模型严格规定不同类加载器加载不同的类,简单而强大。双亲委派模型是官方在JDK1.2到JDK1.8推荐使用的类加载模型,并没有强制约束使用

破坏双亲委派模型

通常双亲委派模型能完美的解决类加载,但是在特殊场景下该模型无法满足我们的需求,直到JDK1.9为止java出现过了3次重大的破坏情况。本文中“破坏”不是贬义,指的是不遵循双亲委派模型加载类的方式。

  1. 第一次破坏(兼容

双亲委派模型是jdk1.2才正式引入,java官方为了能兼容1.2之前版本,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。

  1. 第二次破坏(spi 类加载器的实现)

当调用底层基类需要加载自定义实现类实现时类加载顺序刚好和双亲委派模型相反。Java中涉及SPI的加载基本上都采用这种方式来加载类,例如JNDI、 JDBC、JCE、JAXB和JBI等。使得Java设计者不得不采用一些不优雅的手段来破坏双亲委派模型:

  1. 线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
  2. 服务类加载器 (Service Loader)该加载器是在jdk1.6引入java.util.ServiceLoader,以META-INF/services中的配置信息辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
  1. 第三次破坏(动态“热”加载类的实现)

随着时间的推移开发者们对程序动态加载类的追求从传统的静态部署升级到了动态热部署,代码热替换(Hot Swap)、模块热部署(Hot Deployment)等而导致了双亲委派模型的破坏。

破坏双亲委派模型案例

主流的Java Web服务器,如Tomcat、Jetty、WebLogic等都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决如下的这些问题:

同一个服务器上部署的多个web应用程序可以共享一个Java类库。

web服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。

同一个web服务器中不同web应用程序类库独立。

破坏双亲委派模型实战

通过上面我们可知,jdk中实现双亲委派模型是通过ClassLoader 来实现的,我们结合自己的业务需要破坏双亲委派模型时可按如下步骤实现:

  1. 自定义一个类加载器继承于ClassLoader
  2. 重写loadClass方法来打破双亲委派模型
  3. 重新findClass方法来实现定义加载类路径
模块化模型

JDK1.9开始引入了模块化功能,使得传统的双亲委派模型无法满足而演化为复杂的模块化模型,根据图1可知该模型比较复杂,本文暂不详细讨论。

根据图1可知,类加载器完成加载后所有步骤都在虚拟机内部完成。之后章节我们主要讨论由字节码转为Java虚拟机内存结构最终生成Java对象的过程。在了解这过程之前读者必须知道Java虚拟机内存模型,如果读者还对Java虚拟机内存模型概念模糊欢迎阅读博主另一篇博文Java虚拟机内存模型

验证

显然这个步骤是对字节流安全检查,保证字节流满足Java虚拟机运行规范。细心的朋友会发现其实在Java编译阶段已经做严格的语法验证,那么Java虚拟机内部是不是就没有必要了呢?答案是验证必须的,Java虚拟机不仅可运行Java程序,只要满足虚拟机规范的语言都可运行如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等。加载字节码过程可能对字节码进行破坏或者字节文件通过手动编写等,防止加载不规范的字节码文件导致虚拟机无法运行。验证阶段大体做4个验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证,具体验证内容在《Java虚 拟机规范(Java SE 7版)》中有130页的描述,如果读者想了解具体的验证项请移步Java虚拟机规范。

文件格式验证

第一阶段,验证当前字节流是否满足标准的Class文件规范,只有验证通过,二进制字节流才能进入Java虚拟机,且以描述一个标准Java类型的数据信息保存在Java内存的方法区内,其他验证都是基于这个内存数据来进行。下面列出几项:

  • 是否以固定魔法数0xCAFEBABE开头
  • 主、次版本号是否满足当前虚拟机运行范围
  • 常量池索引是否有指向不存在的引用或不符合类型的常量
元数据验证

第二阶段,对元数据的语义验证,保证字节码描述的语义和规范语义一致。下面列出几项:

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

第三阶段,在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析。保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。下面列出几项:

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

第四阶段,该阶段是在[解析]时,虚拟机将符号引用转为直接引用过程,校验符号引用匹配性。下面列出几项:

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

准备

准备阶段是将Java类的静态变量(被 static修饰的类变量)分配内存且赋初始值。这里需要特别说明赋初始值不是赋值

public static int value = 18;

这时 value 的初始值为0,而不是18,赋值18是在[初始化]才发生。如果变量为静态常量(被static final修饰的类变量)

public static final int value= 18;

这时由于编译时Javac将会为value生成ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,即age初始值为18。基本数据类型的初始值如下表:

类型 初始
int 0
long 0L
short (short)0
char ‘\u000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
refrence null

解析

解析过程是将编译阶段生成的符号应用解析为直接引用。通俗讲是将java类信息和虚拟机内存进行映射。

  • 符号引用
    用来标识或者是描述java类的信息可以是任何字面量的符号,和虚拟机内存没有任何关系。

  • 直接引用
    直接引用是直接指向目标的指针偏移量或者是能间接定位到目标的句柄,和虚拟机息息相关,不同的虚拟机在实现上可能存在差异。

初始化

初始化阶段是类加载过程的最后一个步骤。这个一步骤也才是真正执行我们编写java代码。准备步骤是为java静态变量分配内存空间和赋初始值,而初始化阶段就是我们编写代码时客观初始化的各种资源。完成这一个步骤我们才能真正使用一个类。

使用

这个一步顾名思义,我们能正常使用一个java对象属性和方法。

卸载

一个对象使用完成,最后由垃圾回收器回收的过程。

总结

本文主要讲述了java语言如何在虚拟机中运行,从.java文件 ==> .class文件 ===> 虚拟机内存的执行过程。本文对初级开发者没有多大的意义,读完全文发现除了类加载器其他部分与我们日常开发关系不大,大部分都是虚拟机和编译器帮我们完成。但如果你是一个高级开发人员或者编程爱好者希望本文能给你思考,比如java语言的高级特性,自动装箱,自动拆箱、泛型和注解等其实是在编译阶段转为普通代码而诞生的。同时给你学习虚拟机内存模型和java线程模型奠定了基础。


博主能力有限,不合理之处请留言。

参考文献《深入了解Java虚拟机》周志明 第3版
原创不易,转载请标明来源

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