6.面向对象进阶

6 篇文章 0 订阅
6 篇文章 0 订阅

这一节简单介绍一下Java的面向对象特性,主要有包装类、final类、abstract类、interface、Lambda表达式等内容.

包装类

包装类的自动装箱和拆箱

Java提供了8种基本数据类型,而这些数据类型是违反面向对象编程机制的,因为它们不支持面向对象的诸多特性.这样在面向对象编程中就可能出现一些问题,比如所有的引用类都继承了Object类型,当某个方法需要的参数是Object类型,而实际应该传入的却是像1、'a’这样的基本数据类型值时,就会发生问题.

为了解决基本数据类型不能当成Object类型变量使用的问题,Java 8以后特地为每种基本数据类型提供了包装类,如下表:

基本数据类型包装类
byteByte
shortShort
intInteger
longLong
charCharacter
floatFloat
doubleDouble
booleanBoolean

可以看出,除了int和char的包装类以外,其余包装类都是基本数据类型首字母大写即可.

包装类的作用? --很简单,包装类是一个类,一个继承Object的类型,它符合面向对象的特性,可将基本数据类型转换为引用类型.

Java支持自动装箱和自动拆箱的功能,即:

自动装箱: 将基本数据类型赋值给与其对应的包装类时,是无损转换;

自动拆箱: 将包装类赋值给与其对应的基本数据类型时,是无损转换.

下面是一个例子:

public class WrapperClassTest {
	public static void main(String[] args) {
		int i = 10;
		Integer j = i;
		i = j - 5;
		char x = 'k';
		Character y = 'J';
		y = x;
		x = (char)(y - 32);
		System.out.println(i+" "+j+" "+x+" "+y);
	}
}

当然,自动装箱和自动拆箱特性是系统自动完成的,这使得程序员可以不必理会基本数据类型和它们对应的包装类的区别,既可以将基本数据类型当做相应的包装类型来使用,也可以将包装类型当做相应的基本数据类型来使用.注意基本数据类型要和包装类型相对应,不要去强制转换不对应的基本数据类型和包装类型.

包装类和字符串的转换

另外包装类型还可以将字符串转化为基本数据类型.除Character外的包装类都提供了两个静态方法:

parseType(String str): 将String类型的str转换为Type基本数据类型;

valueOf(String str): 将字符串转化为基本数据类型.

同样的,String类中也有多个重载的valueOf(Type t)静态方法,用于将基本类型转化为字符串类型,如以下示例:

public class ToString {
	public static void main(String[] args) {
		float pi = Float.valueOf("3.14");		//String转float;
		int temp = Integer.parseInt("2080");	//String转int;
		String str1 = String.valueOf(1070);			//int转String;
		String str2 = String.valueOf(2333.2333);	//double转String;
		System.out.println("pi is : "+pi+"\n"+"temp is : "+temp);
		System.out.println("str1 is : "+str1+"\n"+"str2 is : "+str2);
	}
}

当然,直接使用基本数据类型+""也可以将基本类型转化为String类型,这里就不多说了.

可使用包装类型和基本数据类型进行比较,其原理就是将包装类型包含的数值直接拿出来作比较,如:

public class PrimitiveCompare {
	public static void main(String[] args) {
		double pi = 3.14;
		Double p_i = Double.valueOf("3.14");
		System.out.println("pi is equal to p_i : "+(pi == p_i));
	}
}

而两个包装类之间的比较,直接比较在一些情况下会出现错误(这是Java实现上的特点,具体原因可以了解一下包装类的实现),因此我们只将包装类当成连接基本数据类型和面向对象之间的桥梁而很少直接使用它们进行一些运算,因为有可能会出现异常现象.

比较基本数据类型值的大小还可以使用各大包装类都带有的静态比较方法compare(Type a,Type b)进行比较操作,用法如下:

compare(Type a,Type b):

返回-1,表示a大b小;

返回0,表示a与b相等;

返回1,表示b大a小.

特别地,当比较Boolean时,有:

true > false

public class PrimitiveCompare {
	public static void main(String[] args) {
		System.out.println(Boolean.compare(true,false));
		System.out.println(Boolean.compare(true,true));
		System.out.println(Boolean.compare(false,true));
	}
}

无符号运算功能

Java 8还增强了包装类的无符号运算功能,提供了一系列的静态方法如下:

以下方法适用于Integer/Long类型(Integer类调用时type为int,Long类调用时type为long):

  • static String toUnsignedString(type i): 将type类型对应的无符号整数转为字符串;
  • static String toUnsignedString(type i,int radix): 将type类型对应的radix进制的无符号整数转为字符串;
  • static type parseUnsignedType(String str): 将字符串转化为指定type类型的无符号整数;
  • static type parseUnsignedType(String str,int radix): 将字符串转化为指定类型的radix进制的无符号整数;
  • static int compareUnsigned(type x,type y): 比较两个type类型的x、y的无符号整数的大小;
  • static long divideUnsigned(long dividend,long divisor): 计算两个无符号整数的商;
  • static long remainderUnsigned(long dividend,long divisor): 计算两个无符号整数的余数.

以下方法适用于Byte/Short类型(Byte类调用时type为byte,Short类调用时type为short):

  • static int toUnsignedInt(type i): 将type类型转化为无符号的int;
  • static int toUnsignedLong(type i): 将type类型转化为无符号的Long.

以下是一个示例:

public class UnsignedTest{
	public static void main(String[] args) {
		byte b = -10;
		int i = Byte.toUnsignedInt(b);	//i == 246;
		System.out.println(i);
		int j = 2;
		String str1 = Integer.toUnsignedString(j,2);	//str1 == "10";
		String str2 = Integer.toUnsignedString(j);		//str2 == "2";
		System.out.println(str1+" "+str2);
		Long x = Long.parseUnsignedLong(str1);			//x = 10;
		Long y = Long.parseUnsignedLong(str1,2);		//y = 2;
		System.out.println(x+" "+y);
		System.out.println(Long.compareUnsigned(x,y));		//1;
		System.out.println(Integer.divideUnsigned(i,j));	//123;
		System.out.println(Integer.remainderUnsigned(i,j));	//0;
	}
}

无符号整数最大的特点就是,没有符号位,不能表示负数,但是能表示正数的范围是有符号整数的两倍,详情参加计算机基础知识.

类的通用方法

我们知道,Java所有的对象都是Object类的实例,那么所有的对象都可以调用Object的方法,这些方法称为通用方法.

toString()方法

toString()方法是一个对象用来"自我介绍"的方法,我们先看一个示例:

class Apple {
	private String color;
	private double weight;
	public Apple(){};
	public Apple(String color,double weight) {
		this.color = color;
		this.weight = weight;
	}
}
public class ToStringTest {
	public static void main(String[] args) {
		Apple a = new Apple();
		System.out.println(a);
		System.out.println(a.toString());
	}
}

在我的计算机上,输出了两条一模一样的语句:

Apple@5fdef03a
Apple@5fdef03a

这说明,直接打印对象和打印对象的toString()方法的返回值是一样的结果.实际上任何对象的toString()方法都是用来"自我介绍"的,这是一个Object类的实例方法,打印对象相当于打印对象调用toString()方法的返回值,而toString()方法,默认的返回值是一个字符串,Object的实例方法toString()的源码如下:

public String toString() {
    return getClass().getName() + '@' + Integer.toHexString(hashCode());
}

其中,hashCode是系统中为每一个对象分配的hash值,这个值可能在不同对象上会发生变化.

显然,系统默认的toString()方法无法起到对象的"自我介绍"作用,因此这个方法应该要被重写,如下:

class Apple {
	private String color;
	private double weight;
	public Apple(){};
	public Apple(String color,double weight) {
		this.color = color;
		this.weight = weight;
	}
	public String toString() {
		return "An Apple of "+weight+" grams, the color is "+color+".";
	}
}
public class ToStringTest {
	public static void main(String[] args) {
		Apple a = new Apple("red",350);
		System.out.println(a);
		System.out.println(a.toString());
	}
}

输出如下:

An Apple of 350.0 grams, the color is red.
An Apple of 350.0 grams, the color is red.

equals()方法

在判断两个引用变量是否相等时,==运算符通常不好用,这是因为只有当两个引用变量指向同一个对象时,==才会返回true,而不是比较引用变量所指的对象的成员值.因此==运算符只有在基本数据类型的值比较时才起正常作用,Object类中提供了equals()实例方法,用于比较两个引用变量的值,但是该方法比较的标准完全等同于==,因此实际上默认的equals()方法没有实际意义,但是,我们可以重写这个方法,来自定义比较规则,这是该方法区别于==的重要标志.

注意: String类已经重写了equals()方法,可以用equals()来判断String类的相等情况.

下面是equals()方法重写的例子:

import java.util.Objects;

class Student {
	private String studentID;
	private String studentName;
	Student(){};
	Student(String studentID,String studentName) {
		this.studentID = studentID;
		this.studentName = studentName;
	}
	public String getStudentID() {
		return studentID;
	}
	public void setStudentID(String studentID) {
		this.studentID = studentID;
	}
	public String getStudentName() {
		return studentName;
	}
	public void setStudentName(String studentName) {
		this.studentName = studentName;
	}
	//重写equals()方法;
	public boolean equals(Object obj) {	
		//this和obj是同一对象的不同引用,true;
		if(this == obj) {	
			return true;
		}
		//obj为空,false;
		if(obj == null) {	
			return false;
		}
		//不是同类对象,false;
		if(getClass() != obj.getClass()) {	
			return false;
		}
		Student tmp = (Student)obj;
		//自定义比较规则,学号相等的学生视作同一学生;
		return Objects.equals(getStudentID(),tmp.getStudentID());
	}
}

public class EqualsTest {
	public static void main(String[] args) {
		Student stu1 = new Student("201736025030","Aaron");
		Student stu2 = new Student("201736025030","Mahone");
		if(stu1.equals(stu2)) {
			System.out.println(true);
		} 
		else {
			System.out.println(false);
		}
	}
}

在上面的例子中,我们重写了equals()方法,并使用了Object.equals()静态方法来完成最后的比较,先来看看它们的源码:

  • Object: equals(Object obj):
public boolean equals(Object obj) {
    return (this == obj);
}
  • Objects.equals(Object a, Object b):
public static boolean equals(Object a, Object b) {
    return (a == b) || (a != null && a.equals(b));
}

其中Objects类是Object的子类,在java.util包下,使用时需要导入包:

import java.util.Objects;

上述两个方法,看似非常接近,但是实际上相差很大,一个是实例方法,另一个是静态方法,且参数列表不一样,Objects.equals()可用来避免一些问题,如学号相等的学生视作同一学生这个规则,有人会这么写:

return getStudentID().equals(tmp.getStudentID());

这么写相当于是String类调用重写的Objects: equals()实例方法,传入另一个String,而问题就在于,如果调用该方法的String类的实例的值是null,那么就会抛出异常,从而使得比较操作失败.因此我们用:

return Objects.equals(getStudentID(),tmp.getStudentID());

参见Objects.equals()的源码,这样就不必考虑null的问题了,因为方法主调者是类而不是实例对象,且Objects.equals()里面有对传入的类是否为null的判断.

最后总结一下equals()方法的重写步骤:

  1. 先判断传入对象和当前对象是否是同一个对象的不同引用,直接用==判断;
  2. 再判断传入对象是否为空;
  3. 再判断传入对象和当前对象是否为同类对象;
  4. 最后进行对象成员的比较,若是引用类型成员,用Objects.equals(),基本数据类型成员用==判断.

将一些条件写在一起,可以压缩一下码量:

//重写equals()方法;
public boolean equals(Object obj) {	
	//this和obj是同一对象的不同引用,true;
	if(this == obj) {	
		return true;
	}
	//obj为空 || 不是同类对象,false;
	if(obj == null || getClass() != obj.getClass()) {	
		return false;
	}
	Student tmp = (Student)obj;
	//自定义比较规则,学号相等的学生视作同一学生;
	return condition;
}

类成员

深入理解类成员

上一篇学习了类成员,我们知道类由实例成员和类成员组成.Java的类中只能有以下5种成员:

成员变量、方法、构造器、初始化块、内部类(包括枚举、接口).

而这些成员又可以分为类成员和实例成员,其中使用static关键字修饰的成员是类成员,关于类成员,要明确两点:

  • 类成员属于类,不属于对象(类的实例);
  • 对象可以访问类成员,但是实际上还是类访问,这是沿袭了C++的一种方便使用的机制.

类成员的好处不必多说,那就是在保护封装性的前提下实现数据共享,因为一个类虽然可能有多个对象,但是类型就只有这一个,那么通过这个类的对象访问类成员时,无论是哪一个对象访问,实际上都是访问的同一块内存区域,这样就能实现多个对象共享的一块公共数据区,那么就在保证各对象内部完整性的前提下实现了数据共享.

我们都知道,null对象是不可以访问实例成员的,它会抛出NullPointerException异常,这是因为null对象是未初始化的对象,没有对应的内存空间,也没有所属的实例成员存在,自然就无法访问实例成员.请看如下程序:

class Test {
	public static String test() {
		return "Class func test";
	}
}

public class NullObjectTest {
	public static void main(String[] args) {
		Test t = null;
		System.out.println(t.test());
	}
}

程序运行结果:

Class func test

可以看到,值为null的对象可以访问类成员.是的,这是因为null虽然没有实际内存空间,但是对象访问类成员是通过系统自动转化为类进行访问的,而不是null对象访问的,因此只要是该类的对象,不管值是否为null,都可以访问类对象.

最后还有一点需要注意的地方: 类成员不可访问实例成员!这也非常好理解,因为类成员是独立于实例成员的,在初始化时一般都是类成员先于实例成员初始化,如果允许类成员(如类方法)访问实例成员的话,就可能会出现访问未初始化的实例成员的错误.

单例类

通常情况下,我们将构造器声明为public访问权限,这样就可以无限制地创建该类的实例对象.但是有时候对于一些特殊的类,我们不希望创建多个对象,比如窗口管理器类、SPOOLing设备类等,这些类的对象在系统中只有一个便可以了.则我们有:

若一个类始终只能创建一个实例,则这个类被称为单例类.

根据单例类的定义,不难总结出它的实现思路:

  • 防止其它类实例随意调用单例类的构造器来创建实例,那么该单例类的所有构造器都应该使用private修饰;
  • 必须有一个方法,可以调用private构造器,由于系统中单例类实例的数量是0或1,则声明单例类实例时系统必然不存在一个单例类实例调用该方法,因此该方法应该是类调用的,属于类方法(static);
  • 此外,该类必须缓存已经创建的实例,否则就无法判断是否只创建了一个实例,这样的缓存显然要开销一个类实例对象作为成员变量,该成员可以被上述的类方法访问,因此也是一个类成员(static).

下面是一个例子:

class Singleton {
	private String str;
	private static Singleton instance;
	private Singleton(String str) {
		this.str = str;
		System.out.println("A singleton Object has been generated!");
	}
	//构造器调用入口;
	public static Singleton getObject(String str) {
		//单例类实例不存在,则构造一个;
		if(instance == null) {
			instance = new Singleton(str);
		}
		//否则返回保存过的实例;
		return instance;
	}
	public void setStr(String str) {
		this.str = str;
	}
	public String getStr() {
		return str;
	}
}

public class SingletonTest {
	public static void main(String[] args) {
		Singleton s1 = Singleton.getObject("Hello World!");
		Singleton s2 = Singleton.getObject("Hello!");
		System.out.println(s1.getStr());
		System.out.println(s2.getStr());
	}
}

运行结果如下:

A singleton Object has been generated!
Hello World!
Hello World!

很显然,虽然s1和s2都是用了Singleton.getObject(String)希望得到一个对象,但是根据二者的getStr()得知,s2的对象实际上就是s1的对象,它们只是两个不同的引用变量,指向同一个对象而已.使用这种形式构造单例类,则除了类内缓存单例类实例的对象和构造器调用方法使用static修饰以外,其它的成员都可以是实例成员.

final修饰符

final关键字类似C++里的const关键字,没错,const在C++中指的是常变量,而Java的final也是一个含义,指的是一个变量一旦获得初值则其值不可再发生改变(不可再被赋值),这里的变量是广义上的概念,Java的final关键字可以修饰类、变量、方法.

final成员变量

在声明类时,若未做其它处理,则成员变量会被系统赋予默认值,如0、’\u0000’、false或null等,若这些值在final的修饰下成为不可改变的值,则显然这些成员变量就根本没有存在的意义.因此,Java作了如下规定:

final修饰的成员变量必须由程序员显式地指定初值.

而成员变量包括类变量和实例变量两种,这两种变量在final修饰下初始化的方式如下:

  • 实例变量: 1️⃣可在声明变量时就指定初值,2️⃣也可以在普通初始化块中指定初值,3️⃣或者在构造器中指定初值,三选一.
  • 类变量: 1️⃣可在声明变量时就指定初值,2️⃣也可以在静态初始化块中指定初值,二选一.

以下是一个简单的例子:

class FinalVariable {
	private final int a = 10;
	private final int b;
	private final int c;
	private static final int d = 5;
	private static final int e;
	{
		b = 2;
	}
	static {
		e = 15;
	}
	public FinalVariable() {
		c = 7;
		System.out.println("a = "+a+" b = "+b+" c = "+c);
		System.out.println("d = "+d+" e = "+e);
	}
}

public class FinalVariableTest {
	public static void main(String[] args) {
		FinalVariable fv = new FinalVariable();
	}
}

输出结果如下:

a = 10 b = 2 c = 7
d = 5 e = 15

值得注意的是,Java不允许直接访问未赋初值的final成员变量,却可以通过方法来访问,这应该是Java的一个设计缺陷:

class FinalVariable {
	private final int a = 10;
	private final int b;
	private final int c;
	private static final int d = 5;
	private static final int e;
	{
		b = 2;
	}
	static {
		e = 15;
	}
	public int getC() {
		return c;
	}
	public FinalVariable() {
		// System.out.println(c);
		System.out.println("a = "+a+" b = "+b+" c = "+getC());
		System.out.println("d = "+d+" e = "+e);
		c = 7;
	}
}

public class FinalVariableTest {
	public static void main(String[] args) {
		FinalVariable fv = new FinalVariable();
	}
}

上述程序运行结果如下:

