JVM(Java Virtual Machine) 详解

1. JVM 内存区域划分

一个 Java 写的程序,跑起来就得到了一个 Java 进程(资源分配的基本单位)= JVM + 上面运行的字节码指令

1) 程序计数器(比较小的空间),保存了下一条要执行的指令的地址

这个不是 CPU 的寄存器,而是内存空间;这里的 "下一条要执行的指令" 是 Java 的字节码(不是 cpu 的 二进制 的机器语言)

2) 堆

JVM 上最大的空间,new 出来的对象都在 堆 上

3) 栈(Stack Overflow)

函数中的局部变量,函数的形参,函数之间的调用关系

Java 虚拟机栈(JVM 之上,运行的 Java 代码的方法调试关系)

本地方法栈(JVM 里,C++ 代码的函数调用关系)

4) 元数据区(方法区)

Java 程序中的指令(指令都是包含在类的方法中)

保存了代码中涉及到的 类的相关信息

类的 static 属性

分析:

在一个 Java 进程中,元数据区和堆都是只有一份的(同一个进程中的所有线程都是共用同一份数据的,共用同一份内存空间)

程序计数器和栈,则可能有多份(当一个 Java 进程中有多个线程的时候,每个线程都有自己的程序计数器和栈)

线程就代表一个 "执行流",每个线程就需要保存自己的 "程序计数器",每个线程也需要记录自己的调用关系

例:以下代码中创建的变量处于哪个内存区域

java 复制代码
class test {
    private int a;
    private Test b = new Test();
    private static int c;
    private static Test d = new Test();

    public static void main(String[] args) {
        int e = 10;
        Test f = new Test();
    }
}

tip:一个变量处于哪个内存区域和变量是不是 "内置类型" 无关,而是和变量的形态有关

  1. 局部变量 -> 栈

  2. 成员变量 -> 堆

  3. 静态成员变量 -> 元数据区(方法区)

2. JVM 类加载的过程

2.1 概述

加载一个 .class 文件就会创建一个对应的类对象

基于该类对象就可以创建该类的实例,类对象其实就是"对象"的说明书/蓝本

类对象例就包含了 .class 文件中的各种信息,如:

类的名字

类里有哪些属性,每个属性名字,每个属性的类型,public/private

类里有哪些方法,每个方法的名字、参数,public/private

继承的父类是啥

实现的接口有哪些

...

2.2 类加载的具体步骤(重要)

五个环节 / 三个环节(将中间3个环节合为1个)

1. 加载

把 .class 文件找到,代码中先找到类的名字,再找到对应的 .class 文件(涉及到一系列目录查找的过程),打开并读取文件内容

2. 验证

验证读到的 .class 文件中的数据是否正确合法(Java 标准文档中,明确定义了 .class 文件的格式)

Java 标准文档

3. 准备(分配内存空间)

最终需要得到类对象 => 需要内存

把刚才读取到的内容,确定出类对象需要的内存空间,申请这样的内存空间,并把内存空间中所有的内容都初始化为 0(Java 操作,C/C++ 申请到的内存不会进行置 0 操作)

置 0 操作是为了避免上次残留的数据被当前程序误使用

4. 解析(主要针对类中的字符串常量进行处理)

解析阶段是 Java 虚拟机将常量池内的 "符号引用(字符串常量,已经在 .class 文件中)" 替换为 "直接引用(里面保存了变量的地址)" 的过程,也就是初始化常量的过程

5. 初始化(针对类对象做最终的初始化操作)

执行静态成员的赋值语句

执行类中的静态代码块

针对 父类 也要加载

3. 面试题:双亲委派模型

3.1 概念

是类加载五个步骤中,第一个步骤里面的一个环节:给定 类全限定名,找到 对应的 class 文件位置

类加载器 JVM 中,已经内置了一些类加载器(JVM 中的功能模块),完成上述的 "类加载" 过程

JVM 默认有三个类加载器

(爷爷)BoorsrtapClassLoader 负责加载标准库中的类(标准库的类,也是有一个专门存放位置的)

