【Java-阔怕的JVM】JVM

我们要讨论的JVM主要是三个方面:

  1. JVM的内存区域划分
  2. 类加载机制
  3. 垃圾回收机制

JVM内存划分

为什么要进行内存划分?

JVM 就是java虚拟机,他是仿照真实的机器,也就是真实的操作系统进行设计的。

我们在真实的操作系统中,对于进程的地址空间是进行了分区域的设计的。

那么同理JVM也是仿照了这样的情况,也是进行了分区域的设计。


那么到底是怎么理解呢,其实我们就对照着这样的一个图来进行解释:

解释:首先我们有一个操作系统,那么这个操作系统中有许多个进程,其中有一个进程是java进程【也就是java虚拟机】,在这个java进程里面,操作系统将这个java进程划分了若干个内存区域,其中有部分内存是系统维护使用的,而有一部分是JVM从操作系统中申请来的,他也是JVM进程的代码自己来使用的,也就是说这样的话我们的JVM就可以在这部分空间里面在进行划分出一些小的空间,并将这些小的空间进行功能划分。

因此我们JVM内存划分就是相当于是JVM进程自身从操作系统中申请空间,然后再将内存空间进行不同功能的划分。


那么JVM向操作系统申请来的空间是怎么进行划分的呢?

  1. 程序计数器:这个是一个内存空间,他是一个很小的区域,主要用来记录或者说存储当前指令执行到哪一个地址了。

  2. 元数据区:用来保存当前类被加载好的数据。【什么意思呢?就是说我们的Java文件是.java,那么经过编译之后就变成了.class文件,这个.class文件是要放到内存中去跑的,那么使用什么样的内存来存这写东西呢,就是使用元数据区来存储这些加载的.class文件,这个过程叫做类加载,加载进去的.class文件就是我们所谓的类对象

  3. 栈:保存方法的调用关系

    为什么有栈呢?他有什么用呢?

    我们的方法调用是:每次调用方法,就会进入方法的内部执行,当方法执行完毕之后,就会返回到调用位置处继续往后走。这个样的一个逻辑需要我们使用栈来维护。一个栈中有很多的栈帧,每一个方法就是一个栈帧。

  4. 堆:保存new的对象


JVM中的栈和操作系统中的栈

首先我们要明确:JVM这个进程本身就是C++代码实现的程序,这个代码本省就是存在一系列方法调用的,所以我们JVM进程就是有一些C++上的方法调用,所以会有C++上的方法栈帧,那么这个栈就是操作系统原生的栈。这个栈我们称之为本地方法栈

然后在C++的代码中,有一些代码就构成了一个JVM虚拟机程序,然后我们通过这些C++代码,解释执行.class中的字节码,在解释执行字节码的时候又会涉及到Java的方法调用,那么通过上述的C++代码,又构造出一个Java的栈。这个Java的栈是由C++代码实现的所以自然无法被Java代码干预到,所以这个也是Java代码搞不了内存的原因。毕竟所谓的虚拟机就是仿照真实世界操作系统使用C++虚拟出一套虚拟的世界。这个栈我们称之为JVM栈

我们需要本地方法栈是因为,有时候在Java代码中会带用C++的代码,比如Thread.sleep这样的操作就是要调用操作系统原生的api的。


栈这样的空间在JVM中不算是很大的区域,一般就是几MB/几十MB(当然我们也可以通过JVM的启动参数类配置),我们大部分情况下这写个空间是够用的,少数情况下,可能会出现栈溢出的情况[StackOverFlow],比如你的递归代码写出了问题。


我们说堆这个空间是存new出来的对象的,那么所谓new出来的对象就是我们平时在代码中写的比如:Test test = new Test(); 这样的代码,那么此时我们要搞清楚一点,到底是哪个部分放在堆里面呢?是整个代码放在堆里面吗?其实不一定,首先我们十分肯定的是,new Test()是一定会放在堆里面的,但是对于前面的test我们是需要看成分的:

  1. 如果test是一个局部变量,那么test就是在栈上的。
  2. 如果test是一个成员变量的话,那么就是在堆上的。
  3. 如果test是一个静态成员变量的话,那么test就是在元数据区的。

堆是JVM中最大的空间区域,所有创建出来的对象都是放在堆中的,如果堆上的对象不再使用的话,就需要释放掉,这个就涉及到我们后面的垃圾回收了。


