Java
简介
广泛应用的 Java
-
Java是基于JVM虚拟机的跨平台语言,一次编写,到处运行;
-
Java程序易于编写,而且有内置垃圾收集,不必考虑内存管理;
-
Java虚拟机拥有工业级的稳定性和高度优化的性能,且经过了长时期的考验;
-
Java拥有最广泛的开源社区支持,各种高质量组件随时可用。
作者:SUN公司(已被Oracle收购)的 詹姆斯·高斯林
一次编写,到处运行
Java介于编译型语言和解释型语言之间,将代码编译成一种“字节码”,它类似于抽象的CPU指令;然后,针对不同平台编写虚拟机,不同平台的虚拟机(JVM)负责加载字节码并执行;对Java开发者而言,实现了一次编写,到处运行
Java版本
-
Java ME:针对嵌入式设备的“瘦身版”
-
Java SE:标准版,包含标准的JVM和标准库
-
Java EE:企业版,Java SE基础上加上了大量的API和库,以便方便开发Web应用、数据库、消息服务
JRE:运行Java字节码的虚拟机(Java Runtime Environment)
jdk:要编译成Java字节码还需JDK(Java Development Kit),JDK包含JRE,还提供了编译器、调试器等开发工具
┌─ ┌──────────────────────────────────┐
│ │ Compiler, debugger, etc. │
│ └──────────────────────────────────┘
JDK ┌─ ┌──────────────────────────────────┐
│ │ │ │
│ JRE │ JVM + Runtime Library │
│ │ │ │
└─ └─ └──────────────────────────────────┘
┌───────┐┌───────┐┌───────┐┌───────┐
│Windows││ Linux ││ macOS ││others │
└───────┘└───────┘└───────┘└───────┘
JSR规范:Java平台功能规范,从JVM的内存模型到Web程序接口(Java Specification Request),它由JCP组织审核
安装
JDK
Oracle的官网下载对应稳定版JDK:[Java Downloads | Oracle](https://www.oracle.com/java/technologies/downloads/) |
设置环境变量
java 1.5后无需手动设置环境,如需要可在系统环境变量新增
Path=%JAVA_HOME%\bin;<现有的其他路径>
运行:java -version
尝试查看版本
程序代码
// Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
Java规定,某个类定义的 public static void main(String[] args)
是Java程序的固定入口方法,必须定义为 public
类型的静态class,Java程序总是从main
方法开始执行
文件名必须与类名完全保持一致
运行代码
通过 javac 将 .java
文件编译成 .class
字节码文件:javac Hello.java
虚拟机执行字节码文件,将自动寻找参数对应 .class
文件:java Hello
或者直接 java Hello.java
Java基础
程序基本结构
Java是面向对象的语言,一个程序的基本单位就是 class
,class
是关键字,这里定义的 class
名字就是 Hello
public class Hello { // 类名是Hello
// ...
} // class定义结束
若无 public
,也能正确编译,但是这个类将无法从命令行执行
class
内部,可以定义若干方法(method):
public class Hello {
public static void main(String[] args) { // 方法名是main
// 方法代码...
} // 方法定义结束
}
变量与数据类型
声明变量:int a = 100; int b
变量赋值:a = 200; int c = a;
基本数据类型
-
整数类型:byte,short,int,long
-
浮点数类型:float,double
-
字符类型:char
-
布尔类型:boolean
整型
int i2 = -2147483648;
int i3 = 2_000_000_000; // 加下划线更容易识别
int i4 = 0xff0000; // 十六进制
long n1 = 9000000000000000000L; // long型的结尾需要加L
long n2 = 900; // 此处900为int,但int类型可以赋值给long
int i6 = 900L; // 错误:不能把long型赋值给int
浮点型
float f1 = 3.14f;
float f2 = 3.14e38f; // 科学计数法表示的3.14x10^3
double d = 1.79e308;
double d2 = -1.79e308;
double d3 = 4.9e-324; // 科学计数法表示的4.9x10^-3248
布尔类型
boolean b1 = true;
boolean b2 = false;
boolean isGreater = 5 > 3; // 计算结果为true
字符串类型
字符类型
char a = 'A'; // 单引号 '
字符串类型
String s = "hello";
常量
final double PI = 3.14; // final 修饰符
var 关键字
赋值语句自动推断出变量的类型:
var sb = new StringBuilder();
StringBuilder sb = new StringBuilder();
作用域
java存在块级作用域,以 {}
为分隔
基础运算
整数运算
整数运算永远返回整数
int x = 12345 / 67; // 184
int y = 12345 % 67; // 12345÷67的余数是17
参与运算的两个数类型不一致,那么计算结果为较大类型的整型。例如,short
和int
计算,结果总是 int
也支持强制转型:
short s = (short) i; // 12345
int n3 = (int) (12.7 + 0.5); // 13
字符串运算
\ 转义
String s = "abc\"xyz"; // 包含7个字符: a, b, c, ", x, y, z、
// \n 换行
多行字符串
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC""";
数组类型
一组相同类型的变量
int[] ns = new int[3]; // 一组字符串的数组
ns[0] = 68;
ns[1] = 79;
ns[2] = 91;
System.out.println(ns[3]); // 索引n不能超出范围
直接初始化
ns = new int[] { 68, 79, 91, 85, 62 };
// 简化为
int[] ns = { 68, 79, 91, 85, 62 };
String[] names = {
"ABC", "XYZ", "zoo"
};
流程控制
输入输出
输出方法
System.out.print()
System.out.println()
输出并换行
System.out.printf(f, String)
格式化输出
格式化输出
指定输出内容格式,并传入参数得到格式输出结果
public class Hello {
public static void main(String[] args) {
double num = 3.1415926;
System.out.printf("%.2f\n", num); // 3.14
}
}
占位符 | 说明 |
---|---|
%d | 格式化输出整数 |
%x | 格式化输出十六进制整数 |
%f | 格式化输出浮点数 |
%e | 格式化输出科学计数法表示的浮点数 |
%s | 格式化字符串 |
条件语句
判断变量值相等
int n = 90;
if(n == 90) {
...
}
判断引用相等
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
if (s1 == s2) {
System.out.println("s1 == s2");
} else {
System.out.println("s1 != s2");
}
判断引用类型值相等
s1.equals(s2); // s1 为 null则会报错 NullPointerException
多重选择
switch
除了基础 switch 语法,还有表达式语法
public static void main(String[] args) {
int n = 90;
String result = switch(n){
case 10 -> "is 10"; // 直接返回
case 90 -> "is 90";
default -> {
yield "is 999"; // yield关键字
}
};
System.out.println(result);
}
循环
while/do while/ for
数组操作
遍历数组
int[] list = { 6, 6, 6, 1, 8 };
for (int i = 0;i < list.length;i++) {
System.out.println(list[i]);
}
for each
循环,迭代数组的每个元素
int[] list = { 6, 6, 6, 1, 8 };
for (int num : list) {
System.out.println(num);
}
快速打印数组内容为字符串
import java.util.Arrays; // Java标准库提供
...
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
数组排序
冒泡排序
内置sort
只需调用JDK提供的 Arrays.sort()
,注意这将修改数组
多维数组
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
Arrays.deepToString(ns);
命令行参数
Java程序的入口是 main
方法,接受一个命令行参数,一个 String[]
数组;
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
System.out.println(Arrays.toString(args));
}
}
面向对象
定义类基础
类与实例的关系,如定义一个 City 类并使用
public class Main {
public static void main(String[] args) {
City changsha = new City();
changsha.name = "changsha";
changsha.latitude = 42.09;
changsha.longitude = 122.92;
System.out.println(changsha.name);
System.out.println(changsha.latitude);
System.out.println(changsha.longitude);
}
}
class City {
public String name;
public double latitude;
public double longitude;
}
注:一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。
方法
定义方法
public String name;
public String getName() {
return this.name; // this指向实例
}
System.out.println(changsha.getName());
定义方法修改/访问私有 field
private int age;
public void setAge(Int age) {
this.age = age;
}
changsha.setAge(200);
System.out.println(changsha.getAge());
定义内部的私有方法
private int birthYears;
public void setBirthYears(int birthYears) {
this.birthYears = birthYears;
}
public int getAge() {
return calcAge(); // 可忽略this
}
private int calcAge(){
return 2024 - this.birthYears;
}
changsha.setBirthYears(-376);
System.out.println(changsha.getAge());
参数
严格按照参数定义传递
public void setNameAndAge(String name, int age) {
...
}
Person ming = new Person();
ming.setNameAndAge(12, "Xiao Ming"); // 参数数量
可变参数
public void setNames(String... names) { // ...可变参数
this.names = names; // 数组类型
}
Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
构造方法
允许指定构造方法初始化实例,未指定将生成空构造函数
class City {
public City(String name, double latitude, double longitude, int birthYears){
this.name = name;
this.latitude = latitude;
this.longitude = longitude;
this.birthYears = birthYears;
}
// ...
}
City changsha = new City("changsha", 42.09, 122.92, -376);
构造方法的名称就是类名,且没有返回值(也没有 void
);
调用构造方法,必须用 new
操作符;
构造方法中初始化字段时,引用类型的字段默认是 null
class Person {
private String name; // 默认初始化为null
private int age; // 默认初始化为0
public Person() {
}
}
或者直接初始化
private String name = "Unamed";
private int age = 10;
多构造方法
根据构造方法的参数数量、位置和类型自动区分
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this.name = name;
this.age = 12;
}
public Person() {
}
方法重载
方法名相同,但各自的参数不同
-
int indexOf(int ch)
:根据字符的Unicode码查找; -
int indexOf(String str)
:根据字符串查找; -
int indexOf(int ch, int fromIndex)
:根据字符查找,但指定起始位置; -
int indexOf(String str, int fromIndex)
根据字符串查找,但指定起始位置。
继承
extends
class Student extends Person {
// 不要重复Person父类的字段/方法,
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}
protected
父类 private
方法无法被子类继承,通过 protected
允许子类继承私有方法
class Person {
protected String name;
protected int age;
}
super
即父类构造方法,默认子类构造方法将补充 super()
;当父类构造方法为参数方法,须手动编写
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}
阻止继承
sealed 支持阻止继承的类,permits 明确可继承子类名称:
public sealed class Shape permits Rect, Circle, Triangle
public final class Rect extends Shape {...}
注:sealed
类在Java 15中目前是预览状态,要启用它,必须使用参数 --enable-preview
和 --source 15
。
向上/向下转型
是把一个子类型安全地变为更加抽象的父类型:
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
p instanceof Student
obj instanceof String
obj instanceof String s
若满足,可以直接使用变量s(Java14支持)
多态
Override/Overload
子类覆写与父类方法签名完全相同的方法
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
与 Override不同,Overload 适用于同名称新方法(参数与返回值不同)覆写的情况
Person p = new Student();
实际类型为 Student
,引用类型(变量声明类型)为 Person
调用 run()
的结果说明:
Java 基于运行时的实际类型的动态调用,而非变量的声明类型。这就是面向对象的多态特性
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
final
用 final
修饰的方法不能被 Override
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
用 final
修饰的类不能被继承,除非通过 permits
额外指定
final class Person {
protected String name;
}
抽象类
多态的存在,每个子类都可以覆写父类的方法;这要求父类必须声明具体子类可覆写方法;如 public void run() { … }
abstract
abstract
表示它是一个抽象方法,本身没有实现任何方法语句,本身无法执行;
此外包含抽象方法的类,也需要声明 abstract
即抽象类,且它无法被实例化;
abstract class Person {
public abstract void run();
}
抽象类本身被设计成只能用于被继承,强迫子类实现其定义的抽象方法,因此抽象方法实际上相当于定义了 “规范”;当前自己的类方法也是允许的;
面向抽象编程的本质就是:
-
上层代码只定义规范(例如:
abstract class Person
); -
不需要子类就可以实现业务逻辑(正常编译);
-
具体的业务逻辑由不同的子类实现,调用者并不关心。
接口
一个抽象类没有字段,所有方法全部都是抽象方法:
abstract class Person {
public abstract void run();
public abstract String getName();
}
interface
把该抽象类改写为接口 interface
,定义接口规范
interface Person {
void run();
String getName();
}
即比抽象类还要抽象的纯抽象接口,不允许字段。且接口定义的所有方法默认都是 public abstract
implements
对一个具体的 class
去实现一个 interface
,且一个class允许是多个 interface
实现
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;
}
}
class Student implements Person, Hello { // 实现了两个interface
...
}
Java的接口特指interface
的定义,表示一个接口类型和一组方法签名
而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
抽象类和接口的对比如下:
abstract class | interface | |
---|---|---|
继承 | 只能extends一个class | 可以implements多个interface |
字段 | 可以定义实例字段 | 不能定义实例字段 |
抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
接口继承
通过 extends
实现一个interface
可以继承自另一个interface
,它相当于扩展了接口的方法
合理设计interface
和abstract class
的继承关系,可以充分复用代码。
一般来说,公共逻辑适合放在abstract class
中,具体逻辑放到各个子类,而接口层次代表抽象程度。
default方法
default
方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default
方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}
Java核心类
字符串与编码
String
本质是一个 String 引用类型的 class,且具备不可变性(复制而非引用)
String s1 = "Hello!";
看做
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});
比较字符串需使用 equals()
或 equalsIgnoreCase
(忽略大小写)
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"
" \tHello\r\n ".trim(); // "Hello"
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符
字符串操作
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}
String s = String.join("***", arr); // "A***B***C"
格式化字符串
formatted()
、format()
静态方法
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
:显示浮点数;
类型转换
valueOf
转为字符串
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c
字符串转其它类型
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String
字符编码
byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换
StringBuilder
一个可变对象,可以预分配缓冲区
往StringBuilder
中新增字符时,不会创建新的临时对象
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();
链式操作
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
注:
普通的字符串+
操作,并不需要我们将其改写为StringBuilder
,
因为Java编译器在编译时就自动把多个连续的+
操作编码为StringConcatFactory
的操作。
在运行期间,StringConcatFactory
会自动把字符串连接操作优化为数组复制或者StringBuilder
操作。
StringBuffer
Java早期的一个StringBuilder
的线程安全版本,它通过同步来保证多个线程操作StringBuffer
也是安全的
枚举类
使用 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 {
SUN, MON, TUE, WED, THU, FRI, SAT;
}
枚举类相对于分散的、无类型的常量优势:
-
enum
常量本身带有类型信息,编译器对比值会自动检查出类型错误 -
不可能引用到非枚举的值,因为无法通过编译
-
不同类型的枚举不能互相比较或者赋值,因为类型不符
enum
定义的枚举类,内部本质是class
所以我们可以定义private
的构造方法,并且,给每个枚举常量添加字段:
enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0); // 构造
public final int dayValue;
private Weekday(int dayValue) {
this.dayValue = dayValue;
}
}
异常处理
异常作为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
Error
表示严重的错误,程序对此一般无能为力,例如:
OutOfMemoryError
:内存耗尽NoClassDefFoundError
:无法加载某个ClassStackOverflowError
:栈溢出
Exception
Exception
则是运行时的错误,它可以被捕获并处理。
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
NumberFormatException
:数值类型的格式错误FileNotFoundException
:未找到文件SocketException
:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
NullPointerException
:对某个null
的对象调用方法或字段IndexOutOfBoundsException
:数组索引越界
Java规定:
-
必须捕获的异常,包括
Exception
及其子类,但不包括RuntimeException
及其子类,这种类型的异常称为Checked Exception。 -
不需要捕获的异常,包括
Error
及其子类,RuntimeException
及其子类。
捕获异常
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
try {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
return s.getBytes(); // 尝试使用用默认编码
}
// 多种异常
catch (IOException | NumberFormatException e)
声明可能抛出异常
使用 throws Xxx
表示该方法可能抛出的异常类型
static byte[] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes("GBK");
}
在调用的时候,必须强制捕获这些异常,否则编译器会报错
try {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
} catch (UnsupportedEncodingException e) {
System.out.println(e);
}
方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。
所有未捕获的异常,最终也必须在main()
方法中捕获,不会出现漏写try
的情况。
这是由编译器保证的。main()
方法也是最后捕获Exception
的机会。
抛出异常
throw new NullPointerException();
// 捕获异常再抛出异常,即异常转换
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e); // 将原异常传入,以正确显示异常信息
}
集合Collection
由若干个确定的元素所构成的整体,便于处理一组类似的数据;
一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,这种Java对象称为集合,如数组 int[] arr = {1, 2, 3}
数组作为集合特点包括
- 数组初始化后大小不可变;
- 数组只能按索引顺序存取。
除了数组,Java提供更多不同特性的集合类处理不同数据
- 可变大小的顺序链表;
- 保证无重复元素的集合;
- …
Collection
Java标准库自带的 java.util
包提供了集合类,除 Map
外所有其他集合类的根接口
特点:
实现了接口和实现类相分离,例如,有序表的接口是 List
,具体的实现类有 ArrayList
,LinkedList
等
支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:
List<String> list = new ArrayList<>(); // 只能放入String类型
java.util
包主要提供了以下三种类型的集合:
List
:一种有序列表的集合;如按索引排列的Student
的List
;Set
:一种保证没有重复元素的集合;如所有无重复名称的Student
的Set
;Map
:一种通过键值(key-value)查找的映射表集合;如根据Student
的name
查找对应Student
的Map
。
List
一种有序列表,最基础的集合
ArrayList
数组添加和删除元素时非常不方便(可访问),对于增删元素的有序列表,使用最多的是ArrayList
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()
LinkedList
List接口除了数组实现(ArrayList),还有链表实现(LinkedList)
创建List
通过List
接口提供的of()
方法,根据给定元素(不包括null)快速创建List
import java.util.List;
List<Integer> list = List.of(1, 2, 5);
迭代List
如果你需要迭代List, Iterator
是最好的方式
Iterator
本身也是一个对象,但它是由List
的实例调用iterator()
方法的时候创建的
不同的List
类型,返回的Iterator
对象实现也是不同的,但总是具有最高的访问效率。
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);
}
}
}
实现了Iterable
接口的集合类都可以直接用for each
循环来遍历,会自动把for each
循环变成Iterator
的调用
// 更简洁的代码
for (String s : list) {
System.out.println(s);
}
List转换Array
List<Integer> list = List.of(12, 34, 56);
Number[] array = list.toArray(new Number[3]); // 注意此处支持new Number,非强制 new Integer
for (Number n : array) {
System.out.println(n);
}
如果传入的数组不够大,那么List
内部会创建一个新的刚好够大的数组,填充后返回;
如果传入的数组比List
元素还要多,那么填充完元素后,剩下的数组元素一律填充null
。
最常用的是传入一个“恰好”大小的数组:
Integer[] array = list.toArray(new Integer[list.size()]);
更简洁的写法是通过List
接口定义的T[] toArray(IntFunction<T[]> generator)
方法:
Integer[] array = list.toArray(Integer[]::new);
Array转换List
Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);
// 或
List<Integer> list = Arrays.asList(array); // JDK 11之前的版本
注:List.of()
返回的是一个只读 List
,
Map
键值(key-value)映射表的数据结构(无固定顺序)
import java.util.HashMap;
import java.util.Map;
Map<String, Student> map = new HashMap<>();
map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
map.containsKey("Xiao Ming");
遍历 Map
通过对 keySet()
方法返回的 Set
集合 for each 遍历
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println(key + " = " + value);
}
通过对 entrySet()
方法返回每一个key-value
映射的集合,同时遍历 key value
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println(key + " = " + value);
}
TreeMap
一种对Key进行排序 Map
即 SortedMap
, SortedMap
是接口,它的实现类即 TreeMap
Map<String, Integer> map = new TreeMap<>();
创建TreeMap
时,放入的Key必须实现Comparable
接口
对于String
、Integer
这些类已经实现了Comparable
接口,可以直接作为Key使用
对于没有Comparable
接口,则必须在创建TreeMap
时同时指定一个自定义排序算法
Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.name.compareTo(p2.name);
}
});
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
public int compare(Student p1, Student p2) {
// 必须考虑相等时返回0
if (p1.score == p2.score) {
return 0;
}
return p1.score > p2.score ? -1 : 1;
}
});
Set
存储不重复的元素集合,它主要提供以下几个方法:
- 将元素添加进
Set<E>
:boolean add(E e)
- 将元素从
Set<E>
删除:boolean remove(Object e)
- 判断是否包含元素:
boolean contains(Object e)
HashSet
对 HashMap
的简单封装
Set<String> set = new HashSet<>();
Set
接口并不保证有序,而 SortedSet
接口则保证元素是有序的:
HashSet
是无序的,因为它实现了Set
接口,并没有实现SortedSet
接口;TreeSet
是有序的,因为它实现了SortedSet
接口。
TreeSet
和使用TreeMap
的要求一样,添加的元素必须正确实现Comparable
接口
日期与时间
时间戳
long t = 1574208900123L;
long timestamp = System.currentTimeMillis(); // 当前时间毫秒数
时间日期API
- 一套定义在
java.util
包,包括Date
、Calendar
和TimeZone
; - 一套新的API在Java 8引入,定义在
java.time
这个包里面,包括LocalDateTime
、ZonedDateTime
、ZoneId
等。
Date
java.util.Date
表示一个日期和时间的对象,内部存储了一个long类型的以毫秒表示的时间戳
import java.util.*;
Date date = new Date();
System.out.println(date.getYear() + 1900); // 必须加上1900
System.out.println(date.getMonth() + 1); // 0~11,必须加上1
System.out.println(date.getDate()); // 1~31,不能加1
// 转换为String:
System.out.println(date.toString());
// 转换为GMT时区:
System.out.println(date.toGMTString());
// 转换为本地时区:
System.out.println(date.toLocaleString());
SimpleDateFormat
使用预定义的字符串表示格式化,对Date
进行转换
import java.text.*;
import java.util.*;
Date date = new Date();
var sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.format(date));
注:
它不能转换时区,除了toGMTString()
可以按GMT+0:00
输出外;
Date总是以当前计算机系统的默认时区为基础进行输出。
难以对日期和时间进行加减,计算两个日期相差多少天,计算某个月第一个星期一的日期
Calendar
获取并设置年、月、日、时、分、秒,与Date
比,主要多了一个可以做简单的日期和时间运算的功能。
获取日期时间
import java.util.*;
Calendar c = Calendar.getInstance();
int y = c.get(Calendar.YEAR);
int m = 1 + c.get(Calendar.MONTH);
int d = c.get(Calendar.DAY_OF_MONTH);
int w = c.get(Calendar.DAY_OF_WEEK);
int hh = c.get(Calendar.HOUR_OF_DAY);
int mm = c.get(Calendar.MINUTE);
int ss = c.get(Calendar.SECOND);
int ms = c.get(Calendar.MILLISECOND);
System.out.println(y + "-" + m + "-" + d + " " + w + " " + hh + ":" + mm + ":" + ss + "." + ms);
设置日期时间
// 当前时间:
Calendar c = Calendar.getInstance();
// 清除所有:
c.clear();
// 设置2019年:
c.set(Calendar.YEAR, 2019);
// 设置9月:注意8表示9月:
c.set(Calendar.MONTH, 8);
// 设置2日:
c.set(Calendar.DATE, 2);
// 设置时间:
c.set(Calendar.HOUR_OF_DAY, 21);
c.set(Calendar.MINUTE, 22);
c.set(Calendar.SECOND, 23);
Calendar转为Date
Date date = Calendar.getTime();
TimeZone
与Date
相比,提供了时区转换的功能
TimeZone tzDefault = TimeZone.getDefault(); // 当前时区
TimeZone tzGMT9 = TimeZone.getTimeZone("GMT+09:00"); // GMT+9:00时区
TimeZone tzNY = TimeZone.getTimeZone("America/New_York"); // 纽约时区
System.out.println(tzDefault.getID()); // Asia/Shanghai
System.out.println(tzGMT9.getID()); // GMT+09:00
System.out.println(tzNY.getID()); // America/New_York
时区的唯一标识是以字符串表示的ID,配合TimeZone.getTimeZone(ID)
可对指定时间进行转换
// 当前时间:
Calendar c = Calendar.getInstance();
// 清除所有:
c.clear();
// 设置为北京时区:
c.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
// 设置年月日时分秒:
c.set(2019, 10 /* 11月 */, 20, 8, 15, 0);
// 显示时间:
var sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 时区转换通过SimpleDateFormat完成
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
System.out.println(sdf.format(c.getTime()));
LocalDateTime
从Java 8开始,java.time
包提供了新的日期和时间API,主要涉及的类型有:
- 本地日期和时间:
LocalDateTime
,LocalDate
,LocalTime
; - 带时区的日期和时间:
ZonedDateTime
; - 时刻:
Instant
; - 时区:
ZoneId
,ZoneOffset
; - 时间间隔:
Duration
。
以及一套新的用于取代SimpleDateFormat
的格式化类型DateTimeFormatter
。
严格区分了时刻、本地日期、本地时间和带时区的日期时间
新API修正了旧API不合理的常量设计:
- Month的范围用1~12表示1月到12月;
- Week的范围用1~7表示周一到周日。
import java.time.*;
LocalDate d = LocalDate.now(); // 当前日期
LocalTime t = LocalTime.now(); // 当前时间
LocalDateTime dt = LocalDateTime.now(); // 当前日期和时间
System.out.println(d); // 严格按照ISO 8601格式打印
System.out.println(t); // 严格按照ISO 8601格式打印
System.out.println(dt); // 严格按照ISO 8601格式打印
// 转换
LocalDateTime dt = LocalDateTime.now(); // 当前日期和时间
LocalDate d = dt.toLocalDate(); // 转换到当前日期
LocalTime t = dt.toLocalTime(); // 转换到当前时间
创建LocaleDateTime
LocalDate d2 = LocalDate.of(2019, 11, 30); // 2019-11-30, 注意11=11月
LocalTime t2 = LocalTime.of(15, 16, 17); // 15:16:17
LocalDateTime dt2 = LocalDateTime.of(2019, 11, 30, 15, 16, 17);
LocalDateTime dt3 = LocalDateTime.of(d2, t2);
因为严格按照ISO 8601的格式,因此,将字符串转换为LocalDateTime
就可以传入标准格式:
LocalDateTime dt = LocalDateTime.parse("2019-11-19T15:16:17");
LocalDate d = LocalDate.parse("2019-11-19");
LocalTime t = LocalTime.parse("15:16:17");
// ISO标准日期如下
// 日期和时间:yyyy-MM-dd'T'HH:mm:ss
// 带毫秒的日期和时间:yyyy-MM-dd'T'HH:mm:ss.SSS
DateTimeFormatter
通过定义 DateTimeFormatter 自定义格式和解析
import java.time.*;
import java.time.format.*;
// 自定义格式化:
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
System.out.println(dtf.format(LocalDateTime.now()));
// 自定义格式解析:
LocalDateTime dt2 = LocalDateTime.parse("2019/11/30 15:16:17", dtf);
System.out.println(dt2);
日期加减
minus-
plus-
LocalDateTime dt = LocalDateTime.of(2019, 10, 26, 20, 30, 59);
// 加5天减3小时:
LocalDateTime dt2 = dt.plusDays(5).minusHours(3); // 链式调用
System.out.println(dt2); // 2019-10-31T17:30:59
调整日期时间
- 调整年:withYear()
- 调整月:withMonth()
- 调整日:withDayOfMonth()
- 调整时:withHour()
- 调整分:withMinute()
- 调整秒:withSecond()
LocalDateTime dt = LocalDateTime.of(2019, 10, 26, 20, 30, 59);
System.out.println(dt);
// 日期变为31日:
LocalDateTime dt2 = dt.withDayOfMonth(31);
System.out.println(dt2); // 2019-10-31T20:30:59
更复杂的调整可以通过 with()
方法配合 TemporalAdjusters
实现
import java.time.*;
import java.time.temporal.*;
// 本月第一天0:00时刻:
LocalDateTime firstDay = LocalDate.now().withDayOfMonth(1).lastStartOfDay();
// 本月最后1天:
LocalDate lastDay = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth());
// 下月第1天:
LocalDate nextMonthFirstDay = LocalDate.now().with(TemporalAdjusters.firstDayOfNextMonth());
// 本月第1个周一:
LocalDate firstWeekday = LocalDate.now().with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
比较日期时间先后
isBefore()
、isAfter()
LocalDateTime now = LocalDateTime.now();
LocalDateTime target = LocalDateTime.of(2019, 11, 19, 8, 15, 0);
System.out.println(now.isBefore(target));
System.out.println(LocalDate.now().isBefore(LocalDate.of(2019, 11, 19)));
注:
LocalDateTime
无法与时间戳进行转换,因为LocalDateTime
没有时区,无法确定某一时刻
Duration与Period
Duration
表示两个时刻之间的时间间隔
Period
表示两个日期之间的天数
LocalDateTime start = LocalDateTime.of(2019, 11, 19, 8, 15, 0);
LocalDateTime end = LocalDateTime.of(2020, 1, 9, 19, 25, 30);
Duration d = Duration.between(start, end);
System.out.println(d); // PT1235H10M30S ISO 8601的格式
Period p = LocalDate.of(2019, 11, 19).until(LocalDate.of(2020, 1, 9));
System.out.println(p); // P1M21D ISO 8601的格式
通过 ofXxx()
或者parse()
方法也可以直接创建Duration
Duration d1 = Duration.ofHours(10); // 10 hours
Duration d2 = Duration.parse("P1DT2H3M"); // 1 day, 2 hours, 3 minutes
IO
IO是指Input/Output,即输入和输出。以内存为中心:
-
Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
-
Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。
因为代码是在内存中运行的,数据也必须读到内存,最终的表示方式无非是byte数组,字符串等,都必须存放在内存里
InputStream/OutputStream
IO流以byte
(字节)为最小单位,可分为 输入字节流与输出字节流
Reader/Writer
如果读写的是字符,且通常字符不全是单字节表示的ASCII字符,那么按照char
来读写更方便,这种流称为字符流。
Java提供了Reader
和Writer
表示字符流,字符流传输的最小数据单位是char
本质上是一个能自动编解码的InputStream
和OutputStream
同步和异步
同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。
而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。
Java标准库的包java.io
提供了同步IO,而java.nio
则是异步IO。
上面的InputStream
、OutputStream
、Reader
和Writer
都是同步IO的抽象类,
对应的具体实现类,以文件为例,有FileInputStream
、FileOutputStream
、FileReader
和FileWriter
。
File
构造File
构造File
对象,即使传入的文件或目录不存在,代码也不会出错,因为构造对象并不会导致任何磁盘操作
因为仅构造File
对象,并不会导致任何磁盘操作。
import java.io.*;
File f = new File("C:\\Windows");
Windows平台使用\
作为路径分隔符,在Java字符串中需要用\\
表示一个\
。Linux平台使用/
作为路径分隔符:
File f = new File("/usr/bin/javac");
路径支持相对路径,相对于当前java程序路径
File f1 = new File("sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File(".\\sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File("..\\sub\\javac"); // 绝对路径是C:\sub\javac
获取文件路径
File f = new File("..");
System.out.println(f.getPath()); // 构造方法参数
System.out.println(f.getAbsolutePath()); // 计算绝对路径 D:\java-practice\..
System.out.println(f.getCanonicalPath()); // 计算规范的绝对路径
系统分隔符
File.separator
根据当前平台打印 “" 或 “/”
文件/目录状态
是否为文件/目录
System.out.println(f1.isFile());
System.out.println(f1.isDirectory());
文件权限和大小
boolean canRead():是否可读
boolean canWrite():是否可写
boolean canExecute():是否可执行
long length():文件字节大小
创建和删除文件
if (file.createNewFile()) {
// 文件创建成功:
// TODO:
if (file.delete()) {
// 删除文件成功:
}
}
临时文件
public static void main(String[] args) throws IOException {
File f = File.createTempFile("tmp-", ".txt"); // 提供临时文件的前缀和后缀
f.deleteOnExit(); // JVM退出时自动删除
System.out.println(f.isFile());
System.out.println(f.getAbsolutePath());
}
遍历文件/目录
list()
和 listFiles()
都可以列出目录下的文件和子目录名
listFiles()
提供了一系列重载方法,可以过滤不想要的文件和目录
File f = new File("C:\\Windows");
File[] fs1 = f.listFiles(); // 列出所有文件和子目录
printFiles(fs1);
File[] fs2 = f.listFiles(new FilenameFilter() { // 仅列出.exe文件
public boolean accept(File dir, String name) {
return name.endsWith(".exe"); // 返回true表示接受该文件
}
});
和文件操作类似,File对象如果表示一个目录,可以通过以下方法创建和删除目录:
boolean mkdir()
:创建当前File对象表示的目录;boolean mkdirs()
:创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;boolean delete()
:删除当前File对象表示的目录,当前目录必须为空才能删除成功。
Path
Path
对象,它位于 java.nio.file
包。Path
对象和 File
对象类似,但操作更加简单
import java.io.*;
import java.nio.file.*;
Path p1 = Paths.get(".", "project", "study"); // 构造一个Path对象
Path p2 = p1.toAbsolutePath(); // 转换为绝对路径
Path p3 = p2.normalize(); // 转换为规范路径
File f = p3.toFile(); // 转换为File对象
for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍历Path
System.out.println(" " + p);
}
InputStream
InputStream
就是Java标准库提供的最基本的输入流,位于 java.io
这个包(所有同步IO的功能)。
InputStream
不是一个接口而是抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是 int read()
,签名如下
public abstract int read() throws IOException;
这个方法会读取输入流的下一个字节,并返回字节表示的int
值(0~255)。如果已读到末尾,返回 -1
表示不能继续读取了。
FileInputStream
InputStream
的一个子类。顾名思义,FileInputStream
就是从文件流中读取数据。
public void readFile() throws IOException {
InputStream input = null;
try {
input = new FileInputStream("src/readme.txt");
int n;
while ((n = input.read()) != -1) { // 利用while同时读取并判断
System.out.println(n);
}
} finally {
if (input != null) { input.close(); }
}; // 关闭流
}
// 更简洁的写法
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(),编译器只看try(resource = ...)中的对象是否实现了java.lang.AutoCloseable接口
}
缓冲 byte[]
读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream
提供了两个重载方法来支持读取多个字节
int read(byte[] b)
:读取若干字节并填充到byte[]
数组,返回读取的字节数int read(byte[] b, int off, int len)
:指定byte[]
数组的偏移量和最大填充数
利用上述方法一次读取多个字节时,需要先定义一个byte[]
数组作为缓冲区,read()
方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()
方法的返回值是返回实际读取了多少个字节。如果返回-1
,表示没有更多的数据了。
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
// 定义1000个字节大小的缓冲区:
byte[] buffer = new byte[1000];
int n;
while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
System.out.println("read " + n + " bytes.");
}
}
}
ByteArrayInputStream
与 FileStream
相同都是 InputStream 的子类,ByteArrayInputStream
可以在内存中模拟一个InputStream
public static void main(String[] args) throws IOException {
byte[] data = { 72, 101, 108, 108, 111, 33 };
try (InputStream input = new ByteArrayInputStream(data)) {
int n;
while ((n = input.read()) != -1) {
System.out.println((char)n);
}
}
它可用于测试代码时模拟读取文件
public static void main(String[] args) throws IOException {
String s;
try (InputStream input = new FileInputStream("C:\\test\\README.txt")) {
s = readAsString(input);
}
System.out.println(s);
}
public static String readAsString(InputStream input) throws IOException {
int n;
StringBuilder sb = new StringBuilder();
while ((n = input.read()) != -1) {
sb.append((char) n); // 转为char类型(对每个字节)
}
return sb.toString();
}
OutputStream
OutputStream
是Java标准库提供的最基本的输出流
这个抽象类定义的一个最重要的方法就是void write(int b)
,签名如下:
public abstract void write(int b) throws IOException;
写入一个字节到输出流。要注意的是,虽然传入的是 int
参数,但只会写入一个字节,即只写入 int
最低8位表示字节的部分。
close():关闭输出流,以便释放系统资源
flush():将缓冲区的内容真正输出到目的地
为什么要有flush()
?
向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]
数组),等到缓冲区写满了,再一次性写入文件或者网络。对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream
flush()
,能强制把缓冲区内容输出。
通常情况下,我们不需要手动调用flush()
,因为缓冲区写满了OutputStream
会自动调用它,并且在调用close()
方法关闭OutputStream
之前,也会自动调用flush()
方法。
小明正在开发一款在线聊天软件,当用户输入一句话后,就通过OutputStream
的write()
方法写入网络流。小明测试的时候发现,发送方输入后,接收方根本收不到任何信息,怎么肥四?
原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。如果缓冲区大小是4K,则发送方要敲几千个字符后,操作系统才会把缓冲区的内容发送出去,这个时候,接收方会一次性收到大量消息。
解决办法就是每输入一句话后,立刻调用flush()
,不管当前缓冲区是否已满,强迫操作系统把缓冲区的内容立刻发送出去。
实际上,InputStream
也有缓冲区。例如,从FileInputStream
读取一个字节时,操作系统往往会一次性读取若干字节到缓冲区,并维护一个指针指向未读的缓冲区。然后,每次我们调用int read()
读取下一个字节时,可以直接返回缓冲区的下一个字节,避免每次读一个字节都导致IO操作。当缓冲区全部读完后继续调用read()
,则会触发操作系统的下一次读取并再次填满缓冲区。
public void writeFile() throws IOException {
try (OutputStream output = new FileOutputStream("out/readme.txt")) {
output.write("Hello".getBytes("UTF-8")); // Hello
} // 编译器在此自动为我们写入finally并调用close()
// 同时读取input.txt,写入output.txt:
try (InputStream input = new FileInputStream("input.txt");
OutputStream output = new FileOutputStream("output.txt"))
{
input.transferTo(output); // transferTo的作用是?
}
}
Filter模式
Java的IO标准库提供的 InputStream
根据来源可以包括:
FileInputStream
:从文件读取数据,是最终数据源;ServletInputStream
:从HTTP请求读取数据,是最终数据源;Socket.getInputStream()
:从TCP连接读取数据,是最终数据源;
除此之外我们经常对以上基础流添加额外功能;直接使用继承,根本无法控制代码的复杂度,很快就会失控。
为了解决依赖继承会导致子类数量失控的问题,JDK首先将InputStream
分为两大类:
一类是直接提供数据的基础InputStream
,例如:
- FileInputStream
- ByteArrayInputStream
- ServletInputStream
- …
一类是提供额外附加功能的InputStream
,例如:
- BufferedInputStream
- DigestInputStream
- CipherInputStream
通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)
InputStream file = new FileInputStream("test.gz");
InputStream buffered = new BufferedInputStream(file);
InputStream gzip = new GZIPInputStream(buffered);
自定义FilterInputStream
编写一个可通过包装,支持输入字节计数的 FilterInputStream
class CountInputStream extends FilterInputStream {
private int count = 0;
CountInputStream(InputStream in) {
super(in);
}
public int getBytesRead() {
return this.count;
}
public int read() throws IOException {
int n = in.read();
if (n != -1) {
this.count ++;
}
return n;
}
public int read(byte[] b, int off, int len) throws IOException {
int n = in.read(b, off, len);
if (n != -1) {
this.count += n;
}
return n;
}
}
byte[] data = "hello, world!".getBytes("UTF-8");
try (CountInputStream input = new CountInputStream(new ByteArrayInputStream(data))) {
int n;
while ((n = input.read()) != -1) {
System.out.println((char)n);
}
System.out.println("Total read " + input.getBytesRead() + " bytes");
}
注意到在叠加多个FilterInputStream
,我们只需要持有最外层的InputStream