一個簡單代碼的不簡單實現


注:這個問題其實就是Java里面的參數傳遞都是值傳遞而非引用傳遞。這里的值傳遞包括兩部分,1、基礎類型;2、對象類型。實際上Java根本不存在真正意義上的引用傳遞,我們先從值傳遞和引用傳遞的概念說起。

  在c/c++里面,值傳遞,就是拷貝一份數據傳給參數,比如基本類型,不管你在函數里面如何修改參數,被傳遞的數據依然不會改變;而引用傳遞,也叫做指針傳遞,傳遞的其實是內存的地址,那么當我們改變內存地址所對應的值的時候,真正的參數也就被改變了,也就是所謂的實參(實際參數),而形參和實參都是同一個指針。這里是c/c++的參數傳遞。

  而到了Java里面,基本類型的參數傳遞和c/c++里面是一樣的,而對象類型,傳遞的依然是棧空間中的變量拷貝,我們稱作“引用”,這里的引用指的是對堆內存的引用,這個引用和上面說的引用傳遞的“引用”不是同一個東西,上面說的引用其實就是指針。既然傳遞的是棧內存的引用拷貝,那么結果就是實參和形參指向了同一個對象,此時如果調用這個對象本身的方法來改變對象內部的數據,那么就會改變實參的值,因為都指向同一個,然而,如果此時對形參做賦值操作,那么就僅僅只會改變形參的指向,而不會改變實參的內部結構。所以下的t1方法會改變map的值,而t2就不行。

public class ParamTest {

    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("0", "0");
        
        t1(map);
        System.err.println(map);
        t2(map);
        System.err.println(map);
    }
    
    public static void t1(Map<String, String> map) {
        map.put("1", "1");
    }
    
    public static void t2(Map<String, String> map) {
        map = new HashMap<>();
    }
}

  那么,我們如何來區別和記憶Java里面的參數傳遞呢。這里給個簡單的方法,當我們在傳遞參數的時候,我們腦海時刻要記住一幅畫面,那就是左邊是棧內存,右邊堆內存,實參和形參分別存在於左邊的占空間,是2個不同的變量,都同時指向一個堆內存,當調用對象本身的方法進行內部數據改變的時候,會真正改變值,而進行賦值操作的時候,只不過把拷貝的占空間中的變量指向了另外一個對象,而不會改變實參本身的內部數據。大概就是下面這幅場景。

而c/c++里面的指針傳遞,非常的徹底,操作的直接就是內存單元的值,所以一定會改變。

那么,c/c++和Java為什么會存在這樣的差異呢?我的理解是,這兩種語言的內存模型不一樣,c/c++是可以直接操作內存地址的計算機語言,而Java是基於棧結構的語言,c/c++是編譯型語言,編譯完成之后直接就是計算機能識別的二進制,可以直接操作內存,而Java是基於JVM的的解釋型語言(拋開JIT技術),Java根本就不提供直接操作內存的能力,甚至可以說Java根本沒有辦法提供,因為當運行到某一行代碼的時候,需要解釋,然后通過轉換符號鏈接為真實地址,Java有一套自己的指令集,而這套指令集cpu根本不認識(當然如果JVM的設計者想要實現這種功能或許有可能實現),是通過jvm轉換成cpu認識的指令,在這個轉換動作之前誰都不知道他指向內存的什么位置,因此就無法直接操作內存,所以說,內存模型導致了本質的區別。

值傳遞和引用傳遞的區別在何處?針對c/c++而言,因為Java根本就只存在值傳遞。如果傳遞的參數是一個int類型(4字節),那么拷貝一個參數可能沒什么,如果傳遞的參數是1G的對象,那區別就大了,首先負值這個對象需要消耗更多的時間,然后會消耗更多的內存。因此值引用傳遞性能更好一點。當然還有一個區別,那就是我們方法調用只能最多返回一個結果,return xxx,而如果傳遞的參數里面有多個指針,在方法調用里面給這多個指針賦值,由於形參和實參是同一個地址,實參也就改變了,相當於可以返回多個值。

事實上,引用傳遞是c++里面提出的語法概念,甚至在c語言里面都是沒有這種概念的,提出這個東西最基本的原因是:c++是c的超級,他比c強大,方便,當在c/c++里面操作指針的時候,會帶有各種型號*,比如*p = xxx,那么為了去掉隨處可見的型號,就增加了一類引用類型,目的就是為了減少各種星號操作,本質上是和指針是一回事,就類似於一種語法糖,更好用一點。我們在理解上面完全可以把他等同於指針(事實上本來就是),所以說引用傳遞和指針傳遞本質上是一回事。他和windows上的快捷方式,Linux上的鏈接是一個道理,理解成別名也行。

題外話:這種問題其實不太清楚在平時的開發當中也不會有太多問題,事實上很多人確實不理解或者不知道,但是也不影響開發,而這個概念在面試的時候可能會碰到,特別面試高級開發,那么,如果沒有c/c++的背景,很有可能對於一個Java程序員,到死那天都不一定會明白。這也就是沒有c/c++背景而只有Java這種高級語言背景的程序員的短板,所以一直以來c/c++程序員看不起Java程序員,覺得Java程序員不夠底層(一直以來的偏見:越是底層就越牛逼)。

