內(nèi)核空間與用戶空間
Kernel space 是 Linux 內(nèi)核的運(yùn)行空間,User space 是用戶程序的運(yùn)行空間。為了安全,它們是隔離的,即使用戶的程序崩潰了,內(nèi)核也不受影響。
內(nèi)核空間中存放的是內(nèi)核代碼和數(shù)據(jù)。內(nèi)核空間是操作系統(tǒng)所在區(qū)域。內(nèi)核代碼有特別的權(quán)力:它能與設(shè)備控制器通訊,控制著用戶區(qū)域進(jìn)程的運(yùn)行狀態(tài),等等。最重要的是,所有 I/O 都直接或間接通過內(nèi)核空間。
用戶空間是常規(guī)進(jìn)程所在區(qū)域,進(jìn)程的用戶空間中存放的是用戶程序的代碼和數(shù)據(jù)。
Linux使用兩級(jí)保護(hù)機(jī)制:0級(jí)供內(nèi)核使用,3級(jí)供用戶程序使用。
當(dāng)一個(gè)任務(wù)(進(jìn)程)執(zhí)行系統(tǒng)調(diào)用而陷入內(nèi)核代碼中執(zhí)行時(shí),我們就稱進(jìn)程處于內(nèi)核運(yùn)行態(tài)(內(nèi)核態(tài))。此時(shí)處理器處于特權(quán)級(jí)最高的(0級(jí))內(nèi)核代碼中執(zhí)行,CPU可執(zhí)行任何指令。當(dāng)進(jìn)程在執(zhí)行用戶自己的代碼時(shí),則稱其處于用戶運(yùn)行態(tài)(用戶態(tài))。
32位Linux的虛擬地址空間為0~4G。Linux內(nèi)核將這4G字節(jié)的空間分為兩部分。將最高的1G字節(jié)(從虛擬地址0xC0000000到0xFFFFFFFF),供內(nèi)核使用,稱為“內(nèi)核空間”。而將較低的3G字節(jié)(從虛擬地址 0x00000000到0xBFFFFFFF),供各個(gè)進(jìn)程使用,稱為“用戶空間)。每個(gè)進(jìn)程有各自的私有用戶空間(0~3G),這個(gè)空間對(duì)系統(tǒng)中的其他進(jìn)程是不可見的。最高的1GB字節(jié)虛擬內(nèi)核空間則為所有進(jìn)程以及內(nèi)核所共享。
str = "my string" // 用戶空間
x = x + 2
file.write(str) // 切換到內(nèi)核空間
y = x + 4 // 切換回用戶空間
上面代碼中,第一行和第二行都是簡(jiǎn)單的賦值運(yùn)算,在 User space 執(zhí)行。第三行需要寫入文件,就要切換到 Kernel space,因?yàn)橛脩舨荒苤苯訉懳募?,必須通過內(nèi)核安排。第四行又是賦值運(yùn)算,就切換回 User space。
分頁(yè)存儲(chǔ)
操作系統(tǒng)在運(yùn)行程序時(shí),需要為每一個(gè)進(jìn)程分配內(nèi)存。比如A進(jìn)程需要200m,B進(jìn)程需要300m,c進(jìn)程需要100m。那么操作系統(tǒng)應(yīng)該如何為他們分配這些內(nèi)存呢?
一種想法是直接分配連續(xù)的內(nèi)存。操作系統(tǒng)維護(hù)一個(gè)內(nèi)存列表,每次申請(qǐng)內(nèi)存時(shí)就去這個(gè)列表中尋找合適的連續(xù)內(nèi)存塊,分配給用戶進(jìn)程。這樣會(huì)帶來一個(gè)問題,那就是內(nèi)存碎片化。由于程序申請(qǐng)內(nèi)存的大小是不規(guī)律的,在經(jīng)過多次分配之后,內(nèi)存空間就會(huì)變得零碎,產(chǎn)生很多不連續(xù)的小的內(nèi)存碎片,這些碎片無法被程序使用(因?yàn)樗槠膬?nèi)存不是連續(xù)的,也不夠大)。
可以通過‘緊湊’的方法將這些碎片拼接成可用的大塊內(nèi)存空間,但是必須要付出很大的開銷。因此產(chǎn)生了離散化的分配方式:允許直接將一個(gè)緊湊直接分散的裝入到許多不相鄰的內(nèi)存塊當(dāng)中。就可以充分的利用內(nèi)存空間。
離散分配其中之一的分配方式就是分頁(yè):將用戶程序的地址空間分為若干個(gè)固定大小的區(qū)域,稱為頁(yè)。比如,每個(gè)頁(yè)為1kb。相應(yīng)的將內(nèi)存空間也分為若干個(gè)物理塊,和頁(yè)的大小相同。這樣就可以將用戶程序的任一頁(yè)放入任一物理塊當(dāng)中,實(shí)現(xiàn)了離散分配。
在分頁(yè)系統(tǒng)中,允許將進(jìn)程的各個(gè)頁(yè)離散的存儲(chǔ)在內(nèi)存的任一物理塊當(dāng)中,為了保證進(jìn)程能夠正確運(yùn)行,即能夠在內(nèi)存中找到每個(gè)頁(yè)面所對(duì)應(yīng)的物理塊,系統(tǒng)為每一個(gè)進(jìn)程建立了一張頁(yè)面映像表,簡(jiǎn)稱頁(yè)表。在進(jìn)程地址空間內(nèi)的所有頁(yè),依次在頁(yè)表中有一頁(yè)表項(xiàng),其中記錄了相應(yīng)頁(yè)在內(nèi)存中的物理塊號(hào)。

