2.1萬字,30張圖詳解作業系統常見面試題(收藏版)

JavaGuide發表於2023-04-13

耗時兩週,新版的作業系統常見知識點/問題總結總算搞完了,手繪了30多張圖。大家可以用來複習作業系統或者準備作業系統面試。對於大部分公司的面試來說基本夠用了,不過,像騰訊、位元組這種大廠的面試還是要適當深入一些。

這篇文章總結了一些我覺得比較重要的作業系統相關的問題比如 使用者態和核心態、系統呼叫、程式和執行緒、死鎖、記憶體管理、虛擬記憶體、檔案系統等等。另外,這篇文章只是對一些作業系統比較重要概念的一個概覽,深入學習的話,建議大家還是老老實實地去看書。

作業系統基礎

什麼是作業系統?

透過以下四點可以概括作業系統到底是什麼:

  1. 作業系統(Operating System,簡稱 OS)是管理計算機硬體與軟體資源的程式,是計算機的基石。
  2. 作業系統本質上是一個執行在計算機上的軟體程式 ,主要用於管理計算機硬體和軟體資源。 舉例:執行在你電腦上的所有應用程式都透過作業系統來呼叫系統記憶體以及磁碟等等硬體。
  3. 作業系統存在遮蔽了硬體層的複雜性。 作業系統就像是硬體使用的負責人,統籌著各種相關事項。
  4. 作業系統的核心(Kernel)是作業系統的核心部分,它負責系統的記憶體管理,硬體裝置的管理,檔案系統的管理以及應用程式的管理。 核心是連線應用程式和硬體的橋樑,決定著系統的效能和穩定性。

很多人容易把作業系統的核心(Kernel)和中央處理器(CPU,Central Processing Unit)弄混。你可以簡單從下面兩點來區別:

  1. 作業系統的核心(Kernel)屬於作業系統層面,而 CPU 屬於硬體。
  2. CPU 主要提供運算,處理各種指令的能力。核心(Kernel)主要負責系統管理比如記憶體管理,它遮蔽了對硬體的操作。

下圖清晰說明了應用程式、核心、CPU 這三者的關係。

Kernel_Layout

作業系統主要有哪些功能?

從資源管理的角度來看,作業系統有 6 大功能:

  1. 程式和執行緒的管理 :程式的建立、撤銷、阻塞、喚醒,程式間的通訊等。
  2. 儲存管理 :記憶體的分配和管理、外存(磁碟等)的分配和管理等。
  3. 檔案管理 :檔案的讀、寫、建立及刪除等。
  4. 裝置管理 :完成裝置(輸入輸出裝置和外部儲存裝置等)的請求或釋放,以及裝置啟動等功能。
  5. 網路管理 :作業系統負責管理計算機網路的使用。網路是計算機系統中連線不同計算機的方式,作業系統需要管理計算機網路的配置、連線、通訊和安全等,以提供高效可靠的網路服務。
  6. 安全管理 :使用者的身份認證、訪問控制、檔案加密等,以防止非法使用者對系統資源的訪問和操作。

常見的作業系統有哪些?

Windows

目前最流行的個人桌面作業系統 ,不做多的介紹,大家都清楚。介面簡單易操作,軟體生態非常好。

玩玩電腦遊戲還是必須要有 Windows 的,所以我現在是一臺 Windows 用於玩遊戲,一臺 Mac 用於平時日常開發和學習使用。

Unix

最早的多使用者、多工作業系統 。後面崛起的 Linux 在很多方面都參考了 Unix。

目前這款作業系統已經逐漸逐漸退出作業系統的舞臺。

Linux

Linux 是一套免費使用、開源的類 Unix 作業系統。 Linux 存在著許多不同的發行版本,但它們都使用了 Linux 核心

嚴格來講,Linux 這個詞本身只表示 Linux 核心,在 GNU/Linux 系統中,Linux 實際就是 Linux 核心,而該系統的其餘部分主要是由 GNU 工程編寫和提供的程式組成。單獨的 Linux 核心並不能成為一個可以正常工作的作業系統。

很多人更傾向使用 “GNU/Linux” 一詞來表達人們通常所說的 “Linux”。

Mac OS

蘋果自家的作業系統,程式設計體驗和 Linux 相當,但是介面、軟體生態以及使用者體驗各方面都要比 Linux 作業系統更好。

使用者態和核心態

什麼是使用者態和核心態?

根據程式訪問資源的特點,我們可以把程式在系統上的執行分為兩個級別:

  • 使用者態(User Mode) : 使用者態執行的程式可以直接讀取使用者程式的資料,擁有較低的許可權。當應用程式需要執行某些需要特殊許可權的操作,例如讀寫磁碟、網路通訊等,就需要向作業系統發起系統呼叫請求,進入核心態。
  • 核心態(Kernel Mode) :核心態執行的程式幾乎可以訪問計算機的任何資源包括系統的記憶體空間、裝置、驅動程式等,不受限制,擁有非常高的許可權。當作業系統接收到程式的系統呼叫請求時,就會從使用者態切換到核心態,執行相應的系統呼叫,並將結果返回給程式,最後再從核心態切換回使用者態。

使用者態和核心態

核心態相比使用者態擁有更高的特權級別,因此能夠執行更底層、更敏感的操作。不過,由於進入核心態需要付出較高的開銷(需要進行一系列的上下文切換和許可權檢查),應該儘量減少進入核心態的次數,以提高系統的效能和穩定性。

為什麼要有使用者態和核心態?只有一個核心態不行麼?

  • 在 CPU 的所有指令中,有一些指令是比較危險的比如記憶體分配、設定時鐘、IO 處理等,如果所有的程式都能使用這些指令的話,會對系統的正常執行造成災難性地影響。因此,我們需要限制這些危險指令只能核心態執行。這些只能由作業系統核心態執行的指令也被叫做 特權指令
  • 如果計算機系統中只有一個核心態,那麼所有程式或程式都必須共享系統資源,例如記憶體、CPU、硬碟等,這將導致系統資源的競爭和衝突,從而影響系統效能和效率。並且,這樣也會讓系統的安全性降低,畢竟所有程式或程式都具有相同的特權級別和訪問許可權。

因此,同時具有使用者態和核心態主要是為了保證計算機系統的安全性、穩定性和效能。

使用者態和核心態是如何切換的?

使用者態切換到核心態的 3 種方式

使用者態切換到核心態的 3 種方式:

  1. 系統呼叫(Trap) :使用者態程式 主動 要求切換到核心態的一種方式,主要是為了使用核心態才能做的事情比如讀取磁碟資源。系統呼叫的機制其核心還是使用了作業系統為使用者特別開放的一箇中斷來實現。
  2. 中斷(Interrupt) :當外圍裝置完成使用者請求的操作後,會向 CPU 發出相應的中斷訊號,這時 CPU 會暫停執行下一條即將要執行的指令轉而去執行與中斷訊號對應的處理程式,如果先前執行的指令是使用者態下的程式,那麼這個轉換的過程自然也就發生了由使用者態到核心態的切換。比如硬碟讀寫操作完成,系統會切換到硬碟讀寫的中斷處理程式中執行後續操作等。
  3. 異常(Exception):當 CPU 在執行執行在使用者態下的程式時,發生了某些事先不可知的異常,這時會觸發由當前執行程式切換到處理此異常的核心相關程式中,也就轉到了核心態,比如缺頁異常。

在系統的處理上,中斷和異常類似,都是透過中斷向量表來找到相應的處理程式進行處理。區別在於,中斷來自處理器外部,不是由任何一條專門的指令造成,而異常是執行當前指令的結果。

系統呼叫

什麼是系統呼叫?

我們執行的程式基本都是執行在使用者態,如果我們呼叫作業系統提供的核心態級別的子功能咋辦呢?那就需要系統呼叫了!

也就是說在我們執行的使用者程式中,凡是與系統態級別的資源有關的操作(如檔案管理、程式控制、記憶體管理等),都必須透過系統呼叫方式向作業系統提出服務請求,並由作業系統代為完成。

系統呼叫

這些系統呼叫按功能大致可分為如下幾類:

  • 裝置管理:完成裝置(輸入輸出裝置和外部儲存裝置等)的請求或釋放,以及裝置啟動等功能。
  • 檔案管理:完成檔案的讀、寫、建立及刪除等功能。
  • 程式管理:程式的建立、撤銷、阻塞、喚醒,程式間的通訊等功能。
  • 記憶體管理:完成記憶體的分配、回收以及獲取作業佔用記憶體區大小及地址等功能。

