Loading

JVM总结——内存相关

内存布局

JVM运行时数据区(内存布局)

JVM,VM代表它是个虚拟机,也就是说它其实是对操作系统一些功能的一个模拟,它有自己模拟的堆、栈、字节码执行等功能。

  1. :支撑Java的过程调用的数据结构,每一个线程都有独立的过程调用分支,所以栈线程独立
  2. 本地方法栈:调用native方法时用的栈结构
  3. :面向Java程序的内存,对象在此分配需要的内存空间
  4. 直接内存:不受JVM管理控制的内存,因为没有JVM层,所以访问较快,但GC无法管理它
  5. 方法区:存放静态变量、常量、类型信息、方法调用字节码的位置,虽然是堆的逻辑部分,但和堆的功能相差甚远
  6. 程序计数器:记录下一条执行的指令地址,和单一线程绑定

各个内存区域可能会抛出的异常

  • 栈/本地方法栈
    • Stackoverflow:当栈深度达到最大限制
    • OutOfMemory:当允许动态扩展时,申请更多的内存空间失败
    • OutOfMemory:无法完成实例分配且堆已经无法扩展时(-Xmx-Xms指定堆空间扩展的上界和下界)
  • 方法区
    • OutOfMemory:无法满足新的内存分配需求时
  • 直接内存
    • OutOfMemory:本机内存不够时

JVM分配内存时如何保证线程安全

  1. TLAB(线程本地分配缓冲),先在线程本地的缓冲区分配
  2. CAS + 失败重试

对象的内存布局

  1. 对象头
    1. Mark Word:存储对象自身的运行时数据,比如GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID...
    2. 类型指针:指向该对象的类型元数据
  2. 实例部分:该对象的各个属性内容
  3. 对齐填充:保持对象的地址整齐,所以有时要在对象后面添加一个对齐填充

对象访问定位

垃圾收集器经常会移动对象

  1. 句柄池:句柄池维护着对象引用和对象实际位置的关系,所以对象实际位置改变时引用无需改变
  2. 直接指针:没有句柄池的多一次访问,但对象被移动后引用必须全部改变(HotSpot实现方式)

自动内存管理系统/GC

判断对象存活性的算法

  1. 引用计数:每个对象维护一个计数器,记录有多少对象在引用它。维护简单,快速,但无法处理独立于引用链的循环引用。
  2. 可达性分析:从一系列源头对象(GC Roots)开始构建引用链图,当一个对象无法从引用链到达时就证明它已经无法被引用了

GCRoots可以是哪些对象

GCRoots是一些源头对象,它们一般不被其它对象引用,而是引用其它对象

  1. 栈的本地变量表中引用的对象
  2. 方法区中的静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中引用的对象
  5. 被同步锁持有的对象
  6. JVM内部引用的对象,比如基本数据类型的Class对象、常驻异常对象、系统类加载器
  7. JMXBean、JVMTI的回调...

由于在不同分代中可能使用不同的内存管理系统(GC),所以跨内存区域的引用对象也可能在运行时被动的加入到GCRoots中

JVM四种引用类型

  1. 强引用obj = new Object()这种,只要强引用存在,JVM就不可以清除这个对象
  2. 软引用:还有用但非必须的对象,在内存溢出异常发生前进行二次回收
  3. 弱引用:只要发生GC就清除的对象
  4. 虚引用:最弱的一种引用,尚不了解

分代收集理论

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次GC的对象就越不容易被回收

所以,把朝生夕灭的对象放到新生代,把熬过很多次GC的对象放入老年代,分别使用何时的自动内存管理系统(GC)分而治之。

不过在不同区域使用不同GC就要求处理跨代引用的问题

跨代引用处理(记忆集和卡表)

假设要用新生代的GC做一次对新生代的垃圾回收,那么需要将整个老年代的对象都加入到GCRoots进行扫描,因为有可能存在新生代GC并不知道的跨代引用,但这是很重的一项任务

  1. 跨代引用假说:跨代引用相比同代引用仅占少数

