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 以内

相关推荐
请你打开电视看看33 分钟前
Jvm知识点
jvm
程序猿进阶1 小时前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
阿龟在奔跑13 小时前
引用类型的局部变量线程安全问题分析——以多线程对方法局部变量List类型对象实例的add、remove操作为例
java·jvm·安全·list
王佑辉13 小时前
【jvm】方法区常用参数有哪些
jvm
王佑辉13 小时前
【jvm】HotSpot中方法区的演进
jvm
Domain-zhuo14 小时前
什么是JavaScript原型链?
开发语言·前端·javascript·jvm·ecmascript·原型模式
Theodore_10222 天前
7 设计模式原则之合成复用原则
java·开发语言·jvm·设计模式·java-ee·合成复用原则
我是苏苏2 天前
Web开发:ORM框架之使用Freesql的DbFrist封装常见功能
java·前端·jvm
天草二十六_简村人2 天前
Java语言编程,通过阿里云mongo数据库监控实现数据库的连接池优化
java·jvm·数据库·mongodb·阿里云·微服务·云计算
老码沉思录2 天前
Android开发实战班 - 数据持久化 - Room 数据库应用
android·jvm·数据库