yeskery

对象与内存控制

简介

Java 的内存管理看上去比较深奥且难于理解,大部分开发者会觉得 Java 内存管理与实际开发距离太远。造成这样一种错误理解的原因在于,Java 向程序员许下了一个美好的承诺:无需关心内存回收,Java 提供了优秀的垃圾回收机制来回收已经分配的内存。在这样的承诺下,大部分 Java 开发者肆无忌惮地挥霍着 Java 程序的内存分配,从而造成 Java 程序的运行效率低下。

Java 内存管理分为两个方面:内存分配和内存回收。这里的内存分配特指创建 Java 对象时 JVM 为该对象在堆内存中所分配的内存空间,内存回收指的是当该 Java 对象失去引用,变成垃圾时,JVM 的垃圾回收机制自动清理该对象,并回收该对象所占用的内存。由于 JVM 内置了垃圾回收机制回收失去引用的 Java 对象所占用的内存,所以很多 Java 开发者会认为 Java 不存在内存泄漏、资源泄漏的问题。实际这是一种错觉,Java 程序依然会有内存泄漏。

由于 JVM 的垃圾回收机制由一条后台线程完成,本身也是非常消耗性能的,因此如果肆无忌惮地创建对象,让系统分配内存,那这些分配的内存都将由垃圾回收机制进行回收。这样做有两个坏处:

  • 不断分配内存使得系统中可用内存减少,从而降低程序运行性能;
  • 大量已分配内存的回收使得垃圾回收的负担加重,降低程序的运行性能。

实例变量和类变量

Java 程序的变量大体可分为成员变量和局部变量。其中局部变量可分为如下3类:

  1. 形参:在方法签名中定义的局部变量,由方法调用者负责为其赋值,随方法的结束而消亡。
  2. 方法内的局部变量:在方法内定义的局部变量,必须在方法内对其显式初始化。这种类型的局部变量从初始化完成之后开始生效,随方法结束而消亡。
  3. 代码块内的局部变量:在代码块内定义的局部变量,必须在代码块内对其显式初始化。这种类型的局部变量从初始化完成后开始生效,随代码块的结束而消亡。

局部变量的作用时间很短暂,它们都被存储在方法的栈内存中。

类体内定义的变量被成为成员变量(英文是 Field)。如果定义该成员变量时没有使用 static 修饰,该成员变量又被成为非静态变量或实例变量;如果使用了 static 修饰,则该成员变量又可被称为静态变量或类变量。

对于 static 关键字而言,从词义上来看,它是“静态”的意思。但从Java程序的角度来看,static的作用就是将实例成员变为类成员。static 只能修饰在类里定义的成员部分,包括成员变量、方法、内部类、初始化块、内枚举类。如果没有使用 static 修饰这些类里的成员,这里成员属于该类的实例;如果使用了 static 修饰,这些成员就属于类本身。从这个意义上看,static 只能修饰类里的成员,不能修饰内部类,不能修饰局部变量、局部内部类。

实例变量和类变量的属性

使用 static 修饰的成员变量是类变量,属于该类本身;没有使用 static 修饰的成员变量是实例变量,属于该类的实例。在同一个 JVM 内,每个类只对应一个 Class 对象,但每个类可以创建多个 Java 对象。

由于同一个 JVM内每个类只对应一个 Class 对象,因此同一个 JVM 内的一个类的类变量只需一块内存空间;但对于实例变量而言,该类每创建一次实例,就需要为实例变量分配一块内存空间。也就是说程序中有几个实例,实例变量就需要几块内存空间。

大部分时候都会把类和对象严格地区分开,但从另一个角度来看,类也是对象,所有类都是 Class 的实例。每个类初始化完成之后,系统都会为该类创建一个对象的 Class 实例,程序可以通过反射来获取某个类所对应的 Class 实例。例如,要获取 String 类对应的 Class 实例,通过 String.classClass.forName("String") 任意一条代码即可。

实例变量的初始化时机

对于实例变量而言,它属于 Java 对象本身,每次程序创建 Java 对象时都需要为实例变量分配内存空间,并执行初始化。

从程序运行的角度看,每次创建 Java 对象都会为实例变量分配内存空间,并对实例变量执行初始化。

从语法角度来看,程序可以在3个地方对实例变量执行初始化:

  1. 定义实例变量时指定初始值;
  2. 非静态初始化块中对实例变量指定初始值;
  3. 构造器中对实例变量指定初始值。

其中第1、2种方式比第3种方式更早执行,但第1、2种方式的执行顺序与它们在源程序中的排列顺序相同。

每当程序调用指定构造器来创建 Java 对象时,该构造器必然会获取执行的机会。除此之外,该类所包含的非静态初始化块将会获得执行的机会,而且总是在构造器执行之前获取执行。

javap 工具的用法

javap <options> <classes>
该工具支持如下常用选项。

  • -c:分解方法代码,也就是显示每个方法具体的字节码。
  • -l:用于指定显示行号和局部变量列表。
  • -public|protected|package|private:用于指定显示哪种级别的类成员,分别对应 Java 的4种访问控制权限。
  • -verbose:用于指定显示更进一步的详细信息。

定义测试类:

  1. public class JavapToolTest {
  2. //定义count实例变量,并为之指定初始值
  3. int count = 20;
  4. {
  5. //初始化块中为count实例变量指定初始值
  6. count = 12;
  7. }
  8. //定义两个构造器
  9. public JavapToolTest() {
  10. System.out.println(count);
  11. }
  12. public JavapToolTest(String name) {
  13. System.out.println(name);
  14. }
  15. }

对于上面的程序,首先编译该程序得到一个 .class 文件,接下来执行如下命令:
javap -c JavapToolTest

  1. Compiled from "JavapToolTest.java"
  2. public class JavapToolTest {
  3. int count;
  4. public JavapToolTest();
  5. Code:
  6. 0: aload_0
  7. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  8. 4: aload_0
  9. 5: bipush 20
  10. 7: putfield #2 // Field count:I
  11. 10: aload_0
  12. 11: bipush 12
  13. 13: putfield #2 // Field count:I
  14. 16: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
  15. 19: aload_0
  16. 20: getfield #2 // Field count:I
  17. 23: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
  18. 26: return
  19. public JavapToolTest(java.lang.String);
  20. Code:
  21. 0: aload_0
  22. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  23. 4: aload_0
  24. 5: bipush 20
  25. 7: putfield #2 // Field count:I
  26. 10: aload_0
  27. 11: bipush 12
  28. 13: putfield #2 // Field count:I
  29. 16: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
  30. 19: aload_1
  31. 20: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  32. 23: return
  33. }

类变量的初始化时机

实例变量属于 Java 类本身,只有当程序初始化该 Java 类时才会为该类的变量分配内存空间,并执行初始化。

从程序运行的角度来看,每个 JVM 对一个 Java 类只初始化一次,因此 Java 程序每运行一次,系统只为类变量分配一次内存空间,执行一次初始化。

从语法角度来看,程序可以在2个地方对类变量执行初始化:

  • 定义类变量时指定初始值;
  • 静态初始化块中对类变量指定初始值。

这两种方式的执行顺序与它们在源程序中的排列顺序相同。

  1. class Price {
  2. //类变量是Price实例
  3. static final Price INSTANCE = new Price(2.8);
  4. //再定义一个类变量
  5. static double initPrice = 20;
  6. //定义该Price的currentPrice实例变量
  7. double currentPrice;
  8. public Price(double discount) {
  9. //根据静态变量计算实例变量
  10. currentPrice = initPrice - discount;
  11. }
  12. }
  13. public class PriceTest {
  14. public static void main(String[] args) {
  15. //通过Price的INSTANCE访问currentPrice实例变量
  16. System.out.println(Price.INSTANCE.currentPrice);
  17. //显式的创建Price实例
  18. Price p = new Price(2.8);
  19. //通过显式创建的Price实例访问currentPrice实例变量
  20. System.out.println(p.currentPrice);
  21. }
  22. }

