Java Nio 系列
Java Nio 之Buffer
Java Nio 之直接内存
Java Nio 之高级搬砖工(FileChannel) 一
Java Nio 之高级搬砖工(FileChannel)二
堆外内存和堆上内存
首先来讲一下什么是堆上内存,在java 中我们经常会编写类似下面一段代码
代码清单1-1
public class HeapByteBufferDemo {
public static void main(String[] args) {
Demo demo = new Demo();//1
demo.print();
}
}
class Demo{
void print() {
System.out.println("i am a demo");
}
}
1 处 使用new 关键字 去创建Demo对象,具体分为三步:
- 在jvm 运行时数据区中的堆上申请一块内存空间
- 初始化实例对象
- 把引用demo 指向分配的内存空间
此时 demo 引用指向的是堆上的一块内存空间,它由jvm管理的,同样也是gc 的主要工作区域。默认我们使用new 关键字,以及newInstance 都是在堆上申请内存。使用堆上的内存在多数情况下都是大家的首选,但是有些情况下我们使用堆外的内存就比较合适,什么是堆外内存呢,就是不被jvm 所管理的其他内存,简称堆外内存。
堆外内存也称直接内存,下面来说下使用直接内存的两个好处:
- 直接内存是在堆外,申请过多不会引起gc;例:申请一块堆外空间,当内存池去使用,netty 就是这种机制,有兴趣的同志可以去研究下,这也是我这个专栏将要涉及的地方。
- 在我们写数据的时候,若数据在堆上,则需要从堆上拷贝到堆外,操作系统才可以去操作这个拷贝的数据;若数据在堆外,就少了一次从堆上拷贝到堆外这个阶段了,节省的时间是非常明显的。大家可能比较纳闷为啥我们的操作系统属于内核态 在ring0级别按理说可以访问所有内存,为啥不直接操作堆上的数据,因为Java 有gc,gc 可能不会回收要被写的数据,但是可能会移动它(把已用内存压缩在一边,清除内存碎片),操作系统是通过内存地址去操作内存的,内存地址变了,这些写到文件或者网络里的数据可能并不是我们想要写的数据,也有可能产生很多未知的错误。
如何使用直接内存(这里使用上节的小例子)
代码清单2-1
/**
* @author lzq
*/
public class DirectBufferDemo {
public static void main(String[] args) {
ByteBuffer demoDirectByteBuffer = ByteBuffer.allocateDirect(8);//A
printBufferProperties("write to demoDirectByteBuffer before ", demoDirectByteBuffer);
//put to buffer 5 bytes utf-8 编码
demoDirectByteBuffer.put("hello".getBytes());
printBufferProperties("after write to demoDirectByteBuffer ", demoDirectByteBuffer);
//invoke flip
demoDirectByteBuffer.flip();
printBufferProperties("after invoke flip ", demoDirectByteBuffer);
byte[] temp = new byte[demoDirectByteBuffer.limit()];
int index = 0;
while (demoDirectByteBuffer.hasRemaining()) {
temp[index] = demoDirectByteBuffer.get();
index++;
}
printBufferProperties("after read from demoDirectByteBuffer", demoDirectByteBuffer);
System.out.println(new String(temp));
}
private static void printBufferProperties(String des, ByteBuffer target) {
System.out.println(String.format("%s--position:%d,limit:%d,capacity:%s",des,
target.position(), target.limit(), target.capacity()));
}
}
只有A处与nio 之 buffer 的demo 不一样,ByteBuffer.allocate(int) 如果加了Direct 则生成的是DirectByteBuffer 实例也就是堆外内存,如果不加则是HeapByteBuffer。
大家可以看到下面调用的方法都是一致的而且输出的都是一致的,这种封装对于使用者无疑是一种释放,面向接口编程真好。但是操作堆外内存和操作堆上内存的实现可能有点不一样,下面来看下如何操作堆外内存。
DirectByteBuffer put(byte)(很简单)
源码清单3-1
public ByteBuffer put(byte x) {
//unsafe 是一个可以操作堆外内存的对象(也可以做其他的),以后有机会可能会和大家讨论一波
unsafe.putByte(ix(nextPutIndex()), ((x)));
return this;
}
final int nextPutIndex() {
//这里和操作堆上内存一样先判断position 是否大于等于limit ,若是大于等于抛出相应异常
if (position >= limit)
throw new BufferOverflowException();
//自增position ,将要写值到buffer 中
return position++;
}
private long ix(int i) {
return address + ((long)i << 0);
//return address+((long)i); 和上面表达的意思一样
// return address+((long)i<<1); DirectShortBufferU里的实现方式
}
向DirectByteBuffer 上写数据:
- 自增position(将要写或读的下一个索引)
- 然后通过 ix 方法计算出堆外的内存地址
address 就是新建DirectByteBuffer 实例时申请的堆外内存首地址 - unsafe.putByte(long,byte) 表示将值存储到给定的地址处,有兴趣大家可以翻看相关native 方法实现
jdk 如何申请DirectByteBuffer
下面我们来看看jdk 到底是怎么申请堆外内存的,有哪些注意点
首先来看下DirectByteBuffer 构造器的实现
源码清单3-2
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap); //A
boolean pa = VM.isDirectMemoryPageAligned(); //B
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));//C
Bits.reserveMemory(size, cap); //D
long base = 0;
try {
base = UNSAFE.allocateMemory(size);// E
} 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;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));//F
att = null;
}
- A 处 调用父类的构造方法 初始化 mark,position,limit,capacity 四个属性。
- B 处和C处联合起来看 表示是否要内存对齐,默认是不对齐的。
- D 处:预定内存,判断未使用内存数(单位:byte)是否大于将要申请的内存size,若是大于等于则总使用量加上要申请的容量,若小于则显式调用System.gc() ,然后整一个循环等待执行完gc,让ReferenceHandler 线程去处理已被回收的虚引用 Cleaner 该类比较特殊 ,ReferenceHandler 在处理Cleaner 时会直接执行它的clean 方法,然后返回,这个 clean 方法就是回收堆外内存的。
- E 处:申请堆外内存。
- F 处: 创建Cleaner 对象,就是在刚才说到的,这个是用来回收堆外内存的。
下面来看下 预定内存的代码 Bits.reserveMemomry(long size,int cap)
具体方法如下 源码清单3-3
static void reserveMemory(long size, int cap) {
//判断是否设置了最大内存限制,若没有则设置下 。
//直接内存的最大内存可以通过 -XX:MaxDirectMemorySize 参数设置
//默认直接内存的最大值为堆空间最大值
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
//乐观的获取内存(准确得说是预定内存)里面就是通过cas 去修改
// totalCapacity 的值,若修改成功,则返回true 如果剩余内存容量小于size 直接返回false
if (tryReserveMemory(size, cap)) {
return;
}
//
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
// retry while helping enqueue pending Reference objects
// which includes executing pending Cleaner(s) which includes
// Cleaner(s) that free direct buffer memory
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
// trigger VM's Reference processing
//显式调用gc,触发虚拟机处理 引用(这个待会再讲),前面确实说过直接内存
//与gc没有关系,但是如果一个DirectByteBuffer对象拥有一个强引用,则该对象
//占用的堆外内存,别的DirectByteBuffer 对象无法使用,所以使用堆外内存也
//要有节制
System.gc();
// 这里就是给虚拟机一点时间去执行gc,构建堆外内存除了我们定义的
//一个强引用外,还有一个虚引用 Cleaner 对象(源码清单3-2的⑤处),构建该
//对象需要传入当前对象也就this,以及一个Deallocator 对象,该类实现Runnable
//接口,run 方法里就是 就是调用 Bits.unreserveDirectory,unsafe.freeMemory()
boolean interrupted = false;
try {
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
if (!jlra.tryHandlePendingReference()) {
try {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;
} catch (InterruptedException e) {
interrupted = true;
}
}
}
// 这里所有堆外内存不能释放,则直接抛出OOM异常
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}
释放堆外内存
首先我还是强调一下堆外内存使用过多不会引起GC,但是刚才在源码清单3-3里也看到若是申请不到堆外内存则会显式调用System.gc(); 这是为啥呢,多说一句可以禁用它,使用jvm 参数-XX:-+DisableExplicitGC 来禁用,对于有些大型项目都是直接禁用的。使用完DirectByteBuffer对象只要把对象引用置为空,等待垃圾回收器去回收该对象即可,DirectByteBuffer对象被回收的时候 它的 虚引用也就是Cleaner 对象会被放到 ReferenceQueue 中,然后专门有一个ReferenceHandle 线程去处理这个队列 若发现从队列里取出的是Cleaner 对象则会执行 clean()方法,clean 方法会调用 thunk 属性的run方法 对于DirectByteBuffer来说这个thunk就是其嵌套类对象,嵌套类代码如下:
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
//回收堆外内存
unsafe.freeMemory(address);
//内存地址置为零,防止重复回收
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
主要看run 方法 就是释放内存。总而言之,就是使用直接内存时也需要时刻注意使用完就把引用置为空,当然也可以申请一块比较大的直接内存自己管理来做一个内存池的玩意,这样更好,但是造轮子需要功力啊。
总结
直接内存即堆外内存,使用时我们需要设置下堆外内存的大小,因为默认为堆的最大大小,还有堆外内存也不是无止境的,使用完还是需要释放的,否则在使用超过堆外内存的最大大小的时候也是会抛出OOM 的,还有若没有内存可以使用,jdk 会显式触发GC,这个在使用较多直接内存的应用中还是禁止掉最好,或者直接禁止掉最好。另外堆外内存和堆内内存调用Api 都是一样的。
后记
点赞加关注走一波,哈哈