你會swap嗎,按值傳遞還是按引用?


問題##

1、Java到底是按值傳遞(Call by Value),還是按引用傳遞(Call by Reference)?
2、如下面的代碼,為什么不能進行交換?

public CallBy swap2(CallBy a,CallBy b) {
    CallBy t = a;
    a = b;
    b = t;
    return b;
}

3、如下面的代碼,為什么能夠交換成功?

public int swap2(CallBy a,CallBy b) {
	int t = a.value;
	a.value = b.value;
	b.value  = t;
	return t;
}

簡單的C++例子##

為了解決上面的三個問題,我們從簡單的例子開始,為什么是C++的例子呢?看完了你就會明白。

假設我們要交換兩個整形變量的值,在C++中怎么做呢?

我們來看多種方式,哪種能夠做到.

#include <iostream>

using namespace std;

// 可以交換的例子
void call_by_ref(int &p,int &q) {
	int t = p;
	p = q;
	q = t;
}

// 不能交換的例子
void call_by_val_ptr(int * p,int * q) {
	int * t = p;
	p = q;
	q = t;
}

// 不能交換的例子
void call_by_val(int p,int q){
	int t = p ;
	p = q;
	q = t;
}

int main() {
	int a = 3;
	int b = 4;

	cout << "---------- input ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl << endl;
	
	call_by_val(a,b);
	cout << "---------- call_by_val ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl << endl;

	call_by_val_ptr(&a,&b);
	cout << "---------- call_by_val_ptr ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl << endl;

	call_by_ref(a,b);
	cout << "---------- call_by_ref ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl;
}

因為例子非常簡單,看代碼即可知道只有call_by_ref這個方法可以成功交換。這里,你一定還知道一種可以交換的方式,別着急,慢慢來,我們先看看為什么只有call_by_ref可以交換。

1、call_by_ref###

這里寫圖片描述

void call_by_ref(int &p,int &q) {
	push   %rbp
	mov    %rsp,%rbp
	mov    %rdi,-0x18(%rbp)
	mov    %rsi,-0x20(%rbp)
    
    //int t = p;
	mov    -0x18(%rbp),%rax
	//關鍵點:rax中存放的是變量的實際地址,將地址處存放的值取出放到eax中
	mov    (%rax),%eax
	mov    %eax,-0x4(%rbp)
    
    //p = q;
	mov    -0x20(%rbp),%rax
	//關鍵點:rax中存放的是變量的實際地址,將地址處存放的值取出放到edx
	mov    (%rax),%edx
	mov    -0x18(%rbp),%rax
	mov    %edx,(%rax)
    
    //q = t;
	mov    -0x20(%rbp),%rax
	mov    -0x4(%rbp),%edx
	//關鍵點:rax存放的也是實際地址,同上.
	mov    %edx,(%rax)
}

上面這段匯編的邏輯非常簡單,我們看到里面的關鍵點都在強調:

將值存放在實際地址中.

上面這句話雖然簡單,但很重要,可以拆為兩點:

1、要有實際地址.
2、要有將值存入實際地址的動作.

從上面的代碼中,我們看到已經有“存值”這個動作,那么傳入的是否實際地址呢?

// c代碼
call_by_val_ptr(&a,&b);

// 對應的匯編代碼

lea    -0x18(%rbp),%rdx
lea    -0x14(%rbp),%rax
mov    %rdx,%rsi
mov    %rax,%rdi
callq  4008c0 <_Z11call_by_refRiS_>

注意到,lea操作是取地址,那么就能確定這種“按引用傳遞“的方式,實際是傳入了實參的實際地址。

那么,滿足了上文的兩個條件,就能交換成功。

2、call_by_val###

這里寫圖片描述

call_by_val的反匯編代碼如下:

void call_by_val(int p,int q){
	push   %rbp
	mov    %rsp,%rbp
	mov    %edi,-0x14(%rbp)
	mov    %esi,-0x18(%rbp)
	
	//int t = p ;
	mov    -0x14(%rbp),%eax
	mov    %eax,-0x4(%rbp)
	
	//p = q;
	mov    -0x18(%rbp),%eax
	mov    %eax,-0x14(%rbp)
	
	//q = t;
	mov    -0x4(%rbp),%eax
	mov    %eax,-0x18(%rbp)
}

可以看到,上面的代碼中在賦值時,僅僅是將某種”值“放入了寄存器,再觀察下傳參的代碼:

// c++代碼
call_by_val(a,b);

// 對應的匯編代碼
mov    -0x18(%rbp),%edx
mov    -0x14(%rbp),%eax
mov    %edx,%esi
mov    %eax,%edi
callq  400912 <_Z11call_by_valii>

可以看出,僅僅是將變量a、b的值存入了寄存器,而非”地址“或者能找到其”地址“的東西。

