自顶向下的Java虚拟机

1,047 阅读10分钟

最近看了《深入理解Java虚拟机》这本书,感觉书中的章节写的很零散,如果能够通过一个完整的例子将所有的知识点串联起来,将整个故事讲清楚,无疑对Java虚拟机运作原理的学习有更好的帮助,本文之所以称为自上而下的Java虚拟机是受《计算机网络:自顶向下方法》启发,想要从上层开始讲起,然后逐步了解这些我们习以为常背后Java虚拟机所做的工作,以期这篇总结能够让Java虚拟机运作的脉络更加清晰。

将Java程序编译成Class文件

在我们写完Java程序后,我们需要利用Javac命令将Java文件编译成Class文件,也就是字节码文件,字节码文件包含Java虚拟机能够执行的指令。Java是面向对象的语言,每个类都会被编译成一个Class文件,这个文件必须符合一定的格式才能够被Java虚拟机所加载。一个Class文件会包含一下几个域:

  • 魔数与版本信息
  • 常量池,分为字面量和符号变量两种,从内容上来说包含了:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符
  • 类的访问标识符
  • 字段表集合,包含元数据信息和属性表(常量时会用到,ConstantValue)
  • 方法表集合,包含元数据信息和属性表(Code属性)
  • 属性表集合
    • Code属性,最重要的一个属性,里面存储了方法编译后字节码指令,并且含有最大操作栈深度,最大局部变量表空间,这些都是在编译后决定的
    • ConstantValue属性,用于存储final关键字定义的常量值
    • 还有其他的如Exceptions异常表等等

加载将要运行的类

在编译Java文件生成Class文件后,要想使用Java命令运行刚刚生成的Java程序的main函数,Java虚拟机首先要加载要执行main函数的主类,Java虚拟机加载一个类的过程5个阶段:加载,验证,准备,解析和初始化。每个阶段都有不同的目的,整个类加载的总的目的我总结为一句话:加载Class文件到Java虚拟机的方法区,并且做必要的安全验证和初始化工作。下面列出了类加载5个阶段的主要目的。

  • 加载 加载的主要目的是通过类的全限定名获取类的二进制流,并将二进制流转换成Java虚拟机方法区的运行时数据结构,生成java.langClass对象(方法区中)作为该类各种数据的访问接口。
  • 验证 验证的主要目的是为了确保Class文件的字节流包含的信息符合虚拟机的要求
  • 准备 准备的主要目的是类变量分配内存并设置初始值,也就是给static的变量设置初始值,例如static int类型的类变量会在方法区初始化为0
  • 解析 解析的主要目的是将常量池的符号引用转换成直接引用的过程
  • 初始化 初始化阶段的目的是执行类构造器的 方法, 方法是为将执行程序中类变量的赋值动作和静态语句块,在执行当前类的初始化之前,Java虚拟机会先初始化其父类,所以这个过程可能会出发新的类加载过程。

解释完类加载的过程,我们还缺少两个问题的答案,一个是什么时候会触发类加载的过程,另一个是类加载的过程是由谁来实现的,下面我们来回答这两个问题。
第一个问题其实我们已经知道了两种情况,一种是在执行主类main函数的时候,主类会被加载,另一种是在类加载的初始化阶段,当当前加载的类有父类的时候,会出发父类的类加载过程,除了这两种情况,还有一下几种情况会出发类加载过程

  • 遇到new,getstatic,putstatic,invokestatic指令时
  • 使用java.lang.reflect包的方法对类的反射调用时

其他的被动引用不会引发类的初始化,典型的有

  • 通过子类来引用父类的静态字段只会出发父类的初始化,不会出发子类的初始化
  • new 数组并不会出发对应类的初始化过程
  • 引用类的final常量也不会引发初始化

第二个问题类的加载过程是由谁来实现的,Java虚拟机使用类加载器来显现类加载的加载动作,类加载器使用双亲委派模型,一句话来解释双亲委派模型就是:只有当父类加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。下面的图解释了这个模型。



图1:类加载器双亲委派模型

开始运行Java程序

类加载使得Java程序做好了正式运行Java程序的准备工作,接下来就是如何运行这些已经读入Java虚拟机的字节码了,在这之前,我们有必要知道Java虚拟机的运行时数据区域,这些区域的作用解释了Java虚拟机是如果分配空间,执行字节码的,从这个角度来说,Java虚拟机就是一个小型的操作系统。