JVM运行时数据区

  • 元数据区中:元数据区 在Java8之前叫做方法区
    • 方法元信息和类元信息都是由类对象提供的。所谓的类元信息就是你这个类叫什么名字,你这个类里面有哪些属性,这些属性都是什么类型啥的;所谓的方法元信息就是你这个方法叫什么名字,以及你这个方法的签名是什么等等。比如:元信息指的就是一些属性,比如类元信息就是:类叫什么名字,是不是public,继承自哪些类,实现了哪些接口。方法元信息就是:方法叫什么名字,参数有几个,都叫啥,都是啥类型,返回值类型等等。
    • 常量池:用于存储咱们在代码中会经常用到的常量。
      注意:上述的图中,我们的元数据和堆是整个Java进程共用一份,但是程序计数器和栈是一个进程可能有多份的【每个线程有一份】也就是说一个线程就有一个栈。因为我们说程序计数器是用来记录地址的这个地址就代表我们将要执行的哪一个指令,而我们的每一个线程执行的都是自己的指令,线程和线程之间是并发执行的,我a线程执行的逻辑和你b线程执行的逻辑是两套不同的逻辑,所以此时我们就需要各自记录自己的指令地址,因此我们的程序计数器每一个线程都会有一份;同样的我们在线程执行的时候我们调用各种方法,调用方法的时候自然就涉及到我们方法的调用栈了,我们第一个线程和第二个线程他们调用方法的逻辑是不一样的,所以我们使用两个独立的栈进行保存,所以我们栈是每一个线程有一份。

JVM类加载

类加载本身是一个复杂的事情。站在我们的角度,对于类加载,主要关心两个方面:

  1. 类加载的步骤有哪些?
  2. 类加载中的双亲委派模型是怎么回事?

类加载触发时机

是不是Java程序一启动,就会加载我们用到的所有的类吗?并不是的!!

这里的思想就是使用的是懒汉模式/懒加载,也就是说只有我们的Java代码用到哪个类的时候才会触发这个类的类加载。比如:

  1. 构造这个类的实例
  2. 调用/使用类的静态方法或者静态属性
  3. 使用某个类的时候,如果他的父类还没有加载,那么也会触发父类的加载

类加载的步骤

在Java的官方文档上有对应类加载的描述,我们Java官方文档上,类加载这个过程主要分为三个大的阶段,其中第二个阶段有分成了三个步骤,所以一共有5个步骤。

  1. 加载:找到.class文件【这里的找的过程就是后面我们需要讨论的双亲委派机制】
    那么这个.class文件怎么找?
    我们是根据类的全限定名(包名+类名,形如:java.lang.String)去找,找到文件之后,我们就可以打开文件,然后读取文件中的内容到内存中。注意:这个加载他读到内存中的内容并未进行解析的,他文件的内容是啥样,他就是啥样。那么,你读到的内容对不对呢?我们需要还需要进行验证。
  2. 验证:解析校验这个.class文件读到的内容时候是合法的,并把内容转成结构化的数据。
    什么叫做将内容转为结构化数据呢?
    注意的是我们的.class文件虽然是二进制文件,但是他的格式是有明确的要求的。
    class 文件采用大端字节序(Big-Endian)存储,包含以下组成部分:
    魔数(Magic Number)
    版本信息(Version)
    常量池(Constant Pool)
    访问标志(Access Flags)
    当前类索引(This Class)
    超类索引(Super Class)
    接口索引表(Interfaces)
    字段表(Fields)
    方法表(Methods)
    属性表(Attributes)
java 复制代码
/*
u4:代表四个字节的无符号整数
u2:代表2个字节的无符号整数
cp_info,filed_info,method_info...代表其他的结构体
*/
ClassFile {
    u4 magic; //魔数:用于区分不同的二进制文件类型的
    u2 minor_version;
    u2 major_version;
    u2 constant_pool_count;//常量池信息
    cp_info constant_pool[constant_pool_count-1];//数组 常量池
    u2 access_flags;//当前类的权限
    u2 this_class;//当前类的编号
    u2 super_class;//当前类父类的编号
    u2 interfaces_count;
    u2 interfaces[interfaces_count];//当前类实现了哪些接口,接口里面的信息
    u2 fields_count;
    field_info fields[fields_count];//类属性信息
    u2 methods_count;
    method_info methods[methods_count];//方法信息
    u2 attributes_count;
    attribute_info attributes[attributes_count];//属性信息
}