而這篇帖子接下來的部分,雖然解決了交換值,但是導致的后果就是直接破壞了JDK的Integer的常量池,犧牲了JVM的正確性,當然帖子也僅僅是給了個解決方法,並不是要我們去這樣子做。

 

前幾天看有人貼了一個java代碼的問題,實在有意思,今天拿出來和大家分享分享。題目是這樣的:給定兩個Integer類型的變量a和b,要求實現一個函數swap,交換他們的值。代碼如下:

 

 

====想一想的分割線 ====

 

大家用30秒鍾想想怎么樣實現呢?

 

====時間到的分割線 ====

 

估摸着好多盆友一看這個題目,第一反應是:擦,這么簡單的題,讓我來做,是不是在侮辱我的智商!!!

 

最簡單的實現:

這題目初看一眼,確實好簡單,可能只需要10秒鍾就可以完成(主要時間花在打字上):

 

 

好了,這就是實現代碼,三行!那我們來看看結果:

 

before : a = 1, b = 2

after : a = 1, b = 2

 

怎么樣,這個結果你猜對了嘛?就是完全沒有交換。那是為什么呢?老王畫了一張圖:

 

 

在我們的main函數里,有兩個對象變量a和b,他們分別指向堆上的兩個Integer對象,地址分別為:0x1234和0x1265,值分別為1和2。在java里,Object a = new Object()這句話執行類似於c++里面的CObj* c = new CObj(),這里a和c實際都是指針(java稱做引用),a和c的值,實際是一個內存地址,而不是1、2、3這樣的具體的數字。

 

所以,在做swap函數調用的時候,傳遞的是值,也就是i1得到的a的值:一個內存地址,指向0x1234。同理,i2得到的也是b的值:另外一個內存地址:0x1265。

 

好了,現在swap入棧,i1、i2、tmp都是指針:

tmp = i1;    // tmp得到i1的值:0x1234

i1    = i2;     // i1得到i2的值:0x1265

i2    = tmp; // i2得到tmp的值:0x1234

 

可以看到,在swap里面,i1和i2做了一個指針交換,最后的結果如下:

 

 

最終,a和b還是指向對應的內存區域,而這個內存區域的值還是不變。所以,swap這個函數等於啥都沒干,完全是浪費表情...

 

那這個題目似乎看起來就是無解的,對嘛?(誰這么無聊搞一個無解的題目來浪費表情!!!)

 

換值,解題的曙光:

在准備放棄之前,我們發現了有一個解法似乎可以做:如果把地址0x1234和0x1265中的值1和2對換,a和b的值就變化了,對吧!

 

那我們就聚焦到用什么方法可以改變這個值呢?

 

如果Integer提供一個函數,叫做setIntValue(int value),那就萬事大吉了。我們可以實現這樣的代碼:

 

public static void swap(Integer i1, Integer i2)

{

    // 第二種可能的實現

    int tmp = i1.getIntValue()

    i1.setIntValue(i2.getIntValue());

    i2.setIntValue(tmp);

}

 

於是,我們就去查閱java.lang.Integer的代碼實現。可惜的是,他沒有這個函數...我們的夢想、我們的曙光,就這樣破滅了...

 

反射,又燃起新的曙光:

在我們快要絕望的時候,我們突然發現了這個東東:

 

/**

* The value of the {@code Integer}.

*

* @serial

*/

private final int value;

 

java的Integer實現,實際內部將整數值存放在一個叫int類型的value變量里。他雖然有get函數,但是卻沒有set函數。因為他是final的(不可修改)!

 

那怎么辦呢?哦,我們差點忘了java里有一個神器:反射!我們可以用反射把取到這個變量,並賦值給他,對吧!

 

 

於是,我們寫下了如上的代碼。我們從Integer類里面,取出value這個屬性,然后分別設置上對應的值。哈哈哈,這下總該完美了吧!run一把:

 

 

sad... 我們得到了這樣的異常:私有的、final的成員是不准我們訪問的!

 

看起來似乎真的沒辦法了。

 

老王的絕殺:

這時候,老王從口袋里掏出了以前存起來的絕殺武器:反射訪問控制變量:

 

AccessibleObject.setAccessible(boolean flag)

 

Field這個類是從AccessibleObject集成下來的,而AccessibleObject提供了一個方法,叫做setAccessible,他能讓我們改變對於屬性的訪問控制。

 


 

他會將override變量設置為我們想要的值,然后在Field類里面:

 

 

只要這個override的只被設置成true,我們就可以順利調用set函數啦,於是,我們就簡單改一下實現代碼:

 

 

就只加了這一句話,我們就成功了!哈哈哈哈!!! 來看結果吧:

 

before : a = 1, b = 2

after  : a = 2, b = 2

 

等等等等, 好像a已經變了,但是b似乎還沒變! 這是怎么搞的?同樣的實現方法,a變了,b沒變,完全說不通啊,難道java虛擬機出問題了?這個時候,心里真是一萬頭草泥馬奔過...

 

看似只差一步,實際還有萬里之遙:

那問題到底出在哪兒呢?那我們重頭開始看看這段代碼。

 

 

在函數的一開始,我們就定義了兩個變量:Integer a = 1; Integer b = 2; 這里1和2是主類型,換句話說他們是int類型,而a和b是Integer類型。他們是等價的嘛?回答是:NO!!!

 

裝箱

那如果類型不等價,為啥編譯的時候不出錯呢?這里就要談到一個java編譯器的一個特性:裝箱。這個是個什么東東?

 

按道理說,我們給a賦值的時候,應該是這樣寫:Integer a = new Integer(1),這才是標准的寫法,對吧。不過這樣寫多麻煩啊,於是,java編譯器給大家做了一個方便的事兒,就是你可以Integer a = 1這樣寫,然后由編譯器來幫你把剩下的東西補充完整(java編譯器真是可愛,他還有很多其他的糖衣,以后有機會老王專門來介紹)。

 

那編譯器給我們做了什么事情呢?難道是:

a = 1 === 編譯 ===> a = new Integer(1) ?

 

老王最初也認為是這樣的,不過后來發現,錯了,他做的操作是:

a = 1 === 編譯 ===> a = Integer.valueOf(1)

 

上面這個過程像不像把1這個int類型放入到Integer的箱子里呢?

 

這是怎么確認的呢?很簡單,我們用javap來查看編譯后的Swap.class代碼即可:

 

 

看,我們的main函數第一行,定義Integer a = 1,實際上是做了 Integer a = Integer.valueOf(1)。這個確實是讓人出乎意料。那這個函數做了什么事情呢?

 

 

這個函數的參數是一個int,然后如果這個int在IntegerCache的low和high之間,就從IntegerCache里面獲取,只有超出這個范圍,才新建一個Integer類型。

 

 

這是IntegerCache的實現,默認在-128和127之間的數,一開始就被新建了,所以他們只有一個實例。老王畫了下面的示意圖(為了讓大家看的清楚,沒有畫完所有的內存)

 


 

我們可以這樣來驗證:

 

Integer i1 = 1;

Integer i2 = 1;

      

Integer i3 = 128;

Integer i4 = 128;

      

System.out.println(i1 == i2);

System.out.println(i3 == i4);

 

大家猜到答案了么? 結果是:true, false

 

因為Integer i1 = 1; 實際是Integer i1 = Integer.valueOf(1),在cache里,我們找到了1對應的對象地址,然后就直接返回了;同理,i2也是cache里找到后直接返回的。這樣,他們就有相同的地址,因而雙等號的地址比較就是相同的。i3和i4則不在cache里,因此他們分別新建了兩個對象,所以地址不同。

 

好了,做了這個鋪墊以后,我們再回到最初的問題,看看swap函數的實現。

 

這個函數的入參:i1和i2分別指向a和b對應的內存地址,這個時候,將i1的值(也就是value)傳遞給int型的tmp,則tmp的值為整數值1,然后我們想把i2的整數值2設置給i1:f.set(i1, i2.intValue()); 這個地方看起來很正常吧?

 

我們來看看這個函數的原型吧:public void set(Objectobj, Object value) 他需要的傳入參數是兩個Object,而我們傳入的是什么呢? Integer的i1,和int的i2.intValue()。對於第一個參數,是完全沒問題的;而第二個參數,編譯器又給我們做了一次裝箱,最終轉化出來的代碼就像這樣:

 

i1.value = Integer.valueOf(i2.intValue()).intValue();

 

那我們手動執行一下,

 

a、i2.intValue() -> 2

b、Integer.valueOf(2) -> 0x1265

c、0x1265.intValue() -> 2

d、i1.value -> 2

 

所以這個時候,內存里的數據就是這樣的了:0x1234被改成2了!!!

 


 

接着,我們執行下一句:f.set(i2, tmp); 按照上面的步驟,我們先展開:

 

i2.value = Integer.valueOf(tmp).intValue();

 

這里tmp等於1,於是分步執行如下:

 

a、Integer.valueOf(1) -> 0x1234

b、0x1234.intValue() -> 2

c、i2.value -> 2

 

注意步驟b的值就是上一步從1改成2的那個值,因此最終內存的值就是:

 


 

所以,我們才看到最后a和b輸出的都是2。終於終於,我們分析清楚了結果了~~

 

那要達到最后我們要求的交換,怎么樣修改呢?我們有兩種方法

 

1、不要讓Integer.valueOf裝箱發揮作用,避免使用cache,因此可以這樣寫:

 

 

我們用new Integer代替了Integer.valueOf的自動裝箱,這樣tmp就分配到了一個不同的地址;

 

2、我們使用setInt函數代替set函數,這樣,需要傳入的就是int型,而不是Integer,就不會發生自動裝箱

 



so...問題解決了!

 

==== 總結的分割線 ====

 

看看,就是這么簡單的一個代碼實現,卻隱藏了這么不簡單的實現,包含了:

1、函數調用的值傳遞;

2、對象引用的值乃是內存地址;

3、反射的可訪問性;

4、java編譯器的自動裝箱;

5、Integer裝箱的對象緩存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM