【JVM】垃圾回收算法(二)

垃圾回收算法

三色标记与读写屏障

所有的垃圾回收算法都要经历标记阶段。如果GC线程在标记的时候暂停所有用户线程(STW),那就没三色标记什么事儿了,但是这样会有一个问题,用户线程需要等到GC线程标记完才能运行,给用户的感觉就是很卡,用户体验很差。
现在主流的垃圾收集器都支持并发标记。什么是并发标记呢?就是标记的时候不暂停或少暂停用户线程,一起运行。这势必会带来三个问题:多标、少标、漏标。垃圾收集器是如何解决这个问题的呢?三色标记+读写屏障

三色标记

把遍历对象过程中遇到的对象,按照"是否访问过"这个条件标记成三种颜色

  • 1.白色:尚未访问过
  • 2.黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了
  • 3.灰色对象:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问完,会转换为灰色

为什么新创建的对象默认是黑色?不能是灰色、白色
不可能是灰色
黑色:本轮GC不管
白色:本来GC要管

经过一轮三色标记后,对象的颜色是何时还原的?
在对象移动之后,就会设置成无色

多标 浮动垃圾

在这里插入图片描述

GC线程已经标记了B,此时用户代码中A断开了对B的引用,但此时B已经被标记成了灰色,本轮GC不会被回收,这就是所谓的多标,多标的对象即成为浮动垃圾,躲过了本次GC.多标对程序逻辑是没有影响的,唯一的影响是该回收的对象躲过了一次GC,造成了些许的内存浪费

少标 浮动垃圾

在这里插入图片描述

并发标记开始后创建的对象,都视为黑色,本轮GC不清除
这里面有的对象用完就变成垃圾了,就可以销毁了,这部分对象即少标环境中的浮动垃圾

三色标记解决的是开始垃圾收集期间数据的变动

  • 1.新创建的引用
  • 2.已有的引用间的关系变动,在漏标问题中,可能出现空指针异常,
  • 2.1 CMS 重新标记(增量更新) G1重新标记(原始快照)
  • 3.如果执行完重新标记之后,又需要回到这些新创建的白色对象的初始标记,标记阶段将永远不会结束,如果频繁在创建对象
漏标问题 程序会出错

在这里插入图片描述

漏标是如何产生的呢? GC把B标记玩,准备标记B引用的对象,这时用户线程执行代码,代码中断开了B对D的引用,改为A对D的引用,但是A已经被标记成黑色,不会再次扫描A,而D还是白色,执行垃圾回收逻辑的时候,D会被回收,程序就会报空指针异常了

代码表示
B.D = null
A.D = ref;
漏标问题是如何产生的?
条件一:灰色对象 断开了白色对象的引用;即灰色对象原来的成员变量的引用发生了变化
条件二:黑色对象 重新引用了该白色对象;即黑色对象成员变量增加了新的引用

  • 1.读屏障+重新标记
    在建立A对D的引用时将D作为白色或灰色对象记录下来,并发标记结束后STW,然后重新标记由D类似的对象组成的集合
    重新标记环节一定要STW,不然标记就没完没了了
  • 2.写屏障+增量更新(IU)
    这种方式解决的是条件二,即通过写屏障记录下更新,具体做法如下:
    对象A对D的引用关系建立时,将D加入待扫描的集合中等待扫描
    这种方式强调的是引用关系的新增对象
    黑色对白色的引用建立,增量更新,更新以后记录
  • 3.写屏障+原始快照(STAB)
    这种方式解决的是条件一,带来的结果是依然能够标记到D,具体做法如下:
    对象B的引用关系变动的时候,即给B对象中的某个属性赋值时,将之前的引用关系记录下来,标记的时候,扫描旧的对象图,这个旧的对象图即原始快照
    这种方式强调的是引用关系的删除对象
    灰色对白色的引用断开 原始快照,断开之前记录
  • 4.实际应用
    CMS:写屏障+ 增量更新(效果不是很理想)
    G1:写屏障 + STAB

最终标记阶段需要STW

读写屏障(有点像Spring的AOP)

  • 1.读屏障(即在读前增加屏障做点事情)
    读屏障()
    读操作
  • 2.写屏障(即写的前后增加屏障做点事情)
    写前屏障()
    写操作
    写后屏障

