NIO三板斧之Buffer,提升程序运行效率的双刃剑

这一节, 将重点来讨论NIO中的Buffer。 彻底理解清楚Buffer是个什么样的东西。

一、 堆内内存与堆外内存

关于Buffer, 在基础篇中已经做过简单介绍。他是网络IO数据与本地数据的缓冲。Nio中相关数据都 是通过Buffer来携带。在这一部分,就来深入看看这个Buffer到底是什么。

实际上Buffer就是映射的一段内存数据, 而在内存中就全是由0和1组成的数据。 Nio中的Buffer有很 多实现类, 其中最为根本的就是ByteBuffer, 因为所有的数据形式最终都可以通过Byte来描述。 但是 java.nio.ByteBuffer只是一个抽象类, 在他的下面有两个主要的实现类: DirectByteBuffer和 HeapedByteBuffer。 整体类图如下:

NIO三板斧之Buffer,提升程序运行效率的双刃剑

其中这个HeapByteBuffer就是对应JVM堆内内存。 而DirectBuffer就对应堆外内存。

另外一个MappedByteBuffer则是一个文件的映射内存, 通常配合FileChannel使用。 在下一个章节中会再来盘他。

堆内内存有JVM的GC进行管理, 使用起来靠谱很多。 而堆外内存则是使用的操作系统的内存,使用 起来风险就会大很多。 需要手动进行管理, 包括分配、 读写、 回收等过程都要自己管理。尤其是要注 意回收。 如果堆外内存没有正确回收, 这块内存就无法被其他应用程序使用, 造成内存泄漏。 最终会 影响整个系统的安全性。

但是, 也正是因为没有GC的管理, 所以堆外内存的使用效率相对会更高。 例如他就不会有GC中一直 困扰的STW问题, 更深入一点, 数据在内存态与内核态之间的拷贝次数也会相对较少。 JVM虚拟机针对DirectBuffer的IO操作也做了大量的优化。例如在JVM底层会尽量避免数据在不同ByteBuffer之间 的拷贝。

关于DirectBuffer, 他可以代表一段具体的内存数据, 同时也可以是其他DirectBuffer的view视 图, 这样才能有效的将内存地址进行传递。而其中提供了一个attachement()方法, 这个方法就 会绕过各层的view, 直接找到最终的内存内容。

堆外适合用来存放一些需要长期存储, 且变化不会太多, 结构也不太复杂的数据。 基本上所有追求极 致性能的场景都会拿这个DirectBuffer开刀。 像Netty、 RocketMQ、 EHcache等很多开源框架都大 量的使用了堆外内存。

另外两个DirectByteBufferR 和 HeapByteBufferR就是对应的只读版本。

接下来就从初始化、 读写数据、 内存回收三个步骤来深入理解下HeapByteBuffer和 DirectByteBuffer。

二、 内存对象初始化

HeapByteBuffer的初始化方式通常只有一个, 就是ByteBuffer的allocate()方法。 这个创建方法比较简单

// # java.nio.ByteBuffer
public static ByteBuffer allocate(int capacity) {    
  if (capacity < 0)      
    throw new IllegalArgumentException();   
  return new HeapByteBuffer(capacity, capacity); 
}
// # java.nio.HeapByteBuffer
HeapByteBuffer(int cap, int lim) {            // package ‐private   
  super( ‐1, 0, lim, cap, new byte[cap], 0);  
}

这里面, 关于mark,position.limit,cap这些参数, 就是ByteBuffer的基础使用机制, 可以去看下基础 篇就行。 这里重点讨论在他们内部如何保存数据。可以看到, 对于HeapByteBuffer, 就直接使用一 个byte数据来保存数据。

DirectBuffer的初始化方式主要有两个, 一个是ByteBuffer的allocateDirect()方法。 另一个是通过 FileChannel的map方法创建映射。 先来看第一个方法。 其中主要的步骤都加了注释。

