Java基础

Posted by page on March 16, 2024

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是面向对象的语言,一个程序的基本单位就是 classclass 是关键字,这里定义的 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

参与运算的两个数类型不一致,那么计算结果为较大类型的整型。例如,shortint 计算,结果总是 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,它相当于扩展了接口的方法

合理设计interfaceabstract 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是异常体系的根,它继承自ObjectThrowable有两个体系:ErrorException

Error

Error表示严重的错误,程序对此一般无能为力,例如:

  • OutOfMemoryError:内存耗尽
  • NoClassDefFoundError:无法加载某个Class
  • StackOverflowError:栈溢出

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,具体的实现类有 ArrayListLinkedList

支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:

List<String> list = new ArrayList<>(); // 只能放入String类型

java.util包主要提供了以下三种类型的集合:

  • List:一种有序列表的集合;如按索引排列的 StudentList
  • Set:一种保证没有重复元素的集合;如所有无重复名称的 StudentSet
  • Map:一种通过键值(key-value)查找的映射表集合;如根据 Studentname 查找对应 StudentMap

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进行排序 MapSortedMapSortedMap 是接口,它的实现类即 TreeMap

Map<String, Integer> map = new TreeMap<>();

创建TreeMap时,放入的Key必须实现Comparable接口

对于StringInteger这些类已经实现了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包,包括DateCalendarTimeZone
  • 一套新的API在Java 8引入,定义在java.time这个包里面,包括LocalDateTimeZonedDateTimeZoneId等。

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,主要涉及的类型有:

  • 本地日期和时间:LocalDateTimeLocalDateLocalTime
  • 带时区的日期和时间:ZonedDateTime
  • 时刻:Instant
  • 时区:ZoneIdZoneOffset
  • 时间间隔: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提供了ReaderWriter表示字符流,字符流传输的最小数据单位是char

本质上是一个能自动编解码的InputStreamOutputStream

同步和异步

同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。

而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。

Java标准库的包java.io提供了同步IO,而java.nio则是异步IO。

上面的InputStreamOutputStreamReaderWriter都是同步IO的抽象类,

对应的具体实现类,以文件为例,有FileInputStreamFileOutputStreamFileReaderFileWriter

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个字节,花费的时间几乎是完全一样的,所以OutputStreamflush(),能强制把缓冲区内容输出。

通常情况下,我们不需要手动调用flush(),因为缓冲区写满了OutputStream会自动调用它,并且在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。

小明正在开发一款在线聊天软件,当用户输入一句话后,就通过OutputStreamwrite()方法写入网络流。小明测试的时候发现,发送方输入后,接收方根本收不到任何信息,怎么肥四?

原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。如果缓冲区大小是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