Netty源码分析——ByteBuf开篇

Netty源码分析——ByteBuf开篇

前言

这是ByteBuf开篇,主要介绍ByteBuf的结构,继承关系等,为我们后面的学习打一下铺垫。

ByteBuf的作用主要是为了平衡CPU处理速度和网络传输速度。一般情况下,CPU处理速度都远大于网络传输速度,如果我们直接把数据写入网络,那么就会导致写入请求大量的被阻塞(都在争抢套接字)。为此我们引入缓冲区的概念,积累一定的数据再往网络中写。

基本概念

ByteBuf维护了两个索引,分别是读索引写索引。图示:

+——————–+——————+—————-+
| discardable bytes | readable bytes | writable bytes |
+——————–+——————+—————-+

读索引的位置就在discardable bytesreadable bytes之间,而写索引的位置就在readable byteswritable bytes之间。(这里很简单理解,为了美观就不在图上表明了)

图示可知,ByteBuf由三个片段构成:废弃段、可读段和可写段。当用户使用read方法或者skip方法时,将会增加读索引。读索引之前的数据将进入废弃段,表示该数据已被使用。调用discard方法则会丢弃discardable bytes段的数据。比如我们按照上图所示,丢弃所有discardable bytes,就会变成这样:

+——————+————————————–+
| readable bytes | writable bytes (got more space) |
+——————+————————————–+

需要注意的是,当我们使用clear方法清空缓冲区时,此时缓冲区的写索引和读索引都将置0,但是并不清除缓冲区中的实际数据。此外,用户可以使用markreset标记并重置读索引和写索引。

另外需要提一句ByteBuf的视图。可以在已有的缓冲区上创建视图即派生缓冲区。这些视图维护各自独立的写索引、读索引以及标记索引,但他们和原生缓冲区共享内部字节数据。创建派生缓冲区的方法有duplicateslice。如果想拷贝缓冲区,也就是说期望维护特有的字节数据而不是共享字节数据,此时可使用copy方法。

类别

按照底层的实现方式,可以把ByteBuf分为几种:

  1. HeapByteBuf,底层实现为JAVA堆内存数组。可以认为就是一个对象,位于JVM堆内存区,可由GC回收,其申请和释放效率较高。
  2. DirectByteBuf,底层实现为操作系统内核空间的字节数组,并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。这部分数据一般情况下我们选择自己释放(Netty就是这么干的)。申请和释放效率都低于堆缓冲区,另一方面,却可以大大提高IO效率。
  3. CompositeByteBuf,组合ByteBuf,顾名思义,后面我们细说。

按照是否被池化,可以分几种:

  1. PooledByteBuf,当对象释放后会归还给对象池,所以可循环使用。
  2. UnpooledByteBuf,不使用对象池的缓冲区。

池化和非池化的使用场景我就说一句,需要来来回回创建使用的时候就用池化。

根据这四种特性,可以组合出不同的ByteBuf,比如PooledHeapByteBuf,再比如UnpooledDirectByteBuf,有各种各样的组合方式方便我们进行使用。

引用计数

我们可以注意到,ByteBuf只是一个抽象类不能直接使用,而且其实现了ReferenceCounted接口。这个接口就是引用计数的抽象,表示ByteBuf是被引用计数管理的。

引用计数的基本规则有下:

  1. 初始计数为1,以ByteBuf为例,new出来的时候就是1。
  2. 引用计数为0时,对象不能再被使用只能被释放。

我们可以使用retain使引用计数增加1,使用release使引用计数减少1,这两个方法都可以指定参数表示引用计数的增加值和减少值(比如retain(2)表示引用计数增加2)。当我们使用引用计数为0的对象时,将抛出异常IllegalReferenceCountException

那我们怎么来决定什么时候释放什么时候增加呢,通用原则是谁最后使用含有引用计数的对象,谁负责释放或销毁该对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// 接收数据,作为生产者(往ByteBuf中写数据),不需要释放
public ByteBuf receiveData(ByteBuf b, byte data) {
b.writeByte(data);
return b;
}

// 完全消费了数据,作为消费者(从ByteBuf中读数据),这里需要释放
public String decodeData(ByteBuf b) {
try {
byte data = b.readByte();
return new String(new byte[]{data});
} finally {
b.release();
}
}

我们之前提过派生缓冲区的概念,通过duplicateslice等生成的ByteBuf会共享原ByteBuf的数据,这时派生缓冲区并没有自己独立的引用计数而需要共享原生缓冲区的引用计数。也就是说,当我们需要将派生缓冲区传入下一个组件时,一定要注意先调用retain方法,防止原来的ByteBuf被直接release掉。

泄漏检测

引用计数虽然大大提高了ByteBuf的使用效率,但也引入了一个新的问题:引用计数对象的内存泄露。由于JVM并没有意识到Netty实现的引用计数对象,它仍会将这些引用计数对象当做常规对象处理,也就意味着,当不为0的引用计数对象变得不可达时仍然会被GC自动回收。一旦被GC回收,引用计数对象将不再返回给创建它的对象池,这样便会造成内存泄露。用一句话概括一下就是:对象池(Netty角度)认为这个对象没被回收,但是GC(JVM角度)认为这个对象已经被回收了。

为了便于用户发现内存泄露,Netty提供了相应的检测机制并定义了四个检测级别:

  1. DISABLED,完全关闭内存泄露检测。
  2. SIMPLE,抽样检测,且只对部分方法调用进行记录,消耗较小,有泄漏时可能会延迟报告。默认级别。
  3. ADVANCED,抽样检测,记录对象最近几次的调用记录,有泄漏时可能会延迟报告。
  4. PARANOID,每次创建一个对象时都进行泄露检测,且会记录对象最近的详细调用记录。只推荐在测试时使用。