面試涼經,代碼最近寫的太少了,被面試官屠殺。
痛定思痛,對C++新經典中的內存話題進行復現。
new A
與 new A()
的差別
(1)如果是一個空類,這兩行代碼沒什么區別。當然現實中也沒有程序員會寫一個空類。
(2)類A
中如果有public
修飾的成員變量,new A()
這種初始化對象的方式會把和成員變量有關的內存設置為0
?。
(3)如果類A
中有用public
修飾的構造函數,new A()
這種初始化對象的方式也不會把和成員變量有關的內存設置為0
?。想必是成員變量的初始化工作要轉交給類A
的構造函數做了(不再是系統內部做)?,而構造函數的函數體為空(什么也沒做)?,所以,m_i
的值是一個隨機的值。
(4)感覺不同。new A()
感覺像調用了一個無參的構造函數。而new A
不像函數調用,但實際上它也是調用類A
的默認構造函數的。
(5)簡單類型的差別主要還是在初值上。
new A()
做了什么事
new
可以稱為關鍵字,速覽定義可以發現 operator new
字樣如下:
設置斷點,打開反匯編窗口,這樣就可以看代碼對應的匯編語言代碼。
new
關鍵字主要做了兩件事:
①調用 operator new
。
②調用類 A
的構造函數。
調試中可以逐語句跳轉進operator new
,發現operator new
調用了malloc
。
new
關鍵字的調用關系如下:
delete
做了什么事
分配內存,必然有釋放內存。調試,反匯編
delete
關鍵字的調用關系如下
new
與 malloc
的區別
(1)new
是關鍵字/操作符,而 malloc
是函數。
(2)new
一個對象的時候,不但分配內存,而且還會調用類的構造函數(如果類沒有構造函數,系統也沒有給類生成構造函數,那就沒法調用構造函數了)?。
(3)在某些情況下,new A()
可以把對象的某些成員變量設置為 0
,這是 new
的能力之一,malloc
沒這個能力。
(4)new
最終是通過調用 malloc
來分配內存的。
delete
與 free
的區別
delete
不但釋放內存,而且在釋放內存之前會調用類的析構函數(當然必須要類的析構函數存在)?。
malloc
怎樣分配內存
這很復雜(它內部有各種鏈表,鏈來鏈去,又要記錄分配,當釋放內存的時候如果相鄰的位置有空閑的內存塊,又要把空閑內存塊與釋放的內存塊進行合并等)?,不同的操作系統有不同的做法。
malloc
可能還需要調用與操作系統有關的更底層的函數來實現內存的分配和管理,malloc
是跨平臺的、通用的函數,但是malloc
再往深入探究,代碼就不通用了。
operator new
、operator delete
極少會在實際項目中用到.
malloc
、free
也只會在C
風格的代碼中才會用到。
寫 C++
程序,多數情況下還是提倡使用 new
和 delete
。
new
內存分配細節
斷點調試,在內存窗口中觀看指針變量 ppoint
所指向的內存中的內容。
ppoint
所指向的內存起始地址是 0x000001DF0C412510
,目前分配的是10
字節內存,每字節內存中的內容都是00
。
觀察ppoint
所指向的內存附近。
釋放ppoint
內存,影響的范圍很廣,雖然分配內存的時候分配出去的是10
字節,但釋放內存的時候影響的不止是10
字節的內存單元,而是一大片?。
內存的分配與釋放時臨近內存的合并
(a)分配了5
塊內存(一共new
了5
次)?,當然每次new
的內存大小可以不同。
(b)率先釋放了第3
塊內存。
(c)再過一會兒,把第2
塊內存也釋放了。這時free
函數還要負責把臨近的空閑塊也合并到一起。
內存釋放細節
free
一個內存塊并不是一件很簡單的事。free
內部有很多的處理,包括合并數據塊、登記空閑塊的大小、設置空閑塊首位的一些標記以方便下次分配等一系列工作。
分配內存的時候,指明了分配10
字節,但釋放內存的時候,我們并沒有告訴編譯器要釋放多少字節。
顯然編譯器肯定在哪里記錄了這塊內存分配出去的是10
字節,在釋放內存的時候編譯器才能正好把這10
字節的內存釋放掉。
那么,編譯器是在哪里記錄呢?可以用觀察法通過觀察來猜測一下。
在ppoint
所指向的首地址之前的16
字節的位置有一個0a
,轉換成十進制數字就是10
,這里的10
估計就是編譯器用來記錄所分配出去的內存字節數的,這樣在釋放內存的時候就知道所需要釋放內存的大小。
將代碼改成分配55
字節,通過設置斷點調試,在所分配的55
字節內存首地址前面的16
字節位置是也記錄著所分配的內存大小這個數字。
分配內存這件事,假設需要分配10
字節,但這絕不意味著只是簡單分配出去10
字節?,而是在這10
字節周圍的內存中記錄了很多其他內容,如記錄分配出去的字節數等。
分配內存最終還是通過malloc
函數進行的。
分配10
字節內存,malloc
函數可能會分配出如圖所示的內存。
編譯器要有效地管理內存的分配和回收,肯定在分配一塊內存之外額外要多分配出許多空間保存更多的信息。
編譯器最終是把它分出去的這一大塊內存中間某個位置的指針返回給ppoint
,作為程序員能夠使用的內存的起始地址。
一次申請1000
字節,多浪費40
字節,也還比較好接受。但若是一次只申請1
字節,結果系統一下多分配出40
多字節,浪費的實在太多。
重載類中的operator new
和operator delete
操作符
站在編譯器的角度,可以把 new A()
和delete pa
翻譯成C++
代碼。
new A()
如下
delete pa
如下
可以自己寫一個類 A
的 operator new
和 operator delete
成員函數來取代系統的 operator new
和 operator delete
函數,自己寫的這兩個成員函數負責分配內存和釋放內存,同時,還可以往自己寫的這兩個成員函數中插入一些額外代碼?。
因為new
和delete
本身稱為關鍵字或者操作符,所以類A
中的operator new
和operator delete
叫作重載operator new
和operator delete
操作符,但這里將重載后的 operator new
和 operator delete
稱為成員函數也沒問題。
設置斷點調試,確定可以調用類A
的operator new
和operator delete
成員函數,觀察調用operator new
時傳遞進去的形參size
的值,發現是1
(因為類至少是1
字節大小)?。
向類A
中增加public
修飾的構造函數和析構函數:
現在既然在類A
中實現了operator new
和operater delete
,那么在new
和delete
一個類A
對象的時候,就會調用程序員自己實現的類A
中的operator new
和operator delete
。
如果程序員突然不想用自己寫的operator new
和operator delete
成員函數了,怎樣做到呢?
不需要把類A
中的operator new
和operator delete
注釋掉,只需要在使用new
和delete
關鍵字時在其之前增加::
?即可。
::
?叫作作用域運算符,在new
和delete
關鍵字之前增加::
?的寫法,表示調用全局的new
和delete
關鍵字。
重載類中的operator new[?]
和operator delete[?]
操作符
這種寫法并不調用類A
中的operator new
和operator delete
。
為數組分配內存,需要重載operator new[?]
和operator delete[?]?
。
在類A
定義的內部增加兩個public
修飾的成員函數聲明,在類A
的外面增加這兩個成員函數的實現。
operator new[?]
和operator delete[?]
只會被調用1
次,但是類A
的構造函數和析構函數會被分別調用3
次
不要誤以為3
個元素大小的數組new
的時候就會分配3
次,delete
執行3
次。
斷點調試,形參size
的值是11
。
為什么會是11
呢?
因為這里創建的是3
個對象的數組,每個對象占1
字節,3
個對象正好占用3
字節。另外8
字節是做什么用的呢?
ppoint
返回的內存地址是0x0000022eb1113b00
。
pa
返回的內存地址是0x0000022eb1113b08
。
也就是說真正拿到手的指針是0x0000022eb1113b08
,而0x0000022eb1113b00
實際上是編譯器malloc
分配內存時得到的首地址,這里多了8
字節。多出這8
字節是其實是記錄數組大小的,數組大小為3
,所以,這8
字節里面記錄的內容就是3
。
釋放數組內存的時候必然會用到這個數字3
?,通過這個數字才知道new
和delete
時數組的大小是多少,從而知道調用多少次類A
的構造函數和析構函數。
new
一個對象數組時真正分配出去的內存概貌:
內存池的概念和實現原理
從malloc
內存分配原理,可以體會到使用malloc
這種分配方式來分配內存會產生比較大的內存浪費,尤其是頻繁分配小塊內存時,浪費更加明顯。
所以內存池就應運而生,內存池的代碼實現千差萬別,但是核心的實現思想比較統一。
內存池要解決的主要問題是:
減少malloc
調用次數,這意味著減少對內存的浪費。
減少對malloc
的調用次數后,能不能提高程序的運行速度呢?(比如避免malloc
的系統調用導致的性能問題)。
內存池的實現原理是什么?
就是用malloc
申請一大塊內存,分配內存的時候,就從這一大塊內存中一點點分配給程序員,當一大塊內存差不多用完的時候,再申請一大塊內存,然后再一點一點地分配給程序員使用。這種做法有效地減少了malloc
的調用次數,從而減少了對內存的浪費。但因為是申請一大塊內存,然后一小塊一小塊分配給程序員用,那么這里面就涉及怎樣分成一小塊一小塊以及怎樣回收的問題。
通過類的operator new
、operator delete
操作符的重載來實現一個針對某個類(類A
)的內存池。
第一次調用operator new
成員函數分配內存的時候,if
條件是成立的,因此會執行該條件內的for
循環語句。整個if
條件中的代碼執行完畢后看起來是一個鏈表?,提前分配了5
塊內存(每塊正好是一個類A
對象的大小)?,然后每一塊的next
(指針成員變量)都指向下一塊的首地址,這樣就非常方便從當前的塊找到下一塊。
內存池初次創建時的情形:
跳出if
語句并執行if
后面的幾行代碼的含義是:m_FreePosi
總是指向下一個能分配的空閑塊的開始地址,而后把tmplink
返回去。
從內存池中返回一塊能用的內存,內存池中的空閑位置指針往下走指向下一個空閑塊:
每次new一個該類(類A
)對象,m_FreePosi
都會往下走指向下一塊空閑待分配內存塊的首地址。假設程序員new
了5
次對象,把內存池中事先準備好的5
塊內存都消耗光了,m_FreePosi
就會指向nullptr
了。此時,程序員第6
次new
對象的話,那么程序中if
條件就又成立了,這時程序又會分配出5
塊內存,并且將新分配的5
塊內存中的第1
塊拿出來返回,m_FreePosi
指向第2
塊新分配的內存塊。
內存池用盡時就要重新new
一大塊內存并鏈入整個內存池中:
深色代表已經分配出去的內存塊,淺色代表沒有分配出去的內存塊。
看內存的回收。
內存池當前已經分配出去了9
塊內存,剩余1
塊空閑內存:
把圖中左上5
塊內存中中間的一塊(第3
塊)內存釋放掉,看一看operator delete
函數里做了什么事。
operator delete
并不是把內存真正歸還給系統,因為把內存真正歸還給系統是需要調用free
函數的,operator delete
做的事情是把要釋放的內存塊鏈回到空閑的內存塊鏈表中來。
(1)由m_FreePosi
串起來的這個鏈(鏈表)代表的是空閑內存塊的鏈,m_FreePosi
指向的是整個鏈的第一個空閑塊的位置,當需要分配內存時,就把這第一個空閑塊分配出去,m_FreePosi
就指向第二個空閑塊。
(2)當回收內存塊的時候,m_FreePosi
就會立即指向這塊回收回來的內存塊的首地址,然后讓回收回來的這塊內存的next
指針指向原來m_FreePosi
所指向的那個空閑塊。所以,m_FreePosi
始終是空閑塊這個鏈的第一個空閑塊(鏈表頭)?。
(3)對于已經分配出去的內存塊的next
指針指向什么已經沒有實際意義了。已經分配出去的內存塊,程序要對它們負責,程序要保證及時地delete
它們促使類A
的operator delete
成員函數被及時執行,從而把不用的內存塊歸還到內存池中。
內存池回收第3
塊內存塊后的情形,由m_FreePosi
串起整個空閑內存塊鏈:
創建類A
對象時所支持的內存池功能就寫好了。進行測試:
如果增加內存池一次分配的內存塊數,就能進一步減少malloc
的調用次數。
感覺能提升一定的速度。
如果不用內存池,而用原生的malloc
進行內存分配,看一看效率如何:
根據運行結果,感覺整個還是要慢一些。
現在把m_sTrunkCount
調整回5
,把MYMEMPOOL
宏定義行改為1
。
在main主函數中修改一下代碼,分配內存的次數由原來的500
萬次修改為15
次,方便觀察內存分配數據。同時,在for
循環中打印一下所分配的內存地址。
每5
個分配的內存地址都是挨著的(間隔8
字節)?,這說明內存池機制在發揮作用(因為內存池是一次分配5
塊內存,顯然這5
塊內存地址是挨在一起的)?。
如果關閉內存池,會發現每次malloc
的地址是不一定挨著的。
當然,這個內存池代碼不完善,例如分配內存的時候是用new
分配的,釋放內存的時候并沒有真正地用delete
來釋放,而是把這塊要釋放的內存通過一個空閑鏈連起來而已。
這種內存池技術的實現要是想通過delete
來真正釋放內存(把內存歸還給操作系統)?,并不容易做到,索性把回收回來的內存攥在手里,需要的時候再分配出去,不需要的時候一直攥在手里(這不屬于內存泄漏)?,只要內存有分配,有回收,這個內存池耗費的內存空間總歸還是有限的。
當整個程序運行即將結束退出的時候,建議把分配出去的內存真正釋放掉,這是一個比較好的習慣。這個內存池所占用的內存如何寫代碼來真正地釋放掉,這個問題待填坑。
嵌入式指針(embedded pointer)
嵌入式指針其實也是一個指針,常用于內存池的代碼實現中。
在剛剛的實現代碼中,為了讓空閑的內存塊能夠正確地分配出去,在類A中引入了一個成員變量next
,這是一個指針,每new
一個類A
對象,都會有這么一個8
字節的next
指針出現,這個多出來的8
字節屬于內存空間的浪費。
嵌入式指針能夠把這8
字節省下來。
嵌入式指針的工作原理就是:
借用類A
對象所占的內存空間的前8
字節(代替next指針)?,這8
字節專門用來鏈住這些空閑的內存塊。當一個空閑的內存塊分配出去之后,這前8
字節的內容就不需要了(對于已經分配出去的內存塊的next指針指向什么已經沒有實際意義)?,即便這8
字節的內容被具體的對象內的數據所覆蓋,也無所謂。
嵌入式指針要成功地使用需要一個前提條件:
那就是這個類A
的sizeof
(new
一個該類對象時所占用的內存字節數)必須要不少于8
字節。
類A
的sizeof
值正好是8字節,而這8
字節恰好是next
成員變量所占的8字節。如果拿掉類A
中的next
成員,就導致sizeof(A)
不夠8
字節,沒法使用嵌入式指針技術了。向類A中增加兩個public
修飾的long long
成員變量,則sizeof(A)
變成16
,這個大小足夠演示嵌入式指針技術了。
利用嵌入式指針實現的內存池初次創建時的情形:
嵌入式指針的實現:
寫一個類,名字叫作TestEP
,為了保證該類的sizeof
值不小于8
,這里給該類兩個long long
類型的成員變量。
類里多了一個結構的定義。其實,跟在類外定義這個結構沒什么區別,只不過如果把這個結構定義在類TestEP
外面的話,外界要用這個obj
結構名時直接寫成obj
,如果定義在類TestEP
里面,外界要用obj
類名時就需要寫成TestEP::obj
,所以這個嵌入式指針是嵌到類里面的一個類(結構)?。
為什么obj
這個結構要嵌入到類TestEP
里面,顯然是因為只想在類TestEP
里面使用這個obj
結構,而不希望在TestEP
類外面也能用obj
這個名字,所以放到里面來(而且一般都用private
修飾)?。
struct obj* next
是一個指針變量,名字叫next
。這個指針變量指向一個obj
結構。
這就是一個鏈表,自己是一個obj
結構對象,把自己這個對象的next
指針指向另外一個obj
結構對象,最終就是把多個自己這種類型的對象通過next
指針串起來:
寫幾行測試代碼看一看嵌入式指針是怎樣使用的:
設置斷點調試,ptmp->next=nullptr
;對應著把mytest
對象內存地址的前8
字節清0
。所以說這里的ptmp->next
占用的是對象mytest
的前8字節。
借用對象的前8
字節保存嵌入式指針指向的內容。測試代碼是讓這個嵌入式指針指向空了,可以讓它指向下一個內存池中內存塊的地址(這里也涉及一個類對象在內存中的布局問題)?。
嵌入式指針改進內存池
對內存池進行改進,應用嵌入式指針來作為塊與塊之間的鏈。
同時上面的內存池是只針對一個類(類A
)而寫的,如果應用到別的類如類B
中,還得在類B
中寫一堆代碼,很不方便。
為了把內存池技術更好地應用到其他類中,這里單獨為內存池技術的使用寫一個類。
有了這個專用的內存池類或者說是內存分配類,怎樣使用?
改造類A
中的代碼。代碼中定義了一個靜態成員變量myalloc
,然后改造了一下類A
中的operator new
和operator delete
成員函數。
每5
個分配的內存地址都是挨著的(間隔16
字節)?,這說明內存池機制在發揮作用(因為內存池是一次分配5
塊內存,顯然這5
塊內存地址是挨在一起的,因為這5
塊內存實際上是一次分配出來的一大塊內存)?。
除了可以對類中的operator new
、operator delete
以及operator new[?]
?、operator delete[?]
重載,也可以重載全局的operator new
、operator delete
以及operator new[?]
?、operator delete[?]?
。
在重載這些全局函數的時候,一定要放在全局空間里,不要放在自定義的命名空間里,否則編譯器會報語法錯。
雖然可以重載全局的operator new
、operator delete
、operator new[?]?
、operator delete[?]?
,但很少有人這樣做,因為這種重載影響面太廣。一般都是重載某個類中的operator new
、operator delete
,這樣影響面比較小(只限制在某個類內)?,也更實用。
當然,如果類 A
中也重載了operator new
、operator delete
、operator new[?]
?、operator delete[?]
?,那么類中的重載會覆蓋掉全局的重載。
推薦一下
0voice