了解JVM(JavaEE初阶系列19)

2023-11-10

目录

前言:

1.JVM是如何运行的

2.JVM中的内存区域划分

3.JVM的类加载机制

3.1JVM加载机制的五大步骤

3.1.1加载

3.1.1验证

3.1.1准备

3.1.1解析

3.1.1初始化

3.2总结

3.3JVM启动时机

3.4双亲委派模型

4.JVM中的垃圾回收策略

4.1JVM垃圾回收机制概念

4.2垃圾回收策略

4.2.1判断引用是否有指向

4.2.2垃圾回收算法

4.2.2.1标记-清除算法

4.2.2.2复制算法

4.2.2.3标记-整理算法

4.2.2.4分代算法

4.3垃圾回收器

4.3.1ZGC回收器

4.3.1.1主要特点

4.3.1.2核心技术

4.3.1.3小结

结束语:


前言:

在前几个博客中小编主要与大家分享了有关于博客系统项目相关的知识以及Linux基础命令的使用,那么接下来小编将与大家分享一下有关于JVM中的一些基础知识。其实作为一名普通的Java程序猿,日常开发几乎是涉及不到JVM相关的内容的,但所以这里就简单的给大家阐述一下JVM中的内存机制区域划分、JVM的类加载机制以及JVM中的垃圾回收策略,大家快来码住啦!

1.JVM是如何运行的

首先我们先来了解一下JVM是如何运行的。

JVMJava Virtual Machine,Java虚拟机)是 Java 程序的运行环境,它负责将 Java 字节码翻译成机器代码并执行。也就是说 Java 代码之所以能够运行,主要是依靠 JVM 来实现的。

JVM的整体的执行流程是这样的:

  1. 程序在执行前先要把Java代码转换成字节码(class文件),JVM首先需要把字节码通过一定的方式类加载器(ClassLoader)把文件加载到内存中运行时数据区(Runtime Data Area)
  2. 但字节码文件是JVM的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器,也就是JVM的执行引擎(Execution Engine)会将字节码翻译成底层系统指令再交由CPU去执行
  3. 在执行的过程中,也需要调用其他语言的接口,如通过本地库接口(Native Interface)来实现整个程序的运行,如下所示:

所以,整体来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:

  1. 类加载器(ClassLoader)
  2. 运行时数据区(Runtime Data Area)
  3. 执行引擎(Execution Engine)
  4. 本地库接口(Native Interface)

2.JVM中的内存区域划分

上面也给大家说了JVM是一个Java进程,Java进程会从操作系统这里申请一大块内存区域给java使用。这一大块内存区域会进一步划分,给出不同的用途,划分结果如下所示:

  • 堆:new出来的对象。(成员变量)
  • 栈:维护方法之间的调用关系。(局部变量)
  • 方法区(旧说法)/元数据区(新说法):放的是类加载之后的类对象。(静态变量)
通常所说的 JVM 内存布局,一般指的是 JVM 运行时数据区(Runtime Data Area),也就是当字节码被类加载器加载之后的执行区域划分。 《Java虚拟机规范》中将 JVM 运行时数据区域划分为以下 5 部分:

①程序计数器(Program Counter Register):用于记录当前线程执行的字节码指令地址,是线程私有的,线程切换不会影响程序计数器的值。

②Java 虚拟机栈(Java Virtual Machine Stacks):用于存储方法执行时的局部变量表、操作数栈、动态链接、方法出口等信息,也是线程私有的。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈等信息。

③本地方法栈(Native Method Stack):与 Java 虚拟机栈类似,用于存储本地方法的信息。

④Java 堆(Java Heap):用于存储对象实例和数组,是 JVM 中最大的一块内存区域,它是所有线程共享的。堆通常被划分为年轻代和老年代,以支持垃圾回收机制。

  • 年轻代(Young Generation):用于存放新创建的对象。年轻代又分为 Eden 区和两个 Survivor 区(通常是一个 From 区和一个 To 区),对象首先被分配在 Eden 区,经过垃圾回收后存活的对象会被移到 Survivor 区,经过多次回收后仍然存活的对象会晋升到老年代。
  • 老年代(Old Generation):用于存放存活时间较长的对象。老年代主要存放长时间存活的对象或从年轻代晋升过来的对象。

⑤方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也是所有线程共享的。

如下图所示:

注意:

  • 虚拟机栈是给java代码使用的。本地方法栈是给JVM内部的本地方法使用的。
  • 堆和元数据区,在一个JVM进程中,只有一份。
  • 栈(本地方法栈和虚拟机栈)和程序计数器则是存在多份,每个进程都有一份。
  • 程序计数器作用就是记录当前程序指定到哪个指令了。

3.JVM的类加载机制

类加载器(Class Loader)是 Java 虚拟机(JVM)的重要组成部分,负责将字节码文件(.class文件)加载到内存中并转换为可执行的类及得到类对象这样的过程。程序要想运行就需要把依赖的“指令和数据”加载到内存中。

类的加载步骤如下所示:

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)

那么上面的这几个步骤分别都是怎么做的呢?详细请继续往下看。

3.1JVM加载机制的五大步骤

3.1.1加载

加载(Loading):找到.class文件,并读文件内容。这里就会涉及到双亲委派模型,这个后面给大家具体讲解。

在加载 Loading 阶段,Java 虚拟机需要完成以下 3 件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

3.1.1验证

验证(Verification):验证加载的类是否符合 Java 虚拟机规范,比如是否有正确的文件格式、是否有正确的访问权限等。验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。

验证选项:

  • 文件格式验证
  • 字节码验证
  • 符号引用验证...

3.1.1准备

准备(Preparation):给类对象分配内存空间(未初始化的空间,内存空间中的数据是全0的)也就是为类的静态变量分配内存,并设置默认初始值。准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

比如此时有这样一行代码:

public static int value = 123;

它是初始化 value 的 int 值为 0,而非 123。

3.1.1解析

解析(Resolution):针对字符串常量进行初始化,也就是将类中的符号引用转换为直接引用,比如将类中的方法名转换为实际的内存地址。解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。

这里的符号引用就是字符串常量,他在.class文件中就已经存在了,但是他们只是知道彼此之间的相对位置,不知道自己在内存中的实际位置,这个时候的字符串常量就是符号引用。只有真正加载到内存中,就会把字符串常量填充到内存中的特定地址上,字符串常量之间的相对位置还是一样的,但是这些字符串有了自己真正的内存地址,此时的字符串就是直接引用了。

3.1.1初始化

初始化(Initialization):针对类对象进行初始化,也就是执行类的初始化代码,包括静态变量赋值和静态代码块的执行。

3.2总结

以上 5 个步骤总共分为 3 个大步骤:

  1. 加载: 查找并加载类的二进制数据。
  2. 连接: 将 Java 类的二进制数据合并到 JVM 运行状态之中。
    1. 验证:验证加载的类是否符合 Java 虚拟机规范。
    2. 准备:为类的静态变量分配内存,并设置默认初始值。
    3. 解析:将类中的符号引用转换为直接引用。
  3. 初始化: 执行类的初始化代码,包括静态变量赋值和静态代码块的执行。

3.3JVM启动时机

那么知道了JVM的类加载机制之后,类加载究竟是在什么时候进行加载呢?

类加载这个动作并不是在JVM一启动,就会把所有的.class文件都加载了,而是整体是一个“懒加载”的策略(懒汉模式),采取非必要不加载。

那么什么又叫必要呢?

  • 创建了这个类的实例。
  • 使用了这个类的实例。
  • 使用子类,会触发父类加载。

3.4双亲委派模型

在类加载中一个重要的考点就是双亲委派模型。它做的工作就是在第一个步骤中,找.class文件这个过程中。双亲委派模型是 Java 类加载器的一种工作机制。

它是指当一个类加载器需要加载一个类时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。在JVM中,内置了三个类加载器,如下所示:

  • Bootstrap ClassLoader:他负责加载Java标准库中的类。
  • Extension ClassLoader:他负责加载一些非标准的但是是Sun/Oracle扩展的库。
  • Application ClassLoader:他负责加载项目中自己的类以及第三方库中的类。