系統呼叫和普通庫函式呼叫非常相似,只是系統呼叫由作業系統核心提供,執行於核心態,而普通的庫函式呼叫由函式庫或使用者自己提供,執行於使用者態。

總結:系統呼叫是應用程式與作業系統之間進行互動的一種方式,透過系統呼叫,應用程式可以訪問作業系統底層資源例如檔案、裝置、網路等。

系統呼叫的過程瞭解嗎?

系統呼叫的過程可以簡單分為以下幾個步驟:

  1. 使用者態的程式發起系統呼叫,因為系統呼叫中涉及一些特權指令(只能由作業系統核心態執行的指令),使用者態程式許可權不足,因此會中斷執行,也就是 Trap(Trap 是一種中斷)。
  2. 發生中斷後,當前 CPU 執行的程式會中斷,跳轉到中斷處理程式。核心程式開始執行,也就是開始處理系統呼叫。
  3. 核心處理完成後,主動觸發 Trap,這樣會再次發生中斷,切換回使用者態工作。

系統呼叫的過程

程式和執行緒

什麼是程式和執行緒?

  • 程式(Process) 是指計算機中正在執行的一個程式例項。舉例:你開啟的微信就是一個程式。
  • 執行緒(Thread) 也被稱為輕量級程式,更加輕量。多個執行緒可以在同一個程式中同時執行,並且共享程式的資源比如記憶體空間、檔案控制程式碼、網路連線等。舉例:你開啟的微信裡就有一個執行緒專門用來拉取別人發你的最新的訊息。

程式和執行緒的區別是什麼?

下圖是 Java 記憶體區域,我們從 JVM 的角度來說一下執行緒和程式之間的關係吧!

Java 執行時資料區域(JDK1.8 之後)

從上圖可以看出:一個程式中可以有多個執行緒,多個執行緒共享程式的方法區 (JDK1.8 之後的元空間)資源,但是每個執行緒有自己的程式計數器虛擬機器棧本地方法棧

總結:

  • 執行緒是程式劃分成的更小的執行單位,一個程式在其執行的過程中可以產生多個執行緒。
  • 執行緒和程式最大的不同在於基本上各程式是獨立的,而各執行緒則不一定,因為同一程式中的執行緒極有可能會相互影響。
  • 執行緒執行開銷小,但不利於資源的管理和保護;而程式正相反。

有了程式為什麼還需要執行緒?

  • 程式切換是一個開銷很大的操作,執行緒切換的成本較低。
  • 執行緒更輕量,一個程式可以建立多個執行緒。
  • 多個執行緒可以併發處理不同的任務,更有效地利用了多處理器和多核計算機。而程式只能在一個時間幹一件事,如果在執行過程中遇到阻塞問題比如 IO 阻塞就會掛起直到結果返回。
  • 同一程式內的執行緒共享記憶體和檔案,因此它們之間相互通訊無須呼叫核心。

為什麼要使用多執行緒?

先從總體上來說:

  • 從計算機底層來說: 執行緒可以比作是輕量級的程式,是程式執行的最小單位,執行緒間的切換和排程的成本遠遠小於程式。另外,多核 CPU 時代意味著多個執行緒可以同時執行,這減少了執行緒上下文切換的開銷。
  • 從當代網際網路發展趨勢來說: 現在的系統動不動就要求百萬級甚至千萬級的併發量,而多執行緒併發程式設計正是開發高併發系統的基礎,利用好多執行緒機制可以大大提高系統整體的併發能力以及效能。

再深入到計算機底層來探討:

  • 單核時代: 在單核時代多執行緒主要是為了提高單程式利用 CPU 和 IO 系統的效率。 假設只執行了一個 Java 程式的情況,當我們請求 IO 的時候,如果 Java 程式中只有一個執行緒,此執行緒被 IO 阻塞則整個程式被阻塞。CPU 和 IO 裝置只有一個在執行,那麼可以簡單地說系統整體效率只有 50%。當使用多執行緒的時候,一個執行緒被 IO 阻塞,其他執行緒還可以繼續使用 CPU。從而提高了 Java 程式利用系統資源的整體效率。
  • 多核時代: 多核時代多執行緒主要是為了提高程式利用多核 CPU 的能力。舉個例子:假如我們要計算一個複雜的任務,我們只用一個執行緒的話,不論系統有幾個 CPU 核心,都只會有一個 CPU 核心被利用到。而建立多個執行緒,這些執行緒可以被對映到底層多個 CPU 上執行,在任務中的多個執行緒沒有資源競爭的情況下,任務執行的效率會有顯著性的提高,約等於(單核時執行時間/CPU 核心數)。

執行緒間的同步的方式有哪些?

執行緒同步是兩個或多個共享關鍵資源的執行緒的併發執行。應該同步執行緒以避免關鍵的資源使用衝突。

下面是幾種常見的執行緒同步的方式:

  1. 互斥鎖(Mutex) :採用互斥物件機制,只有擁有互斥物件的執行緒才有訪問公共資源的許可權。因為互斥物件只有一個,所以可以保證公共資源不會被多個執行緒同時訪問。比如 Java 中的 synchronized 關鍵詞和各種 Lock 都是這種機制。
  2. 讀寫鎖(Read-Write Lock):允許多個執行緒同時讀取共享資源,但只有一個執行緒可以對共享資源進行寫操作。
  3. 訊號量(Semaphore) :它允許同一時刻多個執行緒訪問同一資源,但是需要控制同一時刻訪問此資源的最大執行緒數量。
  4. 屏障(Barrier) :屏障是一種同步原語,用於等待多個執行緒到達某個點再一起繼續執行。當一個執行緒到達屏障時,它會停止執行並等待其他執行緒到達屏障,直到所有執行緒都到達屏障後,它們才會一起繼續執行。比如 Java 中的 CyclicBarrier 是這種機制。
  5. 事件(Event) :Wait/Notify:透過通知操作的方式來保持多執行緒同步,還可以方便的實現多執行緒優先順序的比較操作。

PCB 是什麼?包含哪些資訊?

PCB(Process Control Block) 即程式控制塊,是作業系統中用來管理和跟蹤程式的資料結構,每個程式都對應著一個獨立的 PCB。你可以將 PCB 視為程式的大腦。

當作業系統建立一個新程式時,會為該程式分配一個唯一的程式 ID,並且為該程式建立一個對應的程式控制塊。當程式執行時,PCB 中的資訊會不斷變化,作業系統會根據這些資訊來管理和排程程式。

PCB 主要包含下面幾部分的內容:

  • 程式的描述資訊,包括程式的名稱、識別符號等等;
  • 程式的排程資訊,包括程式阻塞原因、程式狀態(就緒、執行、阻塞等)、程式優先順序(標識程式的重要程度)等等;
  • 程式對資源的需求情況,包括 CPU 時間、記憶體空間、I/O 裝置等等。
  • 程式開啟的檔案資訊,包括檔案描述符、檔案型別、開啟模式等等。
  • 處理機的狀態資訊(由處理機的各種暫存器中的內容組成的),包括通用暫存器、指令計數器、程式狀態字 PSW、使用者棧指標。
  • ......

程式有哪幾種狀態?

我們一般把程式大致分為 5 種狀態,這一點和執行緒很像!

  • 建立狀態(new) :程式正在被建立,尚未到就緒狀態。
  • 就緒狀態(ready) :程式已處於準備執行狀態,即程式獲得了除了處理器之外的一切所需資源,一旦得到處理器資源(處理器分配的時間片)即可執行。
  • 執行狀態(running) :程式正在處理器上執行(單核 CPU 下任意時刻只有一個程式處於執行狀態)。
  • 阻塞狀態(waiting) :又稱為等待狀態,程式正在等待某一事件而暫停執行如等待某資源為可用或等待 IO 操作完成。即使處理器空閒,該程式也不能執行。
  • 結束狀態(terminated) :程式正在從系統中消失。可能是程式正常結束或其他原因中斷退出執行。

程式狀態圖轉換圖

程式間的通訊方式有哪些?

