徐锦洪 发表于 2024-9-2 06:24:39

【JVM】垃圾采取算法(二)

垃圾采取算法

三色标记与读写屏蔽

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

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


[*]1.白色:尚未访问过
[*]2.玄色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了
[*]3.灰色对象:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问完,会转换为灰色
为什么新创建的对象默认是玄色?不能是灰色、白色
不可能是灰色
玄色:本轮GC不管
白色:本来GC要管
经过一轮三色标记后,对象的颜色是何时还原的?
在对象移动之后,就会设置成无色
多标 浮动垃圾

https://i-blog.csdnimg.cn/direct/8b6730b7a16c4a87b74bfcd4f188209a.png
GC线程已经标记了B,此时用户代码中A断开了对B的引用,但此时B已经被标记成了灰色,本轮GC不会被采取,这就是所谓的多标,多标的对象即成为浮动垃圾,躲过了本次GC.多标对程序逻辑是没有影响的,唯一的影响是该采取的对象躲过了一次GC,造成了些许的内存浪费
少标 浮动垃圾

https://i-blog.csdnimg.cn/direct/d48a9998adfc41baab9d7d6556174d35.png
并发标记开始后创建的对象,都视为玄色,本轮GC不清除
这内里有的对象用完就酿成垃圾了,就可以销毁了,这部分对象即少标环境中的浮动垃圾
三色标记解决的是开始垃圾收集期间数据的变动


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

https://i-blog.csdnimg.cn/direct/52f95bba2fe94193af487136fc04952b.png
漏标是怎样产生的呢? 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即可包管不对全堆扫描也不会有遗漏。
详细的设计实现?

https://i-blog.csdnimg.cn/direct/2a54bb0bcc534a0ca45a0b5181787cf4.png
以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的需求
因此,操纵系统通常接纳空闲列表、位图、伙伴系统等更复杂的内存分配策略,这些策略可以或许更好地处置惩罚多任务、多用户环境下的内存分配和碎片标题,同时保持较高的内存使用率和系统性能

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 【JVM】垃圾采取算法(二)