所以,相比于扫描整个老年代,将老年代分成若干小块,再在新生代上消耗一点内存记录老年代中存在跨代引用的区域,这个数据结构叫“记忆集”

三种垃圾回收算法

  1. 标记-清理:将垃圾对象标记,然后清理掉。
    • 算法简单,无需修改引用
    • 执行效率不稳定,因为要进行大量的标记、清除过程
    • 容易产生内存碎片
  2. 标记-复制:将非垃圾对象标记,复制到另一块内存区域,清除掉原区域
    • 只需要一次清理操作,效率更高
    • 无内存碎片
    • 需要复制并移动对象
    • 需要额外的内存空间来维持复制
    • 一般被对象存活率较低的新生代使用
  3. 标记-整理:将非垃圾对象整理到堆的一头,清除掉剩下的所有空间
    • 也是需要移动的算法,有着和上面一样的优点缺点
    • 除此之外,它不需要额外内存空间来维持复制

需要移动对象的垃圾回收器在回收垃圾时性能更低,但无需移动对象的在分配内存时性能更低,因为会产生内存碎片并且内存是不整齐的(整齐的内存只需要使用简单的指针碰撞技术,不整齐的可能要维护复杂的数据结构)。而赋值器(Mutator)的操作在程序中比较频繁,所以移动对象会带来更好的整体吞吐量,不移动对象会带来更低的用户线程延迟

基于上面的论述,主打低延迟的垃圾回收器CMS采用标记清理算法,主打吞吐量的Parallel Scavenge采用标记整理算法

CMS在内存碎片已经到达无法容忍的程度时会使用标记整理算法进行一次收集,让内存变规整

OopMap/安全点/安全区域/记忆集/卡表——HotSpot实现细节

这里主要介绍HotSpot的垃圾回收算法的细节。

根节点枚举

根节点枚举是使用可达性分析算法的垃圾回收器回收垃圾的第一步,它要做的事是遍历GCRoots中的每一个对象,扫描它们持有的其它引用类型数据,稍后对这些引用类型数据进行扫描

不过,有一些痛点

  1. 为保证垃圾收集的准确性,对GC Roots进行扫描时必须暂停用户线程
  2. 在现代Java程序中有大量动态装载的类型信息,所以根节点枚举的过程极其耗时

如果有一种方法能够直接获得这些GCRoots持有的引用类型的对象,根节点枚举的停顿就会短些

OopMap(普通对象指针地图)

HotSpot是一款采用准确式垃圾收集的虚拟机,它在类加载时就将对象什么偏移量上是什么类型的数据计算出来,在执行时将对象中引用类型的数据记录到OopMap中,可以理解为OopMap中记录着一个对象中的哪里(where)存在着什么(what)引用数据

痛点:导致引用关系变化的指令太多了,不可以每个指令都生成一下OopMap

安全点

实际上只有在安全点才生成一次OopMap,安全点可以被认为是马上要开始GC的时间点,这时所有线程都要停下,因为我们需要为GC准备OopMap了!

安全点的选定不能太少也不能太频繁,一般是在具有让程序长时间执行的特征的位置,如方法调用循环跳转...

让每个线程走到安全点的常见方法:

  1. 抢先式:GC先中断所有线程,发现有没在安全点上的在放它执行一会儿,重新中断,直到所有线程都到达安全点
  2. (线程)主动式:设置一个让线程主动轮询的标志位,线程一旦发现标志位为true,就立即走到离自己最近的安全点后主动挂起(常用,HotSpot使用)

痛点:对于那些并没有正在执行的线程(处于挂起或睡眠状态的),它们没法轮询标志位,也就无法发现并走到最近的安全点

安全区域

如果线程在一段时间内不会修改引用关系,那么它可以声明自己在安全区域中,垃圾回收器不会等待这些线程到达安全点。

  1. 当线程挂起或睡眠时,标识自己进入安全区域
  2. 当线程苏醒时,检查虚拟机是否在执行根节点枚举,如果是就等待,否则正常执行