下面這部分總結參考了:《程式間通訊 IPC (InterProcess Communication)》 這篇文章,推薦閱讀,總結的非常不錯。

  1. 管道/匿名管道(Pipes) :用於具有親緣關係的父子程式間或者兄弟程式之間的通訊。
  2. 有名管道(Named Pipes) : 匿名管道由於沒有名字,只能用於親緣關係的程式間通訊。為了克服這個缺點,提出了有名管道。有名管道嚴格遵循先進先出(first in first out)。有名管道以磁碟檔案的方式存在,可以實現本機任意兩個程式通訊。
  3. 訊號(Signal) :訊號是一種比較複雜的通訊方式,用於通知接收程式某個事件已經發生;
  4. 訊息佇列(Message Queuing) :訊息佇列是訊息的連結串列,具有特定的格式,存放在記憶體中並由訊息佇列識別符號標識。管道和訊息佇列的通訊資料都是先進先出的原則。與管道(無名管道:只存在於記憶體中的檔案;命名管道:存在於實際的磁碟介質或者檔案系統)不同的是訊息佇列存放在核心中,只有在核心重啟(即,作業系統重啟)或者顯式地刪除一個訊息佇列時,該訊息佇列才會被真正的刪除。訊息佇列可以實現訊息的隨機查詢,訊息不一定要以先進先出的次序讀取,也可以按訊息的型別讀取.比 FIFO 更有優勢。訊息佇列克服了訊號承載資訊量少,管道只能承載無格式字 節流以及緩衝區大小受限等缺點。
  5. 訊號量(Semaphores) :訊號量是一個計數器,用於多程式對共享資料的訪問,訊號量的意圖在於程式間同步。這種通訊方式主要用於解決與同步相關的問題並避免競爭條件。
  6. 共享記憶體(Shared memory) :使得多個程式可以訪問同一塊記憶體空間,不同程式可以及時看到對方程式中對共享記憶體中資料的更新。這種方式需要依靠某種同步操作,如互斥鎖和訊號量等。可以說這是最有用的程式間通訊方式。
  7. 套接字(Sockets) : 此方法主要用於在客戶端和伺服器之間透過網路進行通訊。套接字是支援 TCP/IP 的網路通訊的基本操作單元,可以看做是不同主機之間的程式進行雙向通訊的端點,簡單的說就是通訊的兩方的一種約定,用套接字中的相關函式來完成通訊過程。

程式的排程演算法有哪些?

常見程式排程演算法

這是一個很重要的知識點!為了確定首先執行哪個程式以及最後執行哪個程式以實現最大 CPU 利用率,電腦科學家已經定義了一些演算法,它們是:

  • 先到先服務排程演算法(FCFS,First Come, First Served) : 從就緒佇列中選擇一個最先進入該佇列的程式為之分配資源,使它立即執行並一直執行到完成或發生某事件而被阻塞放棄佔用 CPU 時再重新排程。
  • 短作業優先的排程演算法(SJF,Shortest Job First) : 從就緒佇列中選出一個估計執行時間最短的程式為之分配資源,使它立即執行並一直執行到完成或發生某事件而被阻塞放棄佔用 CPU 時再重新排程。
  • 時間片輪轉排程演算法(RR,Round-Robin) : 時間片輪轉排程是一種最古老,最簡單,最公平且使用最廣的演算法。每個程式被分配一個時間段,稱作它的時間片,即該程式允許執行的時間。
  • 多級反饋佇列排程演算法(MFQ,Multi-level Feedback Queue) :前面介紹的幾種程式排程的演算法都有一定的侷限性。如短程式優先的排程演算法,僅照顧了短程式而忽略了長程式 。多級反饋佇列排程演算法既能使高優先順序的作業得到響應又能使短作業(程式)迅速完成。,因而它是目前被公認的一種較好的程式排程演算法,UNIX 作業系統採取的便是這種排程演算法。
  • 優先順序排程演算法(Priority) : 為每個流程分配優先順序,首先執行具有最高優先順序的程式,依此類推。具有相同優先順序的程式以 FCFS 方式執行。可以根據記憶體要求,時間要求或任何其他資源要求來確定優先順序。

什麼是殭屍程式和孤兒程式?

在 Unix/Linux 系統中,子程式通常是透過 fork()系統呼叫建立的,該呼叫會建立一個新的程式,該程式是原有程式的一個副本。子程式和父程式的執行是相互獨立的,它們各自擁有自己的 PCB,即使父程式結束了,子程式仍然可以繼續執行。

當一個程式呼叫 exit()系統呼叫結束自己的生命時,核心會釋放該程式的所有資源,包括開啟的檔案、佔用的記憶體等,但是該程式對應的 PCB 依然存在於系統中。這些資訊只有在父程式呼叫 wait()或 waitpid()系統呼叫時才會被釋放,以便讓父程式得到子程式的狀態資訊。

這樣的設計可以讓父程式在子程式結束時得到子程式的狀態資訊,並且可以防止出現“殭屍程式”(即子程式結束後 PCB 仍然存在但父程式無法得到狀態資訊的情況)。

  • 殭屍程式 :子程式已經終止,但是其父程式仍在執行,且父程式沒有呼叫 wait()或 waitpid()等系統呼叫來獲取子程式的狀態資訊,釋放子程式佔用的資源,導致子程式的 PCB 依然存在於系統中,但無法被進一步使用。這種情況下,子程式被稱為“殭屍程式”。避免殭屍程式的產生,父程式需要及時呼叫 wait()或 waitpid()系統呼叫來回收子程式。
  • 孤兒程式 :一個程式的父程式已經終止或者不存在,但是該程式仍在執行。這種情況下,該程式就是孤兒程式。孤兒程式通常是由於父程式意外終止或未及時呼叫 wait()或 waitpid()等系統呼叫來回收子程式導致的。為了避免孤兒程式佔用系統資源,作業系統會將孤兒程式的父程式設定為 init 程式(程式號為 1),由 init 程式來回收孤兒程式的資源。

如何檢視是否有殭屍程式?

Linux 下可以使用 Top 命令查詢,zombie 值表示殭屍程式的數量,為 0 則代表沒有殭屍程式。

殭屍程式檢視

下面這個命令可以定位殭屍程式以及該殭屍程式的父程式:

ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'

死鎖

什麼是死鎖?

死鎖(Deadlock)描述的是這樣一種情況:多個程式/執行緒同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於程式/執行緒被無限期地阻塞,因此程式不可能正常終止。

能列舉一個作業系統發生死鎖的例子嗎?

假設有兩個程式 A 和 B,以及兩個資源 X 和 Y,它們的分配情況如下:

程式 佔用資源 需求資源
A X Y
B Y X

此時,程式 A 佔用資源 X 並且請求資源 Y,而程式 B 已經佔用了資源 Y 並請求資源 X。兩個程式都在等待對方釋放資源,無法繼續執行,陷入了死鎖狀態。

產生死鎖的四個必要條件是什麼?

  1. 互斥:資源必須處於非共享模式,即一次只有一個程式可以使用。如果另一程式申請該資源,那麼必須等待直到該資源被釋放為止。
  2. 佔有並等待:一個程式至少應該佔有一個資源,並等待另一資源,而該資源被其他程式所佔有。
  3. 非搶佔:資源不能被搶佔。只能在持有資源的程式完成任務後,該資源才會被釋放。
  4. 迴圈等待:有一組等待程式 {P0, P1,..., Pn}P0 等待的資源被 P1 佔有,P1 等待的資源被 P2 佔有,......,Pn-1 等待的資源被 Pn 佔有,Pn 等待的資源被 P0 佔有。

注意 ⚠️ :這四個條件是產生死鎖的 必要條件 ,也就是說只要系統發生死鎖,這些條件必然成立,而只要上述條件之一不滿足,就不會發生死鎖。

下面是百度百科對必要條件的解釋:

如果沒有事物情況 A,則必然沒有事物情況 B,也就是說如果有事物情況 B 則一定有事物情況 A,那麼 A 就是 B 的必要條件。從邏輯學上看,B 能推匯出 A,A 就是 B 的必要條件,等價於 B 是 A 的充分條件。

能寫一個模擬產生死鎖的程式碼嗎?

下面透過一個實際的例子來模擬下圖展示的執行緒死鎖:

執行緒死鎖示意圖

public class DeadLockDemo {
    private static Object resource1 = new Object();//資源 1
    private static Object resource2 = new Object();//資源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "執行緒 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "執行緒 2").start();
    }
}

Output

Thread[執行緒 1,5,main]get resource1
Thread[執行緒 2,5,main]get resource2
Thread[執行緒 1,5,main]waiting get resource2
Thread[執行緒 2,5,main]waiting get resource1

執行緒 A 透過 synchronized (resource1) 獲得 resource1 的監視器鎖,然後透過Thread.sleep(1000);讓執行緒 A 休眠 1s 為的是讓執行緒 B 得到執行然後獲取到 resource2 的監視器鎖。執行緒 A 和執行緒 B 休眠結束了都開始企圖請求獲取對方的資源,然後這兩個執行緒就會陷入互相等待的狀態,這也就產生了死鎖。