上述图片的意思就是当Application 收到请求之后,先问问自己的父亲Extension有没有,然后Extension再去问自己的父亲BootStrap有没有,到了BootStrap之后有于他没有父亲类加载器了,因此就只能自己来搜索自己负责的片区,如果搜到,就直接进行后续加载步骤 ,如果搜不到,再交给孩子来处理。然后Extension收到了父亲的反馈,自己来找,如果搜索自己负责的片区找到了,直接进行后续加载步骤,如果没有搜到,再交给孩子处理。Application收到了父亲的反馈,自己来找,自己来找,如果搜索自己负责的片区找到了,直接进行后续加载步骤,如果没有搜到,也是交给自己的孩子来处理,没有孩子了就抛出ClassNotFoundException。

双亲委派模型的优点是:

  1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
  2. 更安全:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。

4.JVM中的垃圾回收策略

4.1JVM垃圾回收机制概念

JVM的垃圾回收就是帮助程序猿自动释放内存的。如果不释放内存就可能会出现内存泄漏。

所以在Java后续的编程语言中引入了GC来解决内存泄漏。这样会有效的减少内存泄漏出现的概率。

其实关于内存的释放的时机是一个比较纠结的问题。我们在申请的时机一般是明确的,使用到了就必须要申请,但是释放的时机是模糊的,它只有彻底不使用了才能释放。

那么在JVM中的内存有好几个区域,是释放哪个部分的空间呢?这里我们释放的是堆,也就是我们new出来的对象。程序计数器,就是一个单纯存地址的整数,不需要的时候会随着线程一起销毁。栈也是随着线程一起销毁,方法调用完毕之后,方法的局部变量自然随着出栈操作就销毁了,元数据区/方法区,存的是类对象,它是很少会“卸载”。所以“堆”才是GC的主要目标。GC也就是以对象为单位进行释放的。这里说是释放内存其实是在释放对象。

4.2垃圾回收策略

上面我们提到了GC机制,其中GC机制中主要分成了两个阶段。

  1. 找,谁是垃圾。
  2. 释放内存,把垃圾对象给释放掉。

在第一个找的阶段中我们就涉及到了垃圾回收算法。在Java中一个对象如果后期不再使用了,就认为是垃圾,Java中使用一个对象的时候,我们是通过引用来实现的。在Java中只是单纯通过引用没有指向这个操作,来判定垃圾的。具体在Java中如何知道一个对象是否有引用指向呢?接下来我们继续往下看。

4.2.1判断引用是否有指向

在Java中有以下两种方式:

  • 引用计数:给对象里安排一个额外的空间,保存一个整数,表示该对象有几个引用指向。
  • 可达性分析:这个是Java中所采取的策略。

下面我们来分别看一下。

①计数引用

如下所示:

随着引用的增加计数器就会增加,引用销毁,计数器就会减少。当计数器为0的时候,则认为该对象没有引用了,就是垃圾了。

他有两个缺陷:

  • 浪费内存空间。
  • 存在循环引用的情况,会导致引用计数的判定逻辑出错。

循环引用出错情况如下所示:
 

如果两者互相引用就会使得计数器增加,此时如果a对象和b对象销毁了,这个时候两个的引用计数器不是0,不能作为垃圾,但是这两个对象却无法再使用了。所以此时就会出现问题,陷入了一个逻辑上的循环。 

②可达性分析

可达性分析我们可以将对象之间的引用关系理解成一个树型结构,从一些特殊的起点出发,进行遍历,只要能遍历访问到对象,就是“可达”的,再把“不可达”的当做垃圾处理掉即可。

伪代码如下所示:

创建出来的二叉树如下所示:

如上图所示:

此时通过root就可以访问到整个树的任意结点。对于不可达,如果root.right.right = null。此时f就不可达了。如果root.right = null。此时c就不可达了。诸如此类。

可达性分析的关键要点,进行上述的遍历需要起点:

  • 栈上的局部变量(每个栈的每个局部变量都是起点)。
  • 常量池中的引用对象。
  • 方法区中的静态成员引用的对象。

可达性分析总的来说就是从所有的gcroots的起点出发,看看该对象里又通过引用能访问到哪些对象,顺藤摸瓜的把所有可以访问的对象都给遍历一遍,将其标记为可达的。剩下的自然就是不可达的。

