禁用编译器扩展以确保程序符合 C++ 标准

g++ 编译器可以通过添加 -pedantic-errors 选项来禁用扩展:

1
g++ main.cpp -pedantic-errors

程序示例:

1
2
3
4
5
int main() {
int n = 1024;
int a[n];
return 0;
}

运行结果:

1
2
// 禁用前正常运行
error: ISO C++ forbids variable length array 'a' // 禁用后报错

Java 快速输入输出

输入

Scanner 会使用正则表达式解析输入,而 BufferedReader 直接读取输入,所以 Scanner 更慢。

输出

System.out(类型为 PrintStream)的 autoFlush 属性默认为 True,所以 System.out 更慢。

模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class FastIO extends PrintWriter {
private BufferedReader br;
private StringTokenizer st;

public FastIO() {
this(System.in, System.out);
}

public FastIO(InputStream in, OutputStream out) {
super(out);
br = new BufferedReader(new InputStreamReader(in));
}

public FastIO(String input, String output) throws FileNotFoundException {
super(output);
br = new BufferedReader(new FileReader(input));
}

public String next() {
try {
while (st == null || !st.hasMoreTokens())
st = new StringTokenizer(br.readLine());
return st.nextToken();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

public int nextInt() {
return Integer.parseInt(next());
}

public double nextDouble() {
return Double.parseDouble(next());
}

public long nextLong() {
return Long.parseLong(next());
}
}

测试

INOUTEST - Enormous Input and Output Test

Java 基础

基本类型

整数类型

整数进行除法运算,商向零取整。

整数被 0 除将会产生一个异常,而浮点数被 0 除将会得到无穷大或 NaN。

思考:进行取模运算时,数值的正负对结果有什么影响?

取模的定义:(a / b) * b + a % b = a。根据定义算就行,或者记住取模的结果和左边的数(被除数)符号相同,但是结果本质上依赖于除法的舍入规则,所以最好还是按定义算。

1
2
3
4
5
int a = 3 % 2; // 1
int b = -3 % 2; // -1
int c = 3 % -2; // 1
int d = -3 % -2; // -1
System.out.printf("%d %d %d %d",a, b, c, d);

浮点类型

思考:double 类型的取值范围和精度是多少?(float 类型同理)

double 类型的表示使用 IEEE 754 标准,以 \(V=(-1)^{s}\times M\times 2^{E}\) 的形式来表示浮点数。其中,\(s,M,E\) 分别表示符号、尾数和阶码,分别使用 1、11 和 52 位进行编码。根据标准定义,能够表示的最大/最小规格化值是 \(\pm (1+1-2^{-52})\times2^{(2^{11}-2-1023)}\approx\pm 1.79769313486231570e+308\)。

通常,会说 double 的精度是 15,计算方式为 \(\log{2^{-52}}\) 或者 \(\log{2^{-53}}\)(由于隐含的 \(1\))。但是,这里所说的精度都是仅从尾数层面计算的,而没有涉及阶码(或者部分涉及)。在十进制表示中,由于阶码的影响(以及科学记数法表示),说精度是 15 没有什么意义。而在二进制表示中,直接说精度是 52 比较合理。

思考:为什么浮点运算可能产生误差?是否所有浮点运算都会产生误差?BigDecimal 是如何避免误差的?

声明:这里所讨论的误差是指运算过程中的舍入误差,不包括由于不能准确表示而产生的舍入误差,例如浮点数 0.1 只是实数 0.1 的近似,也不包括输出产生的舍入误差,例如虽然浮点数能够精确表示 Double.MAX_VALUE,但是输出会得到近似值 1.7976931348623157E308。

(1)发生误差的根本原因是,IEEE 754 标准不能准确表示所有小数(由于范围和精度限制),所以浮点运算只能近似地表示实数运算。标准定义了四种不同的舍入(rounding)方式,默认是将计算结果向偶数舍入(round-to-even)。向偶数舍入是指,将非中间值向最接近的数舍入,将中间值向偶数舍入。

为什么选择向偶数舍入?如果计算一组数的平均数:向上/下舍入会使结果偏大/小;向零舍入在统计数据都是正/负数时,会使结果偏小/大;而向偶数舍入大概率可以避免这种统计偏差,因为此时向上和向下舍入的概率各占一半。

(2)并非所有浮点运算都会产生误差。首先,0.5 + 0.25 不会产生误差。

1
2
3
4
5
// 0 01111111110 0000000000000000000000000000000000000000000000000000
System.out.println(Long.toBinaryString(Double.doubleToLongBits(0.5)));
// 0 01111111101 0000000000000000000000000000000000000000000000000000
System.out.println(Long.toBinaryString(Double.doubleToLongBits(0.25)));
System.out.println(0.5 + 0.25 == 0.75); // true

那么,是否可以猜测,能够被 IEEE 754 标准精确表示的数,浮点运算就不会产生误差?然而不是,只要运算结果超出浮点数能够表示的范围或者精度,那么运算就会产生误差。例如:

1
2
3
4
5
// 0 01111111111 0000000000000000000000000000000000000000000000000000
System.out.println(Long.toBinaryString(Double.doubleToLongBits(1.0)));
// 0 10000110100 0000000000000000000000000000000000000000000000000000
System.out.println(Long.toBinaryString(Double.doubleToLongBits(9007199254740992.0)));
System.out.println(1.0 + 9007199254740992.0); // 9.007199254740992E15

那么,不能被 IEEE 754 标准精确表示的数,浮点运算是否总会产生误差?也不是,例如 0.1 + 0.1 就没有误差,而 0.1 + 0.2 就会有误差,可以使用这个网站查看计算过程。

1
2
3
4
5
6
7
8
// 0 01111111011 1001100110011001100110011001100110011001100110011010
System.out.println(Long.toBinaryString(Double.doubleToLongBits(0.1)));
// 0 01111111100 1001100110011001100110011001100110011001100110011010
System.out.println(Long.toBinaryString(Double.doubleToLongBits(0.2)));
// 0 01111111101 0011001100110011001100110011001100110011001100110011
System.out.println(Long.toBinaryString(Double.doubleToLongBits(0.3)));
System.out.println(0.1 + 0.1 == 0.2); // true
System.out.println(0.1 + 0.2 == 0.3); // false

(3)BigDecimal 表示不可变的任意精度的小数,可以将其视为由任意精度的未缩放值 \(x\),以及 32 位的缩放值 \(k\) 组成,最终表示的数就是 \(x\times 10^{-k}\)。如果未缩放值 \(x\) 非常大,则会使用 BigInteger 来表示。BigInteger 表示不可变的任意精度的整数,内部使用 int 类型的数组存储整数的补码表示的各个部分。例如,\(9\times 10^{9}\) 会被存储为 \([2,410065408]\),对应补码表示 \(01000011000011100010001101000000000\) 的 \(010\) 和 \(00011000011100010001101000000000\) 两部分。

思考:double 类型不能准确表示的最小正整数是多少?(CSAPP 练习题 2.49)

假设阶码位数足够大,对于有 \(n\) 位尾数的浮点数,不能准确表示的最小正整数是 \(2^{n+1}+1\)。所以,double 类型不能准确表示的最小正整数是 \(2^{53}+1=9007199254740993\)。示例如下,最低位的 1 被舍掉。

1
System.out.println(9007199254740993.); // 9.007199254740992E15

需要注意,不能根据上述结论推断出,能够准确表示的最大正整数是 \(2^{n+1}\)。反例是,\(2^{n+1}+2\) 可以被准确表示,更简单的反例是 \(2^{n+2}\)。总的来说,能转换为 \((-1)^{s}\times M\times 2^{E}\) 形式的数都可以被准确表示。

思考:将三个 int 类型的整数转换为 double 类型(假设为 \(x,y,z\)),问 \((xy)z=x(yz)\) 是否总是成立?如果不是,反例是什么?(CSAPP 2.89 D

反例如下,简单来说乘法可能存在舍入误差,不同的结合方式可能会有不同的舍入形式。

1
2
3
4
5
6
double x = (1 << 30) + 1;
double y = (1 << 23) + 1;
double z = (1 << 24) + 1;
System.out.println((x * y) * z == x * (y * z)); // false
z = (1 << 24);
System.out.println((x * y) * z == x * (y * z)); // true

可以根据乘法结果能否转换为标准定义的浮点形式,来判断是否存在舍入误差。首先 \((2^{30}+1)\times(2^{23}+1)=(1+2^{-23}+2^{-30}+2^{-53})\times 2^{53}\),由于尾数能表示的最小值是 \(2^{-52}\),所以最后 \(1\) 位会被舍掉,得到 \(xy=(1+2^{-23}+2^{-30})\times 2^{53}\)。然后 \((1+2^{-23}+2^{-30})\times 2^{53}\times(2^{24}+1)=(1+2^{-23}+2^{-30}+2^{-24}+2^{-47}+2^{-54})\times 2^{77}\),所以最后 \(2\) 位会被舍掉,得到 \((xy)z=(1+2^{-23}+2^{-30}+2^{-24}+2^{-47})\times 2^{77}=151115754614164973158400\)。

字符类型

Java 的 char 类型占用两个字节,使用 Unicode 字符集,并且采用 UTF-16 编码方式。

一个 Unicode 字符在 UTF-16 编码中由 1 ~ 2 个代码单元组成,一个 char 值表示 UTF-16 编码中的一个代码单元。

char 类型可以使用转义序列 \u 表示,例如 \u0061 表示字符 a。\u 转义序列与其他转义序列不同,它可以出现在加引号的字符常量或字符串之外。如下所示,第一行代码中的 \u005B 和 \u005D 分别是 [ 和 ] 的编码。

1
public static void main(String\u005B\u005D args)

值得注意的是 Unicode 转义序列会在解析代码前进行处理。例如,下面的代码看上去是没有问题,但是有两个语法错误,因为 \u000A 会被替换为一个换行符,而 \user 会被视为非法的 Unicode 转义,因为 \u 后面没有跟着 4 个十六进制数。

1
2
3
// \u000A is a newline
// look inside c:\user
String s = "\u0022+\u0022"; // 表示一个空串

思考:能否使用 Character.isDigit 方法判断字符是 0~9?能否使用 Character.isLetter 方法判断字符是 a~z 或 A~Z?

在 Unicode 字符集中,数字和字母的范围更大。例如,\u0669 表示 Arabic-Indic 数字中的 9,但是不能将其和字符 9 等同,字符 9 的 Unicode 表示为 \u0039。同理,\u03c0 表示希腊字母 π,Character.isLetter 也会返回 true

1
2
3
4
5
char c = '\u0669';
System.out.println(c); // ٩
System.out.println(Character.getNumericValue(c)); // 9
System.out.println(Character.isDigit(c)); // true
System.out.println(c == '9'); // false

类型转换

思考:自动类型转换是否可能损失精度?什么情况下需要强制类型转换?

(1)自动类型转换可能损失精度,例如之前提到 double 不能准确表示的最小正整数是 9007199254740993。支持的自动类型转换如下(图片参考《Java 核心技术 卷一》),损失精度一般都涉及到浮点数。

(2)简单来说,将大范围类型转换为小范围类型,需要强制类型转换,反之则不需要。或者按照书中的表述,可能损失信息的转换,需要强制类型转换。

思考:以下代码会发生几次类型转换?

1
long x = 1; double y = 1; x += y;

使用 javap -v 反编译字节码,部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
0: lconst_1
1: lstore_1
2: dconst_1
3: dstore_3
4: lload_1
5: l2d
6: dload_3
7: dadd
8: d2l
9: lstore_1
10: return

可以发现,字面量 1 直接使用 lconst_1 和 dconst_1 获取,不会发生类型转换。而 x += y 相当于 x = (long) (x + y),所以发生两次类型转换。

类与接口

通常使用逆序的因特网域名作为包名,然后对不同工程使用不同的子包,以防止相同名字的类产生冲突。可以使用完全限定名访问其他包中的类,也可以使用 import 语句(位于 package 语句的后面)导入其他包中的类,然后就可以直接使用类名。当导入的包存在命名冲突时,仍然需要使用完全限定名。编译器将 java 文件编译为 class 文件后,class 文件中的字节码使用的都是完全限定名。

使用星号可以导入其他包中的所有类,但是不能使用星号导入多个包,例如 import java.*import java.*.* 是不允许的。嵌套的包之间没有任何关系,每一个包都是独立的类集合。用星号导入某个包中的所有类后,使用其子包仍然需要显示导入。使用 import static 可以导入静态方法/静态字段。

访问修饰符

常规类(非内部类)可以被 public 修饰或者无修饰符,字段和方法可以被所有修饰符修饰或者无修饰符。不同修饰符的访问级别如下(图片源自 Controlling Access to Members of a Class)。

  • public:被修饰的类/字段/方法可以被任意类访问。特别的,一个源文件只能有一个 public 类,并且文件名必须和该类名相同。
  • protected:被修饰的字段/方法可以被所有子类和相同包中的类访问。需要注意,在不同包中,子类可以访问的是自身继承的 protected 字段/方法,而不能访问父类对象的(或者其他子类继承的) protected 字段/方法。(避免滥用保护机制,不能通过派生子类来访问父类对象的 protected 字段/方法)
  • no modifier:无修饰符,类/字段/方法可以被相同包中的类访问。
  • private:被修饰的字段/方法可以被所属的类访问。

思考:private 字段一定不会被其他类访问到吗?(不使用反射)

虽然 private 字段不能通过 obj.field 形式访问,但是如果 private 字段从某个方法暴露出去,则会被其他类访问到。

对象与对象变量

以下代码使用 new 关键字构造 Date 类型的对象,然后将该对象的引用(地址)赋值给 deadline,这个 deadline 就是对象变量(可以看作指针)。

1
Date deadline = new Date();

字段的初始化顺序

静态字段:在类加载时,按照类中出现的顺序执行显式初始化和静态初始化块。

实例字段:首先执行默认初始化,然后按照类中出现的顺序执行显式初始化和初始化块,最后执行构造器代码。

父类字段:父类静态字段的初始化在子类静态字段之前,父类实例字段的初始化在子类实例字段之前。

方法的重载和重写

重载方法是指在相同类中,方法名相同但参数类型不同的方法(返回值类型不重要)。方法名 + 参数类型被称为方法的签名。在进行方法调用时,如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个匹配的方法,编译器就会报错。

重写方法是指子类重写(覆盖)父类的方法,重写方法通常会加上 @Override 注解。方法重写遵循以下规则:

  • 重写方法的可见性不能低于原方法。
  • 重写方法的返回值类型可以改为原返回类型的子类型。
  • 重写方法声明的异常不能比原方法声明的异常更通用。
  • 父类的 static 方法不能被子类重写,但是可以被子类中相同签名的 static 方法隐藏。
  • 参数数量可变的形参换成数组类型构成重写。

如果重写方法的返回值类型是原返回类型的子类型,此时编译器会在子类中生成一个桥方法,该桥方法的返回值类型和原方法相同,并且桥方法会调用重写的方法。

思考:方法参数是值传递还是引用传递?

方法参数总是值传递,即方法得到的是参数的副本。对于基本类型,肯定是值传递。对于引用类型,只要明白对象和对象变量的区别,就可以知道引用类型也是值传递,因为对象变量(指针)的值就是对象的引用(地址)。

继承和多态(向上转型)

在 Java 中,类之间只支持单继承,而接口之间支持多继承。在子类的构造器中,可以使用 super 语句调用父类的构造器,该语句必须是子类构造器的第一条语句。如果子类的构造器没有显式地调用父类的构造器,将自动地调用父类的无参数构造器。如果父类没有无参数构造器,并且在子类的构造器中又没有显式地调用父类的其他构造器,Java 编译器就会报错。

一个对象变量可以指示多种实际类型的现象称为多态,在运行时能够自动地选择适当的方法,称为动态绑定。在将父类强制类型转换成子类之前,应该使用 instanceof 进行检查。否则,如果类型不符,将会产生类型转换异常。

思考:方法调用的原理?解析和分派?字段没有多态性?(详细分析见《深入理解 Java 虚拟机》第 8 章)

所有方法调用的目标方法在 Class 文件中都是一个常量池中的符号引用。在类加载的解析阶段就可以将符号引用解析为直接引用的方法被称为非虚方法,包括静态方法、私有方法、实例构造器、父类方法以及 final 方法。这类方法的调用被称为解析。

虚方法是指除非虚方法以外的方法,需要通过分派确定调用目标。重载方法是静态分派(重载解析)的,依据对象的外观类型(Apparent Type)来选择调用的方法。重写方法是动态分派的,依据对象的运行时类型(Runtime Type)来选择调用的方法。

字段没有多态性,访问字段依据的是当前方法所属的类或者外观类型。

接口

  • 方法默认被 public abstract 修饰,字段默认且必须被 public static final 修饰。
  • 可以使用 default 修饰符声明默认方法(必须是非静态的)。
  • 在 Java 8 中,可以声明静态方法,静态方法只能通过接口调用,不能通过实现类及其对象调用。
  • 在 Java 9 中,可以声明私有方法(用作其他方法的辅助方法),私有方法可以是静态方法或实例方法。

在继承关系中,接口的默认方法有时会和其他类/接口的方法冲突,解决规则如下:

  • 父类与接口冲突,父类优先。 如果父类的方法和接口的默认方法有相同签名,则接口中的默认方法会被忽略。
  • 接口与接口冲突,覆盖方法。 如果两个接口有相同签名的方法,并且其中一个接口的方法是默认方法,则必须覆盖这个方法来解决冲突。

如果想要在实现类中显示调用接口中的默认方法,可以使用 interfaceName.super.methodName(xxx)

常用关键字

this

每个实例方法都会有一个隐含的参数 this,表示当前对象的引用。在字节码中,实例方法的 args_size 至少是 1,就是因为 this 作为隐含的参数。

可以使用 this 调用当前对象的构造器、字段/方法。特别的,可以在内部类中使用 OuterClass.this 表示外部类的引用;在 lambda 表达式中,this 表示创建这个表达式的对象的引用(类似事实最终变量)。

super

  • 调用父类的构造器/方法,获取父类的字段。
  • 调用接口的默认方法,interfaceName.super.xxx()

与 this 不同,super 不是对象的引用,不能将 super 赋给另一个对象变量。

final

  • 修饰类时,类不能被继承。
  • 修饰方法时,方法不能被重写。
  • 修饰字段/变量时,字段/变量必须被初始化。

static

可以修饰字段/方法/初始化块/内部类,它们在类加载时被创建。当 static 修饰方法时,该方法不能访问实例字段、不能使用 this 和 super 关键字。import static 表示静态导入。

abstract

可以使用 abstract 关键字来声明抽象类/方法。abstract 不能修饰私有/静态方法,以及 final 方法/类。包含抽象方法的类必须被声明为抽象类,但是抽象类可以不包含抽象方法。

常用类与接口

Object

Object 类是 Java 中所有类的父类,可以使用 Object 类型的变量引用任何类型的对象。在 Java 中,只有基本类型不是对象,基本类型由于自动装箱可以赋值给 Object 类型的变量。数组类型没有重写 equals、hashcode 和 toString 方法,通常会借助 Arrays 工具类来执行这些操作。

equals 方法

Object 类中的 equals 方法比较两个对象的引用是否相等,源码如下:

1
2
3
public boolean equals(Object obj) {
return (this == obj);
}

如果要比较两个对象的内容是否相等,需要覆盖 equals 方法,通用的实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Employee {
private String name;
private double salary;

public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}

@Override
public boolean equals(Object otherObject) {
// 1.判断对象的引用是否相等
if(this == otherObject) return true;

// 2.判断 otherObject 是否为 null
if(otherObject == null) return false;

// 3.判断 this 与 otherObject 的类型是否相同
if(getClass() != otherObject.getClass()) return false;

// 4.将 otherObject 强制类型转换为相应的类型
Employee other = (Employee) otherObject;

// 5.使用 == 比较基本类型
// 使用 Object.equals 比较除数组之外的引用类型
// 使用 Arrays.equals 比较数组类型
// 不使用 name.equals(other.name) 比较是为了避免空指针异常
// 如果当前类的直接父类不是 Object,还需要调用 super.equals(other) 比较父类中的字段
return Objects.equals(name, other.name)
&& salary == other.salary;
}
}

hashCode 方法

Object 类中的 hashCode 方法是本地方法,它根据对象的存储地址计算得出散列码。如果重新定义 equals 方法,那么就要为可能插入散列表的对象重新定义 hashCode 方法。且 equals 与 hashCode 的定义必须相容:如果 x.equals(y) 返回 true,那么 x.hashCode() 就必须与 y.hashCode() 相等。

clone 方法

Object 类中的 clone 方法为 protected 修饰的本地方法,执行的是浅拷贝。如果要使用 clone 方法,需要在类上实现 Cloneable 标记接口,同时指定 public 修饰符。

String

String 类对象是不可变的,我们只能改变 String 类型的对象变量的值(指针的指向),而不能改变对象本身。

字符串字面量是共享的,存储在常量池中。拼接两个非 final 的 String 对象变量以及执行 substring 操作得到的字符串不是共享的(存储在堆中)。拼接两个 final 的 String 对象变量,编译器会执行常量折叠优化,直接从常量池中获取字符串对象。

思考:为什么说 String 类对象是不可变的?

因为 String 类被 final 修饰不可继承,String 类的 value 字段被 private final 修饰且没有逸出,也没有提供修改该字段的方法(不论方法被什么访问修饰符修饰),String 类对象的 this 引用不会在构造器中逸出。

思考:字符串拼接会有什么性能问题?编译器是如何优化字符串拼接 + 操作的?

如果不优化,每次拼接都会创建一个新的 String 对象。在拼接多次的场景下,拼接产生的中间对象在拼接之后就不会使用,这会浪费创建对象的时间和空间。例如,在循环中拼接:

1
2
3
4
String s = "7";
for (int i = 0; i < 10; i++) {
s += s;
}

如果优化,很容想到使用 StringBuilder 类,在 Java 8 中编译器也是这么做的。但是,不推荐在循环中使用 + 拼接,因为编译器会在循环中创建 StringBuilder 对象。而在 Java 9 之后编译器会使用 invokedynamic 指令,优点是可以动态地选择拼接策略。(或许可以看下这篇文章

包装类

所有的基本类型都有一个对应的包装类(不可变的)。在使用包装类时,可以直接当作基本类型操作,编译器在编译时会自动地插入装箱和拆箱指令。例如:

1
Integer x = 1, y = 2, z = x + y;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
0: iconst_1
1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: astore_1
5: iconst_2
6: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: astore_2
10: aload_1
11: invokevirtual #3 // Method java/lang/Integer.intValue:()I
14: aload_2
15: invokevirtual #3 // Method java/lang/Integer.intValue:()I
18: iadd
19: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: astore_3
23: return

可以看到,编译器会调用 Integer.valueOf 装箱,调用 obj.intValue() 拆箱。似乎对包装类进行运算总是会拆箱转换为基本类型,然后将运算结果装箱,所以效率不是很高。为了提高性能,包装类会在静态初始化块中创建常用对象缓存池,缓存对象的范围如下:Boolean(true 和 false)、Byte | Short | Int | Long(-128 ~ 127)、Character(0 ~ 127),Float 和 Long 类不会缓存对象。

比较器

Comparable 接口

实现 Comparable 接口的类可以进行自然排序,该接口在 java.lang 包下。所谓自然排序,就是指排序时,默认会使用 Comparable 接口的 compareTo 方法排序,而定制排序需要在排序时需要显示传入实现 Comparator 接口的对象。

1
2
3
public interface Comparable<T> {
public int compareTo(T o);
}

使用 compareTo 方法较当前对象与指定对象的大小关系,当前对象小于/等于/大于指定对象时,返回负整数/零/正整数。注意,返回值是 int 类型的整数。

文档建议 CompareTo 方法应当与 equals 方法兼容,即当 x.equals(y) == true 时,x.compareTo(y) == 0。特别的,BigDecimal 类不遵循该建议。

1
2
3
4
BigDecimal x = new BigDecimal("1.0");
BigDecimal y = new BigDecimal("1.00");
System.out.println(x.equals(y)); // false
System.out.println(x.compareTo(y)); // 0

Comparator 接口

如果类的自然排序与需求不匹配,可以定义Comparator 接口的实现类,然后将类对象作为参数传入排序方法中,执行定制排序。Comparator 接口在 java.util 包下。

1
2
3
public interface Comparator<T> {
int compare(T o1, T o2);
}

枚举类

可以使用 enum 关键字创建枚举类,枚举类的构造器默认且必须是私有的,所以在比较时可以直接使用 == 运算符。枚举类实例的定义必须在字段/方法的定义之前。枚举类默认是 Enum 类的子类,所以枚举类型不能显示继承其他类。源代码和反编译得到的字节码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Size {
SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL");

private String abbreviation;

private Size(String abbreviation) {
this.abbreviation = abbreviation;
}

public String getAbbreviation() {
return abbreviation;
}
}
1
2
3
4
5
6
7
8
9
10
11
final class Size extends java.lang.Enum<Size> {
public static final Size SMALL;
public static final Size MEDIUM;
public static final Size LARGE;
public static final Size EXTRA_LARGE;
public java.lang.String abbreviation;
public static Size[] values();
public static Size valueOf(java.lang.String);
public java.lang.String getAbbreviation();
static {};
}

异常

异常层次结构如下,异常被分为检查型异常和非检查型异常。非检查型异常:派生于 Error 类或 RuntimeException 类的所有异常。检查型异常:除非检查型异常以外的所有异常。

一个方法必须声明(throws)或捕获(catch)所有可能抛出的检查型异常,而非检查型异常要么在控制之外(Error),要么从一开始就应该避免(RuntimeException),所以不应该声明。

处理异常的一般经验是,捕获知道如何处理的异常,而继续传播(throw)不知道怎样处理的异常。

try-catch-finally

使用规则:

  • 当 catch 捕获多个异常时,异常变量隐含为 final 变量。
  • 可以在 catch 中使用 initCause 方法将原异常设置为新异常的原因,当捕获到新异常时,可以使用 getCause 方法获取原异常。
  • 不论是否有异常被捕获,finally 中的代码都会在方法返回之前执行执行。
  • finally 用于清理资源,不要把改变控制流的语句(return,throw, break,continue)放在 finally 中。如果在 finally 中包含 return 语句,则 finally 中的 return 会在 try/catch 中的 return 或者 throw 之前执行,有可能会丢失异常。

思考:以下代码在未发生异常和发生异常情况下的返回值分别是多少?(示例源自《深入理解 Java 虚拟机》第 6 章)

1
2
3
4
5
6
7
8
9
10
11
12
public int inc() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}

如果未发生异常,由于 try 在 finally 之前执行,所以返回值已经确定是 1。如果在 try 中发生 Exception 及其子类的异常,由于 catch 也在 finally 之前执行,所以返回值已经确定是 2。如果在 try 中发生其他异常,或者在 catch/finally 中发生任意异常,则方法非正常退出,没有返回值。

try-with-resources

在 Java 7 中,如果资源实现了 AutoCloseable 接口,就可以使用 try-with-resources 语句处理资源。当 try 块退出时,会自动调用资源的 close 方法关闭资源。

使用规则:

  • catch 和 finally 在资源关闭之后执行。
  • 在 Java 9 中,允许在 try 首部中使用之前声明的 final 或 effective final 变量。
  • 如果 try 和 close 都抛出异常,则 close 方法抛出的异常会被抑制,并由 addSuppressed 方法添加到 try 的抛出的异常对象中。如果想查看被抑制的异常,可以使用 getSuppressed 方法获取被抑制的异常数组。

泛型

基本概念

使用泛型的目的是什么?如果不使用泛型,可以向集合类中添加任何类型的对象,并且读取时需要类型判断和强制类型转换。所以,使用泛型的目的是允许编译器进行类型检查,以及避免频繁地强制类型转换。

定义泛型类 class C<T>,定义泛型方法 public static <T> void m(T x)

Java 库使用参数 E 表示集合的元素类型,K 和 V 分别表示表的键和值的类型,T(或者 U 和 S)表示任意类型。

可以使用 extends 关键字对类型参数进行限定 <T extends BoundingType>,表示 T 是限定类型(bounding type)的子类型(subtype),T 和限定类型可以是类或接口。

一个类型参数或通配符可以有多个限定,限定类型用 & 分隔,而类型参数用逗号分隔。如果有一个类作为限定,它必须是限定列表中的第一个限定。

类型擦除

在虚拟机中没有泛型类型,所有对象都属于普通类。无论何时定义一个泛型类型,都会自动提供一个相应的原始类型,这个原始类型的名字就是去掉类型参数后的泛型类型名。类型参数会在编译时被擦除,替换为第一个限定类型,对于无限定的类型参数则替换为 Object 类型。

需要注意,如果将限定 <T extends Comparable & Serializable> 换为 <T extends Serializable & Comparable>,会用 Serializable 替换 T,而这会导致在调用 compareTo 方法时进行额外的强制类型转换。为了提高效率,应该将标记接口放在限定列表的末尾。

(1)当调用的泛型方法的返回类型被擦除,编译器会插入强制类型转换(checkcast 指令)。

(2)编译器会生成桥方法,来避免类型擦除和多态之间的冲突。假设泛型类 Class A<T> 有个方法 public void m(T x),那么它的子类在继承时指定 Class B extends A<Integer>,对应的重写方法是 public void m(Integer x)。类型擦除之后,两个方法的签名不同不构成重写,所以编译器会在子类中生成 public void m(Object x) 方法作为代理(构成重写),从而解决冲突。

泛型的限制和继承规则

使用限制:Restrictions on Generics

继承规则:无论 S 与 T 有什么关系,通常 Pair<S>Pair<T> 都没有任何关系(偏序)。

通配符的限定

子类限定 <? extends Type>,将泛型对象的类型参数限制为 Type 类型或其子类型。此时,如果类型参数作为方法参数,只能传入 null,因为不知道该传入具体哪个子类。如果类型参数作为返回值,只能将返回值赋值给 Type 类型或其父类型的变量(向上转型)。

父类限定 <? super Type>,将泛型对象的类型参数限制为 Type 类型或其父类型。此时,如果类型参数作为方法参数,只能传入 Type 类型或其子类型的变量(向上转型)。如果类型参数作为返回值,只能将返回值赋值给 Object 类型的变量,因为只有 Object 必定是该类型参数的父类。

无限定 <?>,不限制泛型对象的类型参数。此时,如果类型参数作为方法参数,只能传入 null,因为不知道该传入具体哪个类。如果类型参数作为返回值,只能将返回值赋值给 Object 类型的变量,因为只有 Object 必定是该类型参数的父类。

PECS(Producer Extends,Consumer Super)原则:读取数据使用子类限定,写入数据使用父类限定。

自限定的类型 class SelfBound<T extends SelfBound<T>>,SelfBound 的类型参数 T 限定为 SelfBound<T> 的子类。示例如下:

1
2
3
class A extends SelfBound<A> {...}
class B extends SelfBound<A> {...}
class C extends SelfBound<B> {...} // Error

以上代码中,A 继承 SelfBound<A> ,使得 A 可以作为 SelfBound 的类型参数。而 B 没有继承 SelfBound<B>,所以不能将 B 作为 SelfBound 的类型参数。使用自限定的类型,目的是保证 SelfBound 类的类型参数为当前定义的类(例如 A)。虽然 B 继承的 SelfBound 类的类型参数是 A 而不是当前定义的 B,但是一般情况下并不会这样使用。

如果看不懂,可以考虑没有自限定类型的情况:

1
2
3
class SelfBound<T> {...}

class A extends SelfBound<Any-Type> {...}

如果 SelfBound 没有自限定,A 类在继承 SelfBound 时,类型参数可以是任意类型。

反射

反射是 Java 中的一项功能,允许程序在运行时分析类,并且操纵其内部属性。(Using Java Reflection

一个 Class 对象表示一个类型,包括类类型(包括数组类型)、接口类型、基本类型和 void。每个类型的 Class 对象是唯一的,所以可以直接使用 == 运算符比较。

获取 Class 对象的方式如下:使用 obj.getClass() 方法获取对象运行时类型的 Class 对象,使用 Class.forName(xxx) 方法获取指定类的 Class 对象。如果 T 是任意的 Java 类型或 void 关键字,T.class 将代表对应的 Class 对象。特别的,使用 .class 获取 Class 对象的引用时,不会初始化该 Class 对象表示的类,而前两种方式会进行初始化。(类加载的过程:加载、链接和初始化)

注解

可以使用注解(也被称为元数据)在代码中提供额外的信息,然后这些信息可以在编译时解析或运行时利用反射获取。

定义注解

注解的定义类似接口,区别在于需要再 interface 关键字之前加上 @ 符号。声明元素需要在元素名之后加上 (),可以使用 default 关键字为元素指定默认值。注解元素的类型限制:基本类型、String、Class、枚举类型、注解类型,以及由这些类型组成的一维数组。

1
2
3
@interface Demo {
int num() default 1;
}

所有的注解都隐式的继承自 java.lang.annotation.Annotation 接口。如果反编译上述注解的字节码:

1
2
3
interface Demo extends java.lang.annotation.Annotation {
public abstract int num();
}

可以发现,注解就是接口,默认继承自 Annotation 接口。而且在其中定义的元素实际上是接口中的抽象方法,只是可以使用 default 指定默认值。

使用注解

在定义注解时,如果没有为元素指定默认值,则在使用时需要显示的赋值;否则,可以不显示赋值,自动使用默认值。注解元素的值必须是编译期常量,并且不能设置为 null。如果元素值是一个数组,那么要将它的值用 {} 括起来。

1
@AnnotationName(elementName1 = value1, elementName2 = value2, ...)
  • 标记注解:如果注解没有任何元素或者所有元素都提供了默认值,则在使用注解时可以不需要括号。
  • 单值注解:如果注解只有一个名为 value 的元素,则在指定该元素的值时,可以忽略元素名以及赋值运算符。

内置注解

元注解

@Target:指定注解适用的上下文。@Retention:指示注解的保留时长,默认的保留策略为 CLASS。SOURCE 注解将被编译器丢弃。CLASS 注解将由编译器记录在类文件中,但会被 VM 丢弃。RUNTIME 注解将在运行时由 VM 保留,可以使用反射机制读取注解信息。

@Documented:指示将注解包含在 Java 文档中。@Inherited:允许子类继承父类中的注解,当被修饰的注解作用于类时,该元注解才有效。@repeatable:指示注解可以在同一上下文重复使用。

  • 字段和方法上的注解只要没有被覆盖,就会被继承(前提是字段和方法会被继承)。
  • 接口上的注解永远都不会被继承,类上的注解只有在使用 @Inherited 时才会被继承。

标准注解

@Overried:指示当前方法将覆盖父类中的方法或实现接口中的方法。@Deprecated:指示目标被弃用。@SuppressWarnings:抑制目标中给定类型的编译器警告。@SafeVarargs:指示将会安全地操作可变参数。@FunctionalInterface:指示接口为函数式接口。

I/O 流

字节流

所有字节流都继承自 InputStream/OutputStream 抽象类,FileInputStream/FileOutputStream 是基本的字节流。FilterInputStream/FilterOutputStream 内部使用其他字节流对象作为数据来源,没有提供额外的功能,但是其子类会通过重写方法来提供额外的功能。

BufferedInputStream/BufferedOutputStream 对流使用缓冲区技术,每次向流读取/写入时,不必每次都进行实际的物理读取/写入操作。DataInputStream/DataOutputStream 允许从流读取/写入基本数据类型。PrintStream 支持格式化输出。

ByteArrayInputStream/ByteArrayOutputStream 对字节数组进行读取/写入,由于没有使用到文件,所以不需要对该流执行关闭操作。ObjectInputStream/ObjectOutputStream 见序列化。

字符流

所有字符流都继承自 Reader/Writer 抽象类,字符流内部都是基于字节流的。在使用字符流时,注意保证字符的编码和解码方式的一致性。InputStreamReader/OutputStreamWriter 是字节流和字符流之间的桥梁。FileReader/FileWriter 内部使用 FileInputStream,是使用 InputStreamReader/OutputStreamWriter 的快捷方式。

BufferedReader/BufferedWriter 对流使用缓冲区技术,每次向流读取/写入时,不必每次都进行实际的物理读取/写入操作。PrintWriter 是使用 BufferedWriter 的快捷方式,并且支持格式化输出。

标准 I/O 和 NIO

System.in 的类型是 InputStream,System.out 和 System.err 的类型是 PrintStream。可以使用 System 中的 setIn、setOut 和 setErr,对标准 I/O 进行重定向。

对象序列化

序列化就是将对象转换为字节序列的形式,在通过网络传输对象或者将对象存储到磁盘时会进行序列化操作,反序列化同理。需要实现 Serializable 接口以支持对象序列化,可以使用 transient 关键字关闭某个字段的序列化。

在序列化时,会生成对象的序列号和类的序列化版本号(serialVersionUID)。如果将一个对象序列化两次,那么反序列化将得到两个相同的对象。生成的序列化版本号是类的指纹,使用 SHA 计算结果的前 8 个字节表示。在反序列化时,会比较存储的指纹和当前指纹,如果不匹配就说明对象所属类的定义在序列化该对象后修改过,从而会产生 InvalidClassException 异常。此时,如果想要反序列化成功,就需要在类的定义中添加旧版本类的指纹,以此表明它对旧版本兼容。

使用 ObjectOutputStream 的 writeObject 方法将对象序列化,ObjectInputStream 的 readObject 方法将对象反序列化。反序列化时,要保证序列化对象所属的类在类路径中,否则在类型转换时会抛出 ClassNotFoundException 异常。

可以实现 Externalizable 接口对序列化的过程进行控制,该接口继承自 Serializable 接口。

  • Serializable:将对象完全序列化,并且反序列化时不会调用构造器。
  • Externalizable:序列化时调用 writeExternal 方法序列化字段,反序列化时首先调用公共的无参构造器,然后调用 readExternal 方法反序列化字段。

如果不想实现 Externalizable 接口,还可以在类中定义 writeObject/readObject 方法,方法会在序列化/反序列化时自动调用。方法需要按照如下格式定义:

1
2
3
private void writeObject(ObjectOutputStream stream) throws IOException {...}

private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {...}

如果想在 writeObject/readObject 方法中使用默认的序列化/反序列化机制,可以在其中调用 ObjectOutputStream/ObjectInputStream 的 defaultWriteObject/defaultReadObject 方法。

思考:序列化单例有什么问题?如何解决?

如果将单例序列化,在反序列化时将得到不同的对象。解决方案是,在 readResolve 方法中返回单例对象,该方法会在反序列化时被调用。

1
2
3
protected Object readResolve() {
return xxx;
}

容器

集合类会以内部类的形式实现 Iterator 接口。Collection 接口继承自 Iterable 接口,所以可以使用 Iterable 接口中的 iterator 方法获取 Iterator 对象来遍历集合。Map 接口的 entrySet 方法会返回 Collection 类型的对象,所以也支持迭代器遍历。

Collection

List

List 表示有序集合,可以存储重复的元素。

ArrayList

ArrayList 是支持动态扩容的数组,它的底层是一个 Object 类型的数组,所以可以存放任何类型的元素。

虽然底层数组的默认初始容量是 10,但是如果在创建时没有指定容量,并不会立即创建容量为 10 的数组,而是将创建数组的操作延迟到添加元素时。

1
2
3
4
private static final Object[] EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

使用 transient 关键字修饰数组字段,表示不自动序列化,而是在 writeObject 方法中自定义序列化方式。因为数组元素数量小于等于数组容量,只序列化有效元素可以减少空间占用。

1
transient Object[] elementData;
LinkedList

LinkedList 的底层是一个双向链表,实现自 List 和 Deque 接口。

使用 get 方法获取指定索引的值时,会根据索引是否小于链表长度的一半,来决定正序或倒序遍历。如果使用默认序列化,则会丢失头节点和尾节点之间的所有节点,所以使用 transient 修饰相关字段,然后手动序列化链表元素。

1
2
3
transient int size = 0;
transient Node<E> first;
transient Node<E> last;

Set

Set 表示没有重复元素的集合。HashSet 的底层是 HashMap,只是所有的 value 都是相同的单例对象。LinkedHashSet 的底层是 LinkedHashMap,TreeSet 的底层是 TreeMap。

1
2
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

Map

HashMap

HashMap 的底层是数组 + 链表/红黑树,不能保证迭代的顺序,遍历的时间与哈希表的容量和元素数量成正比。

默认容量是 16,负载因子是 0.75。在构造器中,不会创建底层数组,只会将负载因子和阈值初始化,数组的创建延迟到添加元素时。在源代码中,threshold 不只是存储阈值,在构造器中还会临时存储容量,该值会在初始扩容时使用。

当桶中的节点数量大于等于 8 时,如果哈希表的容量小于 64,会执行扩容操作,否则会将链表转为红黑树(树化)。当桶中的节点数量小于等于 6 时,会将红黑树转为链表(取消树化)。

1
2
3
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

计算哈希值时会将高位和低位异或,因为哈希表的容量总是 2 的幂,计算索引值时高位变化不会引起索引变化,从而会产生严重的哈希冲突。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

当元素数量大于扩容阈值时(容量和负载因子的乘积),会将容量扩容为原来的 2 倍。由于容量是 2 倍的关系,所以元素的索引值要么不变,要么加上旧容量的大小,取决于 e.hash & oldCap 的值。

LinkedHashMap

LinkedHashMap 具有确定的迭代顺序,默认是按插入顺序遍历。底层是哈希表 + 双向链表,遍历的时间只与元素数量成正比(通过双向链表遍历)。双向链表使用尾插法,头节点表示最早插入/访问的节点,尾节点表示最近插入/访问的节点。

1
2
3
4
// 双向链表的元素排列方式
// false 按插入顺序排列(默认)
// true 按访问顺序排列(实现 LRU 算法)
final boolean accessOrder;

LinkedHashMap 添加元素的方法继承自 HashMap,但是以下方法被重写,用于实现双向链表的排列和淘汰策略。

1
2
3
4
5
6
7
// 如果是按访问顺序排列,则将访问的结点移动到链表尾部
void afterNodeAccess(Node<K,V> e)

// 在插入元素后调用,内部调用 removeEldestEntry 方法,用于实现淘汰策略
// removeEldestEntry 方法默认返回 false,即不会进行淘汰
// 可以重写该方法结合 accessOrder = true,来实现 LRU 淘汰策略
void afterNodeInsertion(boolean evict)