(爸爸)ExtensionClassLoader 负责加载扩展类(JVM 厂商对 Java 功能做出的一些扩展)

(儿子)ApplicationClassLoader 负责加载第三方库中的类 / 自己写的代码中的类

tip:上面三者不是 Java 中父类子类的继承关系,而是在类加载器中有一个 parent 这样的引用指向父亲

3.2 双亲委派模型的工作流程

输入:类的全限定名(字符串),类似于 java.lang.String

输出:找到对应的 .class 文件

这样设定的最核心目的:防止用户自己写的类把标准库的类给覆盖掉

保证标准库的类被加载的优先级最高(扩展库其次,最后是第三方库)

4. 垃圾回收机制(GC)

4.1 引子

C/C++ 中,malloc/new 一个对象后,都需要手动释放内存 free/delete,如果不释放就可能产生内存泄漏(头号职业杀手,后果可能很严重,排查非常不好搞)

C 中,针对内存泄漏,直接摆烂了

C++ 中,针对内存泄漏,给出了一个方案:"智能指针"(然而它并不太智能)

在 Java 中对内存泄漏给出更系统更完整的解决方案(在 Java 之前也有一些语言使用了这样的方案)

垃圾回收,有了它,程序员可以放心大胆的 new,JVM 会自动识别哪些 new 完的对象再也不用了,就会把这样的对象自动释放掉

其他语言一看都觉得香,纷纷投入到 垃圾回收 的怀抱,如:Go、Python、PHP、JS...主流语言中大部分都是内置了 GC

但是,GC 也是有代价的,C++ 不是搞不了 GC,而是开发 C++ 的大佬们评估了之后,不愿承担这些代价

GC 需要 JVM 中引入额外的逻辑:

  1. 消耗不少 CPU 开销,进行垃圾的扫描和释放

  2. 进行 GC 的时候可能会触发 STW(Stop The World)问题,导致程序卡顿,对于性能要求高的场景就会影响很大

因此,GC 就会 提高开发效率,影响运行效率

4.2 Java 的垃圾回收

JVM 中有好几个内存区域,GC 主要负责的是 堆

其中 程序计数器和栈 是跟随线程的;元数据区(一个程序里面要加载的类都是有上限的,不会出现无限增长的情况)

垃圾回收,也就是回收内存,是以对象为维度进行回收的(回收对象)

4.3 GC 具体怎样回收

1) 先找出谁是垃圾

需要针对每个对象分别判定,是否为垃圾

在 Java 中使用一个对象,一般都是通过 "引用" 来使用的,如果一个对象没有引用指向了,就可以认为这个对象是垃圾了

方案一:引用计数

给每个对象分配一个计数器,衡量有多少个引用指向

每增加一个引用,计数器 + 1

每减少一个引用,计数器 - 1

当计数器减为 0,此时对象就是垃圾了

这样做是可以回收垃圾,但是假设 Test 类就只有一个 int 成员(4字节),此时为了引入引用计数,少说得搞个 short(2字节),内存多占用了 50%

上述引用计数方案在 Java 中(JVM)没有采纳,因为其存在两个问题:

  1. 消耗额外的空间

  2. 引用计数可能导致 "循环引用",使得上述的判定出错(和死锁类似),如下示例:

以上的循环引用也是有解的,但是需要引入更多机制(环路检测),代价就更大了


方案二:可达性分析(用时间换空间)

JVM 中专门搞了一些线程,周期性的扫描代码中的所有对象,判定某个对象是否是 "可达"(可以被访问到),对应的,不可达的对象就是垃圾了

如上图,JVM 中进行可达性分析的线程就是在做这样的事,从 root 出发,尽可能的通过 root 访问到更多的对象,相当于遍历的过程(严格来说,不是 "树的遍历",而是 "图的遍历")

可达性分析的起点称为 GC root,一个程序中 GC root 不是只有一个,而是有很多个,例如:

  1. 栈上的局部变量(引用类型)

  2. 方法区中,静态的成员(引用类型)

  3. 常量池引用指向的对象