解決死鎖的方法

解決死鎖的方法可以從多個角度去分析,一般的情況下,有預防,避免,檢測和解除四種

  • 預防 是採用某種策略,限制併發程式對資源的請求,從而使得死鎖的必要條件在系統執行的任何時間上都不滿足。

  • 避免則是系統在分配資源時,根據資源的使用情況提前做出預測,從而避免死鎖的發生

  • 檢測是指系統設有專門的機構,當死鎖發生時,該機構能夠檢測死鎖的發生,並精確地確定與死鎖有關的程式和資源。

  • 解除 是與檢測相配套的一種措施,用於將程式從死鎖狀態下解脫出來

死鎖的預防

死鎖四大必要條件上面都已經列出來了,很顯然,只要破壞四個必要條件中的任何一個就能夠預防死鎖的發生。

破壞第一個條件 互斥條件:使得資源是可以同時訪問的,這是種簡單的方法,磁碟就可以用這種方法管理,但是我們要知道,有很多資源 往往是不能同時訪問的 ,所以這種做法在大多數的場合是行不通的。

破壞第三個條件 非搶佔 :也就是說可以採用 剝奪式排程演算法,但剝奪式排程方法目前一般僅適用於 主存資源處理器資源 的分配,並不適用於所有的資源,會導致 資源利用率下降

所以一般比較實用的 預防死鎖的方法,是透過考慮破壞第二個條件和第四個條件。

1、靜態分配策略

靜態分配策略可以破壞死鎖產生的第二個條件(佔有並等待)。所謂靜態分配策略,就是指一個程式必須在執行前就申請到它所需要的全部資源,並且知道它所要的資源都得到滿足之後才開始執行。程式要麼佔有所有的資源然後開始執行,要麼不佔有資源,不會出現佔有一些資源等待一些資源的情況。

靜態分配策略邏輯簡單,實現也很容易,但這種策略 嚴重地降低了資源利用率,因為在每個程式所佔有的資源中,有些資源是在比較靠後的執行時間裡採用的,甚至有些資源是在額外的情況下才使用的,這樣就可能造成一個程式佔有了一些 幾乎不用的資源而使其他需要該資源的程式產生等待 的情況。

2、層次分配策略

層次分配策略破壞了產生死鎖的第四個條件(迴圈等待)。在層次分配策略下,所有的資源被分成了多個層次,一個程式得到某一次的一個資源後,它只能再申請較高一層的資源;當一個程式要釋放某層的一個資源時,必須先釋放所佔用的較高層的資源,按這種策略,是不可能出現迴圈等待鏈的,因為那樣的話,就出現了已經申請了較高層的資源,反而去申請了較低層的資源,不符合層次分配策略,證明略。

死鎖的避免

上面提到的 破壞 死鎖產生的四個必要條件之一就可以成功 預防系統發生死鎖 ,但是會導致 低效的程式執行資源使用率 。而死鎖的避免相反,它的角度是允許系統中同時存在四個必要條件 ,只要掌握併發程式中與每個程式有關的資源動態申請情況,做出 明智和合理的選擇 ,仍然可以避免死鎖,因為四大條件僅僅是產生死鎖的必要條件。

我們將系統的狀態分為 安全狀態不安全狀態 ,每當在未申請者分配資源前先測試系統狀態,若把系統資源分配給申請者會產生死鎖,則拒絕分配,否則接受申請,併為它分配資源。

如果作業系統能夠保證所有的程式在有限的時間內得到需要的全部資源,則稱系統處於安全狀態,否則說系統是不安全的。很顯然,系統處於安全狀態則不會發生死鎖,系統若處於不安全狀態則可能發生死鎖。

那麼如何保證系統保持在安全狀態呢?透過演算法,其中最具有代表性的 避免死鎖演算法 就是 Dijkstra 的銀行家演算法,銀行家演算法用一句話表達就是:當一個程式申請使用資源的時候,銀行家演算法 透過先 試探 分配給該程式資源,然後透過 安全性演算法 判斷分配後系統是否處於安全狀態,若不安全則試探分配作廢,讓該程式繼續等待,若能夠進入到安全的狀態,則就 真的分配資源給該程式

銀行家演算法詳情可見:《一句話+一張圖說清楚——銀行家演算法》

作業系統教程書中講述的銀行家演算法也比較清晰,可以一看.

死鎖的避免(銀行家演算法)改善了 資源使用率低的問題 ,但是它要不斷地檢測每個程式對各類資源的佔用和申請情況,以及做 安全性檢查 ,需要花費較多的時間。

死鎖的檢測

對資源的分配加以限制可以 預防和避免 死鎖的發生,但是都不利於各程式對系統資源的充分共享。解決死鎖問題的另一條途徑是 死鎖檢測和解除 (這裡突然聯想到了樂觀鎖和悲觀鎖,感覺死鎖的檢測和解除就像是 樂觀鎖 ,分配資源時不去提前管會不會發生死鎖了,等到真的死鎖出現了再來解決嘛,而 死鎖的預防和避免 更像是悲觀鎖,總是覺得死鎖會出現,所以在分配資源的時候就很謹慎)。

這種方法對資源的分配不加以任何限制,也不採取死鎖避免措施,但系統 定時地執行一個 “死鎖檢測” 的程式,判斷系統內是否出現死鎖,如果檢測到系統發生了死鎖,再採取措施去解除它。

程式-資源分配圖

作業系統中的每一刻時刻的系統狀態都可以用程式-資源分配圖來表示,程式-資源分配圖是描述程式和資源申請及分配關係的一種有向圖,可用於檢測系統是否處於死鎖狀態

用一個方框表示每一個資源類,方框中的黑點表示該資源類中的各個資源,每個鍵程式用一個圓圈表示,用 有向邊 來表示程式申請資源和資源被分配的情況

圖中 2-21 是程式-資源分配圖的一個例子,其中共有三個資源類,每個程式的資源佔有和申請情況已清楚地表示在圖中。在這個例子中,由於存在 佔有和等待資源的環路 ,導致一組程式永遠處於等待資源的狀態,發生了 死鎖

程式-資源分配圖

程式-資源分配圖中存在環路並不一定是發生了死鎖。因為迴圈等待資源僅僅是死鎖發生的必要條件,而不是充分條件。圖 2-22 便是一個有環路而無死鎖的例子。雖然程式 P1 和程式 P3 分別佔用了一個資源 R1 和一個資源 R2,並且因為等待另一個資源 R2 和另一個資源 R1 形成了環路,但程式 P2 和程式 P4 分別佔有了一個資源 R1 和一個資源 R2,它們申請的資源得到了滿足,在有限的時間裡會歸還資源,於是程式 P1 或 P3 都能獲得另一個所需的資源,環路自動解除,系統也就不存在死鎖狀態了。

死鎖檢測步驟

知道了死鎖檢測的原理,我們可以利用下列步驟編寫一個 死鎖檢測 程式,檢測系統是否產生了死鎖。

  1. 如果程式-資源分配圖中無環路,則此時系統沒有發生死鎖
  2. 如果程式-資源分配圖中有環路,且每個資源類僅有一個資源,則系統中已經發生了死鎖。
  3. 如果程式-資源分配圖中有環路,且涉及到的資源類有多個資源,此時系統未必會發生死鎖。如果能在程式-資源分配圖中找出一個 既不阻塞又非獨立的程式 ,該程式能夠在有限的時間內歸還佔有的資源,也就是把邊給消除掉了,重複此過程,直到能在有限的時間內 消除所有的邊 ,則不會發生死鎖,否則會發生死鎖。(消除邊的過程類似於 拓撲排序)

死鎖的解除

當死鎖檢測程式檢測到存在死鎖發生時,應設法讓其解除,讓系統從死鎖狀態中恢復過來,常用的解除死鎖的方法有以下四種:

  1. 立即結束所有程式的執行,重新啟動作業系統 :這種方法簡單,但以前所在的工作全部作廢,損失很大。
  2. 撤銷涉及死鎖的所有程式,解除死鎖後繼續執行 :這種方法能徹底打破死鎖的迴圈等待條件,但將付出很大代價,例如有些程式可能已經計算了很長時間,由於被撤銷而使產生的部分結果也被消除了,再重新執行時還要再次進行計算。
  3. 逐個撤銷涉及死鎖的程式,回收其資源直至死鎖解除。
  4. 搶佔資源 :從涉及死鎖的一個或幾個程式中搶佔資源,把奪得的資源再分配給涉及死鎖的程式直至死鎖解除。

記憶體管理

記憶體管理主要做了什麼?

