想寫這個系列好久了,對本身也是個總結與提升。原來在學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 這一類別