前言
本文主要由以下内容
- JVM整体结构
- 运行时数据区域
- 字节码文件解析
- 对象是如何存储与创建的
- 垃圾收集器
JVM结构
Java这门语言的理想是是“一次编写,到处运行”,而Java虚拟机是实现这个理想不可或缺的一环。
运行时数据区域
Java虚拟机整体结构可以大致分为三部分:类装载子系统、内存模型、执行引擎,本文重点是内存模型,也是JVM核心部分
由上图可见,运行时数据区域分为五大块,其中方法区和堆是线程共享的,虚拟机栈、程序计数器和本地方法栈是线程私有的。
线程私有区域:
- 程序计数器 Program Counter Register:有操作系统基础的读者应该能顾名思义,此区域是当前线程所执行的字节码的行号指示器。由于多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换后能恢复到正确的执行位置,每条线程需要有一个独立的程序计数器。
此区域也是唯一一个没有规定任何OutOfMemoryError情况的区域
- 本地方法栈 Native Method Stack:为虚拟机使用到的Native方法服务
- 虚拟机栈 Java Virtual Machine Stacks:为虚拟机栈执行Java方法服务。每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。栈帧的大小是提前计算好的,入栈出栈对应着一个方法的调用到执行完成。该区域如果栈深度大于虚拟机允许的深度,则抛出StackOverflowError异常;如果无法申请足够的内存,则抛出OutOfMemoryError异常
- 局部变量表:等价一个数组,存放基本数据类型,在方法运行期间不会改变大小。long和double占用两个单元,其余均占用一个单元。
- 局部变量表:等价一个数组,存放基本数据类型,在方法运行期间不会改变大小。long和double占用两个单元,其余均占用一个单元。
线程共享区域:
- 方法区 Method Area:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等,此区域的内存回收目标是针对常量池的回收和对类型的卸载
- 运行时常量池 Runtime Constant Pool:除了类的描述信息,还存放编译期生成的各种字面量和符号引用,该部本具备动态性,也受到方法区内存的限制,当无法再申请到内存的时候则抛出OutOfMemoryError异常
方法区在JDK 1.8后改了名字,叫做元空间
- Java堆 Java Heap:是JVM内存中最大的一块,唯一目的就是存放对象实例,也是垃圾收集器管理的主要区域,因此也称为GC堆(Garbage Collected Heap)。堆内空间可分为:新生代和老年代,更细致点:Eden、From Survivor、To Survivor。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,则抛出OutOfMemoryError异常
直接内存
我们注意到,直接内存 Direct Memory 并不是运行时数据区的一部分,但这部分内存也被频繁使用,而且可能导致OutOfMemoryError异常
在JDK 1.4中加入了 NIO 类,引入一种基于通道与缓冲区的I/O方式,他可以直接分配堆外内存,避免在Java堆和Native堆中来回复制数据
字节码文件
接下来解读一个方法执行过程中的字节码文件
public class Math {
public static final Integer CONSTANT = 666;
public static Object obj = new Object();
public int compute() { //一个方法对应一个栈帧
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("test");
}
}
我们需要将代码从java文件转化成class文件,然后进行反汇编
javac Math.java
javap -c Math.class
打印出来的数据如下,可以看到跟我们的代码结构其实基本是一致的
Compiled from "Math.java"
public class com.Math {
public static final java.lang.Integer CONSTANT;
public static java.lang.Object obj;
public com.Math();
Code:
//省略
public int compute();
Code:
//省略
public static void main(java.lang.String[]);
Code:
//省略
细看一下compute方法,读者之前没有接触过的话可能会觉得有点懵,但其实每条指令都可以在 JVM指令手册 中查到
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
iconst_1:将int型的常量压入操作数栈
istore_1:将int型类型值存入局部变量1
这两行相当于在局部表量表中增加了一条 a = 1 的记录,也就是执行代码行 int a = 1;
在这个过程中,操作数栈相当于一个临时的中转区
iload_1:取出int型的局部变量1的值
iadd:两个int型局部变量相加
bipush 10:将常量10压入栈中
imul:将两个int型类型相乘
这一步的操作数栈执行过程是这样的
对象
在Java虚拟机中,一个对象的内存布局如图所示
可以看到,一个对象分为三部分:对象头、实例数据、对齐填充
- 对象头:包含两个部分,一部分是Mark Word,另一部分是类型指针,如果是数组还需要记录数组的长度。其中Mark Word设计成非固定的数据结构目的是在极小的空间内存储尽量多的信息;虚拟机通过类型指针确定这个对象是哪个类的实例
- 实例数据:真正存储的有效信息,无论是父类继承下来的,还是子类中定义的,都需要记录。相同宽度的字段总是分配到一起,在这个前提下,父类中定义的变量会出现在子类之前
- 对齐填充不是必然存在的,仅仅是占位符的作用,比如在一些虚拟机中,对象的大小必须是8字节的整数倍
对象的创建过程(仅限于普通Java对象,不包括数组和Class对象)
- 当虚拟机遇到一个new指令的时候,检查类是否已被加载、解析和初始化过,如果否,则先执行类加载过程
- 分配内存,分配方式有指针碰撞和空闲列表两种方式,选择哪种由Java堆是否规整决定
- 初始化为零值(不包括对象头),保证对象可以不赋初始值就直接使用
- 对对象进行必要的设置,例如是哪个类的实例等,这些数据放在对象头中
- 执行 init 方法,把对象按照程序员的意愿进行初始化
垃圾收集器
垃圾收集器主要考虑三个问题
- 哪些内存需要回收
- 什么时候回收
- 如何回收
我们从判断一个对象是“存活”还是“死去”开始
最先判断一个对象是否存在的算法是给对象添加一个引用计数器,对象每引用一次,计数器就 加一。但这种算法很难解决对象相互循环引用的问题,例如下列代码在这种算法下将不会回收两个对象
public class Math {
public Object instance = null;
public static void main(String[] args) {
Math math1 = new Math();
Math math2 = new Math();
math1.instance = math2;
math2.instance = math1;
math1 = null;
math2 = null;
System.gc();
}
}
现在主流的实现中,是通过 可达性分析 判断一个对象是否存活的,从GC Roots的对象作为起点,开始向下搜索,走过的路径称之为引用链,当一个对象到GC Roots没有任何引用链的时候则证明对象不可用
可作为GC Roots的对象包括
- 虚拟机栈栈帧中的本地变量表中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- Native方法引用的对象
很多事情并不是“非黑即白”,在代码的世界也是一样的,引用也不是简单的两种状态,而是分为以下4种引用
- 强引用:如*Math math1 = new Math()*这类的引用,只要对象存在强引用,垃圾收集器永远不会回收
- 软引用:描述一些有用但并非必须的对象。如果系统发生内存溢出异常之前,将会对此部分对象进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 弱引用:描述非必须对象的,强度比软引用更弱。这部分对象只能生存到下一次垃圾收集发生之前
- 虚引用:最弱的引用,唯一目的是能在这个对象被回收时收到一个系统通知
垃圾收集算法
- 标记 - 清除:首先标记所有需要回收的对象,在标记完成后统一回收被标记的对象。
不足处是效率不高且回收后产生大量不连续的内存碎片 - 复制算法:内存等划分成两部分,每次只使用其中一部分,当内存用完了,将存活着的对象复制到另一块内存中
不足处:实现简单,运行高效的代价是内存缩减成原来一半
现在商业虚拟机都采用这种算法回收新生代,因为新生代的对象98%是朝生夕死的,所以并不需要按照1:1的比例划分内存空间,而是划分成文中之前所说的Eden空间和两块较小的Survivor空间,比例是8:1:1,当Survivor空间不够用时,需要依赖老年代进行分配担保,比如直接进入老年代
- 标记 - 整理:根据老年代中对象的特点,标记过程仍然与“标记 - 清除”算法相同,但让所有存活的对象都向一端移动,之后直接清理掉端边界以外的内存
JMM中的对象分配原则
- 对象优先在Eden分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判定
- 空间分配担保
对象每经历一次垃圾回收后还存活,年龄就加一,最终大于15的就进入老年代
但这个并不是非得15才能进入老年代,如果新生代空间不够,有可能对象年龄小于15的会提前进入老年代
七种垃圾收集器
- Serial收集器: 单线程,在进行垃圾回收的时候必须暂停其它所有工作线程,直到收集结束
- ParNew收集器:是Serial收集器的多线程版本
- Parallel Scavenge收集器:目的是达到一个可控制的吞吐量,从而高校利用CPU时间
- Serial Old收集器:是Serial收集器的老年代版本,使用“标记 - 整理”算法
- Parallel Old收集器:是Parallel Scavenge的老年代版本
- CMS收集器:以获取最短回收停顿时间为目标的收集器,基于“标记 - 清除”算法
- G1收集器:收集器技术发展最前沿成果之一
七种收集器其实都有各自的优点和缺点,Serial收集器简单而高校,只要不频繁发生,停顿时间还是可以接受的,对于运行在Client模式下的虚拟机是一个很好的选择;ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,除了Serial,目前只有它能与CMS配合;Parallel Scavenge常称为“吞吐量优先”收集器,主要适合在后台运算而不需要太多交互的任务;Serial Old如果在Server模式下主要两大用途,一是与Parallel Scavenge搭配,二是作为CMS的后备预案;CMS是优秀的收集器,做到了并发手机、低停顿,但缺点是对CPU资源敏感,无法处理浮动垃圾,会产生大量内存碎片;G1收集器从论文到实现,将近花了十年时间,优点是并行并发、分代收集、空间整合、可预测停顿,但由于发布时间较短,如果现在使用的收集器没有问题,那么没有理由选择G1,但G1未来可期!
浅谈调优
如果GC进行时必须停顿所有Java执行进程,这件事情称为 “Stop The World”
当Eden区满时,触发 Minor GC
当System.gc()、老年代或方法区空间不足时,触发Full GC
因为Full GC会STW,所以控制Full GC的频率以及减少每次Full GC的时间是调优的关键
结论
由于Java虚拟机这一块的知识不是一篇文章就能搞定的,涉及到的知识非常广且每个知识点都可以深入研究,所以本文主要是对Java虚拟机描述出一个整体框架,如果想更加深入了解建议反复阅读《深入理解Java虚拟机》!
补充
1、jps:查看本机java进程信息。
2、jstack:打印线程的栈信息,制作线程dump文件。
3、jmap:打印内存映射,制作堆dump文件
4、jstat:性能监控工具
5、jhat:内存分析工具
6、jconsole:简易的可视化控制台
7、jvisualvm:功能强大的控制台