// # java.nio.ByteBuffer
public static ByteBuffer allocateDirect(int capacity) {
  return new DirectByteBuffer(capacity);
}
// # java.nio.DirectByteBuffer
DirectByteBuffer(int cap) {
  //1、  调用父构造器时,  没有传入保存数据的结构。 而是会由后续的unsafe类来直接操作内存数据。
  super( ‐1, 0, cap, cap);
  boolean pa = VM.isDirectMemoryPageAligned();
  int ps = Bits.pageSize();
  long size = Math.max(1L, (long)cap + (pa ? ps : 0));
  //2、  记录相关内存信息。
  Bits.reserveMemory(size, cap);long base = 0;
  //3、  分配内存地址
  try {
    base = unsafe.allocateMemory(size);
  } catch (OutOfMemoryError x) {
    
Bits.unreserveMemory(size, cap);
    throw x;
  }
  unsafe.setMemory(base, size, (byte) 0);
  if (pa && (base % ps != 0)) {
    // Round up to page boundary
    address = base + ps ‐ (base & (ps ‐ 1));
  } else {
    address = base;
  }
  //4、  构建内存回收对象
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;
}

从这个初始化过程中会看到, DirectBuffer的缓存数据并没有通过JVM中的对象来保存, 而是通过 unsafe类直接操作的内存数据。

另一个初始化的方式可以参见示例代码中的com.roy.zerocopy.MappedByteBufferDemo, 也就是 常说的零拷贝的一种方式。 其中核心的创建方式是

MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

关于这几个参数, 第一个是打开的模式, READ_WRITE表示可读可写。

第二个参数是position 表示可以直接修改的起始位置。

第三个参数是size 表示映射到内存的大小。

创建时需要指定一个映射的范围, 只有映射范围内的文件内容是可以修改的, 映射范围外的文件内容 如果尝试修改会抛异常IndexOutOfBoundsException。 这跟ByteBuffer的工作机制一一致的。 这种 方式在程序内存中实际上是映射的一些文件相关的元数据信息, 而不需要拷贝完整的文件内容, 所以 能够减少用户态到JVM内存的拷贝过程。 在RocketMQ中就大量的使用了这种机制来管理本地的落盘 文件。

但是这个源码就很难看了, 因为这里面涉及到了很多跟操作系统内核对应的一些代码。 例如对FileDescriptor的操作。

三、 内存数据读写

其实数据读写操作都是基于他们不同的数据存储方式。HeapByteBuffer是以一个byte[]数组来保存 数据,所有的数据读写操作最终都落地为对byte[]数组的操作, 相对就简单很多。

但是DirectByteBuffer不缓存数据,所有的数据操作都是通过unsafe类来对内存进行实际的操作。

有很多人在面试时很难解释清楚零拷贝到底是个什么玩意。其实,从这里就可以体现。通过 DirectByteBuffer不存储内存数据,所以也就少了一次数据的拷贝过程。

实际上,这种机制在java中使用是非常非常频繁的。具体可以看下上一章节介绍的lsof指令。

四、 内存释放

每个内存使用完了都需要释放,回收。对于ByteBuffer也不例外。这其中,HeapByteBuffer比较简单, 直接交由GC处理就行。但是对于DirectByteBuffer,这个回收过程就有点麻烦了。

因为对于一个DirectByteBuffer,变量要在JVM中的栈内存中分配, 而实际的内存空间又需要分配在堆空间上。 而堆空间并不保存实际的内存数据,只保存一个到对外内存的映射。像这样:

NIO三板斧之Buffer,提升程序运行效率的双刃剑

这时如果程序只是简单的终止, 那么JVM中的栈内存和堆内存都可以由GC回收。但是操作系统内存 中的直接内存地址就没办法回收了。如果不回收, 这就是内存泄漏的问题。所以,DirectByteBuffer 中还专门设计了内存回收的机制,保证直接内存地址会在堆内存地址被GC回收时, 一起回收。

其实如何对GC过程进行干预,是一直伴随java发展的一个问题。对于Object类的finalize方法,也是一直被人说道的地方。那怎么在对象GC回收过程中进行人工的干预呢? 这个DirectByteBuffer就提供了最好的示例。

对DirectByteBuffer的内存回收机制, 可以从这样一条路线串起来。

Step1: 在DirectByteBuffer构建时, 就创建了一个Cleaner对象

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

在创建Cleaner时, 传入了一个Deallocator对象。 这个对象是一个实现了runnable的线程资源类, 在他的run方法中就实现了对实际内存地址的回收逻辑。