图2:Java虚拟机运行时数据区域划分

如图2显示了Java虚拟机在运行时的数据区域划分,类加载子系统的加载步骤将Class文件的加载到了Java虚拟机的方法区,并且生成了java.lang.Class对象,所以方法区是用来存储编译后的字节码,包括类信息,常量,静态变量,编译后的代码数据。方法区是所有线程所共享的,这个区域也被称为永久代,所以这里面的数据的生命周期和应用程序的生命周期一样,如果出现过大过多的静态变量,常量,这个区域会出现内存溢出的异常。
堆是存放对象实例的地方,是所有线程所共享的,同时也是垃圾回收的主要区域
虚拟机栈 虚拟机栈是用来支持Java方法调用的内存模型,它是线程私有的内存区域。每个方法在执行的同时都会在虚拟机栈中创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用至执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈到出栈的过程。Java虚拟机栈会在之后详细介绍。
本地方法栈 本地方法栈执行Native方法时候使用
程序计数器 程序计数器指向下一条字节码的行号,用来实现跳转。

运行时栈帧结构



图3:运行时栈帧结构

如图3所示,虚拟机栈包含了n个栈帧,每个栈帧对应着一个方法,栈顶对应着当前正在执行的方法,在一个栈帧中含有4个主要的域:局部变量表,操作栈,动态链接,返回地址。
  • 局部变量表,用于存放方法参数和方法内部定义的局部变量
  • 操作数栈,用于字节码指令操作数的写入和提取,例如iadd指令运行时会弹出操作数栈的两个数相加,并把结果压入栈中
  • 动态连接,用于支持方法调用过程中的动态连接
  • 方法返回地址
    所以完整的故事是这样的,在我们调用Java命令运行Java主类的main函数导致Java主类被加载到Java虚拟机的方法区,然后Java虚拟机栈中装入Java main函数的栈帧,程序计数器记录main函数的第一行指令,根据指令进行操作数栈压栈操作或者分配本地变量表,将指令交给JVM执行引擎执行相应的操作,或者遇到其他的方法调用,引入更多的方法栈帧,重复以上操作。

另外还有一个问题,对于方法调用,当遇到invokevirtual指令时有个分派的问题,分派有两种分派方法静态分派和动态分派,静态分派的典型例子是重载,在Parent-Sub关系的方法参数上,如果Parent p=new Sub()的情况,method(p)只会根据Parent类型进行方法调用。动态分派的典型例子是重写,如果在p.method()的情况只会根据p的实际类型进行方法调用。

垃圾回收

Java和C++之间最大的差别是Java有自动的垃圾回收机制,理解Java的垃圾回收机制只需要回答一下三个问题。

  • 哪些内存需要回收?

Java垃圾回收的主要区域是堆区,主流的实现中是通过可达性分析来判断对象是否存活,搜索的路径是通过GC Roots对象作为起始点,GC Roots包括虚拟机栈中引用的对象,方法去中类静态属性引用的对象,常量引用的对象。

  • 什么时候进行垃圾回收?新生代没有空间的时候将会发生GC,新生代的GC称为Minor GC,在15次没有被回收的对象移到老年代,如果老年代也没有足够的空间容纳对象,将会发起一次Full GC.

  • 采用什么方法回收垃圾对象?

有四种常用算法回收垃圾对象:

  • 标记清除算法
  • 复制算法
  • 标记整理算法
  • 分代收集算法,将堆划分为新生代和老年代,新生代一般使用复制算法,老年代使用标记清除算法或者标记整理算法

对象的创建和内存布局

对象的创建

当执行new指令创建新的对象时,会依次做如下步骤

  1. 检查类是否被加载到方法区中,如果没有被加载,执行类的加载过程
  2. 在堆中为新生对象分配内存,用指针碰撞或者空闲列表方式
  3. 初始化内存为零值,设置对象头信息
  4. 执行 构造方法

对象的内存分布

对象在内存中分为3块:对象头、实例数据和对齐填充
对象头存储对象自身的运行时数据,例如哈希码,GC分布年龄等等。可能会包含类型指针,指向类元数据。
实例数据包含对象的实例字段等等。

参考文章

漫谈 JVM 内存分代、垃圾回收
深入理解Java运行时数据区
Java对象在Java虚拟机中的创建过程
Java虚拟机 :Java字节码指令的执行


坚持原创技术分享,您的支持将鼓励我继续创作!