最近看了《深入理解Java虚拟机》这本书,感觉书中的章节写的很零散,如果能够通过一个完整的例子将所有的知识点串联起来,将整个故事讲清楚,无疑对Java虚拟机运作原理的学习有更好的帮助,本文之所以称为自顶向下的Java虚拟机是受《计算机网络:自顶向下方法》启发,想要从上层开始讲起,然后逐步了解这些我们习以为常背后Java虚拟机所做的工作,以期这篇总结能够让Java虚拟机运作的脉络更加清晰。
在我们写完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虚拟机使用类加载器来显现类加载的加载动作,类加载器使用双亲委派模型,一句话来解释双亲委派模型就是:只有当父类加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。下面的图解释了这个模型。
类加载使得Java程序做好了正式运行Java程序的准备工作,接下来就是如何运行这些已经读入Java虚拟机的字节码了,在这之前,我们有必要知道Java虚拟机的运行时数据区域,这些区域的作用解释了Java虚拟机是如果分配空间,执行字节码的,从这个角度来说,Java虚拟机就是一个小型的操作系统。
所以完整的故事是这样的,在我们调用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字节码指令的执行
你同样可以在我的博客看到这篇文章, 欢迎批评指正!