目录
前言:
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中的垃圾回收策略,大家快来码住啦!
首先我们先来了解一下JVM是如何运行的。
JVM(Java Virtual Machine,Java虚拟机)是 Java 程序的运行环境,它负责将 Java 字节码翻译成机器代码并执行。也就是说 Java 代码之所以能够运行,主要是依靠 JVM 来实现的。
JVM的整体的执行流程是这样的:
所以,整体来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:
上面也给大家说了JVM是一个Java进程,Java进程会从操作系统这里申请一大块内存区域给java使用。这一大块内存区域会进一步划分,给出不同的用途,划分结果如下所示:
①程序计数器(Program Counter Register):用于记录当前线程执行的字节码指令地址,是线程私有的,线程切换不会影响程序计数器的值。
②Java 虚拟机栈(Java Virtual Machine Stacks):用于存储方法执行时的局部变量表、操作数栈、动态链接、方法出口等信息,也是线程私有的。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈等信息。
③本地方法栈(Native Method Stack):与 Java 虚拟机栈类似,用于存储本地方法的信息。
④Java 堆(Java Heap):用于存储对象实例和数组,是 JVM 中最大的一块内存区域,它是所有线程共享的。堆通常被划分为年轻代和老年代,以支持垃圾回收机制。
⑤方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也是所有线程共享的。
注意:
类加载器(Class Loader)是 Java 虚拟机(JVM)的重要组成部分,负责将字节码文件(.class文件)加载到内存中并转换为可执行的类及得到类对象这样的过程。程序要想运行就需要把依赖的“指令和数据”加载到内存中。
类的加载步骤如下所示:
那么上面的这几个步骤分别都是怎么做的呢?详细请继续往下看。
加载(Loading):找到.class文件,并读文件内容。这里就会涉及到双亲委派模型,这个后面给大家具体讲解。
在加载 Loading 阶段,Java 虚拟机需要完成以下 3 件事:
验证(Verification):验证加载的类是否符合 Java 虚拟机规范,比如是否有正确的文件格式、是否有正确的访问权限等。验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。
验证选项:
准备(Preparation):给类对象分配内存空间(未初始化的空间,内存空间中的数据是全0的)也就是为类的静态变量分配内存,并设置默认初始值。准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
比如此时有这样一行代码:
public static int value = 123;
它是初始化 value 的 int 值为 0,而非 123。
解析(Resolution):针对字符串常量进行初始化,也就是将类中的符号引用转换为直接引用,比如将类中的方法名转换为实际的内存地址。解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
这里的符号引用就是字符串常量,他在.class文件中就已经存在了,但是他们只是知道彼此之间的相对位置,不知道自己在内存中的实际位置,这个时候的字符串常量就是符号引用。只有真正加载到内存中,就会把字符串常量填充到内存中的特定地址上,字符串常量之间的相对位置还是一样的,但是这些字符串有了自己真正的内存地址,此时的字符串就是直接引用了。
初始化(Initialization):针对类对象进行初始化,也就是执行类的初始化代码,包括静态变量赋值和静态代码块的执行。
以上 5 个步骤总共分为 3 个大步骤:
那么知道了JVM的类加载机制之后,类加载究竟是在什么时候进行加载呢?
类加载这个动作并不是在JVM一启动,就会把所有的.class文件都加载了,而是整体是一个“懒加载”的策略(懒汉模式),采取非必要不加载。
那么什么又叫必要呢?
在类加载中一个重要的考点就是双亲委派模型。它做的工作就是在第一个步骤中,找.class文件这个过程中。双亲委派模型是 Java 类加载器的一种工作机制。
它是指当一个类加载器需要加载一个类时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。在JVM中,内置了三个类加载器,如下所示:
上述图片的意思就是当Application 收到请求之后,先问问自己的父亲Extension有没有,然后Extension再去问自己的父亲BootStrap有没有,到了BootStrap之后有于他没有父亲类加载器了,因此就只能自己来搜索自己负责的片区,如果搜到,就直接进行后续加载步骤 ,如果搜不到,再交给孩子来处理。然后Extension收到了父亲的反馈,自己来找,如果搜索自己负责的片区找到了,直接进行后续加载步骤,如果没有搜到,再交给孩子处理。Application收到了父亲的反馈,自己来找,自己来找,如果搜索自己负责的片区找到了,直接进行后续加载步骤,如果没有搜到,也是交给自己的孩子来处理,没有孩子了就抛出ClassNotFoundException。
双亲委派模型的优点是:
JVM的垃圾回收就是帮助程序猿自动释放内存的。如果不释放内存就可能会出现内存泄漏。
所以在Java后续的编程语言中引入了GC来解决内存泄漏。这样会有效的减少内存泄漏出现的概率。
其实关于内存的释放的时机是一个比较纠结的问题。我们在申请的时机一般是明确的,使用到了就必须要申请,但是释放的时机是模糊的,它只有彻底不使用了才能释放。
那么在JVM中的内存有好几个区域,是释放哪个部分的空间呢?这里我们释放的是堆,也就是我们new出来的对象。程序计数器,就是一个单纯存地址的整数,不需要的时候会随着线程一起销毁。栈也是随着线程一起销毁,方法调用完毕之后,方法的局部变量自然随着出栈操作就销毁了,元数据区/方法区,存的是类对象,它是很少会“卸载”。所以“堆”才是GC的主要目标。GC也就是以对象为单位进行释放的。这里说是释放内存其实是在释放对象。
上面我们提到了GC机制,其中GC机制中主要分成了两个阶段。
在第一个找的阶段中我们就涉及到了垃圾回收算法。在Java中一个对象如果后期不再使用了,就认为是垃圾,Java中使用一个对象的时候,我们是通过引用来实现的。在Java中只是单纯通过引用没有指向这个操作,来判定垃圾的。具体在Java中如何知道一个对象是否有引用指向呢?接下来我们继续往下看。
在Java中有以下两种方式:
下面我们来分别看一下。
①计数引用
如下所示:
随着引用的增加计数器就会增加,引用销毁,计数器就会减少。当计数器为0的时候,则认为该对象没有引用了,就是垃圾了。
他有两个缺陷:
如果两者互相引用就会使得计数器增加,此时如果a对象和b对象销毁了,这个时候两个的引用计数器不是0,不能作为垃圾,但是这两个对象却无法再使用了。所以此时就会出现问题,陷入了一个逻辑上的循环。
②可达性分析
可达性分析我们可以将对象之间的引用关系理解成一个树型结构,从一些特殊的起点出发,进行遍历,只要能遍历访问到对象,就是“可达”的,再把“不可达”的当做垃圾处理掉即可。
创建出来的二叉树如下所示:
如上图所示:
此时通过root就可以访问到整个树的任意结点。对于不可达,如果root.right.right = null。此时f就不可达了。如果root.right = null。此时c就不可达了。诸如此类。
可达性分析的关键要点,进行上述的遍历需要起点:
可达性分析总的来说就是从所有的gcroots的起点出发,看看该对象里又通过引用能访问到哪些对象,顺藤摸瓜的把所有可以访问的对象都给遍历一遍,将其标记为可达的。剩下的自然就是不可达的。
对于可达性分析克服了引用计数的两个缺点,但是他也有自己的缺点:
垃圾收集器有两个重要的功能:第一,先识别和标记死亡对象;第二,使用合理的垃圾回收算法回收垃圾。那常见的垃圾回收算法有哪些呢?HotSpot 官方默认的虚拟机采用的有什么哪种垃圾回收算法呢?接下来我们一起来看。
常见的垃圾回收算法有以下 4 个:
标记-清除(Mark-Sweep)算法属于早期的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。 而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。 标记-清除算法的执行流程如下图所示:
从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。
优点:实现简单。
缺点:产生不连续的内存碎片,如果程序需要分配一个连续内存的大对象时,就需要提前触发一次垃圾回收。
复制算法是将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。 这样就不会产生内存碎片的问题了,其执行流程如下图所示:
从上图可以看出:使用复制算法是可以解决内存碎片的问题的,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。
优点:执行效率高,没有内存碎片的问题。
缺点:空间利用率低,因为复制算法每次只能使用一半的内存。
标记-整理算法是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:
优点:解决了内存碎片问题,比复制算法空间利用率高。
缺点:因为有局部对象移动,所以效率不是很高。
分代算法并不能是某种具体的算法,而是一种策略,我们就姑且称它为分代算法吧。目前 HotSpot 虚拟机使用的就是此算法,在 HotSpot 虚拟机中将垃圾回收区域堆划分为两个模块:新生代和老生代,这里给对象设定了“年龄”这样的概念,描述了这个对象存在多久了,如果一个对象刚刚诞生,认为是0岁,每经过一轮扫描(可达性分析),没有被标记成垃圾,这个时候对象的年龄就长一岁,通过这个年龄来区分对象的存活时间,如下图所示:
过程描述:
举一个例子:
伊甸区就是hr收到的简历,此时会收到很多的简历,大是大部分简历是过不了初筛的,只有小部分同学可以通过,然后进入到下一轮的笔试和面试,此时就到了幸存区,开始笔试和面试,这一轮又会刷很多人,然后如果有幸的话就会通过面试拿到offer,正式进入公司,也就是进入到了老年区,但是在老年区中也是需要考核的,不过此时的考核频率就会下降。
为什么要将堆分为新生代和老生代呢? 因为对象分为两种,绝大多数对象都是朝生夕灭的,也就是用完一次之后就不用了,而剩下一小部分对象是要重复使用多次的,将不同的对象划分到不同的区域,不同的区域使用不同的算法进行垃圾回收,这样可以大大提高 Java 虚拟机的工作效率。
JVM 常见的垃圾回收器有以下几个:
下面我们主要了解一下ZGC回收器。
ZGC(Z Garbage Collector)是一种低延迟的垃圾回收器,是 JDK 11 引入的一项垃圾回收技术。它主要针对大内存、多核心的应用场景,旨在减少垃圾回收带来的停顿时间。
ZGC 的主要特点包括:
ZGC 有以下几项核心技术来达成毫秒级停顿和大内存支持的目标:
这些核心技术的运用使得 ZGC 可以实现毫秒级别的 GC 停顿,并支持将近 4TB 的大内存,适用于对低延迟和大内存有要求的应用场景。
ZGC 是一种低延迟的垃圾回收器,是 JDK 11 引入的一项垃圾回收技术,也是 JDK 15 之后默认的垃圾回收器,它可以实现毫秒级停顿和大内存支持,适用于需要低延迟和高吞吐量的场景。
好了这节小编就给大分享到这里啦,希望这节对大家有关于JVM的基础知识的了解有一定帮助,想要学习的同学记得关注小编和小编一起学习吧!如果文章中有任何错误也欢迎各位大佬及时为小编指点迷津(在此小编先谢过各位大佬啦!)