1. JVM G1 TLAB 机制原理
1.1. TLAB是什么?TLAB是怎么分配的?
程序创建的对象是由线程创建的。线程在分配的时候,也是以一个对象的身份分配出来的。Thread thread= new Thread()
。
在G1中,在分配线程对象的时候,会从JVM堆内存中分配一个固定大小的内存区域,并将它作为线程的私有缓冲区,这个私有缓冲区就是TLAB。
注意:在分配给线程TLAB的时候,是需要加锁的,在G1中,使用了CAS锁来分配TLAB。
分配线程对象的时候,给线程从JVM堆内存上分配一个TLAB,供线程使用,有多少个线程就有多少TLAB缓冲区。线程数量不会是无限的,所以,TLAB数量跟随线程数量的来定。
1.2. TLAB有多大?如何确定TLAB的大小?
如果TLAB过小,可能会快速被填满,导致对象不走TLAB分配,效率会变差。如果TLAB过大,可能会造成内存碎片,回收的效率会被拖慢(因为运行过程中,TLAB可能很多内存都没有当前线程被使用,造成内存碎片,同时,在垃圾回收的时候,要对TLAB做一些判定,回收的效率会被拖慢)。所以TLAB要有一个平衡点。
TLAB初始化的时候,会有一个公式来计算,TLABSize = Eden x 2 x 1% / 线程个数(乘以2是因为JVM的开发者默认TLAB的内存使用服从均匀分布。均匀分布,意思是使用的时候,均匀分布在整个TLAB空间,50%的空间会被使用)。
例如,10G eden,40个TLAB,每个TLAB的大小: 10GB x 2 x 1 % / 40 = 5.12MB
分配好TLAB之后,系统运行的时候,线程创建对象,会优先通过TLAB来创建对象,假如TLAB满了,无法分配对象,可以推测一下,思路有如下两种:
- 重新再申请一个TLAB给这个线程,然后继续去分配对象,可能需要对旧TLAB做一下处理
- 直接通过堆内存分配对象
在G1中,是使用了两者结合的方式来操作的。如果无法分配对象了,优先去申请一个新的TLAB来存储对象。
如果无法申请新的TLAB,才有可能会对堆内存加锁,直接在堆上分配对象。
1.3. 一个关键的问题,怎么判断TLAB满了?
为什么需要判断TLAB满了?因为TLAB大小分配好了之后,假如是10MB,对象的大小不是规则的,例如45KB、256KB、333KB等等,很有可能会出现对象放不进TLAB中去的情况,但是TLAB还有一定比例的空间没有使用,这种场景会造成比较严重的内存浪费,所以如何判断TLAB满了,是一个比较难做的事情。
例如,TLAB 10KB,11KB,它满了吗?TLAB 2MB空间,3MB对象,它满了吗?
G1设计了一个refill_waste的值,在JVM虚拟机内部维护。这个值代表一个TLAB可以浪费的内存大小是refill_waste。一个TLAB中最多可以剩余refill_waste这么大的空闲空间,如果剩余了refill_waste,就可以代表这个TLAB已经满了。
refill_waste的值,通过TLABRefillWasteFraction来调整,它表示TLAB中可以浪费的比例,默认值是64,即可以浪费的比例为1/64。
1.4. TLAB满了怎么办?经常满又怎么办?
G1设计的refill_waste不是判断是否满了就可以了,判断过程比较复杂。具体逻辑如下:
分配一个对象时,有一个refill_waste,会对比对象所需空间大小是否大于这个值,如果大于refill_waste,则直接在TLAB外分配(不同的GC算法,有不同的规则)也就是堆内存里直接分配。
如果小于这个值,判断线程持有的TLAB空间是否足够,足够直接在TLAB内进行分配,否则就重新申请一个TLAB,用来存储新创建的对象。重新申请新的TLAB的时候,会根据模型(TLABRefillWasteFraction)做一些动态调整,以适应当前系统分配对象的情况。
动态调整的依据是:refill_waste这个阈值和TLAB的大小,无法满足当前系统的对象分配。因为对象既大于当前剩余的可用空间,又小于refill_waste,就是TLAB没满且剩余空间不够放下对象,所以代表refill_waste和TLAB大小没有特别合理。
因此,系统运行过程中,会边运行,边动态调整这两个参数到一个更加合理的值。
1.5. TLAB怎么实现分配对象的?
对象分配是比较复杂的过程,对象创建包含很多东西,比如引用,对象头,对象元数据,各种标记位,对象的klass类型对象,锁标记,GC标记,Rset,卡表等等。
首先来关注一下TLAB是怎么实现分配一个对象的。分配一个对象的时候,TLAB是只给当前线程使用的。当前线程可以找到这个TLAB进行对象的分配,分配流程可以参考下图:
对象在TLAB中能不能放的下,是比较关键的,用什么机制来判断能不能放得下的?
一个比较简单的思路:对象在一个TLAB里面的分配是连续内存来分配。直接遍历整个TLAB,找到第一个没有被使用的内存位置。用TLAB的结束地址,减去第一个没有被使用的内存地址,再和对象的大小做比较。
这个思路有问题,每次都要遍历,会比较浪费时间。可以直接用一个指针(top),记下来上一次分配对象的结束地址,下次直接用该指针作为起始位置直接分配。
如图所示,分配一个obj3
对象的时候,TLAB里面的top指针记录的就是obj2
的对象结束位置。
当obj3
分配完成时,直接把指针更新到最新的位置。
分配对象时有可能空间会不够用。所以会判断剩余内存空间是否能够分配对象。需要记录整个TLAB的结束位置(end指针),分配对象的时候,判断一下待分配对象的空间(objSize)和剩余的空间大小关系。
即 objSize <= end - top 则可以分配对象;如果objSize > end -top 则不能分配对象。
思考:TLAB是一个固定的长度,对象可能有大有小,所以有可能会产生一部分内存空间无法被使用的情况(内存碎片),内存碎片应该怎么处理?
1.6. dummy哑元对象的作用
TLAB本身并不大,计算公式:(Eden x 2 x 1%)/ 线程个数。如果造成一定程度的内存碎片,实际会比普通小对象的大小还要小一些。
一个系统可能有几十个、几百个线程,加起来的内存碎片估计几百K到几MB之间,如果为了这么小的空间,去专门做内存压缩机制,得不偿失,并且压缩实现也很困难。每个线程都是独立的TLAB,把所有的对象进行压缩,进入stw,然后再做一些存取引用等等操作,性能会很慢,也很复杂,不方便维护扩展。
不是当前线程TLAB分配的对象,放到自己的TLAB里面,对象的管理会有很多问题。所以,压缩机制是不合理的。
这块小碎片,对内存浪费其实是可以接受的,可以直接放弃掉。这样做会有一个新的问题:在GC的时候,遍历一个对象,是可以直接跳过这个对象长度的内存的(对象数据中有长度等等数据,可以跳到对象尾部,遍历下个对象),如果是TLAB中最后的小碎片,由于没有对象属性信息,不能直接跳过,需要把内存碎片全都遍历,这样性能就会下降。
对于内存碎片,G1使用一种填充方式,来解决遍历整个内存碎片空间的问题。即直接在碎片中填充dummy对象,GC遍历到内存碎片时,可以按照dummy对象的长度,直接跳过内存碎片的遍历。
1.7. TLAB分配方式无法分配对象,会怎么处理?
当TLAB剩余内存太小,无法分配对象,会有不同情况。如果大于refill_waste,会在堆内存分配;如果小于refill_waste,且剩余内存空间不够,会重新分配一个TLAB使用。
如果无法分配新的TLAB,会进入堆加锁分配。如果堆加锁不能分配对象,此时会尝试扩展分区(再申请一些新的region),成功扩展region则用来分配对象,不成功就会进行垃圾回收来释放空间。如果垃圾回收失败超过阈值,就会OOM。
最后的垃圾回收,是因为内存空间不够,导致无法分配对象。垃圾回收之后,空间还是不足,说明存活对象太多、堆内存不足,程序无法分配对象、无法继续运行的,就会OOM。OOM之前,会做一些垃圾回收的尝试,直到尝试到某个阈值。例如,3次回收还是无法分配新对象,只能OOM。