記憶體管理主要做的事情

作業系統的記憶體管理非常重要,主要負責下面這些事情:

  • 記憶體的分配與回收 :對程式所需的記憶體進行分配和釋放,malloc 函式:申請記憶體,free 函式:釋放記憶體。
  • 地址轉換 :將程式中的虛擬地址轉換成記憶體中的實體地址。
  • 記憶體擴充 :當系統沒有足夠的記憶體時,利用虛擬記憶體技術或自動覆蓋技術,從邏輯上擴充記憶體。
  • 記憶體對映 :將一個檔案直接對映到程式的程式空間中,這樣可以透過記憶體指標用讀寫記憶體的辦法直接存取檔案內容,速度更快。
  • 記憶體最佳化 :透過調整記憶體分配策略和回收演算法來最佳化記憶體使用效率。
  • 記憶體安全 :保證程式之間使用記憶體互不干擾,避免一些惡意程式透過修改記憶體來破壞系統的安全性。
  • ......

什麼是記憶體碎片?

記憶體碎片是由記憶體的申請和釋放產生的,通常分為下面兩種:

  • 內部記憶體碎片(Internal Memory Fragmentation,簡稱為記憶體碎片) :已經分配給程式使用但未被使用的記憶體。導致內部記憶體碎片的主要原因是,當採用固定比例比如 2 的冪次方進行記憶體分配時,程式所分配的記憶體可能會比其實際所需要的大。舉個例子,一個程式只需要 65 位元組的記憶體,但為其分配了 128(2^7) 大小的記憶體,那 63 位元組的記憶體就成為了內部記憶體碎片。
  • 外部記憶體碎片(External Memory Fragmentation,簡稱為外部碎片) :由於未分配的連續記憶體區域太小,以至於不能滿足任意程式所需要的記憶體分配請求,這些小片段且不連續的記憶體空間被稱為外部碎片。也就是說,外部記憶體碎片指的是那些併為分配給程式但又不能使用的記憶體。我們後面介紹的分段機制就會導致外部記憶體碎片。

記憶體碎片

記憶體碎片會導致記憶體利用率下降,如何減少記憶體碎片是記憶體管理要非常重視的一件事情。

常見的記憶體管理方式有哪些?

記憶體管理方式可以簡單分為下面兩種:

  • 連續記憶體管理 :為一個使用者程式分配一個連續的記憶體空間,記憶體利用率一般不高。
  • 非連續記憶體管理 :允許一個程式使用的記憶體分佈在離散或者說不相鄰的記憶體中,相對更加靈活一些。

連續記憶體管理

塊式管理 是早期計算機作業系統的一種連續記憶體管理方式,存在嚴重的記憶體碎片問題。塊式管理會將記憶體分為幾個固定大小的塊,每個塊中只包含一個程式。如果程式執行需要記憶體的話,作業系統就分配給它一塊,如果程式執行只需要很小的空間的話,分配的這塊記憶體很大一部分幾乎被浪費了。這些在每個塊中未被利用的空間,我們稱之為內部記憶體碎片。除了內部記憶體碎片之外,由於兩個記憶體塊之間可能還會有外部記憶體碎片,這些不連續的外部記憶體碎片由於太小了無法再進行分配。

在 Linux 系統中,連續記憶體管理採用了 夥伴系統(Buddy System)演算法 來實現,這是一種經典的連續記憶體分配演算法,可以有效解決外部記憶體碎片的問題。夥伴系統的主要思想是將記憶體按 2 的冪次劃分(每一塊記憶體大小都是 2 的冪次比如 2^6=64 KB),並將相鄰的記憶體塊組合成一對夥伴(注意:必須是相鄰的才是夥伴)。

當進行記憶體分配時,夥伴系統會嘗試找到大小最合適的記憶體塊。如果找到的記憶體塊過大,就將其一分為二,分成兩個大小相等的夥伴塊。如果還是大的話,就繼續切分,直到到達合適的大小為止。

假設兩塊相鄰的記憶體塊都被釋放,系統會將這兩個記憶體塊合併,進而形成一個更大的記憶體塊,以便後續的記憶體分配。這樣就可以減少記憶體碎片的問題,提高記憶體利用率。

夥伴系統(Buddy System)記憶體管理

雖然解決了外部記憶體碎片的問題,但夥伴系統仍然存在記憶體利用率不高的問題(內部記憶體碎片)。這主要是因為夥伴系統只能分配大小為 2^n 的記憶體塊,因此當需要分配的記憶體大小不是 2^n 的整數倍時,會浪費一定的記憶體空間。舉個例子:如果要分配 65 大小的記憶體快,依然需要分配 2^7=128 大小的記憶體塊。

夥伴系統記憶體浪費問題

對於內部記憶體碎片的問題,Linux 採用 SLAB 進行解決。由於這部分內容不是本篇文章的重點,這裡就不詳細介紹了。

非連續記憶體管理

非連續記憶體管理存在下面 3 種方式:

  • 段式管理 :以段(—段連續的實體記憶體)的形式管理/分配實體記憶體。應用程式的虛擬地址空間被分為大小不等的段,段是有實際意義的,每個段定義了一組邏輯資訊,例如有主程式段 MAIN、子程式段 X、資料段 D 及棧段 S 等。
  • 頁式管理 :把實體記憶體分為連續等長的物理頁,應用程式的虛擬地址空間劃也被分為連續等長的虛擬頁,現代作業系統廣泛使用的一種記憶體管理方式。
  • 段頁式管理機制 :結合了段式管理和頁式管理的一種記憶體管理機制,把實體記憶體先分成若干段,每個段又繼續分成若干大小相等的頁。

虛擬記憶體

什麼是虛擬記憶體?有什麼用?

虛擬記憶體(Virtual Memory) 是計算機系統記憶體管理非常重要的一個技術,本質上來說它只是邏輯存在的,是一個假想出來的記憶體空間,主要作用是作為程式訪問主存(實體記憶體)的橋樑並簡化記憶體管理。

虛擬記憶體作為程式訪問主存的橋樑

總結來說,虛擬記憶體主要提供了下面這些能力:

  • 隔離程式 :實體記憶體透過虛擬地址空間訪問,虛擬地址空間與程式一一對應。每個程式都認為自己擁有了整個實體記憶體,程式之間彼此隔離,一個程式中的程式碼無法更改正在由另一程式或作業系統使用的實體記憶體。
  • 提升實體記憶體利用率 :有了虛擬地址空間後,作業系統只需要將程式當前正在使用的部分資料或指令載入入實體記憶體。
  • 簡化記憶體管理 :程式都有一個一致且私有的虛擬地址空間,程式設計師不用和真正的實體記憶體打交道,而是藉助虛擬地址空間訪問實體記憶體,從而簡化了記憶體管理。
  • 多個程式共享實體記憶體:程式在執行過程中,會載入許多作業系統的動態庫。這些庫對於每個程式而言都是公用的,它們在記憶體中實際只會載入一份,這部分稱為共享記憶體。
  • 提高記憶體使用安全性 :控制程式對實體記憶體的訪問,隔離不同程式的訪問許可權,提高系統的安全性。
  • 提供更大的可使用記憶體空間 : 可以讓程式擁有超過系統實體記憶體大小的可用記憶體空間。這是因為當實體記憶體不夠用時,可以利用磁碟充當,將實體記憶體頁(通常大小為 4 KB)儲存到磁碟檔案(會影響讀寫速度),資料或內碼表會根據需要在實體記憶體與磁碟之間移動。

沒有虛擬記憶體有什麼問題?

如果沒有虛擬記憶體的話,程式直接訪問和操作的都是實體記憶體,看似少了一層中介,但多了很多問題。

具體有什麼問題呢? 這裡舉幾個例子說明(參考虛擬記憶體提供的能力回答這個問題):

  1. 使用者程式可以訪問任意實體記憶體,可能會不小心操作到系統執行必需的記憶體,進而造成作業系統崩潰,嚴重影響系統的安全。
  2. 同時執行多個程式容易崩潰。比如你想同時執行一個微信和一個 QQ 音樂,微信在執行的時候給記憶體地址 1xxx 賦值後,QQ 音樂也同樣給記憶體地址 1xxx 賦值,那麼 QQ 音樂對記憶體的賦值就會覆蓋微信之前所賦的值,這就可能會造成微信這個程式會崩潰。
  3. 程式執行過程中使用的所有資料或指令都要載入實體記憶體,根據區域性性原理,其中很大一部分可能都不會用到,白白佔用了寶貴的實體記憶體資源。
  4. ......

