從Linux內核理解JAVA的NIO

前言

IO 能夠簡單分爲磁盤 IO網絡 IO ,磁盤 IO 相對於網絡 IO 速度會快一點,本文主要介紹 磁盤 IO網絡 IO 下週寫。html

JAVA 對 NIO 抽象爲 Channel , Channel 又能夠分爲 FileChannel (磁盤 io)和 SocketChannel (網絡 io)。java

若是你對 IO 的理解只是停留在 api 層面那是遠遠不夠的,必定要了解 IO 在系統層面是怎麼處理的。node

本文內容:linux

  • FileChannel 讀寫複製文件的用法。
  • ByteBuffer 的介紹
  • jvm 文件進程鎖,FileLock
  • HeapByteBuffer ,DirectByteBuffer 和 mmap 誰的速度更快
  • Linux 內核 中的 虛擬內存系統調用文件描述符InodePage Cache缺頁異常講述整個 IO 的過程
  • jvm 堆外的 DirectByteBuffer 的內存怎麼回收

<img src="http://oss.mflyyou.cn/blog/20200711165857.png?author=zhangpanqin" alt="image-20200711165857889" style="zoom: 33%;" />api

本文計算機系統相關的圖所有來自 《深刻理解計算機系統》

對 Linux 的瞭解都是來自書上和查閱資料,本文內容主要是我本身的理解和代碼驗證,有的描述不必定準確,重在理解過程便可。數組

NIO

NIO 是 從 Java 1.4 開始引入的,被稱之爲 Non Blocking IO,也有稱之爲 New IO。緩存

NIO 抽象爲 Channel 是面向緩衝區的(操做的是一塊數據),非阻塞 IO。bash

Channel 只負責傳輸,數據由 Buffer 負責存儲。微信

Buffer

Buffer 中的 capacitylimitposition 屬性是比較重要的,這些弄不明白,讀寫文件會遇到不少坑。網絡

capacity 標識 Buffer 最大數據容量,相等於一個數組的長度。

limit 爲一個指針,標識當前數組可操做的數據的最大索引。

position 表示爲下一個讀取數據時的索引

<img src="http://oss.mflyyou.cn/blog/20200711202515.png?author=zhangpanqin" alt="image-20200711202515462" style="zoom:50%;" />

@Test
public void run1() {
    // `DirectByteBuffer`
    final ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
    // `HeapByteBuffer`
    final ByteBuffer allocate = ByteBuffer.allocate(1024);
}

HeapByteBuffer 會分配在 Jvm堆內,受 JVM 堆大小的限制,建立速度快,可是讀寫速度慢。實際底層是一個字節數組。

DirectByteBuffer 會分配 Jvm 堆外,不受 JVM 堆大小的限制,建立速度慢,讀寫快。DirectByteBuffer 內存在 Linux 中,屬於進程的堆內。DirectByteBuffer 受 jvm 參數 MaxDirectMemorySize 的影響。

設置 jvm 堆 100m,運行程序報錯 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space。由於指定了 jvm 堆爲 100m,而後一些 class 文件也會放在 堆中的,實際堆內存時不足 100m,當申請 100m 堆內存只能報錯了。

public class BufferNio {
    // -Xmx100m
    public static void main(String[] args) throws InterruptedException {
        // HeapByteBuffer 是 jvm 堆內,由於堆不足分配 100m(java 中的一些 class 也會佔用堆),致使 oom
        System.out.println("申請 100 m `HeapByteBuffer`");
        Thread.sleep(5000);
        ByteBuffer.allocate(100 * 1024 * 1024);
    }
}

設置 jvm 堆爲 100m,MaxDirectMemorySize 爲 1g,死循環建立 DirectByteBuffer,打印 10 次 申請 directbuffer 成功,報錯 Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory,後面再說這個堆外的 DirectByteBuffer 怎麼進行回收。