从代码上看上面的程序将会输出两个17.2,但实际输出结果却是-2.817.2,如果仅仅停留在代码表面来看这个问题,往往很难得到正确的结果,下面将从内存角度来分析这个程序。第一次用到 Price 类时,程序开始对 Price 类进行初始化,初始化分为以下2个阶段。

  1. 系统为 price 的两个类变量分配内存空间。
  2. 按照初始化代码(定义时指定初始值和初始化块中执行的初始值)的排列顺序对类变量执行初始化。

初始化第一阶段,系统先为 INSTANCE、initPrice 两个类变量分配内存空间,此时 INSTANCE、initPrice 的值为默认值 null 和 0.0。接着初始化进入第二个阶段,程序按顺序一次为 INSTANCE、initPrice 进行赋值。对 INSTANCE 赋值时要调用 Price(2.8),创建 Price 实例,此时立即执行 currentPrice = initPrice - discount; 为 currentPrice 进行赋值,此时 initPrice 值为 0.0,因此 currentPrice 等于 -2.8。接着程序再次将 initPrice 赋值为 20.0,但此时对 INSTANCE 的 currentPrice 实例变量已经不起作用了。

当 Price 类初始化完成后,INSTANCE 类变量引用到一个 currentPrice 为 -2.8 的 Price 实例,而 initPrice 类变量的值为20.0。当再次创建 Price 实例时,该 Price 实例的 currentPrice 实例变量的值才等于 20.0 - discount

父类构造器

当创建任何 Java 对象时,程序总会先依次调用每个父类非静态初始化块、父类构造器(总是从 Object 开始)执行初始化,最后才调用本类的非静态初始化块、构造器执行初始化。

隐式调用和显式调用

当调用某个类的构造器来创建 Java 对象时,系统总会先调用父类的非静态初始化块执行初始化。这个调用是隐式执行的,而且父类的静态初始化块总是会被执行。接着总会调用父类的一个或多个构造器执行初始化,这个调用既可以是通过super进行显式调用,也可以隐式调用。

当所有父类的非静态初始化块、构造器依次调用完成后,系统调用本类的非静态初始化块、构造器来执行初始化,最后返回本类的实例。

  1. class Creature {
  2. {
  3. System.out.println("Creature的非静态初始化块");
  4. }
  5. //下面定义两个构造器
  6. public Creature() {
  7. System.out.println("Creature无参数的构造器");
  8. }
  9. public Creature(String name) {
  10. //使用this调用另一个重载、无参数的构造器
  11. this();
  12. System.out.println("Creature带有name参数的构造器,name参数:" + name);
  13. }
  14. }
  15. class Animal extends Creature {
  16. {
  17. System.out.println("Animal的非静态初始化块");
  18. }
  19. public Animal(String name) {
  20. super(name);
  21. System.out.println("Animal带一个参数的构造器,name参数:" + name);
  22. }
  23. public Animal(String name, int age) {
  24. //使用this调用另一个重载的构造器
  25. this(name);
  26. System.out.println("Animal带2个参数的构造器,其age:" + age);
  27. }
  28. }
  29. class Wolf extends Animal {
  30. {
  31. System.out.println("Wolf 的非静态初始化块");
  32. }
  33. public Wolf() {
  34. //显式调用父类有2个参数的构造器
  35. super("灰太狼", 3);
  36. System.out.println("Wolf无参数的构造器");
  37. }
  38. public Wolf(double weight) {
  39. //使用this调用另一个重载的构造器
  40. this();
  41. System.out.println("Wolf的带weight参数的构造器,weight参数:" + weight);
  42. }
  43. }
  44. public class InitTest {
  45. public static void main(String[] args) {
  46. new Wolf(5.6);
  47. }
  48. }

执行结果

  1. Creature的非静态初始化块
  2. Creature无参数的构造器
  3. Creature带有name参数的构造器,name参数:灰太狼
  4. Animal的非静态初始化块
  5. Animal带一个参数的构造器,name参数:灰太狼
  6. Animal2个参数的构造器,其age3
  7. Wolf 的非静态初始化块
  8. Wolf无参数的构造器
  9. Wolf的带weight参数的构造器,weight参数:5.6

只要在程序创建 Java 对象,系统总是先调用最顶层父类的初始化操作,包括初始化块和构造器,然后依次向下调用所有父类的初始化操作,最终执行本类的初始化操作返回本类的实例。至于调用父类哪个构造器执行初始化,则分为如下几种情况:

  • 子类构造器执行体的第一行代码使用 super 显式调用父类构造器,系统将根据 super 调用里传入的实参数列表来确定调用父类的哪个构造器;
  • 子类构造器执行体的第一行代码使用 this 显式调用本类中重载的构造器,系统将根据 this 调用里传入的实参数列表来确定本类的另一个构造器(执行本类中另一个构造器时即进入第一种情况);
  • 子类构造器执行代码中既没有 super 调用。也没有 this 调用,系统将会在执行子类构造器之前,隐式调用父类无参数的构造器。如果父类没有无参数的构造器,程序编译时将会出错。

super 调用用于显式调用父类的构造器,this 调用用于显式调用本类另一个重载的构造器,super 调用和 this 调用都只能在构造器中使用,而且 super 调用和 this 调用都必须作为构造器的第一行代码,因此构造器中的 super 调用和 this 调用最多只能使用其中之一,而且最多只能调用一次。

访问子类对象的实例变量

子类的方法可以访问父类的实例变量,这是因为子类继承父类就会获得父类的成员变量和方法,但父类的方法不能访问子类的实例变量,因为父类根本无从知道它将被那个子类继承,它的子类将会增加怎样的成员变量。

但是,在极端的情况下,可能出现父类访问子类变量的情况。

  1. class Base {
  2. //定义一个名为i的实例变量
  3. private int i = 2;
  4. public Base() {
  5. this.display();
  6. }
  7. public void display() {
  8. System.out.println(i);
  9. }
  10. }
  11. //继承Base的Derived子类
  12. class Derived extends Base {
  13. //定义一个名为i的实例变量
  14. private int i = 22;
  15. //构造器,将实例变量初始化为222
  16. public Derived() {
  17. i = 222;
  18. }
  19. public void display() {
  20. System.out.println(i);
  21. }
  22. }
  23. public class Test {
  24. public static void main(String[] args) {
  25. //创建Derived的构造器创建实例
  26. new Derived();
  27. }
  28. }

上面的程序调用了 Derived 里的构造器,由于 Derived 类继承了 Base 父类,而且 Derived 构造器里没有显式使用 super 来调用父类的构造器,因此系统将会自动调用 Base 类中的无参数的构造器来执行初始化。

在 Base 类的无参数构造器中,只是简单地调用了 this.display() 方法来输出实例变量 i 的值,那么执行该程序,会输出2、22还是222呢?运行该程序后发现,输出结果为0。

接下来将详细介绍这个程序的运行过程,从内存分配的角度来分析程序的输出结果,从而更好地把握程序运行的真实过程。

当程序执行 new Derived(); 时,系统开始为这个 Derived 对象分配内存空间。需要指出的是,这个 Derived 对象并不是只有一个 i 实例变量,它将拥有两个 i 实例变量。

Java 构造器只是负责对 Java 对象实例变量执行初始化(也就是赋初始值),在执行构造器代码之前,该对象所占的内存就已经被分配下来,这些内存里值默认是空值————对于基本类型的变量,默认的空值就是 0 或 false,对于引用类型类型的变量,默认的空值就是 null。

当程序调用 new Derived(); 时,系统会先为 Derived 对象分配内存空间。此时系统内存需要为这个 Derived 对象分配两块内存,它们分别用于存放 Derived 对象的两个 i 实例变量,其中一个属于 Base 类定义的 i 实例变量,一个属于 Derived 类定义的 i 实例变量,此时这两个 i 变量的值都是0。

接下来程序在执行 Derived 类的构造器之前,首先会执行 Base 类定义 i 实例变量时指定的初始值2,因此经过编译器处理后,该构造器应该包含如下两行代码:

  1. i = 2;
  2. this.display();