记忆集(Remembered Set)、卡表(Card Table)

我们知道在G1垃圾收集器中,它是把Java堆分为多个Region,那么垃圾收集是否就真的能以Region为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region不可能是鼓励的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象所引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性分析确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保证准确性?这个问题起始并非在G1中才有,只是在G1中更加突出。在CMS垃圾收集器中,也会存在这样的引用关系:新生代->新生代(没问题,对象要么都存活要么都死亡)、新生代->老年代(也是没问题的,无非新生代的对象存活的时间久点)、老年代->老年代(也没问题,同生共死)、老年代-> 新生代(有问题,万一新生代被回收了,会发生空指针异常)。那么如何解决这个问题的呢?
答案就是利用卡表
在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中的每个Region都有一个与之对应的Remembered Set都有一个与之对应的Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remebered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

具体的设计实现?

在这里插入图片描述

以G1为例,G1基于Region模型划分了2048个Region,每个Region是2M,总共是4G.也就是说要有2048张卡表,卡表中的每一页是512B.卡页中的1B管理4KB的内存(2M/512B=4KB),卡表:Region = 1:1.如果在4KB空间中如果存在老年代->新生代,卡页的位置标成1,卡页变成脏页。再扫描的时候只需要把4KB中的所有老年代对象拿出来扫描就解决了。当然也可以扩容每个Region的大小

为什么JVM在给对象分配内存时必须要求是一块连续的内存,不可以是散乱的内存吗?

JVM在给对象分配内存时要求时一块连续的内存,主要有以下几个原因:

  • 1.性能优化:连续内存可以更好地利用CPU的缓存,提高访问速度。因为CPU的缓存是以缓存行(cache line)为单位存储数据的,连续的内存可以使一个缓存行中存储更多的有用数据,减少缓存失效(cache miss)的概率,从而提高程序的执行效率
  • 2.简化内存管理:使用连续的内存块可以简化内存管理,特别是在垃圾回收(Garbage Collection,GC)时,如果对象分布在不连续的内存中,垃圾回收器在回收和整理时会更加复杂和低效。连续内存使得标记-清楚和压缩算法更容易实现和优化。
  • 3.对象访问的便利性:在Java中,对象引用实际上是一个指针,如果对象存储在连续的内存中,通过指针偏移可以快速地访问对象的字段。这种方式比起遍历不连续的内存块要高效得多
  • 4.堆的结构设计:JVM的堆内存通常被设计为一个大的连续内存区域,这样可以有效地进行内存分配和回收。分配连续的内存块符合堆的设计原则,有助于维护堆的结构和性能。

尽管理论上可以将对象分配到不连续的内存中,但这样做会引入大量的复杂性,并且带来性能上的损失。因此,JVM选择了在大多数情况分配连续内存的策略,以确保系统的高效和稳定运行。

对象的创建

Java是一门面向对象的编程语言,在Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字而已,而在虚拟机中,对象(引用类型的对象)的创建又是一个怎样的过程呢?
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号一弄,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行响应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针想空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"(Bump the Pointer)。如果Java堆中的内存并不是规整的,已使用内存和空闲的内存相互交错,那就没有办法简单进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"(Free List).选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法时指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表
(内存分配算法也跟对象的存活周期有关,新生代大部分对象都朝生夕死,复制算法进行GC完之后,内存就是规整的,而老年代,存活对象相比新生代来说存活率要高,内存不太容易规整,如果不带整理的话,使用指针碰撞失败的概率会高很多。所以老年代采用空闲列表)
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是堆分配内存空间的动作进行同步处理——实际上迅即采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用了TLAB,可以通过-XX:+/-UseTLAB参数来设定

内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

接下来,虚拟机要对对象进行必要的设置,例如这个对象时哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——方法还没有执行,所有的字段都还为0,所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全生成出来

扩展

JVM中的内存分配策略为什么不使用空闲列表的方式而是采用指针碰撞?

  • 1.操作系统的内存分配策略采用的空闲列表机制是什么?
    在操作系统中,内存分配策略的空闲列表机制是一种管理内存资源的方法。
    基本原理:
  • 1.内存块管理:操作系统将内存划分为多个块(block),每个块可以是空闲的,也可以是已分配的
  • 2.空闲列表:操作系统维护一个记录所有空闲内存块的列表,称为空闲列表。这个列表通常会记录每个空闲块的大小和起始地址

