Loading

TLAB

G1的对象分配原理是怎样的

系统程序运行,分配一个对象,肯定是先找新生代的eden区来存储,在G1里面,就是从eden包含的region里面选择一个region来做对象的分配。

但是,如果说,有两个线程,同时要找其中一个region来分配对象,并且,两个线程刚刚好找到了某个region里面的同段内存,这个时候怎么办?岂不是要冲突了?很简单,和我们写代码同样的道理,这个时候出现了并发安全问题。

如何解决对象创建过程的冲突问题?

一个简单的思路:加锁!线程1在分配对象的时候,直接对堆内存加个锁!分配完成之后,再由线程2进行对象的分配!此时一定不会出现并发的问题。为什么要对堆内存进行加锁?因为对象分配的过程是非常复杂的,不单单是分配一个对象,还要做引用替换,引用关系处理,region的一些元数据的维护,对象头的一些处理等等,非常复杂,所以只是锁一个region,或者只是锁一段内存是不够的,所以只能锁整个堆内存。但是新的问题出现了,这个分配效率很显然低的要命!那么如何解决这个分配的效率问题?

无锁化分配 ——G1的快速分配原理TLAB撞针分配

如果要解决并发安全问题,一般有几种思路:

(1)使用锁

(2)使用CAS这种自旋模式(和锁的思想类似)

(3)使用自己本地的内存,自己改自己的

那么G1采取的就是本地缓冲区的思想来分配的,TLAB全称,Thread Local Allocation Buffer。也就是说,每个线程都有一个自己线程的本地分配缓冲区,专门用于对象的快速分配。

这个缓冲区,保证了一个线程过来的时候,尽可能的走这个TLAB来分配对象,能够做到快速分配,并且实现了无锁化分配。

TLAB可以直接用来分配对象,那怎么分配TLAB?只需要在分配TLAB的时候对堆内存加锁,大大减少了锁冲突,导致串行化执行的问题. 分配TLAB。可能也就是跟线程数量是一致。也就是执行几十次,最多上百次就OK。

对象分配全景图:

image-20230220101237586

深入分析TLAB机制原理

首先,我们需要知道的一个点是,程序创建的对象是由线程创建的。线程在分配的时候,其实也是以一个对象的身份分配出来的。大家还记不记得Thread thread= new Thread(),也就是说,搞一个线程的时候,实际上也是有一个线程对象需要被分配出来的。

在G1中,实际上,在分配线程对象的时候,就会从JVM堆内存中分配一个固定大小的内存区域,并将它作为线程的私有缓冲区,这个私有缓冲区,其实就是TLAB。

注意:在分配给线程TLAB的时候,是需要加锁的,在G1中,使用了CAS锁来分配TLAB

TLAB的数量应该怎么限制?

因为我们说了,是分配线程对象的时候,给线程从JVM堆内存上分配一个TLAB,供线程使用,那么理论上,有多少个线程就有多少了TLAB缓冲区对不对?那么我们线程数量首先肯定不会是无限的,不然CPU绝对爆掉!所以说,这个数量就是跟随线程数量的。

那么新的问题来了,有多少个线程,就有多少个TLAB,如果TLAB过大,会有什么问题?如果TLAB过小,会有什么问题?

首先要确定一个问题,如果TLAB过小,会导致TLAB快速被填满,从而导致对象不走TLAB分配,效率会变差。如果TLAB过大,造成内存碎片,回收的效率会被拖慢。因为运行过程中,TLAB可能很多内存都没有被使用,造成内存碎片,同时,在垃圾回收的时候,因为肯定要对TLAB做一些判定,回收的效率会被拖慢。所以说TLAB要有一个平衡点。

分配好TLAB之后,在系统运行的时候,线程创建对象,就会优先通过TLAB来创建对象,但是我们还是有一个问题,假如TLAB满了,无法分配对象了,会怎么处理?我们其实可以推测一下,思路无非两种:

(1)重新再申请一个TLAB给这个线程,让它继续去分配对象,可能需要对老的做一下处理

(2)直接通过堆内存分配对象

在G1中,是使用了两者结合的方式来操作的。也就是说,如果说无法分配对象了,就优先去申请一个新的TLAB来存储对象。

如果无法申请新的TLAB,才有可能会走对堆内存加锁,然后直接在堆上分配对象的逻辑。

如何确定TLAB的大小呢?

TLAB初始化的时候,其实是有一个公式计算的,TLABSize = Eden * 2 * 1%/线程个数,其中这个乘以2是因为,JVM的开发者默认TLAB的内存使用服从均匀分布。这个是一个数学概念。均匀分布,意思是,我使用的时候,均匀分布在整个TLAB空间,最终结果是什么呢?50%的空间会被使用。

eden 100MB,40个线程,TLAB是多大? 100 * 2 * 1 % = 2MB,TLAB = 2MB/40

我们怎么来判断TLAB满了?

首先我们要知道为啥需要判断TLAB满了?原因很简单,因为我们的TLAB大小分配好了之后,就固定了10MB,而对象的大小却不是规则的45KB,256KB,333KB,所以很有可能会出现对象放不进TLAB中去的情况,但是TLAB却还有比较大比例的空间没有使用,这种场景会造成比较严重的内存浪费,所以如何判断TLAB满了,是一个比较难做的事情。

那么G1是怎么做的呢?实际上,G1设计了一个refill_waste的值,在JVM虚拟机内部维护。

这个值是什么意思?简单来说,就是,一个TLAB可以浪费的内存大小是refill_waste。也就是说,一个TLAB中最多可以剩余refill_waste这么多的空闲空间,如果剩余了这么多,就可以代表这个TLAB已经满了。

refill_waste的值,通过TLABRefillWasteFraction来调整,它表示TLAB中可以浪费的比例,默认值是64,即可以浪费的比例为1/64。

TLAB满了怎么办?经常满又怎么办?

G1设计的这个refill_waste不是简简单单的判断是否满了就万事大吉了。判断过程会比较复杂。具体逻辑如下:

我要分配一个对象,先从线程持有的TLAB里面分配,如果空间够了,就直接分配。

空间不够,这个时候有一个refill_waste,此时需要对比对象所需空间大小是否大于这个浪费空间的值,如果大于refill_waste,则直接在TLAB外分配(这个过程,不同的GC算法,有不同的规则)也就是堆内存里直接分配,如果小于这个浪费空间,就重新申请一个TLAB,用来存储新创建的对象,重新申请一个新的TLAB的时候,会根据一个模型(TLABRefillWasteFraction)来动态调整,以适应当前系统分配对象的情况。

动态调整的依据是:因为很明显,refillwaste这个阈值,和TLAB的大小,无法满足当前系统的对象分配。因为对象既大于当前剩余的可用空间,又小于refillwaste,也就是剩余空间实在太小了,分配对象经常没办法分配,只能走到堆内存加锁分配,所以很显然,还没有达到一个更加合理的refillswaste和TLAB大小。

因此,系统运行过程中,会一边运行,一边动态调整这两个参数到一个更加合理的值。

image-20230220093747627

借助TLAB分配对象的实现原理是什么

TLAB中用了什么机制来判断能不能放得下的?

一个比较简单的思路:因为我们TLAB是一个很小的空间,分配出去了哪些空间?对象在一个TLAB里面,的分配是按照连续内存来分配。

我直接去遍历整个TLAB,然后找到第一个没有被使用的内存位置。就可以了,然后用TLAB的结束地址,减去第一个没有被使用的内存地址,再和对象的大小做一个比较。

但是这个思路有一个问题,每一次都要遍历,是不是没太大必要啊?大家想啊,我每次分配新对象的起始地址,是不是就是上一次分配对象的结束地址?那我直接用一个指针(top),记下来这个玩意不就OK了,下次直接用这个作为起始位置直接分配就OK了~。

如图所示,在分配一个obj3对象的时候,TLAB里面的top指针记录的就是obj2的对象结束位置,

image-20230220101810196

当obj3分配完成的时候,此时就直接把指针更新一下,更新到最新的位置上去。

image-20230220101823824

但是分配对象肯定不可能直接就分配上了,因为有可能空间会不够用是吧?所以在分配对象的时候会判断一下剩余内存空间是否能够分配这个对象。那么具体应该怎么判断呢?其实也很简单,我们只需要记录一下整个TLAB的结束位置(end指针),在分配对象的时候,判断一下待分配对象的空间(objSize)和剩余的空间大小关系就OK了

image-20230220101946111

知道end指针的位置,那么判断这个关系就很容易,直接objSize <= end - top 就分配对象,如果objSize > end -top那就不能分配对象。

dummy哑元对象的作用

因为TLAB是一个固定的长度,而对象很有可能有的大有的小,所以有可能会产生一部分内存空间无法被使用的情况,也就是产生了内存碎片,这个内存碎片应该怎么处理呢?

对于一个系统来说,可能几十个线程几百个线程,总共加起来的内存碎片也就是几百K到几MB之间,所以说,为了这么小的空间,去专门搞一个内存压缩机制,肯定是不太合理的,而且也不太好压缩,因为,每个线程都是独立的TLAB,把所有的对象压缩一下,进入stw,然后把对象集中搞到几十个线程的TLAB吗?

如果说,不是在自己的TLAB分配的,此时搞到自己的TLAB里面了,那对象应该谁来管理?所以说,压缩肯定是不合理的。

我们想想,这块小碎片,对内存占用的压力不大的话,能不能直接放弃掉?答案是可以的!G1也确实是这么做的,这块儿内存直接放弃了,不使用了。而且在线程申请一个新的TLAB的时候,这个TLAB会被废弃掉(不是直接销毁,而是,不再使用这个TLAB,等待GC)。

此时会有一个新的问题:我们在GC的时候,遍历到一个对象,是可以直接跳过这个对象长度的内存的,因为,对象长度占用了这块儿内存,所以直接跳过,然后遍历下一段,但是如果是TLAB里面的小碎片,由于没有所谓的对象属性信息,所以,不能直接跳过,需要把这块儿内存一点一点的遍历,这样子性能就会下降了。所以说,对这块儿内存,G1直接使用了一个填充的方式,来解决GC标记遍历的时候,需要遍历这块儿碎片空间的问题。直接在碎片里面,填充一个dummy对象,这样子,GC遍历到这块儿内存的时候,就可以直接按照dummy对象的长度,直接跳过这块儿碎片的遍历。

image-20230220133149587

如果TLAB这种分配方式实在是无法分配对象,应该怎么处理

(1)TLAB剩余内存太小,无法分配对象,会有不同情况,要么是大于refill_waste,直接走堆内存分配,不走TLAB了,要么是小于refill_waste,但是TLAB剩余内存空间不够,这个时候会重新分配一个TLAB来用。

(2)如果无法分配新的TLAB,这个过程CAS分配一块儿TLAB,如果说失败。就会进入堆加锁分配,再去分配一个TLAB。如果说能够分配成功,就可以直接在TLAB分配对象,那么就万事大吉。

(3)如果不能分配,此时就会尝试去扩展分区,也就是再申请一些新的region,成功扩展了region,就分配TLAB,然后分配对象,如果不成功就会走垃圾回收。

(4)如果分配尝试的次数超过了某个阈值(默认为2次),就直接结束,OOM。

什么叫快速分配?什么叫慢速分配?

TLAB分配对象的过程就叫做快速分配。之所以叫他快速分配,是因为多个线程,直接通过自己的TLAB就可以分配对象,不需要加锁,直接就可以完成多个线程并行去创建对象,没有加锁的过程。

而慢速分配,其实也简单,没有办法走快速TLAB分配的就是慢速分配,因为慢速分配需要加锁,甚至可能要涉及到GC的过程,所以速度非常慢,我们称之为慢速分配。

慢速分配大概是有两种情况:

  • 本来走TLAB,但是TLAB空间不够,要重新申请TLAB,并且TLAB初步申请还失败了
  • 上来判断就无法走TLAB,只能走堆内存分配对象

大对象分配过程:大对象定义:ObjSize > regionSize/2

  • 大对象分配的时候,会先尝试进行垃圾回收(ygc 或者 mixed gc),同时启动并发标记。注意是尝试去判断是否需要GC,是否需要启动并发标记,需要才会启动。不需要的话就不启动。
  • 大对象大于HeapRegionSize的一半,但是小于一个分区的大小,此时一个完整的分区就能放得下,那就可以直接从一个空闲列表拿一个分区给他用。或者空闲列表里面没有,就分配一个新的堆分区 --- 扩展堆分区。
  • 如果占用的空间大于了一个完整分区的大小,此时就需要分配多个对分区给它用。
  • 如果上面的分配过程失败,就尝试垃圾回收,之后再分配
  • 最终成功分配,或者失败达到一定次数,则分配失败。

对象分配全过程:

image-20230220141417551

我们使用TLAB进行快速分配的过程,第一次进入慢速分配,扩展空间失败的时候,就是ygc 或者 mixed gc,再次进入慢速分配,有可能还会执行gc,在分配过程中执行的这个ygc 或者 mixed gc,慢速分配也失败的时候,就会进入最终的尝试,最终尝试会执行两次full gc,一次不回收软引用,一次回收软引用。

一般来说,对象分配都是走的快速分配。慢速分配的场景比较少,一般是TLAB大小不合理造成的短暂的慢速分配,或者是大对象的分配直接进入慢速分配。在慢速分配的过程中,因为要做很多扩展的处理,加锁的处理,甚至gc处理,所以过程所需要的时间非常长。

posted @ 2023-02-20 14:41  秋风飒飒吹  阅读(53)  评论(0编辑  收藏  举报