面向对象是怎样工作的-. 第 5 章 理解内存结构: 程序员的基本素养
第4章从编程的角度介绍了 OOP 结构的便捷性。本章将稍微转换一下视角,来介绍使用 OOP 编写的程序在计算机中是怎样运行的。
本章是一个独立的话题。对于使用 OOP 的人来说,本章内容是其应该掌握的基本知识。掌握了内部运行机制之后,也能够更深入地理解OOP 的功能。希望大家能够借此机会将之前不明白的地方一并掌握。
5.1 理解 OOP 程序的运行机制
在使用 Java、C#、Python、PHP 和 Ruby 等现在主流的编程语言时,我们一般并不关心使用这些语言编写的程序实际是如何运行的。使用 OOP编写的程序的特征在于内存使用方式,但如果大家在编写程序时完全不了解内部运行机制,那么编写的程序可能就会占用过多内存,从而影响机器资源。有时即便在调试时发现了问题,也有可能什么都做不了。
因此,关于自己所编写的程序的运行机制,我们需要了解一些最基本的知识。在汇编语言占据主流的时代,这种最基本的知识就是硬件寄存器的结构,在 C 语言时代则是指针结构。而在编程语言进一步进化的今天,笔者认为最基本的知识则是“内存的使用方法”。这也可以说是使用 OOP的程序员的基本素养。
5.2 两种运行方式:编译器与解释器
我们首先来介绍一下程序的基本运行方式,大致可以分为编译器方式和解释器方式两种(图 5-1)。
图 5-1 编译器方式和解释器方式
编译器方式是将程序中编写的命令转换为计算机能够理解的机器语言之后再运行。将命令转换为机器语言的程序称为编译器。
解释器方式则是一边对源代码中编写的程序命令进行解释一边运行。这种方式能读取源代码并立即运行,因此不需要编译器。如果程序有语法错误,运行时就会发生错误。
这两种方式各有优缺点。
编译器方式的优点是运行效率高。计算机直接读取机器语言执行动作,没有解释程序命令的多余动作,因此运行速度快。而缺点是运行前会耗费一些时间。这是因为程序无法立即运行,需要先进行编译。另外,在发现错误的情况下,还需要将错误修正后才可以运行。
解释器方式的优点是可以立即运行。在使用这种方式的情况下,编写完程序后就可以立即运行以查看结果。另外,该方式还有一个优点,就是可以确保不同平台(机器、操作系统)之间的兼容性。如果将机器语言代码发布到其他环境的硬件中,通常代码是无法运行的 A。不过,解释器方式会匹配机器环境进行解释、运行,因此无须对程序进行修改,就可以在多种环境下运行。该方式的缺点是运行速度慢,与编译器方式的优点正好相反。
程序的运行方式分为编译器方式和解释器方式两种。编译器方式的运行效率高,而解释器方式能使同一个程序在不同的环境中运行。
这两种方式各有优劣,我们通常会根据具体情况进行选择。在特定的机器环境下,如果应用程序要求较高的处理性能,那么通常会采用编译器方式。政府和银行系统、企业的基础系统等大多采用编译器方式。
对于经由互联网下载到各种机器中运行的软件,解释器方式更能发挥优势。其中,为了提高在 Web 浏览器上显示的画面的操作性而使用的脚本语言等就是典型的例子。另外,即使最终采用编译器方式运行,为了节省编译操作的时间,在有些开发环境中也会使用解释器来执行从编码到调试的工作。
这两种运行方式与编程语言之间基本上没有什么对应关系。实际上,许多编程语言既支持编译器方式,又支持解释器方式。
不过在比较新的编程语言 Java 和 .NET 中,情况则稍有不同。有趣的是,这些编程环境中采用的并非这两种方式,而是中间代码方式(图 5-2)。
这种方式首先使用编译器将源代码转换为不依赖于特定机器语言的中间代码,然后使用专门的解释器来解释中间代码并运行。
这样做是为了汲取两种方式的优点:既可以将同一个程序发布到不同的机器上,又可以发扬编译器运行效率高的优点。通常这种贪婪的做法容易导致“鱼与熊掌不可兼得”的结果,但得益于硬件的进步和各种机器共存的互联网环境,这种方式最终得以实现。
采用中间代码方式,同一个程序可以在不同的运行环境中高效地运行。
5.3 解释、运行中间代码的虚拟机
下面我们来简单介绍一下实现中间代码方式的结构。由于中间代码的命令是不依赖于特定运行环境的形式,所以 CPU 无法直接读取并运行。因此,我们需要一种解释中间代码并将其转换为 CPU 能够直接运行的机器语言的结构,这种结构一般被称为虚拟机(Virtual Machine,VM)。比如 Java 中的 Java VM(Java Virtual Machine,Java 虚拟机)就是虚拟机结构的一个例子。各个平台都有相应的 Java VM,运行时读取 Java 的中间代码——字节码,转换为该平台使用的机器语言,从而运行程序(图 5-3)。
微软开发的 .NET 的结构也是如此。但是,由于在 .NET 中,C# 和Visual Basic 等各种编程语言使用共同的虚拟机,所以我们称之为公共语言运行时(Common Language Runtime,CLR)。
之所以能进行大规模的重用,即创建类库和框架等可重用构件群,是因为 OOP 提供了类、多态和继承等优秀的编程功能。而之所以能进行大范围的重用,即能在各种平台上使用所创建的软件构件,则是因为使用了虚拟机结构。这种虚拟机结构为促进软件重用做出了重要贡献。
5.4 CPU 同时运行多个线程
在介绍了程序的运行机制之后,接下来我们再介绍另外一个重要的概念——线程(thread)。
作为一个计算机术语,线程的意思是“程序的运行单位”。英文单词 “thread”是“线”的意思,进一步延伸出“生命线”“寿命”的含义,而 “生命线”这一含义就能很贴切地表现计算机处理的运行单位。
听到“程序的运行单位”这样的介绍,有的读者可能会联想到在计算机上独立运行的应用程序,比如电子邮件软件、Web 浏览器和电子表格软件等。它们确实也是计算机中的一种运行单位,但这种单位通常被称为进程(process)。
进程表示的单位比线程大,一个进程可以包含多个线程。实际上,许多应用程序是使用多个线程实现的,比较常见的例子有文字处理软件中的文本创建和打印处理、Web 浏览器中的请求处理和“停止”按钮的处理等。这些独立的处理是通过一个应用程序中作用不同的多个线程同时并发运行来实现的(图 5-4)。
图 5-4 进程和线程的示例
虽然我们在这里采用了“多个线程同时并发运行”的说法,但是严格来说,这样的表述并不准确。实际上,计算机的心脏——CPU 在某个时刻只能执行一个处理。那么,我们为什么说并发处理能够实现呢?这是因为CPU 会依次循环执行多个线程的处理。当 CPU 执行线程的处理时,并不是将一个线程从开始一直执行到结束,而是仅在非常短的规定时间(通常以毫秒为单位)内执行。在执行完这段规定时间后,即使该线程的处理还未完成,计算机也会暂时中断处理,转而处理下一个线程。下一个线程也是如此,仅执行规定的时间,然后马上跳转到下一个线程。虽然 CPU 实际上是在交替执行多个线程的处理,但是由于其处理速度非常快,所以在计算机的使用者看来就像在同时执行多个作业一样 (图 5-5)。这种能够同时运行多个线程的环境称为多线程环境。这种多线程功能基本上是作为操作系统的功能提供的。
图 5-5 依次执行多个作业的多线程功能
为什么要进行这么复杂的操作呢?原因就在于这样可以减少等待时间,提高整体的处理效率。
计算机的工作并不只是执行机器语言的命令,还有读写硬盘、使用打印机打印、与其他联网的计算机进行通信、等待来自鼠标和键盘的输入等,需要与外部进行很多交互。这种与外部的交互对人类而言可能只是一瞬间的事,但对以微秒和毫秒为单位进行作业的 CPU 来说却是很长的等待时间。因此,如果在此期间只是默默等待,那么 CPU 不执行作业的空闲时间就会变得非常多。
为了避免出现这种状态,CPU 不会集中于一个线程的作业,而是会同时执行多个线程的作业。
通过并发处理多个线程,可以高效利用 CPU 资源。
5.5 使用静态区、堆区和栈区进行管理
接下来介绍内存使用方式。正如本章开头介绍的那样,OOP 运行环境的特征就在于内存使用方式。不过,OOP 运行环境与使用传统编程语言编写的程序的运行环境也存在许多共同点。因此,这里我们抛开 OOP 特有的部分,先为大家介绍一下程序运行时一般的内存使用方式。
程序的内存区域基本上可以分为静态区 、堆区和栈区三部分(图 5-6)。
程序的内存区域分为静态区、堆区和栈区三部分。
下面依次对各个区域进行介绍。
静态区从程序开始运行时产生,在程序结束前一直存在。之所以称为 “静态”,是因为该区域中存储的信息的配置在程序运行时不会发生变化。
静态变量,即全局变量和将程序命令转换为可执行形式的代码信息就存储在该区域中。
堆区是程序运行时动态分配的内存区域。“堆”的英文“heap”有“许多”“大量”之意。由于在程序开始运行时预先分配大量的内存区域,所以命名为堆区。
堆区是在程序运行过程中根据应用程序请求的大小进行分配的,当不再需要时就将其释放。最好为堆区划分一块较大的空区域,以便有效利用内存。在多个线程同时请求内存时,也需要保持一致性,因此一般由操作系统或虚拟机提供管理功能。实际的分配和释放处理都是通过该管理功能进行的。
栈区是用于线程的控制的内存区域。堆区供多个线程共同使用,而栈区则是为每个线程准备一个。各个线程依次调用子程序(在 OOP 中是方法)执行动作。栈区是用于控制子程序调用的内存区域,存储着子程序的参数、局部变量和返回位置等信息。
栈区这一名称来源于其使用方法。“栈”的英文“stack”有“堆积” 的含义。栈区中不断堆积新的信息,使用时从最上面放置的信息开始使用,这种用法称为后进先出(Last In First Out,LIFO),如图 5-7 所示。子程序调用是嵌套结构,在调用的子程序的处理结束之前,再次调用子程序。通过这种方式,可以高效地使用内存区域。
5.6 OOP 的特征在于内存的用法
到这里为止,我们介绍了一般的程序运行环境,包括编译器、解释器、虚拟机、线程和内存管理等。对于程序员来说,这些内容可以说是必须掌握的常识。如果之前读者有什么地方不明白,请一定借此机会牢记。
接下来我们将探讨 OOP 特有的话题。即使是使用 OOP 编写的程序,其基本结构也与之前介绍的一样。不过,使用 OOP 编写的程序的内存用法与之前有很大不同,下面我们就来详细地说明一下。
虽然统称为 OOP,但实际上存在许多编程语言,如 Java、C++、C#、 Smalltalk、Ruby、Python、Visual Basic.NET、PHP 和 JavaScript 等。 这些编程语言都支持类、多态和继承这三大要素的功能,不过语言规范略有不同。另外,根据编译器、操作系统等的不同,运行时的机制有时也会不一样。
接下来,我们以 Java 为例来介绍程序的运行机制。不过,这里的目的是从原理上介绍 OOP 的结构,而不是介绍 Java 的详细结构。因此,如果读者想要知道 Java 等具体编程语言的运行环境的结构,请参考相关图书和产品手册等。
5.7 每个类只加载一个类信息
我们在第 4 章中介绍过,当使用 OOP 编写的程序运行时,会从类创建实例并执行动作。而实际上,当程序运行时,在创建实例之前,需要将对应的类信息加载到内存中。
这里所说的类信息,是不依赖于各个实例的类固有的信息。类信息的内容根据编程语言的不同而有所变化,但无论哪种语言,最重要的都是方法中编写的代码信息。即使是从同一个类创建的实例,实例变量的值也各有不同,但方法中编写的代码信息是不会变的。因此,代码信息是类固有的信息,每个类只加载一个。
每个类只加载一个“方法中编写的代码信息”。
加载类信息的方式大致分为两种:一种方式是预先统一加载所有类信息;另一种方式是在需要时依次将类信息加载到内存中。
前者是在应用程序开始运行时将所定义的类全部加载到内存中。这种在最开始就加载所有代码信息的方式是传统编程语言中采用的一般结构。在 OOP 中,考虑到与 C 语言的兼容性而创建的 C++ 也采用该方式。
Java 和 .NET 等则采用后一种方式。通常使用解释器来执行的 Python、 PHP 和 Ruby 等基本上也采用这种方式。在使用该方式的情况下,每当所执行的代码使用新类时,都会从文件中读取对应的类信息并加载到内存中。此时,该信息会与其他已经加载的类信息进行关联。虽然采用此种方式会在每次读取新类时都产生额外开销,导致运行性能变差,但由于实际上只使用运行的代码所占用的内存,所以能够减少整体的内存使用量。另外,使用该方式还可以保证运行时的灵活性,比如在各个网络中分散管理的程序文件在运行时更容易结合起来运行等。
加载类信息的内存区域相当于图 5-6 中的静态区。不过,Java 中采用依次加载所需的类信息的方式,在运行时内存配置会发生变化,因此将加载类信息的内存区域称为方法区,而不是静态区 。本章及之后的讲解都将使用“方法区”这一术语。不过,为了便于与前面的讲解进行对比,我们会根据需要使用“方法区(静态区)”的表述。
5.8 每次创建实例都会使用堆区
接下来介绍实例的结构。
在执行创建实例的命令(Java 中是 new命令)时,程序会在堆区分配所需大小的内存,用于存储该类的实例变量。这时,为了实现指定实例来调用方法的结构,还需要将实例和方法区中的类信息对应起来。
第 4 章的图 4-4 展示了针对每个实例都有方法和变量在内存中展开的情况,不过它只是一个抽象的示意图。其实在每个类中,方法中编写的代码信息都只存在于一个位置,通过实例指向该位置进行管理。
以第 4 章中编写的持有三个方法的 TextFileReader类为例,三个方法间的关系如图 5-8 所示。
OOP 中的内存使用方法的最大特征就是实例的创建方法。
在使用传统编程语言编写的程序中,通过将代码和全局变量配置在静态区,并使用栈区传递子程序的调用信息,几乎可以实现所有处理。堆区在执行分配处理时会产生额外开销,另外,如果在使用完后忘记释放内存,还容易造成内存泄漏问题。
不过,在 Java 等诸多 OOP 中,创建的实例都被配置在堆区中。程序员必须意识到“使用 OOP 编写的程序会大量使用有限的堆区来运行”这一点。
使用 OOP 编写的程序会大量使用有限的堆区来运行。
由于近来硬件的性能得到了极大的提升,从整体来看,从堆区分配内存、释放内存的额外开销已经变得非常小了。另外,得益于垃圾回收功能(后述),我们也几乎不用在意忘记释放不再使用的内存而导致内存泄漏问题了。
不过,虽说内存容量变大了,但同时创建几万、几十万个实例还是会使 CPU 的负荷变大,从而造成内存区域不足,甚至引发系统故障。
因此,当编写的应用程序要一下子读取大量信息并进行处理时,我们必须预先规划该处理会使用多少堆区。
5.9 在变量中存储实例的指针
本节将介绍创建的实例是如何存储到变量中的。首先我们再来看一下
第 4 章中从 TextFileReader类创建实例的代码示例(代码清单 5.1)。
代码清单5.1 创建TextFileReader的实例
TextFileReader reader = new TextFileReader();
通过前面的介绍我们已经知道,创建的 TextFileReader类的实例被配置在堆区中。
那么,变量 reader中存储的是什么信息呢?
该变量并不一定在堆区中。如果是方法的参数或局部变量,则配置在栈中,也有可能配置在方法区(静态区)的类信息中。
因此,变量 reader中存储的并不是 TextFileReader类的实例本身,而是堆区中创建的实例的指针 。
存储实例的变量中存储的并不是实例本身,而是实例的指针。
用一句话来说,指针就是“表示内存区域的位置的信息”。假设堆区中分配的内存区域是土地,那么指针就相当于住址。不管土地多么辽阔,住址的形式都是省、市、区、街道、门牌号。指针也是如此,无论内存区域多大,其表示形式都是固定的。采用该方法,就可以不用在意实例的大小,一直使用相同的形式来管理实例(图 5-9)。
C++ 中可以指定是在变量中存储实例本身还是指针,而 Java 无法在变量中直接存储实例。随着 Java 的语言规范逐渐变得简单,堆区中存储的实例的内存管理可以通过垃圾回收自动进行,因此,通常在堆区创建实例,并在变量中存储指针。
5.10 复制存储实例的变量时要多加注意
下面我们来介绍一个编程时应该注意的事项。
那就是复制存储实例的变量时的动作。如果在编程时不加以注意,就可能会引起难以察觉的 bug。
接下来,我们使用一段 Java 代码来对此进行说明。
首先定义一个非常简单的 Person类,该类仅持有一个姓名的实例变量(代码清单 5.2)。
然后,我们准备两个变量来存储 Person类的实例,并分别设置姓名(代码清单 5.3)。
之后,输出两个变量的姓名(代码清单 5.4)。代码清单5.4 输出Person的姓名
结果如下所示(代码清单 5.5)。代码清单5.5 输出Person姓名的结果
好奇怪!明明将姓名“John”赋给了变量 john,将姓名“Paul”赋给了变量 paul,为什么最终都变为“Paul”了呢?如果你认为这是理所当然的,那么请跳过接下来的讲解,直接进入下一节。
遗憾的是,一定还有读者不太明白,这是因为他们没有充分理解实例和存储实例的变量之间的关系,请不明白的读者务必阅读接下来的内容。
让我们从头开始依次讲解代码清单 5.3 的运行结果。首先从 (1) 处开始,这里在堆区创建 Person实例,并将该实例的指针(表示位置的信息)存储到变量 musician中。此时的内存状态如图 5-10 所示。
到这里并没有问题。然后,在 (2) 处将变量 musician的内容赋给变量 john,在 (3) 处将姓名设置为“John”。此时的内存状态如图 5-11 所示。
图 5-11 代码清单 5.3 中 (3) 处的堆区
这里需要注意的是 (2) 处的赋值处理。从图 5-11 可以看出,这里只是复制了 Person实例的指针,在堆区中配置的 Person实例依然只有一个。关于这一点,即使查看代码清单 5.3 的代码,可能也很难立马发现。不过,在 Java 中,仅当执行 new命令时才会在堆区新创建实例,像 (2) 处
这样对存储实例的变量进行赋值时,只是复制了指针而已。
在这种状态下,即使在 (4) 处再次将变量 musician的内容赋给变量paul,由于 Person实例只有一个,所以变量 paul与变量 john指向的内容也相同。在 (5) 处将姓名设为“Paul”之后,状态如图 5-12 所示。
图 5-12 代码清单 5.3 中 (5) 处的堆区
在这种状态下,如果输出的是变量 john和 paul指向的实例的姓名,结果都会得到“Paul”。这是因为三个变量都指向同一个实例,所以产生这样的结果也是理所当然的。
需要注意的是,在代码清单 5.3 的 (2) 处和 (4) 处,无论复制多少个变量,复制的都只是存储实例的指针(表示位置的信息)。实例本身一直都只有一个,并不会改变。
在 Java 中,为了在堆区创建实例,需要使用 new命令。这里我们来参考一下正确地设置了 Person实例的姓名的代码,如代码清单 5.6 所示。
代码清单5.6 正确地设置了Person实例的姓名的代码
如果像这样分别对两个变量使用 new命令来创建实例,则内存的状态如图 5-13 所示。这样一来,我们就可以对两个变量分别指向的实例设置不同的姓名。
图 5-13 执行代码清单 5.6 后的堆区
在 Java 中,为了简化语言规范,我们无法在程序中显式地操作表示实例位置的指针。虽然在 Java 中无法显式地操作指针,但变量中存储的是指针,当在方法的参数和返回值中指定对象时,实际传递的也是指针。程序员需要充分理解这些内容。
最后,我们再来汇总一下需要注意的地方。
变量中存储的并不是实例本身,而是实例的指针(表示位置的信息)。当将存储实例的变量赋给其他变量时,只是复制指针,堆区中的实例本身并不会发生变化。
5.11 多态让不同的类看起来一样
我们在第 4 章中介绍过,多态是创建公用主程序的结构。这种结构的关键在于,即使替换被调用端的类(相当于子程序),也不会影响调用端的类(相当于主程序)。
具体的实现方法有很多种,这里我们以最典型的方法表 A 方式为例进行介绍。
首先在各个类中准备一个方法表。该方法表中依次存储着各个类定义的方法在内存中展开的位置,即方法的指针 B,具体情形如图 5-14 所示。
5-14 方法表
多态中需要将对象类的方法调用方式全部统一,即让被调用的类“看起来都一样”。
方法表就是用于让不同的类看起来都一样的结构。我们创建一个方法表来汇集指向方法存储位置的指针,将对象类统一为该形式,这样就完成了准备工作。
当调用方法时,编译器会通过该方法表找到目标方法来执行。这样一来,即使方法中编写的代码不同,也可以统一调用方式。这里我们对多态结构加以整理,如图 5-15 所示。前面我们说方法表是用于让不同的类看起来都一样的结构,其实更准确的说法应该是,方法表是让不同的类都戴上相同“面具”的结构。不同的类的方法各不相同,但通过戴上同一个面具——方法表,在调用端看来它们就都一样了。
图 5-15 中有 TextFileReader和 NetworkReader两个实例,由于方法表的形式与超类 TextReader相同,所以我们可以采用相同的方式对这两个实例调用方法。
这里介绍的多态结构都是由编译器和运行环境提供的,因此程序员无须关注该结构。从运行效率来看,采用这种结构的做法比单纯调用方法的做法效率低,但就现在的机器性能而言,差别其实并不大。
5.12 根据继承的信息类型的不同,内存配置也不同
接下来介绍继承。
我们在第 4 章中介绍过,继承是将类的共同部分汇总到其他类中的结构,这里的“共同部分”具体是指共同的方法和实例变量。使用继承结构,超类的定义信息就可以直接应用到子类中。
不过,即使继承的信息一样,从内存配置的角度来看,方法和实例变量也是完全不同的。下面我们来介绍一下继承的信息在内存中是如何配置的。
以继承了 Person类的 Employee(员工)类为例。Employee类非常简单,它持有员工编号 employeeNum的实例变量,以及获取和设置员工编号的方法,具体代码如代码清单 5.7 所示。
代码清单5.7 Employee类
由于该 Employee类继承了 Person类,所以可以直接使用 Person类中定义的方法和实例变量。
最开始的内存配置的整体情况如图 5-16 所示。
图 5-16 继承的信息的内存配置
我们先从方法开始介绍。
子类中可以直接使用超类中定义的方法。由于方法区中存储的代码信息也可以被直接使用,所以子类中会使用超类的信息,而不将继承的方法的代码信息在内存中展开(图 5-16 中 (1) 处的说明)。另外,由于能够对子类的实例调用超类中定义的方法,所以在子类的“面具”——方法表中定义了包含继承的方法在内的所有方法(图 5-16 中 (2) 处的说明)。
也就是说,继承的方法虽然存储在方法表中,但实际的代码信息使用的却是超类的内容。
接着我们再来看一下实例变量。通过继承,实例变量的定义也被复制到了子类中,但实际的值却会根据实例的不同而有所不同。因此,堆区中创建的子类的所有实例都会复制并持有超类中定义的实例变量(图
5-16 中 (3) 处的说明)。这样一来,所有的实例都会被分配变量区域,这与使用“隐藏”功能声明为 private(私有)的实例变量是一样的,请大家注意。
从超类继承的方法和实例变量的内存配置是完全不同的。
堆区中的子类的所有实例都会复制并持有超类中定义的实例变量。
5.13 孤立的实例由垃圾回收处理
最后我们来介绍一下 OOP 运行机制中的垃圾回收。正如第 4 章中介绍的那样,垃圾回收会自动删除堆区中残留的不再需要的实例。虽然这项功能对程序员来说非常便捷,但是实现起来却非常复杂。
因此,本书中不会深入介绍垃圾回收的详细结构。不过,理解什么状态的实例是垃圾回收的对象是使用 OOP 的程序员应该具备的基本素养。如果不了解这部分内容,编写出来的代码就可能会残留大量无法删除的实例,这样一来,无论垃圾回收的算法多么优秀也无法防止内存泄漏。因此,接下来笔者将为大家介绍垃圾回收的基本结构,以及什么样的实例是删除对象。
我们先来看一下由谁执行垃圾回收。其实垃圾回收是由一个被称为垃圾回收器的专用程序执行的。该程序由编程语言的运行环境(在 Java 中为Java VM)提供,并作为独立的线程运行。该程序会在适当的时间点确认堆区的状态,当发现空内存区域变少时,就会启动垃圾回收处理。
看到这里大家可能会产生疑问:垃圾回收器是怎么判断实例不被需要了呢?
答案就是“发现孤立的实例”。
使用 OOP 编写的应用程序通过从类创建实例并对该实例调用方法来运行,并且实例还可以引用其他实例(具体结构是在变量中存储实例的指针,相关内容我们已经在前面介绍过了)。在使用 OOP 编写的应用程序中会创建很多实例,它们互相引用,整体构成一个网络。
这种实例网络并不只在堆区发挥作用,在栈区和方法区(静态区)中也会起到很重要的作用。
我们在前面介绍过,作为一般的程序结构,正在运行的方法的参数和局部变量存储在栈区中。OOP 也是如此。不过在 OOP 中,参数和局部变量可以指定实例。在这种情况下,栈区中存储的是堆区中的实例的指针。方法区也可以引用堆区的实例 A,这里就不再详细介绍了。
栈区和方法区中存储着应用程序处理所需的信息,因此这里引用的实例不会成为垃圾回收的对象。也就是说,栈区和方法区是网络的“根部”。
脱离网络关系的实例,即从根部无法到达的实例,就是垃圾回收的对象。
讲到这里,我们再来看一下本章开头的热身问答。
< 热身问答 >
请从图 5-17 中选出是垃圾回收对象的实例(A~L的长方形表示实例,箭头表示引用关系)。
< 问题的答案和解析 >
正确答案是 B、F、I、J、K和 L这六个实例(图 5-18)。
首先,最容易理解的是 B。因为没有任何位置引用该实例,该实例本身也没有引用其他位置,处于完全孤立的状态,所以将其删除也不会引发任何问题。其次比较容易理解的是 F和 I。F和 I是互相引用的关系,但都无法从方法区和栈区到达。在这种状态下,应用程序无法对其进行访问,因此这两者也是删除对象。J可能稍微有点难以理解。J引用 G,乍一看它好像位于网络中。不过,由于没有实例引用 J,所以 J也是删除对象。 K、L与 J一样。它们之间互相引用,虽然 K还引用其他实例,但从其他位置是无法访问 K和 L的。
各位感觉如何?如果能够理解以上内容,那么就说明你具备垃圾回收相关的基本素养。如果做错了,请一定再试着挑战一下。
在编程时应该注意的是,栈区和方法区不要一直引用不再需要的实例。不过,在实际编程时很难注意到这一点,而且在没有连锁引用大量实例时也无须在意。不过,如果内存使用率过高,程序运行速度会变得很慢,在极端情况下甚至会异常结束。请大家记住,在这种情况下,垃圾回收机制是调试应用程序时的一个要点。
垃圾回收就是删除栈区和方法区无法到达的不需要的实例。
最后让我们来打个比方:垃圾回收是一种“垃圾回收器大魔王找到与谁都没有‘拉手’的实例,并将其当场清除”的结构。
到目前为止,本书一直在主张 OOP 结构不是直接表示现实世界的技术,如果硬要用现实世界的事物进行比喻,结果会非常糟糕。哪怕只是松开手一下,这个实例就再也见不到了,大魔王会当场下手。真是一个可怕的世界。