public class BufferNio {
//    -Xmx100m -XX:MaxDirectMemorySize=1g
    public static void main(String[] args) throws InterruptedException {
        System.out.println("申請 100 m DirectByteBuffer");
        final ArrayList<Object> objects = new ArrayList<>();
        while (true) {
            // DirectByteBuffer 不在 jvm 堆內,因此能夠申請成功,可是不是無限制的,也有限制(MaxDirectMemorySize)
            final ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100 * 1024 * 1024);
            objects.add(byteBuffer);
            System.out.println("申請 directbuffer 成功");
            System.out.println(ManagementFactory.getMemoryMXBean().getHeapMemoryUsage());
            System.out.println(ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage());
        }
    }
}

FileChannel

讀文件

@Test
public void read() throws IOException {
    final Path path = Paths.get(FILE_NAME);
    // 建立一個 FileChannel,指定這個 channel 讀寫的權限
    final FileChannel open = FileChannel.open(path, StandardOpenOption.READ);
    // 建立一個和這個文件大小同樣的 buffer,小文件能夠這樣,大文件,循環讀
    final ByteBuffer allocate = ByteBuffer.allocate((int) open.size());
    open.read(allocate);
    open.close();
    // 切換爲讀模式,position=0
    allocate.flip();
    // 用 UTF-8 解碼
    final CharBuffer decode = StandardCharsets.UTF_8.decode(allocate);
    System.out.println(decode.toString());
}

寫文件

@Test
public void write() throws IOException {
    final Path path = Paths.get("demo" + FILE_NAME);
    // 通道具備寫權限,create 標識文件不存在的時候建立
    final FileChannel open = FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    final ByteBuffer allocate = ByteBuffer.allocate(1024);
    allocate.put("張攀欽aaaaa-1111111".getBytes(StandardCharsets.UTF_8));
    // 切換寫模式,position=0
    allocate.flip();
    open.write(allocate);
    open.close();
}

複製文件

