JVM(内存区域划分、类加载机制、垃圾回收机制)

一. 内存区域划分

正如一个公司的运作模式,首先公司需要一块场地,再接着需要根据公司的各个部分,将这块场地划分成一块一块的部门,每个部门都有独立的功能。

JVM的内存区域划分也是如此,JVM启动的时候会申请一整个很大的内存区域(JVM是个应用程序,要从操作系统中申请内存),接着根据需求,将整个空间分成几个部分。

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

native表示JVM内部的C++代码(JVM是用C++写的),所以本地方法栈就是给调用native方法(JVM内部方法)准备的栈空间

问题:C++代码是不是不用跑在虚拟机上 答:像C、C++、Go、Rust都是把代码变成成native code,也就是可以直接被cpu识别的机械指令 而Java、Python、PHP为了跨平台,都是统一翻译成指定的字节码(字节码都是一样的),然后由对应的虚拟机转换成机械指令

2.虚拟机栈(JVM Stacks)

给Java代码使用的栈

注意:stack栈,在之前数据结构中也学习过,数据结构中的"栈"是"后进先出"的,和这里所说的栈不是一个东西。

这里所提的栈,是JVM中的一个特定的空间。

  • 对于JVM虚拟机栈,这里存储的是方法之间的调用关系
  • 对于本地方法栈,存储的是native方法之间的调用关系

整个栈空间内部,可以认为是包含很多个元素,每个元素表示一个方法,把这里的每个元素,称之为"栈帧",这一个栈帧里,会包含这个方法的入口地址、方法参数、返回地址、局部变量.....

但是由于函数调用中,也有"后进先出"的特点,因此此处的栈也是后进先出的,只是数据结构中的栈是一个更通用的,更广泛的概念,而JVM中的栈是特指JVM上的一块内存空间。

从内存划分图中也可以看出,这里的栈有多个,每个线程有一个(线程是一个独立的执行流),使用jconsole(jdk下bin目录中)可以查看java进程内部的情况,点击线程就可以看到线程调用栈的情况。

问题:这些线程每次创建都分相同空间,那么栈会不会被很快就用完了? 答:栈的整体大小一般都不会很大,但是每个栈帧的大小也是比较小的,遇到无限递归的代码会遇到栈溢出的情况,就正常写代码,创建销毁线程一般是不需要担心这个问题的。

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

记录当前线程执行到哪条指令(很小的一块存一个地址),也是每个线程有一份。

4.堆(Heap)

堆是整个JVM空间的最大的区域,new出来的对象,都是在堆上的,类的成员对象也就在堆上了

  • 堆 是一个进程只有一份的

  • 栈 是每个线程有一份,一个进程有多个线程

  • 堆,多个线程用的都是同一个堆

  • 栈,每个线程用自己的栈

每个jvm就是一个java进程

5.元数据区(Metaspace)

以前这个区域叫做方法区,java8开始改为了元数据区 这里存储的有类对象、常量池、静态成员

元数据区也是一个进程一份,多个线程共用这一份

问题:final修饰的变量在这里存储吗? 答:不一定,有一定概率被优化成字面值常量,也有可能没优化

总结

  • 局部变量在栈
  • 普通成员变量在堆
  • 静态成员变量在方法区/元数据区

二.类加载机制

准确的来说,类加载就是将.class文件,从文件(硬盘)被加载到内存中(元数据区)这样的过程。

1.加载

  • (1).通过一个类的全限定名来获取这个类的二进制字节流
  • (2).将这个字节流多代表的静态存储结构转化成方法区的运行时数据结构
  • (3).在内存中生成一个代表这个类的java.lang.Class对象

但是对应上述的要求并不是具体的,JVM的实现过程和应用是比较灵活的,比如获取类的二进制字节流,并没有说明如何获取,所以就有了从压缩包中获取(jar、war、ear),从网路中获取(Applet),运行时计算生成(动态代理)

对于不是数组的类的加载我们可以自定义去控制字节流的获取方式,而数组类就不一样了,因为数组类本身不是通过类加载进行创建的,而是由JVM直接创建

2.验证

根据jvm虚拟机规范,检查.class文件的格式是否符合要求

3.准备

给类对象分配内存空间(此时内存初始化成全0),在这个阶段里,为静态变量分配内存并设置静态变量初始值。这里说的初始值通常情况下,不是代码中写的初始值,而是数据类型的零值。代码中写的初始值,是在初始化阶段赋值的。如果是静态常量(被final修饰),这个阶段就会被直接赋值为代码中写的初始值

4.解析

针对字符串常量进行初始化,把符号引用转为直接引用

字符串常量,需要有一块内存空间存这个字符的实际内容,还需要一个引用,来保存这个内存空间的起始地址。 在类加载之前,字符串常量是处在.class文件中的,这个时候没有地址这个概念,此时这个"引用"记录的并非是真正的地址,而是他在文件中的"偏移量"(或者是个占位符)。 在类加载之后,才真正把这个字符串常量给放到内存中,此时才有"内存地址",这个原因才能真正赋值成指定的内存地址。

5.初始化

真正针对类对象里面的内容进行初始化,加载父类、执行静态代码块的代码.....

总结

问题:一个类,什么时候会被加载呢? 答:不是java程序一运行就将所有的类都加载了,而是真正用到的时候才加载(赖汉模式),比如构造类的实例、调用这个类的静态方法/使用静态属性、加载子类,就会先加载父类。 而一旦加载过后,后续再使用就不需要重新加载了。

"双亲委派模型"

JVM 默认提供了三个类加载器

  • BootstrapClassLoader 负责加载标准库中的类
  • ExtensionClassLoader 负责加载JVM扩展库中的类
  • ApplicationClassLoader 负责加载用户提供的第三方库,用户项目代码中的类

而上述三个类存在"父子关系",BootstrapClassLoader为最大,ApplicationClassLoader为最小。

此处的"父子关系",不是父类、子类,而是每个ClassLoader中都有一个parent属性指向自己的父 类加载器

上述三个类加载器的工作模式:

首先加载一个类的时候,先从ApplicationClassLoader开始

但是ApplicationClassLoader会把加载任务交给父亲,于是ExtensionClassLoader就会去加载,但是也不是真加载,而是再委托自己的父亲去加载

于是BootstrapClassLoader就去加载,他也想委托给父亲,但是发现父亲为null,所以就由自己加载,那么BootstrapClassLoader就会搜索自己负责的标准库中的类,如果找到就加载,如果没找到就交给自己的子类进行加载

于是ExtensionClassLoader就去加载,搜索自己负责的JVM扩展库中的类,找到则加载,找不到就交给子类

于是ApplicationClassLoader进行加载,搜索用户用的第三方库和项目中的类,找到则加载,找不到的话由于自己没有子类,就只能抛出类找不到这种异常

使用这个顺序进行,主要的目的据说为了保证Bootstrap能够先加载,Application能够后加载,这就避免了用户创建一些奇怪的类而导致的bug

比如用户写了一个java.lang.String这个类,此时能保证JVM加载的还是标准库中的类,而不会加载到用户写的这个类,这样就能保证即使出现上述问题,也不会让jvm的代码混乱,最多就是用户写的代码不生效

三. GC 垃圾回收机制

首先要知道的是在JVM中进行垃圾回收的是"堆",并且GC是以"对象"为基本单位进行回收的

GC回收的是整个对象都不再使用的情况,而那种一部分使用,一部分不使用的对象先不回收了(一个对象里有很多属性,可能其中10个属性后面要用,10个属性后面再也不用了)

GC实际工作过程

1.找到垃圾/判定垃圾

关键思路在于看看到底有没有"引用"指向它,Java中使用对象只有一条路,通过引用来使用,如果一个对象有引用指向它那么就有可能被用到,如果没有引用指向它那么就不可能被用到

那么怎么知道对象是否有引用指向?

(1).引用计数(Java没有使用)

给每个对象分配一个计数器(整数),每次创建一个引用指向该对象,计数器就+1,每次该引用被销毁了计数器就-1

问题:这种方法很好理解,但是为什么Java不使用呢?

答: 1.引用计数内存空间浪费的多,每个对象都要分配一个计数器,计数器按照4个字节算,代码中对象非常少无所谓,怕的就是对象特别多,并且每个对象都比较小,那么这个时候占用的额外空间就会很多(一个对象的体积是1k,多4个字节无所谓,一个对象体积是4个字节,再多4个字节相当于体积扩大一倍)

2.存在循环引用问题

(2).可达性分析(Java使用的)

Java中的对象,都是通过引用来指向并访问的,经常是一个引用指向一个对象,这个对象里的成员又指向别的对象

ini 复制代码
class Node{
    public int val;
    public Node left;
    public Node right;
}

public class Test {
    public static Node build(){
        Node a = new Node();
        Node b = new Node();
        Node c = new Node();
        Node d = new Node();
        Node e = new Node();
        Node f = new Node();

        a.val = 1;
        b.val = 2;
        c.val = 3;
        d.val = 4;
        e.val = 5;
        f.val = 6;

        a.left = b;
        a.right = c;
        b.left = d;
        d.left = e;
        return a;
    }

    public static void main(String[] args) {
        Node root = new Node();
        //此时这个root就相当于根节点
        //当前代码只有一个引用root,但是却管理了n个对象
    }
}

整个Java中的所有对象,就通过类似这种树形/链式结构,整体串起来

可达性分析,就是把所有这些对象组织的结构视为树,从根节点出发,遍历树,所有能被访问到的对象就标记为"可达"(不能被访问到的,就是不可达)

此处的图中虽然只有root引用,但是上述6个对象都是可达的

  • root-->a
  • root.left-->b
  • root.right-->c
  • root.left.left-->d
  • root.left.left.left-->e

小结:可达性分析需要进行类似于"树遍历"整个操作相比于引用计数来说肯定要慢一些的,但是速度慢没关系,上述遍历操作,并不需要一直执行,只需要每隔一段时间分析一遍即可

进行可达性分析遍历的起点,称为GCroots

1.栈上的局部变量

2.常量池中的对象

3.静态成员变量

一个代码中可能有很多这样的GCroots,把每个起点都往下遍历一遍就完成了扫描过程

2.如何清理垃圾

(1).标记清除

标记清理的特点是简单粗暴,但是会导致内存碎片化问题,被释放的空闲空间是零散的,不是连续的

但是我们申请内存的要求是需要连续的空间,总的空闲空间可能很大,但是每个具体的内存空间可能很小,可能导致申请大一点的内存就失败了

例如总的空闲空间是10k,分成1k一个,一共十个,此时如果需要申请2k的空间,那么就会申请失败

(2).复制算法

复制算法解决了内存的碎片化问题

复制算法把整个内存分为两半,用一半丢一半

把不是"垃圾"的对象复制到另外一半内存空间,然后把之前的一半整个空间删掉

若是后续再触发GC,那么就在右边这一半进行复制算法到左边即可

缺点:空间利用率低下,如果垃圾少,有效对象多,那么复制成本就比较大了

(3).标记整理

标记整理解决了复制算法的缺点

类似于顺序表中的删除中间元素,有元素搬运的操作,这样既保证了空间利用率,也解决了内存碎片化问题

但是,很明显,这种做法,效率也不高,元素搬运如果需要搬运的空间大,那么开销也大

上述的三种方法都有其对应的缺点,那么有没有比较完美的办法呢?

"分代回收"

把垃圾回收,分成不同的场景,对应不同的场景使用上述不同的办法

那么如何定义上述的不同场景呢?也就是分代是如何分的?

这里的分,是基于一个经验规律:如果一个东西(对象),存在的时间长了,那么大概率还会继续的长时间存在下去(比如C语言从197x年就开始诞生了,到如今还依然存在并且活跃,那么我们就有理由相信他还会存在很长时间),而上述规律对应Java中的对象也是有效的,java对象要么就是生命周期特别短,要么就是特别长

根据上方的规律,我们给对象引入一个概念"年龄"(这个年的单位是熬过的GC的轮次),年龄越大那么存在的时间就越久

刚new出来的对象,年龄是0的对象,放到伊甸区(出自圣经,上帝在伊甸园造小人)

熬过一轮GC,对象就要被放到幸存区了,那么伊甸区-->幸存区就使用复制算法(将有效对象复制到幸存区,伊甸区整体释放)

到幸存区后,也要接受GC的考验,如果变成垃圾就要被释放,如果不是垃圾,那么就拷贝到另一个幸存区(这两个幸存区,同一时刻值用一个,在两个幸存区反复横跳),所有这里也是使用复制算法(由于幸存区的体积不大,此处浪费的空间也能接受)

如果这个对象在幸存区中GC了很多次了,那么这个时候就进入到老年代,老年代都是年龄大的对象,生命周期普遍更长,那么针对老年代进行的GC扫描频率就低了,如果老年代的对象是垃圾了,那么就使用标记整理的方式进行释放

小结:

上述的GC中典型的垃圾回收算法:如何确定垃圾、如何清理垃圾,这里的策略,实际上在JVM实现的时候,会有一定的差异,JVM有很多的"垃圾回收实现"称为"垃圾回收器",回收器的具体实现做法,会按照上述算法思想展开,不同的垃圾回收器侧重点不同,有的追求GC扫描快、有点追求扫描好、有点追求用户打扰少(STW短).....

相关推荐
HUNAG-DA-PAO3 小时前
Spring AOP是什么
java·jvm·spring
No regret.4 小时前
JVM内存模型、垃圾回收机制及简单调优方式
java·开发语言·jvm
东阳马生架构13 小时前
JVM实战—2.JVM内存设置与对象分配流转
jvm
撸码到无法自拔15 小时前
深入理解.NET内存回收机制
jvm·.net
吴冰_hogan1 天前
JVM(Java虚拟机)的组成部分详解
java·开发语言·jvm
东阳马生架构2 天前
JVM实战—1.Java代码的运行原理
jvm
ThisIsClark2 天前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试
王佑辉2 天前
【jvm】内存泄漏与内存溢出的区别
jvm
大G哥2 天前
深入理解.NET内存回收机制
jvm·.net
泰勒今天不想展开2 天前
jvm接入prometheus监控
jvm·windows·prometheus