把所有的 GC root 都遍历一遍,针对每一个都尽可能往下延申...

可达性分析是很消耗时间的,尤其是当程序中对象特别多的情况下


2) 释放垃圾的内存空间

a) 标记-清除(直接针对内存中的对应对象进行释放)

当标记到垃圾位置,并对其清除时,会引入 "内存碎片问题",哪些对象要释放是随机的,很可能要释放的位置对于整个内存来说不是连续的,虽然将上述内存释放掉了,但是整体这些空闲内存并没有连在一起,后续申请内存的时候,就可能申请不了(申请的内存一定是连续的)

例如:上述回收完垃圾后,空闲内存一共 3M,现要申请 2M 的空间时,就会失败(不考虑未分配空间)

因此,尽量避免内存碎片,是释放内存的关键问题


b) 复制算法

将内存一分为二,同一时刻只使用其中的一半

当要回收垃圾时,将不是垃圾的对象拷贝到另一侧(确保这些被拷贝的对象是连续的)

然后将要回收一侧的空间全部释放掉

虽然能解决 "内存碎片化" 的问题,但是其缺点也很明显:

  1. 内存空间利用率低

  2. 如果存活下来的对象比较多,复制成本也比较大


c) 标记-整理

非常类似于顺序表中删除中间的元素

缺点:这样搬运的开销依旧不小


d) 分代回收

JVM 中真正的解决方案是把上面几个方案综合一下,取长补短

JVM 根据对象的年龄,把对象区分成:新生代(年轻的)、老年代(年老的)

tip:可达性分析是周期性的,每经过一轮扫描,对象仍然存活(不是垃圾),年龄 + 1

根据经验规律:

  1. 绝大部分的新对象活不过第一轮 GC,留存下来的对象拷贝到幸存区

  2. 幸存区是两个相等的空间,也是按照复制算法反复进行多次

新生代中,真正要拷贝的对象不多(经验规律),内存利用率低

  1. 如果一个对象在幸存区已经反复拷贝多次,不是垃圾,年龄不断增长,达到一定程度之后,对象就要拷贝到老年代了

  2. 根据经验规律,老年代中的对象生命周期都比较长,老年代的对象肯定还会进行可达性分析,但是进行 GC 的频率就会降低

另外,老年代也是通过标记整理的(需要整理的次数不多)

4.4 JVM 中的垃圾回收器

分代回收是 JVM 中 GC 的基本思想方法,具体落实到 JVM 的实现层面上,JVM 还提供了多种 "垃圾回收器" 对上述的分代回收做进一步的扩充和具体实现

1. CMS GC

CMS 设计理念:把整个 GC 过程拆分成多个阶段,能和业务线程并发运行的,就尽量并发,尽可能减少 STW 的时间

2. G1 GC

G1 是把内存分成很多块,不同的颜色(字母)表示这一小块是新生代(伊甸区/幸存区)、老年代

进行 GC 的时候,不要求一个周期就把所有的内存都回收一遍,而是一轮 GC 只回收其中的一部分就好(限制一轮 GC 花的时间/工作量),使 STW 的时间在可控范围之内

这些方案的主要目的:降低 STW 的影响(ZGC,目前比较新的垃圾回收器),目前可以把 Java STW 的时间降低到 1ms 以内

相关推荐
ThisIsClark18 分钟前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试
王佑辉30 分钟前
【jvm】内存泄漏与内存溢出的区别
jvm
大G哥3 小时前
深入理解.NET内存回收机制
jvm·.net
泰勒今天不想展开3 小时前
jvm接入prometheus监控
jvm·windows·prometheus
东阳马生架构1 天前
JVM简介—3.JVM的执行子系统
jvm
程序员志哥1 天前
JVM系列(十三) -常用调优工具介绍
jvm
后台技术汇1 天前
JavaAgent技术应用和原理:JVM持久化监控
jvm
程序员志哥1 天前
JVM系列(十二) -常用调优命令汇总
jvm
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭1 天前
聊聊volatile的实现原理?
java·jvm·redis