因此,程序先将 Base 类中定义的实例变量赋值为2,再调用this.display()方法。此处有一个关键:this代表谁?

回答这个问题之前,先进行一些简单的修改,将 Base 类的构造器改为如下形式。

  1. public Base() {
  2. //直接输出this.i
  3. System.out.println(this.i);
  4. this.display();
  5. }

再次运行该程序,将会先后输出 2、0。

当 this 在构造器中时,this 代表正在初始化的 Java 对象。此时的情况是:从源代码来看,此时的 this 位于 Base() 构造器内,但这些代码实际放在 Derived() 构造器内执行————是Derived()构造器隐式调用了 Base() 构造器代码。由此可见,此时的 this 应该是 Derived 对象,而不是 Base 对象。

this 虽然代表了 Dervied 对象,但它却位于 Base 构造器中,它的编译时类型是 Base,而它实际引用一个 Derived 对象。为了证实这一点,再次改写程序。

为 Dervied 类增加一个简单的 sub() 方法,然后将 Base 构造器改为如下形式:

  1. public Base() {
  2. //直接输出this.i
  3. System.out.println(this.i);
  4. this.display();
  5. //输出this的实际类型,将看打 输出 Derived
  6. System.out.println(this.getClass());
  7. //因为this的编译类型是 Base,所以依然不能调用 sub() 方法
  8. this.sub();
  9. }

上面程序调用 this.getClass()来获取 this 代表对象的类,将看到输出 Derived 类,这表名此时 this 所引用代表的是 Derived 对象。但接下来,程序通过 this 调用 sub() 方法时,则无法通过编译,这就是因为 this 的编译时类型是 Base 的缘故。

当变量的编译时类型和运行时类型不同时,通过该变量访问它引用的对象的实例变量时,该实例变量的值由声明该变量的类型决定。但通过该变量调用它引用的对象的实例方法时,该方法行为将由它实际所引用的对象来决定。因此,当程序访问 this.i 时,将会访问 Base 类中定义的 i 实例变量,也就是输出2;但执行 this.display; 代码时,则实际表现出 Derived 对象的行为,也就是输出 Derived 对象的 i 实例变量,即 0。

调用子类重写的方法

在访问权限允许的情况下,子类可以调用父类方法,这是因为子类继承父类会获得父类定义的成员变量和方法,但父类不能调用子类的方法,因为父类根本无从它将被哪个子类继承,它的子类将会增加怎样的方法。

但又一种特殊情况,当子类方法重写了父类方法之后,父类表面上只是调用属于自己的、被子类重写的方法,但随着执行 content 的改变,将会变成父类实际调用子类的方法。

  1. class Animal {
  2. //desc实例变量保存对象 toString 方法的返回值
  3. private String desc;
  4. public Animal() {
  5. //调用 getDesc()方法初始化 desc 实例变量
  6. this.desc = getDesc();
  7. }
  8. public String getDesc() {
  9. return "Animal";
  10. }
  11. public String toString() {
  12. return desc;
  13. }
  14. }
  15. public class Wolf extends Animal {
  16. //定义name、weight两个实例变量
  17. private String name;
  18. private double weight;
  19. public Wolf(String name, double weight) {
  20. //为name、weight两个实例变量赋值
  21. this.name = name;
  22. this.weight = weight;
  23. }
  24. //重写父类的getDesc()方法
  25. @Override
  26. public String getDesc() {
  27. return "Wolf[name=" + name +", weight=" + weight + "]";
  28. }
  29. public static void main(String[] args) {
  30. System.out.println(new Wolf("灰太狼", 32.3));
  31. }
  32. }

运行该程序将会输出 Wolf[name=null, weight=0.0],理解这个程序的关键在于 this.desc = getDesc();,表面上此处是调用父类中定义的 getDesc() 方法,但实际运行过程中,此处会变为调用被子类重写的 getDesc() 方法。

程序从最开始调用 Wolf 类对应的构造器来初始化该 Wolf 对象。但在执行 Wolf 构造器里面的代码之前,系统会隐式 Animal 类的无参构造方法。在执行 this.desc = getDesc(); 时,不再调用父类的 getDesc() 方法,而是调用 Wolf 类的 getDesc() 方法。此时,程序还执行 Wolf 类的构造函数,因此 Wolf 类的 name、weight 实例变量都将保持默认值————name 的值为 null,weight 的值为0.0,因此 Wolf 的 getDesc() 方法返回值是 Wolf[name=null, weight=0.0],于是 desc 实例变量将被赋为 Wolf[name=null, weight=0.0] ,者就是看到的输出结果。

通过上面的分析可以看到,该程序产生这种输出的原因在于, getDesc() 方法是被子类重写过的方法。这样使得对 Wolf 对象的实例变量赋值的语句 this.name = name;this.weight = weight;getDesc() 方法之后被执行,因此 getDesc() 方法不能得到 Wolf 对象的 name、weight 实例变量的值。

为了避免这种不希望看到的结果,应该避免在 Animal 类的构造器调用被子类重写过的方法,因此将 Animal 类改为如下形式即可:

  1. class Animal2 {
  2. public String getDesc() {
  3. return "Animal";
  4. }
  5. public String toString() {
  6. return getDesc();
  7. }
  8. }

经过改写的 Animal2 类不再提供构造器(系统会为之提供一个无参数的构造器),程序改由 toString() 方法来调用被重写的 getDesc() 方法。者就保证了对 Wolf 对象的实例变量赋值的语句 this.name = name;this.weight = weight;getDesc() 方法之前被执行,从而使得 getDesc() 方法得到 Wolf 对象的 name、weight 实例变量的值。

如果父类构造器调用了被子类重写的方法,且通过子类构造器来创建子类对象,调用(不管是显式还是隐式)了这个父类的构造器,就会导致子类的重写方法在子类构造器的所有代码之前被执行,从而导致子类的重写方法访问不到子类的实例变量值的情形。

父子实例的内存控制

继承是面向对象的3大特征之一,也是 Java 语言的重要特性,而父、子继承关系则是 Java 编程中需要重点注意的地方。下面将继续深入分析父子实例的内存控制。

继承成员变量和继承方法的区别

当子类继承父类时,子类会获得父类中定义的成员变量和方法。当访问权限允许的情况下,子类可以直接访问父类中定义的成员变量和方法。这种介绍其实稍显笼统,因为 Java 继承中对成员变量和方法的处理是不同的,示例如下:

  1. class Base {
  2. int count = 2;
  3. public void display() {
  4. System.out.println(this.count);
  5. }
  6. }
  7. class Derived extends Base {
  8. int count = 20;
  9. @Override
  10. public void display() {
  11. System.out.println(this.count);
  12. }
  13. }
  14. public class FieldAndMethodTest {
  15. public static void main(String[] args) {
  16. //声明并创建一个Base对象
  17. Base b = new Base();
  18. //直接访问count实例变量和通过display访问count实例变量
  19. System.out.println(b.count);
  20. b.display();
  21. //声明并创建一个Derived对象
  22. Derived d = new Derived();
  23. //直接访问count实例变量和通过display访问count实例变量
  24. System.out.println(d.count);
  25. d.display();
  26. //声明一个Base变量,并将Derived对象赋给该变量
  27. Base bd = new Derived();
  28. //直接访问count实例变量和通过display访问count实例变量
  29. System.out.println(bd.count);
  30. bd.display();
  31. //让d2b变量指向原d变量所执行的Derived对象
  32. Base d2b = d;
  33. //访问d2b所指对象的count实例变量
  34. System.out.println(d2b.count);
  35. }
  36. }

上面的程序定义了2个类:Base 和 Derived 类。在程序中 Base bd = new Derived();,声明了一个 Base 变量 bd,却将一个 Derived 对象赋给该变量。此时系统将会自动进行向上转型来保证程序正确。直接通过 bd 来访问 count 实例变量,输出的将是 Base (声明时的类型)对象的 count 实例变量的值,如果通过 bd 来调用 display() 方法,该方法将表现出 Derived (运行时类型)对象的行为方式。

