BIO
文件 IO 方面
操作系統分爲用戶空間和內核空間,應用程序一般不能直接操作系統資源,需要通過操作系統開放的接口才能使用系統資源,例如網絡,磁盤等等。
Linux 系統寫數據的步驟流程如下圖:
從上往下分析張圖片,用戶數據通過 stdio lib 的 printf(), fputc() 等將數據轉換到 stdio buffer(位於用戶內存空間), 當 stdio buffer 區滿時,stdio lib 將會使用系統調用 write 方法,將數據轉移到 kernel buffer cache (位於內核內存空間),最後通過 kernel initiated write 將 kernel buffer cache 的數據寫入到磁盤。
爲什麼有 stdio buffer的存在,爲什麼不直接通過系統調用write方法?
使用緩存的原因是系統調用總是昂貴的。如果用戶代碼以較小的size不斷的讀或寫文件的話,stdio lib將多次的讀或者寫操作通過buffer進行聚合是可以提高程序運行效率的。
在 Java 中將 BIO 按讀取最小單元的不同分爲字節流和字符流( 2 個字節),BIO 是以流的方式讀或者寫數據,並且是單向方式。
下面是部分類的結構圖:
網絡 IO 方面
在Linux 操作系統,一切皆文件。網絡IO 可以看似文件IO,但是網絡和文件有些不同。
系統調用和socket使用如下圖:
socket 關鍵系統調用如下:
1. socket() 方法通過系統調用會創建新的socket。
2. bind()方法通過系統調用一個socket綁定一個地址。
3. listen()方法允許一個對於接受入站的來自其他socket連接的socket流。
4. accept()方法接受一個來自對等應用的socket。
5. connect()方法與另外一個socket建立連接。
可以發現socket的建立過程基本都使用了系統調用,那麼意味着socket的建立是比較昂貴的。
我有以下的疑問
1.有什麼方式能減少socket的建立的開銷成本?
2.socket能不能複用?有沒有像線程池一樣的類似socket池的東西?
如果對以上問題感興趣,可以自行查詢資料。
在 Java 層面使用和上圖 Linux 的調用大致一樣,有個問題就是常說的 TCP 的三次握手在哪個方法中執行的呢?
從上面圖來分析,三次握手的發起方是客服端,socket() 方法是創建一個 socket,可以知道三次握手的階段是在 connect() 方法中完成的。
NIO
文件 IO 方面
這部分內容通過 JDK 源碼來展示。
1. 使用 NIO 普通的文件讀寫
下面以讀文件爲例
FileChannel fileChannel = FileChannel.open(Paths.get("D:\\a.txt")); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); fileChannel.read(byteBuffer); byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); fileChannel.close();
sun.nio.ch.FileChannelImpl#read
public int read(ByteBuffer var1) throws IOException { //... 省略代碼 if (this.isOpen()) { do { var3 = IOUtil.read(this.fd, var1, -1L, this.nd); } while(var3 == -3 && this.isOpen()); // ... 省略代碼 }
sun.nio.ch.IOUtil#read
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException { if (var1.isReadOnly()) { // 判斷是否只讀,如果爲只讀拋異常 throw new IllegalArgumentException("Read-only buffer"); } else if (var1 instanceof DirectBuffer) { // 如果爲DirectBuffer 讀取 return readIntoNativeBuffer(var0, var1, var2, var4); } else { ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining()); // 如果不是,將開闢一個堆外的空間 DirectBuffer int var7; try { int var6 = readIntoNativeBuffer(var0, var5, var2, var4); // 讀取數據 var5.flip(); // 翻轉 if (var6 > 0) { var1.put(var5); // 讀到數據放入到傳入的ByteBuffer中 } var7 = var6; } finally { Util.offerFirstTemporaryDirectBuffer(var5); // 將DirectBuffer放入到BufferCache中,如果空間過大直接釋放,否則可用作下次重複使用 } return var7; } }
從全局來看讀數據的過程如下:
DirectBuffer 是在堆外空間並且在用戶態空間,從源碼上來看使用 DirectBuffer 可以減少一次數據的拷貝,從堆外的 DirectBuffer 拷貝到 堆內的 HeapByteBuffer 中。
2. 文件內存映射文件讀寫 (mmap)
對於文件的內存映射,先需要理解操作系統中的內存管理。下圖是邏輯地址、物理地址以及磁盤間的關係。
CPU 讀或者寫數據,使用邏輯地址通過 MMU (內存管理單元) 配合 PT(Page Table) 查詢到物理地址,從內存中讀取或者寫入對應的數據,如果在內存中不存在,將會從磁盤加載大量內存中。
從Linux 系統的角度來看,大致如下:
用戶空間的邏輯地址和內核空間的地址映射到相同的物理地址。
內存映射 I/O 之所以能夠帶來性能優勢的原因如下:
1.正常的 read()或 write()需要兩次傳輸:一次是在文件和內核高速緩衝區之間,另一次 是在高速緩衝區和用戶空間緩衝區之間。使用 mmap()就無需第二次傳輸了。對於輸 入來講,一旦內核將相應的文件塊映射進內存之後用戶進程就能夠使用這些數據了。 對於輸出來講,用戶進程僅僅需要修改內存中的內容,然後可以依靠內核內存管理器 來自動更新底層的文件。
2.除了節省了內核空間和用戶空間之間的一次傳輸之外,mmap()還能夠通過減少所需使 用的內存來提升性能。當使用 read()或 write()時,數據將被保存在兩個緩衝區中:一 個位於用戶空間,另一個位於內核空間。當使用 mmap()時,內核空間和用戶空間會 共享同一個緩衝區。此外,如果多個進程正在在同一個文件上執行 I/O,那麼它們通 過使用 mmap()就能夠共享同一個內核緩衝區,從而又能夠節省內存的消耗。
網絡 IO 方面
NIO在網絡方面的特點是 IO 多路複用。
1. IO 多路複用是什麼?
多路是指網絡連接,複用指的是同一個線程。I/O 多路複用允許我們同時監控多個文件描述符,以及查看其中任意一個有 I/O 操作的可能。
2. IO 多路複用解決了什麼問題?
普通的 IO 每一個系統調用被阻塞直到有數據的傳輸。例如,從 pipe 中讀取數據,如果當前沒有數據,read() 就會阻塞,同時 write(), 如果 pipe 空間不足,也會出現阻塞。這種情況有 2 種方式來解決一種是線程,一個連接一個線程,如果大量的IO 請求,大量的線程將會佔用大量的內存空間,另一種是 IO 多路複用。
3. IO 多路複用如何實現的?
在 Linux 系統有 select(), poll(), epoll() 方式。
select() 模型如下圖所示:
圖中的fd1...fd5 是在一個數組的結構中,如下圖
在 select() 時,fd3,fd4 以及 fd6 有 I/O event,在 select()之後, fd4 和fd6 在 rset (read descriptor set) 中爲 0 可以理解,爲什麼 fd3 還是 1 呢? 自己猜想了一下是不是服務端的監聽的 socket 的文件描述符,但是不能確定,最後從網上找相關的資料,找到了一個文檔,證明了猜想是正確的,同時也說明服務端監聽的 socket 和客戶端請求來的 socket 會在同一個集合中,從下圖還可以看出 fd0 是標準輸入,fd1是標準輸出,fd2 是錯誤輸出。
AIO
文件 IO 方面
先看一下簡單讀文件的例子
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get("F:\\application.properties"), StandardOpenOption.READ); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); fileChannel.read(byteBuffer, 0, byteBuffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { System.out.println("result:" + result); attachment.flip(); byte[] data = new byte[attachment.limit()]; attachment.get(data); System.out.println(new String(data)); attachment.clear(); } @Override public void failed(Throwable exc, ByteBuffer attachment) { } }); System.in.read();// 阻塞
從上面例子需要弄明白下面的幾個問題。
1. 異步 IO 是如何實現的?
使用線程池
2.是否存在線程或者線程池?
使用線程池
3.CompletionHandler 是如何被回調的?
在文件讀取完,在異步線程中被回調
下面我們帶着問題從源碼的角度來分析 AsynchronousFileChannel
以 Linux 版本,實現類爲 SimpleAsynchronousFileChannelImpl#implRead
@Override <A> Future<Integer> implRead(final ByteBuffer dst, final long position, final A attachment, final CompletionHandler<Integer,? super A> handler) { // 省略部分代碼 ..... // 構造Future final PendingFuture<Integer,A> result = (handler == null) ? new PendingFuture<Integer,A>(this) : null; // 構造Runnable Runnable task = new Runnable() { public void run() { int n = 0; Throwable exc = null; int ti = threads.add(); try { begin(); do { //讀取文件和 NIO 內部實現一致 n = IOUtil.read(fdObj, dst, position, nd); } while ((n == IOStatus.INTERRUPTED) && isOpen()); if (n < 0 && !isOpen()) throw new AsynchronousCloseException(); } catch (IOException x) { if (!isOpen()) x = new AsynchronousCloseException(); exc = x; } finally { end(); threads.remove(ti); } if (handler == null) { result.setResult(n, exc); } else { // 回調handler中的方法 Invoker.invokeUnchecked(handler, attachment, n, exc); } } }; //將任務提交到線程池中 executor.execute(task); return result; }
默認線程池是怎麼樣配置的?
public static AsynchronousFileChannel open(FileDescriptor fdo, boolean reading, boolean writing, ThreadPool pool) { // Executor is either default or based on pool parameters ExecutorService executor = (pool == null) ? DefaultExecutorHolder.defaultExecutor : pool.executor(); return new SimpleAsynchronousFileChannelImpl(fdo, reading, writing, executor); }
默認線程池
static ThreadPool createDefault() { int var0 = getDefaultThreadPoolInitialSize(); if (var0 < 0) { var0 = Runtime.getRuntime().availableProcessors(); } ThreadFactory var1 = getDefaultThreadPoolThreadFactory(); if (var1 == null) { var1 = defaultThreadFactory; } // 創建線程池 ExecutorService var2 = Executors.newCachedThreadPool(var1); return new ThreadPool(var2, false, var0); }
網絡 IO 方面
從Linux 層面稱之爲信號驅動 IO (Signal-Driven I/O), 使用IO多路複用,一個進程通過系統調用 (select()/poll()) 爲了檢查文件描述是否存在 IO事件。使用信號驅動 IO,當文件描述符存在 IO事件,內核將會發送一個信號給進程。進程可以執行其他操作,直到有IO事件發生。
Linux 通過 epoll_create 方法創建 epoll 實例,使用 epoll_ctl 方法將 interest list 註冊到文件描述符上,epoll_wait 返回關聯epoll實例上的就緒的列表。
對於AIO網絡部分內容,通過查看源碼來分析,使用 Linux 系統的 JDK 來分析,UnixAsynchronousServerSocketChannelImpl#implAccept
@Override Future<AsynchronousSocketChannel> implAccept(Object att, CompletionHandler<AsynchronousSocketChannel,Object> handler) { // 省略代碼 ...... try { begin(); // 接受連接 int n = accept(this.fd, newfd, isaa); if (n == IOStatus.UNAVAILABLE) { // need calling context when there is security manager as // permission check may be done in a different thread without // any application call frames on the stack PendingFuture<AsynchronousSocketChannel,Object> result = null; synchronized (updateLock) { if (handler == null) { this.acceptHandler = null; result = new PendingFuture<AsynchronousSocketChannel,Object>(this); this.acceptFuture = result; } else { this.acceptHandler = handler; this.acceptAttachment = att; } this.acceptAcc = (System.getSecurityManager() == null) ? null : AccessController.getContext(); this.acceptPending = true; } // 關鍵代碼 註冊事件 epoll // register for connections port.startPoll(fdVal, Net.POLLIN); return result; } } catch (Throwable x) { // accept failed if (x instanceof ClosedChannelException) x = new AsynchronousCloseException(); exc = x; } finally { end(); } // 省略代碼 ...... if (handler == null) { return CompletedFuture.withResult(child, exc); } else { Invoker.invokeIndirectly(this, handler, att, child, exc); return null; } }
關鍵代碼 EpollPort#startPoll
// invoke by clients to register a file descriptor @Override void startPoll(int fd, int events) { // update events (or add to epoll on first usage) int err = epollCtl(epfd, EPOLL_CTL_MOD, fd, (events | EPOLLONESHOT)); if (err == ENOENT) err = epollCtl(epfd, EPOLL_CTL_ADD, fd, (events | EPOLLONESHOT)); if (err != 0) throw new AssertionError(); // should not happen }
epollCtl爲native方法
static native int epollCtl(int epfd, int opcode, int fd, int events);
找到JVM源碼,查看實現
JNIEXPORT jint JNICALL Java_sun_nio_ch_EPoll_epollCtl(JNIEnv *env, jclass c, jint epfd, jint opcode, jint fd, jint events) { struct epoll_event event; int res; event.events = events; event.data.fd = fd; // epoll_ctl 爲系統調用 RESTARTABLE(epoll_ctl(epfd, (int)opcode, (int)fd, &event), res); return (res == 0) ? 0 : errno; }
總結
對BIO、NIO、AIO 從 Java 層面到操作系統層面的實現簡單的做了一下介紹,從中可以看到Java 層面的IO操作,基本依賴底層操作系統的實現,裏面還有很多的細節需要探究,希望讀者有所收穫吧。
參考文獻:
聊聊 Linux IO https://www.0xffffff.org/2017/05/01/41-linux-io/
《The Linux Programming Interface》
http://www.cs.ucy.ac.cy/courses/EPL428/labs/wk11/IO_multiplexing.pdf
http://www.cs.toronto.edu/~krueger/csc209h/lectures/Week11-Select.pdf
http://www.cse.fau.edu/~sam/course/netp/lec_note/ioMux.pdf