一个Java程序的一生(编译-加载-执行)

2 篇文章 0 订阅

前言

学习Java也不短的日子了,总想写些东西纪录下学习的过程,不如就从Java程序的一生开始谈起吧。

Java程序的一生

一个Java程序,从被我们编辑的.java文件,到执行程序,完成我们想要的功能。这中间发生了哪些有趣的事情呢
,让我们来一探究竟!
简单来说可以分为以下几个过程:

  • 编写源代码即.java文件
  • 编译.java文件生成字节码.class文件
  • jvm虚拟机通过类加载器加载.class文件
  • jvm通过执行引擎找到main()方法入口,执行代码

编译字节码

java程序是运行在jvm上的(使得可以实现java一次编译,到处执行的特性,具体可参考JVM与java字节码),因此java文件会先被编译器编译成字节码.class文件,然后通过jvm解释器,将字节码文件翻译为机器可以直接执行的机器码。
字节码的格式可参考下图:
在这里插入图片描述下面对其进行简单解释:

  • magic:魔数,为指定的数字,用来表示class文件,所有的字节码文件都是以该数开头,占4个字节。
  • minor_version/major_version:见名知意,分别表示java的次/主版本号
  • constant_pool_count:表示常量池的常量个数
  • constant_pool:常量池信息,用于存储常量信息,其由若干个常量池结构cp_info(据jvm虚拟机规范,一共有14中cp_info)组成
  • access_flags:访问标记,用于识别一些类或接口层次的访问信息,比如记录这个class是类还是接口还是枚举?定义为public、abstract?
  • this_class:类索引,用于确定这个类的全限定名,比如其指向常量池中的某个常量来表示类名
  • super_class:父类索引,与类索引类似,其用来确定父类的全限定名
  • interface_count:接口计数器,用于记录类实现接口的索引表容量
  • interfaces:接口类容,用于记录类实现接口的内容
  • field_count:字段表字段数量
  • fields:字段表集合,用于描述接口或者类中声明的变量,注意其包括类级变量和实例级变量但不包括在方法内部声明的局部变量。字段表的每个字段用一个名为 field_info 的表结构来表示
  • methods_count:记录方法表中方法个数
  • methods:方法表集合,记录接口或者类中的方法信息,方法表中的每个方法都用一个 method_info 表示
  • attributes_count:属性表数量
  • attrbutes:属性表,其记录的是类的属性,与方法中的属性信息是不同的,就像类成员变量与局部变量的区别差不多

简单地说,字节码的按结构可参考下图:
在这里插入图片描述

类加载

字节码文件描述了类的数据,而要想被jvm虚拟机直接使用运行,需要把.class的字节码文件加载到内存中,并对数据进行校验、转换解析与初始化,这就是虚拟机类加载所干的事情。

什么时候进行类加载呢?

我们知道需要进行类加载,来让虚拟机执行,那么什么时候进行类加载呢,一般在下面几种时间点进行类加载:

  • 在new操作、get/put static字段(被final修饰且已在编译时把结果放到常量池的静态字段除外)以及invoke static (调用类的静态方法)时,如果此时对应的类没有进行初始化,则需要对相应的类进行初始化
  • 初始化一个类的时候发现类的父类没有初始化,会先触发父类初始化(类加载过程之一)
  • 使用java.lang.reflect包方法时对类进行反射调用时也会触发类加载
  • 当虚拟机开始启动时,用户需要指定一个主类(main),虚拟机会先执行这个主类的初始化

类加载的过程

类的生命周期可分为5个阶段,加载、连接、初始化、使用、卸载。类的加载过程可分为加载、连接、初始化三个步骤。而且连接阶段又可分为三个步骤:验证、准备、解析。
在这里插入图片描述

类加载

简单的说类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象(规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中)用来封装类在方法区内的数据结构

加载.class文件的方式可分为以下几种:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件
加载的过程
连接

类的连接分为以下三个过程:

  • 验证:执行各种i按察,确保类的正确性。(比较重要且比较耗时,但不一定必要,可使用jvm参数-Xverify:none来关闭)
  • 准备:为类的静态变量分配内存,设置默认值,但是到达初始化之前,类的静态变量都没有初始化为真正的初始值,在jdk1.7之前在方法区,1.7之后再堆
  • 解析:解析过程就是在类型的常量池中寻找类、接口、字段和方法的符号引用,把这些符号引用转换为直接引用的过程
验证

验证的目的时为了保证加载后的二进制字节流的信息符合虚拟机的规范,并没有安全问题。java语言的安全性主要时通过编译器来保证的,但是编译器和虚拟机是两个独立的东西,虚拟机只认识二进制字节流,不会管所获的二进制流从哪里来。当让编译器给它的那么就是相对安全的,但如果是从其他路径获的,就无法保证二进制字节流是安全的。
验证的果真可以分为以下几步:

  1. 格式检查:进行魔数验证、版本检查、长度检查等
  2. 语义检查:检查是否继承final、是否有父类、是否实现了抽象方法
  3. 字节码验证:检查跳转指令是否指向了正确的位、操作数的类型是否合理
  4. 符号引用验证:检查符号引用的直接引用是否存在