程序 Base b2d = d;,直接将 d 变量赋值给 d2b 变量,只是 d2b 变量的类型是 Base。这意味着 d2b 和 d 两个变量指向同一个 Java 对象,因此如果在程序中判断 d2b == d,将返回 true。但是,访问 d.count 时将输出 20,访问 d2b.count 时却输出 2.这一点看上去很诡异:两个指向同一个对象的变量,分别访问它们的实例变量时却输出不同的值。这表明在 d2b、d 变量所指向的 Java 对象中包含了两块内存,分别存放值为 2 的 count 实例变量和值为 20 的 count 实例变量。

但不管是 d 变量,还是 bd 变量,只要它们实际指向一个 Derived 对象,不管声明它们时用什么类型,当通过这些变量调用方法时,方法的行为总是表现出它们实际类型的行为。但如果通过这些变量来访问它们所指对象的实例变量,这些实例变量的值总是表现出声明这些变量所用类型的行为。由此可见,Java 继承在处理成员变量和方法时是有区别的。

  1. class Animal {
  2. public String name;
  3. public void info() {
  4. System.out.println(name);
  5. }
  6. }
  7. //继承Animal
  8. public class Wolf extends Animal {
  9. private double weight;
  10. }

上面的程序中,Wolf 类继承了 Animal 类,因此它会获得 Animal 类中声明的成员变量和方法,但这种“获得”是有区别的。用 javap 工具来分析 Wolf 类,在命令行窗口运行如下命令:
javap -c -private Wolf

  1. Compiled from "Wolf.java"
  2. public class Wolf extends Animal {
  3. private double weight; //并没有增加name实例变量
  4. public Wolf();
  5. Code:
  6. 0: aload_0
  7. 1: invokespecial #1 // Method Animal."<init>":()V
  8. 4: return
  9. public void info(); //从Animal 类“获得”的info方法
  10. Code:
  11. 0: aload_0
  12. 1: invokespecial #2 // Method Animal.info:()V
  13. 4: return
  14. }

从上面可以看出,当 Wolf 类继承 Animal 类时,编译器会直接将 Animal 里的 void info() 方法转移道 Wolf 类中。者意味着,如果 Wolf 类也包含了 void info() 方法,就会导致编译器无法将 Animal 的 void info() 方法转移道 Wolf 类中。

当子类使用 public 访问控制修饰符,而父类不适用 public 修饰符修饰时,才可以通过 javap 看到编译器将父类的 public 方法直接转移到子类中。

从上面的 javap 命令生成的信息中可以看出,编译器在处理方法和成员变量时存在的区别。对于 Animal 中定义的 public 成员变量 name 而言,系统依然将其保留在 Animal 类中,并不会将它转移到其子类 Wolf 类中。这使得 Animal 类和 Wolf 类可以同时拥有同名的实例变量。

如果在子类中重写了父类的方法,就意味着子类里定义的方法彻底覆盖了父类的同名方法,系统将不可能把父类的方法转移到子类中。对于实例变量则不存在这样的现象,即使子类中定义了与父类完全同名的实例变量,这个实例变量依然不可能覆盖父类中定义的实例变量。

因为继承成员变量和继承方法之间存在这样的差别,所以对于一个引用类型的变量而言,当通过该变量访问它所引用的对象的实例变量时,该实例变量的值取决于声明该变量时类型,当通过该变量来调用它所引用的对象的方法时,该方法行为取决于它实际引用的对象的类型。

内存中子类实例

在上面的 FieldAndMethodTest.java 程序中,可以看到一个非常极端的情况:两个引用变量引用同一个对象,但程序通过这两个引用变量访问同名的 count 实例变量时,居然输出不同的结果。下面把几条关键语句抽取出来。

  1. //声明并创建一个Derived对象
  2. Derived d = new Derived();
  3. //通过d变量来访问它所引用对象的count实例变量
  4. System.out.println(d.count);
  5. //让d2b变量指向原d变量指向的Derived对象
  6. Base d2b = d;
  7. //访问d2b所指对象的count实例变量
  8. System.out.println(d2b.count);

可以看到,程序中只创建了一个 Derived 对象,不管是 d 变量,还是 d2b 变量,它们都指向该 Derived 对象,但通过 d 变量、d2b 变量来访问 count 实例变量时,一个输出 20,一个输出2。关于这一点,前面已有介绍:当通过引用变量来访问它所引用对象的实例变量时,该实例变量的值取决于该变量时所引用的类型。

现在的问题是:Derived 对象在内存中到底如何存储?很明显它有两个不同的 count 实例变量,这意味着必须用两块内存保存它们。

  1. class Base {
  2. int count = 2;
  3. }
  4. class Mid extends Base {
  5. int count = 20;
  6. }
  7. public class Sub extends Mid {
  8. int count = 200;
  9. public static void main(String[] args) {
  10. //创建一个Sub对象
  11. Sub s = new Sub();
  12. //将Sub对象向上转型后赋为Mid、Base类型的变量
  13. Mid s2m = s;
  14. Base s2b = s;
  15. //分别通过3个变量来访问count实例变量
  16. System.out.println(s.count);
  17. System.out.println(s2m.count);
  18. System.out.println(s2b.count);
  19. }
  20. }

上面的程序定义了 3 个带有父子关系的类:Base 派生了 Mid、Mid 派生了 Sub,而且这 3 个类中都定义了名为 count 的实例变量。程序将创建一个 Sub 对象,并将这个 Sub 对象向上转型。程序的结果将会输出 200、20 和 2。这意味着 s、s2m、s2b 这 3 个变量所引用的 Java 对象拥有 3 个 count 实例变量,也就是说需要 3 块内存储存它们。

extend_memory

从上面的图可以看出,这个 Sub 对象不进储存了它自身的 count 实例变量,还需要储存从 Mid、Base 两个父类那里继承到的 count 实例变量。但这 3 个 count 实例变量在底层是有区别的,程序通过 Base 型变量来访问该对象的 count 实例变量时,将输出 2;通过 Mid 型变量来访问该对象的 count 实例变量时,将输出20;当通过 Sub 型变量的 count 实例变量时,将输出 200。为了在 Sub 类中访问 Mid 定义的 count 实例变量,可以在 count 实例变量之前增加 suoper 关键字作为限定。例如在 Sub 类增加如下方法:

  1. public void accessMid() {
  2. System.out.println(super.count);
  3. }

如上面右侧图片来看内存分配的情况,也就是说在内存中保存了 3 个 Java 对象————Sub 对象、Mid 对象和 Base 对象。3 个对象各具有一个 count 实例变量,而 Sub 类中的 super 正是引用该 Sub 对象关联的 Mid 对象,因此通过 super 来访问 count 实例变量时输出20。实际上会发现系统中只有一个 Sub 对象,而且这个 Sub 对象持有 3 个 count 实例变量。

系统内存中并不存在 Mid 和 Base 两个对象,程序内存中只有一个 Sub 对象,只是这个 Sub 对象中不仅保存了在 Sub 类中定义的所有实例变量,还保存了它所有父类所定义的全部实例变量。

那 super 关键字的作用到底是什么?

  1. class Fruit {
  2. String color = "未确定颜色";
  3. //定义一个方法,该方法返回调用该方法的实例
  4. public Fruit getThis() {
  5. return this;
  6. }
  7. public void info() {
  8. System.out.println("Fruit 方法");
  9. }
  10. }
  11. public class Apple extends Fruit {
  12. //重写父类的方法
  13. @Override
  14. public void info() {
  15. System.out.println("Apple 方法");
  16. }
  17. //通过 super 调用父类的 info 方法
  18. public void accessSuperInfo() {
  19. super.info();
  20. }
  21. //尝试返回 super 关键字所代表的内容
  22. public Fruit getSuper() {
  23. return super.getThis();
  24. }
  25. String color = "红色";
  26. public static void main(String[] args) {
  27. //创建一个Apple对象
  28. Apple a = new Apple();
  29. //调用getSuper方法获取Apple对象关联的super引用
  30. Fruit f = a.getSuper();
  31. //判断 a 和 f 的关系
  32. System.out.println("a 和 f 所引用的对象是否相同:" + (a == f));
  33. System.out.println("访问 a 所引用对象的color实例变量:" + a.color);
  34. System.out.println("访问 f 所引用对象的color实例变量:" + f.color);
  35. //分别通过a、f两个变量来调用info方法
  36. a.info();
  37. f.info();
  38. //调用accessSuperInfo来调用父类的info方法
  39. a.accessSuperInfo();
  40. }
  41. }

执行结果:

  1. a f 所引用的对象是否相同:true
  2. 访问 a 所引用对象的color实例变量:红色
  3. 访问 f 所引用对象的color实例变量:未确定颜色
  4. Apple 方法
  5. Apple 方法
  6. Fruit 方法

Java 程序允许某个方法通过 return this; 返回调用该方法的 Java 对象,但不允许直接 return super;,甚至不允许直接将 super 当成一个引用变量使用。关于这些语法规则,接下来还会有更深入的分析。

super 关键字本身并没有引用任何对象,它甚至不能被当成一个真正的引用变量使用。主要有如下两个原因:

  • 子类方法不能直接使用 return super;,但使用 return this;,返回调用该方法的对象是允许的;
  • 程序不允许直接把 super 当成变量使用,例如:试图判断 super 和 a 变量是否引用同一个 Java 对象————super == a;,但这条语句将引起编译错误。

至此。对父、子对象在内存中存储有个准备的结论:当程序创建一个子类对象时,系统不仅会为该类中定义的实例变量分配内存,也会为其父类中定义的所有实例变量分配内存,即使子类定义了与父类中同名的实例变量。也就是说,当系统创建一个 Java 对象时,如果该 Java 类有两个父类(一个直接父类A,一个间接父类B),假如 A 类中定义了 2 个实例变量,B 类中定义了 3 个实例变量,当前类中定义了 2 个实例变量,那这个 Java 对象将会保存 2+3+2 个实例变量。

如果在子类里定义了与父类中已有变量同名的变量,那么子类中定义的变量会隐藏父类中定义的变量。注意不是完全覆盖,因此系统为创建子类对象时,依然会为父类中定义、被隐藏的变量分配内存空间。

为了在子类方法中访问父类中定义的、被隐藏的实例变量,或者为了在子类方法中调用父类中定义的、被覆盖(Override)的方法,可以通过 super. 作为限定来修饰这些实例变量和实例方法。因为子类中定义与父类中同名的实例变量并不会完全覆盖父类中定义的实例变量,它只是简单地隐藏了父类中的实例变量,所以会出现如下特殊的情形。

  1. class Parent {
  2. public String tag = "疯狂 Java 讲义";
  3. }
  4. class Derived extends Parent {
  5. //定义一个私有的 tag 实例变量来隐藏父类的 tag 实例变量
  6. private String tag = "轻量级 Java EE 企业应用实战";
  7. }
  8. public class HideTest {
  9. public static void main(String[] args) {
  10. Derived d = new Derived();
  11. //程序不可访问d的私有变量:tag,所以下面的语句将引起编译错误
  12. //System.out.println(d.tag);
  13. //将d变量显式地向上转型为Parent后,即可访问tag实例变量
  14. //程序将输出:"疯狂 Java 讲义"
  15. System.out.println(((Parent)d).tag);
  16. }
  17. }

父、子类的类变量

理解了上面介绍的父、子类实例在内存中分配之后,接下来的父、子类的类变量基本与此类似。不同的是,类变量属于类本身。而实例变量则属于 Java 对象;类变量在类初始化阶段完成初始化,而实例变量则是在对象初始化阶段完成初始化。

由于类变量本质上属于类本身,因此通常不会涉及父、子实例变量那样复杂的情形,但由于 Java 允许通过对象来访问类变量,因此也可以使用 super. 作为限定来访问父类中定义的类变量。

  1. class StaticBase {
  2. //定义一个count类变量
  3. static int count = 20;
  4. }
  5. public class StaticSub extends StaticBase {
  6. //子类再定义一个count类变量
  7. static int count = 200;
  8. public void info() {
  9. System.out.println("访问本类的count类变量:" + count);
  10. System.out.println("访问父类的count类变量:" + StaticBase.count);
  11. System.out.println("访问父类的count类变量:" + super.count);
  12. }
  13. public static void main(String[] args) {
  14. StaticSub sb = new StaticSub();
  15. sb.info();
  16. }
  17. }

通常来说,建议使用 类.类变量的方式来访问类变量,因为类变量属于类本身,总是使用类名作为主调来访问类变量,能保持最好的代码可读性。

final 修饰符

final 修饰符是 Java 语言中比较简单的一个修饰符,但也是一个被“误解”较多的修饰符。对很对 Java 程序员来说,何时使用 final 修饰符,使用 final 修饰符后对程序有何影响……这些问题其实他们并不清楚,即使把某些书上的概念背诵得很流利。

  • final 可以修饰变量,被 final 修饰的变量被赋初始值之后,不能对它重新赋值。
  • final 可以修饰方法,被 final 修饰的方法不能被重写。
  • final 可以修饰类,被 final 修饰的类不能派生子类。

final 修饰的变量

首先回顾以下关于 final 实例变量的知识。被 final 修饰的实例变量必须显式指定初始值,而且只能在如下 3 个位置指定初始值。

  • 定义 final 实例变量时指定初始值;
  • 在非静态初始化块中为 final 实例变量指定初始值;
  • 在构造器中为 final 实例变量指定初始值。

对于普通实例变量,Java 程序可以对它执行默认的初始化,也就是将实例变量的值指定为默认的 0 或者nul,但对于 final 实例变量,则必须由程序员显式指定初始值。

  1. public class FinalInstanceVaribaleTest {
  2. //定义 final 实例变量时赋初始值
  3. final int var1 = "疯狂Java讲义".length();
  4. final int var2;
  5. final int var3;
  6. //在初始化块中为 var2 赋初始值
  7. {
  8. var2 = "轻量级 Java EE 企业应用实战".length();
  9. }
  10. //在构造器中为 var3 赋初始值
  11. public FinalInstanceVaribaleTest() {
  12. var3 = "疯狂 XML 讲义".length();
  13. }
  14. public static void main(String[] args) {
  15. FinalInstanceVaribaleTest fiv = new FinalInstanceVaribaleTest();
  16. System.out.println(fiv.var1);
  17. System.out.println(fiv.var2);
  18. System.out.println(fiv.var3);
  19. }
  20. }

使用 javap 工具来分析该程序。
javap -c FinalInstanceVaribaleTest
可看到如下输出:

  1. Compiled from "FinalInstanceVaribaleTest.java"
  2. public class FinalInstanceVaribaleTest {
  3. final int var1;
  4. final int var2;
  5. final int var3;
  6. //下面就是构造器代码
  7. public FinalInstanceVaribaleTest();
  8. Code:
  9. 0: aload_0
  10. 1: invokespecial #12 // Method java/lang/Object."<init>":()V
  11. 4: aload_0
  12. 5: ldc #14 // String 疯狂Java讲义
  13. 7: invokevirtual #16 // Method java/lang/String.length:()I
  14. 10: putfield #22 // Field var1:I
  15. 13: aload_0
  16. 14: ldc #24 // String 轻量级 Java EE 企业应用实战
  17. 16: invokevirtual #16 // Method java/lang/String.length:()I
  18. 19: putfield #26 // Field var2:I
  19. 22: aload_0
  20. 23: ldc #28 // String 疯狂 XML 讲义
  21. 25: invokevirtual #16 // Method java/lang/String.length:()I
  22. 28: putfield #30 // Field var3:I
  23. 31: return
  24. public static void main(java.lang.String[]);
  25. Code:
  26. 0: new #1 // class FinalInstanceVaribaleTest
  27. 3: dup
  28. 4: invokespecial #38 // Method "<init>":()V
  29. 7: astore_1
  30. 8: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
  31. 11: aload_1
  32. 12: getfield #22 // Field var1:I
  33. 15: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
  34. 18: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
  35. 21: aload_1
  36. 22: getfield #26 // Field var2:I
  37. 25: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
  38. 28: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
  39. 31: aload_1
  40. 32: getfield #30 // Field var3:I
  41. 35: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
  42. 38: return
  43. }