@Test
public void copy() throws IOException {
    final Path srcPath = Paths.get(FILE_NAME);
    final Path destPath = Paths.get("demo" + FILE_NAME);
    final FileChannel srcChannel = FileChannel.open(srcPath, StandardOpenOption.READ);
    final FileChannel destChannel = FileChannel.open(destPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    // transferTo 實現類中,用的是一個 8M MappedByteBuffer 作數據的 copy ,可是這個方法只能 copy 文件最大字節數爲 Integer.MAX
    srcChannel.transferTo(0, srcChannel.size(), destChannel);
    destChannel.close();
    srcChannel.close();
}

FileLock

FileLcok 是 jvm 進程文件鎖,在多個 jvm 進程間生效,進程享有文件的讀寫權限,有共享鎖 和 獨佔鎖。

同一個進程不能鎖同一個文件的重複區域,不重複是能夠鎖的。

同一個進程中第一個線程鎖文件的 (0,2),同時另外一個線程鎖(1,2),文件鎖的區域有重複,程序會報錯。

一個進程鎖(0,2),另外一個進程鎖(1,2)這是能夠的,由於 FileLock 是 JVM 進程鎖。

運行下面程序兩次,打印結果爲

第一個程序順利打印

獲取到鎖0-3,代碼沒有被阻塞
獲取到鎖4-7,代碼沒有被阻塞

第二個程序打印

獲取到鎖4-7,代碼沒有被阻塞
獲取到鎖0-3,代碼沒有被阻塞

第一個程序運行的時候,file_lock.txt 的 0-2 位置被鎖住了,第一個程序持有鎖 10 s,第二個程序運行的時候,會在這裏阻塞等待 FileLock,直到第一個程序釋放了鎖。

public class FileLock {
    public static void main(String[] args) throws IOException, InterruptedException {
        final Path path = Paths.get("file_lock.txt");
        final FileChannel open = FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.READ);
        final CountDownLatch countDownLatch = new CountDownLatch(2);
        new Thread(() -> {
         
            try (final java.nio.channels.FileLock lock = open.lock(0, 3, false)) {
             
                System.out.println("獲取到鎖0-3,代碼沒有被阻塞");
                Thread.sleep(10000);
                final ByteBuffer wrap = ByteBuffer.wrap("aaa".getBytes());
                open.position(0);
                open.write(wrap);
                Thread.sleep(10000);
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        }).start();
        Thread.sleep(1000);
        new Thread(() -> {
            try (final java.nio.channels.FileLock lock = open.lock(4, 3, false)) {
                System.out.println("獲取到鎖4-7,代碼沒有被阻塞");
                final ByteBuffer wrap = ByteBuffer.wrap("bbb".getBytes());
                open.position(4);
                open.write(wrap);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        }).start();
        countDownLatch.await();
        open.close();
    }
}

當將上面的程序第二個線程改成 java.nio.channels.FileLock lock = open.lock(1, 3, false) ,由於同一個進程不容許鎖文件的重複區域,程序會報錯。

Exception in thread "Thread-1" java.nio.channels.OverlappingFileLockException

HeapByteBuffer 和 DirectByteBuffer 誰的讀寫效率高?

FileChannel 的實現類 FileChannelImpl,當讀寫 ByteBuffer 會判斷是不是 DirectBuffer,不是的話,會建立一個 DirectBuffer,將原來的的 Buffer 數據 copy 到 DirectBuffer 中使用。因此讀寫效率上來講,DirectByteBuffer 讀寫更快。可是 DirectByteBuffer 建立相對來講耗時。

儘管 DirectByteBuffer 是堆外,可是當堆外內存佔用達到 -XX:MaxDirectMemorySize 的時候,也會觸發 FullGC ,若是堆外沒有辦法回收內存,就會拋出 OOM。

// 下面這個程序會一直執行下去,可是會觸發 FullGC,來回收掉堆外的直接內存
public class BufferNio {
    //    -Xmx100m -XX:MaxDirectMemorySize=1g
    public static void main(String[] args) throws InterruptedException {
        System.out.println("申請 100 m `HeapByteBuffer`");
        while (true) {
            // 當前對象沒有被引用,GC root 也就到達不了 DirectByteBuffer
            ByteBuffer.allocateDirect(100 * 1024 * 1024);
            System.out.println("申請 directbuffer 成功");
        }
    }
}

死循環建立的 DirectByteBuffer 沒有 GC ROOT 到達,對象會被回收掉,回收掉的時候,也只是回收掉堆內啊,堆外的回收怎麼作到的呢?

DirectByteBuffer 源碼着手,能夠看到它有一個成員變量 private final Cleaner cleaner;,當觸發 FullGC 的時候,由於 cleaner 沒有 gc root 可達,致使 cleaner 會被回收,回收的時候會觸發 Cleaner.clean (在 Reference.tryHandlePending 觸發)方法的調用,thunk 就是 DirectByteBuffer.Deallocator 的示例,這個 run 方法中,調用了Unsafe.freeMemory 來釋放掉了堆外內存。

public class Cleaner extends PhantomReference<Object> {
      private final Runnable thunk;
     public void clean() {
        if (remove(this)) {
            try {
                this.thunk.run();
            } catch (final Throwable var2) {
                AccessController.doPrivileged(new PrivilegedAction<Void>() {
                    public Void run() {
                        if (System.err != null) {
                            (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                        }

                        System.exit(1);
                        return null;
                    }
                });
            }

        }
    }
}

內存映射

<img src="http://oss.mflyyou.cn/blog/20200712125658.png?author=zhangpanqin" alt="image-20200712125657989" style="zoom:50%;" />

當應用程序讀文件的時候,數據須要從先從磁盤讀取到內核空間(第一次讀寫,沒有 page cache 緩存數據),在從內核空間 copy 到用戶空間,這樣應用程序才能使用讀到的數據。當一個文件的所有數據都在內核的 Page Cache 上時,就不用再從磁盤讀了,直接從內核空間 copy 到用戶空間去了。

應用程序對一個文件寫數據時,先將要寫的數據 copy 到內核 的 page cache,而後調用 fsync 將數據從內核落盤到文件上(只要調用返回成功,數據就不會丟失)。或者不調用 fsync 落盤,應用程序的數據只要寫入到 內核的 pagecache 上,寫入操做就算完成了,數據的落盤交由 內核 的 Io 調度程序在適當的時機來落盤(忽然斷電會丟數據,MySQL 這樣的程序都是本身維護數據的落盤的)。

咱們能夠看到數據的讀寫總會通過從用戶空間與內核空間的 copy ,若是能把這個 copy 去掉,效率就會高不少,這就是 mmap (內存映射)。將用戶空間和內核空間的內存指向同一塊物理內存。內存映射 英文爲 Memory Mapping ,縮寫 mmap。對應系統調用 mmap

這樣在用戶空間讀寫數據,實際操做的也是內核空間的,減小了數據的 copy 。

<img src="http://oss.mflyyou.cn/blog/20200712145306.png?author=zhangpanqin" alt="image-20200712145306814" style="zoom:50%;" />

怎麼實現的呢,簡單來講就是 linux 中進程的地址是虛擬地址,cpu 會將虛擬地址映射到物理內存的物理地址上。mmap 實際是將用戶進程的某塊虛擬地址與內核空間的某塊虛擬地址映射到同一塊物理內存上,已達到減小數據的 copy 。

用戶程序調用系統調用 mmap 以後的數據的讀寫都不須要調用系統調用 readwrite 了。

虛擬內存與物理內存的映射

計算機的主存能夠看作是由 M 個連續字節組成的數組,每一個字節都有一個惟一物理地址 (Physical Address)。

Cpu 使用的 虛擬尋址VA,Virtual Address) 來查找物理地址。

<img src="http://oss.mflyyou.cn/blog/20200711171400.png?author=zhangpanqin" alt="image-20200711171400757" style="zoom:50%;" />

CPU 會將進程使用的 虛擬地址 經過 CPU 上的硬件 內存管理單元 (Memory Management Unit MMU) 的進行地址翻譯找到物理主存中的物理地址,從而獲取數據。

當進程加載以後,系統會爲進程分配一個虛擬地址空間,當虛擬地址空間中的某個 虛擬地址 被使用的時候,就會將其先映射到主存上的 物理地址

當多個進程須要共享數據的時候,只須要將其虛擬地址空間中的某些虛擬地址映射相同的物理地址便可。

一般咱們操做數據的時候,不會一個字節一個字節的操做,這樣效率過低,一般都是連續訪問某些字節。因此在內存管理的時候,將內存空間分割爲頁來管理,物理內存中有物理頁Physical Page),虛擬內存中有 Virtual Page 來管理。一般頁的大小爲 4KB。

系統經過 MMU 和 頁表(Page Table) 來管理 虛擬頁物理也 的對應關係,頁表就是頁表條目(Page Table Entry,PTE)的數組

<img src="http://oss.mflyyou.cn/blog/20200711183510.png?author=zhangpanqin" alt="image-20200711183510194" style="zoom:50%;" />

PTE 的有效爲1時,標識數據在內存中,標識爲 0 時,標識在磁盤上。

當訪問的虛擬地址對應的數據再也不物理內存上時,會有兩種狀況處理:

一、在內存夠用的時候,會直接將虛擬頁對應在磁盤上的數據加載到物理內存上,

二、當內存不夠用的時候,就會觸發 swap,會根據 LRU 將最近使用頻率比較低的虛擬頁對應物理也淘汰掉,寫入到磁盤中去,淘汰掉一部分物理內存中的數據,而後對對應的虛擬頁設置爲 0,而後將磁盤上的數據再加載到內存中去。

進程的虛擬內存

Linux 會爲每一個進程分配一個單獨的虛擬內存地址,

<img src="http://oss.mflyyou.cn/blog/20200711174755.png?author=zhangpanqin" alt="image-20200711174755550" style="zoom: 50%;" />

當咱們的程序運行的時候,不是整個程序的代碼文件一次性所有加載到內存中去,而是執行懶加載。

機械硬盤使用扇區來管理磁盤,磁盤控制器會經過塊管理磁盤,系統經過 Page Cache 與磁盤控制器打交道。

一個塊包含多個扇區,一個頁也包含多個塊。

磁盤上會有一個文件對應一個 Inode,Innode 記錄文件的元數據及數據所在位置。

當系統啓動的時候,這些 Inode 數據會被加載到主存中去。不過系統中的 Inode 還記錄他們對應的物理內存中的位置(實際就是對應 Page Cache),有的 Inode 對應的數據沒有加載到內存中,Inode 就不會記錄其對應的內存地址。

程序執行以前會初始化其虛擬內存,虛擬內存會記錄代碼對應哪些 Innode。

當執行程序的時候,系統會初始化當前程序的虛擬內存,而後運行 main 函數,當發現執行代碼時,有的代碼沒有加載到內存,就會觸發缺頁異常,將根據虛擬頁找到對應的 Innoe ,而後將磁盤中須要的數據加載到內存中,而後將虛擬頁標記爲已加載到內存,下次訪問直接從內存中訪問。

Java 中的 mmap

看源碼咱們發現 open.map 返回的也是 DirectByteBuffer,只是這個方法返回的 DirectByteBuffer 使用了不一樣的構造方法,它綁定了一個 fd 。當咱們讀寫數據的時候是不會觸發系統調用 read 和 write 的,也就是內存映射的好處。

public class MMapDemo {
    public static void main(String[] args) throws URISyntaxException, IOException, InterruptedException {
        final URL resource = MMapDemo.class.getClassLoader().getResource("demo.txt");
        final Path path = Paths.get(resource.toURI());
        final FileChannel open = FileChannel.open(path, StandardOpenOption.READ);
        // 發起系統調用 mmap
        final MappedByteBuffer map = open.map(FileChannel.MapMode.READ_ONLY, 0, open.size());
        // 讀取數據時,不會再出發調用 read,直接從本身的虛擬內存中便可拿數據
        final CharBuffer decode = StandardCharsets.UTF_8.decode(map);
        System.out.println(decode.toString());
        open.close();
        Thread.sleep(100000);
    }
}

儘管下面這個也是 DirectByteBuffer ,可是它和 mmap 不一樣的是,他沒有綁定 fd,讀寫數據的時候仍是要通過從用戶空間到內核空間的 copy ,也會發生系統調用,效率相對 mmap 低。

public class MMapDemo {
    public static void main(String[] args) throws URISyntaxException, IOException, InterruptedException {
        final URL resource = MMapDemo.class.getClassLoader().getResource("demo.txt");
        final Path path = Paths.get(resource.toURI());
        final FileChannel open = FileChannel.open(path, StandardOpenOption.READ);
        // 這個 DirectByteBuffer 使用的構造不同,它會走系統調用 read
        final ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
        final int read = open.read(byteBuffer);
        byteBuffer.flip();
        System.out.println(StandardCharsets.UTF_8.decode(byteBuffer).toString());
        Thread.sleep(100000);
    }
}

追蹤代碼的系統調用,在 linux 下使用 strace

#!/bin/bash
rm -fr /nio/out.*
cd /nio/target/classes
strace -ff -o /nio/out java com.fly.blog.nio.MMapDemo

數據讀寫速度上 mmap 大於 ByteBuffer.allocateDirect 大於 ByteBuffer.allocate


本文由 張攀欽的博客 http://www.mflyyou.cn/ 創做。 可自由轉載、引用,但需署名做者且註明文章出處。

如轉載至微信公衆號,請在文末添加做者公衆號二維碼。微信公衆號名稱:Mflyyou

相關文章
相關標籤/搜索