其中文件格式验证阶段都是基于方法区的存储结构进行,只有通过本阶段的验证,才允许被放到方法区存储。后面的三个验证阶段都是基于方法区的存储结构进行,不会再直接操作字节流。

准备

类的准备阶段主要来为类变量(static修饰的字段变量)分配内存,并且设置该类型变量的初始值即零值,这个操作不包括final修饰的static变量,因为final修饰的在编译的时候就会分配好,同时也不包括实例变量,因为类变量会分配在方法区中,而实例变量是会随着对象一起分配的java堆中。

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

  1. 为已在方法区中的类的静态变量分配内存
  2. 为静态变量设置初始值
    此外,准备阶段还会构建方法表,这个方法表是用来解决动态绑定的问题的,解析的时候通过这个方法表,根据实际类型来解析获取对应的方法。
解析

解析阶段就是要把常量池中的类、字段、方法的符号引用转换为可以直接使用的直接引用。
例如一个类的解析就是通过类名查找类,并保存这个类的class对象的引用,有了class对象的直接引用才能使用这个类。虚拟机规范并没有规定符号引用的解析在何时执行,只要在使用之前解析完成就行。我这里实现是把静态解析放在了类加载的时候,方法动态链接放在运行时,如果像书中一样都放在运行时解析也是可以的。

初始化

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

值得注意的是:此步骤中虚拟机会保证在多线程环境中一个类的方法被正确的加锁。

类的初始化顺序,正常类的加载初始化顺序为:静态变量/静态代码块 -> main方法 -> 非静态变量/代码块 -> 构造方法
说明:静态代码块与静态变量的执行顺序同代码定义的顺序;非静态变量与代码块的执行顺序同代码执行顺序
那么在存在继承的情况下类的加载顺序是怎样的呢?经过实践,结果如下:
父类–静态变量/父类–静态初始化块
子类–静态变量/子类–静态初始化块
父类–变量/父类–初始化块
父类–构造器
子类–变量/子类–初始化块
子类–构造器

由此得到以下结论:

  • 子类的静态变量和静态初始化块的初始化是在父类的变量、初始化块和构造器初始化之前就完成了
  • 静态变量、静态初始化块顺序取决于它们在类中出现的先后顺序
  • 变量、初始化块初始化顺序取决于它们在类中出现的先后顺序

类加载器

所谓的类加载器(Class Loader)就是加载.class文件到JVM中的实现。

类加载器分类

去下图,在jvm中有三种类加载器:启动类(也叫根类)加载器(BootStrap ClassLoader)、扩展类加载器(ExtClassLoader)、应用类加载器(AppClassLoader)
在这里插入图片描述
启动类加载器:这个加载器不是一个Java类,其底层有C++实现,负责将存在JAVA_HOME下lib目录中的类库,比如rt.jar,进行加载。因为启动类加载器不属于Java类,所以无法被Java程序直接引用,用户在编译自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。

扩展类加载器:由sun.misc.Launcher$ExtClassLoader实现,负责加载JAVA_HOME下lib\ext下的类,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器

应用类加载器:由sun.misc.Launcher$AppClassLoader实现的。由于这个类加载器时ClassLoader中的getSystemClassLoader方法的返回值,所以也叫系统类加载器。负责加载用户类路径上所指定的类,可以被直接使用。如果没有自定义类加载器,那么默认为此类加载器。

此外还有自定义加载器(User ClassLoader)这是由用户自己定义的类加载器,一般情况下我们不会自定义类加载器,但有些特殊情况,比如JDBC能够通过连接各种不同的数据库就是自定义类加载器来实现的。

双亲委派模型

除启动类加载器外,扩展类加载器和应用类加载器都是通过类sun.misc.Launcher进行初始化,而Launcher类则由根类加载器进行加载

双亲委派模型

想一想如果有不法分子在你项目中构造了一个java.lang.String类,并在该类中植入了一些不良代码,但你自己浑然不知,以为使用的String类还是 rt.jar 包下的,那可能会给你系统造成不良的影响。

聪明的Java虚拟机实现者也想到了这个问题,于是,他们引入了 双亲委派模型来解决这个问题。
在这里插入图片描述
如上图,简单来说:双亲委派机制就是说如果一个类收到了类加载请求,他首先不会自己尝试区加载这个类,把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求都会被发送给顶层的启动类加载器中,只有父类加载器反馈无法完成这个加载请求(它的作用范围没有找到这个类),子加载器才会尝试自己去加载

其实,这里叫双亲委派可能有点不妥,因为按道理来讲只有父加载器,这里的“双亲”是“parents”的直译,并不表示汉语中的父母双亲。另外,这里的父加载器也不是继承的关系。

回到上面提出的问题,如果你自定义了一个 java.lang.String类,你会发现这个自定义的String.java可以正常编译,但是永远无法被加载运行。因为加载这个类的加载器,会一层一层的往上推,最终由启动类加载器来加载,而启动类加载的会是源码包下的String类,不是你自定义的String类。

所起双亲委派机制的作用有以下两点:
1)避免类的重复加载
2)保护程序安全,防止核心API被随意篡改

