初识JVM
这篇博客主要介绍JVM中的内存区域划分、双亲委派模型、垃圾回收机制。
一、JVM简介
JVM 是 Java Virtual Machine 的简称,意为 Java 虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。
JVM 和其他两个虚拟机的区别:
- VMwave 与 VirtualBox 是通过软件模拟物理 CPU 的指令集,物理系统中会有很多的寄存器;
- JVM 则是通过软件模拟 Java 字节码的指令集,JVM 中只是主要保留了 PC 寄存器,其他的寄存器都进行了裁剪。
JVM 是一台被定制过的现实当中不存在的计算机。
二、内存区域划分
JVM是虚拟的,仿造了操作系统在进程运行时的区域划分。
JVM内存区域划分,相当于就是JVM进程从操作系统申请到了一部分空间,然后将这个空间划为不同模块,执行不同功能。
就像房间的布局:
JVM划分出的四个核心区域:
(一)程序计数器(线程私有)
它是一个很小的空间,用于记录cpu指令执行到哪一个地址了。
(二)方法区(线程共享)
方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。 在《Java虚拟机规范中》把此区域称之为“方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace)。
当我们写一段代码,代码执行的流程是:
.java->.class->加载到内存中,元数据区就是用来存放当前类被加载好的数据。
(三)java虚拟机栈(线程私有)
Java 虚拟机栈的作用:保存方法的调用关系。Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。 Java 虚拟机栈中包含了以下 4 部分:
(四)堆(线程共享)
堆的作用:程序中创建的所有对象都在保存在堆中。
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象会放入老生代。新生代还有 3 个区域:一个 Endn + 两个 Survivor(S0/S1)。
三、JVM类加载
(一)类加载的步骤
1.加载
根据类的全限定名(包名+类名,形如java.lang.String),找到并打开文件,读取文件内容到内存里。
2.验证
校验.class文件读到的内容是否是合法的,并且把这里的内容转化成结构化的数据。
3.准备
给类对象申请内存空间并设置类变量初始化。
public static int value = 123;
初始化value的值为0,并非123。
4.解析
从.class文件里解析出来的字符串常量,放到内存空间里,并进行初始化,也就是初始化常量的过程。
5.初始化
针对刚才谈到的类对象进行最终的初始化,对类对象的的各种属性进行填充(包括这个类中的静态成员,如果这个类还有父类,而且父类还没加载,此环节也会触发父类的类加载)。
(二)双亲委派模型
提到类加载机制,不得不提的一个概念就是“双亲委派模型”。 站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。 站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一些。自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构器。
⭐️双亲委派模型的过程
进行类加载,通过全限定类名找 .class 文件时,会从 ApplicationClassLoader 作为入口开始。
然后把加载类的任务,委托给 “父亲”ExtensionClassLoader 来进行。ExtensionClassLoader 也不会立即进行查找,而是同样委托给 “父亲”BootstrapClassLoader 来进行。
BootstrapClassLoader 也想委托给 “父亲”,但由于没有 “父亲”,只能自己进行类加载。它会根据类名,在标准库范围内查找是否存在匹配的 .class 文件。若 BootstrapClassLoader 没有找到,会把任务还给 “孩子”ExtensionClassLoader,接下来由 ExtensionClassLoader 负责找 .class 文件的过程。找到就加载,没找到就把任务还给 “孩子”ApplicationClassLoader。最后由 ApplicationClassLoader 负责找 .class 文件,找到就加载,没找到就抛出异常。
⭐️双亲委派模型的优点
- 避免重复加载类:比如A类和B类都有一个父类C类,那么当A启动时就会将C类加载起来,那么在B类进行加载时就不需要在重复加载C类了。
- 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。
四、JVM中的垃圾回收机制(GC)
在JVM划分的内存区域中,程序计数器中的数据在线程销毁的时候就自然释放掉了。
栈中的栈帧在方法结束,栈帧就释放了。
元数据区存放的是类加载好的数据,一般不会释放。
GC回收的是JVM中堆中的数据。
(一)找到垃圾
有以下几种方式:
1.引用计数
引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理。
但是引用计数不能解决循环引用的问题。
2.可达性分析
此算法的核心思想为:通过一系列称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为 "引用链",当一个对象到 GC Roots 没有任何的引用链相连时 (从 GC Roots 到这个对象不可达) 时,证明此对象是不可用的。以下图为例:
对象 Object5-Object7 之间虽然彼此还有关联,但是它们到 GC Roots 是不可达的,因此他们会被判定为可回收对象。
在 Java 语言中,可作为 GC Roots 的对象包含下面几种:
- 虚拟机栈 (栈帧中的本地变量表) 中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI (Native 方法) 引用的对象。
(二)回收垃圾
JVM使用可达性分析算法,就可以将要回收的垃圾进行标记,标记之后就能进行回收了。
1.标记-清除算法
把标记为垃圾的内存,直接进行释放。
因为在申请内存的时候,是申请的连续的空间,如果直接进行释放,就会造成内存碎片问题。
2.复制算法
当为对象申请空间时,额外申请一倍的空间。
申请的对象只会在一个空间里,当GC时会标记清除所有的垃圾,再把剩下的移到右边的新空间里,这样就能确保对象所在的内存是连续的。
缺点:内存的空间利用率是很低的。
一但不是垃圾的对象较多,那么复制空间的成本就很高。
3.标记-整理算法
在标记-清除算法的基础上,回收垃圾之后,将不是垃圾的对象进行“插空转移”,解决了内存碎片化问题。缺点是复制成本依旧很大。
4.分代算法
当前 JVM 垃圾收集都采用的是 "分代收集 (Generational Collection)" 算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用 "标记 - 清理" 或者 "标记 - 整理" 算法。
哪些对象会进入新生代?哪些对象会进入老年代?
- 新生代:一般创建的对象都会进入新生代;
- 老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代移动到老年代。
新生代区又有两个部分:伊甸区(Eden)和幸存区(S0、S1)。
新创建的对象就先放在伊甸区,经过一次GC后,存活下来的就放入幸存区。
在幸存区中执行的就是复制算法,当在幸存区中经过GC达到一定次数后,就进入老年区。