[JVM] 笔记

127 阅读14分钟

这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战

参考

juejin.cn/post/685695…

blog.csdn.net/ok3356/arti…

juejin.cn/post/694124…

juejin.cn/post/684490…

juejin.cn/post/698435…

运行时数据区域

这部分分为5个部分,如下,其中:

  • 堆,方法区为共享区域
  • 虚拟机栈,本地方法栈,程序计数器是线程私有区域

这些部分构成了运行时所需要的所有动态数据


graph LR
共享区域-->堆
共享区域-->方法区
私有区域-->虚拟机栈
私有区域-->本地方法栈
私有区域-->程序计数器

共享区域

堆放的是程序运行时创建的所有实例对象不存放基本类型和对象引用

  • 也就是说,这里放的是实际分配给对象的动态内存空间。
  • GC主要工作区域就在这里。

由于是共享区域,因此有一个问题:在这上面的工作,是线程不安全的

  • 由于内存有限,因此这里会抛出OOM异常。

java堆区,出于GC以及相关考虑,一般划分如下:

pie
title 堆划分
    "新生代-eden" : 26.4
    "新生代-S1" : 3.3
    "新生代-S2" : 3.3
    "老年代" : 66.6

一般来说:

  • 新生代和老年代占比是1:2
  • 新生代中,eden区,S1(Survivor1),S2(Survivor2)占比是8:1:1

方法区

方法区对照着来说,通俗点来讲的话放的就是会被调用的一些和方法、静态常量等相关信息的地方。

由于:

  • 也是为了支持运行时进程中所有线程调用,因此这个区域也是线程不安全的。

  • 同样地,因为内存有限且这块区域中的内容可能不能移除,因此这块区域同样地会抛出OOM异常。

  • 方法区存储的是从Class文件加载进来的静态变量、类信息、常量池以及编译器编译后的代码

  • 对于方法区,我觉得重点应该说一下常量池。常量池可以分为Class文件常量池以及运行时常量池,Java程序运行后,Class文件中的信息被字节码执行引擎加载到了方法区,从而形成了运行时常量池。

    另外,说起方法区,可能还有人会把它与永久代、元空间混为一谈。那么他们之间的区别到底是什么?方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。不过Java 8以后就没有永久代这个说法了,元空间取代了永久代。

私有区域

这部分内容就属于线程私有的了,也就是说:

  • 这部分内容,应当来说,和线程生命周期是相关的,线程如果结束运行了,这些空间就应当得到释放;
  • 同时,随着线程的运行,这块内容应当也是会做出对应修改的。

虚拟机栈

虚拟机栈简单来说,就是线程运行过程中,调用方法的记录。由于调用方法必然是后运行的结果在前面返回,因此这个区域就设计成栈了。

  • 因为是线程私有的,因此这里必然是线程安全的
    • 线程中方法调用的记录,和其他线程显然没什么关系
  • 这里会存放基本数据类型以及对象引用
  • 方法的执行,在个区域的体现是:
    • 每个方法执行的时候,都会在VM栈的栈顶创建一个栈帧,等到方法执行完毕,对应的栈帧就会出栈并销毁

由于栈帧是方法执行抽象出的信息,因此栈帧包括了以下内容:

局部变量表、操作数栈、动态链接、方法出口以及额外附加信息。

显然内存并不是无穷无尽的,因此该区域会抛出以下异常:

  • StackOverflowError(栈深度大于虚拟机允许的深度)
  • OOM(JVM动态扩展时,无法申请到足够内存)

本地方法栈

和虚拟机栈设计、实现、边界都差不多,只是:

  • 本地方法栈的栈帧中,存放的都是 调用本地方法时(native方法)的相关信息

程序计数器

现代CPU都是多核的,那么程序运行时可能会遇到CPU资源被切换走后再获得的操作。

为了避免切换再获得之后,丢失了前一次运行的信息,因此JVM中设计了该区域来专门记录线程执行位置

它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。

程序计数器从设计上来看,大部分情况下大小并不会多大

  • 如果这块区域占用内存很多,那么说明有很多线程在运行,并且一直没有被销毁,那么显然栈部分,吃掉的内存会远比这个区域大

因此,这个区域并不会抛出OOM。

而且,程序计数器也是线程私有的,天然线程安全。

对象

对象无疑是java中最重要的内容了,前面也提到了:对象中实际的数据,都会存放到中。那么:

  • 对象的生命周期如何?
  • 对象的内容包括哪些?

对象生命周期

对象的创建

在C系列语言中,要创建一个对象实例,我们总需要为其分配空间,因此在java中也不例外。

jvm中,创建对象的流程大致如下:

graph LR
字节码new指令-->到方法去的常量池查找对应类是否被加载,解析,初始化-->de{1}
de-->yes,为新生对象分配内存
de-->no,先加载类信息-->yes,为新生对象分配内存

