CS61B 课程笔记(Lecture 03)

Java 内存与变量

比特与基本类型

  • 比特 (Bits): 内存中的信息存储为一和零的序列。
  • Java 中的 8 种基本数据类型
    • byte, short, int, long, float, double, boolean, char
    • 基本类型 (Primitives): 这些是数据的简单表示形式,每种类型在内存中都有固定的大小。

声明变量(简化版)

  • 当声明一个变量时,Java 会:
    1. 分配足够的比特来存储该类型的数据。
    2. 在内部表中将变量名映射到内存位置。
    3. 不会自动为变量赋值或初始化内存中的内容。
  • 关键规则:如果访问未初始化的变量,会导致错误,这是为了安全。

Java 内存抽象

  • 隐藏内存地址:Java 隐藏了具体的内存地址,防止手动管理内存。
    • 优点:减少编程错误(例如内存泄漏、段错误)。
    • 缺点:降低了对底层内存优化的控制能力。
  • 过早优化:引用 Donald Knuth 的话,过早关注小的效率优化往往是不明智的。类似于不能手动控制心跳,避免人为操作带来的风险。

等号的黄金法则 (Golden Rule of Equals, GRoE)

  • 赋值 (=):将一个变量的所有比特从源复制到目标。
  • 对于基本类型:这意味着直接复制值。

等号的黄金法则 (Golden Rule of Equals, GRoE)详解

在 Java 中,等号 (=) 的作用是将一个变量的值从源复制到目标,而这一过程的背后实质是比特的复制。我们可以通过理解“等号的黄金法则”更深入地理解赋值操作的原理。

基本类型 (Primitive Types)

基本类型包括以下几种数据类型:

  • byte: 8 比特 (bit)
  • short: 16 比特
  • int: 32 比特
  • long: 64 比特
  • float: 32 比特
  • double: 64 比特
  • boolean: 虽然表示为 truefalse,但通常用 1 比特表示。
  • char: 16 比特

每种基本类型在内存中都占据了固定大小的比特空间。当我们对这些基本类型进行赋值时,实质上是将这些比特从一个内存位置复制到另一个内存位置。

赋值操作的背后机制
  1. 声明变量时,计算机会根据变量的数据类型在内存中分配足够的空间。比如,声明 int x;,系统会在内存中为 x 分配 32 比特的空间。

  2. 赋值操作时,如 x = 42;,系统会将表示数字 42 的比特模式写入 x 对应的内存空间。赋值 = 的作用是把源值的比特逐一复制到目标变量的内存空间中。例如:

    1
    2
    int x = 42;
    int y = x; // y 获得 x 中的 32 比特值

    在这个例子中,y = x 的操作就是将 x 所对应的 32 比特从 x 的内存地址复制到 y 的内存地址。这是一个完全独立的复制,因此 xy 各自拥有自己的内存空间,修改 y 的值不会影响 x,反之亦然。

    比如:

    1
    2
    y = 100;
    System.out.println(x); // 输出仍然是 42
等号的黄金法则与基本类型

“等号的黄金法则”表明,在赋值时,是比特的复制,这意味着赋值不会影响源变量本身。即使目标变量改变,源变量的比特值保持不变。这一点对于基本类型来说尤为重要,因为这些类型在赋值时总是复制实际的值,而不是引用。

复制比特的影响

内存独立性

由于基本类型的赋值是将比特复制到一个独立的内存区域,因此在进行赋值操作后,两个变量各自独立。修改一个变量不会影响另一个变量。例如:

1
2
3
4
int a = 10;
int b = a;
b = 20;
System.out.println(a); // 输出 10

这里,ab 是两个独立的变量,尽管 b 是从 a 中复制的值。但赋值后,它们之间没有任何内存上的联系。

性能与效率

对于基本类型的赋值操作,由于这些类型的数据量通常较小(如 int 占用 32 比特),所以比特的复制是非常高效的。现代计算机硬件可以快速进行这些固定长度比特的复制操作。

常见的误解

初学者常常会将基本类型的赋值操作与引用类型混淆,以为修改了一个变量的值,另一个变量也会随之改变。实际上,基本类型的赋值是值的复制,与引用类型的赋值机制不同。

Java 中的引用类型

引用类型基础

  • 引用类型存储的是指向内存中对象的64 位地址,而不是对象本身的数据。

  • 声明示例

    1
    2
    Walrus someWalrus;  // 声明了对 Walrus 对象的引用。
    someWalrus = new Walrus(1000, 8.3); // `new` 操作符创建对象并返回其内存地址。
  • 关键点:变量 someWalrus 保存的是引用(地址),而不是实际的 Walrus 数据。

未定义 vs. 空值

  • 引用类型的变量可以是未定义的(即已声明但未初始化)。
  • 空值 (null):明确表示该引用指向的是“空”,即不指向任何对象。

参数传递(值传递)

  • 在 Java 中,参数传递是值传递:变量的比特(无论是基本类型还是引用类型)都会被复制到参数变量中。
    • 即使是对象,也是传递对象的地址,而不是实际对象本身。

数组与实例化

声明数组

  • 声明数组(例如 int[] x;)会创建一个指向数组的64 位引用
  • new 操作符为数组分配内存,并返回其地址。
  • 数组大小:在创建时固定,无法更改。

