文章目录
- JVM 简介
- JVM内存划分
- 堆(线程共享)
- Java虚拟机栈(线程私有)
- 本地方法栈(线程私有)
- 程序计数器(线程私有)
- 方法区(线程共享)
- JVM类加载机制
- 类加载过程
- 双亲委派模型
- JVM垃圾回收机制
- 找到谁是垃圾
- 引用计数算法(不是JVM采取的方案,而是 Python/PHP 的方案)
- 可达性分析算法(JVM采用)
- 释放对应的内存的算法
- 标记-清除算法(不实用)
- 复制算法
- 标记-整理算法
- 分代算法
JVM 简介
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。常见的虚拟机:JVM、VMwave、Virtual Box
JVM 和其他两个虚拟机的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器
- JVM则是通过软件模拟Java字节码的指令集,JVM中主要保留了PC寄存器,其他的寄存器都进行了裁剪
PS: 本文以下部分,默认都是使用 HotSpot,也就是 Oracle Java 默认的虚拟机为前提来进行介绍的
JVM内存划分
注意它和 Java 内存模型(Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:
堆(线程共享)
堆是整个JVM内存区域中最大的区域, 放的就是程序中创建的所有对象
常见的 JVM 参数设置 -Xms10m 是最小启动内存,-Xmx10m 是最大运行内存 ,这都是针对堆的(ms 是 memory start 简称,mx 是 memory max 的简称)
Java虚拟机栈(线程私有)
Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建⼀个栈帧(Stack Frame), 用于存储局部变量表、操作栈、动态链接、方法出口等信息。
本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM (Java实现的方法)使用的,而本地方法栈是给本地方法(C++实现的方法)使用的。
程序计数器(线程私有)
程序计数器是内存区域中最小的区域,保存当前要执行的下一条指令(JVM字节码,不是cpu指令)的地址
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是⼀个Native方法,这个计数器值为空。
程序计数器内存区域是唯一一个在JVM规范中没有规定任何OOM情况的区域!
方法区(线程共享)
方法区用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
在《Java虚拟机规范中》把此区域称之为“方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace),也叫元数据区
JVM类加载机制
类加载过程
- 加载
在硬盘上找到对应的.class文件,读取文件内容加载到内存中
- 验证
这一阶段的目的是确保Class文件中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
- 准备
准备阶段是为类中定义的静态变量分配内存(在元数据区中)并设置初始值为0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
- 解析
针对字符串常量来初始化, 把.class文件的常量放到"元数据区"
- 初始化
针对类对象进行初始化(不是针对对象的初始化,和构造方法无关), 执行静态代码块
执行完这五步后类对象就创建完成了,后续代码就可以使用这个类对象创建实例,或者使用里面的静态成员了
双亲委派模型
描述了JVM加载.class文件过程中,找文件的过程
"类加载器"负责 类加载 工作。自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构器
- Bootstrap ClassLoader(爷爷) : 负责加载 标准库 的类, 标准库是Java官方给出的"规范文档"上面要求提供的类
- Extension ClassLoader(父亲) : 负责加载 JVM扩展库 的类,各个JVM厂商在实现JVM的时候会根据需要,在标准库的基础上做出一些扩展。扩展库是JVM自带的, 安装了JVM就有的(现在很少使用)
- Application ClassLoader(儿子) : 负责加载 第三方库 和 自己写的类
此处的"父子关系"不是通过 类 的继承表示(不是 父类 子类), 而是 通过一个"parent" 字段指向自己的"父亲"
什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它不会自己先去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该委托到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才自己尝试去完成加载
工作过程:
例如,给定一个自己写的 类 。全限定类名(包名+类名): java111.Test
此时加载过程如下:
- 从Application ClassLoader 开始。Application ClassLoader 并不会立即搜索第三方库的相关目录而是把任务交给自己的父亲(Extension ClassLoader)来处理
- 工作就到了 Extension ClassLoader。Extension ClassLoader 也不会立即搜索扩展库的目录,也是把任务交给自己的父亲(Bootstrap ClassLoader)来处理
- 工作就到了 Bootstrap ClassLoader。Bootstrap ClassLoader 也想交给自己的父亲来处理,但是它的parent指向null, 只能自己处理。Bootstrap ClassLoader 就在标准库的目录中搜索 java111.Test
- 如果这个类在标准库找到了,找文件的过程就结束了;如果没找到,任务还是继续交给儿子来处理
- 工作回到了Extension ClassLoader。此时就搜索扩展库对应的目录。如果找到就结束,没找到就还给儿子处理
- 工作回到了Application ClassLoader。此时就搜索第三方库/用户自己写的目录了,找到了结束,没找到,任务还是继续交给儿子来处理, 此时没有儿子了,就会抛出ClassNotFoundException异常
双亲委派模型主要是为了应对这个场景:比如自己代码里写的全限定类名和标准库冲突了,JVM会确保加载的类是标准库的(不加载自己写的类了),如果标准库缺失,整个Java进程没法正常工作了
类名可以重复,全限定类名不能重复
双亲委派模型的优点
- 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了
- 安全性:使用双亲委派模型也可以保证 Java 的核心 API 不被篡改
- 确保自己添加的类加载器都能被执行到
类加载器并非是固定就只有3个,还可以手动添加更多的类加载器到中间
JVM垃圾回收机制
- 程序计数器、虚拟机栈、本地方法栈:不需要额外回收。这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了
- 元数据区: 一般也不需要,都是加载类,很少"卸载类"
- 堆: GC的主力部分
Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。
堆在内存中, GC回收的是"内存" , 而且一定是回收完整的对象,而不是回收半个对象
GC主要是两个步骤:
- 找到谁是垃圾(不用的对象)
- 释放对应的内存
找到谁是垃圾
如果某个对象没有引用指向它,就认为是不再使用了。介绍两种方式判定某个对象是否有引用指向:
引用计数算法(不是JVM采取的方案,而是 Python/PHP 的方案)
给每个对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1。计数器为0的对象就是不再被使用的,即对象已"死"。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法,但两个缺陷:
- 消耗额外的存储空间。对象比较小,引用计数空间占比就大了,并且对象越多,空间浪费就越多
- 在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题。
循环引用示例:
class Test {
Test t;
}
public class Main {
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
a = null;
b = null;
}
}
a实例里面有b的实例,b实例里面有a的实例,就算双方置为null, 双方的引用计数还是1
可达性分析算法(JVM采用)
虽然解决了空间和循环引用问题,但是花了更多的时间
把对象之间的引用关系,用"树形结构"管理起来。会周期性不停遍历这样的结构,把能够遍历到的对象标记为"可达",剩下的就是"不可达"
此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的
在Java中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象。
由于可达性分析需要消耗一定的时间,因此Java的垃圾回收,没法做到"实时性"。周期性进行扫描(JVM提供了一组专门负责GC的线程,不停的进行"扫描"工作)
除了最早我们使用"引用"来查找对象,现在我们还可以使用“引用”来判断死亡对象了。
释放对应的内存的算法
通过上面的算法我们可以将死亡对象标记出来了,标记出来之后我们就可以进行垃圾回收操作了,我们先看下垃圾回收机器使用的几种算法:
标记-清除算法(不实用)
"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
"标记-清除"算法的不足主要有两个 :
- 效率问题 : 标记和清除这两个过程的效率都不高
- 空间问题 : 标记清除后会产生大量不连续的内存碎片,碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够大的连续内存而不得不提前触发另一次垃圾收集(申请的内存都是"连续的")
复制算法
"复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。最大的问题是空间浪费太多了,如果要保留的空间比较大,回收的空间比较少,复制的开销也不小
标记-整理算法
标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存
能解决内存碎片和空间利用率问题,但是时间开销更大
分代算法
当前JVM实际的方案是综合上述方案的"分代回收"。分代算法是根据对象存活周期将堆划分为几块。通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。
某个对象,经历了一轮GC之后,还不是垃圾,"年龄"就会+1
一般是把Java堆分为新生代和老年代。新创建的对象都会进入新生代,在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法(存活的对象较少,复制开销也很低,生存区(S0/S1)空间也不必很大)
HotSpot实现的复制算法流程如下:
- 当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
- 当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。
- 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代
复制算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不使用复制算法。老年代中对象存活率高(GC扫描频率低)、没有额外空间对它进行分配担保,就采用"标记-清理"或者"标记-整理"算法。