从上面分析结果可以看出:final 实例变量必须显式地被赋初始值,而且本质上 final 实例变量只能在构造器中被赋初始值。当然,就程序员变成来说,还可在定义 final 实例变量时指定初始值,也可以在初始化块中为 final 实例变量指定初始值,但它们本质上是一样的。除此之外,final实例变量将不能被再次复制。

对于 final 类变量而言,同样必须显式指定初始值,而且 final 类变量只能在 2 个地方指定初始值:

  • 定义 final 类变量时指定初始值;
  • 在静态初始化块中为 final 类变量指定初始值。

    1. public class FinalInstanceVaribaleTest {
    2. //定义final类变量时赋初始值
    3. final static int var1 = "疯狂 Java 讲义".length();
    4. final static int var2;
    5. //在静态块中为 var2 赋初始值
    6. static {
    7. var2 = "轻量级 Java EE 企业应用实战".length();
    8. }
    9. public static void main(String[] args) {
    10. System.out.println(FinalInstanceVaribaleTest.var1);
    11. System.out.println(FinalInstanceVaribaleTest.var2);
    12. }
    13. }

    使用 javap 工具来分析该程序。
    javap -c FinalInstanceVaribaleTest
    可看到如下输出:

    1. Compiled from "FinalInstanceVaribaleTest.java"
    2. public class FinalInstanceVaribaleTest {
    3. final int var1;
    4. final int var2;
    5. final int var3;
    6. //系统为该类增加的无参数的构造器
    7. public FinalInstanceVaribaleTest();
    8. Code:
    9. 0: aload_0
    10. 1: invokespecial #12 // Method java/lang/Object."<init>":()V
    11. 4: aload_0
    12. 5: ldc #14 // String 疯狂Java讲义
    13. 7: invokevirtual #16 // Method java/lang/String.length:()I
    14. 10: putfield #22 // Field var1:I
    15. 13: aload_0
    16. 14: ldc #24 // String 轻量级 Java EE 企业应用实战
    17. 16: invokevirtual #16 // Method java/lang/String.length:()I
    18. 19: putfield #26 // Field var2:I
    19. 22: aload_0
    20. 23: ldc #28 // String 疯狂 XML 讲义
    21. 25: invokevirtual #16 // Method java/lang/String.length:()I
    22. 28: putfield #30 // Field var3:I
    23. 31: return
    24. public static void main(java.lang.String[]);
    25. Code:
    26. 0: new #1 // class FinalInstanceVaribaleTest
    27. 3: dup
    28. 4: invokespecial #38 // Method "<init>":()V
    29. 7: astore_1
    30. 8: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
    31. 11: aload_1
    32. 12: getfield #22 // Field var1:I
    33. 15: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
    34. 18: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
    35. 21: aload_1
    36. 22: getfield #26 // Field var2:I
    37. 25: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
    38. 28: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
    39. 31: aload_1
    40. 32: getfield #30 // Field var3:I
    41. 35: invokevirtual #45 // Method java/io/PrintStream.println:(I)V
    42. 38: return
    43. }

    可以看到 var1、var2 两个类变量的初始值过程都放在静态初始化块内完成的,由此可见,final 类变量必须显式地被赋初始值,而且本质上 final 类变量只能在静态初始化块中被赋初始值。当然对于程序员百年城来说,还可在定义 final 类变量时指定初始值,也可以在静态初始化块中为 final 类变量指定初始值,但它们本质上是一样的。除此之外,final 类变量将本能呗再次赋值。

final 修饰局部变量的情形则比较简单————Java 本来就要求局部变量必须被显式地赋初始值,final 修饰的局部变量一样需要被显式地赋初始值。与普通初始变量不同的是:final 修饰的局部变量被赋初始值之后,以后再也不能对 final 局部变量重新赋值。

经过上面的介绍,大致可以发现 final 修饰符的第一个简单功能:被 final 修饰的变量一旦被赋初始值,final 变量的值以后将不会被改变。

除此之外,final 修饰符还有一个功能,先回顾类变量的初始化时机中结束的 PriceTest.java 程序,对程序稍加修改:

  1. class Price {
  2. //类变量是Price实例
  3. static final Price INSTANCE = new Price(2.8);
  4. //再定义一个类变量
  5. final static double initPrice = 20;
  6. //定义该Price的currentPrice实例变量
  7. double currentPrice;
  8. public Price(double discount) {
  9. //根据静态变量计算实例变量
  10. currentPrice = initPrice - discount;
  11. }
  12. }
  13. public class PriceTest {
  14. public static void main(String[] args) {
  15. //通过Price的INSTANCE访问currentPrice实例变量
  16. System.out.println(Price.INSTANCE.currentPrice);
  17. //显式的创建Price实例
  18. Price p = new Price(2.8);
  19. //通过显式创建的Price实例访问currentPrice实例变量
  20. System.out.println(p.currentPrice);
  21. }
  22. }

使用 javap 工具进行分析
javap -c Price
将看到如下输出:

  1. Compiled from "PriceTest.java"
  2. class Price {
  3. static final Price INSTANCE;
  4. static final double initPrice;
  5. double currentPrice;
  6. static {};
  7. Code:
  8. 0: new #1 // class Price
  9. 3: dup
  10. 4: ldc2_w #16 // double 2.8d
  11. 7: invokespecial #18 // Method "<init>":(D)V
  12. 10: putstatic #22 // Field INSTANCE:LPrice;
  13. 13: return
  14. public Price(double);
  15. Code:
  16. 0: aload_0
  17. 1: invokespecial #26 // Method java/lang/Object."<init>":()V
  18. 4: aload_0
  19. 5: ldc2_w #10 // double 20.0d
  20. 8: dload_1
  21. 9: dsub
  22. 10: putfield #28 // Field currentPrice:D
  23. 13: return
  24. }

类变量的初始化时机中,不适用 final 修饰程序中的 initPrice类变量,其 javap 命令分析如下:

  1. Compiled from "PriceTest.java"
  2. class Price {
  3. static final Price INSTANCE;
  4. static double initPrice;
  5. double currentPrice;
  6. static {};
  7. Code:
  8. 0: new #1 // class Price
  9. 3: dup
  10. 4: ldc2_w #13 // double 2.8d
  11. 7: invokespecial #15 // Method "<init>":(D)V
  12. 10: putstatic #19 // Field INSTANCE:LPrice;
  13. 13: ldc2_w #21 // double 20.0d
  14. 16: putstatic #23 // Field initPrice:D
  15. 19: return
  16. public Price(double);
  17. Code:
  18. 0: aload_0
  19. 1: invokespecial #27 // Method java/lang/Object."<init>":()V
  20. 4: aload_0
  21. 5: getstatic #23 // Field initPrice:D
  22. 8: dload_1
  23. 9: dsub
  24. 10: putfield #29 // Field currentPrice:D
  25. 13: return
  26. }

对比上面两个输出结果,不难发现当使用 final 修饰类变量时候,如果定义该 final 类变量时指定了初始值,而且该初始值可以在编译时就被确定下来,系统将不会在静态初始化块中对该类变量赋初始值,而将是在类变量中直接使用该初始值代替该 final 变量。

对于一个使用 final 修饰的变量而言,如果定义该 final 变量时就指定初始值,而且这个初始值可以在编译时就确定下来,那么这个 final 变量将不再是一个i额变量,系统会将其当成“宏变量”处理。也就是说,所有出现该变量的地方,系统将直接把它当成对应的值处理。

对于上面的 Price 类而言,由于使用了 final 关键字修饰 initPrice 类变量,因此 Price 类的构造器中执行 currentPrice = initPrice - discount; 代码时,程序直接会将 initPrice 替换成 20.因此,执行该代码的效果相当于 currentPrice = 20 - discount;

执行“宏替换”的变量