private static class Deallocator
 implements Runnable
{
  ...
  public void run() {
    ...
    //回收对外内存
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
  }
}

接下来就要寻找这个资源回收线程在什么时候启动。 而启动的机制就在这个Cleaner中了。Step2: 在Cleaner对象中, 有一个clean()方法, 这个方法中实现了具体的对象GC销毁逻辑。

// # sun.misc.Cleaner  
public void clean() {
  //1、  将自己移出双向队列
  if (remove(this)) {
    try {
      //2、  启动销毁线程
      this.thunk.run();
    } catch (final Throwable var2) {
      AccessController.doPrivileged(new PrivilegedAction() { public Void run() {
        if (System.err != null) {
          (new Error("Cleaner terminated abnormally",
                     var2)).printStackTrace();
        }
        System.exit(1);
        return null;
      }
    });  
  }  
}
}

注解1、 在Cleaner类内部, 维护了一个Cleaner对象组成的双向链表结构。在调用Cleaner的 create方法时, 会将创建出来的cleaner对象加入到这个双向链表中。

注解2、 Cleaner在销毁时,会启动一个对象销毁的线程。Cleaner类只管启动销毁线程, 并不管 销毁的逻辑。 销毁的具体逻辑, 是在创建Cleaner时传入的。在我们讨论的这个场景, 这个thunk就是传入的Deallocator对象。

到了这一步后, 我们就可以有一种手动的方式来回收对外内存。 cleaner方法就会返回这个Cleaner 对象, 直接调用clean方法就会进行堆外内存的回收。

public static void clean(final ByteBuffer byteBuffer) {
  if (byteBuffer.isDirect()) {
    ((DirectBuffer)byteBuffer).cleaner().clean();
  }
}

在RocketMQ中, 他的本地存盘文件, 都是以这种堆外内存的形式映射到内存中来操作的。 而他 在应用停止时, 正是用的这种方法来回收内存的。 有兴趣可以自己去翻阅一下源码。

只不过要注意下,DirectByteBuffer在使用时,即可以代表一块堆外内存,也可以代表一个DirectByteBuffer的映射,可以多次传递。这时要先通过attachment()方法, 获得原始的对外内 存。下面这段是DirectByteBuffer源码中对于attachment的注解

// An object attached to this buffer. If this buffer is a view of another
// buffer then we use this field to keep a reference to that buffer to
// ensure that its memory isn 't freed before we are done with it.
private final Object att;

资源销毁的步骤又往前找了一步。 接下来就是要找到DirectBuffer中是如何自动触发Cleaner的clean 方法了。 毕竟不可能每次都要求应用程序自己去手动触发堆外内存的回收。

Step3: DirectByteBuffer的自动内存回收, 要从Cleaner的构造函数说起。 Cleaner继承了 PhantomReference类, 表示这是一个虚引用。java中有强引用、 软引用、 弱引用、 虚引用四种引用 类型, 这四种引用类型的区别, 会在下一章继续整理。 我们先来梳理清楚DirectByteBuffer自动内存 回收的主线。

// # sun.misc.Cleaner
private Cleaner(Object var1, Runnable var2) {
  super(var1, dummyQueue);
  this.thunk = var2;
}

在Cleaner的构造函数中, 引用了父类的构造函数, 并传入了一个ReferenceQueue dummyQueue队列。 而在Cleaner的父类java.lang.ref.Reference中, 有一段静态代码块, 实现了 DirectByteBuffer的自动内存回收。

static {
  ThreadGroup tg = Thread.currentThread().getThreadGroup();
  for (ThreadGroup tgn = tg;
   tgn != null;
    tg = tgn, tgn = tg.getParent());
  // 核心就在这个线程中。
  Thread handler = new ReferenceHandler(tg, "Reference Handler");
  handler.setPriority(Thread.MAX_PRIORITY);
  handler.setDaemon(true);
  handler.start();
}

在这个静态代码块中, 启动了一个ReferenceHandler线程, 并且这个线程是一个守护线程, 并且优 先级很高。 也就是说他会一直随着java进程执行。 而这个ReferenceHandler的定义, 就在这段静态 代码块的上方。

