为什么说函数只存在值传递 & 引用类型是什么

182 阅读7分钟

​ 前面是俩例子,值传递和引用类型并不冲突, 这两句话不矛盾.

​ 第三节后面会讲到 JVM的引用类型实现,为什么会这样.

1. 例子一

我们先来几个例子. 先来Java的吧. Java最假了. 一般人都认为是引用传递(基本类型值传递). 所以我们又拿C++做例子.

写 a , b 是为了好区分开. 其实 写俩a都行. 不好书面表达

public class Demo {

    public static void main(String[] args) {
        A a = new A();
        System.out.println("初始化 : " + a.name);
        a.name = "main";
        System.out.println("init函数调用前 : " + a.name);
        // change 函数传递
        init(a);
        // 结果 ?
        System.out.println("init函数调用后 : "+a.name);
    }

    static void init(A b) {
        // a被初始化了..
        b = new A();
    }

    private static class A {
        String name;
    }
}

输出 :

初始化 : null
init函数调用前 : main
init函数调用后 : main

结果是啥. 如果真的是引用传递. 那么为啥a.name为啥不是空呢.

我用C++ 给大家写一下.

#include <iostream>
#include <string>

using namespace std;

class A
{
public:
   string name;
};

void init(A *b)
{
   cout << "未改变的地址 : " << b << endl;
   *b = A();
   cout << "改变后的地址 : " << b << endl;
}
int main(int argc, char const *argv[])
{
   A *a = new A();
   cout << "初始化 : " << a->name << endl;
   a->name = "main";
   cout << "init函数调用前 : " << a->name << endl;
   init(a);
   cout << "init函数调用后 : " << a->name << endl;
   return 0;
}

输出

初始化 : 
init函数调用前 : main
未改变的地址 : 0xe699d0
改变后的地址 : 0xe699d0
init函数调用后 : 

显然C++和Java的表现是不一样的. 为什么C++ 赋值可以改变值. 而Java却不是呢.

我们先解释C++的做法.

我们 init(A *b)做了啥

首先是我们定义了一个变量 A *a=new A("main") , 我没有写构造函数, 假设的.

此时传递给函数init(A *b) , 此时 b=a ,a= 0xe699d0 . 所以呢 b= 0xe699d0 .

后面我们的操作就是基于 b 的. 此时我们将 *b = A(); , 是不是将 0xe699d0指针指向了 A() 对象呢(其实就是块内存,指向的是内存的首地址) . 此时是不是修改了 0xe699d0指针 的指向呢. 那么a也等于 0xe699d0 .所以a的值也被修改了 .

然后我们看看Java的做法.

static void init(A b) {
    System.out.println("未改变的地址: 0x"+Integer.toHexString(a.hashCode()));
    // a被初始化了..
    b = new A();
    System.out.println("改变后的地址: 0x"+Integer.toHexString(a.hashCode()));
}

我们打印一下 hashcode. 因为Java的hashcode其实就是确定一个对象的唯一标识, 如果你想深入了解hashcode的话介意看看JVM的源码,C++和Java对象的映射关系 (如果是对象地址的话,那么内存回收会改变大量的内存地址,难道还是地址吗,我们这里不考虑这个.只要知道这个是Java对象的唯一值类似于hash码 ).

输出结果:

初始化 : null
init函数调用前 : main
未改变的地址: 0x6e8cf4c6
改变后的地址: 0x12edcd21
init函数调用后 : main

此时我们发现地址发生了改变. 其实理解了上面C++那部分聪明的就明白了.

由于Java引用类型传递如果是Hotspot虚拟机则实现的就是简单的指针传递. 这个变量指向Java对象的数据区域.

由于一开始 a=0x6e8cf4c6(Java引用对象) , 然后函数赋值 b=a , 此时 b= 0x6e8cf4c6.

关键点在于new A()地方; 执行的是,实例化一个A对象,在栈顶开辟一块空间, 将A对象保存在栈顶中, 此时栈顶值=0x12edcd21, 然后将栈顶值存入到变量b中. 此时b=0x12edcd21 . 那么改变原来0x6e8cf4c6指向的内容了吗,并没有.

我们再看看javac编译后的结果 :

static void init(com.jvm.reference.Demo$A);
descriptor: (Lcom/jvm/reference/Demo$A;)V
flags: ACC_STATIC
Code:
// 栈的深度需要3 , 变量表需要一个,参数一个
  stack=3, locals=1, args_size=1
      // 实例化一个对象
     0: new           #2                  // class com/jvm/reference/Demo$A
     3: dup
     4: aconst_null
     // 调用构造方法
     5: invokespecial #3                  // Method com/jvm/reference/Demo$A."<init>":(Lcom/jvm/reference/Demo$1;)V
     // 将栈顶值存入变量0中
     8: astore_0
     // 返回
     9: return

准确点说Java的做法是 : 如下操作.

void init(A *b)
{
    cout << "未改变的地址 : " << b << endl;
    A a = A();
    b = &a;
    cout << "改变后的地址 : " << b << endl;
}

​ 其实各种做法也有各种做法的好处. 没有绝对的好坏.

2. 例子二

​ 其实你理解上面这个例子. 你就明白了为啥值传递了. 我们继续拿C++说话. Java查看地址不方便.

#include <iostream>

using namespace std;

void swap(int *a, int *b)
{
    int *temp = a;
    a = b;
    b = temp;
    cout << "a : " << *a << " , b : " << *b << endl;
}

int main(int argc, char const *argv[])
{
    int x = 1;
    int y = 2;

    cout << "x : " << x << " , y : " << y << endl;
    // swap 方法.
    swap(&x, &y);

    cout << "x : " << x << " , y : " << y << endl;
    /* code */
    return 0;
}

输出

x : 1 , y : 2
a : 2 , b : 1
x : 1 , y : 2

我们发现为啥函数内部 . a 和 b 成功交换了地址 . 所以 a 和 b的值就互换了 .

但是为啥呢.

我们再次打印一下地址

cout << "&x  : " << &x << " , &y : " << &y << endl;
// 输出 : 
&x : 0x61fefc , &y : 0x61fef8

&x = 0x61fefc , &y=0x61fef8 ,

执行swap函数. 此时 a=0x61fefc , b=0x61fef8 ,

然后经过一番操作, 此时 a=0x61fef8 , b=0x61fefc . 然后输出 *a=2, *b=1, 所以成功了.

但是为啥 x 和 y 没变呢. 是不是发现 &x 和 &y依旧着原来的地址呢.

正确的swap操作 , 必须修改指针指向的值.

void swap(int *a, int *b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

3. Java的引用类型

​ 创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图2-2所示。
  • 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息 ,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图2-3所示。

本文第三节引用自 <<深入理解Java虚拟机>> .

​ 这两种对象访问方式各有优势 :

  • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

所以句柄访问方便管理, 直接指针访问效率高, 但是不方便管理.