在配置了頁(yè)表之后,進(jìn)程執(zhí)行時(shí),通過查找該表,即可找到每頁(yè)在內(nèi)存中的物理塊號(hào)??梢?,頁(yè)表的作用是實(shí)現(xiàn)從頁(yè)號(hào)到物理塊號(hào)的地址映射。
虛擬內(nèi)存
所有現(xiàn)代操作系統(tǒng)都使用虛擬內(nèi)存。虛擬內(nèi)存意為使用虛假(或虛擬)地址取代物理(硬件RAM)內(nèi)存地址。這樣做好處頗多,總結(jié)起來可分為兩大類:
一個(gè)以上的虛擬地址可指向同一個(gè)物理內(nèi)存地址。
虛擬內(nèi)存空間可大于實(shí)際可用的硬件內(nèi)存。
那么,這是如何做到的呢?
我們會(huì)同時(shí)運(yùn)行多個(gè)進(jìn)程,而每個(gè)進(jìn)程占用的內(nèi)存大小不固定,但是這些進(jìn)程所需要的內(nèi)存大小加起來卻會(huì)超過我們實(shí)際的物理內(nèi)存(比如4g內(nèi)存),用戶感覺到的內(nèi)存容量會(huì)比實(shí)際內(nèi)存容量大的多。這是因?yàn)椋?/p>
應(yīng)用程序在運(yùn)行之前沒有必要將之全部裝入內(nèi)存,而僅需將那些當(dāng)前要運(yùn)行的少數(shù)頁(yè)面裝入內(nèi)存便可運(yùn)行,其余部分暫留在磁盤上。程序在運(yùn)行時(shí),如果他要訪問的頁(yè)已經(jīng)調(diào)入內(nèi)存,便可繼續(xù)執(zhí)行下去;但如果程序所要訪問的頁(yè)面尚未調(diào)入內(nèi)存(缺頁(yè)),便發(fā)出缺頁(yè)請(qǐng)求(頁(yè)錯(cuò)誤),此時(shí)操作系統(tǒng)將利用請(qǐng)求調(diào)頁(yè)功能將他們調(diào)入內(nèi)存,以便程序能夠繼續(xù)執(zhí)行下去。如果此時(shí)內(nèi)存已滿,無法再裝入新的頁(yè),操作系統(tǒng)還需再利用頁(yè)的置換功能,將內(nèi)存中暫時(shí)不用的頁(yè)調(diào)到磁盤上,騰出足夠的內(nèi)存空間后,再將要訪問的頁(yè)調(diào)入內(nèi)存,使程序繼續(xù)執(zhí)行下去。這樣,可以使一個(gè)或多個(gè)大的用戶程序在較小的內(nèi)存空間中運(yùn)行。
聯(lián)想一下Linux系統(tǒng)在硬盤分區(qū)時(shí)需要讓我們選擇一個(gè)swap分區(qū),結(jié)合上面的知識(shí),可知這個(gè)swap分區(qū)就是上面置換時(shí)提到的磁盤。摘抄一段百度百科對(duì)swap的定義:
Swap分區(qū)在系統(tǒng)的物理內(nèi)存不夠用的時(shí)候,把硬盤空間中的一部分空間釋放出來,以供當(dāng)前運(yùn)行的程序使用。那些被釋放的空間可能來自一些很長(zhǎng)時(shí)間沒有什么操作的程序,這些被釋放的空間被臨時(shí)保存到Swap分區(qū)中,等到那些程序要運(yùn)行時(shí),再?gòu)腟wap分區(qū)中恢復(fù)保存的數(shù)據(jù)到內(nèi)存中。
因此,虛擬內(nèi)存的實(shí)現(xiàn)利用了上面提到的分頁(yè)存儲(chǔ)的方法,同時(shí),需要存儲(chǔ)系統(tǒng)需要增加頁(yè)面置換和頁(yè)面調(diào)度功能。
我們知道頁(yè)表的基本作用就是將用戶地址空間中的邏輯地址映射為內(nèi)存空間中的物理地址,為了滿足頁(yè)面的換進(jìn)換出功能,在頁(yè)表中增加幾個(gè)字段:

對(duì)上面字段的解釋:
狀態(tài)位P: 由于在請(qǐng)求分頁(yè)系統(tǒng)中,只將應(yīng)用程序的一部分調(diào)入內(nèi)存,還有一部分在磁盤上,所以需要在頁(yè)表中增加一個(gè)存在位字段,指示該夜是否已調(diào)入內(nèi)存,供應(yīng)用程序參考。
訪問字段A:用于記錄本頁(yè)在一段時(shí)間內(nèi)的訪問次數(shù),或已有多長(zhǎng)時(shí)間未被訪問,提供給置換算法在選擇換出頁(yè)面時(shí)參考。
修改位M:標(biāo)識(shí)該頁(yè)在調(diào)入內(nèi)存后是否被修改過。由于內(nèi)存中的每一頁(yè)都在外存上保留一個(gè)副本,因此,在置換該頁(yè)時(shí),若未被修改,就不需要將該頁(yè)再寫回到外存,減少磁盤交互的次數(shù);若已被修改,則必須將該頁(yè)重寫到外存上,保證外存中所保留的副本是最新的。
外存地址:指出該頁(yè)在外存上的地址,通常是物理塊號(hào),供調(diào)入該頁(yè)時(shí)參考。
回想一下,在前面 **內(nèi)核空間與用戶空間 這一節(jié)當(dāng)中,提到了 Linux的虛擬地址空間為0~4G,從0x00000000到0xFFFFFFFF。這里的虛擬地址,經(jīng)過MMU的轉(zhuǎn)換,可以映射為物理頁(yè)號(hào)。每一個(gè)進(jìn)程都維護(hù)自己的虛擬地址,從虛擬地址中分配內(nèi)存,實(shí)際上底層將這些虛擬地址,通過查詢頁(yè)表映射到物理塊號(hào),然后進(jìn)行相應(yīng)的置換或者讀入。實(shí)際上,是所有的進(jìn)程共享這些物理內(nèi)存,此時(shí)的物理內(nèi)存相當(dāng)于一個(gè)池(聯(lián)想 線程池?)。

IO原理
有了上面的基礎(chǔ),我們?cè)賮砜匆幌虏僮飨到y(tǒng)中的IO:

進(jìn)程使用read()系統(tǒng)調(diào)用,要求其緩沖區(qū)被填滿。內(nèi)核隨即向磁盤控制硬件發(fā)出命令,要求其從磁盤讀取數(shù)據(jù)。磁盤控制器把數(shù)據(jù)直接寫入內(nèi)核內(nèi)存緩沖區(qū),這一步通過 DMA 完成,無需主CPU協(xié)助。一旦磁盤控制器把緩沖區(qū)裝滿,內(nèi)核即把數(shù)據(jù)從內(nèi)核空間的臨時(shí)緩沖區(qū)拷貝到進(jìn)程執(zhí)行read()調(diào)用時(shí)指定的緩沖區(qū)。
我們可能會(huì)覺得,把數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間似乎有些多余。為什么不直接讓磁盤控制器把數(shù)據(jù)送到用戶空間的緩沖區(qū)呢?這樣做有幾個(gè)問題。首先,硬件通常不能直接訪問用戶空間。其次,像磁盤這樣基于塊存儲(chǔ)的硬件設(shè)備操作的是固定大小的數(shù)據(jù)塊,而用戶進(jìn)程請(qǐng)求的可能是任意大小的或非對(duì)齊的數(shù)據(jù)塊。在數(shù)據(jù)往來于用戶空間與存儲(chǔ)設(shè)備的過程中,內(nèi)核負(fù)責(zé)數(shù)據(jù)的分解、再組合工作,因此充當(dāng)著中間人的角色。
采用分頁(yè)技術(shù)的操作系統(tǒng)執(zhí)行 I/O 的全過程可總結(jié)為以下幾步:
確定請(qǐng)求的數(shù)據(jù)分布在文件系統(tǒng)的哪些頁(yè)(磁盤扇區(qū)組)。磁盤上的文件內(nèi)容和元數(shù)據(jù)可能跨越多個(gè)文件系統(tǒng)頁(yè),而且這些頁(yè)可能也不連續(xù)。
在內(nèi)核空間分配足夠數(shù)量的內(nèi)存頁(yè),以容納得到確定的文件系統(tǒng)頁(yè)。
在內(nèi)存頁(yè)與磁盤上的文件系統(tǒng)頁(yè)之間建立映射。
為每一個(gè)內(nèi)存頁(yè)產(chǎn)生頁(yè)錯(cuò)誤。
虛擬內(nèi)存系統(tǒng)俘獲頁(yè)錯(cuò)誤,安排頁(yè)面調(diào)入,從磁盤上讀取頁(yè)內(nèi)容,使頁(yè)有效。
一旦頁(yè)面調(diào)入操作完成,文件系統(tǒng)即對(duì)原始數(shù)據(jù)進(jìn)行解析,取得所需文件內(nèi)容或?qū)傩孕畔ⅰ?/p>
內(nèi)存映射文件
傳統(tǒng)的文件 I/O 是通過用戶進(jìn)程發(fā)布read()和write()系統(tǒng)調(diào)用來傳輸數(shù)據(jù)的。比如FileInputStream.read(byte b[]),實(shí)際上是調(diào)用了read()系統(tǒng)調(diào)用完成數(shù)據(jù)的讀取?;叵?a target="_blank" rel="nofollow">上一篇文章,FileInputStream.read(byte b[])會(huì)造成幾次數(shù)據(jù)拷貝呢?
從磁盤到內(nèi)核緩沖區(qū)的拷貝
內(nèi)核緩沖區(qū)到JVM進(jìn)程直接緩沖區(qū)的拷貝
JVM直接緩沖區(qū)到
FileInputStream.read(byte b[])中byte數(shù)組b指向的堆內(nèi)存的拷貝
可見,傳統(tǒng)的IO要經(jīng)歷至少三次數(shù)據(jù)拷貝才可以把數(shù)據(jù)讀出來,即使是使用直接緩沖區(qū)DirectBuffer,也需要至少兩次拷貝過程。
我們知道,設(shè)備控制器不能通過 DMA 直接存儲(chǔ)到用戶空間,但是利用虛擬內(nèi)存一個(gè)以上的虛擬地址可指向同一個(gè)物理內(nèi)存地址這個(gè)特點(diǎn),則可以把內(nèi)核空間地址與用戶空間的虛擬地址映射到同一個(gè)物理地址,這樣,DMA 硬件(只能訪問物理內(nèi)存地址)就可以填充對(duì)內(nèi)核與用戶空間進(jìn)程同時(shí)可見的緩沖區(qū)。