a = 10 b = 2 c = 0
d = 5 e = 15

上述程序的第17行若取消注释,则无法通过编译,会提示错误:

FinalVariableTest.java:17: 错误: 可能尚未初始化变量c
		System.out.println(c);
		                   ^

但是在上述例子中,却使用了getC()方法访问未赋初值的成员变量c,而c被自动赋初值为0,这样的c是没有意义的,因此我们要尽量避免使用未初始化的final成员变量.

final局部变量

Java的局部变量在声明时,系统不会对其赋初值,需要程序员显式地为其赋初值,则使用final修饰局部变量时,既可以在声明局部变量就赋初值,也可以再后来为其赋初值,不管如何赋初值,都只能赋值一次.

此时的情况比较简单,我们就写一个简单例子:

class FinalLocalVariable {
	public void test(final int a) {
		// a = 5;
		final int c;
		//定义b时就初始化;
		final int b = 10;
		System.out.println("a = " + a);
		System.out.println("a - b = " + (a-b));
		//用到c之前再初始化;
		c = 7;
		System.out.println("b + c = " + (b+c));
	}
}

public class FinalLocalVariableTest {
	public static void main(String[] args) {
		FinalLocalVariable flb = new FinalLocalVariable();
		flb.test(20);
	}
}

其它的不必多说,只要注意第3行的注释,若取消注释则会出现错误,这是因为final形参变量的值由传入的值决定,不能显式指定.

final限定的引用类型

前面讲的final运算符的例子,都是final限定基本类型,在final限定基本类型时,被限定的变量值不能发生变化,这点很好理解.但对于引用类型变量而言,它保存的仅仅是一个引用,指向一片具体的内存空间,final只保证该引用类型变量所指向的地址不变,而地址的内容(值),可以发生改变.下面是一个简单的例子:

class Person {
	private String name;
	public Person(){}
	public Person(String name) {
		this.name = name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getName() {
		return name;
	}
}

public class FinalReferenctTest {
	public static void main(String[] args) {
		final Person p = new Person("Aaron");
		System.out.println(p.getName());
		Person s = new Person("Alex");
		// p = s;
		// 改变引用变量p所指对象的成员值,允许;
		p.setName("Alex");
		System.out.println(p.getName());
		final int[] intReference1 = {1,2,3,4,5};
		int [] intReference2 = {6,7,8,9,10};
		// intReference1 = intReference2;
		// 对final引用变量所指对象的成员赋值,允许;
		for(int i = 0; i < intReference1.length; i++) {
			intReference1[i] = intReference2[i];
		}
		for(int i = 0; i < intReference1.length; i++) {
			System.out.print(intReference1[i] + " ");
		}
	}
}

运行结果如下:

Aaron
Alex
6 7 8 9 10

在上述例子中,若是取消第20/26行的注释,则会出现编译错误,提示不可为final引用变量赋值,但我们却可以改变这个引用变量内部实例成员的值.换言之,Java的final引用变量只保证该变量仅指向一个对象且指向不可发生改变(地址是对象的唯一标识),但不能保证该对象的实例成员值(地址所在内存块中所存的数据)不发生改变.

使用final进行宏替换

final修饰符最常用的一个情景就是宏替换,相当于C里的#define关键字,此时的final变量不再是变量,就是一个直接量.

要让变量被系统当成一个直接量处理,需要满足以下条件:

  1. 变量以final修饰;
  2. final变量在定义时就赋予初值;
  3. 编译时即可确定final变量的初值.

解释一下第3点: 编译时即可确定值指的是为变量赋值时使用的是直接量、基本类型直接量的算术表达式、字符串直接量的连接操作等,这些直接量/表达式不依托其它的普通变量/方法获得值,因此编译时就能确定值,若赋值号(=)右边的表达式使用了其它的普通变量、调用了方法等,则表达式一般都不会是编译时就能确定结果的,此时被赋值的变量就不是编译即可确定值的变量.

public class FinalReplaceTest {
	public static void main(String[] args) {
		final int a = 10;
		final double b = 10 / 3;
		final String c = "str";
		final String d = "str" + 10;
		final String e = "str" + String.valueOf(10);
		System.out.println(a);
		System.out.println(b);
		System.out.println(c);
		System.out.println(d);
		System.out.println(e);
	}
}

以上程序的输出结果自然不必多说,其中三条输出语句在系统中其实被转换成如下形式执行了:

System.out.println(10);
System.out.println(3.0);
System.out.println("str");
System.out.println("str10");

为什么e不被当做直接量处理呢?因为字符串d实际上调用了String.valueOf()方法,再和"str"连接,因此编译时无法确定d的值.上面的程序这就相当于用直接量宏替换了final变量,但是这并不是本小节的重点,重点是: 这么做的目的是什么?

Java会使用常量池来管理曾使用过的字符串直接量,比如有:

String a = “str”;

String b = “str”;

在执行完String a = "str"以后,常量池将会将"str"保存起来,当执行String b = "str"后,会让b直接指向常量池中已有的"str"字符串直接量,而不是重新为b分配直接量内存空间,这样就可以达到节省内存的作用.

比如下面的例子:

public class ConstPoolTest { 
	public static void main(String[] args) {
		String s1 = "HelloWorld";
		String s2 = "Hello" + "World";
		System.out.println(s1 == s2);
		String s3 = "Hello";
		String s4 = "World";
		String s5 = s3 + s4;
		System.out.println(s5 == s1);
	}
}

程序运行结果如下:

true
false

分析常量池的变化,可得:

  1. 在s1完成初始化之后,常量池就有了"HelloWorld";
  2. s2使用两个String类直接量连接赋值,这个值在编译时即可确定,即相当于用"HelloWorld"直接量赋值给s2,则s2直接指向常量池中的"HelloWorld",且将"Hello"和"World"存入常量池(因为s2初始化时用过);
  3. 使用直接量"Hello"为s3赋值,则s3指向常量池中的"Hello",但s3是一个普通String实例,是变量;
  4. 使用直接量"World"为s4赋值,则s4指向常量池中的"World",但s4是一个普通String实例,是变量;
  5. s5不是直接量的连接,而是两个String实例变量的连接,则s5无法在编译时就确定值,也是一个变量,所以s5会占用变量的内存空间,而不是指向常量池的"HelloWord"(即使它们的值相同);

通过分析,我们知道s1 == s2 != s5,因为s1和s2是同一个对象的不同引用,而s5只是恰好值相等而已,最终还是指向不同对象.

要让s1 == s5,其实非常简单,只需将原来的程序第6~9行修改为如下即可:

final String s3 = "Hello";
final String s4 = "World";
String s5 = s3 + s4;

这样其实是用了final变量的宏替换,s5相当于两个字符串直接量的连接,自然也是一个直接量"HelloWorld".

final方法

final方法就是用final修饰的方法,这类方法不可被重写.若不想让子类重写父类的方法,则该方法应该使用final修饰.

例如Java的Object类就有一个final方法: getClass(),这个方法不希望任何类重写,同样也有非final方法,如toString()和equals()方法等,它们是允许重写的方法.关于final方法,注意以下几点:

  • 若父类的final方法是public、protected访问权限的,子类不可重写父类的final方法,但是可以重载;
  • 若父类的final方法是private访问权限的,子类不可见父类的final方法,即使定义一个同样的方法,也不是重写或重载.

下面是一个例子:

class FinalMethod {
	public final void test1() {
		System.out.println("test1");
	}
	private final void test2() {
		System.out.println("test2");
	}
}

class TestClass extends FinalMethod {
	// public void test1() {}
	// 重载final方法test1();
	public void test1(String str) {
		super.test1();
		System.out.println(str);
	}
	// 既不是重写,也不是重载,而是子类新的方法;
	public void test2() {
		super.test1();
	}
}

public class FinalMethodTest {
	public static void main(String[] args) {
		TestClass tc = new TestClass();
		tc.test1("Hello World");
		tc.test2();
	}
}

以上例子中,若取消第11行的注释,则会出现编译错误,因为不可以重写父类的final方法,而第12行的重载final方法却是可以的,而第13行的方法test2(),看似和重写父类的final方法test2()很像,但是由于父类的test2()是private访问权限,对子类是隐藏的,因此子类的test2()既不是重载也不是重写父类方法,而就是子类全新的一个方法.

final类

final修饰的类不允许有子类,如java.lang.Math就是一个final类,它不允许有子类.

通常,final类适用于多层派生,当派生进行到某一个程度时不希望再进行派生了,就把最底层的派生类设为final类,如:

class TestClass {}

class SubTestClass extends TestClass {}

class SubSubTestClass extends SubTestClass {}

final class SubSubSubTestClass extends SubSubTestClass {}

// class SubSubSubSubTestClass extends SubSubSubTestClass {}

public class FinalClassTest {
	public static void main(String[] args) {
		SubSubSubTestClass sssstc = new SubSubSubTestClass();
	}
}

比较懒,就不去实现这些类了,第9行的语句若取消注释则会报错,原因上面已经说过了.

不可变类

不可变类意味着该类实例创建以后,其实例的值不可改变,它比final修饰的引用变量更加严苛,final引用变量只是该引用变量无法指向其他的同类实例,但是它所指实例的值可以发生改变.Java提供的8个包装类和java.lang.String都是不可变类,当创建实例以后,实例的值无法改变,如:

Double d = new Double(6.5);
String str = new String("Hello");

上面的程序创建了一个Double对象和一个String对象,并且调用了构造器,传入参数初始化,那么Double类和String类肯定要提供实例变量来存这两个参数,但程序无法修改这个实例变量的值,因为Double类和String类没有提供修改它们的方法.

若要创建自定义的不可变类,可遵循以下规则:

  • 使用private和final修饰符来修饰该类的成员变量;
  • 保护好该类的(如果有)内部类引用实例变量;
  • 提供带参构造器,用以初始化类中的成员变量;
  • 仅为成员变量提供getter()而不提供setter()方法[final修饰的变量也没必要提供setter()];
  • 若有必要,重写Object类下的hashCode()和equals()方法.

额外说明一下hashCode()和equals():

equals(): 使用对象的关键成员的值来判断对象相等,除此之外,还应保证两个用equals()判等的对象的hashCode()也相等.

hashCode(): HashSet类根据对象的hashCode()值决定它存在HashSet中的哪个位置,一般为了兼顾HashSet类,我们认为当两个对象使用equals()判断相等,且hashCode()也相等时,这两个对象就是冗余的,可以认为是一个.

例如: String类的equals()是根据其内部的字符串序列计算得到是否相等的,而String类的hashCode()也是根据其内部的字符串序列计算得到的,这样拥有同一个字符串序列成员值的String类对象就是相等的对象.

下面是一个String类的示例:

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);
		// 二者equals()相等;
		System.out.println(str1.equals(str2));
		// 二者的hashCode相同;
		System.out.println(str1.hashCode());
		System.out.println(str2.hashCode());
	}
}

运行结果如下:

false
true
69609650
69609650

下面自己实现一个简单的不可变类:

import java.util.Objects;

class Book {
	private final String bookName;
	private final String authorName;
	public Book(String bookName,String authorName) {
		this.bookName = bookName;
		this.authorName = authorName;
	}
	public void info() {
		System.out.println("《" + bookName + "》, By " + authorName);
	}
	public boolean equals(Object obj) {
		if(this == obj) {
			return true;
		}
		if(obj == null || getClass() != obj.getClass()) {
			return false;
		}
		Book tmp = (Book)obj;
		return Objects.equals(bookName,tmp.bookName) &&
			   		Objects.equals(authorName,tmp.authorName) &&
			   		hashCode() == tmp.hashCode();
	}
	public int hashCode() {
		return bookName.hashCode() + authorName.hashCode() * 63;
	}
}

public class ImmutableClassTest {
	public static void main(String[] args) {
		Book b = new Book("疯狂Java讲义","李刚");
		Book c = new Book("疯狂Java讲义","李刚");
		b.info();
		c.info();
		System.out.println(b.equals(c));
		System.out.println(b.hashCode());
		System.out.println(c.hashCode());
	}
}

运行结果如下:

《疯狂Java讲义》, By 李刚
《疯狂Java讲义》, By 李刚
true
274304448
274304448

显然Book类的所有实例都是final类型的,因此Book是一个不可变类型,只能通过初始化来赋值.

不难想到,如果打算把某个类写成一个不可变类,但是其内部实例成员又包含一个内部类,则不管该内部类是否用final修饰,都会出现问题 -> 这个内部类是值可变的!那么这就破坏了整个不可变类设计的初衷,因此这是一种失败的设计,请看示例:

import java.util.Objects;

class Name {
	private String firstname;
	private String surname;
	public Name() {}
	public Name(String firstname,String surname) {
		this.firstname = firstname;
		this.surname = surname;
	}
	public void setFirstname(String firstname) {
		this.firstname = firstname;
	}
	public String getFirstname() {
		return firstname;
	}
	public void setSurname(String surname) {
		this.surname = surname;
	}
	public String getSurname() {
		return surname;
	}
	public boolean equals(Object obj) {
		if(this == obj) {
			return false;
		}
		if(obj == null || getClass() != obj.getClass()) {
			return false;
		}
		Name tmp = (Name)obj;
		return Objects.equals(firstname,tmp.firstname) && 
			   Objects.equals(surname,tmp.surname);
	}
	public int hashCode() {
		return (firstname.hashCode() + surname.hashCode()) * 32;
	}
}

class Person {
	private final Name name;
	private final int age;
	private final String gender;
	Person(Name name,int age,String gender) {
		this.name = name;
		this.age = age;
		this.gender = gender;
	}
	public Name getName() {
		return name;
	}
	public int getAge() {
		return age;
	}
	public String getGender() {
		return gender;
	}
	public boolean equals(Object obj) {
		if(this == obj) {
			return true;
		}
		if(obj == null || getClass() != obj.getClass()) {
			return false;
		}
		Person tmp = (Person)obj;
		return Objects.equals(name,tmp.name) && age == tmp.age &&
			   Objects.equals(gender,tmp.gender) && hashCode() == tmp.hashCode();
	}
	public int hashCode() {
		return (name.hashCode() + age + gender.hashCode()) * 32;
	}
}

public class FailedImmutableTest {
	public static void main(String[] args) {
		Name name = new Name("Aaron","Danny");
		Person p = new Person(name,20,"Male");
		System.out.print(p.getName().getFirstname() + " " + p.getName().getSurname() + " "); 
		System.out.println(p.getAge() + " " + p.getGender());
		// 通过一开始传入Person的Name实例来改变Person.Name.surname;
		name.setSurname("Mahone");
		// 甚至可从Person中直接取出name,来修改Person.Name.firstname;
		Name temp = p.getName();
		temp.setFirstname("Alex");
		System.out.print(p.getName().getFirstname() + " " + p.getName().getSurname() + " "); 
		System.out.println(p.getAge() + " " + p.getGender());
	}
}

运行结果如下:

Aaron Danny 20 Male
Alex Mahone 20 Male

显然原本打算设计成不可变类的Person类现在是一个可变类了,这是不能允许的.

为了保持不可变类的特性,必须将其内部的引用实例变量保护好.于是将Person类的两个方法改写如下:

Person(Name name,int age,String gender) {
	// 通过新建一个临时对象,摆脱了Person.name和参数对象name的关联;
	this.name = new Name(name.getFirstname(),name.getSurname());
	this.age = age;
	this.gender = gender;
}
public Name getName() {
	// 返回一个匿名对象,而不是返回Person中的name对象,外部引用变量就无法访问到Person中的name;
	return new Name(name.getFirstname(),name.getSurname());
}

通过上述写法,就可以保证: 虽然Person里面的name是个可变实例变量,但是Person将它和外界沟通的方法都做了相应的处理,相当于将name和外界隔绝开来,这样就不会受到外界的影响了,因此也可以看作不可变实例变量了.

总结一下包含可变成员的不可变类,就是:

可变成员在赋值/被return到类外时,都通过中间的匿名对象来完成,不要直接和参数/return语句打交道.

缓存实例的不可变类

不可变类的确定性,导致它可以被很多个对象共享,此时就可能存在非常多对象使用一个不可变类对象的情况,而如果能用缓存将这个不可变类对象缓存下来的话,就可以节省非常多的资源.这也是缓存机制的最大优势.

下面的例子就是一个缓存实例的不可变类:

import java.util.Objects;

class CacheImmutable {
	private static int MAX_SIZE = 10;
	private static CacheImmutable[] cache = new CacheImmutable[MAX_SIZE];
	// 缓冲区当前位置指针;
	private static int pos = 0;
	private final String name;
	public CacheImmutable(String name) {
		this.name = name;
	}
	public String getName() {
		return name;
	}
	public static CacheImmutable valueOf(String name) {
		// 遍历缓冲区;
		for(int i = 0; i < MAX_SIZE; i++) {
			// 已缓冲,则直接使用即可;
			if(cache[i] != null && Objects.equals(cache[i].getName(),name)) {
				return cache[i];
			}
		}
		// 否则需要创建对象放入缓冲区;
		cache[pos] = new CacheImmutable(name);
		pos = (pos + 1) % MAX_SIZE;
		return cache[(pos - 1 + MAX_SIZE) % MAX_SIZE];
	}
	public boolean equals(Object obj) {
		if(this == obj) {
			return true;
		}
		if(obj == null || getClass() != obj.getClass()) {
			return false;
		}
		CacheImmutable tmp = (CacheImmutable)obj;
		return Objects.equals(name,tmp.name) && hashCode() == tmp.hashCode();
	}
	public int hashCode() {
		return name.hashCode() * 99;
	}
}

public class CacheImmutableTest {
	public static void main(String[] args) {
		CacheImmutable c1 = CacheImmutable.valueOf("Hello");
		CacheImmutable c2 = CacheImmutable.valueOf("Hello");
		System.out.println(c1 == c2);
	}
}

以上就是用了一个CacheImmutable类来缓存一些不可变类的对象,缓冲区大小为MAX_SIZE = 10,采用的是循环队列的存放方式,即先进先出,存满再重头替换的策略.由于缓冲区、缓冲区指针、缓冲区大小都是整个CacheImmutable类的全局指标,因此都设成static类变量的形式,而其他的成员都是实例对象的成员.不难看出,使用valueOf()方法构造新对象时,才会使用到缓冲区,而使用缓冲区时,无需构造对象的条件是需要使用的不可变类的对象恰好已经存在缓冲区里了.而我们可以发现,CacheImmutable类的构造器是public的访问权限,这就意味着我们可以使用new关键字构造一个该类的对象,但是这种构造方法显然不使用缓冲区.我们可以隐藏CacheImmutable的构造器,将其设置为private即可,是否隐藏需要根据具体情况而定.

像这种,用不同方式调用构造器,就可以决定对象是否缓存的机制,其实Java里早就出现过了,比如Integer类,使用new构造器调用构造器则不进行缓存,而用Integer.valueOf()构造器则可以缓存下来,但是由于整数实在是太多了,Java的限制是Integer只缓存取值范围在[-128,128)之间的数,避免浪费不必要的资源,下面是一个例子:

public class IntegerTest {
	public static void main(String[] args) {
		Integer int1 = new Integer(10);
		Integer int2 = Integer.valueOf(10);
		Integer int3 = Integer.valueOf(10);
		System.out.println(int1 == int2);
		System.out.println(int2 == int3);
		Integer int4 = Integer.valueOf(300);
		Integer int5 = Integer.valueOf(300);
		System.out.println(int4 == int5);
	}
}

运行结果如下:

false
true
false

显然int3是使用的int2在Integer类中缓存的那个不可变类对象,因此int2和int3指向同一个对象,而int1直接调用了new构造器,并不会缓存一个对象,int4和int5由于数值大过缓存范围,因此也是直接生成对象而不进行缓存.

关于Integer类的构造器,有一点值得说一下,new直接构造的构造器在Java 9以上已经标记为过时,编译会有提示如下:

注: IntegerTest.java使用或覆盖了已过时的 API。
注: 有关详细信息, 请使用 -Xlint:deprecation 重新编译。

注意: 不只是Integer,Java的8个包装类的构造器,在Java 9中若是通过new关键字直接调用都会提示是"过时"的,以后使用的时候注意使用valueOf()方法进行构造效率会好一些.

最后要注意一下缓存的利弊,Java包装类建议直接使用valueOf()构造对象这没什么好说的,Java会有优化的缓存机制,自动进行缓存/不缓存,对于自定义的可缓存类来说,只有那些出现次数稍多的不可变类对象缓存下来,而出现次数少的对象(比如一次性的),就不要缓存下来了,以免降低系统性能.

抽象类

当我们写某一个类时,通常会为这个类提供一系列的方法来描述这个类的行为特征,这些方法一般都是具体的.而有时,我们在写一个类时,十分明确这个类一定会被继承,比如shape类,表示形状,我们不能直接声明一个shapeName就不管了,而是应该考虑到不同的形状有不同的属性,如三角形有三条边而四边形有四条边,因此shape注定就会是一个"不具体"的类,我们需要通过派生去充实这个类,这样的话shape类就只能模糊地知道子类应该有什么行为特征,而不知道如何具体实现这些行为特征.譬如求周长的方法: calPerimeter(),计算面积的方法: calArea()等,这些都要具体情况具体分析,抽象类就是用来解决这些问题的.

Q: 既然不知道如何实现,那能不能不去管这个抽象方法呢?根本不在父类中定义不行么?

A:

这是一个不好的设计思路,当多态发生时,父类对象调用方法总是呈现子类方法的行为特征,若不在父类中声明抽象方法,则以后多态发生的条件下父类可能无法调用一些关键方法,因为该方法是子类特有的,这样就降低了程序的灵活性.

抽象方法和抽象类

抽象类和抽象方法必须用abstract关键字修饰,抽象方法就是上述的,知道有什么作用,但是不知道如何实现的方法,抽象方法只能定义在抽象类中,抽象类可以不包含抽象方法.抽象方法和抽象类的规则如下:

➡️ 抽象方法必须使用abstract修饰符定义,抽象方法不能有方法体,只能有方法声明;

➡️ 抽象类不能被实例化,不能用new关键字构造对象,即使抽象类不含抽象方法,它也不能实例化对象;

➡️ 抽象类可以包含普通类的所有5种成分,另外还可以包含抽象方法,抽象类的构造器不能实例化对象,主要用于被子类调用;

➡️ 含抽象方法的类,其所有的抽象方法必须在派生时被子类覆盖(重写).

定义抽象方法只需在普通方法上加上abstract修饰符,再将普通方法的方法体去掉即可,在参数列表结束的位置加上分号";"即可.

首先我们定义一个抽象类shape,带有抽象方法calAera():

abstract class Shape {
	private String type;
	// 构造器是用于被子类调用的,Shape无法实例化对象;
	protected Shape() {}
	protected Shape(String type) {
		this.type = type;
	}
	protected void setType(String type) {
		this.type = type;
	}
	protected String getType() {
		return type;
	}
	// 没有方法体的抽象方法;
	public abstract double calArea();
}

这是一个非常简单的类,很显然抽象方法没有方法体指的就是没有方法后面的大括号部分,现在我们看看它的两个子类:

class Circle extends Shape {
	private double radius;
	public Circle(double radius) {
		if(radius < 0) {
			System.out.println("圆形半径应该 >= 0!");
			return;
		}
		setType("Circle");
		this.radius = radius;
	}
	public double calArea() {
		return 3.14 * radius * radius;
	}
}

class Triangle extends Shape {
	private double side1;
	private double side2;
	private double side3;
	public Triangle(double side1,double side2,double side3) {
		if(side1 + side2 <= side3 || side1 + side3 <= side2 || side2 + side3 <= side1) {
			System.out.println("三角形两边之和要大于第三边!");
			return;
		}
		setType("Triangle");
		this.side1 = side1;
		this.side2 = side2;
		this.side3 = side3;
	}
	public double calArea() {
		double p = (side1 + side2 + side3) / 2.0;
		return Math.sqrt(p * (p-side1) * (p-side2) * (p-side3));
	}
}

上面实现了一个圆形类和三角形类,也是非常简单的类,注意一下这两个类必须覆盖抽象父类的所有抽象方法,在这里就是calArea()这个方法,这样就可以表现出它们独有的行为特性了.最后是一个简单的测试用的主函数:

public class AbstractClassTest {
	public static void main(String[] args) {
		Circle c = new Circle(10.0);
		Triangle t = new Triangle(3.0,4.0,5.0);
		System.out.println(c.getType() + ": " + c.calArea());
		System.out.println(t.getType() + ": " + t.calArea());
	}
}

程序输出结果如下:

Circle: 314.0
Triangle: 6.0

这么看下来,抽象类并没有非常难以理解,只不过需要掌握一定的类继承/派生相关的知识,因为抽象类只能通过子类来使用,而不能直接实例化对象.通过抽象类和抽象方法,可以更好地发挥多态的优势,提高程序的灵活性.

关于abstract关键字的一些使用限制和注意事项,在这里说一下,具体如下:

↪️ abstract不能修饰局部变量,成员变量和构造器,只能修饰普通实例方法,抽象类的构造器是普通构造器;

↪️ abstract不能修饰类方法,因为abstract方法没有方法体,通过类调用一个abstract类方法调用时会出现问题;

↪️ abstract虽然不能修饰类方法,但是abstract和static关键字并非无法共存,比如abstract可以修饰static内部类;

↪️ abstract修饰的方法必须被子类重写,因此abstract修饰的方法不能是private访问权限,否则对该方法会子类不可见.

↪️ final修饰的类不可被继承,final修饰的方法不可被重写,因此final和abstract永远不可能同时使用在一个类或方法上.

抽象类的作用

抽象类不能创建实例,只能被当做父类来继承,因此抽象类是从多个具体类中抽象出来的父类,具有更高层次的抽象.从多个具有相同特征的类中抽象出一个抽象类,以这个抽象类为模板类来设计子类,避免子类设计的随意性.抽象类会提供多个子类的通用方法,并把一个或多个方法留给子类去实现,这就是一种模板模式,模板模式是最常用和最简单的设计模式之一.

下面的例子写了一个抽象父类,该父类的普通方法依赖于一个抽象方法,而抽象方法则推迟到子类中去实现:

abstract class SpeedMeter {
	private double turnRate;
	protected SpeedMeter(double turnRate) {
		this.turnRate = turnRate;
	}
	protected void setTurnRate(double turnRate) {
		this.turnRate = turnRate;
	}
	protected double getTurnRate() {
		return turnRate;
	}
	protected abstract double calGirth();
	protected double getSpeed() {
		// 速度 = 车轮周长 * 转速;
		return calGirth() * turnRate;
	}
}

然后是这个类的子类:

class CarSpeedMeter extends SpeedMeter {
	private double radius;
	public CarSpeedMeter(double turnRate) {
		super(turnRate);
	}
	public CarSpeedMeter(double turnRate,double radius) {
		super(turnRate);
		this.radius = radius;
	}
	public double calGirth() {
		return 2 * Math.PI * radius;
	}
}

最后是测试用的主方法:

public class SpeedMeterTest {
	public static void main(String[] args) {
		CarSpeedMeter csm = new CarSpeedMeter(100.0,5.0);
		System.out.println(csm.getSpeed());
	}
}

SpeedMeter()类提供了速度表的通用算法,但是具体实现需要推迟到子类CarSpeedMeter中去,这是典型的模板模式.

模板模式使用非常简单,在面向对象程序设计中很常用,下面是这种模式的一些规则:

▶️ 抽象父类可以只定义需要使用的某些方法,把不能实现的部分抽象成抽象方法,留给子类去实现;

▶️ 父类中可能包含需要调用其他系列方法的方法,这些被调用的方法既可以被父类实现,也可以被子类实现.建议父类中提供一个通用算法,具体的行为特征留给子类具体分析和实现.

接口

抽象类是从多个类中抽取出来的模板类,如果将这种抽象进行地更彻底,则可以提炼出一种更加特殊的"抽象类"–接口(interface).接口更像是一种类的公共行为规范,接口通常定义一组公用方法.Java 9对接口进行了改进,允许提供默认方法和类方法及其实现,增加了一种私有方法,且允许提供私有方法的实现.

接口的概念

很多人都听说过接口,比如PCI接口、NVME接口,大多数人会以为接口是主板上的硬件插槽,但是其实并不是这样,接口实际上是一种协议,实际上的插槽只是这种协议的物理实现,为了统一才做成一个样子,实际上完全可以提供不一样的物理实现,但这样就会给硬件厂商带来很多兼容性问题,因此才做成了同一个接口用同一种物理实现的方式.硬件厂商提供接口协议就是为了硬件设计的规范性和统一性,主板厂商不需要考虑其余设备的内部实现,只要该设备遵循一定的协议,便可以通过各类总线和其他设备进行通信.

Java的接口显然也是这样,用来规范化类的设计,只要多个类使用了某种特定的接口进行实现,则就一定会有相似的行为准则.软件厂商采用接口来降低各模块之间的耦合性,为系统提供更好的可扩展性和可维护性.

Java 9中接口的定义

接口使用的是interface关键字,其定义的关键语句如下:

[修饰符] interface interfaceName extends interface1,interface2,...,interfaceN {
    0个到多个常量定义...
    0个到多个抽象方法定义...
    0个到多个内部类、接口、枚举定义...
    0个到多个私有方法、默认方法或类方法定义...
}

关于这个定义规范,请注意以下几点(基于Java 8以上的版本):

▶️ 接口修饰符只能是public或省略,省略则默认按照包访问控制符处理,即和接口在同一个包下的类才可以访问接口;

▶️ 接口可以多继承,一个接口可有多个直接父类接口,但是接口只能继承自接口,不能继承自类,类可以继承自接口;

▶️ 接口内不能有初始化块/构造器,因为接口是一种规范的定义,不是一种类型的定义;

▶️ 接口可以包含成员变量(只能是静态常量(static final))、方法(抽象实例方法/类方法/默认方法/私有方法)、内部类(包括内部接口/枚举)定义;

▶️ 接口的成员(除私有方法)只能是public访问权限,因为接口是公共行为规范,当然也可以省略访问权限,此时和public没有区别;

▶️ 私有方法主要作用是作为工具方法为默认方法或类方法提供支持.私有方法不能用default修饰,但可用static修饰,也就是说私有方法既可以是实例方法也可以是类方法;

▶️ 接口中的静态常量是接口相关的,不管是否使用public static final修饰符,系统总是会自动为其加上这些修饰符,由于接口中没有构造器和初始化块,则这些静态常量只能在定义时就赋予初值;

▶️ 接口中的实例方法一定是抽象方法,不管是否使用public abstract修饰,系统总是会自动为其加上这些修饰符,实例方法不能有方法体,而类方法、默认方法、私有方法都必须有方法体;

▶️ 接口中的内部类、内部接口、内部枚举都默认采用public static修饰,不管是否指定,系统总是会自动为其加上这些修饰符.

下面定义一个接口:

public interface Output {
	// 接口定义的变量只能是常量,下一句等价于:public static final int MAX_CACHE_LINE = 50;
	int MAX_CACHE_LINE = 50;
	// 接口定义的实例方法只能是静态抽象方法,下一句等价于:public abstract void out();
	void out();
	public abstract void getData(String msg);
	// 接口中的默认方法,使用default修饰;
	public default void print(String... msgs) {
		for(String msg : msgs) {
			System.out.println(msg);
		}
	}
	public default void test() {
		System.out.println("默认方法test()");
	}
	// 接口中定义类方法;
	public static String staticTest() {
		return "接口的类方法";
	}
	// 接口的私有方法;
	private void foo() {
		System.out.println("foo私有方法");
	}
	// 静态私有方法;
	private static void bar() {
		System.out.println("bar静态私有方法");
	}
}

上面定义了一个Output接口,接口中包含了一个成员变量: MAX_CACHE_LINE.此外,接口还定义了两个普通方法: getData()和out().这就定义了Output接口的规范: 只要某个类能获取数据,并将数据输出,该类就是一个输出设备,至于这个类如何实现,就不是在接口阶段应该考虑的问题了.

Java 8允许在接口中定义默认方法,使用default关键字修饰,且不能与static同时修饰,默认方法可以实现方法体.其实默认方法就是实例方法,但是接口中实例方法必须是抽象方法,不允许有方法体.因此Java 8引入了默认方法,目的是为了在实例方法的基础上实现方法体.Java 8还允许在接口中定义类方法,类方法不能用default修饰.

Java 9增加了带方法体的私有方法,实际上是为了解决Java 8的历史遗留问题.因为在Java 8中,两个(或多个)默认方法(或类方法)如果重用了同样的模块,那么这个模块就应该被单独提出来作为工具方法,而工具方法应该是私有的,因此Java 9将其引入.

接口的继承

接口的继承和类继承不一样,接口支持多继承,类仅支持单继承,如下:

class Temp {
	public String str = "100";
	public int a = 19;
}

interface interfaceA {
	int A = 5;
	interface bc {
		void test0();
		int a = 10;
		String str = "10";
	}
	public static void testAC() {
		System.out.println("接口A的类方法");
	}
	void testA();
	public default void testA_1() {
		System.out.println("interfaceA");
	}
}

interface interfaceB {
	int B = 10;
	Temp t = new Temp();
	void testB();
	public default void testB_1() {
		System.out.println("interfaceB");
	}
}

interface interfaceC extends interfaceA,interfaceB {
	int C = 15;
	void testC();
}

class Test implements interfaceC {
	public void testA(){}
	public void testB(){}
	public void testC() {
		System.out.println("interfaceC");
	}
}

public class InterfaceExtendTest {
	public static void main(String[] args) {
		interfaceA.testAC();
		System.out.println(interfaceC.bc.str + " " + interfaceC.bc.a);
		System.out.println(interfaceC.t.str + " " + interfaceC.t.a);
		System.out.println(interfaceC.A + " " + interfaceC.B + " " + interfaceC.C);
		Test t = new Test();
		t.testA_1();
		t.testB_1();
		t.testC();
	}
}

程序运行结果如下:

接口A的类方法
10 10
100 19
5 10 15
interfaceA
interfaceB
interfaceC

接口的继承将会使子类接口获得父类接口除类方法、私有方法以外的一切成员.上述程序中使用内部接口,来验证内部接口会被继承,使用类实现了接口,来验证接口继承了默认方法,具体的用类实现接口请看下节–接口的实现.

接口的实现

接口不可创建实例,但可以用来声明引用变量,当接口用于声明引用变量时,该引用变量必须指向接口的实现类,类可以实现接口,从而获得接口的常量(成员变量)和方法(包括抽象方法和默认方法),并要求覆盖(重写)接口的所有抽象方法(实现这个接口),一般来说,接口的用途有如下三种:

  • 定义变量,也可用于强制类型转换;
  • 被其他类实现;
  • 调用接口中定义的的成员成员(包括内部类、内部接口、内部枚举).

注意类是"实现"一个接口,并非"继承"一个接口,因此使用的关键字是implements而不是extends,且一个类可以实现多个接口,这是Java为弥补单继承机制的不足而进行的补救措施,用法如下:

[修饰符] class className extends supClassName implements 接口1,接口2,... {
	类体;
}

一个类可以继承一个父类并实现多个接口,extends关键字一定在implements关键字之前,类实现一个接口,相当于该类继承一个彻底抽象的特殊类(除默认方法外,其余继承下来的方法都是抽象方法).下面是一个类实现接口的例子:

interface Product {
	int getProduceTime();
}

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/C++");
		o.getData("Python");
		o.out();
		o.getData("Html/CSS");
		o.getData("Ruby");
		o.out();
		// 调用Output接口中的默认方法;
		o.print("1","2","3");
		o.test();
		// 创建一个Printer对象,当成Product用;
		Product p = new Printer();
		System.out.println(p.getProduceTime());
		// 所有接口类型的引用变量都可以直接赋值给Object类型的变量;
		Object obj = p;
	}
}

其中Output接口已经在上面的例子中定义过了,这里接直接拿来使用.上面的程序可以看出,Printer类实现了Output和Product接口,因此Printer类对象既可赋值给Output,也可赋值给Product变量,仿佛Printer类既是Output的子类,又是Product的子类一样,这就是Java用接口来模拟的多继承.

接口本身不能显式继承任何类,但任何接口的引用对象都可以赋值给Object类的对象,原因很简单: 因为接口不可实例化对象,则接口的引用对象实际上引用的是它的子类的一个对象,这个对象一定是Object的子类对象,于是接口引用对象赋值给Object类的引用对象实际上是经过隐式中间转换的过程得到的,用于赋值的对象其实是接口的类型子类的对象.