对一个 final 变量,不管它是类变量、实例变量,还是局部变量,只要定义该变量时就使用 final 修饰符修饰,并在定义该 final 类变量时指定了初始值,而且该初始值可以再编译时就确定下来,那么这个 final 变量本质上就已经不再是变量,而是相当于一个直接量。

  1. public class FinalLocalTest {
  2. public static void main(String[] args) {
  3. //定义一个普通局部变量
  4. int a = 5;
  5. System.out.println(a);
  6. }
  7. }

使用 javap 工具来分析这个类。
javap -c FinalLocalTest
将看到如下结果:

  1. Compiled from "FinalLocalTest.java"
  2. public class FinalLocalTest {
  3. public FinalLocalTest();
  4. Code:
  5. 0: aload_0
  6. 1: invokespecial #8 // Method java/lang/Object."<init>":()V
  7. 4: return
  8. public static void main(java.lang.String[]);
  9. Code:
  10. 0: iconst_5
  11. 1: istore_1
  12. 2: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
  13. 5: iload_1
  14. 6: invokevirtual #22 // Method java/io/PrintStream.println:(I)V
  15. 9: return
  16. }

经过 javap 处理过的代码可以看出:如果没有使用 final 修饰变量a,系统会把它当成一个变量处理。但如果使用 final 修饰它,并使用 javap 来分析将看到如下输出:

  1. Compiled from "FinalLocalTest.java"
  2. public class FinalLocalTest {
  3. public FinalLocalTest();
  4. Code:
  5. 0: aload_0
  6. 1: invokespecial #8 // Method java/lang/Object."<init>":()V
  7. 4: return
  8. public static void main(java.lang.String[]);
  9. Code:
  10. 0: iconst_5
  11. 1: istore_1
  12. 2: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
  13. 5: iconst_5
  14. 6: invokevirtual #22 // Method java/io/PrintStream.println:(I)V
  15. 9: return
  16. }

从上面分析代码可以看出,此时变量 a 完全消失了,程序中根本不存在这个变量,当程序执行 System.out.println(a); 代码时,实际转换为执行 System.out.println(5);

final 修饰符的一个重要用途就是定义“宏变量”,当定义 final 变量时就为该变量指定了初始值,而且该初始值可以在编译时就确定下来,那这个 final 变量本质上就是一个“宏变量”,编译器会把程序中所有用到该变量的地方直接替换陈该变量的值。

除了上面那种为 final 变量赋值时赋直接量的情况外,如果被赋的表达式只是基本的算术运算表达式或字符串连接运算,没有访问普通变量,调用方法,Java 编译器同样会将这种 final 变量当成“宏变量”处理。

  1. public class FinalTest {
  2. public static void main(String[] args) {
  3. //下面定义了4个final“宏变量”
  4. final int a = 5 + 2;
  5. final double b = 1.2 / 3;
  6. final String str = "疯狂" + "java";
  7. final String book = "疯狂 Java 讲义:" + 99.0;
  8. //下面book2变量的值因为调用了方法,所以无法在编译时被确定下来
  9. final String book2 = "疯狂 Java 讲义:" + String.valueOf(99.0);
  10. System.out.println(book == "疯狂 Java 讲义:99.0");
  11. System.out.println(book2 == "疯狂 Java 讲义:99.0");
  12. }
  13. }

从表面上看,book 和 book2 没有太大的区别,只是定义 book2 变量时显式将数值 99.0 转换为字符串,但由于该变量的值需要调用 String 类的方法,因此编译器无法在编译时确定 book2 的值,books 不会被当成“宏变量”处理。

程序最后两行代码判断 book、book2和“疯狂 Java 讲义:99.0”是否相等。由于 book 是一个“宏变量”,它将被直接替换成“疯狂 Java 讲义:99.0”,因此 book 和“疯狂 Java 讲义:99.0”相等,但 book2 和该字符串不相等。

Java 会缓存所用曾经用过的字符串直接量,例如执行 String a = "java"; 语句之后,系统h的字符串池中就会缓存一个字符串“java”;如果程序再次执行 String b = "java",系统将会让 b 直接指向字符串池中的“java”字符串,因此 a==b 将会返回 true。

为了加深对 final 修饰符的印像,先看如下简单的程序。

  1. public class StringJoinTest {
  2. public static void main(String[] args) {
  3. String s1 = "疯狂 Java";
  4. String s2 = "疯狂" + " Java";
  5. System.out.println(s1 == s2);
  6. //定义2个字符串直接量
  7. String str1 = "疯狂";
  8. String str2 = " Java";
  9. //将str1和str2进行连接运算
  10. String s3 = str1 + str2;
  11. System.out.println(s1 == s3);
  12. }
  13. }

上面的程序中分别判断 s1 和 s2 是否想到,以及 s1 和 s3 是否相等。s1 是一个普通的字符串直接量“疯狂 Java”,s2 的值是两个字符串直接量进行连接运算,由于编译器可以在编译阶段就确定 s2 的值为“疯狂 Java”,所以系统会让 s2 直接指向字符串池中缓存的“疯狂 Java”字符串,由此可见 s1 == s2 将输出 true。

对于 s3 而言,它的值由 str1 和 str2 进行连接运算后得到。由于 str1 和 str2 只是两个普通变量,编译器不会执行“宏替换”,因此编译器无法在编译时确定 s3 的值,不会让 s3 指向字符串池中缓存中的 “疯狂 Java”。由此可见,s1 == s3 将输出 false。

为了让 s1 == s3 输出 true 也很简单,只要编译器可以对 str1、str2 两个变量进行“宏替换”。这样编译器即可在编译阶段就确定 s3 的值,就会让 s3 指向字符串池中缓存的“疯狂 Java”。

  1. public class StringJoinTest {
  2. public static void main(String[] args) {
  3. String s1 = "疯狂 Java";
  4. String s2 = "疯狂" + " Java";
  5. System.out.println(s1 == s2);
  6. //定义2个字符串直接量
  7. final String str1 = "疯狂";
  8. final String str2 = " Java";
  9. //将str1和str2进行连接运算
  10. String s3 = str1 + str2;
  11. System.out.println(s1 == s3);
  12. }
  13. }

对于实例变量而言,除了可以在定义该变量时赋初始值之外,还可以在非静态初始化块、构造器中对它赋初始值,而且这 3 个地方指定初始值的效果基本一样。但对于 final 实例变量而言。只有在定义该变量时指定初始值才会有“宏变量”的效果,在非静态初始化块、构造器中为 final 实例变量指定初始值则不会有这种效果。

  1. public class FinalInitTest {
  2. //定义3个final实例变量
  3. final String str1;
  4. final String str2;
  5. final String str3 = "Java";
  6. //str1、str2分别在非静态初始化块、构造器中初始化
  7. {
  8. str1 = "Java";
  9. }
  10. public FinalInitTest() {
  11. str2 = "Java";
  12. }
  13. //判断str1、str2、str3是否执行宏替换
  14. public void display() {
  15. System.out.println(str1 + str1 == "JavaJava");
  16. System.out.println(str2 + str2 == "JavaJava");
  17. System.out.println(str3 + str3 == "JavaJava");
  18. }
  19. public static void main(String[] args) {
  20. FinalInitTest fit = new FinalInitTest();
  21. fit.display();
  22. }
  23. }

上面的程序中定义 3 个 final 实例变量,但只有 str3 在定义变量时指定了初始值,另外的 str1、str2 分别在非静态初始化块、构造器中指定初始值,因此系统不会对 str1、str2 执行“宏替换”,但会对 str3 执行“宏替换”。

与此类似的是,对于普通类变量,在定义时指定初始值、在静态初始化块中赋初始值的效果基本一样。但对于 final 类变量而言,只有在定义 final 类变量时指定初始值,系统才会对该 final 类变量执行“宏替换”。

  1. public class FinalStaticTest {
  2. //动漫国2个final类变量
  3. final static String str1;
  4. final static String str2 = "Java";
  5. //将str1放在静态初始化块中初始化
  6. static {
  7. str1 = "Java";
  8. }
  9. public static void main(String[] args) {
  10. System.out.println( str1 + str1 == "JavaJava");
  11. System.out.println( str2 + str2 == "JavaJava");
  12. }
  13. }

