來自:程式設計無界(微訊號:qianshic)
本文旨在用最通俗的語言講述最枯燥的基本知識 學過Java基礎的人都知道:值傳遞和取用傳遞是初次接觸Java時的一個難點,有時候記得了語法卻記不得怎麼實際運用,有時候會的了運用卻解釋不出原理,而且坊間討論的話題又是充滿爭議:有的論壇帖子說Java只有值傳遞,有的部落格說兩者皆有;這讓人有點摸不著頭腦,下麵我們就這個話題做一些探討,對書籍、對論壇部落格的說法,做一次考證,以得出信得過的答案。 其實,對於值傳遞和取用傳遞的語法和運用,百度一下,就能出來可觀的解釋和例子數目,或許你看一下例子好像就懂,但是當你參加面試,做一道這個知識點的筆試題時感覺自己會,胸有成熟的寫了答案,卻發現是錯的,或者是你根本不會做。 是什麼原因? 那是因為你對知識點沒有瞭解透徹,只知道其皮毛。要熟讀一個語法很簡單,要理解一行程式碼也不難,但是能把學過的知識融會貫通,串聯起來理解,那就是非常難了,在此,關於值傳遞和取用傳遞,小編會從以前學過的基礎知識開始,從記憶體模型開始,一步步的引出值傳遞和取用傳遞的本質原理,故篇幅較長,知識點較多,望讀者多有包涵。 我們先來重溫一組語法: 舉個慄子: 例子中 所謂資料型別,是程式語言中對記憶體的一種抽象表達方式,我們知道程式是由程式碼檔案和靜態資源組成,在程式被執行前,這些程式碼存在在硬碟裡,程式開始執行,這些程式碼會被轉成計算機能識別的內容放到記憶體中被執行。 資料型別實質上是用來定義程式語言中相同型別的資料的儲存形式,也就是決定瞭如何將代表這些值的位儲存到計算機的記憶體中。 所以,資料在記憶體中的儲存,是根據資料型別來劃定儲存形式和儲存位置的。 4種整數型別:byte、short、int、long 類 有了資料型別,JVM對程式資料的管理就規範化了,不同的資料型別,它的儲存形式和位置是不一樣的,要想知道JVM是怎麼儲存各種型別的資料,就得先瞭解JVM的記憶體劃分以及每部分的職能。 Java語言本身是不能操作記憶體的,它的一切都是交給JVM來管理和控制的,因此Java記憶體區域的劃分也就是JVM的區域劃分,在說JVM的記憶體劃分之前,我們先來看一下Java程式的執行過程,如下圖: 有圖可以看出:Java程式碼被編譯器編譯成位元組碼之後,JVM開闢一片記憶體空間(也叫執行時資料區),透過類載入器加到到執行時資料區來儲存程式執行期間需要用到的資料和相關資訊,在這個資料區中,它由以下幾部分組成:
我們接著來瞭解一下每部分的原理以及具體用來儲存程式執行過程中的哪些資料。 1. 虛擬機器棧 虛擬機器棧是Java方法執行的記憶體模型,棧中存放著棧幀,每個棧幀分別對應一個被呼叫的方法,方法的呼叫過程對應棧幀在虛擬機器中入棧到出棧的過程。 棧是執行緒私有的,也就是執行緒之間的棧是隔離的;當程式中某個執行緒開始執行一個方法時就會相應的建立一個棧幀並且入棧(位於棧頂),在方法結束後,棧幀出棧。 下圖表示了一個Java棧的模型以及棧幀的組成: 棧幀:是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧的棧元素。
每個棧幀中包括: 2. 堆: 堆是用來儲存物件本身和陣列的,在JVM中只有一個堆,因此,堆是被所有執行緒共享的。 3. 方法區: 方法區是一塊所有執行緒共享的記憶體邏輯區域,在JVM中只有一個方法區,用來儲存一些執行緒可共享的內容,它是執行緒安全的,多個執行緒同時訪問方法區中同一個內容時,只能有一個執行緒裝載該資料,其它執行緒只能等待。 方法區可儲存的內容有:類的全路徑名、類的直接超類的權全限定名、類的訪問修飾符、類的型別(類或介面)、類的直接介面全限定名的有序串列、常量池(欄位,方法資訊,靜態變數,型別取用(class))等。 4. 本地方法棧: 本地方法棧的功能和虛擬機器棧是基本一致的,並且也是執行緒私有的,它們的區別在於虛擬機器棧是為執行Java方法服務的,而本地方法棧是為執行本地方法服務的。 有人會疑惑:什麼是本地方法?為什麼Java還要呼叫本地方法? 5. 程式計數器: 執行緒私有的。 從上面程式執行圖我們可以看到,JVM在程式執行時的記憶體分配有三個地方: 相應地,每個儲存區域都有自己的記憶體分配策略: 我們已經知道:Java中的資料型別有基本資料型別和取用資料型別,那麼這些資料的儲存都使用哪一種策略呢? 1. 基本資料型別的儲存: 2. 取用資料型別的儲存 1. 基本資料型別的儲存 我們分別來研究一下: 如上圖,在方法內定義的變數直接儲存在棧中,如 當我們寫“int age=50;”,其實是分為兩步的: 首先JVM建立一個名為age的變數,存於區域性變數表中,然後去棧中查詢是否存在有字面量值為50的內容,如果有就直接把age指向這個地址,如果沒有,JVM會在棧中開闢一塊空間來儲存“50”這個內容,並且把age指向這個地址。因此我們可以知道: 我們再來看“int weight=50;”,按照剛才的思路:字面量為50的內容在棧中已經存在,因此weight是直接指向這個地址的。由此可見:棧中的資料在當前執行緒下是共享的。 那麼如果再執行下麵的程式碼呢? 當程式碼中重新給weight變數進行賦值時,JVM會去棧中尋找字面量為40的內容,發現沒有,就會開闢一塊記憶體空間儲存40這個內容,並且把weight指向這個地址。由此可知: 基本資料型別的資料本身是不會改變的,當區域性變數重新賦值時,並不是在記憶體中改變字面量內容,而是重新在棧中尋找已存在的相同的資料,若棧中不存在,則重新開闢記憶體存新資料,並且把要重新賦值的區域性變數的取用指向新資料所在地址。 成員變數:顧名思義,就是在類體中定義的變數。 我們看per的地址指向的是堆記憶體中的一塊區域,我們來還原一下程式碼: 同樣是區域性變數的age、name、grade卻被儲存到了堆中為per物件開闢的一塊空間中。因此可知:基本資料型別的成員變數名和值都儲存於堆中,其生命週期和物件的是一致的。 前面提到方法區用來儲存一些共享資料,因此基本資料型別的靜態變數名以及值儲存於方法區的執行時常量池中,靜態變數隨類載入而載入,隨類消失而消失 2. 取用資料型別的儲存: 上面提到:堆是用來儲存物件本身和陣列,而取用(控制代碼)存放的是實際內容的地址值,因此透過上面的程式執行圖,也可以看出,當我們定義一個物件時 實際上,它也是有兩個過程: 在執行Person per;時,JVM先在虛擬機器棧中的變數表中開闢一塊記憶體存放per變數,在執行per=new Person()時,JVM會建立一個Person類的實體物件併在堆中開闢一塊記憶體儲存這個實體,同時把實體的地址值賦值給per變數。因此可見: 前面已經介紹過形參和引數,也介紹了資料型別以及資料在記憶體中的儲存形式,接下來,就是文章的主題:值傳遞和取用的傳遞。 值傳遞: 來看個例子: 輸出結果: 從上面的列印結果可以看到: 這是什麼造型呢?!! 下麵我們根據上面學到的知識點,進行詳細的分析: 首先程式執行時,呼叫mian()方法,此時JVM為main()方法往虛擬機器棧中壓入一個棧幀,即為當前棧幀,用來存放main()中的區域性變數表(包括引數)、操作棧、方法出口等資訊,如a和w都是mian()方法中的區域性變數,因此可以斷定,a和w是躺著mian方法所在的棧幀中 而當執行到valueCrossTest()方法時,JVM也為其往虛擬機器棧中壓入一個棧,即為當前棧幀,用來存放valueCrossTest()中的區域性變數等資訊,因此age和weight是躺著valueCrossTest方法所在的棧幀中,而他們的值是從a和w的值copy了一份副本而得,如圖: 因而可以a和age、w和weight對應的內容是不一致的,所以當在方法內重新賦值時,實際流程如圖: 也就是說,age和weight的改動,只是改變了當前棧幀(valueCrossTest方法所在棧幀)裡的內容,當方法執行結束之後,這些區域性變數都會被銷毀,mian方法所在棧幀重新回到棧頂,成為當前棧幀,再次輸出a和w時,依然是初始化時的內容。
取用傳遞: 舉個慄子: 我們寫個函式測試一下: 輸出結果: 可以看出,person經過personCrossTest()方法的執行之後,內容發生了改變,這印證了上面所說的“取用傳遞”,對形參的操作,改變了實際物件的內容。 那麼,到這裡就結題了嗎? 下麵我們對上面的例子稍作修改,加上一行程式碼, 輸出結果: ` 按照上面講到JVM記憶體模型可以知道,物件和陣列是儲存在Java堆區的,而且堆區是共享的,因此程式執行到main()方法中的下列程式碼時 JVM會在堆內開闢一塊記憶體,用來儲存p物件的所有內容,同時在main()方法所在執行緒的棧區中建立一個取用p儲存堆區中p物件的真實地址,如圖: 當執行到PersonCrossTest()方法時,因為方法內有這麼一行程式碼: JVM需要在堆內另外開闢一塊記憶體來儲存new Person(),假如地址為“xo3333”,那此時形參person指向了這個地址,假如真的是取用傳遞,那麼由上面講到:取用傳遞中形參引數指向同一個物件,形參的操作會改變引數物件的改變。 可以推出:引數也應該指向了新建立的person物件的地址,所以在執行PersonCrossTest()結束之後,最終輸出的應該是後面建立的物件內容。 然而實際上,最終的輸出結果卻跟我們推測的不一樣,最終輸出的仍然是一開始建立的物件的內容。 由此可見:取用傳遞,在Java中並不存在。 但是有人會疑問:為什麼第一個例子中,在方法內修改了形參的內容,會導致原始物件的內容發生改變呢? 這是因為:無論是基本型別和是取用型別,在引數傳入形參時,都是值傳遞,也就是說傳遞的都是一個副本,而不是內容本身。 有圖可以看出,方法內的形參person和引數p並無實質關聯,它只是由p處copy了一份指向物件的地址,此時: p和person都是指向同一個物件。 因此在第一個例子中,對形參p的操作,會影響到引數對應的物件內容。而在第二個例子中,當執行到new Person()之後,JVM在堆內開闢一塊空間儲存新物件,並且把person改成指向新物件的地址,此時: p依舊是指向舊的物件,person指向新物件的地址。 所以此時對person的操作,實際上是對新物件的操作,於引數p中對應的物件毫無關係。 因此可見:在Java中所有的引數傳遞,不管基本型別還是取用型別,都是值傳遞,或者說是副本傳遞。 如果是對基本資料型別的資料進行操作,由於原始內容和副本都是儲存實際值,並且是在不同的棧區,因此形參的操作,不影響原始內容。 如果是對取用型別的資料進行操作,分兩種情況,一種是形參和引數保持指向同一個物件地址,則形參的操作,會影響引數指向的物件的內容。一種是形參被改動指向新的物件地址(如重新賦值取用),則形參的操作,不會影響引數指向的物件的內容。 以上為小編關於“值傳遞和取用傳遞”問題的思考和論證,對於這個問題,歷來都是多有爭論,在此希望和讀者一起探討和學習。理性評論,不喜勿噴。 ●編號826,輸入編號直達本文 ●輸入m獲取文章目錄 Web開發 更多推薦《25個技術類公眾微信》 涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。
1. 形參與引數
1public static void func(int a){
2 a=20;
3 System.out.println(a);
4}
5public static void main(String[] args) {
6 int a=10;//變數
7 func(a);
8}
int a=10;中的a在被呼叫之前就已經建立並初始化,在呼叫func方法時,他被當做引數傳入,所以這個a是引數。
而func(int a)中的a只有在func被呼叫時它的生命週期才開始,而在func呼叫結束之後,它也隨之被JVM釋放掉,,所以這個a是形參。2. Java的資料型別
因此
那麼
Java的資料型別有哪些?
2種浮點數型別:float、double
1種字元型別:char
1種布林型別:boolean
介面
陣列3.JVM記憶體的劃分及職能
1. 虛擬機器棧
2. 堆
3. 程式計數器
4. 方法區
5. 本地方法棧
記錄著當前執行緒所執行的位元組碼的行號指示器,在程式執行過程中,位元組碼直譯器工作時就是透過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、異常處理、執行緒恢復等基礎功能都需要依賴計數器完成。
4. 資料如何在記憶體中儲存?
這裡要分以下的情況進行探究:
A.基本資料型別的區域性變數
1int age=50;
2int weight=50;
3int grade=6;
1int age;//定義變數
2age=50;//賦值
我們宣告並初始化基本資料型別的區域性變數時,變數名以及字面量值都是儲存在棧中,而且是真實的內容。1weight=40;
B. 基本資料型別的成員變數
看下圖: 1public class Person{
2 private int age;
3 private String name;
4 private int grade;
5//篇幅較長,省略setter getter方法
6 static void run(){
7 System.out.println("run....");
8 };
9}
10
11//呼叫
12Person per=new Person();
C. 基本資料型別的靜態變數
1Person per=new Person();
1Person per;//定義變數
2per=new Person();//賦值
對於取用資料型別的物件/陣列,變數名存在棧中,變數值儲存的是物件的地址,並不是物件的實際內容。6. 值傳遞和取用傳遞
在方法被呼叫時,引數透過形參把它的內容副本傳入方法內部,此時形參接收到的內容是引數值的一個複製,因此在方法內對形參的任何操作,都僅僅是對這個副本的操作,不影響原始值的內容。 1public static void valueCrossTest(int age,float weight){
2 System.out.println("傳入的age:"+age);
3 System.out.println("傳入的weight:"+weight);
4 age=33;
5 weight=89.5f;
6 System.out.println("方法內重新賦值後的age:"+age);
7 System.out.println("方法內重新賦值後的weight:"+weight);
8 }
9
10//測試
11public static void main(String[] args) {
12 int a=25;
13 float w=77.5f;
14 valueCrossTest(a,w);
15 System.out.println("方法執行後的age:"+a);
16 System.out.println("方法執行後的weight:"+w);
17}
1傳入的age:25
2傳入的weight:77.5
3
4方法內重新賦值後的age:33
5方法內重新賦值後的weight:89.5
6
7方法執行後的age:25
8方法執行後的weight:77.5
a和w作為引數傳入valueCrossTest之後,無論在方法內做了什麼操作,最終a和w都沒變化。
如圖:
因此:
值傳遞傳遞的是真實內容的一個副本,對副本的操作不影響原內容,也就是形參怎麼變化,不會影響引數對應的內容。
”取用”也就是指向真實內容的地址值,在方法呼叫時,引數的地址透過方法呼叫被傳遞給相應的形參,在方法體內,形參和引數指向通愉快記憶體地址,對形參的操作會影響的真實內容。
先定義一個物件: 1public class Person {
2 private String name;
3 private int age;
4
5 public String getName() {
6 return name;
7 }
8 public void setName(String name) {
9 this.name = name;
10 }
11 public int getAge() {
12 return age;
13 }
14 public void setAge(int age) {
15 this.age = age;
16 }
17}
1public static void PersonCrossTest(Person person){
2 System.out.println("傳入的person的name:"+person.getName());
3 person.setName("我是張小龍");
4 System.out.println("方法內重新賦值後的name:"+person.getName());
5 }
6//測試
7public static void main(String[] args) {
8 Person p=new Person();
9 p.setName("我是馬化騰");
10 p.setAge(45);
11 PersonCrossTest(p);
12 System.out.println("方法執行後的name:"+p.getName());
13}
1傳入的person的name:我是馬化騰
2方法內重新賦值後的name:我是張小龍
3方法執行後的name:我是張小龍
不是的,沒那麼簡單,
能看得到想要的效果
是因為剛好選對了例子而已!!!1public static void PersonCrossTest(Person person){
2 System.out.println("傳入的person的name:"+person.getName());
3 person=new Person();//加多此行程式碼
4 person.setName("我是張小龍");
5 System.out.println("方法內重新賦值後的name:"+person.getName());
6 }
1傳入的person的name:我是馬化騰
2方法內重新賦值後的name:我是張小龍
3方法執行後的name:我是馬化騰
為什麼這次的輸出和上次的不一樣了呢?
看出什麼問題了嗎?1Person p=new Person();
2 p.setName("我是馬化騰");
3 p.setAge(45);
4 PersonCrossTest(p);
1person=new Person();
結語
只是在傳遞過程中: