Java是按值传递还是按引用传递?

7757

我一直认为Java使用的是按引用传递。然而,我看了一篇博客文章,声称Java使用的是按值传递。我不认为我理解作者所做的区分。

这是什么意思?


7
我们更常用的说法是,一个“按引用传递”的变量可以被改变。这个术语出现在教科书中,因为语言理论家需要一种区分原始数据类型(int、bool、byte)和复杂结构对象(数组、流、类)的方法,也就是说,那些可能具有无限内存分配的对象。 - jlau
7
我想提醒您,在大多数情况下不需要考虑这个问题。我在编程中使用Java很多年,直到学习了C++之前,我完全不知道传递引用和传递值是什么意思。直到那时,我的直觉解决方案总是有效的,这就是为什么Java是最适合初学者的语言之一。因此,如果您目前担心您的函数需要引用还是值,请按原样传递它,您会没问题的。 - Tobias
73
Java通过值传递引用。 - The Student
24
简单来说,这种混淆是因为在Java中,所有非基本数据类型都是通过"引用"来处理/访问的。然而,传递始终是按值进行的。因此,对于所有非基本类型,引用是按其值传递的。所有基本类型也都是按值传递的。 - Ozair Kafray
6
我觉得这篇文章非常有帮助: https://www.baeldung.com/java-pass-by-value-or-pass-by-reference - Natasha Kurian
显示剩余16条评论
95个回答

173

我很惊讶没有人提到巴巴拉·利斯科夫 (Barbara Liskov)。1974 年她设计 CLU 时,遇到了类似的术语问题,她为值为引用的“按值调用”这种特定情况创造了术语 共享调用(也称为 对象共享调用对象调用)。


另一个术语,加剧了关于Java岛的混乱,只是因为说“根据我们在堆栈上找到的内容,对象是按引用传递”的说法在政治上不正确。 - Sam Ginrich

141

问题的关键在于,“按引用传递”表达式中的单词“reference”,其意义与Java中单词“reference”通常的含义完全不同。

通常情况下,在Java中,“reference”指的是一个对象的引用。但是,编程语言理论中的技术术语“按引用/按值传递”所谈论的是一个引用内存单元的变量的引用,这是完全不同的东西。


是的,对象引用在技术上是一个句柄,而不是地址,因此甚至比“按值传递”更进一步。 - Sam Ginrich

111

已经有很好的答案涵盖了这个问题。我想通过分享一个非常简单的例子(可以编译)来做出小贡献,以对比C ++中的按引用传递和Java中的按值传递的行为。

几点说明:

  1. 术语“引用”具有两个不同含义的重载。在Java中,它仅表示指针,但在“按引用传递”的上下文中,它表示对传入的原始变量的句柄。
  2. Java是按值传递的。 Java是C的后代(其他语言之一)。在C之前,几种(但并非所有)早期语言,如FORTRAN和COBOL支持PBR,但C不支持。 PBR允许这些其他语言在子程序内更改传递的变量的值。为了实现相同的功能(即在函数内更改变量的值),C程序员将变量的指针传递到函数中。受C启发的语言,如Java,借鉴了这个思想,并继续像C一样传递方法的指针,只不过Java将其称为引用。再次强调,这是“按引用传递”中“引用”一词与此不同的用法。
  3. C ++允许通过使用“&”字符声明引用参数来进行按引用传递(该字符恰好是在C和C ++中用于指示一个变量地址的相同字符)。例如,如果我们通过引用传递指针,则参数和参数不仅指向同一对象。而且它们是相同的变量。如果一个被设置为不同的地址或为空,另一个也是如此。
  4. 在下面的C++示例中,我通过引用传递了一个指针到以null结尾的字符串。在下面的Java示例中,我通过值传递了一个指向String的Java引用(再次强调,它与指向String的指针相同)。注意注释中的输出。

C++按引用传递示例:

using namespace std;
#include <iostream>

void change (char *&str){   // the '&' makes this a reference parameter
    str = NULL;
}

int main()
{
    char *str = "not Null";
    change(str);
    cout<<"str is " << str;      // ==>str is <null>
}

Java按值传递“Java引用”示例

public class ValueDemo{
    
    public void change (String str){
        str = null;
    }

