Java内存管理分为两个方面:内存分配和内存回收。这里的内存分配特指创建Java对象时JVM为该对象在堆内存中所分配的内存空间。内存回收指的是当该Java对象失去引用,变成垃圾时,JVM的垃圾回收机制自动清理该对象,并回收该对象所占用的内存。由于JVM内置了垃圾回收机制回收失去引用的Java对象所占用的内存,所以很多Java开发者认为Java不存在内存泄漏,资源泄漏的问题。实际上这是一种错觉,Java程序依然会有内存泄漏。
由于JVM的垃圾回收机制由一条后台线程完成,本身也是非常消耗性能的,因此如果肆无忌惮地创建对象,让系统分配内存,那这些分配的内存都将由垃圾回收机制进行回收。这样做有两个坏处:
(1) 不断分配内存使得系统中可用内存减少,从而降低程序运行性能;
(2) 大量已分配内存的回收使得垃圾回收的负担加重,降低程序的运行性能。
2.1实例变量和类变量
Java程序的变量大体可分为成员变量和局部变量。其中局部变量可分为如下3类:
(1) 形参:在方法签名中定义的局部变量,由方法调用者为其赋值,随方法的结束而消亡。
(2) 方法内的局部变量:在方法内定义的局部变量,必须在方法内对其进行显式初始化。这种类型的局部变量从初始化完成后开始生效,随方法的结束而消亡。
(3) 代码块内的局部变量:在代码块内定义的局部变量,必须在代码块内对其进行显式初始化。这种类型的局部变量从初始化完成后开始生效,随代码块的结束而消亡。
局部变量的作用时间很短暂,他们都被存储在方法的栈内存中。
类体内定义的变量被成为成员变量(Field)。如果定义该成员变量时没有使用static修饰,该成员变量又被称为非静态变量或实例变量;如果使用了static修饰,则该成员变量又被成为静态变量或类变量。
(对于static关键字而言,从词义上看来,它是“静态”的意思。但从Java程序的角度来看,static的作用就是将实例成员变成类成员,static只能修饰在类里定义的成员部分,包括成员变量,方法,内部类,初始化块,内部枚举类。如果没有使用static修饰这些类里的成员,这些成员属于该类的实例;如果使用了static修饰,这些成员就属于类本身。从这个意义上看,static只能修饰类里的成员,不能修饰外部类,不能修饰局部变量,局部内部类)。
表面上看,Java类里定义成员变量时没有先后顺序,但实际上Java要求定义成员变量时必须采用合法的前向引用。示例如下:
public class ErrorDef { //下面代码将提示:非法向前引用 int num1 = num2 + 2; int num2 = 20; } public class ErrorDef2 { //下面代码将提示:非法向前引用 static int num1 = num2 + 2; static int num2 = 20; }
但如果一个是实例变量,一个是类变量,则实例变量总是可以引用类变量,因为类变量的初始化时机总是处于实例变量的初始化时机之前。示例如下:
public class RightDef { //下面代码完全正确 int num1 = num2 + 2; static int num2 = 20; }
2.1.1 实例变量和类变量的属性
使用static修饰的成员变量是类变量,属于该类本身;没有使用static修饰的成员变量是实例变量,属于该类的实例。在同一个JVM内,每个类只对应一个Class对象,但每个类可以创建多个Java对象。
由于同一个JVM内每个类只对应一个Class对象,因此同一个JVM内的一个类的类变量只需一块内存空间;但对于实例变量而言,该类每创建一次实例,就需要为实例变量分配一块内存空间。也就是说,程序中有几个实例,实例变量就需要几块内存空间。
下面程序可以很好地表现出实例变量属于对象,而类变量属于类的特性
class Person { String name; int age; static int eyeNum; public void info() { System.out.println("我的名字是:" + name + ", 我的年龄是:" + age); } } public class FieldTest { public static void main(String[] args) { //类变量属于该类本身,只要该类初始化完成,程序即可使用类变量。 Person.eyeNum = 2; //通过Person类访问eyeNum类变量 System.out.println("Person的eyeNum属性:" + Person.eyeNum); //创建第一个Person对象 Person p = new Person(); p.name = "猪八戒"; p.age = 300; //通过p访问Person类的eyeNum类变量 System.out.println("通过p变量访问eyeNum类变量:" + p.eyeNum); p.info(); //创建第二个Person对象 Person p2 = new Person(); p2.name = "孙悟空"; p2.age = 500; p2.info(); //通过p2修改Person类的eyeNum类变量 p2.eyeNum = 3; //分别通过p、p2和Person访问Person类的eyeNum类变量 System.out.println("通过p变量访问eyeNum类变量:" + p.eyeNum); System.out.println("通过p2变量访问eyeNum类变量:" + p2.eyeNum); System.out.println("通过Person类访问eyeNum类变量:" + Person.eyeNum); } }
执行完这段程序,内存情况如图:
当Person类初始化完成后,eyeNum类变量也随之初始化完成。
2.1.2 实例变量的初始化时机
从程序运行的角度来看,每次创建Java对象都会为实例变量分配内存空间,并对实例变量执行初始化。
从语法角度来看,程序可以在3个地方对实例变量执行初始化:
(1) 定义实例变量时指定初始值;
(2) 非静态初始化块中对实例变量指定初始值;
(3) 构造器中对实例变量指定初始值。
其中第1,2种方式比第3种方式更早执行,但他们的执行顺序与排列顺序相同。
下面程序示范了实例变量的初始化时机
class Cat { //定义name、age两个实例变量 String name; int age; //使用构造器初始化name、age两个实例变量 public Cat(String name , int age) { System.out.println("执行构造器"); this.name = name; this.age = age; } { System.out.println("执行非静态初始化块"); weight = 2.0; } //定义时指定初始值 double weight = 2.3; public String toString() { return "Cat[name=" + name + ",age=" + age + ",weigth=" + weight + "]"; } } public class InitTest { public static void main(String[] args) { Cat cat = new Cat("kitty" , 2); System.out.println(cat); Cat c2 = new Cat("Jerfield" , 3); System.out.println(c2); } }
当程序执行Cat cat = new Cat("kitty" , 2);时,创建第一个Cat对象,程序就会先执行Cat类的非静态初始化块,再调用该Cat类的构造器来初始化该Cat实例,内存分配如图:
从上图可以看出,该Cat对象的weight实例变量的值为2.3,而不是初始化块中指定的。这是因为,初始化块中指定初始值,定义weight时指定初始值,都属于对该实例变量执行的初始化操作,它们的执行顺序与它们在源程序中的排列顺序相同。在本程序中,初始化块中对weight的赋值位于定义weight语句之前,因此程序将先执行初始化块中的操作,执行完成后weight的值为2.0;然后再执行定义weight时指定的初始值,完成后weight为2.3,从这里看,初始化块中对weight所指定的初始化值每次都将被2.3所覆盖
2.1.3 类变量的初始化时机
从程序运行的角度来看,每JVM对一个Java类只初始化一次,因此Java程序每运行一次,系统只为类变量分配一次内存空间,执行一次初始化。
从语法角度来看,程序可以在2个地方对类变量执行初始化:
(1) 定义类变量时指定初始值;
(2) 静态初始化块中对类变量指定初始值
这两种方式的执行顺序与它们在源程序中排列顺序相同。下列程序示范了类变量的初始化时机。
public class StaticInitTest { //定义count类变量,定义时指定初始值。 static int count = 2; //通过静态初始化块为name类变量指定初始值 static { System.out.println("StaticInitTest的静态初始化块"); name = "Java编程"; } //定义name类变量时指定初始值 static String name = "疯狂Java讲义"; public static void main(String[] args) { //访问该类的两个类变量 System.out.println("count类变量的值:" + StaticInitTest.count); System.out.println("name类变量的值:" + StaticInitTest.name); } }
2.2 父类构造器
当创建任何Java对象时,程序总会先依次调用每个父类非静态初始化块,父类构造器执行初始化,最后才调用本类的非静态初始化块,构造器执行初始化。
2.2.1 隐式调用和显式调用
当调用某个类的构造器来创建Java对象时,系统总会先调用父类的非静态初始化块进行初始化。这个调用是隐式执行的,而且父类的静态初始化块总是会被执行。接着调用父类一个或多个构造器执行初始化,这个调用既可以是通过super进行显式调用,也可以是隐式调用。
当所有父类的非静态初始化块,构造器依次调用完成后,系统调用本类的非静态初始化块,构造器执行初始化,最后返回本类实例。
如果有本图的继承关系:
程序会按如下步骤进行初始化:
(1) 执行Object类非静态初始化块(如果有的话)。
(2) 隐式或显式调用Object类的一个或多个构造器执行初始化。
(3) 执行Parent类非静态初始化块(如果有的话)。
(4) 隐式或显式调用Parent类的一个或多个构造器执行初始化。
(5) 执行Mid类非静态初始化块(如果有的话)。
(6) 隐式或显式调用Mid类的一个或多个构造器执行初始化。
(7) 执行Sub类非静态初始化块(如果有的话)。
(8) 隐式或显式调用Sub类的一个或多个构造器执行初始化。
只要在程序创建Java对象,系统总是先调用最顶层父类的初始化操作,包括初始化块和构造器,然后依次向下调用所有父类的初始化操作,最终执行本类的初始化操作返回本类的实例。至于调用父类的哪个构造器执行初始化,则分为如下几种情况:
(1) 子类构造器执行体的第一行代码使用super显式调用父类构造器,系统将根据super调用里传入的实参列表来确定调用父类的哪个构造器;
(2) 子类构造器执行的第一行代码使用this显式调用本类中重载的构造器,系统将根据this调用里传入的实参列表来确定本类的另一个构造器(执行本类中另一个构造器时即进入第一种情况);
(3) 子类构造器执行提中既没有super调用,也没有this调用,系统将会在执行子类构造器之前,隐式调用父类无参数的构造器。
(super调用用于显式调用父类的构造器,this调用用于显式调用本类中另一个重载的构造器。Super调用和this调用都只能在构造器中使用,而且super调用和this调用都必须作为构造器的第一行代码,因此构造器中的super调用和this调用最多只能使用其中之一,而且最多只能调用一次)。
2.2.2 访问子类对象的实例变量
子类的方法可以访问父类的实例变量,这是因为子类继承父类就会获得父类的成员变量和方法;但父类的方法不能访问子类的实例变量,因为父类根本无从知道它将被哪个子类继承,它的子类将会增加怎样的成员变量。
但是,在极端的情况下,可能出现父类访问子类变量的情况,例子如下:
class Base { //定义了一个名为i的实例变量 private int i = 2; public Base() { this.display(); } public void display() { System.out.println(i); } } //继承Base的Derived子类 class Derived extends Base { //定义了一个名为i的实例变量 private int i = 22; //构造器,将实例变量i初始化为222 public Derived() { i = 222; } public void display() { System.out.println(i); } } public class Test { public static void main(String[] args) { //创建Derived的构造器创建实例 new Derived(); } }
程序main里只有new Derived();。它会调用Derived的构造器,因为Derived继承了Dase,而且Derived构造器里没有显式使用super来调用父类的构造器,因此系统将会自动调用Base中无参数的构造器来初始化。
最后程序输出是0。不是22,也不是222,这是怎么回事呢?
为了解释这个程序,首先需要澄清一个概念:Java对象是由构造器创建的吗?很多书籍,资料中会说,是的。
但实际情况是:构造器只负责对Java对象实例变量执行初始化(也就是赋初始值),在执行构造器代码之前,该对象所占的内存已经被分配下来,这些内存里值都是默认是空值。
当程序执行new Derived();时,系统会先为Derived对象分配内存空间。此时系统内存需要为这个Derived对象分配两块内存,它们分别用于存放Derived对象的两个i实例变量,其中一个属于Base类定义的i,一个属于Derived类定义的i,此时这2个i的值都是0。
接下来程序在执行Derived的构造器之前,首先会执行Base类的构造器。表面上看,Base的构造器内只有一行代码this.display();,但由于Base类定义了i时指定了初始值是2,因此经过编译处理后,该构造器应该包含如下两行代码。
i = 2;
this.display();
现在的问题就是此处的this代表谁?
Java的定义是,当this在构造器中时,this代表正在初始化的Java对象。此时的情况是:从源代码来看,此时的this位于Base构造器内,但这些代码实际放在Derived构造器内执行,是Derived构造器隐式调用了Base构造器的代码。由此可见,此时的this应该是Derived对象,而不是Base对象。但是这个this虽然代表Derived对象,但它却位于Base构造器中,它的编译时类型是Base,而它实际引用了一个Derived对象。
为了验证这一点,为Derived类增加一个简单的sub()方法,然后改造Base构造器
public Base() { System.out.println(this.i); this.display(); System.out.println(this.getClass()); //因为this的编译类型是Base,所以依然不能调用sub()方法 this.sub(); }
上面程序调用this.getClass()来获取this代表对象的类,将看到输出Derived类,这表明此时this引用代表是Derived对象,但程序调用sub()时,则无法通过编译,这是因为this的编译时类型是Base的缘故。
当变量的编译时类型和运行时类型不同时,通过该变量访问它引用的对象的实例变量时,该实例变量的值由声明该变量的类型决定。但通过该变量调用它引用的对象的实例方法时,该方法行为将由它实际所引用的对象来决定。因此,当程序访问this.i时,将会访问Base类中定义的i实例变量,也就是输出2;但执行this.display()时,则实际表现出Derived对象的行为,也就是输出Derived对象的i实例变量,即0。
2.2.3 调用被子类重写的方法
在访问权限允许的情况下,子类可以调用父类方法,这是因为子类继承父类会获得父类定义的成员变量和方法;但父类不能调用子类的方法,因此父类根本无从知道它将被哪个子类继承,它的子类将会增加怎样的方法。
但有一种特殊情况,当子类方法重写了父类方法之后,父类表面上只有调用属于自己的,被子类重写的方法,但随着执行context的改变,将会变成父类实际调用子类的方法。
实例如下:
class Animal { //desc实例变量保存对象toString方法的返回值 private String desc; public Animal() { //调用getDesc()方法初始化desc实例变量 this.desc = getDesc(); } public String getDesc() { return "Animal"; } public String toString() { return desc; } } public class Wolf extends Animal { //定义name、weight两个实例变量 private String name; private double weight; public Wolf(String name , double weight) { //为name、weight两个实例变量赋值 this.name = name; this.weight = weight; } //重写父类的getDesc()方法 @Override public String getDesc() { return "Wolf[name=" + name + " , weight=" + weight + "]"; } public static void main(String[] args) { System.out.println(new Wolf("灰太郎" , 32.3)); } }
这段代码会输出Wolf[name=null , weight=0.0],而不是Wolf[name=灰太狼 , weight=32.3],那我们赋的值哪去了?
理解这个程序的关键在于this.desc = getDesc();。表面上此处是调用父类中定义的getDesc()方法,但实际运行过程中,此处会变成调用被子类重写的getDesc()方法。
程序在执行new Wolf("灰太郎" , 32.3) 之前,系统会隐式执行父类无参数的构造器,也就是先执行this.desc = getDesc();但不再调用父类中定义的getDesc()方法,而是调用子类的getDesc()方法。此时还没有为name,weight赋值,所以会输出Wolf[name=null , weight=0.0]
通过上面分析可以看到,该程序产生这种输出的原因在于this.desc = getDesc();处调用的getDesc()方法是被子类重写过的方法。这样使得对Wolf对象的实例变量赋值的语句this.name = name;this.weight = weight;在getDesc()方法之后被执行,因此getDesc()方法不能得到Wolf对象的name,weight实例变量的值。
为了避免这种不希望看到的结果,应该避免在Animal类的构造器中调用被子类重写过的方法,因此将Animal类改成如下形式即可:
class Animal2 { public String getDesc() { return "Animal"; } public String toString() { return getDesc(); } }
经过改写的Animal2类不再提供构造器(系统会为止提供一个无参数的构造器),程序改由toString()方法来调用被重写的getDesc()方法。这就保证了对Wolf对象的实例变量赋值的语句his.name = name;this.weight = weight;在getDesc()方法之前被执行,从而使得getDesc()方法得到Wolf对象的name,weight实例变量的值。
如果父类构造器调用了被子类重写的方法,且通过子类构造器来创建子类对象,调用(不管是显式还是隐式)了这个父类构造器,就会导致子类的重写方法在子类构造器的所有代码之前被执行,从而导致子类的重写方法访问不到子类的实际变量值的情形。
2.3 父子实例的内存控制
继承是面向对象的3大特征之一,也是Java语言的重要特征,而父,子继承关系则是Java编程中需要重点注意的地方。下面将继续深入分析父子实例的内存控制。
2.3.1 继承成员变量和继承方法的区别
很多java书籍,资料都会介绍,当子类继承父类时,子类会获得父类中定义的成员变量和方法。当访问权限允许的情况下,子类可以直接访问父类中定义的成员变量和方法。这种介绍其实稍嫌笼统,因为Java继承中对成员变量和方法的处理是不同的,示例如下:
class Base { int count = 2; public void display() { System.out.println(this.count); } } class Derived extends Base { int count = 20; @Override public void display() { System.out.println(this.count); } } public class FieldAndMethodTest { public static void main(String[] args) { // //声明、创建一个Base对象 Base b = new Base(); // //直接访问count实例变量和通过display访问count实例变量 System.out.println(b.count); b.display(); //声明、并创建一个Derived对象 Derived d = new Derived(); //直接访问count实例变量和通过display访问count实例变量 System.out.println(d.count); d.display(); // //声明一个Base变量,并将Derived对象赋给该变量 Base bd = new Derived(); //(3) // //直接访问count实例变量和通过display访问count实例变量 System.out.println(bd.count); bd.display(); // //让d2b变量指向原d变量所指向的Dervied对象 Base d2b = d; //(4) //访问d2b所指对象的count实例变量 System.out.println(d2b.count); } }
在(3)这块代码中,直接通过db访问count实例变量,输出的将是Base(声明时的类型)对象的count实例变量的值;如果通过db来调用display()方法,该方法将表现出Derived(运行时类型)对象的行为方式。
程序(4)代码直接将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变量,d2b变量,只要它们实际指向一个Dervied对象,不管声明它们时用什么类型,当通过这些变量调用方法时,方法的行为总是表现出它们实际类型的行为;但如果通过这些变量来访问它们所指对象的实例变量,这些实例变量的值总是表现出声明这些变量所用类型的行为。由此可见,Java继承在处理成员变量和方法时是有区别的。
如果在子类重写了父类方法,就意味着子类里定义了方法彻底覆盖了父类里的同名方法,系统将不可能把父类里的方法转移到子类中。对于实例变量则不存在这样的现象,即使子类中定义了与父类完全同名的实例变量,这个实例变量依然不可能覆盖父类中定义的实例变量。
因为继承成员变量和继承方法之间存在这样的差别,所以对于一个引用类型的变量而言,当通过该变量访问它所引用的对象的实例变量时,该实例变量的值取决于声明该变量时类型;当通过该变量来调用它所引用的对象的方法时,该方法行为取决于它所实际引用的对象的类型。
2.3.2 内存中子类实例
看下面一段程序:
class Base { int count = 2; } class Mid extends Base { int count = 20; } public class Sub extends Mid { int count = 200; public static void main(String[] args) { //创建一个Sub对象 Sub s = new Sub(); //将Sub对象向上转型后赋为Mid、Base类型的变量 Mid s2m = s; Base s2b = s; //分别通过3个变量来访问count实例变量 System.out.println(s.count); System.out.println(s2m.count); System.out.println(s2b.count); } }
打印的结果是:
200
20
2
s,s2m,s2b,这3个变量所引用Java对象拥有3个count实例变量,需要3块内存存储它们。如图:
从上图可以看出,Sub对象不仅存储了它自身的count实例变量,还存储从Mid,Base两个父类那里继承到的count实例变量。但这3个count实例变量在底层是有区别的,程序通过Base型变量来访问该对象的count实例变量时,输出2;通过Mid新的变量来访问该对象的count实例变量时,输出20;当直接在Sub类中访问实例变量i时,输出200。
为了在Sub类中访问Mid类定义的count实例变量,可以在count之前加入super关键字为限定。例:super.count;。这样就可以访问到父类中定义的count实例变量。
系统内存中并不存在Mid和Base两个对象,程序内存中只有一个Sub对象,只是这个Sub对象中不仅保存了在Sub类中定义的所有实例变量,还保存了它的所有父类所定义的全部实例变量。
那super关键字的作用到底是什么?看下个程序:
class Fruit { String color = "未确定颜色"; //定义一个方法,该方法返回调用该方法的实例 public Fruit getThis() { return this; } public void info() { System.out.println("Fruit方法"); } } public class Apple extends Fruit { //重写父类的方法 @Override public void info() { System.out.println("Apple方法"); } //通过super调用父类的Info()方法 public void AccessSuperInfo() { super.info(); } //尝试返回super关键字代表的内容 public Fruit getSuper() { return super.getThis(); } String color = "红色"; public static void main(String[] args) { //创建一个Apple对象 Apple a = new Apple(); //调用getSuper()方法获取Apple对象关联的super引用 Fruit f = a.getSuper(); //判断a和f的关系 System.out.println("a和f所引用的对象是否相同:" + (a == f)); System.out.println("访问a所引用对象的color实例变量:" + a.color); System.out.println("访问f所引用对象的color实例变量:" + f.color); //分别通过a、f两个变量来调用info方法 a.info(); f.info(); //调用AccessSuperInfo来调用父类的info()方法 a.AccessSuperInfo(); } }
输出结果为:
a和f所引用的对象是否相同:true
访问a所引用对象的color实例变量:红色
访问f所引用对象的color实例变量:未确定颜色
Apple方法
Apple方法
Fruit方法
Java程序允许某个方法通过return this;返回调用该方法的Java对象,但不允许直接return super;甚至不允许直接将super当成一个引用变量使用。
通过上面程序可以看出:super关键字本身并没有引用任何对象,它甚至不能被当成一个真正的引用变量使用。主要有如下两个原因:
(1) 子类方法不能直接使用return super;但使用return this;返回调用该方法的对象是允许的。
(2) 程序不允许直接把super当成变量使用,例如,试图判断super和a变量是否引用同一个Java对象—super==a;但这条语句将引起编译错误。
至此,对父,子对象在内存中存储有了准确的结论:当程序创建一个子类对象时,系统不仅会为该类中定义的实例变量分配内存,也会为其父类中定义的所有实例变量分配内存,即使子类定义了与父类中同名实例变量。
如果在子类里定义了与父类中已有变量同名的变量,那么子类中定义的变量会隐藏父类中定义的变量。之一不是完全覆盖,因此系统为创建子类对象时,依然会为父类中定义的,被隐藏的变量分配内存空间。
为了在子类方法中访问父类中定义的,被隐藏的实例变量,或者为了在子类方法中调用父类中定义的,被覆盖的方法,可以通过super作为限定来修饰这些实例变量和实例方法。
2.3.3 父,子类的类变量
理解了上面介绍的父,子实例在内存中分配之后,接下来的父,子类的类变量基本与此类似。不同的是,类变量属于类本身,而实例变量则属于Java对象;类变量在类初始化阶段完成初始化,而实例变量则在对象初始化阶段完成初始化。
由于类变量本质上属于类本身,因此通常不会涉及父,子实例变量那样复杂的情形,但由于Java允许通过对象来访问类变量,因此也可以使用super作为限定来访问父类中定义的类变量。下面程序示范了这种用法:
class StaticBase { //定义一个count类变量 static int count = 20; } public class StaticSub extends StaticBase { //子类再定义一个count类变量 static int count = 200; public void info() { System.out.println("访问本类的count类变量:" + count); System.out.println("访问父类的count类变量:" + StaticBase.count); System.out.println("访问父类的count类变量:" + super.count); } public static void main(String[] args) { StaticSub sb = new StaticSub(); sb.info(); } }
要访问父类中定义的count类变量,有2中方式
(1) 直接使用父类的类名作为主调来访问count类变量。
(2) 使用super作为限定来访问count类变量。
2.4 final修饰符
final修饰符是Java语言中比较简单的一个修饰符,定义如下:
(1) final可以修饰变量,被final修饰的变量被赋初始值之后,不能对它重新赋值。
(2) final可以修饰方法,被final修饰的方法不能被重写。
(3) final可以修饰类,被final修饰的类不能派生子类。
只掌握定义是不够的,下面将从几个方面来分析final修饰符功能。
2.4.1 final修饰的变量
被final修饰的实例变量必须显式指定初始值,而且只能在如下3个位置指定初始值。
(1) 定义final实例变量时指定初始值。
(2) 在非静态初始化块中为final实例变量指定初始值。
(3) 在构造器中为final实例变量指定初始值。
对于普通实例变量,Java程序可以对它执行默认的初始化,也就是将实例变量的值指定为默认的初始值0或null,但对于final实例变量,则必须由程序员显式指定初始值。
下面程序示范了在3个地方对final实例变量进行初始化。
public class FinalInstanceVaribaleTest { //定义final实例变量时赋初始值 final int var1 = "疯狂Java讲义".length(); final int var2; final int var3; //在初始化块中为var2赋初始值 { var2 = "轻量级Java EE企业应用实战".length(); } //在构造器中为var3赋初始值 public FinalInstanceVaribaleTest() { this.var3 = "疯狂XML讲义".length(); } public static void main(String[] args) { FinalInstanceVaribaleTest fiv = new FinalInstanceVaribaleTest(); System.out.println(fiv.var1); System.out.println(fiv.var2); System.out.println(fiv.var3); } }
上面程序用了3种给final变量初始化的方式,但是经过编译器的处理,这3种方式都会被抽取到构造器中赋初始值。
对于final类变量而言,同样必须显式指定初始值,而且final类变量只能在2个地方初始值:
(1) 定义final类变量时指定初始值;
(2) 在静态初始化块中为final类变量指定初始值。
下面程序示范了2个地方对final类变量进行初始化
public class FinalClassVaribaleTest { //定义final类变量时赋初始值 final static int var1 = "疯狂Java讲义".length(); final static int var2; //在静态初始化块中为var2赋初始值 static { var2 = "轻量级Java EE企业应用实战".length(); } public static void main(String[] args) { System.out.println(FinalClassVaribaleTest.var1); System.out.println(FinalClassVaribaleTest.var2); } }
上面程序用了2种方法初始化,但是经过编译器的处理,这2种方式都会被抽取到静态初始化块中赋初始值。
当使用final修饰类变量时,如果定义该final类变量时指定了初始值,而且该初始值可以在编译时就被确定下来,系统将不会在静态初始化块中对该类变量赋初始值,而将是在类定义中直接使用该初始值代替该final变量。
对于一个使用final修饰的变量而言,如果定义该final变量时就指定初始值,而且这个初始值可以在编译时就确定下来,那么这个final变量将不再是一个变量,系统会将其当成“宏变量”处理。也就是说,所有出现该变量的地方,系统将直接把它当成对应的值处理。
2.4.2 执行“宏替换”的变量
对一个final变量,不管它是类变量,实例变量,还是局部变量,只要定义该变量时使用了final修饰符修饰,并在定义该final类变量时指定了初始值,而且该初始值可以在编译时就被确定下来,那么这个final变量本质上已经不再是变量,而是相当于一个直接量。
Final修饰符的一个重要用途就是定义“宏变量”。当定义final变量时就为该变量指定了初始值,而且该初始值可以在编译时就确定下来,那这个final变量本质上就是一个“宏变量”,编译器会把程序中所有用到该变量的地方直接替换成该变量的值。
Java会缓存所有曾经用过的字符串直接量。例如执行String a = “java”;语句之后,系统的字符串池中就会缓存一个字符串”java”;如果程序再次执行String b = “java”;系统将会让b直接指向字符串池中的”java”字符串,因此a==b将会返回true。
为了加深对final修饰符的印象,看如下程序
public class StringJoinTest { public static void main(String[] args) { String s1 = "疯狂Java"; String s2 = "疯狂" + "Java"; System.out.println(s1 == s2); //定义2个字符串直接量 String str1 = "疯狂"; String str2 = "Java"; //将str1和str2进行连接运算 String s3 = str1 + str2; System.out.println(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两个变量执行“宏替换”。及在str1和str2之前加上fianl,这样编译器即可在编译阶段就确定s3的值,就会让s3指向字符串池中缓存中的“疯狂Java”。
对于实例变量而言,除了可以在定义该变量时赋初始值之外,还可以在非静态初始化块,构造器中对它赋初始值,而且在这3个地方指定初始值的效果基本一样。但对于fianl实例变量而言,只有在定义该变量时指定初始值才会有“宏变量”的效果,在非静态初始化块,构造器中为final实例变量指定初始值则不会有这种效果,示例如下:
public class FinalInitTest { //定义3个final实例变量 final String str1; final String str2; final String str3 = "Java"; //str1、str2分别放在非静态初始化块、构造器中初始化 { str1 = "Java"; } public FinalInitTest() { str2 = "Java"; } //判断str1、str2、str3是否执行"宏替换" public void display() { System.out.println(str1 + str1 == "JavaJava"); System.out.println(str2 + str2 == "JavaJava"); System.out.println(str3 + str3 == "JavaJava"); } public static void main(String[] args) { FinalInitTest fit = new FinalInitTest(); fit.display(); } }
上面程序str3是true,前两个都是false,就说明了这个问题。
2.4.3 final方法不能被重写
如果父类中某个方法使用了final修饰符进行修饰,那么这个方法将不可能被它的子类访问到,因此这个方法也不可能被它的子类重写。例子如下:
class Base { private final void info() { System.out.println("Base的info方法"); } } public class FinalMethodTest extends Base { //这个info方法并不是覆盖父类方法。 // @Override public void info() { System.out.println("FinalMethodTest的Info方法"); } }
子类FinalMethodTest的info方法只是一个普通方法,并不是重写父类的方法,如果打开@Override的话会提示错误。
2.4.4 内部类中的局部变量
如果程序需要在匿名内部类中使用局部变量,那么这个局部变量必须使用final修饰符修饰。示例如下:
import java.util.*; interface IntArrayProductor { //接口里定义的product方法用于封装“处理行为” int product(); } public class CommandTest { //定义一个方法,该生成指定长度的数组,但每个数组元素由于cmd负责产生 public int[] process(IntArrayProductor cmd , int length) { int[] result = new int[length]; for (int i = 0; i < length ; i++ ) { result[i] = cmd.product(); } return result; } public static void main(String[] args) { CommandTest ct = new CommandTest(); final int seed = 5; //生成数组,具体生成方式取决于IntArrayProductor接口的匿名实现类 int[] result = ct.process(new IntArrayProductor() { public int product() { return (int)Math.round(Math.random() * seed); } } , 6); System.out.println(Arrays.toString(result)); } }
不仅匿名内部类,即使是普通内部类,在任何内部类中访问的局部变量都应该使用final修饰。
那为什么Java要求内部类访问的局部变量必须使用final修饰呢?
Java要求所有被内部类访问的局部变量都使用final修饰也是有其原因的:对于普通局部变量而言,它的作用域就是停留在该方法内,当方法执行结束,该局部变量也随之消失;但内部类则可能产生隐式的“闭包”,闭包将使得局部变量脱离它所在的方法继续存在。
下面程序是局部变量脱离它所在方法继续存在的例子:
public class ClosureTest { public static void main(String[] args) { //定义一个局部变量 final String str = "Java"; //在内部类里访问局部变量str new Thread(new Runnable() { public void run() { for (int i = 0; i < 100 ; i++ ) { //此处将一直可以访问到str局部变量 System.out.println(str + " " + i); //暂停0.1秒 try { Thread.sleep(100); } catch (Exception ex) { ex.printStackTrace(); } } } }).start(); //执行到此处,main方法结束 } }
上面程序定义了一个局部变量str。正常情况下,当程序执行完main后,他的声明周期就结束了,局部变量str的作用域也会随之结束。但只要新线程里的run方法没有执行完,匿名内部类的实例的生命周期就没有结束,将一直可以访问str局部变量的值,这就是内部类会扩大局部变量作用域的实例。
由于内部类可能扩大局部变量的作用域,如果再加上这个被内部类访问的局部变量没有使用final修饰,也就是说该变量的值可以随意改变,那将引起极大的混乱,因此Java编译器要求所有被内部类访问的局部变量必须使用final修饰符修饰。