我们在.java中写的代码最终都会转为这样的.class中。我们刚才提到的验证阶段,他就是读取这个.class文件的内容,验证这个内容是不是符合这个一套格式,如果符合要求才会继续往后走,如果不符合就会抛出异常。那么验证完毕没有问题之后我们进入第三个阶段:准备。

  1. 准备:他会去做一个工作就是给类对象 申请内存空间。我们类加载中最主要的目的就是得到类对象,即将.class文件变为一个类对象。次数申请的内存空间是一个全0的空间【也就是说我们只是申请了内存空间,但是没有对空间进行初始化】
  2. 解析:主要就是针对我们的字符串常量进行初始化。刚刚上述我们说到的.class格式中就有constant_pool常量池的信息。所以我们的字符串常量本身就是包含在.class文件中,所以我们就需要把.class文件中解析出来的字符串常量放到我们的内存空间里(也就是我们的元数据区常量池中)。
  3. 初始化:针对我们刚才所提到的类对象进行最终的初始化。对类对象中的各种属性进行填充,包括我们类中的静态成员【如果你的属性是static修饰的,那么他就是在元数据区,他的初始化时机就是在类加载中】,如果这个类还有父类,并且父类还未加载,那么此时也会触发父类的类加载。

双亲委派机制

双亲委派模型描述了类加载中,根据全限定类名找到.class文件的过程。

其实双亲委派模型更准确的名字应该是单亲委派模型 或者是父亲委派模型

我们谈到的类加载这样一个过程,他在JVM是有一个专门的模块来负责的,负责类加载,那么这个模块我们称为是类加载器

JVM中提供了三种类加载器:

  1. BootstrapClassLoader
  2. ExtensionClassLoader
  3. ApplicationClassLoader
    这三个类加载器存在着父子关系:BootstrapClassLoader是爷爷一辈的, ExtensionClassLoader是父亲一辈的,ApplicationClassLoader是孩子一辈的。那么注意的是,此处的父子关系 不是父类和子类 关系,而是通过parent这样的引用来指向的。类似于构成链表这样的一个结构,如下图所示:

那么明确了这样的一个父子关系之后,我们具体在类加载的时候,他们起到了一个什么样的作用呢?其实这三个类加载器,首当其冲的就是要进行找.class文件环节,也就是我们会给定一个全限定类名如java.lang.String类似于这样的一些全限定类名,通过类名去找对应的.class文件。

此处这三个不同类加载器他们最主要的区别就是:他们负责去找的那个目录的范围是不一样的。

其中:

  • 爷爷这一层BootstrapClassLoader负责找的是Java标准库中的目录,也就是说如果你现在要加载的类是Java标准库中的类,那么这个标准库中的类就是由BootstrapClassLoader负责加载的。
  • 父亲这一层ExtensionClassLoader主要就是负责加载Java扩展库的目录,扩展库是JVM产商对于Java的库做的补充。但是现在这些扩展库我们已经很少用了,我们更多的是使用第三方提供的库,那么第三方的库需要孩子那一层去加载。
  • 孩子这一层ApplicationClassLoader主要就是负责加载Java的第三方库或者就似乎当前项目中我们自己写的类

双亲委派模型的过程

我们在进行类加载的时候,通过全限定类名找那个.class的时候,他就会从ApplicationClassLoader作为入口开始,然后把加载类这样的任务委托给他的父亲来进行,也就是委托给ExtensionClassLoader,那么ExtensionClassLoader也只是类似,他也不会立即就进行查找,而是他也会委托给他的父亲来进行,也就是委托给BootstrapClassLoader,那么BootstrapClassLoader也想委托给自己的父亲,但是由于他没有父亲,所以他只能自己进行类加载,所以BootstrapClassLoader他就会根据类名,找标准库范围内容,看是否有存在匹配的.class文件。那么BootstrapClassLoader没有找到对应的.class文件,那么此时就再将这个找文件的任务还给他的孩子ExtensionClassLoader,于是接下来ExtensionClassLoader就来复杂进行找.class文件的过程,如果是找到就加载,如果没找到,那么ExtensionClassLoader就将任务还给他的孩子ApplicationClassLoader,于是接下来ApplicationClassLoader就去负责找这个.class文件,如果找到了就加载,如果没有找到就抛出异常。

