深入理解java虚拟机
走进java
java不仅仅是一门编程语言,还是一个由一系列计算机软件和规范形成的技术体系。她有以下优点:
- 结构严谨,面向对象;
- 摆脱硬件平台的限制,实现了一次编写,到处运行;
- 提供了一个相对安全的内存管理和访问机制;
- 有一套完善的应用程序接口,以及先进的开源生态;
java内存区域与内存溢出异常
运行时数据区域
- 程序计数器
线程私有,可以看做当前线程所执行的字节码的行号指示器。
- java虚拟机栈
线程私有,对应java方法执行的内存模型,即方法栈帧,存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈
线程私有,为虚拟机用到的native方法的内存模型。
- java堆
线程共享,唯一的目的就是存放对象实例。虽然java虚拟机规范要求所有的对象都在java堆上分配,但是随着栈上分配、标量替换等优化技术的出现,这一要求也显得不那么严格了。堆内部还可以按照不同的原则细分为更细的区域,但都是为了垃圾回收等目的,并没有改变堆内存储的内容。堆内存的分配要求逻辑上连续,但不要求物理上连续。
- 方法区
线程共享,存储已被虚拟机加载的类信息、常量、静态变量、及时编译后的代码等数据。区别于堆中的永久代,将方法区直接以永久代的方式实现并不是一个好主意。
- 运行时常量池
方法区的一部分,字节码文件在编译期生成的各种字面量和符号引用,在类加载之后进入方法区的运行时常量池中存放。
- 直接内存
在java1.4中引入了NIO类,可以使用native函数直接分配对外内存,然后通过java堆中的DirectByteBuffer对象来对这块内存的引用进行操作,用来提高性能。很显然,直接内存不受java堆的内存限制,但受OS物理内存的限制,也会跑出OutOfMemeoryError异常。
HotSpot虚拟机对象揭秘
对象的创建
当遇到一条new指令时:
- 检查在常量池中是否存在类的符号应用,并检查对应的类是否已加载,没有则去加载;
- 类加载后,将为对象在堆中分配内存,通常通过指针碰撞法 (带Compact过程)or 空闲列表法(CMS);
- 为了防止对象分配存在并发冲突问题,可以采用CAS配上失败重试保证其原子性,也可以使用TLAB(本地线程分配缓冲);
- 将出了对象头的内存空间都初始化为0,是为了让java对象的属性不初始化,可以访问各类型的零值;
- 设置对象元信息;
- 运行init方法,完成对象的创建工作;
对象的内存布局
对象的内存布局可以分为3块区域:对象头、实例数据和对齐填充。
对象头包括对象元数据,这部分根据位数决定元数据所占内存大小,但会复用压缩;还包括类型指针,指向这个对象属于哪个类型。
实例数据存储的是真正有效信息,包括程序代码中定义的各种类型的字段内容,也包括 集成的字段。
对齐填充仅仅起着占位符的作用,是因为HotSpotVM的自动内存管理系统要求对象的起始地址必须是8的整数倍。
对象的访问定位
- 使用句柄:在堆中划分一块内存区域作为句柄池,reference中存储的是对象的句柄地址,句柄中包含了对象的实例数据和类型数据;
- 直接指针:直接存储的对象地址;
二者各有优劣,使用句柄借助了句柄的缓冲,在对象移动时不必修改reference的内容,而直接内存则少了一次指针寻址,提升了效率。
垃圾收集器与内存分配策略
对象存活判断
引用计数法
给对象添加引用计数器,每增加一个引用,计数器加1,引用失效,计数器减1。实现简单,但存在循环引用的问题。
可达性分析
通过GC Roots对象作为起始点向下搜索,当一个对象到GC Roots不存在引用链时,即可认为该对象不可达,可以回收了。GC Roots对象主要包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的常量。
再谈引用
- 强引用
代码直接指明的引用,只要引用还在,永远不GC。
- 软引用
有用但非必须的对象,在OOM之前,会尝试回收这些对象,如果内存还是不够,才会抛出OOM。
- 弱引用
非必须对象,在下次GC时会直接回收。
- 虚引用
最弱的引用,不会为对象的生命周期造成影响,也无法取得对象实例,唯一的目的是在GC时获取通知。
回收方法区
方法区(HotSpot中的永久代)的回收主要是回收常量和无用的类。当常量不存在引用时,就可能会被回收。而类是否无用的判断就比较苛刻:不存在该类的对象、加载该类的ClassLoader已回收、对应的Class对象没有在任何地方被引用(无法通过反射方法访问该类的对象)。而且还可以通过虚拟机参数控制是否进行回收。
垃圾收集算法
标记-清除算法
最基础的收集方法,先标记出需要回收的对象,在标记完成后统一回收。主要存在效率问题和空间碎片。
复制算法
将内存分为两块,标记结束后,将存活的对象统一拷贝到另一块内存,然后对原区域整块进行回收,解决了空间碎片问题,但将可用内存减小了一半。
现实中则会将比例进行调整,例如根据8:1:1的比例将内存分配为Eden、SurviorA、SurvivorB三块,每次使用Eden + 1块Survior,回收时将存货对象全部放入另一块Survivor中。但存在分配担保问题。
标记-整理方法
主要是老年代使用,不会有很多对象的复制操作。在标记之后不进行清除,而是向一端进行移动。
HotSpot的算法实现
枚举根节点
准确式GC要求进行时必须停顿所有java执行线程,以避免回收过程中发生引用变动。使用OopMap数据结构在类加载完成之后就预存储哪些位置存在全局性引用,方便在GC时直接定位到。
安全点
HotSpot没有为所有指令设置OopMap,只是在“特定位置”记录这些信息,这就是Safepoint。安全点的选定基本上是以程序“是否具有让程序上时间运行”为标准进行的,例如方法调用、循环跳转、异常跳转等。
安全区域
当程序处于sleep或者blocked的状态,就无法相应jvm的中断请求,自然无法stop the world,这时候就需要安全区域(safe region)。指的是在一段代码片段中,引用关系不会发生变化,可以安全GC。进入安全区域代码时,需要进行标识,离开安全区域时,也要检查GC是否完成。
垃圾收集器
Serial
最基本、最悠久的收集器。单线程收集器,简单而高效,仍是client模式下默认的新生代收集器。
ParNew
其实就是Serial收集器的多线程版本。只有它和Serial能个CMS收集器配合工作。
Parallen Scavenge
使用复制算法的新生代并行多线程收集器,关注点主要在于提升吞吐率,即CPU运行于用户代码的时间与CPU总运行时间的比值。适合在后台运算而不需要太多交互的任务。
Serial Old
Serial的老年代版本,使用“标记-整理”算法,主要是两个目的:一个是jdk1.5之前与Parallel Scavenge搭配使用;另一个是作为CMS失败的备用方案。
Parallel Old
Parallen Scavenge的老年代版本,主要是为了与Parallen Scavenge搭配使用。
CMS
一种以获取最短回收停顿时间为目标的垃圾收集器。整个过程分为四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
三个明显的缺点:
- 对CPU资源敏感
- 无法处理浮动垃圾
- 碎片化现象严重
G1
最新的收集器,同时处理新生代和老年代,具有以下特点:
- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿
内存分配与回收策略
对象的内存分配,主要是分配在新生代Eden区上,当然如果启用了TLAB,则会优先在TLAB上分配。少数情况下也会直接在老年代中。分配的规则并不是百分之百的,而是根据垃圾收集器组合还有虚拟机中相关参数的设置来决定的。
对象优先在Eden分配
对象分配优先在Eden区中,如果发现空间不足,则会触发一次Minor GC。这里介绍一下Minor GC和Major GC。
- Minor GC:新生代GC,因为java对象朝生夕灭的特性,所以新生代GC非常频繁,一般回收速度也较快;
- Major GC:老年代GC,也称Full GC。一次Full GC通常伴随着一次Minor GC。通常耗时也较长。
大对象直接在老年代分配
例如byte[]数组就是典型的大对象。虚拟机设置了参数可以控制超过多大的对象直接进入老年代。
长期存活的对象将进入老年代
对象在Survivor中每熬过一次Minor GC,年龄就增加一岁,当年龄增长到一定程度(默认是15岁)就会进入老年代。这个值也可以通过参数设置。
动态对象年龄判定
虚拟机并不是永远根据设定的参数来控制是否进入老年代。如果Survivor空间中相同年龄的对象大于总对象大小的一般,那么大于改对象年龄的对象都将进入老年代。
空间分配担保
在Minor GC之前,会检查老年代的剩余连续空间是否大于新生代所有对象空间之和,如果成立,则Minor GC是安全的,反之则不安全。如果不安全,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,则会检查老年代最大连续空间是否大于历次Minor GC晋升至老年代的对象大小的平均值,如果大于,则会尝试GC,如果小于或者不允许担保失败,则会直接出发一次Full GC。虽然担保失败会耗费较多时间,但是大部分情况下担保还是成功的,为了避免频繁的Full GC,还是应该打开担保失败的开关。
虚拟机性能监控和故障处理工具
JDK的命令行工具
- jps:查看进程状况
- jstat:虚拟机统计信息
- jinfo:查看java进程的配置参数
- jmap:生成堆转储快照
- jhat:分析堆转储快照
- jstack:生成虚拟机当前时刻的线程快照
JDK可视化工具
- jconsole
- virtualVM
类文件结构
无关性的基石
java规范规定的字节码存储格式,是构成平台无关性的基石。java虚拟机不和java在内的任何变成语言绑定,只与“Class文件”这种特定的二进制文件格式所关联。
Class类文件的结构
字节码文件使用一种类似于C语言结构体的伪结构来存储数据。结构中只有两种数据:无符号数和表。
- 无符号数是基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值;
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性的以“_info”结尾。字节码文件本质上就是一张表。
从前往后,字节码中的数据依次为:
- 魔数(CAFEBABE)
- 版本号:Minor Version(2个字节)+ Major Version(2个字节)
- 常量池
- 访问标志
- 类索引、父类索引与接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
字节码指令简介
java虚拟机的指令由一个字节长度的、代表某种特定操作含义的数字以及跟随其后的零至多个操作所需参数构成。操作码长度为一个字节,所以总数不能超过256个。
字节码与数据类型
java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。但由于指令总数限制,大部分指令都没有支持整数类型byte、char和short。编译器会在编译期或者运行期将byte和short类型的数据带符号扩展成相应的int类型数据,将boolean和char类型数据零位扩展成相应的int类型数据。大多数对于boolean、short、byte都会转为对应的int类型数据。
加载和存储指令
用于将数据在栈帧中的 局部变量表和操作数栈中来回传输。
运算指令
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈顶。
类型转换指令
用于两种不同的数值类型进行相互转换,一般用于实现代码中显式类型转换操作。
对象创建与访问指令
创建和访问对象的指令。
操作数栈管理指令
直接操作操作数栈的指令。
控制转移指令
可以让java虚拟机有条件或无条件的从指定位置继续执行。
方法调用与返回指令
异常处理指令
同步指令
虚拟机类加载机制
类加载的时机
类从被加载,到卸载出内存为止,整个生命周期包括:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
java虚拟机规范规定了有且只有5中情况必须立即对类进行初始化(而加载、验证、准备自然要在此之前):
- 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时;
- 使用java.lang.reflect包的方法对类进行反射调用的时候;
- 初始化一个类,发现其父类还没有初始化,必须先进行父类的初始化;
- 当虚拟机启动时,main方法所在的主类必须先初始化;
- 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析的结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有初始化,则需要先触发其初始化。
加载
属于类加载的一个阶段,主要分为三步:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流中定义的静态存储结构转化为方法区的运行时结构;
- 在内存中生成一个此类的对象,作为访问此类在方法区的访问入口;
验证
验证和加载是交叉进行的,但是有着严格的前后顺序。验证阶段主要是验证class文件内二进制数据的合法性,主要包括:
- 文件格式验证:是否是CAFEBABE魔术头,java版本是否在有效范围内等,是否存在无效的UTF-8字符;
- 元数据验证:验证类是否有父类,子类是否按要求实现父类的方法;
- 字节码验证:通过分析数据流和控制流,来确定程序语义的合法性;
- 符号引用验证:通过类的全限定名是否能找到对应的类;
准备
为类变量分配内存(方法区)并赋初始值。这里仅包括类变量(static)。这时候没有执行任何类的代码,包括变量声明和赋值,所以变量的值都是初始的默认值,但有一种情况例外,那就是constant变量,这个变量会在准备阶段就初始化为指定的值,而不是初始值。
解析
将常量池中的符号引用替换为直接引用的过程。其中符号引用包括:
- 类或接口
- 字段
- 类方法
- 接口方法
- 方法类型
- 方法句柄
- 调用点限定符
初始化
执行类的初始化代码的过程。
类加载器
类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。
双亲委派模型
加载器类型主要分为:
- 启动类加载器
- 扩展类加载器
- 应用程序加载器
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,首先不会自己尝试加载这个类,而是把这个请求委托给父类加载器去完成。
破坏双亲委派模型
三种双亲委派模型被破坏的情况:
- 发生在双亲委派模型出现之前
- java.lang.Thread的setContextClassLoader()方法设置加载器;
- 代码热替换,通过将模块的加载器一起换掉的方式实现代码的热替代;
虚拟机字节码执行引擎
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机栈的栈元素。
栈帧里面主要有局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。
局部变量表
存放方法参数和内部定义的局部变量。最小单位为slot,通过索引定位的方式来使用。不同于类变量,局部变量表中的变量如果没有初始化,是不能使用的。
操作数栈
存储指令待操作数的栈。两个栈帧的操作数栈可以通过部分区域重叠实现栈帧之间的数据共享。
动态链接
指向运行时常量池中该栈帧所属方法的引用。
方法返回地址
方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可 能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。