javaSE笔记(自用)
JAVA SE快速入门
class | 类 |
---|---|
method | 方法 |
field | 域,指一种属性,可以是类变量、方法变量、参数等 |
instance | 实例 |
注意区分变量和实例
· Person ming = new Person();
Person ming
是定义Person
类型的变量ming
,而new Person()
是创建Person
实例。
· List
List<List
使用ArrayList继承List类,因为List是一个接口,不允许直接实例化。https://www.cnblogs.com/geili/p/10193060.html
简介
Java代码本质是一种字节码,类似于抽象的CPU指令,针对不同平台编写虚拟机,通过虚拟机加在字节码执行,实现多平台开发。
- JAVA SE:标准版
- JAVA EE:企业版(加入大量API和库)
- JAVA ME:迷你版(针对嵌入式的瘦身版)(SE的标准库在ME上无法使用)
先学习Java SE,掌握Java语言本身、Java核心开发技术以及Java标准库的使用;
如果继续学习Java EE,那么Spring框架、数据库开发、分布式架构就是需要学习的;
特性:简单性、面向对象、可移植性、高性能、分布式(URL)、动态性(反射)、多线程、安全性、健壮性
JDK和JRE:
JDK:依赖包(JAVA Developement Kit)从源码到字节码
JRE:运行环境(Java Runtime Environment)类似虚拟机
JDK包括JRE,另外还提供了编译器和调试器等开发工具
JSR:接口规范要求(Java Specification Request),给JAVA加功能时先穿件一个JSR规范,定义好接口,一个JSR发布时应该同时发布示例代码。
JVM:JAVA虚拟机
运行过程:
源代码被Java编译器转化成字节码(Byte Code),然后通过JVM这个虚拟机,字节码就能在各个平台上运行。
hello.java
public class Hello{
//以类(class)为结构展开,大小写敏感,public表示类是公开的。一个Java源码只能定义一个Public类型的class
public static void main(String[] args){
// 定义一个main方法,其中void是返回类型,String[]是参数类型,参数名为args,public和static修饰方法(公开且静态)
System.out.println("Hello World!");
// java使用‘;’结尾,缩进一般为4个空格
}
}
注:Java程序总是从main方法开始执行
保存文件时,文件名必须为Hello.java,即要和类名保持一致
两种运行方法:
- java Hello.java直接运行源码(仅用于单个不依赖第三方库的Java源码!)
- javac Hello.java ——> 产生Hello.class; java Hello ——>执行类
小结
- 一个Java源码只能定义一个
public
类型的class,并且class名称和文件名要完全一致; - 使用
javac
可以将.java
源码编译成.class
字节码; - 使用
java
可以运行一个已编译的Java程序,参数是类名。
基础知识
程序基本结构
public class Hello{ //访问修饰符 类关键词 类名
public static void main(String[] args){ //访问修饰符 修饰符 返回类型 方法名(参数类型 参数名)
System.out.println("Hello World!");
}
}
以hello为例,JAVA是面向对象语言,一个程序的基本单位是class
Public是访问修饰符,如果没有这个修饰类也能编译,但不能从命令行执行
类中可以定义若干的方法(Method),如main
static表示静态方法,也是修饰符
//static 修饰符可以让方法的适用范围变为全局,不需要通过具体的实例
//如果没有static就需要通过具体的实例来执行。
public class Calculator {
public sum2(int num1, int num2) {
return num1 + num2;
}
public static void main(String[] args) {
Calculator calculator = new Calculator();
System.out.println(calculator.sum2(1, 2));
}
}
JAVA入口程序规定的方法必须是静态方法,方法名必须是main,括号内的参数必须是String数组。
注释:采用“/** **/” 跨行注释时,放于类和方法定义处,可以自动创建文档
变量
基本类型变量
先定义后使用,定义是可以给初始值(默认初始0),定义阶段要指明变量类型。可以重复赋值
public class Main{
public static void main(String[] args){
int n = 100;
int x = n+100;
System.out.println("x = "+x);// 这里的+值的是输出第二个内容的意思。即括号内改成 "n="+"x",则输出"n = x"
}
}
变量类型:(4+2+1+1)
- 整数类型:byte(1字节),short(2字节),int(4字节),long(8字节)
- 浮点数类型:float(4字节),double(8字节)
- 字符类型:char(2字节)(可以表示中文,即unicode)
- 布尔类型:boolean(通常存作4字节)
不同进制表示同一个数是完全相同的 15=
0xf=
0b1111
引用类型变量
类似指针
eg : String s = "hello"
定义变量时加上final修饰符,变量成为常量 final double PI = 3.14
var关键字:
var sb = new StringBuilder();
//编译器会推断出sb是StringBuilder,使得以下两句等价
StringBuilder sb = new StringBuilder();
var sb = new StringBuilder();
变量作用范围:
在语句块中定义的变量,它有一个作用域,就是从定义处开始,到语句块结束。
小结
- Java提供了两种变量类型:基本类型和引用类型
- 基本类型包括整型,浮点型,布尔型,字符型。
- 变量可重新赋值,等号是赋值语句,不是数学意义的等号。
- 常量在初始化后不可重新赋值,使用常量便于理解程序意图
整数运算
/ :整除
% :取余
注:数值溢出的时候不会报错,但会返回奇怪的结果(采用long类型避免溢出)
++n
表示先加1再引用n,n++
表示先引用n再加1(不建议用于常规运算)
左移处理(<<)等同于十进制2*
对负数右移(>>),最高位的1不限,即仍然为负数(但>>>则为无符号右移,可以改变正负)
与或非:&、|、 -
异或: ^
int和short运算时,输出为int(运算前short被转为int)
强制转型可能报错,int转short,则丢失2个高位字节
public class Main{
public static void main(String[] args){
int i1 = 1234567
short s1 = (short) i1; //-10617
System.out.println(s1);
int i2 = 12345678;
short s2 = (short) i2; //24910
System.out.println(s2)
}
}
小结
- 整数运算的结果永远是精确的;
- 运算结果会自动提升;
- 可以强制转型,但超出范围的强制转型会得到错误的结果;
- 应该选择合适范围的整型(
int
或long
),没有必要为了节省内存而使用byte
和short
进行整数运算。
浮点数运算
只能做数值运算,不能做位运算和移位运算。
浮点数存在无法精确表示的问题:二进制无法表示某些小数,只能趋近(如0.1)
故比较浮点数大小采用减法进行判断
// 比较x和y是否相等,先计算其差的绝对值:
double r = Math.abs(x - y);
// 再判断绝对值是否足够小:
if (r < 0.00001) {
} else {
}
参与运算整数去兼容浮点数
浮点数可以做0的除法
double d1 = 0.0 / 0; // NaN
double d2 = 1.0 / 0; // Infinity
double d3 = -1.0 / 0; // -Infinity
浮点到整形要进行四舍五入可以通过+0.5实现。
布尔运算
优先级:
!
>
,>=
,<
,<=
==
,!=
&&
||
短路运算:如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。
eg:boolean b = 5 < 3;
boolean result = b && (5 / 0 > 0); // b已经false不用计算后续,否则 5 / 0 将报错
三元计算:
b ? x : y // b为true则运行x,否则y,返回选中的操作
小结
- 与运算和或运算是短路运算;
- 三元运算
b ? x : y
后面的类型必须相同,三元运算也是“短路运算”,只计算x
或y
。
字符和字符串
中文英文都占用2字节,显示unicode编码只需要将char复制给int
转义符
可以用\u+unicode编码表示一个字符
在需要表示 “ 字符时,使用 \” 表示,\被称为转义字符(\ \ 则表示\)
\"
表示字符"
\'
表示字符'
\\
表示字符\
\n
表示换行符\r
表示回车符\t
表示Tab- 3
- ++
\u####
表示一个Unicode编码的字符
可以通过 + 连接任意字符串和其他数据类型,以便字符串处理。
可以通过char(Unicode)转为字符
多行字符串:
采用"""..."""
表示多行字符串如(共同空格将被去掉):
public class Main {
public static void main(String[] args) {
String s = """ // 开始
users
WHERE id > 100
ORDER BY name DESC
"""; // 结束
System.out.println(s); //包括5行,因为DESC后相当于还有一个\n
}
}
字符串是不可变的引用变量——变的只会是变量的指向
public class Main {
public static void main(String[] args){
String s = "hello";
System.out.println(s);
s = "world";
System.out.println(s);
}
}
public class Main {
public static void main(String[] args) {
String s = "hello";
String t = s;
s = "world";
System.out.println(t); // 输出"hello"
}
}
创建引用变量可以指向一个空值(null),注意null不等于"",后者是空字符串。
小结
- Java的字符类型
char
是基本类型,字符串类型String
是引用类型; - 基本类型的变量是“持有”某个数值,引用类型的变量是“指向”某个对象;
- 引用类型的变量可以是空值
null
; - 要区分空值
null
和空字符串""
。
数组类型
int[] ns = new int[5] //其中int为数组元素类型,5为数组大小
int[] ns = new int[] { 68, 79, 91, 85, 62 }; //直接初始化元素就不必定义数组大小
数组是引用类型,数组一旦创建后,大小就不可改变。
可以用ns.length获取数组大小(ns为数组名)
如果用ArrayList即动态数组初始化时就不用限制数组大小
字符串数组:
如果数组元素不是基本类型,而是一个引用类型如字符串,对于String[]
类型的数组变量names
,它实际上包含3个元素,但每个元素都指向某个字符串对象。对其中元素赋新值时效果如下图:
小结
- 数组是同一数据类型的集合,数组一旦创建后,大小就不可变;
- 可以通过索引访问数组元素,但索引超出范围将报错;
- 数组元素可以是值类型(如int)或引用类型(如String),但数组本身是引用类型;
流程控制
输入输出
输出
System.out.println() //换行
System.out.print() //不换行
System.out.printf("%.4f\n", d); // 显示4位小数3.1416,格式化显示
占位符 | 说明 |
---|---|
%d | 格式化输出整数 |
%x | 格式化输出十六进制整数 |
%f | 格式化输出浮点数 |
%e | 格式化输出科学计数法表示的浮点数 |
%s | 格式化字符串 |
https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/Formatter.html#syntax
输入
System.in 即标准输入流,通过Scanner类简化in的后续操作
scanner.nextLine()
读取用户输入的字符串
scanner.nextInt()
读取用户输入的整数
import java.util.Scanner;
public class Main{
public static void main(String[] args){
Scanner sanner = new Scanner(System.in);// 创建Scanner对象
System.out.print("Input your name: "); // 打印提示
String name = scanner.nextLine(); // 读取一行输入并获取字符串
System.out.print("Input your age: "); // 打印提示
int age = scanner.nextInt(); // 读取一行输入并获取整数
System.out.printf("Hi, %s, you are %d\n", name, age); // 格式化输出
}
}
小结
Java提供的输出包括:System.out.println()
/ print()
/ printf()
,其中printf()
可以格式化输出;
Java提供Scanner对象来方便输入,读取对应的类型可以使用:scanner.nextLine()
/ nextInt()
/ nextDouble()
课后题:
import java.util.Scanner;
// 输入两次成绩,返回保留两位小数的提升百分比
public class Main {
public static void main(String[] args){
Scanner scanner = new Scanner(System.in);
System.out.println("Input your last score:");
int score1 = scanner.nextInt();
System.out.println("Input your this score:");
int score2 = scanner.nextInt();
double percent = 100.0*(score2-score1)/score1;
System.out.printf("improve:%.2f%%",percent);
}
}
if判断
当if
语句块只有一行语句时,可以省略花括号{};但仍要换行。
样例:
if (n >= 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
注意边界条件。
注:判断浮点数的 == 时,通常采用减法后的某个数 < 某个临界值来判断。
当两个引用类型的变量指向不同对象但内容相同时,用==判断的结果也为false,应当使用equals() 方法
如:s1.equals(s2)
小结
if ... else
可以做条件判断,else
是可选的;- 不推荐省略花括号
{}
; - 多个
if ... else
串联要特别注意判断顺序; - 要注意
if
的边界条件; - 要注意浮点数判断相等不能直接用
==
运算符; - 引用类型判断内容相等要使用
equals()
,注意避免NullPointerException
。
switch多重选择
switch (option) {
case 1: //case判定字符串时是判断内容,不是地址
System.out.println("Selected 1");
break;
case 2:
System.out.println("Selected 2");
break;
default:
System.out.println("Not selected");
break;
}
用break跳出选择,如果没有break将在选择到的语句后一直执行直到出现break
用default表示没有匹配到任何case时执行
新版java中采用 ->{} 连接匹配语句避免穿透执行。并且可以直接返回值
public class Main {
public static void main(String[] args) {
String fruit = "apple";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> 0;
}; // 注意赋值语句要以;结束
System.out.println("opt = " + opt);
}
}
yield
大多数时候,在switch
表达式内部,我们会返回简单的值。但如果需要复杂的语句,我们也可以写很多语句,放到{...}
里,然后,用yield
返回一个值作为switch
语句的返回值
public class Main {
public static void main(String[] args) {
String fruit = "orange";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> {
int code = fruit.hashCode();
yield code; // switch语句返回值
}
};
System.out.println("opt = " + opt);
}
}
小结
switch
语句可以做多重选择,然后执行匹配的case
语句后续代码;switch
的计算结果必须是整型、字符串或枚举类型;- 注意千万不要漏写
break
,建议打开fall-through
警告; - 总是写上
default
,建议打开missing default
警告; - 从Java 14开始,
switch
语句正式升级为表达式,不再需要break
,并且允许使用yield
返回值。
while循环
先判断是否成立,然后循环
while( 条件 ){ 语句 }
do while
另外还有do while,区别在于,do while先进行一次循环主体再进行判定
do{语句}while(条件);
小结
while
循环先判断循环条件是否满足,再执行循环语句;while
循环可能一次都不执行;- 编写循环时要注意循环条件,并避免死循环。
do while
循环先执行循环,再判断条件;do while
循环会至少执行一次。
for 循环
与python基本一致
for (初始条件; 循环检测条件; 循环后更新计数器) {}
注,在循环条件内尽量不要修改计数器以避免逻辑错误
for each
常用于遍历数组
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) { //不需计数器,直接是数组元素
System.out.println(n);
}
可以直接用 i : ArrayName
小结
for
循环通过计数器可以实现复杂循环;for each
循环可以直接遍历数组的每个元素;- 最佳实践:计数器变量定义在
for
循环内部,循环体内部不修改计数器;
break和continue
break跳出当前这层循环。当存在循环嵌套的情况时,我们可以采用定义循环名的形式指定跳出的循环层(默认为当前层的循环)
loop: while(true){
Scanner sc = new Scanner(System.in);
String choose = sc.next();
switch (choose){
case "5" -> {
System.out.println("退出");
break loop;
//此处跳出的循环为最外层的loop循环
}
}
}
continue会结束本次循环,进入下次循环
小结
break
语句可以跳出当前循环;break
语句通常配合if
,在满足条件时提前结束整个循环;break
语句总是跳出最近的一层循环;continue
语句可以提前结束本次循环;continue
语句通常配合if
,在满足条件时提前结束本次循环
数组操作
遍历
通过 for循环
打印数组时需要注意,System.out.println(ns) 将打印ns数组的引用地址
采用for循环或者Arrays.toString()
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
}
}
小结
- 遍历数组可以使用
for
循环,for
循环可以访问数组索引,for each
循环直接迭代每个数组元素,但无法获取索引; - 使用
Arrays.toString()
可以快速获取数组内容。
排序
冒泡、插入和快速
冒泡排序
每次循环最大的数交换到末尾
功能函数:Arrays.sort(ns) ——> 在ns上直接做修改,即数组的指向内容已经变化
小结
- 常用的排序算法有冒泡排序、插入排序和快速排序等;
- 冒泡排序使用两层
for
循环实现排序; - 交换两个变量的值需要借助一个临时变量。
- 可以直接使用Java标准库提供的
Arrays.sort()
进行排序; - 对数组排序会直接修改数组本身。
多维数组
二维数组:
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
加入定义普通数组,并把ns[0]赋值给它,则它获得第一个数组
二维数组的每个数组元素的长度并不要求相同,例如,可以这么定义ns
数组:
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};
要打印可以用两层for循环,或者使用Java标准库Arrays.deepToString()
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
System.out.println(Arrays.deepToString(ns));
}
}
三维数组:
小结
- 二维数组就是数组的数组,三维数组就是二维数组的数组;
- 多维数组的每个数组元素长度都不要求相同;
- 打印多维数组可以使用
Arrays.deepToString()
; - 最常见的多维数组是二维数组,访问二维数组的一个元素使用
array[row][col]
。
命令行参数
java的命令入口是main方法,而main方法可以接受一个命令行参数,是一个String[],这个命令行参数由JVM接收用户输入并传给main。
可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version
参数,打印程序版本号:
public class Main {
public static void main(String[] args) {
for (String arg : args) {
if ("-version".equals(arg)) {
System.out.println("v 1.0");
break;
}
}
}
}
在命令行执行,需要再传递一个参数
javac Main.java
java Main -version
v 1.0 //输出
小结
- 命令行参数类型是
String[]
数组; - 命令行参数由JVM接收用户输入并传给
main
方法; - 如何解析命令行参数需要由程序自己实现。
面向对象编程
面向对象和面向过程:
面向过程(按照步骤一步一步来):把模型分解成一步一步的步骤,读取文件——>编写——>保存
面向对象:和对象互动,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
GirlFriend gf = new GirlFriend(); //类初始化
gf.name = "Alice";
gf.send("flowers");
面向对象基础
类:class
实例:instance
举例:
现实世界 | 计算机模型 | Java代码 |
---|---|---|
人 | 类 / class | class Person |
小明 | 实例 / ming | Person ming = new Person() |
小红 | 实例 / hong | Person hong = new Person() |
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型;
而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同;
class可以当做一个荷包蛋模具,instance可以当做一个个独特的荷包蛋
定义class
在Java中,创建一个类,例如,给这个类命名为Person
,就是定义一个class
:
class Person {
public String name;
public int age;
}
一个class
可以包含多个字段(field
),字段用来描述一个类的特征。
上面的Person
类,我们定义了两个字段,一个是String
类型的字段,命名为name
,一个是int
类型的字段,命名为age
。因此,通过class
,把一组数据汇集到一个对象上,实现了数据封装。
public
是用来修饰字段的,它表示这个字段可以被外部访问。
创造实例
用new操作符。 Person ming = new Person();
Person ming
是定义Person
类型的变量ming
,而new Person()
是创建Person
实例。用 变量.字段 来进行操作和访问。 eg :
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
System.out.println(ming.name); // 访问字段name
Person hong = new Person();
hong.name e = "Xiao Hong";
hong.age = 15;
两个instance
拥有class
定义的name
和age
字段,且各自都有一份独立的数据,互不干扰。
一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
小结
- 在OOP中,
class
和instance
是“模版”和“实例”的关系; - 定义
class
就是定义了一种数据类型,对应的instance
是这种数据类型的实例; class
定义的field
,在每个instance
都会拥有各自的field
,且互不干扰;- 通过
new
操作符创建新的instance
,然后用变量指向它,即可通过变量来引用这个instance
; - 访问实例字段的方法是
变量名.字段名
; - 指向
instance
的变量都是引用变量。
方法
方法创建和使用
一个class
可以包含多个field
,例如,我们给Person
类就定义了两个field
;但是直接把field
用public
暴露给外部可能会破坏封装性。
可以用private修饰field,拒绝外部访问。 private String name;
此时不能直接访问field(如 ming.name = "Xiao Ming"; ),使用方法来让外部代码可以间接修改field。
在类中定义访问类私有属性的方法:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}
class Person {
private String name;
private int age;
public String getName() {
return this.name;
}
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid name");
}
this.name = name.strip(); // 去掉首尾空格
}
public int getAge() {
return this.age;
}
public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}
上例中你可以用public方法setName和setAge来修改private字段。采用 实例变量.方法名(参数); 来调用方法语句。如:ming.setName("Xiao Ming");
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
另外在类中也可以定义private方法,注意仅可用于该类中
this变量中指向当前实例,因此 this.field可以访问当前实例的字段。(避免存在命名冲突的情况)
方法参数
可以是任意个的参数,用于传递变量。(注意参数的类型和数量)
class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}
或者可以采用可变参数,可变参数用类型...
定义,可变参数相当于数组类型,如:(也可以采用数组,即String[]来改写。)
注意:一个方法的输入中最多有一个可变参数,且要被放置于最后
class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}
此时setName调用时可以写作:
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
参数绑定
基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
而当传递引用类型参数时,把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // 是"Bart Simpson"
}
}
class Person {
private String[] name;
public String getName() {
return this.name[0] + " " + this.name[1];
}
public void setName(String[] name) {
this.name = name;
}
}
注: 上述实例的属性发生变化,是因为采用的是编辑进行操作,而如果重新赋值(即改变这个变量的指向地址,而不是变量本身),输出就不会发生变化。
fullname = new String[] { "Homer1", "Simpson1" };
System.out.println(p.getName()); //输出将为上一次的结果
小结
- 方法可以让外部代码安全地访问实例字段;
- 方法是一组执行语句,并且可以执行任意逻辑;
- 方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);
- 外部代码通过public方法操作实例,内部代码可以调用private方法;
- 理解方法的参数绑定。
构建方法
实现在创建对象实例时就把内部字段全部初始化为合适的值
调用时必须用new操作符,且该方法没有返回值
public class Main {
public static void main(String[] args) {
Person p = new Person("Xiao Ming", 15);
System.out.println(p.getName());
System.out.println(p.getAge());
}
}
class Person {
private String name;
private int age;
//构建方法的名字要是类名,用于初始化,且不能有返回值
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
如果没有人为设定,那么构造方法默认为
public Person() {
}//即为空
如果希望创建时既可以初始化也可以不初始化,可以在构造方法中都定义出来:
public class Main {
public static void main(String[] args) {
Person p1 = new Person("Xiao Ming", 15); // 既可以调用带参数的构造方法
Person p2 = new Person(); // 也可以调用无参数构造方法
}
}
class Person {
private String name = "Unamed";//也可以在类内部初始化
private int age = 10;
public Person() {
}
// 此处为两种初始化格式
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
当在类内部和构造方法中都进行初始化时,由于先内部初始化再执行构造方法,故最终得到的初始化是构造方法中的。
多个构造方法时,会根据输入参数的数量、位置、类型自动选择,一个构造函数还可以调用另一个构造函数(用this.实现):
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}
public Person() {
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}
小结
- 实例在创建时通过
new
操作符会调用其对应的构造方法,构造方法用于初始化实例; - 没有定义构造方法时,编译器会自动创建一个默认的无参数构造方法;
- 可以定义多个构造方法,编译器根据参数自动判断;
- 可以在一个构造方法内部调用另一个构造方法,便于代码复用。
方法重构
方法名相同,但各自的参数不同,称为方法重载(Overload
)。返回值通常都相同。
小结
- 方法重载是指多个方法的方法名相同,但各自的参数不同;
- 重载方法应该完成类似的功能,参考
String
的indexOf()
; - 重载方法返回值类型应该相同。
继承
类A 包含大量类B 已有的字段和方法,将类A的方法继承给类B,则类B获得类A 的所有功能(除了private的),只需要写新增功能。用extends实现:class Student extends Person(Student继承Person)。若用protected代替,则可以被子类继承和访问。
class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}
注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
术语中,我们把Person
称为超类(super class),父类(parent class),基类(base class),把Student
称为子类(subclass),扩展类(extended class)。
所有没有特殊extend的类都是Object的子类。Java只允许一个class继承自一个类(但可以有复数个子类),因此,一个类有且仅有一个父类。只有Object
特殊,它没有父类。
使用关键字时用super来实现。如
class Student extends Person {
public String hello() {
return "Hello, " + super.name;//super表示从父类继承
}
}
注:任何class
的构造方法,第一行语句必须是调用父类的构造方法。如果没有,编译器自动视为加入一句super();此时若父类没有该调用方法的构造方法则报错。
子类不会继承任何父类的构造方法,子类默认的构造方法是编译器生成的,而不是继承的。
限制继承
用sealed修饰class并通过permits写出能让class继承的子类。
public sealed class Shape permits Rect, Circle, Triangle {
...
} //这里Shape就是一个sealed类,只允许Rect, Circle, Triangle继承
向上转型
即 Person p = new Student(); // 用Student类为模板新建一个Person实例
把子类类型变为父类类型称为向上转型。注意到继承树是Student > Person > Object
,所以,可以把Student
类型转型为Person
,或者更高层次的Object
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型。
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
本身是子类类型的单位可以再转回,否则会报错(缺少功能)
用instanceof操作符判断一个实例是不是某种类型:
Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p; // 一定会成功
}
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}
组合
使用组合,Student
可以持有一个Book
实例:
class Student extends Person {
protected Book book;
protected int score;
}
继承是is关系,组合是has关系。
小结
- 继承是面向对象编程的一种强大的代码复用方式;
- Java只允许单继承,所有类最终的根类是
Object
; protected
允许子类访问父类的字段和方法;- 子类的构造方法可以通过
super.
调用父类的构造方法; - 可以安全地向上转型为更抽象的类型;
- 可以强制向下转型,最好借助
instanceof
判断; - 子类和父类的关系是is,has关系不能用继承。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法(包括名字、返回数据类型和输入数据类型),被称为覆写(Override)。
例如:
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override //加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。
public void run() {
System.out.println("Student.run");
}
}
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。该情况称为多态。
意义在于,对新增的处理需求,不需要修改代码,只需要从之前的方法中派生正确覆写即可。允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
一个例题:不同税率下的总收税计算
public class Learn_Polymorphic {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
System.out.println("start");
Income[] incomes = new Income[] {
new Income(3000), // 三者中都有getTax方法
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
double tax_all = totalTax(incomes);
System.out.println(tax_all);
}
public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}
class Income {
protected double income;
public Income(double income) {
this.income = income;
}
public double getTax() {
return income * 0.1; // 税率10%
}
}
class Salary extends Income {
public Salary(double income) {
super(income);
}
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}
@Override
public double getTax() {
return 0;
}
}
使用时,totalTax方法只和Income打交道,而不需要知道,这个Income是父类Income还是它的某种子类
覆写Object方法
所有的class
最终都继承自Object
,而Object
定义了几个重要的方法,在必要的情况下,我们可以覆写Object
的这几个方法:
toString()
:把instance输出为String
;equals()
:判断两个instance是否逻辑相等;hashCode()
:计算一个instance的哈希值。
通过调用super来调用被覆写之前的父类方法,如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
final
对不想被覆写的方法,将方法标记为final,被final修饰的方法不能被覆写。
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望被继承,可以把类本身标记为final。
final class Person {
protected String name;
}
小结
- 子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
- Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
final
修饰符有多种作用:final
修饰的方法可以阻止被覆写;final
修饰的class可以阻止被继承;final
修饰的field必须在创建对象时初始化,随后不可修改。
抽象类
由于多态特性的存在和子类覆写的需求,父类中即使某一方法不被用到,依然需要定义。如果父类的方法不需要任何功能,仅用于定义方法签名,目的是让子类覆写,可以把它声明为抽象方法,若方法是抽象的,那么类也要声明为抽象类:
abstract class Person {
public abstract void run();
}
使用abstract
修饰的类就是抽象类。我们无法实例化一个抽象类,只能用于继承,相当于定义了规范,即子类必须覆写抽象方法:
// Person p = new Person(); // 编译错误
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run();
}
}
abstract class Person {
public abstract void run();
}
class Student extends Person {
@Override
public void run() { //覆写
System.out.println("Student.run");
}
}
可以用抽象类Person去引用具体的子类实例:
Person s = new Student();
Person t = new Teacher();
这种尽量引用高层类型,避免引用实际子类型的方法,称之为面向抽象编程。
- 上层代码只定义规范(如:abstract class Person)
- 不需要子类就能实现业务逻辑
- 具体的业务逻辑由不同的子类实现,调用者不用关心(直接使用父类即可)
样例:
/*
* @Description : 如果父类方法无需实现功能,仅仅是为了定义签名,目的是让子类去覆写,那么可以把父类的方法声明为抽象方法。
含有抽象方法的类必须定义为抽象类,无法实例化。
抽象类本身被设计成只能继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错,相当于定义了规范。
*/
public class Learn_abstract{
public static void main(String[] args){
// TODO: 用抽象类给一个有工资收入和稿费收入的小伙伴算税:
Income[] incomes = new Income[] {new SalaryIncome(7500), new RoyaltyIncome(12000) };
double total = 0;
// TODO:
for (Income in:incomes)
{
total += in.getTax();
}
System.out.println(total);
}
}
/* 计税的抽象类 */
abstract class Income
{
protected double income;
public Income(double income)
{
this.income = income;
}
public abstract double getTax();
}
/* 工资计税 */
class SalaryIncome extends Income
{
public SalaryIncome(double income)
{
super(income);
}
@Override
public double getTax()
{
if (this.income <= 5000) {
return 0;
}
return (this.income - 5000) * 0.2;
}
}
/** * 稿费计税 */
class RoyaltyIncome extends Income
{
public RoyaltyIncome(double income)
{
super(income);
}
@Override
public double getTax()
{
return this.income * 0.1;
}
}
小结
- 通过
abstract
定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范; - 定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
- 如果不实现抽象方法,则该子类仍是一个抽象类;
- 面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。
接口
如果一个抽象类没有字段,所有方法全都是抽象方法,那么可以把抽象类改写成接口 interface(可以有default方法)。默认修饰符都是public abstract,在使用时使用implements:
interface Person {
void run();
String getName();
}
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println(this.name + " run");
}
@Override
public String getName() {
return this.name;
}
}
一个类可以实现多个interface(不同于继承只能有一个):
class Student implements Person, Hello {
// 实现了两个interface
...}
术语
Java的接口特指interface
的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
一个interface可以继承自另一个interface
interface Hello {
void hello();
}
interface Person extends Hello { //接口从接口继承
void run();
String getName();
}
default方法
在接口中,可以定义default
方法。例如,把Person
接口的run()
方法改为default
方法:
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}
interface Person {
String getName();
default void run() { //default方法
System.out.println(getName() + " run");
}
}
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
default
方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default
方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default
方法和抽象类的普通方法是有所不同的。因为interface
没有字段,default
方法无法访问字段,而抽象类的普通方法可以访问实例字段。
小结
- Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
- 接口也是数据类型,适用于向上转型和向下转型;
- 接口的所有方法都是抽象方法,接口不能定义实例字段;
- 接口可以定义
default
方法(JDK>=1.8)。
静态字段和静态方法
静态字段
static修饰的字段称为静态字段,每个实例有自己的方空间,所有静态字段只有一个公共共享空间,所有实例都能共享该字段
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例,所有实例共享一个静态字段。:
public class Main{
public static void main(String[] args){
Person ming = new Person("Xiao Ming",12);
Person hong = new Person("Xiao Hong",15);
ming.number = 88;
System.out.println(hong.number); // 88
hong.number = 99;
System.out.println(ming.number); // 99
}
}
class Person {
public String name;
public int age;
public static int number;
public Person(String name,int age){
this.name = name;
this.age = age;
}
}
不推荐用实例变量.静态字段
去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段
来访问静态对象。可以把静态字段理解为描述class
本身的字段(非实例字段)。
Person.number = 99;
静态方法
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}
class Person {
public static int number;
public static void setNumber(int value) {
number = value;
}
}
接口的静态字段
因为interface
是一个纯抽象类,所以它不能定义实例字段。但是,interface
是可以有静态字段的,并且静态字段必须为final
类型(严格来讲只能是public static final
类型):
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
小结
- 静态字段属于所有实例“共享”的字段,实际上是属于
class
的字段; - 调用静态方法不需要实例,无法访问
this
,但可以访问静态字段和其他静态方法; - 静态方法常用于工具类和辅助方法。
包
处理同类名冲突:Java定义了一种名字空间,称之为包:package
。一个类总是属于某个包,类名(比如Person
)只是一个简写,真正的完整类名是包名.类名
。
如:JDK的Arrays
类存放在包java.util
下面,因此,完整类名是java.util.Arrays
。
在定义class时,第一行生命这个class属于哪个包,多层的包名用‘.’隔开:
package ming; // 申明包名ming
public class Person {
}
需要按照包结构把上面的Java文件组织起来。假设以package_sample
作为根目录,src
作为源码目录,所有Java文件对应的目录层次要和包的层次一致。:
编译后的.class
文件也需要按照包结构存放。如果使用IDE,把编译后的.class
文件放到bin
目录下,那么,编译的文件结构就是:
编译的命令相对比较复杂,我们需要在src
目录下执行javac
命令:
javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java
作用域
同一个包的类,可以访问包作用域的字段和方法。不用public
、protected
、private
修饰的字段和方法就是包作用域。例如Main类和Person类都在hello包下
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
import
引用其他包的类:例如,小明的ming.Person
类,如果要引用小军的mr.jun.Arrays
类,他有三种写法:
- 写出完整包名
// Person.java
package ming;
public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}
-
用import语句
import mr.jun.Arrays;
在写
import
的时候,可以使用*
,表示把这个包下面的所有class
都导入进来(但不包括子包的class
)import mr.jun.*;
-
import static
的语法,它可以导入可以导入一个类的静态字段和静态方法(用的少):
import static java.lang.System.*;
Java编译器最终编译出的.class
文件只使用完整类名,因此,在代码中,当编译器遇到一个class
名称时:
- 如果是完整类名,就直接根据完整类名查找这个
class
; - 如果是简单类名,按下面的顺序依次查找:
- 查找当前
package
是否存在这个class
; - 查找
import
的包是否包含这个class
; - 查找
java.lang
包是否包含这个class
。
- 查找当前
如果有两个class
名称相同,例如,mr.jun.Arrays
和java.util.Arrays
,那么只能import
其中一个,另一个必须写完整类名。要注意不要和java.lang
包的类重名,也不要和JDK常用类重名。
小结
- Java内建的
package
机制是为了避免class
命名冲突; - JDK的核心类使用
java.lang
包,编译器会自动导入; - JDK的其它常用类定义在
java.util.*
,java.math.*
,java.text.*
,……; - 包名推荐使用倒置的域名,例如
org.apache
。
作用域
Java内建的访问权限包括public
、protected
、private
和package
权限;
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
final
修饰符不是访问权限,它可以修饰class
、field
和method
;
一个.java
文件只能包含一个public
类,但可以包含多个非public
类。
内部类
通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系。
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。
Inner Class
一个类定义在另一个类内部,不能单独存在,必须依附于一个outer Class的实例:
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer,删除则报错
Outer.Inner inner = outer.new Inner(); // 调用Outer实例的new来创建实例化一个Inner
inner.hello();
}
}
class Outer {
private String name;
Outer(String name) { // 构造函数
this.name = name;
}
class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}
这是因为Inner Class除了有一个this
指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this
访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。
另外Inner实例可以修改Outer Class的private
字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private
字段和方法。
Anonymous Class
在方法内部,通过匿名类(Anonymous Class)来定义。匿名类没有用到class关键词来声明定义类名。
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}
观察asyncHello()
方法,我们在方法内部实例化了一个Runnable
。Runnable
本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable
接口的匿名类,并且通过new
实例化该匿名类,然后转型为Runnable
。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
Runnable r = new Runnable() {
// 实现必要的抽象方法...
};
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
import java.util.HashMap;
public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();//普通HashMap实例
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类实例!继承自HashMap
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};//添加static代码块初始化数据
System.out.println(map3.get("A"));
}
}
Static Nested Class(静态内部类)
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}
完全独立,不依附于Outer的实例。无法引用Outer.this,可以访问Outer的静态字段和静态方法。
小结
- Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有
Outer.this
实例,并拥有Outer Class的private
访问权限; - Static Nested Class是独立类,但拥有Outer Class的
private
访问权限。
classpath和jar
classpath是JVM用到的一个环境变量,用来指示JVM搜索class
建议在启动JVM时传入classpath:
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
jar:
jar包可以把package
组织的目录层级,以及各个目录下的所有文件(包括.class
文件和其他文件)都打成一个jar文件,便于备份或者交付。
jar包还可以包含一个特殊的/META-INF/MANIFEST.MF
文件,MANIFEST.MF
是纯文本,可以指定Main-Class
和其它信息。JVM会自动读取这个MANIFEST.MF
文件,如果存在Main-Class
,我们就不必在命令行指定启动的类名,而是用更方便的命令:java -jar hello.jar
如果要执行jar包的class,就可以把jar包放到classpath中:
java -cp ./hello.jar abc.xyz.Hello
通过压缩文件压成zip再自行改后缀即可。
可以用Maven创建jar包
小结
- JVM通过环境变量
classpath
决定搜索class
的路径和顺序; - 不推荐设置系统环境变量
classpath
,始终建议通过-cp
命令传入; - jar包相当于目录,可以包含很多
.class
文件,方便下载和使用; MANIFEST.MF
文件可以提供jar包的信息,如Main-Class
,这样可以直接运行jar包。
模块
jar只是用于存放class的容器,它并不关心class之间的依赖,引入模块主要是为了解决“依赖”这个问题。
如果a.jar
必须依赖另一个b.jar
才能运行,那我们应该给a.jar
加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar
,这种自带“依赖关系”的class容器就是模块。
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。模块也进一步隔离了代码的访问权限
编写模块
bin
目录存放编译后的class文件,src
目录存放源码,按包名的目录结构存放,仅仅在src
目录下多了一个module-info.java
这个文件,这就是模块的描述文件。在这个模块中,它长这样:
module hello.world { //module是关键字,后面是模块名
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java
代码如下:
package com.itranswarp.sample;
// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;
public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}
创建方法:
切换到模块目录,编译所有.java并存放到bin目录:
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
然后将bin中的class打包成jar,注意传入--main-class参数,让jar能自己定位main方法所在的类。在将jar转为模块。
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .
$ jmod create --class-path hello.jar hello.jmod
运行模块
$ java --module-path hello.jar --module hello.world
Hello,xml!
jmod不能放在--module-path中换成.jar。jmod用于打包JRE
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
我们在--module-path
参数指定了我们自己的模块hello.jmod
,然后,在--add-modules
参数中指定了我们用到的3个模块java.base
、java.xml
和hello.world
,用,
分隔。最后,在--output
参数指定输出目录。
现在,在当前目录下,我们可以找到jre
目录,这是一个完整的并且带有我们自己hello.jmod
模块的JRE。试试直接运行这个JRE:
$ jre/bin/java --module hello.world
Hello, xml!
要分发我们自己的Java应用程序,只需要把这个jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
访问时,要访问不同模块之间的,只能访问导入的包:
举个例子:我们编写的模块hello.world
用到了模块java.xml
的一个类javax.xml.XMLConstants
,我们之所以能直接使用这个类,是因为模块java.xml
的module-info.java
中声明了若干导出:
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world
模块中的com.itranswarp.sample.Greeting
类,我们必须将其导出:
module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
小结
- Java 9引入的模块目的是为了管理依赖;
- 使用模块可以按需打包JRE;
- 使用模块对类的访问权限有了进一步限制。
Java核心类
方法的包前缀用String,如果有String s,则可以用s做前缀
字符串
String本身也是一个class(实际上内部是char[])
字符串相关方法
判断 | |
---|---|
s.equals() | 比较是否相等 |
s.equalsIgnoreCase() | 忽略大小写比较相等 |
s.isEmpty() | 判定是否为空字符串 |
s.isBlank() | 判定是否包含空白字符 |
检索、提取 | |
---|---|
s.contains("") | s中是否包含字符串 |
s.indexOf("") | s中字符的第一个位置 |
s.lastIndexOf("") | s中字符的最后一个位置 |
s.startsWith("") | 检查字符串是否以指定子字符串开头 |
s.endsWith("") | 检查字符串是否以指定子字符串结尾 |
s.substring(int,int) | 提取索引号范围的子字符串 |
处理 | |
---|---|
s.toUpperCase() | 转为大写 |
s.toLowerCase() | 转为小写 |
s.trim() | 去除字符串首尾空白字符,包括\t, \r, \n。返回一个新字符串 |
s.strip() | 功能同上,增加去除\u3000 |
s.stripLeading() | 去除前空白字符 |
s.stripTrailing() | 去除后空白字符 |
s.replace(str,str) | 用前替换后 |
s.replaceAll() | s.replaceAll("[\,\;\s]+", ","); // "A,B,C,D" |
s.split(" ") | 用字符串分割s |
s.valueOf() | 重载,转为字符串 |
Integer.parseInt(数字) | 字符串转int。int n1 = Integer.parseInt("123"); // 123 |
Boolean.parseBoolean() | 字符串转boolean类型 |
getInteger(String) | 把字符串对应变量转换为Integer |
Array.copyOf(目标array,复制长度) | 复制目标列表中的固定长度 |
s.getBytes() | 转换为某一类型,如UTF-8或GBK,转为byte类型数组 |
拼接字符串:s.join( , )
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
String s3 = String.join("*","A","B","C"); //"A*B*C"
格式化字符串:s.format( , )
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5)); //有几个占位符,后面就传入几个参数。参数类型要和占位符一致。
}
}
%s
:显示字符串;%d
:显示整数;%x
:显示十六进制整数;%f
:显示浮点数。
s.toCharArray() | String -> char[] |
---|---|
new String(char[]) | char[] -> String;赋值之后直接修改原[]不会改到String,是复制不是引用 |
小结
- Java字符串
String
是不可变对象; - 字符串操作不改变原字符串内容,而是返回新字符串;
- 常用的字符串操作:提取子串、查找、替换、大小写转换等;
- Java使用Unicode编码表示
String
和char
; - 转换编码就是将
String
和byte[]
转换,需要指定编码; - 转换为
byte[]
时,始终优先考虑UTF-8
编码。
StringBuilder
为了高效拼接字符串,避免不断扔掉旧字符串创建新字符串,提供了StringBuilder库,可以预分配缓存区,往StringBuilder新增字符时,不会创建新的临时对象。
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();
可以链式操作:(因为原函数内不断调用this)
sb.append("Mr ")
.append("Bob")
.append("!");
同理设计计数器:
public class Main {
public static void main(String[] args) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}
class Adder {
private int sum = 0;
public Adder add(int n) {
sum += n;
return this;
}
public Adder inc() {
sum ++;
return this;
}
public int value() {
return sum;
}
}
小结
StringBuilder
是可变对象,用来高效拼接字符串;StringBuilder
可以支持链式操作,实现链式操作的关键是返回实例本身;StringBuffer
是StringBuilder
的线程安全版本,现在很少使用。
StringJoiner
类似用分隔符拼接数组
import java.util.StringJoiner;
var sj = new StringJoiner(", ", "Hello ", "!"); // 连接符,开头,结尾
import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ", "Hello ", "!"); // 连接符,开头,结尾
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}
小结
- 用指定分隔符拼接字符串数组时,使用
StringJoiner
或者String.join()
更方便; - 用
StringJoiner
拼接字符串时,还可以额外附加一个“开头”和“结尾”。
包装类型
- 基本类型:
byte
,short
,int
,long
,boolean
,float
,double
,char
- 引用类型:所有
class
和interface
类型
把基本类型视作引用类型:如int,可以定义一个Integer
类,它只包含一个实例字段int
,这样,Integer
类就可以视为int
的包装类
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}
基本类型 | 对应的引用类型 |
---|---|
boolean | java.lang.Boolean |
byte | java.lang.Byte |
short | java.lang.Short |
int | java.lang.Integer |
long | java.lang.Long |
float | java.lang.Float |
double | java.lang.Double |
char | java.lang.Character |
Auto Boxing
Integer n = 100; // 编译器自动Integer.valueOf(int)
int x = n; // 编译器自动Integer.intValue()
int—>Integer 自动装箱
Integer—>int 自动拆箱
不变类
所有包装类型都是不变类,即用final修饰。不能用==比较,要用equal();
因为Integer.valueOf()
可能始终返回同一个Integer
实例,因此,在我们自己创建Integer
的时候,以下两种方法,方法2更好,因为方法1总是创建新的Integer
实例,方法2把内部优化留给Integer
的实现者去做
- 方法1:
Integer n = new Integer(100);
- 方法2:
Integer n = Integer.valueOf(100);
把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()
就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。
进制转换
Integer.parseInt("100") //把字符串解析成整数
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
所有的整数和浮点数的包装类型都继承自Number
,因此,可以非常方便地直接通过包装类型获取各种基本类型
// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue();
int n = num.intValue();
long ln = num.longValue();
float f = num.floatValue();
double d = num.doubleValue();
在Java中,并没有无符号整型(Unsigned)的基本数据类型。byte
、short
、int
和long
都是带符号整型,最高位是符号位。
小结
- Java核心库提供的包装类型可以把基本类型包装为
class
; - 自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);
- 装箱和拆箱会影响执行效率,且拆箱时可能发生
NullPointerException
; - 包装类型的比较必须使用
equals()
; - 整数和浮点数的包装类型都继承自
Number
; - 包装类型提供了大量实用方法。
JavaBean
class 定义符合的规范:
- 若干
private
实例字段; - 通过
public
方法来读写实例字段。
如果读写方法符合以下这种命名规范:那么这种class
被称为JavaBean
。
包括1.私有化成员变量;2.空参数构建方法;3.带全部参数的构造方法;4.get/set方法。
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)
我们通常把一组对应的读方法(getter
)和写方法(setter
)称为属性(property
)。例如,name
属性:
- 对应的读方法是
String getName()
- 对应的写方法是
setName(String)
只有getter
的属性称为只读属性(read-only),例如,定义一个age只读属性,只有setter
的属性称为只写属性(write-only)。
要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector
:
import java.beans.*;
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}
class Person {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class
属性是从Object
继承的getClass()
方法带来的。
小结
- JavaBean是一种符合命名规范的
class
,它通过getter
和setter
来定义属性; - 属性是一种通用的叫法,并非Java语法规定;
- 可以利用IDE快速生成
getter
和setter
; - 使用
Introspector.getBeanInfo()
可以获取属性列表。
枚举类
enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}
enum Weekday { //用enum关键词来替代class
SUN, MON, TUE, WED, THU, FRI, SAT;//枚举的内容
}
enum 常量本身带有类型信息,即Weekday.SUN
类型是Weekday
,编译器会自动检查出类型错误,其次,不可能引用到非枚举的值,因为无法通过编译。最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,下面的语句不可能编译通过:
int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}
因为enum
类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==
比较。
只能定义出enum
的实例,而无法通过new
操作符创建enum
的实例;可以将enum
类型用于switch
语句。
方法 | 作用 | 举例 |
---|---|---|
name() | 返回常量的名字 | String s = Weekday.SUN.name(); // "SUN" |
ordinal() | 返回定义的常量的顺序 | int n = Weekday.MON.ordinal(); // 1 |
但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()
的返回值。因为enum
本身是class
,所以我们可以定义private
的构造方法,并且,给每个枚举常量添加字段:
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
switch(day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out.println("Today is " + day + ". Work at office!");
break;
case SAT:
case SUN:
System.out.println("Today is " + day + ". Work at home!");
break;
default:
throw new RuntimeException("cannot process " + day);
}
}
}
enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}
对枚举常量调用toString()
会返回和name()
一样的字符串。但是,toString()
可以被覆写,而name()
则不行。我们可以给Weekday
添加toString()
方法:
public String toString() {
return this.chinese;
}
加上default
语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。
小结
- Java使用
enum
定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }
; - 通过
name()
获取常量定义的字符串,注意不要使用toString()
; - 通过
ordinal()
返回常量定义的顺序(无实质意义); - 可以为
enum
编写构造方法、字段和方法 enum
的构造方法要声明为private
,字段强烈建议声明为final
;enum
适合用在switch
语句中。
记录类
不变类特点:
- 定义class时使用
final
,无法派生子类; - 每个字段使用
final
,保证创建实例后无法修改任何字段。
假设希望定义一个类,有x、y两个变量,同时是一个不变类:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() {
return this.x;
}
public int y() {
return this.y;
}
}
Record类
等同于
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out.println(p.x());
System.out.println(p.y());
System.out.println(p);
}
}
public record Point(int x, int y) {}
//上述record语句等同于以下代码
public final class Point extends Record {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() {
return this.x;
}
public int y() {
return this.y;
}
public String toString() {
return String.format("Point[x=%s, y=%s]", x, y);
}
public boolean equals(Object o) {
...
}
public int hashCode() {
...
}
}
除了用final
修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()
、equals()
和hashCode()
方法。
和enum
类似,我们自己不能直接从Record
派生,只能通过record
关键字由编译器实现继承。
可以通过在Point 的构造方法中加入检查逻辑来检查参数
添加静态方法,如of()方法来创建Point:
public record Point(int x, int y) {
public static Point of() {
return new Point(0, 0);
}
public static Point of(int x, int y) {
return new Point(x, y);
}
}
记录类可以帮助我们写出更简洁的代码如下:
var z = Point.of();
var p = Point.of(123, 456);
小结
- 使用
record
定义的是不变类; - 可以编写Compact Constructor对参数进行验证;
- 可以定义静态方法。
BigInteger
用来模拟超出long型的整数
小结
BigInteger
用于表示任意大小的整数;BigInteger
是不变类,并且继承自Number
;- 将
BigInteger
转换成基本类型时可使用longValueExact()
等方法保证结果准确。
BigDecimal
BigDecimal
可以表示一个任意大小且精度完全准确的浮点数
小结
BigDecimal
用于表示精确的小数,常用于财务计算;- 比较
BigDecimal
的值是否相等,必须使用compareTo()
而不能使用equals()
。
常用工具类
Math
用于数学计算
方法 | 作用 |
---|---|
Math.abs() | 绝对值 |
Math.max() | |
Math.pow(n,m) | n的m次方 |
Math.sqrt() | 开方 |
Math.exp() | ex |
Math.random() | 随机数 |
Random r = new Random(); //伪随机
r.nextInt() | 每次都不一样 |
---|---|
r.nextInt(10) | 生成一个[0,10)之间的int |
r.nextLong() | |
r.nextFloat() | |
r.nextDouble() |
SecureRandom
SecureRandom
无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom
实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
时刻牢记必须使用SecureRandom
来产生安全的随机数。
小结
- Math:数学计算
- Random:生成伪随机数
- SecureRandom:生成安全的随机数
异常处理
java异常
两个调试方法:
1.自行设定返回错误码
2.提供一个异常处理机制
try catch
异常是一种class
,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:
try {
String s = processFile(“C:\\test.txt”);
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}
从继承关系可知:Throwable
是异常体系的根,它继承自Object
。Throwable
有两个体系:Error
和Exception
,Error
表示严重的错误,程序对此一般无能为力,例如:
OutOfMemoryError
:内存耗尽NoClassDefFoundError
:无法加载某个ClassStackOverflowError
:栈溢出
而Exception
则是运行时的错误,它可以被捕获并处理。
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
NumberFormatException
:数值类型的格式错误FileNotFoundException
:未找到文件SocketException
:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
NullPointerException
:对某个null
的对象调用方法或字段IndexOutOfBoundsException
:数组索引越界
Exception
又分为两大类:
RuntimeException
以及它的子类;- 非
RuntimeException
(包括IOException
、ReflectiveOperationException
等等)
java规定
- 必须捕获的异常,包括
Exception
及其子类,但不包括RuntimeException
及其子类,这种类型的异常称为Checked Exception。 - 不需要捕获的异常,包括
Error
及其子类,RuntimeException
及其子类。
捕获异常
try...catch
语句,把可能发生异常的代码放到try {...}
中,然后使用catch
捕获对应的Exception
及其子类:
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}
static byte[] toGBK(String s) {
try {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
return s.getBytes(); // 尝试使用用默认编码
}
}
}
因为String.getBytes(String)方法的定义是:
public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
...
}
throws Xxx
表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
在toGBK()
方法中,因为调用了String.getBytes(String)
方法,就必须捕获UnsupportedEncodingException
。我们也可以不捕获它,而是在方法定义处用throws表示toGBK()
方法可能会抛出UnsupportedEncodingException
,就可以让toGBK()
方法通过编译器检查:
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}
static byte[] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes("GBK");
}
}
仍然会得到编译错误,但这一次,编译器提示的不是调用return s.getBytes("GBK");
的问题,而是byte[] bs = toGBK("中文");
。因为在main()
方法中,调用toGBK()
,没有捕获它声明的可能抛出的UnsupportedEncodingException
。修复方法是在main()
方法中捕获异常并处理:
static byte[] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
}
只要是方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。所有未捕获的异常,最终也必须在main()
方法中捕获,不会出现漏写try
的情况。这是由编译器保证的。main()
方法也是最后捕获Exception
的机会。
所有异常都可以调用printStackTrace()
方法打印异常栈,这是一个简单有用的快速打印异常的方法。
小结
- Java使用异常来表示错误,并通过
try ... catch
捕获异常; - Java的异常是
class
,并且从Throwable
继承; Error
是无需捕获的严重错误,Exception
是应该捕获的可处理的错误;RuntimeException
无需强制捕获,非RuntimeException
(Checked Exception)需强制捕获,或者用throws
声明;- 不推荐捕获了异常但不进行任何处理。至少应该记录下来:e.printStackTrace();
捕获异常
无论是否有异常发生,如果我们都希望执行一些语句,例如清理工作,怎么写?
可以把执行语句写若干遍:正常执行的放到try
中,每个catch
再写一遍。例如:
public static void main(String[] args) {
try {
process1();
process2();
process3();
System.out.println("END");
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
System.out.println("END");
} catch (IOException e) {
System.out.println("IO error");
System.out.println("END");
}
}
存在多个catch
的时候,catch
的顺序非常重要:子类必须写在前面。Java的try ... catch
机制还提供了finally
语句,finally
语句块保证有无错误都会执行。上述代码可以改写如下:
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
} finally {
System.out.println("END");
}
}
//如果没有发生异常,就正常执行try { ... }语句块,然后执行finally。如果发生了异常,就中断执行try { ... }语句块,然后跳转执行匹配的catch语句块,最后执行finally。
小结
- 多个
catch
语句的匹配顺序非常重要,子类必须放在前面; finally
语句保证了有无异常都会执行,它是可选的;- 一个
catch
语句也可以匹配多个非继承关系的异常。
抛出异常
当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try ... catch
被捕获为止。通过printStackTrace()
可以打印出方法的调用栈
如何抛出异常?参考Integer.parseInt()
方法,抛出异常分两步:
- 创建某个
Exception
的实例; - 用
throw
语句抛出。
void process2(String s) {
if (s==null) {
NullPointerException e = new NullPointerException();
throw e;
}
}
//等同于
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}
如果在捕获后又在子句中抛出异常类型,原始的异常将会被覆盖。
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}
static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
// throw new IllegalArgumentException(e); 上一调用改为这句可以返回原始异常
}
}
static void process2() {
throw new NullPointerException();
}
}
在代码中获取原始异常可以使用Throwable.getCause()
方法。如果返回null
,说明已经是“根异常”了。
finally
抛出异常后,原来在catch
中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常(Suppressed Exception)。
在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用origin
变量保存原始异常,然后调用Throwable.addSuppressed()
,把原始异常添加进来,最后在finally
抛出:
通过Throwable.getSuppressed()
可以获取所有的Suppressed Exception
。绝大多数情况下,在finally
中不要抛出异常。因此,我们通常不需要关心
小结
- 调用
printStackTrace()
可以打印异常的传播栈,对于调试非常有用; - 捕获异常并再次抛出新的异常时,应该持有原始异常信息;
- 通常不要在
finally
中抛出异常。如果在finally
中抛出异常,应该原始异常加入到原有异常中。调用方可通过Throwable.getSuppressed()
获取所有添加的Suppressed Exception
。
自定义异常
标准异常:
Exception
│
├─ RuntimeException
│ ├─ NullPointerException
│ ├─ IndexOutOfBoundsException
│ ├─ SecurityException
│ └─ IllegalArgumentException
│ └─ NumberFormatException
│
├─ IOException
│ ├─ UnsupportedCharsetException
│ ├─ FileNotFoundException
│ └─ SocketException
│
├─ ParseException
├─ GeneralSecurityException
├─ SQLException
└─ TimeoutException
自定义新的异常类型时,常采用定义一个BaseException作为根异常,然后派生各种业务类型异常。通常从RuntimeException。
public class BaseException extends RuntimeException {
public BaseException() {// 多种构造方法
super();
}
public BaseException(String message, Throwable cause) {
super(message, cause);
}
public BaseException(String message) {
super(message);
}
public BaseException(Throwable cause) {
super(cause);
}
}
public class UserNotFoundException extends BaseException {
}
public class LoginFailedException extends BaseException {
}
...
小结
- 抛出异常时,尽量复用JDK已定义的异常类型;
- 自定义异常体系时,推荐从
RuntimeException
派生“根异常”,再派生出业务异常; - 自定义异常时,应该提供多种构造方法。
NullPointerException
NullPointerException
即空指针异常,俗称NPE。如果一个对象为null
,调用其方法或访问其字段就会产生NullPointerException
,这个异常通常是由JVM抛出的
使用空字符串""
而不是默认的null
可避免很多NullPointerException
,编写业务逻辑时,用空字符串""
表示未填写比null
安全得多。
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}
使用断言
断言(Assertion)是一种调试程序的方式。在Java中,使用assert
关键字来实现断言。
public static void main(String[] args) {
double x = Math.abs(-123.45);
assert x >= 0; //断言条件x>=0,结果为false则断言失败
System.out.println(x);
}
断言失败时会抛出AssertionError
,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。
JVM默认关闭断言,在虚拟机下通过java -ea Main.java
使用断言中断
小结
- 断言是一种调试方式,断言失败会抛出
AssertionError
,只能在开发和测试阶段启用断言; - 对可恢复的错误不能使用断言,而应该抛出异常;
- 断言很少被使用,更好的方法是编写单元测试。
JDK Logging
Java标准库内置了日志包java.util.logging
,我们可以直接用。先看一个简单的例子:
import java.util.logging.Level;
import java.util.logging.Logger;
public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal();
logger.info("start process...");
logger.warning("memory is running out...");
logger.fine("ignored.");
logger.severe("process will be terminated...");
}
}
再仔细观察发现,4条日志,只打印了3条,logger.fine()
没有打印。这是因为,日志的输出可以设定级别。JDK的Logging定义了7个日志级别,从严重到普通:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。
小结
- 日志是为了替代
System.out.println()
,可以定义格式,重定向到文件等; - 日志可以存档,便于追踪问题;
- 日志记录可以按级别分类,便于打开或关闭某些级别;
- 可以根据配置文件调整日志,无需修改代码;
- Java标准库提供了
java.util.logging
来实现日志功能。
Commons Logging
默认情况下,Commons Loggin自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。
第一步,通过LogFactory
获取Log
类的实例; 第二步,使用Log
实例的方法打日志。
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}
第三方库的引入
下载解压找到commons-logging-1.2.jar
这个文件,再把Java源码Main.java
放到一个lib目录下。右键 add as library。
如果在静态方法中引用Log
,通常直接定义一个静态类型变量
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog(Main.class);
static void foo() {
log.info("foo");
}
}
在实例方法中引用Log
,通常定义一个实例变量:
public class Person {
protected final Log log = LogFactory.getLog(getClass());
void foo() {
log.info("foo");
}
}
注意到实例变量log的获取方式是LogFactory.getLog(getClass())
,虽然也可以用LogFactory.getLog(Person.class)
,但是前一种方式有个非常大的好处,就是子类可以直接使用该log
实例。
// 在子类中使用父类实例化的log:
public class Student extends Person {
void bar() {
log.info("bar");
}
}
由于Java类的动态特性,子类获取的log
字段实际上相当于LogFactory.getLog(Student.class)
,但却是从父类继承而来,并且无需改动代码。
此外,Commons Logging的日志方法,例如info()
,除了标准的info(String)
外,还提供了一个非常有用的重载方法:info(String, Throwable)
,这使得记录异常更加简单:
try {
...
} catch (Exception e) {
log.error("got exception!", e);
}
小结
- Commons Logging是使用最广泛的日志模块;
- Commons Logging的API非常简单;
- Commons Logging可以自动检测并使用其他日志模块。
使用Log4j
Commons Logging,可以作为“日志接口”来使用。而真正的“日志实现”可以使用Log4j。
当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:
- console:输出到屏幕;
- file:输出到文件;
- socket:通过网络输出到远程计算机;
- jdbc:输出到数据库
在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。
以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml
的文件放到classpath
下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name="file.err.filename">log/err.log</Property>
<Property name="file.err.pattern">log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name="console" target="SYSTEM_OUT">
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern="${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}">
<PatternLayout pattern="${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size="1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max="10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref="console" level="info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref="err" level="error" />
</Root>
</Loggers>
</Configuration>
并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。
正确使用时目录包含文件:
root
│
├─ lib
│ ├─ log4j-api-2.x.jar
│ ├─ log4j-core-2.x.jar
│ ├─ log4j-jcl-2.x.jar
│ └─ commons-logging-1.2.jar
│
├─ src
│ ├─ log4j2.x.jar
小结
- 通过Commons Logging实现日志,不需要修改代码即可使用Log4j;
- 使用Log4j只需要把log4j2.xml和相关jar放入classpath;
- 如果要更换Log4j,只需要移除log4j2.xml和相关jar;
- 只有扩展Log4j时,才需要引用Log4j的接口(例如,将日志加密写入数据库的功能,需要自己开发)。
使用SLF4J和Logback
另一种日志格式
https://www.liaoxuefeng.com/wiki/1252599548343744/1264739155914176
反射
Java的反射是指程序在运行期可以拿到一个对象的所有信息。正常情况下,如果我们要调用一个对象的方法,或者访问一个对象的字段,通常会传入对象实例:
// Main.java
import com.itranswarp.learnjava.Person;
public class Main {
String getFullName(Person p) {
return p.getFirstName() + " " + p.getLastName();
}
}
但是,如果不能获得Person
类,只有一个Object
实例,比如这样:
String getFullName(Object obj) {
return ???
}
//若强制转型:
String getFullName(Object obj) {
Person p = (Person) obj;
return p.getFirstName() + " " + p.getLastName();
}
所以,反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。
class类
除了int
等基本类型外,Java的其他类型全部都是class
(包括interface
)。例如:String、Object、Runnable、Exception
无继承关系的数据类型无法赋值:
Number n = new Double(123.456); // OK
String s = new Double(123.456); // compile error!
class
是由JVM在执行过程中动态加载的。JVM在第一次读取到一种class
类型时,将其加载进内存。每加载一种class
,JVM就为其创建一个Class
类型的实例,并关联起来。注意:这里的Class
类型是一个名叫Class
的class
。
public final class Class {
private Class() {}
}
以String
类为例,当JVM加载String
类时,它首先读取String.class
文件到内存,然后,为String
类创建一个Class
实例并关联起来:
Class cls = new Class(String);
一个Class
实例包含了该class
的所有完整信息:
JVM为每个加载的class
创建了对应的Class
实例,并在实例中保存了该class
的所有信息,包括类名、包名、父类、实现的接口、所有方法、字段等,因此,如果获取了某个Class
实例,我们就可以通过这个Class
实例获取到该实例对应的class
的所有信息。
这种通过Class
实例获取class
信息的方法称为反射(Reflection)。
1.直接通过一个class
的静态变量class
获取:
Class cls = String.class;
2.如果我们有一个实例变量,可以通过该实例变量提供的getClass()
方法获取:
String s = "Hello";
Class cls = s.getClass();
3.如果知道一个class
的完整类名,可以通过静态方法Class.forName()
获取:
Class cls = Class.forName("java.lang.String");
因为Class
实例在JVM中是唯一的,所以,上述方法获取的Class
实例是同一个实例。
Class
实例比较和instanceof
的差别
Integer n = new Integer(123);
boolean b1 = n instanceof Integer; // true,因为n是Integer类型
boolean b2 = n instanceof Number; // true,因为n是Number类型的子类
boolean b3 = n.getClass() == Integer.class; // true,因为n.getClass()返回Integer.class
boolean b4 = n.getClass() == Number.class; // false,因为Integer.class!=Number.class
用instanceof
不但匹配指定类型,还匹配指定类型的子类。而用==
判断class
实例可以精确地判断数据类型,但不能作子类型比较。
因为反射的目的是为了获得某个实例的信息。因此,当我们拿到某个Object
实例时,我们可以通过反射获取该Object
的class
信息:
void printObjectInfo(Object obj) {
Class cls = obj.getClass();
}
从class实例获取获取的基本信息:
// reflection
public class Main {
public static void main(String[] args) {
printClassInfo("".getClass());
printClassInfo(Runnable.class);
printClassInfo(java.time.Month.class);
printClassInfo(String[].class);
printClassInfo(int.class);
}
static void printClassInfo(Class cls) {
System.out.println("Class name: " + cls.getName());
System.out.println("Simple name: " + cls.getSimpleName());
if (cls.getPackage() != null) {
System.out.println("Package name: " + cls.getPackage().getName());
}
System.out.println("is interface: " + cls.isInterface());
System.out.println("is enum: " + cls.isEnum());
System.out.println("is array: " + cls.isArray());
System.out.println("is primitive: " + cls.isPrimitive());
}
}
数组(例如String[]
)也是一种类,而且不同于String.class
,它的类名是[Ljava.lang.String;
。此外,JVM为每一种基本类型如int
也创建了Class
实例,通过int.class
访问。
如果获取到了一个Class
实例,我们就可以通过该Class
实例来创建对应类型的实例:
// 获取String的Class实例:
Class cls = String.class;
// 创建一个String实例:
String s = (String) cls.newInstance();
上述代码相当于new String()
。通过Class.newInstance()
可以创建类实例,它的局限是:只能调用public
的无参数构造方法。带参数的构造方法,或者非public
的构造方法都无法通过Class.newInstance()
被调用。
动态加载
JVM在执行Java程序的时候,并不是一次性把所有用到的class全部加载到内存,而是第一次需要用到class时才加载。例如:
// Main.java
public class Main {
public static void main(String[] args) {
if (args.length > 0) {
create(args[0]);
}
}
static void create(String name) {
Person p = new Person(name);
}
}
利用JVM动态加载class
的特性,我们才能在运行期根据条件加载不同的实现类。 例如,Commons Logging总是优先使用Log4j,只有当Log4j不存在时,才使用JDK的logging。
// Commons Logging优先使用Log4j:
LogFactory factory = null;
if (isClassPresent("org.apache.logging.log4j.Logger")) {
factory = createLog4j();
} else {
factory = createJdkLog();
}
boolean isClassPresent(String name) {
try {
Class.forName(name);
return true;
} catch (Exception e) {
return false;
}
}
这就是为什么我们只需要把Log4j的jar包放到classpath中,Commons Logging就会自动使用Log4j的原因。
小结
- JVM为每个加载的
class
及interface
创建了对应的Class
实例来保存class
及interface
的所有信息; - 获取一个
class
对应的Class
实例后,就可以获取该class
的所有信息; - 通过Class实例获取
class
信息的方法称为反射(Reflection); - JVM总是动态加载
class
,可以在运行期根据条件来控制加载class。
访问字段
对任意的一个Object
实例,只要我们获取了它的Class
,就可以获取它的一切信息。提供获取字段:
- Field getField(name):根据字段名获取某个public的field(包括父类)
- Field getDeclaredField(name):根据字段名获取当前类的某个field(不包括父类)
- Field[] getFields():获取所有public的field(包括父类)
- Field[] getDeclaredFields():获取当前类的所有field(不包括父类)
public class Main {
public static void main(String[] args) throws Exception {
Class stdClass = Student.class;
// 获取public字段"score":
System.out.println(stdClass.getField("score"));
// 获取继承的public字段"name":
System.out.println(stdClass.getField("name"));
// 获取private字段"grade":
System.out.println(stdClass.getDeclaredField("grade"));
}
}
class Student extends Person {
public int score;
private int grade;
}
class Person {
public String name;
}
上述代码首先获取Student
的Class
实例,然后,分别获取public
字段、继承的public
字段以及private
字段。
一个Field对象包含一个字段的所有信息:
getName()
:返回字段名称,例如,"name"
;getType()
:返回字段类型,也是一个Class
实例,例如,String.class
;getModifiers()
:返回字段的修饰符,它是一个int
,不同的bit表示不同的含义。
以String
类的value
字段为例,它的定义是:
public final class String {
private final byte[] value;
}
Field f = String.class.getDeclaredField("value");//定义对象
f.getName(); // "value"
f.getType(); // class [B 表示byte[]类型
int m = f.getModifiers();
Modifier.isFinal(m); // true
Modifier.isPublic(m); // false
Modifier.isProtected(m); // false
Modifier.isPrivate(m); // true
Modifier.isStatic(m); // false
获取字段值
利用反射拿到字段的一个Field
实例只是第一步,我们还可以拿到一个实例对应的该字段的值。例如,对于一个Person
实例,我们可以先拿到name
字段对应的Field
,再获取这个实例的name
字段的值:
// reflection
import java.lang.reflect.Field;
public class Main {
public static void main(String[] args) throws Exception {
Object p = new Person("Xiao Ming");
Class c = p.getClass();
Field f = c.getDeclaredField("name");
// f.setAccessible(true);
Object value = f.get(p);
System.out.println(value); // "Xiao Ming"
}
}
class Person {
private String name;
public Person(String name) {
this.name = name;
}
}
先获取Class
实例,再获取Field
实例(上述中的c),然后,用Field.get(Object)
获取指定实例的指定字段的值。运行代码,如果不出意外,会得到一个IllegalAccessException
,这是因为name
被定义为一个private
字段,正常情况下,Main
类无法访问Person
类的private
字段。
可以在调用Object value = f.get(p);
前,先写一句:
f.setAccessible(true);
调用Field.setAccessible(true)
的意思是,别管这个字段是不是public
,一律允许访问。
设置字段值
通过Field.set(Object, Object)
实现的,其中第一个Object
参数是指定的实例,第二个Object
参数是待修改的值。
import java.lang.reflect.Field;
public class Main {
public static void main(String[] args) throws Exception {
Person p = new Person("Xiao Ming");
System.out.println(p.getName()); // "Xiao Ming"
Class c = p.getClass();
Field f = c.getDeclaredField("name");
f.setAccessible(true);
f.set(p, "Xiao Hong");
System.out.println(p.getName()); // "Xiao Hong"
}
}
class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
小结
- Java的反射API提供的
Field
类封装了字段的所有信息: - 通过
Class
实例的方法可以获取Field
实例:getField()
,getFields()
,getDeclaredField()
,getDeclaredFields()
; - 通过Field实例可以获取字段信息:
getName()
,getType()
,getModifiers()
; - 通过Field实例可以读取或设置某个对象的字段,如果存在访问限制,要首先调用
setAccessible(true)
来访问非public
字段。 - 通过反射读写字段是一种非常规方法,它会破坏对象的封装。
调用方法
我们已经能通过Class
实例获取所有Field
对象,同样的,可以通过Class
实例获取所有Method
信息。Class
类提供了以下几个方法来获取Method
:
Method getMethod(name, Class...)
:获取某个public
的Method
(包括父类)Method getDeclaredMethod(name, Class...)
:获取当前类的某个Method
(不包括父类)Method[] getMethods()
:获取所有public
的Method
(包括父类)Method[] getDeclaredMethods()
:获取当前类的所有Method
(不包括父类)
实例:
public class Main {
public static void main(String[] args) throws Exception {
Class stdClass = Student.class;
// 获取public方法getScore,参数为String: public int Student.getScore(java.lang.String)
System.out.println(stdClass.getMethod("getScore", String.class));
// 获取继承的public方法getName,无参数: public java.lang.String Person.getName()
System.out.println(stdClass.getMethod("getName"));
// 获取private方法getGrade,参数为int: private int Student.getGrade(int)
System.out.println(stdClass.getDeclaredMethod("getGrade", int.class));
}
}
class Student extends Person {
public int getScore(String type) {
return 99;
}
private int getGrade(int year) {
return 1;
}
}
class Person {
public String getName() {
return "Person";
}
}
一个Method
对象包含一个方法的所有信息:
getName()
:返回方法名称,例如:"getScore"
;getReturnType()
:返回方法返回值类型,也是一个Class实例,例如:String.class
;getParameterTypes()
:返回方法的参数类型,是一个Class数组,例如:{String.class, int.class}
;getModifiers()
:返回方法的修饰符,它是一个int
,不同的bit表示不同的含义。
如:System.out.println(stdClass.getMethod("getScore",String.class).getName());
调用方法
取到一个Method
对象时,就可以对它进行调用。我们以下面的代码为例:
String s = "Hello world";
String r = s.substring(6); // "world"
如果用反射来调用substring
方法,需要以下代码:
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Exception {
// String对象:
String s = "Hello world";
// 获取String substring(int)方法,参数为int:
Method m = String.class.getMethod("substring", int.class);
// 在s对象上调用该方法并获取结果:
String r = (String) m.invoke(s, 6);
// 打印调用结果:
System.out.println(r);
}
}
对Method
实例调用invoke
就相当于调用该方法,invoke
的第一个参数是对象实例,即在哪个实例上调用该方法,后面的可变参数要与方法参数一致
调用静态方法
对Method
实例调用invoke
就相当于调用该方法,invoke
的第一个参数是对象实例,即在哪个实例上调用该方法,后面的可变参数要与方法参数一致
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Exception {
// 获取Integer.parseInt(String)方法,参数为String:
Method m = Integer.class.getMethod("parseInt", String.class);
// 调用该静态方法并获取结果:
Integer n = (Integer) m.invoke(null, "12345");
// 打印调用结果:
System.out.println(n);
}
}
调用非public方法
和Field类似,对于非public方法,我们虽然可以通过Class.getDeclaredMethod()
获取该方法实例,但直接对其调用将得到一个IllegalAccessException
。为了调用非public方法,我们通过Method.setAccessible(true)
允许其调用:
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Exception {
Person p = new Person();
Method m = p.getClass().getDeclaredMethod("setName", String.class);
m.setAccessible(true);
m.invoke(p, "Bob");
System.out.println(p.name);
}
}
class Person {
String name;
private void setName(String name) {
this.name = name;
}
}
多态
一个Person
类定义了hello()
方法,并且它的子类Student
也覆写了hello()
方法,那么,从Person.class
获取的Method
,作用于Student
实例时,调用的方法到底是哪个?
public class Main {
public static void main(String[] args) throws Exception {
// 获取Person的hello方法:
Method h = Person.class.getMethod("hello");
// 对Student实例调用hello方法:
h.invoke(new Student());
}
}
class Person {
public void hello() {
System.out.println("Person:hello");
}
}
class Student extends Person {
public void hello() {
System.out.println("Student:hello");
}
}
小结
- 通过
Class
实例的方法可以获取Method
实例:getMethod()
,getMethods()
,getDeclaredMethod()
,getDeclaredMethods()
; - 通过
Method
实例可以获取方法信息:getName()
,getReturnType()
,getParameterTypes()
,getModifiers()
; - 通过
Method
实例可以调用某个对象的方法:Object invoke(Object instance, Object... parameters)
; - 通过设置
setAccessible(true)
来访问非public
方法; - 通过反射调用方法时,仍然遵循多态原则。
调用构造方法
通常使用new
操作符创建新的实例:
Person p = new Person();
如果通过反射来创建新的实例,可以调用Class提供的newInstance()方法:
Person p = Person.class.newInstance();
调用Class.newInstance()的局限是,它只能调用该类的public无参数构造方法。如果构造方法带有参数,或者不是public,就无法直接通过Class.newInstance()来调用。
为了调用任意的构造方法,Java的反射API提供了Constructor对象,它包含一个构造方法的所有信息,可以创建一个实例。Constructor对象和Method非常类似,不同之处仅在于它是一个构造方法,并且,调用结果总是返回实例:
import java.lang.reflect.Constructor;
public class Main {
public static void main(String[] args) throws Exception {
// 获取构造方法Integer(int):
Constructor cons1 = Integer.class.getConstructor(int.class);
// 调用构造方法:
Integer n1 = (Integer) cons1.newInstance(123);
System.out.println(n1);
// 获取构造方法Integer(String)
Constructor cons2 = Integer.class.getConstructor(String.class);
Integer n2 = (Integer) cons2.newInstance("456");
System.out.println(n2);
}
}
通过Class实例获取Constructor的方法如下:
getConstructor(Class...)
:获取某个public
的Constructor
;getDeclaredConstructor(Class...)
:获取某个Constructor
;getConstructors()
:获取所有public
的Constructor
;getDeclaredConstructors()
:获取所有Constructor
。
注意Constructor
总是当前类定义的构造方法,和父类无关,因此不存在多态的问题。
调用非public
的Constructor
时,必须首先通过setAccessible(true)
设置允许访问。setAccessible(true)
可能会失败。
小结
Constructor
对象封装了构造方法的所有信息;- 通过
Class
实例的方法可以获取Constructor
实例:getConstructor()
,getConstructors()
,getDeclaredConstructor()
,getDeclaredConstructors()
; - 通过
Constructor
实例可以创建一个实例对象:newInstance(Object... parameters)
; 通过设置setAccessible(true)
来访问非public
构造方法。
获取继承关系
当我们获取到某个Class
对象时,实际上就获取到了一个类的类型:
Class cls = String.class; // 获取到String的Class
还可以用实例的getClass()
方法获取:
String s = "";
Class cls = s.getClass(); // s是String,因此获取到String的Class
最后一种获取Class
的方法是通过Class.forName("")
,传入Class
的完整类名获取:
Class s = Class.forName("java.lang.String");
这三种方式获取的Class
实例都是同一个实例,因为JVM对每个加载的Class
只创建一个Class
实例来表示它的类型。
获取父类的Class
有了Class
实例,我们还可以获取它的父类的Class
:
public class Main {
public static void main(String[] args) throws Exception {
Class i = Integer.class;
Class n = i.getSuperclass();
System.out.println(n);
Class o = n.getSuperclass();
System.out.println(o);
System.out.println(o.getSuperclass());
}
}
运行上述代码,可以看到,Integer
的父类类型是Number
,Number
的父类是Object
,Object
的父类是null
。除Object
外,其他任何非interface
的Class
都必定存在一个父类类型。
获取interface
由于一个类可能实现一个或多个接口,通过Class
我们就可以查询到实现的接口类型。例如,查询Integer
实现的接口:
import java.lang.reflect.Method
public class Main {
public static void main(String[] args) throws Exception {
Class s = Integer.class;
Class[] is = s.getInterfaces();
for (Class i : is) {
System.out.println(i);
}
}
}
Integer
实现的接口有:
- java.lang.Comparable
- java.lang.constant.Constable
- java.lang.constant.ConstantDesc
要特别注意:getInterfaces()
只返回当前类直接实现的接口类型,并不包括其父类实现的接口类型:
import java.lang.reflect.Method
public class Main {
public static void main(String[] args) throws Exception {
Class s = Integer.class.getSuperclass();
Class[] is = s.getInterfaces();
for (Class i : is) {
System.out.println(i);
}
}
}
Integer
的父类是Number
,Number
实现的接口是java.io.Serializable
。
此外,对所有interface
的Class
调用getSuperclass()
返回的是null
,获取接口的父接口要用getInterfaces()
:
System.out.println(java.io.DataInputStream.class.getSuperclass()); // java.io.FilterInputStream,因为DataInputStream继承自FilterInputStream
System.out.println(java.io.Closeable.class.getSuperclass()); // null,对接口调用getSuperclass()总是返回null,获取接口的父接口要用getInterfaces()
如果一个类没有实现任何interface
,那么getInterfaces()
返回空数组。
继承关系
当我们判断一个实例是否是某个类型时,正常情况下,使用instanceof
操作符:
Object n = Integer.valueOf(123);
boolean isDouble = n instanceof Double; // false
boolean isInteger = n instanceof Integer; // true
boolean isNumber = n instanceof Number; // true
boolean isSerializable = n instanceof java.io.Serializable; // true
如果是两个Class
实例,要判断一个向上转型是否成立,可以调用isAssignableFrom()
:
// Integer i = ?
Integer.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Integer
// Number n = ?
Number.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Number
// Object o = ?
Object.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Object
// Integer i = ?
Integer.class.isAssignableFrom(Number.class); // false,因为Number不能赋值给Integer
小结
通过Class
对象可以获取继承关系:
Class getSuperclass()
:获取父类类型;Class[] getInterfaces()
:获取当前类实现的所有接口。
通过Class
对象的isAssignableFrom()
方法可以判断一个向上转型是否可以实现。
动态代理
我们来比较Java的class
和interface
的区别:
- 可以实例化
class
(非abstract
); - 不能实例化
interface
。
所有interface
类型的变量总是通过某个实例向上转型并赋值给接口类型变量的:
CharSequence cs = new StringBuilder();
有没有可能不编写实现类,直接在运行期创建某个interface
的实例呢?
这是可能的,因为Java标准库提供了一种动态代理(Dynamic Proxy)的机制:可以在运行期动态创建某个interface
的实例。
什么叫运行期动态创建?听起来好像很复杂。所谓动态代理,是和静态相对应的。我们来看静态代码怎么写:
定义接口:
public interface Hello {
void morning(String name);
}
编写实现类:
public class HelloWorld implements Hello {
public void morning(String name) {
System.out.println("Good morning, " + name);
}
}
创建实例,转型为接口并调用:
Hello hello = new HelloWorld();
hello.morning("Bob");
这种方式就是我们通常编写代码的方式。
还有一种方式是动态代码,我们仍然先定义了接口Hello
,但是我们并不去编写实现类,而是直接通过JDK提供的一个Proxy.newProxyInstance()
创建了一个Hello
接口对象。这种没有实现类但是在运行期动态创建了一个接口对象的方式,我们称为动态代码。JDK提供的动态创建接口对象的方式,就叫动态代理。
一个最简单的动态代理实现如下:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class Main {
public static void main(String[] args) {
InvocationHandler handler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(method);
if (method.getName().equals("morning")) {
System.out.println("Good morning, " + args[0]);
}
return null;
}
};
Hello hello = (Hello) Proxy.newProxyInstance(
Hello.class.getClassLoader(), // 传入ClassLoader
new Class[] { Hello.class }, // 传入要实现的接口
handler); // 传入处理调用方法的InvocationHandler
hello.morning("Bob");
}
}
interface Hello {
void morning(String name);
}
在运行期动态创建一个interface
实例的方法如下:
-
定义一个
InvocationHandler
实例,它负责实现接口的方法调用; -
通过
Proxy.newProxyInstance()
创建interface
实例,它需要3个参数:
- 使用的
ClassLoader
,通常就是接口类的ClassLoader
; - 需要实现的接口数组,至少需要传入一个接口进去;
- 用来处理接口方法调用的
InvocationHandler
实例。
- 使用的
-
将返回的
Object
强制转型为接口。
动态代理实际上是JVM在运行期动态创建class字节码并加载的过程,它并没有什么黑魔法,把上面的动态代理改写为静态实现类大概长这样:
public class HelloDynamicProxy implements Hello {
InvocationHandler handler;
public HelloDynamicProxy(InvocationHandler handler) {
this.handler = handler;
}
public void morning(String name) {
handler.invoke(
this,
Hello.class.getMethod("morning", String.class),
new Object[] { name });
}
}
其实就是JVM帮我们自动编写了一个上述类(不需要源码,可以直接生成字节码),并不存在可以直接实例化接口的黑魔法。
小结
- Java标准库提供了动态代理功能,允许在运行期动态创建一个接口的实例;
- 动态代理是通过
Proxy
创建代理对象,然后将接口方法“代理”给InvocationHandler
完成的。
注解
使用注解
主要作用:
- 第一类是由编译器使用的注解,例如:
@Override
:让编译器检查该方法是否正确地实现了覆写;@SuppressWarnings
:告诉编译器忽略此处代码产生的警告。
-
由工具处理
.class
文件使用的注解,比如有些工具会在加载class的时候,对class做动态修改,实现一些特殊的功能。这类注解会被编译进入.class
文件,但加载结束后并不会存在于内存中。这类注解只被一些底层库使用,一般我们不必自己处理。 -
在程序运行期能够读取的注解,它们在加载后一直存在于JVM中,这也是最常用的注解。例如,一个配置了
@PostConstruct
的方法会在调用构造方法后自动被调用(这是Java代码读取该注解实现的功能,JVM并不会识别该注解)。
定义注解
Java语言使用@interface
语法来定义注解(Annotation
),它的格式如下:
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
有一些注解可以修饰其他注解,这些注解就称为元注解(meta annotation)。
@Target
最常用的元注解是@Target
。使用@Target
可以定义Annotation
能够被应用于源码的哪些位置:
- 类或接口:
ElementType.TYPE
; - 字段:
ElementType.FIELD
; - 方法:
ElementType.METHOD
; - 构造方法:
ElementType.CONSTRUCTOR
; - 方法参数:
ElementType.PARAMETER
。
例如,定义注解@Report
可用在方法上,我们必须添加一个@Target(ElementType.METHOD)
:
@Target(ElementType.METHOD)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
定义注解@Report
可用在方法或字段上,可以把@Target
注解参数变为数组{ ElementType.METHOD, ElementType.FIELD }
:
@Target({
ElementType.METHOD,
ElementType.FIELD
})
public @interface Report {
...
}
实际上@Target
定义的value
是ElementType[]
数组,只有一个元素时,可以省略数组的写法。
@Retention
另一个重要的元注解@Retention
定义了Annotation
的生命周期:
- 仅编译期:
RetentionPolicy.SOURCE
; - 仅class文件:
RetentionPolicy.CLASS
; - 运行期:
RetentionPolicy.RUNTIME
。
如果@Retention
不存在,则该Annotation
默认为CLASS
。
@Retention(RetentionPolicy.RUNTIME)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
@Repeatable
使用@Repeatable
这个元注解可以定义Annotation
是否可重复。这个注解应用不是特别广泛。
@Repeatable(Reports.class)
@Target(ElementType.TYPE)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
@Target(ElementType.TYPE)
public @interface Reports {
Report[] value();
}
经过@Repeatable
修饰后,在某个类型声明处,就可以添加多个@Report
注解:
@Report(type=1, level="debug")
@Report(type=2, level="warning")
public class Hello {
}
@Inherited
使用@Inherited
定义子类是否可继承父类定义的Annotation
。@Inherited
仅针对@Target(ElementType.TYPE)
类型的annotation
有效,并且仅针对class
的继承,对interface
的继承无效
@Inherited
@Target(ElementType.TYPE)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
在使用的时候,如果一个类用到了@Report
:
@Report(type=1)
public class Person {
}
则它的子类默认也定义了该注解:
public class Student extends Person {
}
如何定义Annotation
我们总结一下定义Annotation
的步骤:
第一步,用@interface
定义注解:
public @interface Report {
}
第二步,添加参数、默认值:
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
把最常用的参数定义为value()
,推荐所有参数都尽量设置默认值。
第三步,用元注解配置注解:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Report {
int type() default 0;
String level() default "info";
String value() default "";
}
其中,必须设置@Target
和@Retention
,@Retention
一般设置为RUNTIME
,因为我们自定义的注解通常要求在运行期读取。一般情况下,不必写@Inherited
和@Repeatable
。
处理注解
判断某个注解是否存在于Class
、Field
、Method
或Constructor
:
Class.isAnnotationPresent(Class)
Field.isAnnotationPresent(Class)
Method.isAnnotationPresent(Class)
Constructor.isAnnotationPresent(Class)
例如:
// 判断@Report是否存在于Person类:
Person.class.isAnnotationPresent(Report.class);
使用反射API读取Annotation:
Class.getAnnotation(Class)
Field.getAnnotation(Class)
Method.getAnnotation(Class)
Constructor.getAnnotation(Class)
例如:
// 获取Person定义的@Report注解:
Report report = Person.class.getAnnotation(Report.class);
int type = report.type();
String level = report.level();
使用注释
注解如何使用,完全由程序自己决定。例如,JUnit是一个测试框架,它会自动运行所有标记为@Test
的方法。
我们来看一个@Range
注解,我们希望用它来定义一个String
字段的规则:字段长度满足@Range
的参数定义:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Range {
int min() default 0;
int max() default 255;
}
在某个JavaBean中,我们可以使用该注解:
public class Person {
@Range(min=1, max=20)
public String name;
@Range(max=10)
public String city;
}
但是,定义了注解,本身对程序逻辑没有任何影响。我们必须自己编写代码来使用注解。这里,我们编写一个Person
实例的检查方法,它可以检查Person
实例的String
字段长度是否满足@Range
的定义:
void check(Person person) throws IllegalArgumentException, ReflectiveOperationException {
// 遍历所有Field:
for (Field field : person.getClass().getFields()) {
// 获取Field定义的@Range:
Range range = field.getAnnotation(Range.class);
// 如果@Range存在:
if (range != null) {
// 获取Field的值:
Object value = field.get(person);
// 如果值是String:
if (value instanceof String) {
String s = (String) value;
// 判断值是否满足@Range的min/max:
if (s.length() < range.min() || s.length() > range.max()) {
throw new IllegalArgumentException("Invalid field: " + field.getName());
}
}
}
}
}
这样一来,我们通过@Range
注解,配合check()
方法,就可以完成Person
实例的检查。注意检查逻辑完全是我们自己编写的,JVM不会自动给注解添加任何额外的逻辑。
泛型
ArrayList
内部就是一个Object[]
数组,配合存储一个当前分配的长度,就可以充当“可变数组”:
public class ArrayList {
private Object[] array;
private int size;
public void add(Object e) {...}
public void remove(int index) {...}
public Object get(int index) {...}
}
需要强制转型,不便易错:
ArrayList list = new ArrayList();
list.add("Hello");
// 获取到Object,必须强制转型为String:
String first = (String) list.get(0);、
list.add(new Integer(123)); // ERROR: ClassCastException:
我们可以为String
单独编写一种ArrayList
:
public class StringArrayList {
private String[] array;
private int size;
public void add(String e) {...}
public void remove(int index) {...}
public String get(int index) {...}
} //这样一来,存入的必须是String,取出的也一定是String,不需要强制转型
如果要存储Integer
,还需要为Integer
单独编写一种ArrayList
:
public class IntegerArrayList {
private Integer[] array;
private int size;
public void add(Integer e) {...}
public void remove(int index) {...}
public Integer get(int index) {...}
}
还需要为其他所有class单独编写一种ArrayList
:
- LongArrayList
- DoubleArrayList
- PersonArrayList
- ...
为了解决新的问题,我们必须把ArrayList
变成一种模板:ArrayList<T>
,代码如下:
public class ArrayList<T> {
private T[] array;
private int size;
public void add(T e) {...}
public void remove(int index) {...}
public T get(int index) {...}
}
T
可以是任何class。这样一来,我们就实现了:编写一次模版,可以创建任意类型的ArrayList
:
// 创建可以存储String的ArrayList:
ArrayList<String> strList = new ArrayList<String>();
// 创建可以存储Float的ArrayList:
ArrayList<Float> floatList = new ArrayList<Float>();
// 创建可以存储Person的ArrayList:
ArrayList<Person> personList = new ArrayList<Person>();
泛型就是定义一种模板,例如ArrayList<T>
,然后在代码中为用到的类创建对应的ArrayList<类型>
:
ArrayList<String> strList = new ArrayList<String>();
strList.add("hello"); // OK
String s = strList.get(0); // OK
strList.add(new Integer(123)); // compile error!
Integer n = strList.get(0); // compile error!
既实现了编写一次,万能匹配,又通过编译器保证了类型安全:这就是泛型。
向上转型
类型ArrayList<T>
可以向上转型为List<T>
。
public class ArrayList<T> implements List<T> {
...
}
List<String> list = new ArrayList<String>();
特别注意:不能把ArrayList<Integer>
向上转型为ArrayList<Number>
或List<Number>
// 创建ArrayList<Integer>类型:
ArrayList<Integer> integerList = new ArrayList<Integer>();
// 添加一个Integer:
integerList.add(new Integer(123));
// “向上转型”为ArrayList<Number>:
ArrayList<Number> numberList = integerList;
// 添加一个Float,因为Float也是Number:
numberList.add(new Float(12.34));
// 从ArrayList<Integer>获取索引为1的元素(即添加的Float):
Integer n = integerList.get(1); // ClassCastException!
// ArrayList<Integer>和ArrayList<Number>两者完全没有继承关系。
小结:
- 泛型就是编写模板代码来适应任意类型;
- 泛型的好处是使用时不必对类型进行强制转换,它通过编译器对类型进行检查;
- 注意泛型的继承关系:可以把
ArrayList<Integer>
向上转型为List<Integer>
(T
不能变!),但不能把ArrayList<Integer>
向上转型为ArrayList<Number>
(T
不能变成父类)。
使用泛型
使用ArrayList
时,如果不定义泛型类型时,泛型类型实际上就是Object
:
// 编译器警告:
List list = new ArrayList();
list.add("Hello");
list.add("World");
String first = (String) list.get(0);
String second = (String) list.get(1);
此时,只能把<T>
当作Object
使用,没有发挥泛型的优势。当我们定义泛型类型<String>
后,List<T>
的泛型接口变为强类型List<String>
:
// 无编译器警告:
List<String> list = new ArrayList<String>();
list.add("Hello");
list.add("World");
// 无强制转型:
String first = list.get(0);
String second = list.get(1);
当我们定义泛型类型<Number>
后,List<T>
的泛型接口变为强类型List<Number>
:
List<Number> list = new ArrayList<Number>();
list.add(new Integer(123));
list.add(new Double(12.34));
Number first = list.get(0);
Number second = list.get(1);
编译器如果能自动推断出泛型类型,就可以省略后面的泛型类型。例如,对于下面的代码:ListList<Number>
就可以自动推断出后面的ArrayList<T>
的泛型类型必须是ArrayList<Number>
,因此,可以把代码简写为:List
泛型接口
例如,Arrays.sort(Object[])
可以对任意数组进行排序,但待排序的元素必须实现Comparable<T>
这个泛型接口:
public interface Comparable<T> {
/**
* 返回负数: 当前实例比参数o小
* 返回0: 当前实例与参数o相等
* 返回正数: 当前实例比参数o大
*/
int compareTo(T o);
}
可以直接对String进行排序,因为String
本身已经实现了Comparable<String>
接口:
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String[] ss = new String[] { "Orange", "Apple", "Pear" };
Arrays.sort(ss);
System.out.println(Arrays.toString(ss));
}
}
自定义的类型要进行排序,则:
// sort
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
Person[] ps = new Person[] {
new Person("Bob", 61),
new Person("Alice", 88),
new Person("Lily", 75),
};
Arrays.sort(ps);
System.out.println(Arrays.toString(ps));
}
}
class Person implements Comparable<Person> {
String name;
int score;
Person(String name, int score) {
this.name = name;
this.score = score;
}
// 若是缺乏compareTo方法将无法正确排序
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}// 可以正确实现按name进行排序。
public String toString() {
return this.name + "," + this.score;
}
}
小结
- 用泛型时,把泛型参数
<T>
替换为需要的class类型,例如:ArrayList<String>
,ArrayList<Number>
等; - 可以省略编译器能自动推断出的类型,例如:
List<String> list = new ArrayList<>();
; - 不指定泛型参数类型时,编译器会给出警告,且只能将
<T>
视为Object
类型; - 可以在接口中定义泛型类型,实现此接口的类必须实现正确的泛型类型。
编写泛型
编写泛型类比普通类要复杂。通常来说,泛型类一般用在集合类中,例如ArrayList<T>
,我们很少需要编写泛型类。需要编写时按照以下步骤:
首先按照某类型,例如:String来编写类:
public class Pair {
private String first;
private String last;
public Pair(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() {
return first;
}
public String getLast() {
return last;
}
}
然后,标记所有的特定类型,这里是String
替换成T,并申明<T>
:
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
静态方法
编写泛型类时,要特别注意,泛型类型<T>
不能用于静态方法。例如:
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }
// 对静态方法使用<T>:
public static Pair<T> create(T first, T last) {
return new Pair<T>(first, last);
}
}
我们无法在静态方法create()
的方法参数和返回类型上使用泛型类型T
。
对于静态方法,我们可以单独改写为“泛型”方法,只需要使用另一个类型即可。对于上面的create()
静态方法,我们应该把它改为另一种泛型类型,例如,<K>
:
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }
// 静态泛型方法应该使用其他类型区分,此处为K:
public static <K> Pair<K> create(K first, K last) {
return new Pair<K>(first, last);
}
}
多泛型类型
泛型还可以定义多种类型。例如,我们希望Pair
不总是存储两个类型一样的对象,就可以使用类型<T, K>
:
public class Pair<T, K> {
private T first;
private K last;
public Pair(T first, K last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public K getLast() { ... }
}
使用时也需要指定两种类型:Pair<String, Integer> p = new Pair<>("test", 123);
小结:
- 编写泛型时,需要定义泛型类型
<T>
; - 静态方法不能引用泛型类型
<T>
,必须定义其他类型(例如<K>
)来实现静态泛型方法; - 泛型可以同时定义多种类型,例如
Map<K, V>
。
擦拭法
泛型是一种类似”模板代码“的技术,不同语言的泛型实现方式不一定相同。JAVA语言的实现方法是擦拭法。所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。
例如:
// 编译器看到的代码
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
// 这是虚拟机执行的代码:
public class Pair {
private Object first;
private Object last;
public Pair(Object first, Object last) {
this.first = first;
this.last = last;
}
public Object getFirst() {
return first;
}
public Object getLast() {
return last;
}
}
在使用时:
// 编译器看到:
Pair<String> p = new Pair<>("Hello", "world");
String first = p.getFirst();
String last = p.getLast();
// 虚拟机执行:
Pair p = new Pair("Hello", "world");
String first = (String) p.getFirst();
String last = (String) p.getLast();
Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T
视为Object
处理,但是,在需要转型的时候,编译器会根据T
的类型自动为我们实行安全地强制转型。
局限:
<T>
不能是基本类型,例如int
,因为实际类型是Object
,Object
类型无法持有基本类型:
Pair<int> p = new Pair<>(1, 2); // compile error!
-
无法取得带泛型的
Class
。因为T
是Object
,我们对Pair<String>
和Pair<Integer>
类型获取Class
时,获取到的是同一个Class
,也就是Pair
类的Class
。 所有泛型实例,无论T
的类型是什么,getClass()
返回同一个Class
实例,因为编译后它们全部都是Pair<Object>
。 -
无法判断带泛型的类型
-
不能实例化
T
类型。要实例化T
类型,我们必须借助额外的Class<T>
参数:public class Pair<T> { private T first; private T last; public Pair(Class<T> clazz) { first = clazz.newInstance(); last = clazz.newInstance(); } }
上述代码借助
Class<T>
参数并通过反射来实例化T
类型,使用的时候,也必须传入Class<T>
。例如:Pairpair = new Pair<>(String.class);
不恰当的覆写方法
有些时候,一个看似正确定义的方法会无法通过编译。例如:
public class Pair<T> {
public boolean equals(T t) {
return this == t;
}
}
定义的equals(T t)
方法实际上会被擦拭成equals(Object t)
,而这个方法是继承自Object
的,编译器会阻止一个实际上会变成覆写的泛型方法定义。
换个方法名,避开与Object.equals(Object)
的冲突就可以成功编译:
public class Pair<T> {
public boolean same(T t) {
return this == t;
}
}
泛式继承
一个类可以继承自一个泛型类。例如:父类的类型是Pair<Integer>
,子类的类型是IntPair
,可以这么继承:
public class IntPair extends Pair<Integer> {
}
使用的时候,因为子类IntPair
并没有泛型类型,所以,正常使用即可:
IntPair ip = new IntPair(1, 2);
前面讲了,我们无法获取Pair<T>
的T
类型,即给定一个变量Pair<Integer> p
,无法从p
中获取到Integer
类型。
在父类是泛型类型的情况下,编译器就必须把类型T
(对IntPair
来说,也就是Integer
类型)保存到子类的class文件中,不然编译器就不知道IntPair
只能存取Integer
这种类型。在继承了泛型类型的情况下,子类可以获取父类的泛型类型。
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
public class Main {
public static void main(String[] args) {
Class<IntPair> clazz = IntPair.class;
Type t = clazz.getGenericSuperclass();
if (t instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) t;
Type[] types = pt.getActualTypeArguments(); // 可能有多个泛型类型
Type firstType = types[0]; // 取第一个泛型类型
Class<?> typeClass = (Class<?>) firstType;
System.out.println(typeClass); // Integer
}
}
}
class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
class IntPair extends Pair<Integer> {
public IntPair(Integer first, Integer last) {
super(first, last);
}
}
因为Java引入了泛型,所以,只用Class
来标识类型已经不够了。实际上,Java的类型系统结构如下:
小结:
- Java的泛型是采用擦拭法实现的;
- 擦拭法决定了泛型
<T>
: -
- 不能是基本类型,例如:
int
; - 不能获取带泛型类型的
Class
,例如:Pair<String>.class
; - 不能判断带泛型类型的类型,例如:
x instanceof Pair<String>
; - 不能实例化
T
类型,例如:new T()
。
- 不能是基本类型,例如:
- 泛型方法要防止重复定义方法,例如:
public boolean equals(T obj)
; - 子类可以获取父类的泛型类型
<T>
。
extends通配符
假设我们定义了Pair<T>
:public class Pair<T> { ... }
然后,我们又针对Pair<Number>
类型写了一个静态方法,它接收的参数类型是Pair<Number>
:
public class PairHelper {
static int add(Pair<Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}
}
上述代码是可以正常编译的。使用的时候,我们传入:
int sum = PairHelper.add(new Pair<Number>(1, 2));
注意:传入的类型是Pair<Number>
,实际参数类型是(Integer, Integer)
。既然实际参数是Integer
类型,试试传入Pair<Integer>
:
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);// 报错,因为Pair<Integer>不是Pair<Number>的子类,add(Pair<Number>)不接受参数类型Pair<Integer>。
System.out.println(n);
}
static int add(Pair<Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}
}
class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
实际类型是Integer
,引用类型是Number
,没有问题。问题在于方法参数类型定死了只能传入Pair<Number>
。使用Pair<? extends Number>
(上界通配符)使得方法接收所有泛型类型为Number
或Number
子类(包括Integer、Double、BigDecimal等)的Pair
类型。
改写为:
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}
static int add(Pair<? extends Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}
}
class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
如果我们考察对Pair<? extends Number>
类型调用getFirst()
方法,实际的方法签名变成了:<? extends Number> getFirst();
再来考察一下Pair<T>
的set
方法
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}
static int add(Pair<? extends Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
p.setFirst(new Integer(first.intValue() + 100));// set方法报错
p.setLast(new Integer(last.intValue() + 100));
return p.getFirst().intValue() + p.getFirst().intValue();
}
}
class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
public void setFirst(T first) {
this.first = first;
}
public void setLast(T last) {
this.last = last;
}
}
原因在于擦拭法。如果我们传入的p
是Pair<Double>
,显然它满足参数定义Pair<? extends Number>
,然而,Pair<Double>
的setFirst()
显然无法接受Integer
类型。
这就是<? extends Number>
通配符的一个重要限制:方法参数签名setFirst(? extends Number)
无法传递任何Number
子类型给setFirst(? extends Number)
。
这里唯一的例外是可以给方法参数传入null
:
p.setFirst(null); // ok, 但是后面会抛出NullPointerException
p.getFirst().intValue(); // NullPointerException
extends作用
如果我们考察Java标准库的java.util.List<T>
接口,它实现的是一个类似“可变数组”的列表,主要功能包括:
public interface List<T> {
int size(); // 获取个数
T get(int index); // 根据索引获取指定元素
void add(T t); // 添加一个新元素
void remove(T t); // 删除一个已有元素
}
定义一个方法处理列表每个元素:
int sumOfList(List<? extends Integer> list) {// 进行只读的方法
int sum = 0;
for (int i=0; i<list.size(); i++) {
Integer n = list.get(i);
sum = sum + n;
}
return sum;
}
为什么我们定义的方法参数类型是List<? extends Integer>
而不是List<Integer>
? 从方法内部代码看,传入List<? extends Integer>
或者List<Integer>
是完全一样的,但是,注意到List<? extends Integer>
的限制:
- 允许调用
get()
方法获取Integer
的引用; - 不允许调用
set(? extends Integer)
方法并传入任何Integer
的引用(null
除外)。
因此,方法参数类型List<? extends Integer>
表明了该方法内部只会读取List
的元素,不会修改List
的元素(因为无法调用add(? extends Integer)
、remove(? extends Integer)
这些方法。换句话说,这是一个对参数List<? extends Integer>
进行只读的方法(恶意调用set(null)
除外)。
使用extends限定T类型
在定义泛型类型Pair<T>
的时候,也可以使用extends
通配符来限定T
的类型:
public class Pair<T extends Number> { ... }
现在,我们只能定义:
Pair<Number> p1 = null;
Pair<Integer> p2 = new Pair<>(1, 2);
Pair<Double> p3 = null;
因为Number
、Integer
和Double
都符合<T extends Number>
。
非Number
类型将无法通过编译:
Pair<String> p1 = null; // compile error!
Pair<Object> p2 = null; // compile error!
因为String
、Object
都不符合<T extends Number>
,因为它们不是Number
类型或Number
的子类。
小结
- 使用类似
<? extends Number>
通配符作为方法参数时表示: -
- 方法内部可以调用获取
Number
引用的方法,例如:Number n = obj.getFirst();
; - 方法内部无法调用传入
Number
引用的方法(null
除外),例如:obj.setFirst(Number n);
。
- 方法内部可以调用获取
- 即一句话总结:使用
extends
通配符表示可以读,不能写。 - 使用类似
<T extends Number>
定义泛型类时表示: -
- 泛型类型限定为
Number
以及Number
的子类。
- 泛型类型限定为
super通配符
我们前面已经讲到了泛型的继承关系:Pair<Integer>
不是Pair<Number>
的子类。
考察下面的set
方法:
void set(Pair<Integer> p, Integer first, Integer last) {
p.setFirst(first);
p.setLast(last);
}
传入Pair<Integer>
是允许的,但是传入Pair<Number>
是不允许的。和extends
通配符相反,这次,我们希望接受Pair<Integer>
类型,以及Pair<Number>
、Pair<Object>
,因为Number
和Object
是Integer
的父类,setFirst(Number)
和setFirst(Object)
实际上允许接受Integer
类型。使用super
通配符来改写这个方法:
void set(Pair<? super Integer> p, Integer first, Integer last) {
p.setFirst(first);
p.setLast(last);
}
方法参数接受所有泛型类型为Integer
或Integer
父类的Pair
类型。
唯一可以接收getFirst()
方法返回值的是Object
类型:Object obj = p.getFirst();
因此,使用<? super Integer>
通配符表示:
- 允许调用
set(? super Integer)
方法传入Integer
的引用; - 不允许调用
get()
方法获得Integer
的引用。
唯一例外是可以获取Object
的引用:Object o = p.getFirst()
。
换句话说,使用<? super Integer>
通配符作为方法参数,表示方法内部代码对于参数只能写,不能读。
对比
作为方法参数,<? extends T>
类型和<? super T>
类型的区别在于:
<? extends T>
允许调用读方法T get()
获取T
的引用,但不允许调用写方法set(T)
传入T
的引用(传入null
除外);<? super T>
允许调用写方法set(T)
传入T
的引用,但不允许调用读方法T get()
获取T
的引用(获取Object
除外)。
一个是允许读不允许写,另一个是允许写不允许读。Producer Extends Consumer Super
即:如果需要返回T
,它是生产者(Producer),要使用extends
通配符;如果需要写入T
,它是消费者(Consumer),要使用super
通配符。
Java标准库的Collections
类定义的copy()
方法:
public class Collections {
// 把src的每个元素复制到dest中:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i=0; i<src.size(); i++) {
T t = src.get(i);
dest.add(t);
}
}
}
把一个List
的元素依次添加到另一个List
中。它的第一个参数是List<? super T>
,表示目标List
,第二个参数List<? extends T>
,表示要复制的List
。可以简单地用for
循环实现复制。在for
循环中,我们可以看到,对于类型<? extends T>
的变量src
,我们可以安全地获取类型T
的引用,而对于类型<? super T>
的变量dest
,我们可以安全地传入T
的引用。
这个copy()
方法的定义就完美地展示了extends
和super
的意图:
copy()
方法内不会读取dest
,因为不能调用dest.get()
获取T
的引用;copy()
方法内也不会修改src
,因为不能调用src.add(T)
。
意外修改了src
,或者意外读取了dest
,就会导致一个编译错误:
public class Collections {
// 把src的每个元素复制到dest中:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
...
T t = dest.get(0); // compile error!
src.add(t); // compile error!
}
}
这个copy()
方法可以安全地把List<Integer>
添加到List<Number>
,但无法反添加:
// copy List<Integer> to List<Number> ok:
List<Number> numList = ...;
List<Integer> intList = ...;
Collections.copy(numList, intList);
// ERROR: cannot copy List<Number> to List<Integer>:
Collections.copy(intList, numList);
而这些都是通过super
和extends
通配符,并由编译器强制检查来实现的。
无限定通配符
Java的泛型还允许使用无限定通配符(Unbounded Wildcard Type),即只定义一个?
:void sample(Pair<?> p) {}
因为<?>
通配符既没有extends
,也没有super
,因此:
- 不允许调用
set(T)
方法并传入引用(null
除外); - 不允许调用
T get()
方法并获取T
引用(只能获取Object
引用)。
既不能读,也不能写,那只能做一些null
判断。大多数情况下,可以引入泛型参数<T>
消除<?>
通配符:
static boolean isNull(Pair<?> p) {
return p.getFirst() == null || p.getLast() == null;
}//不可读不可写
static <T> boolean isNull(Pair<T> p) {
return p.getFirst() == null || p.getLast() == null;
}
<?>
通配符有一个独特的特点,就是:Pair<?>
是所有Pair<T>
的超类,如Pair<Integer>
是Pair<?>
的子类,可以安全地向上转型。
小结
- 使用类似
<? super Integer>
通配符作为方法参数时表示: -
- 方法内部可以调用传入
Integer
引用的方法,例如:obj.setFirst(Integer n);
; - 方法内部无法调用获取
Integer
引用的方法(Object
除外),例如:Integer n = obj.getFirst();
。
- 方法内部可以调用传入
- 即使用
super
通配符表示只能写不能读。 - 使用
extends
和super
通配符要遵循PECS原则。 - 无限定通配符
<?>
很少使用,可以用<T>
替换,同时它是所有<T>
类型的超类。
泛型和反射
Java的部分反射API也是泛型。例如:Class<T>
就是泛型:
// compile warning:
Class clazz = String.class;
String str = (String) clazz.newInstance();
// no warning:
Class<String> clazz = String.class;
String str = clazz.newInstance();
调用Class
的getSuperclass()
方法返回的Class
类型是Class<? super T>
:
Class<? super String> sup = String.class.getSuperclass();
构造方法Constructor<T>
也是泛型:
Class<Integer> clazz = Integer.class;
Constructor<Integer> cons = clazz.getConstructor(int.class);
Integer i = cons.newInstance(123);
我们可以声明带泛型的数组,但不能用new
操作符创建带泛型的数组:
Pair<String>[] ps = null; // ok
Pair<String>[] ps = new Pair<String>[2]; // compile error!
必须通过强制转型实现带泛型的数组:
@SuppressWarnings("unchecked")
Pair<String>[] ps = (Pair<String>[]) new Pair[2];
使用泛型数组要特别小心,因为数组运行期没有泛型,编译器强制检查变量ps
,因为类型是泛型数组。但编译器不会检查变量arr
,因为它不是泛型数组。因为这两个变量实际上指向同一个数组,所以,操作arr
可能导致从ps
获取元素时报错,例如,以下代码演示了不安全地使用带泛型的数组:
Pair[] arr = new Pair[2];
Pair<String>[] ps = (Pair<String>[]) arr;
ps[0] = new Pair<String>("a", "b");
arr[1] = new Pair<Integer>(1, 2);
// ClassCastException:
Pair<String> p = ps[1];
String s = p.getFirst();
要安全地使用泛型数组,必须扔掉arr
的引用:
@SuppressWarnings("unchecked")
Pair<String>[] ps = (Pair<String>[]) new Pair[2];
上面代码由于拿不到原始数组的引用,就只能对泛型数组ps
进行操作,这种操作就是安全的。带泛型的数组实际上是编译器的类型擦除:
Pair[] arr = new Pair[2];
Pair<String>[] ps = (Pair<String>[]) arr;
System.out.println(ps.getClass() == Pair[].class); // true
String s1 = (String) arr[0].getFirst();
String s2 = ps[0].getFirst();
所以我们不能直接创建泛型数组T[]
,因为擦拭后代码变为Object[]
:
// compile error:
public class Abc<T> {
T[] createArray() {
return new T[5];
}
}
T[] createArray(Class<T> cls) {
return (T[]) Array.newInstance(cls, 5);
} // 必须借助`Class<T>`来创建泛型数组;
还可以利用可变参数创建泛型数组T[]
:
public class ArrayHelper {
@SafeVarargs
static <T> T[] asArray(T... objs) {
return objs;
}
}
String[] ss = ArrayHelper.asArray("a", "b", "c");
Integer[] ns = ArrayHelper.asArray(1, 2, 3);
谨慎使用泛型可变参数
在上例中我们看到,通过:
static <T> T[] asArray(T... objs) {
return objs;
}
似乎可以安全地创建一个泛型数组。但实际上,这种方法非常危险。
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String[] arr = asArray("one", "two", "three");
System.out.println(Arrays.toString(arr));
// ClassCastException:
String[] firstTwo = pickTwo("one", "two", "three");
System.out.println(Arrays.toString(firstTwo));
}
static <K> K[] pickTwo(K k1, K k2, K k3) {
return asArray(k1, k2);
}
static <T> T[] asArray(T... objs) {
return objs;
}
}
直接调用asArray(T...)
似乎没有问题,但是在另一个方法中,我们返回一个泛型数组就会产生ClassCastException
,原因还是因为擦拭法,在pickTwo()
方法内部,编译器无法检测K[]
的正确类型,因此返回了Object[]
。
如果仔细观察,可以发现编译器对所有可变泛型参数都会发出警告,除非确认完全没有问题,才可以用@SafeVarargs
消除警告。
小结
- 部分反射API是泛型,例如:
Class<T>
,Constructor<T>
; - 可以声明带泛型的数组,但不能直接创建带泛型的数组,必须强制转型;
- 可以通过
Array.newInstance(Class<T>, int)
创建T[]
数组,需要强制转型; - 同时使用泛型和可变参数时需要特别小心。
集合
集合简介
数组存在的限制:
- 数组初始化后大小不可变;
- 数组只能按索引顺序存取。
collection
java.util
包提供了集合类:Collection
,它是除Map
外所有其他集合类的根接口。Java的java.util
包主要提供了以下三种类型的集合:
List
:一种有序列表的集合,例如,按索引排列的Student
的List
;Set
:一种保证没有重复元素的集合,例如,所有无重复名称的Student
的Set
;Map
:一种通过键值(key-value)查找的映射表集合,例如,根据Student
的name
查找对应Student
的Map
。
Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List
,具体的实现类有ArrayList
,LinkedList
等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:
List<String> list = new ArrayList<>(); // 只能放入String类型
Java访问集合总是通过迭代器(Iterator)来实现,它最明显好处在于无需知道集合内部元素是按什么方式存储的。
一些早先版本的遗留类不再使用:
Hashtable
:一种线程安全的Map
实现;
Vector
:一种线程安全的List
实现;
Stack
:基于Vector
实现的LIFO
的栈。
接口Enumeration<E>
:已被Iterator<E>
取代。
小结:
Java的集合类定义在java.util
包中,支持泛型,主要提供了3种集合类,包括List
,Set
和Map
。Java集合使用统一的Iterator
遍历,尽量不要使用遗留接口。
List
有序列表,增删比起数组更容易。ArrayList
把添加和删除的操作封装起来,让我们操作List
类似于操作数组,却不用关心内部元素如何移动。
List<E>
接口,可以看到几个主要的接口方法:
- 在末尾添加一个元素:
boolean add(E e)
- 在指定索引添加一个元素:
boolean add(int index, E e)
- 删除指定索引的元素:
E remove(int index)
- 删除某个元素:
boolean remove(Object e)
- 获取指定索引的元素:
E get(int index)
- 获取链表大小(包含元素的个数):
int size()
ArrayList通过数组实现,LinkedList则是通过链表实现。
ArrayList | LinkedList | |
---|---|---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
允许添加null
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple"); // size=1
list.add(null); // size=2
list.add("pear"); // size=3
String second = list.get(1); // null
System.out.println(second);
}
}
创建
除了使用ArrayList
和LinkedList
,还可以通过List
接口提供的of()
方法,根据给定元素快速创建List
,但是List.of()
方法不接受null
值:
List<Integer> list = List.of(1, 2, 5);
遍历
可以用for
循环根据索引配合get(int)
方法遍历;
使用迭代器Iterator
来访问List
:terator
对象知道如何遍历一个List
,并且不同的List
类型,返回的Iterator
对象实现也是不同的,但总是具有最高的访问效率。
Iterator
对象有两个方法:boolean hasNext()
判断是否有下一个元素,E next()
返回下一个元素。因此,使用Iterator
遍历List
代码如下:
import java.util.Iterator;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
}
}
// 另一种
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (String s : list) {
System.out.println(s);
}
}
}
转换
List—>Array:
1. Object[] array = list.toArray();
丢失类型信息
-
给
toArray(T[])
传入一个类型相同的Array
,Integer[] array = list.toArray(new Integer[3]);传入类型不匹配的数组,例如,
String[]
类型的数组,由于List
的元素是Integer
,所以无法放入String
数组,这个方法会抛出ArrayStoreException
。常用的是传入一个“恰好”大小的数组。 -
通过
List
接口定义的T[] toArray(IntFunction<T[]> generator)
方法:Integer[] array = list.toArray(Integer[]::new);
Array—>List:
通过List.of(T...)
方法最简单:
Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);
可以使用Arrays.asList(T...)
方法把数组转换成List
。
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
list.add(999); // UnsupportedOperationException
}
}//对只读List用add()、remove()方法会抛出UnsupportedOperationException。
练习题:给定一组无序连续的整数,例如:10,11,12,……,20,但其中缺失一个数字,试找出缺失的数字:
import java.util.*;
public class Main_list {
public static void main(String[] args) {
// 构造从start到end的序列:
final int start = 10;
final int end = 20;
List<Integer> list = new ArrayList<>();
for (int i = start; i <= end; i++) {
list.add(i);
}
// 洗牌算法shuffle可以随机交换List中的元素位置:
Collections.shuffle(list);
// 随机删除List中的一个元素:
int removed = list.remove((int) (Math.random() * list.size()));
int found = findMissingNumber(start, end, list);
System.out.println(list.toString());
System.out.println("missing number: " + found);
System.out.println(removed == found ? "测试成功" : "测试失败");
}
static int findMissingNumber(int start, int end, List<Integer> list) {
int sum = 0;
int count = 0;
for(Integer element:list){
count += element;
sum += (start++);
}
sum += start;
return sum-count;
}
}
小结:
List
是按索引顺序访问的长度可变的有序表,优先使用ArrayList
而不是LinkedList
;- 可以直接使用
for each
遍历List
; List
可以和Array
相互转换。
equals方法
List
提供了boolean contains(Object o)
方法来判断List
是否包含某个指定元素。此外,int indexOf(Object o)
方法可以返回某个元素的索引,如果元素不存在,就返回-1
。
List
内部并不是通过==
判断两个元素是否相等,而是使用equals()
方法判断两个元素是否相等,例如contains()
方法可以实现如下:
public class ArrayList {
Object[] elementData;
public boolean contains(Object o) {
for (int i = 0; i < elementData.length; i++) {
if (o.equals(elementData[i])) {
return true;
}
}
return false;
}
}
要正确使用List
的contains()
、indexOf()
这些方法,放入的实例必须正确覆写equals()
方法,否则,放进去的实例,查找不到。能正常放入String
、Integer
这些对象,是因为Java标准库定义的这些类已经正确实现了equals()
方法。
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Person> list = List.of(
new Person("Xiao Ming"),
new Person("Xiao Hong"),
new Person("Bob")
);
System.out.println(list.contains(new Person("Bob"))); // false
}
}
// 虽然放入了new Person("Bob"),但是用另一个new Person("Bob")查询不到,原因就是Person类没有覆写equals()方法。
class Person {
String name;
public Person(String name) {
this.name = name;
}
}
编写equals
equals()
方法要求我们必须满足以下条件:
- 自反性(Reflexive):对于非
null
的x
来说,x.equals(x)
必须返回true
; - 对称性(Symmetric):对于非
null
的x
和y
来说,如果x.equals(y)
为true
,则y.equals(x)
也必须为true
; - 传递性(Transitive):对于非
null
的x
、y
和z
来说,如果x.equals(y)
为true
,y.equals(z)
也为true
,那么x.equals(z)
也必须为true
; - 一致性(Consistent):对于非
null
的x
和y
来说,只要x
和y
状态不变,则x.equals(y)
总是一致地返回true
或者false
; - 对
null
的比较:即x.equals(null)
永远返回false
。
以Person为例:
public class Person {
public String name;
public int age;
}
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
return Objects.equals(this.name, p.name) && this.age == p.age; //equal条件
}
return false;
}
- 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
- 用
instanceof
判断传入的待比较的Object
是不是当前类型,如果是,继续比较,否则,返回false
; - 对引用类型用
Objects.equals()
比较,对基本类型直接用==
比较。
练习:给Person类增加equals方法,使得调用indexOf()方法返回正常:
import java.util.List;
import java.util.Objects;
public class Main {
public static void main(String[] args) {
List<Person> list = List.of(
new Person("Xiao", "Ming", 18),
new Person("Xiao", "Hong", 25),
new Person("Bob", "Smith", 20)
);
boolean exist = list.contains(new Person("Bob", "Smith", 20));
System.out.println(exist ? "测试成功!" : "测试失败!");
}
}
class Person {
String firstName;
String lastName;
int age;
public Person(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
小结:
- 在
List
中查找元素时,List
的实现类通过元素的equals()
方法比较两个元素是否相等,因此,放入的元素必须正确覆写equals()
方法,Java标准库提供的String
、Integer
等已经覆写了equals()
方法; - 编写
equals()
方法可借助Objects.equals()
判断。 - 如果不在
List
中查找元素,就不必覆写equals()
方法。
Map
Map
这种键值(key-value)映射表的数据结构,作用就是能高效通过key
快速查找value
(元素)。
用Map
来实现根据name
查询某个Student
的代码如下:
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 99);
Map<String, Student> map = new HashMap<>();
map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
System.out.println(target == s); // true,同一个实例
System.out.println(target.score); // 99
Student another = map.get("Bob"); // 通过另一个key查找
System.out.println(another); // 未找到返回null
}
}
class Student {
public String name;
public int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
}
Map<K, V>
是一种键-值映射表,当我们调用put(K key, V value)
方法时,就把key
和value
做了映射并放入Map
。当我们调用V get(K key)
时,就可以通过key
获取到对应的value
。如果key
不存在,则返回null
。和List
类似,Map
也是一个接口,最常用的实现类是HashMap
。
如果只是想查询某个key
是否存在,可以调用boolean containsKey(K key)
方法。
如果我们在存储Map
映射关系的时候,对同一个key调用两次put()
方法,分别放入不同的value
,会有什么问题呢?重复放入key-value
并不会有任何问题,但是一个key
只能关联一个value
。在上面的代码中,一开始我们把key
对象"apple"
映射到Integer
对象123
,然后再次调用put()
方法把"apple"
映射到789
,这时,原来关联的value
对象123
就被“冲掉”了。实际上,put()
方法的签名是V put(K key, V value)
,如果放入的key
已经存在,put()
方法会返回被删除的旧的value
,否则,返回null
。
此外,在一个Map
中,虽然key
不能重复,但value
是可以重复的。
遍历
对Map
来说,要遍历key
可以使用for each
循环遍历Map
实例的keySet()
方法返回的Set
集合,它包含不重复的key
的集合:
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println(key + " = " + value);
}
}
}
同时遍历key
和value
可以使用for each
循环遍历Map
对象的entrySet()
集合,它包含每一个key-value
映射:
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println(key + " = " + value);
}
}
}
Map
和List
不同的是,Map
存储的是key-value
的映射关系,并且,它不保证顺序。在遍历的时候,遍历的顺序既不一定是put()
时放入的key
的顺序。
练习:请编写一个根据name
查找score
的程序,并利用Map
充当缓存,以提高查找效率:
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Student> list = List.of(
new Student("Bob", 78),
new Student("Alice", 85),
new Student("Brush", 66),
new Student("Newton", 99));
var holder = new Students(list);
System.out.println(holder.getScore("Bob") == 78 ? "测试成功!" : "测试失败!");
System.out.println(holder.getScore("Alice") == 85 ? "测试成功!" : "测试失败!");
System.out.println(holder.getScore("Tom") == -1 ? "测试成功!" : "测试失败!");
}
}
class Students {
List<Student> list;
Map<String, Integer> cache;
Students(List<Student> list) {
this.list = list;
cache = new HashMap<>();
}
/**
* 根据name查找score,找到返回score,未找到返回-1
*/
int getScore(String name) {
// 先在Map中查找:
Integer score = this.cache.get(name);
if (score == null) {
// TODO:
score = findInList(name);
this.cache.put(name, score);
}
return score == null ? -1 : score.intValue();
}
Integer findInList(String name) {
for (var ss : this.list) {
if (ss.name.equals(name)) {
return ss.score;
}
}
return null;
}
}
class Student {
String name;
int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
}
小结
Map
是一种映射表,可以通过key
快速查找value
。- 可以通过
for each
遍历keySet()
,也可以通过for each
遍历entrySet()
,直接获取key-value
。 - 最常用的一种
Map
实现是HashMap
。
编写equals和HashCode
以hashmap为例:
Map<String, Person> map = new HashMap<>();
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));
map.put("c", new Person("Xiao Jun"));
map.get("a"); // Person("Xiao Ming")
map.get("x"); // null
HashMap
之所以能根据key
直接拿到value
,原因是它内部通过空间换时间的方法,用一个大数组存储所有value
,并根据key直接计算出value
应该存储在哪个索引:
如果key
的值为"a"
,计算得到的索引总是1
,因此返回value
为Person("Xiao Ming")
,如果key
的值为"b"
,计算得到的索引总是5
,因此返回value
为Person("Xiao Hong")
,这样,就不必遍历整个数组,即可直接读取key
对应的value
。
当我们使用key
存取value
的时候,就会引出一个问题:
我们放入Map
的key
是字符串"a"
,但是,当我们获取Map
的value
时,传入的变量不一定就是放入的那个key
对象。换句话讲,两个key
应该是内容相同,但不一定是同一个对象。测试代码如下:
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
String key1 = "a";
Map<String, Integer> map = new HashMap<>();
map.put(key1, 123);
String key2 = new String("a");
map.get(key2); // 123
System.out.println(key1 == key2); // false
System.out.println(key1.equals(key2)); // true
}
}
我们经常使用String
作为key
,因为String
已经正确覆写了equals()
方法。但如果我们放入的key
是一个自己写的类,就必须保证正确覆写了equals()
方法。
通过key
计算索引的方式就是调用key
对象的hashCode()
方法,它返回一个int
整数。HashMap
正是通过这个方法直接定位key
对应的value
的索引,继而直接返回value
。
因此,正确使用Map
必须保证:
- 作为
key
的对象必须正确覆写equals()
方法,相等的两个key
实例调用equals()
必须返回true
; - 作为
key
的对象还必须正确覆写hashCode()
方法,且hashCode()
方法要严格遵循以下规范:
- 如果两个对象相等,则两个对象的
hashCode()
必须相等;(正确性) - 如果两个对象不相等,则两个对象的
hashCode()
尽量不要相等。(保证查询和存储效率)
编写equals()方法与前文相同,引用类型使用Objects.equals()
比较,基本类型使用==
比较。在正确实现equals()
的基础上,我们还需要正确实现hashCode()
,即上述3个字段分别相同的实例,hashCode()
返回的int
必须相同:
public class Person {
String firstName;
String lastName;
int age;
@Override
int hashCode() {
int h = 0;
h = 31 * h + firstName.hashCode();
h = 31 * h + lastName.hashCode();
h = 31 * h + age;// 31*h是为了把不同的Person实例的hashCode均匀分布
return h;
}
}
如果firstName
或lastName
为null
,上述代码工作起来就会抛NullPointerException
。为了解决这个问题,我们在计算hashCode()
的时候,经常借助Objects.hash()
来计算:
int hashCode() {
return Objects.hash(firstName, lastName, age);
}
equals()
用到的用于比较的每一个字段,都必须在hashCode()
中用于计算;equals()
中没有使用到的字段,绝不可放在hashCode()
中计算。
延伸:
实际上HashMap
初始化时默认的数组大小只有16,任何key
,无论它的hashCode()
有多大,都可以简单地通过:
int index = key.hashCode() & 0xf; // 0xf = 15
把索引确定在0~15,即永远不会超出数组范围,上述算法只是一种最简单的实现。
对长度为32的数组计算hashCode()
对应的索引,计算方式要改为:
int index = key.hashCode() & 0x1f; // 0x1f = 31
如果不同的两个key
,例如"a"
和"b"
,它们的hashCode()
恰好是相同的(这种情况是完全可能的,因为不相等的两个实例,只要求hashCode()
尽量不相等),那么,当我们放入:
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));
时,由于计算出的数组索引相同,后面放入的"Xiao Hong"
会不会把"Xiao Ming"
覆盖了?
当然不会!使用Map
的时候,只要key
不相同,它们映射的value
就互不干扰。但是,在HashMap
内部,确实可能存在不同的key
,映射到相同的hashCode()
,即相同的数组索引上,肿么办?
我们就假设"a"
和"b"
这两个key
最终计算出的索引都是5,那么,在HashMap
的数组中,实际存储的不是一个Person
实例,而是一个List
,它包含两个Entry
,一个是"a"
的映射,一个是"b"
的映射:
在查找的时候,例如:
Person p = map.get("a");
HashMap内部通过"a"
找到的实际上是List<Entry<String, Person>>
,它还需要遍历这个List
,并找到一个Entry
,它的key
字段是"a"
,才能返回对应的Person
实例。
我们把不同的key
具有相同的hashCode()
的情况称之为哈希冲突。在冲突的时候,一种最简单的解决办法是用List
存储hashCode()
相同的key-value
。显然,如果冲突的概率越大,这个List
就越长,Map
的get()
方法效率就越低,这就是为什么要尽量满足条件二:
小结
- 要正确使用
HashMap
,作为key
的类必须正确覆写equals()
和hashCode()
方法; - 一个类如果覆写了
equals()
,就必须覆写hashCode()
,并且覆写规则是:- 如果
equals()
返回true
,则hashCode()
返回值必须相等; - 如果
equals()
返回false
,则hashCode()
返回值尽量不要相等。
- 如果
- 实现
hashCode()
方法可以通过Objects.hashCode()
辅助方法实现。
使用EnumMap
如果作为key的对象是enum
类型,那么,还可以使用Java集合库提供的一种EnumMap
,它在内部以一个非常紧凑的数组存储value,并且根据enum
类型的key直接定位到内部数组的索引,并不需要计算hashCode()
,不但效率最高,而且没有额外的空间浪费。
我们以DayOfWeek
这个枚举类型为例,为它做一个“翻译”功能:
import java.time.DayOfWeek;
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
map.put(DayOfWeek.MONDAY, "星期一");
map.put(DayOfWeek.TUESDAY, "星期二");
map.put(DayOfWeek.WEDNESDAY, "星期三");
map.put(DayOfWeek.THURSDAY, "星期四");
map.put(DayOfWeek.FRIDAY, "星期五");
map.put(DayOfWeek.SATURDAY, "星期六");
map.put(DayOfWeek.SUNDAY, "星期日");
System.out.println(map);
System.out.println(map.get(DayOfWeek.MONDAY));
}
}
使用EnumMap
的时候,我们总是用Map
接口来引用它,因此,实际上把HashMap
和EnumMap
互换,在客户端看来没有任何区别。
小结
- 如果
Map
的key是enum
类型,推荐使用EnumMap
,既保证速度,也不浪费空间。 - 使用
EnumMap
的时候,根据面向抽象编程的原则,应持有Map
接口。
TreeMap
SortedMap
可以在内部对Key进行排序,本质是接口,实现类是TreeMap。
SortedMap
保证遍历时以Key的顺序来进行排序。例如,放入的Key是"apple"
、"pear"
、"orange"
,遍历顺序一定是"apple"
、"orange"
、"pear"
,默认按字母排序:
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new TreeMap<>();
map.put("orange", 1);
map.put("apple", 2);
map.put("pear", 3);
for (String key : map.keySet()) {
System.out.println(key);
} // apple, orange, pear
}
}
放入的Key必须实现Comparable
接口。如果作为Key的class没有实现Comparable
接口,必须在创建TreeMap
时指定一个自定义排序算法。Comparator
接口要求实现一个比较方法,它负责比较传入的两个元素a
和b
,如果a<b
,则返回负数,通常是-1
,如果a==b
,则返回0
,如果a>b
,则返回正数,通常是1
。TreeMap
内部根据比较结果对Key进行排序:
public class Main {
public static void main(String[] args) {
Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.name.compareTo(p2.name);
}
});
map.put(new Person("Tom"), 1);
map.put(new Person("Bob"), 2);
map.put(new Person("Lily"), 3);
for (Person key : map.keySet()) {
System.out.println(key);
}
// {Person: Bob}, {Person: Lily}, {Person: Tom}
System.out.println(map.get(new Person("Bob"))); // 2
}
}
class Person {
public String name;
Person(String name) {
this.name = name;
}
public String toString() {
return "{Person: " + name + "}";
}
}
我们来看一个稍微复杂的例子:这次我们定义了Student
类,并用分数score
进行排序,高分在前:
public class Main {
public static void main(String[] args) {
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
});
map.put(new Student("Tom", 77), 1);
map.put(new Student("Bob", 66), 2);
map.put(new Student("Lily", 99), 3);
for (Student key : map.keySet()) {
System.out.println(key);
}
System.out.println(map.get(new Student("Bob", 66))); // null?
}
}
class Student {
public String name;
public int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
public String toString() {
return String.format("{%s: score=%d}", name, score);
}
}
在for
循环中,我们确实得到了正确的顺序。但是根据相同的Key:new Student("Bob", 66)
进行查找时,结果为null
!在这个例子中,TreeMap
出现问题,原因其实出在这个Comparator
上:
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
在p1.score
和p2.score
不相等的时候,它的返回值是正确的,但是,在p1.score
和p2.score
相等的时候,它并没有返回0
!这就是为什么TreeMap
工作不正常的原因。
public int compare(Student p1, Student p2) {
if (p1.score == p2.score) {
return 0;
}
return p1.score > p2.score ? -1 : 1;
}
小结
SortedMap
在遍历时严格按照Key的顺序遍历,最常用的实现类是TreeMap
;- 作为
SortedMap
的Key必须实现Comparable
接口,或者传入Comparator
; - 要严格按照
compare()
规范实现比较逻辑,否则,TreeMap
将不能正常工作。
Properties
编写程序写配置文件,如:
# 上次最后打开的文件:
last_open_file=/data/hello.txt
# 自动保存文件的时间间隔:
auto_save_interval=60
配置文件的特点是,它的Key-Value一般都是String
-String
类型的,因此我们完全可以用Map<String, String>
来表示它。
读取
用Properties
读取配置文件非常简单。Java默认配置文件以.properties
为扩展名,每行以key=value
表示,以#
课开头的是注释。以下是一个典型的配置文件:
# setting.properties
last_open_file=/data/hello.txt
auto_save_interval=60
可以从文件系统读取这个.properties
文件:
String f = "setting.properties";
Properties props = new Properties();
props.load(new java.io.FileInputStream(f));
String filepath = props.getProperty("last_open_file");
String interval = props.getProperty("auto_save_interval", "120");
可见,用Properties
读取配置文件,一共有三步:
- 创建
Properties
实例; - 调用
load()
读取文件; - 调用
getProperty()
获取配置。
调用getProperty()
获取配置时,如果key不存在,将返回null
。我们还可以提供一个默认值,这样,当key不存在的时候,就返回默认值。
也可以从classpath读取.properties
文件,因为load(InputStream)
方法接收一个InputStream
实例,表示一个字节流,它不一定是文件流,也可以是从jar包中读取的资源流:
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
试试从内存读取一个字节流:
import java.io.*;
import java.util.Properties;
public class Main {
public static void main(String[] args) throws IOException {
String settings = "# test" + "\n" + "course=Java" + "\n" + "last_open_date=2019-08-07T12:35:01";
ByteArrayInputStream input = new ByteArrayInputStream(settings.getBytes("UTF-8"));
Properties props = new Properties();
props.load(input);
System.out.println("course: " + props.getProperty("course"));
System.out.println("last_open_date: " + props.getProperty("last_open_date"));
System.out.println("last_open_file: " + props.getProperty("last_open_file"));
System.out.println("auto_save: " + props.getProperty("auto_save", "60"));
}
}
如果有多个.properties
文件,可以反复调用load()
读取,后读取的key-value会覆盖已读取的key-value:
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
props.load(new FileInputStream("C:\\conf\\setting.properties"));
上面的代码演示了Properties
的一个常用用法:可以把默认配置文件放到classpath中,然后,根据机器的环境编写另一个配置文件,覆盖某些默认的配置。
Properties
设计的目的是存储String
类型的key-value,但Properties
实际上是从Hashtable
派生的,它的设计实际上是有问题的,但是为了保持兼容性,现在已经没法修改了。除了getProperty()
和setProperty()
方法外,还有从Hashtable
继承下来的get()
和put()
方法,这些方法的参数签名是Object
,我们在使用Properties
的时候,不要去调用这些从Hashtable
继承下来的方法。
写入
如果通过setProperty()
修改了Properties
实例,可以把配置写入文件,以便下次启动时获得最新配置。写入配置文件使用store()
方法:
Properties props = new Properties();
props.setProperty("url", "http://www.liaoxuefeng.com");
props.setProperty("language", "Java");
props.store(new FileOutputStream("C:\\conf\\setting.properties"), "这是写入的properties注释");
load(InputStream)
默认总是以ASCII编码读取字节流,所以会导致读到乱码。我们需要用另一个重载方法load(Reader)
读取:
Properties props = new Properties();
props.load(new FileReader("settings.properties", StandardCharsets.UTF_8));
就可以正常读取中文。InputStream
和Reader
的区别是一个是字节流,一个是字符流。字符流在内存中已经以char
类型表示了,不涉及编码问题。
小结
- Java集合库提供的
Properties
用于读写配置文件.properties
。.properties
文件可以使用UTF-8编码。 - 可以从文件系统、classpath或其他任何地方读取
.properties
文件。 - 读写
Properties
时,注意仅使用getProperty()
和setProperty()
方法,不要调用继承而来的get()
和put()
等方法。
Set
如果只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set
。Set
可以用于去除重复元素。
Set
用于存储不重复的元素集合,它主要提供以下几个方法:
- 将元素添加进
Set<E>
:boolean add(E e)
- 将元素从
Set<E>
删除:boolean remove(Object e)
- 判断是否包含元素:
boolean contains(Object e)
public class Main {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
System.out.println(set.add("abc")); // true
System.out.println(set.add("xyz")); // true
System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在
System.out.println(set.contains("xyz")); // true,元素存在
System.out.println(set.contains("XYZ")); // false,元素不存在
System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在
System.out.println(set.size()); // 2,一共两个元素
}
}
要正确实现equals()
和hashCode()
方法,否则该元素无法正确地放入Set
。
最常用的Set
实现类是HashSet
,实际上,HashSet
仅仅是对HashMap
的一个简单封装,它的核心代码如下:
public class HashSet<E> implements Set<E> {
// 持有一个HashMap:
private HashMap<E, Object> map = new HashMap<>();
// 放入HashMap的value:
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}
Set
接口并不保证有序,而SortedSet
接口则保证元素是有序的:
HashSet
是无序的,因为它实现了Set
接口,并没有实现SortedSet
接口;TreeSet
是有序的,因为它实现了SortedSet
接口。
HashSet
的输出:
public class Main {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}
把HashSet
换成TreeSet
,在遍历TreeSet
时,输出就是有序的,这个顺序是元素的排序顺序:
public class Main {
public static void main(String[] args) {
Set<String> set = new TreeSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}
使用TreeSet
和使用TreeMap
的要求一样,添加的元素必须正确实现Comparable
接口,如果没有实现Comparable
接口,那么创建TreeSet
时必须传入一个Comparator
对象。
练习:在聊天软件中,发送方发送消息时,遇到网络超时后就会自动重发,因此,接收方可能会收到重复的消息,在显示给用户看的时候,需要首先去重。请练习使用Set
去除重复的消息:
import java.util.*;
public class Main_set {
public static void main(String[] args) {
List<Message> received = List.of(
new Message(1, "Hello!"),
new Message(2, "发工资了吗?"),
new Message(2, "发工资了吗?"),
new Message(3, "去哪吃饭?"),
new Message(3, "去哪吃饭?"),
new Message(4, "Bye")
);
List<Message> displayMessages = process(received);
for (Message message : displayMessages) {
System.out.println(message.text);
}
}
static List<Message> process(List<Message> received) {
// TODO: 按sequence去除重复消息
Set<String> messageSet = new HashSet<>();
List<Message> message = new ArrayList<>();
for(Message r:received){
boolean is_add = messageSet.add(r.text);
if (is_add){
message.add(r);
}
}
return message;
}
}
class Message {
public final int sequence;
public final String text;
public Message(int sequence, String text) {
this.sequence = sequence;
this.text = text;
}
}
小结:
Set
用于存储不重复的元素集合:- 放入
HashSet
的元素与作为HashMap
的key要求相同; - 放入
TreeSet
的元素与作为TreeMap
的Key要求相同;
- 放入
- 利用
Set
可以去除重复元素; - 遍历
SortedSet
按照元素的排序顺序遍历,也可以自定义排序算法。
Queue(队列)
先进先出(FIFO):把元素添加到队列末尾 + 从队列头部取出元素。
队列接口Queue
定义了以下几个方法:
int size()
:获取队列长度;boolean add(E)
/boolean offer(E)
:添加元素到队尾;E remove()
/E poll()
:获取队首元素并从队列中删除;E element()
/E peek()
:获取队首元素但并不从队列中删除。
throw Exception | 返回false或null | |
---|---|---|
添加元素到队尾 | add(E e) | boolean offer(E e) |
取队首元素并删除 | E remove() | E poll() |
取队首元素但不删除 | E element() | E peek() |
LinkedList
即实现了List
接口,又实现了Queue
接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();
小结:
- 队列
Queue
实现了一个先进先出(FIFO)的数据结构:- 通过
add()
/offer()
方法将元素添加到队尾; - 通过
remove()
/poll()
从队首获取元素并删除; - 通过
element()
/peek()
从队首获取元素但不删除。
- 通过
- 要避免把
null
添加到队列。
PriorityQueue
它的出队顺序与元素的优先级有关,对PriorityQueue
调用remove()
或poll()
方法,返回的总是优先级最高的元素。
import java.util.PriorityQueue;
import java.util.Queue;
public class Main {
public static void main(String[] args) {
Queue<String> q = new PriorityQueue<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // null,因为队列为空
}
}
PriorityQueue
允许我们提供一个Comparator
对象来判断两个元素的顺序。
import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Queue;
public class Main {
public static void main(String[] args) {
Queue<User> q = new PriorityQueue<>(new UserComparator());
// 添加3个元素到队列:
q.offer(new User("Bob", "A1"));
q.offer(new User("Alice", "A2"));
q.offer(new User("Boss", "V1"));
System.out.println(q.poll()); // Boss/V1
System.out.println(q.poll()); // Bob/A1
System.out.println(q.poll()); // Alice/A2
System.out.println(q.poll()); // null,因为队列为空
}
}
// TODO
class UserComparator implements Comparator<User> {
public int compare(User u1, User u2) {
if (u1.number.charAt(0) == u2.number.charAt(0)) {
// 如果两人的号都是A开头或者都是V开头,比较号的大小:
return u1.number.compareTo(u2.number);
}
if (u1.number.charAt(0) == 'V') {
// u1的号码是V开头,优先级高:
return -1;
} else {
return 1;
}
}
}
class User {
public final String name;
public final String number;
public User(String name, String number) {
this.name = name;
this.number = number;
}
public String toString() {
return name + "/" + number;
}
}
小结:
PriorityQueue
实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。PriorityQueue
默认按元素比较的顺序排序(必须实现Comparable
接口),也可以通过Comparator
自定义排序算法(元素就不必实现Comparable
接口)。
Deque
双端队列:既可以添加到队尾,也可以添加到队首;既可以从队首获取,又可以从队尾获取。
Queue | Deque | |
---|---|---|
添加元素到队尾 | add(E e) / offer(E e) | addLast(E e) / offerLast(E e) |
取队首元素并删除 | E remove() / E poll() | E removeFirst() / E pollFirst() |
取队首元素但不删除 | E element() / E peek() | E getFirst() / E peekFirst() |
添加元素到队首 | 无 | addFirst(E e) / offerFirst(E e) |
取队尾元素并删除 | 无 | E removeLast() / E pollLast() |
取队尾元素但不删除 | 无 | E getLast() / E peekLast() |
注意到Deque
接口实际上扩展自Queue
:
public interface Deque<E> extends Queue<E> {
...
}
因此,Queue
提供的add()
/offer()
方法在Deque
中也可以使用,但是,使用Deque
,最好不要调用offer()
,而是调用offerLast()
:
import java.util.Deque;
import java.util.LinkedList;
public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.offerLast("A"); // A
deque.offerLast("B"); // A <- B
deque.offerFirst("C"); // C <- A <- B
System.out.println(deque.pollFirst()); // C, 剩下A <- B
System.out.println(deque.pollLast()); // B, 剩下A
System.out.println(deque.pollFirst()); // A
System.out.println(deque.pollFirst()); // null
}
}
Deque
是一个接口,它的实现类有ArrayDeque
和LinkedList
。
总是用特定的接口来引用LinkedList,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。
// 不推荐的写法:
LinkedList<String> d1 = new LinkedList<>();
d1.offerLast("z");
// 推荐的写法:
Deque<String> d2 = new LinkedList<>();
d2.offerLast("z");
可见面向抽象编程的一个原则就是:尽量持有接口,而不是具体的实现类。
小结
Deque可以:
- 将元素添加到队尾或队首:
addLast()
/offerLast()
/addFirst()
/offerFirst()
; - 从队首/队尾获取元素并删除:
removeFirst()
/pollFirst()
/removeLast()
/pollLast()
; - 从队首/队尾获取元素但不删除:
getFirst()
/peekFirst()
/getLast()
/peekLast()
; - 总是调用
xxxFirst()
/xxxLast()
以便与Queue
的方法区分开; - 避免把
null
添加到队列。
Stack(栈)
先进后出
- 把元素压栈:
push(E)
; - 把栈顶的元素“弹出”:
pop()
; - 取栈顶元素但不弹出:
peek()
。
我们用Deque
可以实现Stack
的功能:
- 把元素压栈:
push(E)
/addFirst(E)
; - 把栈顶的元素“弹出”:
pop()
/removeFirst()
; - 取栈顶元素但不弹出:
peek()
/peekFirst()
。
Java的集合类没有单独的Stack
接口。因为有个遗留类名字就叫Stack
,出于兼容性考虑,所以没办法创建Stack
接口,只能用Deque
接口来“模拟”一个Stack
了。
当我们把Deque
作为Stack
使用时,注意只调用push()
/pop()
/peek()
方法,不要调用addFirst()
/removeFirst()
/peekFirst()
方法,这样代码更加清晰。
作用
维护方法调用的层次。
static void main(String[] args) {
foo(123);
}
static String foo(x) {
return "F-" + bar(x + 1);
}
static int bar(int x) {
return x << 2;
}
JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。
可以用于进制转换
计算中缀表达式(带括号的数学表达式)
练习:请利用Stack把一个给定的整数转换为十六进制:
import java.util.*;
public class Main {
public static void main(String[] args) {
String hex = toHex(12500);
if (hex.equalsIgnoreCase("30D4")) {
System.out.println("测试通过");
} else {
System.out.println("测试失败");
}
}
static String toHex(int n) {
// TODO
Deque<String> stack = new ArrayDeque();
while(n>0){
stack.push(Integer.toHexString(n%16));
n /= 16;
}
StringBuilder answer = new StringBuilder();
int size = stack.size();
for (int i = 0; i < size; i++){
answer.append(stack.pop());
}
return answer.toString();
}
}
小结:
- 栈(Stack)是一种后进先出(LIFO)的数据结构,操作栈的元素的方法有:
- 把元素压栈:
push(E)
; - 把栈顶的元素“弹出”:
pop(E)
; - 取栈顶元素但不弹出:
peek(E)
。
- 把元素压栈:
- 在Java中,我们用
Deque
可以实现Stack
的功能,注意只调用push()
/pop()
/peek()
方法,避免调用Deque
的其他方法。 - 最后,不要使用遗留类
Stack
。
Iterator
实际上,Java编译器并不知道如何遍历List
。上述代码能够编译通过,只是因为编译器把for each
循环通过Iterator
改写为了普通的for
循环:
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
我们把这种通过Iterator
对象遍历集合的模式称为迭代器。
但是这样一来,调用方就必须知道集合的内部存储结构。如果把ArrayList
换成LinkedList
,get(int)
方法耗时会随着index的增加而增加。如果把ArrayList
换成Set
,上述代码就无法编译,因为Set
内部没有索引。
用Iterator
遍历就没有上述问题,因为Iterator
对象是集合对象自己在内部创建的,它自己知道如何高效遍历内部的数据集合,调用方则获得了统一的代码,编译器才能把标准的for each
循环自动转换为Iterator
遍历。
如果我们自己编写了一个集合类,想要使用for each
循环,只需满足以下条件:
- 集合类实现
Iterable
接口,该接口要求返回一个Iterator
对象; - 用
Iterator
对象迭代集合内部数据。
这里的关键在于,集合类通过调用iterator()
方法,返回一个Iterator
对象,这个对象必须自己知道如何遍历该集合。
一个简单的Iterator
示例如下,它总是以倒序遍历集合:
import java.util.*;
public class Main {
public static void main(String[] args) {
ReverseList<String> rlist = new ReverseList<>();
rlist.add("Apple");
rlist.add("Orange");
rlist.add("Pear");
for (String s : rlist) {
System.out.println(s);
}
}
}
class ReverseList<T> implements Iterable<T> {
private List<T> list = new ArrayList<>();
public void add(T t) {
list.add(t);
}
@Override
public Iterator<T> iterator() {
return new ReverseIterator(list.size());
}
class ReverseIterator implements Iterator<T> {
int index;
ReverseIterator(int index) {
this.index = index;
}
@Override
public boolean hasNext() {
return index > 0;
}
@Override
public T next() {
index--;
return ReverseList.this.list.get(index);
}
}
}
小结
Iterator
是一种抽象的数据访问模型。使用Iterator
模式进行迭代的好处有:- 对任何集合都采用同一种访问模型;
- 调用者对集合内部结构一无所知;
- 集合类返回的
Iterator
对象知道如何迭代。
- Java提供了标准的迭代器模型,即集合类实现
java.util.Iterable
接口,返回java.util.Iterator
实例。
Collections
Collections
是JDK的工具类,提供一系列静态方法更方便地操作各种集合。
创建空集合
Collections
提供了一系列方法来创建空集合:
- 创建空List:
List<T> emptyList()
- 创建空Map:
Map<K, V> emptyMap()
- 创建空Set:
Set<T> emptySet()
要注意到返回的空集合是不可变集合,无法向其中添加或删除元素。
也可以用各个集合接口提供的of(T...)
方法创建空集合。例如,以下创建空List
的两个方法是等价的:
List<String> list1 = List.of();
List<String> list2 = Collections.emptyList();
单元素集合
Collections
提供了一系列方法来创建一个单元素集合:
- 创建一个元素的List:
List<T> singletonList(T o)
- 创建一个元素的Map:
Map<K, V> singletonMap(K key, V value)
- 创建一个元素的Set:
Set<T> singleton(T o)
要注意到返回的单元素集合也是不可变集合,无法向其中添加或删除元素。
也可以用各个集合接口提供的of(T...)
方法创建单元素集合。例如,以下创建单元素List
的两个方法是等价的:
List<String> list1 = List.of("apple");
List<String> list2 = Collections.singletonList("apple");
实际上,使用List.of(T...)
更方便,因为它既可以创建空集合,也可以创建单元素集合,还可以创建任意个元素的集合:
List<String> list1 = List.of(); // empty list
List<String> list2 = List.of("apple"); // 1 element
List<String> list3 = List.of("apple", "pear"); // 2 elements
List<String> list4 = List.of("apple", "pear", "orange"); // 3 elements
排序
Collections
可以对List
进行排序。因为排序会直接修改List
元素的位置,因此必须传入可变List
:
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("pear");
list.add("orange");
// 排序前:
System.out.println(list);
Collections.sort(list);
// 排序后:
System.out.println(list);
}
}
洗牌
随机打乱List
内部元素的顺序:
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i=0; i<10; i++) {
list.add(i);
}
// 洗牌前:
System.out.println(list);
Collections.shuffle(list);
// 洗牌后:
System.out.println(list);
}
}
不可变集合
Collections
还提供了一组方法把可变集合封装成不可变集合,通过创建一个代理对象,拦截掉所有修改方法实现的。
- 封装成不可变List:
List<T> unmodifiableList(List<? extends T> list)
- 封装成不可变Set:
Set<T> unmodifiableSet(Set<? extends T> set)
- 封装成不可变Map:
Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
immutable.add("orange"); // UnsupportedOperationException!
}
}
继续对原始的可变List
进行增删是可以的,并且,会直接影响到封装后的“不可变”List
:
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
mutable.add("orange");
System.out.println(immutable);
}
}
如果我们希望把一个可变List
封装成不可变List
,那么,返回不可变List
后,最好立刻扔掉可变List
的引用。
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
// 立刻扔掉mutable的引用:
mutable = null;
System.out.println(immutable);
}
}
线程安全集合
Collections
还提供了一组方法,可以把线程不安全的集合变为线程安全的集合:
- 变为线程安全的List:
List<T> synchronizedList(List<T> list)
- 变为线程安全的Set:
Set<T> synchronizedSet(Set<T> s)
- 变为线程安全的Map:
Map<K,V> synchronizedMap(Map<K,V> m)
后续引入了更高效的并发集合类,所以上述这几个同步方法已经没有什么用了。
小结
Collections
类提供了一组工具方法来方便使用集合类:
- 创建空集合;
- 创建单元素集合;
- 创建不可变集合;
- 排序/洗牌等操作。
多线程(Thread)
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
实现和同步是重点
创建新线程
当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()
方法。在main()
方法中,我们又可以启动其他线程。当run()
方法结束时,新线程就结束了。而main()
方法结束时,主线程也结束了。
要创建一个新线程非常容易,我们需要实例化一个Thread
实例,然后调用它的start()
方法:
public class Main {
public static void main(String[] args) {
Thread t = new Thread();
t.start(); // 启动新线程
}
}
多线程执行方法:
- 从
Thread
派生一个自定义类,然后覆写run()
方法:
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
// start()方法会在内部自动调用实例的run()方法。
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
- 创建
Thread
实例时,传入一个Runnable
实例:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}
或者用lambda语法进一步简写为:
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start();
System.out.println("main end...");
}
}
要模拟并发执行的效果,我们可以在线程中调用Thread.sleep()
,强迫当前线程暂停一段时间,可以看到main
线程和t
线程执行的先后顺序:
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
System.out.println("thread end.");
}
};
t.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}
使用时是开启start(),而不是直接调用run()
方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。
可以对线程设定优先级,设定优先级的方法是:
Thread.setPriority(int n) // 1~10, 默认值5
优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
小结
- Java用
Thread
对象表示一个线程,通过调用start()
启动一个新线程; - 一个线程对象只能调用一次
start()
方法; - 线程的执行代码写在
run()
方法中; - 线程调度由操作系统决定,程序本身无法决定调度顺序;
Thread.sleep()
可以把当前线程暂停一段时间。
线程状态
一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。线程的状态包括:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行
run()
方法的Java代码; - Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行
sleep()
方法正在计时等待; - Terminated:线程已终止,因为
run()
方法执行完毕。
终止的原因有:
- 线程正常终止:
run()
方法执行到return
语句返回; - 线程意外终止:
run()
方法因为未捕获的异常导致线程终止; - 对某个线程的
Thread
实例调用stop()
方法强制终止(强烈不推荐使用)。
一个线程还可以等待另一个线程直到其运行结束。例如,main
线程在启动t
线程后,可以通过t.join()
等待t
线程结束后再继续运行:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start();
t.join();//等待`t`线程结束后再继续运行
System.out.println("end");
}
}
小结
- Java线程对象
Thread
的状态包括:New
、Runnable
、Blocked
、Waiting
、Timed Waiting
和Terminated
; - 通过对另一个线程对象调用
join()
方法可以等待其执行结束; - 可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
- 对已经运行结束的线程调用
join()
方法会立刻返回。
中断线程
需要在其他线程中对目标线程调用interrupt()
方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是就立刻结束运行。
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停1毫秒
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}
例如,t.join()
会让main
线程进入等待状态,此时,如果对main
线程调用interrupt()
,join()
方法会立刻抛出InterruptedException
,因此,目标线程只要捕获到join()
方法抛出的InterruptedException
,就说明有其他线程对其调用了interrupt()
方法,通常情况下该线程应该立刻结束运行。
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main
线程通过调用t.interrupt()
从而通知t
线程中断,而此时t
线程正位于hello.join()
的等待中,此方法会立刻结束等待并抛出InterruptedException
。由于我们在t
线程中捕获了InterruptedException
,因此,就可以准备结束该线程。在t
线程结束前,对hello
线程也进行了interrupt()
调用通知其中断。如果去掉这一行代码,可以发现hello
线程仍然会继续运行,且JVM不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个running
标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running
置为false
,就可以让线程结束:
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
注意到HelloThread
的标志位boolean running
是一个线程间共享的变量。线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
因此,volatile
解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
小结
- 对目标线程调用
interrupt()
方法可以请求中断一个线程,目标线程通过检测isInterrupted()
标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException
; - 目标线程检测到
isInterrupted()
为true
或者捕获了InterruptedException
都应该立刻结束自身线程; - 通过标志位判断需要正确使用
volatile
关键字; volatile
关键字解决了共享变量在线程间的可见性问题。
守护线程
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
守护线程不能持有任何需要关闭的资源,例如打开文件等
小结
- 守护线程是为其他线程服务的线程;
- 所有非守护线程都执行完毕后,虚拟机退出;
- 守护线程不能持有需要关闭的资源(如打开文件等)。
线程同步
如果多个线程同时读写共享变量,会出现数据不一致的问题:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count += 1; }
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间。这种方法称为临界区(Critical Sectio),任何时候临界区最多只有一个线程能执行。
Java程序使用synchronized
关键字对一个对象进行加锁,将上述代码改写为:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count += 1;
} // 用Counter.lock实例作为锁
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}
加锁和解锁需要消耗一定的时间,所以,synchronized
会降低程序的执行效率。我们来概括一下如何使用synchronized
:
- 找出修改共享变量的线程代码块;
- 选择一个共享实例作为锁;
- 使用
synchronized(lockObject) { ... }
。
注意:若两个线程各自的synchronized
锁住的不是同一个对象,则依然无法正确保证同步。这会使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。
例:4个线程对两个共享变量分别进行读写操作。但根据逻辑实际可以分为两组,通过两组锁来实现同步,以提升效率
public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
class Counter {
public static final Object lock = new Object(); //创建实例
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}
不需要synchronized的操作
JVM规范定义了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
; - 引用类型赋值,例如:
List<String> list = anotherList
。
long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
对引用也是类似。例如:
public void set(String s) {
this.value = s;
}
上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
就不再需要同步,因为this.pair = ps
是引用赋值的原子操作。而语句:int[] ps = new int[] { first, last };
。这里的ps
是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。
小结
- 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过
synchronized
同步; - 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
- 注意加锁对象必须是同一个实例;
- 对JVM定义的单个原子操作不需要同步。
同步方法
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized
逻辑封装起来。例如,我们编写一个计数器如下:
public class Counter {
private int count = 0;
public void add(int n) {
synchronized(this) {
count += n;
}//在方法内部将操作锁起来
}
public void dec(int n) {
synchronized(this) {
count -= n;
}
}
public int get() {
return count;
}
}
synchronized
锁住的对象是this
,即当前实例,这又使得创建多个Counter
实例的时候,它们之间互不影响,可以并发执行。
var c1 = Counter();
var c2 = Counter();
// 对c1进行操作的线程:
new Thread(() -> {
c1.add();
}).start();
new Thread(() -> {
c1.dec();
}).start();
// 对c2进行操作的线程:
new Thread(() -> {
c2.add();
}).start();
new Thread(() -> {
c2.dec();
}).start();
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter
类就是线程安全的。Java标准库的java.lang.StringBuffer
也是线程安全的。
当我们锁住的是this
实例时,实际上可以用synchronized
修饰这个方法。下面两种写法是等价的:
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
//--------------------------------
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
如果对一个静态方法添加synchronized
修饰符,它锁住的是哪个对象?
public synchronized static void test(int n) {
...
}
// 相当于:
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}
对于static
方法,是没有this
实例的,因为static
方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class
实例,因此,对static
方法添加synchronized
,锁住的是该类的Class
实例。
再考察Counter
的get()
方法:
public class Counter {
private int count;
public int get() {
return count;
}
...
}
它没有同步,因为读一个int
变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个int
的对象:
public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}
就必须要同步了。
小结
- 用
synchronized
修饰方法可以把整个方法变为同步代码块,synchronized
方法加锁对象是this
; - 通过合理的设计和数据封装可以让一个类变为“线程安全”;
- 一个类没有特殊说明,默认不是thread-safe;
- 多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。
死锁
Java的线程锁是可重入的锁。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。我们还是来看例子:
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
- 线程1:进入
add()
,获得lockA
; - 线程2:进入
dec()
,获得lockB
。
随后:
- 线程1:准备获得
lockB
,失败,等待中; - 线程2:准备获得
lockA
,失败,等待中。
两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序,改写dec()
方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
小结:
- Java的
synchronized
锁是可重入锁; - 死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;
- 避免死锁的方法是多线程获取锁的顺序要一致。
wait和notify
但是synchronized
并没有解决多线程协调的问题。
仍然以上面的TaskQueue
为例,我们再编写一个getTask()
方法取出队列的第一个任务:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}// while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。
}
多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}// 必须在当前获取的锁对象上调用。
return queue.remove();
}
必须在synchronized
块中才能调用wait()
方法,因为wait()
方法调用时,会释放线程获得的锁,wait()
方法返回后,线程又会重新试图获得锁。
当一个线程在this.wait()
等待时,它就会释放this
锁,从而使得其他线程能够在addTask()
方法获得this
锁。
使用notify()让等待的线程被重新唤醒,然后从wait()
方法返回
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
注意到在往队列中添加了任务后,线程立刻对this
锁对象调用notify()
方法,这个方法会唤醒一个正在this
锁等待的线程(就是在getTask()
中位于this.wait()
的线程),从而使得等待线程从this.wait()
方法返回。
完整样例:
import java.util.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
使用notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
要始终在while
循环中wait()
,并且每次被唤醒后拿到this
锁就必须再次判断:
while (queue.isEmpty()) {
this.wait();
}
小结
- 在
synchronized
内部可以调用wait()
使线程进入等待状态; - 必须在已获得的锁对象上调用
wait()
方法; - 在
synchronized
内部可以调用notify()
或notifyAll()
唤醒其他等待线程; - 必须在已获得的锁对象上调用
notify()
或notifyAll()
方法; - 已唤醒的线程还需要重新获得锁后才能继续执行。
使用ReentrantLock
java.util.concurrent.locks
包提供的ReentrantLock
用于替代synchronized
加锁
public class Counter {
private int count;
public void add(int n) {
synchronized(this) {
count += n;
}
}
}
// 改写为
public class Counter {
private final Lock lock = new ReentrantLock();// lock
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
synchronized
是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock
是Java代码实现的锁,我们就必须先获取锁,然后在finally
中正确释放锁。
和synchronized
不同的是,ReentrantLock
可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {// 尝试获取锁时最多等待1s
try {
...
} finally {
lock.unlock();
}
}
tryLock()
返回false
,程序就可以做一些额外处理,而不是无限等待下去。在tryLock()
失败的时候不会导致死锁。
小结
ReentrantLock
可以替代synchronized
进行同步;ReentrantLock
获取锁更安全;- 必须先获取到锁,再进入
try {...}
代码块,最后使用finally
保证释放锁; - 可以使用
tryLock()
尝试获取锁。
使用Condition
用Condition
对象来实现wait
和notify
的功能。
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition(); // 获得一个绑定了Lock实例的Condition实例
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll(); // notifyAll
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await(); // wait
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
引用的Condition
对象必须从Lock
实例的newCondition()
返回。
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
小结
Condition
可以替代wait
和notify
;Condition
对象必须从Lock
对象获取。
使用ReadWriteLock
(本章内容被下章内容迭代)
前面讲到的ReentrantLock
保证了只有一个线程可以执行临界区代码:
public class Counter {
private final Lock lock = new ReentrantLock();
private int[] counts = new int[10];
public void inc(int index) {
lock.lock();
try {
counts[index] += 1;
} finally {
lock.unlock();
}
}
public int[] get() {
lock.lock();
try {
return Arrays.copyOf(counts, counts.length);
} finally {
lock.unlock();
}
}
}
但是有些时候,这种保护有点过头。因为我们发现,任何时刻,只允许一个线程修改,也就是调用inc()
方法是必须获取锁,但是,get()
方法只读取数据,不修改数据,它实际上允许多个线程同时调用。实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。
使用ReadWriteLock
可以保证:
- 只允许一个线程写入(其他线程既不能写入也不能读取);
- 没有写入时,多个线程允许同时读(提高性能)。
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
小结
ReadWriteLock
只允许一个线程写入;ReadWriteLock
允许多个线程在没有写入时同时读取;ReadWriteLock
适合读多写少的场景。
StampedLock
如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。引入了新的读写锁:StampedLock
。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
我们通过tryOptimisticRead()
获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()
去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。
StampedLock
是不可重入锁,不能在一个线程中反复获取同一个锁。提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
小结
StampedLock
提供了乐观读锁,可取代ReadWriteLock
以进一步提升并发性能;StampedLock
是不可重入锁。
使用Concurrent集合
我们在前面已经通过ReentrantLock
和Condition
实现了一个BlockingQueue
:
public class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
BlockingQueue
的意思就是说,当一个线程调用这个TaskQueue
的getTask()
方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()
方法才会返回。
因为BlockingQueue
非常有用,所以我们不必自己编写,可以直接使用Java标准库的java.util.concurrent
包提供的线程安全的集合:ArrayBlockingQueue
。
除了BlockingQueue
外,针对List
、Map
、Set
、Deque
等,java.util.concurrent
包也提供了对应的并发集合类。
interface | non-thread-safe | thread-safe |
---|---|---|
List | ArrayList | CopyOnWriteArrayList |
Map | HashMap | ConcurrentHashMap |
Set | HashSet / TreeSet | CopyOnWriteArraySet |
Queue | ArrayDeque / LinkedList | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
使用这些并发集合与使用非线程安全的集合类完全相同。我们以ConcurrentHashMap
为例:
Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");
因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,把:
Map<String, String> map = new HashMap<>();
// 改为对应的类
Map<String, String> map = new ConcurrentHashMap<>();
小结
- 使用
java.util.concurrent
包提供的线程安全的并发集合可以大大简化多线程编程: - 多线程同时读写并发集合是安全的;
- 尽量使用Java标准库提供的并发集合,避免自己编写同步代码。
使用Atomic
提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic
包。
AtomicInteger
为例,它提供的主要操作有:
- 增加值并返回新值:
int addAndGet(int delta)
- 加1后返回新值:
int incrementAndGet()
- 获取当前值:
int get()
- 用CAS方式设置:
int compareAndSet(int expect, int update)
Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。
如果我们自己通过CAS编写incrementAndGet()
,它大概长这样:
public int incrementAndGet(AtomicInteger var) {
int prev, next;
do {
prev = var.get();
next = prev + 1;
} while ( ! var.compareAndSet(prev, next));
return next;
}
CAS是指,在这个操作中,如果AtomicInteger
的当前值是prev
,那么就更新为next
,返回true
。如果AtomicInteger
的当前值不是prev
,就什么也不干,返回false
。通过CAS操作并配合do ... while
循环,即使其他线程修改了AtomicInteger
的值,最终的结果也是正确的。
我们利用AtomicLong
可以编写一个多线程安全的全局唯一ID生成器:
class IdGenerator {
AtomicLong var = new AtomicLong(0);
public long getNextId() {
return var.incrementAndGet();
}
}
通常情况下,我们并不需要直接用do ... while
循环调用compareAndSet
实现复杂的并发操作,而是用incrementAndGet()
这样的封装好的方法,因此,使用起来非常简单。
在高度竞争的情况下,还可以使用Java 8提供的LongAdder
和LongAccumulator
。
小结
使用java.util.concurrent.atomic
提供的原子操作可以简化多线程编程:
- 原子操作实现了无锁的线程安全;
- 适用于计数器,累加器等。
使用线程池
频繁创建和销毁大量线程需要消耗大量时间。把很多小任务让一组线程来执行,而不是一个任务对应一个新线程。这种能接收大量小任务并进行分发处理的就是线程池。
简单地说,线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。如果有新任务,就分配一个空闲线程执行。如果所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。
ExecutorService
接口表示线程池,它的典型用法如下:
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
因为ExecutorService
只是接口,Java标准库提供的几个常用实现类有:
- FixedThreadPool:线程数固定的线程池;
- CachedThreadPool:线程数根据任务动态调整的线程池;
- SingleThreadExecutor:仅单线程执行的线程池。
创建这些线程池的方法都被封装到Executors
这个类中。
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
使用shutdown()
方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow()
会立刻停止正在执行的任务,awaitTermination()
则会等待指定的时间让线程池关闭。
如果我们把线程池改为CachedThreadPool
,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以6个任务可一次性全部同时执行。
我们想把线程池的大小限制在4~10个之间动态调整:
int min = 4;
int max = 10;
ExecutorService es = new ThreadPoolExecutor(min, max,
60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
ScheduledThreadPool
还有一种任务,需要定期反复执行,例如,每秒刷新证券价格。这种任务本身固定,需要反复执行的,可以使用ScheduledThreadPool
。放入ScheduledThreadPool
的任务可以定期反复执行。
创建一个ScheduledThreadPool
仍然是通过Executors
类:
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);
// 1秒后执行一次性任务:
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);
// 2秒后开始执行定时任务,每3秒执行:
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);
// 2秒后开始执行定时任务,以3秒为间隔执行:
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);
FixedRate是指任务总是以固定时间间隔触发,不管任务执行多长时间,而FixedDelay是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务。
小结
- 线程池内部维护一组线程,可以高效执行大量小任务;
Executors
提供了静态方法创建不同类型的ExecutorService
;- 必须调用
shutdown()
关闭ExecutorService
; ScheduledThreadPool
可以定期调度多个任务。
使用Future
(被下一章的内容迭代)
在执行多个任务的时候,使用Java标准库提供的线程池是非常方便的。我们提交的任务只需要实现Runnable
接口,就可以让线程池去执行:
class Task implements Runnable {
public String result;
public void run() {
this.result = longTimeCalculation();
}
}
Runnable
接口有个问题,它的方法没有返回值。Java标准库还提供了一个Callable
接口,它多了一个返回值。
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
并且Callable
接口是一个泛型接口,可以返回指定类型的结果。
如何获得异步执行的结果?
如果仔细看ExecutorService.submit()
方法,可以看到,它返回了一个Future
类型,一个Future
类型的实例代表一个未来能获取结果的对象:
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
当我们提交一个Callable
任务后,我们会同时获得一个Future
对象,然后,我们在主线程某个时刻调用Future
对象的get()
方法,就可以获得异步执行的结果。在调用get()
时,如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么get()
会阻塞,直到任务完成后才返回结果。
一个Future<V>
接口表示一个未来可能会返回的结果,它定义的方法有:
get()
:获取结果(可能会等待)get(long timeout, TimeUnit unit)
:获取结果,但只等待指定的时间;cancel(boolean mayInterruptIfRunning)
:取消当前任务;isDone()
:判断任务是否已完成。
小结
对线程池提交一个Callable
任务,可以获得一个Future
对象;
可以用Future
在将来某个时刻获取结果。
使用CompletableFuture
使用Future
获得异步执行结果时,要么调用阻塞方法get()
,要么轮询看isDone()
是否为true
,这两种方法都不是很好,因为主线程也会被迫等待。
以获取股票价格为例,看看如何使用CompletableFuture
:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice);
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
return null;
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static Double fetchPrice() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
if (Math.random() < 0.3) {
throw new RuntimeException("fetch price failed!");
}
return 5 + Math.random() * 20;
}
}
创建一个CompletableFuture
是通过CompletableFuture.supplyAsync()
实现的,它需要一个实现了Supplier
接口的对象:
public interface Supplier<T> {
T get();
}
紧接着,CompletableFuture
已经被提交给默认的线程池执行了,我们需要定义的是CompletableFuture
完成时和异常时需要回调的实例。
public interface Consumer<T> {
void accept(T t);
}
异常时,CompletableFuture
会调用Function
对象:
public interface Function<T, R> {
R apply(T t);
}
这里用lambda语法简化了代码。
CompletableFuture
的优点是:
- 异步任务结束时,会自动回调某个对象的方法;
- 异步任务出错时,会自动回调某个对象的方法;
- 主线程设置好回调后,不再关心异步任务的执行。
CompletableFuture
更强大的功能是,多个CompletableFuture
可以串行执行,例如,定义两个CompletableFuture
,第一个CompletableFuture
根据证券名称查询证券代码,第二个CompletableFuture
根据证券代码查询证券价格,这两个CompletableFuture
实现串行操作如下:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 第一个任务:
CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油");
});
// cfQuery成功后继续执行下一个任务:
CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> {
return fetchPrice(code);
});
// cfFetch成功后打印结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}
static String queryCode(String name) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
并行:同时从新浪和网易查询证券代码,只要任意一个返回结果,就进行下一步查询价格,查询价格也同时从新浪和网易查询,只要任意一个返回结果,就完成操作:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 两个CompletableFuture执行异步查询:
CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://finance.sina.com.cn/code/");
});
CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://money.163.com/code/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163);
// 两个CompletableFuture执行异步查询:
CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://finance.sina.com.cn/price/");
});
CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://money.163.com/price/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163);
// 最终结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static String queryCode(String name, String url) {
System.out.println("query code from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code, String url) {
System.out.println("query price from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
除了anyOf()
可以实现“任意个CompletableFuture
只要一个成功”,allOf()
可以实现“所有CompletableFuture
都必须成功”,这些组合操作可以实现非常复杂的异步流程控制。
CompletableFuture
的命名规则:
xxx()
:表示该方法将继续在已有的线程中执行;xxxAsync()
:表示将异步在线程池中执行。
小结:
CompletableFuture
可以指定异步处理流程:
thenAccept()
处理正常结果;exceptional()
处理异常结果;thenApplyAsync()
用于串行化另一个CompletableFuture
;anyOf()
和allOf()
用于并行化多个CompletableFuture
。
ForkJoin
Fork/Join线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。使用Fork/Join对大数据进行并行求和:
import java.util.Random;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
// 创建2000个随机数组成的数组:
long[] array = new long[2000];
long expectedSum = 0;
for (int i = 0; i < array.length; i++) {
array[i] = random();
expectedSum += array[i];
}
System.out.println("Expected sum: " + expectedSum);
// fork/join:
ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
long startTime = System.currentTimeMillis();
Long result = ForkJoinPool.commonPool().invoke(task);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
static Random random = new Random(0);
static long random() {
return random.nextInt(10000);
}
}
class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 500;
long[] array;
int start;
int end;
SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// 如果任务足够小,直接计算:
long sum = 0;
for (int i = start; i < end; i++) {
sum += this.array[i];
// 故意放慢计算速度:
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
}
return sum;
}
// 任务太大,一分为二:
int middle = (end + start) / 2;
System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end));
SumTask subtask1 = new SumTask(this.array, start, middle);
SumTask subtask2 = new SumTask(this.array, middle, end);
invokeAll(subtask1, subtask2);
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
Long result = subresult1 + subresult2;
System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
return result;
}
}
核心代码SumTask
继承自RecursiveTask
,在compute()
方法中,关键是如何“分裂”出子任务并且提交子任务:
class SumTask extends RecursiveTask<Long> {
protected Long compute() {
// “分裂”子任务:
SumTask subtask1 = new SumTask(...);
SumTask subtask2 = new SumTask(...);
// invokeAll会并行运行两个子任务:
invokeAll(subtask1, subtask2);
// 获得子任务的结果:
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
// 汇总结果:
return subresult1 + subresult2;
}
}
Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)
可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。
小结
- Fork/Join是一种基于“分治”的算法:通过分解任务,并行执行,最后合并结果得到最终结果。
ForkJoinPool
线程池可以把一个大任务分拆成小任务并行执行,任务类必须继承自RecursiveTask
或RecursiveAction
。- 使用Fork/Join模式可以进行并行计算以提高效率。
使用ThreadLocal
多线程是Java实现多任务的基础,Thread
对象代表一个线程,我们可以在代码中调用Thread.currentThread()
获取当前线程。例如,打印日志时,可以同时打印出当前线程的名字:
public class Main {
public static void main(String[] args) throws Exception {
log("start main...");
new Thread(() -> {
log("run task...");
}).start();
new Thread(() -> {
log("print...");
}).start();
log("end main.");
}
static void log(String s) {
System.out.println(Thread.currentThread().getName() + ": " + s);
}
}
Java标准库提供的线程池可以方便地执行这些任务,同时复用线程。Web应用程序就是典型的多任务应用,每个用户请求页面时,我们都会创建一个任务,类似:
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}
然后,通过线程池去执行这些任务。
观察process()
方法,它内部需要调用若干其他方法,同时,我们遇到一个问题:如何在一个线程内传递状态?
process()
方法需要传递的状态就是User
实例。有的童鞋会想,简单地传入User
就可以了:
public void process(User user) {
checkPermission(user);
doWork(user);
saveStatus(user);
sendResponse(user);
}
但是往往一个方法又会调用其他很多方法,这样会导致User
传递到所有地方:
void doWork(User user) {
queryStatus(user);
checkStatus();
setNewStatus(user);
log();
}
这种在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。
给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,User
对象就传不进去了。
Java标准库提供了一个特殊的ThreadLocal
,它可以在一个线程中传递同一个对象。
ThreadLocal
实例通常总是以静态字段初始化如下:
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
它的典型使用方式如下:
void processUser(user) {
try {
threadLocalUser.set(user);
step1();
step2();
} finally {
threadLocalUser.remove();
}
}
通过设置一个User
实例关联到ThreadLocal
中,在移除之前,所有方法都可以随时获取到该User
实例:
void step1() {
User u = threadLocalUser.get();
log();
printUser();
}
void log() {
User u = threadLocalUser.get();
println(u.name);
}
void step2() {
User u = threadLocalUser.get();
checkUser(u.id);
}
注意到普通的方法调用一定是同一个线程执行的,所以,step1()
、step2()
以及log()
方法内,threadLocalUser.get()
获取的User
对象是同一个实例。
实际上,可以把ThreadLocal
看成一个全局Map<Thread, Object>
:每个线程获取ThreadLocal
变量时,总是使用Thread
自身作为key:
Object threadLocalValue = threadLocalMap.get(Thread.currentThread());
因此,ThreadLocal
相当于给每个线程都开辟了一个独立的存储空间,各个线程的ThreadLocal
关联的实例互不干扰。
最后,特别注意ThreadLocal
一定要在finally
中清除:
try {
threadLocalUser.set(user);
...
} finally {
threadLocalUser.remove();
}
这是因为当前线程执行完相关代码后,很可能会被重新放入线程池中,如果ThreadLocal
没有被清除,该线程执行其他代码时,会把上一次的状态带进去。
为了保证能释放ThreadLocal
关联的实例,我们可以通过AutoCloseable
接口配合try (resource) {...}
结构,让编译器自动为我们关闭。例如,一个保存了当前用户名的ThreadLocal
可以封装为一个UserContext
对象:
public class UserContext implements AutoCloseable {
static final ThreadLocal<String> ctx = new ThreadLocal<>();
public UserContext(String user) {
ctx.set(user);
}
public static String currentUser() {
return ctx.get();
}
@Override
public void close() {
ctx.remove();
}
}
使用的时候,我们借助try (resource) {...}
结构,可以这么写:
try (var ctx = new UserContext("Bob")) {
// 可任意调用UserContext.currentUser():
String currentUser = UserContext.currentUser();
} // 在此自动调用UserContext.close()方法释放ThreadLocal关联对象
这样就在UserContext
中完全封装了ThreadLocal
,外部代码在try (resource) {...}
内部可以随时调用UserContext.currentUser()
获取当前线程绑定的用户名。
小结
ThreadLocal
表示线程的“局部变量”,它确保每个线程的ThreadLocal
变量都是各自独立的;ThreadLocal
适合在一个线程的处理流程中保持上下文(避免了同一参数在所有方法中传递);- 使用
ThreadLocal
要用try ... finally
结构,并在finally
中清除。
IO
Java标准库的java.io
包提供了同步IO功能:
- 字节流接口:
InputStream
/OutputStream
; - 字符流接口:
Reader
/Writer
。
File对象
File f = new File(filepath);
路径表示
getPath()
,返回构造方法传入的路径
getAbsolutePath()
,返回绝对路径
getCanonicalPath
,类似绝对路径,但返回的是规范路径。
因为Windows和Linux的路径分隔符不同,File对象有一个静态变量用于表示当前平台的系统分隔符:
System.out.println(File.separator); // 根据当前平台打印"\"或"/"
isFile()
,判断该File
对象是否是一个已存在的文件isDirectory()
,判断File`对象是否是一个已存在的目录
File f1 = new File("C:\\Windows");
System.out.println(f1.isFile());
System.out.println(f1.isDirectory());
boolean canRead()
:是否可读;boolean canWrite()
:是否可写;boolean canExecute()
:是否可执行;long length()
:文件字节大小。
创建和删除
createNewFile()
创建一个新文件,返回布尔值delete()
删除该文件(其中文件夹只能删除空的
import java.io.File;
import java.io.IOException;
public class FileHandler{
public static void main(String[] args) {
File newFile = new File("newFile.txt");
newFile.createNewFile();
newFile.delete();
}
}
给一个文件写入内容,则需要 FileWriter 这个类
try {
FileWriter writer = new FileWriter("newFile.txt");
writer.write("Hello World!");
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
对目录:
list()
和listFiles()
列出目录下的文件和子目录名。
boolean mkdir()
:创建当前File对象表示的目录;boolean mkdirs()
:创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;boolean delete()
:删除当前File对象表示的目录,当前目录必须为空才能删除成功。
InputStream
InputStream
并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read()
这个方法会读取输入流的下一个字节,并返回字节表示的int
值(0~255)。如果已读到末尾,返回-1
表示不能继续读取了。
FileInputStream
是InputStream
的一个子类。FileInputStream
就是从文件流中读取数据。
InputStream input = new FileInputStream("src/readme.txt");
InputStream
和OutputStream
都是通过close()
方法来关闭流。关闭流就会释放对应的底层资源。用try ... finally
来保证InputStream
在无论是否发生IO错误的时候都能够正确地关闭.
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
int n;
while ((n = input.read()) != -1) {
System.out.println(n);
}
} // 编译器在此自动为我们写入finally并调用close()
}
缓冲
InputStream
提供了两个重载方法来支持读取多个字节:
int read(byte[] b)
:读取若干字节并填充到byte[]
数组,返回读取的字节数int read(byte[] b, int off, int len)
:指定byte[]
数组的偏移量和最大填充数
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)