步骤:

  • 1.初始化:当系统启动时,除了操作系统本身占用的内存外,其余的内存都被视为一个大的空闲块,并被加入到空闲列表中
  • 2.分配内存:
    a.当一个进程请求内存时,操作系统会根据请求的大小在空闲列表中查找合适的空闲块
    b.查找策略可以时首次适配(first fit)、最佳适配(best fit)或最坏适配(worst fit)等
    c.一旦找到合适的空闲块,操作系统会从空闲列表中移除该块,并将其标记为已分配,然后将内存分配给请求的进程
  • 3.内存释放
    a.当进程释放内存时,操作系统会回收这块内存,并将其标记为空闲。
    b.操作系统可能会将这块空闲内存与周围的空闲块合并,形成一个更大的空闲块,以减少内存碎片
    c.合并后的空闲块或新的空闲块会被重新加入到空闲列表中
  • 4.碎片整理
    a.随着内存的分配和释放,内存可能会出现碎片化,即空闲内存分散在各个角落,导致无法满足大的内存请求
    b.空闲列表机制可能会通过移动已分配的内存块来整理碎片,但这在实际操作中可能比较复杂耗时

优点:

  • 1.简单性:空闲列表机制相对简单,易于实现
  • 2.灵活性:可以根据不同的内存分配策略(如首次适配、最佳适配等)来优化内存使用
    缺点:
  • 1.维护开销:随着内存分配和释放的频繁进行,空闲列表的维护可能会带来一定的开销
  • 2.内存碎片:可能导致内存碎片,尤其是当空闲块和已分配块的大小频繁变动时

操作系统会根据具体的场景和需求选择最合适的内存分配策略和机制。空闲列表机制是其中一种常用的做法

操作系统中的空闲列表中的可用内存是连续的吗?

在操作系统的内存管理中,空闲列表中的可用内存不一定是连续的。内存的分配和释放会导致内存空间被分割成多个不连续的块,这些块可能会被链接成一个或多个空闲列表。
内存分配与碎片:
内存分配器在运行过程中,不同进程或程序请求不同大小的内存块,内存分配器需要从空闲列表中找到适合的内存块进行分配。当内存被释放时,这些内存块被归还给空闲列表。
如果这些释放的内存块不与现有的空闲块相邻,它们将成为新的、独立的空闲块,这样就会导致内存空间中出现不连续的空闲块,成为外部碎片。
内存分配算法:
不同的内存分配算法在管理空闲内存块时,处理碎片和保持内存连续性的方法有所不同:

  • 1.首次适配(First-Fit):从空闲列表的开始位置查找第一个足够大的看空闲块进行分配。这种方法可能会在内存开始部分产生较多的碎片
  • 2.最佳适配(Best-Fit):在空闲列表中查找最接近所需大小的空闲块进行分配。这种方法可能会产生更多的小碎片。
  • 3.最差适配(Worst-Fit):在空闲列表中查找最大的空闲块进行分配。这种方法可以减少大的空闲块的数量,但可能会留下大的碎片

内存合并(Coalescing):
为了减少碎片,操作系统通常会在内存释放时进行内存块的合并。若相邻的两个或多个内存块都空闲,则将它们合并成一个更大的内存块,
从而减少外部碎片,增加可用的连续内存块

为什么JVM不采用空闲列表的内存分配策略而是采用指针碰撞的形式?(质谱轻言)

