CS2 软件设计与数据结构

Chapter 5 Introduction to Pointers in Java

| 关于   «  5. 局部内存   ::   目录   ::   7. 链结点  »

6. 堆内存

6.1. 堆内存

"堆" 内存,也称为 "动态" 内存,是局部栈内存的一种替代方案。 局部内存 相当自动化。 函数被调用时,局部变量被自动分配;函数退出时,它们被自动释放。 堆内存则在各个方面都不同。 程序员显式地请求分配空间,在 Java 或 C++ 中使用 new 运算符。 这块内存 "块" 会具有特定的大小,通常根据被创建对象的大小自动确定。 那块内存(你的对象)会一直处于已分配状态,直到发生某件事使它消失。 在某些语言中(尤其是 C 和 C++),堆内存中的对象 只有 在程序员显式请求释放它时才会消失。 因此,程序员对内存拥有大得多的控制权,但也要承担更大的责任,因为现在必须主动管理内存。 在释放内存之前就丢弃指向某内存位置的所有引用,是 C/C++ 中一个重要的错误来源,而且这种情况非常常见,以至于有了一个专有名字: 内存泄漏 。 (事实上,许多用 C++ 实现的商业程序都存在内存泄漏,这最终会让它们在长期使用之后崩溃。) Java 通过使用 垃圾回收 自动处理内存释放,消除了这一错误来源。 其缺点是,垃圾回收是一个缓慢的过程,而且发生的时间不可预测。

堆内存的优点是:

  • 生存期 。 由于程序员现在可以精确控制内存何时分配,就有可能先在内存中构建一个数据结构,然后把该数据结构返回给调用方。 这在局部内存下是不可能的,因为局部内存会在函数退出时被自动释放。

  • 大小 。 所分配内存的大小可以控制得更加精细。 例如,可以在运行时分配一个字符串缓冲区,其大小恰好能容纳某个特定的字符串。 若使用局部内存,代码更可能声明一个大小为 1000 的缓冲区,然后听天由命。 (参见下面的 StringCopy() 例子。)

堆内存的缺点是:

  • 更多工作 。 堆分配需要在代码中显式安排,这本来就是更多的工作。

  • 更多 bug 。 由于现在是在代码中显式完成的,分配偶尔会出错,从而导致内存 bug。 局部内存虽然受限,但至少永远不会 出错 。

尽管如此,仍有许多问题只能靠堆内存来解决,所以事情只能如此。

下面是关于垃圾回收的一些要点。

  • 垃圾回收是由 Java 虚拟机调用的一种机制,用于清除不再使用的堆内存对象。 它会移除运行中的 Java 程序不再使用的每一个对象。 这一过程的结果是释放出未使用的内存,让其他新对象能够使用那块内存。

  • 在 C 和 C++ 等其他编程语言中,释放不再使用的对象和数组所占用的内存是程序员的责任。 这样做消耗程序员的时间,并增加代码的复杂度。 另一方面,垃圾回收替程序员打理内存管理,使编程变得更加容易。

  • 在把对象从内存中移除之前,垃圾回收会调用该对象的 finalize() 方法,给它一个机会执行所需的任何清理工作。 如果程序员没有重写这个方法,就会调用默认的终结器方法(即 Object 类中定义的方法)。

  • Java 虚拟机根据从堆中动态分配内存的大小来决定何时调用垃圾回收。 垃圾回收 很慢 ,而且难以预测。 对于有实时性能约束的程序来说,这可能是个问题。

下面是一些使对象可能被垃圾回收从堆内存中移除的情形:

  1. 程序员把指向某个对象的所有引用都改成了别的东西。

  2. 如果对象定义在某个代码块内部,且指向该对象的所有引用都是局部的。 当该代码块执行完毕时,局部变量被自动销毁,使得堆内存中的对象不再有任何引用。 (所以这实际上是规则 (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.
    }
    
  3. 假设对象 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.
  }
}
Settings

Proficient Saving... Error Saving
Server Error
Resubmit

在了解确切的细节之前,先来看一个堆中分配与释放的大致例子。

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. 堆示例

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

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 构造器的值决定。

堆内存为程序员提供了更大的控制权—内存块可以按任意大小请求,并且保持已分配状态,直到不再有任何指针指向它们、被垃圾回收器回收为止。 堆内存中的对象可以传递回函数的调用方,因为函数退出时它不会被释放。 它还可以用来构建链式结构,例如链表和二叉树。 堆内存的缺点是,程序必须显式调用分配操作来管理堆内存,而且垃圾回收器运行时程序必须等待。 堆内存不会像局部内存那样自动而方便地运作。

   «  5. 局部内存   ::   目录   ::   7. 链结点  »

关闭窗口