那么,因為不滿足上文的兩個條件,所以不能交換。

這里還有一點有趣的東西,也就是我們常聽說的拷貝(Copy)

當一個值,被放入寄存器或者堆棧中,其擁有了新的地址,那么這個值就和其原來的實際地址沒有關系了,這種行為,是不是很像一種拷貝?

但實際上,在我看來,這是一個很誤導的術語,因為上面的按引用傳遞的call_by_ref實際上也是拷貝一種值,它是個地址,而且是實際地址。

所以,應該記住的是那兩個條件,在你還不能真正理解拷貝的意義之前最好不要用這個術語。

2、call_by_val_ptr###

這里寫圖片描述

這種方式,本來是可以完成交換的,因為我們可以用指針來指向實際地址,這樣我們就滿足了條件1:

要有實際地址。

別着急,我們先看下上文的實現中,為什么沒有完成交換:

void call_by_val_ptr(int * p,int * q) {
	push   %rbp
	mov    %rsp,%rbp
	mov    %rdi,-0x18(%rbp)
	mov    %rsi,-0x20(%rbp)
	//int * t = p;
	mov    -0x18(%rbp),%rax
	mov    %rax,-0x8(%rbp)
	//p = q;
	mov    -0x20(%rbp),%rax
	mov    %rax,-0x18(%rbp)
	//q = t;
	mov    -0x8(%rbp),%rax
	mov    %rax,-0x20(%rbp)
}

可以看到,上面的邏輯和call_by_val非常類似,也只是做了將值放到寄存器這件事,那么再看下傳給它的參數:

// c++代碼
call_by_val_ptr(&a,&b);

// 對應的匯編代碼
lea    -0x18(%rbp),%rdx
lea    -0x14(%rbp),%rax
mov    %rdx,%rsi
mov    %rax,%rdi
callq  4008ec <_Z15call_by_val_ptrPiS_>

注意到,lea是取地址,所以這里實際也是將地址傳進去了,但為什么沒有完成交換?

因為不滿足條件2:

將值存入實際地址。

call_by_val_ptr中的交換,從匯編代碼就能看出,只是交換了指針指向的地址,而沒有通過將值存入這個地址而改變地址中的值

Java##

通過上面的例子,我們掌握了要完成交換的兩個條件,也了解了什么是傳引用,什么是傳值,從實際效果來講:

如果傳入的值,是實參的實際地址,那么就可以認為是按引用傳遞。否則,就是按值傳遞。而實際上,傳值和傳引用在匯編層面或者機器碼層面沒有語義,因為都是將某個”值“丟給了寄存器或者堆棧。

所以,類似Java是按值傳遞還是按引用傳遞這種問題,通常沒有任何意義,因為要看站在哪個抽象層次上看。

如果非要定義一下,好吧,也許你會在面試中碰到這種問題,那么最好這樣回答:

Java是按值傳遞的,但可以達到按引用傳遞的效果。

那么,什么是有意義的呢?
上文的那兩個條件。

但從編譯的角度講,引用和地址有很強的關系,卻不是一回事。

Java按值傳遞的行為###

我們回顧下開頭的三個問題中的第二個問題:

如下面的代碼,為什么不能進行交換?

public CallBy swap2(CallBy a,CallBy b) {
    CallBy t = a;
    a = b;
    b = t;
    return b;
}

我們首先從比較簡單的bytecode看起:

public com.haoran.CallBy swap2(com.haoran.CallBy, com.haoran.CallBy);
    descriptor: (Lcom/haoran/CallBy;Lcom/haoran/CallBy;)Lcom/haoran/CallBy;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=4, args_size=3
         0: aload_1
         1: astore_3
         2: aload_2
         3: astore_1
         4: aload_3
         5: astore_2
         6: aload_2
         7: areturn
      LineNumberTable:
        line 45: 0
        line 46: 2
        line 47: 4
        line 48: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lcom/haoran/CallBy;
            0       8     1     a   Lcom/haoran/CallBy;
            0       8     2     b   Lcom/haoran/CallBy;
            2       6     3     t   Lcom/haoran/CallBy;

集中精力看這一塊:

//t = a
0: aload_1
1: astore_3

//a = b
2: aload_2
3: astore_1

//b = t
4: aload_3
5: astore_2

6: aload_2

代碼很簡單,注釋也表明了在做什么, 那么需要不需要看傳遞給它什么參數呢?

不需要,先看下匯編代碼,滿足不滿足”將值放到實際地址“這個條件,我們截取a = b這一句來觀察:

// a = b bytecode
2: aload_2
3: astore_1
/***************************************
* aload_2對應的匯編(未優化)
****************************************/
mov    -0x10(%r14),%rax\n