上述给出的这三个类加载器是属于JVM自带的,那么其实我们是可以自定义类加载器的,当你自定义的时候,就可以将你的类加载器也放到双亲委派模型中。

🌏疑问:按照上述的流程,都是丢给BootstrapClassLoader的,那么要下面ApplicationClassLoader和ExtensionClassLoader这两个有什么用?直接将BootstrapClassLoader作为入口不行吗?

答:这样的疑问我觉得是不太有意义的,上述这样的一套体系,目的是为了约定优先级,也就是我们希望的是收到一个类名之后,一定是先在标准库中找,再到扩展库中找,最后才是到第三方库或者项目目录中找的。那么其实上述的流程人家JVM源码中就是这样写的,所以这样的问题其实是没有多大意义的。


垃圾回收(GC)

垃圾回收简称GC,他是我们Java中释放内存的手段。

学过C语言的老铁都知道,C语言中我们申请内存是通过malloc,申请之后,我们一定是需要手动调用free进行释放的,否则就会出现内存泄漏。

这样的方式其实是对我们不太友好的,因为free我们很可能不小心就忘记了。

因此针对这个问题,Java认为手动释放内存太麻烦了,太容易出错了,因此Java引入了垃圾回收机制,对内存进行自动释放,也就是说JVM自动识别出某个内存是不是后续不再使用了,如果是,那么就将他自动释放。


那么垃圾回收是回收哪一步分的内存呢?

GC回收的是JVM中 内存区域。

那么,我们说JVM中内存划分有四个:程序计数器,栈,堆,元数据区。那么你说为什么GC就是针对堆区回收的呢?因为程序计数器我们的线程销毁,他自然就释放了,栈的话方法执行结束栈帧就结束了,也随之释放了,对于元数据区我们存的是类对象,类对象一般是不会释放的,我们的类加载就是加载了,我们一般不会去释放他。而在堆中我们会时不时的创建很多新的对象,此时也会有就对象的消亡,我们的GC就是回收这些对象的。


那么垃圾回收GC是怎么工作的?

  1. 找到垃圾(就是不再使用的对象)
  2. 释放垃圾(就是将对应的内存释放掉)

找到垃圾

那么我们是怎么找到垃圾的?或者怎么判定某个对象是不是垃圾呢?这里其实我们有两点方案:

  1. 引用计数【python,php采用了这个方案】

    就是给每个对象在new的时候,都搭配一个小的内存空间,来保存一个整数[引用计数],这个整数就表示当前对象有多少个引用指向他,如果有一个对象指向他,那么引用计数就是1,如果是两个对象指向他,那么引用计数就是2,例如:

    每次我们进行引用赋值的时候,都会自动触发引用计数的修改,此时我们就通过引用计数记录有多少个引用,那么这样的引用计数有什么用呢 ?在Java中,我们想要通过使用某个对象,那么一定是通过引用来完成的,但是如果引用计数为0了,那么就是代表没有引用指向这个对象了,此时这个对象就是垃圾。

    而引用计数这样的方案也有一些缺陷如下:
    (1).消耗的内存更多 :毕竟我们是为每一个对象都开辟一个空间来记录这个对象的引用次数,你有几个对象就得有几个这样的空间,尤其是对象本身比较小的话,那么计数消耗的空间的比例就会大一些。假设引用计数是4个字节,对象本身是8个字节,那么引用计数就相当于提高了50%的空间占用率。
    (2).可能会出现"循环引用"这样的问题 :那么对于什么是循环引用呢?在下述图中有描述:

    1)我们的代码所对应的内存图如下所示

    2)那么现在我有代码:a.t=b;b.t=a;那么对应的内存图如下,观察可知这两个对象的引用计数都是2

    3)那么接下来我们将a对象和b对象置为null,此时按理来说a对象和b对象是不会被引用了的,引用对象理应为0,但是却出现为1的情况,并且出现了循环引用,此过程就是引用计数的缺陷

  2. 可达性分析【Java采用了这个方案】

    刚刚我们说引用计数是有空间开销的,那么我们的可达性分析是用时间来换空间,也就是确实不像引用计数一样有空间的损耗,但是他有时间上的损失。
    那么可达性分析他大概是怎么做的呢?

    (第一步):以代码中的一些特定对象,作为遍历的"起点",这样的起点我们成为GCRoots。

    那么具体来说什么是特定的对象呢?比如说:

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

    2)常量池引用指向的对象

    3)元数据区中的静态成员(得是引用类型)

    (第二步):以上述那几个特定的对象,即以他们作为起点,从这些起点出发尽可能的进行遍历,然后来判定某个对象是否能够访问到。什么意思呢?简图如下所示:

    也就是说我们从引用T出发,指向他所引用的对象,再看这个对象中时候还有其他成员引用其他对象,如果有那么我们就进一步指向,这个过程就是我们所谓的可达性分析,每一次访问到一个对象,都会将他标记为可达。

    (第三步):每一次访问到一个对象,都会把这个对象标记为可达。那么当我们完成所有的对象的遍历之后,未被标记可达 的对象就是不可达 ,因为我们JVM中一共有多少个对象,JVM自身是知道的,那么现在我们通过可达性分析知道了哪些是可达的,那么剩下的就是不可达的,而这些不可达的对象,就是接下来我们要回收的垃圾。如此一来就解决了内存占用和循环引用的缺陷