接口和抽象类的比较

接口和抽象类很像,它们都具有如下特征:

▶️ 接口和抽象类不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承;

▶️ 接口和抽象类都可包含抽象方法,实现接口/继承抽象类的类都必须实现这些抽象方法;

但同时,接口和抽象类差别也是巨大的,主要体现在它们的设计目的上,如下:

接口:

作为系统和外界交互的窗口,接口体现的是一种设计规范,对接口的实现者而言,接口要求的是开发者要向外界提供什么样的服务;对接口的调用者而言,接口规定了调用者可以调用哪些服务,如何调用这些服务.当一个应用程序使用接口时,接口是多个模块之间的耦合标准,当多个应用程序使用接口时,接口是多个应用程序之间的通信标准.

抽象类:

抽象类作为系统中多个子类的共同父类,它体现的是一种模板式设计.抽象类可以被当成系统设计过程中的中间产品,这个中间产品已经实现了系统的部分功能,但是离最终产品要达到的效果就需要在模块中补充,这种补充有多种的实现途径,因此这些工作应当在不同的子类中进行.

除此之外,接口和抽象类在用法上也存在如下区别:

▶️ 接口只可包含实例(抽象)方法、静态方法、默认方法和私有方法,不可为实例方法提供实现,抽象类可以包含抽象方法和实例方法,且可以为实例方法提供实现;

▶️ 接口只能定义静态常量,不能定义普通成员变量,抽象类则二者都可以定义;

▶️ 接口不包含构造器,抽象类可以包含构造器,但是抽象类的构造器是给子类调用的,而非直接实例化对象;

▶️ 接口里不能包含初始化块,抽象类可以包含初始化块;

▶️ 一个类最多只能有一个直接父类,包括抽象类;但是一个类可以实现多个接口,以此弥补Java单继承的不足.

面向接口编程

接口可以让规范和实现分离,降低程序模块之间的耦合性,提高系统的可扩展性和可维护性.基于这种原则,很多软件架构设计理论都提倡"面向接口"编程,而不是面向实现类编程,希望以此来降低系统的耦合度,下面介绍两个接口应用场景:

  1. 简单工厂模式

假设程序中Computer类需要一个输出设备,选择如下: 直接让Computer组合一个Printer 或 让Computer类组合一个Output接口.

若是选择和Printer直接结合,那么假设系统中不止Computer一个类使用输出设备,而是有10000个类使用,此时在维护系统时,要修改Printer的结构,就需要相应去修改10000个类的结构,这显然是不合理的.

而选择和Output接口组合,再使用Output接口连接上Printer,则修改Printer结构的时候,只需要修改Output接口的结构,即修改一个"输出标准"即可,这样只需要修改一个类和一个接口,从而省去了修改10000个类的工作量.

下面是Computer类的实现:

class Computer {
	private Output out;
	// 这里的out实际上是实现out接口的对象;
	public Computer(Output out) {
		this.out = out;
	}
	// 定义模拟获取字符串输入的方法;
	public void keyIn(String msg) {
		out.getData(msg);
	}
	// 定义模拟打印的方法;
	public void print() {
		out.out();
	}
}

上述Computer类已经完全和Printer分离,Computer不负责创建Output对象,系统提供一个Output工厂类创建Output对象:

class OutputFactory {
	// 生产Output子类的对象;
	public Output getOutput() {
		// Printer实现了Output接口;
		return new Printer();
	}
}

在该工厂类OutputFactory中包含一个getOutput()方法,该方法返回一个Output实例,实际上就是实现了Output接口的类的对象.因此该工厂类具体制造什么样的对象可以自定义,只要该对象实现了Output接口即可.下面是Printer类的实现:

class Printer 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]);
			System.arraycopy(printData,1,printData,0,--dataNum);
		}
	}
	public void getData(String msg) {
		if(dataNum >= MAX_CACHE_LINE * 2) {
			System.out.println("输出队列满,添加打印任务失败");
		}
		else {
			printData[printData++] = msg;
		}
	}
}

然后是一个测试用的主方法:

public class PrinterTest {
	public static void main(String[] args) {
		Computer c = new Computer(OutputFactory.getOutput());
		c.keyIn("Hello World");
		c.keyIn("I don't like Java");
		c.print();
		c.print();
	}
}

其中Output接口已经在上文实现过了,这个程序的运行结果如下:

打印机正打印: Hello World
打印机正打印: I don't like Java

上面介绍的就是一种被称为"简单工厂"的设计模式,设计模式是对特定问题的一种惯性思维: 解决某些问题在设计程序上大同小异,我们就把这些问题的程序设计方法归纳为一个设计模式.

  1. 命令模式

想象一下这样的场景: 某个方法需要完成某一个行为,但这个行为的具体实现无法确定,必须要等到执行该方法时才可以确定.这样的方法可以类比到Linux命令,比如rm命令:

rm 文件名 
rm -r 目录名

多指定一个参数,就完成了从删除文件到删除目录的行为方式的变化,-r代表–recursive,指定删除时在目录中进行递归操作,而我们在进行程序设计时,也可以这么考虑:

不但在方法参数中给出普通数据(如对象/变量之类),还在参数中给出方法的"行为方式".

某些编程语言(Ruby)中,允许传入一个代码块作为参数,在Java 8以后的Java里,增加了Lambda表达式来支持代码块参数.

于是对于上述的需求,传入代码块来控制方法的行为特征是很好的解决方式,可以考虑定义一个Command接口,接口中定义方法的"行为方式"(这些行为方式也是方法),下面是Command的定义:

interface Command {
	// 定义process方法,用于封装"行为方式";
	void process(int[] target);
}

在Command接口里定义了一个方法,以后只需要使用类实现接口并覆盖方法就可以有具体的行为方式,下面是对数组target的处理类,这个处理类的对象是target,处理行为因实现Command的类不同而有区别:

class ProcessArray {
	public void process(int[] target, Command cmd) {
		cmd.process(target);
	}
}

然后就是Command的两种实现:

// 逐个打印数组元素;
class PrintCommand implements Command {
	public void process(int[] target) {
		for(int i : target) {
			System.out.print(i + " ");
		}
		System.out.println();
	}
}

// 求数组所有元素的和,并打印这个和;
class SumCommand implements Command {
	public void process(int[] target) {
		int sum = 0;
		for(int i : target) {
			sum += i;
		}
		System.out.println("The sum of array is: " + sum);
	}
}

通过一个测试类,来测试上述的模块:

class CommandTest {
	public static void main(String[] args) {
		ProcessArray pa = new ProcessArray();
		int[] array = {1,2,3,4,5};
		pa.process(array,new PrintCommand());
		pa.process(array,new SumCommand());
	}
}

程序运行结果如下:

1 2 3 4 5 
The sum of array is: 15

可以看出,这种处理对象和"行为方式"(处理方法)分离的做法,其实就是把处理对象和处理方法都作为参数传入另一个方法处理,其实质是将处理对象传给了不同的"行为方式"所指代的方法(一个参数传给另一个参数指向的方法),从而产生多种处理结果,这种设计模式叫做命令模式,用接口来实现处理方法的抽象是非常好的一个设计思路.

内部类

将一个类(A)的定义放在另一个类(B)中,这样的类(A)就叫做内部类(或嵌套类),类(B)叫做外部类(或宿主类),内部类有以下作用:

▶️ 内部内提供了更好的封装,把内部类隐藏在外部类之内,不允许同一个包中的其他类访问内部类.

▶️ 内部类成员可访问外部类的私有数据,因为内部类在外部类中,和私有数据之间可以互相访问,但外部类不可以访问内部类的实现细节,如成员变量等.

▶️ 可以使用匿名内部类创建仅使用一次的类,这样有利于保护数据,因为无法直接访问匿名内部类,因此无法对数据进行修改.

内部类和外部类的定义几乎一致,仅有两个区别如下:

  1. 内部类可以使用private、protected、static修饰符,外部类不可以;
  2. 非静态(static)内部类不能拥有静态成员.

多数时候,内部类是作为内部成员定义的,此时内部类和其他内部成员变量类似,但是也有局部内部类和匿名内部类,他们并不是类成员.成员内部类分两种,静态内部类(static)和非静态内部类,局部内部类则是定义在方法里的内部类.

非静态内部类

下面实现一个非静态内部类:

class Car {
	private String brand;
	public Car() {}
	public Car(String brand) {
		this.brand = brand;
	}
	private class Engine {
		private String model;
		public Engine(String model) {
			this.model = model;
		}
		public void setModel(String model) {
			this.model = model;
		}
		public String getModel() {
			return model;
		}
		public void info() {
			// 直接访问外部类的private变量brand;
			System.out.println("A " + brand + "'s car with the engine model of: " + model);
		}
	}
	void info(String model) {
		Engine e = new Engine(model);
		e.info();
	}
}

public class CarTest {
	public static void main(String[] args) {
		Car c = new Car("Bugatti Veyron");
		c.info("W16");
	}
}

程序输出结果如下:

A Bugatti Veyron's car with the engine model of: W16

可以看出,非静态内部类只能在外部类的内部实例化对象,且非静态内部类可以访问外部类的私有成员,和外部类的使用并无很大区别,此外还有一点需要注意,在编译完这个程序后,我们发现生成了3个类,如下图:

可以看出,Java程序在编译时,会将内部类编译成如下名称:

OuterClass$InnerClass: 表示InnerClass是OuterClass的内部类.

内部类之所以能访问外部类的静态方法,是因为系统在内部类中自动缓存了一个当前外部类的对象,如下图:

这样的话,当内部类的方法使用一个变量是时,它会现在方法局部寻找该变量,若不存在,则在内部类中找该变量,若还是不存在,则在外部类中找局部变量,若还是不存在,则报错: 找不到该变量.因此,如果内部类成员变量、外部类成员变量与内部类方法的局部变量重名的话,可通过使用this外部类类名.this作为限定来区分,如以下程序:

public class DiscernVariable {
	private String prop = "外部类的实例变量";
	private class InClass {
		private String prop = "内部类的实例变量";
		private String temp = "temp";
		public void info() {
			String prop = "局部变量";
			System.out.println(prop);
			System.out.println(this.prop);
			System.out.println(DiscernVariable.this.prop);
		}
	}
	public void test() {
		// 不可通过外部类访问内部类实现细节;
		// System.out.println(temp);
		InClass in = new InClass();
		in.info();
	}
	public static void main(String[] args) {
		new DiscernVariable().test();
	}
}

程序运行结果如下:

局部变量
内部类的实例变量
外部类的实例变量

要注意,若将源程序第15行注释去掉,将会出现编译错误:

DiscernVariable.java:14: 错误: 找不到符号
		System.out.println(temp);
		                   ^
  符号:   变量 temp
  位置: 类 DiscernVariable
1 个错误

内部类可以访问外部类的成员,但外部类不可访问内部类的实现细节!只有内部类实例才可以调用内部类方法.

另外,根据静态成员不能访问非静态成员的原则,外部类的静态成员(初始化块/方法等)不可以直接使用非静态内部类,且Java中不允许在非静态内部类中定义静态成员.

非静态内部类对象和外部类对象的关系:

非静态内部类对象必须寄生在外部类的对象里,而外部类不一定有非静态内部类对象寄生其中.换句话说,外部类对象不能直接使用非静态内部类成员,因为不一定存在一个这样的非静态内部类对象寄生在其上;但是非静态内部类可以直接使用外部类的成员,因为它一定寄生在一个已经存在的外部类对象中了.

静态内部类

如果用static修饰某个内部类,则这个内部类就属于外部类本身,而不属于外部类的某个对象.这样的内部类被称为类内部类,有的地方也称其为静态内部类.静态内部类可含有静态成员,也可含有非静态成员.

按照静态成员不能访问非静态成员的原则,静态内部类的所有成员都不允许访问外部类的实例成员,只能访问外部类的静态成员.

下面是一个例子:

public class StaticInnerClassTest {
	private int prop1 = 5;
	private static int prop2 = 9;
	private static class StaticInnerClass {
		// 静态内部类可含有静态成员;
		private static int age;
		public static void accessOuterProp() {
			// 取消注释后下一句有错,静态内部类不能访问外部类实例成员;
			// System.out.println(prop1);
			System.out.println(prop2);
		}
		public int getAge() {
			return age;
		}
	}
	public void test() {
		// 必须有内部类对象,才能访问内部类实例成员;
		StaticInnerClass sic = new StaticInnerClass();
		System.out.println(sic.getAge() + " " + prop1);
	}
	public static void main(String[] args) {
		StaticInnerClassTest.StaticInnerClass.accessOuterProp();
		StaticInnerClassTest scit = new StaticInnerClassTest();
		scit.test();
	}
}

通过上面的例子,我们知道外部类依然不可以直接使用静态内部类的成员,但是可以通过类名调用静态内部类的静态成员,也可以使用静态内部类对象来调用静态内部类的实例成员.

此外,Java还允许在接口中定义内部类,由于接口中所有变量默认使用public static修饰,因此接口内部类只能是静态内部类.

静态内部类对象和外部类对象的关系:

静态内部类是属于类的而非属于对象,静态内部类不寄生在任何外部类对象中,而是寄生在外部类这个类中.当静态内部类对象存在时,并不一定有一个外部类对象存在,反之亦然.静态内部类只持有外部类类的引用,而不持有外部类对象的引用,所以应该限制静态内部类对外部类实例成员的访问,同时也限制外部类实例对象对静态内部类的访问.

使用内部类

定义类的主要目的就是定义变量、创建实例、作为父类被继承等,内部类也是一样,但使用内部类定义变量和创建实例则与外部类有所区别,下面分三种情况讨论一下这些差异.

  1. 在外部类内部使用内部类

通过前面的例子可以看出,在外部类内部使用内部类,和平常使用普通外部类没什么区别,一样可以直接通过内部类类名调用类成员,通过new关键字实例化内部类对象来调用实例方法.唯一的区别是,在没有内部类实例/不使用内部类类名的情况下,外部类无法直接使用内部类的任何成员,而内部类可以无条件地直接使用外部类的成员,当然,静态内部类不可访问外部类实例成员.

内部类也可以定义子类,和平常的子类定义也没有什么区别,各种成员的访问遵循内部类和外部类、父类和子类之间的规则即可.

  1. 在外部类以外使用非静态内部类

若想在外部类以外使用内部类(不论静态与否),则内部类的访问控制权限不能是private,剩下的修饰符如: 省略访问控制权限、protected和public则可以在对应的访问权限内发挥作用,其作用范围不再赘述.

在外部类以外的地方定义内部类(不论静态与否),其语法格式如下:

(PackageName.)OuterClass.InnerClass varName;

也就是说,外部使用内部类,类名至少要是外部类.内部类这种形式,若外部类有包名,则应该把包名加上.

由于非静态内部类必须寄生在外部类对象里,因此创建内部类之前,必须先创建外部类对象,可以按照如下方案处理:

OuterClass outerInstance = new OuterClass(args);
InnerClass varName = outerInstance.new InnerClass(args);

// 或将外部类匿名,来实例化内部类对象;
InnerClass varName = new OuterClass(args).new InnerClass(args);

下面的例子简单地展示了,如何在在外部类以外使用非静态内部类:

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("测试信息");
		/*
		 * 上述代码可改成以下三行;
		 * Out.In in;
		 * Out out = new Out();
		 * in = new out.new In("测试信息");
		 */
	}
}

从以上程序可以看出,在外部类以外使用非静态内部类,非静态内部类的构造器必须通过外部类对象来调用,该外部类对象可以是一个普通对象,也可以是一个匿名对象.

当创建一个子类时,子类构造器总会调用父类构造器,因此在创建一个非静态内部类的子类时,要保证该子类可以调用非静态内部类的构造器,则这个子类显然需要一个外部类对象,才能调用其父类(非静态内部类)的构造器,下面是个简单的例子:

public class SubClass extends Out.In {
	// 显示定义SubClass的构造器;
	public SubClass(Out out) {
		// 传入的out对象显示调用In的构造器;
		out.super("Hello");
	}
	public static void main(String[] args) {
		SubClass sc = new SubClass(new Out());
	}
}

上面的例子看起来可能会很奇怪,但是仔细一想就会发现: out是外部类对象,Out.In是非静态内部类,SubClass继承Out.In,则SubClass需要一个Out类的对象out来调用其父类的构造器,因此SubClass的参数写的没问题,那么最奇怪的就是out.super()这个用法,实际上,super关键字在一个子类中是一个指向匿名父类的指针,可以理解为父类对象的this指针,那么实际上这句话就是:

out.new In("Hello");

但是真的可以这么写么?实际上不行,这么写相当于新建了一个匿名的Out.In对象,而与那个在SubClass中默认就有的匿名父类毫无关系,在继承中,只有super指针能当做指向匿名父类的指针使用,一定要使用super关键字来调用父类的构造器,不管继承关系有多么的奇怪,层次数有多么的多,只需要想想构造父类的普通对象如何调用构造器,再将构造普通父类对象的语句中的new 父类名部分换成super即可,但是在理解上可以像上面这样,按照普通父类对象构造的原理去理解.

  1. 在外部类以外使用静态内部类

因为静态内部类是和外部类类相关的,因此在外部类外创建静态内部类对象时无需创建外部类对象,在外部类外创建静态内部类的语法如下:

new OuterClass.InnerClass(args);

下面是一个简单例子:

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();
		/*
		 * 上述代码可改成以下两行;
		 * StaticOut.StaticIn in in;
		 * in = StaticOut.StaticIn();
		 */
	}
}

就像非静态内部类一样,静态内部类的继承,完整类名也是OuterClass.InnerClass,如:

public class StaticSubClass extends StaticOut.StaticIn {
    // 类体;
}

可见静态内部类和外部类的关系就像外部类是静态内部类的命名空间一样,也就可以当成一个包来使用,由于静态内部类在使用上比非静态内部类简单许多,因此程序设计上使用内部类时应该优先考虑使用静态内部类.

Q: 既然内部类是外部类成员,那么是否可以通过外部类继承,用子类的同名内部类覆盖掉内部类呢?

A:

显然是不可以的,因为内部类,无论是否是静态内部类,都会和外部类有联系,譬如静态/非静态内部类在继承的时候,使用的类名就是OuterClass.InnerClass这样的形式,表示内部类和外部类不仅仅是寄生关系如此简单,内部类将外部类作为一个命名空间使用.子类虽可定义和外部类同名的子类内部类,但是这两个内部类永远都不可能是同一个内部类,因为它们的命名空间,也就是直接外部类不是一个,因此此时并不构成覆盖,也不可能覆盖父类的内部类.

局部内部类

在方法中定义的内部类,和局部变量一样,被称为局部内部类.局部内部类仅在该方法中有效,局部内部类不能在外部类的方法以外的地方使用,因此局部内部类不可声明为static修饰.

下面是一个局部类及其继承的实现:

public class LocalInnerClass {
	public static void main(String[] args) {
		// 局部内部类;
		class InnerBase {
			int a = 10;
			void test() {}
		}
		// 局部内部类的子类;
		class InnerSub extends InnerBase {
			public int a;
			public int b;
			public void test() {}
		}
		InnerSub is = new InnerSub();
		is.b = 10;
		System.out.println(is.a + " " + is.b);
	}
}

上面有一个非常有意思的点,那就是局部内部类的成员默认使用的访问控制符是public,从子类使用public覆盖父类的默认访问控制符的成员变量和方法就可以看出来,说明父类的默认访问控制符优先级 ≥ \ge public,则只能是public本身了.

另外再注意一下局部内部类编译生成的.class文件的命名,如下:

可以看出表明局部内部类的文件遵循以下命名规则:

OuterClass$NInnerClass.class

其中N代表数字,局部内部类比内部类命名多了一个数字,这是因为同一个类里不可能出现两个同名内部类,而同一个类里却可以出现两个以上同名的局部内部类,它们分散在各个方法中,因此有必要加以区分,这里就是用了一个数字进行区分.

注: 定义类的目的是为了使用这个类,比如实例化对象、定义变量、派生子类等,而局部内部类的使用范围非常局限,仅在包含它的方法中有效,因此在实际开发中一般极其少见使用局部内部类的场景,这是一个很"鸡肋"的语法.

Java 8的改进匿名内部类

匿名内部类适用于实例化那些只使用一次的类的对象,创建匿名内部类时会立即创建一个该类的对象,这个类定义立即消失,匿名内部类不能重复使用.匿名内部类的实现语法如下:

new 实现接口() | 父类构造器(args) {
    // 匿名内部类的类体;
}

由此可见,匿名内部类必须实现一个接口/承接一个父类,但也只能实现一个接口/承接一个父类.

匿名内部类还有以下两条规则需要遵守:

▶️ 匿名内部类不能是抽象类,因为抽象类无法创建对象;

▶️ 匿名内部类不能定义构造器,因为没有类名,所以无法调用构造器,因此不能定义构造器,只能使用初始化块来初始化对象.

下面是一个实现了接口的匿名内部类:

interface Product {
	public double getPrice();
	public String getName();
}

public class AnonymousTest {
	public void test(Product p) {
		System.out.println("At the price of $" + p.getPrice() + ", A " + p.getName() + " has been bought.");
	}
	public static void main(String[] args) {
		AnonymousTest at = new AnonymousTest();
		at.test(new Product() {
			public double getPrice() {
				return 499.0;
			}
			public String getName() {
				return "Ryzen 9 3900X";
			}
		});
	}
}

因为test()方法需要一个Product的对象,而Product不能实例化对象,只能被类实现,则此时通过直接调用Product的构造器,再重写它的抽象方法,就相当于实例化了一个已经实现Product接口的对象,该对象没有名称,定义完之后即无法显示访问了.且匿名内部类的定义不需要class关键字,只需要写出该类的实现即可,简单来说就是,匿名内部类必须覆盖其实现接口/承接父类的所有抽象方法,上述程序匿名内部类的使用实质上就是下面的一段程序:

class AnonymousProduct implements Product {
	public double getPrice() {
		return 499.0;
	}
	public String getName() {
		return "Ryzen 9 3900X";
	}
}

at.test(new AnonymousProduct());

显然,第一种匿名内部类的写法更为简洁.同时我们注意到,匿名内部类对象由于可以在一个类中出现多次,而每出现一次都相当于定义了一个新的内部类,它的命名规范为:

OuterClass$N.class

匿名内部类的命名依赖于创建它的外部类(因为内部类没有类名),N代表数字,因为外部类中可出现多个匿名内部类,如下图:

当通过实现接口创建匿名内部类时,由于接口中不含有构造器,因此调用new关键字构造内部类对象时,只能使用一个无参的构造器,即:

new 实现接口();

这样的形式,这个new调用的构造器相当于是系统默认给定的,只能是一个无参构造器,任何其他形式都会导致错误.

当通过继承父类创建匿名内部类时,由于父类中可以有构造器,因此可以使用这些构造器,new后面的构造器可以带参数,如下形式:

new 父类构造器(args);

下面是通过继承抽象父类来创建匿名对象的例子:

abstract class Device {
	private String name;
	public Device(){}
	public Device(String name) {
		this.name = name;
	}
	public abstract double getPrice();
	public void setName(String name) {
		this.name = name;
	}
	public String getName() {
		return name;
	}
}

public class AnonymousInner {
	public void test(Device d) {
		System.out.println("Each " + d.getName() + " costs $" + d.getPrice() + ".");
	}
	public static void main(String[] args) {
		AnonymousInner ai = new AnonymousInner();
		// 调用有参构造器创建匿名内部类;
		ai.test(new Device("AMD Radeon RX 5700 XT") {
			public double getPrice() {
				return 399.99;
			}
		});
		// 调用无参构造器创建匿名内部类;
		ai.test(new Device() {
			// 初始化块;
			{
				super.setName("Nitendo Switch Lite");
				System.out.println("匿名内部类初始化块");
			}
			// 实现父类抽象方法;
			public double getPrice() {
				return 199.99;
			}
		});
	}
}

程序运行结果如下:

Each AMD Radeon RX 5700 XT costs $399.99.
匿名内部类初始化块
Each Nitendo Switch Lite costs $199.99.

上面的程序定义了一个抽象父类Device,有两个构造器: 有参和无参构造器,使用两个匿名内部类分别使用这两个构造器构造对象,且匿名内部类必须覆盖父类所有的抽象方法.要注意匿名内部类不但可以覆盖实现接口/父类的抽象方法,必要时还可以重写其他方法,如实例方法等.

Java 8对(匿名)内部类的改进:

Java 8之前,一切被局部内部类、匿名内部类访问的局部变量都必须加上final修饰符,Java 8取消了这个限制,且对Java 8以后的匿名内部类做了改进: 被匿名内部类访问的局部变量,会自动在编译时具有final"属性",即就算该局部变量没有final修饰,一旦它被匿名内部类访问,就相当与用了final修饰了,这个特性称为"effectively final",也就是被匿名内部类访问的局部变量,可以用fianl修饰,也可不用,但是最后都必须当做final处理————即一次赋值,不可改变.

interface A {
	void test();
}

public class Atest {
	public static void main(String[] args) {
		int age = 8;
		A a = new A() {
			public void test() {
				// Java 8之前,下面的语句会报错: age必须使用final修饰;
				// Java 8之后,下面的语句没有问题;
				System.out.println(age);
				// age = 2;
			}
		};
		a.test();
		// age = 7;
	}
}

如上面的程序,若只取消注释第13行,则会提示如下错误:

Atest.java:13: 错误: 从内部类引用的本地变量必须是最终变量或实际上的最终变量
				age = 2;
				^
1 个错误

因为第12行已经在匿名内部类中访问过局部变量了,因此13行不应该改动局部变量的值.

若只取消注释第17行,则会提示如下错误:

Atest.java:12: 错误: 从内部类引用的本地变量必须是最终变量或实际上的最终变量
				System.out.println(age);
				                   ^
1 个错误

此时却提示是12行的错误了,其实仔细想想,在一个方法中改变一个非final局部变量的值,是没有问题的,但是一旦这个值被匿名内部类访问,就不可以改动了,此时编译器不认为是这个方法的问题,相反会认为是匿名内部类的调用写出了问题.

Java 8新增的Lambda表达式

Lambda表达式支持将代码块作为方法参数,允许使用更简洁的代码来创建只有一个抽象方法的接口(这种接口被称为函数式接口)的实例.本质上作为参数的代码块和C的指向函数的指针是一样的,这样也许对于一部分人来说会更好理解.

Lambda表达式入门

Lambda表达式的主要作用就是代替匿名内部类的繁琐语法.还记得我们使用Command接口来定义数组处理的"行为方式"的例子吗?在学习了内部类之后,我们现在先将它改造成内部类的写法:

class BetterCommandTest {
	public static void main(String[] args) {
		ProcessArray pa = new ProcessArray();
		int[] array = {1,2,3,4,5};
		pa.process(array,new Command() {
			public void process(int[] target) {
				for(int i : target) {
					System.out.print(i + " ");
				}
				System.out.println();
			}
		});
		pa.process(array,new Command() {
			public void process(int[] target) {
				int sum = 0;
				for(int i : target) {
					sum += i;
				}
				System.out.println("The sum of array is: " + sum);
			}
		});
	}
}

相比与原来的那个程序,这个程序的两个处理方法都只能用一次,用完即丢失.看似设计更加合理(因为确实很多方法只用一次).但是,这样的方法还是依附于对象的,要传入这个方法就一定要创建一个对象.而往往我们只需使用一个方法,但是却要在创建对象的时候,不得不覆盖该对象实现接口/继承父类所有的抽象方法,这样就写了一堆没有用的语句(因为我们不会用上),Lambda表达式允许我们只将"处理方法"传入参数,而不一定需要一个附加的对象,如:

class BetterCommandTest {
	public static void main(String[] args) {
		ProcessArray pa = new ProcessArray();
		int[] array = {1,2,3,4,5};
		pa.process(array,(int[] target) -> {
				for(int i : target) {
					System.out.print(i + " ");
				}
				System.out.println();
		});
		pa.process(array,(int[] target) -> {
				int sum = 0;
				for(int i : target) {
					sum += i;
				}
				System.out.println("The sum of array is: " + sum);
		});
	}
}

从上述的程序中,即可知道Lambda表达式的组成:

  • 形参列表,形参列表允许省略形参类型.若该列表中只有一个参数,则可以连括号都省去(直接写这个参数即可).
  • 箭头(->),表示指针,由英文的横杠符号和右尖括号符号构成.
  • 代码块.若代码块只包含一条语句,则可以省略大括号,若这唯一的一条语句是return语句,则可以省略return关键字,Lambda表达式会自动返回这条语句的运算结果.

下面的例子展示了一些Lambda表达式支持的写法:

interface Eatable {
	void taste();
}

interface Flyable {
	void fly(String weather);
}

interface Addable {
	double add(double a,double b);
}

public class LambdaTest {
	public void eat(Eatable e) {
		System.out.println(e);
		e.taste();
	}
	public void drive(Flyable f) {
		System.out.println("Driving: " + f);
		f.fly("sunny");
	}
	public void test(Addable add) {
		System.out.println("5 + 3 = " + add.add(5,3));
	}
	public static void main(String[] args) {
		LambdaTest lt = new LambdaTest();
		// 只有一行的Lambda表达式,省略花括号;
		lt.eat(() -> System.out.println("Chocolate tastes great!"));
		// 只有一个形参的Lambda表达式,省略圆括号;
		lt.drive(weather -> {
			System.out.println("Today is a " + weather + " day.");
			System.out.println("The hellicopter is stable.");
		});
		// 只有一个返回值语句,省略花括号;
		lt.test((a,b) -> a + b);
	}
}

上述程序运行结果如下:

LambdaTest$$Lambda$1/0x0000000800061040@2db0f6b2
Chocolate tastes great!
Driving: LambdaTest$$Lambda$2/0x0000000800062840@5b1d2887
Today is a sunny day.
The hellicopter is stable.
5 + 3 = 8.0

上述的Lambda表达式部分不再多说,关键来说说参数对象的事.上述程序的eat()、drive()、test()各自需要的参数分别是实现Eatable接口的类的对象,实现Flyable接口的类的对象,实现Addable接口的类的对象,但是传入的都是Lambda表达式,且调用这些对象的toString()方法都能打印出对象默认的序列号,这说明实际上确实有对象生成,Lambda表达式被当成了对象处理.下面来详细介绍一下Lambda表达式被当成了何种对象处理.

Lambda表达式与函数式接口

Lambda表达式的类型,也被称为"目标类型(target type)",Lambda表达式的目标类型必须是"函数式接口(functional interface)".函数式接口代表只包含一个抽象方法的接口,函数式接口可包含多个默认方法、类方法,但只能声明一个抽象方法.

如果采用匿名内部类语法来创建函数式接口的实例,则只需实现一个抽象方法.此时即可采用Lambda表达式来创建对象,该表达式创建出来的对象的目标类型就是这个函数式接口.Java 8中有大量的函数式接口,如Runnable、ActionListener等.

Java 8提供了@FunctionalInterface注解关键字,该关键字告知编译器编译时执行更严格的检查————检查该接口必须是函数式接口,否则编译器将会报错.

由于Lambda表达式本身被当做对象,因此完全可用来赋值,前提是赋值给一个函数式接口即可,如下:

// Runnable接口中只包含一个抽象方法,该方法是无参的;
// Lambda表达式代表的匿名方法实现了Runnable中唯一的、无参的方法;
// 于是下面的赋值是完全合法的;
Runnable r = () -> {
    for(int i = 0; i < 100; i ++) {
        System.out.println(i);
    }
};

在上述例子中Lambda表达式返回的就是一个实现了Runnable接口的匿名对象,用表达式内定义的方法覆盖了Runnable的抽象方法.

Lambda表达式有两个限制:

▶️ Lambda表达式的目标类型必须是明确的函数式接口;

▶️ Lambda表达式只能为函数式接口创建对象.

下面的程序会报错: (不兼容的类型: Object不是函数式接口)

Object obj = () -> {
    for(int i = 0; i < 100; i ++) {
        System.out.println(i);
    }
};

上述代码里,编译器只能确定Lambda表达式是Object类型的对象,无法明确确定它是函数式接口,而Object类型不是函数式接口,因此会报错.为了保证Lambda表达式的目标是一个明确的函数式接口,可用如下三种做法:

  • 将Lambda表达式赋值给函数式接口类型的变量;
  • 将Lambda表达式作为函数式接口类型的参数传给某个方法;
  • 使用函数式接口对Lambda进行强制类型转换.

因此,只要这么修改上面的代码,即可完成赋值:

Object obj = (Runnable)() -> {
    for(int i = 0; i < 100; i ++) {
        System.out.println(i);
    }
};

这样,将实现Runnable接口的对象赋值给Object类对象,是完全可以的.

需要注意的是,Lambda表达式不检查方法名称,只需要和某个函数式接口的抽象方法拥有同样的参数列表,就可以认为Lambda表达式实现了这个抽象函数,也就可以转化为该函数式接口的变量,比如:

@FunctionalInterface
interface Test {
    void run();
}

然后再将上一个Lambda表达式强制转换成Test函数接口的变量,其余不变:

Object obj = (Test)() -> {
    for(int i = 0; i < 100; i ++) {
        System.out.println(i);
    }
};

可以看到除了(Test)强制转换,其余和上面提到的程序没有区别,这体现了Lambda表达式不检查方法名称,只检查参数列表的规则.

java.util.function包下的函数式接口

Java 8在java.util.function包下定义了大量函数式接口,典型的4类接口如下:

  • SomeFunction: 这类接口中通常包含一个apply()抽象方法,该方法建议用来(具体实现取决于Lambda表达式)对参数进行处理、转换,然后返回一个新的值.该接口用于对指定的数据进行转换处理.
  • SomeConsumer: 这类接口中通常包含一个accept()抽象方法,该方法与SomeFunction的apply()方法建议使用场景差不多,也是对参数进行处理,但不会返回任何处理结果.
  • SomePredicate: 这类接口中通常包含一个test()抽象方法,该方法建议用来(具体实现取决于Lambda表达式)对参数进行某种判断,然后返回一个boolean值.该接口用于判断参数是否满足特定条件,经常用于筛选数据.
  • SomeSupplier: 这类接口中通常包含一个getAsSome()抽象方法,该方法不需要参数,而是根据某种算法(具体实现取决于Lambda表达式),返回一个数据(一般是Some类型的对象).

方法引用与构造器调用

前面介绍过,如果Lambda表达式只有一条语句,则可省略花括号.不仅如此,如果Lambda表达式只有一条语句,还可以用这一条语句使用方法引用和构造器引用.方法引用和构造器引用可以让Lambda表达式更简洁,方法和构造器的引用都需要使用两个英文冒号(::和C++的域运算符同义).下面列出了Lambda表达式支持的几种引用方式:

  1. 引用类方法

示例:

类名::类方法, 如: Integer::valueOf

说明:

函数式接口中被实现的方法的全部参数传给该类方法作为参数

Lambda表达式写法:

(a, b, …) -> 类名.类方法(a, b, …), 如: (a) -> Integer.valueOf(a)

  1. 引用特定对象的实例方法

示例:

特定对象::实例方法, 如: “Hello World”::indexOf

说明:

函数式接口中被实现的方法的全部参数传给该特定对象的实例方法作为参数

Lambda表达式写法:

(a, b, …) -> 特定对象.实例方法(a, b, …), 如: (a) -> “Hello World”.indexOf(a)

  1. 引用某类对象的实例方法

示例:

类名::实例方法, 如: String::SubString

说明:

函数式接口中被实现方法的第一个参数作为调用者(该类对象),后面剩余的参数全部传给方法作为参数

Lambda表达式写法:

(a, b, c, …) -> a.实例方法(b, c, …), 如: (a, b, c) -> String.subString(b, c)

  1. 引用构造器

示例:

类名::new, 如: JFrame::new

说明:

函数式接口中被实现的方法的全部参数传给该类构造器作为参数

Lambda表达式写法:

(a, b, …) -> new 类名(a, b, …), 如: (a) -> new JFrame(a)

下面就让我们来看一看,在这4中典型的使用场景下,Lambda表达式的引用方法/构造器的方式:

  1. 引用类方法

定义如下函数式接口:

@FunctionalInterface
interface Converter {
    // 将String类型对象转化为Integer类型对象;
    Integer convert(String from);
}