记忆集与卡表

记忆集是一种记录由非收集区域指向收集区域的指针集合的数据结构,比如前面说的新生代保存的老年代区域指向新生代区域的指针集合。

实际上,只要你的垃圾回收不是整堆的就都要建立这个东西,比如G1的每个Region都要维护自己的记忆集,因为它可能被其它Region引用,这也是它比较耗费内存的原因。

卡表是记忆集的一种实现方式,它采用一种粗粒度的方式来实现记忆集:

img

如上所示,卡表是一个简单的数组,上图使用2^9=512Bytes的卡页大小,就代表把非收集区域分成了若干512Bytes的小区域,如果区域中存在跨代引用,数组中的对应值就为1,代表它是脏区域,需要对它进行检查,否则为0。

img

写屏障

记忆集或者说卡表需要在引用关系发生变化时被更新,HotSpot使用写屏障技术,在对引用类型数据进行更新时收到一个环形通知,这允许它在实际写入发生的前后更改卡表

并发的可达性分析

将可达性分析与用户线程并发,提高程序响应性,减少用户停顿。但GCRoots的扫描还得暂停用户线程,不过有了OopMap它已经很快了。

在用户线程和可达性分析共同工作时,可能会出现两个问题:

  1. 把原本消亡的对象标记为存活,导致本次GC没有清除它,这样的垃圾称为浮动垃圾
  2. 把原本存活的对象标记为消亡,导致程序执行出错(致命问题)

下面分析如何解决这个致命问题,我们将可达性分析中引用链上的对象分为三种:

  1. 白色:该对象尚未被GC访问过,分析结束后白色对象就是垃圾
  2. 黑色:该对象且它引用的所有对象都已经被GC访问过
  3. 灰色:该对象已经被垃圾回收器访问过,但至少有一个引用没被GC访问

只有当同时满足下面两个条件时,才会出现将存活的对象标记为消亡的情况:

  1. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用(这导致该白色对象无法被可达性分析算法扫描)
  2. 赋值器插入了一条或多条从黑色对象到白色对象的新引用(这导致该无法被扫描到的白色对象被挂载到引用链中)

相应的,解决这个问题有两个办法,分别破坏上面的两个条件:

  1. 原始快照:破坏第一个条件,删除灰色对象的引用时记录下未删除时的快照,分析结束后按照原始快照来重新遍历这些灰色对象
  2. 增量更新:破坏第二个条件,当黑色对象插入新的引用时,记录下这个黑色对象,扫描结束时再将它按照灰色对象扫描一遍

HotSpot中的垃圾回收器

一道面试题,单拿出来放一篇文章中方便找

Serial系列

Serial和SerialOld分别是这个系列面向新生代和老年代的垃圾收集器,它们的特点就是单线程,而且,一旦开始工作,整个工作过程必须中断用户线程的执行以保证垃圾回收的正确性。

听起来很无法接受,但由于其简单性,它无需维护复杂的数据结构所以内存占用很小,它的单线程工作模式让它无需处理多个垃圾回收线程之间的并发安全,所以,在嵌入式系统、微服务组件、移动智能设备上,它还是有用武之地的。

ParNew

Serial的多线程版本,注意,不是SerialOld的,所以是个新生代垃圾回收器。

除了使用多个线程对新生代进行清理,和Serial并无什么本质的差别,工作过程中也必须中断用户线程。

只有ParNew才能和后面出现的老年代垃圾收集器CMS合作使用

Parallel系列

Parallel系列有两个收集器,Parallel Scavenge和Parallel Old,是多线程的新生代和老年代收集器。

Parallel Scavenge和ParNew相比,亮点是可控的停顿时间和吞吐量

  1. -XX:+MaxGCPauseMillis:指定垃圾回收器的最大停顿时间
  2. -XX:+GCTimeRatio:指定垃圾收集器占用总运行时间的比值

