JVM(Java虚拟机)

目录

什么是JVM?

JVM 的运行流程

JVM运行时数据区

堆(heap)

Java虚拟机栈(JVM Stacks)

本地方法栈(Native Method Stacks)

程序计数器(Program Counter Register)

元数据区(Metaspace)

JVM的类加载机制

加载

验证

准备

解析

初始化

双亲委派模型 

什么是双亲委派模型?

双亲委派模型的优点

如何打破双亲委派模型?

垃圾回收机制(GC)

垃圾识别

引用计数算法

可达性分析算法

垃圾回收

标记-清除算法

复制算法

标记-整理算法

分代算法


什么是JVM?

JVM Java Virtual Machine 的简称,意为 Java虚拟机

那么,什么是虚拟机?

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

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

VMwave 和 Virtual Box 是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器

而 JVM 则是通过软件模拟Java字节码的指令集,JVM 中只是保存了 PC 寄存器,其他的寄存器都进行了裁剪,也可以认为 JVM 是一台被定制过的现实当中不存在的计算机

那么,JVM 用来做什么呢?

Java源代码编译后生成字节码文件(.class文件),然后要在某个具体的系统平台上执行(Windows、Linux等),JVM 则负责将字节码文件转换成对应的 CPU能识别的机器指令

在上述过程中,JVM 相当于起到了 "翻译" 这样的作用

当我们编写和发布了一个 Java 程序,只需要发布编译后的 .class 文件即可,JVM 拿到 .class 文件后,就知道该如何进行转换,如:

Windows 上的 JVM 将 .class 文件转成 Windows 上能够支持的可执行指令

Linux上的 JVM 将 .class 文件转成 Linux 上能够支持的可执行指令

不同平台的 JVM 是存在差异的,但对上(为 Java 层面提供的内容)是一致的

Java程序通过 JVM 实现了 "一次编译,到此运行" 的特性,使得Java程序可以在任何安装了 JVM 的系统上运行,无需重新编译

当然,JVM 作为 Java 的核心组成部分,还提供了丰富的功能和机制,在本篇文章中,我们主要学习 JVM 的内存区域划分类加载机制垃圾回收算法

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

JVM 的运行流程

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

因此,总的来看,JVM 主要通过以下 4 个部分,来执行 Java 程序:

类加载器(ClassLoader)

运行时数据区(Runtime Data Area)

执行引擎(Execution Engine)

本地库接口(Native Interface)

JVM运行时数据区

JVM 其实也是一个进程,进程运行过程中,需要从操作系统申请一些资源(内存就是其中的典型资源),申请的内存空间,就支持了后续 Java程序 的执行

例如,在 Java 中定义变量,就需要申请内存,而这里的内存就是 JVM 从系统这边申请到的内存

JVM 从系统申请了一大块内存,在运行 Java 程序时,会根据实际的使用用途将内存分为不同的区域来管理程序的数据和执行过程(也就是所谓的 "区域划分"),这些区域统称为JVM运行时数据区域