這樣的話,就省去了內(nèi)核與用戶空間的往來拷貝,但前提條件是,內(nèi)核與用戶緩沖區(qū)必須使用相同的頁(yè)對(duì)齊,緩沖區(qū)的大小還必須是磁盤控制器塊大小的倍數(shù)。
內(nèi)存映射 I/O 使用文件系統(tǒng)建立從用戶空間直到可用文件系統(tǒng)頁(yè)的虛擬內(nèi)存映射。這樣做有幾個(gè)好處:
用戶進(jìn)程把文件數(shù)據(jù)當(dāng)作內(nèi)存,所以無需發(fā)布read()或write()系統(tǒng)調(diào)用。
當(dāng)用戶進(jìn)程碰觸到映射內(nèi)存空間,頁(yè)錯(cuò)誤會(huì)自動(dòng)產(chǎn)生,從而將文件數(shù)據(jù)從磁盤讀進(jìn)內(nèi)存。如果用戶修改了映射內(nèi)存空間,相關(guān)頁(yè)會(huì)自動(dòng)標(biāo)記為臟,隨后刷新到磁盤,文件得到更新。
操作系統(tǒng)的虛擬內(nèi)存子系統(tǒng)會(huì)對(duì)頁(yè)進(jìn)行智能高速緩存,自動(dòng)根據(jù)系統(tǒng)負(fù)載進(jìn)行內(nèi)存管理。
數(shù)據(jù)總是按頁(yè)對(duì)齊的,無需執(zhí)行緩沖區(qū)拷貝。
大型文件使用映射,無需耗費(fèi)大量?jī)?nèi)存,即可進(jìn)行數(shù)據(jù)拷貝。
MappedByteBuffer
了解了上面的內(nèi)容,我們知道在操作系統(tǒng)和硬件層面實(shí)際上是為我們提供了內(nèi)存映射文件這樣的機(jī)制的。在java1.4之后,java也提供了對(duì)應(yīng)的接口,可以讓我們利用操作系統(tǒng)這一特性,提高文件讀寫性能,那就是MappedByteBuffer。
MappedByteBuffer繼承自ByteBuffer,MappedByteBuffer被abstract修飾,所以他不能被實(shí)例化。我們可以調(diào)用FileChannel.map()方法獲取一個(gè)MappedByteBuffer:
FileInputStream inputStream = new FileInputStream(file);
FileChannel channel = inputStream.getChannel();
MappedByteBuffer map = channel.map(MapMode.READ_WRITE, 0, file.length());
這個(gè)MappedByteBuffer實(shí)際上是其子類DirectByteBuffer實(shí)例的引用。也就是說,我們獲得的MappedByteBuffer實(shí)際上是DirectBuffer類型的緩沖區(qū)。也就是說,使用MappedByteBuffer并不會(huì)消耗Java虛擬機(jī)內(nèi)存堆。
public abstract class FileChannel extends AbstractChannel implements ByteChannel, GatheringByteChannel, ScatteringByteChannel {
// 這里僅列出部分API
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
public static class MapMode
{
public static final MapMode READ_ONLY
public static final MapMode READ_WRITE
public static final MapMode PRIVATE
}
}
我們可以創(chuàng)建一個(gè)MappedByteBuffer來代表一個(gè)文件中字節(jié)的某個(gè)子范圍。例如,要映射100到299(包含299)位置的字節(jié),可以使用下面的代碼:buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 100, 200);
如果要映射整個(gè)文件則使用:buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
文件映射可以是可寫的或只讀的。前兩種映射模式MapMode.READ_ONLY和MapMode.READ_WRITE意義是很明顯的,它們表示希望獲取的映射只讀還是允許修改映射的文件。請(qǐng)求的映射模式將受被調(diào)用map()方法的FileChannel對(duì)象的訪問權(quán)限所限制。如果通道是以只讀的權(quán)限打開的卻請(qǐng)求MapMode.READ_WRITE模式,那么map()方法會(huì)拋出一個(gè)NonWritableChannelException異常;如果在一個(gè)沒有讀權(quán)限的通道上請(qǐng)求MapMode.READ_ONLY映射模式,那么將產(chǎn)生NonReadableChannelException異常。
第三種模式MapMode.PRIVATE表示想要一個(gè)寫時(shí)拷貝(copy-on-write)的映射。這意味著通過put()方法所做的任何修改都會(huì)導(dǎo)致產(chǎn)生一個(gè)私有的數(shù)據(jù)副本并且該副本中的數(shù)據(jù)只有MappedByteBuffer實(shí)例可以看到。該過程不會(huì)對(duì)底層文件做任何修改。盡管寫時(shí)拷貝的映射可以防止底層文件被修改,但也必須以read/write權(quán)限來打開文件以建立MapMode.PRIVATE映射。只有這樣,返回的MappedByteBuffer對(duì)象才能允許使用put()方法。
一個(gè)映射一旦建立之后將保持有效,直到MappedByteBuffer對(duì)象被施以垃圾收集動(dòng)作為止。關(guān)閉相關(guān)聯(lián)的FileChannel不會(huì)破壞映射,只有丟棄緩沖區(qū)對(duì)象本身才會(huì)破壞該映射。
MappedByteBuffer主要用在對(duì)大文件的讀寫或?qū)?shí)時(shí)性要求比較高的程序當(dāng)中。
For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of kilobytes of data via the usual read and write methods. From the standpoint of performance it is generally only worth mapping relatively large files into memory.
參考
《計(jì)算機(jī)操作系統(tǒng)(第四版)》 西安電子科技大學(xué)出版社