private static class ReferenceHandler extends Thread {
  ...
  public void run() {
    for (;;) {
      Reference r;
      synchronized (lock) {
        if (pending != null) {
          r = pending;
          pending = r.discovered;
          r.discovered = null;
        } else {
          try {
            try {
         lock.wait();
          } catch (OutOfMemoryError x) { }
          } catch (InterruptedException x) { }
          continue;
        }
      }
      // 在这里调用Cleaner的clean方法。
      if (r instanceof Cleaner) {
        ((Cleaner)r).clean();
        continue;
      }
      ReferenceQueue q = r.queue;
      if (q != ReferenceQueue.NULL) q.enqueue(r);
    }
  }
}

执行逻辑是一段死循环,也就是这个线程会一直工作。前面一段并发锁控制, 就是在不断检测当前对象是否被GC。当发现该对象要被GC回收后, 如果发现这个对象是一个Cleaner对象, 就直接调用clean()方法, 然后返回继续工作。通过这种方式, 保证Cleaner对象被GC回收时,能调用到clean方法, 回收对外内存。

代码梳理到这里, DirectByteBuffer的自动内存回收机制到这里也就梳理清楚了。但是, 这里其实还 有一个容易被忽视的小问题。既然Cleaner不需要进行后续的enqueue入列操作,那为什么在 Cleaner中要声明一个静态的dummyQueue呢? 好吧, 我也没有弄明白这个dummyQueue有什么 用。

这是因为虚引用必须和引用队列配合使用,这样才能保持对象的引用关系。

另外,其实在Reference中的这段逻辑涉及到了大量GC的东西所以, 前面的一段锁判断, 其实并 没有代码上看到的这么简单, 其背后涉及到大量GC的机制了。

可以这么理解, Reference代表的是一个从栈指向堆的引用关系。当Reference引用的对象被GC回收后,就会加入到Reference中的ReferenceQueue当中。这样JVM在实际回收完这个对象后, 就可以通过 ReferenceHandler 不断扫描这些队列, 判断是不是需要对这个对象是不是需要做一些特殊的处 理。

五、 Cleaner机制和Finalizer机制

到这里, DirectByteBuffer的自动内存回收机制就已经梳理清楚了。DirectByteBuffer中定义了 Cleaner对象后,在GC时,就会自动回收对外内存。

这个Cleaner内存回收机制与java的另外一个机制Finalizer机制, 效果是差不多的,都是用来对GC回 收机制做一个收尾功能。并且他们也都是会以一个守护进程的方式一直运行。 还记得上一章节用 strace指令跟踪java任务时, 产生的一大堆文件吗? 没错, 其中就有两个文件是分别属于Cleaner和 Finalizer的。

这两个机制其实也还是稍微有点小区别的。 Cleaner机制的守护线程优先级为最高, 而Finalizer机制 的优先级比最好级别低了2。 并且,他们对于异常的处理机制是不同的。finalize()方法中的异常不会 被抛出, 而Cleaner的异常处理更好。

最后要注意的是, Cleaner机制和java的finalizer机制一样,都是极不建议使用的。 一方面, 一个对 象从不可达的垃圾状态到被Cleaner或者Finalizer处理, 这之间的时间间隔是不确定的, 甚至, JVM 都不能保证这些方法一定会执行, 所以不能用他们来做时间敏感的操作。 在JDK9中已经禁止了Finalizer机制,只能在JDK内部使用。 另一方面, 在这两个GC后处理的机制, 都会严重影响程序性能。

这两个后处理机制只能在某些内部场景下作为最后一道安全防线来使用,例如FileInputStream类中, 使用finalize()方法做一次关闭操作,加强安全性。而我们在使用DirectByteBuffer时, 也一定是 需要主动调用clean()方法, 进行内存释放的。

六、JAVA四种引用类型

JVM中对于引用Reference, 有好几种类型的实现, 具体也就对应java.lang.ref.Reference的几个实 现类。 GC针对不同类型的引用关系提供了不同的回收机制。

Java中4种引用的级别和强度由高到低依次为: 强引用 - > 软引用 - > 弱引用 - > 虚引用

当垃圾回收器回收时,某些对象会被回收,某些不会被回收。垃圾回收器会从根对象Object来标记存 活的对象, 然后将某些不可达的对象和一些引用的对象进行回收。