JVM 运行时数据区 也叫做 内存布局,但它 和 Java内存模型(Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念

5 个部分组成:

1. 堆(heap)

2. Java虚拟机栈(JVM Stacks)

3. 本地方法栈(Native Method Stacks)

4. 程序计数器(Program Counter Register)

5. 元数据区(Metaspace)

堆(heap)

堆的作用:程序中创建的所有对象都保存在堆中,对象中持有的非静态成员变量,也保存在堆中

堆里分为两个区域:新生代(一个 Eden 和 两个 Survivor)和 老生代,新生代存放新创建的对象,经过一定 GC 次数后还存活的对象会放入老生代,关于这部分内容会在后续学习 垃圾回收 时再进行详细学习

堆只有一份,是 线程共享 

Java虚拟机栈(JVM Stacks

Java虚拟机栈的作用:Java虚拟机栈的生命周期和线程相同,Java虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个 栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口 等信息。(常说的 堆内存、栈内存中,栈内存就是指的虚拟机栈)

每个方法在执行时会创建一个栈帧,栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每次方法调用,JVM都会在虚拟机栈中压入一个新的栈帧,并在方法返回时将栈帧弹出

局部变量表:存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用,局部变量表所需的内存空间在 编译期间 完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表的大小,简而言之,局部变量表就是存放 方法参数 局部变量

操作栈:每个方法会生成一个 先进后出 的操作栈,用于存储计算过程中的中间结果

动态链接:指向运行时常量池的方法引用

方法返回地址:PC 寄存器的地址

每个线程在创建时都会同时创建一个对应的Java虚拟机栈,因此Java虚拟机栈是 线程私有 的,即每个线程有一份

本地方法栈(Native Method Stacks)

本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的

本地方法栈也是 线程私有 

程序计数器(Program Counter Register)

程序计数器的作用:程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,用于存储正在执行的指令的地址或者下一条即将执行的指令的地址

程序计数器也是 线程私有 

元数据区(Metaspace)

元数据区,在之前的 Java 版本中,也叫做 方法区 

什么是 元数据?  

元数据(meta data)指的是描述数据的数据,描述数据特征、内容、结构和其他属性的信息,帮助理解和管理数据,使得数据更易于被发现、理解、访问和使用

例如,在硬盘上不仅要存储文件的数据,还需要存储一些辅助信息,文件的大小、文件的位置、文件的修改时间、文件的权限信息等,这些数据,就统称为 元数据

元数据区的作用:主要用于存储类的元数据信息,包括类的结构信息、方法和字段的信息、常量池、静态变量等。

我们来通过简单的练习进一步理解内存区域的划分:

class Test {
    private int m;
    private static int n;

}

public class Main{
    public static void main(String[] args) {
        Test t = new Test();
    }
}

上述代码中,m、n、t 各自处于 JVM内存中的哪个区域?

 m 是 Test 的成员变量,因此处于

t 是一个局部变量(引用类型),因此,t 这个变量本身是在 上的

n 被 static 修饰,因此,属于 类属性,在类对象(Test.class)中,也就在 元数据区(方法区)

static 修饰的变量(方法),称为类属性(方法)

非 static 修饰的变量(方法),称为 实例属性(方法)

JVM 将 .class 文件加载到内存之后,就会把这里的信息使用对象来表示,此时这样的对象就是类对象,类对象中包含一系列信息(类的名称、继承自哪个类、实现了哪些接口、都有哪些属性、什么类型......)

要区分一个变量在哪个内存区域中,最主要的就是看这个变量的 "形态"(局部变量、成员变量、静态成员变量...)

普通的成员变量:堆

局部变量:栈

静态变量:元数据区(方法区) 

JVM的类加载机制

类加载,指 Java 进程运行时,将 .class 文件从 硬盘 读取到 内存,并进行一系列校验解析的过程

对于一个类来说,其生命周期为:

其中前 5 步是固定的顺序,也就是类加载的过程 (中间 3 步 都属于连接)

总的来说,类加载可以分为 5 个步骤:

1. 加载

2. 验证

3. 准备

4. 解析

5. 初始化

加载

加载 阶段,Java虚拟机需要完成三件事情:

1. 通过类的全限定名来获取定义此类的二进制字节流

2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构

3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

 简而言之,就是 将 硬盘 上的 .class 文件找到,打开文件,读取文件内容

验证

验证是连接阶段的第一步,这一步的主要目的是为了确保 class 文件的字节流中包含的信息符合 Java虚拟机规范 的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身安全

准备

准备阶段是正式为类中定义的变量(静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段

例如:

public static int m = 100;

初始化 m 的 int 值 为 0,而不是 100 

解析

解析阶段是针对类中的字符串常量进行处理,Java 虚拟机将常量池内的 字符引用 替换为 直接引用 的过程,也就是初始化常量的过程

如何理解  字符引用 替换为 直接引用

我们通过一个例子来进行理解:

public class Test {
    private String s = "abc";
}

在 .class 文件中既要包含 s 这个变量,又要包含 "abc" 

在 s 变量中相当于保存了 "abc" 字符串常量的 "地址",但在 文件 中,不存在 "地址" 这样的概念,虽然没有地址,但是可以存储一个类似地址——偏移量 这样的概念

此处文件中填充给 s 的 "abc" 的偏移量,就可以认为是 "符号引用"

当把 .class 文件加载到内存中,就会将 "abc" 这个字符串加载到内存中,此时 "abc" 就有地址了,接下来,s 中的值就可以替换成当前 "hello" 真实的地址了,也就是 "直接引用" 

初始化

初始化阶段,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权交给应用程序。初始化阶段就是执行 类构造器方法 的过程

其中,在加载环节,在硬盘上查找 .class 文件过程中,有一个重要的概念 —— "双亲委派模型"

双亲委派模型 

站在 Java虚拟机的角度来看,只存在两种不同的类加载器:一种是 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;另一种就是其他所有的类加载器,这些类加载器都由 Java 实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

而站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一些,自 JDK 1.2 以来,Java一直保持着三层类加载器双亲委派的类加载架构器

什么是双亲委派模型?

双亲委派模型,描述了如何查找 .class 文件的策略

JVM 中的类加载器默认是有 三个 的:

上述 三个类加载器,存在着 "父子关系"(不是 面向对象中 父类和子类之间的继承关系,而是类似于 二叉树 中,有一个 引用parent 指向自己的 父类加载器)

 

双亲委派模型的工作过程:

1. 从 ApplicationClassLoader 作为入口,开始工作

2. ApplicationClassLoader 不会立即搜索自己负责的目录,而是会把搜索任务委派给自己的 "父亲"

3. 代码就进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader 也不会立即搜索自己负责的目录,也要将搜索的任务交给自己的 "父亲"

4. 代码就进入到 BootstrapClassLoader 范畴了,由于 BootstrapClassLoader 没有 "父亲",此时就会开始搜索负责的目录(也就是标准库目录),通过全限定类名,尝试在标准库目录中找到符合要求的 .class 文件,若找到了,则直接进行到 打开文件、读文件的流程;若没有找到,则返回到 ExtensionClassLoader 继续尝试加载

5. ExtensionClassLoader 收到 BootstrapClassLoader 返回的任务后,就开始搜索负责目录(扩展库目录),若找到了,则直接进行到 打开文件、读文件的流程;若没有找到,则返回到 ApplicationClassLoader 继续尝试加载

6. ApplicationClassLoader 收到 ExtensionClassLoader 返回的任务后,就开始搜索负责目录(当前项目目录和第三方库目录),若找到了,则直接进行到 打开文件、读文件的流程;若没有找到,此时说明类加载过程失败(默认情况下 ApplicationClassLoader 没有 "孩子"),就会抛出 ClassNotFoundException 异常

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

双亲委派模型的优点

1. 避免类的重复加载:双亲委派模型规定,除了顶层的启动类加载器,每个类加载器在加载一个类之前会先委托给它的父类加载器尝试加载。这样一来,如果一个类已经被一个类加载器加载过了,那么它在整个类加载器层次中的其他类加载器都不需要再次加载,从而避免了类的重复加载

2. 安全:通过双亲委派模型,Java类加载器可以确保核心API类库不会被篡改。启动类加载器只加载JDK自带的核心类库,而不会加载应用程序的类,这样可以防止应用程序恶意修改核心类库

3. 可扩展:双亲委派模型支持自定义类加载器,用户可以根据需要定制自己的类加载器来实现特定的类加载策略

如何打破双亲委派模型?

打破双亲委派模型通常是出于某种特定的需求或特殊的应用场景,可以通过自定义类加载器来实现打破双亲委派模型的效果:

1. 自定义类加载器:编写自己的类加载器,继承自 ClassLoader 类。在自定义的类加载器中重写 loadClass(String name) 方法,覆盖双亲委派模型的默认行为,在加载类的时候,可以根据特定的逻辑判断是否使用父类加载器加载,或者直接自行加载

2. 破坏委派链:在自定义类加载器中,重写 findClass(String name) 方法来实现自定义的类加载逻辑,可以直接在该方法中读取字节码并定义类,不再调用父类加载器的加载方法

但需要注意的是,打破双亲委派模型可能会带来类加载的混乱和冲突,因此在使用时需要谨慎考虑

垃圾回收机制(GC)

在学习C语言动态内存管理时,我们通过 malloc 申请内存,free 释放内存,在实际开发中,很容易出现 free 忘记调用,或是因为一些情况没有执行到的情况,而若服务器每个请求都去申请一块内存,却没有释放,就会使得申请的内存越来越多,后续要想再申请内存就没有了,这也就出现了内存泄露问题

因此,Java就引入了 垃圾回收 这样的机制,让释放内存这样的操作由程序自动负责完成,而不需要依赖我们手工释放

在引入了这样的机制后,就不需要依靠手动进行释放了,程序会自动判定某个内存是否继续使用,若内存后续不用了,就会自动释放掉

垃圾回收中会有一个很重要的问题 STW(stop the world),也就是触发垃圾回收时,很可能会使得当前程序的其他业务逻辑被暂停,但现在,STW 的时间已经能够控制在 1ms 之内(一个服务器请求响应处理时间一般为 几毫秒到几十毫秒)

垃圾回收,我们首先要明白回收什么?

垃圾回收,回收的是内存

而在内存中,对于 程序计数器、虚拟机栈、本地方法栈 这些区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。且这三个区域的内存分配与回收具有确定性,当方法结束或线程结束时,内存就自然随着线程回收了

而对于 元数据区(方法区),一般都是涉及到 "类加载",而很少涉及到 "类卸载"

因此,垃圾回收,主要回收的是

堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些需要回收,因此,说是 内存回收,更准确的应该是 回收对象(每次垃圾回收时,会释放若干个对象,因此,垃圾回收的单位其实都是对象)

垃圾回收,分为两步:

1. 识别垃圾,判断哪些对象是垃圾(不再使用),哪些对象不是垃圾

2. 将标记为垃圾的对象的内存空间进行释放

 我们首先来看 垃圾识别,也就是死亡对象的判断

垃圾识别

在Java中,使用对象,都是通过 引用 的方式来使用的(但有一个例外,那就是 匿名对象,当匿名对象的代码执行完,对应的匿名对象也就会被当作垃圾)

因此,要判断一个对象是否是垃圾,需要判断是否还有引用指向它,若一个对象没有任何引用指向它,则就视为无法被代码使用,就可以视作垃圾了

要判断是否有引用指向对象,主要有两种算法:

1. 引用计数算法

2. 可达性分析算法

引用计数算法

顾名思义,引用计数,就是给对象增加一个引用计数器,每当有一个地方引用它,计数器 + 1,而当应用失效时,计数器 - 1。当计数器为 0 时,该对象就不能再使用了,此时,就被视为垃圾

引用计数法实现简单,判定效率也比较高,大部分情况下都是一个不错的算法,如 python 就是采样的引用计数法进行内存管理

JVM 没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决 对象的循环引用问题

我们通过一个示例来了解 对象的循环引用问题:

public class Test {
    public Test test;

}

class Main{
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        
        a.test = b;
        b.test = a;
        
        a = null;
        b = null;
    }
}

        Test a = new Test();
        Test b = new Test(); 

        a.test = b;
        b.test = a;

        a = null;
        b = null;

此时,两个对象的计数都不为0,不能被 GC 回收掉,但这两个对象又无法引用(类似于死锁)

可达性分析算法

Java 并不采用 引用计数法 来判定对象是否已 "死",而是采用 可达性分析 来判断对象是否存活

 可达性分析本质上是用 "时间" 换 "空间",相比于引用计数,需要消耗更多额外的时间,但总体来说还是可控的,且不会产生类似于 "循环引用" 这样的问题

在编写代码的过程中,会定义很多的变量(栈上的局部变量、方法区中的静态类型变量...),就可以从这些变量作为起点出发,尝试去 遍历,沿着这些变量中持有的引用类型的成员,再进一步的往下进行访问(搜索走过的路径称为 "引用链")

所有能访问到的对象,自然就不是垃圾,而剩下访问不到的对象,就是垃圾

例如:

此时令 a.right = null,则 c 和 f 都是不可达的

因此,它们会被判定为可回收对象

 JVM 中存在扫描线程,会不停的尝试对代码中已有的这些变量,进行遍历,尽可能多的去访问到对象

垃圾回收

通过上述方法我们可以将 "垃圾" 对象标记出来了,接下来,我们就可以将标记为垃圾的内存空间进行释放了,接下来,我们继续学习垃圾回收机器使用的几种算法

标记-清除算法

该算法就是将之前标记为垃圾的对象对应的内存空间直接释放掉

但是,这种释放方式会产生很多 小 且 离散 的 空闲内存空间(内存碎片)

若内存碎片太多就可能导致后面在程序运行中需要分配较大的对象时,无法找到足够的连续内存 

复制算法

复制算法将可用的内存按容量划分为大小相等的两块,每次使用其中的一块,当这块内存需要进行垃圾回收时,就会将此区域还存活着的对象复制到另一块上去,然后将已经使用过的内存区域一次性清理掉

这样做的好处是每次都是对整个半块区间进行内存回收,内存分配时也就不需要考虑内存碎片等复杂的情况,只需要移动堆顶指针,按顺序分配即可,实现简单,运行高效

但缺点也很明显,一次只使用一半的内存空间,总的可用内存就变少了,且若要复制的对象比较多,此时复制开销也会很大,若当前这一轮 GC 过程中,大部分对象都释放,少数对象存活,就适合使用复制算法

标记-整理算法

标记-整理算法也能解决内存碎片问题,其让所有存活对象都向一端移动,然后直接清理掉边界以外的内存

这个过程虽然不会像复制算法一样浪费过多的内存空间,搬运内存的开销很大

分代算法

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

JVM 中有专门的线程负责周期性的扫描、释放,一个对象,如果被线程扫描了一次,且可达(不是垃圾),其年龄 + 1(初始年龄为0)

Java根据 对象年龄(存活周期)的差异,将整个堆内存划分为两个大的部分:新生代(年龄小的对象)和 老年代(年龄大的对象)

而新生代又划分为一个 伊甸区(Eden)两个幸存区(生存区,Survivor)

当代码中创建出一个新的对象时,这个对象就被创建在 伊甸区,因此,伊甸区会有很多对象

但是,伊甸区的对象,大部分活不过第一轮 GC

因此,第一轮 GC 扫描完成后,少数伊甸区幸存的对象,通过 复制算法,拷贝到 幸存区

后续 GC 的扫描线程还会持续进行扫描(不仅要扫描伊甸区,也要扫描幸存区

幸存区中的大部分对象也会在扫描中被标记为垃圾,少数存活的,继续使用 复制算法,拷贝到另一个幸存区中

再次扫描,若继续存活,则再次使用复制算法拷贝到另一半幸存区

每经历一轮 GC 扫描,对象的年龄 + 1

若一个对象在幸存区中经历了若干轮(一般默认是 15 轮) GC 依然存活,JVM 就会认为这个对象的生命周期大概率很长,就会把这个对象从 幸存区 拷贝到 老年代

老年代的对象,当然也要被 GC 扫描,但扫描的频次就会大大降低

若老年代对象 "死亡",此时 JVM 就会按照 标记-整理 的方式,释放内存

  • 20
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

楠枬

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值