     public static void main(String []args){
        ValueDemo vd = new ValueDemo();
        String str = "not null";
        vd.change(str);
        System.out.println("str is " + str);    // ==> str is not null!!
                                                // Note that if "str" was
                                                // passed-by-reference, it
                                                // WOULD BE NULL after the
                                                // call to change().
     }
}

编辑

有几个人写了评论,似乎表明他们要么没有看我的示例,要么不理解c++的示例。不确定出现问题的地方在哪里,但猜想c++的示例不清楚。我将在Pascal中发布相同的示例,因为我认为按引用传递在Pascal中看起来更整洁,但我可能是错的。我可能只会使人更加困惑;希望不是这样。

在Pascal中,通过引用传递的参数称为“var参数”。请注意setToNil过程下面的关键字“var”,它前面是参数“ptr”。当指针传递到此过程时,它将被传递按引用。请注意行为:当此过程将ptr设置为nil(这是Pascal中的NULL),它将把参数设置为nil--您不能在Java中这样做。

program passByRefDemo;
type 
   iptr = ^integer;
var
   ptr: iptr;
   
   procedure setToNil(var ptr : iptr);
   begin
       ptr := nil;
   end;

begin
   new(ptr);
   ptr^ := 10;
   setToNil(ptr);
   if (ptr = nil) then
       writeln('ptr seems to be nil');     { ptr should be nil, so this line will run. }
end.

编辑2

以下摘自Ken Arnold、Java创始人James Gosling以及David Holmes所写的《Java编程语言》第2章第2.6.5节。

所有方法参数都是通过“值传递”传递的。换句话说,在方法中,参数变量的值是调用者指定的参数的副本。

他还就对象作出了同样的论点……

需要注意的是,当参数为对象引用时,传递的是对象引用,而不是对象本身,因为它是按值传递的。

在同一部分的末尾,他对Java仅使用值传递而从不使用引用传递做出了更广泛的陈述。

Java编程语言不会通过引用传递对象,而是通过值传递对象引用。由于两个相同引用的副本指向同一个实际对象,通过一个引用变量进行的更改可以通过另一个引用变量观察到。只有一种参数传递模式-按值传递,这有助于让事情保持简单。

该书的这一部分对Java中的参数传递和值传递与引用传递的区别进行了很好的解释,而且这还是Java的创始人所写。我鼓励任何人都去阅读一下,特别是如果你仍然不确定的话。

我认为两种模型之间的差异非常微妙,除非你有使用过按引用传递的编程经验,否则很容易错过它们之间的区别。

希望这能解决争论,但可能不会。

编辑3

我或许对这篇文章有点痴迷。也许是因为我觉得Java的创造者们无意中散布了错误信息。如果他们没有使用“引用”这个词来代替指针,而是使用其他词汇,比如dingleberry,那么就不会有问题。你可以说,“Java按值传递dingleberries而不是按引用传递”,这样就不会引起混淆了。

这就是为什么只有Java开发人员会遇到这个问题。他们看到“引用”这个词,就认为自己完全知道它的含义了,所以甚至不考虑反对的论点。

无论如何,我在早期的帖子评论中注意到了一个气球类比,我非常喜欢。因此,我决定将一些剪贴画粘在一起,制作一组漫画来说明这一点。

按值传递引用--对引用的更改不会反映在调用者的范围内,但对象的更改会反映出来。这是因为引用被复制了,但原始引用和副本都指向同一个对象。

Passing Object references By Value

按引用传递--没有引用的副本。单个引用由调用者和被调用的函数共享。对引用或对象数据的任何更改都会反映在调用者的范围内。 Pass by reference

编辑4

我看到过有关此主题的文章描述了Java中参数传递的底层实现,我认为这很棒,非常有帮助,因为它使抽象的想法具体化了。但是,对我来说,问题更多地涉及语言规范中描述的行为,而不是行为的技术实现。这是Java语言规范第8.4.1节的摘录:

当调用方法或构造函数(§15.12)时,实际参数表达式的值会初始化新创建的参数变量,每个声明类型,在执行方法或构造函数体之前。在DeclaratorId中出现的标识符可以在方法或构造函数体中用作简单名称,以引用形式参数。