释放垃圾

经过上述当我们已经知道哪些对象是垃圾了,那么我们该怎么释放呢?

  1. 标记-清除【或者叫直接释放】

    就是把垃圾对象的内存直接进行释放,不做特殊处理,但是这样做会产生一个内存碎片 问题。

    什么意思呢?如下图所示,我们将垃圾对象进行直接释放之后,你会发现这些空闲空间不是连续的,这个是其实比较关键的问题,虽然你有空闲空间,但是不连续,这个就会给我们下一次申请内存带来一定的麻烦,因为我们申请内存都是申请连续的内存的,比如申请1MB内存空间,他必须得是连续的,不能是多个部分拼到一起的,所以你的内存碎片如果是非常多的话,那么你的总的空闲空间虽然很大,但是但凡想申请一个稍微大一点的内存都会失败的,比如你总的空闲内存是4G,但是如果此时出现上述的情况, 那么就会使得申请1G空间都可能失败的。

    那么既然有了这个问题,我们怎么解决呢?此时解决的方案就是我们的第二种方案:复制算法。

  2. 复制算法

    在这个算法里面,如下图:

    他首先会把这个空间给一切俩段,分成两个一样大小的空间,然后他在使用的时候,一次只使用其中的一半,比如我只是使用左边的一半,在这个里面我们去分配一些对象1,2,3,4,5,6,那么假设根据可达性分析,我们的2,4,6对象是垃圾,那么我现在就不直接释放2,4,6对象了,我将那些不是垃圾的对象给他拷贝到另外一侧,然后再把这一侧给他整体的释放掉。

    未来如果我们再创建新的对象的话,我们就在右半边创建即可。此时如果我再经过可达性分析,找出了右侧中的垃圾,那么此时我就先将非垃圾对象拷贝到左侧半边,再将右侧整体释放掉即可。如下图:

    此时安装这个复制算法的方案,我们就可以确保了内存是连续的,不会出现内存碎片的问题。但是这样的方案也有缺陷:
    (1)内存的空间利用率很低 ,比如由于我们只用其中的一半,所以2G的内存变为1G,所以空间利用率不高。
    (2)一旦我们不是垃圾的对象较多,那么我们复制的成本就会很高 ,尤其是这样的非垃圾对象很大的时候就不太适合复制了。

    那么有没有什么办法既可以保证不会出现内存碎片,又可以保证没有内层利用率低和复制成本的问题呢?这个就是我们的第三种方案:标记-整理。

  3. 标记-整理

    优点:解决了内存碎片,并且保证了内存利用率。

    比如我们现在还是上述的内存空间,但是我们不像刚才一样分成两半了,而是把这里面的对象给他弄成类似于顺序表搬运 的一个过程,也就是如下图所示:我删除2,4,6元素然后的话后面的元素就依次向前填充。

    这样做的话其实也是有缺陷的,他的缺点其实也就是顺序表的删除缺点,就是内存搬运数据的时候,开销是很大的。复制成本的问题是任然存在的。


以上的几种方案都不是特别理想,然后我们具体实际是怎么做的呢?我们Java使用的是分代回收 ,你可以理解为上述三种方案就是理论的,而具体的实现的时候我们是将上述三种方案进行一个结合起来,扬长补短【主要就是将方案2和方案3结合起来】。
分代回收

那么什么是 呢?

在Java中代就是对象的年龄,而且对象的年龄我们不是使用几岁来表述,而是使用轮次 作为描述单位的,也就是说我们使用GC轮次来描述对象的年龄。比如某一个对象,经过一轮GC可达性分析之后,不是垃圾,那么此时对象的年龄就+1。

那么我们就根据对象不同年龄的大小将堆空间分成新生代老年代

然后我们针对不同的年龄的对象采取不同的释放策略,因为不同年龄的对象,他们的存活特点是不同的。有这样的一个经验规律或者说是现象:

  • 如果某个对象已经是年龄大的对象了,那么此时大概率这个大龄对象还会继续存在很久,说白了,他之所以存活那么久,是因为他是有用的,我们后面还可能会继续使用他,因此这种对象我们是放在老年代的,而且正因为他可能存活久,所以你使用可达性分析在老年代扫描半天基本不会标记出多少对象要释放,所以在老年代我们使用的释放策略都是上述中的方案三:标记-整理,因为他解决了内存碎片,并且保证了内存利用率,同时还不会出现大规模的搬运开销【因为释放的对象少】。老年代的GC频次比较低。
  • 如果一个对象还是小年轻,那么这个对象很可能就会快速就嘎掉了,这样的规律是经验规律或者现象就是这样的,那么话说回来,正是如此所以新生代GC的频次就会比较高。在新生代中我们又对内存进行了进一步的划分:伊甸园区,以及两块相同的空间幸存区 ,他们的比例是8:1:1。

    然后我们新产生的对象放在伊甸区 ,绝大部分对象在伊甸区是都是活不过第一轮GC的,只有少数活过的话就会放到幸存区,正是因为如此,所以我们的伊甸区空间大,而幸存区的空间小,那么我们伊甸园区到幸存区的过程使用的释放对象的策略是:复制算法。因为复制的对象规模很少,复制的开销是可控的。幸存区中的对象也要GC的扫描,每一轮GC都会消灭一大部分的对象,剩余的对象再次通过复制算法复制到另外一个幸存区。

那么如果这个对象在幸存区中经历了多次的复制,都存活下来了,那么这个对象的年龄就大了,此时就会晋升到老年代中。

简单总结就是:

伊甸区==》幸存区==》幸存区==》...》幸存区》老年代

  • 新生代中:使用复制算法,因为新生代中的对象大部分是会快速消亡的,所以每一次复制的开销是可控的
  • 老年代中:使用的是标记-整理,因为老年代中的对象大部分会生命周期很差,所以我们整理的开销也是可控的。

总的来说Java释放对象,采取的是"分代回收"的综合性策略。

OK,那么至此,我们的JVM就先聊到这儿。

相关推荐
2301_780669862 小时前
UDP通信(一发一收,多发多收)、TCP通信(一发一收,多发多收、同时接收多个客户端的消息)、B/S架构的原理
java·tcp/ip·udp
小冷coding2 小时前
工作流是什么呢?
java·面试
不绝1912 小时前
MonoBehavior/GameObject/Time/Transform/位移/角度旋转/缩放看向/坐标转换
开发语言·python
凯子坚持 c2 小时前
Qt常用控件指南(4)
开发语言·qt
像少年啦飞驰点、2 小时前
零基础入门 Redis:从缓存原理到 Spring Boot 集成实战
java·spring boot·redis·缓存·编程入门
代码无bug抓狂人2 小时前
C语言之切蛋糕(运用前缀和算法和单调队列算法)
c语言·开发语言
量子炒饭大师2 小时前
【C++入门】Cyber骇客构造器的核心六元组 —— 【类的默认成员函数】明明没写构造函数也能跑?保姆级带你掌握六大类的默认成员函数(上:函数篇)
开发语言·c++·dubbo·默认成员函数
虾说羊2 小时前
Springboot中配置欢迎页的方式
java·spring boot·后端
漫漫求2 小时前
Go的panic、defer、recover的关系
开发语言·后端·golang