引用类型

被垃圾回收时间

用途

生存时间

强引用

从来不会

对象的一般状态

JVM停止运行时终止

软引用

当内存不足时

对象缓存

内存不足时终止

弱引用

正常垃圾回收时

对象缓存

垃圾回收后终止

虚引用

正常垃圾回收时

跟踪对象的垃圾回收

垃圾回收后终止

1 强引用Strong Reference 强引用是最普遍的引用, 如果一个对象有强引用, 那垃圾回收期绝不会 回收该对象。

2 软引用 SoftReference 如果一个对象只有软引用, 则内存空间充足时, 垃圾回收期不变会回收。 但是如果内存空间不足, 垃圾回收器就会回收该对象, 节省内存空间。

3 弱引用 WeakReference 弱引用与软引用的区别在于, 只有弱引用的对象拥有更短暂的生命周 期。 垃圾回收器线程在扫描他所管辖的内存区域时, 一旦发现只具有弱引用的对象, 不管当前内存是 否足够, 都会立即回收。 不过, 由于垃圾回收器线程是一个优先级非常低的线程, 因此不一定会很快 发现这些只具有弱引用的对象。 弱引用可以重新声明成强引用。

4 虚引用 PhantomReference 虚引用顾名思义, 就是形同虚设。 与其他几种引用都不同, 虚引用并 不会决定对象的生命周期。 如果一个对象仅持有虚引用, 那么它就和没有任何引用一样, 在任何时候 都可能被垃圾回收器回收。

设置虚引用的唯一 目的就是在这个对象被GC回收时, 收到一个系统的通知或者后续添加进一步的处 理。 例如触发对象的finalize方法。

虚引用必须和引用队列联合(ReferenceQueue)使用。

5 引用队列 ReferenceQueue ReferenceQueue是用来配合Reference工作的。 对于软引用、 弱引 用和虚引用, 由于他们引用的对象随时都有可能被GC回收, 为了保证他们的存在感, 就可以用一个 引用队列来将这些引用串起来, 这样就能进行统一的业务操作。

不同的引用类型有不同的使用场景。例如SoftReference适合用来做高速缓存,WeakReference适合用来做普通缓存,而PhantomReference则可用在一些特殊场景,例如DirectByteBuffer的内存回收。另外还有个FinalReference, 主要是用来协助调用对象的finalize()方法的。

另外,在他们的父类Reference中其实还涉及到引用的状态管理。引用示例可以有以下几个状态:

Active

当处于Active状态, gc会特殊处理引用实例, 一旦gc检测到其可达性发生变化, gc就会更改其 状态。 此时分两种情况, 如果该引用实例创建时有注册引用队列, 则会进入pending状态, 否则 会进入inactive状态。 新创建的引用实例为Active。

Pending

当前为pending-Reference列表中的一个元素, 等待被ReferenceHandler线程消费并加入其注 册的引用队列。 如果该引用实例未注册引用队列, 则永远不会处理这个状态。

Enqueued

该引用实例创建时有注册引用队列并且当前处于入队列状态, 属于该引用队列中的一个元素。 当 该引用实例从其注册引用队列中移除后其状态变为Inactive。 如果该引用实例未注册引用队列 , 则永远不会处理这个状态。

Inactive

当处于Inactive状态, 无需任何处理, 一旦变成Inactive状态则其状态永远不会再发生改变。

整体迁移流程图如下:

NIO三板斧之Buffer,提升程序运行效率的双刃剑

七、章节总结

JAVA的GC机制使得内存使用非常的方便, 但是, 其实内存从来都不简单。NIO虽然还是更多的基于JVM的堆内内存进行构建, 但是NIO中也提供了对于堆外内存的完整使用机制。

GC是把双刃剑,简化了应用编程,却不可避免的带来了性能的损耗, 所以,在很多追求性能极致的场景,都会频繁的使用堆外内存。有兴趣可以去跟踪下RocketMQ基于DirectBuffer来管理存盘文件的源码。

下一节, 将再来整理最后的这个Channel。

文章分享就到这里了,了解更多Java知识可关注微信公众号“老周扯IT”

NIO三板斧之Buffer,提升程序运行效率的双刃剑

发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章