这意味着,在执行方法之前,Java会创建传递参数的副本。像大多数学习编译器的人一样,我使用了Dragon Book,它是编译器书籍的经典之作。第1章中对“按值调用”和“按引用调用”的描述与Java规范完全匹配。

回到我上学时学习编译器的时候,90年代我使用的是1986年出版的第一版书,早于Java约9或10年。然而,我刚刚找到了2007年出版的第二版,其中实际提到了Java!第1.6.6节标为“参数传递机制”的描述了参数传递的情况。以下是标题为“按值调用”的摘录,其中提到了Java:

在值调用中,实参被评估(如果它是一个表达式)或复制(如果它是一个变量)。该值被放置在被调用过程的相应形式参数所属的位置。这种方法在C和Java中被使用,并且在C ++中是一种常见选项,以及大多数其他语言中也是如此。该方法被广泛应用于IT技术领域。

2
@SamGinrich,在这种情况下,你正在传递对该对象的引用。对象存在于内存中的某个位置。引用(又称为指针)类似于一个原始数据类型(如long),它保存了对象的内存地址。传递到方法中的实际上是引用的副本。由于传递的是引用的副本,因此这是按值传递(即通过值传递引用)。如果你在方法内将副本设置为null,这不会影响原始对象。如果这是通过引用传递,则将副本设置为null也会将原始对象设置为null。 - Sanjeev
2
@SamGinrich 如果您查看传值的定义,那就是它的本质 - PBV = 传递一个副本。如果您查看Java语言定义,那么Java确实是这样做的。我已经包括了来自“龙书”和Java语言规范(编辑4)的摘录。此外,Arnold和Gosling都是备受推崇的计算机科学家和Java的创造者。他们实际上并没有重命名已经确立的概念。如果您查看他们的书中的摘录(编辑2),他们所说的与我的帖子完全相同,并且与已确立的计算机科学一致。 - Sanjeev
1
@SamGinrich 这些定义在 Java 之前就存在了。它们不是“某个 Java 大师”的定义。《龙书》在 Java 之前就存在了。计算机科学在 Java 之前就存在了。你发布的链接完全没有理解交换测试的要点。为了使其有效,您需要交换实际指针,而不是它们所指向的内容。相信 Sethi、Ullman、Lam 和 Aho 等人,比听取互联网上某个随意写教程的人更加明智。此外,Gosling 不仅仅是一个“大师”。他是 Java 的创造者。我相信他比任何人都更有资格评论 Java。 - Sanjeev
1
哎呀,我完全同意你上面的回答,但不同意引用定义,因为它们既不是来自你也不是来自我。 - Sam Ginrich
1
@SamGinrich,没有人改变定义。我在这篇文章中努力收集了所有信息,这正好说明了这一点。如果您认为有人试图更改定义,请分享一些证据。 - Sanjeev
显示剩余21条评论

102
Java始终是按值传递,而不是按引用传递。
首先,我们需要了解什么是按值传递和按引用传递。
按值传递意味着在内存中复制传入的实际参数的值。这是实际参数内容的副本。
按引用传递(也称为按地址传递)意味着存储实际参数地址的副本。
有时候Java会给人一种按引用传递的错觉。让我们通过下面的例子来看看它是如何工作的:
public class PassByValue {
    public static void main(String[] args) {
        Test t = new Test();
        t.name = "initialvalue";
        new PassByValue().changeValue(t);
        System.out.println(t.name);
    }
    
    public void changeValue(Test f) {
        f.name = "changevalue";
    }
}

class Test {
    String name;
}

这个程序的输出是:
changevalue
让我们一步一步来理解:
Test t = new Test();

众所周知,它将在堆中创建一个对象,并将引用值返回给t。例如,假设t的值为0x100234(我们不知道实际的JVM内部值,这只是一个例子)。

first illustration

new PassByValue().changeValue(t);

当将引用t传递给函数时,它不会直接传递对象test的实际引用值,而是创建t的副本,然后将其传递给函数。由于这是按值传递,它传递的是变量的副本而不是实际的引用。由于我们说t的值是0x100234,所以t和f都将具有相同的值,因此它们将指向同一个对象。

second illustration

