今天聽了楊曉峰老師的java 36講,感覺IO這塊是特別欠缺的,所以講義摘錄如下:
歡迎大家去訂閱:
本文章轉自:https://time.geekbang.org/column/article/8369
IO 一直是軟件開發中的核心部分之一,伴隨着海量數據增長和分布式系統的發展,IO 擴展能力愈發重要。幸運的是,Java 平台 IO 機制經過不斷完善,雖然在某些方面仍有不足,但已經在實踐中證明了其構建高擴展性應用的能力。
今天我要問你的問題是,Java 提供了哪些 IO 方式? NIO 如何實現多路復用?
典型回答
Java IO 方式有很多種,基於不同的 IO 抽象模型和交互方式,可以進行簡單區分。
首先,傳統的 java.io 包,它基於流模型實現,提供了我們最熟知的一些 IO 功能,比如 File 抽象、輸入輸出流等。交互方式是同步、阻塞的方式,也就是說,在讀取輸入流或者寫入輸出流時,在讀、寫動作完成之前,線程會一直阻塞在那里,它們之間的調用是可靠的線性順序。
java.io 包的好處是代碼比較簡單、直觀,缺點則是 IO 效率和擴展性存在局限性,容易成為應用性能的瓶頸。
很多時候,人們也把 java.net 下面提供的部分網絡 API,比如 Socket、ServerSocket、HttpURLConnection 也歸類到同步阻塞 IO 類庫,因為網絡通信同樣是 IO 行為。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以構建多路復用的、同步非阻塞 IO 程序,同時提供了更接近操作系統底層的高性能數據操作方式。
第三,在 Java 7 中,NIO 有了進一步的改進,也就是 NIO 2,引入了異步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。異步 IO 操作基於事件和回調機制,可以簡單理解為,應用操作直接返回,而不會阻塞在那里,當后台處理完成,操作系統會通知相應線程進行后續工作。
考點分析
我上面列出的回答是基於一種常見分類方式,即所謂的 BIO、NIO、NIO 2(AIO)。
在實際面試中,從傳統 IO 到 NIO、NIO 2,其中有很多地方可以擴展開來,考察點涉及方方面面,比如:
-
基礎 API 功能與設計, InputStream/OutputStream 和 Reader/Writer 的關系和區別。
-
NIO、NIO 2 的基本組成。
-
給定場景,分別用不同模型實現,分析 BIO、NIO 等模式的設計和實現原理。
-
NIO 提供的高性能數據操作方式是基於什么原理,如何使用?
-
或者,從開發者的角度來看,你覺得 NIO 自身實現存在哪些問題?有什么改進的想法嗎?
IO 的內容比較多,專欄一講很難能夠說清楚。IO 不僅僅是多路復用,NIO 2 也不僅僅是異步 IO,尤其是數據操作部分,會在專欄下一講詳細分析。
知識擴展
首先,需要澄清一些基本概念:
-
區分同步或異步(synchronous/asynchronous)。簡單來說,同步是一種可靠的有序運行機制,當我們進行同步操作時,后續的任務是等待當前調用返回,才會進行下一步;而異步則相反,其他任務不需要等待當前調用返回,通常依靠事件、回調等機制來實現任務間次序關系。
-
區分阻塞與非阻塞(blocking/non-blocking)。在進行阻塞操作時,當前線程會處於阻塞狀態,無法從事其他任務,只有當條件就緒才能繼續,比如 ServerSocket 新連接建立完畢,或數據讀取、寫入操作完成;而非阻塞則是不管 IO 操作是否結束,直接返回,相應操作在后台繼續處理。
不能一概而論認為同步或阻塞就是低效,具體還要看應用和系統特征。
對於 java.io,我們都非常熟悉,我這里就從總體上進行一下總結,如果需要學習更加具體的操作,你可以通過教程等途徑完成。總體上,我認為你至少需要理解:
-
IO 不僅僅是對文件的操作,網絡編程中,比如 Socket 通信,都是典型的 IO 操作目標。
-
輸入流、輸出流(InputStream/OutputStream)是用於讀取或寫入字節的,例如操作圖片文件。
-
而 Reader/Writer 則是用於操作字符,增加了字符編解碼等功能,適用於類似從文件中讀取或者寫入文本信息。本質上計算機操作的都是字節,不管是網絡通信還是文件讀取,Reader/Writer 相當於構建了應用邏輯和原始數據之間的橋梁。
-
BufferedOutputStream 等帶緩沖區的實現,可以避免頻繁的磁盤讀寫,進而提高 IO 處理效率。這種設計利用了緩沖區,將批量數據進行一次操作,但在使用中千萬別忘了 flush。
-
參考下面這張類圖,很多 IO 工具類都實現了 Closeable 接口,因為需要進行資源的釋放。比如,打開 FileInputStream,它就會獲取相應的文件描述符(FileDescriptor),需要利用 try-with-resources、 try-finally 等機制保證 FileInputStream 被明確關閉,進而相應文件描述符也會失效,否則將導致資源無法被釋放。利用專欄前面的內容提到的 Cleaner 或 finalize 機制作為資源釋放的最后把關,也是必要的。
下面是我整理的一個簡化版的類圖,闡述了日常開發應用較多的類型和結構關系。
1.Java NIO 概覽
首先,熟悉一下 NIO 的主要組成部分:
-
Buffer,高效的數據容器,除了布爾類型,所有原始數據類型都有相應的 Buffer 實現。
-
Channel,類似在 Linux 之類操作系統上看到的文件描述符,是 NIO 中被用來支持批量式 IO 操作的一種抽象。
File 或者 Socket,通常被認為是比較高層次的抽象,而 Channel 則是更加操作系統底層的一種抽象,這也使得 NIO 得以充分利用現代操作系統底層機制,獲得特定場景的性能優化,例如,DMA(Direct Memory Access)等。不同層次的抽象是相互關聯的,我們可以通過 Socket 獲取 Channel,反之亦然。
-
Selector,是 NIO 實現多路復用的基礎,它提供了一種高效的機制,可以檢測到注冊在 Selector 上的多個 Channel 中,是否有 Channel 處於就緒狀態,進而實現了單線程對多 Channel 的高效管理。
Selector 同樣是基於底層操作系統機制,不同模式、不同版本都存在區別,例如,在最新的代碼庫里,相關實現如下:
Linux 上依賴於 epoll(http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java)。
Windows 上 NIO2(AIO)模式則是依賴於 iocp(http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/windows/classes/sun/nio/ch/Iocp.java)。
-
Chartset,提供 Unicode 字符串定義,NIO 也提供了相應的編解碼器等,例如,通過下面的方式進行字符串到 ByteBuffer 的轉換:
Charset.defaultCharset().encode("Hello world!"));
2.NIO 能解決什么問題?
下面我通過一個典型場景,來分析為什么需要 NIO,為什么需要多路復用。設想,我們需要實現一個服務器應用,只簡單要求能夠同時服務多個客戶端請求即可。
使用 java.io 和 java.net 中的同步、阻塞式 API,可以簡單實現。
public class DemoServer extends Thread { private ServerSocket serverSocket; public int getPort() { return serverSocket.getLocalPort(); } public void run() { try { serverSocket = new ServerSocket(0); while (true) { Socket socket = serverSocket.accept(); RequestHandler requestHandler = new RequestHandler(socket); requestHandler.start(); } } catch (IOException e) { e.printStackTrace(); } finally { if (serverSocket != null) { try { serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } ; } } } public static void main(String[] args) throws IOException { DemoServer server = new DemoServer(); server.start(); try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) { BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream())); bufferedReader.lines().forEach(s -> System.out.println(s)); } } } // 簡化實現,不做讀取,直接發送字符串 class RequestHandler extends Thread { private Socket socket; RequestHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (PrintWriter out = new PrintWriter(socket.getOutputStream());) { out.println("Hello world!"); out.flush(); } catch (Exception e) { e.printStackTrace(); } } }
其實現要點是:
-
服務器端啟動 ServerSocket,端口 0 表示自動綁定一個空閑端口。
-
調用 accept 方法,阻塞等待客戶端連接。
-
利用 Socket 模擬了一個簡單的客戶端,只進行連接、讀取、打印。
-
當連接建立后,啟動一個單獨線程負責回復客戶端請求。
這樣,一個簡單的 Socket 服務器就被實現出來了。
思考一下,這個解決方案在擴展性方面,可能存在什么潛在問題呢?
大家知道 Java 語言目前的線程實現是比較重量級的,啟動或者銷毀一個線程是有明顯開銷的,每個線程都有單獨的線程棧等結構,需要占用非常明顯的內存,所以,每一個 Client 啟動一個線程似乎都有些浪費。
那么,稍微修正一下這個問題,我們引入線程池機制來避免浪費。
serverSocket = new ServerSocket(0); executor = Executors.newFixedThreadPool(8); while (true) { Socket socket = serverSocket.accept(); RequestHandler requestHandler = new RequestHandler(socket); executor.execute(requestHandler); }
這樣做似乎好了很多,通過一個固定大小的線程池,來負責管理工作線程,避免頻繁創建、銷毀線程的開銷,這是我們構建並發服務的典型方式。這種工作方式,可以參考下圖來理解。
如果連接數並不是非常多,只有最多幾百個連接的普通應用,這種模式往往可以工作的很好。但是,如果連接數量急劇上升,這種實現方式就無法很好地工作了,因為線程上下文切換開銷會在高並發時變得很明顯,這是同步阻塞方式的低擴展性劣勢。
NIO 引入的多路復用機制,提供了另外一種思路,請參考我下面提供的新的版本。
public class NIOServer extends Thread { public void run() { try (Selector selector = Selector.open(); ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 創建 Selector 和 Channel serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888)); serverSocket.configureBlocking(false); // 注冊到 Selector,並說明關注點 serverSocket.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select();// 阻塞等待就緒的 Channel,這是關鍵點之一 Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectedKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); // 生產系統中一般會額外進行就緒狀態檢查 sayHelloWorld((ServerSocketChannel) key.channel()); iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } private void sayHelloWorld(ServerSocketChannel server) throws IOException { try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode("Hello world!")); } } // 省略了與前面類似的 main }
這個非常精簡的樣例掀開了 NIO 多路復用的面紗,我們可以分析下主要步驟和元素:
-
首先,通過 Selector.open() 創建一個 Selector,作為類似調度員的角色。
-
然后,創建一個 ServerSocketChannel,並且向 Selector 注冊,通過指定 SelectionKey.OP_ACCEPT,告訴調度員,它關注的是新的連接請求。
注意,為什么我們要明確配置非阻塞模式呢?這是因為阻塞模式下,注冊操作是不允許的,會拋出 IllegalBlockingModeException 異常。
-
Selector 阻塞在 select 操作,當有 Channel 發生接入請求,就會被喚醒。
-
在 sayHelloWorld 方法中,通過 SocketChannel 和 Buffer 進行數據操作,在本例中是發送了一段字符串。
可以看到,在前面兩個樣例中,IO 都是同步阻塞模式,所以需要多線程以實現多任務處理。而 NIO 則是利用了單線程輪詢事件的機制,通過高效地定位就緒的 Channel,來決定做什么,僅僅 select 階段是阻塞的,可以有效避免大量客戶端連接時,頻繁線程切換帶來的問題,應用的擴展能力有了非常大的提高。下面這張圖對這種實現思路進行了形象地說明。
在 Java 7 引入的 NIO 2 中,又增添了一種額外的異步 IO 模式,利用事件和回調,處理 Accept、Read 等操作。 AIO 實現看起來是類似這樣子:
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr); serverSock.accept(serverSock, new CompletionHandler<>() { // 為異步操作指定 CompletionHandler 回調函數 @Override public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) { serverSock.accept(serverSock, this); // 另外一個 write(sock,CompletionHandler{}) sayHelloWorld(sockChannel, Charset.defaultCharset().encode ("Hello World!")); } // 省略其他路徑處理方法... });
鑒於其編程要素(如 Future、CompletionHandler 等),我們還沒有進行准備工作,為避免理解困難,我會在專欄后面相關概念補充后的再進行介紹,尤其是 Reactor、Proactor 模式等方面將在 Netty 主題一起分析,這里我先進行概念性的對比:
-
基本抽象很相似,AsynchronousServerSocketChannel 對應於上面例子中的 ServerSocketChannel;AsynchronousSocketChannel 則對應 SocketChannel。
-
業務邏輯的關鍵在於,通過指定 CompletionHandler 回調接口,在 accept/read/write 等關鍵節點,通過事件機制調用,這是非常不同的一種編程思路
今天我要問你的問題是,Java 有幾種文件拷貝方式?哪一種最高效?
典型回答
Java 有多種比較典型的文件拷貝實現方式,比如:
利用 java.io 類庫,直接為源文件構建一個 FileInputStream 讀取,然后再為目標文件構建一個 FileOutputStream,完成寫入工作。
public static void copyFileByStream(File source, File dest) throws IOException { try (InputStream is = new FileInputStream(source); OutputStream os = new FileOutputStream(dest);){ byte[] buffer = new byte[1024]; int length; while ((length = is.read(buffer)) > 0) { os.write(buffer, 0, length); } } }
或者,利用 java.nio 類庫提供的 transferTo 或 transferFrom 方法實現。
public static void copyFileByChannel(File source, File dest) throws IOException { try (FileChannel sourceChannel = new FileInputStream(source) .getChannel(); FileChannel targetChannel = new FileOutputStream(dest).getChannel ();){ for (long count = sourceChannel.size() ;count>0 ;) { long transferred = sourceChannel.transferTo( sourceChannel.position(), count, targetChannel); sourceChannel.position(sourceChannel.position() + transferred); count -= transferred; } } }
當然,Java 標准類庫本身已經提供了幾種 Files.copy 的實現。
對於 Copy 的效率,這個其實與操作系統和配置等情況相關,總體上來說,NIO transferTo/From 的方式可能更快,因為它更能利用現代操作系統底層機制,避免不必要拷貝和上下文切換。
考點分析
今天這個問題,從面試的角度來看,確實是一個面試考察的點,針對我上面的典型回答,面試官還可能會從實踐角度,或者 IO 底層實現機制等方面進一步提問。這一講的內容從面試題出發,主要還是為了讓你進一步加深對 Java IO 類庫設計和實現的了解。
從實踐角度,我前面並沒有明確說 NIO transfer 的方案一定最快,真實情況也確實未必如此。我們可以根據理論分析給出可行的推斷,保持合理的懷疑,給出驗證結論的思路,有時候面試官考察的就是如何將猜測變成可驗證的結論,思考方式遠比記住結論重要。
從技術角度展開,下面這些方面值得注意:
-
不同的 copy 方式,底層機制有什么區別?
-
為什么零拷貝(zero-copy)可能有性能優勢?
-
Buffer 分類與使用。
-
Direct Buffer 對垃圾收集等方面的影響與實踐選擇。
接下來,我們一起來分析一下吧。
知識擴展
1. 拷貝實現機制分析
先來理解一下,前面實現的不同拷貝方法,本質上有什么明顯的區別。
首先,你需要理解用戶態空間(User Space)和內核態空間(Kernel Space),這是操作系統層面的基本概念,操作系統內核、硬件驅動等運行在內核態空間,具有相對高的特權;而用戶態空間,則是給普通應用和服務使用。你可以參考:https://en.wikipedia.org/wiki/User_space。
當我們使用輸入輸出流進行讀寫時,實際上是進行了多次上下文切換,比如應用讀取數據時,先在內核態將數據從磁盤讀取到內核緩存,再切換到用戶態將數據從內核緩存讀取到用戶緩存。
寫入操作也是類似,僅僅是步驟相反,你可以參考下面這張圖。
所以,這種方式會帶來一定的額外開銷,可能會降低 IO 效率。
而基於 NIO transferTo 的實現方式,在 Linux 和 Unix 上,則會使用到零拷貝技術,數據傳輸並不需要用戶態參與,省去了上下文切換的開銷和不必要的內存拷貝,進而可能提高應用拷貝性能。注意,transferTo 不僅僅是可以用在文件拷貝中,與其類似的,例如讀取磁盤文件,然后進行 Socket 發送,同樣可以享受這種機制帶來的性能和擴展性提高。
transferTo 的傳輸過程是:
2.Java IO/NIO 源碼結構
前面我在典型回答中提了第三種方式,即 Java 標准庫也提供了文件拷貝方法(java.nio.file.Files.copy)。如果你這樣回答,就一定要小心了,因為很少有問題的答案是僅僅調用某個方法。從面試的角度,面試官往往會追問:既然你提到了標准庫,那么它是怎么實現的呢?有的公司面試官以喜歡追問而出名,直到追問到你說不知道。
其實,這個問題的答案還真不是那么直觀,因為實際上有幾個不同的 copy 方法。
public static Path copy(Path source, Path target, CopyOption... options) throws IOException public static long copy(InputStream in, Path target, CopyOption... options) throws IOException public static long copy(Path source, OutputStream out) throws IOException
可以看到,copy 不僅僅是支持文件之間操作,沒有人限定輸入輸出流一定是針對文件的,這是兩個很實用的工具方法。
后面兩種 copy 實現,能夠在方法實現里直接看到使用的是 transferTo,你可以直接看源碼;而對於第一種方法的分析過程要相對麻煩一些,可以參考下面片段。簡單起見,我只分析同類型文件系統拷貝過程。
public static Path copy(Path source, Path target, CopyOption... options) throws IOException { FileSystemProvider provider = provider(source); if (provider(target) == provider) { // same provider provider.copy(source, target, options);// 這是本文分析的路徑 } else { // different providers CopyMoveHelper.copyToForeignTarget(source, target, options); } return target; }
我把源碼分析過程簡單記錄如下,JDK 的源代碼中,內部實現和公共 API 定義也不是可以能夠簡單關聯上的,NIO 部分代碼甚至是定義為模板而不是 Java 源文件,在 build 過程自動生成源碼,下面順便介紹一下部分 JDK 代碼機制和如何繞過隱藏障礙。
-
首先,直接跟蹤,發現 FileSystemProvider 只是個抽象類,閱讀它的源碼能夠理解到,原來文件系統實際邏輯存在於 JDK 內部實現里,公共 API 其實是通過 ServiceLoader 機制加載一系列文件系統實現,然后提供服務。
-
我們可以在 JDK 源碼里搜索 FileSystemProvider 和 nio,可以定位到sun/nio/fs,我們知道 NIO 底層是和操作系統緊密相關的,所以每個平台都有自己的部分特有文件系統邏輯。
-
省略掉一些細節,最后我們一步步定位到 UnixFileSystemProvider → UnixCopyFile.Transfer,發現這是個本地方法。
-
最后,明確定位到UnixCopyFile.c,其內部實現清楚說明竟然只是簡單的用戶態空間拷貝!
所以,我們明確這個最常見的 copy 方法其實不是利用 transferTo,而是本地技術實現的用戶態拷貝。
前面談了不少機制和源碼,我簡單從實踐角度總結一下,如何提高類似拷貝等 IO 操作的性能,有一些寬泛的原則:
-
在程序中,使用緩存等機制,合理減少 IO 次數(在網絡通信中,如 TCP 傳輸,window 大小也可以看作是類似思路)。
-
使用 transferTo 等機制,減少上下文切換和額外 IO 操作。
-
盡量減少不必要的轉換過程,比如編解碼;對象序列化和反序列化,比如操作文本文件或者網絡通信,如果不是過程中需要使用文本信息,可以考慮不要將二進制信息轉換成字符串,直接傳輸二進制信息。
3. 掌握 NIO Buffer
我在上一講提到 Buffer 是 NIO 操作數據的基本工具,Java 為每種原始數據類型都提供了相應的 Buffer 實現(布爾除外),所以掌握和使用 Buffer 是十分必要的,尤其是涉及 Direct Buffer 等使用,因為其在垃圾收集等方面的特殊性,更要重點掌握。
Buffer 有幾個基本屬性:
-
capcity,它反映這個 Buffer 到底有多大,也就是數組的長度。
-
position,要操作的數據起始位置。
-
limit,相當於操作的限額。在讀取或者寫入時,limit 的意義很明顯是不一樣的。比如,讀取操作時,很可能將 limit 設置到所容納數據的上限;而在寫入時,則會設置容量或容量以下的可寫限度。
-
mark,記錄上一次 postion 的位置,默認是 0,算是一個便利性的考慮,往往不是必須的。
前面三個是我們日常使用最頻繁的,我簡單梳理下 Buffer 的基本操作:
-
我們創建了一個 ByteBuffer,准備放入數據,capcity 當然就是緩沖區大小,而 position 就是 0,limit 默認就是 capcity 的大小。
-
當我們寫入幾個字節的數據時,position 就會跟着水漲船高,但是它不可能超過 limit 的大小。
-
如果我們想把前面寫入的數據讀出來,需要調用 flip 方法,將 position 設置為 0,limit 設置為以前的 position 那里。
-
如果還想從頭再讀一遍,可以調用 rewind,讓 limit 不變,position 再次設置為 0。
更進一步的詳細使用,我建議參考相關教程。
4.Direct Buffer 和垃圾收集
我這里重點介紹兩種特別的 Buffer。
-
Direct Buffer:如果我們看 Buffer 的方法定義,你會發現它定義了 isDirect() 方法,返回當前 Buffer 是否是 Direct 類型。這是因為 Java 提供了堆內和堆外(Direct)Buffer,我們可以以它的 allocate 或者 allocateDirect 方法直接創建。
-
MappedByteBuffer:它將文件按照指定大小直接映射為內存區域,當程序訪問這個內存區域時將直接操作這塊兒文件數據,省去了將數據從內核空間向用戶空間傳輸的損耗。我們可以使用FileChannel.map創建 MappedByteBuffer,它本質上也是種 Direct Buffer。
在實際使用中,Java 會盡量對 Direct Buffer 僅做本地 IO 操作,對於很多大數據量的 IO 密集操作,可能會帶來非常大的性能優勢,因為:
-
Direct Buffer 生命周期內內存地址都不會再發生更改,進而內核可以安全地對其進行訪問,很多 IO 操作會很高效。
-
減少了堆內對象存儲的可能額外維護工作,所以訪問效率可能有所提高。
但是請注意,Direct Buffer 創建和銷毀過程中,都會比一般的堆內 Buffer 增加部分開銷,所以通常都建議用於長期使用、數據較大的場景。
使用 Direct Buffer,我們需要清楚它對內存和 JVM 參數的影響。首先,因為它不在堆上,所以 Xmx 之類參數,其實並不能影響 Direct Buffer 等堆外成員所使用的內存額度,我們可以使用下面參數設置大小:
-XX:MaxDirectMemorySize=512M
從參數設置和內存問題排查角度來看,這意味着我們在計算 Java 可以使用的內存大小的時候,不能只考慮堆的需要,還有 Direct Buffer 等一系列堆外因素。如果出現內存不足,堆外內存占用也是一種可能性。
另外,大多數垃圾收集過程中,都不會主動收集 Direct Buffer,它的垃圾收集過程,就是基於我在專欄前面所介紹的 Cleaner(一個內部實現)和幻象引用(PhantomReference)機制,其本身不是 public 類型,內部實現了一個 Deallocator 負責銷毀的邏輯。對它的銷毀往往要拖到 full GC 的時候,所以使用不當很容易導致 OutOfMemoryError。
對於 Direct Buffer 的回收,我有幾個建議:
-
在應用程序中,顯式地調用 System.gc() 來強制觸發。
-
另外一種思路是,在大量使用 Direct Buffer 的部分框架中,框架會自己在程序中調用釋放方法,Netty 就是這么做的,有興趣可以參考其實現(PlatformDependent0)。
-
重復使用 Direct Buffer。
5. 跟蹤和診斷 Direct Buffer 內存占用?
因為通常的垃圾收集日志等記錄,並不包含 Direct Buffer 等信息,所以 Direct Buffer 內存診斷也是個比較頭疼的事情。幸好,在 JDK 8 之后的版本,我們可以方便地使用 Native Memory Tracking(NMT)特性來進行診斷,你可以在程序啟動時加上下面參數:
-XX:NativeMemoryTracking={summary|detail}
注意,激活 NMT 通常都會導致 JVM 出現 5%~10% 的性能下降,請謹慎考慮。
運行時,可以采用下面命令進行交互式對比:
// 打印 NMT 信息 jcmd <pid> VM.native_memory detail // 進行 baseline,以對比分配內存變化 jcmd <pid> VM.native_memory baseline // 進行 baseline,以對比分配內存變化 jcmd <pid> VM.native_memory detail.diff
我們可以在 Internal 部分發現 Direct Buffer 內存使用的信息,這是因為其底層實際是利用 unsafe_allocatememory。嚴格說,這不是 JVM 內部使用的內存,所以在 JDK 11 以后,其實它是歸類在 other 部分里。
JDK 9 的輸出片段如下,“+”表示的就是 diff 命令發現的分配變化:
-Internal (reserved=679KB +4KB, committed=679KB +4KB) (malloc=615KB +4KB #1571 +4) (mmap: reserved=64KB, committed=64KB)
注意:JVM 的堆外內存遠不止 Direct Buffer,NMT 輸出的信息當然也遠不止這些,我在專欄后面有綜合分析更加具體的內存結構的主題。
今天我分析了 Java IO/NIO 底層文件操作數據的機制,以及如何實現零拷貝的高性能操作,梳理了 Buffer 的使用和類型,並針對 Direct Buffer 的生命周期管理和診斷進行了較詳細的分析。