上面程序中定义了 2 个 final 类变量,但只有 str2 在定义该变量时指定了初始值,str1 则在静态初始化块中指定初始值,因此系统不会对 str1 执行“宏替换”,但会对 str2 执行“宏替换”。

final 方法不能被重写

有 Java 基础的读者应该都知道:当 final 修饰某个方法时,用于限制该方法不可被它的子类重写。如下:如下程序是错误的。

  1. class A {
  2. final void info(){}
  3. }
  4. class B extends A {
  5. //试图重写父类的final 方法出现错误
  6. void info(){}
  7. }

不过有些情况需要指出:如果父类中某个方法使用了 final 修饰符进行修饰,那么这个方法将不可能被它的子类访问到,因此这个方法也不可能被它的子类重写。从这个意义来说,private 和 final 同时修饰某个方法没有太大意义,但是被 Java 语法允许的。

  1. class Base {
  2. private final void info() {
  3. System.out.println("Base 的 info 方法");
  4. }
  5. }
  6. public class FinalMethodTest extends Base {
  7. //这个 info 方法并不是覆盖父类方法
  8. //@Override
  9. public void info() {
  10. System.out.println("FinalMethodTest 的 info 方法");
  11. }
  12. }

上面的 Base 类中定义了一个 final 修饰的 info 方法,但由于该方法使用了 private 修饰符,因此这个方法不可能在子类中被访问,当然也就不能被子类重写了。

接着,程序从 Base 派生了一个 FinalMethodTest 子类,该子类中也定义了一个 info 方法,由于 FinalMethodTest 子类根本不可能访问到父类中 private 修饰的 info() 方法,所以 FinalMethodTest 子类中定义的 info() 方法只是一个普通方法,并不是重写父类的方法。

为了更好地证实上面 FinalMethodTest 子类中的 info() 方法,只是普通方法,而不是重写父类的 info 方法,可以为 FinalMethodTest 子类的 info() 方法增加 @Override 注释————该注释用于强制该方法必须重写父类方法。

与此类似的是,如果父类和子类没有处于同一个包下,父类中包含的某个方法不适用访问控制符(相当于包访问权限)或者仅使用 private 访问控制符,那子类也是无法重写该方法的。

内部类中的局部变量

对 Java 基础掌握比较好的读者应该还有印像:如果程序需要在匿名内部类中使用局部变量,那么这个局部变量必须使用 final 修饰符修饰。

  1. import java.util.Arrays;
  2. interface IntArrayProductor {
  3. //接口里定义的product方法用于封装“处理行为”
  4. int product();
  5. }
  6. public class CommandTest {
  7. //定义一个方法生成指定长度的数组,但是每个数组元素又 cmd 负责产生
  8. public int[] process(IntArrayProductor cmd, int length) {
  9. int[] result = new int[length];
  10. for (int i = 0;i < length;i++) {
  11. result[i] = cmd.product();
  12. }
  13. return result;
  14. }
  15. public static void main(String[] args) {
  16. CommandTest ct = new CommandTest();
  17. final int seed = 5;
  18. //生成数组,具体生成方式取决于IntArrayProductor接口的匿名实现类
  19. int[] result = ct.process(new IntArrayProductor() {
  20. @Override
  21. public int product() {
  22. return (int)Math.round(Math.random() * seed);
  23. }
  24. }, 6);
  25. System.out.println(Arrays.toString(result));
  26. }
  27. }

在上面的程序中定义了一个匿名内部类,这个匿名内部类实现了 IntArrayProductor 接口,该匿名内部内实现的 produce() 方法访问了局部变量 seed。在 java7 及之前的 Java 版本中,这个局部变量必须使用 final 修饰,否则编译该程序时将提示:“从内部类中访问局部变量 seed;需要被声明为最终类型”;但在 Java8 及之后的 Java 版本中,则可以不显式的添加 final 修饰符,系统会隐式对这个局部变量添加 final 修饰符。这是因为在 Java8 中添加了一个 effectively final 的语法糖,同时也需要按照 final 变量得方式来使用这个变量,例如在内部类中尝试对 seed 变量修改赋值,依旧会编译出错。

不仅仅是匿名内部类,即使是普通内部类,在任何内部类中访问的局部变量都应该使用 final 修饰符,即使在 Java8 之后的 Java 版本里没有显式添加 final 修饰符,系统也会隐式的添加 final 修饰符。

  1. import java.util.Arrays;
  2. interface IntArrayProductor {
  3. //接口里定义的product方法用于封装“处理行为”
  4. int product();
  5. }
  6. public class CommandTest {
  7. //定义一个方法生成指定长度的数组,但是每个数组元素又 cmd 负责产生
  8. public int[] process(IntArrayProductor cmd, int length) {
  9. int[] result = new int[length];
  10. for (int i = 0;i < length;i++) {
  11. result[i] = cmd.product();
  12. }
  13. return result;
  14. }
  15. public static void main(String[] args) {
  16. CommandTest ct = new CommandTest();
  17. final int seed = 5;
  18. class IntArrayProductorImpl implements IntArrayProductor {
  19. @Override
  20. public int product() {
  21. return (int)Math.round(Math.random() * seed);
  22. }
  23. }
  24. //生成数组,具体生成方式取决于IntArrayProductor接口的匿名实现类
  25. int[] result = ct.process(new IntArrayProductorImpl(), 6);
  26. System.out.println(Arrays.toString(result));
  27. }
  28. }

此处所说的内部类值的是局部内部类,因为只有局部内部类(包括匿名内部类)才可以访问局部变量,普通静态内部类、非静态内部类不可能访问方法体内的局部变量。

掌握上面的语法之后,再想一个问题:为什么 Java 要求内部类访问的局部变量必须使用 final 修饰?

千万不要以为哪种编程语言的语法是设计者故意在“刁难”开发者,任何编程语言的设计者设计一门语言的初衷大致相同————对于开发者尽量简单,但又可以保证语言本身没有问题。因此各种编程语言中看似“千奇百怪”的语法,总有其存在的理由————即使有些理由已经稍显过时。如果能理解语言设计者制定该语法的原因,对其掌握该语言,甚至提高编程思路,都会有很大帮助。

Java 要求所有被内部类访问的局部变量都使用 final 修饰也是有其原因的:对于普通局部变量而言,它的作用域就是停留在该方法内,当方法执行结束,该局部变量也随之消失。但内部类则可能产生隐式的“闭包(Closure)”,闭包将使得局部变量脱离它所在的方法继续存在。

下面的程序是局部变量脱离它所在方法继续存在的例子:

  1. public class ClosureTest {
  2. public static void main(String[] args) {
  3. //定义一个局部变量
  4. final String str = "Java";
  5. //在内部类里访问局部变量str
  6. new Thread(new Runnable() {
  7. @Override
  8. public void run() {
  9. for (int i = 0;i < 100;i++) {
  10. //此处将一直可以访问到str局部变量
  11. System.out.println(str + " " + i);
  12. //暂停0.1秒
  13. try {
  14. Thread.sleep(100);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. }
  20. }).start();
  21. //执行到此处,main方法结束
  22. }
  23. }

上面的程序中定义了一个局部变量 str,正常情况下,当程序执行完 main 方法,main 方法的生命周期就结束了,局部变量 str 的作用域也会随之结束。但值要新线程里的 run 方法没有执行完,匿名内部类的实例的生命周期就没有结束,将一直可以访问 str 局部变量 str 局部变量的值,这就是内部类会扩大局部变量作用域的实例。

由于内部类可能扩大内部变量的作用域,如果再加上这个被内部类访问的局部变量没有使用 final 修饰,也就是说该变量的值可以随意改变,那将引起极大的混乱,因此 Java 编译器要求所有被内部类访问的局部变量必须使用 final 修饰符修饰。

本文转载自:《疯狂Java 突破程序员基本功的16课》第二章 对象与内存控制

评论

发表评论 点击刷新验证码

提示

该功能暂未开放