如果您在使用引用f的函数中更改任何内容,它将修改对象的现有内容。这就是为什么我们得到了输出“changevalue”,它在函数中被更新。
为了更清楚地理解这一点,请考虑以下示例:
public class PassByValue {
    public static void main(String[] args) {
        Test t = new Test();
        t.name = "initialvalue";
        new PassByValue().changeRefence(t);
        System.out.println(t.name);
    }
    
    public void changeRefence(Test f) {
        f = null;
    }
}

class Test {
    String name;
}

这会抛出一个NullPointerException吗?不会,因为它只传递了引用的副本。 在按引用传递的情况下,它可能会抛出一个NullPointerException,如下所示:

third illustration


102

在Java中,一切都是引用。因此,当你有像这样的东西时:

Point pnt1 = new Point(0,0);

Java会执行以下操作:

  1. 创建新的Point对象
  2. 创建新的Point引用并将其初始化为先前创建的Point对象上的“点(引用)”。
  3. 从此处开始,在整个Point对象的生命周期中,你将通过pnt1引用访问该对象。因此,我们可以说在Java中,通过其引用来操作对象。

enter image description here

Java不会按引用传递方法参数;它会按值传递。我将使用这个网站上的示例:

public static void tricky(Point arg1, Point arg2) {
  arg1.x = 100;
  arg1.y = 100;
  Point temp = arg1;
  arg1 = arg2;
  arg2 = temp;
}
public static void main(String [] args) {
  Point pnt1 = new Point(0,0);
  Point pnt2 = new Point(0,0);
  System.out.println("X1: " + pnt1.x + " Y1: " +pnt1.y); 
  System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);
  System.out.println(" ");
  tricky(pnt1,pnt2);
  System.out.println("X1: " + pnt1.x + " Y1:" + pnt1.y); 
  System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);  
}

程序的流程:

Point pnt1 = new Point(0,0);
Point pnt2 = new Point(0,0);
创建两个不同的点对象,分别关联两个不同的引用。 enter image description here
System.out.println("X1: " + pnt1.x + " Y1: " +pnt1.y); 
System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);
System.out.println(" ");

正如预期的输出:

X1: 0     Y1: 0
X2: 0     Y2: 0

在这行代码中,'pass-by-value' 开始发挥作用...

tricky(pnt1,pnt2);           public void tricky(Point arg1, Point arg2);

引用 pnt1pnt2 作为参数传递给了一个名为“tricky”的方法,这意味着现在你的引用 pnt1pnt2 有了它们的副本,分别被命名为 arg1arg2。因此,pnt1arg1 指向 同一个对象。(pnt2arg2同理) enter image description here

tricky 方法中:

 arg1.x = 100;
 arg1.y = 100;

enter image description here

接下来是 tricky 方法。

Point temp = arg1;
arg1 = arg2;
arg2 = temp;

在这里,首先创建一个新的temp Point引用,它将指向与arg1引用相同的位置。然后将引用arg1移动到与arg2引用相同的位置。
最后,arg2将指向与temp相同的位置。

enter image description here

从这里开始,tricky方法的作用域消失了,并且您不能再访问三个引用:arg1arg2temp但重要的是,在这些引用仍然“活着”的时候,对它们所指向的对象进行的任何操作都将永久性地影响该对象。

因此,在执行tricky方法后,当您返回到main方法时,您将面临以下情况:
enter image description here

因此,程序的完整执行如下:

X1: 0         Y1: 0
X2: 0         Y2: 0
X1: 100       Y1: 100
X2: 0         Y2: 0

1
租金的一半:你帖子中的“一切”都是“对象”。 - Sam Ginrich
1
你写道:“在Java中,一切都是引用”,这是不正确的。只有对象是引用,原始类型不是。这就是@SamGinrich在他的评论中所指的。 - platypusguy