垃圾收集器显然不会魔法,所以,它要尽量满足上面两个参数设定的值就必须做一些牺牲,比如,如果-XX:+UseAdaptiveSizePolicy参数开着的情况下,它会动态调整新生代的大小,新生代更小了回收起来就更快了,但随之而来的问题就是更多的对象进入老年代,以及新生代的回收越来越频繁,这时吞吐量可能不增反降。

Parallel Old用于和Parallel Scavenge配合,完成新生代以及老年代的共同回收,否则,它就只能用Serial Old了。

CMS

并发的标记清理垃圾回收器,既然是垃圾清理,一定是个老年代回收器喽。

如何区分垃圾回收的并发和并行

前面我们已经看到了很多Par开头的垃圾回收器,ParNew和Parallel系列,它们都是Parallel的意思,这个单词的意思是并行,它主要体现了这些垃圾回收器在垃圾回收阶段,多个垃圾回收线程可以利用多个CPU进行并行工作。

而CMS开头的单词是Concurrency,也就是并发的意思,它的主要含义在于,这个垃圾回收器进行垃圾回收的部分阶段可以和用户线程并行,它们共同交替使用CPU,而不用像之前的垃圾回收器一样完全停止用户线程,虽然在某些阶段仍需完全停止用户线程,但这些阶段占用的时间已经很少了。

现代垃圾回收器一般具有与用户线程并发的能力,比如CMS,而它的名字里没有提到Parallel,并不代表它垃圾回收时只有单个线程,只是说选择了把与用户线程并发这个亮点放在了名字里。

CMS的主要亮点就是,它的垃圾回收阶段可以有相当一部分与用户线程并发。

一般来说,这种垃圾收集器的工作阶段有四个:

  1. 初始标记:需要停止用户线程的阶段,只是简单的找下GC Roots,对它们进行一下标记,占用时间很短
  2. 并发标记:无需停止用户线程的阶段,并发的对它处理范围内所有对象进行扫描、标记,占用时间较长
  3. 重新标记:需要停止用户线程的阶段,占用时间很短。由于与用户线程并发执行,所以并发标记过程中,用户线程有可能改变引用关系,所以需要采用一些机制简单的进行处理,一般有两种算法:
    1. 增量更新(CMS采用的方式)
    2. 原始快照
  4. 并发清除:无需停止用户线程的阶段,将未被标记的对象清除

所以,垃圾回收中两个占用时间比较长的工作都与用户线程并发了,这并不会减少垃圾清理占用的时间,也就是说不会提高系统的吞吐量,但是可以让用户获得更短的停顿时间,然后系统性能在垃圾回收时平缓下降。

G1收集器

G1回收器也是一个可以和用户线程并发的垃圾回收器,所以它的工作阶段和CMS差不多,只不过它采用了原始快照算法来重新标记,而且最后一个清除阶段必须停止用户线程。

G1回收器面对整堆进行清理,也就是Mixed GC,它打破了新生代老年代的限制,采用将内存划分为一些大小相等的Region,对每个Region都通过系统的运行时信息统计它的回收收益比,然后优先选择回收收益大的Region进行清除。

G1也有的Region也分类型,比如新生代、Eden、Survivor、老年代、大对象。

G1收集器的Region设计让它可以一次不清除整个新生代或老年代,一次只清理几个Region,小步快跑。所以它很自然的支持-XX:+MaxGCPauseMillis,即设置最大停顿时间。用户需要知道,如果它设置的太小,很容易让垃圾收集的速度赶不上产生垃圾的速度。

同时,也是由于分Region设计,这代表它要对每一个Region维护一个记忆集来存储跨Region引用,这大大的增加了内存消耗。不过现在的系统都是为了提高速度,多用一些内存也没什么。

posted @ 2022-08-12 10:31  yudoge  阅读(26)  评论(0编辑  收藏  举报