对象丢失

  • 如果没有任何引用指向一个对象,该对象就会被视为丢失,可能会被垃圾回收器回收。
  • 当对象不再需要时,可以安全地丢弃引用。

链表数据结构:IntList

IntList 定义

  • IntList 是一个自定义的整数链表。

  • 每个 IntList 节点包含两部分:

    1. first: 保存一个整数值。
    2. rest: 指向下一个节点(另一个 IntList)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class IntList {
    public int first;
    public IntList rest;

    public IntList(int f, IntList r) {
    first = f;
    rest = r;
    }
    }

创建链表

  • 创建一个包含数字(如 5、10、15)的链表:

    1
    2
    3
    IntList L = new IntList(5, null);
    L.rest = new IntList(10, null);
    L.rest.rest = new IntList(15, null);
  • 另一种方法:反向构建链表,代码更简洁但不易理解:

    1
    2
    3
    IntList L = new IntList(15, null);
    L = new IntList(10, L);
    L = new IntList(5, L);

IntList 方法

递归的 size() 方法

  • 递归:使用基准情况(rest == null)来终止递归。

    1
    2
    3
    4
    5
    6
    public int size() {
    if (rest == null) {
    return 1;
    }
    return 1 + this.rest.size();
    }

迭代的 iterativeSize() 方法

  • 迭代:使用指针变量 (p) 遍历链表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public int iterativeSize() {
    IntList p = this;
    int totalSize = 0;
    while (p != null) {
    totalSize += 1;
    p = p.rest;
    }
    return totalSize;
    }
  • 注意:使用 p 作为指针可以避免重写 this,这是在 Java 中不可行的。

最后说明

破旧沙发法则 (The Law of the Broken Futon)

  • 这一概念强调了理解 Java 中内存与变量处理的核心机制的重要性。
  • 深刻理解:如果没有真正掌握内存和引用的细微差别,表面的知识可能会导致后续出现错误,例如没有充分利用面向对象的特性,或者误解引用传递的工作原理。

破旧沙发法则 (The Law of the Broken Futon) 详解

破旧沙发法则是一种用于解释学生在学习编程语言时,尤其是像 Java 这样抽象程度较高的语言,可能会遇到的理解困境的比喻。

比喻背景

这个法则把学生的编程理解比作一张“破旧的沙发”。当一个人看到一张破旧的沙发时,可能会觉得它还能用,并且在短期内确实能够坐在上面。但实际上,沙发已经有了问题,如果不彻底修理或者更换,它很可能会随着时间的推移变得越来越不稳定,最终崩塌。

编程中的破旧沙发

在编程的学习过程中,许多学生往往会因为浅层的理解而觉得自己掌握了某些概念,但其实这种理解是不完整的。这种情况下,虽然他们能够写出表面上可行的代码,但背后涉及的机制或者原理并没有真正掌握。

例如,在 Java 中处理引用类型和内存时,初学者可能只是知道赋值语句 = 将数据从一个变量复制到另一个变量,但没有意识到这是复制引用(地址),而不是复制实际的数据。这样虽然代码能跑,但在遇到复杂场景时,他们会无法理解为什么程序会出现内存泄漏或对象丢失等问题。

这种“半吊子”的理解就像是一张已经松动的沙发,表面上还能坐,但在关键时刻(例如遇到更复杂的编程任务或 Bug 时),整个认知体系就可能会崩溃。

短期和长期影响

  • 短期影响:学生可能会觉得自己的代码能正常工作,没有任何问题,似乎已经掌握了这个知识点。这种表面上的成功使得他们在短期内可能通过考试或完成任务。
  • 长期影响:当学生需要处理更复杂的问题时(如调试多线程程序、优化内存使用或实现自定义数据结构),他们将会发现自己对底层机制的理解不足,导致解决问题时困难重重。这种不全面的理解就像沙发最终会坍塌一样,在长远的编程道路上会阻碍他们的发展。

为什么要避免“破旧沙发”

编程不仅仅是写出能运行的代码,还需要真正理解代码背后的逻辑和机制。深入理解内存管理、对象引用、数据结构等核心知识,能够帮助程序员避免在更复杂的环境下出错。破旧沙发法则的核心警示是:不要仅仅满足于表面的成功,要确保自己真正掌握了背后的原理

通过扎实的学习基础,学生能够写出更稳定、高效、可靠的代码,并能够应对更具挑战性的编程任务。

实际案例:引用类型误解

  • 问题场景:学生可能会误解引用类型的赋值,以为两个变量之间的赋值是复制了对象的实际数据。

    • 例如:

      1
      2
      3
      Walrus a = new Walrus(1000, 8.3);
      Walrus b = a;
      b.weight = 2000;
    • 学生可能以为 ab 是两个独立的对象,但实际上它们是指向同一个 Walrus 对象,修改 b 的值也会影响 a,这就导致了潜在的逻辑错误。

  • 理解不深的后果:在大型项目中,如果开发者不理解引用和内存的细节,他们可能会无意中修改同一个对象的多个引用,导致难以跟踪的 Bug 和错误。

破旧沙发法则提醒我们不要满足于能“跑起来”的代码,要深刻理解程序的运作方式,特别是涉及到复杂内存管理、引用和对象时。只有这样,才能在编程的长远道路上避免“沙发坍塌”的灾难。