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)

Java 并发 & 虚拟机

基本概念

并发和并行:关于并发和并行的含义其实存在一些争议,但无非是定义不同。比较常见的说法,并发是指多个任务在单核 CPU 上分时执行,并行是指多个任务在多核 CPU 上同时执行。另一种说法,并发是指多个任务的执行在时间上重叠,在单核 CPU 上表现为分时执行,在多核 CPU 上表现为并行执行,并行是并发的子集。(当然还有其他说法,挺无聊的)另外,在单线程中也可以做到并发,例如 I/O 多路复用。

同步和异步:同步方法在方法执行完成之后才返回,异步方法可以在方法执行完成之前返回。异步方法只意味着非阻塞调用,并不一定是在另一个线程中执行。例如 C# 的 async 方法是在当前线程中执行,在遇到 await 时会将方法的剩余部分注册为延续continuation),然后将控制权返回到方法的调用者,同时返回一个 Task 类型的对象,此时方法的调用者可以执行其他不依赖于方法结果的操作,从而避免阻塞。在 await 任务完成之后,延续会在线程池线程或者原始线程中执行。(参考 Task asynchronous programming model

以上关于单线程中并发和异步的示例,在基于事件的系统中都有体现。事件循环在单个线程中接收和处理事件,通常使用 I/O 多路复用接收事件,使用异步 I/O 实现非阻塞。可以使用轮询判断异步 I/O 是否完成,更好的做法是使用 UNIX 信号在异步 I/O 完成时通知应用程序。如果要在异步 I/O 完成之后执行某些操作,通常会使用一种称为延续的数据结构,记录完成该事件需要的信息。(推荐阅读《OSTEP》第 33 章 基于事件的并发)

思考:什么是死锁?产生条件?如何预防(Prevention)、避免(Avoidance)和检测死锁?

死锁简单来说就是 ABBA 问题,即持有资源 A 的线程要获取资源 B,而持有资源 B 的线程也要获取资源 A。产生死锁的条件:互斥,持有并等待,非抢占,循环等待。死锁避免要求知道全局信息,根据信息来判断分配资源是否会发生死锁,例如银行家算法。死锁检测可以通过构建等待图,然后判断图中是否有环来实现。

死锁预防:① 消除循环等待:使用一致的加锁顺序(全序/偏序),例如根据锁的地址获取锁,或者根据对象的哈希值获取锁。② 消除持有并等待:在执行操作之前原子地获取所有锁,但是很难做到且会有性能问题。③ 消除非抢占:使用 tryLock + unlock 的方式获取锁,但是会有活锁问题(可以通过随机化等待时间来避免),以及需要回滚中间执行的操作。④ 消除互斥:使用非阻塞算法(利用 CAS 原子指令)。

并发包

同步器

AbstractQueuedSynchronizer(AQS)为实现依赖 FIFO 等待队列(以链表形式实现)的同步器提供框架。AQS 使用 int 类型的整数表示状态,使用获取(acquire)和释放(release)操作修改状态。状态以及获取和释放的含义由同步器决定:在 ReentrantLock 中表示重入次数;在 Semaphore 中表示剩余的许可数量;在 ReentrantReadWriteLock 中,高 16 位表示读锁计数,低 16 位表示写锁计数。AQS 内部使用 LockSupport 实现线程的阻塞和唤醒,内部使用类似信号量的许可机制(最多一个许可),即使在 unpark 之后调用 park 也不会导致阻塞。

相比 synchronized 关键字,ReentrantLock 提供可中断锁、定时锁和公平锁,以及支持等待多个条件,但是需要在 finally 中显示地执行解锁操作。公平锁和非公平锁的区别是能否插队,当有其他线程在队列中等待时,公平锁总是会将当前加锁线程入队,而非公平锁会尝试加锁,从而避免更多的上下文切换。

Semaphore 提供指定数量的许可,允许多个线程访问资源。ReentrantReadWriteLock 提供可重入的读写锁,允许在持有写锁的情况下获取读锁,从而实现锁降级(写锁降级为读锁),不支持锁升级(读锁升级为写锁),适用于读多写少的场景。不支持锁升级是因为,如果两个读线程同时进行升级(由于读锁是共享锁),则会发生死锁。

CountDownLatch 允许一个或多个线程等待,直到一组操作完成,计数不能被重置。CyclicBarrier 允许一组线程等待彼此到达屏障,当所有线程到达屏障之后,会执行设置的 barrierAction 动作,然后唤醒所有线程,重置计数器。以上提到的同步器只有 CyclicBarrier 基于 ReentrantLock 实现,其他都是直接基于 AQS 实现的。

线程池

创建线程的方式有三种:继承 Thread 类 + 重写 run 方法;实现 Runnable 接口;实现 Callable 接口,然后构造 FutureTask 对象,FutureTask 实现自 RunnableFuture 接口,所以也可以看作 Runnable 对象。

使用线程池的目的是复用线程,避免创建/销毁线程的开销,以及控制线程的数量。过多的线程会占用大量内存,而且不一定能提高系统的性能,因为 CPU 核心数有限(对于 CPU 密集型负载来说)。Executors 工具类提供创建线程池的工厂方法,可以创建固定/动态大小(ThreadPoolExecutor)、支持计划任务(ScheduledThreadPoolExecutor)、支持工作窃取和分解任务(ForkJoinPool)的线程池。

线程池通常会使用队列存放任务(Runnable 类型),队列可以分为同步队列、有界队列和无界队列。当线程池的线程数量达到设置的最大值,且队列已满(不是无界队列),则会执行拒绝策略。JDK 内置的拒绝策略有:抛出异常、丢弃当前任务、丢弃最早的任务、在当前线程执行该任务。

当线程池的线程数量超过 corePoolSize 时,空闲线程在超时之后被销毁。如果使用 ExecutorService 的 submit 提交任务,需要注意异常会被捕获到返回的 Future 对象中。如果没有任务,在 corePoolSize 范围内的线程会在获取任务时被阻塞队列阻塞。如果要终止线程,可以调用 shutdown 方法,然后可以调用 awaitTermination 等待。

并发容器

可以使用 Collections.synchronizedxxx() 方法获取线程安全的容器,但是获取的容器只是简单地对所有方法使用 synchronized 关键字来实现线程安全。CopyOnWriteArrayList 读操作不需要加锁,写操作加锁且不会修改原数组,而是执行写时复制(COW)。ConcurrentLinkedQueue 是非阻塞的无界队列,没有使用锁而是只用 CAS 实现线程安全。

ArrayBlockingQueue 是有界阻塞队列,使用单个的 ReentrantLock 实现线程安全,使用两个 Condition(notEmpty 和 notFull)实现阻塞等待,不过也可以调用非阻塞的方法(在队列满/空时直接返回 false/null)。LinkedBlockingQueue 是无界阻塞队列,使用两个 ReentrantLock(putLock 和 takeLock)实现线程安全,同样使用两个 Condition 实现阻塞等待。

可以使用 Unsafe 或者 VarHandle 实现 CAS 操作。AtomicInteger 使用 CAS 保证原子性,AtomicStampedReference 使用版本号解决 CAS 的 ABA 问题,AtomicIntegerArray 提供原子修改数组的方法,AtomicIntegerFieldUpdater 使用反射和 Unsafe 提供对 volatile 字段原子更新的方法。

如果要设计线程安全的哈希表,最简单的方式是使用 synchronized 关键字修饰所有方法,但是并发性很差。首先,可以想到减少锁的粒度,将单个独占锁分解为每个桶一个锁(结合 CAS 提高性能)。但是,计数操作会修改共享变量,如果使用独占锁将成为性能瓶颈。如果使用原子变量进行计数,在高并发下性能不会更好,由于缓存失效以及频繁的重试。解决方案依然是减少锁的粒度,可以使用类似 LongAdder 的做法。接下来可以思考扩容问题,多个线程辅助扩容可以加快速度,基本上 ConcurrentHashMap 就是这样设计的。

锁优化

思考:减少锁竞争的方式有哪些?

减小锁的范围,减小锁的粒度,读写锁/读写分离,线程私有。

