• 2005-05-16

    Java基本功——Reference

    版权声明:转载时请以超链接形式标明文章原始出处和作者信息及本声明
    http://www.blogbus.com/dreamhead-logs/1189478.html

    这是一篇一年多之前便已写就的文章,那时,因为很多Java程序员只求追随新生的事物,却连基本的概念都没有,很多讨论中,很明显是基本功不过硬,于是萌生写一个系列文章,讨论Java的基本功,下面便是在这个想法下催生出的第一篇文章。可事实上,真正完成的也只有这一篇。因为未能及时发布,它就被我遗忘在硬盘的角落中。今天,JavaEye上关于Java传值还是传引用的论战让我记起了自己曾经写过的这篇文章,愿与大家共享。

    Java基本功——Reference

    有这样一种说法,如今争锋于IT战场的两大势力,MS一族偏重于底层实现,Java一族偏重于系统架构。说法根据无从考证,但从两大势力各自的社区力量和图书市场已有佳作不难看出,此说法不虚。于是,事情的另一面让人忽略了。
    偏巧,我是一个喜欢探究底层实现的Java程序员,虽然我的喜好并非纯正咖啡,剑走偏锋却别是一番风味。

    Reference
    Java世界泰山北斗级大作《Thinking In Java》切入Java就提出“Everything is Object”。在Java这个充满Object的世界中,reference是一切谜题的根源,所有的故事都是从这里开始的。

    Reference是什么?
    如果你和我一样在进入Java世界之前曾经浪迹于C/C++世界,就一定不会对指针陌生。谈到指针,往日种种不堪回首的经历一下子涌上心头,这里不是抱怨的地方,让我们暂时忘记指针的痛苦,回忆一下最初接触指针的甜蜜吧!还记得你看过的教科书中,如何讲解指针吗?留在我印象中的一种说法是,指针就是地址,如同门牌号码一样,有了地址,你可以轻而易举找到一个人家,而不必费尽心力的大海捞针。
    C++登上历史舞台,reference也随之而来,容我问个小问题,指针和reference区别何在?我的答案来自于在C++世界享誉盛名的《More Effective C++》。

    1. 没有null reference。
    2. reference必须有初值。
    3. 使用reference要比使用指针效率高。因为reference不需要测试其有效性。
    4. 指针可以重新赋值,而reference总是指向它最初获得的对象

    设计选择:
    当你指向你需要指向的某个东西,而且绝不会改指向其它东西,或是当你实作一个运算符而其语法需要无法有指针达成,你就应该选择reference。其它任何时候,请采用指针。

    这和Java有什么关系?
    初学Java,鉴于reference的名称,我毫不犹豫的将它和C++中的reference等同起来。不过,我错了。在Java中,reference可以随心所欲的赋值置空,对比一下上面列出的差异,就不难发现,Java的reference如果要与C/C++对应,它不过是一个穿着reference外衣的指针而已。
    于是,所有关于C中关于指针的理解方式,可以照搬到Java中,简而言之,reference就是一个地址。我们可以把它想象成一个把手,抓住它,就抓住了我们想要操纵的数据。如同掌握C的关键在于掌握指针,探索Java的钥匙就是reference。

    一段小程序
    我知道,太多的文字总是令人犯困,那就来段代码吧!
    public class ReferenceTricks {
      public static void main(String[] args) {
        ReferenceTricks r = new ReferenceTricks();
        // reset integer
        r.i = 0;
        System.out.println("Before changeInteger:" + r.i);
        changeInteger(r);
        System.out.println("After changeInteger:" + r.i);

        // just for format
        System.out.println();
      
        // reset integer
        r.i = 0;
        System.out.println("Before changeReference:" + r.i);
        changeReference(r);
        System.out.println("After changeReference:" + r.i);
      }

      private static void changeReference(ReferenceTricks r) {
       r = new ReferenceTricks();
       r.i = 5;
       System.out.println("In changeReference: " + r.i);
      }

      private static void changeInteger(ReferenceTricks r) {
       r.i = 5;
       System.out.println("In changeInteger:" + r.i);
      }

      public int i;
    }

    对不起,我知道,把一个字段设成public是一种不好的编码习惯,这里只是为了说明问题。
    如果你有兴趣自己运行一下这个程序,我等你!

    OK,你已经运行过了吗?结果如何?是否如你预期?下面是我在自己的机器上运行的结果:
    Before changeInteger:0
    In changeInteger:5
    After changeInteger:5

    Before changeReference:0
    In changeReference: 5
    After changeReference:0

    这里,我们关注的是两个change——changeReference和changeInteger。从输出的内容中,我们可以看出,两个方法在调用前和调用中完全一样,差异出现在调用后的结果。

    糊涂的讲解
    先让我们来分析一下changeInteger的行为。
    前面说过了,Java中的reference就是一个地址,它指向了一个内存空间,这个空间存放着一个对象的相关信息。这里我们暂时不去关心这个内存具体如何排布,只要知道,通过地址,我们可以找到r这个对象的i字段,然后我们给它赋成5。既然这个字段的内容得到了修改,从函数中返回之后,它自然就是改动后的结果了,所以调用之后,r对象的i字段依然是5。下图展示了changeInteger调用前后内存变化。

         Reference +--------+                Reference +--------+
        ---------->| i = 0  |               ---------->| i = 5  |
                   |--------|                          |--------|
                   | Memory |                          | Memory |
                   |        |                          |        |
                   |        |                          |        |
                   +--------+                          +--------+

        调用changeInteger之前               调用changeInteger之后

    让我们把目光转向changeReference。
    从代码上,我们可以看出,同changeInteger之间的差别仅仅在于多了这么一句。
    r = new ReferenceTricks();
    这条语句的作用是分配一块新的内存,然后将r指向它。
    执行完这条语句,r就不再是原来的r,但它依然是一个ReferenceTricks的对象,所以我们依然可以对这个r的i字段赋值。到此为止,一切都是那么自然。

         Reference +--------+                          +--------+
        ---------->| i = 0  |                          | i = 0  |
                   |--------|                          |--------|
                   | Memory |                          | Memory |
                   |        |                Reference |--------|
                   |        |               ---------->| i = 5  |
                   +--------+                          +--------+

        调用changeReference之前              调用changeReference之后

    顺着这个思路继续下去的话,执行完changeReference,输出的r的i字段,那么应该是应该是新内存中的i,所以应该是5。至于那块被我们抛弃的内存,Java的GC功能自然会替我们善后的。
    事与愿违。
    实际的结果我们已经看到了,输出的是0。
    肯定哪个地方错了,究竟是哪个地方呢?

    参数传递的秘密
    知道方法参数如何传递吗?
    记得刚开始学编程那会儿,老师教导,所谓参数,有形式参数和实际参数之分,参数列表中写的那些东西都叫形式参数,在实际调用的时候,它们会被实际参数所替代。
    编译程序不可能知道每次调用的实际参数都是什么,于是写编译器的高手就出个办法,让实际参数按照一定顺序放到一个大家都可以找得到的地方,以此作为方法调用的一种约定。所谓“没有规矩,不成方圆”,有了这个规矩,大家协作起来就容易多了。这个公共数据区,现在编译器的选择通常是“栈”,而所谓的顺序就是形式参数声明的顺序。
    显然,程序运行的过程中,作为实际参数的变量可能遍布于内存的各个位置,而并不一定要老老实实的呆在栈里。为了守“规矩”,程序只好将变量复制一份到栈中,也就是通常所说的将参数压入栈中。
    打起精神,谜底就要揭晓了。
    我刚才说什么来着?将变量复制一份到栈中,没错,“复制”!
    这就是所谓的值传递。
    C语言的旷世经典《The C Programming Language》开篇的第一章中,谈到实际参数时说,“在C中,所有函数的实际参数都是传‘值’的”。
    马上会有人站出来,“错了,还有传地址,比如以指针传递就是传地址”。
    不错,传指针就是传地址。在把指针视为地址的时候,是否考虑过这样一个问题,它也是一个变量。前面的讨论中说过了,参数传递必须要把参数压入栈中,作为地址的指针也不例外。所以,必须把这个指针也复制一份。函数中对于指针操作实际上是对于这个指针副本的操作。
    Java的reference等于C的指针。所以,在Java的方法调用中,reference也要复制一份压入堆栈。在方法中对reference的操作就是对这个reference副本的操作。
    谜底揭晓
    好,让我们回到最初的问题上。
    在changeReference中对于reference的赋值实际上是对这个reference的副本进行赋值,而对于reference的本尊没有产生丝毫的影响。
    回到调用点,本尊醒来,它并不知道自己睡去的这段时间内发生过什么,所以只好当作什么都没发生过一般。就这样,副本消失了,在方法中对它的修改也就烟消云散了。
     
    也许你会问出这样的问题,“听了你的解释,我反而对changeInteger感到迷惑了,既然是对于副本的操作,为什么changeInteger可以运作正常?”
    呵呵,很有趣的大脑短路现象。
    好,那我就用前面的说法解释一下changeInteger的运作。
    所谓复制,其结果必然是副本完全等同于本尊。reference复制的结果必然是两个reference指向同一块内存空间。
    虽然在方法中对于副本的操作并不会影响到本尊,但对内存空间的修改确实实实在在的。
    回到调用点,虽然本尊依然不知道曾经发生过的一切,但它按照原来的方式访问内存的时候,取到的确是经过方法修改之后的内容。
    于是方法可以把自己的影响扩展到方法之外。
     
    多说几句
    这个问题起源于我对C/C++中同样问题的思考。同C/C++相比,在changeReference中对reference赋值可能并不会造成什么很严重的后果,而在C/C++中,这么做却会造成臭名昭著的“内存泄漏”,根本的原因在于Java拥有了可爱的GC功能。即便这样,我仍不推荐使用这种的手法,毕竟GC已经很忙了,我们怎么好意思再麻烦人家。
    在C/C++中,这个问题还可以继续引申。既然在函数中对于指针直接赋值行不通,那么如何在函数中修改指针呢?答案很简单,指针的指针,也就是把原来的指针看作一个普通的数据,把一个指向它的指针传到函数中就可以了。
    同样的问题到了Java中就没有那么美妙的解决方案了,因为Java中可没有reference的reference这样的语法。可能的变通就是将reference进行封装成类。至于值不值,公道自在人心。

    参考文献
    1 《Thinking in Java》
    2 《More Effective C++》
    3 《The C Programming Language》

    分享到:

    历史上的今天:

    争论TDD 2010-05-16
    引用地址:

    评论

  • 非常受用的文章,真正在这个知识点上受到了醍醐灌顶的感觉!
  • 大师傅吧手到病除 设备擦设备敌百虫撒播后,上午说大话少年板瓦;
  • 例子不好,在ChangeReference()中的r,基本上没有被使用
  • reference 也可以改变指向的对象
    回复elathen说:
    这是Java与C++ reference的差别。
    2005-06-01 10:13:54
  • 楼主blog的定位有点问题,是与人分享编程思想还是普及java教育?定位不明确岂不浪费别人的时间
    回复yally_l说:
    我并不觉得有什么矛盾的,为什么我的思想就不能是简单的内容呢?我并不把自己放在高手的行列中,所以有些想法简单了也很正常。如果这些内容恰好可以帮助别人,何乐不为呢?事实上,正是因为有些朋友对这个问题不清楚,我倒是觉得这篇文章是有价值的。

    与大家分享这些内容是为了让自己快乐,别把我看得很高,那会让感觉我很累的。
    2005-05-24 20:50:11
  • 不明白为什麽出现这样基础的问题,java已经说明了自己的立场=所有的参数都是按值传递!引用本身就是c/c++中的指针概念,将地址copy给参数不就理解了,至于写这样一打大段文章吗?
    回复Killvin说:
    如果我没有看到很多朋友讨论这种问题,我可能也不会把这篇文章贴出来。对于已经越过初级阶段的你,也许它的意义没有那么大,但是对于初入门的朋友,它还是有一些价值的。
    2005-05-22 20:28:47
  • 从这篇开始,读了你的几篇文章,促使我想了许多更本质的问题。对于刚入行的自己,需要一种清醒,感谢你给了我需要的东西。你是我的榜样
    回复yic说:
    能让你思考是对我付出最大的肯定,让我们一起努力!
    2005-05-22 20:25:59
  • 恩 我觉得也可以这样理解, 函数参数,也就是 private static void changeReference(ReferenceTricks r)

    这个r 应该是在内存中独立开辟出来的一个reference对象. 在changeInteger因为这个r 和 外面那个是指向同一块内存区域,所以changeInteger函数内的改变会影响到外面的那个r对象;

    但是在changeReference函数中的这个r ,你用 new ReferenceTricks() 把这个r指向了一个新的对象,所以你怎么在函数里面改变 都是不会影响外面的!!!

    我是这么理解这段程序的,因为我C 的那块忘记完了,可能楼主的解释才是真正的本质情况, 而我的只是一种理解方式, 还需要向楼主学习! 希望能得到楼主的联系方式!!
    回复zhouysh说:
    我的联系方式就在blog上,把鼠标放在“dreamhead”上就可以看到了。^_^
    2005-05-19 10:53:13
  • 品味这篇文章,让我想到另一个问题,除去原始类型,C++与JAVA在对对象的内存分配上有一个非常大的区别: C++可以把一个对象放在栈上,比如:

    class A ;

    A a ;

    对象的实体是位于栈上的, 而JAVA似乎只能让对象实体位于堆上,对象引用留在栈上,当退出栈时,引用失效,对象实体就可以由GC收集了,如果C++要想得到位于堆上的对象,只有用NEW就可以了,但是没有语言层次的回收机制;当然用智能指针包装的除外。