而分配内存,无非就是把一块内存划出来创建对象,对应的方法有:

  • 指针碰撞:(代表GC:serial,parNew,使用标记-整理来回收)

    • 预先规划一块内存,并给这块内存分配个指针来标定内存中从哪里往后是可以分配的。
    • 每次分配内存,就把指针往后移动需要的位数,并把两次指针位置中间的内存分配给新建的对象。

    当然,这个类似顺序IO分配的方法,有一个前提:堆空间相对规整

  • 空闲列表:(代表GC:CMS,使用标记-清除回收)

    • 上面的问题就在于回收时的效率低,那么就换个思路:

      • 维护一个列表,记录哪些空间可以用于写入

      那么,每次新建对象,就到列表中申请一块并且把对应的记录改掉。

可以看出来这两个方法,都是可行的。具体选择哪个,就取决于虚拟机的类型了。

注意:上面实际上只是分配了一块JVM可管理的内存给对象,对于程序员而言只是申请到了一块内存,还未开始写入实际的数据(构造方法未执行)

对象的访问

上面的执行完了,java代码拿到了一块内存,现在可以对内存进行各种自己想要的操作了,但这些操作实际上还是需要经过jvm的,因为在写入数据前,我们需要定位这块内存的位置。

虽然我们可以通过绝对的地址来访问对象,但这显然越过了jvm管理,因此在jvm中并不是通过这种绝对的物理地址来访问对象内存空间的。

看了前面的运行时数据区域,我们知道对象的引用是存在虚拟机栈中的,而我们的对象又存在堆中,而对象的类信息又存在方法区中。因此,对象引用的映射路径,在jvm中可以以以下方式进行:

  • 使用句柄访问:在堆中划分一块区域作为句柄池,专门存对象的实例数据和类型数据的指针

    这种方式如下:

graph LR
vm栈对象引用-->java堆中句柄池内容A
java堆中句柄池内容A-->A中到对象实例数据的指针-->java堆中的实例池中的对象实例数据
java堆中句柄池内容A-->A中到对象类型数据的指针-->方法区中的对象类型数据
  • 使用直接指针:在堆中存实例对象以外,再存一份类型指针,而不是专门划个句柄池出来。

    这种方式如下:

    graph LR
    vm栈对象引用-->java堆中内容A
    java堆中内容A-->A中包括了对象实例数据
    java堆中内容A-->A中到对象类型数据的指针-->方法区中的对象类型数据
    

粗一看这个句柄访问有一点多余,比起直接指针,缺点在于:

  • 多划分类一块句柄池,要维护这个句柄池内容
  • 定位实例信息还得用指针再去查一次

但如果考虑到VM的回收机制,那么句柄池的设计理念就体现出来了:

  • 如果对象的绝对内存地址被变更了(也就是说,在垃圾回收中被移动了),那么句柄访问就只需要修改句柄指针的指向,而不需要修改vm栈中的对象引用地址(因为是通过句柄去映射的)
  • 而直接指针此时就需要把变更,更新到每个vm栈中,防止这些线程在回收前后读取到了不一致的数据

对象的回收

内存总是有限的,如果我们不再需要一个对象,那么把对象从内存中去掉自然是一个不错的选择,例如在jdk中,我们经常能看到把某个引用置为null的操作,注解上写着help GC。

但虽然我们把引用置为null了,原来引用指向的实例区域还在内存中,我们并没有把这个内存区域给清掉了。

那么,vm是如何将这些我们不要的内容清理掉的呢?

哪些对象应当被回收?

通常来说,判断对象是否被销毁的方法有两种:

  • 引用计数算法

    我们给对象添加一个引用计数器,被引用,则计数器+1;引用失效,则计数器-1。

    毋庸置疑的是,这个方法够简单,但有个严重的问题。

    和Spring中的循环依赖类似,如果存在互相引用,而这两个对象其他地方都不再被引用,那么这俩就都回收不了了。

    当然,我们可以再给引用计数上加点料:如果出现了相互引用,我们做额外处理。

    • 额外处理:我们将出现相互引用的内容额外维护起来;当这些相互引用的计数变成相互引用时,我们查找整个引用的链路,直到找到相互引用以外的引用;如果找不到,那么清除即可。
    graph LR
    A-->B-->A
    B-->C-->B
    C-->...
    
    

    但这种方式显然变得更加复杂了,事实上和下面的可达性分析就很类似。如果要做这种额外处理,为什么不直接采用下面的方法呢?

  • 可达性分析

    对于上面额外的情况,我们将这个处理翻转过来进行:

    • 同样维护引用树,但我们的检查工作,从不会被回收的节点开始,而不是从互相引用的节点开始
    • 如果从不会被回收的节点能找到的引用,那么就对象就不会被回收;反之,则被回收。
    graph TD
    GCRoot-->A-->B-->C-->A-->...
    无-->1
    

    这样的方式很直观:我们应该维护被使用的引用,以及可能使用到的引用(包括被使用的引用引用到的别的引用,可能被使用也有可能尚未被使用到)是存在的。

    在上面所提到的不会被回收的节点,称为GCRoot。以下是可能被作为GCRoot的对象:

    • 虚拟机栈中被引用的对象,例如线程调用的参数、局部变量、临时变量
      • 显然,这些都属于在使用的引用,属于局部的引用,还用着呢就不应该被删掉
    • 和上面相同,本地方法栈中所引用的对象,也不应该被删除
    • 方法区中:
      • 类静态属性引用的对象,比如引用类型的静态变量
      • 常量引用的对象
    • java虚拟机内部的引用,基本数据类型对应的Class对象,常驻的异常对象
    • synchronized持有的对象。

