C++秋招高頻面試題匯總:C++多態(tài)、malloc函數(shù)、內(nèi)存管理、并發(fā)編程、數(shù)據(jù)庫
在競爭激烈的 C++ 秋招戰(zhàn)場上,扎實掌握核心知識、精準(zhǔn)應(yīng)對面試提問是脫穎而出的關(guān)鍵。今天,為大家精心匯總 C++ 秋招中的高頻面試題,聚焦多態(tài)、malloc、內(nèi)存管理、并發(fā)編程、數(shù)據(jù)庫這些重點領(lǐng)域。多態(tài)作為 C++ 面向?qū)ο缶幊痰暮诵奶匦裕尨a更具靈活性與擴(kuò)展性,面試官常圍繞其原理、實現(xiàn)方式深入考查。比如,動態(tài)多態(tài)如何借助虛函數(shù)與運行時綁定來達(dá)成,不同類型的多態(tài)(像函數(shù)重載體現(xiàn)的靜態(tài)多態(tài))在實際場景中怎樣運用。malloc 作為 C 語言就有的動態(tài)內(nèi)存分配函數(shù),在 C++ 開發(fā)里也常用到。理解它與 C++ 專屬的 new 操作符的差異,掌握其內(nèi)存分配機(jī)制、失敗處理方式,是應(yīng)對面試的必備技能。
內(nèi)存管理向來是 C++ 開發(fā)的重難點,內(nèi)存泄漏、懸空指針等問題時刻考驗開發(fā)者功底。智能指針如何借助 RAII 機(jī)制實現(xiàn)自動內(nèi)存管理,堆和棧內(nèi)存的區(qū)別及使用場景,都可能在面試中被問到。并發(fā)編程隨著多核處理器普及愈發(fā)重要。線程與進(jìn)程的區(qū)別、線程同步方式(如互斥鎖、條件變量的運用)、死鎖的成因與預(yù)防,都是面試官愛考察的方向。數(shù)據(jù)庫方面,無論是 MySQL 等關(guān)系型數(shù)據(jù)庫的索引原理、事務(wù)特性,還是 Redis 這類非關(guān)系型數(shù)據(jù)庫的應(yīng)用場景、數(shù)據(jù)結(jié)構(gòu),都可能出現(xiàn)在面試題中。接下來,就讓我們深入剖析這些高頻面試題,為你的秋招之路添磚加瓦,助力你斬獲心儀 offer !
Part1.C++多態(tài):讓代碼 “七十二變”
1.1 C++多態(tài)
多態(tài),是面向?qū)ο缶幊讨械年P(guān)鍵概念,簡單來說,就是 “同一接口,不同實現(xiàn)” ,一個事物可以有多種形態(tài)。就好比生活中的交通工具,都有 “行駛” 這個接口,但汽車、自行車、飛機(jī)的行駛方式截然不同。在編程世界里,多態(tài)允許不同類的對象對同一消息做出不同響應(yīng)。
假設(shè)我們有一個圖形繪制程序,定義一個基類Shape,其中包含一個draw方法用于繪制圖形。然后派生出Circle(圓形)和Rectangle(矩形)類,它們都重寫了draw方法,以各自的方式實現(xiàn)圖形繪制。當(dāng)我們使用Shape類型的變量來引用Circle或Rectangle對象時,調(diào)用draw方法就會根據(jù)實際對象的類型,繪制出圓形或矩形。這就是多態(tài)的魅力,它讓代碼更加靈活和可擴(kuò)展,無需大量的條件判斷語句。
在 C++ 中,多態(tài)通過虛函數(shù)和動態(tài)綁定來實現(xiàn);Java 則依靠方法重寫和運行時動態(tài)綁定機(jī)制達(dá)成多態(tài)效果。多態(tài)在框架設(shè)計、插件系統(tǒng)、策略模式等場景中廣泛應(yīng)用,是構(gòu)建靈活可擴(kuò)展系統(tǒng)的重要基石。
具體來說,多態(tài)的實現(xiàn)涉及以下幾個關(guān)鍵點:
- 虛函數(shù):在基類中使用virtual關(guān)鍵字聲明的函數(shù)稱為虛函數(shù)。這些函數(shù)可以在派生類中被重寫。
- 虛函數(shù)表(vtable):編譯器為包含虛函數(shù)的類生成一個虛函數(shù)表,表中存儲了該類所有虛函數(shù)的地址。
- 動態(tài)綁定:在運行時,根據(jù)對象的實際類型從虛函數(shù)表中查找并調(diào)用相應(yīng)的函數(shù)。
- 動態(tài)聯(lián)編:通過基類指針或引用調(diào)用虛函數(shù)時,編譯時不確定具體調(diào)用哪個實現(xiàn),而是在運行時根據(jù)對象的實際類型確定。
通過這種方式,多態(tài)允許我們編寫與特定實現(xiàn)無關(guān)的代碼,提高了代碼的靈活性和可維護(hù)性。同時,這也帶來了額外的空間和時間開銷,因為每個有虛函數(shù)的對象都需要額外的空間來存儲vptr,并且在運行時需要進(jìn)行虛函數(shù)表的查找。盡管如此,多態(tài)帶來的好處通常超過了這些額外的開銷,使得它在許多情況下成為首選的設(shè)計模式。
1.2 多態(tài)如何解決代碼復(fù)用難題
在軟件開發(fā)中,代碼復(fù)用是提高開發(fā)效率、降低維護(hù)成本的關(guān)鍵。然而,在沒有多態(tài)的情況下,實現(xiàn)代碼復(fù)用往往面臨諸多挑戰(zhàn)。比如,我們要開發(fā)一個圖形繪制系統(tǒng),其中包含圓形、矩形和三角形等多種圖形。如果不使用多態(tài),那么為了繪制這些不同的圖形,我們可能需要編寫大量重復(fù)的代碼。
class Circle {
public:
void drawCircle() {
// 繪制圓形的具體代碼
std::cout << "Drawing a circle" << std::endl;
}
};
class Rectangle {
public:
void drawRectangle() {
// 繪制矩形的具體代碼
std::cout << "Drawing a rectangle" << std::endl;
}
};
class Triangle {
public:
void drawTriangle() {
// 繪制三角形的具體代碼
std::cout << "Drawing a triangle" << std::endl;
}
};
int main() {
Circle circle;
Rectangle rectangle;
Triangle triangle;
circle.drawCircle();
rectangle.drawRectangle();
triangle.drawTriangle();
return 0;
}在這段代碼中,每個圖形類都有自己獨立的繪制函數(shù),當(dāng)我們需要繪制不同的圖形時,需要分別調(diào)用不同的函數(shù)。如果后續(xù)要添加新的圖形,比如梯形,就需要再次編寫新的繪制函數(shù),并且在使用時也需要額外添加調(diào)用邏輯,代碼的擴(kuò)展性和復(fù)用性都很差 。
而當(dāng)我們引入多態(tài)后,情況就大不相同了。我們可以定義一個基類,比如Shape,在其中聲明一個虛函數(shù)draw,然后讓各個圖形類繼承自Shape類,并重寫draw函數(shù)。
class Shape {
public:
virtual void draw() = 0; // 純虛函數(shù),使Shape成為抽象類
};
class Circle : public Shape {
public:
void draw() override {
// 繪制圓形的具體代碼
std::cout << "Drawing a circle" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() override {
// 繪制矩形的具體代碼
std::cout << "Drawing a rectangle" << std::endl;
}
};
class Triangle : public Shape {
public:
void draw() override {
// 繪制三角形的具體代碼
std::cout << "Drawing a triangle" << std::endl;
}
};
void drawShapes(Shape* shapes[], int count) {
for (int i = 0; i < count; ++i) {
shapes[i]->draw();
}
}
int main() {
Circle circle;
Rectangle rectangle;
Triangle triangle;
Shape* shapes[] = {&circle, &rectangle, &triangle};
int count = sizeof(shapes) / sizeof(shapes[0]);
drawShapes(shapes, count);
return 0;
}在這個改進(jìn)后的代碼中,drawShapes函數(shù)可以接受一個Shape類型的指針數(shù)組,無論數(shù)組中的元素是指向Circle、Rectangle還是Triangle對象,都可以通過調(diào)用draw函數(shù)來實現(xiàn)正確的繪制。這樣,當(dāng)我們需要添加新的圖形時,只需要創(chuàng)建一個新的派生類并重寫draw函數(shù),而drawShapes函數(shù)的代碼無需修改,大大提高了代碼的復(fù)用性和可擴(kuò)展性。
1.3 多態(tài)的實現(xiàn)原理
C++實現(xiàn)多態(tài)的主要方式有:
(1)重載(Overloading):通過函數(shù)名相同但參數(shù)不同的多個函數(shù)實現(xiàn)不同行為。在編譯時通過參數(shù)類型決定調(diào)用哪個函數(shù)。
void add(int a, int b) { ... }
void add(double a, double b) { ... }(2)重寫(Overriding):通過繼承讓派生類重新實現(xiàn)基類的虛函數(shù)。在運行時通過指針/引用的實際類型調(diào)用對應(yīng)的函數(shù)。
class Base {
public:
virtual void func() { ... }
};
class Derived extends Base {
public:
virtual void func() { ... }
};
Base* b = new Derived();
b->func(); // Calls Derived::func()(3)編譯時多態(tài):通過模板和泛型實現(xiàn)針對不同類型具有不同實現(xiàn)的函數(shù)。在編譯時通過傳入類型決定具體實現(xiàn)。
template <typename T>
void func(T t) { ... }
func(1); // Calls func<int>
func(3.2); // Calls func<double>(4)條件編譯:通過#ifdef/#elif等預(yù)處理命令針對不同條件編譯不同的代碼實現(xiàn)產(chǎn)生不同行為的程序。編譯時通過定義的宏決定具體實現(xiàn)
#ifdef _WIN32
void func() { ... } // Windows version
#elif __linux__
void func() { ... } // Linux version
#endif綜上,C++通過重載、重寫、模板、條件編譯等手段實現(xiàn)多態(tài)。其中,重寫基于繼承和虛函數(shù)實現(xiàn)真正的運行時多態(tài),增強了程序的靈活性和可擴(kuò)展性。
一個接口,多種方法:
- 用virtual關(guān)鍵字申明的函數(shù)叫做虛函數(shù),虛函數(shù)肯定是類的成員函數(shù)。
- 存在虛函數(shù)的類都有一個一維的虛函數(shù)表叫做虛表。當(dāng)類中聲明虛函數(shù)時,編譯器會在類中生成一個虛函數(shù)表。
- 類的對象有一個指向虛表開始的虛指針。虛表是和類對應(yīng)的,虛表指針是和對象對應(yīng)的。
- 虛函數(shù)表是一個存儲類成員函數(shù)指針的數(shù)據(jù)結(jié)構(gòu)。
- 虛函數(shù)表是由編譯器自動生成與維護(hù)的。
- virtual成員函數(shù)會被編譯器放入虛函數(shù)表中。
- 當(dāng)存在虛函數(shù)時,每個對象中都有一個指向虛函數(shù)的指針(C++編譯器給父類對象,子類對象提前布局vptr指針),當(dāng)進(jìn)行test(parent *base)函數(shù)的時候,C++編譯器不需要區(qū)分子類或者父類對象,只需要在base指針中,找到vptr指針即可)。
- vptr一般作為類對象的第一個成員。
Part2.malloc:動態(tài)內(nèi)存分配的魔法棒
2.1 malloc技術(shù)
在 C 和 C++ 編程中,malloc(memory allocation,動態(tài)內(nèi)存分配)是一個非常重要的函數(shù),它能在程序運行時根據(jù)需要動態(tài)分配內(nèi)存。想象一下,你正在搭建一座積木城堡,一開始不知道需要多少積木,malloc就像是一個神奇的袋子,能隨時根據(jù)你的需求提供積木(內(nèi)存)。
malloc函數(shù)的原型是void* malloc(size_t size),它接受一個參數(shù)size,表示需要分配的內(nèi)存字節(jié)數(shù)。返回值是一個void*類型的指針,指向分配的內(nèi)存塊起始地址,如果分配失敗則返回NULL 。例如,我們要動態(tài)分配一個能存儲 10 個整數(shù)的數(shù)組,可以這樣寫:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *arr;
// 分配10個整數(shù)大小的內(nèi)存空間
arr = (int*)malloc(10 * sizeof(int));
if (arr == NULL) {
printf("內(nèi)存分配失敗\n");
return 1;
}
// 使用數(shù)組
for (int i = 0; i < 10; i++) {
arr[i] = i;
}
// 輸出數(shù)組元素
for (int i = 0; i < 10; i++) {
printf("%d ", arr[i]);
}
// 釋放內(nèi)存
free(arr);
return 0;
}在使用malloc時,有幾個關(guān)鍵的注意事項。首先,一定要檢查返回值是否為NULL,以確保內(nèi)存分配成功,否則對空指針進(jìn)行操作會導(dǎo)致程序崩潰。其次,當(dāng)不再需要分配的內(nèi)存時,必須使用free函數(shù)釋放內(nèi)存,否則會造成內(nèi)存泄漏,就像你借了圖書館的書卻不歸還,導(dǎo)致后續(xù)其他人無法借閱(內(nèi)存無法被重新利用)。并且,free只能釋放由malloc、calloc、realloc分配的內(nèi)存,不能釋放其他類型的內(nèi)存。同時,釋放內(nèi)存后,應(yīng)將指針置為NULL,防止成為野指針,避免不小心再次訪問已釋放的內(nèi)存區(qū)域。
與其他內(nèi)存分配方式相比,malloc屬于動態(tài)內(nèi)存分配,與靜態(tài)內(nèi)存分配(如在函數(shù)內(nèi)部定義的局部變量)不同,靜態(tài)內(nèi)存的生命周期在函數(shù)結(jié)束時就結(jié)束了,而動態(tài)分配的內(nèi)存只要不調(diào)用free,就會一直存在,這使得它在需要靈活管理內(nèi)存的場景中非常有用。和 C++ 中的new操作符相比,malloc只是分配內(nèi)存,不會調(diào)用對象的構(gòu)造函數(shù)進(jìn)行初始化,而new會調(diào)用構(gòu)造函數(shù);malloc分配失敗返回NULL,new則是拋出異常。
2.2 Malloc函數(shù)
(1)數(shù)據(jù)結(jié)構(gòu)
首先我們要確定所采用的數(shù)據(jù)結(jié)構(gòu)。一個簡單可行方案是將堆內(nèi)存空間以塊的形式組織起來,每個塊由meta區(qū)和數(shù)據(jù)區(qū)組成,meta區(qū)記錄數(shù)據(jù)塊的元信息(數(shù)據(jù)區(qū)大小、空閑標(biāo)志位、指針等等),數(shù)據(jù)區(qū)是真實分配的內(nèi)存區(qū)域,并且數(shù)據(jù)區(qū)的第一個字節(jié)地址即為malloc返回的地址,可以使用如下結(jié)構(gòu)體定義一個block
typedef struct s_block *t_block;
struck s_block{
size_t size;//數(shù)據(jù)區(qū)大小
t_block next;//指向下個塊的指針
int free;//是否是空閑塊
int padding;//填充4字節(jié),保證meta塊長度為8的倍數(shù)
char data[1];//這是一個虛擬字段,表示數(shù)據(jù)塊的第一個字節(jié),長度不應(yīng)計入meta
};(2)尋找合適的block
現(xiàn)在考慮如何在block鏈中查找合適的block。一般來說有兩種查找算法:First fit:從頭開始,使用第一個數(shù)據(jù)區(qū)大小大于要求size的塊所謂此次分配的塊 Best fit:從頭開始,遍歷所有塊,使用數(shù)據(jù)區(qū)大小大于size且差值最小的塊作為此次分配的塊 兩種方式各有千秋,best fit有較高的內(nèi)存使用率(payload較高),而first fit具有較高的運行效率。這里我們采用first fit算法
t_block find_block(t_block *last,size_t size){
t_block b = first_block;
while(b&&b->size>=size)
{
*last = b;
b = b->next;
}
return b;
}find_block從first_block開始,查找第一個符合要求的block并返回block起始地址,如果找不到這返回NULL,這里在遍歷時會更新一個叫l(wèi)ast的指針,這個指針始終指向當(dāng)前遍歷的block.這是為了如果找不到合適的block而開辟新block使用的。
(3)開辟新的block
如果現(xiàn)有block都不能滿足size的要求,則需要在鏈表最后開辟一個新的block。這里關(guān)鍵是如何只使用sbrk創(chuàng)建一個struct:
#define BLOCK_SIZE 24
t_block extend_heap{
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE+s)==(void*)-1)
return NULL;
b->size = s;
b->next - NULL;
if(last)
last->next = b;
b->free = 0;
return b;
};(4)分裂block
First fit有一個比較致命的缺點,就是可能會讓更小的size占據(jù)很大的一塊block,此時,為了提高payload,應(yīng)該在剩余數(shù)據(jù)區(qū)足夠大的情況下,將其分裂為一個新的block:
void split_block(t_block b,size_t s)
{
t_block new;
new = b->data;
new->size = b->size-s-BLOCK_SIZE;
new->next = b->next;
new ->free = 1;
b->size = s;
b->next = new;
}2.3 Malloc函數(shù)的實現(xiàn)原理
(1)空閑鏈表機(jī)制
在malloc函數(shù)背后,有著依靠空閑鏈表來管理內(nèi)存的一套機(jī)制。當(dāng)我們調(diào)用malloc函數(shù)時,它會沿著空閑鏈表去查找滿足用戶請求大小的內(nèi)存塊。比如說,鏈表上有多個不同大小的空閑內(nèi)存塊,它就會依次遍歷這些塊來找到合適的那一個。
找到合適的內(nèi)存塊后,如果這個內(nèi)存塊比用戶請求的大小要大,那么就會按需將其分割成兩部分,一部分的大小剛好與用戶請求的大小相等,這部分就會分配給用戶使用,而剩下的那部分則會被放回空閑鏈表中,等待后續(xù)其他的內(nèi)存分配請求再進(jìn)行分配。例如,空閑鏈表中有一個 50 字節(jié)的空閑塊,而用戶請求分配 20 字節(jié)的內(nèi)存,這時malloc就會把這個 50 字節(jié)的塊分成 20 字節(jié)(分配給用戶)和 30 字節(jié)(放回空閑鏈表)兩塊。
而當(dāng)我們使用free函數(shù)釋放內(nèi)存時,相應(yīng)被釋放的內(nèi)存塊又會被重新連接到空閑鏈上。這樣,整個空閑鏈表就處于一個動態(tài)變化的過程,不斷地有內(nèi)存塊被分配出去,也不斷地有釋放的內(nèi)存塊回歸鏈表,以實現(xiàn)內(nèi)存的循環(huán)利用,避免浪費。不過,隨著程序不斷地分配和釋放內(nèi)存,空閑鏈有可能會被切成很多的小內(nèi)存片段,要是后續(xù)用戶申請一個較大的內(nèi)存片段時,空閑鏈上可能暫時沒有可以滿足要求的片段了,這時malloc函數(shù)可能就需要進(jìn)行一些整理操作,比如對這些小的空閑塊嘗試合并等,以便能滿足較大內(nèi)存請求的情況。
(2)虛擬內(nèi)存地址和物理內(nèi)存地址
為了簡單,現(xiàn)代操作系統(tǒng)在處理物理內(nèi)存地址時,普遍采用虛擬內(nèi)存地址技術(shù)。即在匯編程序?qū)用妫?dāng)涉及內(nèi)存地址時,都是使用的虛擬內(nèi)存地址。采用這種技術(shù)時,每個進(jìn)程仿佛自己獨享一片2N字節(jié)的內(nèi)存,其中N是機(jī)器位數(shù)。例如在64位CPU和64位操作系統(tǒng)下每個進(jìn)程的虛擬地址空間為264Byte。
這種虛擬地址空間的作用主要是簡化程序的編寫及方便操作系統(tǒng)對進(jìn)程間內(nèi)存的隔離管理,真實中的進(jìn)程不太可能如此大的空間,實際能用到的空間大小取決于物理內(nèi)存的大小。由于在機(jī)器語言層面都是采用虛擬地址,當(dāng)實際的機(jī)器碼程序涉及到內(nèi)存操作時,需要根據(jù)當(dāng)前進(jìn)程運行的實際上下文將虛擬地址轉(zhuǎn)化為物理內(nèi)存地址,才能實現(xiàn)對內(nèi)存數(shù)據(jù)的操作。這個轉(zhuǎn)換一般由一個叫MMU的硬件完成。
(3)頁與地址構(gòu)成
在現(xiàn)代操作系統(tǒng)中,不論是虛擬內(nèi)存還是物理內(nèi)存,都不是以字節(jié)為單位進(jìn)行管理的,而是以頁為單位。一個內(nèi)存頁是一段固定大小的連續(xù)的連續(xù)內(nèi)存地址的總稱,具體到Linux中,典型的內(nèi)存頁大小為4096 Byte。所以內(nèi)存地址可以分為頁號和頁內(nèi)偏移量。下面以64位機(jī)器,4G物理內(nèi)存,4K頁大小為例,虛擬內(nèi)存地址和物理內(nèi)存地址的組成如下:
圖片
上面是虛擬內(nèi)存地址,下面是物理內(nèi)存地址。由于頁大小都是4k,所以頁內(nèi)偏移都是用低12位表示,而剩下的高地址表示頁號 MMU映射單位并不是字節(jié),而是頁,這個映射通過差一個常駐內(nèi)存的數(shù)據(jù)結(jié)構(gòu)頁表來實現(xiàn)。現(xiàn)在計算機(jī)具體的內(nèi)存地址映射比較復(fù)雜,為了加快速度會引入一系列緩存和優(yōu)化,例如TLB等機(jī)制,下面給出一個經(jīng)過簡化的內(nèi)存地址翻譯示意圖:
圖片
(4)內(nèi)存頁與磁盤頁
我們知道一般將內(nèi)存看做磁盤的緩存,有時MMU在工作時,會發(fā)現(xiàn)頁表表名某個內(nèi)存頁不在物理內(nèi)存頁不在物理內(nèi)存中,此時會觸發(fā)一個缺頁異常,此時系統(tǒng)會到磁盤中相應(yīng)的地方將磁盤頁載入到內(nèi)存中,然后重新執(zhí)行由于缺頁而失敗的機(jī)器指令。關(guān)于這部分,因為可以看做對malloc實現(xiàn)是透明的,所以不再詳述。
真實地址翻譯流程:
圖片
(5)Linux進(jìn)程級內(nèi)存管理
內(nèi)存排布:明白了虛擬內(nèi)存和物理內(nèi)存的關(guān)系及相關(guān)的映射機(jī)制,下面看一下具體在一個進(jìn)程內(nèi)是如何排布內(nèi)存的。以Linux 64位系統(tǒng)為例。理論上,64bit內(nèi)存地址空間為0x0000000000000000-0xFFFFFFFFFFFFFFF,這是個相當(dāng)龐大的空間,Linux實際上只用了其中一小部分。具體分布如圖所示:
圖片
對用戶來說主要關(guān)心的是User Space。將User Space放大后,可以看到里面主要分成如下幾段:
- Code:這是整個用戶空間的最低地址部分,存放的是指令(也就是程序所編譯成的可執(zhí)行機(jī)器碼) Data:這里存放的是初始化過的全局變量
- BSS:這里存放的是未初始化的全局變量
- Heap:堆,這是我們本文主要關(guān)注的地方,堆自底向上由低地址向高地址增長
- Mapping Area:這里是與mmap系統(tǒng)調(diào)用相關(guān)區(qū)域。大多數(shù)實際的malloc實現(xiàn)會考慮通過mmap分配較大塊的內(nèi)存空間,本文不考慮這種情況,這個區(qū)域由高地址像低地址增長Stack:棧區(qū)域,自高地址像低地址增長 。
- Heap內(nèi)存模型:一般來說,malloc所申請的內(nèi)存主要從Heap區(qū)域分配,來看看Heap的結(jié)構(gòu)是怎樣的。
圖片
Linux維護(hù)一個break指針,這個指針執(zhí)行堆空間的某個地址,從堆開始到break之間的地址空間為映射好的,可以供進(jìn)程訪問,而從break往上,是未映射的地址空間,如果訪問這段空間則程序會報錯。
2.4 malloc函數(shù)的使用注意事項
⑴內(nèi)存分配成功判斷
在使用 malloc 函數(shù)時,一定要進(jìn)行內(nèi)存分配成功與否的判斷哦。因為 malloc 函數(shù)在執(zhí)行后,有可能會由于系統(tǒng)內(nèi)存不足等原因,導(dǎo)致無法按照要求分配出相應(yīng)的內(nèi)存空間。此時,它會返回一個 NULL 指針來表示分配失敗。
例如,我們寫這樣一段代碼:
int *p = (int *)malloc(1000000000 * sizeof(int)); // 嘗試分配非常大的內(nèi)存空間,可能超出系統(tǒng)可分配范圍
if (p == NULL) {
printf("內(nèi)存分配失敗,無法繼續(xù)執(zhí)行后續(xù)操作!\n");
// 在這里可以添加一些應(yīng)對分配失敗的處理邏輯,比如返回錯誤碼、進(jìn)行相應(yīng)提示等
return -1;
}
// 如果分配成功,就可以繼續(xù)使用這塊內(nèi)存,例如進(jìn)行賦值等操作
*p = 10;像這樣通過檢查返回的指針是否為 NULL,就能知道內(nèi)存分配是不是成功啦。要是忽略了這個判斷,后續(xù)還繼續(xù)去使用這個可能為 NULL 的指針,就很容易引發(fā)程序崩潰,比如出現(xiàn)段錯誤等情況呢,所以這一步的判斷千萬不能省略哦。
⑵內(nèi)存釋放操作
使用完 malloc 函數(shù)分配的內(nèi)存后,及時用 free 函數(shù)進(jìn)行釋放是非常重要的操作呀。因為如果一直不釋放這些申請來的內(nèi)存,它們就會始終被占用,久而久之,就容易造成內(nèi)存泄漏問題。內(nèi)存泄漏積累起來,會不斷消耗系統(tǒng)的可用內(nèi)存資源,可能使得程序運行越來越卡頓,嚴(yán)重的話甚至?xí)?dǎo)致整個系統(tǒng)癱瘓呢。
free 函數(shù)的使用方式很簡單,它的原型是 void free(void *FirstByte),參數(shù)就是之前 malloc 分配內(nèi)存時返回的那個指針。舉個簡單的例子來說明一下:
char *str = (char *)malloc(50 * sizeof(char)); // 分配可以存放50個字符的內(nèi)存空間
if (str!= NULL) {
strcpy(str, "Hello World"); // 使用分配的內(nèi)存空間存放字符串
// 一些其他對這塊內(nèi)存的操作......
free(str); // 使用完后,用free函數(shù)釋放內(nèi)存
}這樣就把通過 malloc 申請的內(nèi)存歸還給系統(tǒng)了,讓系統(tǒng)可以把這些內(nèi)存再次分配給其他需要的部分使用哦。
⑶避免野指針問題
當(dāng)我們使用 free 函數(shù)釋放了 malloc 分配的內(nèi)存后,還有一個需要特別注意的點,那就是要避免野指針的出現(xiàn)哦。野指針就是指向了一塊已經(jīng)被釋放的內(nèi)存區(qū)域或者是指向不確定內(nèi)存位置的指針啦。
比如說,有這樣一段代碼:
int *ptr = (int *)malloc(10 * sizeof(int));
if (ptr!= NULL) {
*ptr = 10;
free(ptr);
// 這里如果沒有將ptr置為NULL,ptr就變成了野指針
if (ptr!= NULL) { // 這個判斷其實是無效的哦,可能會意外進(jìn)入
*ptr = 20; // 此時再去對這塊已經(jīng)釋放的內(nèi)存區(qū)域進(jìn)行寫操作,就是錯誤的,可能導(dǎo)致程序出現(xiàn)未定義行為,比如崩潰或者數(shù)據(jù)錯亂等情況
}
}所以呀,為了避免這種情況發(fā)生,在釋放內(nèi)存后,正確的做法是把對應(yīng)的指針置為 NULL,像這樣修改一下上面的代碼:
int *ptr = (int *)malloc(10 * sizeof(int));
if (ptr!= NULL) {
*ptr = 10;
free(ptr);
ptr = NULL; // 釋放后將指針置為NULL,避免成為野指針
}這樣就能有效地防止意外訪問已經(jīng)釋放的內(nèi)存區(qū)域,減少程序出現(xiàn)錯誤的風(fēng)險啦。
Part3.內(nèi)存管理:守護(hù)程序的 “內(nèi)存管家”
3.1 Linux內(nèi)存管理
內(nèi)存管理,是計算機(jī)系統(tǒng)中極為關(guān)鍵的一環(huán),就像一個精密工廠的物料管理員,負(fù)責(zé)著內(nèi)存資源的分配、回收和優(yōu)化,確保程序能夠高效穩(wěn)定地運行。在多任務(wù)環(huán)境下,多個程序同時運行,內(nèi)存管理要合理地為每個程序分配內(nèi)存空間,避免沖突和資源浪費。
內(nèi)存分配方式主要有靜態(tài)分配和動態(tài)分配。靜態(tài)分配在程序編譯時就確定了內(nèi)存大小和位置,比如全局變量和靜態(tài)變量,它們在程序運行期間一直占據(jù)固定內(nèi)存,優(yōu)點是簡單高效,缺點是缺乏靈活性 。動態(tài)分配則是在程序運行時根據(jù)需要申請內(nèi)存,如 C 語言中的malloc、C++ 的new以及 Java 中的new操作,它賦予了程序根據(jù)實際情況靈活使用內(nèi)存的能力,但也帶來了管理的復(fù)雜性,如內(nèi)存泄漏和內(nèi)存碎片等問題。
內(nèi)存回收同樣至關(guān)重要。在 C 和 C++ 中,使用free(對應(yīng)malloc)和delete(對應(yīng)new)手動釋放不再使用的內(nèi)存;Java、Python 等語言則引入了垃圾回收機(jī)制(Garbage Collection,GC),自動檢測和回收不再被引用的對象所占用的內(nèi)存,大大減輕了開發(fā)者的負(fù)擔(dān),不過垃圾回收也會帶來一定的性能開銷,并且可能導(dǎo)致程序出現(xiàn)短暫停頓。
內(nèi)存優(yōu)化是提升系統(tǒng)性能的關(guān)鍵。優(yōu)化方法包括減少內(nèi)存碎片,內(nèi)存碎片是由于頻繁的內(nèi)存分配和釋放導(dǎo)致的內(nèi)存不連續(xù)現(xiàn)象,會降低內(nèi)存利用率,可通過采用合適的內(nèi)存分配算法(如伙伴系統(tǒng)算法、 slab 分配器等)來緩解;合理使用緩存,緩存利用局部性原理,將常用數(shù)據(jù)存儲在高速緩存中,減少對主內(nèi)存的訪問次數(shù),從而提高數(shù)據(jù)訪問速度;還有優(yōu)化數(shù)據(jù)結(jié)構(gòu),選擇合適的數(shù)據(jù)結(jié)構(gòu)可以減少內(nèi)存占用,例如在需要頻繁插入和刪除元素的場景中,使用鏈表比數(shù)組更節(jié)省內(nèi)存。
內(nèi)存管理不當(dāng)會引發(fā)一系列問題,如內(nèi)存泄漏,內(nèi)存泄漏指程序中已分配的內(nèi)存由于某種原因未被釋放,隨著程序運行,泄漏的內(nèi)存越來越多,最終導(dǎo)致系統(tǒng)內(nèi)存耗盡,程序崩潰。再比如空指針引用,當(dāng)訪問一個空指針指向的內(nèi)存時,會導(dǎo)致程序異常終止,這通常是由于內(nèi)存分配失敗或內(nèi)存釋放后未將指針置為NULL造成的。
3.2 物理內(nèi)存與虛擬內(nèi)存
(1)物理內(nèi)存的管理
物理內(nèi)存是計算機(jī)系統(tǒng)中實際存在的內(nèi)存,它由計算機(jī)硬件直接管理,通常由 DRAM 芯片組成,是計算機(jī)系統(tǒng)中最快的存儲器 ,其大小通常是固定的,取決于計算機(jī)硬件的配置。在 Linux 內(nèi)核中,物理內(nèi)存被劃分為一個個固定大小的頁框(Page Frame),每個頁框通常為 4KB(也有其他大小,如 2MB、1GB 的大頁)。內(nèi)核通過頁框號(Page Frame Number,PFN)來管理這些頁框,就像給圖書館的每一本書都編上了唯一的編號,方便查找和管理。
物理內(nèi)存的作用至關(guān)重要,它是程序運行和數(shù)據(jù)存儲的直接場所。當(dāng)程序運行時,其代碼和數(shù)據(jù)會被加載到物理內(nèi)存中,CPU 直接從物理內(nèi)存中讀取指令和數(shù)據(jù)進(jìn)行處理。比如,當(dāng)我們啟動一個文本編輯器時,編輯器的程序代碼會被加載到物理內(nèi)存的某個區(qū)域,我們在編輯器中輸入的文本數(shù)據(jù)也會存儲在物理內(nèi)存中,這樣 CPU 才能快速地對這些數(shù)據(jù)進(jìn)行處理,實現(xiàn)文本的編輯、保存等操作。
(2)虛擬內(nèi)存的奧秘
虛擬內(nèi)存,簡單來說,是一種內(nèi)存管理技術(shù),它為每個進(jìn)程提供了一個獨立的、連續(xù)的地址空間,讓進(jìn)程誤以為自己擁有一塊完整且足夠大的內(nèi)存空間 ,而無需關(guān)心實際物理內(nèi)存的具體布局和大小限制。這就好比你擁有一個超大的虛擬倉庫,你可以隨意規(guī)劃貨物的擺放位置,而不用擔(dān)心倉庫空間不夠。
虛擬內(nèi)存的主要作用之一是實現(xiàn)內(nèi)存地址轉(zhuǎn)換。在 Linux 系統(tǒng)中,每個進(jìn)程都有自己的虛擬地址空間,這個空間通過頁表(Page Table)與物理內(nèi)存進(jìn)行映射。頁表就像是一本地址翻譯字典,負(fù)責(zé)將進(jìn)程使用的虛擬地址翻譯成實際的物理地址。
當(dāng)程序運行時,它所訪問的內(nèi)存地址都是虛擬地址。例如,當(dāng)程序需要讀取某個變量的值時,它會給出一個虛擬地址。CPU 首先會根據(jù)這個虛擬地址中的頁號(Page Number)在頁表中查找對應(yīng)的物理頁框號(Page Frame Number)。如果頁表中存在這個映射關(guān)系(即頁表項有效),CPU 就可以通過物理頁框號和虛擬地址中的頁內(nèi)偏移(Offset)計算出實際的物理地址,從而訪問到物理內(nèi)存中的數(shù)據(jù)。
但如果頁表中沒有找到對應(yīng)的映射關(guān)系(即發(fā)生缺頁異常,Page Fault),系統(tǒng)會認(rèn)為這個虛擬頁還沒有被加載到物理內(nèi)存中。此時,操作系統(tǒng)會介入,從磁盤的交換區(qū)(Swap Area)或者文件系統(tǒng)中找到對應(yīng)的物理頁,并將其加載到物理內(nèi)存中,同時更新頁表,建立虛擬地址與物理地址的映射關(guān)系。之后,程序就可以通過新建立的映射關(guān)系訪問到數(shù)據(jù)了。
為了更直觀地理解,我們可以把虛擬內(nèi)存想象成一個圖書館的目錄系統(tǒng)。每個進(jìn)程就像是一個讀者,擁有自己的目錄(虛擬地址空間)。當(dāng)讀者想要查找某本書(訪問數(shù)據(jù))時,會先在自己的目錄中找到對應(yīng)的條目(虛擬地址),然后通過這個條目去書架(物理內(nèi)存)上找到實際的書。如果書架上沒有這本書(缺頁異常),圖書館管理員(操作系統(tǒng))就會從倉庫(磁盤)中把書取出來放到書架上,并更新目錄(頁表),以便下次讀者能更快地找到這本書。
例如:對于程序計數(shù)器位數(shù)為32位的處理器來說,他的地址發(fā)生器所能發(fā)出的地址數(shù)目為2^32=4G個,于是這個處理器所能訪問的最大內(nèi)存空間就是4G。在計算機(jī)技術(shù)中,這個值就叫做處理器的尋址空間或?qū)ぶ纺芰Α?/span>
照理說,為了充分利用處理器的尋址空間,就應(yīng)按照處理器的最大尋址來為其分配系統(tǒng)的內(nèi)存。如果處理器具有32位程序計數(shù)器,那么就應(yīng)該按照下圖的方式,為其配備4G的內(nèi)存:
圖片
這樣,處理器所發(fā)出的每一個地址都會有一個真實的物理存儲單元與之對應(yīng);同時,每一個物理存儲單元都有唯一的地址與之對應(yīng)。這顯然是一種最理想的情況。
3.3 分頁:虛擬內(nèi)存的基石
在 Linux 的世界里,內(nèi)存分頁機(jī)制就像是一位有條不紊的大管家,精心管理著系統(tǒng)的內(nèi)存資源。簡單來說,內(nèi)存分頁機(jī)制就是把物理內(nèi)存和虛擬內(nèi)存分割成固定大小的小塊,這些小塊被稱作 “頁” ,每個頁的大小一般為 4KB 或者 8KB。就好比你有一個巨大的倉庫(內(nèi)存),為了更好地管理里面的貨物(數(shù)據(jù)),你把倉庫劃分成了一個個大小相同的小隔間(頁)。
圖片
(1)什么是分頁機(jī)制
分頁機(jī)制是 80x86 內(nèi)存管理機(jī)制的第二部分。它在分段機(jī)制的基礎(chǔ)上完成虛擬地址到物理地址的轉(zhuǎn)換過程。分段機(jī)制把邏輯地址轉(zhuǎn)換成線性地址,而分頁機(jī)制則把線性地址轉(zhuǎn)換成物理地址。分頁機(jī)制可用于任何一種分段模型。處理器分頁機(jī)制會把線性地址空間劃分成頁面,然后這些線性地址空間頁面被映射到物理地址空間的頁面上。分頁機(jī)制的幾種頁面級保護(hù)措施,可和分段機(jī)制保護(hù)措施或用或替代分段機(jī)制的保護(hù)措施。
(2)分頁機(jī)制如何啟用
在我們進(jìn)行程序開發(fā)的時候,一般情況下,是不需要管理內(nèi)存的,也不需要操心內(nèi)存夠不夠用,其實,這就是分頁機(jī)制給我們帶來的好處。它是實現(xiàn)虛擬存儲的關(guān)鍵,位于線性地址與物理地址之間,在使用這種內(nèi)存分頁管理方法時,每個執(zhí)行中的進(jìn)程(任務(wù))可以使用比實際內(nèi)存容量大得多的連續(xù)地址空間。而且當(dāng)系統(tǒng)內(nèi)存實際上被分成很多凌亂的塊時,它可以建立一個大而連續(xù)的內(nèi)存空間的映象,好讓程序不用操心和管理這些分散的內(nèi)存塊。
分頁機(jī)制增強了分段機(jī)制的性能。頁地址變換是建立在段變換基礎(chǔ)之上的。因為,段管理機(jī)制對于Intel處理器來說是最基本的,任何時候都無法關(guān)閉。所以即使啟用了頁管理功能,分段機(jī)制依然是起作用的,段部件也依然工作。
分頁只能在保護(hù)模式(CR0.PE = 1)下使用。在保護(hù)模式下,是否開啟分頁,由 CR0. PG 位(位 31)決定:
- 當(dāng) CR0.PG = 0 時,未開啟分頁,線性地址等同于物理地址;
- 當(dāng) CR0.PG = 1 時,開啟分頁。
(3)分頁機(jī)制線性地址到物理地址轉(zhuǎn)換過程
80x86使用 4K 字節(jié)固定大小的頁面,每個頁面均是 4KB,并且對其于 4K 地址邊界處。這表示分頁機(jī)制把 2^32字節(jié)(4GB)的線性地址空間劃分成 2^20(1M = 1048576)個頁面。分頁機(jī)制通過把線性地址空間中的頁面重新定位到物理地址空間中進(jìn)行操作。由于 4K 大小的頁面作為一個單元進(jìn)行映射,并且對其于 4K 邊界,因此線性地址的低 12 位可做為頁內(nèi)偏移地量直接作為物理地址的低 12 位。分頁機(jī)制執(zhí)行的重定向功能可以看作是把線性地址的高 20 位轉(zhuǎn)換到對應(yīng)物理地址的高 20 位。
線性到物理地址轉(zhuǎn)換功能,被擴(kuò)展成允許一個線性地址被標(biāo)注為無效的,而非要讓其產(chǎn)生一個物理地址。以下兩種情況一個頁面可以被標(biāo)注為無效的:
- 1. 操作系統(tǒng)不支持的線性地址。
- 2. 對應(yīng)的虛擬內(nèi)存系統(tǒng)中的頁面在磁盤上而非在物理內(nèi)存中。
在第一中情況下,產(chǎn)生無效地址的程序必須被終止,在第二種情況下,該無效地址實際上是請求 操作系統(tǒng)虛擬內(nèi)存管理器 把對應(yīng)的頁面從磁盤加載到物理內(nèi)存中,以供程序訪問。因為無效頁面通常與虛擬存儲系統(tǒng)相關(guān),因此它們被稱為不存在頁面,由頁表中稱為存在的屬性來確定。
當(dāng)使用分頁時,處理器會把線性地址空間劃分成固定大小的頁面(4KB),這些頁面可以映射到物理內(nèi)存中或磁盤存儲空間中,當(dāng)一個程序引用內(nèi)存中的邏輯地址時,處理器會把該邏輯地址轉(zhuǎn)換成一個線性地址,然后使用分頁機(jī)制把該線性地址轉(zhuǎn)換成對應(yīng)的物理地址。
如果包含線性地址的頁面不在當(dāng)前物理內(nèi)存中,處理器就會產(chǎn)生一個頁錯誤異常。頁錯誤異常處理程序就會讓操作系統(tǒng)從磁盤中把相應(yīng)頁面加載到物理內(nèi)存中(操作過程中可能會把物理內(nèi)存中不同的頁面寫到磁盤上)。當(dāng)頁面加載到物理內(nèi)存之后,從異常處理過程的返回操作會使異常的指令被重新執(zhí)行。處理器把用于線性地址轉(zhuǎn)換成物理地址和用于產(chǎn)生頁錯誤的信息包含在存儲與內(nèi)存中的頁目錄與頁表中。
(4)分頁機(jī)制與分段機(jī)制的不同
分頁與分段的最大的不同之處在于分頁使用了固定長度的頁面。段的長度通常與存放在其中的代碼或數(shù)據(jù)結(jié)構(gòu)有相同的長度。與段不同,頁面有固定的長度。如果僅使用分段地址轉(zhuǎn)換,那么存儲在物理內(nèi)存中的一個數(shù)據(jù)結(jié)構(gòu)將包含其所有的部分。如果使用了分頁,那么一個數(shù)據(jù)結(jié)構(gòu)就可以一部分存儲與物理內(nèi)存中,而另一部分保存在磁盤中。
為了減少地址轉(zhuǎn)換所要求的總線周期數(shù)量,最近訪問的頁目錄和頁表會被存放在處理器的一個叫做轉(zhuǎn)換查找緩沖區(qū)(TLB)的緩沖器件中。TLB 可以滿足大多數(shù)讀頁目錄和頁表的請求而無需使用總線周期。只有當(dāng) TLB 中不包含所要求的頁表項是才會出現(xiàn)使用額外的總線周期從內(nèi)存讀取頁表項。通常在一個頁表項很長時間沒有訪問過時才會出現(xiàn)這種情況。
3.4 交換空間:內(nèi)存不足時的后盾
首先呢,提一個概念,交換空間(swap space),這個大家應(yīng)該不陌生,在重裝系統(tǒng)的時候,會讓你選擇磁盤分區(qū),就比如說一個硬盤分幾個部分去管理。其中就會分一部分磁盤空間用作交換,叫做swap space。其實就是一段臨時存儲空間,內(nèi)存不夠用的時候就用它了,雖然它也在磁盤中,但省去了很多的查找時間啊。當(dāng)發(fā)生進(jìn)程切換的時候,內(nèi)存與交換空間就要發(fā)生數(shù)據(jù)交換一滿足需求。所以啊,進(jìn)程的切換消耗是很大的,這也說明了為什么自旋鎖比信號量效率高的原因。
那么我們的程序里申請的內(nèi)存的時候,linux內(nèi)核其實只分配一個虛擬內(nèi)存( 線性地址),并沒有分配實際的物理內(nèi)存。只有當(dāng)程序真正使用這塊內(nèi)存時,才會分配物理內(nèi)存。這就叫做延遲分配和請頁機(jī)制。釋放內(nèi)存時,先釋放線性區(qū)對應(yīng)的物理內(nèi)存,然后釋放線性區(qū);"請頁機(jī)制"將物理內(nèi)存的分配延后了,這樣是充分利用了程序的局部性原來,節(jié)約內(nèi)存空間,提高系統(tǒng)吞吐;就是說一個函數(shù)可能只在物理內(nèi)存中呆了一會,用完了就被清除出去了,雖然在虛擬地址空間還在。(不過虛擬地址空間不是事實上的存儲,所以只能說這個函數(shù)占據(jù)了一段虛擬地址空間,當(dāng)你訪問這段地址時,就會產(chǎn)生缺頁處理,從交換區(qū)把對應(yīng)的代碼搬到物理內(nèi)存上來)
Swap 交換機(jī)制是 Linux 虛擬內(nèi)存管理的另一個重要組成部分。簡單來說,Swap 是磁盤上的一塊區(qū)域,當(dāng)物理內(nèi)存不足時,系統(tǒng)會將一部分暫時不用的內(nèi)存頁面(Page)交換到 Swap 空間中,騰出物理內(nèi)存給更需要的進(jìn)程使用 。當(dāng)被交換出去的頁面再次被訪問時,系統(tǒng)會將其從 Swap 空間換回到物理內(nèi)存中。
Swap 交換機(jī)制的工作原理涉及到內(nèi)存回收和頁面置換算法。當(dāng)系統(tǒng)內(nèi)存緊張時,內(nèi)核會啟動內(nèi)存回收機(jī)制,掃描內(nèi)存中的頁面,選擇一些不常用或最近最少使用的頁面進(jìn)行回收。如果這些頁面是匿名頁面(沒有關(guān)聯(lián)到文件的內(nèi)存頁面,如進(jìn)程的堆和棧空間),就會被交換到 Swap 空間中;如果是文件映射頁面(關(guān)聯(lián)到文件的內(nèi)存頁面,如共享庫、文件緩存等),則會根據(jù)情況進(jìn)行處理,臟頁面(被修改過的頁面)會被寫回文件,干凈頁面(未被修改過的頁面)可以直接釋放。
Swap 交換機(jī)制對系統(tǒng)性能有著重要的影響。當(dāng) Swap 使用頻繁時,說明物理內(nèi)存不足,系統(tǒng)需要頻繁地在物理內(nèi)存和 Swap 空間之間交換頁面,這會導(dǎo)致磁盤 I/O 增加,系統(tǒng)性能下降 。因為磁盤的讀寫速度遠(yuǎn)遠(yuǎn)低于內(nèi)存,過多的 Swap 操作會使系統(tǒng)變得遲緩。因此,在實際應(yīng)用中,需要合理配置 Swap 空間的大小,并密切關(guān)注系統(tǒng)的內(nèi)存使用情況,避免 Swap 過度使用。
例如,可以通過調(diào)整/proc/sys/vm/swappiness參數(shù)來控制系統(tǒng)對 Swap 的使用傾向,swappiness的值范圍是 0 - 100,表示系統(tǒng)將內(nèi)存頁面交換到 Swap 空間的傾向程度,值越大表示越傾向于使用 Swap 。一般來說,對于內(nèi)存充足的系統(tǒng),可以將swappiness設(shè)置為較低的值,如 10 或 20,以減少不必要的 Swap 操作;對于內(nèi)存緊張的系統(tǒng),可以適當(dāng)提高swappiness的值,但也要注意不要過高,以免嚴(yán)重影響性能。
3.5 內(nèi)存映射:高效 I/O 的秘訣
內(nèi)存映射,英文名為 Memory - mapped I/O,從字面意思理解,就是將磁盤文件的數(shù)據(jù)映射到內(nèi)存中。在 Linux 系統(tǒng)中,這一機(jī)制允許進(jìn)程把一個文件或者設(shè)備的數(shù)據(jù)關(guān)聯(lián)到內(nèi)存地址空間,使得進(jìn)程能夠像訪問內(nèi)存一樣對文件進(jìn)行操作 。
舉個簡單的例子,假設(shè)有一個文本文件,通常我們讀取它時,會使用read函數(shù),數(shù)據(jù)從磁盤先讀取到內(nèi)核緩沖區(qū),再拷貝到用戶空間。而內(nèi)存映射則直接在進(jìn)程的虛擬地址空間中為這個文件創(chuàng)建一個映射區(qū)域,進(jìn)程可以直接通過指針訪問這個映射區(qū)域,就好像文件數(shù)據(jù)已經(jīng)在內(nèi)存中一樣,大大簡化了文件操作的流程 。
內(nèi)存映射的工作原理涉及到虛擬內(nèi)存、頁表以及文件系統(tǒng)等多個方面的知識。當(dāng)進(jìn)程調(diào)用mmap函數(shù)進(jìn)行內(nèi)存映射時,大致會經(jīng)歷以下幾個關(guān)鍵步驟 :
- 虛擬內(nèi)存區(qū)域創(chuàng)建:系統(tǒng)首先在進(jìn)程的虛擬地址空間中尋找一段滿足要求的連續(xù)空閑虛擬地址,然后為這段虛擬地址分配一個vm_area_struct結(jié)構(gòu),這個結(jié)構(gòu)用于描述虛擬內(nèi)存區(qū)域的各種屬性,如起始地址、結(jié)束地址、權(quán)限等,并將其插入到進(jìn)程的虛擬地址區(qū)域鏈表或樹中 。就好比在一片空地上,規(guī)劃出一塊特定大小和用途的區(qū)域,并做好標(biāo)記。
- 地址映射建立:通過待映射的文件指針,找到對應(yīng)的文件描述符,進(jìn)而鏈接到內(nèi)核 “已打開文件集” 中該文件的文件結(jié)構(gòu)體。再通過這個文件結(jié)構(gòu)體,調(diào)用內(nèi)核函數(shù)mmap,定位到文件磁盤物理地址,然后通過remap_pfn_range函數(shù)建立頁表,實現(xiàn)文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系 。這一步就像是在規(guī)劃好的區(qū)域和實際的文件存儲位置之間建立起一條通道,讓數(shù)據(jù)能夠順利流通。不過,此時只是建立了地址映射,真正的數(shù)據(jù)還沒有拷貝到內(nèi)存中 。
- 數(shù)據(jù)加載(缺頁異常處理):當(dāng)進(jìn)程首次訪問映射區(qū)域中的數(shù)據(jù)時,由于數(shù)據(jù)還未在物理內(nèi)存中,會觸發(fā)缺頁異常。內(nèi)核會捕獲這個異常,然后在交換緩存空間(swap cache)中尋找需要訪問的內(nèi)存頁,如果沒有找到,則調(diào)用nopage函數(shù)把所缺的頁從磁盤裝入到主存中 。這個過程就像是當(dāng)你需要使用某個物品,但它不在身邊,你就需要去存放它的地方把它取回來。之后,進(jìn)程就可以對這片主存進(jìn)行正常的讀或?qū)懖僮鳎绻麑懖僮鞲淖兞藬?shù)據(jù)內(nèi)容,系統(tǒng)會在一定時間后自動將臟頁面回寫臟頁面到對應(yīng)磁盤地址,完成寫入到文件的過程 。當(dāng)然,也可以調(diào)用msync函數(shù)來強制同步,讓數(shù)據(jù)立即保存到文件里 。
mmap內(nèi)存映射的實現(xiàn)過程,總的來說可以分為三個階段:
①進(jìn)程啟動映射過程,并在虛擬地址空間中為映射創(chuàng)建虛擬映射區(qū)域
- 進(jìn)程在用戶空間調(diào)用庫函數(shù)mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- 在當(dāng)前進(jìn)程的虛擬地址空間中,尋找一段空閑的滿足要求的連續(xù)的虛擬地址
- 為此虛擬區(qū)分配一個vm_area_struct結(jié)構(gòu),接著對這個結(jié)構(gòu)的各個域進(jìn)行了初始化
- 將新建的虛擬區(qū)結(jié)構(gòu)(vm_area_struct)插入進(jìn)程的虛擬地址區(qū)域鏈表或樹中
②調(diào)用內(nèi)核空間的系統(tǒng)調(diào)用函數(shù)mmap(不同于用戶空間函數(shù)),實現(xiàn)文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系
- 為映射分配了新的虛擬地址區(qū)域后,通過待映射的文件指針,在文件描述符表中找到對應(yīng)的文件描述符,通過文件描述符,鏈接到內(nèi)核“已打開文件集”中該文件的文件結(jié)構(gòu)體(struct file),每個文件結(jié)構(gòu)體維護(hù)著和這個已打開文件相關(guān)各項信息。
- 通過該文件的文件結(jié)構(gòu)體,鏈接到file_operations模塊,調(diào)用內(nèi)核函數(shù)mmap,其原型為:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用戶空間庫函數(shù)。
- 內(nèi)核mmap函數(shù)通過虛擬文件系統(tǒng)inode模塊定位到文件磁盤物理地址。
- 通過remap_pfn_range函數(shù)建立頁表,即實現(xiàn)了文件地址和虛擬地址區(qū)域的映射關(guān)系。此時,這片虛擬地址并沒有任何數(shù)據(jù)關(guān)聯(lián)到主存中。
③進(jìn)程發(fā)起對這片映射空間的訪問,引發(fā)缺頁異常,實現(xiàn)文件內(nèi)容到物理內(nèi)存(主存)的拷貝
注:前兩個階段僅在于創(chuàng)建虛擬區(qū)間并完成地址映射,但是并沒有將任何文件數(shù)據(jù)的拷貝至主存。真正的文件讀取是當(dāng)進(jìn)程發(fā)起讀或?qū)懖僮鲿r。
- 進(jìn)程的讀或?qū)懖僮髟L問虛擬地址空間這一段映射地址,通過查詢頁表,發(fā)現(xiàn)這一段地址并不在物理頁面上。因為目前只建立了地址映射,真正的硬盤數(shù)據(jù)還沒有拷貝到內(nèi)存中,因此引發(fā)缺頁異常。
- 缺頁異常進(jìn)行一系列判斷,確定無非法操作后,內(nèi)核發(fā)起請求調(diào)頁過程。
- 調(diào)頁過程先在交換緩存空間(swap cache)中尋找需要訪問的內(nèi)存頁,如果沒有則調(diào)用nopage函數(shù)把所缺的頁從磁盤裝入到主存中。
- 之后進(jìn)程即可對這片主存進(jìn)行讀或者寫的操作,如果寫操作改變了其內(nèi)容,一定時間后系統(tǒng)會自動回寫臟頁面到對應(yīng)磁盤地址,也即完成了寫入到文件的過程。
- 注:修改過的臟頁面并不會立即更新回文件中,而是有一段時間的延遲,可以調(diào)用msync()來強制同步, 這樣所寫的內(nèi)容就能立即保存到文件里了。
Part4.并發(fā)編程:開啟程序高效運行的大門
4.1 并發(fā)編程技術(shù)
隨著計算機(jī)硬件技術(shù)的飛速發(fā)展,多核處理器已經(jīng)成為主流,并發(fā)編程也因此變得愈發(fā)重要。并發(fā)編程,簡單來說,就是讓程序中的多個任務(wù)在同一時間段內(nèi)執(zhí)行,從而充分利用多核 CPU 的資源,顯著提升程序的執(zhí)行效率和響應(yīng)速度。
在日常生活中,并發(fā)編程的應(yīng)用場景隨處可見。比如在 Web 服務(wù)器中,當(dāng)多個用戶同時訪問網(wǎng)站時,服務(wù)器需要并發(fā)處理這些請求,快速響應(yīng)每個用戶,而不是讓用戶排隊等待;在視頻編輯軟件里,一邊加載視頻素材,一邊進(jìn)行視頻渲染,多個任務(wù)并發(fā)進(jìn)行,節(jié)省用戶的時間。
并發(fā)編程帶來強大功能的同時,也伴隨著諸多挑戰(zhàn)。其中,數(shù)據(jù)競爭是一個常見問題,當(dāng)多個線程同時訪問和修改共享數(shù)據(jù)時,就可能出現(xiàn)數(shù)據(jù)不一致的情況。例如,兩個線程同時讀取一個變量的值,然后各自進(jìn)行加 1 操作,最后再寫回變量,由于線程執(zhí)行順序的不確定性,最終變量的值可能并不是預(yù)期的增加了 2 。死鎖也是一個棘手的問題,當(dāng)兩個或多個線程相互等待對方釋放資源時,就會陷入死鎖狀態(tài),程序無法繼續(xù)執(zhí)行。比如線程 A 持有資源 1,等待資源 2,而線程 B 持有資源 2,等待資源 1,此時就會發(fā)生死鎖。
為了解決這些問題,人們想出了許多有效的方法。對于數(shù)據(jù)競爭,可以使用鎖機(jī)制,如互斥鎖(Mutex),它就像一把鑰匙,同一時間只有一個線程能拿到這把鑰匙,進(jìn)入臨界區(qū)訪問共享數(shù)據(jù),從而保證數(shù)據(jù)的一致性。讀寫鎖(Read - Write Lock)也是一種常用的鎖,它允許多個線程同時進(jìn)行讀操作,但寫操作時會獨占資源,適用于讀多寫少的場景。還可以采用原子操作,像 Java 中的Atomic類,它提供了一系列原子操作方法,如AtomicInteger的incrementAndGet方法,能保證對整數(shù)的自增操作是原子的,不會受到其他線程的干擾。對于死鎖問題,避免死鎖的策略包括按照固定順序獲取鎖,避免嵌套鎖;設(shè)置鎖的超時時間,當(dāng)?shù)却i超時后,線程放棄等待,避免無限期等待;使用資源分配圖算法檢測死鎖,一旦發(fā)現(xiàn)死鎖,及時采取措施解除死鎖。
在并發(fā)編程領(lǐng)域,有許多常用的編程模型和工具。線程池是一種廣泛使用的工具,它預(yù)先創(chuàng)建好一定數(shù)量的線程,當(dāng)有任務(wù)到來時,直接從線程池中獲取線程執(zhí)行任務(wù),任務(wù)完成后,線程又回到線程池中等待下一個任務(wù),這樣可以避免頻繁創(chuàng)建和銷毀線程帶來的開銷,提高線程的復(fù)用性和執(zhí)行效率。例如,在 Java 中,可以使用ThreadPoolExecutor類來創(chuàng)建和管理線程池,通過設(shè)置核心線程數(shù)、最大線程數(shù)、任務(wù)隊列等參數(shù),靈活控制線程池的行為。還有并發(fā)集合類,如 Java 中的ConcurrentHashMap,它是線程安全的哈希表,在多線程環(huán)境下能高效地進(jìn)行數(shù)據(jù)存儲和讀取操作,比傳統(tǒng)的HashMap更適合并發(fā)場景 。消息隊列也是一種重要的并發(fā)編程模型,它通過異步消息傳遞的方式,解耦生產(chǎn)者和消費者,實現(xiàn)任務(wù)的異步處理和并發(fā)執(zhí)行,常見的消息隊列有 Kafka、RabbitMQ 等。
4.2 什么是無鎖編程
無鎖編程,即不使用鎖的情況下實現(xiàn)多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現(xiàn)變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization),實現(xiàn)非阻塞同步的方案稱為“無鎖編程算法”。
為什么要非阻塞同步,使用lock實現(xiàn)線程同步有非常多缺點:
- 產(chǎn)生競爭時,線程被阻塞等待,無法做到線程實時響應(yīng)
- dead lock
- live lock
- 優(yōu)先級反轉(zhuǎn)
- 使用不當(dāng),造成性能下降
假設(shè)在不使用 lock 的情況下,實現(xiàn)變量同步,那就會避免非常多問題。盡管眼下來看,無鎖編程并不能替代 lock。
實現(xiàn)級別
非同步阻塞的實現(xiàn)分為三個級別:wait-free/lock-free/obstruction-free
(1)wait-free
- 最理想的模式,整個操作保證每一個線程在有限步驟下完畢
- 保證系統(tǒng)級吞吐(system-wide throughput)以及無線程饑餓
- 截止2011年,沒有多少詳細(xì)的實現(xiàn)。即使實現(xiàn)了,也須要依賴于詳細(xì)CPU
(2)lock-free
- 同意個別線程饑餓,但保證系統(tǒng)級吞吐。
- 確保至少有一個線程可以繼續(xù)運行。
- wait-free的算法必然也是lock-free的。
(3)obstruction-free
在不論什么時間點,一個線程被隔離為一個事務(wù)進(jìn)行運行(其它線程suspended),而且在有限步驟內(nèi)完畢。在運行過程中,一旦發(fā)現(xiàn)數(shù)據(jù)被改動(採用時間戳、版本),則回滾,也叫做樂觀鎖,即樂觀并發(fā)控制(OOC)。
事務(wù)的過程是:
- 讀取,并寫時間戳
- 準(zhǔn)備寫入,版本號校驗
- 檢驗通過則寫入,檢驗不通過,則回滾
lock-free必然是obstruction-free的。
4.3 為什么要無鎖?
首先是性能考慮。通信項目一般對性能有極致的追求,這是我們使用無鎖的重要原因。當(dāng)然,無鎖算法如果實現(xiàn)的不好,性能可能還不如使用鎖,所以我們選擇比較擅長的數(shù)據(jù)結(jié)構(gòu)和算法進(jìn)行l(wèi)ock-free實現(xiàn),比如Queue,對于比較復(fù)雜的數(shù)據(jù)結(jié)構(gòu)和算法我們通過lock來控制,比如Map(雖然我們實現(xiàn)了無鎖Hash,但是大小是限定的,而Map是大小不限定的),對于性能數(shù)據(jù),后續(xù)文章會給出無鎖和有鎖的對比。
次要是避免鎖的使用引起的錯誤和問題:
- 死鎖(dead lock):兩個以上線程互相等待
- 鎖護(hù)送(lock convoy):多個同優(yōu)先級的線程反復(fù)競爭同一個鎖,搶占鎖失敗后強制上下文切換,引起性能下降
- 優(yōu)先級反轉(zhuǎn)(priority inversion):低優(yōu)先級線程擁有鎖時被中優(yōu)先級的線程搶占,而高優(yōu)先級的線程因為申請不到鎖被阻塞。
4.4 如何無鎖?
在現(xiàn)代的 CPU 處理器上,很多操作已經(jīng)被設(shè)計為原子的,比如對齊讀(Aligned Read)和對齊寫(Aligned Write)等。Read-Modify-Write(RMW)操作的設(shè)計讓執(zhí)行更復(fù)雜的事務(wù)操作變成了原子操作,當(dāng)有多個寫入者想對相同的內(nèi)存進(jìn)行修改時,保證一次只執(zhí)行一個操作。
RMW 操作在不同的 CPU 家族中是通過不同的方式來支持的:
- x86/64 和 Itanium 架構(gòu)通過 Compare-And-Swap (CAS) 方式來實現(xiàn)
- PowerPC、MIPS 和 ARM 架構(gòu)通過 Load-Link/Store-Conditional (LL/SC) 方式來實現(xiàn)
在x64下進(jìn)行實踐的,用的是CAS操作,CAS操作是lock-free技術(shù)的基礎(chǔ),我們可以用下面的代碼來描述:
template <class T>
bool CAS(T* addr, T expected, T value)
{
if (*addr == expected)
{
*addr = value;
return true;
}
return false;
}在GCC中,CAS操作如下所示:
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)這兩個函數(shù)提供原子的比較和交換,如果*ptr == oldval,就將newval寫入*ptr,第一個函數(shù)在相等并寫入的情況下返回true,第二個函數(shù)的內(nèi)置行為和第一個函數(shù)相同,只是它返回操作之前的值。
后面的可擴(kuò)展參數(shù)(...)用來指出哪些變量需要memory barrier,因為目前gcc實現(xiàn)的是full barrier,所以可以略掉這個參數(shù),除過CAS操作,GCC還提供了其他一些原子操作,可以在無鎖算法中靈活使用:
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)
type __sync_add_and_fetch (type *ptr, type value, ...)
type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)_sync_*系列的built-in函數(shù),用于提供加減和邏輯運算的原子操作。這兩組函數(shù)的區(qū)別在于第一組返回更新前的值,第二組返回更新后的值。
無鎖算法感觸最深的是復(fù)雜度的分解,比如多線程對于一個雙向鏈表的插入或刪除操作,如何能一步一步分解成一個一個串聯(lián)的原子操作,并能保證事務(wù)內(nèi)存的一致性。
Part5.數(shù)據(jù)庫:數(shù)據(jù)世界的堅固堡壘
簡單來說,數(shù)據(jù)庫就是按照一定結(jié)構(gòu)組織并長期存儲在計算機(jī)內(nèi)、可共享的數(shù)據(jù)集合 ,它就像是一個巨大的倉庫,有條不紊地存放著各種數(shù)據(jù)。
數(shù)據(jù)庫管理系統(tǒng)(DBMS)則是操作和管理數(shù)據(jù)庫的軟件,是數(shù)據(jù)庫的核心樞紐,像常見的 MySQL、Oracle、SQL Server 等都是廣泛使用的數(shù)據(jù)庫管理系統(tǒng),它們?yōu)橛脩艉统绦騿T提供了創(chuàng)建、檢索、更新和管理數(shù)據(jù)的便捷方式,還具備數(shù)據(jù)的安全性保障、一致性維護(hù)、并發(fā)控制以及恢復(fù)機(jī)制等重要功能。
數(shù)據(jù)庫的類型豐富多樣,其中關(guān)系型數(shù)據(jù)庫基于關(guān)系模型設(shè)計,以表格形式存儲數(shù)據(jù),通過行和列來組織數(shù)據(jù),每個表格代表一種實體類型,每一行是一個實例,每一列對應(yīng)實例的一個屬性,并且各表之間通過鍵建立關(guān)聯(lián)關(guān)系,它支持 SQL 語言進(jìn)行強大的數(shù)據(jù)查詢和操作,能保證數(shù)據(jù)的一致性和完整性,適用于對數(shù)據(jù)一致性要求高、事務(wù)處理復(fù)雜的場景,如銀行系統(tǒng)、電商訂單管理系統(tǒng)等 。常見的關(guān)系型數(shù)據(jù)庫有 MySQL、PostgreSQL、Oracle、Microsoft SQL Server 等。
隨著大數(shù)據(jù)時代的來臨,數(shù)據(jù)量呈爆炸式增長,數(shù)據(jù)類型也愈發(fā)復(fù)雜多樣,NoSQL 數(shù)據(jù)庫應(yīng)運而生。它打破了傳統(tǒng)關(guān)系型數(shù)據(jù)庫的固定表結(jié)構(gòu)束縛,更適合處理大規(guī)模分布式環(huán)境下的非結(jié)構(gòu)化數(shù)據(jù),具有靈活的數(shù)據(jù)模型、高可擴(kuò)展性和高性能等特點 。像 MongoDB 屬于文檔型 NoSQL 數(shù)據(jù)庫,它以文檔(類似 JSON 格式)的形式存儲數(shù)據(jù),每個文檔可以有不同的結(jié)構(gòu),非常適合存儲和處理內(nèi)容管理系統(tǒng)、日志記錄系統(tǒng)等場景下的數(shù)據(jù);Redis 是鍵值型 NoSQL 數(shù)據(jù)庫,它將數(shù)據(jù)存儲為鍵值對,讀寫速度極快,常被用作緩存、消息隊列等,在高并發(fā)讀寫的場景中表現(xiàn)出色。
在數(shù)據(jù)庫設(shè)計階段,需求分析至關(guān)重要,需要深入了解業(yè)務(wù)需求,明確數(shù)據(jù)的存儲、查詢和處理要求。概念設(shè)計時,通常使用實體 - 關(guān)系(ER)模型來描述數(shù)據(jù)之間的關(guān)系,將現(xiàn)實世界中的實體、屬性以及它們之間的聯(lián)系抽象為 ER 圖,這就像是搭建房子前的設(shè)計藍(lán)圖,為后續(xù)的設(shè)計奠定基礎(chǔ) 。邏輯設(shè)計則是將 ER 模型轉(zhuǎn)換為具體的數(shù)據(jù)庫模型,如關(guān)系模型,確定數(shù)據(jù)庫的表結(jié)構(gòu)、字段類型、主鍵、外鍵等。物理設(shè)計階段要考慮數(shù)據(jù)庫在物理存儲介質(zhì)上的存儲結(jié)構(gòu)和訪問方法,選擇合適的存儲設(shè)備、索引策略等,以提高數(shù)據(jù)庫的性能。
優(yōu)化數(shù)據(jù)庫性能的方法有很多,比如合理設(shè)計索引,索引能加速數(shù)據(jù)檢索,就像書的目錄,幫助快速定位到所需數(shù)據(jù),但過多的索引會增加存儲開銷和數(shù)據(jù)更新的時間,因此要根據(jù)實際查詢需求創(chuàng)建合適的索引;優(yōu)化 SQL 語句也十分關(guān)鍵,編寫高效的 SQL 查詢,避免全表掃描、減少子查詢的使用、合理使用 JOIN 操作等,可以顯著提升查詢效率;數(shù)據(jù)庫配置優(yōu)化同樣不可忽視,根據(jù)服務(wù)器硬件資源和業(yè)務(wù)負(fù)載,調(diào)整數(shù)據(jù)庫的參數(shù)配置,如緩沖池大小、連接數(shù)、日志設(shè)置等,能讓數(shù)據(jù)庫更好地適應(yīng)實際運行環(huán)境 。
數(shù)據(jù)庫在我們的生活中無處不在,電商平臺利用數(shù)據(jù)庫存儲商品信息、用戶訂單,方便用戶購物和商家管理;社交網(wǎng)絡(luò)依靠數(shù)據(jù)庫保存用戶資料、動態(tài)和好友關(guān)系,讓人們能夠便捷地交流互動;企業(yè)的財務(wù)管理系統(tǒng)通過數(shù)據(jù)庫存儲財務(wù)數(shù)據(jù),支持財務(wù)分析和決策制定。可以說,數(shù)據(jù)庫已經(jīng)成為現(xiàn)代信息系統(tǒng)的基石,支撐著各個領(lǐng)域的信息化運作,其重要性不言而喻。




























