一.什么是JVM
JVM 是 Java Virtual Machine 的简称,意为 Java 虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机: JVM 、 VMwave 、 Virtual Box 。
JVM 和其他两个虚拟机的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
JVM 是一台被定制过的现实当中不存在的计算机。
二.JVM运行流程
程序在执行之前先要把 java 代码转换成字节码( class 文件), JVM 首先需要把字节码通过一定的方式 类加载器( ClassLoader ) 把文件加载到内存中 运行时数据区( Runtime Data Area ) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器 执 行引擎( Execution Engine ) 将字节码翻译成底层系统指令再交由 CPU去执行,而这个过程中需要调用其他语言的接口本地库接口(Native Interface ) 来实现整个程序的功能,这就是这 4 个主要组成部 分的职责与功能。
总结来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:
- 类加载器( ClassLoader )
- 运行时数据区( Runtime Data Area )
- 执行引擎( Execution Engine )
- 本地库接口( Native Interface )
三.JVM运行时数据区
1.堆(线程共享)
创建出来实例对象会被加载到堆中
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象会放入老生代。新生代还有 3 个区域:一个 Endn + 两个 Survivor ( S0/S1 )。
垃圾回收的时候会将 Endn 中存活的对象放到一个未使用的 Survivor 中,并把当前的 Endn 和正在使用的 Survivor 清楚掉。
2.Java****虚拟机栈(线程私有)
虚拟机栈:每个线程都会在虚拟机栈中开辟一个空间,每调用一个方法时,这个方法就会被压入栈中,也就是说每个栈中存放的是方法调用的层级
Java****虚拟机栈主要由以下部分构成
- 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变 量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。
- 操作栈:每个方法会生成一个先进后出的操作栈。
- 动态链接:指向运行时常量池的方法引用。
- 方法返回地址:PC 寄存器的地址。
3. 本地方法栈(线程私有)
本地方法栈:同样是每一个线程都会在本地方法栈中开辟一块内存,每次调用一个本地方法,这个本地方法就会被加载到本地方法栈中。
4.程序计数器(线程私有)
程序计数器:记录当前线程所运行到的指令地址:由于考虑到java虚拟机在多线程模式下是通过线程轮流切换并分配时间片的方式进行的,因此当某个线程分配的时间片使用完但是当前线程并没有执行结束时,这时就需要使用程序计数器记录下当前线程所运行到的指令地址,当当前线程再度被分配到时间片时,从当前指令下继续执行。
5.方法区(线程共享)
在运行时数据区中,类对象被加载到方法区中,便于后面new出来的实例对象可以通过这个类对象模板中创建新的对象。
在《 Java 虚拟机规范中》把此区域称之为 " 方法区 " ,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen ), JDK 8 中叫做元空间( Metaspace )。
永久代和元空间都是对方法区的实现
JDK 1.8 元空间的变化
- 对于 HotSpot 来说, JDK 8 元空间的内存属于本地内存,这样元空间的大小就不在受 JVM 最大内存的参数影响了,而是与本地内存的大小有关。
- JDK 8 中将字符串常量池移动到了堆中。
运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用。
字面量 : 字符串 (JDK 8 移动到堆中 ) 、 final 常量、基本数据类型的值。
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
具体了解可以看这篇博客: 通俗易懂,一文彻底理解JVM方法区
总结:
这里都属于内存溢出,注意和内存泄漏区别!!
四.JVM类加载
其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来说总共分为以下几个步骤:
- 加载
- 连接
- 验证
- 准备
3.解析
- 初始化
1.加载
在加载 Loading 阶段, Java 虚拟机需要完成以下三件事情:
1 )通过一个类的全限定名来获取定义此类的二进制字节流。
2 )将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3 )在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
2.验证
验证.class是否符合JVM的规范
3.准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
4.解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
5.初始化
初始化阶段, Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程
6.双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去完成加载。
类加载总共分为以下四种:
- 启动类加载器(Bootstrap Class Loader):它是 JVM 的内部组件,负责加载 Java 核心类库(如java.lang)和其他被系统类加载器所需要的类。启动类加载器是由 JVM 实现提供的,通常使用本地代码来实现。
- 扩展类加载器(Extension Class Loader):它是 sun.misc.Launcher$ExtClassLoader 类的实例,负责加载 Java 的扩展类库(如 java.util、java.net)等。扩展类加载器通常从 java.ext.dirs 系统属性所指定的目录或 JDK 的扩展目录中加载类。
- 系统类加载器(System Class Loader):也称为应用类加载器(Application Class Loader),它是sun.misc.Launcher$AppClassLoader 类的实例,负责加载应用程序的类。系统类加载器通常从 CLASSPATH 环境变量所指定的目录或 JVM 的类路径中加载类。
- 用户自定义类加载器(User-defined Class Loader):这是开发人员根据需要自己实现的类加载器。用户自定义类加载器可以根据特定的加载策略和需求来加载类,例如从特定的网络位置、数据库或其他非传统来源加载类。
双亲委派模型的优点
- 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
- 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。
五.垃圾回收机制
1.死亡对象的判断算法
1.引用计数器算法
引用计数描述的算法为 :
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就 +1 ;当引用失效时,计数器就 -1 ;任何时刻计数器为0 的对象就是不能再被使用的,即对象已 " 死 " 。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如 Python 语言就采用引用计数法进行内存管理。
但是,在主流的 JVM 中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的 循环引用问题
这种情况下就产生了内存泄漏,注意和内存溢出区分
2.可达性分析算法
JVM中采用的是此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。以下图为例:
在 Java 语言中,可作为 GC Roots 的对象包含下面几种 :
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象。
2.垃圾回收算法
1.标记-清除算法
" 标记 - 清除 " 算法是最基础的收集算法。算法分为 " 标记 " 和 " 清除 " 两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。后续的收集算法都是基于这种思路并对其不足加以改进而已。
" 标记 - 清除 " 算法的不足主要有两个 :
- 效率问题 : 标记和清除这两个过程的效率都不高
- 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
2.复制算法
" 复制 " 算法是为了解决 " 标记 - 清理 " 的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图 :
3.标记-整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代的特点,提出了一种称之为 " 标记 - 整理算法 " 。标记过程仍与 " 标记 - 清除 " 过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。流程图如下:
3.垃圾收集器
垃圾收集器的不断更新,目的就是为了减少STW(stop the world)
JVM 常见的垃圾回收器有以下几个:
- Serial/Serial Old:单线程垃圾回收器;
- ParNew:多线程的垃圾回收器(Serial 的多线程版本);
- Parallel Scavenge/Parallel Old:吞吐量优先的垃圾回收器【JDK8 默认的垃圾回收器】;
- CMS:最小等待时间优先的垃圾收集器;
- G1:可控垃圾回收时间的垃圾收集器【JDK 9 之后(HotSpot)默认的垃圾回收器】;
- ZGC:停顿时间超短(不超过 10ms)的情况下尽量提高垃圾回收吞吐量的垃圾收集器【JDK 15 之后默认的垃圾回收器】。
4.垃圾回收的过程
垃圾回收主要是针对堆区进行回收的
新生代:老年代=1:2 eden:S0:S2=8:1:1
1.所有new出来的对象都放在eden区
2.当eden区满了之后,进行一次Minor GC,将存活下来的对象复制到S0区,清除其他区域的对象 (复制算法)
3.继续new对象,直到eden区满了,对eden和S0区进行Minor GC,将存活下来的对象复制到S1区,交换S0和S1区域的位置,清除其他区域的对象(复制算法)
4.重复3的操作,不停的操作直到15次,将存活的对象放到old区.
5.当old区满了之后,对整个区域进行Full GC.
Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝
生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
Full GC(Full Garbage Collection). Full GC 又称为 老年代GC或者Major GC ,是指对整个堆内存进行垃圾回收的过程。在进行 Full GC 时,会对年轻代和老年代(以及永久代或元数据区)中的所有对象进行回收。
Full GC 通常发生在以下情况之一:
- 显式触发:通过调用 System.gc() 方法显式触发垃圾回收。虽然调用该方法只是向 JVM 发出建议,但在某些情况下,JVM 可能会选择执行 Full GC。
- 老年代空间不足:当老年代空间不足时,无法进行对象的分配,会触发 Full GC。此时,Full GC 的目标是回收老年代中的无效对象,以释放空间供新的对象分配。
- 永久代或元数据区空间不足:在使用永久代(Java 8 之前)或元数据区(Java 8 及之后)存储类的元数据信息时,如果空间不足,会触发 Full GC。
Full GC 是一种较为耗时的操作,因为它需要扫描和回收整个堆内存。在 Full GC 过程中,应用程序的执行通常会暂停,这可能会导致较长的停顿时间(长时间的停顿会影响应用程序的响应性能)。 为了避免频繁的 Full GC,通常采取一些优化措施,如合理设置堆大小、调优垃圾回收参数、减少对象的创建和存活时间等。