C++ | 小小指針不平凡
大家好,我是梁唐。
相信大家應(yīng)該都學(xué)過C語(yǔ)言或者是C++,C/C++當(dāng)中令初學(xué)者比較頭疼的可能就是指針了。畢竟用起來賊麻煩,要new來new去,用完了還得delete,一不小心就燙燙燙燙燙燙了。
我們今天不講指針的這些技術(shù)細(xì)節(jié),只聊一個(gè)問題,為什么設(shè)計(jì)者會(huì)設(shè)計(jì)出這么一個(gè)東西,難道不知道它很難用嗎?
一
吐槽誰(shuí)都會(huì),但是吐槽完了還能去琢磨一下的,這就體現(xiàn)出差距了。
對(duì)于今天增刪改查明天改查增刪的程序員們來說,的確是沒有使用指針的必要。我只要能從數(shù)據(jù)庫(kù)里讀取、寫入數(shù)據(jù)就行,為什么非得用指針?
但是如果大家寫過一些數(shù)據(jù)結(jié)構(gòu),尤其是一些相對(duì)比較復(fù)雜的數(shù)據(jù)結(jié)構(gòu)立馬就能感受到指針的香味。
我隨便在網(wǎng)上找了一段SBT的代碼片段,給大家演示一下:
- void maintain(node *&o,bool d){
- if(o->ch[d]->ch[d]->s>o->ch[!d]->s){
- rotate(o,!d);
- }else if(o->ch[d]->ch[!d]->s>o->ch[!d]->s){
- rotate(o->ch[d],d);
- rotate(o,!d);
- }else return;
- maintain(o->ch[1],1);
- maintain(o->ch[0],0);
- maintain(o,1);
- maintain(o,0);
- }
這段邏輯是用來維護(hù)二叉樹的平衡的,也就是用來進(jìn)行各種各樣的旋轉(zhuǎn)操作。代碼邏輯看不懂沒有關(guān)系,我們只要看下當(dāng)中函數(shù)調(diào)用的部分,都是把一個(gè)孩子節(jié)點(diǎn)的指針丟進(jìn)函數(shù)里去就結(jié)束了。
如果函數(shù)傳遞的不是指針的話,這段邏輯還成立嗎?
顯然就不成立了,因?yàn)楹瘮?shù)傳遞參數(shù)是值傳遞,傳入進(jìn)去的值都會(huì)生成一個(gè)拷貝。我們?cè)诤瘮?shù)內(nèi)部無論如何修改,也不會(huì)影響函數(shù)外的結(jié)果。
我之前用Python寫過一次,因?yàn)镻ython當(dāng)中沒有指針。同樣的數(shù)據(jù)結(jié)構(gòu)就沒這么方便,想要將一個(gè)節(jié)點(diǎn)替換成另外一個(gè),需要先追溯到它的父節(jié)點(diǎn),然后對(duì)它的父節(jié)點(diǎn)當(dāng)中的內(nèi)容進(jìn)行修改。
再比如有了指針之后,我們可以實(shí)現(xiàn)動(dòng)態(tài)分配內(nèi)存。不僅如此,我們還可以直接操作內(nèi)存地址,完成一些匯編語(yǔ)言才能實(shí)現(xiàn)的高端操作。
所以指針這個(gè)設(shè)計(jì)雖然會(huì)導(dǎo)致各種各樣的問題,學(xué)習(xí)成本也不低,但肯定不是一無是處的。許多語(yǔ)言閹割掉了指針功能,雖然在一些問題和場(chǎng)景當(dāng)中編碼舒服了很多,但也遇到了很多其他的問題。
其中最大的問題就是內(nèi)存管理的問題。
二
C/C++當(dāng)中內(nèi)存管理幾乎都是由程序員來執(zhí)行的,我們要使用一塊內(nèi)存的時(shí)候,就通過new/malloc來創(chuàng)建一個(gè)變量或者是數(shù)組,用完了之后就通過free或者是delete將它銷毀。
這種做法的好處是程序員擁有最高的執(zhí)行權(quán)限,我們可以自由控制內(nèi)存的使用與銷毀。像是Java、Python等語(yǔ)言,內(nèi)存管理都是交給底層程序來控制的,我們?cè)谝粔K內(nèi)存使用結(jié)束之后,無法確定它會(huì)在什么時(shí)候釋放。
相比于交給程序去執(zhí)行,由程序員執(zhí)行內(nèi)存管理本身并不是很糟糕的方案。畢竟程序是死的,總有一些特殊case處理不好。而人為處理,靈活性大大增加。
但遺憾的是,大部分情況下人比程序更加不靠譜,人工控制內(nèi)存的問題明面上很好,但是隱患非常大,經(jīng)常出現(xiàn)意外情況。
舉幾個(gè)例子,比如最常見的new了一塊內(nèi)存忘記了delete,或者是還沒有delete就修改了指針,這樣就會(huì)導(dǎo)致有一塊內(nèi)存申請(qǐng)好了放在那里,但是沒有任何一個(gè)指針指向它,除非程序結(jié)束,再也無法釋放。這也就是常說的內(nèi)存泄漏。
除了程序員馬虎忘記了delete之外,有時(shí)候一些意想不到的錯(cuò)誤也會(huì)導(dǎo)致內(nèi)存泄漏。另外一個(gè)很常見的情況如下:
- Node node = new Node();
- dosomething();
- delete node;
很有可能我們?cè)趫?zhí)行something的時(shí)候,報(bào)錯(cuò)了,然后異常拋出,導(dǎo)致delete的操作被跳過了。
除了內(nèi)存泄漏之外,還有可能出現(xiàn)反向出問題的情況。比如一個(gè)指針,我們還沒用完,下游某個(gè)地方還在使用,突然上游delete了,于是引發(fā)報(bào)錯(cuò)。更要命的時(shí)候,有些古老項(xiàng)目好幾百萬(wàn)行,都不知道這個(gè)指針中間經(jīng)歷了什么,也沒辦法追溯它被delete的地方,有可能這整個(gè)鏈路上的邏輯異常復(fù)雜,導(dǎo)致你根本無力修改,只能特判這種情況,如果出現(xiàn)了就重新new一個(gè),于是又增加了一個(gè)內(nèi)存泄漏的隱患。
由程序員掌管內(nèi)存的管理大權(quán)本身并沒有什么問題,但問題是不是每一個(gè)工程師每時(shí)每刻都是諸葛亮,能夠理解項(xiàng)目當(dāng)中的每一個(gè)細(xì)節(jié)。尤其是當(dāng)這個(gè)項(xiàng)目無比龐大了之后,動(dòng)輒幾百萬(wàn)行代碼的項(xiàng)目,也根本超過了人類能夠理解的極限。
最后的結(jié)果就是雜草叢生,問題無數(shù),甚至工程師們無力解決已有的問題,只能往上添加更多的問題。
那把內(nèi)存管理權(quán)限交給程序是否就高枕無憂了呢?
三
把內(nèi)存完全交給程序管理,這就相當(dāng)于從一個(gè)極端走向了另外一個(gè)極端。從完全人工控制走向了人工完全控制不了,其實(shí)也很有很多問題。
表面上減輕了程序員們的負(fù)擔(dān),甚至對(duì)于很多初學(xué)者來說完全沒有意識(shí)到內(nèi)存管理這個(gè)問題,就天然地以為這是編譯器/解釋器理所應(yīng)當(dāng)?shù)奶炻?。沒有什么是理所應(yīng)當(dāng)?shù)?,?dāng)你以為理所應(yīng)當(dāng)?shù)臅r(shí)候,往往就是問題產(chǎn)生的開始。
雖然各個(gè)語(yǔ)言的內(nèi)存管理策略不盡相同,但往往大同小異,以其中比較典型的Java距離,做個(gè)介紹。
我們可以把Java中的內(nèi)存看成幾個(gè)桶,簡(jiǎn)化一下大概是四個(gè)桶。嚴(yán)格來說還有程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧等內(nèi)容,但是不太重要,就不一一列舉了。
把這四個(gè)桶的原理理解了,基本上就能對(duì)Java內(nèi)存管理做到一知半解了。先說方法區(qū),顧名思義就是存儲(chǔ)方法的地方。方法也就是我們開發(fā)程序的時(shí)候?qū)懙暮瘮?shù),只不過在Java當(dāng)中統(tǒng)一稱為方法,因?yàn)镴ava當(dāng)中一切都是類,所有的函數(shù)都是某一個(gè)類的方法。
方法區(qū)的內(nèi)容是存儲(chǔ)在棧當(dāng)中的,棧當(dāng)中空間比較小一般存儲(chǔ)一些程序執(zhí)行時(shí)的上下文信息。比如當(dāng)前方法調(diào)用棧信息,本地、虛擬機(jī)中的棧信息等等。
方法區(qū)當(dāng)中的內(nèi)存比較小,存儲(chǔ)的東西也比較少,因此很少需要清理,只會(huì)在終極清理機(jī)制——full GC的時(shí)候清理。除了方法區(qū)之外的部分都是堆內(nèi)存。
接著是新生代和老生代,新生代是兩個(gè)空間大小相同的內(nèi)存區(qū)域。當(dāng)我們new一個(gè)新的實(shí)例的時(shí)候,開辟的內(nèi)存其實(shí)就是新生代當(dāng)中的內(nèi)存。為什么新生代當(dāng)中會(huì)有兩個(gè)區(qū)域1和2呢?這是因?yàn)闉榱朔奖氵M(jìn)行minor GC。
新生代當(dāng)中必然有一個(gè)桶是空的,我們假設(shè)1是當(dāng)前使用的,2是空閑的。當(dāng)1內(nèi)存滿了之后,會(huì)觸發(fā)minor GC。虛擬機(jī)會(huì)把1桶當(dāng)中的內(nèi)容一個(gè)一個(gè)按順序倒出來,檢查是否還在使用,如果還在使用就存入2當(dāng)中,如果沒在使用就丟棄。這樣雖然空閑了一塊區(qū)域,但是可以保證新生代當(dāng)中的內(nèi)存是連續(xù)的,保證了內(nèi)存的利用率。
當(dāng)一個(gè)實(shí)例經(jīng)過好幾次minor GC還沒有被清理之后,就說明它活得可能會(huì)比較久,所以要移入老生代當(dāng)中。老生代當(dāng)中存儲(chǔ)的都是這種經(jīng)過了好幾次GC還沒被清理的老家伙。老家伙們活得很久, 而且往往占據(jù)的內(nèi)存也比較大,所以針對(duì)這塊內(nèi)存設(shè)計(jì)了新的回收策略,即major GC。
由于老生代只有一塊,所以我們沒辦法像是新生代一樣按照順序來回倒騰,只能一次性處理。這里采取的策略叫做CMS算法,其實(shí)就是標(biāo)記回收算法。算法會(huì)根據(jù)這些老家伙的使用情況,給它們打上標(biāo)簽,看看哪些還在使用不能清理,哪些是已經(jīng)沒用的,可以干掉了。標(biāo)簽打完之后,會(huì)對(duì)這塊內(nèi)存整個(gè)清理,重新分配內(nèi)存空間,保證清理之后的內(nèi)存也是連續(xù)的。
當(dāng)然由于這當(dāng)中需要打標(biāo)簽,還需要移動(dòng)內(nèi)存分片,因此消耗的時(shí)間會(huì)比較久。內(nèi)存分為新生代和老生代的策略也是盡量避免進(jìn)行這樣比較耗時(shí)的回收策略。
當(dāng)進(jìn)行full GC,也就是所有內(nèi)存區(qū)域一同清理的時(shí)候會(huì)觸發(fā)虛擬機(jī)stop the world,顧名思義也就是停止一切響應(yīng),埋頭清理內(nèi)存。這個(gè)時(shí)候會(huì)導(dǎo)致服務(wù)不可用,這也是Java的一大詬病之一,但這也是GC機(jī)制導(dǎo)致的。只能根據(jù)實(shí)際需要以及GC機(jī)制進(jìn)行優(yōu)化,降低頻率,幾乎不能根除。
也正是因?yàn)閮?nèi)存管理策略比較復(fù)雜,所以如果對(duì)內(nèi)存這塊沒有深入的了解的話,很容易導(dǎo)致一些問題。比如頻繁觸發(fā)GC,導(dǎo)致系統(tǒng)經(jīng)常無法響應(yīng)。或者是干脆內(nèi)存使用不合理,導(dǎo)致經(jīng)常會(huì)出現(xiàn)內(nèi)存溢出的情況,直接OOM崩潰。很多服務(wù)剛上線的時(shí)候運(yùn)行得好好的,過了一段時(shí)間突然崩了,往往十有八九都是內(nèi)存管理沒過關(guān)。
所以很多被內(nèi)存回收折騰得頭疼的工程師又會(huì)懷念當(dāng)年C++指針控制內(nèi)存的方便,想用就用,想釋放就釋放,根本不用看虛擬機(jī)的臉色。但反過來C++那邊也在覺得自動(dòng)回收機(jī)制寫代碼方便,是歷史潮流,所以新版的C++當(dāng)中也開發(fā)了類似可智能回收指針這樣的特性。
兩邊都在掙扎,其實(shí)類似的情況在代碼設(shè)計(jì)當(dāng)中非常非常常見,程序里永遠(yuǎn)沒有完美,只有現(xiàn)實(shí)和妥協(xié)。
好了,關(guān)于指針就聊到這里,希望大家喜歡。