什麼是虛擬地址和實體地址?

實體地址(Physical Address) 是真正的實體記憶體中地址,更具體點來說是記憶體地址暫存器中的地址。程式中訪問的記憶體地址不是實體地址,而是 虛擬地址(Virtual Address)

也就是說,我們程式設計開發的時候實際就是在和虛擬地址打交道。比如在 C 語言中,指標裡面儲存的數值就可以理解成為記憶體裡的一個地址,這個地址也就是我們說的虛擬地址。

作業系統一般透過 CPU 晶片中的一個重要元件 MMU(Memory Management Unit,記憶體管理單元) 將虛擬地址轉換為實體地址,這個過程被稱為 地址翻譯/地址轉換(Address Translation)

地址翻譯過程

透過 MMU 將虛擬地址轉換為實體地址後,再透過匯流排傳到實體記憶體裝置,進而完成相應的實體記憶體讀寫請求。

MMU 將虛擬地址翻譯為實體地址的主要機制有兩種: 分段機制分頁機制

什麼是虛擬地址空間和實體地址空間?

  • 虛擬地址空間是虛擬地址的集合,是虛擬記憶體的範圍。每一個程式都有一個一致且私有的虛擬地址空間。
  • 實體地址空間是實體地址的集合,是實體記憶體的範圍。

虛擬地址與實體記憶體地址是如何對映的?

MMU 將虛擬地址翻譯為實體地址的主要機制有 3 種:

  1. 分段機制
  2. 分頁機制
  3. 段頁機制

其中,現代作業系統廣泛採用分頁機制,需要重點關注!

分段機制

分段機制(Segmentation) 以段(—段 連續 的實體記憶體)的形式管理/分配實體記憶體。應用程式的虛擬地址空間被分為大小不等的段,段是有實際意義的,每個段定義了一組邏輯資訊,例如有主程式段 MAIN、子程式段 X、資料段 D 及棧段 S 等。

段表有什麼用?地址翻譯過程是怎樣的?

分段管理透過 段表(Segment Table) 對映虛擬地址和實體地址。

分段機制下的虛擬地址由兩部分組成:

  • 段號 :標識著該虛擬地址屬於整個虛擬地址空間中的哪一個段。
  • 段內偏移量 :相對於該段起始地址的偏移量。

具體的地址翻譯過程如下:

  1. MMU 首先解析得到虛擬地址中的段號;
  2. 透過段號去該應用程式的段表中取出對應的段資訊(找到對應的段表項);
  3. 從段資訊中取出該段的起始地址(實體地址)加上虛擬地址中的段內偏移量得到最終的實體地址。

分段機制下的地址翻譯過程

段表中還存有諸如段長(可用於檢查虛擬地址是否超出合法範圍)、段型別(該段的型別,例如程式碼段、資料段等)等資訊。

透過段號一定要找到對應的段表項嗎?得到最終的實體地址後對應的實體記憶體一定存在嗎?

不一定。段表項可能並不存在:

  • 段表項被刪除 :軟體錯誤、軟體惡意行為等情況可能會導致段表項被刪除。
  • 段表項還未建立 :如果系統記憶體不足或者無法分配到連續的實體記憶體塊就會導致段表項無法被建立。

分段機制為什麼會導致記憶體外部碎片?

分段機制容易出現外部記憶體碎片,即在段與段之間留下碎片空間(不足以對映給虛擬地址空間中的段)。從而造成實體記憶體資源利用率的降低。

舉個例子:假設可用實體記憶體為 5G 的系統使用分段機制分配記憶體。現在有 4 個程式,每個程式的記憶體佔用情況如下:

  • 程式 1:0~1G(第 1 段)
  • 程式 2:1~3G(第 2 段)
  • 程式 3:3~4.5G(第 3 段)
  • 程式 4:4.5~5G(第 4 段)

此時,我們關閉了程式 1 和程式 4,則第 1 段和第 4 段的記憶體會被釋放,空閒實體記憶體還有 1.5G。由於這 1.5G 實體記憶體並不是連續的,導致沒辦法將空閒的實體記憶體分配給一個需要 1.5G 實體記憶體的程式。

分段機制導致外部記憶體碎片

分頁機制

分頁機制(Paging) 把主存(實體記憶體)分為連續等長的物理頁,應用程式的虛擬地址空間劃也被分為連續等長的虛擬頁。現代作業系統廣泛採用分頁機制。

注意:這裡的頁是連續等長的,不同於分段機制下不同長度的段。

在分頁機制下,應用程式虛擬地址空間中的任意虛擬頁可以被對映到實體記憶體中的任意物理頁上,因此可以實現實體記憶體資源的離散分配。分頁機制按照固定頁大小分配實體記憶體,使得實體記憶體資源易於管理,可有效避免分段機制中外部記憶體碎片的問題。

頁表有什麼用?地址翻譯過程是怎樣的?

分頁管理透過 頁表(Page Table) 對映虛擬地址和實體地址。我這裡畫了一張基於單級頁表進行地址翻譯的示意圖。

單級頁表

在分頁機制下,每個應用程式都會有一個對應的頁表。

分頁機制下的虛擬地址由兩部分組成:

  • 頁號 :透過虛擬頁號可以從頁表中取出對應的物理頁號;
  • 頁內偏移量 :物理頁起始地址+頁內偏移量=實體記憶體地址。

具體的地址翻譯過程如下:

  1. MMU 首先解析得到虛擬地址中的虛擬頁號;
  2. 透過虛擬頁號去該應用程式的頁表中取出對應的物理頁號(找到對應的頁表項);
  3. 用該物理頁號對應的物理頁起始地址(實體地址)加上虛擬地址中的頁內偏移量得到最終的實體地址。

分頁機制下的地址翻譯過程

頁表中還存有諸如訪問標誌(標識該頁面有沒有被訪問過)、頁型別(該段的型別,例如程式碼段、資料段等)等資訊。

透過虛擬頁號一定要找到對應的物理頁號嗎?找到了物理頁號得到最終的實體地址後對應的物理頁一定存在嗎?

不一定!可能會存在 頁缺失 。也就是說,實體記憶體中沒有對應的物理頁或者實體記憶體中有對應的物理頁但虛擬頁還未和物理頁建立對映(對應的頁表項不存在)。關於頁缺失的內容,後面會詳細介紹到。

單級頁表有什麼問題?為什麼需要多級頁表?

以 32 位的環境為例,虛擬地址空間範圍共有 2^32(4G)。假設 一個頁的大小是 2^12(4KB),那頁表項共有 4G / 4K = 2^20 個。每個頁表項為一個地址,佔用 4 位元組,2^20 * 2^2/1024*1024= 4MB。也就是說一個程式啥都不幹,頁表大小就得佔用 4M。

系統執行的應用程式多起來的話,頁表的開銷還是非常大的。而且,絕大部分應用程式可能只能用到頁表中的幾項,其他的白白浪費了。

為了解決這個問題,作業系統引入了 多級頁表 ,多級頁表對應多個頁表,每個頁表也前一個頁表相關聯。32 位系統一般為二級頁表,64 位系統一般為四級頁表。

這裡以二級頁表為例進行介紹:二級列表分為一級頁表和二級頁表。一級頁表共有 1024 個頁表項,一級頁表又關聯二級頁表,二級頁表同樣共有 1024 個頁表項。二級頁表中的一級頁表項是一對多的關係,二級頁表按需載入(只會用到很少一部分二級頁表),進而節省空間佔用。

假設只需要 2 個二級頁表,那兩級頁表的記憶體佔用情況為: 4KB(一級頁表佔用) + 4KB * 2(二級頁表佔用) = 12 KB。

多級頁表

多級頁表屬於時間換空間的典型場景,利用增加頁表查詢的次數減少頁表佔用的空間。

TLB 有什麼用?使用 TLB 之後的地址翻譯流程是怎樣的?

為了提高虛擬地址到實體地址的轉換速度,作業系統在 頁表方案 基礎之上引入了 **轉址旁路快取(Translation Lookasjde Buffer,TLB,也被稱為快表) ** 。

加入 TLB 之後的地址翻譯

在主流的 AArch64 和 x86-64 體系結構下,TLB 屬於 (Memory Management Unit,記憶體管理單元) 內部的單元,本質上就是一塊快取記憶體(Cache),快取了虛擬頁號到物理頁號的對映關係,你可以將其簡單看作是儲存著鍵(虛擬頁號)值(物理頁號)對的雜湊表。