则可以使用Lambda表达式创建一个实现Converter接口的对象:

Converter convert1 = from -> Integer.valueOf(from);

上述代码Lambda表达式调用了Integer的类方法valueOf(),这样Converter的实现对象的convert()方法就和Integer.valueOf()等效了,这个匿名对象的convert方法相当于这样:

// 匿名对象其余部分省略;
Integer convert(String from) {
    return Integer.valueOf(from);
}

于是调用也就非常自然:

Integer val = convert1.convert("99");
System.out.println(val);	//输出99;

上面的Lambda表达式只有一行引用类方法的语句,因此可以直接写成如下形式:

Converter converter1 = Integer::valueOf;

即说明使用Integer类的valueOf()方法来实现Converter函数式接口中的唯一的抽象方法convert(),且要求valueOf()的参数列表和convert()的一致.


  1. 引用特定对象的实例方法

我们复用上面的Converter接口,用Lambda表达式创建一个该接口的实现对象:

Converter converter2 = from -> "Hello Word".indexOf(from);

可以看到我们的Lambda表达式调用了一个具体的String型对象"Hello World"的实例方法indexOf().也就是使用这个对象的方法去覆盖函数式接口的抽象方法,相当于:

// 匿名对象其余部分省略;
Integer convert(String from) {
    return "Hello World".indexOf(from);
}

我们可以调用这个方法了:

Integer value = convert2.convert("it");
System.out.println(value);	//输出2;

同样的,上面的Lambda表达式只有一行引用特定对象实例方法的语句,因此可以直接写成如下形式:

Converter converter2 = "Hello Word"::indexOf;

即说明使用"Hello World"对象的indexOf()方法来实现Converter函数式接口中的唯一的抽象方法convert(),且要求indexOf()的参数列表和convert()的一致.


  1. 引用某类对象的实例方法

我们先定义一个函数式接口:

@FunctionalInterface
interface MyTest {
    String test(String a, int b, int c);
}

然后用Lambda表达式为其生成实现对象:

MyTest mt = (a, b, c) -> a.subString(b, c);

可以看出,上面三个参数第一个参数代表其类型对象,后面两个参数是这个对象调用的方法应该接收的参数.也就是用参数String的a对象调用subString()方法去实现函数式接口的抽象方法,等价于:

// 匿名对象其余部分省略;
String test(String a, int b, int c) {
    return a.subString(b, c);
}

对这个方法的调用如下:

System.out.println(mt.test("Java",0,4));
// 输出Jav;

其实不难看出,Lambda表达式的这种用法和上面Lambda表达式引用特定对象的方法并无差异,只是引用某类对象的实例方法需要自己将第一个参数值看作是某类的对象,而引用特定对象的实例方法则是显式指明一个已有的对象,通过它调用实例方法而已.

上面的Lambda表达式只有一行引用某类对象的实例方法的语句,因此可以直接写成如下形式:

MyTest mt = (a, b, c) -> String::subString;

即说明使用a对象的subString()方法来实现MyTest函数式接口中的唯一的抽象方法test(),且要求subString()的参数列表和test()的一致.


  1. 引用构造器.

下面定义了一个函数式接口:

@FunctionalInterface
interface FrameTest {
    JFrame win(String title);
}

下面使用Lambda表达式来创建一个FrameTest的实现对象:

FrameTest ft = (title) -> new JFrame(title);

Lambda通过new关键字调用了JFrame类的构造器,且使用这个构造器去实现FrameTest接口中的win抽象方法,等价于:

// 匿名对象其余部分省略;
JFrame win(String title) {
    return new JFrame(title);
}

我们可以用如下方式调用这个方法:

JFrame jf = ft.win("test");
System.out.println(jf);

上面的Lambda表达式只有一行引用某类对象的构造器的语句,因此可以直接写成如下形式:

FrameTest ft = JFrame::new;

即说明使用JFrame的构造器来实现FrameTest函数式接口中的唯一的抽象方法win(),且要求该构造器参数列表和win()的一致.

Lambda表达式与匿名内部类的联系与区别

Lambda表达式是匿名内部类的一种简化,可以部分取代匿名内部类,特别是涉及到函数式接口实现的时候.它们有如下相似之处:

  • Lambda表达式和匿名内部类都可访问"effectively final"的局部变量,以及外部类的成员变量,在这点上,它们的规则一样;
  • Lambda表达式创建的对象和匿名内部类一样,都可直接调用从接口中继承的默认方法.

请看以下示例程序:

@FunctionalInterface
interface Displayable {
	void display();
	default int add(int a, int b) {
		return a + b;
	}
}

public class LambdaAndInner {
	private int age = 12;
	private static String name = "Hello World";
	public void test() {
		String book = "Childhood";
		Displayable dis = () -> {
			// 访问局部变量;
			System.out.println("book局部变量为: " + book);
			// 访问外部类的实例变量和类变量;
			System.out.println("外部类的实例变量age为: " + age);
			System.out.println("外部类的类变量name为: " + name);
			// 访问接口中的默认方法,将会报错;
			// System.out.println(add(2,5));
		};
		dis.display();
		// 调用dis对象从接口那继承的默认方法add();
		System.out.println(dis.add(2,5));
	}
	public static void main(String[] args) {
		LambdaAndInner lambda = new LambdaAndInner();
		lambda.test();
	}
}

程序输出结果如下:

book局部变量为: Childhood
外部类的实例变量age为: 12
外部类的类变量name为: Hello World
7

可见在Lambda表达式中,可以访问局部变量,外部类类变量和外部类实例变量,需要注意的是Lambda表达式访问过的局部变量具有"effectively final"属性,其值无法再次改变,这一点和匿名内部类一样.Lambda表达式和匿名内部类的主要区别如下:

  • 匿名内部类可为任意接口创建实现实例,Lambda表达式只能为函数式接口创建实现实例;
  • 匿名内部类可为抽象方类甚至普通类创建实例,但Lambda表达式只能为函数式接口创建实现实例;
  • 匿名内部类实现的抽象方法方法体允许调用接口中的默认方法,但Lambda表达式不允许.

关于第三点不同点,尝试将上述代码第21行注释取消掉,则会报错: 找不到方法.但是如果用匿名内部类,就完全可以,如下:

Displayable dis = new Displayable() {
	public void display () {
		// 访问局部变量;
		System.out.println("book局部变量为: " + book);
		// 访问外部类的实例变量和类变量;
		System.out.println("外部类的实例变量age为: " + age);
		System.out.println("外部类的类变量name为: " + name);
		// 匿名内部类访问接口中的默认方法,不会报错;
		System.out.println(add(2,5));
	}
};

因此请记住不要在Lambda表达式的实现里调用接口的默认方法,否则会出现错误.

使用Lambda表达式调用Arrays的类方法

Arrays类中有些方法需要Comparator、SomeOperator、SomeFunction等接口实例,这些接口都是函数式接口,因此可以使用Lambda表达式来调用Arrays类中的方法,如下程序所示:

import java.util.Arrays;

public class LambdaArrays {
	public static void main(String[] args) {
		String[] arr1 = new String[]{"Java", "C++", "Python", "Ruby", "Go"};
		Arrays.parallelSort(arr1, (o1, o2) -> o1.length() - o2.length());
		System.out.println(Arrays.toString(arr1));
		int[] arr2 = new int[]{3, -4, 25, 16, 30, 18};
		// left表示数组中前一个索引处的元素,计算第一个元素时,left为1;
		// right表示当前索引处的元素;
		Arrays.parallelPrefix(arr2, (left, right) -> left * right);
		System.out.println(Arrays.toString(arr2));
		long[] arr3 = new long[5];
		// operand代表正在计算的元素索引;
		Arrays.parallelSetAll(arr3, operand -> operand * 5);
		System.out.println(Arrays.toString(arr3));
	}
}

上述程序运行结果如下:

[Go, C++, Java, Ruby, Python]
[3, -12, -300, -4800, -144000, -2592000]
[0, 5, 10, 15, 20]

以上程序中有三处使用了Lambda表达式,第一处是第6行,如下:

Arrays.parallelSort(arr1, (o1, o2) -> o1.length() - o2.length());

这个Lambda表达式实现了Comparator接口,该Comparator指定了判断字符串大小的标准: 长度越长的字符串越大.于是按照这个优先级,使用Arrays.parallelSort()方法将字符串排序,然后再用Arrays.toString(arr1)输出了字符串.

第二处Lambda表达式是第11行,如下:

Arrays.parallelPrefix(arr2, (left, right) -> left * right);

这个Lambda表达式实现了IntBinaryOperator接口,该对象会根据left和right位置对应的数组元素值来计算当前元素的值,其中:

left和right是一对迭代器,right总是指向当前元素,而left指向当前元素的前一个元素,当right指向数组开头元素时,让left指向整数1.Arrays.parallelPrefix()方法根据left和right的值和指定的算法,动态迭代计算出当前位置的新值,并覆盖当前值(即right的指向值),在这里,Lambda表达式定义的算法是left * right.

第三处Lambda表达式是第15行,如下:

Arrays.parallelSetAll(arr3, operand -> operand * 5);

这个Lambda表达式实现了IntToLongFunction接口,该对象会根据当前索引值(即下标值)和指定的算法,计算出当前位置的值,并覆盖当前值(即索引指向值),在这里,Lambda表达式定义的算法是operand * 5.

由此可见,Lambda表达式可以让程序更加简洁.

枚举类

枚举,在数学上是一种列出一个结果集合中所有结果的求解方式,前提当然是,这个结果集必须是有限且确定的.程序设计也经常遇到这样的问题,比如四季、12月份等等.在面向对象的程序设计中,这些季节、月份都会被抽象成一个类,但这个类实际上拥有的对象是有限的,且是固定的,这样的类可以枚举完,我们把这种成员有限且固定的类称为枚举类.

早期的Java是没有枚举类这个概念的.那时候人们可能会直接使用静态常量来表示枚举,如:

public static final int SEASON_SPRING = 0;
public static final int SEASON_SUMMER = 1;
public static final int SEASON_Autumn = 2;
public static final int SEASON_WINTER = 3;

这种定义方式简单明了,但存在一些问题:

  • 类型不安全: 由于上面每个季节实际上都是整型的,因此允许出现SEASON_SPRING + SEASON_SUMMER的操作,然而实际上这是无意义的,不应该出现这样的行为逻辑;
  • 没有命名空间: 要使用季节时,必须在静态常量前加上前缀"SEASON_",否则可能与其他静态常量冲突;
  • 打印输出时意义不明: 比如输出四个季节,实际上输出的是0~3的整数,指代的意义不能让人一下就联想到四季.

但枚举类确实有存在的价值,早期可以通过定义类来实现枚举,可采用如下设计方式:

  • 通过private隐藏构造器;
  • 把这个类所有可能实例都用public static final变量保存起来;
  • 如有必要,提供一些静态方法,允许其他程序根据特定参数匹配本类实例;
  • 使用枚举类可以使程序更加健壮,避免创建对象的随意性.

但是手动定义类实现枚举会导致码量增大,实现起来也不容易,Java自Java 5以后开始提供枚举类支持.

枚举类入门

Java 5新增枚举类关键字enum,用于定义枚举类,它与class、interface关键字地位相当.所谓地位相当,在这里提一句:

以enum、class、interface三种类型定义的变量,若声明为public访问控制权限,则至多只可出现一个(不可同时出现),且Java源文件名要和这个变量名相同.

枚举类是一种特殊的类,它一样可以有自己的成员变量、方法,可以实现一个或多个接口,也可以定义自己的构造器.但枚举类始终不是普通类,它和普通类之间有如下区别:

▶️ 枚举类可以实现一个或多个接口,但是它默认继承java.lang.Enum类,而不是java.lang.Object类,因此枚举类不能显式继承其他父类.其中java.lang.Enum实现了java.lang.Serializable和java.lang.Comparable两个接口.

▶️ 使用enum定义的非抽象的枚举类默认会使用final修饰,因此*非抽象的枚举类不能派生子类*;

▶️ 枚举类的构造器只能使用private访问控制权限,若省略访问控制符,则系统默认会使用private修饰;

▶️ 枚举类的所有实例必须在枚举类的第一行显式写出,否则这个枚举类就永远无法产生实例,列出这些实例时,程序员不能添加修饰符,系统会默认添加public static final修饰符.

下列程序定义了一个非常简单的枚举类型:

public enum SeasonEnum {
    // 第一行显式列出所有枚举类对象;
    SPRING, SUMMER, AUTUMN, WINTER;
}

可以用EnumClass.variable的形式调用枚举类实例,如:

SeasonEnum.SPRING;

下面是这个枚举类较为完整的展开使用:

enum SeasonEnum {
	SPRING, SUMMER, AUTUMN, WINTER;
}

public class EnumTest {
	public void judge(SeasonEnum s) {
		// s是枚举类型的变量;
		switch(s) {
			case SPRING: 
				System.out.println("Spring");
			break;
			case SUMMER:
				System.out.println("Summer");
			break;
			case AUTUMN:
				System.out.println("Autumn");
			break;
			case WINTER:
				System.out.println("Winter");
			break;
		}
	}
	public static void main(String[] args) {
		// 所有的枚举类都默认有一个values()方法,用于遍历所有的枚举类对象;
		for(SeasonEnum s : SeasonEnum.values()) {
			System.out.println(s);
		}
		// EnumClass.variable的写法来使用枚举类对象;
		new EnumTest().judge(SeasonEnum.SPRING);
	}
}

以上程序的输出结果为:

SPRING
SUMMER
AUTUMN
WINTER
Spring

系统自动为枚举类型提供了一个values()静态方法,该方法返回一个由当前枚举类的所有枚举值组成的一个数组,方便遍历.所有枚举类都可以直接使用java.lang.Enum中的方法,java.lang.Enum提供了如下几个方法:

  • int compareTo(Enum o): 用于与指定的枚举对象比较,若当前对象在指定枚举对象之前,则返回正整数,在指定枚举对象之后,则返回负整数,否则返回0;
  • String name(): 返回次枚举实例的名称,这个名称就是定义枚举时声明的所有枚举对象枚举值之一;
  • int ordinal(): 返回枚举值在枚举类中的索引值(也就是声明时的位置);
  • String toString(): 返回次枚举实例的名称,一般比name方法常用(因为通过它可以获取更多信息);
  • public static<T extends Enum<T>> T valueOf(Class<T> enumType, String name): 这是一个静态常量方法,用于返回枚举类中指定名称的枚举值.名称必须与枚举值声明时所用的标识符完全匹配.

枚举类的成员变量、方法和构造器

枚举类是一个特殊的类,它也可以定义成员变量、方法和构造器.下面是一个含有实例成员变量的枚举类的使用实例:

enum Gender {
	MALE,FEMALE;
	public String name;
}

public class GenderTest {
	public static void main(String[] args) {
    // 返回一个Gender枚举类里,名称为"FEMALE"的枚举值;
		Gender g = Enum.valueOf(Gender.class, "FEMALE");
		g.name = "女性";
		System.out.println(g + " 代表 " + g.name);
	}
}

程序运行结果如下:

FEMALE 代表 女性

上述程序说明了枚举类和普通类的使用方式几乎没什么区别,差别只是生成对象的方式不一样,枚举类实例只能是枚举值,不能通过new随意创建对象.但是Java应该吧类设计成良好的封装形式,而不是像上述程序那样,可以在外部随意访问枚举类的成员,这样可能会出现FEMALE枚举值的name成员变量是"男性"的局面,因此应该改进这个设计,如下:

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("不允许的操作!");
				}
			break;
			case FEMALE:
				if("女性".equals(name)) {
					this.name = name;
				}
				else {
					System.out.println("不允许的操作!");
				}
			break;
		}
	}
	public String getName() {
		return name;
	}
}

public class GenderTest {
	public static void main(String[] args) {
		Gender g = Gender.valueOf("FEMALE");
		g.setName("女性");
		System.out.println(g + " 代表 " + g.getName());
		g.setName("男性");
	}
}

可见上面的程序在枚举类中加了一对方法,并把name设为private访问控制,这样就保证了每一个枚举值的name都是合乎常理的.

但是上面的设计对于一个枚举类来说,还是不够好,通常枚举类的成员变量都建议设置成private final修饰的,这样会更安全,且代码会更简洁.若将枚举类的成员都设为了final修饰,则应该在构造器中为其提供初值(或者在定义时就指定初值,或者在初始化块中指定初值,但是这两种方法并不常用),因此应该为枚举类显示定义带参构造器.

一旦为枚举类显式定义了带参构造器,声明枚举类列出枚举值时就必须对应传入参数,看如下改进:

enum Gender {
	// 必须在列出枚举值时规定每个枚举值构造器的调用方式;
	MALE("男性"),FEMALE("女性");
	private String name;
	// 枚举类的构造器,只能是private;
	private Gender(String name) {
		this.name = name;
	}
	public String getName() {
		return name;
	}
}

public class GenderTest {
	public static void main(String[] args) {
		Gender g = Gender.MALE;
		System.out.println(g + " 代表 " + g.getName());
	}
}

可以看到Gender枚举类型有一个带一个String类型参数的构造器,则在Gender列出枚举值时,其实就是调用这个构造器创建了该类型的对象,只是枚举值对象的创建无需new关键字而已,其中:

	MALE("男性"),FEMALE("女性");

这一句,在意义上等价于:

public static final Gender MALE = new Gender("男性");
public static final Gender FEMALE = new Gender("女性");

当然,只是意义上等价而已,实际上并不可以这么调用,因为枚举类型的构造器是private访问控制符修饰的.

实现接口的枚举类

枚举类也可以实现一个或多个接口,枚举类实现接口和普通类完全一样,需要实现所有接口的所有抽象方法.下面是一个例子:

interface NowOrNeverDesc {
	void info();
}

enum NowOrNever implements NowOrNeverDesc {
	NOW,NEVER;
	public void info() {
		System.out.println("It's now or never.");
	}
}

public class NowOrNeverTest {
	public static void main(String[] args) {
		NowOrNever nn = NowOrNever.NOW;
		NowOrNever no = NowOrNever.NEVER;
		nn.info();
		no.info();
	}
}

上述程序的运行结果如下:

It's now or never.
It's now or never.