//----------------------------------------

movzbl 0x1(%r13),%ebx
inc    %r13
movabs $0x7ffff71ad900,%r10
jmpq   *(%r10,%rbx,8)
/***************************************
* astore_1對應的匯編(未優化)
****************************************/
pop    %rax
mov    %rax,-0x8(%r14)

//----------------------------------------

movzbl 0x1(%r13),%ebx
inc    %r13
movabs $0x7ffff71ae100,%r10
jmpq   *(%r10,%rbx,8)

如果將上面的代碼和c++實例中的call_by_ref和call_by_val對比,就會發現,上面的代碼缺失了這樣一種語義:

將值放入實際地址中。

其僅僅是將值放入寄存器或者堆棧上,並沒有將值放入實際地址這個操作。

為什么不需要觀察給這個方法傳參的過程?
這是一個很簡單的必要條件問題,所以,不需要觀察。

從上面的過程來看,Java的行為是Call by Value的。

Java按引用傳遞的行為###

這里寫圖片描述

現在來討論第三個問題:

如下面的代碼,為什么能夠交換成功?

public int swap2(CallBy a,CallBy b) {
	int t = a.value;
	a.value = b.value;
	b.value  = t;
	return t;
}

還是從bytecode先看起:

public com.haoran.CallBy swap2(com.haoran.CallBy, com.haoran.CallBy);
    descriptor: (Lcom/haoran/CallBy;Lcom/haoran/CallBy;)Lcom/haoran/CallBy;
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=3
         0: aload_1
         1: getfield      #2                  // Field value:I
         4: istore_3
         5: aload_1
         6: aload_2
         7: getfield      #2                  // Field value:I
        10: putfield      #2                  // Field value:I
        13: aload_2
        14: iload_3
        15: putfield      #2                  // Field value:I
        18: aload_1
        19: areturn
      LineNumberTable:
        line 41: 0
        line 42: 5
        line 43: 13
        line 44: 18
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      20     0  this   Lcom/haoran/CallBy;
            0      20     1     a   Lcom/haoran/CallBy;
            0      20     2     b   Lcom/haoran/CallBy;
            5      15     3     t   I

聚焦putfield這句字節碼,其對應的是在b.value的值放到操作數棧頂后,拿下這個值, 給a.value:

a.value = b.value;

因為putfield字節碼對應的匯編代碼非常長,我們進一步聚焦其宏匯編,因為CallBy類的value字段,是個int型,所以我們關注itos:

// itos
{
	__ pop(itos);
	if (!is_static) pop_and_check_object(obj);
	// 關鍵點:只看這里
	__ movl(field, rax);
	if (!is_static) {
	  patch_bytecode(Bytecodes::_fast_iputfield, bc, rbx, true, byte_no);
	}
	__ jmp(Done);
}

其中關鍵的一句:__ movl(field, rax);

void Assembler::movl(Address dst, Register src) {
  InstructionMark im(this);
  prefix(dst, src);
  emit_int8((unsigned char)0x89);
  emit_operand(src, dst);
}

movl對應的匯編代碼:

// 對應的關鍵匯編代碼
...
mov    %eax,(%rcx,%rbx,1)
...

上面的匯編代碼的意思是:

將eax中的值存入rcx + rbx*1所指向的地址處。

其中,eax的值就是b.value的值,而rcx+rbx*1所指向的地址就是a.value的地址。

上面的過程滿足了這樣的語義:

將值存入實際地址中.

所以,a.value和b.value可以交換,類似這樣,Java的按值傳遞方式也可以表現出按引用傳遞的行為

另一種交換值的方式##

還記得在C++的實例中,我們提到還有一種交換值的方式,是什么呢?

call_by_WHAT###

void call_by_WHAT(int * p,int * q) {
	int t = *p;
	*p = *q;
	*q = t;
}

這樣傳參:

int main() {
	int a = 3;
	int b = 4;

	cout << "---------- input ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl << endl;

	call_by_WHAT(&a,&b);
	cout << "---------- call_by_WHAT ------------" << endl;
	cout << "a = " << a << ", b = " << b << endl;
}

會不會交換呢?

// 輸出

---------- input ------------
a = 3, b = 4

---------- call_by_WHAT ------------
a = 4, b = 3

從這種方式中,我們也看到了所有能夠交換值的方式的統一性:

1、指針p、q或者對象引用objectRef,能夠直接指向對象的實際地址。
2、要有一個將值放入實際地址的操作:

// C/C++
*p = *q;
...

// Java
putField -> a.value = b.value
...

// 匯編
mov reg_src , (reg_dst)
mov reg_src , (addr_dst)
...

結語##

這里寫圖片描述

老生常談,並無LUAN用,終。


免責聲明!

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



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