使用 TLB 之後的地址翻譯流程是這樣的:

  1. 用虛擬地址中的虛擬頁號作為 key 去 TLB 中查詢;
  2. 如果能查到對應的物理頁的話,就不用再查詢頁表了,這種情況稱為 TLB 命中(TLB hit)。
  3. 如果不能查到對應的物理頁的話,還是需要去查詢主存中的頁表,同時將頁表中的該對映表項新增到 TLB 中,這種情況稱為 TLB 未命中(TLB miss)。
  4. 當 TLB 填滿後,又要登記新頁時,就按照一定的淘汰策略淘汰掉快表中的一個頁。

使用 TLB 之後的地址翻譯流程

由於頁表也在主存中,因此在沒有 TLB 之前,每次讀寫記憶體資料時 CPU 要訪問兩次主存。有了 TLB 之後,對於存在於 TLB 中的頁表資料只需要訪問一次主存即可。

TLB 的設計思想非常簡單,但命中率往往非常高,效果很好。這就是因為被頻繁訪問的頁就是其中的很小一部分。

看完了之後你會發現快表和我們平時經常在開發系統中使用的快取(比如 Redis)很像,的確是這樣的,作業系統中的很多思想、很多經典的演算法,你都可以在我們日常開發使用的各種工具或者框架中找到它們的影子。

換頁機制有什麼用?

換頁機制的思想是當實體記憶體不夠用的時候,作業系統選擇將一些物理頁的內容放到磁碟上去,等要用到的時候再將它們讀取到實體記憶體中。也就是說,換頁機制利用磁碟這種較低廉的儲存裝置擴充套件的實體記憶體。

這也就解釋了一個日常使用電腦常見的問題:為什麼作業系統中所有程式執行所需的實體記憶體即使比真實的實體記憶體要大一些,這些程式也是可以正常執行的,只是執行速度會變慢。

這同樣是一種時間換空間的策略,你用 CPU 的計算時間,頁的調入調出花費的時間,換來了一個虛擬的更大的實體記憶體空間來支援程式的執行。

什麼是頁缺失?

根據維基百科:

頁缺失(Page Fault,又名硬錯誤、硬中斷、分頁錯誤、尋頁缺失、缺頁中斷、頁故障等)指的是當軟體試圖訪問已對映在虛擬地址空間中,但是目前並未被載入在實體記憶體中的一個分頁時,由 MMU 所發出的中斷。

常見的頁缺失有下面這兩種:

  • 硬性頁缺失(Hard Page Fault) :實體記憶體中沒有對應的物理頁。於是,Page Fault Hander 會指示 CPU 從已經開啟的磁碟檔案中讀取相應的內容到實體記憶體,而後交由 MMU 建立相應的虛擬頁和物理頁的對映關係。
  • 軟性頁缺失(Soft Page Fault):實體記憶體中有對應的物理頁,但虛擬頁還未和物理頁建立對映。於是,Page Fault Hander 會指示 MMU 建立相應的虛擬頁和物理頁的對映關係。

發生上面這兩種缺頁錯誤的時候,應用程式訪問的是有效的實體記憶體,只是出現了物理頁缺失或者虛擬頁和物理頁的對映關係未建立的問題。如果應用程式訪問的是無效的實體記憶體的話,還會出現 無效缺頁錯誤(Invalid Page Fault)

常見的頁面置換演算法有哪些?

當發生硬性頁缺失時,如果實體記憶體中沒有空閒的物理頁面可用的話。作業系統就必須將實體記憶體中的一個物理頁淘汰出去,這樣就可以騰出空間來載入新的頁面了。

用來選擇淘汰哪一個物理頁的規則叫做 頁面置換演算法 ,我們可以把頁面置換演算法看成是淘汰物物理頁的規則。

頁缺失太頻繁的發生會非常影響效能,一個好的頁面置換演算法應該是可以減少頁缺失出現的次數。

常見的頁面置換演算法有下面這 5 種(其他還有很多頁面置換演算法都是基於這些演算法改進得來的):

常見的頁面置換演算法

  1. 最佳頁面置換演算法(OPT,Optimal) :優先選擇淘汰的頁面是以後永不使用的,或者是在最長時間內不再被訪問的頁面,這樣可以保證獲得最低的缺頁率。但由於人們目前無法預知程式在記憶體下的若干頁面中哪個是未來最長時間內不再被訪問的,因而該演算法無法實現,只是理論最優的頁面置換演算法,可以作為衡量其他置換演算法優劣的標準。
  2. 先進先出頁面置換演算法(FIFO,First In First Out) : 最簡單的一種頁面置換演算法,總是淘汰最先進入記憶體的頁面,即選擇在記憶體中駐留時間最久的頁面進行淘汰。該演算法易於實現和理解,一般只需要透過一個 FIFO 佇列即可需求。不過,它的效能並不是很好。
  3. 最近最久未使用頁面置換演算法(LRU ,Least Recently Used) :LRU 演算法賦予每個頁面一個訪問欄位,用來記錄一個頁面自上次被訪問以來所經歷的時間 T,當須淘汰一個頁面時,選擇現有頁面中其 T 值最大的,即最近最久未使用的頁面予以淘汰。LRU 演算法是根據各頁之前的訪問情況來實現,因此是易於實現的。OPT 演算法是根據各頁未來的訪問情況來實現,因此是不可實現的。
  4. 最少使用頁面置換演算法(LFU,Least Frequently Used) : 和 LRU 演算法比較像,不過該置換演算法選擇的是之前一段時間內使用最少的頁面作為淘汰頁。
  5. 時鐘頁面置換演算法(Clock) :可以認為是一種最近未使用演算法,即逐出的頁面都是最近沒有使用的那個。

FIFO 頁面置換演算法效能為何不好?

主要原因主要有二:

  1. 經常訪問或者需要長期存在的頁面會被頻繁調入調出 :較早調入的頁往往是經常被訪問或者需要長期存在的頁,這些頁會被反覆調入和調出。
  2. 存在 Belady 現象 :被置換的頁面並不是程式不會訪問的,有時就會出現分配的頁面數增多但缺頁率反而提高的異常現象。出現該異常的原因是因為 FIFO 演算法只考慮了頁面進入記憶體的順序,而沒有考慮頁面訪問的頻率和緊迫性。

哪一種頁面置換演算法實際用的比較多?

LRU 演算法是實際使用中應用的比較多,也被認為是最接近 OPT 的頁面置換演算法。

不過,需要注意的是,實際應用中這些演算法會被做一些改進,就比如 InnoDB Buffer Pool( InnoDB 緩衝池,MySQL 資料庫中用於管理快取頁面的機制)就改進了傳統的 LRU 演算法,使用了一種稱為"Adaptive LRU"的演算法(同時結合了 LRU 和 LFU 演算法的思想)。

分頁機制和分段機制有哪些共同點和區別?

共同點

  • 都是非連續記憶體管理的方式。
  • 都採用了地址對映的方法,將虛擬地址對映到實體地址,以實現對記憶體的管理和保護。

區別

  • 分頁機制以頁面為單位進行記憶體管理,而分段機制以段為單位進行記憶體管理。頁的大小是固定的,由作業系統決定,通常為 2 的冪次方。而段的大小不固定,取決於我們當前執行的程式。
  • 頁是物理單位,即作業系統將實體記憶體劃分成固定大小的頁面,每個頁面的大小通常是 2 的冪次方,例如 4KB、8KB 等等。而段則是邏輯單位,是為了滿足程式對記憶體空間的邏輯需求而設計的,通常根據程式中資料和程式碼的邏輯結構來劃分。
  • 分段機制容易出現外部記憶體碎片,即在段與段之間留下碎片空間(不足以對映給虛擬地址空間中的段)。分頁機制解決了外部記憶體碎片的問題,但仍然可能會出現內部記憶體碎片。
  • 分頁機制採用了頁表來完成虛擬地址到實體地址的對映,頁表透過一級頁表和二級頁表來實現多級對映;而分段機制則採用了段表來完成虛擬地址到實體地址的對映,每個段表項中記錄了該段的起始地址和長度資訊。
  • 分頁機制對程式沒有任何要求,程式只需要按照虛擬地址進行訪問即可;而分段機制需要程式設計師將程式分為多個段,並且顯式地使用段暫存器來訪問不同的段。

段頁機制

結合了段式管理和頁式管理的一種記憶體管理機制,把實體記憶體先分成若干段,每個段又繼續分成若干大小相等的頁。

在段頁式機制下,地址翻譯的過程分為兩個步驟:

  1. 段式地址對映。
  2. 頁式地址對映。

區域性性原理

