小小的引用计数,大大的性能考究
本文基于 Netty 4.1.56.Final 版本进行讨论在上篇文章《聊一聊 Netty 数据搬运工 ByteBuf 体系的设计与实现》 中,笔者详细地为大家介绍了 ByteBuf 整个体系的设计,其中笔者觉得 Netty 对于引用计数的设计非常精彩,因此将这部分设计内容专门独立出来。
Netty 为 ByteBuf 引入了引用计数的机制,在 ByteBuf 的整个设计体系中,所有的 ByteBuf 都会继续一个抽象类 AbstractReferenceCountedByteBuf , 它是对接口 ReferenceCounted 的实现。
https://img2024.cnblogs.com/blog/2907560/202408/2907560-20240820122135325-978919843.png
public interface ReferenceCounted {
int refCnt();
ReferenceCounted retain();
ReferenceCounted retain(int increment);
boolean release();
boolean release(int decrement);
}每个 ByteBuf 的内部都维护了一个叫做 refCnt 的引用计数,我们可以通过 refCnt() 方法来获取 ByteBuf 当前的引用计数 refCnt。当 ByteBuf 在其他上下文中被引用的时候,我们需要通过 retain() 方法将 ByteBuf 的引用计数加 1。另外我们也可以通过 retain(int increment) 方法来指定 refCnt 增加的大小(increment)。
有对 ByteBuf 的引用那么就有对 ByteBuf 的释放,每当我们利用完 ByteBuf 的时候就需要手动调用 release() 方法将 ByteBuf 的引用计数减 1 。当引用计数 refCnt 酿成 0 的时候,Netty 就会通过 deallocate 方法来释放 ByteBuf 所引用的内存资源。这时 release() 方法会返回 true , 如果 refCnt 还不为 0 ,那么就返回 false 。同样我们也可以通过 release(int decrement) 方法来指定 refCnt 淘汰多少(decrement)。
1. 为什么要引入引用计数
”在其他上下文中引用 ByteBuf “ 是什么意思呢 ? 比如我们在线程 1 中创建了一个ByteBuf,然后将这个 ByteBuf 丢给线程 2 进行处置惩罚,线程 2 又大概丢给线程 3, 而每个线程都有自己的上下文处置惩罚逻辑,比如对 ByteBuf 的处置惩罚,释放等操作。如许就使得 ByteBuf 在事实上形成了在多个线程上下文中被共享的环境。
面对这种环境我们就很难在一个单独的线程上下文中判定一个 ByteBuf 该不该被释放,比如线程1 准备释放 ByteBuf 了,但是它大概正在被其他线程利用。以是这也是 Netty 为 ByteBuf 引入引用计数的重要缘故原由,每当引用一次 ByteBuf 的时候就需要通过 retain() 方法将引用计数加 1, release() 释放的时候将引用计数减 1 ,当引用计数为 0 了,说明已经没有其他上下文引用 ByteBuf 了,这时 Netty 就可以释放它了。
另外相比于 JDK DirectByteBuffer 需要依靠 GC 机制来释放其背后引用的 Native Memory , Netty 更倾向于手动及时释放 DirectByteBuf 。由于 JDK DirectByteBuffer 的释放需要等到 GC 发生,由于 DirectByteBuffer 的对象实例所占的 JVM 堆内存太小了,以是一时很难触发 GC , 这就导致被引用的 Native Memory 的释放有了一定的延迟,严峻的环境会越积越多,导致 OOM 。而且也会导致进程中对 DirectByteBuffer 的申请操作有非常大的延迟。
而 Netty 为了避免这些环境的出现,选择在每次利用完毕之后手动释放 Native Memory ,但是不依靠 JVM 的话,总会有内存泄露的环境,比如在利用完了 ByteBuf 却忘记调用 release() 方法来释放。
以是为了检测内存泄露的发生,这也是 Netty 为 ByteBuf 引入了引用计数的另一个缘故原由,当 ByteBuf 不再被引用的时候,也就是没有任何强引用大概软引用的时候,如果此时发生 GC , 那么这个 ByteBuf 实例(位于 JVM 堆中)就需要被回收了,这时 Netty 就会检查这个 ByteBuf 的引用计数是否为 0 , 如果不为 0 ,说明我们忘记调用 release() 释放了,近而判定出这个 ByteBuf 发生了内存泄露。
在探测到内存泄露发生之后,后续 Netty 就会通过 reportLeak() 将内存泄露的相关信息以 error 的日记级别输出到日记中。
看到这里,大家大概不禁要问,不就是引入了一个小小的引用计数嘛,这有何难 ? 值得这里大书特书吗 ? 不就是在创建 ByteBuf 的时候将引用计数 refCnt 初始化为 1 , 每次在其他上下文引用的时候将 refCnt 加 1, 每次释放的时候再将 refCnt 减 1 吗 ?减到 0 的时候就释放 Native Memory,太简单了吧~~
事实上 Netty 对引用计数的设计非常讲求,绝非云云简单,甚至有些复杂,其背后隐蔽着大大的性能考究以及对复杂并发问题的全面考虑,在性能与线程安全问题之间的反复权衡。
2. 引用计数的最初设计
以是为了理清关于引用计数的整个设计脉络,我们需要将版本回退到最初的起点 —— 4.1.16.Final 版本,来看一下原始的设计。
public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
// 原子更新 refCnt 的 Updater
private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> refCntUpdater =
AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
// 引用计数,初始化为 1
private volatile int refCnt;
protected AbstractReferenceCountedByteBuf(int maxCapacity) {
super(maxCapacity);
// 引用计数初始化为 1
refCntUpdater.set(this, 1);
}
// 引用计数增加 increment
private ByteBuf retain0(int increment) {
for (;;) {
int refCnt = this.refCnt;
// 每次 retain 的时候对引用计数加 1
final int nextCnt = refCnt + increment;
// Ensure we not resurrect (which means the refCnt was 0) and also that we encountered an overflow.
if (nextCnt <= increment) {
// 如果 refCnt 已经为 0 或者发生溢出,则抛异常
throw new IllegalReferenceCountException(refCnt, increment);
}
// CAS 更新 refCnt
if (refCntUpdater.compareAndSet(this, refCnt, nextCnt)) {
break;
}
}
return this;
}
// 引用计数减少 decrement
private boolean release0(int decrement) {
for (;;) {
int refCnt = this.refCnt;
if (refCnt < decrement) {
// 引用的次数必须和释放的次数相等对应
throw new IllegalReferenceCountException(refCnt, -decrement);
}
// 每次 release 引用计数减 1
// CAS 更新 refCnt
if (refCntUpdater.compareAndSet(this, refCnt, refCnt - decrement)) {
if (refCnt == decrement) {
// 如果引用计数为 0 ,则释放 Native Memory,并返回 true
deallocate();
return true;
}
// 引用计数不为 0 ,返回 false
return false;
}
}
}
}起首新版本的 retain0 方法仍然保留了 4.1.17.Final 版本引入的XADD 指令带来的性能优势,大抵的处置惩罚逻辑也是雷同的,一上来先通过 getAndAdd 方法将 refCnt 增加 rawIncrement,对于 retain(T instance) 来说这里直接加 2 。
然后判定原来的引用计数 oldRef 是否是一个奇数,如果是一个奇数,那么就表现 ByteBuf 已经没有任何引用了,逻辑引用计数早已经为 0 了,那么就抛出 IllegalReferenceCountException。
在引用计数为奇数的环境下,无论多线程怎么对 refCnt 并发加 2 ,refCnt 始终是一个奇数,最终都会抛出异常。解决并发安全问题的要点就在这里,一定要包管 retain 方法的并发执行不能改变原来的语义。
最后会判定一下 refCnt 字段是否发生溢出,如果溢出,则进行回退,并抛出异常。下面我们仍然以之前的并发场景为例,用一个具体的例子,来回味一下奇偶设计的精妙之处。
https://img2024.cnblogs.com/blog/2907560/202408/2907560-20240820122316714-1677800974.png
现在线程 1 对一个 refCnt 为 2 的 ByteBuf 执行 release 方法,这时 ByteBuf 的逻辑引用计数就为 0 了,对于一个没有任何引用的 ByteBuf 来说,新版的设计中它的 refCnt 只能是一个奇数,不能为 0 ,以是这里 Netty 会将 refCnt 设置为 1 。然后在步骤 2 中调用 deallocate 方法释放 Native Memory。
线程 2 在步骤 1 和步骤 2 之间插入进来对ByteBuf 并发执行 retain 方法,这时线程 2 看到的 refCnt 是 1,然后通过 getAndAdd 将 refCnt 加到了 3 ,仍然是一个奇数,随后抛出 IllegalReferenceCountException 异常。
线程 3 在步骤 1.1 和步骤 1.2 之间插入进来再次对 ByteBuf 并发执行 retain 方法,这时线程 3 看到的 refCnt 是 3,然后通过 getAndAdd 将 refCnt 加到了 5 ,还是一个奇数,随后抛出 IllegalReferenceCountException 异常。
如许一来就包管了引用计数的并发语义 —— 只要一个 ByteBuf 没有任何引用的时候(refCnt = 1),其他线程无论怎么并发执行retain 方法都会得到一个异常。
但是引用计数并发语义的包管不能单单只靠 retain 方法,它还需要与 release 方法相互配合协作才可以,以是为了并发语义的包管 , release 方法的设计就不能利用性能更高的 XADD 指令,而是要回退到CMPXCHG 指令来实现。
为什么这么说呢 ?由于新版引用计数的设计采用的是奇偶实现,refCnt 为偶数表现 ByteBuf 另有引用,refCnt 为奇数表现 ByteBuf 已经没有任何引用了,可以安全释放 Native Memory 。对于一个 refCnt 已经为奇数的 ByteBuf 来说,无论多线程怎么并发执行 retain 方法,得到的 refCnt 仍然是一个奇数,最终都会抛出 IllegalReferenceCountException,这就是引用计数的并发语义 。
为了包管这一点,就需要在每次调用 retain ,release 方法的时候,以偶数步长来更新 refCnt,比如每一次调用 retain 方法就对 refCnt 加 2 ,每一次调用 release 方法就对 refCnt 减 2 。
但总有一个时候,refCnt 会被减到 0 的对吧,在新版的奇偶设计中,refCnt 是不允许为 0 的,由于一旦 refCnt 被减到了 0 ,多线程并发执行 retain 之后,就会将 refCnt 再次加成了偶数,这又会出现并发问题。
而每一次调用 release 方法是对 refCnt 减 2 ,如果我们采用 XADD 指令实现 release 的话,回想一下 4.1.17.Final 版本中的设计,它起首进来是通过 getAndAdd 方法对 refCnt 减 2 ,如许一来,refCnt 就酿成 0 了,就有并发安全问题了。以是我们需要通过 CMPXCHG 指令将 refCnt 更新为 1。
这里有的同学大概要问了,那可不可以先进行一下 if 判定,如果 refCnt 减 2 之后变为 0 了,我们在通过 getAndAdd 方法将 refCnt 更新为 1 (减一个奇数),如许一来不也可以利用上 XADD 指令的性能优势吗 ?
答案是不行的,由于 if 判定与 getAndAdd 更新这两个操作之间仍然不是原子的,多线程可以在这个间隙仍然有并发执行 retain 方法的大概,如下图所示:
https://img2024.cnblogs.com/blog/2907560/202408/2907560-20240820122334320-1774496062.png
在线程 1 执行 if 判定和 getAndAdd 更新这两个操作之间,线程 2 看到的 refCnt 其实 2 ,然后线程 2 会将 refCnt 加到 4 ,线程 3 紧接着会将 refCnt 增加到 6 ,在线程 2 和线程 3 看来这个 ByteBuf 完全是正常的,但是线程 1 马上就会释放 Native Memory 了。
而且采用这种设计的话,一会通过 getAndAdd 对 refCnt 减一个奇数,一会通过 getAndAdd 对 refCnt 加一个偶数,如许就把本来的奇偶设计搞乱掉了。
以是我们的设计目标是一定要包管在 ByteBuf 没有任何引用计数的时候,release 方法需要原子性的将 refCnt 更新为 1 。 因此必须采用 CMPXCHG 指令来实现而不能利用 XADD 指令。
再者说, CMPXCHG 指令是可以原子性的判定当前是否有并发环境的,如果有并发环境出现,CAS就会失败,我们可以继续重试。但 XADD 指令却无法原子性的判定是否有并发环境,由于它每次都是先更新,后判定并发,这就不是原子的了。这一点,在下面的源码实现中会表现的特别明显。
7. 尽量避免内存屏蔽的开销
public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
private volatile int refCnt;
protected AbstractReferenceCountedByteBuf(int maxCapacity) {
super(maxCapacity);
// 引用计数在初始的时候还是为 1
refCntUpdater.set(this, 1);
}
private ByteBuf retain0(final int increment) {
// 相比于 compareAndSet 的实现,这里将 for 循环去掉
// 并且每次是先对 refCnt 增加计数 increment
int oldRef = refCntUpdater.getAndAdd(this, increment);
// 增加完 refCnt 计数之后才去判断异常情况
if (oldRef <= 0 || oldRef + increment < oldRef) {
// Ensure we don't resurrect (which means the refCnt was 0) and also that we encountered an overflow.
// 如果原来的 refCnt 已经为 0 或者 refCnt 溢出,则对 refCnt 进行回退,并抛出异常
refCntUpdater.getAndAdd(this, -increment);
throw new IllegalReferenceCountException(oldRef, increment);
}
return this;
}
private boolean release0(int decrement) {
// 先对 refCnt 减少计数 decrement
int oldRef = refCntUpdater.getAndAdd(this, -decrement);
// 如果 refCnt 已经为 0 则进行 Native Memory 的释放
if (oldRef == decrement) {
deallocate();
return true;
} else if (oldRef < decrement || oldRef - decrement > oldRef) {
// 如果释放次数大于 retain 次数 或者 refCnt 出现下溢
// 则对 refCnt 进行回退,并抛出异常
refCntUpdater.getAndAdd(this, decrement);
throw new IllegalReferenceCountException(oldRef, decrement);
}
return false;
}
}这里有一个小的细节再次表现出 Netty 对于性能的极致追求,refCnt 字段在 ByteBuf 中被 Netty 申明为一个 volatile 字段。
public final int initialValue() {
return 2;
}我们对 refCnt 的普通读写都是要走内存屏蔽的,但 Netty 在 release 方法中首次读取 refCnt 的值是采用 nonVolatile 的方式,不走内存屏蔽,直接读取 cache line,避免了屏蔽开销。
public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
// 获取 refCnt 字段在 ByteBuf 对象内存中的偏移
// 后续通过 Unsafe 对 refCnt 进行操作
private static final long REFCNT_FIELD_OFFSET =
ReferenceCountUpdater.getUnsafeOffset(AbstractReferenceCountedByteBuf.class, "refCnt");
// 获取 refCnt 字段 的 AtomicFieldUpdater
// 后续通过 AtomicFieldUpdater 来操作 refCnt 字段
private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> AIF_UPDATER =
AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
// 创建 ReferenceCountUpdater,对于引用计数的所有操作最终都会代理到这个类中
private static final ReferenceCountUpdater<AbstractReferenceCountedByteBuf> updater =
new ReferenceCountUpdater<AbstractReferenceCountedByteBuf>() {
@Override
protected AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> updater() {
// 通过 AtomicIntegerFieldUpdater 操作 refCnt 字段
return AIF_UPDATER;
}
@Override
protected long unsafeOffset() {
// 通过 Unsafe 操作 refCnt 字段
return REFCNT_FIELD_OFFSET;
}
};
// ByteBuf 中的引用计数,初始为 2 (偶数)
private volatile int refCnt = updater.initialValue();
}那有的同学大概要问了,如果读取 refCnt 的时候不走内存屏蔽的话,读取到的 refCnt 不就大概是一个错误的值吗 ?
事实上确实是如许的,但 Netty 不 care , 读到一个错误的值也无所谓,由于这里的引用计数采用了奇偶设计,我们在第一次读取引用计数的时候并不需要读取到一个精确的值,既然如许我们可以直接通过 UnSafe 来读取,还能剩下一笔内存屏蔽的开销。
那为什么不需要一个精确的值呢 ?由于如果原来的 refCnt 是一个奇数,那无论多线程怎么并发 retain ,最终得到的还是一个奇数,我们这里只需要知道 refCnt 是一个奇数就可以直接抛 IllegalReferenceCountException 了。具体读到的是一个 3 还是一个 5 其实都无所谓。
那如果原来的 refCnt 是一个偶数呢 ?其实也无所谓,我们大概读到一个精确的值也大概读到一个错误的值,如果恰好读到一个精确的值,那更好。如果读取到一个错误的值,也无所谓,由于我们背面是用 CAS 进行更新,如许的话 CAS 就会更新失败,我们只需要在一下轮 for 循环中更新精确就可以了。
如果读取到的 refCnt 恰好是 2 ,那就意味着本次 release 之后,ByteBuf 的逻辑引用计数就为 0 了,Netty 会通过 CAS 将 refCnt 更新为 1 。
public abstract class ReferenceCountUpdater<T extends ReferenceCounted> {
public final int initialValue() {
// ByteBuf 引用计数初始化为 2
return 2;
}
public final int refCnt(T instance) {
// 通过 updater 获取 refCnt
// 根据 refCnt 在realRefCnt 中获取真实的引用计数
return realRefCnt(updater().get(instance));
}
// 获取 ByteBuf 的逻辑引用计数
private static int realRefCnt(int rawCnt) {
// 奇偶判断
return rawCnt != 2 && rawCnt != 4 && (rawCnt & 1) != 0 ? 0 : rawCnt >>> 1;
}
}如果 CAS 更新失败,则表现此时有多线程大概并发对 ByteBuf 执行 retain 方法,逻辑引用计数此时大概就不为 0 了,针对这种并发环境,Netty 会在 retryRelease0 方法中进行重试,将 refCnt 减 2 。
private boolean retryRelease0(T instance, int decrement) { for (;;) { // 采用 Volatile 的方式读取 refCnt int rawCnt = updater().get(instance), // 获取逻辑引用计数,如果 refCnt 已经变为奇数,则抛出异常 realCnt = toLiveRealRefCnt(rawCnt, decrement); // 如果执行完本次 release , 逻辑引用计数为 0 if (decrement == realCnt) { // CAS 将 refCnt 更新为 1 if (tryFinalRelease0(instance, rawCnt)) { return true; } } else if (decrement < realCnt) { // 原来的逻辑引用计数 realCnt 大于 1(decrement) // 则通过 CAS 将 refCnt 减 2 if (updater().compareAndSet(instance, rawCnt, rawCnt - (decrement
页:
[1]