6. 堆内存¶
6.1. 堆内存¶
"堆" 内存,也称为 "动态" 内存,是局部栈内存的一种替代方案。
局部内存 相当自动化。
函数被调用时,局部变量被自动分配;函数退出时,它们被自动释放。
堆内存则在各个方面都不同。
程序员显式地请求分配空间,在 Java 或 C++ 中使用 new 运算符。
这块内存 "块" 会具有特定的大小,通常根据被创建对象的大小自动确定。
那块内存(你的对象)会一直处于已分配状态,直到发生某件事使它消失。
在某些语言中(尤其是 C 和 C++),堆内存中的对象 只有 在程序员显式请求释放它时才会消失。
因此,程序员对内存拥有大得多的控制权,但也要承担更大的责任,因为现在必须主动管理内存。
在释放内存之前就丢弃指向某内存位置的所有引用,是 C/C++ 中一个重要的错误来源,而且这种情况非常常见,以至于有了一个专有名字: 内存泄漏 。
(事实上,许多用 C++ 实现的商业程序都存在内存泄漏,这最终会让它们在长期使用之后崩溃。)
Java 通过使用 垃圾回收 自动处理内存释放,消除了这一错误来源。
其缺点是,垃圾回收是一个缓慢的过程,而且发生的时间不可预测。
堆内存的优点是:
生存期 。 由于程序员现在可以精确控制内存何时分配,就有可能先在内存中构建一个数据结构,然后把该数据结构返回给调用方。 这在局部内存下是不可能的,因为局部内存会在函数退出时被自动释放。
大小 。 所分配内存的大小可以控制得更加精细。 例如,可以在运行时分配一个字符串缓冲区,其大小恰好能容纳某个特定的字符串。 若使用局部内存,代码更可能声明一个大小为 1000 的缓冲区,然后听天由命。 (参见下面的 StringCopy() 例子。)
堆内存的缺点是:
更多工作 。 堆分配需要在代码中显式安排,这本来就是更多的工作。
更多 bug 。 由于现在是在代码中显式完成的,分配偶尔会出错,从而导致内存 bug。 局部内存虽然受限,但至少永远不会 出错 。
尽管如此,仍有许多问题只能靠堆内存来解决,所以事情只能如此。
下面是关于垃圾回收的一些要点。
垃圾回收是由 Java 虚拟机调用的一种机制,用于清除不再使用的堆内存对象。 它会移除运行中的 Java 程序不再使用的每一个对象。 这一过程的结果是释放出未使用的内存,让其他新对象能够使用那块内存。
在 C 和 C++ 等其他编程语言中,释放不再使用的对象和数组所占用的内存是程序员的责任。 这样做消耗程序员的时间,并增加代码的复杂度。 另一方面,垃圾回收替程序员打理内存管理,使编程变得更加容易。
在把对象从内存中移除之前,垃圾回收会调用该对象的 finalize() 方法,给它一个机会执行所需的任何清理工作。 如果程序员没有重写这个方法,就会调用默认的终结器方法(即
Object类中定义的方法)。Java 虚拟机根据从堆中动态分配内存的大小来决定何时调用垃圾回收。 垃圾回收 很慢 ,而且难以预测。 对于有实时性能约束的程序来说,这可能是个问题。
下面是一些使对象可能被垃圾回收从堆内存中移除的情形:
程序员把指向某个对象的所有引用都改成了别的东西。
如果对象定义在某个代码块内部,且指向该对象的所有引用都是局部的。 当该代码块执行完毕时,局部变量被自动销毁,使得堆内存中的对象不再有任何引用。 (所以这实际上是规则 (1) 的一个特例。) 下面是一个例子:
void test(boolean found) { if (found) { // employee1 is a variable local to this if statement // but the Employee object is in heap memory. Employee employee1 = new Employee("John", 1000); // We can do things here with employee1, and its associated object Printout(employee1.name()); } // At this point, the only reference to the created object is out of scope // since employee1 was local to the if statement block. // So the Employee object will be eligible for garbage collection. }
void test(boolean found) { if (found) { // employee1 is a variable local to this if statement // but the Employee object is in heap memory. Employee employee1 = new Employee("John", 1000); // We can do things here with employee1, and its associated object Printout(employee1.name()); } // At this point, the only reference to the created object is out of scope // since employee1 was local to the if statement block. // So the Employee object will be eligible for garbage collection. }
假设对象
A包含对另一个对象B的引用,并且A持有指向B的唯一引用。 如果A也符合垃圾回收的条件,那么对象B也将符合垃圾回收的条件。 下面是一个例子。
// Date class to represent dates class Date { int day; int month; int year; // Date constructor public Date(int d, int m, int y) { day = d; month = m; year = y; } } // An Employee class that includes a Date class Employee { String name; Date dateOfBirth; // Employee constructor to initialize both name and date public Employee(String name , int d, int m, int y) { this.name = name; dateOfBirth = new Date(d, m, y); } } // Now let's test this code class Test { public static void main() { Employee empPtr = new Employee("Sam", 3, 9, 1983); // Now empPtr references an object that contains a reference to a Date object empPtr = null; // Now the Employee object is eligible for garbage collection. // So its Date object will also be eligible for garbage collection // because nothing else points to it. } }// Date class to represent dates class Date { int day; int month; int year; // Date constructor public Date(int d, int m, int y) { day = d; month = m; year = y; } } // An Employee class that includes a Date class Employee { String name; Date dateOfBirth; // Employee constructor to initialize both name and date public Employee(String name , int d, int m, int y) { this.name = name; dateOfBirth = new Date(d, m, y); } } // Now let's test this code class Test { public static void main() { Employee empPtr = new Employee("Sam", 3, 9, 1983); // Now empPtr references an object that contains a reference to a Date object empPtr = null; // Now the Employee object is eligible for garbage collection. // So its Date object will also be eligible for garbage collection // because nothing else points to it. } }
在了解确切的细节之前,先来看一个堆中分配与释放的大致例子。
6.1.1. 分配¶
堆是一大片可供程序使用的内存区域。
程序可以在堆中请求一些内存区域,即 "块",供自己使用。
为了分配一块特定大小的内存,程序通过调用堆的 分配 操作来发出显式请求。
在 Java 或 C++ 中,这就是 new 运算符。
分配函数在堆中保留一块所请求大小的内存(通常就是你要的对象的大小),并返回指向它的引用。
假设一个程序发出了三次分配请求,在堆中分配内存来存放三幅各自独立的 GIF 图像,每幅占用 1024 字节的内存。
在这三次分配请求之后,内存可能如下所示。
每次分配请求都会在堆中保留一块请求大小的连续区域,并把指向该新块的引用返回给程序。 由于每个块总是通过引用来访问,块始终扮演 "pointee"(第 1 节)的角色,程序也总是通过引用来操作它的堆块。 指向堆块的引用有时也被称为 "基地址" 指针,因为按照惯例,它们指向块的基部(地址最低的字节)。 在这个例子中,三个块从堆的底部开始连续分配,每个块的大小都是所请求的 1024 字节。 实际上,堆管理器可以把块分配到堆中任何它想放的位置,只要块之间互不重叠且大小至少达到请求的大小即可。 在任一特定时刻,堆中的某些区域已经分配给了程序,因而处于 "使用中" 。 其他区域尚未被分配,因此是 "空闲" 的,可以用来满足分配请求。 堆管理器有自己的私有数据结构,用来记录任一时刻堆中的哪些区域被分配作何种用途。 堆管理器从空闲内存池中满足每个分配请求,并更新其私有数据结构,记录堆中哪些区域正在使用。
6.1.2. 释放¶
当程序用完一块内存之后,在某些语言中必须显式释放该块。 在这种情况下,该块会被标记为未使用。 在 Java 中,空间通常是通过不再有任何引用指向它而 "变得可用" 的。 这让 Java 的垃圾回收知道该区域必须被清理。 垃圾回收会隐式地释放堆中未使用的内存块。 堆管理器更新其私有数据结构,表明该块占用的内存区域重新变为空闲,因而日后可以重新用于满足分配请求。 如果垃圾回收释放了三个块中的第二个,堆将如下所示。
释放之后,引用仍然指向那个已被释放的块。
程序再也无法访问那个已释放的 pointee 。
在像 C++ 这样显式释放内存且没有垃圾回收的语言中,程序员必须确保自己不会试图顺着旧引用去访问已释放的块。
这就是指针被画成灰色的原因—指针还在那里,但绝不能使用。
当然,在 Java 中,代码会把指针设为 null 或指向别处,以此告诉垃圾回收:这个对象现在已不再使用。
这在很大程度上解释了为什么 Java 引用比 C++ 指针使用起来更安全。
6.1.3. 对堆编程¶
在大多数语言中,对堆的编程看起来基本相同。 其基本特性是:
堆是一片可用的内存区域,用于为程序分配内存区域("块")。
有一些 "堆管理器" 库代码为程序管理堆。 程序员向堆管理器发出请求,由堆管理器管理堆的内部事务。
堆管理器使用自己的私有数据结构来记录堆中哪些块是 "空闲" 的(可供使用)、哪些块目前正被程序使用以及这些块有多大。 初始时,整个堆都是空闲的。
堆的大小可以是固定的(通常的概念模型),也可以在虚拟内存的支持下表现为固定但极其巨大的大小。 无论哪种情况,如果堆的全部内存都已被分配,堆就可能变 "满",从而无法满足某个分配请求。 分配函数会以某种方式把这种运行时状况告知程序—通常是抛出 OutOfMemoryError 运行时异常。
分配函数请求堆中一块特定大小的内存。 堆管理器选择一块内存区域来满足请求,在其私有数据结构中把该区域标记为 "使用中",并返回指向该堆块的引用。 此后,调用方就可以顺着引用自由使用这块内存。 该块保证保留给调用方单独使用—堆不会把同一块内存区域交给其他调用方。 块不会在堆内移动—一旦分配,它的位置和大小就固定了。
Java 虚拟机会调用垃圾回收来移除任何不再使用的内存块,释放其空间,并把这块内存归还给堆的空闲区域,以便日后重新使用。
6.1.4. 堆示例¶
6.1.5. 数组¶
在 Java 中,数组的内存是在堆中分配的。
数组内存块的大小是每个元素的大小乘以元素的个数。
因此,下面的代码在堆中分配一个包含 100 个 Fraction 对象的数组,把它们全部设为 22/7,然后释放这个堆数组。
// Fraction class
class Fraction {
int numerator, denominator;
// Fraction constructor
public Fraction(int num, int den) {
numerator = num;
denominator = den;
}
}
// Test class
class TestHeapArray {
static void main() {
Fraction[] fracts;
int i;
int length = 100;
// allocate the array
fracts = new Fraction[length];
// use it like an array -- in this case set them all to 22/7
for (i = 0; i < length; i++) {
fracts[i] = new Fraction(22, 7);
}
// Remove access to the array, making it garbage.
// If the Fraction objects that were created are also garbage now.
fracts = null;
}
}
// Fraction class
class Fraction
int numerator, denominator;
// Fraction constructor
public Fraction(int num, int den) {
numerator = num;
denominator = den;
}
}
// Test class
class TestHeapArray {
static void main() {
Fraction[] fracts;
int i;
int length = 100;
// allocate the array
fracts = new Fraction[length];
// use it like an array -- in this case set them all to 22/7
for (i = 0; i < length; i++) {
fracts[i] = new Fraction(22, 7);
}
// Remove access to the array, making it garbage.
// If the Fraction objects that were created are also garbage now.
fracts = null;
}
}
在这个例子中,数组的动态内存分配分两步完成:
第一步是使用
fracts = new Fraction[100];创建数组。 这一行用于分配一个包含 100 个指向Fraction的引用的动态数组。 所有引用都被初始化为null。第二步在循环内部。 每次循环迭代都使用
new动态分配一个Fraction类型的对象。 每个对象的初始值由传给 Fraction 构造器的值决定。
堆内存为程序员提供了更大的控制权—内存块可以按任意大小请求,并且保持已分配状态,直到不再有任何指针指向它们、被垃圾回收器回收为止。 堆内存中的对象可以传递回函数的调用方,因为函数退出时它不会被释放。 它还可以用来构建链式结构,例如链表和二叉树。 堆内存的缺点是,程序必须显式调用分配操作来管理堆内存,而且垃圾回收器运行时程序必须等待。 堆内存不会像局部内存那样自动而方便地运作。