Java虚拟机(JVM)内存分配策略与操作系统内存分配策略的不同,主要是由以下几个因素决定的:

  • 1.内存管理的抽象层级不同
    a.操作系统:操作系统负责管理武力内存,直接与硬件交互,需要处理多种复杂情况,如内存碎片、多进程/线程的内存需求等
    b.JVM:JVM运行在操作系统之上,主要负责管理Java程序的运行时内存,它对内存的管理更加抽象化,并且通常不需要处理硬件级别的内存碎片问题

  • 2.内存分配的特点
    a.空闲列表:适用于需要频繁分配和释放不同大小内存的场景,且物理内存可能存在碎片
    b.指针碰撞(Bump-the-pointer):适用于对象大小相对一致且频繁创建和销毁的场景,如JVM中的对象分配

  • 3.JVM内存分配的具体考虑
    a.效率:指针碰撞是一种非常高效的内存分配方式。在JVM中,当一个新的对象需要被分配时,只需要移动一下指针(分配指针),而不需要遍历整个空闲列表
    来查找合适的内存块。这大大减少了内存分配的开销
    b.内存连续性:碰撞指针可以保证分配的内存是连续的,这对于提高缓存命中率有好处,因为连续的内存访问往往能更好地利用CPU缓存。
    c.内存碎片:由于JVM通常会分配和释放大量大小相似的对象,内存碎片问题不像在操作系统中那么严重。JVM通过垃圾回收(GC)来管理内存,可以在GC过程中
    重新整理内存,减少碎片
    d.垃圾回收:JVM采用垃圾回收机制来自动管理内存。当对象不再被应用时,垃圾回收器会自动回收它们所占用的内存。这种方式与空闲列表中的内存分配策略相比,
    减少了手动释放内存的复杂性,并且可以通过不同的垃圾回收算法来优化内存使用

  • 4.JVM的内存模型
    a.堆空间:JVM的堆空间时用于存储Java对象的地方。堆空间通常分为年轻代、老年代等,不同代的内存管理策略不同。年轻代通常采用复制算法,而老年代可能
    采用标记-清楚或标记-整理算法。这些算法与指针碰撞的内存分配策略更为契合

  • 5.综上所述
    a.JVM选择指针碰撞的内存分配策略,而不是空闲列表,是因为这种策略更符合JVM内存管理的需求,能够提供给更高的内存分配和回收效率,并且与JVM 的垃圾回收机制更为兼容

操作系统为什么不采用指针碰撞的机制进行内存分配

操作系统不采用指针碰撞的机制进行内存分配,主要是因为操作系统的内存管理需要面对更复杂和多样化的环境。以下是一些关键原因:

  • 1.多任务和多用户环境
    操作系统:必须支持多个进程和线程的运行,每个进程或线程可能需要不同大小的内存,且分配和释放内存的时间点是随机的
    指针碰撞:适用于单一连续内存分配的场景,不适合处理多任务环境下的复杂内存请求
  • 2.内存碎片
    操作系统:需要处理内存碎片问题,因为不同大小的内存块被分分配和释放后,内存中可能会留下无法被利用的小空闲块
    指针碰撞:不擅长处理内存碎片,因为它假设内存分配是连续的,如果内存碎片严重,指针碰撞机制将无法有效工作
  • 3.内存分配的灵活性
    操作系统:需要能够分配任意大小的内存块以满足不同进程的需求
    指针碰撞:通常需要一个连续的内存区域,并且当内存区域不足以容纳新分配的内存块时需要额外的机制来处理这种情况
  • 4.物理内存和虚拟内存
    操作系统:使用虚拟内存技术,将物理内存与虚拟内存地址映射起来,这使得内存分配更加复杂
    指针碰撞:适用于简单的物理内存分配,不涉及复杂的地址映射
  • 5.安全性和隔离性
    操作系统:需要确保不同进程的内存是隔离的,防止一个进程访问或修改另一个进程的内存
    指针碰撞:需要额外的机制来保证内存的安全性和隔离性
  • 6.性能考量
    操作系统:必须高效地管理内存以满足性能需求,这通常意味着需要一个能够快速响应的内存分配策略
    指针碰撞:虽然分配速度快,但在多任务环境下,它可能导致内存利用率地下,因为它可能留下很多小的空闲内存块
  • 7.系统调用和API
    操作系统;提供了系统调用和API供应应用程序请求和释放内存,这些调用需要能够处理各种复杂的内存分配请求
    指针碰撞:无法直接适应这些系统调用和API的需求
    因此,操作系统通常采用空闲列表、位图、伙伴系统等更复杂的内存分配策略,这些策略能够更好地处理多任务、多用户环境下的内存分配和碎片问题,同时保持较高的内存利用率和系统性能
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

coffee_babe

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

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

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

打赏作者

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

抵扣说明:

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

余额充值