• JVM内存布局、类加载机制及垃圾回收机制详解


    一、JVM 简介

    JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机
    虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统
    常见的虚拟机:JVM、VMwave、Virtual Box

    JVM 和其他两个虚拟机的区别

    1. VMwaveVirtualBox通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
    2. JVM则是通过软件模拟Java字节码的指令集JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。

    JVM 是一台被定制过的现实当中不存在的计算机。

    JVM出现的初心是为了"跨平台",“—次开发,到处运行”。

    二、JVM 运行流程

    JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢?

    JVM 运行流程
    程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式:类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

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

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

    三、 JVM运行时数据区域(内存布局)

    JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:
    在这里插入图片描述
    JVM实际上是一个Java 进程,进程就是用来管理硬件资源的,比如内存。

    JVM中内存来自于操作系统.JVM启动之后就会从操作系统这里申请到一大块内存,再针对这个内存划分出一些区域。

    具体的内存布局:
    在这里插入图片描述
    对于堆区和方法区,在整个JVM中只存在一份,而程序计数器和栈区是跟进程绑定在一起的,一个java进程中,可能包含着多个线程,每个不同的线程都有独立的一份程序计数器和栈区。

    1. (运行时常量池):new的对象就放到堆中.
    2. 方法区:用来存储被虚拟机加载的类信息、常量、静态变量(静态成员)、即时编译器编译后的代码等数据的。
    3. (JVM栈/本地方法栈):局部变量.
    4. 程序计数器:程序计数器的作用:用来记录当前线程执行的行号的存的,它是个地址,描述当前线程接下来要执行的指令在内存的哪个地方

    在这里插入图片描述

    内存布局中的异常问题

    堆溢出java.lang.OutOfMemoryError,循环创建对象,把对象加到List集合类里.(防止被GC回收)典型的情况就是不断地去new 对象而不去释放内存。
    栈溢出StackOverflowError:写个递归方法,无限递归。

    堆和栈的空间大小,都可以通过JVM(Java进程的命令行参数)来进行配置。
    在这里插入图片描述
    对引用类型的理解
    可以把引用类型当作一个“低配指针”,但从更严谨的角度去看,引用并不是一个指针。Java的引用相当于堆C语言的指针功能进行了裁剪,Java中的引用只能用来解引用(如:使用 . 就是默认地解引用)和比较(==或!=) )。

    四、JVM 类加载

    关于.class文件的格式规范

    类加载其实是JVM 中的一个非常核心的流程,做的事情就是把.class 文件,转成JVM 中的类对象。

    要想完成类加载,必须要明确的知道.class文件中都有啥,按照.class文件中的规则进行解析。因此,编译器和类加载器(JVM)必须要商量好.class文件的格,而.class 文件的格式在 JVM虚拟机规范文档里面已经约定好了的,则编程语言的语法也可以理解为一种“协议”。

    在JVM虚拟机规范文档中可以看到:
    在这里插入图片描述
    关于.class文件格式的规范:
    在这里插入图片描述
    可以看到,它把java代码中定义的一个类的核心信息都体现进去了,只不过这个文件的格式是二进制的。
    因此,根据上述的格式我们也可以自己开发一个编程语言,然后编译就根据.class文件的格式一样,就可以直接在JVM中去解析执行了。

    4.1 类加载过程

    对于一个类来说,它的生命周期是这样的:
    在这里插入图片描述
    其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来说总共分为以下几个步骤:

    1. 加载
    2. 连接
      1. 验证
      2. 准备
      3. 解析
    3. 初始化

    加载

    “加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段。
    加载的目的是把.class 文件给找到。如果代码中需要加载某个类,就需要去特定的目录下去查找该.class文件,找到之后,就需要打开这个文件,并且读取这个文件。此时这些数据就已经读到内存里了。

    验证

    验证:目的是验证后缀为.class 的文件是否是编译器编译生成的,如果是人为地去改后缀变为.class 的文件,那么就不是一个合法的.class 文件。

    除了验证.class 文件的格式外,还需要验证文件里面的字节码指令是否正确。(方法里面具体要执行的指令) 确保Class文件的字节流中包含的信息符合《Java虚拟机
    规范》的全部约束要求
    ,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

    准备

    目的是为类对象中的一些成员变量分配内存空间(静态变量…),并且进行一个初步的初始化(初始空间大小为0).

    比如此时有这样一行代码:
    public static int value = 123;
    它是初始化 value 的 int 值为 0,而非 123。

    解析

    解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程.

    解析主要是针对字符串常量进行的处理。.class文件涉及到一些字符串常量,在解析的过程中就把这些字符串常量替换成当前JVM中的字符串常量。
    注:不是程序一启动,就把所有的类都加载完毕的,而是用到哪个类就加载哪个类,而字符串常量是最初启动JVM的时候就有的。

    初始化

    初始化:主要针对在“准备”环节中,对初步初始化的静态变量进行真正地初始化。同时也会执行static的代码块。

    4.2 双亲委派模型

    双亲委派模型描述的是类加载中,根据类名找类的.class文件的查找过程.

    什么是双亲委派模型

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

    在JVM中,有三个类加载器(三个特殊的对象)来负责找文件的操作。这三个类加载器对象都有各自找的区域。

    BootStarpClassLoader:负责加载标准库中的类;
    ExtClassLoader:负责加载扩展库中的类;
    ApplicationClassLoader:负责加载程序员自定义的类/第三方库的类;

    在这里插入图片描述

    双亲委派模型的流程

    当代码中使用到某个类的时候,就会触发类加载
    首先是从AppClassLoader 开始的,但是AppClassLoader 并不会直接开始去扫描自己负责的目录,而是先找它的爸爸。
    找到了ExtClassLoader 之后,它也一样,不会立刻去扫描自己负责的目录,而是又去找它的爸爸。
    找到BootStarp 之后,它也不会立刻去扫描自己负责的目录,而去找它的爸爸。但是它并没有爸爸,因此就只能自己先去扫描自己负责的目录。如果在自己的目录中,找到了复合的类,就没有其它类加载器的事情了。但是如果没有找到匹配的类,就告诉儿子(ExtClassLoader),ExtClassLoader再来找自己负责的目录,如果找到,就加载,找不到就告诉儿子(AppClassLoader)去查找,AppClassLoader就在自己负责的目录去查找,如果找到就加载,找不到就抛出ClassNotFound异常

    双亲委派模型就约定了上述类加载器之间的优先级(先查哪个目录,后查哪个目录)

    双亲委派模型的优点

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

    破坏双亲委派模型

    只是标准库中的三个类加载器要遵守.其他的类加载器不太需要遵守.
    例如Tomcat 中的类加载器就没遵守.(要从 webapps目录中加载指定的webapp的类)

    五、垃圾回收相关

    垃圾回收回收的是内存。JVM 其实是一个进程(java),一个进程会持有很多的硬件资源,如(CPU,内存,硬盘,带宽),而系统的内存总量是一定的。因此对内存的合理使用是非常重要的。

    5.1 什么时候会造成内存泄露

    内存要经过:申请->使用->释放 过程。内存是有限的,并且要给很多的进程去使用。从代码编写的角度看,内存申请的时机是很明确的,但是内存的释放时机很模糊。对于C语言来说内存的释放是靠程序员自己去手动释放的,如malloc、free等。但是一旦忘了释放内存,就会造成内存泄漏,直到内存耗尽为止。

    对于内存泄漏问题,不同的语言有不同的解决方法

    1. C++中引用了智能指针,在合适的时机去自动释放内存,(一般是通过引用计数的方式来衡量这个内存被引用了多少次,当引用计数为0时就真正释放内存)。
    2. Rust中,采取的方案是基于语法上的强校验,Rust引入了很多对内存操作相关的语法规则,在编译器编译期间就会对进行严格的检查和校验,一旦发现有代码存在内存泄漏的风险,就编译报错。但是也有不好的地方,它的语法非常丑陋,同时也限制了很多功能的实现。以至于在实现一些特殊功能的时候要使用个’unsafe’操作,引入这个操作,之前的校验也就部分的失效了。
    3. Java中采用垃圾回收的方式,对于该机制来说,哪一个代码申请都可以,哪里申请都可以,都是由JVM统一去进行垃圾回收(内存释放),具体来说,就是由JVM 内部的一组专门负责垃圾回收的线程来进行这样的工作

    垃圾回收的优缺点

    优点:能够非常好地保证不出现内存泄漏的情况(不是100%保证),并且是自动去进行内存释放。

    缺点

    1. 需要消耗额外的系统资源
    2. 内存释放可能存在延时(不是内存不用了就马上回收,可能过段时间才会回收)
    3. 可能会出现STW 问题(stop the world),比如说有一大段内存需要去释放,那么可能系统的资源都用来去释放该内存了,而其它的代码就不能够继续执行,没法去做别的事情。但是现在大佬们能够将STW 问题限制在了1ms 之内。

    5.2 GC主要回收堆区内存

    堆为主,方法区为次

    Java运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。方法区里面的是类对象,它是类加载过来的,而对方法区进行垃圾回收,就相当于“类卸载”,这里的规则比较特殊,我们不用考虑。
    在上述几个区中,占据的内存空间就是最大的,它占据了一个程序的大部分内存。

    堆内存分成三个部分:正在使用,已经用过(主要回收),尚未使用.回收以对象为单位.

    在这里插入图片描述

    垃圾回收机制主要回收的就是 完全不再使用的内存。对于一半在使用,一半不再使用的内存,是不回收的,因为回收的成本比较大,当然实现起来也比较麻烦。
    因此,Java中的垃圾回收,是以“对象”为基本单位的,一个对象,要么被回收,要么不被回收,不会出现一个对象被回收一半的情况。

    GC的基本指导方针:先标记,再回收

    5.3 GC标记的方法

    引用计数算法

    引用计数描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。

    引用计数的优点
    规则简单,实现方便,比较高效(程序运行效率高)

    缺点

    1. 空间利用率比较低(比较浪费空间,尤其是针对大量的小对象,此时引用计数就会带来不可忽视的空间开销)。
    2. 存在循环引用问题,循环引用会导致代码的引用计数出现问题,从而无法回收。

    如:

    class Test {
       Test t = null;
    }
    	Test t1 = new Test();//引用计数为1  
    	Test t2 = new Test();//引用计数为1  
    	
    	t1.t = t2;//2
    	t2.t = t1;//2
    	
    	//下面这一个操作销毁了两个引用,但是引用计数只减了1,
    	//这个操作即少了t1,也少了 t1.t
    	t1=null;//
    	t2=null;//
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在这里插入图片描述
    很多编程语言虽然使用了引用计数,但是实际上都是改进的引用计数。
    因此在Java中没有使用引用计数的方式去判定垃圾,而是第二种方式——可达性分析。

    可达性分析算法

    此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。(类似于二叉树)
    在这里插入图片描述
    对象f-g之间虽然彼此还有关联,但是它们到GC Roots(a)是不可达的,因此他们会被判定为可回收对象

    JVM中采取的方案是:在JVM 中就存在一个/一组线程,来周期性地进行上述遍历的过程,不断地找出这些不可达的对象,由JVM进行回收.

    在Java语言中,可作为GC Roots的对象(即可达性分析的初始位置)包含下面几种:

    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;
    2. 方法区中类静态属性引用的对象;
    3. 方法区中常量引用的对象;
    4. 本地方法栈中 JNI(Native方法)引用的对象

    (栈上的局部变量表
    常量池中的引用指向的对象
    方法区中,引用类型的静态成员变量)

    和引用计数相比可达性分析确实更麻烦,同时实现可达性分析的遍历过程开销是比较大的。但是带来的好处是解决了引用计数的两个缺点:内存上不需要消耗额外的空间,也没有循环引用的问题

    从上面我们可以看出“引用”的功能,除了最早我们使用它(引用)来查找对象,现在我们还可以使用“引用”来判断对象的生死了。所以在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。

    1. 强引用:即能访问对象,也能决定对象的生死。
    2. 软引用:能够访问对象,但是只能一定程度上的决定对象的生死(JVM会根据内存是否富裕来自行决定)。
    3. 弱引用:能访问对象,不能决定对象的生死(JVM内部进行垃圾回收的时候)。
    4. 虚引用:既不能找到对象,也不能决定对象生死(只能在对象回收之前进行一些善后工作)。

    5.4 垃圾回收算法

    标记-清除算法

    "标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。

    "标记-清除"算法的不足主要有两个 :

    1. 效率问题 : 标记和清除这两个过程的效率都不高;
    2. 空间问题 : 标记清除后会产生大量不连续的内存碎片(内存碎片化),空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾回收。

    复制算法

    复制算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图 :
    在这里插入图片描述
    复制算法的缺点

    1. 可用的内存空间,只有一半。
    2. 如果要回收的对象比较少,而剩下的对象比较多,复制内存的开销就很大了。

    因此复制算法,适用于:对象会被快速回收,并且整体的内存不大的场景下

    标记-整理算法

    能够解决复制算法的内存空间利用率的问题。它类似于顺序表的“删除”的搬运操作。

    初始:假设此时要回收3,5,7 的内存空间。就把未被回收的依次往前搬,4搬到3位置;5要被回收,不动;6搬到4的位置;7要被回收,不动;8搬到5的位置;搬到最后7没有被覆盖,那么就回收7。
    在这里插入图片描述
    优点:能够有效避免内存碎片,同时也能提高内存利用率
    缺点:在搬运的过程中,是一个很大的开销,这个开销可能比复制算法里面的开销更大。

    分代算法

    分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。

    根据“年龄”去进行划分。年龄是是根据GC的次数来的,每次经历一个扫描周期,就认为“长了一岁”。在JVM中,垃圾回收扫描(可达性分析)是周期性地进行的。因此就根据不同的年龄,就采用不同的垃圾回收算法来处理了。

    当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清除"或者"标记-整理"算法。

    划分结构:
    在这里插入图片描述
    新生代:一般创建的对象都会进入新生代;
    老年代:大对象经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代移动到老年代。

    了解Minor GC和Full GC吗,这两种GC有什么不一样吗

    1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
    2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

    分代回收的过程

    1. 一个新的对象,诞生于伊甸区。
    2. 如果活到一岁的对象(对象经历了一轮 GC 还没死),就拷贝到生存区。

    生存区的内存比较小,那么空间小能放下这么多对象吗?
    答:根据经验规律,伊甸区(Eden)的对象,绝大部分都是活不过一岁的,只有少数对象能够来到生存区,大部分对象都是“朝生夕死”的。

    1. 在生存区中,对象也要经历若干轮GC,每一轮GC 逃过的对象,都通过 复制算法 拷贝到另外的生存区里。这里面的对象来回拷贝,每一轮都会淘汰掉一批对象。

    2. 在生存区中,熬过一定轮次的GC之后,这个对象如果还没有被回收的话,JVM就认为这个对象未来能够更持久地存在下去。于是就将这样的对象拷贝到老年代了。

    3. 进入老年代的对象,JVM都认为是属于能够持久存在的对象。这些对象也需要使用GC 来扫描。但是扫描的频次就大大地降低了。老年代这里通常使用的是标记-整理算法。

    特殊地,如果一个对象的内存特别大,它会直接放入老年代。因为如果把它放入到新生代,如果经过一轮GC没有被淘汰,就放到生存区中。在生存区中拷贝来拷贝去的开销会比较大,甚至有的对象的内存太大在生存区可能放不下,因此直接放入老年代更合适。

    5.5 垃圾回收器

    CMS收集器(老年代收集器,并发GC)

    主要的特点:尽可能地降低STW.
    CMS收集器是基于“标记—清除”算法实现的,整个过程分为4个步骤:

    1. 初始标记(CMS initial mark)
      初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
    2. 并发标记(CMS concurrent mark)
      并发标记阶段就是进行GC Roots Tracing的过程。
    3. 重新标记(CMS remark)
      重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。
    4. 并发清除(CMS concurrent sweep)
      并发清除阶段会清除对象

    由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的.

    优点:并发清除、低停顿。

    G1收集器(唯一一款全区域的垃圾回收器)

    最主要的特点是将内存划分成了更多的小区域(不像上面所说的新生代和老年代),以小区域单位进行GC .

  • 相关阅读:
    架构师系列- 定时任务(一)- 单机和分布式定时任务比较
    如何在不带备份的情况下恢复 Android 手机照片?
    大众动力总成构建全程数字化的数电票管理平台
    213. 打家劫舍 II
    店外营销吸睛,店内体验升级丨餐饮品牌如何「吃」透数据?
    一文搞懂堆外内存(模拟内存泄漏)
    ElasticSearch 分布式搜索引擎
    GSON转换成Long型变为科学计数法及时间格式转换异常的解决方案
    交换机与路由器技术-10-交换机密码恢复
    [Android开发学iOS系列] 快速上手UIKit
  • 原文地址:https://blog.csdn.net/dddddrrrzz/article/details/125243786