对象如何回收?

对象的回收主要有三种算法:

  • 标记-清除
  • 复制
  • 标记-整理

以及一个实现:分代收集。

这部分详见:juejin.cn/post/698435…

分代收集

分代收集的实现,基于两个假设理论:

  • 弱分代假说:大多数对象生命存活周期很短 - 也就是说,大部分对象的活动范围,其实在两次GC之间
  • 强分代假说:经过越多次垃圾回收的对象,存活时间就越久 - 也就是说,越多次GC没有被清理掉的对象,说明在运行中特别重要,不会轻易地被GC。

根据这两个假说,就把堆分为(大概):

  • 新生代(young),1/3
    • eden 80%
    • S1 10%
    • S2 10%
  • 老年代(old),2/3

根据堆的空间以及两个假说,GC分为以下几类:

  • Minor GC/Young GC:针对新生代
  • major GC/Old GC:针对老年代
  • Full GC:针对整个java堆和方法区

GC流程如下,注意:

  • 每经过一次GC,对象分代年龄都会+1;分代年龄到15后,就会被转移到老年代中。

这里是15的原因是:对象头中用于标记分代年龄的只有4位,最大只能表示到15。

graph LR
对象创建-->eden区
首次MinorGC-->eden区存活对象被转移至S区
再次MinorGC-->id1{分代年龄==15?}-->N,eden区存活对象以及S区存活对象被转移到另一S区
id1-->Y,转移到老年代
大对象分配内存而eden区没有足够空间-->gc-->minorGC-->还没有足够空间,分配到老年代

标记-清除算法

标记清除算法,会对无效的对象进行标记,随后清除。

这个算法并不会在清除后做合并操作,因此有一个很大的问题:

  • 如果出现了不规整的情况,那么如果给大对象做分配,找不到足够的连续空间,就会再触发一次GC。

并且,由于算法是标记后再回收,因此如果存在着大量的垃圾对象,那么回收必然需要进行大量的标记以及清除,造成回收效率降低

复制算法

复制算法是一个更简单的算法,步骤如下:

  • 预先分配两块大小相等的内存,一块用于写,一块什么也不做

  • 开始GC的时候,把写的那块中的所有存活对象规整地写到空白的那一块里,然后把原来的那块清空。

这个算法的缺点在于:我们划分了两块内存,然而使用中只用到了一块,堆空间的利用率下降了。

现在大部分vm在年轻代使用的都是标记-复制算法,这也就是上面提到的S1、S2中的实现依据。

这种算法的好处显而易见:速度快,我们不需要做删除操作了而是复制再整个区域删除。

复制算法不需要标记

标记-整理算法

标记整理算法可以说是标记清除算法的一个分支。

根据对于标记-清除算法的描述,我们知道标记清除算法的问题就在于:会产生大量的碎片。

那么,我们把标记-清除算法中清理完毕之后的空间,再整理一下:

  • 把存活对象移动到一端

那么,在另一端就可以获得更为规整的内存空间了。

当然,这个整理算法也是有缺点的:

  • 运行速度是三种方法中最慢的

  • 如果以该方式进行GC,那么进行过程中就得暂停所有用户线程

    还记得之前说的:这个区域是线程不安全的吗?所以就得上锁

标记-整理算法通常在老年代中使用。

对象内容

在创建对象后,我们的对象就在堆里给自己找了一块地方住了。

当然,由于对象也是托付给jvm来管理的,那么必然在内存中,除了自身的信息,还要额外存一些信息,方便jvm管理。

堆中对象的内容,可以分为以下三个部分:

  • 对象头

    • 这部分就属于给jvm管理方便的了,包括以下两类:
      • 存储对象自身运行时数据,例如:hash码、GC分代年龄、锁状态标志
      • 指针类型,让jvm可以确定对象对应的类
  • 实例数据

    • 这部分就属于对象自身的信息了
  • 对齐填充

    • vm中可能存在对于内存地址的要求,例如:hotSpot中就要求对象起始地址必须是8字节的整数倍。

    那么,如果上面这两个加起来又不能是8字节的整数倍,就得补一些空数据上去,这部分数据就称为对齐填充