对于可达性分析克服了引用计数的两个缺点,但是他也有自己的缺点:

  • 消耗的时间更多,因此某个对象成了垃圾,也不定可以第一时间发现,因为扫描的过程需要耗费时间。
  • 在进行可达性分析的时候,要顺藤摸瓜,一旦这个过程中,当前代码中的对象的引用关系发生了变化,就会出现问题。因此为了更加准确的完成这个“摸瓜”的过程,需要让其他的业务线程暂停工作(也就是STW(stop the work)问题。)

4.2.2垃圾回收算法

垃圾收集器有两个重要的功能:第一,先识别和标记死亡对象;第二,使用合理的垃圾回收算法回收垃圾。那常见的垃圾回收算法有哪些呢?HotSpot 官方默认的虚拟机采用的有什么哪种垃圾回收算法呢?接下来我们一起来看。

常见的垃圾回收算法有以下 4 个:

  1. 标记-清除算法;
  2. 复制算法;
  3. 标记-整理算法;
  4. 分代算法。
4.2.2.1标记-清除算法

标记-清除(Mark-Sweep)算法属于早期的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。 而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。 标记-清除算法的执行流程如下图所示:

从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。

优点:实现简单。

缺点:产生不连续的内存碎片,如果程序需要分配一个连续内存的大对象时,就需要提前触发一次垃圾回收。

4.2.2.2复制算法

复制算法是将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。 这样就不会产生内存碎片的问题了,其执行流程如下图所示:

从上图可以看出:使用复制算法是可以解决内存碎片的问题的,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了

优点:执行效率高,没有内存碎片的问题。

缺点:空间利用率低,因为复制算法每次只能使用一半的内存。

4.2.2.3标记-整理算法

标记-整理算法是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:

优点:解决了内存碎片问题,比复制算法空间利用率高。

缺点:因为有局部对象移动,所以效率不是很高。

4.2.2.4分代算法

分代算法并不能是某种具体的算法,而是一种策略,我们就姑且称它为分代算法吧。目前 HotSpot 虚拟机使用的就是此算法,在 HotSpot 虚拟机中将垃圾回收区域堆划分为两个模块:新生代和老生代,这里给对象设定了“年龄”这样的概念,描述了这个对象存在多久了,如果一个对象刚刚诞生,认为是0岁,每经过一轮扫描(可达性分析),没有被标记成垃圾,这个时候对象的年龄就长一岁,通过这个年龄来区分对象的存活时间,如下图所示:

过程描述:

  1. 新创建的对象放到伊甸区,当垃圾回收扫描到伊甸区之后,绝大部分对象都会在第一轮GC中被淘汰,大部分的对象活不过1岁。
  2. 如果伊甸区的对象熬过第一轮GC就会通过复制算法,拷贝到生存区。生存区分成两部分(大小一样)一次使用一半,垃圾回收扫描伊甸区的对象也是发现垃圾就会淘汰,不是垃圾的再次通过复制算法,复制到另一个生存区。
  3. 当这个对象在生存区熬过若干轮GC之后,年龄增长到了一定程度,就会通过复制算法,将其复制到老年代。
  4. 进入老年代的对象,年龄都挺大了,再消亡的概率就会比前面新生代中的对象小不少,针对老年代的GC扫描频率就会降低狠多。如果老年代中发现某个对象是垃圾了,就使用标记清除的方式清除。
  5. 特殊情况:如果对象非常大,直接进入老年代(大对象进行复制算法的成本很高,而且大对象也不会很多 )。

举一个例子:
伊甸区就是hr收到的简历,此时会收到很多的简历,大是大部分简历是过不了初筛的,只有小部分同学可以通过,然后进入到下一轮的笔试和面试,此时就到了幸存区,开始笔试和面试,这一轮又会刷很多人,然后如果有幸的话就会通过面试拿到offer,正式进入公司,也就是进入到了老年区,但是在老年区中也是需要考核的,不过此时的考核频率就会下降。 

为什么要将堆分为新生代和老生代呢? 因为对象分为两种,绝大多数对象都是朝生夕灭的,也就是用完一次之后就不用了,而剩下一小部分对象是要重复使用多次的,将不同的对象划分到不同的区域,不同的区域使用不同的算法进行垃圾回收,这样可以大大提高 Java 虚拟机的工作效率。

4.3垃圾回收器

