在第 1 章里,我们已经看到基本类型(primitives) 、对象(objects)与引用(references)之间的区别。我们了解到,基本类型是 Java 语言自带的类型——换句话说,我们无需自己定义,直接使用即可。比如,int x; 会定义(创建)一个名为 x 的基本类型变量,它的类型是 int。这意味着 x 只能存储整数,例如 -5、0、12 等。
我们还学习到,对象是类的实例化,并且我们使用 new 关键字来创建对象实例。比如,在存在 Person 类的前提下,new Person(); 会实例化(创建)一个 Person 类型的对象。这个对象会存放在**堆(heap)**上。
我们看到,引用让我们能够对对象进行操作;引用有四种类型:类引用、数组引用、接口引用以及 null。当你创建对象时,返回给你的其实是指向该对象的引用。例如在 Person p = new Person(); 这行代码中,引用是 p,其类型为 Person。至于该引用本身是在栈上还是在堆上,取决于上下文(稍后会详细说明)。
理解引用与对象的差异非常重要,它能大大简化面向对象编程(OOP)的核心概念,如继承与多态;同时也有助于定位修复 ClassCastException。了解 Java 的值传递(call-by-value)机制,特别是其与引用的关系,还能避免被称为**引用逸出(escaping references)**的封装性问题。
在本章中,我们将更深入地讨论以下主题:
- 理解栈与堆上的基本类型
- 将对象存储在堆上
- 管理对象引用与安全性
技术要求
本章代码示例可在 GitHub 获取:github.com/PacktPublis…
理解栈与堆上的基本类型
Java 提供了一组预定义的基本数据类型。基本类型全部为小写(例如 double)。与之对照的是其对应的包装类(wrapper) ——它们都是 API 中的类、具有方法(而基本类型没有),并且首字母大写(例如 Double)。
基本类型可以分为两大类:
- 整型(整数) :
byte、short、int、long、char - 浮点型(小数) :
float、double,以及布尔类型boolean(true/false)
基本类型既可能存放在栈上,也可能存放在堆上:
- 当它们是方法的局部变量(包括参数与方法体内声明的变量)时,存放在栈上。
- 当它们是类的成员(即实例变量)时,存放在堆上。实例变量是在类作用域内声明的(即在所有方法之外)。
因此,方法内声明的基本类型变量 → 栈;而对象的实例字段中的基本类型 → 堆(对象内部) 。
现在我们已经理解了基本类型的存放位置,接下来将把注意力转向对象的存储。
将对象存储在堆上
在本节中,我们将考察把对象存储到堆(heap)上的方式。要全面理解这一区域,需要对引用与对象进行对比讨论。我们会查看它们的类型、它们各自存放的位置,尤其是它们之间的差异。最后,会用一段示例代码与配套示意图来结束本节。
引用(References)
引用用于指向对象,并使我们能够访问这些对象。
- 访问实例成员时,我们使用引用。
- 访问静态(类)成员时,我们使用类名。
引用既可以存放在栈上,也可以存放在堆上:
- 如果引用是方法中的局部变量,它就存放在栈上(位于该方法栈帧的本地变量表中)。
- 如果引用是实例变量,它就存放在对象内部,因此在堆上。
与对象对比来看:我们可以声明抽象类类型的引用,但不能创建抽象类的对象。接口同理——我们可以拥有接口类型的引用,但不能实例化接口,也就是说不能创建接口类型的对象。两种情况都如图 2.1 所示:
图 2.1 – 对象实例化报错
在图 2.1 中,第 10 行与第 13 行分别声明了抽象类与接口类型的引用,这没有问题;但在第 11 行与第 14 行尝试创建这些类型的对象时会报错。你可以在此处的 ch2 目录中尝试该代码:
github.com/PacktPublis…
编译错误的原因是:不能基于抽象类或接口创建对象。我们将在下一节修复这些错误。
现在我们已经讨论了引用,接下来看看对象。
对象(Objects)
所有对象都存放在堆上。 要理解对象,首先要理解面向对象中的一个基本构造——类。
类就像一张房屋的设计图:你可以查看、讨论它,但你不能据此去“开门、烧水壶”等等。这正是 OOP 中类的意义——它描述了对象在内存中的样子。当房子建好后,你就能开门、喝茶了;对应地,当对象被创建后,你就拥有了类在内存中的一个实例。借助引用,我们使用点号语法访问对象的实例成员。
让我们修复图 2.1 的编译问题,并顺便展示点号语法的使用:
图 2.2 – 修复后的接口与抽象类引用示例
在图 2.2 中,第 11 行与第 15 行能正常编译,说明:只有非抽象(具体)类才能被实例化(创建对象)。第 12 行与第 16 行展示了点号语法的用法。
现在更详细地看看对象是如何创建的。
如何创建对象
对象通过 new 关键字来实例化(创建)。new 的目的,是在堆上创建一个对象并返回其地址,我们把这个地址存入一个引用变量中。图 2.2 第 11 行类似如下:
h = new Person();
- 赋值号左侧是引用——我们将
h(类型为Human)进行初始化。 - 赋值号右侧是被实例化的对象——我们创建一个
Person类型的对象,并执行其默认构造器。由于代码中没有显式定义Person的构造器,这个默认构造器由编译器合成。
既然我们已分别查看了对象与引用,下面扩展示例,并用示意图同时展示栈与堆的表示方式。
理解引用与对象的差异
为了对比栈与堆,我们对 Person 类与 main() 方法做了修改:
图 2.3 – 栈与堆示例代码
图 2.3 展示了一个 Person 类,包含两个实例变量、一个接收两个参数的构造器,以及 toString() 实例方法。第二个类 StackAndHeap 是驱动类(包含 main())。在 main() 中,我们初始化一个局部基本类型变量 x,并创建一个 Person 实例。
图 2.4 展示了执行到第 27 行后的栈与堆示意图:
图 2.4 – 图 2.3 代码在栈与堆中的表示
结合图 2.3:
- 首先执行的是第 23 行的
main(),这会在栈上压入一个main()的栈帧。局部变量args与x被存放在这个栈帧的本地变量表中。 - 第 25 行,我们创建
Person的实例,并传入字符串字面量 "Joe Bloggs" 与整数字面量 23。任何字符串字面量本身都是一个String对象,存放在堆上;并且作为字面量,它还会存放在堆中的一个特殊区域——字符串常量池(String Pool / String Constant Pool) 。 Person对象内部的实例变量name位于堆上,类型为String,因此它是一个引用变量,指向字符串常量池中的 "Joe Bloggs" 对象。另一个实例变量age是基本类型,其值 23 直接存放在对象内部(堆上)。不过,指向Person对象的引用joeBloggs本身存放在栈上,位于main()方法的栈帧中。
在图 2.3 的第 26 行,我们输出局部变量 x,屏幕上会打印 0。接着执行第 27 行,如图 2.4 所示:
PrintStream的println()方法(out的类型为PrintStream)首先会在栈上再压入一个对应的栈帧。为简化示图,我们没有展开该栈帧的细节。- 在
println()完成之前,需要先执行joeBloggs.toString()。当调用了Person的toString()方法后,又会在栈顶压入一个toString()的新栈帧。 - 在
toString()中,会使用若干字符串字面量和实例变量拼接得到一个名为decoratedName的局部字符串变量。你应该很熟悉:当+运算符两侧任一侧是String实例时,整体运算就是字符串拼接,结果仍为String。 - 这些字符串字面量存放在字符串常量池中。最终得到的字符串为:
"My name is Joe Bloggs and I am 23 years old" ,并赋给局部变量decoratedName。该字符串从toString()返回给第 27 行的println()调用方,随后被打印到屏幕。
本节对“将对象存放到堆上”的讲解到此结束。接下来我们将关注那些可能在代码中引发微妙问题的领域。不过,既然我们已经把引用与对象清晰地区分开来,这些问题将更容易理解与修复。
管理对象引用与安全性
本节我们将审视对象引用,以及在引用管理不当时可能出现的一个隐蔽安全问题:逃逸引用(escaping references) 。我们会通过示例说明它何时、如何发生,并给出修复方式,展示如何应对这一安全隐患。
检视逃逸引用问题
本节首先讨论并举例说明 Java 的按值传递(call-by-value)的参数传递机制。理解按值传递之后,我们就能演示在传递(或返回)引用时会出现的问题。先从 Java 的按值传递机制说起。
按值传递
Java 在向方法传参或从方法返回结果时,采用按值传递。简单说,就是Java 会拷贝一个副本:把实参传给方法时,会拷贝该实参;从方法返回结果时,也会拷贝结果。我们为什么要在意?因为你拷贝的是“基本类型值”还是“引用” ,影响巨大(尤其对 StringBuilder、ArrayList 这类可变类型)。下面用一个示例程序与配图来帮助理解。图 2.5显示了示例代码:
图 2.5 – 按值传递示例代码
图 2.5 展示了一个简单的 Person 类,包含两个属性:String name 与 int(基本类型)age。构造器用来初始化对象状态,同时提供了实例变量的访问器/修改器方法。
CallByValue 类是驱动类。在 main() 第 27 行,声明并初始化一个本地基本类型变量 age = 20。第 28 行创建 Person 对象,传入字符串字面量 "John" 与基本类型变量 age,据此初始化对象状态。引用 john 是本地变量,用于存放堆上 Person 对象的引用。图 2.6展示了执行完第 28 行后的内存状态(为清晰起见省略了 args 数组对象)。
图 2.6 – 初始的栈与堆状态
如图 2.6 所示,main() 的栈帧是当前帧,包含两个局部变量:值为 20 的基本类型 age,以及指向堆上 Person 对象的引用 john。该 Person 对象的两个实例变量已初始化:基本类型 age 为 20,name 引用指向字符串常量池中的 "John"(因为 "John" 是字符串字面量,Java 会将其存放在常量池)。
接下来执行第 29 行 change(john, age);(图 2.5)。这一步开始变得有意思:我们调用 change(),向下传递 john 引用与基本类型 age。由于 Java 是按值传递,每个实参都会被拷贝。图 2.7展示了刚进入 change() 方法、即将执行其第 34 行第一条语句时的栈与堆:
图 2.7 – 进入 change() 方法时的栈与堆
如上图所示,change() 的栈帧被压入栈中。由于按值传递,两个实参的副本被复制到方法的局部变量:age 与 adult。这里的差异至关重要,因此分别说明:
拷贝基本类型
拷贝基本类型就像复印一张纸:把复印件给别人,他在复印件上怎么改与你手里的原件无关。程序里会发生的事正是如此:被调用的 change() 会改变它局部的 age 副本,但 main() 里的 age 原值不会被影响。
拷贝引用
拷贝引用像是再配了一只电视遥控器:把第二只遥控器交给别人,他可以改变你正在看的频道。程序里会发生的是:被调用的 change() 会使用它的 adult 引用去修改 Person 对象中的 name 实例变量,而 main() 中的 john 引用会看到这项修改。
回到图 2.5 的代码,图 2.8展示了执行完第 34、35 行但 change() 尚未返回之前的栈与堆:
图 2.8 – 即将退出 change() 时的栈与堆
如图所示,change() 栈帧里的基本类型 age 已被改为 90。与此同时,字符串常量池中新建了字面量 "Michael" 对象,Person 对象的 name 引用现在指向它。这是因为 String 是不可变的:一旦初始化,其内容不能被更改。因此原来的 "John" 常量池对象如果不再被引用,便可被垃圾回收。
图 2.9展示了 change() 执行结束、控制流返回 main() 之后的栈与堆状态:
图 2.9 – change() 返回后的栈与堆
在图 2.9 中,change() 的栈帧被弹出,main() 的栈帧重新成为当前帧。你会看到:基本类型 age 未变,仍为 20;引用也还是同一个。然而,change() 通过它手中的引用改变了 john 指向对象的实例变量。第 30 行 System.out.println(john.getName() + " " + age); 将输出 Michael 20,印证了这一点。
理解了 Java 的按值传递机制后,我们接着通过一个示例说明逃逸引用。
问题:逃逸引用
面向对象的封装原则是:类的数据应为私有,并通过公有 API 供外部访问。然而在某些情况下,仅有 private 还不够,原因就在于逃逸引用。图 2.10展示了一个存在逃逸引用问题的类:
图 2.10 – 存在逃逸引用的代码
如上图,Person 类有一个私有实例变量 StringBuilder name。构造器基于传入实参初始化该变量,并提供 getName() 访问器让外部获取该私有变量。
驱动类为 EscapingReferences。在 main() 的第 16 行,创建了一个本地 StringBuilder 对象,内容为 "Dan",本地引用名为 sb。该引用被传入 Person 构造器,用于初始化 Person 对象中的 name。图 2.11展示了执行完第 17 行后的栈与堆(为清晰起见省略字符串常量池):
图 2.11 – “向里”方向上的逃逸引用
此时逃逸引用问题开始显现:执行 Person 构造器时,传入的是 sb 引用的拷贝,它被存入实例变量 name。于是,如图 2.11 所示,实例变量 name 与 main() 的本地变量 sb 指向同一个 StringBuilder 对象!
当第 18 行 sb.append("Dan"); 执行时,该对象内容变为 "DanDan",sb 和实例变量 name 都随之改变。第 19 行输出实例变量时,自然是 "DanDan"。
这就是“向里”的问题:用(可变对象的)引用副本来初始化实例变量。稍后我们会给出修复方式。而“向外”也有问题,见图 2.12:
图 2.12 – “向外”方向上的逃逸引用
图 2.12 展示了执行第 21 行 StringBuilder sb2 = p.getName(); 后的栈与堆。这一次,本地引用 sb2 指向的仍然是与实例变量 name 相同的那个对象。因此,使用 sb2 继续 append("Dan"),再输出实例变量,你会得到 "DanDanDan"。
这说明仅有 private 并不足以保护数据。问题的根源在于 StringBuilder 是可变类型,也就是随时都能改动原对象。与之相对,String 是不可变的(包装类型 Double、Integer、Float、Character 等也不可变)。
不可变性
Java 之所以“保护”String,是因为对String的任何改动都会创建一个全新的对象(体现出你的改动)。发起改动的代码会看到变化,但那是个新对象;而其他人可能仍然持有指向原对象的引用,原对象保持不变。
了解了逃逸引用的问题,下面讨论如何修复。
解决方案
本质上,解决方案就是防御性拷贝(defensive copying) 。在这个场景中,对于任意可变对象,我们都不应存储其引用副本;同样也不应在访问器方法中把对私有可变数据的引用副本返回给调用方。
因此需要在“向里”与“向外”两个方向都保持谨慎:都进行内容拷贝。这被称为深拷贝(仅拷贝引用称为浅拷贝)。也就是说:
- 向里:把传入对象的内容复制到一个新对象中,并把新对象的引用存入实例变量;
- 向外:再次复制内容,返回新对象的引用。
这样两头都得到保护。图 2.13展示了对图 2.10 代码的修复:
图 2.13 – 修复后的逃逸引用代码
第 7 行展示了在构造器里(向里)创建副本对象;第 10 行展示了在访问器里(向外)创建副本对象。第 19 与 23 行都会输出 "Dan",符合预期。图 2.14展示了程序即将结束时的栈与堆:
图 2.14 – 修复后代码的栈与堆示意
为清晰起见,省略字符串常量池。我们将 StringBuilder 对象标号为 1~5,可与代码对应如下:
- 第 16 行创建对象 1;
- 第 17 行(调用第 7 行)创建对象 2,
Person的实例变量name指向对象 2; - 第 18 行修改对象 1 为
"DanDan"(注意:实例变量指向的对象 2 未受影响); - 第 19 行创建对象 3,其引用被返回给
main()但未保存;输出"Dan",证明向里的防御性拷贝生效; - 第 21 行创建对象 4,本地引用
sb2指向它; - 第 22 行把对象 4 改为
"DanDan"(实例变量指向的对象未受影响); - 第 23 行创建对象 5;输出
"Dan",证明向外的防御性拷贝生效。
图 2.14 表明:实例变量 name 所指向的 StringBuilder 始终保持为 "Dan"。这正是我们想要的结果。
至此,本章结束。我们已经覆盖了大量内容,下面简要回顾要点:
- Java 按值传递:拷贝基本类型不影响原值,拷贝引用会让被调用方能“遥控”同一对象;
- 逃逸引用发生在把可变对象的引用传入/返回时;
- 通过防御性拷贝(深拷贝) ,在“向里/向外”两个方向都复制内容,可有效避免逃逸引用带来的封装与安全问题。
总结
本章首先探讨了基本类型(primitive)如何存储在内存中。基本类型是语言内置的预定义类型,既可以存放在栈(作为局部变量),也可以存放在堆(作为实例变量)。它们易于识别——名称全部为小写。
与之相对,对象只存储在堆上。在讨论对象时,需要区分引用(reference)与对象本身。我们发现,引用可以是任意类型(接口、抽象类、具体类),但对象本身只能来自真正的具体类(类不能是 abstract)。
要谨慎管理对象引用。管理不当会导致逃逸引用。Java 采用按值传递,也就是在传参或返回时都会拷贝一个“值”。这个“值”是基本类型的值还是对象引用,影响巨大:如果拷贝的是指向可变类型的引用,调用方就能修改你本应私有的数据,这违背了封装原则。
我们查看了带有该问题的代码与配套的栈/堆示意图。解决方案是采用防御性拷贝(defensive copying) :在传入与返回两个方向都拷贝对象内容,从而让引用及其指向的对象保持私有。最后,我们给出了修复后的代码以及对应的栈/堆图示。
在下一章,我们将更深入地了解堆——对象栖居的那片内存区域。