JVM説--直接內存的使用

語言: CN / TW / HK

作者:京東物流 劉作龍

前言:
學習底層原理有的時候不一定你是要用到他,而是學習他的設計思想和思路。再或者,當你在日常工作中遇到棘手的問題時候,可以多一條解決問題的方式

分享大綱:
本次分享主要由io與nio讀取文件速度差異的情況,去了解nio為什麼讀取大文件的時候效率較高,查看nio是如何使用直接內存的,再深入到如何使用直接內存

1 nio與io讀寫文件的效率比對

首先上代碼,有興趣的同學可以將代碼拿下來進行調試查看

package com.lzl.netty.study.jvm;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StopWatch;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * java對於直接內存使用的測試類
 *
 * @author liuzuolong
 * @date 2022/6/29
 **/
@Slf4j
public class DirectBufferTest {


    private static final int SIZE_10MB = 10 * 1024 * 1024;


    public static void main(String[] args) throws InterruptedException {
        //讀取和寫入不同的文件,保證互不影響
        String filePath1 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/ioInputFile.zip";
        String filePath2 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/nioDirectInputFile.zip";
        String filePath3 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/nioHeapInputFile.zip";
        String toPath1 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/ioOutputFile.zip";
        String toPath2 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/nioDirectOutputFile.zip";
        String toPath3 = "/Users/liuzuolong/CODE/OWN/netty-study/src/main/resources/nioHeapOutputFile.zip";
        Integer fileByteLength = SIZE_10MB;
        //新建io讀取文件的線程
        Thread commonIo = new Thread(() -> {
            commonIo(filePath1, fileByteLength, toPath1);
        });
        //新建nio使用直接內存讀取文件的線程
        Thread nioWithDirectBuffer = new Thread(() -> {
            nioWithDirectBuffer(filePath2, fileByteLength, toPath2);
        });
        //新建nio使用堆內存讀取文件的線程
        Thread nioWithHeapBuffer = new Thread(() -> {
            nioWithHeapBuffer(filePath3, fileByteLength, toPath3);
        });
        nioWithDirectBuffer.start();
        commonIo.start();
        nioWithHeapBuffer.start();
    }