90
Java是按值传递(堆栈内存)。
它的工作原理是这样的。
首先我们要了解Java是如何存储原始数据类型和对象数据类型的。
原始数据类型本身和对象引用都存储在栈中。 对象本身存储在堆中。
这意味着栈内存存储原始数据类型以及对象的地址。
而且,你总是传递引用值的副本。
如果是原始数据类型,这些复制的位包含原始数据类型本身的值,这就是为什么当我们在方法内部更改参数值时,外部的值不会发生变化。
如果是像"Foo foo=new Foo()"这样的对象数据类型,那么在这种情况下,传递的是对象地址的副本,就像文件的快捷方式一样。假设我们在"C:\desktop"下有一个文本文件"abc.txt",并且我们创建了该文件的快捷方式并将其放在"C:\desktop\abc-shortcut"下,那么当你从"C:\desktop\abc.txt"访问该文件并写入"Stack Overflow"后关闭文件,然后再次从快捷方式打开文件并写入"is the largest online community for programmers to learn",那么文件的总体变化将是"Stack Overflow is the largest online community for programmers to learn",这意味着无论从哪里打开文件,我们每次都在访问同一个文件。在这里,我们可以将"Foo"看作是一个文件,假设foo存储在"123hd7h"(原始地址,类似于"C:\desktop\abc.txt")地址和"234jdid"(复制的地址,类似于"C:\desktop\abc-shortcut",实际上包含了文件内部的原始地址)。 为了更好地理解,请创建一个快捷方式文件并感受一下。

1
“Java是一种编程语言”,这句话怎么样?“Java的设计者们自创了术语,这些术语在其他地方并不存在”,这句话怎么样? - Sam Ginrich

76

在任何语言中,引用在被表示时始终是一个值。

从一个非常具有创意的角度来看,让我们来看看汇编语言或一些低级内存管理。在CPU层面上,对于任何东西的引用,如果将其写入内存或写入CPU寄存器之一,它立马就变成了一个。(这就是为什么“指针”是一个很好的定义。它既是一个值,同时也有一个目的)。

内存中的数据具有一个位置,并且在那个位置有一个值(字节,单词或其他)。在汇编语言中,我们可以方便地给某个位置(也称为变量)分配一个名称,但在编译代码时,汇编器会将名称简单地替换为指定的位置,就像你的浏览器会将域名替换为IP地址一样。

从本质上说,在任何语言中,不经过表示地传递引用是技术上不可能的(因为它们立即就变成了值)。

假设我们有一个变量Foo,它的位置在内存的第47个字节,它的为5。我们还有另一个变量Ref2Foo,它位于内存的第223个字节,并且它的值是47。这个Ref2Foo可能是一个技术性变量,不是程序显式创建的。如果你只看到5和47而没有其他信息,你将只看到两个。如果你将它们用作引用,那么要到达5,我们必须经过以下步骤:

(Name)[Location] -> [Value at the Location]
---------------------
(Ref2Foo)[223]  -> 47
(Foo)[47]       -> 5

这是跳转表的工作原理。

如果我们想使用Foo的值调用一个方法/函数/过程,那么根据语言及其几种方法调用模式,有几种可能的方式将变量传递给该方法:

  1. 5被复制到CPU寄存器之一(即EAX)中。
  2. 5被PUSH到堆栈中。
  3. 47被复制到CPU寄存器之一中。
  4. 47被PUSH到堆栈中。
  5. 223被复制到CPU寄存器之一中。
  6. 223被PUSH到堆栈中。

在上述每种情况下,都创建了一个值-现有值的副本,现在由接收方法处理它。当您在方法内部写入“Foo”时,它将从EAX中读取出来,或者自动进行取消引用或双重取消引用,该过程取决于语言的工作方式和/或Foo的类型所决定的。直到开发人员绕过取消引用过程之前,这对开发人员而言是隐藏的。因此,在表示时,引用是一个,因为引用是必须在语言级别上进行处理的值。

现在我们已经将Foo传递给方法:

  • 在情况1和2中,如果您更改Foo(Foo = 9),它仅影响局部范围,因为您复制了该值。从方法内部,我们甚至无法确定原始Foo在内存中的位置。
  • 在情况3和4中,如果您使用默认语言结构并更改Foo(Foo = 11),它可能会全局更改Foo(这取决于语言,例如Java或类似Pascal的procedure findMin(x、y、z: integer;var m: integer);)。然而,如果语言允许您绕过取消引用过程,则可以更改47,比如说要更改为49。此时,如果您读取它,则似乎已更改Foo,因为您已将本地指针更改为它。如果您在方法内部修改此Foo(Foo = 12),则可能会破坏程序的执行(称为segfault),因为您将写入不同于预期的内存区域,甚至可以修改注定要保存可执行程序的区域,并且写入它将修改正在运行的代码(Foo现在不在47)。但是,Foo的值47没有全局更改,仅在方法内部更改,因为47也是一份复制品交给了该方法。
  • 在情况5和6中,如果您在方法内部修改223,则会创建与3或4中相同的混乱(指向现在错误值的指针,再次用作指针),但这仍然是一个本地问题,因为223被复制了。但是,如果您能够取消引用Ref2Foo(即223),达到并修改所指向的值47,比如说要更改为49,它将对Foo产生全局影响,因为在这种情况下,方法得到了223的一份副本,但所引用的47</