可以看到,上面的NowOrNever枚举类实现了NowOrNeverDesc接口,并实现了info()抽象方法,但是这样的实现导致枚举类的每个枚举值调用这个info()方法时都会有一样的行为特征,若要使每一个枚举值调用同一个方法时都有其独特的行为特征,应该为每个枚举值单独实现这个方法,改进后的程序如下:

interface NowOrNeverDesc {
	void info();
}

enum NowOrNever implements NowOrNeverDesc {
	NOW {
		public void info() {
			System.out.println("We still have chance.");
		}
	},
	NEVER {
		public void info() {
			System.out.println("It's over.");
		}
	};
}

public class NowOrNeverTest {
	public static void main(String[] args) {
		NowOrNever nn = NowOrNever.NOW;
    	NowOrNever no = NowOrNever.NEVER;
		nn.info();
		no.info();
	}
}

程序输出如下:

We still have chance.
It's over.

上面的程序并没有用什么高级的语法,只是非常合乎设计逻辑的做法————为每一个枚举值都单独实现同一个方法,这就跟上面的那个Gender的两个枚举值MALE/FEMALE与Gender实例变量name取值是"男性"/"女性"一个道理,当每一个枚举值都能有自己独特的特征或行为方式时,这个枚举类才算是设计精良、完整的枚举类.

抽象枚举类

假设有一个Operation枚举类,包含四种操作: PLUS、MINUS、TIMES、DEVIDE.该枚举类需要定义一个eval()方法完成计算,可以看出每一种操作的eval()都不相同,可以通过实现接口来达到,也可以将eval()声明为一个抽象方法,这样就有了一个抽象枚举类.

public enum Operation {
	PLUS {
		public double eval(double a,double b) {
			return a + b;
		}
	},
	MINUS {
		public double eval(double a,double b) {
			return a - b;
		}
	},
	TIMES {
		public double eval(double a,double b) {
			return a * b;
		}
	},
	DEVIDE {
		public double eval(double a,double b) {
			return a / b;
		}
	};
	public abstract double eval(double a,double b);
	public static void main(String[] args) {
		System.out.println(Operation.PLUS.eval(3,4));
		System.out.println(Operation.MINUS.eval(3,4));
		System.out.println(Operation.TIMES.eval(3,4));
		System.out.println(Operation.DEVIDE.eval(10,4));		
	}
}

像这样,包含有抽象方法的枚举类,就是抽象枚举类,但是请注意:

不能将抽象枚举类声明为abstract修饰,系统将会自动处理这个过程,且对于每一个枚举类实例,都必须实现所有抽象方法.

这个语法规定的原因非常简单: 因为枚举类不能派生子类,自然它也不能被声明为abstract.

对象与垃圾回收

前面说过,Java的内存区分为栈内存和堆内存,Java会将所有的对象、数组等引用类实体存入堆内存,当这些对象不再使用时,Java会回收这些对象占有的内存,其垃圾回收机制的特点如下:

  • 垃圾回收机制只回收堆内存中的对象,不负责回收任何物理资源(如数据库连接、网络I/O等资源);
  • 程序无法精确控制垃圾回收的运行,在对象永久失去引用后,垃圾回收机制会在合适的时候自动地回收它占用的内存;
  • 在垃圾回收机制回收任何对象前,会调用其finalize()方法,该方法可能使得对象重新获得引用,从而导致回收取消.

对象在内存中,总共有如下三种状态:

▶️ 可达状态: 对象被一个以上引用变量引用的状态称为可达状态;

▶️ 可恢复状态: 对象失去了所有变量的引用,垃圾回收机制并未调用其finalize()方法的状态;

▶️ 不可达状态: 对象在垃圾回收机制调用finalize()以后,仍然没有回到可达状态,开始真正进入垃圾回收的状态.

强制垃圾回收

一旦对象失去所有引用,就会进入可恢复状态,此时垃圾回收机制将会调用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();
            // 建议系统立即进行垃圾回收;
            Runtime.getRuntime().gc();
        }
    }
    public void finalize() {
        System.out.println("系统正在清理GcTest对象的资源");
    }
}

编译,并使用java -verbose:gc GcTest来运行生成的可执行文件(-verbose:gc参数可以看到每次垃圾回收后的提示信息),如下:

[0.007s][info][gc] Using G1
[0.075s][info][gc] GC(0) Pause Full (System.gc()) 1M->0M(8M) 2.216ms
[0.077s][info][gc] GC(1) Pause Full (System.gc()) 0M->0M(8M) 2.046ms
[0.079s][info][gc] GC(2) Pause Full (System.gc()) 0M->0M(8M) 1.934ms
[0.081s][info][gc] GC(3) Pause Full (System.gc()) 0M->0M(8M) 1.790ms
系统正在清理GcTest对象的资源
系统正在清理GcTest对象的资源
系统正在清理GcTest对象的资源

多运行几次程序,有时侯,我们也会随机得到如下结果:

[0.004s][info][gc] Using G1
[0.051s][info][gc] GC(0) Pause Full (System.gc()) 1M->0M(8M) 1.899ms
[0.053s][info][gc] GC(1) Pause Full (System.gc()) 0M->0M(8M) 2.394ms
[0.055s][info][gc] GC(2) Pause Full (System.gc()) 0M->0M(8M) 1.894ms
[0.057s][info][gc] GC(3) Pause Full (System.gc()) 0M->0M(8M) 1.814ms

这说明有时候Java系统会采取我们的建议立即回收对象内存,有时候则完全无视建议,根本不进行回收.

finalize()方法

在Java进行垃圾回收之前,通常需要调用适当的方法来清理资源,这个方法就是finalize()方法,它在Object类的原型如下:

protected void finalize() throws Throwable

对象的finalize()返回后,对象就消失了,垃圾回收机制开始回收资源.方法原型中的throws Throwable表示可抛出任何类型异常.

finalize()方法的调用是对于程序透明的,只有在系统认为有内存需要的时候才可能会调用可恢复状态对象的finalize()方法.

关于finalize()方法,应该注意如下四点:

▶️ 永远不要主动调用finalize()方法,而是让垃圾回收机制去调用;

▶️ finalize()的调用具有不确定性,不要把finalize()当成一个必定会执行的方法;

▶️ JVM执行finalize()​时,有可能使该对象或系统中其他对象重新变成可达状态;

▶️ JVM执行finalize()时,不会报告异常,而是直接向下执行.

注意: finalize()方法不一定会被执行,因此如果想清理某个类中打开的资源,这样的操作请不要写在finalize()里面.

下面的例子展示了如何在finalize()中复活一个对象,同时可以说明垃圾回收机制的不确定性:

public class FinalizeTest {
	private static FinalizeTest ft = null;
	public void info() {
		System.out.println("finalize()");
	}
	public void finalize() {
		// 让失去引用的对象重新被引用;
		ft = this;
	}
	public static void main(String[] args) throws Exception {
		// 匿名类;
		new FinalizeTest();
		// 建议系统强制垃圾回收;
		System.gc();
		// 强制垃圾回收机制调用可恢复对象的finalize()方法;
		System.runFinalization();
		// Runtime.getRuntime().runFinalization();	//用法同上;
		ft.info();
	}
}

以上程序运行结果如下:

finalize()

上面第14行建议系统进行垃圾回收,然后在第16行立即调用处于可恢复状态的对象的finalize()方法.可以看到,在调用完finalize()之后,原来没有引用的对象,现在被ft引用了,且可以调用对象的方法,因此垃圾回收取消.

如果删除第14行代码,直接强制进行垃圾回收的话,编译后运行会出现以下结果:

Exception in thread "main" java.lang.NullPointerException
	at FinalizeTest.main(FinalizeTest.java:18)

在第18行会抛出一个空指针异常,这是因为ft本身的指向是null,如果没有进行垃圾回收,没有执行finalize(),则它的指向依然会保持null,此时调用info()就会出现空指针异常.那么为什么明明使用了System.runFinalization()立即调用finalize()的指令,系统却依然忽视呢?这是因为此时系统内存资源并不紧张,认为没有必要回收内存,即使强制指定也可能被忽视.

因此我们知道:

  • 使用 System.runFinalization()Runtime.getRuntime().runFinalization() 可建议系统立刻调用finalize()方法;
  • 使用上述方法之前,应该给系统发一个此时进行垃圾回收的建议: System.gc()Runtime.getRuntime().gc().

对象的几种引用方式

对于大多数对象来说,总会有一些引用变量引用它们,这是对象最常见的使用方式.此外,在java.lang.ref包下提供了三个类:

SoftReference、PhantomReference、WeakReference

这些包分别代表软引用、虚引用、弱引用,加上之前的方式,Java对对象一共有4中引用方式:

  1. 强引用(StrongReference)

Java中最常见的引用,创建一个对象和引用变量,将对象赋值给这个引用变量,通过引用变量操作实际的对象.当一个对象以强引用的方式使用时,它就是一个处于可达状态的对象.

  1. 软引用(SoftReference)

通过SoftReference类来实现,当一个对象只有软引用时,它可能会被垃圾回收机制回收,取决于系统的内存情况,只有软引用的对象会在内存资源紧张时于垃圾回收机制运行的过程中被回收,这种引用方式通常处于对内存敏感的程序中.

  1. 弱引用(WeakReference)

通过WeakReference类来实现,优先级低于软引用,当一个对象只有弱引用时,不管此时系统内存如何,该对象都会在垃圾回收机制运行的过程中被回收,这种引用方式处于对内存更敏感的程序中.

  1. 虚引用(PhantomReference)

通过PhantomReference类来实现,虚引用就像没有引用一样.虚引用本身对对象没有太大作用,对象根本感觉不到其存在.这种引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,必须和引用队列(ReferenceQueue)联合使用.

Java的引用队列:

Java引用队列由java.lang.ref.ReferenceQueue类表示,它用于保存被回收后对象的引用.当联合使用软引用、弱引用和引用队列时,系统在回收被引用的对象之后,将把被回收对象对应的引用添加到关联的引用队列中.与软引用和弱引用不同的是,虚引用在对象被释放之前,就将把它对应的虚引用添加到它关联的引用队列中,这使得可以在对象被回收之前采取行动.

软引用和弱引用可单独使用,虚引用单独使用没有意义.系统通过检查与虚引用关联的引用队列中是否存在虚引用,从而判断虚引用所引用的对象是否即将被回收.

下面程序展示了弱引用所引用的对象被系统垃圾回收的过程:

import java.lang.ref.WeakReference;

public class WeakReferenceTest {
	public static void main(String[] args) throws Exception {
		String str = new String("Hello World");
		// 创建str的弱引用对象;
		WeakReference wr = new WeakReference(str);
		// 切断强引用;
		str = null;
		// 取出弱引用的对象;
		System.out.println(wr.get());
		// 强制垃圾回收;
		System.gc();
		System.runFinalization();
		// 再次取出弱引用的对象;
		System.out.println(wr.get());
	}
}

程序运行结果如下:

Hello World
null

可见弱引用的对象在垃圾回收机制执行完之后被释放,其引用也指向了null.需要注意的是:

不要使用String str = "Hello World"来创建str,因为这样将会直接从常量池中返回结果,常量池是一个强引用,即使str的强引用被切断,系统也不会回收这个对象,于是整个程序就达不到设计的效果了.

下列的程序将展示虚引用和引用队列的用法:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceTest {
	public static void main(String[] args) throws Exception {
		String str = new String("Hello World");
		// 创建一个引用队列;
		ReferenceQueue rq = new ReferenceQueue();
		// 创建str的虚引用对象,并将引用队列与其关联;
		PhantomReference pt = new PhantomReference(str, rq);
		// 切断强引用;
		str = null;
		// 不能通过虚引用取出引用对象,输出null;
		System.out.println(pt.get());
		// 强制垃圾回收;
		System.gc();
		System.runFinalization();
		// 垃圾回收后,虚引用将被放入引用队列中;
		// 取出引用队列的队首的引用和pt比较;
		System.out.println(rq.poll() == pt);
	}
}

程序输出结果如下:

null
true

由此可见,系统无法通过虚引用来获取引用对象,因此输出null,当被引用的对象回收后,对应的虚引用将被添加到关联的引用队列中,因此最后程序输出true.

使用这些引用可以避免在应用程序执行过程中将对象留在内存中,这样就可以为减小内存开销带来很大的好处.值得注意的是: 要使用这些引用带来的好处,就必须确保对象所有的强引用都被切断,不然这些引用带来的好处都不会起作用.

由于垃圾回收机制的不确定性,当程序希望从软、弱引用中取出(无强引用的)被引用的对象时,可能这个被引用对象已经被释放了,若程序需要使用它,则需要重新创建它.下面两段伪代码展示了重新创建对象的过程:

// 取出弱引用的对象;
obj = wr.get();
do {
    // 重新创建对象,再让弱引用去引用它;
    wr = new WeakReference(recreateIt());
    // 取出弱引用的对象;
    obj = wr.get();
} while(obj != null);
// 使用obj对象;
// 使用结束,再次切断强引用;
obj = null;
...

这是第二种方式:

// 取出弱引用的对象;
obj = wr.get();
if(obj == null) {
    // 重新创建对象,再让强引用去引用它;
    obj = recreateIt();
    // 建立弱引用;
    wr = new WeakReference(obj);
}
// 使用obj对象;
// 使用结束,再次切断强引用;
obj = null;
...

其中recreateIt()是用于生成一个obj对象,过程可能很像,但是这两种方式实现上还是有一定的区别:

方法1: 使用了do…while()循环语句,保证最后的obj一定是非null值(一定强引用了对象),之所以这么写是为了防止垃圾回收机制的不确定性导致出现问题.若垃圾回收机制在第5行执行后,第7行执行前回收了弱引用对象wr引用的对象,那么obj最后还会是一个null值,因此这一段代码保证obj不会以null收场.

方法2: 先判断若对象是否被释放(通过obj是否为null判断),若被释放,则重新创建一个对象,但是要让obj先引用它,这样的强引用会让垃圾回收机制不去回收这个对象,然后再建立弱引用,这样obj一定是非null的.

修饰符的适用范围

我们已经介绍了大多数的Java修饰符,在这里给出所有修饰符的适用范围总表:

外部类/接口成员属性方法构造器初始化块成员内部类局部成员
public ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark
protected ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark
包访问控制符 ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark ⚪️ ✓ \checkmark ⚪️
private ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark
abstract ✓ \checkmark ✓ \checkmark ✓ \checkmark
final ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark
static ✓ \checkmark ✓ \checkmark ✓ \checkmark ✓ \checkmark
strictfp ✓ \checkmark ✓ \checkmark ✓ \checkmark
synchronized ✓ \checkmark
native ✓ \checkmark
transient ✓ \checkmark
volatile ✓ \checkmark
default ✓ \checkmark

其中,包访问控制符是一个特殊的访问控制符,不使用访问任何控制符就代表了包访问控制符,它是访问控制符中的默认控制符(但不可显式指定),对于初始化块和局部成员来说,它们不允许使用任何访问控制符,看起来就像用了包访问控制符一样,然而实际上并不是,意义并不一样,图上用圆圈标注以示警醒.

strictfp的含义是FP-strict,即精确浮点.在JVM中,一旦使用strictfp修饰外部类/接口、方法、内部类后,Java就会以IEEE-754标准进行浮点运算,因此strictfp关键字可以让浮点运算更加精确(相较默认的JVM精度而言).

native关键字主要用于修饰方法,这个方法有点类似抽象方法,但是与抽象方法不同的是,native修饰的方法通常是使用C语言实现,这使得在Java中使用C语言实现一些底层硬件操作成为可能.如果某个方法需要利用平台相关特性、访问系统硬件等,则可以使用native去修饰,再将方法交给C语言实现.一旦Java程序中使用了native方法,这个程序就会失去跨平台的功能.

volatile是Java提供的一种轻量级的同步机制,其作用目标是属性而不是方法,只给属性加锁而不给方法加锁,只是保证了变量在内存的可见性,并不保证原子性.synchronized通常称为重量级锁,通常是对一个方法加锁,可以保证原子性和可见性.这两个关键字都和多线程编程的同步问题内容相关,这类内容不在这里展开,有兴趣可以去网上搜索一下详细内容.

transient可以防止成员变量被序列化处理,序列化是对象进行持久化处理,也就是将对象转化为一个字节流存储或进行网络传输.同时,也可以从字节中反序列化一个对象出来.这是Java程序中一个重要的概念,因为网络应用中通常需要将对象序列化成字节传输.每一个需要序列化的对象,都要实现Serializable接口,而当某些成员变量不希望被序列化时,就应该声明为transient修饰.

上表列出的所有修饰符中,4个访问控制符是互斥的,最多仅能出现其一;此外abstract和final不可同时出现;abstract和static不可同时修饰方法,但可以同时修饰内部类;abstract和private不可同时修饰方法,但可以同时修饰内部类.

Java 9的多版本JAR包

JAR全称是Java Archive File,即Java档案文件.JAR是一种压缩文件,也称为JAR包.JAR包中默认包含一个目录META-INF,目录下有MANIFEST.MF清单文件,这是打包时系统自动生成的.

JAR文件是Java的一种应用分发机制,开发者可以将自己开发的程序打包成一个JAR包,再分发给别人使用,使用者只需要将这个JAR文件添加到Java的CLASSPATH路径中,JVM就会把这个JAR包当成一个路径,系统就可以加载这个路径下所有的包或类.

使用JAR包有以下好处:

  • 安全: 可以对JAR包进行数字签名,只有知晓签名的人可以使用;
  • 加速下载: 在网络上使用Applet时,如存在多个文件而不打包,则需要为这些文件每个都创建一个HTTP连接,非常耗时.将多个文件压缩为一个JAR包,则只需创建一次HTTP连接就能下载下来.
  • 压缩: JAR包和ZIP使用完全相同的压缩机制,可以得到非常好的文件压缩比;
  • 包装性: 能够让JAR包中的文件依赖于统一版本的类文件,不出现版本冲突问题;
  • 可移植性: JAR包作为内嵌在Java平台内部处理的标准,能够在各种平台上直接使用.

jar命令

jar命令随着JDK一起被安装在我们的系统上,我们在终端可以使用jar -h查看它的用法,如下:

yjzzjy4@yjzzjy4-AERO-15XV8 ~ : jar -h
用法: jar [OPTION...] [ [--release VERSION] [-C dir] files] ...
jar 创建类和资源的档案, 并且可以处理档案中的
单个类或资源或者从档案中还原单个类或资源。

 示例:
 # 创建包含两个类文件的名为 classes.jar 的档案:
 jar --create --file classes.jar Foo.class Bar.class
 # 使用现有的清单创建档案, 其中包含 foo/ 中的所有文件:
 jar --create --file classes.jar --manifest mymanifest -C foo/ .
 # 创建模块化 jar 档案, 其中模块描述符位于
 # classes/module-info.class:
 jar --create --file foo.jar --main-class com.foo.Main --module-version 1.0
     -C foo/ classes resources
 # 将现有的非模块化 jar 更新为模块化 jar:
 jar --update --file foo.jar --main-class com.foo.Main --module-version 1.0
     -C foo/ module-info.class
 # 创建包含多个发行版的 jar, 并将一些文件放在 META-INF/versions/9 目录中:
 jar --create --file mr.jar -C foo classes --release 9 -C foo9 classes

要缩短或简化 jar 命令, 可以在单独的文本文件中指定参数,
并使用 @ 符号作为前缀将此文件传递给 jar 命令。

 示例:
 # 从文件 classes.list 读取附加选项和类文件列表
 jar --create --file my.jar @classes.list


 主操作模式:

  -c, --create               创建档案
  -i, --generate-index=FILE  为指定的 jar 档案生成
                             索引信息
  -t, --list                 列出档案的目录
  -u, --update               更新现有 jar 档案
  -x, --extract              从档案中提取指定的 (或全部) 文件
  -d, --describe-module      输出模块描述符或自动模块名称

 在任意模式下有效的操作修饰符:

  -C DIR                     更改为指定的目录并包含
                             以下文件
  -f, --file=FILE            档案文件名。省略时, 基于操作
                             使用 stdin 或 stdout
      --release VERSION      将下面的所有文件都放在
                             jar 的版本化目录中 (即 META-INF/versions/VERSION/)
  -v, --verbose              在标准输出中生成详细输出

 在创建和更新模式下有效的操作修饰符:

  -e, --main-class=CLASSNAME 捆绑到模块化或可执行 
                             jar 档案的独立应用程序
                             的应用程序入口点
  -m, --manifest=FILE        包含指定清单文件中的
                             清单信息
  -M, --no-manifest          不为条目创建清单文件
      --module-version=VERSION    创建模块化 jar 或更新
                             非模块化 jar 时的模块版本
      --hash-modules=PATTERN 计算和记录模块的散列, 
                             这些模块按指定模式匹配并直接或
                             间接依赖于所创建的模块化 jar 或
                             所更新的非模块化 jar
  -p, --module-path          模块被依赖对象的位置, 用于生成
                             散列

 只在创建, 更新和生成索引模式下有效的操作修饰符:

  -0, --no-compress          仅存储; 不使用 ZIP 压缩

 其他选项:

  -?, -h, --help[:compat]    提供此帮助,也可以选择性地提供兼容性帮助
      --help-extra           提供额外选项的帮助
      --version              输出程序版本

 如果模块描述符 'module-info.class' 位于指定目录的
 根目录中, 或者位于 jar 档案本身的根目录中, 则
 该档案是一个模块化 jar。以下操作只在创建模块化 jar,
 或更新现有的非模块化 jar 时有效: '--module-version',
 '--hash-modules''--module-path'。

 如果为长选项提供了必需参数或可选参数, 则它们对于
 任何对应的短选项也是必需或可选的。

可以看出jar命令在设计模式上是一部分借鉴了tar命令的,这对我们来说是好事,降低了学习成本,我们来说一下常用的几个参数:

创建JAR文件:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -cf classes.jar Foo.class Bar.class 
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : ll
总用量 4.0K
-rw-r--r-- 1 yjzzjy4 yjzzjy4   0 7月  24 10:17 Bar.class
-rw-r--r-- 1 yjzzjy4 yjzzjy4 526 7月  24 10:17 classes.jar
-rw-r--r-- 1 yjzzjy4 yjzzjy4   0 7月  24 10:17 Foo.class

可见此时在当前目录生成了一个JAR文件: classes.jar,包含的内容是: Foo.class Bar.class以及清单文件.

查看生成的JAR文件的内容:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -tf classes.jar 
META-INF/
META-INF/MANIFEST.MF
Foo.class
Bar.class

此外,我们可以在很多场景下使用-v参数,它表示--verbose,冗余的意思,也就是详细信息,比如:

创建JAR文件,同时显示详细信息:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -cvf classes1.jar Foo.class Bar.class 
已添加清单
正在添加: Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: Bar.class(输入 = 0) (输出 = 0)(存储了 0%)

查看JAR文件的详细内容:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -tvf classes1.jar 
     0 Wed Jul 24 10:24:02 CST 2019 META-INF/
    66 Wed Jul 24 10:24:02 CST 2019 META-INF/MANIFEST.MF
     0 Wed Jul 24 10:17:28 CST 2019 Foo.class
     0 Wed Jul 24 10:17:28 CST 2019 Bar.class

以上操作的目标都是当前目录下的文件,若想将整个目录打包成JAR文件,只需要使用-C参数即可:

yjzzjy4@yjzzjy4-AERO-15XV8 ~ : jar -cvf test1.jar -C test1 .
已添加清单
正在添加: Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: main.class(输入 = 0) (输出 = 0)(存储了 0%)

jar命令的-C参数是改变目录的意思,后面跟随两个参数: 目标路径 在目标路径中(被打包的)目标文件的相对路径,比如上面的命令将test1目录下面的.目录下(也就是test1自身)所有文件都打包进JAR包,生成的JAR包结构如下:

yjzzjy4@yjzzjy4-AERO-15XV8 ~ : jar -tf test1.jar 
META-INF/
META-INF/MANIFEST.MF
Foo.class
main.class

可见test目录下存在两个文件: Foo.class main.class,清单文件是自动创建的.

我们可以指定打包时不生成清单文件,使用-M参数,此时打包的信息也会有所不同,如下:

yjzzjy4@yjzzjy4-AERO-15XV8 ~ : jar -cvfM test2.jar -C test1 .
正在添加: Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: main.class(输入 = 0) (输出 = 0)(存储了 0%)

当然,我们也可以添加自己的清单信息到生成的清单文件中,使用-m参数并指定要使用的清单文件即可:

yjzzjy4@yjzzjy4-AERO-15XV8 ~ : jar -cvfm test3.jar test/classes/META-INF/MANIFEST.MF -C test1/ .
已添加清单
正在添加: Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: main.class(输入 = 0) (输出 = 0)(存储了 0%)

上面的例子中我们使用的清单是~/test/class.jar文件提取出来的清单,这个清单的内容会添加到新打包的~/test3.jar自动生成的清单的文件末尾.其中清单文件是一个普通文本文件,要创建自己的清单使用文本编辑器即可,清单应遵循的一些规则如下:

  • 清单的每一行由一对key-value组成,一行只能有一对key-value,其格式如下:

  •   key:<空格>value
    
  • key-value的书写格式必须严格遵守上述格式,key前面不能有空格,key-value中间的英文冒号和空格缺一不可;

  • 文件开头不能有空行,文件结尾必须以一个空行结束.

下面是jdk-11.0.1/lib/jrt-fs.jar中包含的META-INF/MANIFEST.MF清单的内容:

Manifest-Version: 1.0
Specification-Title: Java Platform API Specification
Specification-Version: 11
Specification-Vendor: Oracle Corporation
Implementation-Title: Java Runtime Environment
Implementation-Version: 11.0.1
Implementation-Vendor: Oracle Corporation
Created-By: 10 (Oracle Corporation)

说完了创建,我们来说一下JAR包的解压缩,使用-xf即可完成简单的解压缩,使用-xvf可以在解压缩时显示详细信息:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -xf classes.jar 
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -xvf classes1.jar
  已创建: META-INF/
  已解压: META-INF/MANIFEST.MF
已提取: Foo.class
已提取: Bar.class

jar命令并不支持解压缩到指定的目录,此时可以使用unzip命令解压缩JAR包到指定目录:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : unzip classes.jar -d classes 
Archive:  classes.jar
   creating: classes/META-INF/
  inflating: classes/META-INF/MANIFEST.MF  
 extracting: classes/Foo.class       
 extracting: classes/Bar.class       
yjzzjy4@yjzzjy4-AERO-15XV8  ~/test  tree classes 
classes
├── Bar.class
├── Foo.class
└── META-INF
    └── MANIFEST.MF

1 directory, 3 files

关于解压缩就说到这里,接下类介绍一些如何更新JAR文件,只需要使用-uf参数即可向JAR文件中更新某个文件,若JAR文件中存在与指定外部文件重名的文件,则使用外部文件更新JAR包中的文件,否则将外部文件添加到JAR包中,使用-uvf可以获取详细信息:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -uf classes1.jar Foo.class 
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -uvf classes.jar -C classes .
正在忽略条目META-INF/
正在忽略条目META-INF/MANIFEST.MF
正在添加: Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: Bar.class(输入 = 0) (输出 = 0)(存储了 0%)

注意,更新JAR文件时,指定-C参数可以将一个目录下的指定Java相关文件都更新到JAR文件中,且保持目录结构不变,如:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : tree classes 
classes
├── class
│   ├── Bar.class
│   └── Foo.class
└── META-INF
    └── MANIFEST.MF

2 directories, 3 files
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -uvf classes1.jar -C classes .
正在忽略条目META-INF/
正在忽略条目META-INF/MANIFEST.MF
正在添加: class/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: class/Bar.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: class/Foo.class(输入 = 0) (输出 = 0)(存储了 0%)
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -tvf classes1.jar 
     0 Wed Jul 24 10:17:28 CST 2019 Foo.class
     0 Wed Jul 24 10:17:28 CST 2019 Bar.class
     0 Wed Jul 24 11:47:04 CST 2019 class/
     0 Wed Jul 24 10:17:28 CST 2019 class/Bar.class
     0 Wed Jul 24 10:17:28 CST 2019 class/Foo.class

在Java 9及以后的版本,允许在同一个JAR包中创建针对多个Java版本的class文件.JDK 9为jar命令增添了一个--release参数,查看这个参数的帮助,得到如下信息:

--release VERSION      将下面的所有文件都放在
                       jar 的版本化目录中 (即 META-INF/versions/VERSION/)

其中: VERSION ≥ \ge 9,因为从Java 9之后才支持这个特性.一般在使用这个参数之前,都建议将对应不同版本的class文件提前编译好,放在不同的路径中加以区分,编译时应该指定用什么Java版本,如:

javac --release 7 Test.java

注意这是javac的--release参数,其指定版本没有限制(只要不比安装的JDK版本高即可),上面的命令就表示使用Java 7的语法编译Test.java文件,此时Test.java中若出现版本高于Java 7的语法,则会编译出错.

假设使用Java 7编译的class文件和源文件都在dist7/test下,使用Java 9编译的class文件和源文件都在dist9/test下,则创建不同版本的JAR包的命令如下:

yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -cvf test.jar -C dist7 . --release 9 -C dist9 .
已添加清单
正在添加: test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: test/Test.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: test/Test.java(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: META-INF/versions/9/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: META-INF/versions/9/test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: META-INF/versions/9/test/Test.class(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: META-INF/versions/9/test/Test.java(输入 = 0) (输出 = 0)(存储了 0%)
yjzzjy4@yjzzjy4-AERO-15XV8 ~/test : jar -tf test.jar 
META-INF/
META-INF/MANIFEST.MF
test/
test/Test.class
test/Test.java
META-INF/versions/9/
META-INF/versions/9/test/
META-INF/versions/9/test/Test.class
META-INF/versions/9/test/Test.java

可见使用--release 9指定的Java 9相关的文件保存在JAR包的META-INF/versions/9/下.

创建可执行的JAR包

上面我们介绍了jar命令的基本用法,现在我们来说一下如何将JAR包打包成一个可执行的程序.开发者将一个项目打包成JAR包是非常典型的做法,若该JAR包被打包成一个可执行文件,那么这对用户来说显然是非常方便的.

创建可执行的JAR包其实非常方便,关键在于指定程序运行的主类,主类是整个程序的入口,使用jar命令的-e参数指定即可:

jar -cvfe AnonymousInner.jar test.AnonymousInner -C AnonymousInner . 
已添加清单
正在添加: AnonymousInner.java(输入 = 958) (输出 = 463)(压缩了 51%)
正在添加: test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: test/AnonymousInner$2.class(输入 = 647) (输出 = 464)(压缩了 28%)
正在添加: test/AnonymousInner$1.class(输入 = 431) (输出 = 303)(压缩了 29%)
正在添加: test/AnonymousInner.class(输入 = 1244) (输出 = 678)(压缩了 45%)
正在添加: test/Device.class(输入 = 484) (输出 = 299)(压缩了 38%)

上面的命令参数-e指定JAR包为可执行文件,且主类为test.AnonymousInner,即test包下的AnonymousInner类(注意主类如果依附于某个包,则一定要指定一个完整的类名).使用java命令和参数-jar来运行这个JAR包,如下:

java -jar AnonymousInner.jar 
Each AMD Radeon RX 5700 XT costs $399.99.
匿名内部类初始化块
Each Nitendo Switch Lite costs $199.99.

这个运行结果和我们不打包直接运行主类的结果是一样的,这样我们的JAR包就是可执行的程序了.

另外,前面说过使用-C更换目录,其实可以无需参数,直接指定要压缩的目录,比如以下命令:

jar -cvfe AnonymousInner.jar test.AnonymousInner AnonymousInner/test
已添加清单
正在添加: AnonymousInner/test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: AnonymousInner/test/AnonymousInner$2.class(输入 = 647) (输出 = 464)(压缩了 28%)
正在添加: AnonymousInner/test/AnonymousInner$1.class(输入 = 431) (输出 = 303)(压缩了 29%)
正在添加: AnonymousInner/test/AnonymousInner.class(输入 = 1244) (输出 = 678)(压缩了 45%)
正在添加: AnonymousInner/test/Device.class(输入 = 484) (输出 = 299)(压缩了 38%)

我们发现,这样指定的目录会将父目录(AnonymousInner)包含进来,但我们并不想要这个目录,因此JAR包目录结构会出现问题,这就会导致我们运行这个JAR包会出问题,如:

java -jar AnonymousInner.jar 
错误: 找不到或无法加载主类 test.AnonymousInner
原因: java.lang.ClassNotFoundException: test.AnonymousInner

原因很简单,AnonymousInner.jar的目录结构多了一层AnonymousInner,即./AnonymousInner/test/AnonymousInner.class而打包时希望的目录结构是./test/AnonymousInner.class,这和创建JAR包时声明的主类所在包不同,因此找不到主类AnonymousInner.

Q: 那么可以创建时声明为下面这样吗?

jar -cvfe AnonymousInner.jar AnonymousInner.test.AnonymousInner AnonymousInner/test

A: 当然不行,这样和程序编译时的结构就不符了,此时一样也会找不到主类.

因此我们建议使用下面的形式:

jar -cvfe AnonymousInner.jar test.AnonymousInner -C AnonymousInner .
已添加清单
正在添加: AnonymousInner.java(输入 = 958) (输出 = 463)(压缩了 51%)
正在添加: test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: test/AnonymousInner$2.class(输入 = 647) (输出 = 464)(压缩了 28%)
正在添加: test/AnonymousInner$1.class(输入 = 431) (输出 = 303)(压缩了 29%)
正在添加: test/AnonymousInner.class(输入 = 1244) (输出 = 678)(压缩了 45%)
正在添加: test/Device.class(输入 = 484) (输出 = 299)(压缩了 38%)

或者指定到其子目录也是可以的:

jar -cvfe AnonymousInner.jar test.AnonymousInner -C AnonymousInner ./test
已添加清单
正在添加: test/(输入 = 0) (输出 = 0)(存储了 0%)
正在添加: test/AnonymousInner$2.class(输入 = 647) (输出 = 464)(压缩了 28%)
正在添加: test/AnonymousInner$1.class(输入 = 431) (输出 = 303)(压缩了 29%)
正在添加: test/AnonymousInner.class(输入 = 1244) (输出 = 678)(压缩了 45%)
正在添加: test/Device.class(输入 = 484) (输出 = 299)(压缩了 38%)

可以看到这两者的区别在于: 前者包含AnonymousInner下的所有文件,于是有AnonymousInner/AnonymousInner.java被添加进JAR包,后者只包含AnonymousInner/test下的所有文件,但无论哪种形式,都不会改变我们期望的目录结构,于是可以正常运行JAR包.

建议涉及到子目录操作的情况下,使用-C参数更换到目录再指定其子目录而不是直接使用目录/子目录进行打包.

关于JAR包的技巧

前面说过,JAR文件实际上是一种特殊的ZIP文件,完全可以使用命令,如: unzip进行解压缩,也可以使用zip命令进行创建,这样创建时需要自己写META-INF/MANIFEST.MF清单文件,这个文件至少包括如下两行内容:

Manifest-Version: 1.0
Created-By: 11.0.1 (Oracle Corporation)
  • 第一行: Manifest-Version: 1.0,指定清单文件版本,目前是1.0,可以查看一下安装的JDK中的JAR包的清单文件写法;
  • 第二行: Created-By: 11.0.1 (Oracle Corporation),表示由哪个版本的JDK生成的清单文件,也可以查看JDK里的JAR包写法.

以下这个清单文件是我们上一个可执行JAR包的一个清单文件,包括三行:

Manifest-Version: 1.0
Created-By: 11.0.1 (Oracle Corporation)
Main-Class: test.AnonymousInner

这时的清单文件指明了主类的完整类名,这显然是-e参数的效果,但我们也可以手动添加主类信息.

另外还可以用支持ZIP标准的GUI程序创建JAR包,同样需要注意的就是项目的目录格式和清单文件的正确性.

除此之外,Java还可能生成两种压缩包: WAR包和EAR包.其中WAR包是Web Archive File的意思,EAR包是Enterprise Archive File的意思,分别代表Web应用档案和企业应用档案(通常由Web应用和EJB两个部分组成).事实上,WAR包和EAR包的压缩格式和压缩方式与JAR包完全一致,因此也可以用ZIP操作进行创建,它们之间不同的地方仅仅是扩展名而已.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值