10. 面向对象(下)
10. 面向对象(下)
包装类
Java 的 8 种基本数据类型不支持面向对象的编程机制,基本数据类型的数据也不具备“对象”的特性:没有 Field、方法可以被调用。
在某些时候,基本数据类型会有一些制约,例如所有引用类型的变量都继承了 Object 类,都可当成 Object 类型变量使用。但基本数据类型的变量就不可以,如果有个方法需要 Object 类型的参数,但实际需要的值却是 2、3 等数值,这可能就比较难以处理。
为了解决 8 种基本数据类型的变量不能当成 Object 类型变量使用的问题,Java 提供了包装类(Wrapper Class)的概念,为 8 种基本数据类型分别定义了相应的引用类型,并称之为基本数据类型的包装类。
把基本数据类型变量包装成包装类实例是通过对应包装类的构造器来实现的,8 个包装类中除了 Character
之外,还可以通过传入一个字符串参数来构建包装类对象。
public class Primitive2Wrapper {
public static void main(String[] args) {
boolean bl = true;
// 通过构造器把b1基本类型变量包装成包装类对象
Boolean blObj = new Boolean(bl);
int it = 5;
// 通过构造器把it基本类型变量包装成包装类对象
Integer itObj = new Integer(it);
// 把一个字符串转换成Float对象
Float fl = new Float("4.56");
// 把一个字符串转换成Boolean对象
Boolean bObj = new Boolean("false");
// 下面程序运行时将出现java.lang.NumberFormatException异常
// Long lObj=new Long("ddd");
}
}
当试图使用一个字符串来创建 Boolean
对象时,如果传入的字符串是"true",或是此字符串不同字母的大小写变化形式,如"True",都将创建 true
对应的 Boolean
对象;如果传入其他字符串,则会创建 false
对应的 Boolean
对象。
如果希望获得包装类对象中包装的基本类型变量,则可以使用包装类提供的 xxxValue()
实例方法。
// 取出Boolean对象里的boolean变量
boolean bb = bObj.booleanValue();
// 取出Integer对象里的int变量
int i = itObj.intValue();
// 取出Float对象里的float变量
float f = fl.floatValue();
基本类型变量和包装类对象之间的转换关系如图:
JDK 1.5 提供了自动装箱(Autoboxing
)和自动拆箱(AutoUnboxing
)功能。
所谓自动装箱,就是可以把一个基本类型变量直接赋给对应的包装类变量,或者赋给 Object
变量(Object 是所有类的父类,子类对象可以直接赋给父类变量);自动拆箱则与之相反,允许直接把包装类对象直接赋给一个对应的基本类型变量。
public class AutoBoxingUnboxing {
public static void main(String[] args) {
// 直接把一个基本类型变量赋给Integer对象
Integer inObj = 5;
// 直接把一个boolean类型变量赋给一个Object类型变量
Object boolObj = true;
// 直接把一个Integer对象赋给int类型变量
int it = inObj;
if (boolObj instanceof Boolean) {
// 先把0bject对象强制类型转换为Boolean类型,再赋给boolean变量
boolean b = (Boolean)boolObj;
System.out.println(b);
}
}
}
进行自动装箱和自动拆箱时必须注意类型匹配,例如 Integer
只能自动拆箱成 int
类型变量,不要试图拆箱成 boolean
类型变量;与之类似的是,int
类型变量只能自动装箱成 Integer
对象(即使赋给 Object 类型变量,那也只是利用了 Java 的向上自动转型特性),不要试图装箱成 Boolean
对象。
包装类还可实现基本类型变量和字符串之间的转换。把字符串类型的值转换为基本类型的值有两种方式。
- 利用包装类提供的
parseXxx(String s)
静态方法(除了Character
之外的所有包装类都提供了该方法。- 利用包装类提供的
Xxx(String s)
构造器。
String
类提供了多个重载 valueOf()
方法,用于将基本类型变量转换成字符串。
public class Primitive2String {
public static void main(String[] args) {
String intStr = "123";
// 把一个特定字符串转换成int变量
int it1 = Integer.parseInt(intStr);
int it2 = new Integer(intStr);
System.out.println(it2);
String floatStr = "4.56";
// 把一个特定字符串转换成float变量
float ft1 = Float.parseFloat(floatStr);
float ft2 = new Float(floatStr);
System.out.println(ft2);
// 把一个float变量转换成String变量
String ftStr = String.valueOf(2.345f);
System.out.println(ftStr);
// 把一个double变量转换成String变量
String dbStr = String.valueOf(3.344);
System.out.println(dbStr);
// 把一个boolean变量转换成String变量
String boolStr = String.valueOf(true);
System.out.println(boolStr.toUpperCase());
}
}
基本类型变量和字符串之间的转换关系:
如果希望把基本类型变量转换成字符串,还有一种更简单的方法:将基本类型变量和 ""
进行连接运算,系统会自动把基本类型变量转换成字符串。
// intStr的值为"5"
String intStr = 5 + "";
虽然包装类型的变量是引用数据类型,但包装类的实例可以与数值类型的值进行比较,这种比较是直接取出包装类实例所包装的数值来进行比较的。
Integer a = new Integer(6);
// 输出true
System.out.println("6的包装类实例是否大于5.0" + (a > 5.0));
两个包装类的实例进行比较的情况就比较复杂,因为包装类的实例实际上是引用类型,只有两个包装类引用指向同一个对象时才会返回 true
。
// 输出false
System.out.println("比较2个包装类的实例是否相等:" + (new Integer(2) == new Integer(2)));
自动装箱可以直接把一个基本类型值赋给一个包装类实例,在这种情况下可能会出现一些特别的情形。
// 通过自动装箱,允许把基本类型值赋值给包装类实例
Integer ina = 2;
Integer inb = 2;
// 输出true
System.out.println("两个2自动装箱后是否相等:" + (ina == inb));
Integer biga = 128;
Integer bigb = 128;
// 输出false
System.out.println("两个128自动装箱后是否相等:" + (biga == bigb));
查看 Java 系统中 java.lang.Integer
类的源代码,如下所示。
// 定义一个长度为256的Integer数组
static final Integer[] cache = new Integer[-(-128) + 127 + 1];
static {
// 执行初始化,创建-128到127的Integer实例,并放入cache数组中
for (int i = 0; i < cache.length; i++) {
cache[i] = new Integer(i - 128);
}
}
系统把一个 -128 ~ 127 之间的整数自动装箱成
Integer
实例,并放入了一个名为cache
的数组中缓存起来。如果以后把一个 -128 ~ 127 之间的整数自动装箱成一个Integer
实例时,实际上是直接指向对应的数组元素,因此 -128 ~ 127 之间的同一个整数自动装箱成Integer
实例时,永远都是引用cache
数组的同一个数组元素,所以它们全部相等;但每次把一个不在 -128 ~ 127 范围内的整数自动装箱成Integer
实例时,系统总是重新创建一个Integer
实例,所以出现程序中的运行结果。
相关信息
Java 会把一些创建成本大、需要频繁使用的对象缓存起来,从而提高程序的运行性能。
缓存是一种非常优秀的设计模式,在 Java、Java EE 平台的很多地方都会通过缓存来提高系统的运行性能。
简单地说,如果你需要一台电脑,那么你就去买了一台电脑。但你不可能一直使用这台电脑,你总会离开这台电脑——在你离开电脑的这段时间内,你会不会立即把电脑扔掉?
当然不会,你会把电脑放在房间里,等下次又需要电脑时直接开机使用,而不是再次去购买一台。假设电脑是内存中的对象,而你的房间是内存,如果房间足够大,则可以把所有曾经用过的各种东西都缓存起来,但这不可能,房间的空间是有限制的,因此有些东西你用过一次就扔掉了。
你只会把一些购买成本大、需要频繁使用的东西保存下来。
Java 为所有的包装类都提供了一个静态的 compare(xxx val1, xxx val2)
方法,这样开发者就可以通过包装类提供的 compare(xxx val1, xxx val2)
方法来比较两个基本类型值的大小,包括比较两个 boolean
类型值,两个 boolean
类型值进行比较时,true > false
。
// 输出1
System.out.println(Boolean.compare(true, false));
// 输出0
System.out.println(Boolean.compare(true, true));
// 输出-1
System.out.println(Boolean.compare(false, true));
处理对象
Java 对象都是 Object 类的实例,都可直接调用该类中定义的方法,这些方法提供了处理 Java 对象的通用方法。
toString
class Person {
private String name;
public Person(String name) {
this.name = name;
}
public void info() {
System.out.println("此人名为:" + name);
}
}
public class PrintObject {
public static void main(String[] args) {
// 创建一个Person对象,将之赋给p变量
Person p = new Person("孙悟空");
// 打印p所引用的Person对象
System.out.println(p);
}
}
编译、运行上面程序,看到如下运行结果:
Person@f58205
@符号后的 6 位十六进制数字可能发生改变。但这个输出结果是怎么来的呢?
System.out.println
方法只能在控制台输出字符串,而 Person
实例是一个内存中的对象,当使用该方法输出 Person
对象时,实际上输出的是 Person
对象的 toString()
方法的返回值。
下面两行代码的效果完全一样。
System.out.println(p);
System.out.println(p.toString());
toString()
方法是 Object 类里的一个实例方法,所有的 Java 类都是 Object 类的子类,因此所有的 Java 对象都具有 toString()
方法。
所有的 Java 对象都可以和字符串进行连接运算,当 Java 对象和字符串进行连接运算时,系统自动调用 Java 对象 toString
方法的返回值和字符串进行连接运算,即下面两行代码的结果也完全相同。
String pStr = p + "";
String pStr = p.toString() + "";
toString
方法是一个非常特殊的方法,它是一个“自我描述”方法,该方法通常用于实现这样一个功能:当程序员直接打印该对象时,系统将会输出该对象的“自我描述”信息,用以告诉外界该对象具有的状态信息。
Object 类提供的 toString
方法总是返回该对象实现类的 类名+@+hashCode
值,这个返回值并不能真正实现“自我描述”的功能,因此如果用户需要自定义类能实现“自我描述”的功能,就必须重写 Object 类的 toString
方法。
class Apple {
private String color;
private double weight;
public Apple() {}
// 提供有参数的构造器
public Apple(String color, double weight) {
this.color = color;
this.weight = weight;
}
// 省略color、weight属性的setter和getter方法...
// 重写toString方法,用于实现Apple对象的“自我描述”
public String toString() {
return "一个苹果,颜色是:" + color + ",重量是:" + weight;
}
}
public class ToStringTest {
public static void main(String[] args) {
Apple a = new Apple("红色", 5.68);
// 打印Apple对象
System.out.println(a);
}
}
运行结果:
一个苹果,颜色是:红色,重量是:5.68
大部分时候,重写 toString
方法总是返回该对象的所有令人感兴趣的信息所组成的字符串。通常可返回如下格式的字符串:
类名[Field1 = 值1,Field2 = 值2,...]
将上面 Apple
类的 toString
方法改为如下:
public String toString() {
return "Apple[color=" + color + ", weight=" + weight + "]";
}
这个 toString
方法提供了足够的有效信息来描述 Apple
对象,也就实现了 toString
方法的功能。
equals
Java 程序中测试两个变量是否相等有两种方式:一种是利用 ==
运算符,另一种是利用 equals
方法。
当使用 ==
来判断两个变量是否相等时,如果两个变量是基本类型变量,且都是数值类型(不一定要求数据类型严格相同),则只要两个变量的值相等,就将返回 true
。
但对于两个引用类型变量,它们必须指向同一个对象时,==
判断才会返回 true
。
==
不可用于比较类型上没有父子关系的两个对象。
public class EqualTest {
public static void main(String[] args) {
int it = 65;
float f1 = 65.0f;
// 将输出true
System.out.println("65和65.Of是否相等?" + (it == f1));
char ch = 'A';
// 将输出true
System.out.println("65和'A'是否相等?" + (it == ch));
String str1 = new String("hello");
String str2 = new String("hello");
// 将输出false
System.out.println("str1和str2是否相等?" + (str1 == str2));
// 将输出true
System.out.println("str1是否equals str2?" + (str1.equals(str2)));
// 由于java.lang.String 与EqualTest类没有继承关系
// 所以下面语句导致编译错误
// System.out.println("hello" == new EqualTest());
}
}
"hello"
直接量和 new String("hello")
有什么区别呢?
当 Java 程序直接使用形如 "hello"
的字符串直接量(包括可以在编译时就计算出来的字符串值)时,JVM 将会使用常量池来管理这些字符串;当使用 new String("hello")
时,JVM 会先使用常量池来管理 "hello"
直接量,再调用 String
类的构造器来创建一个新的 String
对象,新创建的 String 对象被保存在堆内存中。
换句话说,new String("hello")
一共产生了两个对象。
相关信息
常量池(constant pool)专门用于管理在编译期被确定并被保存在已编译的 .class
文件中的一些数据。它包括了关于类、方法、接口中的常量,还包括字符串常量。
public class StringCompareTest {
public static void main(String[] args) {
// s1直接引用常量池中的"疯狂Java"
String s1 = "疯狂Java";
String s2 = "疯狂";
String s3 = "Java";
// s4后面的字符串值可以在编译期就确定下来
// s4直接引用常量池中的"疯狂Java"
String s4 = "疯狂" + "Java";
// s5后面的字符串值可以在编译期就确定下来
// s5直接引用常量池中的"疯狂Java"
String s5 = "疯" + "狂" + "Java";
// s6后面的字符串值不能在编译期就确定下来
// 不能引用常量池中的字符串
String s6 = s2 + s3;
// 使用new调用构造器将会创建一个新的string对象
// s7引用堆内存中新创建的String对象
String s7 = new String("疯狂Java");
// 输出true
System.out.println(s1 == s4);
// 输出true
System.out.println(s1 == s5);
// 输出false
System.out.println(s1 == s6);
// 输出false
System.out.println(s1 == s7);
}
}
JVM 常量池保证相同的字符串直接量只有一个,不会产生多个副本。例子中的 s1、s4、s5 所引用的字符串可以在编译期就确定下来,因此它们都将引用常量池中的同一个字符串对象。
使用 new String()
创建的字符串对象是运行时创建出来的,它被保存在运行时内存区内(即堆内存),不会放入常量池中。
很多时候,程序判断两个引用变量是否相等时,也希望有一种类似于“值相等”的判断规则,并不严格要求两个引用变量指向同一个对象。
例如对于两个字符串变量,可能只是要求它们引用字符串对象里包含的字符序列相同即可认为相等。
此时就可以利用
String
对象的equals
方法来进行判断。
equals
方法是 Object
类提供的一个实例方法,因此所有引用变量都可调用该方法来判断是否与其他引用变量相等。但使用这个方法判断两个对象相等的标准与使用 ==
运算符没有区别,同样要求两个引用变量指向同一个对象才会返回 true
。
因此这个 Object
类提供的 equals
方法没有太大的实际意义,如果希望采用自定义的相等标准,则可采用重写 equals
方法来实现。
提示
String
已经重写了 Object
的 equals()
方法,String
的 equals()
方法判断两个字符串相等的标准是:只要两个字符串所包含的字符序列相同,通过 equals()
比较将返回 true
,否则将返回 false
。
相关信息
重写 equals
方法就是提供自定义的相等标准,你认为怎样是相等,那就怎样是相等,一切都是你做主!在极端的情况下,你可以让 Person
对象和 Dog
对象相等。
class Person {
// 重写equals方法,提供自定义的相等标准
public boolean equals(Object obj) {
// 不加判断,总是返回true,即Person对象与任何对象都相等
return true;
}
}
// 定义一个Dog空类
class Dog {}
public class OverrideEqualsError {
public static void main(String[] args) {
Person p = new Person();
System.out.println("Person对象是否equals Dog对象?" + p.equals(new Dog()));
System.out.println("Person对象是否equals String对象?" + p.equals(new String("He11o")));
}
}
当然大部分时候,我们还是希望两个类型相同的对象才可能相等。
class Person {
private String name;
private String idStr;
public Person() {}
public Person(String name, String idStr) {
this.name = name;
this.idStr = idStr;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getIdStr() {
return idStr;
}
public void setIdStr(String idStr) {
this.idStr = idStr;
}
// 重写equals方法,提供自定义的相等标准
public boolean equals(Object obj) {
// 如果两个对象为同一个对象
if (this == obj)
return true;
// 当obj不为nul1,且它是Person类的实例时
if (obj != null && obj.getClass() == Person.class) {
Person personObj = (Person)obj;
// 并且当前对象的idStr与obj对象的idStr相等才可判断两个对象相等
if (this.getIdStr().equals(personObj.getIdStr())) {
return true;
}
}
return false;
}
}
public class OverrideEqualsRight {
public static void main(String[] args) {
Person p1 = new Person("孙悟空", "12343433433");
Person p2 = new Person("孙行者", "12343433433");
Person p3 = new Person("孙悟饭", "99933433");
// p1和p2的idStr相等,所以输出true
System.out.println("p1和p2是否相等?" + p1.equals(p2));
// p2和p3的idStr不相等,所以输出false
System.out.println("p2和p3是否相等?" + p2.equals(p3));
}
}
通常而言,正确地重写 equals 方法应该满足下列条件。
- 自反性:对任意 x,
x.equals(x)
一定返回true
。- 对称性:对任意 x 和 y,如果
y.equals(x)
返回true
,则x.equals(y)
也返回true
。- 传递性:对任意 x, y, z,如果
x.equals(y)
返回true
,y.equals(z)
返回true
,则x.equals(z)
一定返回true
。- 一致性:对任意 x 和 y,如果对象中用于等价比较的信息没有改变,那么无论调用
x.equals(y)
多少次,返回的结果应该保持一致,要么一直是true
,要么一直是false
。- 对任何不是
null
的 x,x.equals(null)
一定返回false
。
Object
默认提供的 equals()
只是比较对象的地址,即 Object
类的 equals
方法比较的结果与 ==
运算符比较的结果完全相同。
因此,在实际应用中常常需要重写 equals
方法,重写 equals
方法时,相等条件是由系统要求决定的,因此 equals
方法的实现也是由系统要求决定的。
相关信息
判断 obj
是否为 Person
类的实例时,为何不用 obj instanceof Person
来判断呢?
对于 instanceof
运算符而言,当前面对象是后面类的实例或其子类的实例时都将返回 true
,所以实际上重写 equals()
方法判断两个对象是否为同一个类的实例时使用 instanceof
是有问题的。
比如有一个 Teacher
类型的变量 t
,如果判断 t instanceof Person
,这也将返回 true
。但对于重写 equals()
方法的要求而言,通常要求两个对象是同一个类的实例,因此使用 instanceof
运算符不太合适。改为使用 t.getClass()==Person.class
比较合适。这行代码用到了反射基础。
类成员
static
关键字修饰的成员就是类成员,类成员有类 Field、类方法、静态初始化块等三个成分,static
关键字不能修饰构造器。static
修饰的类成员属于整个类,不属于单个实例。
理解类成员
在 Java 类里只能包含 Field、方法、构造器、初始化块、内部类(包括接口、枚举)5 种成员,其中 static
可以修饰 Field、方法、初始化块、内部类(包括接口、枚举),以 static
修饰的成员就是类成员。
类 Field 属于整个类,当系统第一次准备使用该类时,系统会为该类 Field 分配内存空间,类 Field 开始生效,直到该类被卸载,该类的类 Field 所占有的内存才被系统的垃圾回收机制回收。系统不会再为类 Field 分配内存,也不会再次对类 Field 进行初始化,类 Field 生存范围几乎等同于该类的生存范围。当类初始化完成后,类 Field 也被初始化完成。
类 Field 既可通过类来访问,也可通过类的对象来访问。但通过对象访问类 Field 只是一种假象,当通过对象来访问类 Field 时,系统会在底层转换为通过该类来访问类 Field,对象根本不拥有对应类的类 Field。
类方法也是类成员的一种,类方法也是属于类的,通常直接使用类作为调用者来调用类方法,但也可以使用对象来调用类方法。与类 Field 类似,即使使用对象来调用类方法,其效果也与采用类来调用类方法完全一样。
当使用实例来访问类成员时,实际上依然是委托给该类来访问类成员,因此即使某个实例为 null,它也可以访问它所属类的类成员。
public class NullAccessStatic {
private static void test() {
System.out.println("static修饰的类方法");
}
public static void main(String[] args) {
// 定义一个NullAccessStatic变量,其值为null
NullAccessStatic nas = null;
// null对象调用所属类的静态方法
nas.test();
}
}
提示
如果一个 null 对象访问实例成员(包括 Field 和方法),将会引发 NullPointerException
异常,因为 null 表明该实例根本不存在,既然实例不存在,理所当然的,它的 Field 和方法也不存在。
静态初始化块也是类成员的一种,静态初始化块用于执行类初始化动作,在类的初始化阶段,系统会调用该类的静态初始化块来对类进行初始化。一旦该类初始化结束后,静态初始化块将永远不会获得执行的机会。
对 static
关键字而言,有一条非常重要的规则:
类成员(包括方法、初始化块、内部类和枚举类)不能访问实例成员(包括 Field、方法、初始化块、内部类和枚举类)。
因为类成员是属于类的,类成员的作用域比实例成员的作用域更大,完全可能出现类成员已经初始化完成,但实例成员还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。
单例
在某些时候,允许其他类自由创建该类的对象没有任何意义,还可能造成系统性能下降(因为频繁地创建对象、回收对象带来的系统开销问题)。例如,系统可能只有一个窗口管理器、一个假脱机打印设备或一个数据库引擎访问点,此时如果在系统中为这些类创建多个对象就没有太大的实际意义。
如果一个类始终只能创建一个实例,则这个类被称为单例类。
在一些特殊场景下,要求不允许自由创建该类的对象,而只允许为该类创建一个对象。为了避免其他类自由创建该类的实例,我们把该类的构造器使用 private
修饰,从而把该类的所有构造器隐藏起来。
一旦把该类的构造器隐藏起来,就需要提供一个 public
方法作为该类的访问点,用于创建该类的对象,且该方法必须使用 static
修饰(因为调用该方法之前还不存在对象,因此调用该方法的不可能是对象,只能是类)。
除此之外,该类还必须缓存已经创建的对象,否则该类无法知道是否曾经创建过对象,也就无法保证只创建一个对象。为此该类需要使用一个成员变量来保存曾经创建的对象,因为该成员变量需要被上面的静态方法访问,故该成员变量必须使用 static
修饰。
class Singleton {
// 使用一个变量来缓存曾经创建的实例
private static Singleton instance;
// 对构造器使用private修饰,隐藏该构造器
private Singleton() {}
// 提供一个静态方法,用于返回Singleton实例
// 该方法可以加入自定义控制,保证只产生一个Singleton对象
public static Singleton getInstance() {
// 如果instance为null,则表明还不曾创建Singleton对象
// 如果instance不为null,则表明已经创建了Singleton对象
// 将不会重新创建新的实例
if (instance == null)
// 创建一个Singleton对象,并将其缓存起来
instance = new Singleton();
return instance;
}
}
public class SingletonTest {
public static void main(String[] args) {
// 创建Singleton对象不能通过构造器
// 只能通过get Instance方法来得到实例
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
// 将输出true
System.out.println(s1 == s2);
}
}
正是通过上面 getInstance
方法提供的自定义控制(这也是封装的优势:不允许自由访问类的 Field 和实现细节,而是通过方法来控制合适暴露),保证 Singleton
类只能产生一个实例。所以,在 SingletonTest
类的 main
方法中,看到两次产生的 Singleton
对象实际上是同一个对象。
final
final
关键字可用于修饰类、变量和方法,final
关键字有点类似 C# 里的 sealed
关键字,用于表示它修饰的类、方法和变量不可改变。
final
修饰变量时,表示该变量一旦获得了初始值就不可被改变,final
既可以修饰成员变量(包括类变量和实例 Z 变量),也可以修饰局部变量、形参。
final
修饰的变量不可被改变,一旦获得了初始值,该 final
变量的值就不能被重新赋值。
因为 final
变量获得初始值之后不能被重新赋值,因此 final
修饰成员变量和修饰局部变量时有一定的不同。
final
成员变量
对于 final
修饰的成员变量而言,一旦有了初始值,就不能被重新赋值,如果既没有在定义成员变量时指定初始值,也没有在初始化块、构造器中为成员变量指定初始值,那么这些成员变量的值将一直是系统默认分配的 0
、'\u0000'
、false
或 null
,这些成员变量也就完全失去了存在的意义。
因此 Java 语法规定: final 修饰的成员变量必须由程序员显式地指定初始值。
final
修饰的类 Field、实例 Field 能指定初始值的地方如下。
- 类 Field:必须在静态初始化块中或声明该 Field 时指定初始值。
- 实例 Field:必须在非静态初始化块、声明该 Field 或构造器中指定初始值。
public class FinalVariableTest {
// 定义成员变量时指定默认值,合法
final int a = 6;
// 下面变量将在构造器或初始化块中分配初始值
final String str;
final int c;
final static double d;
// 既没有指定默认值,又没有在初始化块、构造器中指定初始值
// 下面定义char Field是不合法的
// final char ch;
// 初始化块,可对没有指定默认值的实例Field指定初始值
{
// 在初始化块中为实例Field指定初始值,合法
str = "He1lo";
// 定义a Field时已经指定了默认值
// 不能为a重新赋值,下面赋值语句非法
// a = 9;
}
// 静态初始化块,可对没有指定默认值的类Field指定初始值
static {
// 在静态初始化块中为类Field指定初始值,合法
d = 5.6;
}
// 构造器,可对既没有指定默认值,又没有在初始化块中
// 指定初始值的实例Field指定初始值
public FinalVariableTest() {
// 如果初始化块中对str指定了初始值
// 则构造器中不能对final变量重新赋值,下面赋值语句非法
// str = "java";
c = 5;
}
public void changeFinal() {
// 普通方法不能为final修饰的成员变量赋值
// d = 1.2;
// 不能在普通方法中为final成员变量指定初始值
// ch = 'a';
}
public static void main(String[] args) {
FinalVariableTest ft = new FinalVariableTest();
System.out.println(ft.a);
System.out.println(ft.c);
System.out.println(d);
}
}
如果打算在构造器、初始化块中对 final 成员变量进行初始化,则不要在初始化之前就访问成员变量的值。
public class FinalErrorTest {
// 定义一个final修饰的Field
// 系统不会对final成员Field进行默认初始化
final int age;
{
// age没有初始化,所以此处代码将引起错误
System.out.println(age);
age = 6;
System.out.println(age);
}
public static void main(String[] args) {
new FinalErrorTest();
}
}
final
局部变量
==系统不会对局部变量进行初始化,局部变量必须由程序员显式初始化。==因此使用 final
修饰局部变量时,既可以在定义时指定默认值,也可以不指定默认值。
如果 final
修饰的局部变量在定义时没有指定默认值,则可以在后面代码中对该 final
变量赋初始值,但只能一次,不能重复赋值;如果 final
修饰的局部变量在定义时已经指定默认值,则后面代码中不能再对该变量赋值。
public class FinalLocalVariableTest {
public void test(final int a) {
// 不能对final修饰的形参赋值,下面语句非法
// a = 5;
}
public static void main(String[] args) {
// 定义final局部变量时指定默认值,则str变量无法重新赋值
final String str = "hello";
// 下面赋值语句非法
// str =" Java";
// 定义final局部变量时没有指定默认值,则d变量可被赋值一次
final double d;
// 第一次赋初始值,成功
d = 5.6;
// 对final变量重复赋值,下面语句非法
// d = 3.4;
}
}
final
对象
当使用 final
修饰基本类型变量时,不能对基本类型变量重新赋值,因此基本类型变量不能被改变。但对于引用类型变量而言,它保存的仅仅是一个引用,final
只保证这个引用类型变量所引用的地址不会改变,即一直引用同一个对象,但这个对象完全可以发生改变。
class Person {
private int age;
public Person() {}
// 有参数构造器
public Person(int age) {
this.age = age;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
public class FinalReferenceTest {
public static void main(String[] args) {
// final修饰数组变量,iArr是一个引用变量
final int[] iArr = {5, 6, 12, 9};
System.out.println(Arrays.toString(iArr));
// 对数组元素进行排序,合法
Arrays.sort(iArr);
System.out.println(Arrays.toString(iArr));
// 对数组元素赋值,合法
iArr[2] = -8;
// final修饰
final Person p = new Person(45);
// 改变Person对象的age Field,合法
p.setAge(23);
System.out.println(p.getAge());
// 下面语句对p重新赋值,非法
// p=null;
}
}
宏变量
对一个 final
变量来说,不管它是类 Field、实例 Field,还是局部变量,只要该变量满足 3 个条件,这个 final 变量就不再是一个变量,而是相当于一个直接量。
- 使用
final
修饰符修饰;- 在定义该
final
变量时指定了初始值;- 该初始值可以在编译时就被确定下来。
public class FinalLocalTest {
public static void main(String[] args) {
// 定义一个普通局部变量
final int a = 5;
System.out.println(a);
}
}
对于这个程序来说,变量 a
其实根本不存在,当程序执行 System.out.println(a);
代码时,实际转换为执行 System.out.println(5)
。
除了上面那种为 final
变量赋值时赋直接量的情况外,如果被赋的表达式只是基本的算术表达式或字符串连接运算,没有访问普通变量,调用方法,Java 编译器同样会将这种 final
变量当成“宏变量”处理。
public class FinalReplaceTest {
public static void main(String[] args) {
// 下面定义了4个final“宏变量”
final int a = 5 + 2;
final double b = 1.2 / 3;
final String str = "疯狂" + "Java";
final String book = "疯狂Java讲义:" + 99.0;
// 下面的book2变量的值因为调用了方法,所以无法在编译时被确定下来
// ①
final String book2 = "疯狂Java讲义:" + String.valueOf(99.0);
System.out.println(book == "疯狂Java讲义:99.0");
System.out.println(book2 == "疯狂Java讲义:99.0");
}
}
从表面上看,① 行代码定义的 book2
与 book
没有太大的区别,只是定义 book2
变量时显式将数值 99.0 转换为字符串,但由于该变量的值需要调用 String
类的方法,因此编译器无法在编译时确定 book2
的值,book2
不会被当成“宏变量”处理。
程序最后两行代码分别判断 book
、book2
和"疯狂 Java 讲义:99.0"
是否相等。由于 book
是一个“宏变量”,它将被直接替换成 "疯狂 Java 讲义:99.0"
,因此 book
和 "疯狂 Java 讲义:99.0"
相等,但 book2
和该字符串不相等。
相关信息
回顾:
Java 会使用常量池来管理曾经用过的字符串直接量,例如执行 String a="java";
语句之后,系统的字符串池中就会缓存一个字符串 "java"
;如果程序再次执行 String b="java";
,系统将会让 b
直接指向字符串池中的 "java"
字符串,因此 a==b
将会返回 true
。
public class StringJoinTest {
public static void main(String[] args) {
String s1 = "疯狂Java";
// s2变量引用的字符串可以在编译时就确定下来
// 因此引用常量池中已有的"疯狂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
两个变量执行“宏替换”,这样编译器即可在编译阶段就确定 s3
的值,就会让 s3
指向字符串池中缓存的 "疯狂Java"
。只要将 ①、② 两行代码所定义的 str1
、str2
使用 final
修饰即可。
提示
对于实例变量而言,既可以在定义该变量时赋初始值,也可以在非静态初始化块、构造器中对它赋初始值,在这 3 个地方指定初始值的效果基本一样。但对于 final
实例变量而言,只有在定义该变量时指定初始值才会有“宏变量”的效果。
final
方法
final
修饰的方法不可被重写,如果出于某些原因,不希望子类重写父类的某个方法,则可以使用 final
修饰该方法。
java 提供的 Object
类里就有一个 final
方法:getClass()
,因为 Java
不希望任何类重写这个方法,所以使用 final
把这个方法密封起来。但对于该类提供的 toString()
和 equals()
方法,都允许子类重写,因此没有使用 final
修饰它们。
试图重写 final
方法,将会引发编译错误。
public class FinalMethodTest {
public final void test() {}
}
class Sub extends FinalMethodTest {
// 下面方法定义将出现编译错误,不能重写final方法
// public void test() {}
}
对于一个 private
方法,因为它仅在当前类中可见,其子类无法访问该方法,所以子类无法重写该方法——如果子类中定义一个与父类 private
方法有相同方法名、相同形参列表、相同返回值类型的方法,也不是方法重写,只是重新定义了一个新方法。
因此,即使使用 final
修饰一个 private
访问权限的方法,依然可以在其子类中定义与该方法具有相同方法名、相同形参列表、相同返回值类型的方法。
public class PrivateFinalMethodTest {
private final void test() {
}
}
class Sub extends PrivateFinalMethodTest {
// 下面的方法定义不会出现问题
public void test() {}
}
final
修饰的方法仅仅是不能被重写,并不是不能被重载。
public class Final0verload {
// final修饰的方法只是不能被重写,完全可以被重载
public final void test() {}
public final void test(String arg) {}
}
final
类
final
修饰的类不可以有子类,例如 java.lang.Math
类就是一个 final
类,它不可以有子类。
为了保证某个类不可被继承,则可以使用 final
修饰这个类。
public final class FinalClass {}
//下面的类定义将出现编译错误
class Sub extends FinalClass {}
不可变类
不可变(immutable
)类的意思是创建该类的实例后,该实例的 Field 是不可改变的。Java 提供的 8 个包装类和 java.lang.String
类都是不可变类,当创建它们的实例后,其实例的 Field 不可改变。
Double d = new Double(6.5);
String str = new String("Hello");
Double
类和 String
类需要提供实例 Field 来保存这两个参数,但程序无法修改这两个实例 Field 值,因此 Double
类和 String
类没有提供修改它们的方法。
如果需要创建自定义的不可变类,可遵守如下规则。
- 使用
private
和final
修饰符来修饰该类的 Field。- 提供带参数构造器,用于根据传入参数来初始化类里的 Field。
- 仅为该类的 Field 提供
getter
方法,不要为该类的 Field 提供setter
方法,因为普通方法无法修改final
修饰的 Field。- 如果有必要,重写
Object
类的hashCode
和equals
方法。equals
方法以关键 Field 来作为判断两个对象是否相等的标准,除此之外,还应该保证两个用equals
方法判断为相等的对象的hashCode
也相等。
java.lang.String
这个类就做得很好,它就是根据 String
对象里的字符序列来作为相等的标准,其 hashCode
方法也是根据字符序列计算得到的。
public class ImmutableStringTest {
public static void main(String[] args) {
String str1 = new String("Hello");
String str2 = new String("Hello");
// 输出false
System.out.println(str1 == str2);
// 输出true
System.out.println(str1.equals(str2));
// 下面两次输出的hashCode相同
System.out.println(str1.hashCode());
System.out.println(str2.hashCode());
}
}
定义一个不可变的 Address
类,程序把 Address
类的 detail
和 postCode
成员变量都使用 private
隐藏起来,并使用 final
修饰这两个成员变量,不允许其他方法修改这两个 Field 值。
public class Address {
private final String detail;
private final String postCode;
// 在构造器里初始化两个实例Field
public Address() {
this.detail = "";
this.postCode = "";
}
public Address(String detail, String postCode) {
this.detail = detail;
this.postCode = postCode;
}
public int hashCode() {
return detail.hashCode() + postCode.hashCode() * 31;
}
// 重写equals方法,判断两个对象是否相等
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj != null && obj.getClass() == Address.class) {
Address ad = (Address)obj;
// 当detail和postCode相等时,可认为两个Address对象相等
return this.getDetail().equals(ad.getDetail()) && this.getPostCode().equals(ad.getPostCode());
}
return false;
}
// 仅为两个实例Field提供getter方法
public String getDetail() {
return this.detail;
}
public String getPostCode() {
return this.postCode;
}
}
当使用 final
修饰引用类型变量时,仅表示这个引用类型变量不可被重新赋值,但引用类型变量所指向的对象依然可改变。
当创建不可变类时,如果它包含 Field 的类型是可变的,那么其对象的 Field 值依然是可改变的——这个不可变类其实是失败的。
class Name {
private String firstName;
private String lastName;
public Name() {}
public Name(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getFirstName() {
return this.firstName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getLastName() {
return this.lastName;
}
}
public class Person {
private final Name name;
public Person(Name name) {
this.name = name;
}
public Name getName() {
return name;
}
public static void main(String[] args) {
Name n = new Name("悟空", "孙");
Person p = new Person(n);
// Person对象的name的firstName值为"悟空”
System.out.println(p.getName().getFirstName());
// 改变Person对象的name的firstName值
n.setFirstName("八戒");
// Person对象的name的firstName值被改为"八戒”
System.out.println(p.getName().getFirstName());
}
}
为了保持 Person
对象的不可变性,必须保护好 Person
对象的引用类型 Field:name
,让程序无法访问到 Person
对象的 name
Field,也就无法利用 name
Field 的可变性来改变 Person
对象了。
public class Person {
private final Name name;
public Person(Name name) {
// 设置name Field为临时创建的Name对象,该对象的firstName和lastName
// 与传入的name对象的firstName和lastName相同
this.name = new Name(name.getFirstName(), name.getLastName());
}
public Name getName() {
// 返回一个匿名对象,该对象的firstName和lastName
// 与该对象里的name的firstName和lastName相同
return new Name(name.getFirstName(), name.getLastName());
}
}
当程序向 Person
构造器里传入一个 Name
对象时,该构造器创建 Person
对象时并不是直接利用已有的 Name
对象,而是重新创建了一个 Name
对象来赋给 Person
对象的 name
Field。
当 Person
对象返回 name
Field 时,它并没有直接把 name
Field 返回,直接返回 name
Field 的值也可能导致它所引用的 Name
对象被修改。
如果需要设计一个不可变类,尤其要注意其引用类型 Field,如果引用类型 Field 的类是可变的,就必须采取必要的措施来保护该 Field 所引用的对象不会被修改,这样才能创建真正的不可变类。
缓存不可变类的实例
不可变类的实例状态不可改变,可以很方便地被多个对象所共享。如果程序经常需要使用相同的不可变类实例,则应该考虑缓存这种不可变类的实例。
class CacheImmutale {
private static int MAX_SIZE = 10;
// 使用数组来缓存已有的实例
private static CacheImmutale[] cache = new CacheImmutale[MAX_SIZE];
// 记录缓存实例在缓存中的位置,cache[pos-1]是最新缓存的实例
private static int pos = 0;
private final String name;
private CacheImmutale(String name) {
this.name = name;
}
public static CacheImmutale valueOf(String name) {
// 遍历已缓存的对象,
for (int i = 0; i < MAX_SIZE; i++) {
// 如果已有相同实例,则直接返回该缓存的实例
if (cache[i] != null && cache[i].getName().equals(name)) {
return cache[i];
}
}
// 如果缓存池已满
if (pos == MAX_SIZE) {
// 把缓存的第一个对象覆盖,即把刚刚生成的对象放在缓存池的最开始位置
cache[0] = new CacheImmutale(name);
// 把pos设为1
pos = 1;
} else {
// 把新创建的对象缓存起来,pos加1
cache[pos++] = new CacheImmutale(name);
}
return cache[pos - 1];
}
public String getName() {
return name;
}
public int hashCode() {
return name.hashCode();
}
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj != null && obj.getClass() == CacheImmutale.class) {
CacheImmutale ci = (CacheImmutale)obj;
return name.equals(ci.getName());
}
return false;
}
}
public class CacheImmutaleTest {
public static void main(String[] args) {
CacheImmutale c1 = CacheImmutale.valueOf("hello");
CacheImmutale c2 = CacheImmutale.valueOf("hello");
// 下面代码将输出true
System.out.println(c1 == c2);
}
}
当使用 CacheImmutale
类的 valueOf
方法来生成对象时,系统是否重新生成新的对象,取决于数组内是否已经存在该对象。如果该数组中已经缓存了该类的对象,系统将不会重新生成对象。
提示
是否需要隐藏 CacheImmutale
类的构造器完全取决于系统需求。盲目乱用缓存也可能导致系统性能下降,缓存的对象会占用系统内存,如果某个对象只使用一次,重复使用的概率不大,缓存该实例就弊大于利;反之,如果某个对象需要频繁地重复使用,缓存该实例就利大于弊。
例如 Java 提供的 java.lang.Integer
类,它就采用了与 CacheImmutale
类相同的处理策略,如果采用 new
构造器来创建 Integer
对象,则每次返回全新的 Integer
对象;如果采用 valueOf
方法来创建 Integer
对象,则会缓存该方法创建的对象。
public class IntegerCacheTest {
public static void main(String[] args) {
// 生成新的Integer对象
Integer in1 = new Integer(6);
// 生成新的Integer对象,并缓存该对象
Integer in2 = Integer.valueOf(6);
// 直接从缓存中取出Integer对象
Integer in3 = Integer.valueOf(6);
// 输出false
System.out.println(in1 == in2);
// 输出true
System.out.println(in2 == in3);
// 由于Integer只缓存-128~127之间的值
// 因此200对应的Integer对象没有被缓存
Integer in4 = Integer.valueOf(200);
Integer in5 = Integer.valueOf(200);
// 输出false
System.out.println(in4 == in5);
}
}
抽象类
在某些情况下,某个父类只是知道其子类应该包含怎样的方法,但无法准确地知道这些子类如何实现这些方法。例如定义了一个 Shape
类,这个类应该提供一个计算周长的方法 calPerimeter()
,但不同 Shape
子类对周长的计算方法是不一样的,即 Shape
类无法准确地知道其子类计算周长的方法。
如何既能让 Shape
类里包含 calPerimeter()
方法,又无须提供其方法实现呢?
使用抽象方法即可满足该要求:抽象方法是只有方法签名,没有方法实现的方法。
抽象方法和抽象类
抽象方法和抽象类必须使用 abstract
修饰符来定义,有抽象方法的类只能被定义成抽象类,抽象类里可以没有抽象方法。
抽象方法和抽象类的规则如下。
- 抽象类必须使用
abstract
修饰符来修饰,抽象方法也必须使用abstract
修饰符来修饰,抽象方法不能有方法体。- 抽象类不能被实例化,无法使用
new
关键字来调用抽象类的构造器创建抽象类的实例。即使抽象类里不包含抽象方法,这个抽象类也不能创建实例。- 抽象类可以包含 Field、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类、枚举类 6 种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。
- 含有抽象方法的类(包括直接定义了一个抽象方法;继承了一个抽象父类,但没有完全实现父类包含的抽象方法,以及实现了一个接口,但没有完全实现接口包含的抽象方法 3 种情况)只能被定义成抽象类。
定义抽象方法只需在普通方法上增加 abstract
修饰符,并把普通方法的方法体(也就是方法后花括号括起来的部分)全部去掉,并在方法后增加分号即可。
提示
抽象方法和空方法体的方法不是同一个概念。例如,public abstract void test();
是一个抽象方法,它根本没有方法体,即方法定义后面没有一对花括号;但 public void test(){}
方法是一个普通方法,它已经定义了方法体,只是方法体为空,即它的方法体什么也不做,因此这个方法不可使用 abstract
来修饰。
一个普通类(没有包含抽象方法的类)增加 abstract
修饰符后也将变成抽象类。
public abstract class Shape {
private String color;
{
System.out.println("执行Shape的初始化块...");
}
// 定义Shape的构造器,该构造器并不是用于创建Shape对象
// 而是用于被子类调用
public Shape() {}
public Shape(String color) {
System.out.println("执行Shape的构造器...");
this.color = color;
}
// 定义一个计算周长的抽象方法
public abstract double calPerimeter();
// 定义一个返回形状的抽象方法
public abstract String getType();
}
上面的 Shape
类里包含了两个抽象方法:calPerimeter()
和 getType()
,所以这个 Shape
类只能被定义成抽象类。Shape
类里既包含了初始化块,也包含了构造器,这些都不是在创建 Shape
对象时被调用的,而是在创建其子类的实例时被调用。
- 抽象类必须使用
abstract
修饰符来修饰,抽象方法也必须使用abstract
修饰符来修饰,抽象方法不能有方法体。- 抽象类不能被实例化,无法使用
new
关键字来调用抽象类的构造器创建抽象类的实例。即使抽象类里不包含抽象方法,这个抽象类也不能创建实例。- 抽象类可以包含 Field、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类、枚举类 6 种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。
- 含有抽象方法的类(包括直接定义了一个抽象方法;继承了一个抽象父类,但没有完全实现父类包含的抽象方法,以及实现了一个接口,但没有完全实现接口包含的抽象方法 3 种情况)只能被定义成抽象类。
抽象类不能用于创建实例,只能当作父类被其他子类继承。
public class Triangle extends Shape {
// 定义三角形的三边
private double a;
private double b;
private double c;
public Triangle(String color, double a, double b, double c) {
super(color);
this.setSides(a, b, c);
}
public void setSides(double a, double b, double c) {
if (a >= b + c || b >= a + c || c >= a + b) {
System.out.println("三角形两边之和必须大于第三边");
return;
}
this.a = a;
this.b = b;
this.c = c;
}
// 重写Shape类的计算周长的抽象方法
public double calPerimeter() {
return a + b + c;
}
// 重写Shape类的返回形状的抽象方法
public String getType() {
return "三角形";
}
}
再定义一个 Circle
普通类,Circle
类也是 Shape
类的一个子类。
public class Circle extends Shape {
private double radius;
public Circle(String color, double radius) {
super(color);
this.radius = radius;
}
public void setRadius(double radius) {
this.radius = radius;
}
// 重写Shape类的计算周长的抽象方法
public double calPerimeter() {
return 2 * Math.PI * radius;
}
// 重写Shape类的返回形状的抽象方法
public String getType() {
return getColor() + "圆形";
}
public static void main(String[] args) {
Shape s1 = new Triangle("黑色", 3, 4, 5);
Shape s2 = new Circle("黄色", 3);
System.out.println(s1.getType());
System.out.println(s1.calPerimeter());
System.out.println(s2.getType());
System.out.println(s2.calPerimeter());
}
}
主方法中定义了两个 Shape
类型的引用变量,它们分别指向 Triangle
对象和 Circle
对象。由于在 Shape
类中定义了 calPerimeter()
方法和 getType()
方法,所以程序可以直接调用 s1
变量和 s2
变量的calPerimeter()
方法和getType()
方法,无须强制类型转换为其子类类型。
当使用 abstract
修饰类时,表明这个类只能被继承;当使用 abstract
修饰方法时,表明这个方法必须由子类提供实现(即重写)。而 final
修饰的类不能被继承,final
修饰的方法不能被重写。因此 final
和 abstract
永远不能同时使用。
除此之外,当使用 static
修饰一个方法时,表明这个方法属于该类本身,即通过类就可调用该方法,但如果该方法被定义成抽象方法,则将导致通过该类来调用该方法时出现错误(调用了一个没有方法体的方法肯定会引起错误)。因此 static
和 abstract
不能同时修饰某个方法,即没有所谓的类抽象方法。
abstract
关键字修饰的方法必须被其子类重写才有意义,否则这个方法将永远不会有方法体,因此 abstract
方法不能定义为 private
访问权限,即 private
和 abstract
不能同时使用。
提示
abstract
不能用于修饰 Field,不能用于修饰局部变量,即没有抽象变量、没有抽象 Field 等说法;abstract
也不能用于修饰构造器,没有抽象构造器,抽象类里定义的构造器只能是普通构造器。
抽象类的作用
抽象类是从多个具体类中抽象出来的父类,它具有更高层次的抽象。从多个具有相同特征的类中抽象出一个抽象类,以这个抽象类作为其子类的模板,从而避免了子类设计的随意性。
抽象类体现的就是一种模板模式的设计,抽象类作为多个子类的通用模板,子类在抽象类的基础上进行扩展、改造,但子类总体上会大致保留抽象类的行为方式。
public abstract class SpeedMeter {
// 转速
private double turnRate;
public SpeedMeter() {
}
// 把返回车轮半径的方法定义成抽象方法
public abstract double getRadius();
public void setTurnRate(double turnRate) {
this.turnRate = turnRate;
}
// 定义计算速度的通用算法
public double getSpeed() {
// 速度等于车轮半径*2*PI*转速
return java.lang.Math.PI * 2 * getRadius() * turnRate;
}
}
程序定义了一个抽象的 SpeedMeter
类(车速表),该表里定义了一个 getSpeed
方法,该方法用于返回当前车速,getSpeed
方法依赖于getRadius
方法的返回值。
对于一个抽象的 SpeedMeter
类而言,它无法确定车轮的半径,因此 getRadius
方法必须推迟到其子类中实现。
public class CarSpeedMeter extends SpeedMeter {
public double getRadius() {
return 0.28;
}
public static void main(String[] args) {
CarSpeedMeter csm = new CarSpeedMeter();
csm.setTurnRate(15);
System.out.println(csm.getSpeed());
}
}
模板模式在面向对象的软件中很常用,其原理简单,实现也很简单。
下面是使用模板模式的一些简单规则。
- 抽象父类可以只定义需要使用的某些方法,把不能实现的部分抽象成抽象方法,留给其子类去实现。
- 父类中可能包含需要调用的其他系列方法的方法,这些被调方法既可以由父类实现,也可以由其子类实现。父类里提供的方法只是定义了一个通用算法,其实现也许并不完全由自身实现,而必须依赖于其子类的辅助。
接口
接口里不能包含普通方法,接口里的所有方法都是抽象方法。
接口的概念
接口是从多个相似类中抽象出来的规范,接口不提供任何实现。接口体现的是规范和实现分离的设计哲学。
经常听说接口,比如 PCI 接口、AGP 接口等,不是指主机板上的插槽。当我们说 PCI 接口时,指的是主机板上那个插槽遵守了 PCI 规范,而具体的 PCI 插槽只是 PCI 接口的实例。
对于不同型号的主机板而言,它们各自的 PCI 插槽都需要遵守一个规范,遵守这个规范就可以保证插入该插槽里的板卡能与主机板正常通信。对于同一个型号的主机板而言,它们的 PCI 插槽需要有相同的数据交换方式、相同的实现细节,它们都是同一个类的不同实例。
接口定义的是多个类共同的公共行为规范,这些行为是与外部交流的通道,这就意味着接口里通常是定义一组公用方法。
接口的定义
定义接口不再使用 class
关键字,而是使用 interface
关键字。
[修饰符] interface 接口名 extends 父接口1,父接口2... {
零个到多个常量定义...
零个到多个抽象方法定义...
}
- 修饰符可以是
public
或者省略,如果省略了public
访问控制符,则默认采用包权限访问控制符,即只有在相同包结构下才可以访问该接口。- 接口名应与类名采用相同的命名规则,即如果仅从语法角度来看,接口名只要是合法的标识符即可;如果要遵守 Java 可读性规范,则接口名应由多个有意义的单词连缀而成,每个单词首字母大写,单词与单词之间无须任何分隔符。
- 一个接口可以有多个直接父接口,但接口只能继承接口,不能继承类。
由于接口定义的是一种规范,因此接口里不能包含构造器和初始化块定义。接口里可以包含 Field (只能是常量)、方法(只能是抽象实例方法)、内部类(包括内部接口、枚举)定义。而且接口里的 Field 只能是常量,接口里的方法只能是抽象方法。
口里定义的是多个类共同的公共行为规范,因此接口里的所有成员,包括常量、方法、内部类和枚举类都是 public
访问权限。
定义接口成员时,可以省略访问控制修饰符,如果指定访问控制修饰符,则只能使用 public
访问控制修饰符。
在接口中定义 Field 时,不管是否使用 public static final
修饰符,接口里的 Field 总将使用这三个修饰符来修饰。而且,接口里没有构造器和初始化块,因此接口里定义的 Field 只能在定义时指定默认值。
接口里定义 Field 采用如下两行代码的结果完全一样。
//系统自动为接口里定义的Field增加public static final修饰符
int MAX_SIZE=50;
public static final int MAX_SIZE=50;
对于接口里定义的方法而言,它们只能抽象方法,因此系统会自动为其增加 abstract
修饰符;
由于接口里的方法全部是抽象方法,因此接口里不允许定义静态方法,即不可使用 static
修饰接口里定义的方法。
不管定义接口里方法时是否使用 public abstract
修饰符,接口里的方法总是使用 public abstract
来修饰。
提示
接口里定义的内部类、接口、枚举类默认都采用 public static
两个修饰符,不管定义时是否指定这两个修饰符,系统都会自动使用 public static
对它们进行修饰。
package lee;
public interface Output {
//接口里定义的Field只能是常量
int MAX_CACHE_LINE = 50;
//接口里定义的只能是public的抽象实例方法
void out();
void getData(String msg);
}
这个接口里包含了一个常量 Field:MAX_CACHE_LINE
,还定义了两个方法:表示取得数据的 getData()
方法和表示输出的 out()
方法。
这就定义了 Output
接口的规范:只要某个类能取得数据,并可以将数据输出,那它就是一个输出设备,至于这个设备的实现细节,这里暂时不关心。
接口里的 Field 默认是使用 public static final
修饰的,因此即使另一个类处于不同包下,也可以通过接口来访问接口里的常量 Field。
package yeeku;
public class OutputPropertyTest {
public static void main(String[] args) {
// 访问另一个包中的0utput接口的MAX_CACHE_LINE
System.out.println(lee.Output.MAX_CACHE_LINE);
// 下面语句将引起"为final变量赋值"的编译异常
// lee.Output.MAX_CACHE_LINE=2O;
}
}
提示
从某个角度来看,接口可被当成一个特殊的类,因此一个 Java 源文件里最多只能有一个 public
接口,如果一个 Java 源文件里定义了一个 public
接口,则该源文件的主文件名必须与该接口名相同。
接口的继承
接口的继承和类继承不一样,接口完全支持多继承,即一个接口可以有多个直接父接口。和类继承相似,子接口扩展某个父接口,将会获得父接口里定义的所有抽象方法、常量 Field、内部类和枚举类定义。
一个接口继承多个父接口时,多个父接口排在 extends
关键字之后,多个父接口之间以英文逗号 ,
隔开。
interface interfaceA {
int PROP_A = 5;
void testA();
}
interface interfaceB {
int PROP_B = 6;
void testB();
}
interface interfaceC extends interfaceA, interfaceB {
int PROP_C = 7;
void testC();
}
public class InterfaceExtendsTest {
public static void main(String[] args) {
System.out.println(interfaceC.PROP_A);
System.out.println(interfaceC.PROP_B);
System.out.println(interfaceC.PROP_C);
}
}
interfaceC
接口继承了 interfaceA
和 interfaceB
,所以 interfaceC
中获得了它们的常量 Field,因此在 main
方法中看到通过 interfaceC
来访问 PROP_A
、PROP_B
和 PROP_C
常量。
使用接口
接口不能用于创建实例,但接口可以用于声明引用类型变量。当使用接口来声明引用类型变量时,这个引用类型变量必须引用到其实现类的对象。除此之外,接口的主要用途就是被实现类实现。
一个类可以实现一个或多个接口,继承使用 extends
关键字,实现则使用 implements
关键字。因为一个类可以实现多个接口,这也是 Java 为单继承灵活性不足所做的补充。
[修饰符] class 类名 extends 父类 implements 接口1,接口2... {
类体部分
}
实现接口与继承父类相似,一样可以获得所实现接口里定义的常量 Field、抽象方法、内部类和枚举类定义。
一个类可以继承一个父类,并同时实现多个接口,implements
部分必须放在 extends
部分之后。
一个类实现了一个或多个接口之后,这个类必须完全实现这些接口里所定义的全部抽象方法(也就是重写这些抽象方法);否则该类将保留从父接口那里继承到的抽象方法,该类也必须定义成抽象类。
// 定义一个Product接口
interface Product {
int getProduceTime();
}
// 让Printer类实现Output接口和Product接口
public class Printer implements Output, Product {
private String[] printData = new String[MAX_CACHE_LINE];
// 用以记录当前需打印的作业数
private int dataNum = 0;
public void out() {
// 只要还有作业,就继续打印
while (dataNum > 0) {
System.out.println("打印机打印:" + printData[0]);
// 把作业队列整体前移一位,并将剩下的作业数减1
System.arraycopy(printData, 1, printData, 0, --dataNum);
}
}
public void getData(String msg) {
if (dataNum >= MAX_CACHE_LINE) {
System.out.println("输出队列已满,添加失败");
} else {
// 把打印数据添加到队列里,已保存数据的数量加1
printData[dataNum++] = msg;
}
}
public int getProduceTime() {
return 45;
}
public static void main(String[] args) {
// 创建一个Printer对象,当成Output使用
Output o = new Printer();
o.getData("轻量级Java EE企业应用实战");
o.getData("疯狂Java讲义");
o.out();
o.getData("疯狂Android讲义");
o.getData("疯狂Ajax讲义");
o.out();
// 创建一个Printer对象,当成Product使用
Product p = new Printer();
System.out.println(p.getProduceTime());
// 所有接口类型的引用变量都可直接赋给Object类型的变量
Object obj = p;
}
}
Printer
类实现了 Output
接口和 Product
接口,因此 Printer
对象既可直接赋给 Output
变量,也可直接赋给 Product
变量。仿佛 Printer
类既是 Output
类的子类,也是 Product
类的子类,这就是 Java 提供的模拟多继承。
提示
实现接口方法时,必须使用 public
访问控制修饰符,因为接口里的方法都是 public
的,而子类(相当于实现类)重写父类方法时访问权限只能更大或者相等,所以实现类实现接口里的方法时只能使用 public
访问权限。
接口不能显式继承任何类,但所有接口类型的引用变量都可以直接赋给 Object
类型的引用变量。所以在上面程序中可以把 Product
类型变量直接赋给 Object
类型变量,这是利用向上转型来实现的,因为编译器知道任何 Java 对象都必须是 Object
或其子类的实例,Product
类型的对象也不例外(它必须是 Product
接口实现类的对象,该实现类肯定是 Product
的显式或隐式子类)。
接口和抽象类
接口和抽象类很像,它们都具有如下特征。
- 接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。
- 接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法。
但接口和抽象类之间的差别非常大,这种差别主要体现在二者设计目的上。
接口作为系统与外界交互的窗口,接口体现的是一种规范。
对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务(以方法的形式来提供);对于接口的调用者而言,接口规定了调用者可以调用哪些服务,以及如何调用这些服务(就是如何来调用方法)。
当在一个程序中使用接口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信标准。
接口类似于整个系统的“总纲”,它制定了系统各模块应该遵循的标准,因此一个系统中的接口不应该经常改变。
一旦接口被改变,对整个系统甚至其他系统的影响将是辐射式的,导致系统中大部分类都需要改写。
抽象类则不一样,抽象类作为系统中多个子类的共同父类,它所体现的是一种模板式设计。
抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间产品,这个中间产品已经实现了系统的部分功能(那些已经提供实现的方法),但这个产品依然不能当成最终产品,必须有更进一步的完善,这种完善可能有几种不同方式。
接口和抽象类在用法上也存在如下差别。
- 接口里不能定义静态方法;抽象类里可以定义静态方法。
- 接口里只能定义静态常量 Field,不能定义普通 Field;抽象类里则既可以定义普通 Field,也可以定义静态常量 Field。
- 接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。
- 接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
- 一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接口可以弥补 Java 单继承的不足。
面向接口编程
简单工厂模式
假设程序中有个 Computer
类需要组合一个输出设备,现在有两个选择:直接让 Computer
类组合一个 Printer
,或者让 Computer
类组合一个 Output
,那么到底采用哪种方式更好呢?
假设让 Computer
类组合一个 Printer
对象,如果有一天系统需要重构,需要使用 BetterPrinter
来代替 Printer
,于是我们需要打开 Computer
类源代码进行修改。如果系统中只有一个 Computer
类组合了 Printer
还好,但如果系统中有 100 个类组合了 Printer
,甚至 1000 个、10000 个……将意味着我们要打开 100 个、1000 个、10000 个类进行修改。
为了避免这个问题,我们让 Computer
类组合一个 Output
类型的对象,将 Computer
类与 Printer
类完全分离。Computer
对象实际组合的是 Printer
对象还是 BetterPrinter
对象,对 Computer
而言完全透明。当 Printer
对象切换到 BetterPrinter
对象时,系统完全不受影响。
public class Computer {
private Output out;
public Computer(Output out) {
this.out = out;
}
// 定义一个模拟获取字符串输入的方法
public void keyIn(String msg) {
out.getData(msg);
}
// 定义一个模拟打印的方法
public void print() {
out.out();
}
}
Computer
类已经完全与 Printer
类分离,只是与 Output
接口耦合。
Computer
不再负责创建 Output
对象,系统提供一个 Output
工厂来负责生成 Output
对象。
public class OutputFactory {
public Output getOutput() {
return new Printer();
}
public static void main(String[] args) {
OutputFactory of = new OutputFactory();
Computer c = new Computer(of.getOutput());
c.keyIn("轻量级Java EE企业应用实战");
c.keyIn("疯狂Java讲义");
c.print();
}
}
OutputFactory
类中包含了一个 getOutput
方法,该方法返回一个 Output
实现类的实例,该方法负责创建 Output
实例,具体创建哪一个实现类的对象由该方法决定。
如果系统需要将 Printer
改为 BetterPrinter
实现类,只需让 BetterPrinter
实现 Output
接口,并改变 OutputFactory
类中的 getOutput
方法即可。
BetterPrinter
只是对原有的 Printer
进行简单修改,以模拟系统重构后的改进。
class BetterPrinter implements Output {
private String[] printData = new String[MAX_CACHE_LINE * 2];
private int dataNum = 0;
public void out() {
// 只要还有作业,就继续打印while(dataNum >0)
{
System.out.println("高速打印机正在打印:" + printData[0]);
// 把作业队列整体前移一位,并将剩下的作业数减1
System.arraycopy(printData, 1, printData, 0, --dataNum);
}
}
public void getData(String msg) {
if (dataNum >= MAX_CACHE_LINE * 2) {
System.out.println("输出队列已满,添加失败");
} else {
// 把打印数据添加到队列里,已保存数据的数量加1
printData[dataNum++] = msg;
}
}
}
把 OutputFactory
工厂类的 getOutput
方法改为如下代码:
public Output getOutput() {
return new BetterPrinter();
}
再次运行前面的 OutputFactory.java
程序,发现系统运行时已经改为 BetterPrinter
对象,而不再是原来的 Printer
对象。
相关信息
设计模式
所谓设计模式,就是对经常出现的软件设计问题的成熟解决方案。
很多人把设计模式想象成非常高深的概念,实际上设计模式仅仅是对特定问题的一种惯性思维。
设计模式的理解必须以足够的代码积累量作为基础。
最好是经历过某种苦痛,或者正在经历一种苦痛,就会对设计模式有较深的感受。
命令模式
假设有个方法需要遍历某个数组的数组元素,但无法确定在遍历数组元素时如何处理这些元素,需要在调用该方法时指定具体的处理行为。
这个方法不仅需要普通数据可以变化,甚至还有方法执行体也需要变化,难道我们能把“处理行为”作为一个参数传入该方法?
提示
在某些编程语言(如 Ruby 等)里,确实允许传入一个代码块作为参数。但 Java 暂时还不支持代码块作为参数。
对于这样一个需求,我们必须把“处理行为”作为参数传入该方法,这个“处理行为”用编程来实现就是一段代码。那如何把这段代码传入该方法呢?
我们使用一个 Command
接口来定义一个方法,用这个方法来封装“处理行为”。
public interface Command {
// 接口里定义的process方法用于封装“处理行为”
void process(int[] target);
}
下面是需要处理数组的处理类,在这个处理类中包含一个 process
方法,这个方法无法确定处理数组的处理行为,所以定义该方法时使用了一个 Command
参数,这个 Command
参数负责对数组的处理行为。
public class ProcessArray {
public void process(int[] target, Command cmd) {
cmd.process(target);
}
}
PrintCommand
类和 AddCommand
类:
public class PrintCommand implements Command {
public void process(int[] target) {
for (int tmp : target) {
System.out.println("迭代输出目标数组的元素:" + tmp);
}
}
}
public class AddCommand implements Command {
public void process(int[] target) {
int sum = 0;
for (int tmp : target) {
sum += tmp;
}
System.out.println("数组元素的总和是:" + sum);
}
}
通过一个 Command
接口,就实现了让 ProcessArray
类和具体“处理行为”的分离,程序使用 Command
接口代表了对数组的处理行为。Command
接口也没有提供真正的处理,只有等到需要调用 ProcessArray
对象的 process
方法时,才真正传入一个 Command
对象,才确定对数组的处理行为。
public class CommandTest {
public static void main(String[] args) {
ProcessArray pa = new ProcessArray();
int[] target = {3, -4, 6, 4};
// 第一次处理数组,具体处理行为取决于Print
Commandpa.process(target, new PrintCommand());
System.out.println("-----------------―");
// 第二次处理数组,具体处理行为取决于Add
Commandpa.process(target, new AddCommand());
}
}
内部类
在某些情况下,我们把一个类放在另一个类的内部定义,这个定义在其他类内部的类就被称为内部类(有的地方也叫嵌套类),包含内部类的类也被称为外部类(有的地方也叫宿主类)。
内部类主要有如下作用。
- 内部类提供了更好的封装,可以把内部类隐藏在外部类之内,不允许同一个包中的其他类访问该类。假设需要创建
Cow
类,Cow
类需要组合一个CowLeg
对象,CowLeg
类只有在Cow
类里才有效,离开了Cow
类之后没有任何意义。在这种情况下,就可把CowLeg
定义成Cow
的内部类,不允许其他类访问CowLeg
。- 内部类成员可以直接访问外部类的私有数据,因为内部类被当成其外部类成员,同一个类的成员之间可以互相访问。但外部类不能访问内部类的实现细节,例如内部类的成员变量。
- 匿名内部类适合用于创建那些仅需要一次使用的类。对于前面的命令模式,当需要传入一个
Command
对象时,重新专门定义PrintCommand
和AddCommand
两个实现类可能没有太大的意义,因为这两个实现类可能仅需要使用一次。在这种情况下,使用匿名内部类将更方便。
非静态内部类
定义内部类非常简单,只要把一个类放在另一个类内部定义即可。此处的“类内部”包括类中的任何位置,甚至在方法中也可以定义内部类(方法里定义的内部类被称为局部内部类)。
内部类定义语法格式如下:
public class OuterClass {
//此处可以定义内部类
class InnerClass {}
}
大部分时候,内部类都被作为成员内部类定义,而不是作为局部内部类。成员内部类是一种与 Field、方法、构造器和初始化块相似的类成员;局部内部类和匿名内部类则不是类成员。
成员内部类分为两种:静态内部类和非静态内部类,使用 static
修饰的成员内部类是静态内部类,没有使用 static
修饰的成员内部类是非静态内部类。
因为内部类作为其外部类的成员,所以可以使用任意访问控制符如 private
、protected
和 public
等修饰。
提示
外部类的上一级程序单元是包,所以它只有 2 个作用域:同一个包内和任何位置。
因此只需 2 种访问权限:包访问权限和公开访问权限,正好对应省略访问控制符和 public
访问控制符。
省略访问控制符是包访问权限,即同一包中的其他类可以访问省略访问控制符的成员。
因此,如果一个外部类不使用任何访问控制符修饰,则只能被同一个包中其他类访问。
而内部类的上一级程序单元是外部类,它就具有 4 个作用域:同一个类、同一个包、父子类和任何位置,因此可以使用 4 种访问控制权限。
class Cow {
private double weight;
// 外部类的两个重载的构造器
public Cow() {}
public Cow(double weight) {
this.weight = weight;
}
// 定义一个非静态内部类
private class CowLeg {
// 非静态内部类的两个Field
private double length;
private String color;
// 非静态内部类的两个重载的构造器
public CowLeg() {}
public CowLeg(double length, String color) {
this.length = length;
this.color = color;
}
public void setLength(double length) {
this.length = length;
}
public double getLength() {
return this.length;
}
public void setColor(String color) {
this.color = color;
}
public String getColor() {
return this.color;
}
// 非静态内部类的实例方法
public void info() {
System.out.println("当前牛腿颜色是:" + color + ",高:" + length);
// 直接访问外部类的private修饰的Field
// ①
System.out.println("本牛腿所在奶牛重:" + weight);
}
}
public void test() {
CowLeg cl = new CowLeg(1.12, "黑白相间");
cl.info();
}
public static void main(String[] args) {
Cow cow = new Cow(378.9);
cow.test();
}
}
上面程序中把一个类定义放在了另一个类的内部,所以它就成了一个内部类,可以使用 private
修饰符来修饰这个类。
外部类 Cow
里包含了一个 test
方法,该方法里创建了一个 CowLeg
对象,并调用该对象的 info
方法。在外部类里使用非静态内部类时,与平时使用普通类并没有太大的区别。
编译上面程序,看到在文件所在路径生成了两个 class
文件,一个是 Cow.class
,另一个是 Cow$CowLeg.class
,前者是外部类 Cow
的 class
文件,后者是内部类 CowLeg
的 class
文件,即成员内部类(包括静态内部类、非静态内部类)的 class
文件总是这种形式: OuterClass$InnerClass.class
。
在非静态内部类对象里,保存了一个它寄存的外部类对象的引用(当调用非静态内部类的实例方法时,必须有一个非静态内部类实例,而非静态内部类实例必须寄存在外部类实例里)。
当在非静态内部类的方法内访问某个变量时,系统优先在该方法内查找是否存在该名字的局部变量,如果存在就使用该变量;
如果不存在,则到该方法所在的内部类中查找是否存在该名字的成员变量,如果存在则使用该成员变量;
如果不存在,则到该内部类所在的外部类中查找是否存在该名字的成员变量,如果存在则使用该成员变量;
如果依然不存在,系统将出现编译错误:提示找不到该变量。
如果外部类成员变量、内部类成员变量与内部类里方法的局部变量同名,则可通过使用
this
、外部类类名.this
作为限定来区分。
public class DiscernVariable {
private String prop = "外部类的实例变量";
private class InClass {
private String prop = "内部类的实例变量";
public void info() {
String prop = "局部变量";
// 通过外部类类名.this.varName 访问外部类实例Field
System.out.println("外部类的Field值:" + DiscernVariable.this.prop);
// 通过 this.varName 访问内部类实例的Field
System.out.println("内部类的Field值:" + this.prop);
// 直接访问局部变量
System.out.println("局部变量的值:" + prop);
}
}
public void test() {
InClass in = new InClass();
in.info();
}
public static void main(String[] args) {
new DiscernVariable().test();
}
}
通过 OutterClass.this.propName
的形式访问外部类的实例 Field,通过 this.propName
的形式访问非静态内部类的实例 Field。
非静态内部类的成员可以访问外部类的 private
成员,但反过来就不成立了。
如果外部类需要访问非静态内部类的成员,则必须显式创建非静态内部类对象来调用访问其实例成员。
public class Outer {
private final int outProp = 9;
public static void main(String[] args) {
// 执行下面代码,只创建了外部类对象,还未创建内部类对象
Outer out = new Outer();
//①
out.accessInnerProp();
}
public void accessInnerProp() {
// 外部类不能直接访问非静态内部类的实例Field
// 下面代码出现编译错误
// System.out.println("内部类的inProp值:" + inProp);
// 如需访问内部类的实例Field,则必须显式创建内部类对象
System.out.println("内部类的inProp值:" + new Inner().inProp);
}
class Inner {
private final int inProp = 5;
public void accessOuterProp() {
// 非静态内部类可以直接访问外部类的成员
System.out.println("外部类的outProp值:" + outProp);
}
}
}
main
方法的 ① 号代码创建了一个外部类对象,并调用外部类对象的 accessInnerProp
方法。此时非静态内部类对象根本不存在,如果允许 accessInnerProp
方法访问非静态内部类对象,将肯定引起错误。
根据静态成员不能访问非静态成员的规则,外部类的静态方法、静态代码块不能访问非静态内部类,包括不能使用非静态内部类定义变量、创建实例等。
public class StaticTest {
// 定义一个非静态内部类,是一个空类
private class In {
// 外部类的静态方法
public static void main(String[] args) {
// 下面代码引发编译异常,因为静态成员(main方法)
// 无法访问非静态成员(In类)
new In();
}
}
}
Java 不允许在非静态内部类里定义静态成员。
public class InnerNoStatic {
private class InnerClass {
/*
下面三个静态声明都将引发如下编译错误:非静态内部类不能有静态声明
*/
static {
System.out.println("====三=====");
}
private static int inProp;
private static void test() {
}
}
}
提示
非静态内部类里不可以有静态初始化块,但可以包含普通初始化块。非静态内部类普通初始化块的作用与外部类初始化块的作用完全相同。
静态内部类
如果使用 static
来修饰一个内部类,则这个内部类就属于外部类本身,而不属于外部类的某个对象。因此使用 static
修饰的内部类被称为类内部类,有的地方也称为静态内部类。
静态内部类可以包含静态成员,也可以包含非静态成员。根据静态成员不能访问非静态成员的规则,静态内部类不能访问外部类的实例成员,只能访问外部类的类成员。即使是静态内部类的实例方法也不能访问外部类的实例成员,只能访问外部类的静态成员。
public class StaticInnerClassTest {
private int prop1 = 5;
private static int prop2 = 9;
static class StaticInnerClass {
// 静态内部类里可以包含静态成员
private static int age;
public void accessOuterProp() {
// 下面代码出现错误
// 静态内部类无法访问外部类的实例成员
// System.out.println(prop1);
// 下面代码正常
System.out.println(prop2);
}
}
}
提示
为什么静态内部类的实例方法也不能访问外部类的实例属性呢?
因为静态内部类是外部类的类相关,而不是外部类的对象相关的。
也就是说,静态内部类对象不是寄存在外部类对象里的,而是寄存在外部类的类本身中。
当静态内部类对象存在时,并不存在一个被它寄存的外部类对象,静态内部类对象里只有外部类的类引用,没有持有外部类对象的引用。
如果允许静态内部类的实例方法访问外部类的实例成员,但找不到被寄存的外部类对象,这将引起错误。
静态内部类是外部类的一个静态成员,因此外部类的静态方法、静态初始化块中可以使用静态内部类来定义变量、创建对象等。
外部类依然不能直接访问静态内部类的成员,但可以使用静态内部类的类名作为调用者来访问静态内部类的类成员,也可以使用静态内部类对象作为调用者来访问静态内部类的实例成员。
public class AccessStaticInnerClass {
static class StaticInnerClass {
private static int prop1 = 5;
private int prop2 = 9;
}
public void accessInnerProp() {
// System.out.printIn(prop1);
// 上面代码出现错误,应改为如下形式
// 通过类名访问静态内部类的类成员
System.out.println(StaticInnerClass.prop1);
// System.out.printIn(prop2);
// 上面代码出现错误,应改为如下形式
// 通过实例访问静态内部类的实例成员
System.out.println(new StaticInnerClass().prop2);
}
}
除此之外,Java 还允许在接口里定义内部类,接口里定义的内部类默认使用 public static
修饰,也就是说,接口内部类只能是静态内部类。
如果为接口内部类指定访问控制符,则只能指定 public
访问控制符;如果定义接口内部类时省略访问控制符,则该内部类默认是 public
访问控制权限。
提示
接口里是否能定义内部接口?
可以。接口里的内部接口是接口的成员,因此系统默认添加 public static
两个修饰符。
如果定义接口里的内部接口时指定访问控制符,则只能使用 public
修饰符。
当然,定义接口里的内部接口的意义不大,因为接口的作用是定义一个公共规范(暴露出来供大家使用),如果把这个接口定义成一个内部接口,那么意义何在呢?
使用内部类
使用内部类定义变量和创建实例与外部类存在的一些小小的差异。
- 在外部类内部使用内部类
在外部类内部使用内部类时,与平常使用普通类没有太大的区别。一样可以直接通过内部类类名来定义变量,通过
new
调用内部类构造器来创建实例。唯一存在的一个区别是:不要在外部类的静态成员(包括静态方法和静态初始化块)中使用非静态内部类,因为静态成员不能访问非静态成员。
在外部类内部定义内部类的子类与平常定义子类也没有太大的区别。
- 在外部类以外使用非静态内部类
如果希望在外部类以外的地方访问内部类(包括静态和非静态两种),则内部类不能使用
private
访问控制权限,private
修饰的内部类只能在外部类内部使用。对于使用其他访问控制符修饰的内部类,则能在访问控制符对应的访问权限内使用。
- 省略访问控制符的内部类,只能被与外部类处于同一个包中的其他类所访问。
- 使用
protected
修饰的内部类,可被与外部类处于同一个包中的其他类和外部类的子类所访问。- 使用
public
修饰的内部类,可以在任何地方被访问。
在外部类以外的地方定义内部类(包括静态和非静态两种)变量的语法格式如下:
OuterClass.InnerClass varName
在外部类以外的地方使用内部类时,内部类完整的类名应该是 OuterClass.InnerClass
。如果外部类有包名,则还应该增加包名前缀。
因为非静态内部类的对象必须寄存在外部类的对象里,因此创建非静态内部类对象之前,必须先创建其外部类对象。在外部类以外的地方创建非静态内部类实例的语法如下:
Outerlnstance.new InnerConstructor()
在外部类以外的地方创建非静态内部类实例必须使用外部类实例和 new
来调用非静态内部类的构造器。
class Out {
// 定义一个内部类,不使用访问控制符
// 即只有同一个包中的其他类可访问该内部类
class In {
public In(String msg) {
System.out.println(msg);
}
}
}
public class CreateInnerInstance {
public static void main(String[] args) {
Out.In in = new Out().new In("测试信息");
/*
* 上面代码可改为如下三行代码:
* 使用OuterClass.InnerClass的形式定义内部类变量
* Out.In in;
* 创建外部类实例,非静态内部类实例将寄存在该实例中
* Out out = new Out();
* 通过外部类实例和new来调用内部类构造器创建非静态内部类实例
* in = out.new In("测试信息");
*/
}
}
当创建一个子类时,子类构造器总会调用父类的构造器,因此在创建非静态内部类的子类时,必须保证让子类构造器可以调用非静态内部类的构造器,调用非静态内部类的构造器时,必须存在一个外部类对象。
public class SubClass extends Out.ln {
// 显示定义SubClass的构造器
public SubClass(Out out) {
// 通过传入的Out对象显式调用ln的构造器
out.super("hello");
}
}
从上面代码中可以看出,如果需要创建 SubClass
对象时,必须先创建一个 Out
对象。
因为 SubClass
是非静态内部类 In
类的子类,非静态内部类 In
对象里必须有一个对 Out
对象的引用,其子类 SubClass
对象里也应该存在一个 Out
对象的引用。
当创建 SubClass
对象时传给该构造器的 Out
对象,就是 SubClass
对象里 Out
对象引用所指向的对象。
非静态内部类 In
对象和 SubClass
对象都必须保留有指向 Outer
对象的引用,区别是创建两种对象时传入 Out
对象的方式不同:当创建非静态内部类 In
类的对象时,必须通过 Outer
对象来调用 new
关键字;当创建 SubClass
类的对象时,必须使用 Outer
对象作为调用者来调用 In
类的构造器。
提示
非静态内部类的子类不一定是内部类,它可以是一个外部类。但非静态内部类的子类实例一样需要保留一个引用,该引用指向其父类所在外部类的对象。
如果有一个内部类子类的对象存在,则一定存在与之对应的外部类对象。
- 在外部类以外使用静态内部类
因为静态内部类是外部类类相关的,因此创建内部类对象时无须创建外部类对象。
new OuterClass.InnerConstructor()
在外部类以外的地方创建静态内部类的实例。
class StaticOut {
// 定义一个静态内部类,不使用访问控制符
// 即同一个包中的其他类可访问该内部类
static class StaticIn {
public StaticIn() {
System.out.println("静态内部类的构造器");
}
}
}
public class CreateStaticInnerInstance {
public static void main(String[] args) {
StaticOut.StaticIn in = new StaticOut.StaticIn();
/*
* 上面代码可改为如下两行代码:
* 使用OuterClass.InnerClass的形式定义内部类变量
* StaticOut.StaticIn in;
* 通过new来调用内部类构造器创建静态内部类实例
* in = new StaticOut.StaticIn();
*/
}
}
不管是静态内部类还是非静态内部类,它们声明变量的语法完全一样,区别只是在创建内部类对象时,静态内部类只需使用外部类即可调用构造器,而非静态内部类必须使用外部类对象来调用构造器。
因为调用静态内部类的构造器时无须使用外部类对象,所以创建静态内部类的子类也比较简单,下面代码就为静态内部类 StaticIn
类定义了一个空的子类。
public class StaticSubClass extends StaticOut.Staticln {}
使用静态内部类比使用非静态内部类要简单很多,只要把外部类当成静态内部类的包空间即可。因此当程序需要使用内部类时,应该优先考虑使用静态内部类。
相关信息
既然内部类是外部类的成员,那么是否可以为外部类定义子类,在子类中再定义一个内部类来重写其父类中的内部类?
不可以!内部类的类名不再是简单地由内部类的类名组成,它实际上还把外部类的类名作为一个命名空间,作为内部类类名的限制。
因此子类中的内部类和父类中的内部类不可能完全同名,即使二者所包含的内部类的类名相同,但因为它们所处的外部类空间不同,所以它们不可能完全同名,也就不可能重写。
局部内部类
如果把一个内部类放在方法里定义,则这个内部类就是一个局部内部类,局部内部类仅在该方法里有效。由于局部内部类不能在外部类的方法以外的地方使用,因此局部内部类也不能使用访问控制符和 static
修饰符修饰。
提示
对于局部成员而言,不管是局部变量还是局部内部类,它们的上一级程序单元都是方法,而不是类,使用 static
修饰它们没有任何意义。
因此,所有的局部成员都不能使用 static
修饰。
不仅如此,因为局部成员的作用域是所在方法,其他程序单元永远也不可能访问另一个方法中的局部成员,所以所有的局部成员都不能使用访问控制符修饰。
如果需要用局部内部类定义变量、创建实例或派生子类,那么都只能在局部内部类所在的方法内进行。
public class LocalInnerClass {
public static void main(String[] args) {
// 定义局部内部类
class InnerBase {
int a;
}
// 定义局部内部类的子类
class InnerSub extends InnerBase {
int b;
}
// 创建局部内部类的对象
InnerSub is = new InnerSub();
is.a = 5;
is.b = 8;
System.out.println("InnerSub对象的a和b Field是:" + is.a + "," + is.b);
}
}
编译上面程序,看到生成了三个 class
文件:LocalInnerClass.class
、LocalInnerClass$1InnerBase.class
和 LocalInnerClass$1InnerSub.class
,这表明局部内部类的 class
文件总是遵循如下命名格式:OuterClass$NInnerClass.class
。
注意到局部内部类的 class
文件的文件名比成员内部类的 class
文件的文件名多了一个数字,这是因为同一个类里不可能有两个同名的成员内部类,而同一个类里则可能有两个以上同名的局部内部类(处于不同方法中),所以 Java 为局部内部类的 class
文件名中增加了一个数字,用于区分。
匿名内部类
匿名内部类适合创建那种只需要一次使用的类,例如前面介绍命令模式时所需要的 Command
对象。匿名内部类的语法有点奇怪,创建匿名内部类时会立即创建一个该类的实例,这个类定义立即消失,匿名内部类不能重复使用。
new 父类构造器(实参列表)|实现接口() {
//匿名内部类的类体部分
}
匿名内部类必须继承一个父类,或实现一个接口,但最多只能继承一个父类,或实现一个接口。
- 匿名内部类不能是抽象类,因为系统在创建匿名内部类时,会立即创建匿名内部类的对象。因此不允许将匿名内部类定义成抽象类。
- 匿名内部类不能定义构造器,因为匿名内部类没有类名,所以无法定义构造器,但匿名内部类可以定义实例初始化块,通过实例初始化块来完成构造器需要完成的事情。
最常用的创建匿名内部类的方式是需要创建某个接口类型的对象。
interface Product {
double getPrice();
String getName();
}
public class AnonymousTest {
public static void main(String[] args) {
AnonymousTest ta = new AnonymousTest();
// 调用test方法时,需要传入一个Product参数
// 此处传入其匿名实现类的实例
ta.test(new Product() {
public double getPrice() {
return 567.8;
}
public String getName() {
return "AGP显卡";
}
});
}
public void test(Product p) {
System.out.println("购买了一个" + p.getName() + ",花掉了" + p.getPrice());
}
}
AnonymousTest
类定义了一个 test
方法,该方法需要一个 Product
对象作为参数,但 Product
只是一个接口,无法直接创建对象,因此此处考虑创建一个 Product
接口实现类的对象传入该方法——如果这个 Product
接口实现类需要重复使用,则应该将该实现类定义成一个独立类;如果这个 Product
接口实现类只需一次使用,则可采用上面程序中的方式,定义一个匿名内部类。
由于匿名内部类不能是抽象类,所以匿名内部类必须实现它的抽象父类或者接口里包含的所有抽象方法。
上面创建 Product
实现类对象的代码,可以拆分成如下代码。
class AnonymousProduct implements Product{
public double getPrice(){
return 567.8;
}
public String getName(){
return "AGP显卡";
}
}
/*
ta.test(new Product() {
public double getPrice() {
return 567.8;
}
public String getName() {
return "AGP显卡";
}
});
*/
ta.test(new AnonymousProduct());
当通过实现接口来创建匿名内部类时,匿名内部类也不能显式创建构造器,因此匿名内部类只有一个隐式的无参数构造器,故 new
接口名后的括号里不能传入参数值。
但如果通过继承父类来创建匿名内部类时,匿名内部类将拥有和父类相似的构造器,此处的相似指的是拥有相同的形参列表。
abstract class Device {
private String name;
public Device() {
}
public Device(String name) {
this.name = name;
}
public abstract double getPrice();
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class AnonymousInner {
public static void main(String[] args) {
AnonymousInner ai = new AnonymousInner();
// 调用有参数的构造器创建Device匿名实现类的对象
ai.test(new Device("电子示波器") {
public double getPrice() {
return 67.8;
}
});
// 调用无参数的构造器创建Device匿名实现类的对象
Device d = new Device() {
// 初始化块
{
System.out.println("匿名内部类的初始化块...");
}
// 实现抽象方法
public double getPrice() {
return 56.2;
}
// 重写父类的实例方法
public String getName() {
return "键盘";
}
};
ai.test(d);
}
public void test(Device d) {
System.out.println("购买了一个" + d.getName() + ",花掉了" + d.getPrice());
}
}
程序创建了一个抽象父类 Device
类,这个抽象父类里包含两个构造器:一个无参数的,一个有参数的。当创建以 Device
为父类的匿名内部类时,既可以传入参数,也可以不传入参数。
当创建匿名内部类时,必须实现接口或抽象父类里的所有抽象方法。如果有需要,也可以重写父类中的普通方法。
如果匿名内部类需要访问外部类的局部变量,则必须使用 final
修饰符来修饰外部类的局部变量,否则系统将报错。
interface A {
void test();
}
public class ATest {
public static void main(String[] args) {
int age = 0;
A a = new A() {
public void test() {
// 下面语句将提示错误:
// 匿名内部类内访问局部变量必须使用final修饰
// System.out.printIn(age);
}
};
}
}
相关信息
为什么需要用 final
修饰局部变量呢?
public class InnerClass {
private int defaultAge = 5;
// 局部变量 age,必须添加final关键字,这里先不加
public void addAge(int age){
//局部内部类
class NewAge{
private int getAge(){
return age + defaultAge;
}
}
NewAge newAge = new NewAge();
System.out.print(newAge.getAge());
}
}
生命周期不同:
因为局部变量直接存储在栈中,当方法执行结束,非
final
的局部变量就被销毁,而局部内部类对局部变量的引用依然存在,当局部内部类要调用局部变量时,就会出错,出现非法引用 。简单来说,就是非
final
的局部变量的生命周期比局部内部类的生命周期短。是不是直接可以拷贝变量到局部内部类?
这样内部类中就可以使用而且不担心生命周期问题呢?
也是不可以的,因为直接拷贝又会出现第二个问题,就是数据不同步。
数据不同步:
内部类并不是直接使用传递进来的参数,而是将传递进来的参数通过自己的构造器备份到自己内部,表面看是同一个变量,实际调用的是自己的属性而不是外部类方法的参数,如果在内部类中,修改了这些参数,并不会对外部变量产生影响,仅仅改变局部内部类中备份的参数。
如果外部类调用外部变量时发现值并没有被修改,这种问题就会很尴尬,造成数据不同步。
所以使用
final
避免数据不同步的问题。
那为什么添加 final
修饰的局部变量,就可以被局部内部类引用呢?
若定义为
final
,则 java 编译器则会在内部类NewAge
内生成一个外部变量的拷贝,而且可以既可以保证内部类可以引用外部属性,又能保证值的唯一性。也就是拷贝了一个变量的副本,提供给局部内部类,这个副本的生命周期和局部内部类一样长,并且这个副本不可以修改,保证了数据的同步。
在 Java8 中,被局部内部类引用的局部变量,默认添加 final
,所以不需要添加 final
关键词。
idea 查看编译后的 class
:
class ATest$1NewAge {
// 局部内部类中的使用的age,是通过构造函数传递进来的,并不是直接引用外部变量。
ATest$1NewAge(ATest this$0, int var2) {
this.this$0 = this$0;
this.val$age = var2;
}
private int getAge() {
return this.val$age + 5;
}
}
闭包和回调
闭包(Closure)是一种能被调用的对象,它保存了创建它的作用域信息。
对于非静态内部类而言,它不仅记录了其外部类的详细信息,还保留了一个创建非静态内部类对象的引用,并且可以直接调用外部类的 private
成员,因此可以把非静态内部类当成面向对象领域的闭包。
回调就是某个方法一旦获得了内部类对象的引用后,就可以在合适的时候反过来去调用外部类实例的方法。
所谓回调,就是允许客户类通过内部类引用来调用其外部类的方法,这是一种非常灵活的功能。
假设现在有一个人,既是一个程序员,也是一个教师。也就是说,需要定义一个特殊的类,既需要实现 Teachable
接口,也需要继承 Programmer
父类。
表面上看起来这没有任何问题,问题是 Teachable
接口和 Programmer
父类里包含了相同的 work
方法。
class TeachableProgrammer extends Programmer implements Teachable {
public void work() {
System.out.println(getName() + "教师在讲台上讲解...");
}
}
上面的 TeachableProgrammer
类只有一个 work
方法,这个 work
方法只能进行“教学”,不可以进行“编程”。
class TeachableProgrammer extends Programmer {
public TeachableProgrammer() {}
public TeachableProgrammer(String name) {
super(name);
}
// 教学工作依然由TeachableProgrammer类定义
private void teach() {
System.out.println(getName() + "教师在讲台上讲解...");
}
// 返回一个非静态内部类引用,允许外部类通过该非静态内部类引用来回调外部类的方法
public Teachable getCallbackReference() {
return new Closure();
}
private class Closure implements Teachable {
/*
非静态内部类回调外部类实现work方法,非静态内部类引用的
作用仅仅是
向客户类提供一个回调外部类的途径
*/
public void work() {
teach();
}
}
}
上面的 TeachableProgrammer
类只是 Programmer
类的子类,它可以直接调用 Programmer
基类的 work
方法,该类也包含教学的 teach
方法,但这个方法与 Teachable
接口没有任何关系,TeachableProgrammer
也不能当成 Teachable
使用。
此时创建了一个 Closure
内部类,它实现了 Teachable
接口,并实现了教学的 work
方法,但这种实现是通过回调 TeachableProgrammer
类的 teach
方法实现的。
如果需要让 TeachableProgrammer
对象进行教学,只需调用 Closure
内部类(它是 Teachable
接口的实现类)对象的 work
方法即可。
TeachableProgrammer
类提供了一个获取内部类对象的方法,该方法无须返回 Closure
类型,只需返回所实现接口:Teachable
类型即可,接下来它就可当成一个 Teachable
对象使用了。
从图中清楚地看出 Closure
内部类的作用,它可以实现 Teachable
接口,也可以当成 Teachable
使用,而且它是 TeachableProgrammer
的内部类,是回调TeachableProgrammer
对象方法的入口,它的 work
方法实际上回调了 TeachableProgrammer
类的 teach
方法。
如何让 TeachableProgrammer
对象既执行“教学”的 work
方法,也执行“编程”的 work
方法。
class TeachableProgrammerTest {
public static void main(String[] args) {
TeachableProgrammer tp = new TeachableProgrammer("李刚");
// 直接调用TeachableProgrammer类从Programmer类继承到的work方法
tp.work();
// 表面上调用的是Closure的work方法
// 实际上是回调TeachableProgrammer的teach方法
tp.getCallbackReference().work();
}
}
枚举类
在某些情况下,一个类的对象是有限而且固定的,比如季节类,它只有 4 个对象;再比如行星类,目前只有 9 个对象。这种实例有限而且固定的类,在 Java 里被称为枚举类。
手动实现枚举类
- 通过
private
将构造器隐藏起来。 - 把这个类的所有可能实例都使用
public static final
修饰的类变量来保存。 - 如果有必要,可以提供一些静态方法,允许其他程序根据特定参数来获取与之匹配的实例。
下面程序将定义一个 Season
类,这个类只能产生 4 个对象,该 Season
类被定义成一个枚举类。
public class Season {
public static final Season SPRING = new Season("春天", "趁春踏青");
public static final Season SUMMER = new Season("夏天", "夏日炎炎");
public static final Season FALL = new Season("秋天", "秋高气爽");
public static final Season WINTER = new Season("冬天", "围炉赏雪");
// 把Season类定义成不可变的,将其Field也定义成final
private final String name;
private final String desc;
// 将构造器定义成private访问权限
private Season(String name, String desc) {
this.name = name;
this.desc = desc;
}
public static Season getSeason(int seasonNum) {
switch (seasonNum) {
case 1:
return SPRING;
case 2:
return SUMMER;
case 3:
return FALL;
case 4:
return WINTER;
default:
return null;
}
}
// 只为name和desc提供getter方法
public String getName() {
return this.name;
}
public String getDesc() {
return this.desc;
}
}
Season
类是一个不可变类,Season
类中包含了 4 个 static final
常量 Field,这 4 个常量 Field 就代表了该类所能创建的对象。其他程序需要使用 Season
对象时,既可通过如 Season.SPRING
的方式来取得 Season
对象,也可通过 getSeason()
静态工厂方法来获得 Season
对象。
class SeasonTest {
public SeasonTest(Season s) {
System.out.println(s.getName() + ",这真是一个" + s.getDesc() + "的季节");
}
public static void main(String[] args) {
// 直接使用Season的FALL常量代表一个Season对象
new SeasonTest(Season.FALL);
}
}
枚举类可以使程序更加健壮,避免创建对象的随意性。
枚举类入门
Java 5 新增了一个 enum
关键字(它与 class
、interface
关键字的地位相同),用以定义枚举类。
枚举类是一种特殊的类,它一样可以有自己的 Field、方法,可以实现一个或者多个接口,也可以定义自己的构造器。一个 Java 源文件中最多只能定义一个 public
访问权限的枚举类,且该 Java 源文件也必须和该枚举类的类名相同。
它与普通类有如下简单区别。
- 枚举类可以实现一个或多个接口,使用
enum
定义的枚举类默认继承了java.lang.Enum
类,而不是继承Object
类。其中java.lang.Enum
类实现了java.lang.Serializable
和java.lang. Comparable
两个接口。- 使用
enum
定义、非抽象的枚举类默认会使用final
修饰,因此枚举类不能派生子类。- 枚举类的构造器只能使用
private
访问控制符,如果省略了构造器的访问控制符,则默认使用private
修饰;如果强制指定访问控制符,则只能指定private
修饰符。- 枚举类的所有实例必须在枚举类的第一行显式列出,否则这个枚举类永远都不能产生实例。列出这些实例时,系统会自动添加
public static final
修饰,无须程序员显式添加。
所有的枚举类都提供了一个 values
方法,该方法可以很方便地遍历所有的枚举值。
public enum SeasonEnum {
// 在第一行列出4个枚举实例
SPRING,SUMMER,FALL,WINTER;
}
编译上面 Java 程序,将生成一个 SeasonEnum.class
文件,这表明枚举类是一个特殊的 Java 类。enum
关键字和 class
、interface
关键字的作用大致相似。
如果需要使用该枚举类的某个实例,则可使用 EnumClass.variable
的形式,如 SeasonEnum.SPRING
。
class EnumTest {
public void judge(SeasonEnum s) {
// switch语句里的表达式可以是枚举值
switch (s) {
case SPRING:
System.out.println("春暖花开,正好踏青");
break;
case SUMMER:
System.out.println("夏日炎炎,适合游泳");
break;
case FALL:
System.out.println("秋高气爽,进补及时");
break;
case WINTER:
System.out.println("冬日雪飘,围炉赏雪");
break;
}
}
public static void main(String[] args) {
// 所有的枚举类都有一个values方法,返回该枚举类的所有实例
for (SeasonEnum s : SeasonEnum.values()) {
System.out.println(s);
}
// 平常使用枚举实例时
// 总是通过EnumClass.variable的形式来访问
new EnumTest().judge(SeasonEnum.SPRING);
}
}
上面程序的 switch
表达式中还使用了 SeasonEnum
对象作为表达式,这是 JDK 1.5 增加枚举后对 switch
的扩展:switch
的控制表达式可以是任何枚举类型。不仅如此,当 switch
控制表达式使用枚举类型时,后面 case
表达式中的值直接使用枚举值的名字,无须添加枚举类作为限定。
所以枚举类可以直接使用 java.lang.Enum
类中所包含的方法。
java.lang.Enum
类中提供了如下几个方法。
int compareTo(E o)
:该方法用于与指定枚举对象比较顺序,同一个枚举实例只能与相同类型的枚举实例进行比较。如果该枚举对象位于指定枚举对象之后,则返回正整数;如果该枚举对象位于指定枚举对象之前,则返回负整数,否则返回零。String name()
:返回此枚举实例的名称,这个名称就是定义枚举类时列出的所有枚举值之一。与此方法相比,大多数程序员应该优先考虑使用toString()
方法,因为toString()
方法返回更加用户友好的名称。int ordinal()
:返回枚举值在枚举类中的索引值(就是枚举值在枚举声明中的位置,第一个枚举值的索引值为零)。String toString()
:返回枚举常量的名称,与name
方法相似,但toString()
方法更常用。public static <T extends Enum<T>> T valueOf(Class<T> enumType,String name)
:这是一个静态方法,用于返回指定枚举类中指定名称的枚举值。名称必须与在该枚举类中声明枚举值时所用的标识符完全匹配,不允许使用额外的空白字符。
当我们使用 System.out.println(s)
语句来打印枚举值时,实际上输出的是该枚举值的 toString()
方法,也就是输出该枚举值的名字。
枚举类的 Field、方法和构造器
public enum Gender{
MALE,FEMALE;
// 定义一个public修饰的实例变量
public String name;
}
使用该枚举类。
class GenderTest {
public static void main(String[] args) {
// 通过Enum的valueOf方法来获取指定枚举类的枚举值
Gender g = Enum.valueOf(Gender.class, "FEMALE");
// 直接为枚举值的Field赋值
g.name = "女";
// 直接访问枚举值的Field值
System.out.println(g + "代表:" + g.name);
}
}
把类设计成良好封装的类,不允许直接访问 Gender
类的 name
成员变量,而是通过方法来控制对 name
的访问。
改进 Gender
类的设计。
public enum Gender {
MALE, FEMALE;
private String name;
public void setName(String name) {
switch (this) {
case MALE:
if ("男".equals(name)) {
this.name = name;
} else {
System.out.println("参数错误");
return;
}
break;
case FEMALE:
if ("女".equals(name)) {
this.name = name;
} else {
System.out.println("参数错误");
return;
}
break;
}
}
public String getName() {
return this.name;
}
}
把 name
设置成 private
,从而避免其他程序直接访问该 name
成员变量,必须通过 setName()
方法来修改 Gender
实例的 name
变量,而 setName()
方法就可以保证不会产生混乱。
class GenderTest {
public static void main(String[] args) {
// 通过Enum的valueOf方法来获取指定枚举类的枚举值
Gender g = Enum.valueOf(Gender.class, "FEMALE");
// 直接为枚举值的Field赋值
g.setName("女");
// 直接访问枚举值的Field值
System.out.println(g + "代表:" + g.getName());
// 参数错误
g.setName("男");
System.out.println(g + "代表:" + g.getName());
}
}
实际上这种做法依然不够好,枚举类通常应该设计成不可变类,也就是说,它的 Field 值不应该允许改变,这样会更安全,而且代码更加简洁。
我们应该将枚举类的 Field 都使用 private final
修饰。因为我们将所有的 Field 都使用了 final
修饰符来修饰,所以必须在构造器里为这些 Field 指定初始值(或者在定义 Field 时指定默认值,或者在初始化块中指定初始值,但这两种情况并不常见),因此应该为枚举类显式定义带参数的构造器。
public enum Gender {
// 此处的枚举值必须调用对应的构造器来创建
MALE("男"), FEMALE("女");
private final String name;
// 枚举类的构造器只能使用private修饰
private Gender(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
上面程序中的枚举值实际上等同于如下两行代码:
public static final Gender MALE = new Gender("男");
public static final Gender FEMALE = new Gender("女");
实现接口的枚举类
枚举类也可以实现一个或多个接口。与普通类实现一个或多个接口完全一样,枚举类实现一个或多个接口时,也需要实现该接口所包含的方法。
public interface GenderDesc {
void info();
}
Gender
枚举类。
enum Gender implements GenderDesc {
MALE("男"), FEMALE("女");
private final String name;
private Gender(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
// 增加下面的info方法,实现GenderDesc接口必须实现的方法
@Override public void info() {
System.out.println("这是一个用于定义性别Field的枚举类");
}
}
如果由枚举类来实现接口里的方法,则每个枚举值在调用该方法时都有相同的行为方式(因为方法体完全一样)。
如果需要每个枚举值在调用该方法时呈现出不同的行为方式,则可以让每个枚举值分别来实现该方法,每个枚举值提供不同的实现方式,从而让不同的枚举值调用该方法时具有不同的行为方式。
enum Gender implements GenderDesc {
//此处的枚举值必须调用对应的构造器来创建
MALE("男") {
@Override public void info() {
System.out.println("这个枚举值代表男性");
}
}, FEMALE("女") {
@Override public void info() {
System.out.println("这个枚举值代表女性");
}
};
private final String name;
private Gender(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
当我们创建 MALE
和 FEMALE
两个枚举值时,后面又紧跟了一对花括号,这对花括号里包含了一个 info
方法定义。
花括号部分实际上就是一个类体部分,在这种情况下,当创建 MALE
、FEMALE
枚举值时,并不是直接创建 Gender
枚举类的实例,而是相当于创建 Gender
的匿名子类的实例。
相关信息
枚举类不是用 final
修饰了吗?怎么还能派生子类呢?
并不是所有的枚举类都使用了 final
修饰!非抽象的枚举类才默认使用 final
修饰。对于一个抽象的枚举类而言——只要它包含了抽象方法,它就是抽象枚举类,系统会默认使用 abstract
修饰,而不是使用 final
修饰。
编译上面的程序,可以看到生成了 Gender.class
、Gender$1.class
和 Gender$2.class
三个文件,这样的三个 class
文件正好证明了上面的结论:MALE
和 FEMALE
实际上是 Gender
匿名子类的实例,而不是 Gender
类的实例。
包含抽象方法的枚举类
假设有一个 Operation
枚举类,它的 4 个枚举值 PLUS
, MINUS
, TIMES
, DIVIDE
分别代表加、减、乘、除 4 种运算。为此,我们定义下面的 Operation
枚举类。
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;
public static void main(String[] args) {
System.out.println(Operation.PLUS.eval(3, 4));
System.out.println(Operation.MINUS.eval(5, 4));
System.out.println(Operation.TIMES.eval(5, 4));
System.out.println(Operation.DIVIDE.eval(5, 4));
}
// 为枚举类定义一个方法,用于实现不同的运算
double eval(double x, double y) {
switch (this) {
case PLUS:
return x + y;
case MINUS:
return x - y;
case TIMES:
return x * y;
case DIVIDE:
return x / y;
default:
return 0;
}
}
}
仔细观察上面的 Operation
类不难发现,实际上 PLUS
,MINUS
,TIMES
,DIVIDE
4 个值对 eval
方法各有不同的实现。
为此,我们可以采用前面介绍的方法,让它们分别为 4 个枚举值提供 eval
的实现,然后在 Operation
类中定义一个 eval
的抽象方法。
public enum Operation {
PLUS {
@Override public double eval(double x, double y) {
return x + y;
}
}, MINUS {
@Override public double eval(double x, double y) {
return x - y;
}
}, TIMES {
@Override public double eval(double x, double y) {
return x * y;
}
}, DIVIDE {
@Override public double eval(double x, double y) {
return x / y;
}
};
public static void main(String[] args) {
System.out.println(Operation.PLUS.eval(3, 4));
System.out.println(Operation.MINUS.eval(5, 4));
System.out.println(Operation.TIMES.eval(5, 4));
System.out.println(Operation.DIVIDE.eval(5, 4));
}
// 为枚举类定义一个抽象方法
// 这个抽象方法由不同的枚举值提供不同的实现
public abstract double eval(double x, double y);
}
编译上面程序会生成 5 个 class
文件,其实 Operation
对应一个 class
文件,它的 4 个匿名内部子类分别各对应一个 class
文件。
枚举类里定义抽象方法时不能使用 abstract
关键字将枚举类定义成抽象类(因为系统自动会为它添加 abstract
关键字),但因为枚举类需要显式创建枚举值,而不是作为父类,所以定义每个枚举值时必须为抽象方法提供实现,否则将出现编译错误。
对象与垃圾回收
Java 的垃圾回收是 Java 语言的重要功能之一。当程序创建对象、数组等引用类型实体时,系统都会在堆内存中为之分配一块内存区,对象就保存在这块内存区中,当这块内存不再被任何引用变量引用时,这块内存就变成垃圾,等待垃圾回收机制进行回收。垃圾回收机制具有如下特征。
- 垃圾回收机制只负责回收堆内存中的对象,不会回收任何物理资源(例如数据库连接、网络 IO 等资源)。
- 程序无法精确控制垃圾回收的运行,垃圾回收会在合适的时候进行。当对象永久性地失去引用后,系统就会在合适的时候回收它所占的内存。
- 在垃圾回收机制回收任何对象之前,总会先调用它的
finalize()
方法,该方法可能使该对象重新复活(让一个引用变量重新引用该对象),从而导致垃圾回收机制取消回收。
对象在内存中的状态
当一个对象在堆内存中运行时,根据它被引用变量所引用的状态,可以把它所处的状态分成如下三种。
可达状态:
当一个对象被创建后,若有一个以上的引用变量引用它,则这个对象在程序中处于可达状态,程序可通过引用变量来调用该对象的 Field 和方法。
可恢复状态:
如果程序中某个对象不再有任何引用变量引用它,它就进入了可恢复状态。在这种状态下,系统的垃圾回收机制准备回收该对象所占用的内存,在回收该对象之前,系统会调用所有可恢复状态对象的
finalize()
方法进行资源清理。如果系统在调用finalize()
方法时重新让一个引用变量引用该对象,则这个对象会再次变为可达状态;否则该对象将进入不可达状态。
不可达状态:
当对象与所有引用变量的关联都被切断,且系统已经调用所有对象的
finalize()
方法后依然没有使该对象变成可达状态,那么这个对象将永久性地失去引用,最后变成不可达状态。只有当一个对象处于不可达状态时,系统才会真正回收该对象所占有的资源。
下面程序简单地创建了两个字符串对象,并创建了一个引用变量依次指向两个对象。
public class StatusTranfer {
public static void main(String[] args) {
// ③
test();
}
public static void test() {
// ①
String a = "轻量级Java EE企业应用实战";
// ②
a = "疯狂Java讲义";
}
}
程序执行 test
方法的 ① 代码时,代码定义了一个 a
变量,并让该变量指向 "轻量级 Java EE 企业应用实战"
字符串,该代码执行结束后,"轻量级 Java EE 企业应用实战"
字符串对象处于可达状态。
当程序执行了 test
方法的 ② 代码后,代码再次定义了 "疯狂 Java 讲义"
字符串对象,并让 a
变量指向该对象。此时,"轻量级 Java EE 企业应用实战"
字符串对象处于可恢复状态,而 "疯狂 Java 讲义"
字符串对象处于可达状态。
一个对象可以被一个方法的局部变量引用,也可以被其他类的类变量引用,或被其他对象的实例变量引用。当某个对象被其他类的类变量引用时,只有该类被销毁后,该对象才会进入可恢复状态;当某个对象被其他对象的实例变量引用时,只有当该对象被销毁后,该对象才会进入可恢复状态。
强制垃圾回收
当一个对象失去引用后,系统何时调用它的 finalize()
方法对它进行资源清理,何时它会变成不可达状态,系统何时回收它所占有的内存,对于程序完全透明。程序只能控制一个对象何时不再被任何引用变量引用,绝不能控制它何时被回收。
程序无法精确控制 Java 垃圾回收的时机,但我们依然可以强制系统进行垃圾回收——这种强制只是通知系统进行垃圾回收,但系统是否进行垃圾回收依然不确定。大部分时候,程序强制系统垃圾回收后总会有一些效果。强制系统垃圾回收有如下两个方法。
- 调用
System
类的gc()
静态方法:System.gc()
- 调用
Runtime
对象的gc()
实例方法:Runtime.getRuntime().gc()
public class GCTest {
public static void main(String[] args) {
for (int i = 0; i < 4; i++) {
new GCTest();
}
}
public void finalize() {
System.out.println("系统正在清理GcTest对象的资源...");
}
}
编译、运行上面程序,看不到任何输出,可见直到系统退出,系统都不曾调用 GCTest
对象的 finalize()
方法。
但如果将程序修改成如下形式:
public class GCTest {
public static void main(String[] args) {
for (int i = 0; i < 4; i++) {
new GCTest();
// 下面两行代码的作用完全相同,强制系统进行垃圾回收
// System.gc();
Runtime.getRuntime().gc();
}
}
public void finalize() {
System.out.println("系统正在清理GcTest对象的资源...");
}
}
编译上面程序,使用如下命令来运行此程序:
java -verbose:gc GCTest
运行 java 命令时指定 -verbose:gc
选项,可以看到每次垃圾回收后的提示信息。
每次调用了 Runtime.getRuntime().gc()
代码后,系统垃圾回收机制还是“有所动作”的,可以看出垃圾回收之前、回收之后的内存占用对比。
虽然图中显示了程序强制垃圾回收的效果,但这种强制只是建议系统立即进行垃圾回收,系统完全有可能并不立即进行垃圾回收,垃圾回收机制也不会对程序的建议完全置之不理:垃圾回收机制会在收到通知后,尽快进行垃圾回收。
finalize 方法
在垃圾回收机制回收某个对象所占用的内存之前,通常要求程序调用适当的方法来清理资源,在没有明确指定清理资源的情况下,Java 提供了默认机制来清理该对象的资源,这个机制就是 finalize()
方法。
该方法是定义在 Object
类里的实例方法,方法原型为:
protected void finalize() throws Throwable
当 finalize()
方法返回后,对象消失,垃圾回收机制开始执行。方法原型中的 throws Throwable
表示它可以抛出任何类型的异常。
任何 Java 类都可以重写 Object
类的 finalize()
方法,在该方法中清理该对象占用的资源。如果程序终止之前始终没有进行垃圾回收,则不会调用失去引用对象的 finalize()
方法来清理资源。
垃圾回收机制何时调用对象的 finalize()
方法是完全透明的,只有当程序认为需要更多的额外内存时,垃圾回收机制才会进行垃圾回收。
因此,完全有可能出现这样一种情形:某个失去引用的对象只占用了少量内存,而且系统没有产生严重的内存需求,因此垃圾回收机制并没有试图回收该对象所占用的资源,所以该对象的 finalize()
方法也不会得到调用。
finalize()
方法具有如下 4 个特点。
- 永远不要主动调用某个对象的
finalize()
方法,该方法应交给垃圾回收机制调用。 finalize()
方法何时被调用,是否被调用具有不确定性,不要把finalize()
方法当成一定会被执行的方法。- 当 JVM 执行可恢复对象的
finalize()
方法时,可能使该对象或系统中其他对象重新变成可达状态。 - 当 JVM 执行
finalize()
方法时出现异常时,垃圾回收机制不会报告异常,程序继续执行。
提示
由于 finalize()
方法并不一定会被执行,因此如果想清理某个类里打开的资源,则不要放在 finalize()
方法中进行清理。
在 finalize
方法里复活自身。
public class FinalizeTest {
private static FinalizeTest ft = null;
public static void main(String[] args) throws Exception {
// 创建FinalizeTest对象立即进入可恢复状态
new FinalizeTest();
// ① 通知系统进行资源回收
System.gc();
// ② 让程序暂停2秒
Thread.sleep(2000);
ft.info();
}
public void info() {
System.out.println("测试资源清理的finalize方法");
}
public void finalize() {
// 让ft引用到试图回收的可恢复对象,可恢复对象重新变成可达状态
ft = this;
}
}
上面程序中定义了一个 FinalizeTest
类,重写了该类的 finalize()
方法,在该方法中把需要清理的可恢复对象重新赋给 ft
引用变量,从而让该可恢复对象重新变成可达状态。
上面程序中的 main
方法创建了一个 FinalizeTest
类的匿名对象,因为创建后没有把这个对象赋给任何引用变量,所以该对象立即进入可恢复状态。
进入可恢复状态后,系统调用 ① 行代码通知系统进行垃圾回收,② 行代码强制系统暂停 2 秒,再次调用 ft
对象的 info
方法。
编译、运行上面程序,看到 ft
的 info
方法被正常执行。
如果删除 ② 行代码,取消让系统暂停 2 秒。再次编译、运行上面程序,将会看到如下结果。
当程序执行了 System.gc();
后,系统并未立即进行垃圾回收,否则将会先调用可恢复对象的 finalize()
方法,也就会让 ft
引用到堆内存中的 FinalizeTest
对象,就不会引发空指针异常了。
当增加 ② 行代码让系统暂停 2 秒后,程序暂停了,垃圾回收机制也收到了程序的通知,因此开始进行垃圾回收,也就调用了可恢复对象的 finalize()
方法,从而让 ft
引用到堆内存中的 FinalizeTest
对象。
System
和 Runtime
类里都提供了一个 runFinalization
方法,可以强制垃圾回收机制调用系统中可恢复对象的 finalize
方法。
class FinalizeTest2 {
private static FinalizeTest2 ft = null;
public static void main(String[] args) throws Exception {
// 创建TestFinalize对象立即进入可恢复状态
new FinalizeTest2();
// 通知系统进行资源回收
System.gc();
// ① 强制垃圾回收机制调用可恢复对象的finalize方法
Runtime.getRuntime().runFinalization();
// ② System.runFinalization();
ft.info();
}
public void info() {
System.out.println("测试资源清理的finalize方法");
}
public void finalize() {
// 让ft引用到试图回收的可恢复对象,即可恢复对象重新变成可达状态
ft = this;
}
}
上面程序不再让系统暂停,而是增加了 ① 行代码,这行代码强制垃圾回收机制调用可恢复对象的 finalize()
方法。该方法执行结束后,ft
将引用到系统试图回收的 FinalizeTest2
对象,所以上面程序可以正常执行 ft
的 info
方法。上面程序中被注释的 ② 行代码与 ① 行代码有相同的功能。
对象的软、弱和虚引用
对大部分对象而言,程序里会有一个引用变量引用该对象,这是最常见的引用方式。除此之外,java.lang.ref
包下提供了 3 个类:SoftReference
、PhantomReference
和 WeakReference
,它们分别代表了系统对对象的 3 种引用方式:软引用、虚引用和弱引用。因此,Java 语言对对象的引用有如下 4 种方式。
强引用(StrongReference
)
这是 Java 程序中最常见的引用方式。程序创建一个对象,并把这个对象赋给一个引用变量,程序通过该引用变量来操作实际的对象,前面介绍的对象和数组都采用了这种强引用的方式。当一个对象被一个或一个以上的引用变量所引用时,它处于可达状态,不可能被系统垃圾回收机制回收。
软引用(SoftReference
)
软引用需要通过
SoftReference
类来实现,当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象;当系统内存空间不足时,系统可能会回收它。软引用通常用于对内存敏感的程序中。
弱引用(WeakReference
)
弱引用通过
WeakReference
类实现,弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的对象而言,当系统垃圾回收机制运行时,不管系统内存是否足够,总会回收该对象所占用的内存。当然,并不是说当一个对象只有弱引用时,它就会立即被回收——正如那些失去引用的对象一样,必须等到系统垃圾回收机制运行时才会被回收。
虚引用(PhantomReference
)
虚引用通过
PhantomReference
类实现,虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象甚至感觉不到虚引用的存在。如果一个对象只有一个虚引用时,那么它和没有引用的效果大致相同。虚引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,虚引用必须和引用队列(ReferenceQueue
)联合使用。
上面三个引用类都包含了一个 get
方法,用于获取被它们所引用的对象。
相关信息
如果需要掌握 JDK 系统类的详细用法,例如,包含哪些可用的 Field 和方法(protected
和 public
权限的 Field 和方法),以及包含哪些构造器都应该查阅 Java 提供的 API 文档。
引用队列由 java.lang.ref.ReferenceQueue
类表示,它用于保存被回收后对象的引用。当联合使用软引用、弱引用和引用队列时,系统在回收被引用的对象之后,将把被回收对象对应的引用添加到关联的引用队列中。
与软引用和弱引用不同的是,虚引用在对象被释放之前,将把它对应的虚引用添加到它关联的引用队列中,这使得可以在对象被回收之前采取行动。
软引用和弱引用可以单独使用,但虚引用不能单独使用,单独使用虚引用没有太大的意义。虚引用的主要作用就是跟踪对象被垃圾回收的状态,程序可以通过检查与虚引用关联的引用队列中是否已经包含了该虚引用,从而了解虚引用所引用的对象是否即将被回收。
弱引用所引用的对象被系统垃圾回收过程。
public class ReferenceTest {
public static void main(String[] args) throws Exception {
// 创建一个字符串对象
String str = new String("疯狂Java讲义");
// ① 创建一个弱引用,让此弱引用引用到"疯狂Java讲义"字符串
WeakReference wr = new WeakReference(str);
// ② 切断str引用和"疯狂Java讲义"字符串之间的引用
str = null;
// ③ 取出弱引用所引用的对象
System.out.println(wr.get());
// 强制垃圾回收
System.gc();
System.runFinalization();
// ④ 再次取出弱引用所引用的对象
System.out.println(wr.get());
}
}
系统内存如图:
提示
编译上面程序时会出现一个警告提示,这个警告提示是一个泛型提示。此处先不要理它。
不仅如此,上面程序创建 "疯狂 Java 讲义"
字符串对象时,不要使用 String str="疯狂Java讲义";
,否则将看不到运行效果。
因为采用 String str="疯狂Java讲义";
代码定义字符串时,系统会使用常量池来管理这个字符串直接量(会使用强引用来引用它),系统不会回收这个字符串直接量。
下面程序与上面程序基本相似,只是使用了虚引用来引用字符串对象,虚引用无法获取它引用的对象。
下面程序还将虚引用和引用队列结合使用,可以看到被虚引用所引用的对象被垃圾回收后,虚引用将被添加到引用队列中。
public class PhantomReferenceTest {
public static void main(String[] args) throws Exception {
// 创建一个字符串对象
String str = new String("疯狂Java讲义");
// 创建一个引用队列
ReferenceQueue rq = new ReferenceQueue();
// 创建一个虚引用,让此虚引用引用到"疯狂Java讲义"字符串
PhantomReference pr = new PhantomReference(str, rq);
// 切断str引用和"疯狂Java讲义"字符串之间的引用
str = null;
// 取出虚引用所引用的对象
// ①并不能通过虚引用访问被引用的对象,所以此处输出null
System.out.println(pr.get());
// 强制垃圾回收
System.gc();
System.runFinalization();
// 垃圾回收之后,虚引用将被放入引用队列中
// ②取出引用队列中最先进入队列的引用与pr进行比较
System.out.println(rq.poll() == pr);
}
}
因为系统无法通过虚引用来获得被引用的对象,所以执行 ① 处的输出语句时,程序将输出 null
(即使此时并未强制进行垃圾回收)。当程序强制垃圾回收后,只有虚引用引用的字符串对象会被垃圾回收,当被引用的对象被回收后,对应的虚引用将被添加到关联的引用队列中,因而将在 ② 代码处看到输出 true
。
使用这些引用类可以避免在程序执行期间将对象留在内存中。如果以软引用、弱引用或虚引用的方式引用对象,垃圾收集器就能够随意地释放对象。如果希望尽可能减小程序在其生命周期中所占用的内存大小时,这些引用类就很有用处。
必须指出:要使用这些特殊的引用类,就不能保留对对象的强引用;如果保留了对对象的强引用,就会浪费这些引用类所提供的任何好处。
由于垃圾回收的不确定性,当程序希望从软、弱引用中取出被引用对象时,可能这个被引用对象已经被释放了。如果程序需要使用那个被引用的对象,则必须重新创建该对象。这个过程可以采用两种风格的代码完成。
// 取出弱引用所引用的对象
obj = wr.get();
// 如果取出的对象为null
if (obj == null) {
// ①重新创建一个新的对象,再次让弱引用去引用该对象
wr = new WeakReference(recreatelt());
// ②取出弱引用所引用的对象,将其赋给obj变量
obj = wr.get();
}
// 操作obj对象
// 再次切断obj和对象之间的关联
obj = null;
另一种取出被引用对象的风格。
// 取出弱引用所引用的对象
obj = wr.get();
// 如果取出的对象为null
if (obj == null) {
// 重新创建一个新的对象,并使用强引用来引用它
obj = recreatelt();
// 取出弱引用所引用的对象,将其赋给obj变量
wr = new WeakReference(obj);
}
// 操作obj对象
// 再次切断obj和对象之间的关联
obj = null;
上面两段代码采用的都是伪码,其中 recreateIt
方法用于生成一个 obj
对象。这两段代码都是先判断 obj
对象是否已经被回收,如果已经被回收,则重新创建该对象。如果弱引用引用的对象已经被垃圾回收释放了,则重新创建该对象。
但第一段代码存在一定的问题:当 if
块执行完成后,obj
还是有可能为 null
。因为垃圾回收的不确定性,假设系统在 ① 和 ② 行代码之间进行垃圾回收,则系统会再次将 wr
所引用的对象回收,从而导致 obj
依然为 null
。第二段代码则不会存在这个问题,当 if
块执行结束后, obj
一定不为 null
。
修饰符的适用范围
Java 修饰符适用范围总表。
包访问控制符是一个特殊的修饰符,不用任何访问控制符的就是包访问控制。对于初始化块和局部成员而言,它们不能使用任何访问控制符,所以看起来像使用了包访问控制符。
strictfp
关键字的含义是 FP-strict
,也就是精确浮点的意思。在 Java 虚拟机进行浮点运算时,如果没有指定 strictfp
关键字,Java 的编译器和运行时环境在浮点运算上不一定令人满意。一旦使用了 strictfp
来修饰类、接口或者方法时,那么在所修饰的范围内 Java 的编译器和运行时环境会完全依照浮点规范 IEEE-754
来执行。因此,如果想让浮点运算更加精确,就可以使用 strictfp
关键字来修饰类、接口和方法。
native
关键字主要用于修饰一个方法,使用 native
修饰的方法类似于一个抽象方法。与抽象方法不同的是,native
方法通常采用 C 语言来实现。如果某个方法需要利用平台相关特性,或者访问系统硬件等,则可以使用 native
修饰该方法,再把该方法交给 C 去实现。一旦 Java 程序中包含了 native
方法,这个程序将失去跨平台的功能。
在表列出的所有修饰符中,4 个访问控制符是互斥的,最多只能出现其中之一。不仅如此,还有 abstract
和 final
不能同时使用,abstract
和 static
不能同时使用,abstract
和 private
不能同时使用。