ThreadLocal 的构造函数是空的,所以创建的对象没有和线程绑定,只有当调用 get/set 方法之后才会绑定。实际上,Thread 类有一个 ThreadLocalMap 类型的实例字段,set 方法会获取当前 ThreadLocal 对象的哈希值,使用 WeakReference 类型的数组存储 key/value(类似 WeakHashMap),key 就是 ThreadLocal 对象,value 就是 set 的参数。(该哈希表使用的是开放寻址法处理冲突)

通常所说的 ThreadLocal 存在内存泄露问题是指,当不再使用设置的 value 时(假设是很大的对象),如果存在该 ThreadLocal 的强引用,或者即使该 ThreadLocal 对象已经被回收,但是之后没有对 ThreadLocalMap 做操作,依然无法回收 value 对象。因为 ThreadLocalMap 的 Entry 对 value 有强引用,而只有在执行下一次 set/remove 操作时,该 Entry 以及 Entry 的 value 才会变为不可达的,所以最好在不使用时显式地执行 remove 操作。

思考:HotSpot 虚拟机对 synchronized 的优化有哪些?(详见《深入理解 Java 虚拟机》第 13 章)

自适应的自旋:动态调整自旋的时间,尽量避免重量级锁(互斥锁)的上下文切换开销,以及避免自旋过久占用 CPU 资源的开销。

锁消除:虚拟机在即时编译时,对不可能发生竞争的锁进行消除(依赖逃逸分析)。

锁粗化:如果连续地对相同对象加锁再解锁,会导致不必要的性能损耗,此时虚拟机会加大锁的范围(合并锁)。

轻量级锁:当目标对象没有被锁定,虚拟机会使用 CAS 获取对象的轻量级锁,目的是避免重量级锁的上下文切换开销。如果目标对象被轻量级锁定,那么自旋一段时间,超时之后升级为重量级锁。或者,如果有两个线程在等待获取轻量级锁,那么将锁升级为重量级锁。(轻量级锁本质上是复制对象的 Mark Word 到当前线程的栈帧中,然后使用 CAS 修改该对象的 Mark Word)

偏向锁:对象锁会偏向第一个加锁的线程,目的是减少无竞争情况下轻量级锁的 CAS 开销。即如果线程 A 对某个对象加锁(使用 CAS 修改 Mark Word),且该对象是第一次被加锁,那么线程 A 之后再次获取该对象锁时就不需要执行实际的加锁操作。但是,只要有其他线程尝试获取该对象的锁,那么偏向锁就会被撤销,变为未锁定或者轻量级锁定状态。(简单来说是这样,当然还有重偏向,以及如果调用过未重写的 Object::hashCode() 方法就不能偏向,之类的东西)

思考:轻量级锁和偏向锁优化什么情况下可以提升性能?什么情况下会降低性能?

轻量级锁和偏向锁都假设锁竞争发生的概率很小,如果真的发生(激烈的)锁竞争,那么轻量级锁和偏向锁很快就会升级为重量级锁,“优化”反而会额外增加轻量级锁 CAS 操作的开销以及撤销偏向的开销。

在 Java 15 中,偏向锁已弃用,详见 JEP 374: Deprecate and Disable Biased Locking。主要原因是偏向锁的实现代码复杂且具有侵入性,妨碍 HotSpot 虚拟机中同步系统设计的更改,以及在当前环境下偏向锁的适用范围有限。

内存区域

简单来说:程序计数器存储当前执行的字节码指令的地址;栈存储方法级别的数据,方法调用会创建栈帧,存储局部变量、返回地址等数据,有虚拟机栈和本地方法栈两种;堆存储对象数据;方法区也被称为类区,存储类级别的数据,包括类的字节码、静态变量和常量(在运行时常量池中)等数据。

思考:HotSpot 虚拟机使用 new 关键字创建对象的过程?对象在堆中的内存布局?

检查 new 指令的参数是否在运行时常量池中有对应的符号引用,然后检查该符号引用代表的类是否执行过类加载(区分类加载和加载,类加载包括加载、链接和初始化)。如果类加载完成,则为对象分配内存(使用 CAS + 重试保证原子性,或者使用线程私有内存),然后设置对象头。最后调用构造器执行初始化。

对象存储在堆中,由对象头、实例数据和对齐填充组成。对象头存储运行时数据(Mark Word)和指向类型元数据的指针。实例数据存储字段值,包括继承的字段,默认相同宽度的字段会被存放在一起,在该前提下父类字段会在子类之前。对齐填充的作用是内存对齐,HotSpot 虚拟机使用的是 8 字节对齐。

垃圾收集

基本概念

由于程序计数器、虚拟机栈和本地方法栈是线程私有的,占用的内存随方法返回或者线程结束而回收,所以不是垃圾收集器关注的重点。而堆和方法区是线程共享的,占用的内存何时回收是动态的,垃圾收集器会通过可达性分析判断什么对象可以回收。

最简单的想法是使用引用计数判断对象是否可以被回收,但是无法处理循环引用的问题。所以,通常是通过图的可达性分析来判断对象是否可以被回收,实际上就是维护一个可达的 GC Roots 的对象集,如果某个对象从 GC Roots 不可达,那么该对象就可以被回收。

引用类型:强引用、软引用、弱引用和虚引用。除强引用外的其他引用都对应一个继承自 Reference 抽象类的类,可以存储对其他对象的引用(称为 referent)。简单来说,强可达的对象不会被回收,软可达的对象会在内存溢出之前被回收,弱可达的对象会在下次垃圾收集时被回收,虚可达的对象已经是 finalized 的,不可达的对象可以被回收。(参考 java.lang.ref 文档)

思考:什么是 finalization 机制?finalize 方法为什么被弃用?

当对象不可达时,如果对象没有重写 finalize 方法,则对象可以直接被回收。如果对象已重写 finalize 方法且该方法没有被调用过,则对象是 finalizable 的。虚拟机的 Finalizer 线程会将该对象加入队列排队,等待调用其 finalize 方法,调用之后对象是 finalized 的。如果对象是 finalized 且依然不可达,那么对象就会被回收变为 reclaimed。

垃圾收集器至少需要两个周期才能回收 finalizable 对象,并且被该对象引用的所有不可达对象会被保留,直到该对象被回收。此外,JVM 不保证会调用所有 finalizable 对象的 finalize 方法。(推荐阅读 How to Handle Java Finalization’s Memory-Retention Issues

finalization 机制存在问题,会导致性能问题、死锁和挂起。finalizer 的错误可能导致资源泄露;没有办法取消 finalization;不同对象的 finalize 方法调用之间没有指定的顺序。此外,无法保证 finalization 的完成时间。finalize 方法只能在 finalizable 对象上经过不确定的延迟之后调用。(参考 finalize 文档)

回收算法

分代收集理论:假设对象存活概率随对象年龄(经历垃圾收集的次数)的增加而增加,且跨代引用很少发生,则可以根据对象年龄,将堆划分成不同的区域(例如,新生代和老年代),然后使用不同的频率进行回收。相对于扫描所有对象而言,能够有效减少时间开销。

类加载机制

类的生命周期有 7 个阶段,各个阶段通常是交叉混合执行的,解析阶段可能在初始化之后开始。通常所说的类加载是指加载、连接和初始化。简单来说,加载阶段将类加载到方法区,验证阶段验证字节码是否符合规范,准备阶段执行静态字段的默认初始化(有例外情况),解析阶段将常量池的符号引用替换为直接引用。初始化阶段执行编译器生成的类构造器 <clint>() 方法(不是对象构造器),包括静态字段的显示初始化和静态初始化块。

类加载器用于实现加载阶段的“通过类的全限定名获取该类的二进制字节流”动作。在虚拟机中,类由类加载器和类的全限定名唯一确定。如果相同的类被不同类加载器加载,那么 instanceof 的判断结果就是 false。双亲委派机制是指,类加载器总是会将加载请求委派给父类加载器(父子类加载器是组合关系而不是继承关系),只有当父类加载器无法完成该加载请求时,子类加载器才会尝试加载,从而确保核心类(例如 Object)的唯一性。