执行引擎

字节码执行引擎的作用

字节码执行引擎是字节码文件执行的一种概念模型,将输入的字节码文件进行解析处理来得到程序的执行结果。与方法调用、方法执行息息相关。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在这里插入图片描述

局部变量表
  • 定义:局部变量表是一组变量值存储空间
  • 作用:存放方法参数和方法内部定义的局部变量
  • 分配时期:Java 程序编译为 Class文件时,会在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量
  • 最小单位:变量槽
大小:虚拟机规范中没有明确指明一个变量槽占用的内存空间大小,允许变量槽长度随着处理器、操作系统或虚拟机的不同而发生变化
对于 32 位以内的数据类型(boolean、byte、char、short、int、float、reference、returnAddress ),虚拟机会为其分配一个变量槽空间
对于 64 位的数据类型(long、double ),虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间

特点:可重用。为了尽可能节省栈帧空间,若当前字节码 PC 计数器的值已超出了某个变量的作用域,则该变量对应的变量槽可交给其他变量使用
  • 访问方式:通过索引定位。索引值的范围是从 0 开始至局部变量表最大的变量槽数量
  • 局部变量表第一项是名为 this的一个当前类引用,它指向堆中当前对象的引用(由反编译得到的局部变量表可知)
    在这里插入图片描述
操作数栈
  • 定义:操作数栈是一个后入先出栈 作用:在方法执行过程中,写入(进栈)和提取(出栈)各种字节码指令
  • 分配时期:同上,在编译时会在方法的Code 属性的 max_stacks 数据项中确定操作数栈的最大深度
  • 栈容量:操作数栈的每一个元素可以是任意的 Java 数据类型 ——32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2

注意:操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译时编译器需要验证一次、在类校验阶段的数据流分析中还要再次验证

动态链接
  • 定义:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
  • 静态解析和动态连接区别:
Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用:
1.一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态解析)
2.另一部分会在每一次运行期间转化为直接引用(动态连接)
方法返回地址

方法退出的两种方式:

  • 正常退出:执行中遇到任意一个方法返回的字节码指令
  • 异常退出:执行中遇到异常、且在本方法的异常表中没有搜索到匹配的异常处理器区处理

作用:在方法返回时都可能在栈帧中保存一些信息,用于恢复上层方法调用者的执行状态

  • 正常退出时,调用者的 PC 计数器的值可以作为返回地址
  • 异常退出时,通过异常处理器表来确定返回地址

方法退出的执行操作:

  • 恢复上层方法的局部变量表和操作数栈
  • 若有返回值把它压入调用者栈帧的操作数栈中
  • 调整 PC 计数器的值以指向方法调用指令后面的一条指令等
方法调用

方法调用即指确认调用哪个方法的过程,并不是指执行方法的过程。Java 的编译并不包含传统编译过程中的连接步骤,所以在 .java 代码编译成 .class 文件之后,在 .class 文件中存储的是方法的符号引用(方法在常量池中的符号),并不是方法的直接引用(方法在内存布局中的入口地址),所以需要在加载或运行阶段才会确认目标方法的直接引用。

解析调用

有几种方法的调用,在加载阶段就可以确认该方法的直接引用,前提是:方法在程序真正运行之前就有一个可确定的调用版本(调用哪一个方法),并且这个方法的调用版本在运行期是不可变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
有四种方法是进行的方法的解析:静态方法、私有方法、实例构造器、父类方法,这四类方法称为非虚方法,与之对应的就是续方法(final 方法除外),调用这四类方法的字节码指令是:invokestatic、invokespecial 指令,也就是说被 invokestatic、invokespecial 字节码调用的方法,在类加载的解析阶段就可以通过方法的符号引用确认方法的直接引用。在 Java 字节码中,还有几种调用方法的字节码指令如下:
invokestatic:调用静态方法
invokespecial:调用实例构造器方法、私有方法、父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时确认一个实现此接口的对象
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。被 final 关键字修饰的方法,在字节码中是被 invokevirtual 指令调用的,但是被 final 修饰的方法无法被重载或重写,所以只有一个方法,在加载阶段就可以确认调用哪个方法,所以也是一种虚方法,方法调用时走的也是解析流程。

分派调用

解析调用是一个静态的过程,在加载阶段就可以确认目标方法的直接引用。分派调用有可能是静态的,也有可能是动态的,根据分派的宗量数又可以分为单分派和多分派,这两类两两组合,所以分派共可以细分为:静态单分派、静态多分派、动态单分派、动态多分派。
在讲解本节中的分派的过程中,会揭示一些 Java 中的多态性在 Java 虚拟机层面的基本体现,如“重载”和“重写”在 Java 虚拟机中是如何实现的。
静态分配

依赖静态类型来定位方法的执行版本
典型应用是方法重载
发生在编译阶段,不由 JVM 来执行

动态分派

依赖动态类型来定位方法的执行版本
典型应用是方法重写
发生在运行阶段,由 JVM 来执行

单分派

根据一个宗量对目标方法进行选择(方法的接受者与方法的参数统称为方法的宗量)

多分派

根据多于一个宗量对目标方法进行选择

总结

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值