想写这个系列好久了,对本身也是个总结与提升。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,可是用的时候咱们通常很难想起来,由于咱们用的少而且不知道为何。知其因此然方能印象深入并学以至用。java
本篇文章针对堆外内存与DirectBuffer进行深刻分析,了解Java对于堆外内存处理的机制,为下一篇文件IO作好准备linux
首先咱们扔出一个公式:nginx
java程序最大可能占用内存 = -Xmx指定的最大堆内存大小 + 最大活跃线程数量*-Xss指定的每一个线程栈内存大小 + -XX:MaxDirectMemorySize指定的最大直接内存大小 + MetaSpace大小
复制代码
堆栈内存指的是堆内存和栈内存:堆内存是GC管理的内存,栈内存是线程内存。web
堆内存结构:api
还有一个更细致的结构图(包括MetaSpace还有code cache):数组
注意在Java8之后PermGen被MetaSpace代替,运行时可自动扩容,而且默认是无限大 缓存
咱们看下面一段代码来简单理解下堆栈的关系:安全
public static void main(String[] args) {
Object o = new Object();
}
复制代码
其中new Object()
是在堆上面分配,而Object o这个变量,是在main这个线程栈上面。bash
-Xmx
咱们能够指定最大堆内存大小,经过-Xss
咱们能够指定每一个线程线程栈占用内存大小除了堆栈内存,剩下的就都是堆外内存了,包括了jvm自己在运行过程当中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等服务器
而做为java开发者,咱们常说的堆外内存溢出了,实际上是狭义的堆外内存,这个主要是指java.nio.DirectByteBuffer在建立的时候分配内存,咱们这篇文章里也主要是讲狭义的堆外内存,由于它和咱们平时碰到的问题比较密切
为啥要使用堆外内存。一般由于:
Java调用原生方法即JNI就是系统调用的一种。
咱们举个例子,文件读取;Java自己并不能读取文件,由于用户态没有权限访问外围设备。须要经过系统调用切换内核态进行读取。
目前,JAVA的IO方式有基于流的传统IO还有基于块的NIO方式(虽然文件读取其实不是严格意义上的NIO,哈哈)。面向流意味着从流中一次能够读取一个或多个字节,拿到读取的这些作什么你说了算,这里没有任何缓存(这里指的是使用流没有任何缓存,接收或者发送的数据是缓存到操做系统中的,流就像一根水管从操做系统的缓存中读取数据)并且只能顺序从流中读取数据,若是须要跳过一些字节或者再读取已经读过的字节,你必须将从流中读取的数据先缓存起来。面向块的处理方式有些不一样,数据是先被 读/写到buffer中的,根据须要你能够控制读取什么位置的数据。这在处理的过程当中给用户多了一些灵活性,然而,你须要额外作的工做是检查你须要的数据是否已经所有到了buffer中,你还须要保证当有更多的数据进入buffer中时,buffer中未处理的数据不会被覆盖。
咱们这里只分析基于块的NIO方式,在JAVA中这个块就是ByteBuffer。
大部分web服务器都要处理大量的静态内容,而其中大部分都是从磁盘文件中读取数据而后写到socket中。咱们以这个过程为例子,来看下不一样模式下Linux工做流程
涉及的代码抽象:
//从文件中读取,存入tmp_buf
read(file, tmp_buf, len);
//将tmp_buf写入socket
write(socket, tmp_buf, len);
复制代码
看上去很简单的步骤可是通过了不少复制:
从上面的过程能够看出,数据白白从内核模式到用户模式走了一圈,浪费了两次 copy(第一次,从kernel模式拷贝到user模式;第二次从user模式再拷贝回kernel模式,即上面4次过程的第2和3步骤。),而这两次 copy 都是 CPU copy,即占用CPU资源
经过 sendfile 传送文件只须要一次系统调用,当调用 sendfile 时:
Linux2.4 内核对sendFile模式进行了改进:
改进后的处理过程以下:
通过上述过程,数据只通过了 2 次 copy 就从磁盘传送出去了。(事实上这个 Zero copy 是针对内核来说的,数据在内核模式下是 Zero-copy 的)。
当前许多高性能 http server 都引入了 sendfile 机制,如 nginx,lighttpd 等。
Zero-Copy技术省去了将操做系统的read buffer拷贝到程序的buffer,以及从程序buffer拷贝到socket buffer的步骤,直接将read buffer拷贝到socket buffer. Java NIO中的FileChannal.transferTo()方法就是这样的实现
public void transferTo(long position,long count,WritableByteChannel target);
复制代码
transferTo()方法将数据从一个channel传输到另外一个可写的channel上,其内部实现依赖于操做系统对zero copy技术的支持。在unix操做系统和各类linux的发型版本中,这种功能最终是经过sendfile()系统调用实现。下边就是这个方法的定义:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
复制代码
和以前所述同样,咱们用下面两幅图更清楚的展现一下发生的复制以及内核态用户态切换:
内核、用户态切换的次数只有两次,将数据的复制次只有三次(只有一次用到cpu资源) 在Linux2.4以后,咱们能够将这仅有的一次cpu复制也去掉
在内核为2.4或者以上版本的linux系统上,socket缓冲区描述符将被用来知足这个需求。这个方式不只减小了内核用户态间的切换,并且也省去了那次须要cpu参与的复制过程。 从用户角度来看依旧是调用transferTo()方法,可是其本质发生了变化:
调用transferTo方法后数据被DMA从文件复制到了内核的一个缓冲区中。
数据再也不被复制到socket关联的缓冲区中了,仅仅是将一个描述符(包含了数据的位置和长度等信息)追加到socket关联的缓冲区中。DMA直接将内核中的缓冲区中的数据传输给协议引擎,消除了仅剩的一次须要cpu周期的数据复制。
直接上源码:
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
public class FileCopyTest {
/**
* 经过字节流的方式复制文件
* @param fromFile 源文件
* @param toFile 目标文件
* @throws FileNotFoundException 未找到文件异常
*/
public static void fileCopyNormal(File fromFile, File toFile) throws FileNotFoundException {
InputStream inputStream = null;
OutputStream outputStream = null;
try {
inputStream = new BufferedInputStream(new FileInputStream(fromFile));
outputStream = new BufferedOutputStream(new FileOutputStream(toFile));
//用户态缓冲有1kB这么大,不算小了
byte[] bytes = new byte[1024];
int i;
//读取到输入流数据,而后写入到输出流中去,实现复制
while ((i = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, i);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (inputStream != null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 用filechannel进行文件复制
*
* @param fromFile 源文件
* @param toFile 目标文件
*/
public static void fileCopyWithFileChannel(File fromFile, File toFile) {
FileInputStream fileInputStream = null;
FileOutputStream fileOutputStream = null;
FileChannel fileChannelInput = null;
FileChannel fileChannelOutput = null;
try {
fileInputStream = new FileInputStream(fromFile);
fileOutputStream = new FileOutputStream(toFile);
//获得fileInputStream的文件通道
fileChannelInput = fileInputStream.getChannel();
//获得fileOutputStream的文件通道
fileChannelOutput = fileOutputStream.getChannel();
//将fileChannelInput通道的数据,写入到fileChannelOutput通道
fileChannelInput.transferTo(0, fileChannelInput.size(), fileChannelOutput);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
if (fileChannelInput != null) {
fileChannelInput.close();
}
if (fileOutputStream != null) {
fileOutputStream.close();
}
if (fileChannelOutput != null) {
fileChannelOutput.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws IOException {
File fromFile = new File("D:/readFile.txt");
File toFile = new File("D:/outputFile.txt");
//预热
fileCopyNormal(fromFile, toFile);
fileCopyWithFileChannel(fromFile, toFile);
//计时
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
fileCopyNormal(fromFile, toFile);
}
System.out.println("fileCopyNormal time: " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
fileCopyWithFileChannel(fromFile, toFile);
}
System.out.println("fileCopyWithFileChannel time: " + (System.currentTimeMillis() - start));
}
}
复制代码
测试结果:
fileCopyNormal time: 14271
fileCopyWithFileChannel time: 6632
复制代码
差了一倍多的时间(文件大小大概8MB),若是文件更大这个差距应该更加明显。
Java中NIO的核心缓冲就是ByteBuffer,全部的IO操做都是经过这个ByteBuffer进行的;Bytebuffer有两种: 分配HeapByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(int capacity);
复制代码
分配DirectByteBuffer
ByteBuffer buffer = ByteBuffer.allocateDirect(int capacity);
复制代码
二者的区别:
FileChannel的force方法: FileChannel.force()方法将通道里还没有写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操做系统会将数据缓存在内存中,因此没法保证写入到FileChannel里的数据必定会即时写到磁盘上。要保证这一点,须要调用force()方法。 force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。
不管是FileChannel仍是SocketChannel,他们的读写方法都依赖IOUtil的相同方法,咱们这里来看下: IOUtil.java
static int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
//若是是DirectBuffer,直接写
if (var1 instanceof DirectBuffer) {
return writeFromNativeBuffer(var0, var1, var2, var4);
} else {
//非DirectBuffer
//获取已经读取到的位置
int var5 = var1.position();
//获取能够读到的位置
int var6 = var1.limit();
assert var5 <= var6;
//申请一个源buffer可读大小的DirectByteBuffer
int var7 = var5 <= var6 ? var6 - var5 : 0;
ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7);
int var10;
try {
var8.put(var1);
var8.flip();
var1.position(var5);
//经过DirectBuffer写
int var9 = writeFromNativeBuffer(var0, var8, var2, var4);
if (var9 > 0) {
var1.position(var5 + var9);
}
var10 = var9;
} finally {
//回收分配的DirectByteBuffer
Util.offerFirstTemporaryDirectBuffer(var8);
}
return var10;
}
}
//读的方法和写相似,这里省略
复制代码
首先,先说一点,执行native方法的线程,被认为是处于SafePoint,因此,会发生 NIO 若是不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的状况(能够参考个人另外一篇文章: blog.csdn.net/zhxdick/art…
传统 BIO 是面向 Stream 的,底层实现能够理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,可是拿这个数组的引用照样能找到最新的地址,,对应的方法时是:FileOutputStream.write
private native void writeBytes(byte b[], int off, int len, boolean append)
throws IOException;
复制代码
可是NIO,为了提高效率,传的是内存地址,省去了一次间接应用,可是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write
abstract int write(FileDescriptor fd, long address, int len)
throws IOException;
复制代码
那为什么内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减小内存碎片。DirectByteBuffer不受GC控制。若是不用DirectByteBuffer而是用HeapByteBuffer,若是在调用系统调用时,发生了GC,致使HeapByteBuffer内存位置发生了变化,可是内核态并不能感知到这个变化致使系统调用读取或者写入错误的数据。因此必定要经过不受GC影响的HeapByteBuffer来进行IO系统调用。
假设咱们要从网络中读入一段数据,再把这段数据发送出去的话,采用Non-direct ByteBuffer的流程是这样的:
网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络
复制代码
这种方式是直接在堆外分配一个内存(即,native memory)来存储数据, 程序经过JNI直接将数据读/写到堆外内存中。由于数据直接写入到了堆外内存中,因此这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操做了。这样在进行I/O操做时,只须要将这个堆外内存地址传给JNI的I/O的函数就行了。
采用Direct ByteBuffer的流程是这样的:
网络 –> 应用 Direct ByteBuffer –> 网络
复制代码
能够看到,除开构造和析构临时Direct ByteBuffer的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何状况下都采用Direct Buffer呢?
不是。对于大部分应用而言,两次内存拷贝的时间几乎能够忽略不计,而构造和析构DirectBuffer的时间却相对较长。在JVM的实现当中,某些方法会缓存一部分临时Direct ByteBuffer,意味着若是采用Direct ByteBuffer仅仅能节约掉两次内存拷贝的时间, 而没法节约构造和析构的时间。就用Sun的实现来讲,write(ByteBuffer)和read(ByteBuffer)方法都会缓存临时Direct ByteBuffer,而write(ByteBuffer[])和read(ByteBuffer[])每次都生成新的临时Direct ByteBuffer。
分配在堆上的,直接由Java虚拟机负责垃圾收集,你能够把它想象成一个字节数组的包装类
class HeapByteBuffer
extends ByteBuffer
{
HeapByteBuffer(int cap, int lim) { // package-private
super(-1, 0, lim, cap, new byte[cap], 0);
/*
hb = new byte[cap];
offset = 0;
*/
}
}
public abstract class ByteBuffer
extends Buffer
implements Comparable<ByteBuffer>
{
// These fields are declared here rather than in Heap-X-Buffer in order to
// reduce the number of virtual method invocations needed to access these
// values, which is especially costly when coding small buffers.
//
final byte[] hb; // Non-null only for heap buffers
final int offset;
boolean isReadOnly; // Valid only for heap buffers
// Creates a new buffer with the given mark, position, limit, capacity,
// backing array, and array offset
//
ByteBuffer(int mark, int pos, int lim, int cap, // package-private
byte[] hb, int offset)
{
super(mark, pos, lim, cap);
this.hb = hb;
this.offset = offset;
}
复制代码
这个类就没有HeapByteBuffer简单了
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
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;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
复制代码
Bits.reserveMemory(size, cap) 方法
static void reserveMemory(long size, int cap) {
synchronized (Bits.class) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// -XX:MaxDirectMemorySize limits the total capacity rather than the
// actual memory usage, which will differ when buffers are page
// aligned.
if (cap <= maxMemory - totalCapacity) {
reservedMemory += size;
totalCapacity += cap;
count++;
return;
}
}
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException x) {
// Restore interrupt status
Thread.currentThread().interrupt();
}
synchronized (Bits.class) {
if (totalCapacity + cap > maxMemory)
throw new OutOfMemoryError("Direct buffer memory");
reservedMemory += size;
totalCapacity += cap;
count++;
}
}
复制代码
在DirectByteBuffer中,首先向Bits类申请额度,Bits类有一个全局的totalCapacity变量,记录着所有DirectByteBuffer的总大小,每次申请,都先看看是否超限,堆外内存的限额默认与堆内内存(由-Xmx 设定)相仿,可用 -XX:MaxDirectMemorySize 从新设定。
若是不指定,该参数的默认值为Xmx的值减去1个Survior区的值。 如设置启动参数-Xmx20M -Xmn10M -XX:SurvivorRatio=8,那么申请20M-1M=19M的DirectMemory
若是已经超限,会主动执行Sytem.gc(),期待能主动回收一点堆外内存。System.gc()会触发一个full gc,固然前提是你没有显示的设置-XX:+DisableExplicitGC来禁用显式GC。而且你须要知道,调用System.gc()并不可以保证full gc立刻就能被执行。而后休眠一百毫秒,看看totalCapacity降下来没有,若是内存仍是不足,就抛出OOM异常。若是额度被批准,就调用大名鼎鼎的sun.misc.Unsafe去分配内存,返回内存基地址
因此,通常的框架里面,会在启动时申请一大块DirectByteBuffer,而后本身作内存管理
最后,建立一个Cleaner,并把表明清理动做的Deallocator类绑定 – 下降Bits里的totalCapacity,并调用Unsafe调free去释放内存。
HeapByteBuffer就不要说了,GC就帮忙处理了。这儿主要说下DirectByteBuffer 存在于堆内的DirectByteBuffer对象很小,只存着基地址和大小等几个属性,和一个Cleaner,但它表明着后面所分配的一大段内存,是所谓的冰山对象。 其中first是Cleaner类的静态变量,Cleaner对象在初始化时会被添加到Clener链表中,和first造成引用关系,ReferenceQueue是用来保存须要回收的Cleaner对象。
若是该DirectByteBuffer对象在一次GC中被回收了 此时,只有Cleaner对象惟一保存了堆外内存的数据(开始地址、大小和容量),在下一次Full GC时,把该Cleaner对象放入到ReferenceQueue中,并触发clean方法。
快速回顾一下堆内的GC机制,当新生代满了,就会发生young gc;若是此时对象还没失效,就不会被回收;撑过几回young gc后,对象被迁移到老生代;当老生代也满了,就会发生full gc。
这里能够看到一种尴尬的状况,由于DirectByteBuffer自己的个头很小,只要熬过了young gc,即便已经失效了也能在老生代里舒服的呆着,不容易把老生代撑爆触发full gc,若是没有别的大块头进入老生代触发full gc,就一直在那耗着,占着一大片堆外内存不释放。
这时,就只能靠前面提到的申请额度超限时触发的system.gc()来救场了。但这道最后的保险其实也不很好,首先它会中断整个进程,而后它让当前线程睡了整整一百毫秒,并且若是gc没在一百毫秒内完成,它仍然会无情的抛出OOM异常。还有,万一,万一你们迷信某个调优指南设置了-DisableExplicitGC禁止了system.gc(),那就很差玩了。
因此,堆外内存仍是本身主动点回收更好,好比Netty就是这么作的
MBeanServer mbs = ManagementFactory. getPlatformMBeanServer() ;
ObjectName objectName = new ObjectName("java.nio:type=BufferPool,name=direct" ) ;
MBeanInfo info = mbs.getMBeanInfo(objectName) ;
for(MBeanAttributeInfo i : info.getAttributes()) {
System.out .println(i.getName() + ":" + mbs.getAttribute(objectName , i.getName()));
}
复制代码
JMX获取 若是目标机器没有启动JMX,那么添加jvm参数:
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremotAe.ssl=false
复制代码
重启进程 而后本机经过JMX链接访问:
String jmxURL = "service:jmx:rmi:///jndi/rmi://10.125.6.204:9999/jmxrmi" ;
JMXServiceURL serviceURL = new JMXServiceURL(jmxURL);
Map map = new HashMap() ;
String[] credentials = new String[] { "monitorRole" , "QED" } ;
map.put( "jmx.remote.credentials" , credentials) ;
JMXConnector connector = JMXConnectorFactory. connect(serviceURL , map);
MBeanServerConnection mbsc = connector.getMBeanServerConnection() ;
ObjectName objectName = new ObjectName("java.nio:type=BufferPool,name=direct" ) ;
MBeanInfo mbInfo = mbsc.getMBeanInfo(objectName) ;
for(MBeanAttributeInfo i : mbInfo.getAttributes()) {
System.out .println(i.getName() + ":" + mbsc.getAttribute(objectName , i.getName()));
}
复制代码
本地也能够经过 JConsole 工具查看:
可是注意,采集不要太频繁。不然会触发全部线程进入安全点(也就是 Stop the world)
这个须要开启 native memory 采集,可是这个会常常触发全部线程进入安全点(也就是 Stop the world),因此不推荐线上应用打开。
示例:
$ jcmd 71 VM.native_memory
71:
Native Memory Tracking:
Total: reserved=1631932KB, committed=367400KB
- Java Heap (reserved=131072KB, committed=131072KB)
(mmap: reserved=131072KB, committed=131072KB)
- Class (reserved=1120142KB, committed=79830KB)
(classes #15267)
( instance classes #14230, array classes #1037)
(malloc=1934KB #32977)
(mmap: reserved=1118208KB, committed=77896KB)
( Metadata: )
( reserved=69632KB, committed=68272KB)
( used=66725KB)
( free=1547KB)
( waste=0KB =0.00%)
( Class space:)
( reserved=1048576KB, committed=9624KB)
( used=8939KB)
( free=685KB)
( waste=0KB =0.00%)
- Thread (reserved=24786KB, committed=5294KB)
(thread #56)
(stack: reserved=24500KB, committed=5008KB)
(malloc=198KB #293)
(arena=88KB #110)
- Code (reserved=250635KB, committed=45907KB)
(malloc=2947KB #13459)
(mmap: reserved=247688KB, committed=42960KB)
- GC (reserved=48091KB, committed=48091KB)
(malloc=10439KB #18634)
(mmap: reserved=37652KB, committed=37652KB)
- Compiler (reserved=358KB, committed=358KB)
(malloc=249KB #1450)
(arena=109KB #5)
- Internal (reserved=1165KB, committed=1165KB)
(malloc=1125KB #3363)
(mmap: reserved=40KB, committed=40KB)
- Other (reserved=16696KB, committed=16696KB)
(malloc=16696KB #35)
- Symbol (reserved=15277KB, committed=15277KB)
(malloc=13543KB #180850)
(arena=1734KB #1)
- Native Memory Tracking (reserved=4436KB, committed=4436KB)
(malloc=378KB #5359)
(tracking overhead=4058KB)
- Shared class space (reserved=17144KB, committed=17144KB)
(mmap: reserved=17144KB, committed=17144KB)
- Arena Chunk (reserved=1850KB, committed=1850KB)
(malloc=1850KB)
- Logging (reserved=4KB, committed=4KB)
(malloc=4KB #179)
- Arguments (reserved=19KB, committed=19KB)
(malloc=19KB #512)
- Module (reserved=258KB, committed=258KB)
(malloc=258KB #2356)
复制代码
其中,DirectBuffer 用的内存被包含在 Other 这一类别