67

在Java中, 方法参数都是按值传递的:

Java的参数都是按值传递的(当方法使用值或引用时,该值或引用被复制):

对于原始类型,Java的行为很简单: 该值会被复制到原始类型的另一个实例中。

对于对象而言,也是一样的: 对象变量是引用(只包含对象的地址的内存桶,而不是原始值),使用“new”关键字创建,并像原始类型一样被复制。

这种行为可能与原始类型不同:因为复制的对象变量包含相同的地址(指向相同的对象)。 对象的内容/成员仍然可以在方法内进行修改,并在稍后访问外部,使人产生所包含的对象本身是按引用传递的错觉。

“String”对象似乎是反驳谣言“对象是按引用传递”的一个很好的反例

实际上,在使用方法时,您将永远无法更新作为参数传递的字符串的值:

一个String对象,通过一个声明为final的数组来保存字符,该数组不能被修改。 只有使用“new”替换对象的地址时,才能更新变量,但由于变量最初是按值传递和复制的,因此无法从外部访问该对象。


63
据我所知,Java 只支持按值调用。这意味着对于基本数据类型,你将使用一个副本,而对于对象,你将使用指向对象的副本引用。然而我认为还存在一些陷阱,比如这个例子不起作用:
public static void swap(StringBuffer s1, StringBuffer s2) {
    StringBuffer temp = s1;
    s1 = s2;
    s2 = temp;
}


public static void main(String[] args) {
    StringBuffer s1 = new StringBuffer("Hello");
    StringBuffer s2 = new StringBuffer("World");
    swap(s1, s2);
    System.out.println(s1);
    System.out.println(s2);
}

这将填充“Hello World”而不是“World Hello”,因为在交换函数中,您使用的副本对主函数中的引用没有影响。但是,如果您的对象不是不可变的,您可以进行更改,例如:

public static void appendWorld(StringBuffer s1) {
    s1.append(" World");
}

public static void main(String[] args) {
    StringBuffer s = new StringBuffer("Hello");
    appendWorld(s);
    System.out.println(s);
}

这将在命令行上显示“Hello World”。如果你把StringBuffer改为String,它只会生成“Hello”,因为String是不可变的。例如:

public static void appendWorld(String s){
    s = s+" World";
}

public static void main(String[] args) {
    String s = new String("Hello");
    appendWorld(s);
    System.out.println(s);
}

不过,您可以创建一个类似这样的 String 包装器,它可以使其能够与 String 一起使用:

class StringWrapper {
    public String value;

    public StringWrapper(String value) {
        this.value = value;
    }
}

public static void appendWorld(StringWrapper s){
    s.value = s.value +" World";
}

public static void main(String[] args) {
    StringWrapper s = new StringWrapper("Hello");
    appendWorld(s);
    System.out.println(s.value);
}

编辑:我认为这也是在处理“添加”两个字符串时使用StringBuffer的原因,因为您可以修改原始对象,而对于像字符串这样的不可变对象则无法修改。


61

不是引用传递。

根据Java语言规范,Java采用按值传递:

当方法或构造函数被调用(§15.12)时,“实际参数表达式的值将初始化新创建的参数变量”,它们均为已声明的类型,在执行方法或构造函数体之前。在方法或构造函数的主体中,可以使用出现在DeclaratorId中的标识符作为简单名称来引用形式参数


Java自己这样定义。在计算机科学的历史上,在Kernighan&Ritchie发明指针和值的混淆之前,将数据传递给函数的概念和方式早已存在。对于Java来说,可以说,在调用的上下文中,引用突然成为值而不是对象实例时,自身的教条主义被打破了,因为它是面向对象的。 - Sam Ginrich

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接