要想更好地理解虛擬記憶體技術,必須要知道計算機中著名的 區域性性原理(Locality Principle)。另外,區域性性原理既適用於程式結構,也適用於資料結構,是非常重要的一個概念。

區域性性原理是指在程式執行過程中,資料和指令的訪問存在一定的空間和時間上的區域性性特點。其中,時間區域性性是指一個資料項或指令在一段時間內被反覆使用的特點,空間區域性性是指一個資料項或指令在一段時間內與其相鄰的資料項或指令被反覆使用的特點。

在分頁機制中,頁表的作用是將虛擬地址轉換為實體地址,從而完成記憶體訪問。在這個過程中,區域性性原理的作用體現在兩個方面:

  • 時間區域性性 :由於程式中存在一定的迴圈或者重複操作,因此會反覆訪問同一個頁或一些特定的頁,這就體現了時間區域性性的特點。為了利用時間區域性性,分頁機制中通常採用快取機制來提高頁面的命中率,即將最近訪問過的一些頁放入快取中,如果下一次訪問的頁已經在快取中,就不需要再次訪問記憶體,而是直接從快取中讀取。
  • 空間區域性性 :由於程式中資料和指令的訪問通常是具有一定的空間連續性的,因此當訪問某個頁時,往往會順帶訪問其相鄰的一些頁。為了利用空間區域性性,分頁機制中通常採用預取技術來預先將相鄰的一些頁讀入記憶體快取中,以便在未來訪問時能夠直接使用,從而提高訪問速度。

總之,區域性性原理是計算機體系結構設計的重要原則之一,也是許多最佳化演算法的基礎。在分頁機制中,利用時間區域性性和空間區域性性,採用快取和預取技術,可以提高頁面的命中率,從而提高記憶體訪問效率

檔案系統

檔案系統主要做了什麼?

檔案系統主要負責管理和組織計算機儲存裝置上的檔案和目錄,其功能包括以下幾個方面:

  1. 儲存管理 :將檔案資料儲存到物理儲存介質中,並且管理空間分配,以確保每個檔案都有足夠的空間儲存,並避免檔案之間發生衝突。
  2. 檔案管理 :檔案的建立、刪除、移動、重新命名、壓縮、加密、共享等等。
  3. 目錄管理 :目錄的建立、刪除、移動、重新命名等等。
  4. 檔案訪問控制 :管理不同使用者或程式對檔案的訪問許可權,以確保使用者只能訪問其被授權訪問的檔案,以保證檔案的安全性和保密性。

硬連結和軟連結有什麼區別?

在 Linux/類 Unix 系統上,檔案連結(File Link)是一種特殊的檔案型別,可以在檔案系統中指向另一個檔案。常見的檔案連結型別有兩種:

1、硬連結(Hard Link)

  • 在 Linux/類 Unix 檔案系統中,每個檔案和目錄都有一個唯一的索引節點(inode)號,用來標識該檔案或目錄。硬連結透過 inode 節點號建立連線,硬連結和原始檔的 inode 節點號相同,兩者對檔案系統來說是完全平等的(可以看作是互為硬連結,源頭是同一份檔案),刪除其中任何一個對另外一個沒有影響,可以透過給檔案設定硬連結檔案來防止重要檔案被誤刪。
  • 只有刪除了原始檔和所有對應的硬連結檔案,該檔案才會被真正刪除。
  • 硬連結具有一些限制,不能對目錄以及不存在的檔案建立硬連結,並且,硬連結也不能跨越檔案系統。
  • ln 命令用於建立硬連結。

2、軟連結(Symbolic Link 或 Symlink)

  • 軟連結和原始檔的 inode 節點號不同,而是指向一個檔案路徑。
  • 原始檔刪除後,硬連結依然存在,但是指向的是一個無效的檔案路徑。
  • 軟連線類似於 Windows 系統中的快捷方式。
  • 不同於硬連結,可以對目錄或者不存在的檔案建立軟連結,並且,軟連結可以跨越檔案系統。
  • ln -s 命令用於建立軟連結。

硬連結為什麼不能跨檔案系統?

我們之前提到過,硬連結是透過 inode 節點號建立連線的,而硬連結和原始檔共享相同的 inode 節點號。

然而,每個檔案系統都有自己的獨立 inode 表,且每個 inode 表只維護該檔案系統內的 inode。如果在不同的檔案系統之間建立硬連結,可能會導致 inode 節點號衝突的問題,即目標檔案的 inode 節點號已經在該檔案系統中被使用。

提高檔案系統效能的方式有哪些?

  • 最佳化硬體 :使用高速硬體裝置(如 SSD、NVMe)替代傳統的機械硬碟,使用 RAID(Redundant Array of Inexpensive Disks)等技術提高磁碟效能。
  • 選擇合適的檔案系統選型 :不同的檔案系統具有不同的特性,對於不同的應用場景選擇合適的檔案系統可以提高系統效能。
  • 運用快取 :訪問磁碟的效率比較低,可以運用快取來減少磁碟的訪問次數。不過,需要注意快取命中率,快取命中率過低的話,效果太差。
  • 避免磁碟過度使用 :注意磁碟的使用率,避免將磁碟用滿,儘量留一些剩餘空間,以免對檔案系統的效能產生負面影響。
  • 對磁碟進行合理的分割槽 :合理的磁碟分割槽方案,能夠使檔案系統在不同的區域儲存檔案,從而減少檔案碎片,提高檔案讀寫效能。

常見的磁碟排程演算法有哪些?

磁碟排程演算法是作業系統中對磁碟訪問請求進行排序和排程的演算法,其目的是提高磁碟的訪問效率。

一次磁碟讀寫操作的時間由磁碟尋道/尋找時間、延遲時間和傳輸時間決定。磁碟排程演算法可以透過改變到達磁碟請求的處理順序,減少磁碟尋道時間和延遲時間。

常見的磁碟排程演算法有下面這 6 種(其他還有很多磁碟排程演算法都是基於這些演算法改進得來的):

常見的磁碟排程演算法

  1. 先來先服務演算法(First-Come First-Served,FCFS) :按照請求到達磁碟排程器的順序進行處理,先到達的請求的先被服務。FCFS 演算法實現起來比較簡單,不存在演算法開銷。不過,由於沒有考慮磁頭移動的路徑和方向,平均尋道時間較長。同時,該演算法容易出現飢餓問題,即一些後到的磁碟請求可能需要等待很長時間才能得到服務。
  2. 最短尋道時間優先演算法(Shortest Seek Time First,SSTF) :也被稱為最佳服務優先(Shortest Service Time First,SSTF)演算法,優先選擇距離當前磁頭位置最近的請求進行服務。SSTF 演算法能夠最小化磁頭的尋道時間,但容易出現飢餓問題,即磁頭附近的請求不斷被服務,遠離磁頭的請求長時間得不到響應。實際應用中,需要最佳化一下該演算法的實現,避免出現飢餓問題。
  3. 掃描演算法(SCAN) :也被稱為電梯(Elevator)演算法,基本思想和電梯非常類似。磁頭沿著一個方向掃描磁碟,如果經過的磁軌有請求就處理,直到到達磁碟的邊界,然後改變移動方向,依此往復。SCAN 演算法能夠保證所有的請求得到服務,解決了飢餓問題。但是,如果磁頭從一個方向剛掃描完,請求才到的話。這個請求就需要等到磁頭從相反方向過來之後才能得到處理。
  4. 迴圈掃描演算法(Circular Scan,C-SCAN) :SCAN 演算法的變體,只在磁碟的一側進行掃描,並且只按照一個方向掃描,直到到達磁碟邊界,然後回到磁碟起點,重新開始迴圈。
  5. 邊掃描邊觀察演算法(LOOK) :SCAN 演算法中磁頭到了磁碟的邊界才改變移動方向,這樣可能會做很多無用功,因為磁頭移動方向上可能已經沒有請求需要處理了。LOOK 演算法對 SCAN 演算法進行了改進,如果磁頭移動方向上已經沒有別的請求,就可以立即改變磁頭移動方向,依此往復。也就是邊掃描邊觀察指定方向上還有無請求,因此叫 LOOK。
  6. 均衡迴圈掃描演算法(C-LOOK) :C-SCAN 只有到達磁碟邊界時才能改變磁頭移動方向,並且磁頭返回時也需要返回到磁碟起點,這樣可能會做很多無用功。C-LOOK 演算法對 C-SCAN 演算法進行了改進,如果磁頭移動的方向上已經沒有磁軌訪問請求了,就可以立即讓磁頭返回,並且磁頭只需要返回到有磁軌訪問請求的位置即可。

參考

相關文章