JVM 常见的垃圾回收器有以下几个:

  1. Serial/Serial Old:单线程垃圾回收器;
  2. ParNew:多线程的垃圾回收器(Serial 的多线程版本);
  3. Parallel Scavenge/Parallel Old:吞吐量优先的垃圾回收器【JDK8 默认的垃圾回收器】;
  4. CMS:最小等待时间优先的垃圾收集器;
  5. G1:可控垃圾回收时间的垃圾收集器【JDK 9 之后(HotSpot)默认的垃圾回收器】;
  6. ZGC:停顿时间超短(不超过 10ms)的情况下尽量提高垃圾回收吞吐量的垃圾收集器【JDK 15 之后默认的垃圾回收器】。

下面我们主要了解一下ZGC回收器。

4.3.1ZGC回收器

ZGC(Z Garbage Collector)是一种低延迟的垃圾回收器,是 JDK 11 引入的一项垃圾回收技术。它主要针对大内存、多核心的应用场景,旨在减少垃圾回收带来的停顿时间。

4.3.1.1主要特点

ZGC 的主要特点包括:

  1. 低停顿时间:ZGC 设计的目标之一是尽量减少垃圾回收带来的停顿时间。它采用了并发的垃圾回收方式,尽可能地与应用程序并发执行,以减少停顿的时间和影响。在目标范围内,单次垃圾回收的停顿时间通常不超过 10 毫秒。
  2. 大内存支持:ZGC 被设计为支持非常大的堆内存。它能有效地处理数十到数百 GB 大小的堆,并且具有可控的、较为恒定的停顿时间。
  3. 并发回收:ZGC 采用了全程并发的垃圾回收方式。它会与应用程序同时运行,通过在并发阶段遍历并标记存活对象,并在并发预备阶段处理待回收的对象。这样可以将停顿时间分散到整个应用程序运行过程中,减少对应用程序的影响。
  4. 空间压缩:ZGC 会对堆进行动态的空间压缩,以避免堆内存碎片化的问题。这有助于提高堆的使用效率,并减少内存的浪费。
4.3.1.2核心技术

ZGC 有以下几项核心技术来达成毫秒级停顿和大内存支持的目标:

  1. 并发标记:ZGC 采用增量式并发标记算法来实现并发垃圾回收。它不会在标记阶段产生长时间停顿,可以与用户线程并发运行。
  2. 粉碎压缩:ZGC 采用粉碎压缩算法来避免产生内存碎片。它会将内存按照特定大小(例如 2MB)分为多个区域,然后将存活对象连续放置在相邻区域,释放掉边界外的内存空间。这可以最大限度减少内存碎片。
  3. 直接内存映射:ZGC 会直接映射内存空间,而不需要进行内存分配。这可以避免统计堆内存碎片情况所带来的性能消耗。
  4. 微任务:ZGC 采用了微任务(Microtasks)机制来增量完成垃圾回收工作,从而不会产生长时间停顿。它会将总工作分割为多个微任务,这些微任务会在安全点(Safepoint)之间执行。
  5. 可扩展的堆内存:ZGC 不需要指定最小堆(Xmn)和最大堆(Xmx)大小,它可以跟踪堆内存变化并根据需要动态调整堆空间大小。这使得 ZGC 可以支持将近 4TB 的堆内存。
  6. 可插拔组件:ZGC 是一个独立的 GC 组件,它不依赖于 Gradle 等构建工具,可以与不同的工具或框架一起使用,这增强了其可移植性。

这些核心技术的运用使得 ZGC 可以实现毫秒级别的 GC 停顿,并支持将近 4TB 的大内存,适用于对低延迟和大内存有要求的应用场景。

4.3.1.3小结

ZGC 是一种低延迟的垃圾回收器,是 JDK 11 引入的一项垃圾回收技术,也是 JDK 15 之后默认的垃圾回收器,它可以实现毫秒级停顿和大内存支持,适用于需要低延迟和高吞吐量的场景。

结束语:

好了这节小编就给大分享到这里啦,希望这节对大家有关于JVM的基础知识的了解有一定帮助,想要学习的同学记得关注小编和小编一起学习吧!如果文章中有任何错误也欢迎各位大佬及时为小编指点迷津(在此小编先谢过各位大佬啦!)

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

了解JVM(JavaEE初阶系列19) 的相关文章

随机推荐