前言
話說現在寫程式已經不是資訊系的專利了,只要隨便一個路人突然想不開,他就可以跑進書店去買一本什麼 Java 啦、C# 啦或是 Ruby on Rails 的書,然後搖身一變成為一個天天加班的苦命 Web Developer 或是軟體工程師。
這都是拜這些程式語言或框架所賜,幫我們把電腦的底層運作抽象化,並且替我們簡化了非常多的事情,讓我們這些寫程式的人可以專注在思考程式的邏輯,而不是繁瑣的細節操作。
我覺得這是一件好事,但這件事同時也是一體兩面的--抽象化的愈厲害,你愈難知道你的程式究竟在你的電腦做了哪些事情,當你的程式不如預期的運行的時候,你愈難肯定是這一層一層的抽象化中的哪個環節出錯了。
所以我也一直覺得,寫程式的人最好理解你用的程式語言到底幫你做了哪些事情,而其中一個很重要的問題就是--你的程式到底對記憶體做了哪些好事。
畢竟十之八九的程式碼,在做的事情就是在記憶體中挖一塊出來操作,設定一些變數,對那些變數加加減減之類的。
所以知道你的程式碼在某個時候對記憶體做了些什麼,可以讓你有一個基本的武器,在事情出錯的時候判斷到底是發生了什麼事情,或是在有的網站上看到有人說 Java is call by reference 這句話的時候,知道這句話根本就是狗屁不通。
因為好像沒有看到比較完整的、簡介性的東西,而這些東西在坊間教你寫程式的書本也很難找到。會講到這類問題的書籍大概主要是 OS 或是 System Programming 的書,但這些書對於只會寫基本的 C / Java 程式的人可能不是很容易懂,所以乾脆自己跳下來寫一篇從 C / Java 的 Programmer 來看記憶體的操作算了。
雖然因為我比較熟的語言只有 C 和 Java,所以這篇是講 C 和 Java 的記憶體管理和運作,但我強列建議您最好也試著去了解,自己所慣用的程式語言對於記憶體到底是怎麼做操作的。
記憶體是什麼
呃……既然都會寫 C 和 Java 程式了,應該不用講記憶體是什麼了吧?
這邊要強調的,就只有是記憶體在抽象的概念上可以把他想象成是一塊連續的儲存空間,而且你可以透過 Memory Address 來做定位。
例如你可以說我要取得從記憶體開頭算來的第 40 個 byte 到第 80 個 byte 之間的空間這樣(這部份我簡化非常多,真的有興趣可以參考 OS 的書)。
有了以上的基本概念,才能理解 C 的 Pointer 和 Java 的 Reference 到底是什麼東西,以及為什麼會有 Memory Leak 的情況發生。
斯斯有兩種,記憶體也有兩種
雖然記憶體實際上是一大塊的連續空間,但由於電腦程式的運作方式,當我們從程式語言的角度來看時,會把記憶體分為以下的這兩大用途:
- Stack
- Heap
要注意的是這裡的 Stack / Heap 和你在資料結構所學的 Stack / Heap 不一樣,單純是由於程式運行時的特性而給的名稱,這也是為什麼我不把他翻成中文的原因,請把你學過的資料結構忘掉!這裡的 Stack 和 Heap 就單純是記憶體的空間!
懂得將記憶分類為 Stack 和 Heap 兩種不同的類型是很重要的一件事,因為你程式的資料是存在 Stack 或是 Heap 上,對於他們什麼時候可以被使用,什麼時候不能用有非常重大的影響,所以請跟著我唸五次:
- 記憶體分成 Stack 和 Heap!
- 記憶體分成 Stack 和 Heap!
- 記憶體分成 Stack 和 Heap!
- 記憶體分成 Stack 和 Heap!
- 記憶體分成 Stack 和 Heap!
這句話是一切的重點,是內功心法第一零一招,通常只要你知道了你的資料到底是存在 Stack 還是 Heap 上,並且能夠在腦海或紙上畫出 Stack 和 Heap 的長相的時候,有高達八成的問題都可以釐清!
Stack 與區域變數
Note
這節用的都是 C 語言的範例,但如果你只會 Java 語言也沒關係,程式碼都很簡單,應該可以很容易看得懂。更重要的是,雖然 Java 程式是運行在 VM 上,但在這部份的概念是一模一樣的,你就把裡面的 C 程碼式全當成是 Java 的 static method 就好。
既然知道記憶體可以分為兩類,那就讓我們先來看看第一類的 Stack 到底是什麼東西。
請回想一下你寫程式的時候,是不是會把某些小功能寫成 C 的函式或 Java 的方法,然後在需要的時候才去呼叫那個函式做事情呢?
而通常在這個時候,你的函式裡面也會宣告一些區域變數來做暫時的運算,你有沒想過,在程式執行的時候,那些變數到底是存在哪裡的?
這一節,就是要回答上面這個問題。
首先,我們來看一個非常簡單的 C 程式碼:
// filename: test.c
#include <stdlib.h>
#include <stdio.h>
void function1()
{
printf("Inside function1\n");
}
void function2()
{
printf("Inside function2\n");
}
void function3()
{
printf("Indeise function3\n");
function1();
function2();
printf("End of function3\n");
}
int main()
{
function1();
function2();
function3();
function2();
function1();
}
以上的程式應該非常簡單,他的輸出長得如下:
brianhsu@USBGentoo ~ $ gcc test.c
brianhsu@USBGentoo ~ $ ./a.out
Inside function1
Inside function2
Indeise function3
Inside function1
Inside function2
End of function3
Inside function2
Inside function1
他的結果完全符合我們的預期,照順序印出我們想看到的字樣。
咦?這和 Stack 有什麼關係嗎?別急,請從 main() 開始畫起,照著程式的流程走一次,每遇到一個 function call 就把該 function 的名字加在現在的圖上,而每一個函式返回的時候,就把那個函式的名字擦掉,最後你會生出像下面一樣的圖(點圖可放大):
上面的這個圖就叫 Call Stack,有沒有發現他的特性和我們在學資料結構時遇到的堆疊非常像呢?
上面的那一格格一格的東西,就是你的程式在執行的時候,被保留下來的記憶體空間。
當你每呼叫一個函式的時候,系統就會幫你多保留一塊的記憶體給你的函式用,而當你的函式結束的時候,原本保留給你的記憶體空間就會被標成可以給其他人用,於是你的圖看起來就會像是一個 Stack 一樣。
而圖中的每一格和每一格都是相臨的,也就是當你呼叫 function1() 的時候,他配置給 function1() 的空間會緊接著在給 main() 的後面。
那這個時候,如果我們在 function1 裡加了一個整數變數,把我們的 function1 改成像下面這樣,會發生什麼事情呢?
void function1()
{
int x = 10;
printf("Inside function1\n");
}
其實很簡單,就是在上面被保留起來給 function1 的記憶體會稍微大一點,讓你可以放下那個整數變數。
所以現在我們的 Call Stack 圖型就會變成像下面這樣:
如果仔細觀察上面的圖的話,你會發現以下兩件事:
- 雖然都都是 function1 裡的 x 變數,但每次 function1 執行的時候,x 所在的位置是不一定的,都會被重新配置一份。
- function1 結束後,x 變數就無法被取得。
以上兩件事情,就是在 C 和 Java 程式當中,存放在 Stack 中的資料會具有的特性。
現在回過頭來看,你可能常常在講 C 或 Java 的書上看到「在 C / Java 裡區域變數的生命週期只有在函數執行時」這句話,現在應該就變得很好理解了--因為函數結束後,存放你的變數的那塊記憶體可能早就已經被其他人給蓋掉了,所以你的變數當然不會繼續活著囉(不然就天下大亂了)。
小結
在這一篇文章中,我們提到了以下幾個重點:
記憶體分成 Stack 和 Heap 兩種用途
在 C / Java 裡的區域變數,其內容都是存在 Stack 上,而這樣的變數有以下的特性:
- 每次執行到該函式時,變數所佔的記憶體空間都會被重新配置一次,而且位置是不一定的。
- 函式結束的時候,該變數就被視為「死亡」,不可以再做存取,原先放那個變數的記憶體空間,也可能被其他的資料蓋掉。
區域變數和 Stack 的關係大致上就是這樣,也是寫 C / Java 的時候應該要注意的一件事情,特別當你是應用遞迴寫程式的時候--因為你每遞迴呼叫一次你的函式,你的 Stack 記憶體上面就會多一層,假設你遞迴呼叫了一百次我們的 function1,那就會出現一百個不同的 x 變數,佔掉你 100 個整數的空間。
這件事乍看之下沒啥重要的,但如果你的遞回非常深,你的 Stack 就會爆掉,例如以下的 Java 程式:
public class Test
{
public static int deep(int x)
{
return deep(x+1);
}
public static void main(String [] args)
{
deep(0);
}
}
現在可以了解為什麼有的時候用 Java 寫遞迴的程式出錯的時候,Java 就會丟個 java.lang.StackOverflowError 出來,然後把程式結束掉了吧?
答案就是因為你的函式一直配置新的空間,導至整個 Stack 爆掉了。
下一篇文章,我們會來看看什麼是 Heap,還有 Heap 有什麼特性。
回響