了解Class文件结构和虚拟机的类加载机制
一、了解Class文件的结构规范
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式管理。我们的java虚拟机只识别符合它规范的Class文件,因此在了解JVM其他的相关知识点的时候,我们需要先了解Class文件的结构规范。
Class文件以一组8位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑的排列在Class文件中,中间没有添加任何分隔符。
Class文件个采用伪结构来存储数据,两种数据类型:无符号数和表。
- 无符号数:属于基本的数据类型,已u1、u2、u4、u8来分别表示1个字节、2个字节、4个字节、8个字节,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8 编码构成字符串值。
- 表:由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地 以“_info”结尾
Class文件格式如下图所示:
Class文件的数据项
对Android应用开发者来讲,class文件的数据项以及相关的意义比较多,我们了解一些概率性的东西即可。
魔数与Class文件的版本
魔数用于标识该文件的类型,用四个字节表示.
紧接着模数的Class文件的版本号:Minor Version和Major Version。
常量池
常量池:Class文件之中的资源仓库。常量池中主要存放两大类型常量:字面量和符号引用。
- 字面量:比较接近于java层面的常量概念,比如文本字符串,生命为final的常量值等。
//这里的hello就是字面量
String tem = "hello";
-
符号引用:属于编译原理方面的概率,包括以下三类常量:
1.类和接口的全限定名
2.字段的名称和描述符
3.方法的名称和描述符
虚拟机在加载Class文件的时候进行动态连接,Class文件中不会保存各个方法、字段的最终内存布局信息。当虚拟机运行时,从常量池获得对应的符号引用,在类创建或者运行解析、翻译到具体的内存地址中。
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识 别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类 型;是否定义为abstract类型;如果是类的话,是否被声明为final等
类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集 合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承 关系。
字段表集合
用于描述接口或者类中申明的变量。
描述字段包括以下信息:作用域(public、private、protected)、实例变量还是类变量(static修饰)、可变性(final)、并发可见性(volatile)、字段名称
下面了解一下全限定名、简单名称、描述符:
- 全限定名:org/test/TestClass 类的全限定名
- 简单名称:没有类型和参数修饰的方法或者字段名称。inc()方法和m字段的简单名称分别是inc和m
- 描述符:描述字段的数据类型、方法的参数列表(数量,类型,顺序)和返回值
方法表集合
方法定义,方法里面代码,经过编译器编译为字节码指令之后,存放在方法属性集合 中一个名为“Code”的属性里面。
属性表集合
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍 微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人 实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不 认识的属性
二、虚拟机的类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机使用的类型,这就是虚拟机的类加载机制。
类的加载时机
虚拟机规定有且只有一下5种情况必须立即对类进行初始化:
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初 始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化, 则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父 类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个 类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄 所对应的类没有进行过初始化,则需要先触发其初始化。
类的加载过程
类从加载到虚拟机内存开始,到卸载出内存为止,他的整个生命周期包括:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个 部分统称为连接(Linking)
加载
虚拟机在加载阶段完成以下3件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转化为方法去的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据 的访问入口
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机自身的安全。主要完成下面四个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
准备
准备阶段正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法去中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java 方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方 法之中,所以把value赋值为123的动作将在初始化阶段才会执行
解析
解析阶段是虚拟机将常量池的符号引用替换为直接引用的过程。
初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应 用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化 阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)
初始化阶段其实也是执行类构造器()方法的过程。()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的。
虚拟机在保证子类的()方法执行之前,父类的方法已经执行完成。另外,虚拟机会保证一个类的()方法在多线程环境中被正确的加锁,同步。
类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
双亲委派模型
如果一个类收到加载器的类加载请求,它首先不会自己去尝试这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈 自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自 己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着 它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在 rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加 载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有 使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object 类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
三、Java语法糖
几乎各种语言或多或少的都提供一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是他们或能提高效率或者提升语法的严谨性,减少编码出错的机会。但是我们需要去其糖衣,看清程序代码的真实面目。
泛型与类型擦除
泛型是JDK1.5的一项新增特性,他的本质是参数化类型的应用,也就是说所操作类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
Java语言的泛型只在程序源码中存在,在编译后的字节码文件中,就已经转为原来的原生类型,并且在相应的地方插进入了强制转类型代码 ,因此,对于运行期间的Java语言来说,ArrayList 与ArrayLis就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现成为类型擦除,基于这种方法实现的泛型称为伪泛型。下面我们验证一下类型擦除的效果,源程序如下:
public class Demo {
public static void main(String[] args) {
HashMap<String, String> data = new HashMap<>();
data.put("name", "jack");
data.put("city", "cd");
System.out.println(data.get("name"));
System.out.println(data.get("city"));
}
}
编译成Class后我们用反编译工具查看:
public class Demo {
public Demo() {
}
public static void main(String[] var0) {
HashMap var1 = new HashMap();
var1.put("name", "jack");
var1.put("city", "cd");
System.out.println((String)var1.get("name"));
System.out.println((String)var1.get("city"));
}
}
这里我们可以看出HashMap已经没有了具体的类型,在取出的时候进行了强制转换。
自动装箱、拆箱与遍历循环
从纯技术的角度来讲,自动装箱、自动拆箱与遍历循环(Foreach循环)这些语法糖,无 论是实现上还是思想上都不能和上文介绍的泛型相比,两者的难度和深度都有很大差距。通过下面的代码查看源码和编译后代码的变化,源代码:
public class Demo {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int integer : list) {
sum += integer;
}
System.out.println(sum);
}
}
编译成Class后我们用反编译工具查看:
public class Demo {
public Demo() {
}
public static void main(String[] var0) {
List var1 = Arrays.asList(1, 2, 3, 4);
int var2 = 0;
int var4;
for(Iterator var3 = var1.iterator(); var3.hasNext(); var2 += var4) {
var4 = (Integer)var3.next();
}
System.out.println(var2);
}
}
这里没有看出自动装箱、拆箱的代码,与书上看到不一致,具体原因需要进一步研究。遍历循环用了迭代器进行转成处理。
最后:
Java虚拟机的内容很多也很复杂,上面的这些都是概念性的东西,了解这些内容为了帮助我们了解Java虚拟机的一些知识。如果要做虚拟机方面的性能优化工作,则需要更深入的了解,并且每个点都进行实战测试。
参考文献
深入理解Java虚拟机+JVM高级特性与最佳实践(第二版)
本文地址:https://blog.csdn.net/mrRuby/article/details/112250637