【nodejs原理&源码杂记(8)】Timer模块与基于二叉堆的定时器(nodejs 原理)
681
2022-05-30
3.2 快速分配
TLAB产生的目的就是为了进行内存快速分配。通常来说,JVM堆是所有线程的共享区域。因此,从JVM堆空间分配对象时,必须锁定整个堆,以便不会被其他线程中断和影响。为了解决这个问题,TLAB试图通过为每个线程分配一个缓冲区来避免和减少使用锁。
在分配线程对象时,从JVM堆中分配一个固定大小的内存区域并将其作为线程的私有缓冲区,这个缓冲区称为TLAB。只有在为每个线程分配TLAB缓冲区时才需要锁定整个JVM堆。由于TLAB是属于线程的,不同的线程不共享TLAB,当我们尝试分配一个对象时,优先从当前线程的TLAB中分配对象,不需要锁,因此达到了快速分配的目的。
更进一步地讲,实际上TLAB是Eden区域中的一块内存,不同线程的TLAB都位于Eden区,所有的TLAB内存对所有的线程都是可见的,只不过每个线程有一个TLAB的数据结构,用于保存待分配内存区间的起始地址(start)和结束地址(end),在分配的时候只在这个区间做分配,从而达到无锁分配,快速分配。
另外值得说明的是,虽然TLAB在分配对象空间的时候是无锁分配,但是TLAB空间本身在分配的时候还是需要锁的,G1中使用了CAS来并行分配。
图3-2 TLAB在分区中的使用
在图3-2中,Tn表示第n个线程,深灰色表示该TLAB块已经分配完毕,浅灰色表示该TLAB块还可以分配更多的对象。
从图中我们可以看出,线程T1已经使用了两个TLAB块,T1、T2和T4的TLAB块都有待分配的空间。这里并没有提及Eden和多个分区的概念,实际上一个分区可能有多个TLAB块,但是一个TLAB是不可能跨分区的。从图中我们也可以看出,每个线程的TLAB块并不重叠,所以线程之间对象的分配是可以并行的,且无影响。另外图中还隐藏了一些细节:
T1已经使用完两个TLAB块,这两个块在回收的时候如何处理?
我们可以想象TLAB的大小是固定的,但是对象的大小并不固定,因此TLAB中可能存在内存碎片的问题,这个该如何解决?请继续往下阅读。
快速TLAB对象分配也有两步:
从线程的TLAB分配空间,如果成功则返回。
不能分配,先尝试分配一个新的TLAB,再分配对象。
代码如下所示:
hotspot/src/share/vm/gc_interface/collectedHeap.inline.hpp
HeapWord* CollectedHeap::allocate_from_tlab(KlassHandle klass, Thread*
thread, size_t size) {
HeapWord* obj = thread->tlab().allocate(size);
if (obj != NULL) return obj;
// 省略一些判断比如是否需要申请一个新的TLAB
return allocate_from_tlab_slow(klass, thread, size);
}
从TLAB已分配的缓冲区空间直接分配对象,也称为指针碰撞法分配,其方法非常简单,在TLAB中保存一个top指针用于标记当前对象分配的位置,如果剩余空间(end-top)大于待分配对象的空间(objSize),则直接修改top = top + ObjSize,相关代码位于thread->tlab().allocate(size)中。对于分配失败,处理稍微麻烦一些,相关代码位于allocate_from_tlab_slow()中,在学习这部分代码之前,先思考一下这样的内存分配管理该如何设计。
如果TLAB过小,那么TLAB则不能存储更多的对象,所以可能需要不断地重新分配新的TLAB。但是如果TLAB过大,则可能导致内存碎片问题。假设TLAB大小为1M,Eden为200M。如果有40个线程,每个线程分配1个TLAB,TLAB被填满之后,发生GC。假设TLAB中对象分配符合均匀分布,那么发生GC时,TLAB总的大小为:40×1×0.5 = 20M(Eden的10%左右),这意味着Eden还有很多空间时就发生了GC,这并不是我们想要的。最直观的想法是增加TLAB的大小或者增加线程的个数,这样TLAB在分配的时候效率会更高,但是在GC回收的时候则可能花费更长的时间。因此JVM提供了参数TLABSize用于控制TLAB的大小,如果我们设置了这个值,那么JVM就会使用这个值来初始化TLAB的大小。但是这样设置不够优雅,其实TLABSize默认值是0,也就是说JVM会推断这个值多大更合适。采用的参数为TLABWasteTargetPercent,用于设置TLAB可占用的Eden空间的百分比,默认值1%,推断方式为TLABSize = Eden×2×1%/线程个数(乘以2是因为假设其内存使用服从均匀分布),G1中是通过下面的公式计算的:
hotspot/src/share/vm/memory/threadLocalAllocBuffer.cpp
init_sz = (Universe::heap()->tlab_capacity(myThread()) / HeapWordSize) /
(nof_threads * target_refills());
其中,tlab_capacity在G1CollectedHeap中实现,代码如下所示:
hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp
size_t G1CollectedHeap::tlab_capacity(Thread* ignored) const {
return (_g1_policy->young_list_target_length() - young_list()->survivor_
length()) * HeapRegion::GrainBytes;
}
简单来说,tlab_capacity就是Eden所有可用的区域。另外要注意的是,这里采用的启发式推断也仅仅是一个近似值,实际上线程在使用内存分配对象时并不是无关的(不完全服从均匀分布),另外不同的线程类型对内存的使用也不同,比如一些调度线程、监控线程等几乎不会分配新的对象。
在Java对象分配时,我们总希望它位于TLAB中,如果TLAB满了之后,如何处理呢?前面提到TLAB其实就是Eden的一块区域,在G1中就是HeapRegion的一块空闲区域。所以TLAB满了之后无须做额外的处理,直接保留这一部分空间,重新在Eden/堆分区中分配一块空间给TLAB,然后再在TLAB分配具体的对象。但这里会有两个小问题。
1.如何判断TLAB满了?
按照前面的例子TLAB是1M,当我们使用800K,还是900K,还是950K时被认为满了?问题的答案是如何寻找最大的可能分配对象和减少内存碎片的平衡。实际上虚拟机内部会维护一个叫做refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste,在我们的这个例子中,refill_waste的初始值为16K,即TLAB中还剩(1M - 16k = 1024 - 16 = 1008K)1008K内存时直接分配一个新的,否则尽量使用这个老的TLAB。
2.如何调整TLAB
如果要分配的内存大于TLAB剩余的空间则直接在Eden/HeapRegion中分配。那么这个1/64是否合适?会不会太小,比如通常分配的对象大多是20K,最后剩下16K,这样导致每次都进入Eden/堆分区慢速分配中。所以,JVM还提供了一个参数TLAB
WasteIncrement(默认值为4个字)用于动态增加这个refill_waste的值。默认情况下,TLAB大小和refill_waste都会在运行时不断调整,使系统的运行状态达到最优。在动态调整的过程中,也不能无限制变更,所以JVM提供MinTLABSize(默认值2K)用于控制最小值,对于G1来说,由于大对象都不在新生代分区,所以TLAB也不能分配大对象,HeapRegion/2就会被认定为大对象,所以TLAB肯定不会超过HeapRegionSize
的一半。
如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,
并使用-XX:TLABSize手工指定一个TLAB的大小。-XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
继续来看TLAB中的慢速分配,主要的步骤有:
TLAB的剩余空间是否太小,如果很小,即说明这个空间通常不满足对象的分配,所以最好丢弃,丢弃的方法就是填充一个dummy对象,然后申请新的TLAB来分配对象。
如果不能丢弃,说明TLAB剩余空间并不小,能满足很多对象的分配,所以不能丢弃这个TLAB,否则内存浪费很多,此时可以把对象分配到堆中,不使用TLAB分配,所以可以直接返回。
TLAB慢速分配代码如下所示:
hotspot/src/share/vm/gc_interface/collectedHeap.cpp
HeapWord* CollectedHeap::allocate_from_tlab_slow(KlassHandle klass, Thread*
thread, size_t size) {
// 判断TLAB尚未分配的剩余空间是否可以丢掉。如果剩余空间大于阈值则保留,其中阈值为
// refill waste limit,它由desired size和参数TLABRefillWasteFraction
// 计算得到
if (thread->tlab().free() > thread->tlab().refill_waste_limit()) {
// 不能丢掉,根据TLABWasteIncrement更新refill_waste的阈值
thread->tlab().record_slow_allocation(size);
// 返回NULL,说明在Eden/HeapRegion中分配
return NULL;
}
// 说明TLAB剩余空间很小了,所以要重新分配一个TLAB。老的TLAB不用处理,因为它属于Eden,
// GC可以正确回收空间
size_t new_tlab_size = thread->tlab().compute_size(size);
// 分配之前先清理老的TLAB,其目的就是为了让堆保持parsable可解析
thread->tlab().clear_before_allocation();
if (new_tlab_size == 0) return NULL;
// 分配一个新的TLAB...
HeapWord* obj = Universe::heap()->allocate_new_tlab(new_tlab_size);
if (obj == NULL) return NULL;
// 发生一个事件,用于统计分配信息
AllocTracer::send_allocation_in_new_tlab_event(klass, new_tlab_size *
HeapWordSize, size * HeapWordSize);
// 是否把内存空间清零
if (ZeroTLAB) Copy::zero_to_words(obj, new_tlab_size);
// 分配对象,并设置TLAB的start、top、end等信息
thread->tlab().fill(obj, obj + size, new_tlab_size);
return obj;
}
为什么要对老的TLAB做清理动作?
TLAB存储的都是已经分配的对象,为什么要清理以及清理什么?其实这里的清理就是把尚未分配的空间分配一个对象(通常是一个int[]),那么为什么要分配一个垃圾对象?代码说明是为了栈解析(Heap Parsable),Heap Parsable是什么?为什么需要设置?下面继续分析。
内存管理器(GC)在进行某些需要线性扫描堆里对象的操作时,比如,查看Heap
Region对象、并行标记等,需要知道堆里哪些地方有对象,而哪些地方是空白。对于对象,扫描之后可以直接跳过对象的长度,对于空白的地方只能一个字一个字地扫描,这会非常慢。所以可以把这块空白的地方也分配一个dummy对象(哑元对象),这样GC在线性遍历时就能做到快速遍历了。这样的话就能统一处理,示例代码如下:
HeapWord* cur = heap_start;
while (cur < heap_used) {
object o = (object)cur;
do_object(o);
cur = cur + o->size();
}
具体我们可以在新生代垃圾回收的时候再来验证这一点。我们再看一下如何申请一个新的TLAB缓冲区,代码如下所示:
hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp
HeapWord* G1CollectedHeap::allocate_new_tlab(size_t word_size) {
return attempt_allocation(word_size, &dummy_gc_count_before, &dummy_
gclocker_retry_count);
}
它最终会调用到G1CollectedHeap中分配,其分配主要是在attempt_allocation完成的,步骤也分为两步:快速无锁分配和慢速分配。图3-3为慢速分配流程图。
TLAB缓冲区分配代码如下所示:
hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.inline.cpp
inline HeapWord* G1CollectedHeap::attempt_allocation(…) {
AllocationContext_t context = AllocationContext::current();
HeapWord* result = _allocator->mutator_alloc_region(context)->attempt_
allocation(word_size, false /* bot_updates */);
if (result == NULL) {
result = attempt_allocation_slow(…);
}
if (result != NULL) dirty_young_block(result, word_size);
return result;
}
图3-3 申请TLAB分区和对象慢速分配流程图
快速无锁分配:指的是在当前可以分配的堆分区中使用CAS来获取一块内存,如果成功则可以作为TLAB的空间。因为使用CAS可以并行分配,当然也有可能不成功。对于不成功则进行慢速分配,代码如下所示:
hotspot/src/share/vm/gc_implementation/g1/heapRegion.inline.hpp
inline HeapWord* G1OffsetTableContigSpace::par_allocate_impl(size_t size,
HeapWord* const end_value) {
do {
HeapWord* obj = top();
if (pointer_delta(end_value, obj) >= size) {
HeapWord* new_top = obj + size;
HeapWord* result = (HeapWord*)Atomic::cmpxchg_ptr(new_top, top_addr(), obj);
if (result == obj) return obj;
} else {
return NULL;
}
} while (true);
}
对于不成功则进行慢速分配,慢速分配需要尝试对Heap加锁,扩展新生代区域或垃圾回收等处理后再分配。
首先尝试对堆分区进行加锁分配,成功则返回,在attempt_allocation_locked完成。
不成功,则判定是否可以对新生代分区进行扩展,如果可以扩展则扩展后再分配TLAB,成功则返回,在attempt_allocation_force完成。
不成功,判定是否可以进行垃圾回收,如果可以进行垃圾回收后再分配,成功则返回,在do_collection_pause完成。
不成功,如果尝试分配次数达到阈值(默认值是2次)则返回失败。
如果还可以继续尝试,再次判定是否进行快速分配,如果成功则返回。
不成功重新再尝试一次,直到成功或者达到阈值失败。
所以慢速分配要么成功分配,要么尝试次数达到阈值后结束并返回NULL。代码如下:
hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp
HeapWord* G1CollectedHeap::attempt_allocation_slow(…) {
HeapWord* result = NULL;
for (int try_count = 1; /* we'll return */; try_count += 1) {
{
// 加锁分配
result = _allocator->mutator_alloc_region(context)->attempt_
allocation_locked(word_size, false /* bot_updates */);
if (result != NULL) return result;
if (GC_locker::is_active_and_needs_gc()) {
if (g1_policy()->can_expand_young_list()) {
result = _allocator->mutator_alloc_region(context)->attempt_
allocation_force(word_size, false /* bot_updates */);
if (result != NULL) return result;
}
should_try_gc = false;
} else {
if (GC_locker::needs_gc()) {
should_try_gc = false;
} else {
gc_count_before = total_collections();
should_try_gc = true;
}
}
}
if (should_try_gc) {
// GCLocker没有进入临界区,可以进行垃圾回收
result = do_collection_pause(word_size, gc_count_before, &succeeded,
GCCause::_g1_inc_collection_pause);
if (result != NULL) return result;
if (succeeded) {
// 稍后可以进行回收,可以先返回
MutexLockerEx x(Heap_lock);
*gc_count_before_ret = total_collections();
return NULL;
}
} else {
// JNI进入临界区中,判断是否达到分配次数阈值
if (*gclocker_retry_count_ret > GCLockerRetryAllocationCount) {
MutexLockerEx x(Heap_lock);
*gc_count_before_ret = total_collections();
return NULL;
}
GC_locker::stall_until_clear();
(*gclocker_retry_count_ret) += 1;
}
// 可能因为其他线程正在分配或者GCLocker正在被竞争使用等,
// 在进行加锁分配前再尝试进行无锁分配
result = _allocator->mutator_alloc_region(context)->attempt_
allocation(word_size, false /* bot_updates */);
if (result != NULL) return result;
}
ShouldNotReachHere();
return NULL;
}
这里GCLocker是与JNI相关的。简单来说Java代码可以和本地代码交互,在访问JNI代码时,因为JNI代码可能会进入临界区,所以此时会阻止GC垃圾回收。这部分知识相对独立,有关GCLocker的知识可以参看其他文章。
日志及解读
从一个Java的例子出发,代码如下:
public class Test {
private static final LinkedList
public static void main(String[] args) throws Exception {
int iteration = 0;
while (true) {
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 10; j++) {
strings.add(new String("String " + j));
}
}
Thread.sleep(100);
}
}
}
通过命令设置参数,如下所示:
-Xmx128M -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+PrintTLAB -XX:+UnlockExperimentalVMOptions -XX:G1LogLevel=finest
可以得到:
garbage-first heap total 131072K, used 37569K [0x00000000f8000000,
0x00000000f8100400, 0x0000000100000000)
region size 1024K, 24 young (24576K), 0 survivors (0K)
TLAB: gc thread: 0x0000000059ade800 [id: 16540] desired_size: 491KB slow
allocs: 8 refill waste: 7864B alloc: 0.99999 24576KB refills: 50
waste 0.0% gc: 0B slow: 816B fast: 0Bd
对于多线程的情况,这里还会有每个线程的输出结果以及一个总结信息。由于篇幅的关系此处都已经省略。下面我们分析日志中TLAB这个信息的每一个字段含义:
desired_size为期望分配的TLAB的大小,这个值就是我们前面提到如何计算TLABSize的方式。在这个例子中,第一次的时候,不知道会有多少线程,所以初始化为1,desired_size = 24576/50 = 491.5KB这个值是经过取整的。
slow allocs为发生慢速分配的次数,日志中显示有8次分配到heap而没有使用TLAB。
refill waste为retire一个TLAB的阈值。
alloc为该线程在堆分区分配的比例。
refills发生的次数,这里是50,表示从上一次GC到这次GC期间,一共retire过50个TLAB块,在每一个TLAB块retire的时候都会做一次refill把尚未使用的内存填充为dummy对象。
waste由3个部分组成:
gc:发生GC时还没有使用的TLAB的空间。
slow:产生新的TLAB时,旧的TLAB浪费的空间,这里就是新生成50个TLAB,浪费了816个字节。
fast:指的是在C1中,发生TLAB retire(产生新的TLAB)时,旧的TLAB浪费的空间。
JVM
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。