以下内容都是我自己对本书读后的一个理解,感觉总结一下理解的更深刻,如果能帮助到别人就更好了,可能会有些内容说的不正确,如果发现了,希望帮忙留言指正,谢谢。
背景
计算机的种类很多,硬件也都不太一样。比如Windows,Android,iOS等,而具体到某一个平台,也分32位,64位等。想要编写一个适用于众多平台的软件比较困难,要考虑到各个平台的兼容性。Java虚拟机就提供了这么一个平台,让开发者只关心程序的功能开发,兼容的工作就交给虚拟机即可。Java虚拟机做到了一次编译,到处运行。
在JVM上跑一个程序的大概过程
基于JVM的语言除了Java还有Groovy,JRuby,Scala,Fantom,Jython等。JVM支持的只有一种文件格式:.class文件。基于JVM的语言,不管语法是什么样的,脱去漂亮的衣服,最终都要编译成.class文件格式,才能在JVM虚拟机上运行。
.class文件结构
.class文件结构是固定这样的,ux就代表x个字节的内容,.class文件结构中只有两种数据内容:无符号数和表,表可以认为是一个数据结构,表中还是无符号数和表。
- magic,代表该文件是个class文件
- mijor_version,major_version,版本号
- constant_pool_count,常量池中常量个数
-
cp_info,常量池,常量池中的常量个数是constant_pool_count-1个,0号常量被空了出来。
常量池中类型:字面量和符号引用。字面量是基础类型,字符串,整型,浮点型这些。符号引用就是各字段(类的全局变量),方法的名称及描述符,类和接口的全限定名,是以引用其他常量的方式存在,最终都会引用到字面量常量上去。
常量池项目类型:
常量池常量项的结构列表:
举个例子:
//TestClass.java
package com.ejushang.TestClass;
public class TestClass implements Super{
private static final int staticVar = 0;
private int instanceVar=0;
public int instanceMethod(int param){
return param+1;
}
}
interface Super{ }
该Java文件编译为class文件后:
我在"'深入理解Java虚拟机'2018-04-16"这个笔记中,手动将每一项内容都进行了分析,这里就直接用Javap -verbose TestClass输出常量表
ConstantPool就是常量池,索引对应的是常量池的第几个常量。
-
access_flags,访问标志,表示是类还是接口,public,abstract等等
- this_class,super_class,interfaces_count,interfaces,这几项确定了类的继承关系
-
fields_count,fields,字段表,包含属性表
-
methods_count,methods,方法表,包含有属性表
-
attributes_count,attributes,属性表
Code属性:
Java程序方法体中的代码经过编译,转换成了字节码指令存储在Code属性中。Code属性中max_stack代表操作数栈深度,max_locals代表方法中局部变量所占Slot之和,Slot是虚拟机为局部变量分配内存使用的最小单位。
code存储的就是code_length个字节码指令,字节码指令表示你使用代码要做的操作,比如new,+等等,字节码指令可以跟随参数,如果需要参数,字节码后面跟随的应该就是它的参数。
类加载
类从被加载到虚拟机内存中开始,到卸载出内存为止,生命周期包括:加载,验证,准备,解析,初始化,使用和卸载。
什么情况下类被加载?由虚拟机来自由把握。
- 加载,通过类的全限定名获取到对应的clas文件,然后将类信息加载到JVM方法区,再堆中实例化一个Class对象,作为方法区中该类的入口
- 验证,确认类型符合Java语言的语义,并且不会危及JVM的完整性
- 准备,为类变量(Static变量)分配内存,设置初值(通过内存清零实现,此阶段不执行Java代码),final修饰的常量初始值就是Java代码中的初始值
- 解析,在类的常量池中寻找类,接口,方法和字段的符号引用,将符号引用替换为直接引用
- 初始化,为类变量赋予Java代码中的初始值的过程。初始化阶段是执行类构造器<clinit>()方法的过程,该方法是由编辑器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。收集顺序由源文件中出现顺序决定。在子类的<clinit>()方法执行前父类的<clinit>()方法已经执行完毕。因此虚拟机中第一个被执行<clinit>()方法的类肯定是java.lang.Object。
有且只有五种情况必须立即对类初始化
- 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时
- 对一个类反射调用时
- 初始化一个类,如果父类还没初始化,则先初始化父类
- 用户指定一个要执行的主类(包含main()方法的那个类)
- 使用JDK1.7的动态语言支持时,如果java.lang.MethodHandler实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,需要先初始化
虚拟机字节码执行引擎
运行时栈帧结构,先了解下栈帧结构,为以后的运行代码过程做铺垫
虚拟机栈描述的是Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
- 局部变量表,用于存储方法参数和方法内部的局部变量
- 操作数栈,方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出/入栈操作。我的理解,就是在内存中顺序将指令写在内存空间上,这个被写指令的内存空间被叫做操作数栈,这条指令写完了,还有用就留着,继续在下一个地址写下一条指令,写完如果不用了就清空,直到写完最后一条指令。不知道怎么画个易懂的图出来,如果有会画出入栈这种图的朋友,不吝赐教啊。
- 动态链接,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,应该就是方法区中类信息方法的地址吧。
- 方法返回地址,遇到方法返回的字节码指令时,退出当前方法,把当前栈帧出栈。栈帧中保存有PC计数器值,根据该值,恢复上层方法,把返回值(如果有)压入调用者栈帧的操作数栈中。调整PC计数器指向方法调用的后一条指令。
对象的创建,内存布局和访问
- 对象的创建
当虚拟机遇到一个new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的引用符号,并且检查这个引用符号代表的类是否已经被加载,解析,初始化。如果没有,就先加载该类。接下来为新生对象分配内存。 - 对象的内存布局
对象在内存中的布局分为三块区域,对象头,实例数据,对齐补充。
对象头,分为两部分,第一部分用来存储自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。第二部分是类型指针,指向他的类元数据的指针。通过这个指针确定对象是哪个类的实例。
实例数据,存储的是字段内容,包括从父类继承的和自己的。
对齐填充,对象大小必须是8的整数倍,没有对齐的,通过对齐填充补全。
目前问题
- 垃圾回收机制
判断对象是否已死
引用计数法,对象中添加一个引用计数,每当有一个地方引用它,引用计数就加1。当引用失效时,就减1。计数器为0的对象就是不可能再被使用的。但是主流Java虚拟机没有选用该方法来管理内存的。循环引用一个对象的话,该对象不会被回收。
可达性分析算法,当一个引用和GC Roots没有引用链相连,则证明此对象可回收。GC Roots包括,虚拟机栈中引用,方法区静态属性引用,方法区常量引用,本地方法栈引用。
引用
存储着另一块内存的起始地址。分为强引用,软引用,弱引用,虚引用。
两次标记
对象在可达性分析后,发现没有与GC Roots相连,它将会被第一次标记并进行筛选,当对象没有覆盖finalize()方法,或者finalize已经被虚拟机调用过,则会被回收。如果finalize中将引用与GC Roots关联了,则不会被回收。
垃圾收集算法
标记-清除算法,最基础的算法,效率不高,容易产生大量不连续的内存碎片
复制算法,将可用内存容量分为大小相等的两块,每次只用一块,当一块用完了,就将还存活着的对象复制到另一块上,然后将已使用的一次清理掉。现在的商业虚拟机都采用这种算法,但并未按照1:1划分内存空间,而是划分为一块较大的Eden和两块较小的Survivor,每次使用一块Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor上,最后清理掉Eden和刚才用过的Survivor。Eden和Survivor的比例是8:1。
标记整理算法,分代收集算法 -
类加载双亲委派模型
- 内存溢出
在Java虚拟机规范中,虚拟机栈有两种异常情况:如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出StackOverflowError异常;如果虚拟机可以动态扩展,如果扩展到无法申请到足够的内存,就会抛出OutOfMemoryError异常。
如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
根据虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
Java程序从编译到运行完成的过程
public class A{
int value;
public String getValue(){
return value;
}
}
public class Example{
public static void main(String args[]){
A a = new A();
a.getValue();
}
}
1.Java代码编译为class文件,class中包含常量池,字段表,方法表,属性表等;
2.加载(虚拟机自己定义加载时机),将class文件加载到方法区,在堆中实例化一个Class对象,做为方法区中该类的入口;
验证;准备,为类变量分配内存,设置初值0;
解析,将常量池中符号引用替换为直接引用;
初始化,给类变量赋真正的初始值;
3.开始执行main方法,根据Example的全限定名在方法区中找到Example,找到name是main的方法,创建main方法栈帧,将参数args,对象引用a存储到main栈帧的局部变量表中,然后将new指令入栈(操作数栈),将参数A入栈,在堆中保存a对象实例。接下来方法调用指令入栈,参数入栈,参数中有a和getValue,可以根据这些内容找到方法区中A类getValue方法的地址,然后运行该方法,创建getValue方法栈帧,将return指令入栈(getValue的操作数栈),参数value入栈,该方法执行结束,value出栈,return指令出栈。根据程序计数器,跳回刚才的main栈帧的操作数栈中的位置。退出指令入栈,退出指令出栈,其他内容逐一出栈。OK,程序结束。