    public static void commonIo(String filePath, Integer byteLength, String toPath) {
        //進行時間監控
        StopWatch ioTimeWatch = new StopWatch();
        ioTimeWatch.start("ioTimeWatch");
        try (FileInputStream fis = new FileInputStream(filePath);
             FileOutputStream fos = new FileOutputStream(toPath);
        ) {
            byte[] readByte = new byte[byteLength];
            int readCount = 0;
            while ((readCount = fis.read(readByte)) != -1) {
                // 讀取了多少個字節,轉換多少個。
                fos.write(readByte, 0, readCount);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        ioTimeWatch.stop();
        log.info(ioTimeWatch.prettyPrint());
    }





    public static void nioWithDirectBuffer(String filePath, Integer byteLength, String toPath) {
        StopWatch nioTimeWatch = new StopWatch();
        nioTimeWatch.start("nioDirectTimeWatch");
        try (FileChannel fci = new RandomAccessFile(filePath, "rw").getChannel();
             FileChannel fco = new RandomAccessFile(toPath, "rw").getChannel();
        ) {
            // 讀寫的緩衝區(分配一塊兒直接內存)
            //要與allocate進行區分
            //進入到函數中
            ByteBuffer bb = ByteBuffer.allocateDirect(byteLength);
            while (true) {
                int len = fci.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                fco.write(bb);
                bb.clear();
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        nioTimeWatch.stop();
        log.info(nioTimeWatch.prettyPrint());
    }




    public static void nioWithHeapBuffer(String filePath, Integer byteLength, String toPath) {
        StopWatch nioTimeWatch = new StopWatch();
        nioTimeWatch.start("nioHeapTimeWatch");
        try (FileChannel fci = new RandomAccessFile(filePath, "rw").getChannel();
             FileChannel fco = new RandomAccessFile(toPath, "rw").getChannel();
        ) {
            // 讀寫的緩衝區(分配一塊兒直接內存)
            //要與allocate進行區分
            ByteBuffer bb = ByteBuffer.allocate(byteLength);
            while (true) {
                int len = fci.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                fco.write(bb);
                bb.clear();
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        nioTimeWatch.stop();
        log.info(nioTimeWatch.prettyPrint());
    }

}

1.主函數調用
為排除當前環境不同導致的文件讀寫效率不同問題,使用多線程分別調用io方法和nio方法

2.分別進行IO調用和NIO調用
通過nio和io的讀取寫入文件方式進行操作

3.結果
經過多次測試後,發現nio讀取文件的效率是高於io的,尤其是讀取大文件的時候

11:12:26.606 [Thread-1] INFO com.lzl.netty.study.jvm.DirectBufferTest - StopWatch '': running time (millis) = 1157-----------------------------------------ms     %     Task name-----------------------------------------01157  100%  nioDirectTimeWatch11:12:27.146 [Thread-0] INFO com.lzl.netty.study.jvm.DirectBufferTest - StopWatch '': running time (millis) = 1704-----------------------------------------ms     %     Task name-----------------------------------------01704  100%  ioTimeWatch

4 提出疑問
那到底為什麼nio的速度要快於普通的io呢,結合源碼查看以及網上的資料,核心原因是:
nio讀取文件的時候,使用直接內存進行讀取,那麼,如果在nio中也不使用直接內存的話,會是什麼情況呢?

5.再次驗證
新增使用堆內存讀取文件

執行時間驗證如下:

11:30:35.050 [Thread-1] INFO com.lzl.netty.study.jvm.DirectBufferTest - StopWatch '': running time (millis) = 2653-----------------------------------------ms     %     Task name-----------------------------------------02653  100%  nioDirectTimeWatch11:30:35.399 [Thread-2] INFO com.lzl.netty.study.jvm.DirectBufferTest - StopWatch '': running time (millis) = 3038-----------------------------------------ms     %     Task name-----------------------------------------03038  100%  nioHeapTimeWatch11:30:35.457 [Thread-0] INFO com.lzl.netty.study.jvm.DirectBufferTest - StopWatch '': running time (millis) = 3096-----------------------------------------ms     %     Task name-----------------------------------------03096  100%  ioTimeWatch

根據上述的實際驗證,nio讀寫文件比較快的主要原因還是在於使用了直接內存,那麼為什麼會出現這種情況呢?

2 直接內存的讀寫性能強的原理

直接上圖説明
1.堆內存讀寫文件

堆內存讀寫文件的步驟:
當JVM想要去和磁盤進行交互的時候,因為JVM和操作系統之間存在讀寫屏障,所以在進行數據交互的時候需要進行頻繁的複製

  • 先由操作系統進行磁盤的讀取,將讀取數據放入系統內存緩衝區中
  • JVM與系統內存緩衝區進行數據拷貝
  • 應用程序再到JVM的堆內存空間中進行數據的獲取

2.直接內存讀寫文件

直接內存讀寫文件的步驟
如果使用直接內存進行文件讀取的時候,步驟如下

  • 會直接調用native方法allocateMemory進行直接內存的分配
  • 操作系統將文件讀取到這部分的直接內存中
  • 應用程序可以通過JVM堆空間的DirectByteBuffer進行讀取
    與使用對堆內存讀寫文件的步驟相比減少了數據拷貝的過程,避免了不必要的性能開銷,因此NIO中使用了直接內存,對於性能提升很多

那麼,直接內存的使用方式是什麼樣的呢?

3 nio使用直接內存的源碼解讀

在閲讀源碼之前呢,我們首先對於兩個知識進行補充

1.虛引用Cleaner sun.misc.Cleaner

什麼是虛引用
虛引用所引用的對象,永遠不會被回收,除非指向這個對象的所有虛引用都調用了clean函數,或者所有這些虛引用都不可達

  • 必須關聯一個引用隊列

  • Cleaner繼承自虛引用PhantomReference,關聯引用隊列ReferenceQueue

    概述的説一下,他的作用就是,JVM會將其對應的Cleaner加入到pending-Reference鏈表中,同時通知ReferenceHandler線程處理,ReferenceHandler收到通知後,會調用Cleaner#clean方法

    2.Unsafesun misc.Unsafe
    位於sun.misc包下的一個類,主要提供一些用於執行低級別、不安全操作的方法,如直接訪問系統內存資源、自主管理內存資源等,這些方法在提升Java運行效率、增強Java語言底層資源操作能力方面起到了很大的作用。

    3.直接內存是如何進行申請的 java.nio.DirectByteBuffer

    進入到DirectBuffer中進行查看

    源碼解讀
    PS:只需要讀核心的劃紅框的位置的源碼,其他內容按個人興趣閲讀

    • 直接調用ByteBuffer.allocateDirect方法
    • 聲明一個一個DirectByteBuffer對象
    • 在DirectByteBuffer的構造方法中主要進行三個步驟
      步驟1:調用Unsafe的native方法allocateMemory進行緩存空間的申請,獲取到的base為內存的地址
      步驟2:設置內存空間需要和步驟1聯合進行使用
      步驟3:使用虛引用Cleaner類型,創建一個緩存的釋放的虛引用

    直接緩存是如何釋放的
    我們前面説的了Cleaner的使用方式,那麼cleaner在直接內存的釋放中的流程是什麼樣的呢?

    3.1 新建虛引用

    java.nio.DirectByteBuffer

    步驟如下

    • 調用Cleaner.create()方法
    • 將當前新建的Cleaner加入到鏈表中

    3.2 聲明清理緩存任務

    查看java.nio.DirectByteBuffer.Deallocator的方法

    • 實現了Runnable接口
    • run方法中調用了unsafe的native方法freeMemory()進行內存的釋放

    3.3 ReferenceHandler進行調用

    首先進入:java.lang.ref.Reference.ReferenceHandler

    當前線程優先級最高,調用方法tryHandlePending

    進入方法中,會調用c.clean c—>(Cleaner)

    clean方法為Cleaner中聲明的Runnable,調用其run()方法
    Cleaner中的聲明:private final Runnable thunk;

    回到《聲明清理緩存任務》這一節,查看Deallocator,使用unsafe的native方法freeMemory進行緩存的釋放

    4 直接內存的使用方式

    直接內存特性

    • nio中比較經常使用,用於數據緩衝區ByteBuffer
    • 因為其不受JVM的垃圾回收管理,故分配和回收的成本較高
    • 使用直接內存的讀寫性能非常高

    直接內存是否會內存溢出
    直接內存是跟系統內存相關的,如果不做控制的話,走的是當前系統的內存,當然JVM中也可以對其使用的大小進行控制,設置JVM參數-XX:MaxDirectMemorySize=5M,再執行的時候就會出現內存溢出

    直接內存是否會被JVM的GC影響
    如果在直接內存聲明的下面調用System.gc();因為會觸發一次FullGC,則對象會被回收,則ReferenceHandler中的會被調用,直接內存會被釋放。

    我想使用直接內存,怎麼辦
    如果你很想使用直接內存,又想讓直接內存儘快的釋放,是不是我直接調用System.gc();就行?
    答案是不行的

    • 首先調用System.gc();會觸發FullGC,造成stop the world,影響系統性能
    • 系統怕有初級研發顯式調用System.gc();會配置JVM參數:-XX:+DisableExplicitGC,禁止顯式調用

    如果還想調用的話,自己使用Unsafe進行操作,以下為示例代碼
    PS:僅為建議,如果沒有對於Unsafe有很高的理解,請勿嘗試

    package com.lzl.netty.study.jvm;import sun.misc.Unsafe;import java.lang.reflect.Field;/** * 使用Unsafe對象操作直接內存 * * @author liuzuolong * @date 2022/7/1 **/public class UnsafeOperateDirectMemory {    private static final int SIZE_100MB = 100 * 1024 * 1024;    public static void main(String[] args) {        Unsafe unsafe = getUnsafePersonal();        long base = unsafe.allocateMemory(SIZE_100MB);        unsafe.setMemory(base, SIZE_100MB, (byte) 0);        unsafe.freeMemory(base);    }    /**     * 因為Unsafe為底層對象,所以正式是無法獲取的,但是反射是萬能的,可以通過反射進行獲取     * Unsafe自帶的方法getUnsafe 是不能使用的,會拋異常SecurityException     * 獲取 Unsafe對象     *     * @return unsafe對象     * @see sun.misc.Unsafe#getUnsafe()     */    public static Unsafe getUnsafePersonal() {        Field f;        Unsafe unsafe;        try {            f = Unsafe.class.getDeclaredField("theUnsafe");            f.setAccessible(true);            unsafe = (Unsafe) f.get(null);        } catch (Exception e) {            throw new RuntimeException("initial the unsafe failure...");        }        return unsafe;    }}
    

    5 總結

    JVM相關知識是中高級研發人員必備的知識,學習他的一些運行原理,對我們的日常工作會有很大的幫助