1. 文章
  2. Java 进阶线
  3. 配套资料

Java进阶文章合订本

更新于 324 分钟阅读 #java#java-advanced

本合订本包含 14 篇主题文章。基准为 Java 17,课堂旧版本差异已在相应正文说明。核心代码内嵌,完整来源与验证边界见学习导航和来源索引。

我的学习 →

Java 进阶线 · 线路目录
本页导读

Java 进阶:文章合订本

分篇导航 · 复习自测

本合订本把 14 篇按课程 PPT 逐日转写的文章合并成一个文件,便于整体检索与全文搜索。基准为 Java 17,课堂旧版本差异已在相应正文说明。各篇原文可单独阅读,完整来源与核对边界见课程来源与作业索引。

day01|面向对象进阶(一)

本日进入面向对象的高级部分:先用 static 解决”数据要在所有对象之间共享”的问题,再通过代码块、单例设计模式理解类的初始化与对象的唯一性,最后进入面向对象的第二大特征——继承,掌握继承的特点、权限修饰符、方法重写、就近原则以及子类构造器与 this/super 的配合方式。 学完之后应当能够:判断一个成员该定义成类成员还是实例成员;写出饿汉式与懒汉式单例;熟练使用 extends 抽取父类、用 @Override 重写方法,并解释”子类构造器为什么一定先调用父类构造器”。

1. 本日范围:三个问题与知识地图

1.1 课程要回答的三个问题

课件用三个真实存在的问题引出本日全部内容:

  1. 之前定义的成员变量 name、score 属于每个对象,每个对象中都有一份该数据。有些数据是要被所有对象共享的,应该怎么定义? → static 修饰成员变量(类变量)。
  2. 如何让一个类对外只能产生一个对象(例如任务管理器对象)? → 单例设计模式。
  3. 系统中定义了很多实体类,它们中有很多属性、行为存在重复代码,如何降低代码冗余、提升代码复用? → 继承。

1.2 知识地图

课件目录给出的顺序如下(本文的章节顺序与之一致):

序号知识块本文章节
1static 修饰成员变量第 2 节
2static 修饰成员变量的应用场景第 2.4 节
3static 修饰成员方法第 3 节
4static 修饰成员方法的应用场景第 3.4 节
5static 的注意事项第 4 节
6static 的应用知识:代码块第 5 节
7static 的应用知识:单例设计模式第 6 节
8面向对象三大特征之二:继承第 7–12 节

2. static 修饰成员变量:类变量

2.1 成员变量的两种类型

static 叫静态,可以修饰成员变量、成员方法。 成员变量按照有无 static 修饰分为两种:

  • 类变量(静态成员变量):有 static 修饰,属于类,在计算机里只有一份,会被类的全部对象共享。
  • 实例变量(对象的变量):无 static 修饰,属于每个对象,每个对象中都有一份。

访问方式:

  • 类名.类变量(推荐)
  • 对象.类变量(不推荐)
  • 对象.实例变量(实例变量只能用对象访问)
public class Student {
    // 类变量
    static String name;

    // 实例变量(对象变量)
    int age;
}

Student 学生表示意,static name 属于类,age 属于对象

2.2 类变量与实例变量的区别

类型修饰归属内存中的份数访问方式
类变量(静态成员变量)有 static属于类只有一份,可被类和类的所有对象共享类名.类变量(推荐)、对象.类变量(不推荐)
实例变量(对象变量)无 static属于每个对象每个对象中一份对象.实例变量

补充说明:课件把类变量的特点表述为”与类一起加载一次”;更准确的表述是在类初始化阶段完成类变量的赋值(类只会初始化一次,因此类变量在内存中确实只有一份)。

2.3 类变量与实例变量的执行原理

课件用一段代码 + 内存图说明:方法区里存着 Test.class、Student.class 两张”设计图”,类变量 static String name 跟着 Student.class 只有一份;每个 new Student() 在堆内存中各自拥有一个 int age。

public class Test {
    public static void main(String[] args) {
        // 1、类变量的用法
        // 类名.类变量(推荐)
        Student.name = "袁华";

        // 对象.类变量(不推荐)
        Student s1 = new Student();
        s1.name = "马冬梅";

        Student s2 = new Student();
        s2.name = "秋雅";

        System.out.println(s1.name);        // 秋雅(共享同一份,最后一次赋值生效)
        System.out.println(Student.name);   // 秋雅

        // 2、实例变量的用法
        // 对象.实例变量
        s1.age = 23;
        s2.age = 18;
        System.out.println(s1.age);         // 23
        // System.out.println(Student.age); // 报错:实例变量不能用类名访问
    }
}

内存中的取值变化(课件图注):name 初始为 null,依次被赋为 袁华 → 马冬梅 → 秋雅,所以 s1.name、Student.name 都是 秋雅;而 s1.age = 23、s2.age = 18 各自独立。

s1 对象内存示意(实例变量 age 独立一份)

s2 对象内存示意(实例变量 age 独立一份)

小结(课件原文):

  • 类变量:属于类,与类一起加载一次,在内存中只有一份,可以被类和类的所有对象共享。
  • 实例变量:属于对象,每个对象中都有一份,只能用对象访问。

2.4 类变量的应用场景:统计创建了多少个对象

应用场景:开发中如果某个数据只需要一份,且希望能被共享(访问、修改),就可以定义成类变量来记住。

案例:系统启动后,要求用户类可以记住自己创建了多少个用户对象。

public class User {
    // 类变量:只有一份,可以被共享
    public static int number;

    // 构造器:每创建一次对象就自增一次
    public User() {
        number++;   // 访问当前类中的类变量,可以省略类名不写
    }
}
public class Test2 {
    public static void main(String[] args) {
        new User();
        new User();
        new User();
        System.out.println("创建了几个对象:" + User.number); // 3
    }
}

2.5 小结与追问

  1. 成员变量有几种?各自在什么情况下定义?
    • 类变量:数据只需要一份,且需要被共享(访问、修改)时。
    • 实例变量:每个对象都要有一份、数据各不同(如 name、score、age)。
  2. 访问自己类中的类变量,是否可以省略类名不写?可以。 注意:在某个类中访问其他类里的类变量,必须带类名访问。

3. static 修饰成员方法:类方法

3.1 成员方法的分类与访问方式

成员方法按照有无 static 修饰也分为两种:

  • 类方法(静态方法):有 static 修饰的成员方法,属于类。可以直接用类名访问,也可以用对象访问。
  • 实例方法(对象的方法):无 static 修饰的成员方法,属于对象,只能用对象访问。

访问方式:

  • 类名.类方法(推荐)
  • 对象名.类方法(不推荐)
  • 对象.实例方法
public class Student {
    double score;

    // 类方法:有 static 修饰,属于类
    public static void printHelloWorld() {
        System.out.println("Hello World!");
        System.out.println("Hello World!");
    }

    // 实例方法(对象的方法)
    public void printPass() {
        System.out.println("成绩:" + (score >= 60 ? "及格" : "不及格"));
    }
}
public class Test {
    public static void main(String[] args) {
        // 1、类方法的用法
        // 类名.类方法(推荐)
        Student.printHelloWorld();

        // 对象.类方法(不推荐)
        Student s = new Student();
        s.printHelloWorld();

        // 2、实例方法的用法
        // 对象.实例方法
        s.printPass();
        // Student.printPass(); // 报错:实例方法不能用类名访问
    }
}

课件给出的执行原理图说明:方法区中 Student.class 保存 public void printPass()… 与 public static void printHelloWorld()… 两张方法表,堆内存中的 Student s 对象只持有 double score,调用类方法时不需要对象参与。

类方法 printHelloWorld() 的输出结果

实例方法 printPass() 的输出结果(成绩:不及格)

小结:

  1. static 修饰的成员方法叫类方法(静态方法),属于类,可以直接用类名访问,也可以用对象访问。
  2. 无 static 修饰的成员方法叫实例方法(对象的方法),属于对象,只能用对象访问。

3.2 补充知识:main 方法为什么能直接运行

课件的”搞懂 main 方法”环节指出:public static void main(String[] args) 本身就是一个类方法,因此 JVM 不需要先创建对象,直接以 Test.main(...) 的形式用类名调用即可运行。这也解释了为什么程序入口必须写成 public static void main。

4. 类方法的常见应用场景:工具类

4.1 什么是工具类

类方法最常见的应用场景是做工具类。

  • 工具类是什么? 工具类中的方法都是一些类方法,每个方法都是用来完成一个功能的,工具类是给开发人员共同使用的。
  • 使用类方法来设计工具类有啥好处? 提高了代码复用;调用方便,提高了开发效率。
public class XxxxUtil {
    public static void xxx() {
        ...
    }

    public static boolean xxxx(String email) {
        ...
    }

    public static String xxxxx(int n) {
        ...
    }
    ...
}

(片段:课件用 XxxxUtil 示意工具类的骨架,方法体省略。)

课件演示了两个使用场景页面:登录页需要生成验证码,注册页也需要生成验证码——同一段逻辑被两处复用,正是”工具类 + 类方法”的典型价值。

登录页面演示(工具类生成验证码的使用场景)

注册页面演示(工具类生成验证码的使用场景)

工程中的对应实现(生成指定位数的验证码):

public class ItheimaUtil {
    // 工具类没有创建对象的需求,建议将构造器私有化
    private ItheimaUtil() {
    }

    public static String createCode(int n) {
        String code = "";
        String data = "abcdFGHIJKLMNOPQRSTUVWXYZ01234evwxyzABCDfghijklmnopqrstuE56789";
        Random r = new Random();
        for (int i = 0; i < n; i++) {
            int index = r.nextInt(data.length());   // 随机产生一个索引
            char c = data.charAt(index);            // 取出该索引处的字符
            code += c;                              // 拼接
        }
        return code;
    }
}

4.2 为什么用类方法而不是实例方法

  • 实例方法需要创建对象来调用,此时对象只是为了调用方法而存在,对象占内存,会浪费内存。
  • 类方法直接用类名调用即可,调用方便,也能节省内存。

多学一招:工具类没有创建对象的需求,建议将工具类的构造器私有化(如上面的 private ItheimaUtil(){})。

4.3 小结

问题回答
类方法有啥应用场景?可以用来设计工具类
工具类是什么,有什么好处?工具类中的方法都是类方法,每个类方法完成一个功能;提高代码复用性,调用方便,提高开发效率
为什么工具类要用类方法而不是实例方法?实例方法需要创建对象来调用,会浪费内存
工具类定义时有什么要求?不需要创建对象,建议将构造器私有化

5. static 的注意事项

课件给出三条规则:

  1. 类方法中可以直接访问类的成员,不可以直接访问实例成员。
  2. 实例方法中既可以直接访问类成员,也可以直接访问实例成员。
  3. 实例方法中可以出现 this 关键字,类方法中不可以出现 this 关键字。
public class Test {
    // 类变量
    public static String schoolName = "深圳黑马";

    // 类方法
    public static void inAddr() {
        System.out.println("我们都在深圳黑马学Java~~");
    }

    // 实例变量
    private String name;

    // 实例方法
    public void printInfo() {
        System.out.println("名字叫:" + name);
    }

    // 1、类方法中可以直接访问类的成员,不可以直接访问实例成员
    public static void testStatic() {
        System.out.println(schoolName);
        inAddr();
        // System.out.println(this);  // 报错:类方法中不能出现 this
        // System.out.println(name);  // 报错:不能直接访问实例变量
        // printInfo();               // 报错:不能直接访问实例方法
    }

    // 2、实例方法中两种成员都可以直接访问;3、实例方法中可以出现 this
    public void testNoStatic() {
        System.out.println(schoolName);
        inAddr();
        System.out.println(this);
        System.out.println(name);
        printInfo();
    }
}

记忆方式:类方法没有”当前对象”,this 无处指向,所以凡是要靠对象才能确定的东西(实例变量、实例方法、this)都不能在类方法里直接写。

6. 代码块:类的初始化与对象的初始化

6.1 代码块概述

代码块是类的 5 大成分之一(成员变量、构造器、方法、代码块、内部类)。 代码块分为两种:

类型格式特点作用
静态代码块static { }类加载时自动执行,由于类只会加载一次,所以静态代码块也只会执行一次完成类的初始化,例如对类变量的初始化赋值
实例代码块{ }每次创建对象时都会执行实例代码块,并且在构造器前执行和构造器一样,都是用来完成对象的初始化的,例如对实例变量进行初始化赋值

补充说明(措辞校正):课件说法”类加载时自动执行”;更准确的表述是类初始化时自动执行,且只执行一次——类加载之后紧接着做初始化,静态代码块与类变量的赋值都发生在这一步。

6.2 静态代码块:初始化类变量

public class CodeTest1 {
    public static String schoolName = "深圳黑马";
    public static ArrayList<String> names = new ArrayList<>();

    // 静态代码块:与类一起优先加载,自动执行一次;用于初始化静态变量的数据
    static {
        System.out.println("==static静态代码块执行了==");
        names.add("张麻子");
        names.add("叶飞");
        names.add("林楚漩");
    }

    public static void main(String[] args) {
        System.out.println("==main方法执行了==");
        System.out.println(names);
    }
}

输出顺序说明:静态代码块在 main 方法之前执行——JVM 要执行 main 必须先初始化 CodeTest1 这个类,初始化时就跑完了静态代码块,所以 names 在 main 里已经有数据。

6.3 实例代码块:初始化实例变量

public class CodeTest2 {
    private String name;
    private ArrayList<String> names = new ArrayList<>();

    // 实例代码块:属于每个对象,每次创建对象都执行一次,且先于构造器执行
    {
        System.out.println("===实例代码块执行了一次====");
        names.add("东南");
        names.add("西北");
    }

    public CodeTest2() {
        System.out.println("======构造器执行了一次======");
    }

    public static void main(String[] args) {
        new CodeTest2();
        new CodeTest2();
        new CodeTest2();
    }
}

输出顺序说明:每 new 一次依次输出”实例代码块执行了一次 → 构造器执行了一次”,共三轮。对象创建顺序:实例代码块 → 构造器(若有继承关系,父类构造器还会先于子类构造器,见第 12 节)。

7. 单例设计模式

7.1 什么是设计模式

  • 一个问题通常有 n 种解法,其中肯定有一种解法是最优的,这个最优的解法被人总结出来,称为设计模式(Design pattern)。
  • 设计模式有 20 多种,对应 20 多种软件开发中会遇到的问题。
  • 学习设计模式主要学两点:解决什么问题(01)、怎么写(02)。

单例设计模式解决的问题:确保一个类只有一个对象。

7.2 单例的三步写法与饿汉式单例

写法三步:

  1. 把类的构造器私有。
  2. 定义一个类变量记住类的一个对象。
  3. 定义一个类方法返回对象。

饿汉式单例:拿对象时,对象早就创建好了。

// 单例类
public class A {
    // 2、定义一个类变量记住类的一个对象
    private static A a = new A();

    // 1、私有构造器
    private A() {
    }

    // 3、定义一个类方法返回对象
    public static A getObject() {
        return a;
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:实现单例设计模式
        A a1 = A.getObject();
        A a2 = A.getObject();
        System.out.println(a1);   // 两次打印的地址相同
        System.out.println(a2);
    }
}

饿汉式的应用场景(课件举例):任务管理器对象、获取运行时对象(Runtime)。在这些场景下使用单例模式,可以避免浪费内存。

任务管理器(单例对象的典型应用场景)

7.3 懒汉式单例

懒汉式单例:拿对象时,才开始创建对象(延迟加载对象)。

写法:把构造器私有;定义一个类变量用于存储对象;提供一个类方法,保证返回的是同一个对象。

public class B {
    // 2、定义一个类变量用于存储对象
    private static B b;   // 初始为 null

    // 1、单例必须私有构造器
    private B() {
    }

    // 3、提供一个类方法返回类的一个对象
    public static B getObject() {
        // 第一次来拿对象时才创建,之后不再创建
        if (b == null) {
            b = new B();
        }
        return b;
    }
}

注意”保证返回的是同一个对象”这一步:如果忘了 if (b == null) 判断,每次调用都会 new 一个新对象,单例就失效了。

7.4 小结

问题回答
什么是设计模式,设计模式主要学什么?设计模式就是具体问题的最优解决方案;学”解决什么问题”和”怎么写”
单例模式解决了什么问题?确保一个类只有一个对象
单例怎么写?把类的构造器私有;定义一个类变量存储类的一个对象;提供一个类方法返回对象
饿汉式单例的特点是什么?在获取类的对象时,对象已经创建好了
懒汉单例模式的特点是什么?要用类的对象时才创建对象(延迟加载)
单例有啥应用场景,有啥好处?任务管理器对象、获取运行时对象;可避免浪费内存

8. 面向对象三大特征之二:继承

8.1 继承的快速入门

Java 中提供了关键字 extends,用这个关键字,可以让一个类和另一个类建立起父子关系。

public class B extends A {
}
  • 继承的特点:子类能继承父类的非私有成员(成员变量、成员方法)。
  • 继承后对象的创建:子类的对象是由子类、父类共同完成的——用子父类这”多张设计图”一起创建对象;对象能直接访问什么成员,也是由这多张设计图共同决定的。
public class A {
    public int i;

    public void print1() {
        System.out.println("===print1===");
    }

    private int j;

    private void print2() {
        System.out.println("===print2===");
    }
}
public class B extends A {
    private int k;

    public void print3() {
        // 子类可以继承父类的非私有成员
        System.out.println(i);
        print1();
        // System.out.println(j); // 报错
        // print2();              // 报错
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:认识继承、掌握继承的特点
        B b = new B();
        System.out.println(b.i);
        // System.out.println(b.j); // 报错
        // System.out.println(b.k); // 报错
        b.print1();
        // b.print2();              // 报错
        b.print3();
    }
}

课件内存图:方法区中 A.class(含 public int i;、public void print1()…、private int j;、private void print2()…)与 B.class(含 private int k;、print3())共同决定 B 对象的结构;堆内存中的对象同时拥有来自父类的 i 与子类的 k。

小结:

  1. 什么是继承?继承就是用 extends 关键字,让一个类和另一个类建立起父子关系;子类可以继承父类非私有的成员。
  2. 带继承关系的类,Java 会怎么创建它的对象?——会用类和其父类这多张设计图一起创建对象;对象能直接访问哪些成员,由这多张设计图对外暴露的成员共同决定。

8.2 继承的好处与应用场景

需求(黑马的员工管理系统):需要处理讲师、咨询师的数据。

  • 讲师的数据有:姓名、具备的技能。
  • 咨询师的数据有:姓名、解答问题的总人数。

不用继承时,两个类中都要写一遍 name 及其 get/set(重复代码):

public class Teacher {
    private String name;
    private String skill;   // 技能

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getSkill() { return skill; }
    public void setSkill(String skill) { this.skill = skill; }
}

public class Consultant {
    private String name;
    private int number;     // 解答问题的人数

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getNumber() { return number; }
    public void setNumber(int number) { this.number = number; }
}

向上抽取父类 People 之后,重复代码只剩一份:

public class People {
    private String name;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
public class Teacher extends People {
    private String skill;   // 技能

    public String getSkill() { return skill; }
    public void setSkill(String skill) { this.skill = skill; }
}

public class Consultant extends People {
    private int number;     // 解答问题的总人数

    public int getNumber() { return number; }
    public void setNumber(int number) { this.number = number; }
}

使用继承的好处:减少了重复代码的编写,提高了代码的复用性。抽取原则就是”把多个类中相同的属性、行为放到父类里”,课件把这个动作形象地称为”认爸爸”。

9. 权限修饰符

9.1 概念

权限修饰符是用来限制类中的成员(成员变量、成员方法、构造器、代码块…)能够被访问的范围。

9.2 四种权限的作用范围

修饰符访问范围
private只能本类
缺省(不写)本类、同一个包中的类
protected本类、同一个包中的类、不同包的子孙类中
public任意位置

可见范围从小到大:private < 缺省 < protected < public。

(说明:课件第 43 页的表格在 PPT 中以图形方式呈现,上表依据第 42 页的文字说明与工程中的验证代码整理。)

9.3 工程验证

public class Fu {
    // 1、private 私有的,只能在本类中访问
    private void privateMethod() {
        System.out.println("private");
    }

    // 2、缺省:只能在本类、同一个包下的其他类中访问
    void method() {
        System.out.println("缺省");
    }

    // 3、protected 子类权限:本类、同一个包下的类、子孙类中访问
    protected void protectedMethod() {
        System.out.println("protected");
    }

    // 4、public 公开权限:任意类中访问
    public void publicMethod() {
        System.out.println("public");
    }
}
// 同一个包中的其他类:private 不可见,其余三个可见
public class Demo1 {
    public static void main(String[] args) {
        Fu f = new Fu();
        // f.privateMethod();  // 报错
        f.method();
        f.protectedMethod();
        f.publicMethod();
    }
}
// 不同包中的类:只有 public 可见
public class Demo2 {
    public static void main(String[] args) {
        Fu f = new Fu();
        // f.privateMethod();    // 报错
        // f.method();           // 报错(跨包,缺省不可见)
        // f.protectedMethod();  // 报错(不是子孙类)
        f.publicMethod();
    }
}
// 不同包中的子类:protected 与 public 可见(protected 通过继承访问)
public class Zi extends Fu {
    public void print() {
        // privateMethod(); // 报错
        // method();        // 报错
        protectedMethod();
        publicMethod();
    }
}

10. 单继承与 Object 类

  1. Java 是单继承的:一个类只能继承一个直接父类,Java 中的类不支持多继承。
  2. 支持多层继承:class M {} → class N extends M {} → class P extends N {} 是合法的,P 间接继承了 M 的成员。
  3. Object 类是 Java 中所有类的祖宗类:我们写的任何一个类,其实都是 Object 的子类或子孙类。

为什么不支持多继承?课件用”反证法”说明:

public class A {
    public void method() {
        System.out.println("复习数学~");
    }
}

public class B {
    public void method() {
        System.out.println("复习语文~");
    }
}

假设可以写 public class C extends A, B {},那么

public class Test {
    public static void main(String[] args) {
        C c = new C();
        c.method();   // 懵了,不知道听哪个爸爸的
    }
}

A 和 B 都有 method(),C 到底继承哪一个无法确定(方法冲突),因此 Java 中的类不支持多继承。

// 1、Java 是单继承的:一个类只能继承一个直接父类
//class A {}
//class B {}
//class C extends A, B {}  // 报错:不支持多继承

// 2、Java 支持多层继承
class M {}
class N extends M {}
class P extends N {}

// 3、Object 类是 Java 中所有类的祖宗类

11. 方法重写

11.1 什么是方法重写

当子类觉得父类中的某个方法不好用,或者无法满足自己的需求时,子类可以重写一个方法名称、参数列表一样的方法,去覆盖父类的这个方法,这就是方法重写。

注意:重写后方法的访问,Java 会遵循就近原则(调用的是子类重写后的版本)。

public class Animal {
    public void run() {
        System.out.println("动物会跑~~~~");
    }
}
public class Tiger extends Animal {
    // 方法重写:名称和参数列表必须与父类被重写的方法一样
    @Override // 重写的校验注解:安全、可读性好、优雅
    public void run() {
        System.out.println("老虎跑的贼快~~~~");
    }
}

11.2 方法重写的注意事项

注意事项说明
加 @Override 注解可以指定 Java 编译器检查我们方法重写的格式是否正确,代码可读性也更好
访问权限子类重写父类方法时,访问权限必须大于或者等于父类该方法的权限(public > protected > 缺省)
返回值类型必须与被重写方法的返回值类型一样,或者范围更小
不能重写的方法私有方法、静态方法不能被重写,如果重写会报错

11.3 应用场景:重写 Object 的 toString()

子类重写 Object 类的 toString() 方法,以便返回对象的内容(而不是默认的”地址值”形式)。

public class Student {
    private String name;
    private char sex;
    private double height;
    private String desc;

    // 构造器与 get/set 方法省略

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", sex=" + sex +
                ", height=" + height +
                ", desc='" + desc + '\'' +
                '}';
    }
}
public class Test {
    public static void main(String[] args) {
        Student s = new Student("赵敏", '女', 169.5, "执着,真诚,没有私心");
        // 输出对象,默认调用继承自父类 Object 的 toString(),返回所谓的地址值
        System.out.println(s);   // 重写后打印对象内容
    }
}

小结:

  1. 方法重写是什么?——子类写了一个方法名称、形参列表与父类某个方法一样的方法去覆盖父类的该方法。
  2. 有哪些注意事项?——建议加 @Override;访问权限必须大于或等于父类被重写方法的权限;返回值类型一样或范围更小;私有方法、静态方法不能被重写。
  3. 应用场景?——当子类觉得父类的方法不好用、或者不满足需求时。

12. 子类中访问其他成员的特点:就近原则

在子类方法中访问其他成员(成员变量、成员方法),依照就近原则:

  1. 先子类局部范围找。
  2. 然后子类成员范围找。
  3. 然后父类成员范围找;如果父类范围还没有找到则报错。

如果子父类中出现了重名的成员,会优先使用子类的;此时如果一定要在子类中使用父类的,可以通过 super 关键字指定:super.父类成员变量 / super.父类成员方法。

public class Test {
    public static void main(String[] args) {
        Zi zi = new Zi();
        zi.showName();
    }
}

class Zi extends Fu {
    String name = "子类名称";

    public void showName() {
        String name = "局部名称";
        System.out.println(name);         // 局部名称(就近原则,先找局部范围)
        System.out.println(this.name);    // 子类名称(访问子类的成员 name)
        System.out.println(super.name);   // 父类名称(访问父类的成员 name)
    }
}

class Fu {
    String name = "父类名称";
}

方法与成员变量同理:重写后调用 run() 走子类版本,super.run() 走父类版本。

IDEA 中 F.java 与 Z.java,演示子类访问重名成员时的就近原则

class Zi2 extends Fu2 {
    @Override
    public void run() {
        System.out.println("子类跑的贼溜~~");
    }

    public void go() {
        run();        // 子类的 run()
        super.run();  // 父类的 run
    }
}

class Fu2 {
    public void run() {
        System.out.println("父类跑");
    }
}

13. 子类构造器的特点与 this/super

13.1 子类构造器的特点

  1. 子类的全部构造器,都会先调用父类的构造器,再执行自己。
  2. 子类构造器是如何实现调用父类构造器的:
    • 默认情况下,子类全部构造器的第一行代码都是 super()(写不写都有),它会调用父类的无参数构造器。
    • 如果父类没有无参数构造器,则我们必须在子类构造器的第一行手写 super(...),指定去调用父类的有参数构造器。
public class Animal {
    public Animal() {
        System.out.println("===父类Animal的无参数构造器被执行了===");
    }

    public Animal(String n) {
        System.out.println("===父类Animal的有参数构造器被执行了===" + n);
    }
}
public class Wolf extends Animal {
    public Wolf() {
        // super(); // 写不写都有,调用父类无参数构造器
        System.out.println("===子类Wolf的无参数构造器执行了====");
    }

    public Wolf(String n) {
        // super(); // 写不写都有,调用父类无参数构造器
        System.out.println("===子类Wolf的有参数构造器执行了====");
    }
}

运行 new Wolf() 与 new Wolf("七匹狼"),都会先打印父类构造器的输出,再打印子类构造器的输出。

13.2 常见应用场景:用 super(...) 初始化父类部分的数据

子类构造器可以通过调用父类构造器,把对象中包含父类这部分的数据先初始化赋值,再回来把对象里包含子类这部分的数据也进行初始化赋值。

class People {
    private String name;
    private int age;

    public People() {
    }

    public People(String name, int age) {
        this.name = name;
        this.age = age;
    }
    // get + set 方法...
}

class Teacher extends People {
    private String skill;

    public Teacher() {
    }

    public Teacher(String name, int age, String skill) {
        super(name, age);   // 初始化继承自父类部分的数据
        this.skill = skill;
    }
    // get + set 方法...
}
public class Test2 {
    public static void main(String[] args) {
        // 目标:子类调用父类构造器的常见应用场景
        Teacher t = new Teacher("李四", 36, "Java");
        System.out.println(t.getName());   // 李四
        System.out.println(t.getAge());    // 36
        System.out.println(t.getSkill());  // Java
    }
}

内存图中的顺序:new Teacher(...) 时,堆中对象先包含父类部分(name、age)再包含子类部分(skill);super(name, age) 先把父类部分赋值,this.skill = skill 再赋值子类部分,因此三个 get 都能取到值。

13.3 this(...) 调用兄弟构造器

补充知识:任意类的构造器中,是可以通过 this(...) 去调用该类的其他构造器的。

public class Student {
    private String name;
    private int age;
    private String schoolName;

    public Student() {
    }

    public Student(String name, int age) {
        // this 调用兄弟构造器:不给学校时默认"黑马程序员"
        this(name, age, "黑马程序员");
    }

    public Student(String name, int age, String schoolName) {
        this.name = name;
        this.age = age;
        this.schoolName = schoolName;
    }
    // get + set 方法...
}

课件示例中等价于:

public Student(String name) {
    this(name, "黑马程序员");
}

13.4 this(...) 和 super(...) 的使用注意事项

  • this(...)、super(...) 都只能放在构造器的第一行,因此,有了 this(...) 就不能再写 super(...) 了,反之亦然(二者互斥)。
  • super(...) 必须放在构造器的第一行,否则报错。

this 与 super 的作用对照(依据课件第 66 页的表格整理):

关键字含义访问本类成员访问父类成员构造器用法
this代表本类对象的引用this.本类成员变量 / this.本类成员方法()—this(...) 调用本类其他构造器
super代表父类存储空间的标识—super.父类成员变量 / super.父类成员方法()super(...) 调用父类构造器

(课件提示:以上总结中,唯独”this 调用本类其他构造器”是前面没有接触过的用法。)

13.5 为什么子类构造器一定要先调用父类的构造器

引入继承后,子类对象是由子类和父类这多张设计图共同创建出来的。当创建子类对象、调用子类构造器为子类的成员变量初始化时,子类构造器肯定需要先调用父类构造器,把父类中的成员变量先进行初始化,否则父类那部分数据就是”没准备好的”。

小结:

  1. 子类构造器有啥特点?——子类中的全部构造器,都必须先调用父类的构造器,再执行自己。
  2. super(...) 调用父类有参数构造器的常见应用场景?——为对象中包含父类这部分的成员变量进行赋值。
  3. this(...) 的作用?——在构造器中调用本类的其他构造器。
  4. 使用注意?——this(...) 与 super(...) 都必须放在构造器的第一行。

课件最后一页的叮嘱:千里之行、始于足下;好记性不如烂笔头;写代码、写代码、写代码。

14. 作业与练习

作业文档:day01-面向对象进阶(一)/day01作业/day01作业.md

重点知识(整理笔记 + 演讲)

  1. static 可以用来修饰什么?各自有何特点和应用场景?
  2. static 注意事项有哪些?
  3. 单例设计模式是什么意思?什么是懒汉式?什么是饿汉式?
  4. 代码块有几种?分别有何特点和作用?
  5. 继承是什么?为什么要在程序中使用继承?
  6. 方法重写和方法重载是什么样的?
  7. 子类构造器有什么特点?
  8. this 和 super 有哪些作用?

重点代码或案例(上机操作)

  1. 系统启动后,要求用户类可以记住自己创建了多少个用户对象(对应第 2.4 节)。
  2. 定义一个工具类,拥有生成指定位数验证码和获取 2 个整数最大值的功能(对应第 4 节)。
  3. 使用单例设计模式设计 Student 类,保证该类只有一个对象产生(对应第 7 节)。
  4. 在黑马的员工管理系统中,需要处理讲师、咨询师的数据:讲师的数据有姓名、具备的技能;咨询师的数据有姓名、解答问题的总人数(对应第 8.2 节)。

代码题

  1. 定义项目经理类(姓名、工号、工资、奖金,行为 work)与程序员类(姓名、工号、工资,行为 work),向上抽取一个父类,让这两个类都继承该父类,共有的属性写在父类中,子类重写父类中的方法,并编写测试类完成测试。
  2. 使用继承定义 Dog(姓名、颜色、价格;吃饭、看家)与 Cat(姓名、颜色、价格;吃饭、抓老鼠),把相同内容抽取到父类 Animal 中;在测试类中创建对象、赋值并调用各自方法。
  3. (拓展选做)定义银行卡的主卡与副卡类:主卡有余额、卡号、户主等属性,有存款和取款方法(取款余额不足要有提示);副卡额外有透支额度属性(可用主卡余额,余额不够可透支),副卡重写取款方法;写测试类分别测试存取款功能并显示余额。

15. 复习要点

  1. 类变量 vs 实例变量:归属(类 / 对象)、内存份数(一份 / 每对象一份)、访问方式(类名. 推荐 / 对象.)、典型场景(共享计数 / 个人属性)。
  2. static 三条注意事项:类方法只能直接访问类成员;实例方法两者都能直接访问;this 只能出现在实例方法中。
  3. 工具类 = 全是类方法 + 构造器私有化;用它的理由:调用方便、节省内存、提高复用。
  4. 代码块是类的 5 大成分之一;静态代码块在类初始化时执行一次(初始化类变量),实例代码块在每个对象创建时于构造器之前执行(初始化实例变量)。
  5. 单例三步:私有构造器 + 类变量存唯一对象 + 类方法返回该对象;饿汉式(提前创建)与懒汉式(延迟创建,注意 if (b == null) 判断)。
  6. 继承用 extends 建立父子关系;子类继承父类的非私有成员;子类对象由子父类多张设计图共同创建。
  7. 权限修饰符范围:private(本类)< 缺省(同包)< protected(同包 + 异包子孙类)< public(任意)。
  8. Java 单继承、不支持多继承、支持多层继承;Object 是所有类的祖宗类。
  9. 方法重写:名称与参数列表相同、@Override 校验、权限不能变小、返回值相同或更小、私有/静态方法不能重写;典型应用是重写 toString()。
  10. 就近原则:局部 → 子类成员 → 父类成员;要访问父类被遮蔽的成员用 super.。
  11. 子类构造器一定先调父类构造器:默认 super()(写不写都有),父类无无参构造器必须手写 super(...);this(...) 调用本类其他构造器,二者都必须在第一行且互斥。

day02|面向对象进阶(二)

本日延续 day01 的继承与 static,把面向对象三大特征补完:多态及其成员访问特点、类型转换、final 与常量、抽象类与抽象方法、模板方法设计模式,以及接口(含 JDK 8 之后新增的方法、接口多继承与使用注意事项)。学完后应能说清多态下的「编译期 / 运行期分工」,并能用 abstract 与 interface 把「稳定的约定」和「可替换的实现」分开。

本日 PPT 目录(第 2 页):多态(认识多态 / 使用多态的好处 / 多态下的类型转换问题)、final、枚举、抽象类、接口。目录中列有「枚举」,但本日正文未展开(第 10 页起的目录已去掉该项),枚举留待后续课程。

1. 多态

1.1 什么是多态

多态是在继承 / 实现情况下的一种现象,表现为对象多态与行为多态:

表现形式含义
对象多态父类类型的引用可以指向子类的对象
行为多态同一次方法调用,实际执行的是子类重写后的实现

多态的前提(三条同时满足):

  1. 有继承 / 实现关系;
  2. 存在父类引用指向子类对象;
  3. 存在方法重写。

注意事项:多态是对象、行为的多态,Java 中的属性(成员变量)不谈多态。

People p1 = new Student();   // 对象多态
p1.run();                    // 行为多态:执行 Student 重写的 run()

People p2 = new Teacher();
p2.run();                    // 执行 Teacher 重写的 run()

1.2 多态下的成员访问特点

「属性不谈多态」的具体含义是:成员变量的取值不跟随实际对象,而跟随变量的声明类型。结合课程代码 d1_polymorphism 的注释,可归纳为下表:

访问的内容编译阶段(看左边 = 声明类型)运行阶段
成员变量看左边,取父类中声明的值看左边
实例方法(被子类重写)看左边,确认父类中是否存在该方法看右边,执行子类重写的实现
静态方法看左边看左边
// 父类 Animal 中定义了 String name = "Animal名称"; 与 cry()
Animal a1 = new Cat();
a1.cry();                        // 方法:编译看左边,运行看右边,执行 Cat 重写的 cry()
System.out.println(a1.name);     // 成员变量:编译看左边,运行也看左边,输出 Animal名称

一句话记法:方法「编译看左边,运行看右边」;成员变量「编译看左边,运行也看左边」。

1.3 使用多态的好处

  1. 多态形式下,右边对象是解耦合的,更便于扩展和维护;
  2. 定义方法时使用父类类型的形参,可以接收一切子类对象,扩展性更强、更便利。
public static void main(String[] args) {
    Animal a = new Dog();   // 右边换成别的子类对象,左边代码不用改
    a.cry();

    go(new Cat());
    go(new Dog());
}

// 父类类型形参:一个方法可以接收一切子类对象
public static void go(Animal a) {
    System.out.println("开始。。。");
    a.cry();
    System.out.println("结束。。。");
}

1.4 多态带来的问题:不能调用子类独有功能

多态下变量的类型是父类,编译阶段只认父类中有的成员,因此不能直接调用子类的独有方法。

Animal a = new Dog();
a.cry();           // 可以:Animal 中定义了 cry()
// a.lookDoor();   // 报错:Animal 中没有 lookDoor(),它是 Dog 的独有功能

解决这一问题的办法就是类型转换。

1.5 多态下的类型转换问题

形式写法说明
自动类型转换父类 变量名 = new 子类();向上转型,子类对象赋给父类引用
强制类型转换子类 变量名 = (子类) 父类变量;向下转型,把对象换回它真正的类型

强制类型转换能把对象转换成其真正的类型,从而解决「多态下不能调用子类独有方法」的问题。

注意事项:

  • 只要存在继承 / 实现关系,强制类型转换在编译阶段不会报错;
  • 运行时,如果对象的真实类型与强转后的类型不同,会报类型转换异常 ClassCastException;
  • 因此强转前,Java 建议先使用 instanceof 关键字判断当前对象的真实类型,再进行强转。
Animal a = new Dog();
a.cry();

// 强制类型转换:解决多态下不能调用子类独有方法的问题
Dog d1 = (Dog) a;
d1.lookDoor();

// Cat c1 = (Cat) a;   // 编译不报错,运行时报 ClassCastException

// 方法内部:强转前先用 instanceof 判断对象的真实类型
public static void go(Animal a) {
    a.cry();
    if (a instanceof Dog) {
        ((Dog) a).lookDoor();
    } else if (a instanceof Cat) {
        ((Cat) a).catchFish();
    }
}

课件写法:对象 instanceof 类型,结果为 boolean。

(补充说明)instanceof 判断的是对象的真实类型,而不是变量的声明类型;它表达的是「对象是否具备该类型」。

1.6 本日目录对应的三个小结问答

  1. 使用多态有什么好处?存在什么问题?——可以解耦合、扩展性更强,父类类型形参可以接收一切子类对象;问题是多态下不能直接调用子类的独有方法。
  2. 类型转换有几种形式?能解决什么问题?——自动类型转换与强制类型转换;能把对象转换成其真正的类型,从而解决多态下不能调用子类独有方法的问题。
  3. 强制类型转换需要注意什么?——存在继承 / 实现关系即可强转,编译阶段不报错,运行时真实类型不符会报 ClassCastException;建议先用 instanceof 判断真实类型再强转。

2. final 关键字

2.1 final 的三种修饰位置

final 关键字是「最终」的意思,可以修饰类、方法、变量。

修饰对象特点
类该类被称为最终类,不能被继承
方法该方法被称为最终方法,不能被重写
变量该变量只能被赋值一次
// 1、final 修饰类:不能被继承
// final class A {}
// class B extends A {}     // 报错

// 2、final 修饰方法:不能被重写
// class C { public final void run() {} }
// class D extends C { @Override public void run() {} }   // 报错

public class FinalDemo {
    // 4、final 修饰静态成员变量:称为常量,值只有一个且不能被改变
    public static final String SCHOOL_NAME = "黑马程序员";

    public static void main(String[] args) {
        // 3、final 修饰变量:有且仅能赋值一次
        //    局部变量:方法内变量、形参、for 循环变量、构造器中的变量
        final int a = 12;
        // a = 15;                 // 报错:第二次赋值
        // SCHOOL_NAME = "黑马2";  // 报错:第二次赋值

        buy(0.7);
    }

    public static void buy(final double z) {
        // z = 0.1;                // 报错:第二次赋值
    }
}

2.2 final 修饰变量的注意点

  • final 修饰基本类型的变量:变量存储的数据不能被改变;
  • final 修饰引用类型的变量:变量存储的地址不能被改变,但地址所指向对象的内容是可以被改变的。

(补充说明)作业排错题中 public void addOne(final Other o) { o.i++; } 是合法的,被固定的是引用 o 本身,不是它所指向对象的字段。

2.3 补充知识:常量详解

使用 static final 修饰的成员变量就被称为常量,作用是通常用于记录系统的配置信息。

public class Constant {
    public static final String SCHOOL_NAME = "传智教育";
}

public class FinalDemo2 {
    public static void main(String[] args) {
        System.out.println(Constant.SCHOOL_NAME);
    }
}
要点说明
好处代码可读性更好,可维护性也更好
执行原理程序编译后,常量会被「宏替换」:出现常量的地方全部会被替换成它记住的字面量,这样可以保证使用常量和直接用字面量的性能是一样的
命名规范建议使用大写英文单词,多个单词之间用下划线连接

(补充说明)课件里常量的示例字面量为 "传智教育",配套课程代码中为 "黑马程序员",含义相同,只是配置内容不同。

3. 抽象类与抽象方法

3.1 什么是抽象类

Java 中有一个关键字 abstract,它就是抽象的意思,可以用它修饰类和成员方法:abstract 修饰类,这个类就是抽象类;修饰方法,这个方法就是抽象方法。

修饰符 abstract class 类名 {
    修饰符 abstract 返回值类型 方法名称(形参列表);
}

public abstract class A {
    // 抽象方法:必须用 abstract 修饰,只有方法签名,不能有方法体
    public abstract void test();
}

3.2 抽象类的注意事项与特点

  1. 抽象类中不一定有抽象方法,有抽象方法的类一定是抽象类;
  2. 类该有的成员(成员变量、方法、构造器)抽象类都可以有;
  3. 抽象类最主要的特点:抽象类不能创建对象,仅作为一种特殊的父类,让子类继承并实现;
  4. 一个类继承抽象类,必须重写完抽象类的全部抽象方法,否则这个类也必须定义成抽象类。
public abstract class A {
    private String name;
    private int age;

    public A() {
    }

    public A(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 抽象方法:abstract 修饰,只能有方法签名,没有方法体
    public abstract void go();
    public abstract void run();

    // getter / setter 省略
}

// 继承抽象类,必须重写完全部抽象方法
public class B extends A {
    @Override public void go() { }
    @Override public void run() { }
}

(补充说明)作业简答题问「abstract 不能和哪些关键字共存」:抽象方法需要子类重写,所以抽象方法不能同时是 private(子类看不到,无法重写)、static(静态方法不参与重写)、final(final 不允许重写)。

3.3 使用抽象类的好处

需求:某宠物游戏需要管理猫、狗的数据。猫的数据有名字,行为是「喵喵喵的叫~」;狗的数据有名字,行为是「汪汪汪的叫~」。

父类知道每个子类都要做某个行为,但每个子类要做的情况不一样,父类就把这个行为定义成抽象方法,交给子类去重写实现。抽出这样的抽象类,就是为了更好地支持多态。

// 抽象父类 Animal:有 private String name 与 getter / setter,
// 并定义了抽象方法 public abstract void cry();
public class Dog extends Animal {
    @Override
    public void cry() {
        System.out.println(getName() + "汪汪汪的叫~~~");
    }
}

public class Cat extends Animal {
    @Override
    public void cry() {
        System.out.println(getName() + "喵喵喵的叫~~~");
    }
}

// 多态调用
Animal a = new Cat();
a.setName("小猫");
a.cry();     // 小猫喵喵喵的叫~~~

(补充说明)好处有两层:抽象方法的方法体没有意义时可以直接不写,代码更简洁;同时强制子类重写,保证多态调用时一定有真实实现。

3.4 抽象类的常见应用场景:模板方法设计模式

3.4.1 模板方法设计模式解决了什么问题

解决方法中存在重复代码的问题。 例如 A、B 两个类都有 sing(),内部都是「代码一样 → 代码不同 → 代码一样」,差异代码被夹在相同的骨架之间,只能整段复制。

public class A {
    public void sing() {
        // 代码一样
        // 代码不同    ← 只有这一段各子类不同
        // 代码一样
    }
}

public class B {
    public void sing() {
        // 代码一样
        // 代码不同
        // 代码一样
    }
}

3.4.2 模板方法设计模式的写法

  1. 定义一个抽象类;
  2. 在里面定义 2 个方法:
    • 一个是模板方法:把相同代码放里面去;
    • 一个是抽象方法:具体实现交给子类完成。

多学一招:建议使用 final 关键字修饰模板方法。因为模板方法是给对象直接使用的,不能被子类重写;一旦子类重写了模板方法,模板方法就失效了。

3.4.3 完整示例

public abstract class People {
    // 模板方法:流程固定,用 final 保护,子类不能重写
    public final void write() {
        System.out.println("\t\t\t《我的爸爸》");
        System.out.println("\t我的爸爸还是很棒的,他是这样的人,听我介绍一下");
        writeMain();   // 变化的部分,交给子类
        System.out.println("\t有这样的爸爸真好!");
    }

    // 抽象方法:正文的具体实现交给子类
    public abstract void writeMain();
}

public class Student extends People {
    @Override
    public void writeMain() {
        System.out.println("\t(学生自己写的正文)");
    }
}

public class Teacher extends People {
    @Override
    public void writeMain() {
        System.out.println("\t(老师自己写的正文)");
    }
}

// 写作文的步骤和架构统一:标题一样、第一段一样、正文随意发挥、结尾一样
People p = new Teacher();
p.write();

3.4.4 应用:黑马员工管理系统

需求:需要管理讲师、咨询师的数据。讲师的数据有姓名、年龄、具备的技能;咨询师的数据有姓名、年龄、回答问题的总人数。讲师和咨询师都需要提供打印个人介绍的方法。

模板方法模式的实现步骤:

  1. 定义一个抽象类;
  2. 在里面定义 2 个方法(模板方法放相同代码,抽象方法交给子类实现);
  3. 定义子类继承抽象类,重写抽象方法;
  4. 创建子类对象,调用模板方法完成功能。

4. 接口

4.1 接口概述

Java 提供了关键字 interface,用它可以定义出一个特殊的结构:接口。

  • 接口不能创建对象;
  • 接口是用来被类实现(implements)的,实现接口的类称为实现类;
  • 一个类可以实现多个接口(接口可以理解成「干爹」);
  • 实现类实现多个接口,必须重写完全部接口的全部抽象方法,否则实现类需要定义成抽象类。

课件说法:JDK 8 之前,接口中只能定义成员变量和成员方法。

// 接口的定义结构
public interface 接口名 {
    // 成员变量(常量)
    // 成员方法(抽象方法)
}

// 类实现接口的结构
修饰符 class 实现类 implements 接口1, 接口2, 接口3, ... {
}

public interface A {
    // 常量:可以省略 public static final 不写,默认会加上
    String SCHOOL_NAME = "黑马程序员";

    // 抽象方法:可以省略 public abstract 不写,默认会加上
    void run();
    void go();
}

public interface B {
    void eat();
}

// 实现类:实现多个接口,必须重写完全部接口的全部抽象方法,否则这个类必须是抽象类
public class BImpl implements A, B {
    @Override public void run() { }
    @Override public void go() { }
    @Override public void eat() { }
}

(补充说明)作业选择题的对应结论:接口没有提供构造器;接口中的方法默认使用 public abstract 修饰;接口中的成员变量默认使用 public static final 修饰;而「接口不允许多继承」是错误的说法(接口之间可以多继承,见 4.4)。

4.2 接口的好处(重点)

  • 弥补了类单继承的不足,一个类同时可以实现多个接口;
  • 让程序可以面向接口编程,这样程序员就可以灵活方便地切换各种业务实现(更利于程序的解耦合)。

(补充说明)「面向接口编程」是指调用方只依赖接口中声明的方法,运行期再传入不同的实现类对象,因此切换实现时不用修改主流程代码。

4.3 接口的应用案例:班级学生信息管理模块

需求:设计一个班级学生的信息管理模块。学生的数据有姓名、性别、成绩。功能 1 要求打印出全班学生的信息;功能 2 要求打印出全班学生的平均成绩。

业务实现是有多套方案的:

  • 第 1 套方案:能打印出班级全部学生的信息;能打印班级全部学生的平均分;
  • 第 2 套方案:能打印出班级全部学生的信息(包含男女人数);能打印班级全部学生的平均分(要求去掉最高分、最低分)。

要求:系统可以支持灵活地切换这些实现方案。

public class Student {
    private String name;
    private char sex;
    private double score;

    public Student(String name, char sex, double score) {
        this.name = name;
        this.sex = sex;
        this.score = score;
    }

    public String getName() { return name; }
    public char getSex() { return sex; }
    public double getScore() { return score; }
}

// 业务约定用接口描述
public interface ClassData {
    void printAllStudentInfos();
    void printAllStudentAverageScore();
}

// 第 1 套实现方案
public class ClassDataImpl1 implements ClassData {
    private ArrayList<Student> students;

    public ClassDataImpl1(ArrayList<Student> students) {
        this.students = students;
    }

    @Override
    public void printAllStudentInfos() {
        System.out.println("===展示全部学生信息===");
        for (int i = 0; i < students.size(); i++) {
            Student s = students.get(i);
            System.out.println(s.getName() + " " + s.getSex() + " " + s.getScore());
        }
    }

    @Override
    public void printAllStudentAverageScore() {
        System.out.println("===展示全部学生的平均分===");
        double allScore = 0;
        for (int i = 0; i < students.size(); i++) {
            allScore += students.get(i).getScore();
        }
        System.out.println(allScore / students.size());
    }
}

// 第 2 套实现方案:打印时区分男女人数,平均分去掉最高分、最低分
// (其余部分与 ClassDataImpl1 相同)
public class ClassDataImpl2 implements ClassData {
    private ArrayList<Student> students;

    public ClassDataImpl2(ArrayList<Student> students) {
        this.students = students;
    }

    @Override
    public void printAllStudentInfos() {
        System.out.println("===展示全部学生信息===");
        int count = 0;
        for (int i = 0; i < students.size(); i++) {
            Student s = students.get(i);
            System.out.println(s.getName() + " " + s.getSex() + " " + s.getScore());
            if (s.getSex() == '男') {
                count++;
            }
        }
        System.out.println("男人数:" + count);
        System.out.println("女人数:" + (students.size() - count));
    }

    // 平均分去掉一个最高分与一个最低分
    @Override
    public void printAllStudentAverageScore() {
        double allScore = 0;
        double max = students.get(0).getScore();
        double min = max;
        for (int i = 0; i < students.size(); i++) {
            double sc = students.get(i).getScore();
            allScore += sc;
            if (sc > max) { max = sc; }
            if (sc < min) { min = sc; }
        }
        System.out.println("最高分:" + max + ",最低分:" + min);
        System.out.println("平均分" + (allScore - max - min) / (students.size() - 2));
    }
}

// 测试类
public class Test {
    public static void main(String[] args) {
        ArrayList<Student> students = new ArrayList<>();
        students.add(new Student("木婉清", '女', 80));
        students.add(new Student("钟灵", '女', 95));
        students.add(new Student("虚竹", '男', 90));
        students.add(new Student("乔峰", '男', 100));

        ClassData data = new ClassDataImpl1(students);   // 多态:切换方案只改这一行
        data.printAllStudentInfos();
        data.printAllStudentAverageScore();
    }
}

(补充说明)「去掉最高分、最低分」的实现需要额外考虑:学生人数不足 3 人时除数会为 0 或为负;所有成绩相同时「去掉极值」的业务含义也要事先约定。

4.4 接口的多继承

接口可以继承多个接口,作用是便于实现类去实现。

// 一个接口可以同时继承多个接口
interface A extends B, C {
    void a();
}

interface B {
    void b();
}

interface C {
    void c();
}

// 接口的多继承让实现类只实现一个接口,就相当于实现了很多个接口
class D implements A {
    @Override public void a() { }
    @Override public void b() { }
    @Override public void c() { }
}

课件写法:public interface C extends B, A { }。

类别关系小结:类与类是单继承的(一个类只能直接继承一个父类);类与接口是多实现的(一个类可以同时实现多个接口);接口与接口是多继承的(一个接口可以同时继承多个接口)。

4.5 JDK 8 开始,接口中新增的三种方法

JDK 8 开始,接口新增了三种形式的方法:

方法类型修饰符起始版本调用方式
默认方法(实例方法)default(默认加 public)JDK 8接口的实现类对象
静态方法(类方法)static(默认加 public)JDK 8只能接口名调用
私有方法privateJDK 9只能在接口内部被调用

为什么新增这些方法:增强了接口的能力,更便于项目的扩展和维护。

public interface A {
    // 默认方法(实例方法):使用 default 修饰,只能使用接口的实现类对象调用
    default void run() {
        go();
        System.out.println("跑的贼快~~~");
    }

    // 私有方法:必须用 private 修饰(JDK 9 开始才支持),只能在接口内部被调用
    private void go() {
        System.out.println("开始跑~~");
    }

    // 类方法(静态方法):使用 static 修饰,只能用接口名来调用
    static void inAddr() {
        System.out.println("我们都听磊哥吹 Java!");
    }
}

public class B implements A {
}

public class Test {
    public static void main(String[] args) {
        B b = new B();
        b.run();      // 默认方法:用实现类对象调用

        A.inAddr();   // 静态方法:用接口名调用
    }
}

课件说法:标题写「JDK 8 开始,接口中新增了三种方法」;更准确的表述:JDK 8 新增的是默认方法与静态方法,private 方法从 JDK 9 才开始支持。复习时按上表区分版本。

4.6 使用接口的注意事项(了解)

  1. 一个接口继承多个接口,如果多个接口中存在方法签名冲突,则此时不支持多继承;
  2. 一个类实现多个接口,如果多个接口中存在方法签名冲突,则此时不支持多实现;
  3. 一个类继承了父类,又同时实现了接口,父类中和接口中有同名的默认方法,实现类会优先用父类的;
  4. 一个类实现了多个接口,多个接口中存在同名的默认方法,可以不冲突,这个类重写该方法即可。
// 3、父类与接口有同名默认方法:优先用父类的
class Animal {
    public void run() {
        System.out.println("动物跑的贼溜~~");
    }
}

interface Go {
    default void run() {
        System.out.println("动物 go 的贼快~~");
    }
}

class Cat extends Animal implements Go {
    public void test() {
        run();            // 父类的
        Go.super.run();   // 接口的:接口名.super.方法名
    }
}

// 4、多个接口有同名默认方法:实现类重写即可,也可用 接口名.super 分别调用
//    (A3、B3 都声明了 default void run())
class C3 implements A3, B3 {
    @Override
    public void run() {
        System.out.println("C3 - run");
        A3.super.run();
        B3.super.run();
    }
}

// 1、2、方法签名冲突的情形(编译报错)
// interface A1 { String run(); }
// interface B1 { void run(); }
// interface C1 extends A1, B1 {}   // 签名冲突,不支持多继承
// class C1 implements A2, B2 {}    // 签名冲突,不支持多实现

4.7 接口与抽象类的区别

对比项抽象类接口
关键字abstract classinterface
能否创建对象不能不能
继承 / 实现关系类之间是单继承一个类可以多实现,接口之间可以多继承
成员变量各种成员变量都可以有只能是常量,默认 public static final
构造器可以有构造器没有构造器
成员方法抽象方法 + 普通方法抽象方法 + 默认 / 静态 / 私有方法(JDK 8 起)
表达的关系「是不是」:共享状态、固定流程「有没有这种能力」:可组合的行为约定
常见用途模板方法设计模式等面向接口编程、解耦合、灵活切换实现

(补充说明)课件本日正文没有单独列出这张对照表,上表依据本日 PPT 中抽象类与接口各自的规则归纳,并对照作业简答题「接口和抽象类有何区别」整理。

5. 作业与练习

作业文档:day02-面向对象进阶(二)/day02作业/day02作业.md

  • 一、选择题(12 题):接口描述与接口定义合法性、super 用法、重载与重写的分派结果、多态前提、编译错误判断、instanceof 判断、抽象类描述、抽象类子类定义、构造器与继承、Parent p = new Parent() 能否编译等。
  • 二、今日单词:instanceof、super、abstract、final、interface、implement。
  • 三、简答题(6 题):多态及其好处 / 弊端 / 解决方式;abstract 不能与哪些关键字共存;抽象类与普通父类的区别;final 各修饰位置的特点;调用父类构造器的方式与目的;接口与抽象类的区别。
  • 四、排错题(3 题):两个接口都有 static 与 default 方法时接口多继承是否可行;子类同时继承父类字段与实现接口常量时的名称歧义;final 修饰引用类型形参能否修改其内部字段。
  • 五、代码题(3 题):动物 / 游泳接口 / 饲养员(多态 + 接口能力判断);TV 与遥控器(面向对象综合);选做题「短信验证码发送」用模板方法设计模式抽取公共流程。

排错题要点提示:

  • 排错题 1:interface C extends A, B,两个父接口都定义 static void method() 与 default void show()。静态方法属于各自接口、不参与实现类的继承冲突;但两个同名的 default void show() 会让 C 产生默认方法冲突,需要在子接口(或最终实现类)中重写 show() 来解决。
  • 排错题 2:class C extends B implements A 同时拿到父类字段 B.x 与接口常量 A.x,直接写 x 会产生名称歧义,需要明确写成 super.x 或 A.x。
  • 排错题 3:final Other o 只固定引用 o 本身,o.i++ 修改的是对象内部字段,因此是合法的。

6. 复习要点

  1. 多态的前提三条:有继承 / 实现关系、父类引用指向子类对象、存在方法重写;表现为对象多态与行为多态;成员变量不谈多态。
  2. 多态下的成员访问:实例方法「编译看左边、运行看右边」,成员变量与静态方法「编译看左边、运行也看左边」。
  3. 多态的好处:右边对象解耦合、父类形参可接收一切子类对象;弊端:不能直接调用子类独有功能。
  4. 类型转换两种形式:自动类型转换(向上)与强制类型转换(向下);有继承 / 实现关系就能编译通过,运行时真实类型不符抛 ClassCastException;强转前先用 instanceof 判断。
  5. final 修饰类 / 方法 / 变量分别表示不可继承 / 不可重写 / 只能赋值一次;final 引用固定的是地址,对象内容仍可改变。
  6. 常量是 static final 成员变量,用于记录系统配置信息;编译后会被宏替换(字面量内联);命名建议全大写 + 下划线。
  7. 抽象类不能创建对象,可以有成员变量、方法、构造器;抽象类中不一定有抽象方法,有抽象方法的类一定是抽象类;继承抽象类的类必须重写全部抽象方法,否则也要定义成抽象类。
  8. 抽象方法不能与 private、static、final 共存。
  9. 模板方法设计模式的写法:抽象类中定义模板方法(相同代码与固定流程)与抽象方法(变化部分),建议用 final 修饰模板方法,防止流程被重写破坏。
  10. 接口用 interface 定义、被类 implements 实现;接口不能创建对象、没有构造器;成员变量隐含 public static final,方法隐含 public abstract;一个类可实现多个接口,接口之间可多继承。
  11. 接口的好处:弥补类单继承的不足;支持面向接口编程,便于切换实现、解耦合。
  12. JDK 8 起接口支持默认方法与静态方法,JDK 9 起支持私有方法;默认方法用实现类对象调用,静态方法只能用接口名调用。
  13. 接口使用注意事项:方法签名冲突时既不能多继承也不能多实现;父类与接口同名默认方法优先用父类的;多个接口同名默认方法需在实现类重写(可用 接口名.super.方法名 分别调用)。

day03|面向对象进阶(三)

本日把”对象写在哪儿”和”类型怎么约束”两件事讲清楚:先看内部类的四种形式(成员、静态、局部、匿名),再用枚举把一组固定取值变成有类型的常量,最后用泛型把容器的元素类型交给编译期检查。课程后半段顺带进入 java.lang 包常用 API 的第一站:Object、Objects、包装类、StringBuilder、StringJoiner。

学完应能:写出四种内部类并说明访问规则;用枚举替代”魔法常量”作为参数;定义泛型类/接口/方法并正确使用通配符上下限;知道泛型擦除与基本类型限制;正确重写 toString()/equals(),并优先用 Objects.equals() 做比较。

1. 内部类概述

内部类是类中的五大成分之一(成员变量、方法、构造器、内部类、代码块)。如果一个类定义在另一个类的内部,这个类就是内部类。

使用场景:当一个类的内部,包含了一个完整的事物,且这个事物没有必要单独设计时,就可以把这个事物设计成内部类。

public class Car {
    // 内部类
    public class Engine {
    }
}

课件把内部类分为四种形式,并标注了重要程度:

序号形式重要程度
01成员内部类了解
02静态内部类了解
03局部内部类了解
04匿名内部类重点

课件对局部内部类的评价是”鸡肋语法,看看就好”——知道语法、能读懂代码即可,实际开发中很少用。

2. 成员内部类[了解]

2.1 定义与创建对象

成员内部类就是类中的一个普通成员,类似前面学过的普通成员变量、成员方法;它没有 static 修饰,属于外部类的对象持有,必须先有外部类对象,才能有成员内部类对象。

创建对象的格式:

外部类名.内部类名 对象名 = new 外部类(...).new 内部类(...);

Outer.Inner in = new Outer().new Inner();
public class Outer {
    // 成员内部类
    public class Inner {
    }
}

注意:JDK 16 之前,成员内部类中不能定义静态成员;JDK 16 开始也可以定义静态成员了。

2.2 成员内部类中访问其他成员的特点

  1. 和前面学过的实例方法一样,成员内部类的实例方法中,同样可以直接访问外部类的实例成员、静态成员。
  2. 可以在成员内部类的实例方法中拿到当前外部类对象,格式是 外部类名.this。
public class People {
    private int heartBeat = 110;

    // 成员内部类
    public class Heart {
        private int heartBeat = 95;

        public void show() {
            int heartBeat = 80;
            System.out.println(heartBeat);              // 80,就近取局部变量
            System.out.println(this.heartBeat);         // 95,内部类自己的成员
            System.out.println(People.this.heartBeat);  // 110,外部类对象.this
        }
    }
}
public class Outer {
    public static String schoolName = "黑马";

    public static void inAddr() {
        System.out.println("我们在深圳黑马!");
    }

    private String hobby;
    private double height;

    public void run() {
        System.out.println("run");
    }

    // 成员内部类:无 static 修饰,属于外部类的对象持有
    public class Inner {
        public void show() {
            // 1、可以直接访问外部类的静态成员
            System.out.println(schoolName);
            inAddr();
            // 2、可以直接访问外部类的实例成员
            System.out.println(hobby);
            System.out.println(height);
            run();
        }
    }
}

class Test {
    public static void main(String[] args) {
        // 外部类名.内部类名 对象名 = new 外部类名().new 内部类名();
        Outer.Inner in = new Outer().new Inner();
        in.show();
    }
}

小结(课件自测页的口径):

  • 成员内部类是什么?如何创建其对象?—— 就是类中的一个普通成员,类似普通成员变量、成员方法;外部类名.内部类名 对象名 = new 外部类(...).new 内部类(...);
  • 成员内部类的实例方法中访问其他成员有啥特点?—— 可以直接访问外部类的实例成员、静态成员;可以拿到当前外部类对象,格式是 外部类名.this。

3. 静态内部类[了解]

什么是静态内部类? 有 static 修饰的内部类,属于外部类自己持有(不依赖外部类对象)。

public class Outer {
    // 静态内部类
    public static class Inner {
    }
}

创建对象的格式:

外部类名.内部类名 对象名 = new 外部类.内部类(...);

Outer.Inner in = new Outer.Inner();

静态内部类中访问外部类成员的特点:可以直接访问外部类的静态成员,不可以直接访问外部类的实例成员。

public class Outer {
    public static String schoolName = "黑马";
    private double height;

    public static class Inner {
        public void show() {
            System.out.println(schoolName); // 可以:访问外部类静态成员
            // System.out.println(height);  // 不可以:不能直接访问外部类实例成员
        }
    }
}

class Test {
    public static void main(String[] args) {
        Outer.Inner in = new Outer.Inner();
        in.show();
    }
}

对比记忆:成员内部类靠外部对象活,静态内部类靠外部类活。

4. 局部内部类[了解]

局部内部类定义在方法中、代码块中、构造器等执行体中。

public class Test {
    public static void main(String[] args) {

    }

    public static void go() {
        class A {          // 局部类
        }

        abstract class B { // 局部抽象类
        }

        interface C {      // 局部接口
        }
    }
}

局部内部类只能在该方法/代码块的局部作用域内使用。课件提示这是”鸡肋语法”,重点在于能读懂,以及它是理解匿名内部类的前置知识(匿名内部类就是一种特殊的局部内部类)。

5. 匿名内部类[重点]

5.1 认识匿名内部类

匿名内部类就是一种特殊的局部内部类;所谓匿名,指的是程序员不需要为这个类声明名字。

格式:

new 类或接口(参数值...) {
    类体(一般是方法重写);
};
new Animal() {
    @Override
    public void cry() {
    }
};
  • 特点:匿名内部类本质就是一个子类,并会立即创建出一个子类对象。
  • 作用:用于更方便地创建一个子类对象。
public class Test {
    public static void main(String[] args) {
        // 匿名内部类本质是一个子类,同时会立即创建一个子类对象
        Animal a = new Animal() {
            @Override
            public void cry() {
                System.out.println("🐕是汪汪汪的叫~~~");
            }
        };
        a.cry();
    }
}

abstract class Animal {
    public abstract void cry();
}

上面这段代码等价于”先写一个 Dog extends Animal 的子类,再 new Dog()”,但省掉了一个只用一次的子类文件。

5.2 匿名内部类的常见使用场景

通常作为一个参数传输给方法。

需求:猫、狗参加游泳比赛。

public interface Swimming {
    void swim();
}

public class Test {
    public static void main(String[] args) {
        // 目标:认识匿名内部类,并掌握其作用。
        Swimming s1 = new Swimming() {
            @Override
            public void swim() {
                System.out.println("狗🏊飞快~~~");
            }
        };
        go(s1);

        go(new Swimming() {
            @Override
            public void swim() {
                System.out.println("猫🏊也还行~~~");
            }
        });
    }

    public static void go(Swimming s) {
        System.out.println("开始============");
        s.swim();
    }
}

小结(课件自测页的口径):

问题回答
书写格式是什么?new 类或接口(参数值...) { 类体(一般是方法重写); };
有啥特点?本质就是一个子类,并会立即创建出一个子类对象
有啥作用、应用场景?可以更方便地创建出一个子类对象;通常作为一个参数传输给方法

6. 枚举

6.1 认识枚举

枚举是一种特殊类。格式:

修饰符 enum 枚举类名 {
    名称1, 名称2, ...;
    其他成员...
}
public enum A {
    X, Y, Z;
}

注意:枚举类中的第一行,只能写一些合法的标识符(名称),多个名称用逗号隔开。这些名称本质是常量,每个常量都会记住枚举类的一个对象。

6.2 枚举类的特点

  1. 枚举类的第一行只能罗列一些名称,这些名称都是常量,并且每个常量记住的都是枚举类的一个对象。
  2. 枚举类的构造器都是私有的(写不写都只能是私有的),因此,枚举类对外不能创建对象。
  3. 枚举都是最终类,不可以被继承。
  4. 枚举类中,从第二行开始,可以定义类的其他各种成员。
  5. 编译器为枚举类新增了几个方法,并且枚举类都是继承 java.lang.Enum 类的,从 Enum 类也会继承到一些方法。

从编译器视角看,一个枚举类编译后大致长这样(课件给出的反编译结果):

// Compiled from "A.java"
public final class A extends java.lang.Enum<A> {
    public static final A X = new A();
    public static final A Y = new A();
    public static final A Z = new A();

    public static A[] values();
    public static A valueOf(java.lang.String);
}

这解释了为什么”枚举不能被继承”(final class)、“枚举不能对外创建对象”(构造器私有)以及 values()、valueOf() 从哪来。

public enum A {
    // 枚举类的第一行必须罗列的是枚举对象的名称
    X, Y, Z;

    // 从第二行开始可以定义类的其他各种成员
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

class Test {
    public static void main(String[] args) {
        // A a = new A(); // 报错:枚举类对外不能创建对象
        A a1 = A.X;
        A a2 = A.Y;

        // values():拿到枚举类的全部对象,放到一个数组中返回
        A[] as = A.values();
        for (int i = 0; i < as.length; i++) {
            System.out.println(as[i]);
        }

        // valueOf():按名称拿到对应的枚举对象
        A y = A.valueOf("Y");
        System.out.println(y == a2); // true
    }
}

多学一招:使用枚举类实现单例设计模式——只写一个枚举项(如 enum B { X; ... }),JVM 保证该实例唯一且线程安全,这是最简洁的单例写法。

多学一招——使用枚举类实现单例设计模式

6.3 枚举的常见应用场景

枚举的常见应用场景:用来表示一组信息,然后作为参数进行传输。

方案做法效果
定义一个个常量用 public static final int 表示一组信息并作为参数传输参数值不受约束,可读性差,维护麻烦
定义枚举用 enum 表示一组信息并作为参数传输代码可读性好,参数值得到了约束,对使用者更友好,建议使用
public class Constant {
    public static final int DOWN = 1;
    public static final int UP = 2;
    public static final int HALF_UP = 3;
    public static final int DELETE_LEFT = 4;
}

public enum Constant2 {
    DOWN, UP, HALF_UP, DELETE_LEFT;
}

class Test {
    public static void main(String[] args) {
        // 常量方式:调用方可以传任意 int,参数值不受约束
        System.out.println(handleData(3.9991, Constant.DOWN));

        // 枚举方式:参数值被约束为枚举项,可读性更好
        System.out.println(handleData(3.9991, Constant2.DOWN));
    }

    // 常量为参数:flag 是 int,随便传 999 也能编译通过
    public static double handleData(double number, int flag) {
        switch (flag) {
            case Constant.DOWN:
                number = Math.floor(number);  // 向下取整,3.9991 == 3
                break;
            case Constant.UP:
                number = Math.ceil(number);   // 向上取整,3.1 == 4
                break;
            case Constant.HALF_UP:
                number = Math.round(number);  // 四舍五入,3.6 == 4
                break;
            case Constant.DELETE_LEFT:
                number = (int) number;        // 去掉小数部分
                break;
        }
        return number;
    }

    // 枚举为参数:flag 只能是 Constant2 中列出的枚举项
    public static double handleData(double number, Constant2 flag) {
        switch (flag) {
            case DOWN:
                number = Math.floor(number);
                break;
            case UP:
                number = Math.ceil(number);
                break;
            case HALF_UP:
                number = Math.round(number);
                break;
            case DELETE_LEFT:
                number = (int) number;
                break;
        }
        return number;
    }
}

示例中的性别选择就是同一思路:界面上”女生 / 男生”这类取值固定且需要作为参数传递的信息,用枚举表达比用数字或字符串更安全。

枚举的应用场景——界面上性别等固定取值作为参数传递

7. 泛型

7.1 认识泛型

定义类、接口、方法时,同时声明了一个或者多个类型变量(如 <E>),称为泛型类、泛型接口、泛型方法,它们统称为泛型。

public class ArrayList<E> {
    ...
}
  • 作用:泛型提供了在编译阶段约束所能操作的数据类型,并自动进行检查的能力!这样可以避免强制类型转换,及其可能出现的异常。
  • 本质:把具体的数据类型作为参数传给类型变量。
import java.util.ArrayList;

public class Test {
    public static void main(String[] args) {
        // 不用泛型:类型无法统一,取出来必须强制转换
        ArrayList list = new ArrayList();
        list.add("黑马");
        list.add(true);
        list.add(99.5);
        for (int i = 0; i < list.size(); i++) {
            Object ele = list.get(i);
            String result = (String) ele; // 强制转换,类型不对就抛异常
            System.out.println(result);
        }

        // 使用泛型:编译期就约束了元素类型
        ArrayList<String> list2 = new ArrayList<>(); // JDK 1.7 开始后面类型可以不写
        list2.add("黑马");
        list2.add("深圳");
        list2.add("Java");
        for (int i = 0; i < list2.size(); i++) {
            String ele = list2.get(i); // 不需要强制转换
            System.out.println(ele);
        }
    }
}

7.2 泛型类

修饰符 class 类名<类型变量, 类型变量, ...> {
}
public class ArrayList<E> {
    ...
}

注意:类型变量建议用大写的英文字母,常用的有:E、T、K、V 等。

需求:模拟 ArrayList 集合,自定义一个 MyArrayList 泛型类。

import java.util.ArrayList;

// 泛型类
public class MyArrayList<E> {
    private ArrayList list = new ArrayList();

    public boolean add(E e) {
        list.add(e);
        return true;
    }

    public boolean remove(E e) {
        return list.remove(e);
    }

    @Override
    public String toString() {
        return list.toString();
    }
}

class Test {
    public static void main(String[] args) {
        // 需求:模拟 ArrayList 集合,自定义一个 MyArrayList 的泛型类。
        MyArrayList<String> list = new MyArrayList<>();
        list.add("张无忌");
        list.add("赵敏");
        list.add("周芷若");
        list.add("小昭");
        list.remove("周芷若");
        System.out.println(list);
    }
}

7.3 泛型接口

修饰符 interface 接口名<类型变量, 类型变量, ...> {
}
public interface A<E> {
    ...
}

需求:必须完成学生、老师数据的增删改查操作——CRUD 逻辑完全一样,只有数据类型不同,用泛型接口一次写清楚。

// 泛型接口
public interface Data<T> {
    void add(T t);
    void delete(T t);
    void update(T t);
    T getById(int id);
}

public class Student {
}

public class Teacher {
}

// 实现类一:把 T 固定成 Student
public class StudentData implements Data<Student> {
    @Override
    public void add(Student student) {
    }

    @Override
    public void delete(Student student) {
    }

    @Override
    public void update(Student student) {
    }

    @Override
    public Student getById(int id) {
        return null;
    }
}

// 实现类二:把 T 固定成 Teacher,方法与 StudentData 完全一致
public class TeacherData implements Data<Teacher> {
    @Override
    public void add(Teacher teacher) {
    }

    // delete / update / getById 同 StudentData,只是类型换成 Teacher(略)
}

7.4 泛型方法

修饰符 <类型变量, 类型变量, ...> 返回值类型 方法名(形参列表) {
}

注意类型变量写在返回值类型之前,这是泛型方法与普通方法、以及”普通方法里出现了未定义类型变量”的分界。

public static <T> void test(T t) {
}

需求:接收任意对象数组。

public class Test {
    public static void main(String[] args) {
        String[] names = {"谢霆锋", "陈羽凡", "王宝强", "贾乃亮"};
        String rs1 = printArray(names);

        Student[] students = new Student[60];
        Student rs2 = printArray(students);
    }

    public static <T> T printArray(T[] array) {
        return array[0];
    }
}

7.5 通配符与上下限

  • 通配符:就是 ?,可以在”使用泛型”的时候代表一切类型;E T K V 是在”定义泛型”的时候使用。
  • 泛型上限:? extends Car,? 能接收的必须是 Car 或者其子类。
  • 泛型下限:? super Car,? 能接收的必须是 Car 或者其父类。

需求:所有汽车一起参与比赛。

import java.util.ArrayList;

public class Test2 {
    public static void main(String[] args) {
        ArrayList<TSL> tsls = new ArrayList<>();
        tsls.add(new TSL());
        tsls.add(new TSL());
        go(tsls);

        ArrayList<LX> lxes = new ArrayList<>();
        lxes.add(new LX());
        lxes.add(new LX());
        go(lxes);

        // ArrayList<Dog> dogs = new ArrayList<>();
        // go(dogs); // 编译错误:Dog 不是 Car 的子类,不满足 ? extends Car
    }

    // 虽然 TSL 和 LX 是 Car 的子类,但 ArrayList<TSL>、ArrayList<LX> 与 ArrayList<Car> 没有关系。
    // 所以这里要用通配符 ? 来表示"Car 及其子类的集合"。
    public static void go(ArrayList<? extends Car> cars) {
    }
}

class Car {
}

class TSL extends Car {
}

class LX extends Car {
}

class Dog {
}

7.6 泛型的擦除问题与注意事项

  1. 泛型是工作在编译阶段的,一旦程序编译成 class 文件,class 文件中就不存在泛型了,这就是泛型擦除。
  2. 泛型不能直接支持基本数据类型,只能支持对象类型(引用数据类型)——所以集合、泛型中要写 Integer 而不是 int。

课件说法:“class 文件中就不存在泛型了”;更准确的表述:编译后类型参数会被擦除为它的上界(没有上界就是 Object),但 class 文件仍保留泛型签名(Signature)元数据,反射可以读到(补充说明)。

8. 常用 API(一):Object、Objects、包装类与字符串工具

8.1 什么是 API

API(Application Programming Interface):应用程序编程接口。就是 Java 帮我们已经写好的一些程序,如:类、方法等,我们直接拿过来用就可以解决一些问题。

本节的知识块(课件目录顺序):

序号内容
1API 概述、接下来课程介绍
2Object 类
3Objects
4包装类
5StringBuilder
6StringJoiner

学习建议(课件的比喻):学 API 像学英语——先认”一个一个的单词”(类名、方法名往往就是英语单词),再靠”语法知识”把它们组合起来。看得懂英文单词和方法签名,查 API 文档的效率会显著提高。

JDK API 文档(黑马程序员版)——所有类的索引

API 文档按包组织,常用类集中在 java.base 下的 java.lang、java.util、java.io 等包中。

API 文档中的包结构(java.base → java → io / lang / net / util 等)

8.2 Object 类

Object 类是 Java 中所有类的祖宗类,因此,Java 中所有类的对象都可以直接使用 Object 类中提供的一些方法。一个类没有写 extends 时,默认继承 Object。

本日重点掌握两个方法:

方法基本作用存在的意义
toString()返回对象的字符串形式为了被子类重写,以便返回对象具体的内容
equals(Object o)默认比较两个对象的地址是否相等为了被子类重写,以便子类自己定制比较规则(比如比较对象内容)

关键理解:直接比较两个对象的地址是否相同完全可以用 == 替代 equals,所以 Object 提供的 equals 的意义就是留给子类重写。

public class Student {
    private String name;
    private int age;
    private double score;

    // ... 构造器与 getter/setter 省略 ...

    /**
     * 比较者:t1 ==> this
     * 被比较者:t2 ==> o
     */
    @Override
    public boolean equals(Object o) {
        // 1、判断两个对象是否是同一个对象在比较,如果是,直接返回 true
        if (this == o) {
            return true;
        }
        // 2、如果 o 是 null 返回 false;如果 o 不是学生类型也返回 false  (Student != Pig)
        if (o == null || this.getClass() != o.getClass()) {
            return false;
        }
        // 3、说明 o 一定是学生类型而且不为 null,开始比较内容
        Student student = (Student) o;
        return this.age == student.age
                && Double.compare(this.score, student.score) == 0
                && Objects.equals(this.name, student.name);
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", score=" + score +
                '}';
    }
}

class Test {
    public static void main(String[] args) {
        Student s1 = new Student("小昭", 21, 100);
        // toString 默认返回"地址形式"没意义,开发中输出对象更想看内容
        System.out.println(s1.toString()); // 直接输出对象时 toString 可以不写
        System.out.println(s1);

        Student t1 = new Student("殷素素", 35, 98);
        Student t2 = new Student("殷素素", 35, 98);
        System.out.println(t1.equals(t2)); // true:重写后按内容比较
        System.out.println(t1 == t2);      // false:两个不同对象
    }
}

(补充说明)Object 还提供 hashCode()、getClass()、clone() 等方法,本日作业选择题里出现过 getClass() 与”不属于 Object 的方法”(如 trim())的判断。按字段重写 equals() 时通常应一并重写 hashCode()。

课件自测页的口径:

  1. toString 的作用是返回对象的字符串形式;存在的意义是让子类重写,以便返回子类对象的内容。
  2. equals 的作用是默认比较两个对象的地址是否相等;存在的意义是让子类重写,以便比较对象的内容是否相同。

8.3 Objects 工具类

Objects 是一个工具类,提供了很多操作对象的静态方法。

为什么要用 Objects 的 equals 方法,而不是用对象自己的 equals 方法来比较?——更安全。

public static boolean equals(Object a, Object b) {
    return (a == b) || (a != null && a.equals(b));
}

源码分析:先比引用(同一个对象或都为 null 直接返回 true),再确认 a != null 才调用 a.equals(b),因此主调对象为 null 时不会爆空指针异常。

import java.util.Objects;

public class Test {
    public static void main(String[] args) {
        Student t1 = null;
        Student t2 = new Student("蜘蛛精", 300, 85.5);

        // System.out.println(t1.equals(t2)); // 主调对象是 null,直接爆出空指针异常
        System.out.println(Objects.equals(t1, t2)); // false:更安全、更可靠,推荐使用

        System.out.println(Objects.isNull(t1));  // true
        System.out.println(t1 == null);          // true
        System.out.println(Objects.nonNull(t1)); // false
        System.out.println(t1 != null);          // false
    }
}

今后比较两个对象时,建议用 Objects 的 equals 来判断。

8.4 包装类

为什么要用包装类? 为了”万物皆对象”,并且泛型和集合都不支持基本类型,只支持包装类。

包装类就是把基本类型的数据包装成对象。

基本数据类型包装类基本数据类型包装类
byteBytefloatFloat
shortShortdoubleDouble
intIntegercharCharacter
longLongbooleanBoolean

记忆法:8 种包装类中,int → Integer、char → Character,其他的都是首字母大写。

  • 自动装箱:基本数据类型可以自动转换为包装类型。
  • 自动拆箱:包装类型可以自动转换为基本数据类型。
public class Test {
    public static void main(String[] args) {
        int a = 12;

        // 1、把基本类型通过包装类包装成对象(手动包装)
        // Integer it1 = new Integer(a); // 构造器方式已过时
        Integer it1 = Integer.valueOf(a);

        // 2、自动装箱:基本类型的数据可以直接变成对象赋值给包装类
        Integer it2 = 128;
        Integer it3 = 128;
        System.out.println(it2 == it3); // false:超出缓存范围,是两个不同对象

        // 3、自动拆箱:包装类的对象可以直接赋值给基本数据类型
        int it4 = it3;
        System.out.println(it4);

        // 功能一:包装类可以把基本类型的数据转换成字符串
        int a1 = 23;
        String rs = Integer.toString(a1); // "23"
        System.out.println(rs + 1);       // 231
        System.out.println(a1 + "" + 1);  // 231,更简单的写法

        // 功能二:把字符串类型的数值转换成对应的基本数据类型(很有用!)
        String ageStr = "23";
        int age = Integer.parseInt(ageStr);
        System.out.println(age + 1);      // 24

        String scoreStr = "99.5";
        double score = Double.parseDouble(scoreStr);
        System.out.println(score + 0.5);  // 100.0
    }
}

包装类的其他常见操作:

方向常用方法
基本类型 → 字符串Integer.toString(int)、包装对象 .toString()、值 + ""
字符串 → 基本类型Integer.parseInt(String)、Double.parseDouble(String)、Integer.valueOf(String)

(补充说明)Integer.valueOf() 对 -128~127 之间的值会返回缓存对象,所以两个 Integer 都用 == 比较时,小值可能为 true、大值为 false。比较包装类的数值应使用 equals()、Objects.equals() 或先拆箱,不要依赖 ==。字符串格式非法时 parseInt 会抛 NumberFormatException,拆箱 null 会抛 NullPointerException。

8.5 StringBuilder

StringBuilder 代表可变字符串对象,相当于是一个容器,它里面装的字符串是可以改变的,就是用来操作字符串的。

好处:StringBuilder 比 String 更适合做字符串的修改操作,效率会更高,代码也会更简洁。

// 用 String 频繁拼接:每次都会产生新的字符串对象,慢
public class Test {
    public static void main(String[] args) {
        String s = "";
        for (int i = 0; i < 1000000; i++) {
            s = s + "abc";
        }
        System.out.println(s);
    }
}
// 用 StringBuilder 拼接:始终是同一个容器在追加,快
public class Test {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 1000000; i++) {
            sb.append("abc");
        }
        System.out.println(sb);
    }
}

结论:

  1. 对于字符串相关的操作,如频繁的拼接、修改等,建议用 StringBuilder,效率更高!
  2. 注意:如果操作字符串较少,或者不需要操作,以及定义字符串变量,还是建议用 String。
  3. 原因:String 是不可变字符串,频繁操作字符串会产生很多无用对象,性能差;StringBuilder 是内容可变的字符串,拼接字符串性能好、代码优雅。

StringBuffer 与 StringBuilder:StringBuffer 的用法与 StringBuilder 是一模一样的,但 StringBuilder 是线程不安全的,StringBuffer 是线程安全的。

8.6 StringJoiner

为什么学 StringJoiner? ——既要高效,又要实现更方便的拼接。

StringJoiner 是 JDK 8 开始才有的,跟 StringBuilder 一样,也是用来操作字符串的,也可以看成是一个容器,创建之后里面的内容是可变的。

好处:不仅能提高字符串的操作效率,并且在有些场景下使用它操作字符串,代码会更简洁(例如带分隔符、带前后缀的拼接)。

(补充说明)常用用法:new StringJoiner(分隔符) 或 new StringJoiner(分隔符, 前缀, 后缀),通过 add() 追加元素,toString() 得到结果;此时不需要自己判断”最后一个元素不加分隔符”。

小结:StringJoiner 是 JDK 8 出现的一个可变的、操作字符串的容器,用它拼接字符串时不仅高效,而且代码的编写更加方便。

8.7 综合案例:返回任意整型数组的内容

需求:设计一个方法,用于返回任意整型数组的内容,要求返回的数组内容格式如:[11, 22, 33]。

分析:

问题结论
方法是否需要接收数据?需要接收整型数组
方法是否需要返回数据?需要返回拼接后的结果
方法内部怎么做?遍历数组的数据,把遍历到的数据都拼接起来,此时使用 StringBuilder 来完成拼接
public static String getArrayData(int[] arr) {
    // 1、判断 arr 是否为 null
    if (arr == null) {
        return null;
    }
    // 2、arr 数组对象存在。例如 arr = [11, 22, 33]
    StringBuilder sb = new StringBuilder();
    sb.append("[");
    for (int i = 0; i < arr.length; i++) {
        if (i == arr.length - 1) {
            sb.append(arr[i]);                 // 最后一个元素后面不加逗号
        } else {
            sb.append(arr[i]).append(", ");    // 链式调用
        }
    }
    sb.append("]");

    return sb.toString();
}

9. 作业与练习

作业文档:day03-面向对象高级部分:匿名内部类,泛型,枚举/day03作业/day03作业.md 配套代码工程:day03-面向对象高级部分:匿名内部类,泛型,枚举/代码/day03-oop-code/(src/com/itheima 下按 d1_innerclass1 … d13_integer 分目录,与本节章节一一对应)

题型要点
一、选择题(12 题)Object 类的继承关系与常见方法;外部类与内部类成员相互访问规则;泛型定义语法(如 class Test4{ void <T> test(){} } 不正确);匿名内部类能否有构造方法;成员/局部内部类的访问与继承报错
二、今日单词enum;Object 类的方法名;JDK 八种包装类类名(注意 String 不是包装类)
三、简答题静态内部类的加载时机;枚举与静态常量的区别;泛型的定义方式;泛型注意事项;浅克隆与深克隆;包装类的作用
四、排错题(4 题)成员内部类中修改成员变量/局部变量;Integer 的 == 比较结果;匿名内部类中定义静态方法(注意 JDK 16 前后的版本差异);Inner extends Demo1 与 Demo1() 中 new Inner() 形成的递归构造
五、代码题(3 题)Outer.method().show() 补全代码输出 HelloWorld(静态方法返回接口实现对象);用两种方式在 Test 中调用 methodA()(具名实现类 + 匿名内部类);【选做】用枚举优化”付款类型标号 → 中文含义”的 if...else

两个值得推导的重点:

  • Outer.method().show() 可拆成”静态方法返回一个 Inter 实现对象,再调用接口方法”,返回对象既可以是具名实现类,也可以是匿名内部类,返回类型写接口即可。
  • 排错题 4 中 Inner extends Demo1,而 Demo1() 又创建 new Inner(),构造 Inner 会先调 Demo1 构造器,后者再次创建 Inner,形成递归构造直至栈溢出——解决要打断对象创建环,而不是只加输出语句。

10. 复习要点

  1. 内部类是类的五大成分之一;四种形式是成员内部类、静态内部类、局部内部类(均为了解)、匿名内部类(重点)。
  2. 成员内部类无 static,属于外部类对象:new Outer().new Inner();可直接访问外部类的实例成员和静态成员;用 外部类名.this 取外部对象。JDK 16 起成员内部类允许定义静态成员。
  3. 静态内部类有 static,属于外部类本身:new Outer.Inner();只能直接访问外部类的静态成员,不能直接访问实例成员。
  4. 局部内部类定义在方法、代码块、构造器中,只在该作用域内可见;匿名内部类是特殊的局部内部类,格式 new 类或接口(参数值...){ 类体 },本质是一个子类并立即创建出对象。
  5. 匿名内部类的典型用法是作为参数传给方法(如把 Swimming 实现交给 go())。
  6. 枚举是一种特殊类:第一行只能是名称常量,每个名称记住一个枚举对象;构造器私有、类是 final、继承 java.lang.Enum;编译器自动生成 values()、valueOf()。
  7. 枚举用于表示一组信息并作为参数传输,比一堆 static final 常量可读性更好、参数值受约束;用单个枚举项可实现最简洁的单例。
  8. 泛型的本质是把数据类型作为参数传给类型变量:泛型类 <E> 写在类名后、泛型接口写在接口名后、泛型方法写在返回值类型前;? 是使用时的通配符,? extends Car 是上限、? super Car 是下限。
  9. 泛型工作在编译阶段,编译后发生擦除;泛型不能直接用基本数据类型,要用包装类。注意”class 文件里完全不存在泛型”是课件的简化说法(签名元数据仍保留)。
  10. Object 是所有类的祖宗类;toString()、equals() 的意义都是留给子类重写;比较对象优先用 Objects.equals(a, b)(对 null 安全)。
  11. 八种包装类:int→Integer、char→Character,其余首字母大写;自动装箱/拆箱;包装类可做基本类型与字符串互转(parseInt/valueOf/toString);包装类数值比较用 equals 而非 ==。
  12. 频繁拼接/修改字符串用 StringBuilder(线程不安全)或 StringBuffer(线程安全),用法一致;JDK 8 的 StringJoiner 在带分隔符/前后缀的场景更简洁;字符串变量本身仍用 String。

day04|java.lang、java.util 包下的常用 API

本日围绕「字符串怎么高效拼、金额怎么算准、日期怎么处理」三类最常见的 API 展开:StringBuilder / StringBuffer / StringJoiner、Math / System / Runtime、BigDecimal,以及 JDK 8 之前的 Date / SimpleDateFormat / Calendar 与 JDK 8 新增的 java.time 系列。学完后应能回答:为什么频繁拼接要用 StringBuilder,为什么金额不能用 double 算,为什么有了 Date 还要学 LocalDateTime。

本日 PPT 目录(第 2 页):StringBuilder、StringBuffer → StringJoiner → Math、System、Runtime → BigDecimal → JDK8 之前传统的日期、时间 → JDK8 开始新增的日期、时间。

两点说明(供复习时核对):

  • 课件第 1 页标题是「常用API(二)」,目录中不含 Object / Objects、包装类与自动装箱拆箱、String 的常用方法、Arrays 等内容,这些不在本日 PPT 范围内(后续课程安排)。
  • 课件里大多数「常见方法」页在抽取文本中只留下 [表格] 占位,没有表体。本文按课件的题目页、配套示例代码 day04-api-code 与 JDK API 还原这些表格,还原处已用「(表格按课件题目与配套代码整理)」标注。

1. StringBuilder 与 StringBuffer

1.1 为什么需要 StringBuilder

StringBuilder 代表可变字符串对象,相当于一个容器,里面装的字符串内容是可以改变的,就是用来操作字符串的。好处是:StringBuilder 比 String 更适合做字符串的修改操作,效率更高,代码也更简洁。

原因在第 6 页的小结里说得很明确:

  • String 是不可变字符串,频繁操作字符串会产生很多无用对象,性能差;
  • StringBuilder 是内容可变的字符串,拼接字符串性能好、代码优雅。

注意(课件原话):如果操作字符串较少,或者不需要操作,以及定义字符串变量时,还是建议用 String。

1.2 性能对比演示

课件用一段「拼接 100 万次」的代码对比了两种写法(第 4 页,同样的实验在第 9 页又出现了一次):

// 写法一:用 String 拼接
public class Test {
    public static void main(String[] args) {
        String s = "";
        for (int i = 0; i < 1000000; i++) {
            s = s + "abc";
        }
        System.out.println(s);
    }
}
// 写法二:用 StringBuilder 拼接
public class Test {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 1000000; i++) {
            sb.append("abc");
        }
        System.out.println(sb);
    }
}

课件对两种写法的评价是:第一种「速度忒慢」,第二种「速度快了」。第 4 页的提问「为啥操作字符串建议使用 StringBuilder,而不用原来学过的 String?」答案就是 1.1 中的不可变性与无用对象问题。

1.3 StringBuilder 的常见方法

(表格按课件题目与配套代码整理)

方法说明
public StringBuilder()创建内容为空的可变字符串容器
public StringBuilder(String str)用指定内容创建
public StringBuilder append(任意类型)追加数据,返回容器本身,可链式调用
public StringBuilder reverse()反转内容
public int length()返回内容的长度
public String toString()把容器内容转回 String

配套代码 d1_stringbuilder/StringBuilderDemo1.java 把上述方法串了一遍:

public class StringBuilderDemo1 {
    public static void main(String[] args) {
        // 1、创建 StringBuilder 对象
        StringBuilder sb = new StringBuilder();      // sb = ""
        StringBuilder sb2 = new StringBuilder("黑马"); // sb2 = "黑马"

        // 2、拼接内容
        sb.append("深圳黑马").append("Java").append(666).append(true);

        // 3、反转内容
        sb.reverse();

        // 4、拿长度
        System.out.println(sb.length());

        // 5、把 StringBuilder 对象转换成 String 对象
        // StringBuilder 是拼接字符串的手段,String 才是开发中的目的
        String result = sb.toString();
        System.out.println(result);
    }
}

1.4 StringBuffer 与 StringBuilder 的区别

第 5 页给出结论:

对比项StringBuilderStringBuffer
用法完全一样(方法名、参数都一样)完全一样
线程安全线程不安全线程安全
使用建议单线程、线程私有的局部变量拼接需要多线程共享同一个容器时使用

1.5 案例:返回任意整型数组的内容

需求(第 7 页):设计一个方法,用于返回任意整型数组的内容,要求返回的数组内容格式如 [11, 22, 33]。

分析过程(课件):

  1. 方法是否需要接收数据?——需要接收整型数组;
  2. 方法是否需要返回数据?——需要返回拼接后的结果;
  3. 方法内部:遍历数组的数据,把遍历到的数据都拼接起来,此时使用 StringBuilder 来完成拼接。
public class StringBuilderTest3 {
    public static void main(String[] args) {
        int[] arr = {11, 44, 33, 66};
        System.out.println(getArrayData(arr));   // [11, 44, 33, 66]
    }

    public static String getArrayData(int[] arr) {
        // 1、判断 arr 是否为 null
        if (arr == null) {
            return null;
        }
        // 2、创建 StringBuilder 对象
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        // 3、遍历数组中的内容
        for (int i = 0; i < arr.length; i++) {
            int data = arr[i];
            sb.append(data).append(i == arr.length - 1 ? "" : ", ");
        }
        sb.append("]");
        return sb.toString();
    }
}

课件第 9 页的写法是用 if (i == arr.length - 1) 分支处理最后一个元素不加分隔符,配套代码则用三元表达式把这两行合成一行,两种写法结果一致。

2. StringJoiner

2.1 为什么还要学 StringJoiner

第 9 页末尾提出的问题是:「既能高效,又能实现更方便的拼接?」——StringBuilder 已经很快,但拼接 [11, 22, 33] 这种带首尾符号、元素之间还要加分隔符的场景,需要自己写 if 判断最后一个元素,代码不够简洁。

StringJoiner(第 10 页):JDK8 开始才有的,跟 StringBuilder 一样,也是用来操作字符串的,也可以看成是一个容器,创建之后里面的内容是可变的。好处是:不仅能提高字符串的操作效率,并且在有些场景下使用它操作字符串,代码会更简洁。

2.2 构造器与常用方法

(表格按课件题目与配套代码整理,两类构造器的区别就是「有没有首尾符号」)

构造器说明
public StringJoiner(CharSequence delimiter)只用间隔符,如 ,
public StringJoiner(CharSequence delimiter, CharSequence prefix, CharSequence suffix)间隔符 + 开始符号 + 结束符号
方法说明
public StringJoiner add(CharSequence newElement)添加元素,返回容器本身,可链式调用
public int length()返回内容的字符数
public String toString()返回拼接好的字符串(包含首尾符号)

2.3 案例:用 StringJoiner 重写 1.5

配套代码 d2_stringjoiner/StringJoinerTest1.java:

import java.util.StringJoiner;

public class StringJoinerTest1 {
    public static void main(String[] args) {
        int[] arr = {11, 44, 33, 66};
        System.out.println(getArrayData(arr));   // [11, 44, 33, 66]
    }

    public static String getArrayData(int[] arr) {
        if (arr == null) {
            return null;
        }
        // 参数一:间隔符  参数二:开始符号  参数三:结束符号
        StringJoiner sb = new StringJoiner(", ", "[", "]");
        for (int i = 0; i < arr.length; i++) {
            sb.add(Integer.toString(arr[i]));
        }
        return sb.toString();
    }
}

对比 1.5 的 StringBuilder 版本,这里不再需要判断「是不是最后一个元素」,分隔符与首尾符号都由 StringJoiner 负责。

3. Math、System、Runtime

3.1 Math

Math 代表数学,是一个工具类,里面提供的都是对数据进行操作的一些静态方法。

(表格按课件题目与配套代码整理)

方法说明(含配套代码的运行结果)
public static int abs(int a) / double abs(double a)取绝对值,结果一定是正数。Math.abs(-12) → 12
public static double ceil(double a)向上取整。Math.ceil(4.0000001) → 5.0,Math.ceil(4.0) → 4.0
public static double floor(double a)向下取整。Math.floor(4.999999) → 4.0
public static long round(double a)四舍五入。Math.round(3.4999) → 3,Math.round(3.50001) → 4
public static int max(int a, int b) / min(int a, int b)取较大值 / 较小值
public static double pow(double a, double b)取次方。Math.pow(2, 3) → 8.0
public static double random()取随机数,范围 [0.0, 1.0)(包前不包后)

3.2 System

System 代表程序所在的系统,也是一个工具类。

(表格按课件题目与配套代码整理)

方法说明
public static void exit(int status)终止当前运行的 Java 虚拟机;按照惯例非零状态码表示异常终止,0 表示正常终止
public static long currentTimeMillis()返回从 1970-01-01 00:00:00 走到此刻的总毫秒值(1s = 1000ms)

配套代码 d3_math/SystemTest.java 用两个毫秒值之差做了一次性能统计:

long time = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
    System.out.println(i);
}
long time2 = System.currentTimeMillis();
System.out.println((time2 - time) / 1000.0 + "s");

(补充说明:配套代码中 exit(0) 被注释并标注「禁止使用」;另外测「经过时间」更常用 System.nanoTime(),课件未涉及。)

3.3 时间毫秒值与时区起点

第 15 页专门解释了时间毫秒值:指的是从 1970 年 1 月 1 日 00:00:00 走到此刻的总的毫秒数,应该是很大的(1s = 1000ms)。

为什么选择「1970 年 1 月 1 日 00:00:00」作为时间的起点?课件的说法是:1969 年 8 月,贝尔实验室的程序员肯·汤普逊(Ken Thompson)利用妻儿离开一个月的机会,开始着手创造一个全新的革命性的操作系统,他使用 B 编译语言在老旧的 PDP-7 机器上开发出了 Unix 的一个版本;随后汤普逊和同事丹尼斯·里奇(Dennis Ritchie)改进了 B 语言,开发出了 C 语言,重写了 UNIX。1970 年 1 月 1 日算 C 语言的生日,这个起点就沿用下来了。

作业里的相关追问是「那我们中国也是这个时间么」:起点本身是同一个 UTC 时刻,中国标准时间为世界标准时间 + 8 小时,所以这个瞬间在中国显示为 1970-01-01 08:00:00;但「毫秒值」这个数值是全球统一的,换时区只影响显示(详见 6.3 的时区部分)。

3.4 Runtime

Runtime 代表程序所在的运行环境,并且是一个单例类:对象不通过 new 创建,而是用 getRuntime() 获取。

(表格按课件题目与配套代码整理)

方法说明
public static Runtime getRuntime()返回与当前 Java 应用程序关联的运行时对象
public void exit(int status)终止当前运行的虚拟机
public int availableProcessors()获取虚拟机能够使用的处理器数
public long totalMemory()返回 JVM 中的内存总量(字节数)
public long freeMemory()返回 JVM 中的可用内存量(字节数)
public Process exec(String command)执行命令、启动程序,返回代表该程序的 Process 对象
Runtime jre = Runtime.getRuntime();
System.out.println("处理器数量:" + jre.availableProcessors());
System.out.println("Java虚拟机中的内存总量:" + jre.totalMemory() / 1024.0 / 1024.0 + "MB");
System.out.println("Java虚拟机中的可用内存量:" + jre.freeMemory() / 1024.0 / 1024.0 + "MB");

Process process = jre.exec("QQ");   // 返回的 Process 对象代表启动的 QQ 软件
// ... 需要时调用 process.destroy() 关闭该进程

4. BigDecimal

4.1 浮点型运算失真的问题

BigDecimal 用于解决浮点型运算时出现结果失真的问题。第 18 页的演示代码:

public static void main(String[] args) {
    // 浮点型运算时,直接 + - * / 可能会出现运算结果失真
    System.out.println(0.1 + 0.2);
    System.out.println(1.0 - 0.32);
    System.out.println(1.015 * 100);
    System.out.println(1.301 / 100);
}

浮点型运算失真的实际输出结果

对应输出为 0.30000000000000004、0.6799999999999999、101.49999999999999、0.013009999999999999——所以金额计算不能直接用 double。

4.2 构造器与常用方法

(表格按课件题目与配套代码整理;课件第 19 页单独标出了 BigDecimal(double val) 这个构造器)

构造器说明
public BigDecimal(String val)推荐:按字符串描述构造,能避免二进制浮点的近似细节
public BigDecimal(double val)课件列出的构造器,但传入 double 会带上近似值,一般不用于金额
public static BigDecimal valueOf(double val)更推荐的方式,等价于用 Double.toString 的结果构造
方法说明
public BigDecimal add(BigDecimal augend)加
public BigDecimal subtract(BigDecimal subtrahend)减
public BigDecimal multiply(BigDecimal multiplicand)乘
public BigDecimal divide(BigDecimal divisor)除(结果无法有限表示时会抛 ArithmeticException)
public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode)除并指定保留位数与舍入模式
public double doubleValue()把结果转回基本类型使用

第 20 页的问答正好是本节要点:

  1. BigDecimal 的作用是什么?——解决浮点型运算时出现结果失真的问题;
  2. 如何把浮点型转换成 BigDecimal 对象?——BigDecimal b1 = BigDecimal.valueOf(0.1);
  3. BigDecimal 提供了哪些常见方法?——见上表。
double a = 0.1;
double b = 0.2;

// 包装成 BigDecimal 对象
BigDecimal a1 = new BigDecimal(Double.toString(a));
BigDecimal b1 = new BigDecimal(Double.toString(b));
// 阿里巴巴的规范更推荐用 valueOf,与上面的做法本质一样
BigDecimal a11 = BigDecimal.valueOf(a);
BigDecimal b11 = BigDecimal.valueOf(b);

// 调用方法进行精度运算:add / subtract / multiply / divide
BigDecimal c11 = a11.add(b11);
double result = c11.doubleValue();   // BigDecimal 是手段,结果仍是基本类型
System.out.println(result);         // 0.3

4.3 除法必须交代精度与舍入模式

BigDecimal i = BigDecimal.valueOf(0.1);
BigDecimal j = BigDecimal.valueOf(0.3);
// 参数一:除数  参数二:保留位数  参数三:舍入模式
BigDecimal k = i.divide(j, 2, RoundingMode.HALF_UP);
System.out.println(k);   // 0.33

课件作业中的「元转分再转元」用的是旧写法 divide(money2, 2, BigDecimal.ROUND_DOWN),参数含义相同(保留 2 位、直接舍去多余位数),只是 RoundingMode 常量在 JDK 9 之后更推荐使用。

5. JDK 8 之前:Date、SimpleDateFormat、Calendar

5.1 Date

Date 代表的是日期和时间。

(表格按课件第 22、23 页的题目整理)

构造器说明
public Date()创建表示当前日期时间的对象
public Date(long time)用时间毫秒值创建日期对象
方法说明
public long getTime()获取该日期对象对应的时间毫秒值
public void setTime(long time)把时间毫秒值设置到该日期对象上

第 23 页的两个问答也就是上表内容:日期对象如何创建、如何获取时间毫秒值(Date()、getTime());时间毫秒值怎么转成日期对象(Date(long time)、setTime(long time))。

public static void main(String[] args) {
    // 1、创建当前日期时间对象
    Date d = new Date();
    System.out.println(d);

    // 2、得到当前时间的毫秒值
    long time = d.getTime();
    System.out.println(time);
}

Date 对象直接打印的结果与当前时间毫秒值

打印结果形如 Tue Jan 04 12:39:17 CST 2022 和 164127157743——这也正是下一节要学 SimpleDateFormat 的原因。

5.2 SimpleDateFormat

SimpleDateFormat 代表简单日期格式化,可以用来把日期对象、时间毫秒值格式化成我们想要的形式,也可以把字符串的时间形式解析成 Date 日期对象。

先看「为什么需要它」:业务系统里给用户看的时间都是有固定格式的,而不是 Date.toString() 的结果。

电商订单列表中的下单时间(格式化后的日期时间)

12306 出发日期输入框,录入的是 yyyy-MM-dd 形式的字符串时间

(表格按课件第 27、30 页的题目整理)

构造器说明
public SimpleDateFormat(String pattern)用指定的格式字符串创建格式化对象
方法说明
public final String format(Date d)格式化日期对象
public final String format(Object time)格式化时间毫秒值
public Date parse(String source)解析字符串时间,得到 Date 对象(需处理 ParseException)

时间格式的常见符号(第 28 页):

符号含义符号含义
y年m分
M月s秒
d日EEE星期几
H时a上午 / 下午

同一页给出的格式示例:yyyy-MM-dd HH:mm:ss → 2020-11-11 13:27:06;yyyy年MM月dd日 HH:mm:ss → 2020年11月11日 13:27:06。

格式化与解析的双向流程可以概括为:

Date 对象 / 时间毫秒值  --format-->  2099-11-12 11:11:11
"2022-12-12 12:30:30"  --parse--->  Date 日期对象
// 格式化:日期对象 → 字符串
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String s = sdf.format(new Date());

// 解析:字符串 → 日期对象(格式必须与字符串完全一致)
SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年MM月dd日");
Date date = sdf2.parse("2023年11月11日");

5.3 案例:能不能参加上秒杀活动

需求(第 31 页):

  • 活动方:秒杀开始时间 2023 年 11 月 11 日 0:00:00,结束时间 0:10:00;
  • 小贾下单并付款的时间为:2023 年 11 月 11 日 0:01:18;
  • 小皮下单并付款的时间为:2023 年 11 月 11 日 0:10:51。

秒杀活动的开始时间与结束时间

思路:把三个时间都转成毫秒值再比较,落在活动区间内才算参加上。

public static void main(String[] args) throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
    long start = sdf.parse("2023年11月11日 0:00:00").getTime();
    long end = sdf.parse("2023年11月11日 0:10:00").getTime();

    long jia = sdf.parse("2023年11月11日 0:01:18").getTime();  // 小贾
    long pi = sdf.parse("2023年11月11日 0:10:51").getTime();   // 小皮

    System.out.println(jia >= start && jia <= end);  // true:小贾参加上了
    System.out.println(pi >= start && pi <= end);    // false:小皮超出结束时间
}

5.4 Calendar

第 33、34 页先摆出一个需求:将 2023 年 09 月 10 日增加一个月。如果沿用前面的思路,就会变成:把字符串解析成 Date → 取毫秒值 → 加上「一个月的毫秒值 1000 * 60 * 60 * 24 * 30」→ 再格式化。课件用这个做法反衬出问题:不同月份天数不同,「一个月」并不能用一个固定毫秒数表示(3 月与 4 月、闰年 2 月都会算错)。这就是要学 Calendar 的理由。

第 35、36 页:Calendar 代表的是系统此刻时间对应的日历,通过它可以单独获取、修改时间中的年、月、日、时、分、秒等。

(表格按课件第 36、37 页的题目与题目文档整理)

方法说明
public static Calendar getInstance()获取当前日历对象
public int get(int field)获取某个日历字段的值,如 Calendar.DATE
public void set(int field, int value)设置某个日历字段
public final void set(int year, int month, int date)一次设置年、月、日
public abstract void add(int field, int amount)让某个字段增加 / 减少指定量
public final Date getTime()把日历对象转换成 Date 对象

注意:Calendar 是可变对象,一旦修改后其对象本身表示的时间将产生变化。

Calendar calendar = Calendar.getInstance();
calendar.set(2023, 8, 10);          // 月份从 0 开始:8 表示 9 月
calendar.add(Calendar.MONTH, 1);    // 增加一个月:月份天数由日历自己处理
Date date = calendar.getTime();
System.out.println(new SimpleDateFormat("yyyy年MM月dd日").format(date));  // 2023年10月10日

配套题目还把「月份从 0 开始」这一点用在了求 2 月天数上:把日期设为「用户输入年份的 3 月 1 日」(即 set(year, 2, 1)),再 add(Calendar.DATE, -1) 退一天,get(Calendar.DATE) 得到的就是 2 月的最后一天,等于 28 即为平年,否则为闰年。

6. JDK 8 开始新增的日期、时间

6.1 类总览与学习路线

第 39 页把 java.time 包下的类一次列全:

类表达含义
LocalDate年、月、日
LocalTime时、分、秒
LocalDateTime年、月、日 + 时、分、秒
ZoneId时区
ZonedDateTime带时区的时间
Instant时间戳 / 时间线
DateTimeFormatter用于时间的格式化和解析
Duration时间间隔(时、分、秒,纳秒)
Period时间间隔(年、月、日)

IDEA 中输入 java.time. 时列出的时间类

课件的章节页用 STEP 1 → STEP 4 标注了学习顺序:LocalDate / LocalTime / LocalDateTime → ZoneId / ZonedDateTime → Instant → DateTimeFormatter → Duration / Period。本节也按这个顺序展开。

6.2 LocalDate、LocalTime、LocalDateTime

三者都是不可变对象(withXxx、plusXxx 等方法返回新对象,原对象不变):

  • LocalDate:代表本地日期(年、月、日、星期);
  • LocalTime:代表本地时间(时、分、秒、纳秒);
  • LocalDateTime:代表本地日期、时间(年、月、日、星期、时、分、秒、纳秒),是三个类里最重要的一个。

获取对象的方案(表格按配套代码整理):

方法说明
public static LocalDate now() / LocalTime.now() / LocalDateTime.now()获取当前系统时间对应的对象
public static LocalDate of(int year, int month, int dayOfMonth)获取指定日期的对象
public static LocalTime of(int hour, int minute, int second)获取指定时间的对象
public static LocalDateTime of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond)获取指定日期时间的对象

LocalDate 常用 API(处理年、月、日、星期):

方法说明
getYear() / getMonthValue() / getDayOfMonth() / getDayOfYear()年 / 月 / 日 / 一年中的第几天
getDayOfWeek().getValue()星期几(1 = 周一 … 7 = 周日)
withYear(int) / withMonth(int) / withDayOfMonth(int) / withDayOfYear(int)直接修改某个信息,返回新对象
plusYears(long) / plusMonths(long) / plusWeeks(long) / plusDays(long)把某个信息加多少
minusYears(long) / minusMonths(long) / minusWeeks(long) / minusDays(long)把某个信息减多少
equals(Object) / isBefore(LocalDate) / isAfter(LocalDate)判断两个日期对象是否相等、在前还是在后

LocalTime 常用 API(处理时、分、秒、纳秒):方法名与上表一一对应,把「年/月/日/周」换成 getHour() / getMinute() / getSecond() / getNano(),withHour/withMinute/withSecond/withNano,plusHours/plusMinutes/plusSeconds/plusNanos,minusHours/minusMinutes/minusSeconds/minusNanos,比较同样是 equals / isBefore / isAfter。

LocalDateTime 常用 API:可以处理年、月、日、星期、时、分、秒、纳秒等全部信息,即上面两张表的方法它基本都有(getYear … getNano、withXxx、plusXxx、minusXxx、equals/isBefore/isAfter)。

LocalDate ld = LocalDate.now();
LocalDate ld2 = ld.withYear(2099);      // 返回新对象,ld 本身不变
LocalDate ld3 = ld.plusMonths(2);       // 加两个月
LocalDate ld8 = LocalDate.of(2099, 12, 12);
System.out.println(ld8.isAfter(ld));    // true

LocalDateTime ldt = LocalDateTime.now();
System.out.println(ldt.getDayOfWeek().getValue());  // 星期几
System.out.println(ldt.getNano());                  // 纳秒

LocalDateTime 与 LocalDate / LocalTime 之间的转换:

方法说明
public LocalDate toLocalDate()取日期部分
public LocalTime toLocalTime()取时间部分
public static LocalDateTime of(LocalDate date, LocalTime time)由日期与时间合成
LocalDate ld = ldt.toLocalDate();              // 合久必分
LocalTime lt = ldt.toLocalTime();
LocalDateTime ldt10 = LocalDateTime.of(ld, lt); // 分久必合

6.3 ZoneId 与 ZonedDateTime

由于世界各个国家与地区的经度不同,各地区的时间也有所不同,因此会划分为不同的时区。ZoneId 代表时区 Id,写法形如 Asia/Shanghai、Asia/Chongqing、America/New_York(一般是「洲名/城市名」或「国家名/城市名」)。中国标准时间 = 世界标准时间(UTC)+ 8 小时。

世界时区划分与时差对照图

ZoneId 的常见方法:

方法说明
public static ZoneId systemDefault()获取系统默认的时区
public static Set<String> getAvailableZoneIds()获取 Java 支持的全部时区 Id
public static ZoneId of(String zoneId)把某个时区 Id 封装成 ZoneId 对象

ZonedDateTime 的常见方法:

方法说明
public static ZonedDateTime now()获取系统默认时区的带时区时间对象
public static ZonedDateTime now(ZoneId zone)获取某个时区的带时区时间对象
其余 getXxx / withXxx / plusXxx / minusXxx功能与 LocalDateTime 一样,只是多带时区信息
ZoneId zoneId = ZoneId.systemDefault();
System.out.println(zoneId.getId());            // Asia/Shanghai

ZoneId an = ZoneId.of("America/New_York");
ZonedDateTime zdt = ZonedDateTime.now(an);

// 很多服务器需要世界标准时间
ZonedDateTime utcNow = ZonedDateTime.now(Clock.systemUTC());

6.4 Instant

通过获取 Instant 的对象可以拿到此刻的时间,该时间由两部分组成:从 1970-01-01 00:00:00 开始走到此刻的总秒数 + 不够 1 秒的纳秒数。

课件给的换算关系:1 秒 = 1000 毫秒;1 毫秒 = 1000 微秒;1 微秒 = 1000 纳秒;1 秒 = 1 000 000 000 纳秒。

方法说明
public static Instant now()获取此刻的 Instant 对象
public long getEpochSecond()从 1970-01-01 00:00:00 走到此刻的总秒数
public int getNano()不够 1 秒的纳秒数

传统 Date 类只能精确到毫秒,并且是可变对象;新增的 Instant 类可以精确到纳秒,并且是不可变对象,课件建议用 Instant 代替 Date。它的作用是:用来记录代码的执行时间,或用于记录用户操作某个事件的时间点。

Instant now = Instant.now();
System.out.println(now.getEpochSecond());   // 例如 1663253222
System.out.println(now.getNano());          // 例如 63411500

6.5 DateTimeFormatter

DateTimeFormatter 是格式化器,用于时间的格式化和解析,作用与 SimpleDateFormat 对应,但是不可变且线程安全。

方法说明
public static DateTimeFormatter ofPattern(String pattern)创建格式化器,格式符号与 SimpleDateFormat 相同
public String format(TemporalAccessor temporal)把日期时间对象格式化成字符串

LocalDateTime 提供的格式化、解析时间的方法:

方法说明
public String format(DateTimeFormatter formatter)用格式化器格式化
public static LocalDateTime parse(CharSequence text, DateTimeFormatter formatter)用格式化器把字符串解析成 LocalDateTime 对象
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss EEE a");
LocalDateTime ldt = LocalDateTime.now();

String result = dtf.format(ldt);       // 方式一
String result2 = ldt.format(dtf);      // 方式二,效果相同

// 解析:格式化器的格式必须与待解析字符串一模一样
DateTimeFormatter dtf2 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime ldt2 = LocalDateTime.parse("2023-11-11 11:11:11", dtf2);

6.6 Period 与 Duration

Period(一段时期)可以用于计算两个 LocalDate 对象相差的年数、月数、天数:

方法说明
public static Period between(LocalDate startDateInclusive, LocalDate endDateExclusive)得到两个日期之间的 Period 对象
getYears() / getMonths() / getDays()相差的年数 / 月数 / 天数
LocalDate start = LocalDate.of(2024, 3, 19);
LocalDate end = LocalDate.of(2024, 10, 13);

Period period = Period.between(start, end);
System.out.println(period.getYears());   // 0
System.out.println(period.getMonths());  // 6
System.out.println(period.getDays());    // 24

Duration(持续时间)可以用于计算两个时间对象相差的天数、小时数、分数、秒数、纳秒数,支持 LocalTime、LocalDateTime、Instant 等时间:

方法说明
public static Duration between(Temporal startInclusive, Temporal endExclusive)得到两个时间之间的 Duration 对象
toDays() / toHours() / toMinutes() / toSeconds() / toMillis() / toNanos()换算成间隔的天数 / 小时 / 分 / 秒 / 毫秒 / 纳秒
LocalDateTime start = LocalDateTime.of(2025, 11, 11, 11, 10, 10);
LocalDateTime end = LocalDateTime.of(2025, 11, 11, 11, 11, 11);

Duration duration = Duration.between(start, end);
System.out.println(duration.toDays());       // 0
System.out.println(duration.toHours());      // 0
System.out.println(duration.toMinutes());    // 1
System.out.println(duration.toSeconds());    // 61
System.out.println(duration.toMillis());     // 61000
System.out.println(duration.toNanos());      // 61000000000

(补充说明:配套代码 Test9.java、Test10.java 还演示了 ChronoUnit.YEARS.between(...) 这类「按单位求间隔」的写法,以及 Duration.toHoursPart() / toMinutesPart() / toSecondsPart() 这类「只取某一段」的写法(高考倒计时案例),课件正文未展开。)

7. 作业与练习

  • 作业文档:day04-常用API/day04作业/作业04/day04作业.md
  • 题目与参考答案目录(可参考):day04-常用API/day04作业/03_题目/day04.md

作业要点:

  1. 单词:字符串拼接类的单词(StringBuilder / StringBuffer / StringJoiner)、精确计算浮点数类的单词(BigDecimal)、Java 新时间各个类的单词。
  2. 简答:StringBuilder 有何作用、与 StringBuffer 的区别;为什么 Java 时间零点是「1970-01-01 00:00:00」、中国是不是这个时间;LocalDate、ZoneId、ZonedDateTime、Instant、DateTimeFormatter、Period、Duration 各自的作用。
  3. 代码题一(BigDecimal):手机 3999 元、钢化膜 9.9 元、满 4000 打 98 折,买一台手机和两块膜应付多少(精确到分)。注意先把总价算出来再判断门槛、再折扣,最后统一按分舍入。
  4. 代码题二(BigDecimal):给出让 0.1 + 0.2 精确输出 0.2(即按精度舍入)的代码。
  5. 代码题三(Date + SimpleDateFormat):补全素材 seckill 项目,实现秒杀倒计时(开始时间为「明天 06:00:00」);提示是构造方法里初始化开始时间,run() 里取当前时间减去开始时间得到毫秒值,再换算成时、分、秒。
  6. 代码题四(Date + Calendar):"2015-10-20" → 日期对象 → 日历对象;求出是星期几、这一年的第几天;支持键盘录入,输出形如「2015年-10月-20日 是 星期二,是2015年的第 293 天」。
  7. 选做(JDK 8 时间):孙工上三天休一天(首次休息日 2020-02-01),输入年、月,用日历方式打印该月休息日(中括号标记),并统计本月休息天数、轮到周末休息的天数。提示用 LocalDate 找月最大天数,再算与首次休息日的间隔天数。

题目文档中还额外出现了几处本日 API 的组合用法:Calendar.set/add/get 求 2 月天数与闰年判断、System.arraycopy 复制旧数组 + Arrays.sort 让新商品价格重新排序、Math.abs/ceil 统计区间内符合条件的整数个数、BigDecimal 的元转分与分转元(multiply 乘 100、divide 保留 2 位)、SimpleDateFormat.parse + System.currentTimeMillis() 计算入职年限。

8. 复习要点

  1. String 不可变,频繁拼接会产生大量无用对象;StringBuilder 是内容可变的容器,适合频繁拼接与修改;少量拼接或只是定义字符串变量时仍用 String,不要机械替换。
  2. StringBuilder 与 StringBuffer 用法完全一样,区别只在线程安全:StringBuilder 线程不安全,StringBuffer 线程安全。
  3. StringBuilder.append() 返回容器本身,可以链式调用;最终结果要用 toString() 转回 String。
  4. StringJoiner 是 JDK8 新增的可变容器,构造时给定「间隔符 +(可选)开始符号与结束符号」,省掉手写最后一个元素判断。
  5. Math 是静态工具类:abs、ceil、floor、round、max/min、pow、random([0.0, 1.0))。
  6. System.currentTimeMillis() 返回从 1970-01-01 00:00:00 起的总毫秒值;起点是 C 语言(Unix)沿用下来的纪元,中国标准时间 = UTC + 8 小时,毫秒值本身全球统一。
  7. Runtime 是单例类,用 Runtime.getRuntime() 获取;常用方法有 exit、availableProcessors、totalMemory、freeMemory、exec。
  8. 浮点运算会失真,金额用 BigDecimal,优先 new BigDecimal("0.1") 或 BigDecimal.valueOf(0.1);四则运算返回新对象,除法必须交代精度与舍入模式。
  9. JDK8 之前:Date 表示时间点(getTime / setTime 在毫秒值之间转换)、SimpleDateFormat 负责格式化与解析(y M d H m s EEE a)、Calendar 负责按字段取改日期(月份从 0 开始,且 Java 对象可变)。用固定毫秒数表示「一个月」是不可靠的。
  10. JDK8 新增 java.time 系列都是不可变对象:LocalDate / LocalTime / LocalDateTime 处理本地日期时间,ZoneId / ZonedDateTime 处理时区,Instant 精确到纳秒表示时间线上的时刻,DateTimeFormatter 负责格式化与解析,Period 求日期差(年/月/日),Duration 求时间差(天/时/分/秒/纳秒)。
  11. 课件特别建议:需要时间戳时用不可变且精确到纳秒的 Instant 代替 Date。

day05|java.lang 包下的常用 API、算法、正则表达式

本日从「数组怎么操作」出发,先用 Arrays 工具类收口数组的打印、拷贝、改写与排序,再顺着「对象数组怎么排序」引出 JDK8 的两大语法糖:Lambda 表达式与方法引用;后半程换到算法视角,用冒泡排序、选择排序、顺序查找、二分查找四个例子讲「先理清流程,再推敲代码」;最后是正则表达式,解决数据格式校验、文本内容爬取、搜索替换与分割三类问题。学完后应能回答:Lambda 到底简化了什么、什么条件下才能写成 类名::方法、二分查找为什么必须有序、matches / find / replaceAll / split 分别在什么场景用。

本日 PPT 目录(第 2、7、13、15、23、33、46 页反复出现的导航条):Arrays → JDK8 新特性:Lambda 表达式(认识 / 简化例子 / 省略规则) → JDK8 新特性:方法引用(静态方法引用、实例方法引用、特定类型方法引用、构造器引用) → 常见算法(简单认识算法、排序算法、查找算法) → 正则表达式(概述与初体验、书写规则、应用案例、用于查找信息、用于搜索替换与分割内容)。

本日不涉及异常:课件文件名里带了「异常」二字,但整份 PPT(61 页)没有任何异常相关知识页,第 60、61 页是结束动画,异常内容在 day06 展开。

1. Arrays 工具类

1.1 概述与常见方法

Arrays 是「用来操作数组的一个工具类」(第 3 页)。课件给出的常见方法如下(课件表格中 copyOf、setAll 的返回类型省略,这里按配套代码 d1_array/ArraysDemo1.java 补全):

方法名说明
public static String toString(类型[] arr)返回数组的内容
public static int[] copyOfRange(类型[] arr, 起始索引, 结束索引)拷贝数组(指定范围)
public static 类型[] copyOf(类型[] arr, int newLength)拷贝数组
public static void setAll(double[] array, IntToDoubleFunction generator)把数组中的原数据改为新数据
public static void sort(类型[] arr)对数组进行排序(默认是升序排序)
public static <T> void sort(T[] arr, Comparator<? super T> c)对数组进行排序(支持自定义排序规则)(第 5 页)

配套代码把这几个方法串了一遍:

public class ArraysDemo1 {
    public static void main(String[] args) {
        // 1、返回数组内容
        int[] arr = {11, 55, 33, 22, 98};
        System.out.println(Arrays.toString(arr));

        // 2、拷贝数组的指定范围(包前不包后)
        int[] arr2 = Arrays.copyOfRange(arr, 1, 4);
        System.out.println(Arrays.toString(arr2));

        // 3、给数组扩容:新长度大于原长度时后面补默认值
        int[] arr3 = Arrays.copyOf(arr, 10);
        System.out.println(Arrays.toString(arr3));

        // 4、修改数组中每个数据,再存入
        double[] scores = {99.5, 90, 59.5, 78, 98, 55};
        Arrays.setAll(scores, new IntToDoubleFunction() {
            @Override
            public double applyAsDouble(int index) {
                return scores[index] + 10;
            }
        });
        System.out.println(Arrays.toString(scores));

        // 5、排序(默认升序)
        Arrays.sort(scores);
        System.out.println(Arrays.toString(scores));
    }
}

1.2 演示:数值数组的排序

第 4 页用「对数组中的数据进行排序」引出 Arrays.sort():

double[] prices = {99.8, 128, 100};
Arrays.sort(prices);
System.out.println(Arrays.toString(prices));

Arrays.sort(double[]) 后打印的结果

1.3 对象数组怎么排序:两种方式

紧接着课件提问:「如果数组中存储的是对象,如何排序?」(第 5 页)以 Student 对象数组为例,给出了两条路:

Student[] students = new Student[4];
students[0] = new Student("蜘蛛精", 169.5, 23);
students[1] = new Student("紫霞", 163.8, 26);
students[2] = new Student("紫霞", 163.8, 26);
students[3] = new Student("至尊宝", 167.5, 24);
方式做法调用
方式一让该对象的类实现 Comparable(比较规则)接口,然后重写 compareTo() 方法,自己来制定比较规则Arrays.sort(students);
方式二使用带比较器的 sort 重载方法,创建 Comparator 比较器接口的匿名内部类对象,自己制定比较规则Arrays.sort(students, new Comparator<Student>() { ... });
// 方式一:类实现 Comparable,重写 compareTo()
public class Student implements Comparable<Student> {
    // ... 属性与构造器省略 ...

    // 比较者:this;被比较者:o
    @Override
    public int compareTo(Student o) {
        // return this.age - o.age; // 升序
        return o.age - this.age;     // 降序
    }
}
// 方式二:sort 重载方法 + Comparator 匿名内部类(课件标注:优先使用这种)
Arrays.sort(students, new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return Double.compare(o1.getHeight(), o2.getHeight());
    }
});

1.4 比较规则的官方约定

第 6 页给出「自定义排序规则时需要遵循的官方约定」:

判断返回值
认为左边对象 大于 右边对象返回正整数
认为左边对象 小于 右边对象返回负整数
认为左边对象 等于 右边对象返回 0

按这个约定返回,Arrays.sort() 默认就是升序;想要降序,把 this 与 o(或 o1 与 o2)的运算顺序对调即可,例如 o2.age - o1.age。

配套代码 d1_array/Student.java 的注释把这一点说得很直白:「只要这么干,默认就是升序排序」。浮点字段不能直接相减,要用 Double.compare(a, b) 来产生正负整数。

(课件表格笔误:第 6 页第三行写成「应该返回 0 整数」,应为「返回 0」。)

2. JDK8 新特性:Lambda 表达式

2.1 认识 Lambda 表达式

第 8 页的结论:Lambda 表达式是 JDK 8 开始新增的一种语法形式,作用是简化匿名内部类的代码写法。

它简化的是函数式接口的匿名内部类:

  • 什么是函数式接口?有且仅有一个抽象方法的接口。
  • 格式:
(被重写方法的形参列表) -> {
    被重写方法的方法体代码。
}
  • 注意:将来见到的大部分函数式接口,上面都可能会有一个 @FunctionalInterface 注解,有该注解的接口就必定是函数式接口。
  • 注意:Lambda 表达式只能简化函数式接口的匿名内部类(第 9 页小结:作用 = 简化函数式接口的匿名内部类;函数式接口 = 只有一个抽象方法的接口)。

配合代码的写法是:用 @FunctionalInterface 标注接口,再用 Lambda 提供实现。

@FunctionalInterface // 函数式接口中有且仅有一个抽象方法
interface Swimming {
    void swim();
}

public class LambdaTest1 {
    public static void main(String[] args) {
        // 匿名内部类写法
        Swimming s1 = new Swimming() {
            @Override
            public void swim() {
                System.out.println("学生游泳贼溜");
            }
        };
        s1.swim();

        // Lambda 写法:可以上下文推断出真实的代码形式
        Swimming s2 = () -> {
            System.out.println("老师游泳贼溜");
        };
        s2.swim();
    }
}

(配套代码 d2_lambda/LambdaTest1.java 还演示了一个错误示范:把一个抽象类 Animal 的匿名内部类改写成 Lambda 会编译失败,因为抽象类不是函数式接口。)

2.2 例子一:简化 Arrays.setAll() 的匿名内部类

第 11 页的需求是给分数打八折:

double[] scores = {99.8, 128.0, 100.0};

// 匿名内部类写法
Arrays.setAll(scores, new IntToDoubleFunction() {
    @Override
    public double applyAsDouble(int value) {
        return scores[value] * 0.8;
    }
});
System.out.println(Arrays.toString(scores));

// Lambda 写法
Arrays.setAll(scores, (int value) -> {
    return scores[value] * 0.8;
});

IntToDoubleFunction 只有一个抽象方法 applyAsDouble(int value),所以可以直接用 Lambda 顶替匿名内部类。

2.3 例子二:简化 Comparator 接口的匿名形式

第 12 页沿用 Student 数组:

public static void main(String[] args) {
    // ...

    // 匿名内部类写法
    Arrays.sort(students, new Comparator<Student>() {
        @Override
        public int compare(Student s1, Student s2) {
            return s1.getAge() - s2.getAge();
        }
    });

    // Lambda 写法
    Arrays.sort(students, (Student s1, Student s2) -> {
        return s1.getAge() - s2.getAge();
    });
}

(课件文字里年龄相减的减号被写成了长破折号「–」,实际代码应为半角减号 -。)

2.4 省略规则

第 14 页给出「省略写法(进一步简化 Lambda 表达式的写法)」,共三条:

  1. 参数类型可以省略不写。
  2. 如果只有一个参数,参数类型可以省略,同时 () 也可以省略。
  3. 如果 Lambda 表达式中的方法体代码只有一行,可以省略大括号不写,同时要省略分号!此时,如果这行代码是 return 语句,也必须去掉 return 不写。

配套代码 d2_lambda/LambdaTest2.java 用 setAll 和 sort 各演示了一遍逐级简化的过程:

// 从完整写法开始,一路简化到最短形式
Arrays.setAll(scores, (int index) -> {
    return scores[index] + 10;
});
Arrays.setAll(scores, (index) -> {
    return scores[index] + 10;
});
Arrays.setAll(scores, index -> {
    return scores[index] + 10;
});
Arrays.setAll(scores, index -> scores[index] + 10);   // 省略 {}、; 与 return
Arrays.sort(students, (Student o1, Student o2) -> {
    return Double.compare(o1.getHeight(), o2.getHeight());
});
Arrays.sort(students, (o1, o2) -> {
    return Double.compare(o1.getHeight(), o2.getHeight());
});
Arrays.sort(students, (o1, o2) -> Double.compare(o1.getHeight(), o2.getHeight()));

3. JDK8 新特性:方法引用

方法引用的标志性符号是 ::(第 15 页配图文字:「方法引用的标志性符号 ::」)。只有当 Lambda 表达式「只是调用一个已有方法」时,才可以进一步改写成方法引用——四种形式的判断标准,课件总结为「参数形式一致」。

3.1 静态方法引用

  • 格式:类名::静态方法。
  • 使用场景:如果某个 Lambda 表达式里只是调用一个静态方法,并且前后参数的形式一致,就可以使用静态方法引用。
// 目标:理解静态方法引用
Student[] students = new Student[4];
students[0] = new Student("周芷若", 21, '女', 169.3);
students[1] = new Student("殷素素", 38, '女', 172.2);
students[2] = new Student("小昭", 19, '女', 168.5);
students[3] = new Student("张无忌", 23, '男', 183.5);

// 1、完全自定义比较规则
Arrays.sort(students, (o1, o2) -> Double.compare(o1.getHeight(), o2.getHeight()));
// 2、Lambda 里只调用了一个静态方法
Arrays.sort(students, (o1, o2) -> Student.compareByHeight(o1, o2));
// 3、静态方法引用(终极简化代码)
Arrays.sort(students, Student::compareByHeight);

System.out.println(Arrays.toString(students));

其中 Student.compareByHeight 就是类里定义的一个静态方法:

public static int compareByHeight(Student o1, Student o2) {
    return Double.compare(o1.getHeight(), o2.getHeight());
}

3.2 实例方法引用

  • 格式:对象名::实例方法。
  • 使用场景:如果某个 Lambda 表达式里只是调用一个实例方法,并且前后参数的形式一致,就可以使用实例方法引用。
public class Test2 {
    public static void main(String[] args) {
        // ... 准备 students 数组,省略 ...

        Test2 t = new Test2();
        // 简化前:Arrays.sort(students, (o1, o2) -> t.compare(o1, o2));
        Arrays.sort(students, t::compare);
    }

    public int compare(Student o1, Student o2) {
        return Double.compare(o1.getHeight(), o2.getHeight());
    }
}

注意与静态方法引用的区别:这里被调用的方法属于某个对象,所以引用的是「对象名」而不是「类名」。

3.3 特定类型的方法引用

  • 格式:类型::方法。
  • 使用场景:如果某个 Lambda 表达式里只是调用一个实例方法,并且前面参数列表中的第一个参数是作为方法的主调,后面的所有参数都是作为该实例方法的入参,则此时就可以使用特定类型的方法引用。
String[] names = {"dlei", "Angela", "baby", "caocao", "Coach", "曹操", "deby", "eason", "andy"};

// 1、匿名内部类
Arrays.sort(names, new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return o1.compareToIgnoreCase(o2);   // o1 是主调,o2 是入参
    }
});
// 2、Lambda
Arrays.sort(names, (o1, o2) -> o1.compareToIgnoreCase(o2));
// 3、特定类型的方法引用(忽略大小写排序)
Arrays.sort(names, String::compareToIgnoreCase);

System.out.println(Arrays.toString(names));

这里 o1.compareToIgnoreCase(o2) 中 o1 恰好是第一个参数,类型是 String,所以可以写成 String::compareToIgnoreCase。

3.4 构造器引用

  • 格式:类名::new。
  • 使用场景:如果某个 Lambda 表达式里只是在创建对象,并且前后参数情况一致,就可以使用构造器引用。
public class Test4 {
    public static void main(String[] args) {
        // 匿名内部类写法:new Create() { public Car createCar(String name) { return new Car(name); } }
        // Lambda 写法:Create c1 = name -> new Car(name);
        // 构造器引用
        Create c1 = Car::new;
        Car car = c1.createCar("布加迪威龙");
        System.out.println(car);
    }
}

@FunctionalInterface // 函数式接口
interface Create {
    Car createCar(String name);
}

3.5 四种方法引用对照

形式写法使用场景(课件表述)
静态方法引用类名::静态方法Lambda 里只是调用一个静态方法,并且前后参数的形式一致
实例方法引用对象名::实例方法Lambda 里只是调用一个实例方法,并且前后参数的形式一致
特定类型的方法引用类型::方法Lambda 里只是调用一个实例方法,且第一个参数作为方法的主调,后面的参数都是该实例方法的入参
构造器引用类名::newLambda 里只是在创建对象,并且前后参数情况一致

4. 常见算法

4.1 简单认识算法

第 24 页的配图文字给出了三个递进的提问与回答:

  • 为什么要学习算法? 因为算法能力是编程思维的一部分,生活中到处是算法:地图导航的路线规划、网约车的派单与预计时间、直播间观众列表的排序,背后都是算法在算。
  • 什么是算法? 算法就是解决某个实际问题的过程和方法。

为什么学算法 · 导航路线规划

为什么学算法 · 打车路线与时间预估

为什么学算法 · 观众列表的排序规则

为什么学算法 · 编程思维对应的工程场景

第 29 页给出学习算法的技巧:

  1. 先搞清楚算法的流程。
  2. 直接去推敲如何写代码。

4.2 排序算法:升序与降序

第 26~28 页用同一个数组演示了「排序 → 升序排序 → 降序排序」:

7   23   79   81   103   127   131   147
0    1    2    3     4     5     6     7
  • 排序:把一组数据按某个规则排列。
  • 升序排序:由小到大。
  • 降序排序:由大到小。

4.3 冒泡排序

第 30 页的定位是:「每次从数组中找出最大值放在数组的后面去。」(更准确的表述:冒泡排序通过相邻两个元素两两比较,把本轮的最大值一步步交换到末尾。)

实现冒泡排序的关键步骤分析:

  • 确定总共需要做几轮:数组的长度 - 1。
  • 每轮比较几次:当前位置大于后一个位置则交换数据,次数规律为 数组的长度 - i - 1。
i(第几轮)比较位次数
00、1、23 次
10、12 次
201 次

以 int[] arr = {5, 2, 3, 1}; 为例,逐轮过程(课件第 30 页用「第一轮 / 第二轮 / 第三轮」三组表格配合动画演示,下表按算法流程与配套代码整理):

轮数 ij 的取值本轮比较与交换本轮结束后数组
第 1 轮0、1、25>2 交换 → [2,5,3,1];5>3 交换 → [2,3,5,1];5>1 交换 → [2,3,1,5][2, 3, 1, 5]
第 2 轮0、12<3 不动;3>1 交换 → [2,1,3,5][2, 1, 3, 5]
第 3 轮02>1 交换 → [1,2,3,5][1, 2, 3, 5]

代码实现(配套代码 d4_sf/Demo1.java):

public class Demo1 {
    public static void main(String[] args) {
        int[] arr = {5, 2, 3, 1};
        //           0  1  2  3

        // 1、外层循环控制冒几轮
        for (int i = 0; i < arr.length - 1; i++) {
            // 轮数(i)  每轮的次数   j 的取值
            // 第一轮 0     3       0 1 2
            // 第二轮 1     2       0 1
            // 第三轮 2     1       0

            // 2、内层循环控制每轮比较几次
            for (int j = 0; j < arr.length - i - 1; j++) {
                // 3、当前位置大于后一个位置则交换
                if (arr[j] > arr[j + 1]) {
                    int temp = arr[j + 1];
                    arr[j + 1] = arr[j];
                    arr[j] = temp;
                }
            }
        }
        System.out.println(Arrays.toString(arr));   // [1, 2, 3, 5]
    }
}

4.4 选择排序

第 32 页:每轮选择当前位置,开始找出后面的较小值与该位置交换。

选择排序的关键:

  • 确定总共需要选择几轮:数组的长度 - 1。
  • 控制每轮以当前位置为基准,与后面元素选择几次。
i(第几轮)比较位次数
01、2、33 次
12、32 次
231 次

以 int[] arr = {5, 1, 3, 2}; 为例(课件同样用三组表格 + 动画演示每轮结束后的数组状态,下表按算法流程与配套代码整理):

轮数 ij 的取值本轮比较与交换本轮结束后数组
第 1 轮1、2、31<5 交换 → [1,5,3,2];3<1 不换;2<1 不换[1, 5, 3, 2]
第 2 轮2、33<5 交换 → [1,3,5,2];2<3 交换 → [1,2,5,3][1, 2, 5, 3]
第 3 轮33<5 交换 → [1,2,3,5][1, 2, 3, 5]

写法一:一发现更小值就立刻交换(配套代码 d4_sf/Demo2.java)。

int[] arr = {5, 1, 3, 2};
//           0  1  2  3

for (int i = 0; i < arr.length - 1; i++) {
    // 轮数(i)  次数   j 的取值
    // 0        3     1 2 3
    // 1        2     2 3
    // 2        1     3
    for (int j = i + 1; j < arr.length; j++) {
        if (arr[j] < arr[i]) {
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
}
System.out.println(Arrays.toString(arr));

写法二:先用变量记住本轮最小值索引,本轮结束再交换一次(配套代码 d4_sf/Demo2_2.java,交换次数更少)。

for (int i = 0; i < arr.length - 1; i++) {
    int min = i;                       // 记住本轮最小值对应的索引
    for (int j = i + 1; j < arr.length; j++) {
        if (arr[j] < arr[min]) {
            min = j;
        }
    }
    if (min != i) {                    // 本轮结束再交换
        int temp = arr[i];
        arr[i] = arr[min];
        arr[min] = temp;
    }
}

4.5 查找算法之一:基本查找(顺序查找)

第 34 页:

81   103   131   127   147   23   7   79

基本查找就是「从前往后挨个找」(又叫顺序查找),找 81 时第一个元素就命中。课件提示:在数据量特别大的时候,基本查找这种从前往后挨个找的形式,性能是很差的!

4.6 查找算法之二:二分查找(折半查找)

第 35~45 页。两个前提与核心思想:

  • 前提条件:数组中的数据必须是有序的。
  • 核心思想:每次排除一半的数据,查询数据的性能明显提高极多!
  • 中间位置:mid = (left + right) / 2。

以数组 {7, 23, 79, 81, 103, 127, 131, 147}(索引 0~7)为例。

查找 79(能查到):

轮次leftrightmidarr[mid]判断与处理
10738179 < 81 → 往左找,right = mid - 1 = 2
20212379 > 23 → 往右找,left = mid + 1 = 2
322279相等 → 返回索引 2

查找 150(查不到):

轮次leftrightmidarr[mid]判断与处理
107381150 > 81 → left = 4
2475127150 > 127 → left = 6
3676131150 > 131 → left = 7
4777147150 > 147 → left = 8
结束87——left > right,循环结束,未找到

第 44 页的结论:二分查找正常的折半条件应该是开始位置 left <= 结束位置 right。

第 45 页归纳了实现步骤:

  1. 定义变量记录左边和右边位置。
  2. 使用 while 循环控制二分查询(条件是左边位置 <= 右边位置)。
  3. 循环内部获取中间元素索引。
  4. 判断当前要找的元素如果大于中间元素,左边位置 = 中间索引 + 1。
  5. 判断当前要找的元素如果小于中间元素,右边位置 = 中间索引 - 1。
  6. 判断当前要找的元素如果等于中间元素,返回当前中间元素索引。
public static int searchDataIndex(int[] array, int number) {
    // array = {7, 23, 79, 81, 103, 127, 131, 147}
    // 1、定义头尾指针
    int left = 0;
    int right = array.length - 1;

    // 2、开始折半查询
    while (left <= right) {
        // 3、取中间索引
        int middle = (left + right) / 2;
        // 4、判断当前要找的数据与中间位置处的数据大小情况
        if (number > array[middle]) {
            left = middle + 1;          // 5、往右边找
        } else if (number < array[middle]) {
            right = middle - 1;         // 6、往左边找
        } else {
            return middle;              // 找到,返回索引
        }
    }
    return -1;                          // 循环结束仍未找到
}

调用效果:searchDataIndex(array, 79) 返回 2;searchDataIndex(array, 179) 返回 -1。

5. 正则表达式

5.1 概述与初体验

第 47 页给出定义与两大作用:

  • 定义:正则表达式就是由一些特定的字符组成,代表的是一个规则。
  • 作用一:用来校验数据格式是否合法(例:电话 13811112222、10233233A、020-3212323;邮箱 dlei@163.com、dlei#gmail.com;QQ 号 12abc、25666666、09999)。
  • 作用二:在一段文本中查找满足要求的内容。

正则校验用到的示例数据(身份证号、姓名、性别、所属地区)

String 提供了一个匹配正则表达式的方法(第 49 页):

方法名说明
public boolean matches(String regex)判断字符串是否匹配正则表达式,匹配返回 true,不匹配返回 false

用「校验 QQ 号」做初体验:需求是「号码必须 5 位以上、全部是数字、不能以 0 开头」。不用正则要写一堆判断,用了正则只要一行:

public static boolean checkQQ(String qq) {
    // 不用正则:逐个条件判断 + 逐位检查字符
    if (qq == null || qq.startsWith("0") || qq.length() <= 5) {
        return false;
    }
    for (int i = 0; i < qq.length(); i++) {
        char c = qq.charAt(i);
        if (c < '0' || c > '9') {
            return false;
        }
    }
    return true;
}

public static boolean checkQQ2(String qq) {
    // 用正则:一位不能是 0,后面至少 5 位数字
    return qq != null && qq.matches("[1-9]\\d{5,}");
}

5.2 书写规则:字符类、预定义字符、数量词

字符类(只匹配单个字符):

规则含义
[abc]只能是 a、b 或 c
[^abc]除了 a、b、c 之外的任何字符
[a-zA-Z]a 到 z、A 到 Z(范围)
[a-d[m-p]]a 到 d,或 m 到 p
[a-z&&[def]]d、e 或 f(交集)
[a-z&&[^bc]]a 到 z,除了 b 和 c(等同于 [ad-z])
[a-z&&[^m-p]]a 到 z,除了 m 到 p(等同于 [a-lq-z])

预定义字符(只匹配单个字符):

规则含义
.任何字符
\d一个数字:[0-9]
\D非数字:[^0-9]
\s一个空白字符
\S非空白字符:[^\s]
\w[a-zA-Z_0-9]
\W[^\w],一个非单词字符

数量词:

规则含义
X?X,一次或 0 次
X*X,零次或多次
X+X,一次或多次
X{n}X,正好 n 次
X{n,}X,至少 n 次
X{n,m}X,至少 n 但不超过 m 次

第 50 页的小结表把常用符号集中列了一遍:

符号含义举例
[]里面的内容出现一次[abc]
^取反[^abc]
&&交集,不能写单个的 &[a-z&&m-p]
.任意字符\n 回车符号不匹配
\转义字符\\d
\d0-9\d+
\D非 0-9\D+
\s空白字符—
\S非空白字符[^\s]
\w单词字符[a-zA-Z_0-9]
\W非单词字符[^\w]
()分组a(bc)+
|写在方括号外面表示并集ab|AB
?0 次或 1 次\d?
*0 次或多次\d*、(abc)*
+1 次或多次\d+、(abc)+
{}具体次数a{7}、\d{7,19}
(?i)忽略后面字符的大小写(?i)abc;a((?i)b)c 只忽略 b 的大小写

在 Java 中要特别注意转义:\ 有特殊用途,不能独立存在,写正则时必须用 \\ 表示一个 \(配套代码注释:「\ 需要再使用 \ 转义」)。上面带 [内容] 的字符类和预定义字符都只能匹配单个字符,要匹配多个字符必须配合数量词。

5.3 应用案例一:校验数据格式

第 52 页的需求:校验用户输入的电话、邮箱、时间是否合法。课件示例中的做法(配套代码 d5_regex/RegexTest3.java)是「循环 + matches,不合法就重新输入」:

public static void checkPhone() {
    while (true) {
        Scanner sc = new Scanner(System.in);
        System.out.println("请您输入手机号码:");
        String phone = sc.next();
        if (phone.matches("1[3-9]\\d{9}")) {
            System.out.println("手机号码合法,录入成功!");
            break;
        } else {
            System.out.println("您输入的手机号码有毛病!请重新输入!");
        }
    }
}

public static void checkEmail() {
    while (true) {
        Scanner sc = new Scanner(System.in);
        System.out.println("请您输入邮箱:");
        String email = sc.next();
        // 示例:dlei0009@163.com、5423253@qq.com、xulei2@itcast.com.cn
        if (email.matches("\\w{2,30}@\\w{2,20}(\\.\\w{2,20}){1,2}")) {
            System.out.println("邮箱合法,录入成功!");
            break;
        } else {
            System.out.println("您输入的邮箱有毛病!请重新输入!");
        }
    }
}

5.4 应用案例二:用于查找信息(爬取)

第 54~57 页给出三段文本爬取的需求,套路固定为四步:定义规则 → 封装成 Pattern → 得到 Matcher → 循环 find() 取内容。

需求 1:把下面文本中的电话、邮箱、座机号码、热线都爬取出来。

String data = "来黑马程序员学习Java,\n" +
        "电话:18512516758,18512508907\n" +
        "或者联系邮箱: boniu@itcast.cn\n" +
        "座机电话:01036517895,010-98951256\n" +
        "邮箱:bozai@itcast.cn,\n" +
        "邮箱2:dlei0009@163.com,\n" +
        "热线电话:400-618-9090 ,400-618-4000,4006184000,4006189090";

// 1、定义爬取规则(正则表达式)
String regex = "(\\w{1,}@\\w{2,10}(\\.\\w{2,10}){1,2})|" +
        "(1[3-9]\\d{9})|(0\\d{2,5}-?\\d{5,15})|400-?\\d{3,8}-?\\d{3,8}";

// 2、把正则表达式封装成一个 Pattern 对象
Pattern pattern = Pattern.compile(regex);

// 3、通过 pattern 对象得到查找内容的匹配器
Matcher matcher = pattern.matcher(data);

// 4、通过匹配器开始去内容中查找信息
while (matcher.find()) {
    System.out.println(matcher.group());   // 取出信息
}

(配套代码 RegexTest4.java 的规则更严格一些:(\w{2,30}@\w{2,20}(\.\w{2,20}){1,2})|(1[3-9]\d{9})|(0\d{2,6}-?[1-9]\d{3,10})|(400-?[1-9]\d{2,6}-?[1-9]\d{2,6})。)

需求 2:只需要把每个邮箱中的用户名爬取出来——关键在于用分组 () 圈住要单独取出的部分,再用 matcher.group(1) 取第一组:

String regex = "(\\w{2,})@\\w{2,20}(\\.\\w{2,10}){1,2}";

Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(data);
while (matcher.find()) {
    System.out.println(matcher.group(1));   // 取出第一组信息(用户名)
}

需求 3:把日志里进入系统的用户名爬出来,这里要区分贪婪匹配与非贪婪匹配:

String data = "欢迎张全蛋光临本系统!他删库并跑路,欢迎李二狗子光临本系统!" +
        "欢迎马六子光临本系统!它浏览了很多好看的照片!欢迎夏洛光临本系统!他在六点钟购买了一台拖拉机!";

String regex  = "欢迎(.+)光临";    // .+ 是贪婪匹配:最大范围匹配
String regex2 = "欢迎(.+?)光临";   // .+? 是非贪婪匹配

Pattern pattern = Pattern.compile(regex2);
Matcher matcher = pattern.matcher(data);
while (matcher.find()) {
    String rs  = matcher.group();     // 找到的整段内容
    System.out.println(rs);
    String rs2 = matcher.group(1);    // 第一组内容,就是人的名字
    System.out.println(rs2);
}

用贪婪的 .+ 时,一整段「欢迎……光临」会被当成一个匹配;用非贪婪的 .+? 才会一次只匹配到最近的一个「光临」,从而把每个名字分别取出来。

5.5 应用案例三:用于搜索替换、分割内容

第 59 页给出需要配合的 String 方法:

方法名说明
public String replaceAll(String regex, String newStr)按照正则表达式匹配的内容进行替换
public String[] split(String regex)按照正则表达式匹配的内容进行分割字符串,返回一个字符串数组
String s1 = "古力娜扎ai8888迪丽热巴99fafas9aa5566马尔扎哈fbbADFFfsfs42425卡尔扎巴";

// 1、把所有非中文字符替换成 "-"
String result = s1.replaceAll("\\w+", "-");
System.out.println(result);

// 2、把人名分割出来
String[] names = s1.split("\\w+");
for (int i = 0; i < names.length; i++) {
    System.out.println(names[i]);
}

6. 作业与练习

  • 作业文档:day05-常用API(二):Lambda、方法引用详解/day05作业/作业05/day05作业.md
  • 配套代码:day05-常用API(二):Lambda、方法引用详解/代码/day05-api(d1_array、d2_lambda、d3_method_reference、d4_sf、d5_regex)

作业要点:

  1. 选择题 9 题:Lambda 的格式与省略模式(错项是「多个参数之间用分号隔开」;-> 不可以省略)、函数式接口与 ::、Arrays.sort(T[], Comparator) 只能对引用类型数组排序、方法引用与构造器引用 int[]::new 的正确写法、冒泡与选择排序的对比(「冒泡效率最高」是错的,选择排序效率更高),以及两组正则完全匹配题(back / back-end;(010)-62661617 / 01062661617)。
  2. 今日单词:Arrays、Functional interface(函数式接口)、Method Reference(方法引用)、bubble sort(冒泡排序)、Binary search(二分查找)。
  3. 简答题 6 题:如何用 Arrays 实现降序排序;什么是面向函数式编程、与面向对象编程有何区别;()->{} 每个符号的含义;什么是方法引用、什么情况下可以使用;冒泡与选择排序的排序原理;正则表达式区分语言吗、有何作用。
  4. 排错题 4 题:① Arrays.sort(int[], Comparator<int>)——Comparator 不能用于基本类型数组,泛型也不能是 int;② 对无序数组 {5,2,3,1,4} 直接用 Arrays.binarySearch(arr, 5)——二分查找要求数组先有序,必须先排序;③ @FunctionalInterface 接口里同时写了 show() 和 toString()——toString 是 Object 的方法,接口中不允许重写 Object 的公共方法(应删掉);④ 给出的一段 Lambda 写法纠错。
  5. 代码题:第一题在 sort 项目 Entry 类里补全「学生按身高从小到大排队」,先用冒泡排序、再用选择排序比较身高;第二题在 supplier 项目里用 Supplier<String> 生成四位随机验证码(字符从 OPTIONS_CAHS 中获取);第三题在 function 项目里用 Function<String[], String> 实现点名器(从 userNames 数组随机取一个元素);第四题(选做)在 lottery 项目里按权重抽奖:一等奖 1、二等奖 2、三等奖 3、谢谢惠顾 4,权重区间为 [0,1)、[1,3)、[3,6)、[6,10),生成随机权重数后用二分查找定位奖项。

7. 复习要点

  1. Arrays 是操作数组的工具类:toString、copyOfRange(包前不包后)、copyOf(扩容)、setAll(按索引改写每个元素)、sort(默认升序)。
  2. 对象数组排序有两条路:类实现 Comparable 并重写 compareTo,或用带 Comparator 的 sort 重载方法传比较器;后者是课件标注的优先方案。
  3. 自定义比较规则的官方约定:左边大于右边返回正整数、小于返回负整数、等于返回 0;照此返回即为升序,对调运算顺序即得降序。浮点字段用 Double.compare(a, b)。
  4. Lambda 表达式是 JDK8 新增语法,只能简化函数式接口的匿名内部类;函数式接口就是「有且仅有一个抽象方法」的接口,常用 @FunctionalInterface 标注。
  5. Lambda 的三条省略规则:参数类型可省;只有一个参数时类型和 () 都可省;方法体只有一行时可省 {}、; 以及 return。
  6. 方法引用用 ::,是 Lambda 的进一步简化,条件是「Lambda 里只是调用一个已有方法且参数形式一致」:静态方法引用 类名::静态方法、实例方法引用 对象名::实例方法、特定类型方法引用 类型::方法、构造器引用 类名::new。
  7. 冒泡排序:共 数组长度 - 1 轮,第 i 轮比较 长度 - i - 1 次,相邻两两比较,大的往后换;选择排序:每轮以当前位置为基准,用它后面的较小值与之交换,两种写法(立即交换 / 记住最小索引最后交换)。
  8. 查找分两类:基本查找(顺序查找)从前往后挨个找,数据量大时性能差;二分查找(折半查找)要求数组有序,折半条件是 left <= right,mid = (left + right) / 2,比中间值大就往右找(left = mid + 1),比中间值小就往左找(right = mid - 1),找不到返回 -1。
  9. 正则表达式是用特定字符描述的规则,两大作用:校验数据格式是否合法、在一段文本中查找满足要求的内容;String.matches(regex) 用于整体匹配校验。
  10. 正则的书写规则由字符类([abc]、[^abc]、[a-z&&[^bc]])、预定义字符(.、\d、\s、\w 等)、数量词(?、*、+、{n,m})与其他符号(^、&&、()、|、(?i))组成;Java 中 \ 要写成 \\。
  11. 文本爬取四步:定义规则 → Pattern.compile → pattern.matcher → while (matcher.find()) 配合 group() 取整体、group(n) 取第 n 组;(.+) 是贪婪匹配,(.+?) 是非贪婪匹配。
  12. 搜索替换与分割用 replaceAll(regex, newStr) 和 split(regex),配合 \w+ 可以一键清理文本里的非中文字符或按非中文字符切分。

day06|java.util 包下的常用 API:集合(Collection、List、Set)

本日有两条主线。上半场是异常——代码在编译或执行过程中出问题时,程序怎样把”这次操作没能按约定完成”这件事表达出来,并带回到有能力处理它的地方;下半场是集合(一)——当一组数据需要动态增删、还需要按索引访问或自动去重时,用哪种容器来组织,以及它们底层的数组、链表、哈希表、红黑树各自带来什么代价。学完后应能回答:编译时异常和运行时异常差在哪、throws 和 try…catch 该怎么选、迭代器为什么不能在遍历时用集合自己的方法删元素、ArrayList/LinkedList/HashSet/LinkedHashSet/TreeSet 分别在什么场景下用。

本日 PPT 目录(第 2 页 / 第 7 页):异常 → 认识异常 → 自定义异常 → 异常的处理 → 集合体系概述 → Collection 的常用方法 → Collection 的遍历方式 → List 集合 → Set 集合 → 注意事项:集合的并发修改异常问题。

三点说明(供复习时核对):

  • 课件第 1 页标题是「异常、集合(一)」,本日只讲 Collection 这一支;Map(双列集合)与 Collections 工具类在 day07。
  • 课件里多处「常见方法」页在抽取文本中只留下 [表格] 占位、没有表体。本文按课件的题目页与配套示例工程还原这些表格,还原处已标注「(表格按课件题目整理)」。
  • PPT 抽出的 79 张图片中绝大多数是装饰文字图(如「数组的特点」「查询快、增删慢」)、卡通与表情包,与知识点无关,已按规范舍弃;本文只内嵌少量真正有信息量的截图与结构图。

1. 认识异常

1.1 异常是什么

课件第 6 页的定义很直接:异常是代码在编译或者执行的过程中可能出现的错误。

课件的引入例子是两行注定出错的代码(第 3 页):

int[] arr = {10, 20, 30};
System.out.println(arr[3]);   // 下标越界
System.out.println(10 / 0);   // 除数为 0

这两行分别会在运行时抛出不同类型的异常对象,控制台会把异常的类型、消息和出错位置打成一段堆栈信息:

数组索引越界的控制台输出 ArrayIndexOutOfBoundsException

除零异常的控制台输出 ArithmeticException: / by zero

课件用这两段报错说明:异常对象本身携带了出错类型 + 出错原因 + 出错位置三样信息,这正是后面「异常的作用」那一节的基础。

1.2 异常的体系结构

异常的根类是 java.lang.Throwable,它下面分成两支:

java.lang.Throwable
├── Error                       ← 系统级别错误,开发人员不用管
└── Exception                   ← 程序可能出现的问题,我们用的就是它
    ├── RuntimeException        ← 运行时异常(及其子类)
    └── 其他异常…               ← 编译时异常

课件对两支的定位(第 4 页原话):

  • Error:代表的系统级别错误(属于严重问题),也就是说系统一旦出现问题,sun 公司会把这些问题封装成 Error 对象给出来。说白了,Error 是给 sun 公司自己用的,不是给我们程序员用的,因此我们开发人员不用管它。
  • Exception:叫异常,它代表的才是我们程序可能出现的问题,所以程序员通常会用 Exception 以及它的孩子来封装程序出现的问题。

课件第 18 页把 Map 体系的 LinkedHashMap 误写成 LinedHashMap,属笔误(见第 3.1 节)。

1.3 编译时异常与运行时异常

课件第 6 页给出的分类依据:

类别判断依据编译阶段的表现课件举的例子
运行时异常继承自 RuntimeException 的异常或其子类编译阶段不报错,运行时才出现数组索引越界异常
编译时异常没有继承 RuntimeException 的异常编译阶段就会出错日期解析异常

课件说法:「编译时异常:没有继承 RuntimeException 的异常,编译阶段就会出错。」;更准确的表述:编译时异常并不表示错误发生在编译期——它表示编译器要求你「要么 try…catch 处理、要么在方法上 throws 声明」的受检异常,真正的失败仍然发生在运行期,只是编译器提前把这类必须表态的异常拦下来了。

(补充说明)JDK 里的对应术语是 checked / unchecked exception;Error、RuntimeException 及其子类属于非受检,其余 Exception 子类属于受检。

2. 异常的作用与自定义异常

2.1 异常的作用

课件第 8 页用连续四张图给出两条作用:

  1. 异常是用来查寻系统 Bug 的关键参考信息。(异常堆栈里带着类名、方法名、行号,是定位问题最快的入口)
  2. 异常可以作为方法内部的一种特殊返回值,以便通知上层调用者底层的执行情况。

第 2 条是理解后面「异常的处理」的关键:当一个方法没法在自己的职责范围内决定怎么处理失败时,它可以把失败「返回」给调用者——只不过这个返回值不是 return,而是抛出的异常对象。

2.2 自定义异常

课件第 9–10 页说明了为什么要自定义:

Java 无法为这个世界上全部的问题都提供异常类来代表,如果企业自己的某种问题想通过异常来表示,以便用异常来管理该问题,那就需要自己来定义异常类了。

自定义异常分两类,定义步骤完全对称:

种类定义方式效果
自定义编译时异常定义一个异常类继承 Exception;重写构造器;通过 throw new 异常类(xxx) 来创建异常对象并抛出编译阶段就报错,提醒更加强烈
自定义运行时异常定义一个异常类继承 RuntimeException;重写构造器;通过 throw new 异常类(xxx) 来创建异常对象并抛出编译阶段不报错,提醒不强烈,运行时才可能出现

以本日作业三的 ATM 取钱场景为例,把上面的规则落成代码(按课件描述整理):

// 自定义运行时异常:取款金额不合法
public class MoneyIllegalException extends RuntimeException {

    public MoneyIllegalException() {
    }

    public MoneyIllegalException(String message) {
        super(message);   // 把失败原因交给父类保存,getMessage() 可以取回
    }
}
public void drawMoney(double money) {
    if (money > balance) {
        // 创建异常对象并抛出,通知调用者这次取款没有完成
        throw new MoneyIllegalException("余额不足,当前余额为:" + balance);
    }
    balance -= money;
}

选择建议(按课件两条效果反推):希望调用方必须面对这个失败、否则编译不过,就继承 Exception;这个失败属于用法/数据错误、让它在运行时暴露即可,就继承 RuntimeException。

3. 异常的处理

课件把处理方式分成两大类(第 5 页与第 14 页内容一致,第 14 页多给了推荐写法)。

3.1 抛出异常(throws)

在方法上使用 throws 关键字,可以将方法内部出现的异常抛出去给调用者处理。

方法 throws 异常1, 异常2, 异常3 ... {
}

// 推荐方式
方法 throws Exception {
}
// Exception 代表可以捕获一切异常

throws 只负责「声明」,它并不处理异常;异常会沿着方法调用链一路往上走,直到某一层接住它。

3.2 捕获异常(try…catch)

直接捕获程序出现的异常。

try {
    // 监视可能出现异常的代码!
} catch (异常类型1 变量) {
    // 处理异常
} catch (异常类型2 变量) {
    // 处理异常
}
// 推荐方式
try {
    // 可能出现异常的代码!
} catch (Exception e) {
    e.printStackTrace();   // 直接打印异常对象的信息
}
// Exception 代表可以捕获一切异常

(补充说明)课件把 throws Exception / catch (Exception e) 称为「推荐方式」,是从「最外层集中兜底」的开发习惯出发的;catch 多个分支时应遵守先具体、后一般的顺序,否则子类分支永远匹配不到。

3.3 开发中对于异常的常见处理方式

课件第 13 页把实际项目里的做法归为三种:

  1. 捕获异常,记录异常并响应合适的信息给用户(最常见:把技术异常转成用户看得懂的提示);
  2. 捕获异常,尝试重新修复(例如网络抖动后重试);
  3. 异常,抛给调用者(当前方法无法处理,交给上层决定)。

课件第 15 页的总结给出了工程上的默认姿势:

在开发中异常的常见处理方式是:底层的异常抛出去给最外层,最外层集中捕获处理。

也就是说:底层方法用 throws 声明并向上传播,只在真正有能力恢复、转换或展示失败的那一层 try…catch。

4. 集合体系概述

集合是一种容器,用来装数据的,类似于数组,但集合的大小可变,开发中也非常常用。

4.1 单列集合与双列集合

课件第 18–19 页把集合分成两套体系:

  • Collection 代表单列集合,每个元素(数据)只包含一个值。
  • Map 代表双列集合,每个元素包含两个值(键值对)。课件用「脉劫 5 元 / 康帅傅 3 元 / 粤利粤 10 元」这类「商品名 → 价格」的对应关系来演示键值对。

整体体系结构:

Collection<E>(接口)
├── List<E>(接口)          → ArrayList<E>、LinkedList<E>
│                              还有 Vector / Stack 等(本日不讲)
└── Set<E>(接口)           → HashSet<E>、LinkedHashSet<E>、TreeSet<E>

Map<K, V>(接口)            → HashMap<K, V>、LinkedHashMap<K, V>、TreeMap<K, V>

课件第 18 页的图里 LinkedHashMap 被拼成了 LinedHashMap,属课件笔误。Map 不继承 Collection,是并列的另一棵树。

4.2 List 与 Set 两大系列的特点

课件第 20 页原话:

List 系列集合:添加的元素是有序、可重复、有索引。 ArrayList、LinkedList:有序、可重复、有索引。 Set 系列集合:添加的元素是无序、不重复、无索引。 HashSet:无序、不重复、无索引; LinkedHashSet:有序、不重复、无索引。 TreeSet:按照大小默认升序排序、不重复、无索引。

系列有序性是否可重复有无索引实现类
List有序(保留添加顺序)可重复有索引ArrayList、LinkedList
SetHashSet 无序 / LinkedHashSet 有序 / TreeSet 按大小排序不重复无索引HashSet、LinkedHashSet、TreeSet

课件第 21 页的自测三问:单列集合的代表是 Collection、双列集合的代表是 Map;Collection 下有 List、Set 两大体系。

(补充说明)List 的「有序」指元素有位置、按下标排列,不等于按数值大小自动排序;Set 的「有序/无序」指的是「迭代顺序是否等于添加顺序」,TreeSet 则是按比较结果排序——三者的「序」含义不同,这是最容易混淆的地方。

5. Collection 的常用方法

课件第 23 页先回答了「为啥要先学 Collection 的常用方法」:

Collection 是单列集合的祖宗,它规定的方法(功能)是全部单列集合都会继承的。

所以记住一张表,List 和 Set 的方法就基本会了。

(表格按课件题目整理,课件原表为图片、抽取文本中只有 [表格] 占位)

方法说明
public boolean add(E e)把给定的对象添加到当前集合中
public void clear()清空集合中所有的元素
public boolean remove(E e)把给定的对象在当前集合中删除
public boolean contains(Object obj)判断当前集合中是否包含给定的对象
public boolean isEmpty()判断当前集合是否为空
public int size()返回集合中元素的个数
public Object[] toArray()把集合中的元素,存储到数组中

(补充说明)remove 与 contains 判断的是「按相等规则是否命中」,对 Set 的去重、对存自定义对象的集合尤为重要,见第 8.4 节。

6. Collection 的遍历方式

课件导航页(第 25 / 29 / 33 页)列出三种遍历方式,并特意追问一句「以前学习的普通 for 呢?」——这个问题留到 List 一节回答。

6.1 迭代器

迭代器是用来遍历集合的专用方式(数组没有迭代器),在 Java 中迭代器的代表是 Iterator。

获取迭代器(Collection 提供):

方法说明
Iterator<E> iterator()得到迭代器对象,默认指向当前集合的索引 0

Iterator 迭代器中的常用方法:

方法说明
boolean hasNext()询问当前位置是否有元素存在
E next()获取当前位置的元素,并同时将迭代器对象移向下一个位置

遍历代码(课件第 27 页,同一段代码在页面上重复演示了每一步的指针位置):

Iterator<String> it = lists.iterator();
while (it.hasNext()) {
    String ele = it.next();
    System.out.println(ele);
}

课件用集合 ["赵敏", "小昭", "素素", "灭绝"] 演示执行流程:iterator() 得到迭代器后默认指向索引 0;每调用一次 next() 取走当前元素并把指针后移一位;当 hasNext() 返回 false(课件图中标出的最后一个状态)时循环结束。

课件第 28 页的自测问答:如何获取集合的迭代器?——Iterator<E> iterator(),默认指向当前集合的索引 0。通过迭代器获取集合的元素,如果取元素越界会出现什么异常?——NoSuchElementException(所以必须先用 hasNext() 判断)。

6.2 增强 for 循环

格式:

for (元素的数据类型 变量名 : 数组或者集合) {
    // 在此处使用变量即可,该变量就是元素
}

示例:

Collection<String> c = new ArrayList<>();
// ...
for (String s : c) {
    System.out.println(s);
}

课件强调三点:

  • 增强 for 既可以用遍历集合,也可以用来遍历数组;
  • 增强 for 遍历集合,本质就是迭代器遍历集合的简化写法;
  • 修改增强 for 中的变量值不会影响到集合中的元素(第 31 页用一个 "q" 的赋值演示:循环变量只是元素的一个副本,改它不改变集合里的 "a" "b" "c")。

第 2 点在第 9 章会直接决定「遍历时删元素」会不会报错。

6.3 Lambda 表达式

得益于 JDK 8 开始的新技术 Lambda 表达式,提供了一种更简单、更直接的方式来遍历集合。

先看匿名内部类的写法,再看 Lambda 的简化:

Collection<String> lists = new ArrayList<>();
// ...
lists.forEach(new Consumer<String>() {
    @Override
    public void accept(String s) {
        System.out.println(s);
    }
});
lists.forEach(s -> {
    System.out.println(s);
});

// 参数只有一个、方法体只有一行时还可以更短
// lists.forEach(s -> System.out.println(s));

(表格按课件整理)

方法说明
void forEach(Consumer<? super E> action)结合 Lambda 表达式遍历集合

6.4 以前学的普通 for 呢

普通 for 依赖 get(int index) 取元素,而 get(int index) 是 List 因为有索引才提供的特有方法(第 7.1 节)。Collection 接口本身不提供按索引取值,所以普通 for 只能用来遍历 List 系列,不能用来遍历 Set。

6.5 案例:遍历集合中的自定义对象

课件第 35–37 页给了「展示多部电影信息」的案例。

  • 需求:展示多部电影信息。
  • 分析:每部电影都是一个对象,多部电影要使用集合装起来;遍历集合中的 3 个电影对象,输出每部电影的详情信息。

课件直接引用了豆瓣电影 Top 250 的页面作为数据结构参照:

案例参照的电影信息页面(片名/评分/主演/评价人数)

电影类:

public class Movie {
    private String name;     // 片名
    private double score;    // 评分
    private String acotr;    // 主演(课件拼写为 acotr)

    public Movie(String name, double score, String acotr) {
        this.name = name;
        this.score = score;
        this.acotr = acotr;
    }

    // ... getter + setter
    public String getName() { return name; }
    public double getScore() { return score; }
    public String getAcotr() { return acotr; }

    @Override
    public String toString() {
        return name + " " + score + " " + acotr;
    }
}

测试类:

public class SystemDemo {
    public static void main(String[] args) {
        Collection<Movie> movies = new ArrayList<>();
        movies.add(new Movie("《肖生克的救赎》", 9.7, "罗宾斯"));
        movies.add(new Movie("《霸王别姬》", 9.6, "张国荣、张丰毅"));
        movies.add(new Movie("《阿甘正传》", 9.5, "汤姆.汉克斯"));
        System.out.println(movies);

        for (Movie movie : movies) {
            System.out.println("片名:" + movie.getName());
            System.out.println("评分:" + movie.getScore());
            System.out.println("主演:" + movie.getAcotr());
        }
    }
}

课件第 36 页把类名、变量名和内存示意图画在同一页(SystemDemo / Movie / movices 混排),抽取文本顺序被打乱,上面的代码是按语义重新拼回的。

集合存储对象的原理(课件第 36–37 页的内存图与问答):集合中存储的是元素对象的地址。movies 变量在栈内存中指向堆内存里的集合对象,集合内部(数组或哈希表)的每个格子存放的是 Movie 对象的地址,而不是对象本身——所以第 2.1 节说的「集合与对象的关系」在内存上就是「容器持有引用」。

第 37 页的自测题答案也在这里:集合中存储的是元素对象的地址。

7. List 集合

课件导航(第 38 / 42 / 44 页):特点、特有方法 → 遍历方式 → ArrayList 集合的底层原理 → LinkedList 集合的底层原理。

7.1 特点与特有方法

List 系列集合特点:有序,可重复,有索引。 ArrayList:有序,可重复,有索引。 LinkedList:有序,可重复,有索引。

List 集合因为支持索引,所以多了很多与索引相关的方法,当然,Collection 的功能 List 也都继承了。

(表格按课件题目整理)

方法说明
void add(int index, E element)在此集合中的指定位置插入指定的元素
E remove(int index)删除指定索引处的元素,返回被删除的元素
E set(int index, E element)修改指定索引处的元素,返回被修改的元素
E get(int index)返回指定索引处的元素

(补充说明)这里有个经典重载陷阱:list.remove(2) 删除的是索引 2 的元素,list.remove("java") 删除的是内容为 “java” 的元素;对 List<Integer> 来说 remove(2) 与 remove(Integer.valueOf(2)) 结果完全不同。本日作业第 8 题和第 11 题考的就是这一点。

7.2 List 集合支持的遍历方式

课件第 43 页列出四种:

  • for 循环(因为 List 集合有索引);
  • 迭代器;
  • 增强 for 循环;
  • Lambda 表达式。

比 Collection 多出来的那一种,正是 6.4 节回答的普通 for。

7.3 ArrayList 集合的底层原理

底层基于数组实现。

课件第 46–49 页用「A B C E F G D」+ 下标 0~6 的格子图,配三句话总结数组这种存储方式的表现:

  • 查询速度快(注意:是根据索引查询数据快):查询数据通过地址值和索引定位,查询任意数据耗时相同。
  • 删除效率低:可能需要把后面很多的数据进行前移。
  • 添加效率极低:可能需要把后面很多的数据后移,再添加元素;或者也可能需要进行数组的扩容。

接着第 50–58 页用逐步动画演示了 ArrayList 的扩容过程:

  1. 利用无参构造器创建的集合,会在底层创建一个默认长度为 0 的数组(elementData)。
  2. 添加第一个元素时,底层会创建一个新的长度为 10 的数组。
  3. 数组里同时维护 size 变量——它既是元素个数,也是下次存入的位置。
  4. 存满时,会扩容 1.5 倍(10 → 15)。
  5. 如果一次添加多个元素,1.5 倍还放不下,则新创建数组的长度以实际为准。课件举例:在已有 10 个元素的基础上再 addAll 11 个数据,新数组长度直接是 21。
List<String> list1 = new ArrayList<>();
list1.add("a");
// ...
List<String> list2 = new ArrayList<>();
list2.add("a11");
// ...
list1.addAll(list2);   // 把集合 list2 的数据全部倒入到 list1 集合

课件说法:「添加第一个元素时,底层会创建一个新的长度为 10 的数组」;更准确的表述:容量 10 不是在构造时就分配,而是延迟初始化——构造时给的是空数组,第一次 add 触发扩容时才分配 10。(补充说明)「1.5 倍」和「长度 21」都是 JDK 的具体实现细节,复习时理解「按需扩容、新长度取两者较大值」即可,不必背数字。

应用场景(第 59–60 页问答):

  • 适合:根据索引查询数据,比如根据随机索引取数据(高效),或者数据量不是很大时。
  • 不适合:数据量大的同时,又要频繁地进行增删操作。

7.4 LinkedList 集合的底层原理

基于双链表实现。

课件第 63 页的结点图给出了链表的本质:

链表中的结点是独立的对象,在内存中是不连续的,每个结点包含数据值和下一个结点的地址。(双向链表的结点还额外包含前一个结点的地址。)

第 63–66 页用 A、B、D、E 四个结点演示了「创建一个链表 → 在 B、D 之间添加 C → 删除 C、E 之间的 D」的全过程,两个动作都只改指针:

  • 插入 C:① 让 C 的下一个结点地址指向 D;② 让 B 的下一个结点地址指向 C。
  • 删除 D:① 让 C 的下一个结点地址指向 E;② 数据 D 本身不再被引用。

于是链表的特点:

  • 查询慢:无论查询哪个数据都要从头开始找。
  • 增删相对快。
  • 课件第 67 页补充:对首尾元素进行增删改查的速度是极快的。

第 67 页还并排画了单向链表与双向链表的结点结构对比图。

LinkedList 新增了很多首尾操作的特有方法:

(表格按课件题目整理)

方法说明
public void addFirst(E e)在该列表开头插入指定的元素
public void addLast(E e)将指定的元素追加到此列表的末尾
public E getFirst()返回此列表的第一个元素
public E getLast()返回此列表的最后一个元素
public E removeFirst()从此列表中删除并返回第一个元素
public E removeLast()从此列表中删除并返回最后一个元素

应用场景(第 69–74 页):LinkedList 可以用来实现队列,也可以用来实现栈。

课件对队列的描述是「先进先出,后进后出」,并画了一端开口入队、另一端开口出队的示意图:

队列——一端入队、另一端出队,先进先出

课件对栈的描述是「后进先出,先进后出」,数据进入栈模型的过程称为压/进栈(push),离开栈模型的过程称为弹/出栈(pop),栈只有一端开口,分成栈顶与栈底:

栈——一端开口一端封闭,后进先出

(补充说明)用 LinkedList 当栈用时对应 push() / pop(),当队列用时对应 addLast() / removeFirst();课件这一页只讲了模型和适用场景,没有给出代码。

8. Set 集合

课件导航(第 76 / 79 / 104 / 108 页):特点 → HashSet 集合的底层原理 → LinkedHashSet 集合的底层原理 → TreeSet 集合。

8.1 特点

Set 系列集合特点:无序(添加数据的顺序和获取出的数据顺序不一致)、不重复、无索引。 HashSet:无序、不重复、无索引。 LinkedHashSet:有序、不重复、无索引。 TreeSet:排序、不重复、无索引。

课件还特别提醒:

注意:Set 要用到的常用方法,基本上就是 Collection 提供的!自己几乎没有额外新增一些常用功能!

也就是说 Set 没有 get(int index),想按位置取元素只能靠遍历。

8.2 前置知识:哈希值

在正式了解 HashSet 集合的底层原理前,我们需要先搞清楚一个前置知识:哈希值!

  • 哈希值就是一个 int 类型的数值,Java 中每个对象都有一个哈希值。
  • Java 中的所有对象,都可以调用 Object 类提供的 hashCode 方法,返回该对象自己的哈希值:public int hashCode()。
  • 取值范围就是 int 的范围(-21 亿多 ~ 21 亿多)。课件用「45 亿个对象 → 只有 21 亿多 × 2 个哈希值」提示了哈希碰撞的必然性。

对象哈希值的特点:

  • 同一个对象多次调用 hashCode() 方法返回的哈希值是相同的。
  • 不同的对象,它们的哈希值一般不相同,但也有可能会相同(哈希碰撞)。

8.3 HashSet 集合的底层原理

HashSet 基于哈希表实现。哈希表是一种增删改查数据性能都较好的数据结构。

  • JDK 8 之前,哈希表 = 数组 + 链表
  • JDK 8 开始,哈希表 = 数组 + 链表 + 红黑树

JDK 8 之前 HashSet 的详细流程(课件第 82–99 页,用 0~15 共 16 个格子的 table[] 数组逐步演示):

  1. 创建一个默认长度 16、默认加载因子为 0.75 的数组,数组名 table;
  2. 使用元素的哈希值对数组的长度做运算,计算出应存入的位置;
  3. 判断当前位置是否为 null,如果是 null 直接存入;
  4. 如果不为 null,表示有元素,则调用 equals 方法比较属性值:相等则不存;不相等则存入数组。
    • JDK 8 之前:新元素存入数组,占老元素位置,老元素挂下面;
    • JDK 8 开始之后:新元素直接挂在老元素下面。
  5. 当数组存满到 16 × 0.75 = 12 时,就自动扩容,每次扩容为原先的两倍。

JDK 8 开始:当链表长度超过 8,且数组长度 ≥ 64 时,自动将链表转成红黑树。课件的小结是:

JDK8 开始后,哈希表中引入了红黑树后,进一步提高了操作数据的性能。

(补充说明)课件第 87 / 96 页的红黑树示意图上把底层数组标注成了 Segment[]——那是 JDK 7 里 HashMap 的分段锁结构,与 HashSet/HashMap 的 table 数组不是一回事。以课件第 82–86 页的 table[] 为准。

8.4 理解树结构

课件在第 89–95 页插了一组「树」的示意图,作为理解红黑树的前置知识。

二叉树:每个节点最多有两个子节点。课件给出的术语:

术语含义
根节点最顶层的节点
父节点 / 左子节点 / 右子节点结点的上下关系
左子树 / 右子树以子节点为根的子树
度每一个节点的子节点数量
树高树的总层数

二叉树中,任意节点的度 ≤ 2。每个节点在内存中可以表示为「父节点地址 + 值 + 左子节点地址 + 右子节点地址」。

用现实中的树类比数据结构中的树

二叉查找树(二叉排序树)的存放规则,课件用一张卡片概括:

二叉查找树的排序规则——小的存左边、大的存右边、一样的不存

二叉查找树存在的问题:当数据已经是排好序的(课件用 7、10、11、12、13 依次插入演示),树会退化成一条斜链,导致查询的性能与单链表一样,查询速度变慢。

平衡二叉树:在满足查找二叉树的大小规则下,让树尽可能矮小,以此提高查数据的性能。

红黑树:就是可以自平衡的二叉树。红黑树是一种增删改查数据性能相对都较好的结构。

红黑树结构示意图

8.5 HashSet 去重复

课件第 100–103 页先抛出一个问题:

比如内容一样的两个学生对象存入到 HashSet 集合中去,HashSet 集合是不能去重复的! HashSet 集合默认不能对内容一样的两个不同对象去重复!

原因是 8.3 节的第 4 步:HashSet 先用哈希值定位、再用 equals 比较;如果类没有重写这两个方法,用的就是 Object 的默认实现(比较地址 / 返回地址推导出的哈希值),两个内容相同的新对象自然被判为「不相等」。

结论:

如果希望 Set 集合认为 2 个内容一样的对象是重复的,必须重写对象的 hashCode() 和 equals() 方法。

课件的需求与实现步骤:

  • 需求:创建一个存储学生对象的集合,存储多个学生对象,使用程序实现在控制台遍历该集合,要求:学生对象的成员变量值相同,我们就认为是同一个对象。
  • 分析:定义学生类 → 创建 HashSet 集合对象 → 创建学生对象 → 把学生添加到集合 → 在学生类中重写两个方法 hashCode() 和 equals()(自动生成即可) → 遍历集合(增强 for)。
public class Student {
    private String name;
    private int age;

    // 构造器、getter / setter 省略

    // 均为 IDEA 自动生成的形式
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Student student = (Student) o;
        return age == student.age && Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

(补充说明)重写要遵守两条约定:equals 为 true 的两个对象 hashCode 必须相同;hashCode 相同不代表对象相等(还要过 equals 这一关)。另外,对象一旦加入哈希集合,就不要修改参与这两个方法的字段。

8.6 LinkedHashSet 集合的底层原理

依然是基于哈希表(数组、链表、红黑树)实现的。但是,它的每个元素都额外多了一个双链表的机制记录它前后元素的位置。

特点与原理小结(第 107 页问答):

  • 特点:有序、不重复、无索引。
  • 底层基于哈希表,使用链表记录添加顺序。

也就是说:LinkedHashSet 在 HashSet 的哈希表之外挂了一条双向链表,遍历时顺着这条链表走,得到的就是添加顺序——去重能力来自哈希表,顺序能力来自链表。

8.7 TreeSet 集合

特点(第 109 页):

不重复、无索引、可排序(默认升序排序,按照元素的大小,由小到大排序),底层是基于红黑树实现的排序。

课件给出的三条注意:

  • 对于数值类型:Integer、Double,默认按照数值本身的大小进行升序排序。
  • 对于字符串类型:默认按照首字符的编号升序排序。
  • 对于自定义类型如 Student 对象,TreeSet 默认是无法直接排序的(因为 Java 不知道按什么比大小)。

自定义排序规则(第 110 页):TreeSet 集合存储自定义类型的对象时,必须指定排序规则,支持两种方式:

方式做法
方式一让自定义的类(如学生类)实现 Comparable 接口,重写里面的 compareTo 方法来指定比较规则
方式二通过调用 TreeSet 集合有参数构造器,设置 Comparator 对象(比较器对象)来指定比较规则:public TreeSet(Comparator<? super E> comparator)

两种方式中,关于返回值的规则(第 111 页):

  • 如果认为第一个元素 > 第二个元素,返回正整数即可;
  • 如果认为第一个元素 < 第二个元素,返回负整数即可;
  • 如果认为第一个元素 = 第二个元素,返回 0 即可,此时 TreeSet 集合只会保留一个元素,认为两者重复。

课件还特别提醒:

如果类本身有实现 Comparable 接口,TreeSet 集合同时也自带比较器,默认使用集合自带的比较器排序。

(补充说明)方式二的写法大致如下,课件只给了构造器签名,未给出代码:

TreeSet<Student> set = new TreeSet<>(new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return Double.compare(o1.getScore(), o2.getScore());   // 按分数升序
    }
});

课件第 112 页自测答案重申:TreeSet 可排序、不重复、无索引,底层红黑树,增删改查性能较好;自定义类型有两种指定比较规则的方式。

第 109 页还配了一张「你让我怎么玩?!」的吐槽图,正好对应「自定义类型默认不能排序、必须先说清按什么比」这件事。

8.8 该选哪个集合

课件第 113 页再次给出完整体系特点,第 114 页按业务需求给了五条选择建议:

需求选择特点与底层
记住添加顺序 + 存重复元素 + 频繁按索引查询ArrayList有序、可重复、有索引,底层数组(常用)
记住添加顺序 + 增删首尾数据较多LinkedList有序、可重复、有索引,底层双链表
不在意顺序 + 无重复元素 + 增删改查都要快HashSet无序、不重复、无索引,底层哈希表(常用)
记住添加顺序 + 无重复元素 + 增删改查都要快LinkedHashSet有序、不重复、无索引,底层哈希表 + 双链表
要对元素排序 + 无重复元素 + 增删改查都要快TreeSet可排序、不重复、无索引,底层红黑树

9. 注意事项:集合的并发修改异常

课件第 116 页把这个问题单独拎出来作为一个章节:

  • 使用迭代器遍历集合时,又同时在删除集合中的数据,程序就会出现并发修改异常的错误。
  • 由于增强 for 循环遍历集合就是迭代器遍历集合的简化写法,因此使用增强 for 循环遍历集合,又在同时删除集合中的数据时,程序也会出现并发修改异常的错误。

怎么保证遍历集合同时删除数据时不出 bug?

  1. 使用迭代器遍历集合,但用迭代器自己的删除方法删除数据即可(Iterator.remove()):
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String ele = it.next();
    if ("java".equals(ele)) {
        it.remove();    // 用迭代器自己的删除方法
    }
}
  1. 如果能用 for 循环遍历时:可以倒着遍历并删除;或者从前往后遍历,但删除元素后做 i-- 操作。
// 倒着遍历并删除
for (int i = list.size() - 1; i >= 0; i--) {
    if ("java".equals(list.get(i))) {
        list.remove(i);
    }
}

// 从前往后遍历,删除后 i--
for (int i = 0; i < list.size(); i++) {
    if ("java".equals(list.get(i))) {
        list.remove(i);
        i--;
    }
}

(补充说明)这个异常的名字是 ConcurrentModificationException;它并不是「多线程才有的问题」,单线程里边迭代边做结构性修改同样会触发——它的作用就是尽早暴露误用,不是线程安全保障。

10. 作业与练习

作业文档:day06-异常、Collection/day06作业/作业06/day06作业.md

一、选择题(12 题)——覆盖本日全部知识块:异常分类、所有异常的父类、try…catch…finally 结构(可以有多个 catch、可以没有 catch、finally 不是必需)、catch 子类在前导致的编译错误、throws Exception 该加在哪几行、迭代器说法辨析、泛型集合 ArrayList<String> 里 add(true) 的编译失败、遍历删除 "java" 的正确写法(倒序遍历)、List 的遍历方式、增强 for 的写法、迭代器遍历中 add 触发 ConcurrentModificationException、ArrayList 与 LinkedList 的对比。

二、今日单词:Exception、RuntimeException、throws、throw、List、Set、Collection、ArrayList、LinkedList、HashSet、LinkedHashSet、TreeSet。

三、简答题(6 题):什么是异常、为什么要有这么多异常类;异常的体系结构涉及哪些类;List 与 Set 的区别;什么是迭代器、什么是并发修改异常、如何解决;ArrayList 的底层数据结构与特点;LinkedList 的底层数据结构与特点。

四、排错题(4 题):

  • 迭代器遍历中写 list.remove(str) 会抛 ConcurrentModificationException,应改成 it.remove();
  • 数组越界:arr 的最大索引为 2 却取了索引 3;
  • finally 中的语句总会执行,即使前面抛出了空指针异常且没有 catch;
  • 重写方法不能抛出更宽的编译时异常(父类方法没声明 ParseException 时,子类重写方法不能抛出它)。

五、代码题(3 题):

  • 第一题 抓阄游戏:5 个名称集合 + 5 个阄集合,遍历名称集合,随机生成索引从阄集合中取出并删除该元素,输出结果——练 List 的随机索引访问与删除;
  • 第二题 斗地主发牌:创建 4 个 List(3 个玩家 + 底牌),遍历牌盒把牌存入对应集合,看牌即遍历集合;【选做】对每人手上的牌按 [3,4,5,6,7,8,9,10,J,Q,K,A,2,王] 排序;
  • 第三题【选做】ATM 取钱金额校验:在 AtmServiceImpl 的 drawMoney 方法中加入校验逻辑,提示信息放 Entry 类,考虑使用自定义异常。

11. 复习要点

  1. 异常的根接口/根类是 Throwable;Error 是系统级严重问题、开发人员不管,Exception 才是程序员要封装的程序问题;Exception 下再分受检(编译时)与非受检(运行时)两支。
  2. 判断标准:继承自 RuntimeException 的是运行时异常(编译不报错、运行才出问题),没有继承 RuntimeException 的是编译时异常(编译阶段就必须处理或声明)。课件把编译时异常说成「编译阶段就会出错」,更准确的说法是「编译器强制要求你 try/catch 或 throws」。
  3. 异常的两个作用:查找 Bug 的关键参考信息、作为方法内部的特殊返回值通知上层调用者;处理方式有捕获并提示、捕获并修复、向上抛出三种,工程默认姿势是「底层抛、最外层集中捕获」。
  4. 自定义异常两条路线:继承 Exception(提醒强烈、必须处理)或继承 RuntimeException(编译不报错、运行才暴露),都要重写构造器并用 throw new 异常类(xxx) 抛出。
  5. Collection 是单列集合的祖宗,Map 是双列集合;List = 有序 + 可重复 + 有索引,Set = 不重复 + 无索引(HashSet 无序、LinkedHashSet 有序、TreeSet 可排序)。注意三种「序」的含义不同。
  6. 三种遍历:迭代器(iterator() / hasNext() / next(),越界取元素抛 NoSuchElementException)、增强 for(本质是迭代器的简化写法,改循环变量不影响集合元素)、Lambda(forEach);普通 for 只有 List 能用,因为只有它有索引。
  7. ArrayList 基于数组:按索引查询快(地址值 + 索引定位),增删需要移动元素且可能扩容;无参构造默认空数组、第一次 add 分配长度 10、满后扩 1.5 倍、一次加太多则按实际长度。适合「按索引查询、数据量不大」,不适合「数据量大又频繁增删」。
  8. LinkedList 基于双链表:结点不连续、含数据值与前后结点地址,查询慢、增删相对快、首尾操作极快;可用来实现队列(先进先出)与栈(后进先出)。
  9. HashSet 基于哈希表:JDK 8 之前是数组 + 链表,JDK 8 开始是数组 + 链表 + 红黑树;流程是「哈希值算位置 → 空位直接存 → 非空用 equals 比属性值 → 相等不存、不等挂链」;默认 16 长度 + 0.75 加载因子,存到 12 翻倍扩容,链表长度 > 8 且数组长度 ≥ 64 转红黑树。
  10. 想让 Set 认为内容相同的两个对象重复,必须重写 hashCode() 和 equals();LinkedHashSet 在哈希表之外用双链表记住添加顺序;TreeSet 默认不可排自定义类型,需要用 Comparable(compareTo)或 Comparator(构造器)指定规则,返回 0 即视为重复只保留一个。
  11. 迭代器遍历(含增强 for)时用集合自身的方法删元素会抛 ConcurrentModificationException;解决办法是用 Iterator.remove(),或改用倒序 for 删除、正序删除后补 i--。

day07|java.util 包下的常用 API:集合(Collections、Map、Stream 流)

本日接着 day06 的集合往下走,由两块内容组成。第一块是 Collection 的补充知识:可变参数(让方法灵活接收个数不定的数据)、Collections 工具类(批量添加、打乱、排序)、以及把这三样东西串起来用的综合案例(斗地主)。第二块是 Map 双列集合:为什么需要”键值对”这种数据结构、Map 的常用方法与三种遍历方式、HashMap / LinkedHashMap / TreeMap 三种实现各自的特点与底层原理、最后用”集合的嵌套”表达一对多关系。学完后应能回答:Collections.sort() 的两种排序规则分别怎么写、Map 为什么不能直接用增强 for 遍历、自定义对象做 HashMap 的键为什么必须重写 hashCode() 与 equals()、TreeMap 排序排的是键还是值。

本日 PPT 目录(第 2 页 / 第 10 页):Collection 的其他相关知识(前置知识:可变参数 → Collections → 综合案例)→ Map 集合(概述 → 常用方法 → 遍历方式 → HashMap → LinkedHashMap → TreeMap → 补充知识:集合的嵌套)。

四点说明(供复习时核对):

  • 课件第 1 页标题是「集合进阶(二)」,全篇共 56 页;本日 PPT 讲的就是 Collection 的补充部分与整个 Map 体系。
  • 课件文件名里写了「Stream 流」,但本日 56 页幻灯片逐页核对后没有任何 Stream 内容(目录页第 2 页也只有上面两块)。Stream 流实际在 day08 课件《java.io包下的常用API-io流》第 1–12 页,讲义归入 day08 的文章,本文不臆造,详见第 12 章。
  • 课件的「常用方法」页在抽取文本中只留下 [表格] 占位、没有表体。本文中的表格是按课件表格的原文还原的(含方法签名与说明文字)。
  • 图片核对过程:候选图 25 张逐张打开查看后,只有第 9 页斗地主游戏界面与第 13 页电商购物车截图带有场景信息;其余分别是卡通人物/吉祥物/表情包(第 4、12、13、29、43、55 页)、纯装饰图标(第 20 页点赞手势、第 27 页对勾、第 43 页铅笔、“划重点”贴图),以及第 11 / 24 / 30 / 41 / 53 页的大字标题条(如”单列集合""Map 集合的遍历方式""集合的嵌套”)。按规范中”丢弃封面/章节分割页大字背景、卡通、表情包、纯装饰图标”的要求,本文只内嵌那两张场景截图;课件其余知识点均以文字/代码呈现(课堂演示代码在 IDE 中现场敲写,课件未配代码截图)。

1. 前置知识:可变参数

1.1 什么是可变参数

课件第 3 页的定义:可变参数就是一种特殊形参,定义在方法、构造器的形参列表里,格式是 数据类型... 参数名称;。

维度内容
特点可以不传数据给它;可以传一个或者同时传多个数据给它;也可以传一个数组给它
好处常常用来灵活的接收数据
注意事项 1可变参数在方法内部就是一个数组
注意事项 2一个形参列表中可变参数只能有一个
注意事项 3可变参数必须放在形参列表的最后面

课件第 4 页给的需求正是它的典型用法:定义一个求和方法,让它能算 1 个、2 个、3 个……n 个数据的和,甚至可以一个参数都不传就调用。如果不用可变参数,要么重载一堆方法,要么让调用方每次都自己 new 一个数组。

1.2 代码示例

import java.util.Arrays;

public class Test {
    public static void main(String[] args) {
        // 目标:掌握可变参数的使用。
        // 需求:求任意个整数数据的和。
        sum();                          // 可以不传参数
        sum(10);                        // 可以传一个参数
        sum(20, 15, 32, 100);           // 可以传多个参数
        sum(new int[]{10, 20, 30, 40}); // 也可以直接传一个数组
    }

    // 作用:接收数据非常灵活
    // 注意事项:可变参数在形参列表中只能出现一个,且必须放到形参列表的最后面
    public static void sum(int... nums) {
        // 本质:可变参数在方法内部就是一个数组
        System.out.println("个数:" + nums.length);
        System.out.println("内容:" + Arrays.toString(nums));
    }
}

2. Collections 工具类

2.1 定位

课件第 6 页:Collections 是一个用来操作集合的工具类,提供的是静态方法,所以直接用 Collections.方法名(...) 调用——注意它和接口 Collection 只差一个字母,前者是工具类,后者是单列集合的根接口。

2.2 常用静态方法(课件第 6 页)

方法名称说明
public static <T> boolean addAll(Collection<? super T> c, T... elements)给集合批量添加元素
public static void shuffle(List<?> list)打乱 List 集合中的元素顺序
public static <T> void sort(List<T> list)对 List 集合中的元素进行升序排序
public static <T> void sort(List<T> list, Comparator<? super T> c)对 List 集合中元素,按照比较器对象指定的规则进行排序

第 6 页同时强调了一句重要限制:Collections 只能支持对 List 集合进行排序(sort、shuffle 的形参就是 List,Set 集合没有索引、本身又可能无序,排不了)。作业一第 2 题(双色球)就利用了这个限制:先把 HashSet 里的号码倒进 ArrayList,再用 Collections.sort() 排序。

2.3 两种排序方式(课件第 7 页)

排序方式 1:默认规则

方法名称说明
public static <T> void sort(List<T> list)对 List 集合中元素按照默认规则排序

课件第 7 页的注意:本方法可以直接对自定义类型的 List 集合排序,但自定义类型必须实现了 Comparable 接口,指定了比较规则才可以。(否则编译不通过)

Comparable 的规则写成口诀就是:compareTo() 里把左边对象当成 this、右边对象当成参数 o,认为左 > 右就返回正整数、左 < 右就返回负整数、相等返回 0,集合就会按这个规则升序排列。

排序方式 2:比较器规则

方法名称说明
public static <T> void sort(List<T> list, Comparator<? super T> c)对 List 集合中元素,按照比较器对象指定的规则进行排序

方式 2 把比较规则从类里搬到调用处,好处是不用为了换个排序字段就去改实体类,配合 Lambda 写起来更短。

2.4 代码示例

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * 目标:掌握 Collections 集合工具类的使用。
 */
public class CollectionsTest1 {
    public static void main(String[] args) {
        // 1、public static <T> boolean addAll(Collection<? super T> c, T... elements):为集合批量添加数据
        List<String> names = new ArrayList<>();
        Collections.addAll(names, "张无忌", "赵敏", "小昭", "殷素素");
        System.out.println(names);

        // 2、public static void shuffle(List<?> list):打乱 List 集合中的元素顺序
        Collections.shuffle(names);
        System.out.println(names);

        List<Student> students = new ArrayList<>();
        Student s1 = new Student("赵敏", 19, 169.5);
        Student s2 = new Student("周芷若", 18, 171.5);
        Student s3 = new Student("周芷若", 18, 171.5);
        Student s4 = new Student("小昭", 17, 165.5);
        Collections.addAll(students, s1, s2, s3, s4);

        // 3、public static <T> void sort(List<T> list):对 List 集合中的元素进行升序排序
        //    方式一:让对象的类实现 Comparable 接口,重写 compareTo 方法,指定大小比较规则
        Collections.sort(students);
        System.out.println(students);

        // 4、public static <T> void sort(List<T> list, Comparator<? super T> c)
        //    方式二:指定 Comparator 比较器对象,再指定比较规则(这里按身高降序)
        Collections.sort(students, (o1, o2) -> Double.compare(o2.getHeight(), o1.getHeight()));
        System.out.println(students);
    }
}
// 方式一:让自定义的类实现比较规则接口
public class Student implements Comparable<Student> {
    private String name;
    private int age;
    private double height;

    // s1.compareTo(s2):左边对象 s1 就是 this,右边对象 s2 就是参数 o
    @Override
    public int compareTo(Student o) {
        // 制定比较规则:按照年龄排序
        // 官方规则:认为左边 > 右边返回正整数;左边 < 右边返回负整数;相等返回 0(自动升序)
        // return this.age - o.age;  // 升序
        return o.age - this.age;     // 降序
    }

    public Student() {
    }

    public Student(String name, int age, double height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    // 此处省略 getName()/setName()、getAge()/setAge()、getHeight()/setHeight() 与 toString()
}

3. 综合案例:斗地主

3.1 业务需求(课件第 9 页)

在启动游戏房间的时候,应该提前准备好 54 张牌;接着,需要完成洗牌、发牌、对牌排序、看牌。

课件的业务分析:

  • 总共有 54 张牌:点数 "3","4","5","6","7","8","9","10","J","Q","K","A","2"(13 个)× 花色 "♠","♥","♣","♦"(4 个)= 52 张,再加上大小王 "👲"、"🃏"。
  • 斗地主:发出 51 张牌,剩下 3 张做为底牌。

斗地主游戏界面,三家各 17 张手牌,另留 3 张底牌

3.2 实现思路

课件把”分析实现”拆成”先设计对象、再分步骤操作”,配套示例工程里落成了两个类:

  • Card(牌):一张牌有 number(点数)、color(花色),再加一个 size(大小权重,用来排序)。
  • Room(房间):持有 ArrayList<Card> allCards,在实例初始化块里造好 54 张牌,start() 里依次做洗牌 → 发牌 → 排序 → 看牌。

发牌的思路是:把洗好的牌按索引轮流发给三个玩家(i % 3 决定发给谁),循环条件 i < allCards.size() - 3 保证最后 3 张不发出去,再用 subList() 取出底牌。

排列手牌时用到方式 2 的 Collections.sort(list, Comparator),比较的是牌的 size 权重,而不是点数/花色的字符串。示例工程里用 o2.getSize() - o1.getSize() 得到了从大到小的顺序。

3.3 代码示例

// 牌的实体类(示例工程用 Lombok 简化了 get/set 与构造器)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Card {
    private String number; // 点数
    private String color;  // 花色
    private int size;      // 大小权重,用于排序

    @Override
    public String toString() {
        return number + color;
    }
}
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

// 房间对象
public class Room {
    // 1、准备一副牌
    private ArrayList<Card> allCards = new ArrayList<>();

    // 2、初始化 54 张牌
    {
        // 3、准备点数:个数确定,类型确定
        String[] numbers = {"3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "2"};
        // 4、准备花色:个数确定,类型确定
        String[] colors = {"♠", "♥", "♣", "♦"};

        // 5、组装每一张牌对象
        int size = 0;
        for (String number : numbers) {
            size++;
            for (String color : colors) {
                // 6、创建牌对象封装这张牌的数据
                Card c = new Card(number, color, size);
                // 7、加入到集合中去
                allCards.add(c);
            }
        }
        // 8、单独加入大小王
        allCards.add(new Card("", "🃏", ++size));
        allCards.add(new Card("", "👲", ++size));
        System.out.println("新牌是:" + allCards);
    }

    public void start() {
        // 9、洗牌
        Collections.shuffle(allCards);
        System.out.println("洗牌后:" + allCards);

        // 10、发牌:定义三个玩家
        List<Card> lhc = new ArrayList<>();
        List<Card> ryy = new ArrayList<>();
        List<Card> dfbb = new ArrayList<>();

        // 发出去 51 张,最后 3 张留作底牌
        for (int i = 0; i < allCards.size() - 3; i++) {
            Card c = allCards.get(i);
            if (i % 3 == 0) {
                lhc.add(c);
            } else if (i % 3 == 1) {
                ryy.add(c);
            } else {
                dfbb.add(c);
            }
        }

        // 拿到最后三张底牌
        List<Card> lastThreeCards = allCards.subList(allCards.size() - 3, allCards.size());
        // 抢地主:底牌归地主
        ryy.addAll(lastThreeCards);

        // 11、对牌排序
        sortCards(lhc);
        sortCards(ryy);
        sortCards(dfbb);

        // 12、看牌
        System.out.println("啊冲:" + lhc);
        System.out.println("啊盈:" + ryy);
        System.out.println("啊败:" + dfbb);
    }

    public void sortCards(List<Card> cards) {
        // 方式二:把比较规则写在调用处
        Collections.sort(cards, new Comparator<Card>() {
            @Override
            public int compare(Card o1, Card o2) {
                return o2.getSize() - o1.getSize();
            }
        });
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:完成斗地主案例的实现。
        // 1、每张牌是一个对象,设计一个牌类
        // 2、设计一个房间类,用于创建房间对象、开启游戏
        Room r = new Room();
        r.start();
    }
}

(补充说明)subList(from, to) 返回的是原 List 的一个视图,对它的结构修改会影响原集合;本案例只取出来读,所以安全。

4. Map 集合概述

4.1 什么是 Map 集合(课件第 12 页)

课件先在第 11 页用一组”脉劫 / 康帅傅 / 粤利粤”的山寨商品做引子,再给出 Map 的定义:

  • Map 集合称为双列集合,格式:{key1=value1, key2=value2, key3=value3, ...},一次需要存一对数据做为一个元素。
  • Map 集合的每个元素”key=value”称为一个键值对 / 键值对对象 / 一个 Entry 对象,Map 集合也被叫做”键值对集合”。
  • Map 集合的所有键是不允许重复的,但值可以重复;键和值是一一对应的,每一个键只能找到自己对应的值。

一句话总结课件的第 11–12 页对照:Collection 是单列集合,一次存一个元素;Map 是双列集合,一次存”键 + 值”两个对象,但整体仍是一个元素。

4.2 什么时候用 Map(课件第 13 页)

课件给的判断标准只有一句:需要存储一一对应的数据时,就可以考虑使用 Map 集合来做。

课件举的例子是购物车场景 {商品1=2, 商品2=3, 商品3=2, 商品4=3}——商品是键、数量是值,“同一件商品的数量”正是一一对应的数据:

电商购物车截图,每件商品对应一个购买数量,正是 Map 的键值对场景

4.3 Map 集合体系(课件第 14 / 33 / 45 / 48 页)

Map<K, V>                       ← 接口(双列集合的祖宗)
├── HashMap<K, V>               ← 实现类,用得最多
├── LinkedHashMap<K, V>         ← 实现类
└── TreeMap<K, V>               ← 实现类
实现类(由键决定)特点课件备注
HashMap无序、不重复、无索引用的最多
LinkedHashMap有序、不重复、无索引有序指保留添加顺序
TreeMap按照键的大小默认升序排序、不重复、无索引只能对键排序

课件特别强调:Map 系列集合的特点都是由键决定的,值只是一个附属品,值是不做要求的。 这句话解释了为什么后面讲底层原理时,说的都是”键怎么算哈希、键怎么比较大小”。

4.4 小结自测(课件第 15 页)

  • Map 集合是什么?——键值对集合。
  • 什么时候考虑使用?——需要存储一一对应的数据时。
  • 有哪些实现类、各自特点?——HashMap:键无序、不重复、无索引;LinkedHashMap:键有序、不重复、无索引;TreeMap:键排序、不重复、无索引。(三者的值都不做要求)

5. Map 的常用方法

5.1 为什么要先学 Map 的方法(课件第 17 页)

课件的解释是:Map 是双列集合的祖宗,它的功能是全部双列集合都可以继承过来使用的。 也就是说 HashMap、LinkedHashMap、TreeMap 的增删改查方法都来自 Map,先把这一套学会,后面三个实现类就只需要记”特点差别”。

5.2 常用方法(课件第 18 页)

方法名称说明
public V put(K key, V value)添加元素
public int size()获取集合的大小
public void clear()清空集合
public boolean isEmpty()判断集合是否为空,为空返回 true,反之
public V get(Object key)根据键获取对应值
public V remove(Object key)根据键删除整个元素
public boolean containsKey(Object key)判断是否包含某个键
public boolean containsValue(Object value)判断是否包含某个值
public Set<K> keySet()获取全部键的集合
public Collection<V> values()获取 Map 集合的全部值

两个容易踩的点:

  • get() 找不到键时返回 null,但存进去的值为 null 时也返回 null,所以不能只凭 get() == null 判断键是否存在,要判断键在不在应该用 containsKey()。
  • put() 遇到已经存在的键会覆盖(课件示例注释原话:“后面重复的键会覆盖前面整个数据!”);HashMap 的键和值都允许为 null。

5.3 代码示例一:Map 集合的特点

import java.util.HashMap;
import java.util.Map;

public class MapDemo1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的特点
        // 特点:按照键,无序、不重复、无索引;值不做要求,键和值都可以是 null
        Map<String, Integer> map = new HashMap<>(); // 多态,一行经典代码
        map.put("华为手表", 31);
        map.put("iphone15", 1);
        map.put("mete60", 10);
        map.put("Java入门到跑路", 2);
        map.put("iphone15", 31); // 后面重复的键会覆盖前面整个数据!
        map.put("娃娃", 1);
        map.put("雨伞", 10);
        map.put(null, null);
        // {Java入门到跑路=2, null=null, iphone15=31, 雨伞=10, mete60=10, 华为手表=31, 娃娃=1}
        System.out.println(map);
    }
}

5.4 代码示例二:常用 API 逐个演示

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的常用 API(重点)
        Map<String, Integer> map = new HashMap<>();
        map.put("手表", 2);
        map.put("iphone", 31);
        map.put("huawei", 365);
        map.put("iphone", 1);   // 覆盖上面 iphone=31
        map.put("娃娃", 31);
        map.put("Java入门", 1);
        map.put(null, null);
        System.out.println(map);
        // map = {null=null, 手表=2, huawei=365, Java入门=1, 娃娃=31, iphone=1}

        // 1、获取集合的大小(元素个数)
        System.out.println(map.size());   // 6

        // 2、清空集合
        // map.clear();

        // 3、判断集合是否为空
        System.out.println(map.isEmpty());

        // 4、根据键获取对应的值(重点)
        System.out.println(map.get("手表"));   // 2
        System.out.println(map.get("手表2"));  // 没有这个键就返回 null

        // 5、根据键删除整个数据,返回删除数据对应的值(重点)
        System.out.println(map.remove("娃娃")); // 31
        System.out.println(map);

        // 6、判断是否包含某个键(重点)
        System.out.println(map.containsKey("娃娃"));    // false
        System.out.println(map.containsKey("huawei"));  // true
        System.out.println(map.containsKey(null));      // true

        // 7、判断是否包含某个值
        System.out.println(map.containsValue(1));       // true
        System.out.println(map.containsValue("1"));     // false(类型不同)

        // 8、获取 Map 集合的全部键,返回一个 Set 集合
        Set<String> keys = map.keySet();
        for (String key : keys) {
            System.out.println(key);
        }

        // 9、获取 Map 集合的全部值,返回一个 Collection 集合
        Collection<Integer> values = map.values();
        for (Integer value : values) {
            System.out.println(value);
        }
    }
}

6. Map 集合的遍历方式

课件第 20 页把遍历方式归成三种:① 键找值;② 键值对(把”键值对”看成一个整体进行遍历,难度较大);③ Lambda(JDK 1.8 开始之后的新技术,非常的简单)。

6.1 方式一:键找值(先用 keySet 再 get)

需要用到的方法(课件第 21 页):

方法名称说明
public Set<K> keySet()获取所有键的集合
public V get(Object key)根据键获取其对应的值

流程(课件第 22 页用”编号丈夫 ↔ 编号妻子”打比方):先从 Map 里拿出全部键(Set 集合,可以用增强 for 遍历),然后对每个键调一次 get() 把值找出来。缺点很明显:遍历一次要额外调用一次 get() 去查表。

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式一:键找值
        Map<String, Integer> map = new HashMap<>(); // 一行经典代码
        map.put("iphone", 6);
        map.put("小米", 3);
        map.put("huawei", 3);
        map.put("诺基亚", 31);
        System.out.println(map); // {huawei=3, 诺基亚=31, iphone=6, 小米=3}

        // 1、获取 map 集合的全部键
        Set<String> keys = map.keySet();
        // 2、根据键提取值
        for (String key : keys) {
            Integer value = map.get(key);
            System.out.println(key + "====>" + value);
        }
    }
}

6.2 方式二:键值对(entrySet + Map.Entry)

课件第 24 页先演示了一个”走不通”的写法:想直接用增强 for 遍历 Map 本身——

for (元素类型 kv : map) {   // 编译不通过
    ...
}

原因是课件第 24 页给出的那句话:元素类型无法直接确定! Map 里的一个元素是”键 + 值”两段数据,Java 里没有这样一个现成的类型可以直接写在 for 的括号里。解决办法是先调 entrySet() 把 Map 转成 Set 集合,集合里的元素类型就明确为 Map.Entry<K, V> 了。

需要用到的方法(课件第 25 页):

Map 提供的方法说明
Set<Map.Entry<K, V>> entrySet()获取所有”键值对”的集合
Map.Entry 提供的方法说明
K getKey()获取键
V getValue()获取值
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式二:键值对遍历(难度大点)
        Map<String, Integer> map = new HashMap<>();
        map.put("蜘蛛精", 1000);
        map.put("小龙女", 23);
        map.put("木婉清", 31);
        map.put("黄蓉", 35);
        System.out.println(map);
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}

        // 1、直接增强 for 遍历 Map 集合是不行的,因为键值对直接来看是不存在数据类型的

        // 2、调用 entrySet() 把 Map 集合转换成 Set 集合来遍历
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}
        //   ↓ map.entrySet()
        // Set<Map.Entry<String, Integer>> entries = [(蜘蛛精=1000), (小龙女=23), (黄蓉=35), (木婉清=31)]
        //                                                     ↑ 每个元素就是一个 Entry 对象
        Set<Map.Entry<String, Integer>> entries = map.entrySet();
        for (Map.Entry<String, Integer> entry : entries) {
            String key = entry.getKey();
            Integer value = entry.getValue();
            System.out.println(key + " ===> " + value);
        }
    }
}

6.3 方式三:Lambda(forEach)

需要用到的方法(课件第 29 页):

方法名称说明
default void forEach(BiConsumer<? super K, ? super V> action)结合 lambda 遍历 Map 集合

BiConsumer 是”接收两个参数、没有返回值”的函数式接口,两个参数正好对应键和值,所以 Lambda 里写 (k, v) -> {...} 就行:

import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;

public class MapDemo3 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式三:Lambda
        Map<String, Integer> map = new HashMap<>();
        map.put("蜘蛛精", 1000);
        map.put("小龙女", 23);
        map.put("木婉清", 31);
        map.put("黄蓉", 35);
        System.out.println(map);
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}

        // 匿名内部类写法
        map.forEach(new BiConsumer<String, Integer>() {
            @Override
            public void accept(String key, Integer value) {
                System.out.println(key + "===>" + value);
            }
        });

        // Lambda 写法
        map.forEach((key, value) -> {
            System.out.println(key + "===>" + value);
        });
    }
}

三种方式怎么选:只要键用 keySet();键和值都要用用 entrySet()(省掉一次 get() 查表);只是对每个键值对做一件事(打印、累加等)用 forEach() 最省事。

7. 案例:统计投票人数

7.1 需求与分析(课件第 30–31 页)

某个班级 80 名学生,现在需要组织秋游活动,班长提供了四个景点依次是(A、B、C、D),每个学生只能选择一个景点,请统计出最终哪个景点想去的人数最多。

课件的分析步骤把”Map 做计数”的套路写得很清楚:

  1. 将 80 个学生选择的数据拿到程序中去,形如 [A, A, B, A, B, C, D, ...];
  2. 准备一个 Map 集合用于存储统计结果,Map<String, Integer>,键是景点,值代表投票数量;
  3. 遍历这 80 个选择,每遍历一个景点,就看 Map 中是否存在该景点:不存在就存入”景点=1”,存在则把它对应的值 +1。

7.2 代码示例

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

public class MapTest4 {
    public static void main(String[] args) {
        // 目标:使用 Map 集合完成投票案例
        // 1、把 80 个学生选择的景点造出来
        String[] locations = {"玉龙雪山", "伶仃岛", "三亚", "泰国"};

        // 2、定义一个 List 集合,随机挑选 80 个学生想去的景点
        List<String> data = new ArrayList<>();
        Random r = new Random();
        for (int i = 0; i < 80; i++) {
            data.add(locations[r.nextInt(locations.length)]);
        }
        System.out.println(data);

        // 3、定义一个 Map 集合,键存储景点,值存储想去的人数
        Map<String, Integer> map = new HashMap<>();
        for (String s : data) {
            // 4、判断 map 集合中是否出现了这个景点键,如果没有出现,说明是第一次添加
            if (map.containsKey(s)) {
                // 之前统计过,其值 +1
                map.put(s, map.get(s) + 1);
            } else {
                // 第一次添加
                map.put(s, 1);
            }
        }

        map.forEach((s, n) -> {
            System.out.println(s + "选择人数是:" + n);
        });
    }
}

统计结束后想知道”哪个景点人最多”,需要再比较一次各个 value(Map 本身没有”按值排序”的功能,TreeMap 也只排键)。

8. HashMap 的底层原理

8.1 与 HashSet 的关系(课件第 34 页)

课件第 34 页一句话把两个集合打通了:

  • HashMap 跟 HashSet 的底层原理是一模一样的,都是基于哈希表实现的。
  • 实际上:原来学的 Set 系列集合的底层就是基于 Map 实现的,只是 Set 集合中的元素只要键数据,不要值数据而已。

课件给出的源码印证是 HashSet 的构造器:

public HashSet() {
    map = new HashMap<>();
}

也就是说,HashSet 里其实”包”了一个 HashMap,往 HashSet 里加的元素被当成了 HashMap 的键。哈希表本身是一种增删改查数据,性能都较好的数据结构。

8.2 哈希表的组成(课件第 34–40 页)

版本哈希表 =
JDK8 之前数组 + 链表
JDK8 开始数组 + 链表 + 红黑树

课件用第 35–40 页的连续动画演示了 map.put() 的过程,关键规则有三条:

  1. 利用键计算哈希值,跟值无关——先算出键的哈希值,再换算成数组下标,把该键值对放进对应的桶里。
  2. 同一个下标上如果已经有元素,就以链表形式挂在后面(课件里用一串 null 表示空桶,桶被占用后挂成链)。
  3. 链表长度超过 8 且数组长度 >= 64 时,自动转成红黑树——这就是 JDK8 相比 JDK7 的最大改动,避免极端情况下链表过长导致查询退化。

8.3 HashMap 的特点与键唯一性(课件第 41–42 页)

  • 特点:无序、不重复、无索引(由键决定特点),但增删改查性能都较好。
  • HashMap 的键依赖 hashCode 方法和 equals 方法保证键的唯一。
  • 如果键存储的是自定义类型的对象,需要重写 hashCode() 和 equals() 方法,这样才能在多个对象内容一样时,让 HashMap 认为是重复的键。

(补充说明)课件第 42 页的自测问答就是这两条,背诵版为:“由键决定:无序、不重复、无索引,底层是哈希表结构,增删改查性能都较好”;“键的唯一性靠 hashCode 与 equals,自定义对象作键必须重写这两个方法”。

8.4 案例:HashMap 存储自定义对象并遍历(课件第 43 页)

需求:创建一个 HashMap 集合,键是学生对象(Student),值是籍贯(String)。存储三个键值对元素,并遍历。

课件给的思路是标准五步:定义学生类 → 创建 HashMap 集合对象 → 创建学生对象 → 把学生添加到集合 → 遍历集合。配套示例工程把”键是自定义对象”这件事演示得更彻底,用的键是 Movie(电影名、评分、主演):

import java.util.HashMap;
import java.util.Map;

public class MapTest1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合(键)去重复的机制
        Map<Movie, String> map = new HashMap<>();
        map.put(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"), "19:00");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "20:50");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "21:50"); // 与上一行内容相同 → 覆盖
        map.put(new Movie("阿甘正传", 7.5, "汤姆汉克斯"), "21:00");
        System.out.println(map);
    }
}
@Data // @Data 自带无参构造 + get/set + toString + hashCode + equals
@NoArgsConstructor
@AllArgsConstructor
public class Movie implements Comparable<Movie> {
    private String name;
    private double score;
    private String actor;

    @Override
    public int compareTo(Movie o) {
        return Double.compare(this.score, o.score);
    }
}

示例里的 Movie 有 Lombok 的 @Data 注解,它自动生成的 hashCode() / equals() 就是”三个属性都相同即视为同一个键”——所以第二个”三傻宝莱坞”把第一个覆盖掉了(只留下 21:50)。如果这里不重写 hashCode/equals,HashMap 就会把两个 new 出来的对象当成两个不同的键,去重逻辑失效。

9. LinkedHashMap 的原理

9.1 原理(课件第 46 页)

课件的原话:底层数据结构依然是基于哈希表实现的,只是每个键值对元素又额外多了一个双链表的机制记录元素顺序(保证有序)。实际上:原来学习的 LinkedHashSet 集合的底层原理就是 LinkedHashMap。

也就是说:LinkedHashMap = HashMap 的哈希表结构 + 一条贯穿所有 Entry 的双链表。“有序”指的就是这条双链表维持的添加顺序,同样由键决定特点(有序、不重复、无索引),值不做要求。

9.2 代码示例

import java.util.LinkedHashMap;
import java.util.Map;

public class MapTest2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的特点
        // LinkedHashMap:按照键有序(保留添加顺序)、不重复、无索引;值不做要求,键和值都可以是 null
        Map<String, Integer> map = new LinkedHashMap<>();
        map.put("手表", 2);
        map.put("iphone", 31);
        map.put("huawei", 365);
        map.put("iphone", 1);
        map.put("娃娃", 31);
        map.put("Java入门", 1);
        map.put(null, null);
        // {手表=2, iphone=1, huawei=365, 娃娃=31, Java入门=1, null=null}
        System.out.println(map);
    }
}

对照上面 MapDemo1(HashMap)的输出 {Java入门到跑路=2, null=null, iphone15=31, ...} 可以看到:同样的 put 顺序,HashMap 打印出来是”乱”的,LinkedHashMap 打印出来就是添加顺序。另外注意 iphone 的值被后一次 put 改成了 1,但它的位置仍停在第一次加入的位置(修改不算新插入)。

10. TreeMap

10.1 特点与原理(课件第 49 页)

  • 特点:不重复、无索引、可排序(按照键的大小默认升序排序,只能对键排序)。
  • 原理:TreeMap 跟 TreeSet 集合的底层原理是一样的,都是基于红黑树实现的排序。

10.2 两种指定排序规则的方式(课件第 49–50 页)

方式做法
方式一让类实现 Comparable 接口,重写比较规则(compareTo)
方式二TreeMap 有一个有参数构造器,支持创建 Comparator 比较器对象,用来指定比较规则

课件第 50 页的自测答案就是这两种;效果上,方式二传给构造器的比较器优先级更高(同时存在时会覆盖类里的自然排序)。

import java.util.Map;
import java.util.TreeMap;

public class MapTest3 {
    public static void main(String[] args) {
        // 目标:了解 TreeMap 集合:按照键升序排序,不重复,无索引
        // 方式二:TreeMap 集合可以自带比较器对象指定比较规则
        Map<Movie, String> map = new TreeMap<>(
                (m1, m2) -> Double.compare(m2.getScore(), m1.getScore())); // 按评分降序

        // 方式一:Movie 类实现 Comparable 接口指定比较规则(见第 8.4 节的 Movie)
        map.put(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"), "19:00");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "20:50");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "21:50");
        map.put(new Movie("阿甘正传", 7.5, "汤姆汉克斯"), "21:00");
        System.out.println(map);
    }
}

10.3 案例:TreeMap 练习(课件第 51 页)

需求:创建一个 TreeMap 集合,键是学生对象(Student),值是籍贯(String)。学生属性姓名和年龄,按照年龄进行排序并遍历。

思路同样是五步:定义学生类 → 创建 TreeMap 集合对象 → 创建学生对象 → 把学生添加到集合 → 遍历集合。关键点在于”按年龄排序”必须落成键的比较规则——要么 Student implements Comparable<Student> 并在 compareTo() 里比较年龄,要么用 new TreeMap<>((s1, s2) -> s1.getAge() - s2.getAge()):

// 按课件需求整理的写法(课堂在 IDE 中演示)
Map<Student, String> map = new TreeMap<>((s1, s2) -> s1.getAge() - s2.getAge());
map.put(new Student("张三", 18, 171.5), "北京");
map.put(new Student("李四", 20, 180.5), "上海");
map.put(new Student("王五", 19, 165.5), "广州");
map.forEach((student, place) -> System.out.println(student.getName() + "->" + place));
// 输出顺序:张三(18) → 王五(19) → 李四(20)

(补充说明)TreeMap 判断”键重复”用的是比较结果是否为 0,而不是 equals();如果比较规则只比年龄,那么年龄相同的两个不同学生会被当成同一个键而被丢掉。

11. 补充知识:集合的嵌套

11.1 什么是集合的嵌套(课件第 53 页)

课件给的一句话定义:集合的嵌套指的就是集合中的元素又是一个集合。 在 Map 体系里最常用的两种嵌套是:

  • Map<String, List<String>>:一个键对应多个值(一对多),例如”省份 → 城市列表”。
  • Map<String, Map<String, String>>:值本身又是一个 Map,例如”班级 → (学号 → 姓名)”。

11.2 案例:省和市(课件第 54 页)

需求:要求在程序中记住如下省份和其对应的城市信息,记录成功后,要求可以查询出湖北省的城市信息。

  • 江苏省 = 南京市,扬州市,苏州市,无锡市,常州市
  • 湖北省 = 武汉市,孝感市,十堰市,宜昌市,鄂州市
  • 河北省 = 石家庄市,唐山市,邢台市,保定市,张家口市

课件的分析:定义一个 Map 集合,键表示省份名称,值表示城市名称,注意城市会有多个。 因为”城市会有多个”,所以值的类型要写成 List<String>;查询时用 provinces.get("湖北省") 拿到列表再展示。

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MapTest4 {
    public static void main(String[] args) {
        // 目标:掌握集合的嵌套(重点)
        // 1、定义一个 Map 集合存储全部省份和城市信息
        Map<String, List<String>> provinces = new HashMap<>();

        // 2、存入省份信息
        List<String> cities1 = new ArrayList<>();
        Collections.addAll(cities1, "南京市", "扬州市", "苏州市", "无锡市", "常州市");
        provinces.put("江苏省", cities1);

        List<String> cities2 = new ArrayList<>();
        Collections.addAll(cities2, "武汉市", "襄阳市", "孝感市", "十堰市", "宜昌市");
        provinces.put("湖北省", cities2);

        List<String> cities3 = new ArrayList<>();
        Collections.addAll(cities3, "石家庄市", "唐山市", "邢台市", "保定市", "张家口市");
        provinces.put("河北省", cities3);

        System.out.println(provinces);

        // 3、查询湖北省的城市信息
        List<String> hbcities = provinces.get("湖北省");
        for (String hbcity : hbcities) {
            System.out.println(hbcity);
        }
    }
}

课件第 54 页写的湖北省城市是”武汉市,孝感市,十堰市,宜昌市,鄂州市”,而配套示例工程里写的是”武汉市,襄阳市,孝感市,十堰市,宜昌市”(少了鄂州市)。两者不一致,以自己课堂实际演示的版本为准即可,本条不属于知识点。

Map 嵌套 Map 的写法见本日作业二第 2 题(学校 → 班级 → 学号 → 姓名),遍历套路是先遍历外层 Map 拿到内层 Map,再遍历内层。

12. 关于 Stream 流:本日课件核对说明

课件文件名是《day07、java.util包下的常用API-集合(Collections、Map、Stream流).pptx》,但本文逐页核对了该文件全部 56 页幻灯片,其中没有任何 Stream 相关内容:第 2 页目录只有”Collection 的其他相关知识(可变参数 / Collections / 综合案例)“和”Map 集合”两块,正文也止于第 54 页的”省和市”案例(第 53、55、56 页是演示用图)。

Stream 流的知识块——认识 Stream、Stream 流的常用方法(中间方法 / 终结方法)、收集 Stream 流——出现在 day08 课件《java.io包下的常用API-io流》第 1–12 页,按文章分工归入 day08,本文不重复展开、也不臆造内容。

(补充说明)day08 课件第 2 页对 Stream 的定义是:“也叫 Stream 流,是 JDK8 开始新增的一套 API(java.util.stream.*),可以用于操作集合或者数组的数据,大量结合 Lambda 语法风格编程,代码更简洁、可读性更好。“本章只做核对说明,具体 API 请对照 day08 的文章。

13. 作业与练习

作业文档路径题目与训练目标
作业 1day07-Collections、Map/作业/作业1/03_题目/作业1.md① 4 名学生按成绩从小到大排序(Comparator、增强 for、Collections);② 随机生成一注双色球,同色号码不重复(HashSet、ArrayList、Collections.sort);③ 京东注册用户重名判断(HashSet + 重写 hashCode/equals)
作业 2day07-Collections、Map/作业/作业2/04_题目/作业2.md① 统计售出商品的种类与数量(HashMap + HashMap 的遍历);② 用 HashMap 嵌套 HashMap 存储”班级 → 学号 → 姓名”;③ 把商品加入购物车,同店同商品数量加一,不同店铺视为新商品

几道题的关键点(都直接对应本日知识块):

  • 作业一 · 题目 1:Collections.sort(list, new Comparator<Student>() {...}),用 o1.getScore() - o2.getScore() 得到按分数升序;这就是第 2.3 节的”排序方式 2”。
  • 作业一 · 题目 2:先 HashSet 去重(while (hs.size() < 6) 反复生成直到凑够 6 个不重复红球),再把 HashSet 倒进 ArrayList 才能 Collections.sort() 排序——正好印证”Collections 只能对 List 排序”。
  • 作业一 · 题目 3:把”用户名 + 密码”封装成 User 对象放进 HashSet,靠 add() 的返回值判断成功失败(true 成功、false 已存在),前提是 User 重写了 hashCode() / equals()。
  • 作业二 · 题目 1:map.containsKey(goods) 为真则 map.put(goods, map.get(goods) + 1),否则 map.put(goods, 1);注意 end 要在计数之前判断跳出,否则会被当成一件商品。
  • 作业二 · 题目 2:HashMap<String, HashMap<String, String>>,外层键是班级(如”Java基础班”),内层键是学号(如 "001")——两个班都有学号 001,靠外层分组才不冲突。
  • 作业二 · 题目 3(购物车):用 Product(store, name) 作为键、数量作为值;先重写 hashCode()/equals() 让”同店同商品”算同一个键,再用 containsKey() 判断是新增(存 1)还是 get() 出来加一后重新 put()。这正是第 4.2 节”一一对应的数据”和第 8.3 节”自定义键要去重”两处知识点的合体题。

14. 复习要点

  1. 可变参数的格式是 数据类型... 参数名;它在方法内部就是一个数组,一个形参列表只能有一个且必须放最后。
  2. Collections 是操作集合的工具类(静态方法),能批量添加 addAll()、打乱 shuffle()、排序 sort(),但只能对 List 排序。
  3. Collections.sort() 的两种规则:类实现 Comparable 重写 compareTo();或传入 Comparator 对象——前者改类,后者改调用处。
  4. Map 是双列集合,元素是 Entry(键值对);键不重复、值可重复;需要”一一对应”的数据时就用它。
  5. Map 是双列集合的祖宗,常用方法:put/get/remove/size/clear/isEmpty/containsKey/containsValue/keySet/values;get() 返回 null 不能证明键不存在。
  6. Map 的三种遍历:keySet() + get()(键找值)、entrySet() + Entry.getKey()/getValue()(键值对)、forEach((k, v) -> ...)(Lambda);Map 不能直接增强 for 遍历。
  7. HashMap 底层是哈希表(JDK8 前 = 数组 + 链表;JDK8 起 = 数组 + 链表 + 红黑树,链表长度 > 8 且数组长度 >= 64 转红黑树);键的唯一性靠 hashCode() + equals(),自定义对象作键必须重写。
  8. HashSet 底层就是 HashMap(只要键不要值);LinkedHashSet 底层就是 LinkedHashMap。
  9. LinkedHashMap 在哈希表基础上多了双链表记录添加顺序(有序);TreeMap 基于红黑树,按键排序、不重复、无索引,只能对键排序,可用 Comparable 或 Comparator 指定规则。
  10. 集合的嵌套指”集合中的元素又是一个集合”:Map<String, List<String>> 表达一对多(省 → 城市),Map<String, Map<String, String>> 表达两层分组(班级 → 学号 → 姓名)。

day08|java.io 包下的常用 API:IO 流(File、方法递归、IO 流入门)

本日先把集合/数组的数据处理换成更简洁的 Stream 流水线写法,再进入 java.io 包:用 File 表示文件与文件夹、用方法递归遍历目录树,最后入门 IO 字节流(FileInputStream / FileOutputStream)与资源释放的两种写法。字符集(ASCII、GBK、Unicode、UTF-8)是理解 IO 乱码的前置知识,本日一并介绍。

学完应能:写出 Stream 的获取→处理→收集三段式;用 File 完成创建、判断、获取信息、删除、遍历;按”公式 + 终结点 + 走向终结点”三要素写递归;说明字节流读中文为什么会乱码,以及如何用 try-with-resource 释放资源。

1. Stream 流:更简单地操作集合与数组

1.1 什么是 Stream 流

也叫 Stream 流,是 JDK 8 开始新增的一套 API(java.util.stream.*),可以用于操作集合或者数组的数据。

优势:Stream 流大量结合了 Lambda 语法风格来编程,提供了一种更强大、更简单的方式操作集合或数组中的数据,代码更简洁、可读性更好。

一句话定位:Stream 流是操作集合/数组的手段,集合/数组本身才是开发的目的。

1.2 为什么需要 Stream:一个体验案例

需求:把集合中所有以”张”开头、且是 3 个字的元素存储到一个新集合中。

public class StreamTest1 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("张无忌");
        list.add("周芷若");
        list.add("赵敏");
        list.add("张强");
        list.add("张三丰");

        // 传统写法:手动遍历 + 判断 + 添加
        List<String> newList = new ArrayList<>();
        for (String s : list) {
            if (s.startsWith("张") && s.length() == 3) {
                newList.add(s);
            }
        }
        System.out.println(newList);

        // Stream 写法:一行完成"筛选 + 收集"
        List<String> newList2 = list.stream()
                .filter(s -> s.startsWith("张") && s.length() == 3)
                .collect(Collectors.toList());
        System.out.println(newList2); // [张无忌, 张三丰]
    }
}

1.3 Stream 流的使用步骤

步骤做什么说明
① 获取 Stream 流从数据源(集合、数组、…)拿到流Stream 流代表一条流水线,并与数据源建立连接
② 调用方法处理过滤、排序、去重、加工、…调用流水线的各种方法对数据进行处理、计算
③ 获取处理结果遍历、统计、收集收集到一个新集合或数组中返回

复习问答(PPT 原文):

  1. Stream 是什么?有什么作用?结合了什么技术?——简化集合、数组操作的 API,结合了 Lambda 表达式。
  2. Stream 流处理数据的步骤是什么?——先得到集合或数组的 Stream 流,再调用 Stream 流的方法处理数据,最后获取处理的结果。

1.4 获取 Stream 流

数据源获取方式
Collection 集合(List、Set…)default Stream<E> stream()
Map 集合keySet().stream() 键流、values().stream() 值流、entrySet().stream() 键值对流
数组Arrays.stream(数组) 或 Stream.of(数组)
零散数据Stream.of(T... values)
public class StreamTest2 {
    public static void main(String[] args) {
        // 1、获取集合的 Stream 流
        Collection<String> list = new ArrayList<>();
        Collections.addAll(list, "张无忌", "周芷若", "赵敏", "张强", "张三丰");
        Stream<String> listS = list.stream();

        // 2、获取 Map 集合的 Stream 流
        Map<String, Integer> map = new HashMap<>();
        Stream<String> keyS = map.keySet().stream();                        // 键流
        Stream<Integer> valueS = map.values().stream();                     // 值流
        Stream<Map.Entry<String, Integer>> entryS = map.entrySet().stream(); // 键值对流

        // 3、获取数组的 Stream 流
        String[] names = {"赵敏", "王菲", "小昭"};
        Stream<String> stream1 = Arrays.stream(names);
        Stream<String> stream2 = Stream.of(names);
    }
}

1.5 中间方法

中间方法指的是调用完成后会返回新的 Stream 流,可以继续使用(支持链式编程)。

方法作用
filter(Predicate)过滤,保留满足条件的元素
sorted() / sorted(Comparator)排序;元素类是自定义对象时,需实现 Comparable 或传入比较器
limit(long n)取前 n 个
skip(long n)跳过前 n 个
distinct()去重(对象去重要求重写 hashCode() 与 equals())
map(Function)加工:把流上的数据加工成新数据
Stream.concat(a, b)合并两个流
public class StreamTest3 {
    public static void main(String[] args) {
        // 1、过滤
        List<String> list = new ArrayList<>();
        Collections.addAll(list, "张无忌", "周芷若", "赵敏", "张强", "张三丰");
        list.stream().filter(s -> s.startsWith("张")).forEach(System.out::println);

        // 2、准备对象集合
        List<Movie> movies = new ArrayList<>();
        movies.add(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("阿甘正传", 7.5, "汤姆汉克斯"));

        // 排序:Movie 实现 Comparable 后,sorted() 即可按规则排序,否则报错
        movies.stream().sorted().forEach(System.out::println);
        // 也可以传入自定义比较器:按评分降序
        movies.stream().sorted((m1, m2) -> Double.compare(m2.getScore(), m1.getScore()))
                .forEach(System.out::println);

        // 3、limit 取前几个 / 4、skip 跳过前几个
        movies.stream().limit(2).forEach(System.out::println);
        movies.stream().skip(2).forEach(System.out::println);

        // 5、distinct 去重:需要对象类重写 hashCode 和 equals
        movies.stream().distinct().forEach(System.out::println);

        // 6、map 加工:把流上的数据加工成新数据
        movies.stream().map(m -> {
            m.setScore(m.getScore() + 2);
            return m;
        }).forEach(System.out::println);

        // 7、合并流,再统计个数
        Stream<String> s1 = Stream.of("张三", "楚留香", "西门吹牛");
        Stream<String> s2 = Stream.of("李四", "石观音");
        System.out.println(Stream.concat(s1, s2).count());
    }
}

1.6 终结方法

终结方法指的是调用完成后不会返回新 Stream,没法继续使用流了。

方法作用
forEach(Consumer)遍历
count()统计个数,返回 long
max(Comparator) / min(Comparator)取最大值 / 最小值,返回 Optional
public class StreamTest4 {
    public static void main(String[] args) {
        List<Movie> movies = new ArrayList<>();
        movies.add(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"));
        movies.add(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"));
        movies.add(new Movie("阿甘正传", 7.5, "汤姆汉克斯"));

        movies.stream().forEach(System.out::println);                 // 遍历
        System.out.println(movies.stream().skip(2).count());          // 统计

        Movie max = movies.stream()
                .max((m1, m2) -> Double.compare(m1.getScore(), m2.getScore())).get();
        Movie min = movies.stream()
                .min((m1, m2) -> Double.compare(m1.getScore(), m2.getScore())).get();
        System.out.println(max);
        System.out.println(min);
    }
}

1.7 收集 Stream 流

收集就是把 Stream 流操作后的结果转回到集合或者数组中去返回。

目标写法
Listcollect(Collectors.toList())
Setcollect(Collectors.toSet())
Mapcollect(Collectors.toMap(键函数, 值函数))
数组toArray();要指定数组类型可写 toArray(String[]::new)
// 收集到 List
List<String> names = list.stream().filter(s -> s.startsWith("张")).collect(Collectors.toList());
// 收集到 Set(自动去重)
Set<String> names2 = list.stream().filter(s -> s.startsWith("张")).collect(Collectors.toSet());
// 收集到数组
Object[] names3 = list.stream().filter(s -> s.length() == 3).toArray();
// 收集到 Map
Map<String, Double> map = movies.stream().distinct()
        .collect(Collectors.toMap(Movie::getName, Movie::getScore));

注意:同一条件收集到 List 与 Set 结果不同,Set 会去重;Collectors.toMap() 遇到重复键需要按业务指定合并规则,否则可能抛异常。(补充说明)

2. 数据的存储方案:从内存到文件

2.1 内存中的容器,断电就丢

PPT 用一个游戏”历史最高分”的例子引出问题:

存储方式示例特点
变量double money = 9999.5;只能存一个值
数组int[] age = new int[100];定长、同一类型
集合List<Student> students = new ArrayList<>();可变长,但仍在内存中

它们都是内存中的数据容器;它们记住的数据,在断电或程序终止时会丢失。

游戏界面显示历史最高分为 0(内存数据一旦程序结束即丢失)

2.2 文件:硬盘上的持久存储

有些数据想长久保存下来,就要写进文件。文件是非常重要的存储方式,存放在计算机硬盘中;即便断电或者程序终止了,存储在硬盘文件中的数据也不会丢失。

磁盘中的文件与文件夹(code 文件夹、图片、aamb.txt)

2.3 File 与 IO 流的分工

角色职责
File 类代表文件/文件夹这个对象本身:创建、删除、判断类型、获取信息、遍历目录
IO 流读写数据:把磁盘/网络中的数据读进内存,或把内存中的数据写出去

例如”历史最高分”场景:内存中初始为 0,磁盘上有 score.txt(内容 历史最高分=630)。

场景方向结果
程序启动时磁盘 → 内存(输入流)内存中的历史最高分变为 630
分数被刷新后内存 → 磁盘(输出流)把新纪录写回 score.txt

3. File 类:代表文件或文件夹

File 是 java.io 包下的类,File 类的对象用于代表当前操作系统的文件(可以是文件、也可以是文件夹)。它能做:获取文件信息(大小、文件名、修改时间)、创建文件/文件夹、删除文件/文件夹、判断文件类型、遍历文件夹等。

注意:File 类只能对文件本身进行操作,不能读写文件里面存储的数据。

3.1 创建 File 类的对象

构造器说明
File(String pathname)用路径名字符串创建对象
File(String parent, String child)把父路径与子路径拼接起来
File(File parent, String child)父路径是 File 对象时使用

注意:

  • File 对象既可以代表文件、也可以代表文件夹。
  • File 封装的对象仅仅是一个路径名,这个路径可以是存在的,也允许是不存在的。

绝对路径与相对路径

  • 绝对路径:从盘符开始(如 E:\resource\meinv.jpg)。
  • 相对路径:不带盘符,默认直接到当前工程下的目录寻找文件(如 day08-stream-file-charset/src/dlei01.txt)。
// 1、创建 File 对象,代表具体的文件
File f1 = new File("E:/resource/meinv.jpg");     // 正斜杠在 Windows 上同样可用
// File f1 = new File("E:\\resource\\meinv.jpg"); // 反斜杠需要转义
// File f1 = new File("E:" + File.separator + "resource" + File.separator + "meinv.jpg");
System.out.println(f1.length());                 // 返回文件的字节大小

// 2、File 对象可以代表文件,也可以代表文件夹
File f2 = new File("E:/resource");
System.out.println(f2.exists());

// 3、File 对象代表的文件路径可以是不存在的
File f3 = new File("E:/resource/aaa");
System.out.println(f3.mkdir());                  // 创建文件夹(不存在时才会创建)

// 4、绝对路径与相对路径
File f4 = new File("E:/resource/meinv.jpg");                 // 绝对路径
File f5 = new File("day08-stream-file-charset/src/dlei01.txt"); // 相对路径:默认到工程下找
System.out.println(f5.length());

课件说法:相对路径”默认直接到当前工程下的目录寻找文件”。更准确的表述:相对路径是相对进程的当前工作目录解析的,在 IDEA 中默认就是工程的根目录,但换一种运行方式(打包后运行、命令行运行)工作目录可能不同。(补充说明)

3.2 常用方法一:判断文件类型、获取文件信息

方法作用
public boolean exists()判断当前文件对象对应的路径是否存在
public boolean isFile()判断指代的是否是文件
public boolean isDirectory()判断指代的是否是文件夹
public String getName()获取文件名称(包含后缀)
public long length()获取文件大小,返回字节个数
public long lastModified()获取最后修改时间(返回时间毫秒值)
public String getPath()获取创建文件对象时使用的路径
public String getAbsolutePath()获取绝对路径
File f1 = new File("E:/resource");

System.out.println(f1.exists());          // 路径是否存在
System.out.println(f1.isFile());          // 是否是文件
System.out.println(f1.isDirectory());     // 是否是文件夹
System.out.println(f1.getName());         // 名称(含后缀)
System.out.println(f1.length());          // 字节个数
System.out.println(f1.getPath());         // 创建时使用的路径
System.out.println(f1.getAbsolutePath()); // 绝对路径

// 最后修改时间:拿到毫秒值后交给日期格式化
long time = f1.lastModified();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss EEE a");
System.out.println(sdf.format(time));

文件夹的 length() 不代表里面所有文件大小之和,只能作为”是否为空目录”等粗略判断的依据。(补充说明)

3.3 常用方法二:创建文件、删除文件

方法作用
public boolean createNewFile()创建一个新文件(内容为空),创建成功返回 true
public boolean mkdir()创建一级文件夹
public boolean mkdirs()创建多级文件夹(重点)
public boolean delete()删除文件或空文件夹

注意:delete() 默认只能删除文件和空文件夹,删除后的文件不会进入回收站。

public class FileTest3 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个新文件(内容为空)
        File f = new File("day08-stream-file-charset/src/dlei02.txt");
        System.out.println(f.createNewFile());

        // 2、mkdir 只能创建一级文件夹
        File f2 = new File("E:/resource/aaa2");
        System.out.println(f2.mkdir());

        // 3、mkdirs(重点)可以创建多级文件夹
        File f3 = new File("E:/resource/aaa3/bb/cc/dd/ee/ff/gg");
        System.out.println(f3.mkdirs());

        // 4、delete 删除文件(文件里有内容也一并删除)
        System.out.println(f.delete());

        // 5、delete 只能删除空文件夹,非空文件夹删不掉
        File f4 = new File("E:/resource/aaa2");
        System.out.println(f4.delete());
    }
}

复习问答(PPT 原文):创建多级目录用 public boolean mkdirs();删除文件需要注意——可以删除文件、空文件夹,默认不能删除非空文件夹。

3.4 常用方法三:遍历文件夹

方法作用
public String[] list()获取当前目录下所有”一级文件名称”,返回字符串数组
public File[] listFiles()获取当前目录下所有”一级文件对象”,返回 File 数组(常用、重点)

使用 listFiles() 时的注意事项:

  • 当主调是文件,或者路径不存在时,返回 null。
  • 当主调是空文件夹时,返回一个长度为 0 的数组。
  • 当主调是一个有内容的文件夹时,将里面所有一级文件和文件夹的路径放在 File 数组中返回。
  • 当主调是文件夹且里面有隐藏文件时,同样返回,包含隐藏文件。
  • 当主调是文件夹但没有权限访问时,返回 null。
File f = new File("E:/resource");

// 1、list():得到一级文件名称
String[] names = f.list();
for (String name : names) {
    System.out.println(name);
}

// 2、listFiles():得到一级文件对象
File[] files = f.listFiles();
for (File file : files) {
    System.out.println(file.getAbsolutePath());
}

复习问答(PPT 原文):遍历文件夹下的文件对象用 public File[] listFiles()(常用),只能遍历当前文件夹对象下的一级文件对象——要遍历多级,就需要方法递归。

4. 前置知识:方法递归

4.1 什么是方法递归

递归是一种算法,在程序设计语言中广泛应用。从形式上说:方法调用自身的形式称为方法递归(recursion)。

形式说明
直接递归方法自己调用自己
间接递归方法调用其他方法,其他方法又回调方法自己

使用方法递归时需要注意的问题:递归如果没有控制好终止,会出现递归死循环,导致栈内存溢出错误(StackOverflowError)。

public class RecursionTest1 {
    public static void main(String[] args) {
        test1();
    }

    public static void test1() {
        System.out.println("==test1==");
        test1();   // 直接递归:自己调用自己
    }

    public static void test2() {
        System.out.println("==test2==");
        test3();
    }

    public static void test3() {
        System.out.println("==test3==");
        test2();   // 间接递归:test2 → test3 → test2
    }
}

复习问答(PPT 原文):什么是递归——方法调用自己的形式称为方法递归;什么是递归死循环——方法无限调用自己,无法终止,最终引起栈内存溢出。

4.2 案例导学:计算 n 的阶乘

需求:计算 n 的阶乘,5 的阶乘 = 1×2×3×4×5;6 的阶乘 = 1×2×3×4×5×6。

分析:假如存在公式 f(n) = 1*2*3*...*(n-1)*n,那么等价形式就是 f(n) = f(n-1) * n。手工推导:

f(5) = f(4) * 5
f(4) = f(3) * 4
f(3) = f(2) * 3
f(2) = f(1) * 2
f(1) = 1
public class RecursionTest2 {
    public static void main(String[] args) {
        System.out.println("1-5的阶乘:" + f(5));   // 120
    }

    public static int f(int n) {
        if (n == 1) {
            return 1;                 // 终结点
        } else {
            return f(n - 1) * n;      // 递归公式
        }
    }
}

递归求阶乘的执行流程(栈内存视角)

阶段现象
递进main 入栈 → f(5)、f(4)、f(3)、f(2)、f(1) 依次入栈,每一层都停在 n * f(n-1) 处等待返回值
回归f(1) 返回 1 → f(2) 得 2×1 → f(3) 得 3×2 → f(4) 得 4×6 → f(5) 得 5×24 = 120,逐层出栈
输出回到 main,int result = 120,输出”5的阶乘是:120”

4.3 递归算法三要素

  1. 递归的公式:f(n) = f(n-1) * n;
  2. 递归的终结点:f(1)
  3. 递归的方向必须走向终结点:
f(5) = f(4) * 5
f(4) = f(3) * 4
f(3) = f(2) * 3
f(2) = f(1) * 2
f(1) = 1

4.4 递归求 1-n 的和

需求:计算 1-n 的和,使用递归思想解决。

分析:假如 f(n) = 1 + 2 + 3 + ... + (n-1) + n,等价形式为 f(n) = f(n-1) + n,终结点 f(1) = 1。

f(5) = f(4) + 5
f(4) = f(3) + 4
f(3) = f(2) + 3
f(2) = f(1) + 2
f(1) = 1
public class RecursionTest3 {
    public static void main(String[] args) {
        System.out.println("1-5的和:" + f(5));   // 15
    }

    public static int f(int n) {
        if (n == 1) {
            return 1;
        } else {
            return f(n - 1) + n;
        }
    }
}

复习问答(PPT 原文):递归算法三要素大体可以总结为——递归的公式、递归的终结点、递归的方向必须走向终结点。

4.5 案例:猴子吃桃

需求:猴子第一天摘了若干桃子,当即吃了一半,又多吃了一个;第二天又吃了剩余桃子数量的一半,又多吃了一个;以后每天都吃前一天剩余数量的一半再多一个。等到第 10 天时发现桃子只有 1 个了。问猴子第一天摘了多少个桃子?

分析:整体来看每一天都在做同一个事件,是典型的规律化问题,考虑递归三要素。

已知:  f(x) - f(x)/2 - 1 = f(x+1)
变形:  2f(x) - f(x) - 2 = 2f(x+1)
即:    f(x) = 2 * f(x+1) + 2      ← 递归公式
终结点:f(10) = 1
方向:  x 从 1 逐步走向 10

猴子吃桃问题配图

public class RecursionTest4 {
    public static void main(String[] args) {
        // 目标:猴子吃桃问题
        // 公式:f(x) - f(x)/2 - 1 = f(x+1)
        // 变形:f(x) = 2 * f(x + 1) + 2
        System.out.println(f(1));
        System.out.println(f(2));
        System.out.println(f(3));
    }

    public static int f(int x) {
        if (x == 10) {
            return 1;                    // 终结点
        } else {
            return 2 * f(x + 1) + 2;     // 递归公式
        }
    }
}

注意方向:本题是从后往前推(第 10 天 → 第 1 天),所以递进时 x 增大、终结点是 x == 10,不要把时间方向写反。(补充说明)

4.6 案例:文件搜索

需求:从 D 盘中搜索 QQ.exe 这个文件,找到后直接输出其位置。

分析:

  1. 先找出 D 盘下的所有一级文件对象;
  2. 遍历全部一级文件对象,判断是否是文件;
  3. 如果是文件,判断是否是自己想要的;
  4. 如果是文件夹,需要继续进入该文件夹,重复上述过程。

需求场景——在 D 盘中搜索文件

public class FileSearchTest5 {
    public static void main(String[] args) throws Exception {
        File dir = new File("D:/");
        searchFile(dir, "QQ.exe");
    }

    /**
     * 文件搜索
     *
     * @param dir      需要查询的目录
     * @param fileName 需要查询的文件名
     */
    public static void searchFile(File dir, String fileName) throws Exception {
        // 1、极端情况判断:参数非法、目录不存在、传进来的是文件,直接结束
        if (dir == null || fileName == null || !dir.exists() || dir.isFile()) {
            return;
        }

        // 2、拿当前目录下的全部一级文件对象
        File[] files = dir.listFiles();

        // 3、判断是否成功获取到一级文件对象,且个数大于 0
        if (files != null && files.length > 0) {
            // 4、遍历当前每个一级文件对象
            for (File f : files) {
                if (f.isFile()) {
                    // 5、是文件:判断是否就是我们要找的
                    if (f.getName().contains(fileName)) {
                        System.out.println(f.getAbsolutePath());
                        // 找到并启动
                        Runtime r = Runtime.getRuntime();
                        r.exec(f.getAbsolutePath());
                    }
                } else {
                    // 6、是文件夹:递归进入继续寻找
                    searchFile(f, fileName);
                }
            }
        }
    }
}

复习问答(PPT 原文):文件搜索用到了什么技术?——递归,listFiles() 只能搜索到一级文件对象。

4.7 案例:删除文件夹(拓展)

需求:删除非空文件夹。

分析:

  1. File 默认不可以删除非空文件夹;
  2. 我们需要遍历文件夹,先删除里面的内容,再删除自己(后序遍历)。
/* 片段:思路整理,删除动作不可逆,练习时请先用临时目录,并先打印待删除路径 */
public static void deleteDir(File dir) {
    File[] files = dir.listFiles();
    if (files == null || files.length == 0) {
        dir.delete();
        return;
    }
    for (File file : files) {
        if (file.isFile()) {
            file.delete();          // 是文件,直接删
        } else {
            deleteDir(file);        // 是文件夹,递归先删里面
        }
    }
    dir.delete();                   // 最后删自己
}

4.8 案例:啤酒问题

需求:啤酒 2 元 1 瓶,4 个盖子可以换一瓶,2 个空瓶可以换一瓶。请问 10 元钱可以喝多少瓶酒,剩余多少空瓶和盖子?

答案:15 瓶,剩余 3 个盖子、1 个瓶子。

啤酒问题场景配图

思路:维护”已喝瓶数、空瓶数、盖子数”三个状态,反复用「2 空瓶换 1 瓶」「4 盖子换 1 瓶」把空瓶与盖子换成新酒,换来的新酒又会产生新的空瓶与盖子,直到都换不动为止。这类”每次状态转移规则相同”的问题既可以用递归表达,也可以用循环表达(PPT 给出的答案以递推/循环思路为主)。

5. 前置知识:字符集

5.1 编码与解码

  • 编码:把字符按照指定字符集编码成字节。
  • 解码:把字节按照指定字符集解码成字符。

计算机中,任何数据最终都以二进制(0/1)存储;字符与字节之间的映射规则就是字符集。

记事本打开 ab.txt,状态栏显示 Windows (CRLF)、UTF-8

5.2 标准 ASCII 字符集

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)包括了英文、符号等。

  • 标准 ASCII 使用 1 个字节存储一个字符,首尾是 0,总共可表示 128 个字符,对美国来说完全够用。

ASCII 字符代码表(0-127 的十进制、二进制与字符对应关系)

5.3 GBK(汉字内码扩展规范,国标)

  • 汉字编码字符集,包含了 2 万多个汉字等字符;
  • GBK 中一个中文字符编码成两个字节的形式存储;
  • 注意:GBK 兼容了 ASCII 字符集;
  • GBK 规定:汉字的第一个字节的第一位必须是 1。
'我'        'a'      '你'
1xxxxxxx  xxxxxxxx  0xxxxxxx  1xxxxxxx  xxxxxxxx

因为 ASCII 的最高位是 0、GBK 汉字第一个字节最高位是 1,解码时才能区分”这是 1 个 ASCII 字符”还是”这是一个汉字的前半部分”。

5.4 Unicode 字符集(统一码,万国码)

Unicode 是国际组织制定的、可以容纳世界上所有文字、符号的字符集。

  • 它解决的是”字符 → 码点”的统一编号问题,具体的”码点 → 字节”方案由不同编码方案(UTF-8、UTF-16、UTF-32 等)负责。
  • 由于各国字符都要收录,编码后的字节数变多,占存储空间、通信效率变低,于是有了可变长的 UTF-8。

5.5 UTF-8

UTF-8 是 Unicode 字符集的一种编码方案,采取可变长编码方案,共分四个长度区:1 个字节、2 个字节、3 个字节、4 个字节。

字符类型占用字节
英文字符、数字等1 个字节(兼容标准 ASCII 编码)
汉字字符3 个字节

以 a我m 为例:

a  →  97     →  01100001                       (1 字节)
我 →  25105  →  1110xxxx 10xxxxxx 10xxxxxx     (3 字节)
m  →  109    →  01101101                       (1 字节)

注意:技术人员在开发时都应该使用 UTF-8 编码!

5.6 乱码:什么时候会出错

  • 注意 1:字符编码时使用的字符集,和解码时使用的字符集必须一致,否则会出现乱码。
  • 注意 2:英文、数字一般不会乱码,因为很多字符集都兼容了 ASCII 编码。
字符集汉字英文、数字
ASCII不支持1 个字节
GBK2 个字节1 个字节
UTF-83 个字节1 个字节

复习问答(PPT 原文):

  1. 常见字符集有哪些,各自存储数据的特点是什么?——英文和数字在任何国家的字符集中都占 1 个字节;GBK 中一个中文字符占 2 个字节;UTF-8 中一个中文一般占 3 个字节。
  2. 编码前的字符集和解码时的字符集有什么要求?——必须一致,否则会出现乱码。
  3. 英文和数字在常见的字符集中是否会出现乱码?为什么?——不会,因为都兼容 ASCII。

5.7 Java 代码完成编码与解码

操作API
编码(字符 → 字节)byte[] getBytes():默认编码;byte[] getBytes(String charsetName):指定编码
解码(字节 → 字符)String(byte[] bytes):使用默认编码解码;String(byte[] bytes, String charsetName):指定编码解码
// 编码
String s = "我a";
byte[] bytes1 = s.getBytes();             // 使用平台默认字符集
byte[] bytes2 = s.getBytes("GBK");        // 指定字符集
System.out.println(Arrays.toString(bytes1));

// 解码:编码与解码字符集不一致会导致乱码
byte[] data = "我".getBytes("GBK");
System.out.println(new String(data));             // 用默认字符集解码 → 乱码
System.out.println(new String(data, "GBK"));      // 用 GBK 解码 → 我

6. IO 流入门

6.1 IO 流概述

  • I 指 Input,称为输入流:负责把数据读到内存中去;
  • O 指 Output,称为输出流:负责写数据出去。

IO 流的作用:读写文件数据(也可以读写网络中的数据)。谁在读写?程序(进程) 以内存为基准,与磁盘/网络之间搬数据。

思考:

  1. 先搞清楚 IO 流的分类、体系;
  2. 再挨个学习每个 IO 流的作用、用法。

6.2 IO 流的分类

按流的方向分:

类型含义
输入流把磁盘/网络中的数据读到内存
输出流把内存中的数据写出去

按流中数据的最小单位分:

类型适合的场景
字节流适合操作所有类型的文件,比如音频、视频、图片
字符流只适合操作纯文本文件,比如读写 txt、java 文件等

6.3 IO 流的体系:四大流

四大流抽象类文件实现类
字节输入流InputStreamFileInputStream
字节输出流OutputStreamFileOutputStream
字符输入流ReaderFileReader
字符输出流WriterFileWriter

定义:

  • 字节输入流:以内存为基准,来自磁盘文件/网络中的数据以字节的形式读入到内存中去的流;
  • 字节输出流:以内存为基准,把内存中的数据以字节写出到磁盘文件或者网络中去的流;
  • 字符输入流:以内存为基准,来自磁盘文件/网络中的数据以字符的形式读入到内存中去的流;
  • 字符输出流:以内存为基准,把内存中的数据以字符写出到磁盘文件或者网络介质中去的流。

记忆:InputStream / OutputStream / Reader / Writer 是抽象类,都在 java.io 包下;File 系列是它们的实现类,本日先学字节流的两个。

7. 字节流

7.1 FileInputStream:每次读取一个字节

作用:以内存为基准,可以把磁盘文件中的数据以字节的形式读入到内存中去。

构造器说明
FileInputStream(File file)用 File 对象创建管道
FileInputStream(String pathname)用文件路径创建管道
方法说明
int read()每次读取一个字节,读到末尾返回 -1
int read(byte[] buffer)每次读取一个字节数组,返回本次读到的字节个数,读完返回 -1
void close()关闭管道、释放资源
public class FileInputStreamTest1 {
    public static void main(String[] args) throws Exception {
        // 1、创建字节输入流管道,与源文件接通
        InputStream is = new FileInputStream("day08-stream-file-charset/src/dlei01.txt");

        // 2、每次读取一个字节
        int b;
        while ((b = is.read()) != -1) {
            System.out.print((char) b);
        }

        // 3、关闭管道、释放资源
        is.close();
    }
}

问题:使用 FileInputStream 每次读取一个字节,读取性能较差,并且读取汉字输出会乱码。

原因:UTF-8 下一个汉字占 3 个字节,一个字节一个字节地转成 char,得到的只是”半个汉字的编码”,自然显示乱码。

7.2 FileInputStream:每次读取多个字节

一次读一个字节数组,性能提升明显;但读汉字仍可能乱码——因为一个汉字的 3 个字节可能被拆到两个缓冲区里。

public class FileInputStreamTest2 {
    public static void main(String[] args) throws Exception {
        InputStream is = new FileInputStream("day08-stream-file-charset/src/dlei01.txt");

        // 每次读取多个字节:定义一个字节数组作为缓冲区
        byte[] buffer = new byte[3];
        int len;   // 记录本次读取到的字节个数
        while ((len = is.read(buffer)) != -1) {
            // 只把本次真正读到的 len 个字节转成字符串
            System.out.print(new String(buffer, 0, len));
        }

        is.close();
    }
}

复习问答(PPT 原文):每次读取一个字节数组的方法是哪个?有什么好处、存在什么问题?——性能得到了提升;读取中文字符输出无法避免乱码问题。

解决乱码的思路:定义一个与文件一样大的字节数组,一次性读取完文件的全部字节。

7.3 FileInputStream:一次读取完全部字节

方式一:自己定义一个与文件大小一样大的字节数组,一次读完。

方式二:Java 官方为 InputStream 提供了方法,可以直接把文件的全部字节读取到一个字节数组中返回。

// 方式一:自己定义与文件等大的字节数组
File f = new File("day08-stream-file-charset/src/dlei01.txt");
InputStream is1 = new FileInputStream(f);
byte[] bytes1 = new byte[(int) f.length()];
int len = is1.read(bytes1);
System.out.println(new String(bytes1, 0, len));
is1.close();

// 方式二:使用官方 API 一次性读完全部字节
InputStream is2 = new FileInputStream(f);
byte[] bytes2 = is2.readAllBytes();      // JDK 9 起提供
System.out.println(new String(bytes2));  // 用默认字符集解码
is2.close();

复习问答(PPT 原文):直接把文件数据全部读取到一个字节数组可以避免乱码,是否存在问题?——如果文件过大,创建的字节数组也会过大,可能引起内存溢出。

7.4 FileOutputStream:写字节出去

作用:以内存为基准,把内存中的数据以字节的形式写出到文件中去。

构造器说明
FileOutputStream(File file) / FileOutputStream(String pathname)覆盖(清空)原内容后写出
FileOutputStream(File file, boolean append) / FileOutputStream(String pathname, boolean append)append = true 时为追加,不会清空原内容
方法说明
void write(int b)写出一个字节
void write(byte[] buffer)写出一个字节数组
void write(byte[] buffer, int off, int len)写出字节数组的一部分
void close()关闭管道、释放资源
public class FileOutputStreamTest {
    public static void main(String[] args) throws Exception {
        // 1、创建字节输出流管道:第二个参数 true 表示追加
        OutputStream os = new FileOutputStream("src/dlei02.txt", true);

        // 2、写数据
        os.write('a');                                       // 写一个字节
        os.write("\r\n".getBytes());                          // 换行:写换行符的字节
        os.write("窗前明月光".getBytes());                     // 写一个字节数组

        // 3、关闭管道、释放资源
        os.close();
    }
}

复习问答(PPT 原文):

  1. 如何创建字节输出流管道,如何实现写数据的追加操作?——用带 append 参数的构造器,传 true。
  2. 字节输出流写数据的方法有哪些?——write(int)、write(byte[])、write(byte[], int, int)。
  3. 字节输出流如何实现写出去的数据可以换行?——写出换行符的字节,Windows 是 \r\n,也可以写 "\r\n".getBytes()。

7.5 案例:文件复制

核心思路:先创建字节输入流管道(源)与字节输出流管道(目标),读一个缓冲区、写一个缓冲区,循环到读完为止。

右键菜单中的复制命令——文件复制的直观含义

public class CopyFileTest {
    public static void main(String[] args) {
        try (InputStream is = new FileInputStream("C:/resource/meinv.png");
             OutputStream os = new FileOutputStream("D:/resource/meinv2.png")) {

            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                os.write(buffer, 0, len);   // 只写本次真正读到的 len 个字节
            }
            System.out.println("复制完成!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

结论:任何文件的底层都是字节,字节流做复制,是一字不漏地转移完全部字节,只要复制后的文件格式一致就没问题。因此字节流非常适合做一切文件的复制操作。

8. 释放资源的方式

流对象持有操作系统资源,用完必须 close();但一旦中途抛出异常,close() 就可能被跳过,于是需要两种规范的写法。

8.1 try-catch-finally

finally 代码区的特点:无论 try 中的程序是正常执行了,还是出现了异常,最后都一定会执行 finally 区,除非 JVM 终止。

作用:一般用于在程序执行完成后进行资源的释放操作(专业级做法)。

try {
    ...
} catch (IOException e) {
    e.printStackTrace();
} finally {

}
InputStream is = null;
OutputStream os = null;
try {
    is = new FileInputStream("C:/resource/meinv.png");
    os = new FileOutputStream("D:/resource/meinv2.png");
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 关闭资源!
    try {
        if (os != null) os.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
    try {
        if (is != null) is.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

要点:先置 null → 在 try 中创建 → finally 中判空再关闭;关闭本身也会抛异常,所以要分别包一层 try-catch。

8.2 try-with-resource

JDK 7 开始提供了更简单的资源释放方案:try-with-resource。

try (定义资源1; 定义资源2; …) {
    可能出现异常的代码;
} catch (异常类名 变量名) {
    异常的处理代码;
}
  • () 中只能放置资源,否则报错。
  • 什么是资源呢?资源一般指的是最终实现了 AutoCloseable 接口的对象。
  • 该资源使用完毕后,会自动调用其 close() 方法,完成对资源的释放。
public abstract class InputStream implements Closeable { }
public abstract class OutputStream implements Closeable, Flushable { }
public interface Closeable extends AutoCloseable { }
try (InputStream is = new FileInputStream("C:/resource/meinv.png");
     OutputStream os = new FileOutputStream("D:/resource/meinv2.png")) {
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
} catch (IOException e) {
    e.printStackTrace();
}

9. 拓展:拷贝文件夹

需求:将某个磁盘的文件夹拷贝到另一个文件夹下去,包括文件夹中的全部信息。

分析:

  1. IO 默认不可以拷贝文件夹;
  2. 我们需要遍历文件夹,如果是文件则拷贝过去,如果是文件夹则要进行 1:1 创建,继续复制内容。
/* 片段:依据 PPT 分析整理的思路骨架,复制前请确认目标目录不会覆盖已有数据 */
public static void copyDir(File src, File dest) throws IOException {
    dest.mkdirs();                       // ① 在目标位置 1:1 创建同名文件夹
    File[] files = src.listFiles();
    if (files == null || files.length == 0) {
        return;
    }
    for (File file : files) {
        if (file.isFile()) {
            copyFile(file, new File(dest, file.getName()));   // ② 是文件则拷贝
        } else {
            copyDir(file, new File(dest, file.getName()));    // ③ 是文件夹则递归
        }
    }
}

10. 作业与练习

作业文档:day08-Stream、File、方法递归/作业/03_题目/File.md(文档内标题写作”day11IO流”,与文件夹的 day08 编号不一致,以文件夹为准)。

题目目标关键点
题目 1 不死神兔(加强训练)用递归解决斐波那契型问题出口:month == 1 || month == 2 返回 1;规律:getCount(month-1) + getCount(month-2)
题目 2 打印目录结构(加强训练)用递归打印文件夹目录树printDir(dir, depth),每层先打印 num 个 \t 再打印 |-名称;listFiles() 为 null 或长度为 0 时结束
题目 3 计算文件夹大小(综合扩展)递归累加文件大小是文件累加 length(),是文件夹递归累加返回值
题目 4 删除文件夹(综合扩展)递归删除非空文件夹先删子项、再删自己;files == null || length == 0 时直接 delete()

参考要点:

  • 后三题都要求先拿到 File[] files = dir.listFiles(),再判断 files == null || files.length == 0 以结束递归。
  • 题目 3 的参考答案里最后一行输出写成了 System.out.println("大小: " + size + " 字节"),但方法内定义的变量名是 length,变量 size 并不存在——这是文档里的笔误,实际应写 length(或把变量统一命名)。
  • 作业目录另附各题的 avi 视频讲解。

11. 复习要点

  1. Stream 流是 JDK 8 新增、位于 java.util.stream.*,用于简化集合/数组操作的 API,结合 Lambda 表达式;使用三步是获取流 → 中间方法处理(返回新流、可链式)→ 终结方法或收集结果。
  2. 获取流:Collection.stream()、Arrays.stream()、Stream.of(),Map 可分别取 keySet() / values() / entrySet() 的流;中间方法有 filter、sorted、limit、skip、distinct、map、Stream.concat;终结方法有 forEach、count、max、min;收集用 collect(Collectors.toList()/toSet()/toMap()) 或 toArray()。
  3. 变量、数组、集合都是内存中的容器,断电或程序终止会丢失;文件存在硬盘上可以长久保存。File 负责文件本身(创建、删除、判断、获取信息、遍历),不能读写文件内容——读写内容要靠 IO 流。
  4. new File(...) 只是路径对象,路径可以不存在;绝对路径从盘符开始,相对路径不带盘符、默认到当前工程目录找。mkdir() 建一级目录、mkdirs() 建多级目录;delete() 只能删文件和空文件夹且不进回收站。
  5. listFiles() 只遍历一级文件对象:路径不存在、主调是文件、无权限时返回 null,空文件夹返回长度为 0 的数组;要遍历多级必须配合方法递归。
  6. 递归三要素:递归公式、终结点、方向必须走向终结点;缺少终结点会导致递归死循环与栈内存溢出。典型应用:阶乘 f(n)=f(n-1)*n、求和 f(n)=f(n-1)+n、猴子吃桃 f(x)=2f(x+1)+2(终结点 f(10)=1)、文件搜索、删除非空文件夹。
  7. 字符集:ASCII 用 1 字节表示 128 个字符(最高位为 0);GBK 汉字 2 字节、首字节首位为 1 且兼容 ASCII;UTF-8 英文 1 字节、汉字 3 字节,开发中应统一使用 UTF-8。
  8. 编码与解码所用字符集必须一致,否则乱码;英文数字因各字符集都兼容 ASCII,一般不乱码。Java 中编码用 getBytes() / getBytes(String),解码用 new String(byte[]) / new String(byte[], String)。
  9. IO 流按方向分输入流/输出流,按最小单位分字节流/字符流;字节流适合所有类型文件,字符流只适合纯文本。四大抽象类 InputStream、OutputStream、Reader、Writer,文件实现类 FileInputStream、FileOutputStream、FileReader、FileWriter。
  10. 字节流读中文乱码的根因是”按字节切分把多字节字符切断了”;一次性读完全部字节可避免乱码,但文件过大会有内存溢出风险。任何文件的底层都是字节,所以字节流适合做一切文件的复制;释放资源用 try-catch-finally(专业级做法)或 try-with-resource(JDK 7 起,资源需实现 AutoCloseable,自动 close())。

day09|java.io 包下的常用 API:字符集与 IO 流(一)

本日分两块:先补前置知识「字符集」,搞清楚字符、码点、字节之间的关系,以及乱码到底是怎么来的;再进入 java.io 包,学习 IO 流的分类与体系,重点掌握文件字节输入流 FileInputStream、文件字节输出流 FileOutputStream、文件复制案例与两种释放资源的方式。学完后应该能解释「为什么复制图片不会坏、而用字节流读中文会乱」,并能写出符合规范的复制代码。

1. 前置知识:字符集(第 3–13 页)

1.1 字符、码点与二进制

计算机只认识二进制。要把人类读的字符存进计算机,必须给每个字符定一个编号(码点),再按某种规则把编号写成字节,这个过程叫编码;反过来把字节还原成字符叫解码。

PPT 用一串对照说明了这件事:字符 '0'、'1'、'a'、'b'、'}'、'~' …… 各自对应一个十进制码点(48、49、50……97、98、99……125、126、127),码点再写成 8 位二进制就是实际存储的内容。

'0'  →  48  →  00110000
'a'  →  97  →  01100001
'~'  →  126 →  01111110

字符集(Character Set) 就是「字符 → 码点」的对照表;编码方案(编码方式) 则是「码点 → 字节序列」的转换规则。二者经常被统称,但概念上是两层。

1.2 标准 ASCII 字符集(第 6 页)

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)只包括英文、数字、标点符号等。

  • 使用 1 个字节存储一个字符;
  • 首位是 0,所以总共只能表示 128 个字符;
  • 对美国佬来说完全够用。

课件第 7 页复习这句时写成了「首尾是 0」——课件说法:首尾是 0;更准确的表述:最高位(首位)固定为 0,剩下 7 位表示 0–127。同一份课件的第 6 页写的就是「首位是 0」。

标准 ASCII 字符编码表(字符与十进制、十六进制码点对照)

1.3 GBK 字符集(第 7–8 页)

ASCII 只有 128 个位置,容纳不下汉字,于是各国各搞了一套自己的字符集——PPT 用「巴基斯坦码 / 乌兹别克斯坦码 / 吉尔吉斯斯坦码 / 迪拜码 / 哈萨克斯坦码 / 韩文码 / 岛国码」来调侃这种局面。

GBK(汉字内码扩展规范,国标):

  • 是一个汉字编码字符集,包含了 2 万多个汉字等字符;
  • 一个中文字符编码成两个字节存储;
  • GBK 兼容了 ASCII 字符集——英文、数字仍然只占 1 个字节。

GBK 靠首字节的最高位来区分「这是单字节的 ASCII,还是双字节的汉字」:

  • 单字节字符:0xxxxxxx(首位为 0)
  • 汉字:1xxxxxxx xxxxxxxx(第一个字节首位必须是 1,第二个字节首位也必须是 1)

以 “我a你” 为例(每个 x 是一位二进制):

我        a        你
1xxxxxxx xxxxxxxx  0xxxxxxx  1xxxxxxx xxxxxxxx

因为首字节首位不同,解码时才能一个字节一个字节地切分下去,不会切错。

GBK 规定——汉字的第一个字节的第一位必须是 1

课件说法:英文、数字等在任何国家的字符集中都占 1 个字节。更准确的表述:在兼容 ASCII 的字符集(GBK、UTF-8 等)中英文数字占 1 个字节;在 UTF-16 这类编码中英文字符同样占 2 个字节。(补充说明)

1.4 Unicode 字符集(第 9–10 页)

问题很明显:每个国家一套字符集,同一串字节在不同字符集下是不同内容。Unicode(统一码,也叫万国码) 由国际组织制定,目标是容纳世界上所有文字、符号,给每个字符分配一个全球唯一的码点。

Unicode 本身只是码点表,具体怎么把码点写成字节,是另外的编码方案(UTF-8、UTF-16、UTF-32):

  • 有的方案用 2 个字节表示一个字符:1xxxxxxx xxxxxxxx
  • 有的方案(UTF-32)用 4 个字节表示一个字符:00000000 00000000 1xxxxxxx xxxxxxxx
    • 字母 a 的码点 97 在 4 字节方案下变成 00000000 00000000 00000000 01100001,前 3 个字节全是 0

PPT 的评价很直接:UTF-32 虽然简单,但「占存储空间,通信效率变低!」,太「奢侈」了。

1.5 UTF-8 编码方案(第 11 页)

UTF-8 是 Unicode 字符集的一种编码方案,采取可变长编码,共分四个长度区:1 个字节、2 个字节、3 个字节、4 个字节。

  • 英文字符、数字等只占 1 个字节(兼容标准 ASCII 编码);
  • 汉字字符一般占 3 个字节。

各长度区的前缀规则(PPT 抽取文本中该表格为空,下表按 PPT 正文中出现的 0xxxxxxx、1110xxxx 10xxxxxx 10xxxxxx 等前缀示例还原):

占用字节二进制格式适用字符
1 字节0xxxxxxxASCII 范围的英文、数字、符号
2 字节110xxxxx 10xxxxxx部分拉丁字母、希腊字母等
3 字节1110xxxx 10xxxxxx 10xxxxxx常用汉字(BMP 基本平面)
4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx增补平面字符(部分生僻汉字、emoji 等)

以 “a我m” 为例:

a(97)     我(25105)                     m(109)
01100001  1110xxxx 10xxxxxx 10xxxxxx    01101101
            0110   001000   010001

我 的码点 25105 写成二进制是 110 001000 010001,按 4/6/6 位拆开依次填入三字节模板的 x 位,得到 11100110 10001000 10010001(即 E6 88 91)。

同理,解码时只要看到首字节是 1110 开头,就知道后面还要连续读两个 10 开头的字节。

注意:技术人员在开发时都应该使用 UTF-8 编码!

1.6 乱码是怎么产生的(第 12–13 页)

注意 1:字符编码时使用的字符集,和解码时使用的字符集必须一致,否则会出现乱码。

用 GBK 编码得到 E6 88 91 这样的字节,却用 UTF-8 去解码,UTF-8 会把 E6 当成三字节汉字的起始字节,于是从别的字节里拼出一个错误的字符;更糟的是它继续往下切,后面整段内容都会跟着错位乱掉。

注意 2:英文、数字一般不会乱码,因为很多字符集都兼容 ASCII 编码。 英文数字在 GBK、UTF-8 中都只占 1 个字节,二进制形式完全一样,所以怎么解都对。

三种常见字符集的对比:

字符集汉字英文、数字
ASCII不支持1 个字节
GBK2 个字节1 个字节
UTF-83 个字节1 个字节

本节小结:

  • 英文和数字等在任何国家的字符集中都占 1 个字节;
  • GBK 中一个中文字符占 2 个字节;
  • UTF-8 中一个中文一般占 3 个字节;
  • 编码前与解码时的字符集必须一致,否则会出现字符乱码;
  • 英文和数字在常见字符集中不会乱码。

1.7 用 Java 完成编码与解码(第 14–16 页)

  • 编码:把字符按照指定字符集编码成字节。
  • 解码:把字节按照指定字符集解码成字符。

String 类提供了对应的方法:

功能API说明
编码byte[] getBytes()使用默认编码
编码byte[] getBytes(String charsetName)指定编码
解码String(byte[] bytes)使用默认编码解码
解码String(byte[] bytes, String charsetName)指定编码解码

编码——把字符按照指定字符集编码成字节

解码——把字节按照指定字符集解码成字符

import java.util.Arrays;

public class CharsetDemo {
    public static void main(String[] args) throws Exception {
        String name = "abc中国123";

        // 1、编码:把字符串编码成字节
        byte[] bytes1 = name.getBytes();          // 默认使用平台编码(IDEA 默认 UTF-8)
        System.out.println(bytes1.length);        // 3 + 3*2 + 3 = 12
        System.out.println(Arrays.toString(bytes1));

        byte[] bytes2 = name.getBytes("GBK");     // 指定使用 GBK 编码
        System.out.println(bytes2.length);        // 3 + 2*2 + 3 = 10
        System.out.println(Arrays.toString(bytes2));

        // 2、解码:编码与解码的字符集必须一致
        String rs = new String(bytes2, "GBK");    // 指定使用 GBK 解码
        System.out.println(rs);                   // abc中国123

        String bad = new String(bytes2, "UTF-8"); // 用错字符集解码 → 乱码
        System.out.println(bad);
    }
}

同一个字节数组 bytes2,用 GBK 解码得到 abc中国123,用 UTF-8 解码就是乱码——这就是「注意 1」在代码层面的样子。

补充说明:Java 内部 String 使用 UTF-16 代码单元保存字符,getBytes() 做的是「UTF-16 → 目标编码」的转换,解码 new String(...) 则相反。

2. IO 流概述(第 17–24 页)

2.1 什么是 IO 流(第 18 页)

三个学习切入点:1、认识 IO 流;2、应用场景;3、如何学 IO 流。

  • I 指 Input,称为输入流:负责把数据读到内存中去;
  • O 指 Output,称为输出流:负责写数据出去。

数据在「磁盘 / 网络 / ……」这类外部介质和「内存 / 程序(进程)」之间流动。输入输出始终以内存(程序)为基准:读文件是输入,写文件是输出。

IO 的数据来源——磁盘、网络等外部介质

2.2 IO 流的应用场景(第 19 页)

IO 就是读写文件数据:

  • 记事本打开 ab.txt,把磁盘上的字节读进程序再显示出来(读文件);
  • 小游戏把「历史最高分 630」写进 score.txt,下次启动再读回来(写文件 + 读文件);
  • 聊天软件把消息发出去、把对方的消息收回来(网络 IO);
  • 复制 / 移动文件(一个流读、另一个流写)。

用记事本打开 ab.txt 查看文本内容

游戏把历史最高分写入 score.txt,下次启动读回

聊天软件的消息收发也是 IO(网络 IO)

2.3 IO 流的分类与体系(第 21–24 页)

怎么学 IO 流?(第 20 页)

  1. 先搞清楚 IO 流的分类、体系;
  2. 再挨个学习每个 IO 流的作用、用法。

两个分类维度:

按流的方向分:输入流 / 输出流。

按流中数据的最小单位分:

类型适合操作的文件典型用途
字节流所有类型的文件(音频、视频、图片、文本都行)数据的转移,如文件复制、转移
字符流只适合操作纯文本文件(txt、java 等)读写文本内容

所以「IO 流总体来看就有四大流」:

方向字节字符
输入(读到内存)字节输入流 InputStream字符输入流 Reader
输出(写出内存)字节输出流 OutputStream字符输出流 Writer

四者的完整定义:

  • 字节输入流:以内存为基准,来自磁盘文件 / 网络中的数据以字节的形式读入到内存中去的流;
  • 字节输出流:以内存为基准,把内存中的数据以字节的形式写出到磁盘文件或者网络中去的流;
  • 字符输入流:以内存为基准,来自磁盘文件 / 网络中的数据以字符的形式读入到内存中去的流;
  • 字符输出流:以内存为基准,把内存中的数据以字符的形式写出到磁盘文件或者网络介质中去的流。

3. IO 流 - 字节流(第 25–46 页)

3.1 体系结构:抽象类与实现类(第 26、40 页)

java.io 包下,四大流都是抽象类,具体功能由实现类提供:

抽象类(四大流)常用实现类
InputStream(字节输入流)FileInputStream
OutputStream(字节输出流)FileOutputStream
Reader(字符输入流)FileReader
Writer(字符输出流)FileWriter

本日先学字节流的两兄弟:FileInputStream 与 FileOutputStream。

3.2 FileInputStream:每次读取一个字节(第 27–30 页)

作用:以内存为基准,可以把磁盘文件中的数据以字节的形式读入到内存中去。

FileInputStream 的构造器(PPT 中该表格在抽取文本里为空,下表按课件代码用到的形式整理):

构造器说明
FileInputStream(String pathname)按路径字符串绑定源文件
FileInputStream(File file)按 File 对象绑定源文件

核心读取方法:

方法说明
public int read()每次读取一个字节返回;没有字节可读了返回 -1
public void close()关闭流,释放资源
import java.io.FileInputStream;
import java.io.InputStream;

public class FileInputStreamDemo1 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个文件字节输入流管道(对象)与源文件接通
        InputStream is = new FileInputStream("day09-charset-io/src/dlei02.txt");

        // 2、每次读取一个字节返回,没有字节可读了返回 -1
        int b;
        while ((b = is.read()) != -1) {
            System.out.print((char) b);
        }

        // 3、流是资源,用完必须关闭
        is.close();
    }
}

每次读取一个字节存在两个问题:

  1. 读取性能极差(一个字节一次系统调用);
  2. 无法避免读取汉字输出乱码——UTF-8 下一个汉字占 3 个字节,按单字节读出后强转 char,等于把一个汉字拆成三段分别显示。

3.3 FileInputStream:每次读取多个字节(第 31–34 页)

改用 read(byte[] buffer):

方法说明
public int read(byte[] buffer)每次读取一个字节数组的字节,返回本次实际读取的字节个数;没有字节可读返回 -1
import java.io.FileInputStream;
import java.io.InputStream;

public class FileInputStreamDemo2 {
    public static void main(String[] args) throws Exception {
        InputStream is = new FileInputStream("day09-charset-io/src/dlei03.txt");

        byte[] buffer = new byte[3];

        int len = is.read(buffer);
        String rs = new String(buffer);              // 不推荐:没用上 len
        System.out.println("读取了几个字节:" + len);
        System.out.println("读取的内容:" + rs);

        int len2 = is.read(buffer);
        String rs2 = new String(buffer, 0, len2);    // 规范:读取多少就转换多少
        System.out.println("读取了几个字节:" + len2);
        System.out.println("读取的内容:" + rs2);

        int len3 = is.read(buffer);                  // 没有字节可读返回 -1
        System.out.println(len3);
        is.close();
    }
}

为什么必须读多少转换多少? 数组是一个反复使用的「桶」:假设文件内容是 abc3mc,

  • 第一桶:buffer = [a, b, c],len = 3;
  • 第二桶:只读到 3、m、c 三个字节,恰好装满;如果第二次只读到 2 个字节,桶里就会残留上一轮的第 3 个字节,若整桶转换就会多输出内容。

用循环改造:

byte[] buffer = new byte[3];
int len;
while ((len = is.read(buffer)) != -1) {
    String rs = new String(buffer, 0, len);   // 读取多少就倒出多少
    System.out.print(rs);
}

优势与问题:

  • 优势:性能得到了提升,读取性能较好;
  • 问题:读取中文字符输出依然无法避免乱码——buffer 大小是固定的,一个 3 字节的汉字可能被切在两次 read 之间,任何一次单独转换都可能错位。

3.4 FileInputStream:一次读取完全部字节(第 35–38 页)

使用字节流读取中文,如何保证输出不乱码? 答案是:一次性读取完全部字节,再做一次整体解码。

方式一:自己定义一个和被读取文件一样大的字节数组。

File f = new File("day09-charset-io/src/dlei05.txt");
long size = f.length();
System.out.println("文件大小:" + size);

InputStream is = new FileInputStream(f);
byte[] buffer = new byte[(int) size];     // 数组长度必须是 int
int len = is.read(buffer);
System.out.println("读取的字节数:" + len);

String rs = new String(buffer);           // 一次整体解码,不会拆散汉字
System.out.println(rs);
is.close();

方式二:Java 官方为 InputStream 提供的方法 readAllBytes(),直接把文件的全部字节读取到一个字节数组中返回。

InputStream is = new FileInputStream("day09-charset-io/src/dlei05.txt");
byte[] buffer = is.readAllBytes();    // 一次性读取完全部字节
String rs = new String(buffer);
System.out.println(rs);
is.close();

问题:如果文件过大,创建的字节数组也会过大,可能引起内存溢出。 所以这套写法只适用于小文件;大文本的读取应该交给后续课程要学的字符流(Reader)。

3.5 FileOutputStream:写字节出去(第 39–43 页)

作用:以内存为基准,把内存中的数据以字节的形式写出到文件中去。

构造器(覆盖 / 追加的区别是本节的考点):

构造器说明
FileOutputStream(String pathname)覆盖管道:目标文件已存在时先清空再写
FileOutputStream(String pathname, boolean append)append 为 true 时是追加管道,在原内容后面接着写

写出数据的方法:

方法说明
public void write(int a)每次写出去一个字节
public void write(byte[] buffer)每次写出去一个字节数组的数据
public void write(byte[] buffer, int pos, int len)写出字节数组的一部分:从 pos 开始,共写出 len 个字节
public void close()关闭流,释放资源
import java.io.FileOutputStream;
import java.io.OutputStream;

public class FileOutputStreamDemo4 {
    public static void main(String[] args) throws Exception {
        // 1、创建一个文件字节输出流管道与目标文件接通
        // OutputStream os = new FileOutputStream("day09-charset-io/src/dlei06-out.txt");        // 覆盖管道
        OutputStream os = new FileOutputStream("day09-charset-io/src/dlei06-out.txt", true);     // 追加管道

        // 2、每次写出去一个字节
        os.write(97);          // 'a'
        os.write('a');
        // os.write('徐');     // 只写出去第一个字节,会乱码!
        os.write("\r\n".getBytes());   // 换行符号

        // 3、每次写一个字节数组的数据出去
        byte[] bytes = "abc我爱你中国".getBytes();
        os.write(bytes);
        os.write("\r\n".getBytes());

        // 4、写一个字节数组的一部分出去
        os.write(bytes, 3, 9);         // 从索引 3 开始,写出 9 个字节
        os.write("\r\n".getBytes());

        // 流是一种资源,用完必须关闭,为了节省系统资源、提升计算机性能
        os.close();
    }
}

三个问答:

  • 如何创建字节输出流管道、如何实现追加?——new FileOutputStream(路径, true);
  • 写数据的方法有哪些?——write(int)、write(byte[])、write(byte[], pos, len);
  • 如何实现写出去的数据换行?——写出换行符的字节:os.write("\r\n".getBytes())。

3.6 案例:文件复制(第 44–46 页)

思路是「一个桶、两个管道」:一个字节数组在 C 盘源文件和 D 盘目标文件之间来回搬运——读多少,就写多少。

文件的复制 / 移动是 IO 最典型的应用场景

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;

public class CopyDemo {
    public static void main(String[] args) {
        try {
            // 1、创建一个文件字节输入流管道与源文件接通
            InputStream is = new FileInputStream("E:\\resource\\aaa.png");
            // 2、创建一个文件字节输出流管道与目标文件接通
            OutputStream os = new FileOutputStream("D:\\resource\\meinv.png");

            // 3、准备一个字节数组(一个桶)
            byte[] buffer = new byte[1024];   // 1KB

            // 4、开始转移数据
            int len;                          // 记录每次读取的字节个数
            while ((len = is.read(buffer)) != -1) {
                // 5、读取多少就写出去多少
                os.write(buffer, 0, len);
            }

            // 6、释放资源
            os.close();
            is.close();
            System.out.println("复制完成!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

结论:任何文件的底层都是字节,字节流做复制,是一字不漏地转移完全部字节,只要复制后的文件格式一致就没问题!

也就是说,复制 .png、.mp4 时根本不需要知道它是什么编码——复制只搬字节,不解释字节。这也是「复制文件」和「读出文字」是两个不同目标的原因:前者保持字节完全一致,后者必须按正确的字符集解码。

4. 释放资源的方式(第 47–52 页)

流是一种资源,用完必须关闭。如果中间抛异常,close() 就可能被跳过,所以需要用 try 语句保证释放。PPT 给出两种方案:try-catch-finally 与 try-with-resource。

4.1 try-catch-finally(第 48、50 页)

finally 代码区的特点:无论 try 中的程序是正常执行了,还是出现了异常,最后都一定会执行 finally 区,除非 JVM 终止。

作用:一般用于在程序执行完成后进行资源的释放操作(专业级做法)。

try {
    ...
} catch (IOException e) {
    e.printStackTrace();
} finally {
    ...
}

课件的资源释放标准写法:

InputStream is = null;
OutputStream os = null;
try {
    // 可能出异常的代码 + 业务代码
    is = new FileInputStream("E:\\resource\\aaa.png");
    os = new FileOutputStream("D:\\resource\\meinv.png");
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
    System.out.println("复制完成!");
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 关闭资源!无论上面是否异常都会执行
    try {
        if (os != null) os.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
    try {
        if (is != null) is.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

要点:

  • 流变量必须定义在 try 之外并先赋值为 null,否则 finally 里访问不到;
  • 关闭前要判空(创建管道本身就抛异常时,变量还是 null);
  • close() 自己也会抛异常,所以要在 finally 里再套一层 try-catch;
  • 先关闭后创建的资源(这里是 os),再关闭先创建的 is。

代码能跑,但写法臃肿、不优雅——多资源的释放代码会成倍增长。

(补充说明:课件示例代码的注释里还提到「finally 中不要 return 数据,会覆盖前面的 return 数据」,这是 finally 的另一条常见注意事项。)

4.2 try-with-resource(第 51 页)

JDK 7 开始提供了更简单的资源释放方案:

try (定义资源1; 定义资源2; …) {
    可能出现异常的代码;
} catch (异常类名 变量名) {
    异常的处理代码;
}

规则:

  • () 中只能放置资源,否则报错(例如 int age = 12; 会编译报错);
  • 什么是资源? 资源一般指的是最终实现了 AutoCloseable 接口的对象;
  • 该资源使用完毕后,会自动调用其 close() 方法,完成对资源的释放。

看继承关系就明白了:

public abstract class InputStream implements Closeable { }
public abstract class OutputStream implements Closeable, Flushable { }
public interface Closeable extends AutoCloseable { }

Closeable 继承自 AutoCloseable,所以所有 IO 流都是资源。改写后的复制代码:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;

public class TryWithResourceDemo {
    public static void main(String[] args) {
        // 特点:这里只能放资源,程序执行完毕后自动调用它们的 close 方法关闭
        try (
                InputStream is = new FileInputStream("E:\\resource\\aaa.png");
                OutputStream os = new FileOutputStream("D:\\resource\\meinv.png")
        ) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                os.write(buffer, 0, len);
            }
            System.out.println("复制完成!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

try() 里也能放自定义的资源,只要实现 AutoCloseable 接口:

class MyConnection implements AutoCloseable {
    private String name;

    public MyConnection(String name) {
        this.name = name;
    }

    @Override
    public void close() throws Exception {
        System.out.println(name + "被自动关闭了~~");
    }
}
try (
        InputStream is = new FileInputStream("E:\\resource\\aaa.png");
        OutputStream os = new FileOutputStream("D:\\resource\\meinv.png");
        MyConnection conn = new MyConnection("数据库1号链接")
) {
    ...
}

运行结束后会自动打印「数据库1号链接被自动关闭了~~」,说明 close() 被框架自动调用了。

5. 拷贝文件夹(第 53 页)

需求:将某个磁盘的文件夹拷贝到另一个文件夹下去,包括文件夹中的全部信息。

分析:

  1. IO 默认不可以拷贝文件夹(FileInputStream 只能绑定文件,不能绑定目录);
  2. 我们需要遍历文件夹:如果是文件则拷贝过去,如果是文件夹则要进行 1:1 创建,继续复制内容。

也就是说,文件夹的复制 = 递归遍历 + 文件字节流复制(递归遍历的写法见 day08)。实现要点:

  • 先定义「复制文件到文件夹」的方法 copyFile2Dir(File file, File dir):用 FileInputStream/FileOutputStream + byte[] 循环读写;
  • 再定义「复制文件夹到文件夹」的方法 copyDir2Dir(File srcDir, File destDir):在目标位置先建同名目录,listFiles() 拿到子项数组,是文件就调 copyFile2Dir,是文件夹就递归调自己。
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class CopyDir {
    /** 1、把文件复制到目标文件夹中 */
    public static void copyFile2Dir(File file, File dir) throws IOException {
        InputStream is = new FileInputStream(file);
        byte[] buffer = new byte[1024 * 10];
        int len;
        File destFile = new File(dir, file.getName());
        OutputStream os = new FileOutputStream(destFile);
        while ((len = is.read(buffer)) != -1) {
            os.write(buffer, 0, len);
        }
        is.close();
        os.close();
    }

    /** 2、把源文件夹整体复制到目标文件夹中 */
    public static void copyDir2Dir(File srcDir, File destDir) throws IOException {
        // 2.1、在目标文件夹中创建源文件夹
        File newDir = new File(destDir, srcDir.getName());
        if (!newDir.exists()) {
            newDir.mkdirs();
        }
        // 2.2、获取源文件夹中所有文件和文件夹对应的 File 对象数组
        File[] files = srcDir.listFiles();
        // 2.3、数组为 null 或没有内容,结束方法
        if (files == null || files.length == 0) {
            return;
        }
        // 2.4、遍历
        for (File file : files) {
            if (file.isFile()) {
                // 2.5、是文件:复制
                copyFile2Dir(file, newDir);
            } else {
                // 2.6、是文件夹:递归
                copyDir2Dir(file, newDir);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        File srcDir = new File("D:/from/day11");
        File destDir = new File("E:/to");
        copyDir2Dir(srcDir, destDir);
        System.out.println("文件夹复制完毕!!!");
    }
}

细节提醒:listFiles() 对空目录返回空数组,路径不是目录或发生 IO 问题时可能返回 null,所以判空和判长度都要做;目标目录不能位于源目录内部,否则递归会不断发现刚复制出来的新目录。(补充说明)

6. 作业与练习

作业文档:day09-字符集、IO流(一)/作业/IO_每日练习.md(三道综合扩展题,核心训练目标是递归调用定义方法)。

题目需求训练目标关键步骤
题目 1从键盘接收两个文件夹路径,把其中一个文件夹(含内容)拷贝到另一个文件夹下(Java 没有直接复制文件夹的方法)递归复制文件夹定义 copyFile2Dir(File, File);定义 copyDir2Dir(File, File):建目录 → listFiles() → 判空 → 遍历 → 文件复制 / 目录递归
题目 2实现文件夹的剪切递归剪切文件夹复用 copyFile2Dir;定义 cutDir2Dir:复制完一个文件就 file.delete(),遍历结束后 srcDir.delete()
题目 3把源文件夹中所有指定类型的文件复制到目标文件夹递归搜索文件夹定义 copyTypeFiles(File, String type, File):用 getName().toLowerCase().endsWith(type.toLowerCase()) 判断类型,目录则递归

三道题共用的「复制文件到文件夹」方法 copyFile2Dir(File file, File dir) 就是本日 3.6 节的写法:FileInputStream 绑源文件、byte[] 当桶、while ((len = is.read(buffer)) != -1) os.write(buffer, 0, len) 循环读、最后 close()。题目 2 在此基础上每复制完一个文件就 file.delete(),遍历结束后 srcDir.delete();题目 3 在此基础上多一个扩展名判断。完整参考答案见作业文档「参考答案」小节。

7. 复习要点

  1. 字符集是「字符 → 码点」的表,编码方案是「码点 → 字节」的规则;编码和解码必须使用同一个字符集,否则乱码。
  2. 标准 ASCII 用 1 个字节存一个字符、首位为 0,共 128 个字符;GBK 中一个汉字占 2 个字节且兼容 ASCII;UTF-8 中一个汉字一般占 3 个字节、英文数字占 1 个字节。
  3. 英文数字一般不会乱码,因为常见字符集都兼容 ASCII;乱码通常从第一个非 ASCII 字符开始,并且后面会整段错位。
  4. Java 中编码用 getBytes() / getBytes(String charsetName),解码用 String(byte[]) / String(byte[], String charsetName)。
  5. IO 流以内存为基准:InputStream/OutputStream 处理字节,Reader/Writer 处理字符;字节流适合所有文件类型(如复制图片、视频),字符流只适合纯文本。
  6. read() 每次读一个字节,返回 -1 表示读完,性能差且读中文必乱码;read(byte[] buffer) 返回本次实际读取的字节数,必须「读取多少就转换多少」,否则会带出上一轮的残留字节。
  7. 想用字节流读中文不乱码,只能一次性读完再整体解码(自定义等长数组,或 Java 官方 readAllBytes());文件过大会内存溢出,大文本应使用字符流。
  8. FileOutputStream 默认是覆盖管道,第二个参数传 true 才是追加;写数据用 write(int)、write(byte[])、write(byte[], pos, len),换行写 "\r\n".getBytes()。
  9. 文件复制是「一个桶、两个管道」:while ((len = is.read(buffer)) != -1) os.write(buffer, 0, len);,字节流复制不解码,所以格式不会坏。
  10. 释放资源两套方案:try-catch-finally(finally 一定会执行,除非 JVM 终止)与 JDK 7 起的 try-with-resource(() 里只能放实现 AutoCloseable 的资源,用完自动 close())。文件夹复制 = 递归遍历 + 文件字节流复制。

day10|java.io 包下的常用 API:IO 流(二)

day09 解决的是”字节怎么搬运”和”编码是什么”;本日解决的是”文本怎么按字符读写、性能怎么提上来、对象怎么落到文件里”。主线是一条条可以叠起来的管道:原始流负责真正碰磁盘(FileReader/FileWriter),包装流在它外面加缓冲(BufferedReader/BufferedWriter、字节缓冲流),转换流负责在字节与字符之间按指定编码搭桥(InputStreamReader/OutputStreamWriter),打印流、数据流、序列化流则负责把”数据以什么格式写出去”这件事确定下来。学完后应能回答:字符输出流为什么必须 flush()/close()、readLine() 和 newLine() 多了什么能力、四种复制方式的性能差在哪、乱码该用哪一层解决、transient 起什么作用、ObjectOutputStream 写出的文件为什么不能当普通文本改。

本日 PPT 目录(第 2 页,第 13/18/26/29/31/35/40/47/52/58 页重复出现作为进度锚点):IO 流-字符流 → IO 流-缓冲流(字节缓冲流、字符缓冲流、原始流与缓冲流性能分析)→ IO 流-转换流 → IO 流-打印流 → IO 流-数据流 → IO 流-序列化流 → 补充知识:IO 框架。

三点说明(供复习时核对):

  • 本日共 62 页幻灯片。课件多处”常用方法”页在抽取文本里只剩 [表格] 占位、没有表体,代码示例也以截图形式贴在页面上。本文按课件的题目页与配套示例工程(day10-IO(二)/代码/day10-io-code)还原这些内容,还原处标注”(表格按课件题目与配套示例整理)”。
  • PPT 抽出的 28 张图片全部逐张查看过:2 张是几乎空白的损坏图(day10-s04-04.png 143 字节、day10-s15-15.png 746 字节),其余 26 张是装饰文字图(如”原始流/低级流""8KB""不同编码读取出现乱码的问题”)、卡通人物与表情包,与知识点无关,已按规范舍弃;只有第 59 页的两张 jar/工程截图有信息量,已归档内嵌。这是本日配图偏少的原因。
  • 代码按课件顺序重新拼接并规范缩进。课件里 new FileWriter(..., true) 等写法保留了原样;标”(片段)“的表示 PPT 上本身就是不完整片段。

1. IO 流体系回顾与本日路线

1.1 字符流和字节流的分工

课件第 2 页给了最朴素的选择依据:

  • 字符流:适合读写文本文件内容(读、写)。
  • 字节流:适合复制文件等;不适合读写文本文件。

第 11 页的小结把这句话展开成两行:

  • 字节流适合做一切文件数据的拷贝(音视频、文本);字节流不适合读取中文内容输出。
  • 字符流适合做文本文件的操作(读、写)。

1.2 四层抽象与实现类

课件第 3、8、14、19、32、37、41、48、53 页反复画同一张体系图,每讲一个专题就往图里加一层。按课件原图整理如下:

层次字节输入字节输出字符输入字符输出
抽象类InputStreamOutputStreamReaderWriter
文件实现类FileInputStreamFileOutputStreamFileReaderFileWriter
缓冲包装类BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter
转换流——InputStreamReaderOutputStreamWriter
打印流—PrintStream—PrintWriter
数据流DataInputStreamDataOutputStream——
对象流ObjectInputStreamObjectOutputStream——

课件把四层分别叫”抽象类""实现类""字节(字符)缓冲输入/输出流""字符输入/输出转换流”。课上强调的两个词:原始流/低级流(真正对接磁盘的 FileXxx)和包装流/处理流(对原始流进行包装,以提高原始流读写数据的性能)。同一支只有一种”真正碰磁盘”的实现,其余都是逐层套上去的。

(补充说明)课件图上写的 FileOutputSteam、OutputStreamWrite 是笔误,正确拼写为 FileOutputStream、OutputStreamWriter。

2. 文件字符输入流 FileReader

2.1 作用与构造

课件第 4 页的定义:以内存为基准,可以把文件中的数据以字符的形式读入到内存中去。 第 4 页配的”磁盘/网络 → 内存 → 程序”示意图说明数据流动方向,以内存为参照,进内存就是”输入”。

构造器(表格按课件题目与配套示例整理):

构造器说明
FileReader(String fileName)按文件路径接通源文件
FileReader(File file)按 File 对象接通源文件

2.2 读字符的两个方法

课件第 6 页的提问是”每次读取一个字符,每次读取多个字符的方法是啥?它们各有什么特点?“对应两个 API:

方法返回值说明
public int read()读到的字符编号(如 'a' 得到 97);无数据可读返回 -1每次读一个字符,必须用循环,性能较差
public int read(char[] buffer)本次实际读取的字符个数;结束返回 -1每次读一批字符,注意用返回长度构造字符串

2.3 每次读一个字符

package com.itheima.d1_char_stream;

import java.io.FileReader;
import java.io.Reader;

public class FileReaderDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:文件字符输入流的使用:每次读取一个字符。
        // 1、创建文件字符输入流管道与源文件接通
        Reader fr = new FileReader("day10-io-code\\src\\dlei01.txt");
        // 2、读取一个字符编号回来,没有字符可读返回 -1: public int read()
        // int c1 = fr.read();
        // System.out.println((char) c1);

        // 3、使用循环解决
        int c;
        while ((c = fr.read()) != -1) {
            char ch = (char) c;
            System.out.print(ch);
        }
        fr.close();

        // 拓展:是否解决了乱码问题:解决了!性能较差,依然是个垃圾代码!
    }
}

课件在这里的结论是两句话:中文乱码问题解决了(前提是代码编码与文件编码一致,见第 7 章),但单字符循环性能依然很差。

2.4 每次读多个字符(截至此刻最好的文本读取方案)

package com.itheima.d1_char_stream;

import java.io.FileReader;
import java.io.Reader;

public class FileReaderDemo2 {
    public static void main(String[] args) {
        // 目标:文件字符输入流的使用:每次读取多个字符。
        try (
                // 1、创建文件字符输入流管道与源文件接通
                Reader fr = new FileReader("day10-io-code\\src\\dlei01.txt");
        ) {
            // 2、定义一个字符数组用于读取多个字符
            char[] buffer = new char[1024];
            int len; // 记住每次读取了多少个字符
            while ((len = fr.read(buffer)) != -1) {
                String rs = new String(buffer, 0, len);
                System.out.print(rs);
            }

            // 拓展:可以避免乱码吗?可以。性能可以吗?可以。
            // 这是截至此刻我们学过的读取文本内容最好的方案。
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

注意两点:数组是 char[] 而不是 byte[];new String(buffer, 0, len) 必须带上 len,否则最后一轮会把上一轮的残留字符一起读出来。

(补充说明)FileReader 使用的是平台默认字符集,课件没有提到可以显式传 Charset。跨环境读文件时,更稳妥的做法是把这一层换成 new InputStreamReader(new FileInputStream(file), charset)(见第 8 章)。

3. 文件字符输出流 FileWriter

3.1 作用与构造

课件第 9 页的定义:以内存为基准,把内存中的数据以字符的形式写出到文件中去。

构造器(表格按课件题目与配套示例整理):

构造器说明
FileWriter(String fileName)覆盖原文件内容
FileWriter(String fileName, boolean append)append 为 true 时在原内容后面追加
FileWriter(File file)以 File 对象作为输出目标

第 12 页的提问正好对应这一节的四个重点:怎么创建管道和追加、写数据有哪些方法、怎么换行、写完必须注意什么。

3.2 写字符的五个重载

方法说明
public void write(int c)写一个字符(可以传字符编号)
public void write(String s)写一个字符串
public void write(String s, int pos, int len)写字符串的一部分
public void write(char[] chars)写一个字符数组
public void write(char[] chars, int pos, int len)写字符数组的一部分

3.3 换行、刷新与关闭

  • 换行:课件用的写法是 fw.write("\r\n");字符缓冲输出流有 newLine()(见第 5 章)。
  • 必须刷新或关闭:课件第 11 页原话是”字符输出流写出数据后,必须刷新流,或者关闭流,写出去的数据才能生效”。字符输出流内部有缓冲区,flush() 把缓冲内容推给底层,close() 关闭并包含刷新。
  • 字节流/字符流的使用场景(第 11 页小结,表格按课件题目整理):
目标推荐说明
拷贝任意文件(音视频、文本)字节流字节流适合做一切文件数据的拷贝
读取中文内容输出字符流字节流不适合读取中文内容输出
文本文件的读、写字符流按字符处理,天然处理中文

3.4 完整示例

package com.itheima.d1_char_stream;

import java.io.FileWriter;

public class FileWriteDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握文件字符输出流的使用。
                // 1、创建一个文件字符输出流管道与目标文件接通
                // Writer fw = new FileWriter("day10-io-code/src/dlei02-out.txt"); // 覆盖
                FileWriter fw = new FileWriter("day10-io-code/src/dlei02-out.txt", true); // 追加
        ) {
            // 2、写字符数据出去。
            // a.写一个字符出去 : public void write(int c)
            fw.write(97);
            fw.write('磊');
            fw.write("\r\n"); // 换行

            // b.写一个字符串出去:public void write(String s)
            fw.write("abc我爱你中国666");
            fw.write("\r\n"); // 换行

            // c、写字符串的一部分出去 : public void write(String s, int pos, int len)
            fw.write("abc我爱你中国666", 3, 3);
            fw.write("\r\n"); // 换行

            // d.写一个字符数组出去:public void write(char[] chars)
            char[] chars = "abc我爱你中国666".toCharArray();
            fw.write(chars);
            fw.write("\r\n"); // 换行

            // e、写一个字符数组的一部分出去:public void write(char[] chars, int pos, int len)
            fw.write(chars, 6, 2);
            fw.write("\r\n"); // 换行

            // fw.flush(); // 刷新
            // fw.close(); // 关闭包含刷新(这里交给 try-with-resources)
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

fw.write(97) 写出的是字符 a,不是数字 97 的文本——这一点与后面的打印流正好相反。

4. 缓冲流:给原始流加一个 8KB 的池子

4.1 概念

课件第 13 页引出缓冲流时先给了一组名词:原始流/低级流 与 包装流/处理流——包装流的作用是”对原始流进行包装,以提高原始流读写数据的性能”。第 17 页的提问把要点说全了:

  • 缓冲流有几种:字节缓冲输入流 BufferedInputStream、字节缓冲输出流 BufferedOutputStream、字符缓冲输入流 BufferedReader、字符缓冲输出流 BufferedWriter。
  • 为什么能提高性能:字节缓冲流自带 8KB 缓冲区;可以提高原始字节流、字符流读写数据的性能。
  • 怎么用:把原始流作为构造参数传进去,功能上并无很大变化,性能提升了。
包装类包装的流自带缓冲新增能力
BufferedInputStreamInputStream8KB缓冲读
BufferedOutputStreamOutputStream8KB缓冲写
BufferedReaderReader8K(8192)字符readLine() 按行读取
BufferedWriterWriter8K 字符newLine() 换行

4.2 构造器

public BufferedOutputStream(OutputStream os)
public BufferedInputStream(InputStream is)

4.3 原理:内存与磁盘之间多了一层缓冲池

课件第 15 页用”C 盘 / D 盘 / 内存 / 字节数组 / 1KB / 16KB”等标注画了字节缓冲流的作用示意:原始字节流每次只搬 1KB 字节数组,一次一次地访问磁盘;套上缓冲流后,缓冲池(8KB)先在内存里攒够一批再和磁盘交互,从而减少访问磁盘的次数。第 20 页给字符缓冲输入流画的是同一张图,只是单位换成”字符数组 1K / 16K 字符”。

4.4 字节缓冲流示例

package com.itheima.d2_buffer_stream;

import java.io.*;

public class BufferedInputStreamDemo1 {
    public static void main(String[] args) {
        // 目标:使用字节缓冲流提升原始字节流读写数据的性能的。
        try (
                // 1、创建字节输入流管道与源文件接通
                InputStream is = new FileInputStream("E:\\resource\\meinv2.jpg");
                // 使用高级的缓冲字节输入流包装低级的字节输入流
                InputStream bis = new BufferedInputStream(is);

                // 2、创建一个字节输出流管道与目标文件接通
                OutputStream os = new FileOutputStream("E:\\resource\\meinv2-bak2.txt");
                // 使用高级的缓存字节输出流包装低级的字节输出流
                OutputStream bos = new BufferedOutputStream(os);
        ) {
            // 3、准备一个字节数组
            byte[] buffer = new byte[1024];
            // 4、转移数据
            int len;
            while ((len = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

5. 字符缓冲流 BufferedReader / BufferedWriter

5.1 BufferedReader

课件第 20、21 页:自带 8K(8192)的字符缓冲池,可以提高字符输入流读取字符数据的性能;除此之外,字符缓冲输入流新增了”按照行读取字符”的功能。

构造器 / 方法说明
public BufferedReader(Reader r)包装一个字符输入流
public String readLine()一次读取一行;读完了返回 null
package com.itheima.d2_buffer_stream;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.Reader;

public class BufferedReaderDemo2 {
    public static void main(String[] args) {
        // 目标:字符缓冲输入流的使用:提升原始字符输入流的读字符性能,多了按照行读取的能力。
        try (
                // 1、创建文件字符输入流管道与源文件接通
                Reader fr = new FileReader("day10-io-code\\src\\dlei04.txt");
                // 把低级的字符输入流包装成高级的缓存字符输入流。
                BufferedReader br = new BufferedReader(fr);
        ) {
            // 2、也可以按字符数组读取(这里只演示按行读取)
            // char[] buffer = new char[1024];
            // int len;
            // while ((len = br.read(buffer)) != -1) {
            //     System.out.print(new String(buffer, 0, len));
            // }

            // 3、缓存字符输入流多了一个按照行读取内容的功能。(经典代码)
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

readLine() 的判空条件必须是 != null(没有”行号”可读时返回 null),不是 != -1;返回的行内容不含换行符。

5.2 BufferedWriter

课件第 22、23 页:自带 8K 的字符缓冲池,可以提高字符输出流写字符数据的性能;新增了”换行”功能。

构造器 / 方法说明
public BufferedWriter(Writer w)包装一个字符输出流
public void newLine()写一个平台相关的换行符
package com.itheima.d2_buffer_stream;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.Writer;

public class BufferedWriterDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握缓冲字符输出流的使用。
                // 1、创建一个文件字符输出流管道与目标文件接通
                Writer fw = new FileWriter("day10-io-code/src/dlei05-out.txt"); // 覆盖
                BufferedWriter bw = new BufferedWriter(fw);
        ) {
            // 2、写字符数据出去。(write 的五个重载与 FileWriter 完全一致)
            bw.write(97);
            bw.write('磊');
            bw.newLine(); // 换行

            bw.write("abc我爱你中国666");
            bw.newLine();

            bw.write("abc我爱你中国666", 3, 3);
            bw.newLine();

            char[] chars = "abc我爱你中国666".toCharArray();
            bw.write(chars);
            bw.newLine();

            bw.write(chars, 6, 2);
            bw.newLine();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

第 24 页的自查结论可以背下来:字符缓冲流自带 8K 缓冲区,可以提高原始字符流读写数据的性能;BufferedReader 多了 readLine() 按行读取,BufferedWriter 多了 newLine() 换行。

6. 案例:《出师表》按编号恢复顺序

课件第 25 页的需求:把《出师表》的文章顺序进行恢复到一个新文件中。 课件给的分析步骤是六步:

  1. 定义一个缓冲(课件写作”缓存”)字符输入流管道与源文件接通。
  2. 定义一个 List 集合存储读取的每行数据。
  3. 定义一个循环按照行读取数据,存入到 List 集合中去。
  4. 对 List 集合中的每行数据按照首字符编号升序排序。
  5. 定义一个缓冲字符输出管道与目标文件接通。
  6. 遍历 List 集合中的每个元素,用缓冲输出管道写出并换行。

源文件 csb.txt 每行形如 3.侍中、侍郎郭攸之…,编号是乱的(3、8、4、2、1、9、6、7、5)。

package com.itheima.d2_buffer_stream;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Test4 {
    public static void main(String[] args) {
        // 目标:完成出师表案例。
        try (
                // 2、创建缓冲字符输入流与源文件接通
                BufferedReader br = new BufferedReader(new FileReader("day10-io-code\\src\\csb.txt"));
                // 6、创建缓冲字符输出流与目标文件接通
                BufferedWriter bw = new BufferedWriter(new FileWriter("day10-io-code\\src\\csb-new.txt"));
        ) {
            // 1、准备一个集合,存储原文每段落。
            List<String> data = new ArrayList<>();

            // 3、开始按照行读取,并存入到集合中去。
            String line;
            while ((line = br.readLine()) != null) {
                data.add(line);
            }
            System.out.println(data);

            // 4、对每段按照首字符编号进行排序
            Collections.sort(data);
            System.out.println(data);

            // 5、把排序好的集合中的每段文章写出到新文件中去,每行都要换行。
            for (String ln : data) {
                bw.write(ln);
                bw.newLine();
            }

            System.out.println("完成了!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

课件说法:第 4 步是”按照首字符编号升序排序”,示例代码用的是 Collections.sort(data)。更准确的表述:这里实际是按整行字符串的自然顺序排序。本例编号只有 1~9 且每行第二位都是 .,所以字符串排序碰巧得到正确的文章顺序;一旦编号出现两位数,"10.…" 会被排到 "2.…" 前面,结果就错了。要真正按编号排序,应先把 . 前面的完整编号解析成整数再比较。

7. 原始流与缓冲流的性能分析(重点)

课件第 27 页的测试用例是”分别使用原始的字节流,以及字节缓冲流复制一个很大视频”,四种写法:

  1. 使用低级的字节流按照一个一个字节的形式复制文件。
  2. 使用低级的字节流按照字节数组的形式复制文件。
  3. 使用高级的缓冲字节流按照一个一个字节的形式复制文件。
  4. 使用高级的缓冲字节流按照字节数组的形式复制文件。
package com.itheima.d2_buffer_stream;

import java.io.*;

public class TimeTest5 {
    public static final String SRC_VIDEO = "D:\\sziitheima1\\day02-面向对象进阶(二)\\视频\\11、接口的综合案例.avi";
    public static final String DEST_PATH = "E:\\resource\\";

    public static void main(String[] args) {
        // 目标:原始流和缓冲流的性能分析。
        // copy01(); // 使用低级的字节流按照一个一个字节的形式复制文件: 简直慢的让人无法忍受,直接淘汰,禁止使用!
        copy02();   // 使用低级的字节流按照字节数组的形式复制文件:还可以,但是相对较慢!
        // copy03(); // 使用高级的缓冲字节流按照一个一个字节的形式复制文件:还是特别慢,不推荐使用!
        copy04();   // 使用高级的缓冲字节流按照字节数组的形式复制文件:极快!推荐使用!
    }

    // 4、使用高级的缓冲字节流按照字节数组的形式复制文件。
    public static void copy04() {
        long start = System.currentTimeMillis();
        try (
                InputStream is = new FileInputStream(SRC_VIDEO);
                InputStream bis = new BufferedInputStream(is);
                OutputStream os = new FileOutputStream(DEST_PATH + "04.avi");
                OutputStream bos = new BufferedOutputStream(os);
        ) {
            byte[] buffer = new byte[32 * 1024];
            int len;
            while ((len = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("使用高级的缓冲字节流按照字节数组的形式复制文件:" + (end - start) / 1000.0 + "s");
    }

    // 另外三种写法与上面的区别只在"用哪个流"和"怎么读",核心循环如下(片段)。
    // 1、使用低级的字节流按照一个一个字节的形式复制文件(copy01):
    //        int b;
    //        while ((b = is.read()) != -1) {
    //            os.write(b);
    //        }
    // 2、使用低级的字节流按照字节数组的形式复制文件(copy02):
    //        byte[] buffer = new byte[32 * 1024];
    //        int len;
    //        while ((len = is.read(buffer)) != -1) {
    //            os.write(buffer, 0, len);
    //        }
    // 3、使用高级的缓冲字节流按照一个一个字节的形式复制文件(copy03):
    //        int b;
    //        while ((b = bis.read()) != -1) {
    //            bos.write(b);
    //        }
}

四个方法都用 System.currentTimeMillis() 取开始/结束时间打印耗时,只有 copy04 是”缓冲流 + 字节数组”的组合。

课件第 28 页的结论:建议使用字节缓冲输入流、字节缓冲输出流,结合字节数组的方式,目前来看是性能最优的组合。

(补充说明)具体秒数取决于文件大小、磁盘类型和系统缓存。这个排序反映的是”单字节调用会产生海量系统调用”这一结构性问题,而缓冲流把每次调用变成数组搬运;换文件大小或换机器,绝对耗时都会变,但相对顺序基本一致。

8. 转换流:用指定编码搭桥

8.1 引出问题:不同编码读取时会乱码

课件第 30 页给出两条判断:

  • 如果代码编码和被读取的文本文件编码一致,使用字符流读取文本文件时不会出现乱码。
  • 如果不一致,使用字符流读取文本文件时就会出现乱码。

配套示例的注释写得更直接(代码编码 UTF-8):文件编码 UTF-8 时 a我m 正常;文件编码 GBK 时读出来就是乱码。原因和 day09 讲的一样:字节本身没有”编码”属性,字符流必须用一个字符集去解码。

8.2 字符输入转换流 InputStreamReader

课件第 33 页:解决不同编码时,字符流读取文本内容乱码的问题。 解决思路是”先获取文件的原始字节流,再将其按真实的字符集编码转成字符输入流,这样字符输入流中的字符就不乱码了”。

构造器说明
public InputStreamReader(InputStream is)按平台默认字符集解码
public InputStreamReader(InputStream is, String charset)按指定字符集解码,字符流中的字符不乱码
package com.itheima.d3_transfer_stream;

import java.io.*;

public class InputStreamReaderDemo2 {
    public static void main(String[] args) {
        // 目标:字符输入转换流。
        try (
                // 1、得到GBK文件的原始字节输入流
                InputStream is = new FileInputStream("E:\\resource\\abc.txt");
                // 2、通过字符输入转换流把原始字节流按照指定编码转换成字符输入流
                Reader isr = new InputStreamReader(is, "GBK");
                // 3、把字符输入流包装成高级的缓冲字符输入流。
                BufferedReader br = new BufferedReader(isr);
        ) {
            // 4、按照行读取
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

对照实验(d3_transfer_stream/Test1.java):把同一份文件交给 new FileReader(...) 直接读,文件是 GBK 时就会乱码——所以乱码不是”缓冲流没用好”,而是解码用的字符集和文件真实编码不一致。

// 目标:不同编码下,字符流读取文本内容的问题。(片段)
// 代码编码:UTF-8   文件编码:UTF-8   a我m ==> o [ooo] o  不乱码
// 代码编码:UTF-8   文件编码:GBK     a我m ==> o [oo] o   乱码
Reader fr = new FileReader("E:\\resource\\abc.txt");
BufferedReader br = new BufferedReader(fr);

8.3 字符输出转换流 OutputStreamWriter

课件第 36 页先问”调用 String 提供的 getBytes 方法解决?“并给出一行代码:

String data = "我爱你中国abc";
byte[] bytes = data.getBytes("GBK");

这只是”把字符串变成指定编码的字节”这一半;要写出去还得再套一个字节输出流。课件随后给出更完整的方案——使用”字符输出转换流”实现。

课件第 38 页:OutputStreamWriter 的作用是可以控制写出去的字符使用什么字符集编码;解决思路是”获取字节输出流,再按照指定的字符集编码将其转换成字符输出流,以后写出去的字符就会用该字符集编码了”。

构造器说明
public OutputStreamWriter(OutputStream os)按平台默认字符集编码写出
public OutputStreamWriter(OutputStream os, String charset)指定写出去的字符编码
package com.itheima.d3_transfer_stream;

import java.io.*;

public class OutputStreamWriterDemo3 {
    public static void main(String[] args) {
        try (
                // 目标:掌握字符输出转换流:可以指定写出去的字符编码。
                // 1、创建一个文件字节输出流通向目标文件。
                OutputStream os = new FileOutputStream("day10-io-code/src/dlei06-out.txt");
                // 指定写出去的编码是GBK.
                Writer osw = new OutputStreamWriter(os, "GBK");
                // 2、把字符输出流包装成缓冲输出流
                BufferedWriter bw = new BufferedWriter(osw);
        ) {
            bw.write("莫道桑榆晚");
            bw.newLine();
            bw.write("为霞尚满天");
            bw.newLine();
            bw.write("abc666");
            bw.newLine();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

第 39 页的自查结论:public OutputStreamWriter(OutputStream os, String charset) 可以指定编码把字节输出流转换成字符输出流,从而指定写出去的字符编码。

(补充说明)课件用字符串写编码名("GBK")。现代写法更推荐传 Charset 对象或 StandardCharsets.UTF_8,避免拼错的编码名只在运行时才报错。

9. 打印流 PrintStream / PrintWriter

9.1 作用

课件第 42 页:打印流可以实现更方便、更高效的打印数据出去,能实现打印啥出去就是啥出去。

第 41 页的体系图把 PrintStream、PrintWriter 补进 IO 体系:PrintStream 继承自字节输出流 OutputStream,PrintWriter 继承自字符输出流 Writer。

类型父类能写什么典型方法
PrintStreamOutputStream支持写字节数据的方法println(...)、print(...)
PrintWriterWriter支持写字符数据出去println(...)、print(...)

课件第 44、45 页把区别说得很清楚:打印数据的功能上一模一样(都是使用方便、性能高效,这是核心优势),区别只在继承关系——PrintStream 继承自字节输出流,因此支持写字节数据的方法;PrintWriter 继承自字符输出流,因此支持写字符数据出去。

9.2 打印各种类型的数据

package com.itheima.d4_printstream;

import java.io.PrintStream;

public class PrintStreamDemo1 {
    public static void main(String[] args) {
        try (
                // 目标:打印流:方便,高效的写数据出去。
                PrintStream ps = new PrintStream("day10-io-code/src/ps.txt");
                // PrintWriter ps = new PrintWriter("day10-io-code/src/ps.txt");
                // PrintWriter ps = new PrintWriter(new FileWriter("day10-io-code/src/ps.txt", true));
        ) {
            ps.println(666);
            ps.println(97);
            ps.println(97.9);
            ps.println('a');
            ps.println(true);
            ps.println("深圳黑马Java!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

打印流可以直接以文件路径为构造参数,不需要自己先建字节输出流。写入文件里的是数据的文本形式(666、97.9、true),这一点与下一章的数据流完全不同。

9.3 输出语句的重定向

课件第 46 页给的场景是”打印流的一种应用:输出语句的重定向”:

PrintStream ps = new PrintStream("文件地址");
System.setOut(ps);
System.out.println("老骥伏枥");
System.out.println("志在千里");
System.out.println("烈士暮年");
System.out.println("壮心不已");

课件说明:可以把输出语句的打印位置改到某个文件中去。 完整示例(追加方式打开,避免每次运行都清空文件):

package com.itheima.d4_printstream;

import java.io.FileOutputStream;
import java.io.PrintStream;

public class PrintStreamDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:输出语句的重定向。
        System.out.println("红豆生南国");
        System.out.println("春来发几枝");

        PrintStream ps = new PrintStream(new FileOutputStream("day10-io-code/src/ps2.txt", true));
        System.setOut(ps); // 把系统类的打印流改成我的打印流!!

        System.out.println("愿君多采撷");
        System.out.println("此物最相思");
    }
}

前两行仍然打印到控制台,setOut 之后的两行写进了文件——System.out 本身就是一个 PrintStream,只是它的目标被换掉了。

10. 数据流 DataOutputStream / DataInputStream

课件第 49 页:DataOutputStream(数据输出流)允许把数据和其类型一并写出去。第 50 页:DataInputStream(数据输入流)用于读取数据输出流写出去的数据。

类方法(示例用到的)说明
DataOutputStreamwriteByte、writeBoolean、writeInt、writeChar、writeUTF把值和类型一起写出去
DataInputStreamreadByte、readBoolean、readInt、readChar、readUTF按对应方法读回,顺序必须与写出顺序一致
package com.itheima.d5_dataOutputStream;

import java.io.DataOutputStream;
import java.io.FileOutputStream;

public class DataOutputStreamDemo1 {
    public static void main(String[] args) {
        try (
                // 目标:掌握数据输出流写数据出去的特点:可以写数据和其类型,而且后面可以读取出来。
                DataOutputStream dos = new DataOutputStream(new FileOutputStream("day10-io-code/src/data.txt"));
        ) {
            // 2、写数据和其类型出去
            dos.writeByte(97);
            dos.writeBoolean(true);
            dos.writeInt(1000);
            dos.writeChar('a');
            dos.writeUTF("我在北京天安门666");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d5_dataOutputStream;

import java.io.DataInputStream;
import java.io.FileInputStream;

public class DataInputStreamDemo2 {
    public static void main(String[] args) {
        // 目标:掌握特殊数据输入流读取特殊数据输出流写出去的数据。
        try (
                // 1、创建高级的特殊数据输入流管道包装低级的字节输入流管道。
                DataInputStream dis = new DataInputStream(new FileInputStream("day10-io-code/src/data.txt"));
        ) {
            // 2、开始读取
            byte b = dis.readByte();
            System.out.println(b);

            boolean b1 = dis.readBoolean();
            System.out.println(b1);

            int i = dis.readInt();
            System.out.println(i);

            char c = dis.readChar();
            System.out.println(c);

            String s = dis.readUTF();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

数据流写出的文件是二进制协议,不能用记事本直接看;读取时类型和顺序必须和写出时完全一致,否则读到的是错位的字节。第 51 页的自查问题就一句:“数据输入流和数据输出流分别是谁,各自的作用是啥?”

11. 对象序列化流 ObjectOutputStream / ObjectInputStream

11.1 概念

课件第 52 页的两句定义:

  • 对象序列化:把 Java 对象写入到文件中去。
  • 对象反序列化:把文件里的 Java 对象读出来。

11.2 序列化

课件第 54 页:ObjectOutputStream(对象字节输出流)可以把 Java 对象进行序列化,把 Java 对象存入到文件中去;方法 public void writeObject(Object obj)。注意:对象如果要参与序列化,必须实现序列化接口(java.io.Serializable)。

package com.itheima.d6_object;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
// 注意:如果学生对象要参与序列化,必须实现序列化接口
public class Student implements Serializable {
    private String name;
    private int age;
    // transient 修饰的成员变量将不参与序列化
    private transient String passWord;
    private double height;
}
package com.itheima.d6_object;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;

public class ObjectOutputStreamDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:完成对象的序列化:把Java对象存储到文件中去
        // 1、准备一个对象。
        Student s = new Student("风驴子", 27, "mazi666", 160);
        // 2、创建对象字节输出流管道与目标文件接通
        ObjectOutputStream oos =
                new ObjectOutputStream(new FileOutputStream("day10-io-code/src/obj.txt"));
        // 3、开始写对象出去
        oos.writeObject(s);
        // 4、关闭资源
        oos.close();
    }
}

Serializable 是一个标记接口,里面没有任何方法,作用只是告诉序列化机制”这个类的对象允许被写出”。否则运行时会抛 NotSerializableException。

11.3 transient

课件写在 Student 上的注释是:transient 修饰的成员变量将不参与序列化。示例里 passWord 被 transient 修饰,反序列化后读到的会是 null(字符串类型默认值)。它适合放密码、临时状态这类”不值得/不应该落到文件里”的字段。

(补充说明)课件只讲了 transient。更完整的边界是:static 字段属于类而不属于对象实例,默认也不参与对象序列化;反序列化要求类在读写两端都存在且兼容。

11.4 反序列化

课件第 55 页:ObjectInputStream(对象字节输入流)可以把 Java 对象进行反序列化,把存储在文件中的 Java 对象读入到内存中来。

package com.itheima.d6_object;

import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class ObjectInputStreamDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:对象反序列化 使用对象字节输入流管道把文件中的Java对象又读入到内存中来。
        // 1、创建对象字节输入流管道
        ObjectInputStream ois =
                new ObjectInputStream(new FileInputStream("day10-io-code\\src\\obj.txt"));

        // 2、读取对象进来(对象反序列化)
        Student s = (Student) ois.readObject();

        System.out.println(s);

        ois.close();
    }
}

readObject() 返回的是 Object,需要强转成具体类型。

11.5 一次序列化多个对象

课件第 56 页给的办法是:用一个 ArrayList 集合存储多个对象,然后直接对集合进行序列化即可;并特别注明”注意:ArrayList 集合已经实现了序列化接口”。因为集合本身可序列化,只要里面的元素也实现了 Serializable,整体就能一次写出、一次读回。

第 57 页的自查结论:序列化是把对象数据存入到文件中去,用 ObjectOutputStream 的 public void writeObject(Object obj),对象必须实现序列化接口;反序列化是把文件中的 Java 对象恢复到内存中的程序里来,用 ObjectInputStream 的 readObject()。

(补充说明)课件没有提到 serialVersionUID。类结构变化后(例如增删字段)反序列化可能因版本不匹配而失败,工程上通常会显式声明 serialVersionUID;另外,对一个来源不可信的文件做原生反序列化等于执行对方的对象图,练习只应使用自己生成的文件。

12. 补充知识:IO 框架与 Commons-io

12.1 什么是框架

课件第 59 页给了框架的定义与好处:

  • 框架:解决某类问题、编写的一套类、接口等,可以理解成一个半成品,大多框架都是第三方研发的。
  • 好处:在框架的基础上开发,可以得到优秀的软件架构,并能提高开发效率。
  • 形式:一般是把类、接口等编译成 class 形式,再压缩成一个 .jar 结尾的文件发行出去。

本日讲的 IO 框架是 Commons-io(第 61 页):它是 apache 开源基金组织提供的一组有关 IO 操作的小框架,目的是提高 IO 流的开发效率;它”封装了 Java 提供的对文件、数据进行操作的代码,对外提供了更简单的方式来对文件进行操作,对数据进行读写等”。

工程中引入的 commons-io-2.11.0.jar

12.2 引入步骤

课件第 60 页的四步:

  1. 在项目中创建一个文件夹:lib。
  2. 将 commons-io-2.6.jar 文件复制到 lib 文件夹。
  3. 在 jar 文件上点右键,选择 Add as Library -> 点击 OK。
  4. 在类中导包使用。

课件这一页的文字与配图不一致:步骤写的是 commons-io-2.6.jar,而页面上的文件名和配套工程 lib 目录里实际都是 commons-io-2.11.0.jar。以工程实际文件为准,本日使用的是 2.11.0。

解压 commons-io jar 后看到的包与类结构(file、filefilter、function、input、monitor、output、serialization 等包与 CopyUtils、FileDeleteStrategy 等类)

这张截图可以看到框架的”半成品”长什么样:comparator、file、filefilter、function、input、monitor、output、serialization 等包,加上 Charsets、CopyUtils、DirectoryWalker、FileDeleteStrategy 等类——它其实就是把前面那些 InputStream/OutputStream 的组合封装成了更省事的工具方法。

12.3 FileUtils 常用操作

课件第 61 页的表格(按课件题目与配套示例整理):

方法说明
FileUtils.copyFile(File src, File dest)复制文件
FileUtils.copyDirectory(File srcDir, File destDir)复制文件夹(含子目录)
FileUtils.deleteDirectory(File dir)删除文件夹(含内容)
package com.itheima.d7_commons_io;

/**
 * 目标:使用CommonsIO框架进行IO相关的操作。
 */
public class CommonsIOTest1 {
    public static void main(String[] args) throws Exception {
        // FileUtils.copyFile(new File("day10-io-code/src/dlei04.txt"),
        //         new File("day10-io-code/src/dlei04-new.txt"));
        // FileUtils.copyDirectory(new File("E:\\resource"), new File("E:\\resource-dlei-bak"));
        // FileUtils.deleteDirectory(new File("E:\\resource-dlei-bak"));

        // JDK 7开始也新增了单行复制相关的计数。
        Files.copy(Path.of("day10-io-code/src/dlei04.txt"),
                Path.of("day10-io-code/src/dlei04-dlei-666.txt"));
    }
}

最后一行提示:JDK 7 起 java.nio.file.Files 也提供了单行复制,简单场景不必为了复制一个文件引入第三方 jar。

12.4 递归复制文件夹

课件这套框架最典型的用法是”复制文件夹”——文件夹里可能还有文件夹,所以要递归。示例中的关键判断(d8_demo/CopyDirectoryDemo1.java):

package com.itheima.d8_demo;

import org.apache.commons.io.FileUtils;

import java.io.File;

public class CopyDirectoryDemo1 {
    public static void main(String[] args) {
        // 目标:复制文件夹。
        // 源文件夹:E:\resource
        // 目标位置:D:\
        try {
            copyDirectory(new File("E:\\resource"), new File("D:\\"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void copyDirectory(File srcDir, File destDir) throws Exception {
        // 1、极端判断:源和目标必须是已存在的目录
        if (srcDir == null || destDir == null || !srcDir.exists() || !destDir.exists()
                || srcDir.isFile() || destDir.isFile()) {
            return;
        }
        // 2、先在目标位置创建和源文件夹同名的目录
        File destNewDir = new File(destDir, srcDir.getName());
        destNewDir.mkdirs();
        // 3、拿到一级文件对象
        File[] files = srcDir.listFiles();
        if (files == null || files.length == 0) return;
        // 4、遍历一级文件对象:是文件就复制,是文件夹就递归
        for (File file : files) {
            if (file.isFile()) {
                FileUtils.copyFile(file, new File(destNewDir, file.getName()));
            } else {
                copyDirectory(file, destNewDir);
            }
        }
    }
}

配套工程里还有一个不依赖 Commons-io 的”删除文件夹”版本(d8_demo/DeleteDirDemo2.java),它把 day08 的递归套路又走了一遍:是文件直接删,是空文件夹直接删,listFiles() 返回 null(无权限)时不删,否则先递归删子项、最后删自己。这两个例子一起说明:框架省掉的是重复代码,不改变”目录遍历 + 递归”这套逻辑本身。

13. 作业与练习

本日无配套作业文档(day10-IO(二) 目录下只有 PPT/、代码/、类库/,没有作业目录)。

课堂练习以课件与配套工程中的示例为准,建议按顺序各写一遍:

  1. FileReader 每次读一个字符 / 每次读一个字符数组,比较乱码与性能(d1_char_stream)。
  2. FileWriter 覆盖与追加两种模式,把五个 write 重载各用一次(FileWriteDemo3)。
  3. 字节缓冲流 + 字节数组复制文件,再自己计时对比四种复制方式(d2_buffer_stream/TimeTest5)。
  4. 用缓冲字符流完成《出师表》按编号恢复顺序(d2_buffer_stream/Test4,源文件 src/csb.txt)。
  5. 用 InputStreamReader 读 GBK 文件解决乱码;用 OutputStreamWriter 写出 GBK 编码文件。
  6. 用 PrintStream 把 System.out 重定向到文件。
  7. 用 DataOutputStream 写出多种类型,再用 DataInputStream 按同序读回。
  8. 让 Student 实现 Serializable,观察 transient 字段反序列化后的值;再把多个对象放进 ArrayList 一次序列化。
  9. 用 FileUtils 完成文件/文件夹的复制与删除(d7_commons_io、d8_demo)。

需要说明的是,配套工程 d8_demo 下还有 BeerDemo3.java(10 元买啤酒、2 个空瓶换 1 瓶、4 个盖子换 1 瓶的递归练习),课件正文没有对应的讲解页,只当作课堂补充练习看待。

14. 复习要点

  1. 选流:拷贝任意文件用字节流(FileXxx + 缓冲 + 字节数组);读写文本用字符流;不确定编码时用转换流指定字符集。
  2. FileReader:read() 返回字符编号、结束返回 -1;read(char[]) 返回本次读取字符数,构造字符串必须带长度。
  3. FileWriter:写出后必须 flush() 或 close() 才生效;构造器的第二个参数 true 表示追加;五个 write 重载要能对上号。
  4. 缓冲流:四种(字节/字符 × 输入/输出),自带 8KB 缓冲,构造时把原始流包进去;功能不变,性能提升。
  5. 字符缓冲流新增能力:readLine() 按行读到 null 结束(不含换行符);newLine() 换行。
  6. 性能结论:单字节读写最慢(海量系统调用),“缓冲字节流 + 字节数组”是最优组合。
  7. 乱码定位:代码编码与文件编码不一致 → 用 InputStreamReader(is, charset) 按真实编码解码;要控制写出编码用 OutputStreamWriter(os, charset)。
  8. 打印流:PrintStream/PrintWriter 打印功能一致,前者继承 OutputStream、后者继承 Writer;System.setOut(ps) 可以重定向输出。
  9. 数据流:DataOutputStream 写”值 + 类型”,DataInputStream 必须按相同类型、相同顺序读回。
  10. 序列化:对象要实现 Serializable,用 writeObject/readObject;transient 字段不参与序列化;多个对象可以装进 ArrayList 一起序列化。
  11. Commons-io:jar 放进 lib → Add as Library → 导包使用;FileUtils.copyFile/copyDirectory/deleteDirectory 省掉重复代码,逻辑仍是递归遍历目录。

day11|特殊文件:XML、Properties 与日志框架

day08–day10 一直在处理”普通文件”的搬运(字节、字符、缓冲、序列化),本日换了一类目标:有格式的特殊文件。Properties 属性文件负责存”键值对式配置”,XML 负责存”有层次、有关系的复杂数据”,日志技术则解决”程序跑起来以后,怎么把过程记下来、怎么定位 bug”。学完后应能回答:属性文件的三个特点是什么、Properties 为什么说”是 Map 但一般不当 Map 用”、load() 和 store() 各配哪一路 IO、XML 的语法红线有哪些(文档声明在第一行、根标签唯一、正确嵌套)、特殊字符和 CDATA 分别解决什么问题、Dom4j 的”文档对象模型”是怎么一层层往下取数据的、DTD 和 Schema 的区别、为什么要用日志框架而不是 System.out.println、Logback 至少要引哪三个模块、logback.xml 为什么必须放 src 下、日志级别怎么控制输出。

本日 PPT 目录(第 7 页,第 13/22/32/37/42/46/50 页重复出现作为进度锚点):特殊文件:Properties 属性文件 → 特殊文件:XML 文件(概述 / 读取 XML 文件中的数据 / 补充知识:约束 XML 文件的编写[了解])→ 日志技术(概述 / 日志技术体系、Logback 日志框架的概述 / Logback 快速入门 / Logback 设置日志级别)。

四点说明(供复习时核对):

  • 本日共 54 页幻灯片。抽取文本里多处”方法/API”页只剩 [表格] 占位、没有表体(第 10、11、26、28、41、51 页),本文按课件的题目与配套示例工程还原,还原处标注”(表格按课件题目与配套示例整理)”。
  • PPT 抽出的 39 张图片已逐张查看:其中约 29 张是装饰性大字标题图(“日志技术""项目""什么是日志?""什么是日志级别?""为什么要学习日志级别?""不建议用 dom4j,然后用 IO 流写出去!“等)、卡通人物与表情包(“肿么办啊""不合适""好期待,好期待""OK”女生、黑色手指图标)、以及手机 App 截图(淘宝、顺丰),与知识点无关,按规范舍弃。有信息量的 10 张已压缩归档内嵌。
  • 代码取自配套示例工程,包名为 com.itheima.d1_properties / d2_xml / d3_logback;PPT 上的代码是拆散在多个文本框里的截图,本文按语义拼回并统一 4 空格缩进。
  • 课件第 47 页把创建 Logger 写成 LoggerFactory.getLogger("类名");配套代码里写的是 LoggerFactory.getLogger("Test.class")(传的是字符串字面量)。两种写法都能拿到 Logger 对象,实际开发中更常见的是传 Class 对象,即 LoggerFactory.getLogger(Test.class)。

1. 为什么要用特殊文件

课件第 2 页并排列出三个文件:普通文件.txt、属性文件.properties、XML文件.xml;第 3 页给出判断:普通 .txt 是 NO!,属性文件和 XML 文件是 Yes!!!。原因在截图里看得很清楚。

普通文件.txt 里的内容是”格式随意”的:

普通的属性文件、里面的内容可以随便写,格式随意!
以下是一些用户的用户名和密码
admin=12343232zhangsan=23232liergou=xiaohua
wangmazi=23223quandaner=123

程序要读这段数据,就得自己写正则去”猜”哪里是用户名、哪里是密码——因为文件本身没有告诉程序”格式”是什么。第 4 页把需求换成”存储多个用户的:用户名、密码、家乡、性别”,问题更明显:用 .properties 只能写成一堆靠编号区分的同名键(name1、password2、address2、sex3),用 .xml 就可以靠嵌套标签把”一个用户”这个整体表达清楚。

![存同一组用户信息时,属性文件只能用 name1、password2 这类编号键区分](https://studio.shenziggy.top/media/3217fc92-088e-475c-8f6a-714f24e3b164) ![同一组用户信息改用 XML,一个 user 标签就是一个整体](https://studio.shenziggy.top/media/905f44a7-f476-416e-b93b-a77eb504beda)
![普通文件.txt 内容格式随意,无法区分字段](https://studio.shenziggy.top/media/18eb1e5a-25eb-4f05-817a-bf669f7b14d4) ![属性文件.properties 是规整的键值对](https://studio.shenziggy.top/media/d2575405-276c-4564-89a1-30b72cf36fc2) ![XML 文件用嵌套标签表达多个用户的结构](https://studio.shenziggy.top/media/90bdebd7-cc94-4cce-9912-acdf752bb8f7)

课件给特殊文件总结的用途只有两条(第 4 页):

  • 存储有关系的数据,作为系统的配置文件
  • 作为信息进行传输

第 5 页说明本专题的学习目标,也是后面三章的主线:了解它们的特点和作用 → 学习使用程序读取它们里面的数据 → 学习使用程序把数据存储到这些文件里。

2. Properties 属性文件

2.1 属性文件的三个特点

课件第 8 页把属性文件的规则压成三条:

  1. 都只能是键值对;
  2. 键不能重复;
  3. 文件后缀一般是 .properties 结尾。

具体长这样(第 2 页截图):

# 这是注释,以下都是一些用户名和密码信息
# 属性文件的内容都是一些键值对信息,每行都是一个键值对
# 属性文件的后缀:一般都是 .properties 结尾的
admin=123456
liergouzi=小花
zhangsan=666888
lisi=iloveu
全蛋儿=1314520

# 开头是注释行,键=值 是一行数据。

2.2 Properties 是什么:是 Map,但一般不当 Map 用

课件第 9 页把 Properties 放进 Map 家族:

层次类型
接口Map
实现类HashMap、LinkedHashMap、TreeMap、Properties、…

课件的原话是:

Properties 是一个 Map 集合(键值对集合),但是我们一般不会当集合使用。 核心作用:Properties 是用来代表属性文件的,通过 Properties 可以读写属性文件里的内容。

记住这层关系:因为它是 Map,所以 put/get/containsKey/forEach 都能用;但实际写代码时只用它和属性文件打交道的那几个专属方法。

2.3 使用 Properties 读取属性文件里的键值对数据

课件第 10 页的标题是”使用 Properties 读取属性文件里的键值对数据”。第 12 页单独抽出两个关键签名:

  • 可以加载属性文件中的数据到 Properties 对象中来:void load(Reader reader)
  • 可以存储 Properties 属性集的键值对数据到属性文件中去:void store(Writer writer, String comments)

读取相关的方法(表格按课件题目与配套示例整理):

方法说明
Properties()创建一个空的属性集对象,代表一个属性文件
void load(Reader reader)加载属性文件(字符流)中的数据到属性集对象中
void load(InputStream inStream)加载属性文件(字节流)中的数据
String getProperty(String key)根据键取值(不要用 Map 的 get() 去取,见示例注释)
Set<String> stringPropertyNames()拿到属性文件里的全部键
boolean containsKey(Object key)判断是否存在某个键(继承自 Map/Hashtable)

配套示例 PropertiesDemo1:

package com.itheima.d1_properties;

import java.io.FileInputStream;
import java.util.Properties;
import java.util.Set;

public class PropertiesDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:Properties 读取属性文件中的键值对数据
        // 1、创建属性集对象,代表一个属性文件
        Properties properties = new Properties();
        System.out.println(properties);

        // 2、加载属性文件信息到属性集对象中去
        properties.load(new FileInputStream("day11-special-file-log-code\\src\\users.properties"));
        System.out.println(properties);

        // 3、根据键取值
        // System.out.println(properties.get("赵敏"));   // 不推荐
        System.out.println(properties.getProperty("赵敏"));

        // 4、遍历方式一:先拿全部键,再逐个取值
        Set<String> keys = properties.stringPropertyNames();
        for (String key : keys) {
            String value = properties.getProperty(key);
            System.out.println(key + "==>" + value);
        }

        // 5、遍历方式二:Map 的 forEach
        properties.forEach((k, v) -> System.out.println(k + "===>" + v));
    }
}

对应的 users.properties 里存的是用户信息:

# 下面是一个属性文件的各种信息,存储的是用户名和姓名
admin=123456
赵敏=wuji
张无忌=zhaomin
殷素素=cuishan
灭绝=zhouzhiruo

课件强调的一点藏在示例注释里:取值要用 getProperty(String),而不是 Map 的 get(Object)。

2.4 使用 Properties 把键值对数据写出到属性文件里去

课件第 11 页的标题是”使用 Properties 把键值对数据写出到属性文件里去”。写出相关的方法(表格按课件题目与配套示例整理):

方法说明
Object setProperty(String key, String value)往属性集里放入一个键值对(继承自 Hashtable,等价于 put)
void store(Writer writer, String comments)把属性集的键值对写回属性文件,comments 是写进文件头部的注释

配套示例 PropertiesDemo2:

package com.itheima.d1_properties;

import java.io.FileWriter;
import java.util.Properties;

public class PropertiesDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:掌握使用 Properties 把键值对数据写出去到属性文件中去
        // 1、创建属性集对象
        Properties properties = new Properties();
        properties.setProperty("admin", "66666");
        properties.setProperty("玄冥二老", "wangfei");
        properties.setProperty("金毛狮王", "成昆");
        properties.setProperty("小昭", "wuji");
        System.out.println(properties);

        // 2、存储到文件
        properties.store(new FileWriter("day11-special-file-log-code/src/users2.properties"),
                "i saved many users! give me 100!");
    }
}

写出的 users2.properties 长这样:

#i saved many users! give me 100!
#Fri Mar 29 09:33:09 SGT 2024
admin=66666
小昭=wuji
玄冥二老=wangfei
金毛狮王=成昆

两个细节值得注意:

  • store() 会自动在被写出的文件头部加一行 #当前时间 的注释(第二行的时间戳不是我们写的),第一行才是我们自己传的 comments。
  • 课件示例里中文正常显示,但真实环境用 store(Writer) 写中文时,Properties.store() 默认会把非 Latin-1 字符转义成 \uXXXX 的形式(Properties 规范只支持 ISO-8859-1 字符集)。(补充说明)配套工程里的 users.properties 就是这种被转义过的形式:# \u4E0B\u9762\u662F...、\u8D75\u654F=wuji。读回来时 load() 会自动还原,不影响使用。

2.5 属性文件改写的完整套路

课件第 12 页的提问是”Properties 的作用?具体如何使用?“配套示例 PropertiesTest3 演示了”读 → 改 → 存回同一文件”的闭环,这也是属性文件最常见的用法:

package com.itheima.d1_properties;

import java.io.FileReader;
import java.io.FileWriter;
import java.util.Properties;

public class PropertiesTest3 {
    public static void main(String[] args) throws Exception {
        // 1、创建 Properties 对象
        Properties properties = new Properties();
        // 2、加载属性文件中的键值对数据
        properties.load(new FileReader("day11-special-file-log-code\\src\\people.txt"));
        // 3、判断是否存在李芳,存在就把其值改成 18
        if (properties.containsKey("李芳")) {
            properties.setProperty("李芳", "18");
        }
        // 4、把属性文件对象,重新存入到属性文件中去
        properties.store(new FileWriter("day11-special-file-log-code\\src\\people.txt"),
                "give me 100 fen!");
    }
}

load() 这一侧同样有重载:配套的 PropertiesDemo1 传的是 FileInputStream(字节流),PropertiesTest3 传的是 FileReader(字符流),两者都能加载属性文件——说明属性文件这一层已经和 day09/day10 的 IO 流体系对接上了(字节流适合任何文件,字符流适合文本文件)。

3. XML 文件

3.1 概述

课件第 14 页给出的定义:

  • XML(全称 EXtensible Markup Language,可扩展标记语言)本质是一种数据的格式,可以用来存储复杂的数据结构,和数据关系。

3.2 XML 的特点

课件第 15 页列了五条:

  1. XML 中的 <标签名> 称为一个标签或一个元素,一般是成对出现的。
  2. XML 中的标签名可以自己定义(可扩展),但必须要正确的嵌套。
  3. XML 中只能有一个根标签。
  4. XML 中的标签可以有属性。
  5. 如果一个文件中放置的是 XML 格式的数据,这个文件就是 XML 文件,后缀一般要写成 .xml。

3.3 XML 的创建

课件第 16 页:“就是创建一个 XML 类型的文件,要求文件的后缀必须使用 xml,如 hello_world.xml。“在 IDEA 里就是对着 src 右键 → New → File,然后在 New File 对话框里输入 hello_world.xml。

![IDEA 中右键 New → File 新建文件](https://studio.shenziggy.top/media/dbe4a406-3049-479e-b1a7-67aed1ff7c36) ![在 New File 对话框输入 hello_world.xml](https://studio.shenziggy.top/media/b3a2a86e-c1ae-4fd2-90ea-ab464530e4f5)

3.4 XML 的语法规则

课件第 17 页把规则集中在一页:

  • XML 文件的后缀名为 xml,文档声明必须是第一行:
<?xml version="1.0" encoding="UTF-8" ?>

其中 version:XML 默认的版本号码,该属性必须存在;encoding:本 XML 文件的编码。

  • XML 中可以定义注释信息:<!-- 注释内容 -->
  • XML 中书写 <、& 等字符可能会出现冲突导致报错,此时可用如下特殊字符替代:
实体代表的字符含义
&lt;<小于
&gt;>大于
&amp;&和号
&apos;'单引号
&quot;"引号
  • XML 中还可以写一个叫 CDATA 数据区的东西:<![CDATA[ …内容… ]]>,里面的内容可以随便写(不再需要对 <、& 做转义)。

配套示例 demo1-helloworld.xml 把这几条规则全用上了:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- 以下是一个新的xml的内容 -->
<users>
    <user id="1">
        <name>张全蛋</name>
        <sex>男</sex>
        <age>32</age>
        <hobby>炸机</hobby>
        <sql>
            select * from tb_student where age &gt;= 18 &amp;&amp; age &lt;= 35
        </sql>
    </user>
    <user id="2" desc="这是一个脱口秀演员">
        <name>何广智</name>
        <sex>男</sex>
        <age>28</age>
        <hobby>脱口秀</hobby>
        <sql>
            <![CDATA[
              select * from tb_student where age >= 18 && age <= 35
            ]]>
        </sql>
    </user>
</users>

同一个 SQL 片段,第一个 user 里用实体写,第二个 user 里直接塞进 CDATA 区——两种合法写法。

3.5 XML 的作用与应用场景

课件第 18、19 页重复同一句话:本质是一种数据格式,可以存储复杂的数据结构,和数据关系;应用场景是”经常用来作为系统的配置文件;或者作为一种特殊的数据结构,在网络中进行传输”。

第 19 页给的传输示例很直观——一条物流/行程信息用 XML 表达:

<?xml version="1.0" encoding="UTF-8"?>
<data>
    <sender>张三</sender>
    <receiver>李四</receiver>
    <src>北京</src>
    <current>武汉</current>
    <dest>广州</dest>
</data>

3.6 小结(第 20、21 页)

  • XML 是什么? 全称 EXtensible Markup Language,是一种可扩展的标记语言。
  • XML 的作用? 用于进行存储数据和传输数据;作为软件的配置文件。
  • XML 的组成格式要求? 文件后缀一般是 xml,文档声明必须是第一行;必须存在一个根标签,有且只能有一个;可以定义注释信息 <!-- 注释内容 -->;标签必须成对出现(<name></name>);必须能够正确嵌套。

4. 使用程序读取 XML 文件中的数据(Dom4j)

4.1 为什么不自己写解析代码

课件第 23 页说得很直接:

注意:程序员并不需要自己写原始的 IO 流代码来解析 XML,难度较大!也相当繁琐! 其实,有很多开源的、好用的解析 XML 的框架,最知名的是 Dom4j(第三方研发的)。

也就是说,读 XML 的正解是”程序 → 解析器(框架)→ XML 文件”:解析器负责把标签、属性、文本都变成 Java 对象,我们只负责从对象里取数。

4.2 使用 Dom4j 的前置步骤(导包)

课件第 24 页给出的步骤:

  1. 下载 Dom4j 框架,官网下载;
  2. 在项目中创建一个文件夹:lib;
  3. 将 dom4j-2.1.3.jar 文件复制到 lib 文件夹;
  4. 在 jar 文件上点右键,选择 Add as Library → 点击 OK;
  5. 在类中导包使用。

配套工程的 类库/ 下放的是 dom4j-2.1.4.jar(版本号略有出入,用法一致)。

4.3 解析思想:文档对象模型

课件第 25 页用一棵”标签树”解释 Dom4j 的思想——XML 的每个部分都对应一个 Java 对象:

XML 组成对应的 Java 对象说明
整个文档Document整个文档
标签Element元素
标签上的 id="1"Attribute属性
标签里的文字子元素、文本由 Element 提供方法取出
解析入口SAXReaderDom4j 提供的解析器,可以认为是代表整个 Dom4j 框架

课件用的示例数据:

<?xml version="1.0" encoding="UTF-8" ?>
<users>
    <user id="1">
        <name>张无忌</name>
        <sex>男</sex>
        <地址>光明顶</地址>
        <password>minmin</password>
        <data> 3 &lt; 2 &amp;&amp; 5 > 4 </data>
        <data1>
            <![CDATA[
                3 < 2 && 5 > 4
            ]]>
        </data1>
    </user>
    <user id="2">
        <name>敏敏</name>
        <sex>女</sex>
        <地址>光明顶</地址>
        <password>wuji</password>
        <data> 3 &lt; 2 &amp;&amp; 5 > 4 </data>
        <data1>
            <![CDATA[
                3 < 2 && 5 > 4
            ]]>
        </data1>
    </user>
</users>

4.4 得到 Document 对象

课件第 26 页的要点(表格按课件题目与配套示例整理):

类型方法说明
SAXReaderSAXReader()创建 Dom4j 提供的解析器对象
SAXReaderDocument read(String url)把 XML 文件读成一个 Document 文档对象(还有 read(File)、read(InputStream) 重载)
DocumentElement getRootElement()拿到根标签对应的元素对象

4.5 Element 提供的方法

课件第 27、28 页连着两页讲 Element 的方法,并配了一段完整代码截图:

![用 SAXReader 读成 Document,再取根元素](https://studio.shenziggy.top/media/7f82c68e-ec04-491f-82d3-13f219afebf4)

Element 常用方法(表格按课件题目与配套示例整理):

方法返回值说明
String getName()元素(标签)名如根标签返回 contactList
List<Element> elements()全部子元素一级子元素
List<Element> elements(String name)指定名字的子元素集合如 elements("contact")
Element element(String name)单个子元素默认拿第一个匹配的子元素
Attribute attribute(String name)属性对象可再调 getName()、getValue()
String attributeValue(String name)属性值等价于 attribute(name).getValue()
String elementText(String name)子元素的文本值通过父元素直接取
String elementTextTrim(String name)子元素的文本值去掉前后空格
String getText()元素自身的文本值先拿到元素对象再取
String getTextTrim()元素自身的文本值去掉前后空格

配套示例 Dom4JTest1 把这些方法串了一遍:

package com.itheima.d2_xml;

import org.dom4j.Attribute;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.util.List;

public class Dom4JTest1 {
    public static void main(String[] args) throws Exception {
        // 目标:解析 XML 文件:使用 Dom4j 框架
        // 1、创建一个 SaxReader 解析器对象
        SAXReader saxReader = new SAXReader();

        // 2、把 xml 文件读成一个 Document 文档对象
        Document document = saxReader.read("day11-special-file-log-code\\src\\Contact.xml");

        // 3、文档对象中包含了 XML 的全部数据,提供了方法获取数据
        Element rootElement = document.getRootElement();
        System.out.println(rootElement.getName());

        // 4、提取子元素对象
        // List<Element> sonEles = rootElement.elements();
        List<Element> sonEles = rootElement.elements("contact");
        for (Element sonEle : sonEles) {
            System.out.println(sonEle.getName());
        }

        // 指定获取单个子元素对象
        Element userEle = rootElement.element("user");
        System.out.println(userEle.elementText("name"));

        Element contactEle = rootElement.element("contact"); // 默认拿第一个 contact!
        System.out.println(contactEle.elementText("name"));

        // 5、提取子元素的属性对象
        Attribute idAttr = contactEle.attribute("id");
        System.out.println(idAttr.getName());
        System.out.println(idAttr.getValue());

        // 直接拿属性值
        System.out.println(contactEle.attributeValue("id"));

        // 6、文本值
        // 通过父元素拿到子元素文本值
        System.out.println(contactEle.elementText("name"));
        System.out.println(contactEle.elementTextTrim("name")); // 去掉前后空格

        // 先拿到元素对象,再提取其文本值
        Element emailEle = contactEle.element("email");
        System.out.println(emailEle.getText());
        System.out.println(emailEle.getTextTrim()); // 去掉前后空格
    }
}

elementText() 与 elementTextTrim() 的区别,用案例数据一看就明白——Contact.xml 里第一条记录的名字前后带空格:

<?xml version="1.0" encoding="UTF-8"?>
<contactList>
    <contact id="1">
        <name>   潘金莲  </name>
        <gender>女</gender>
        <email>   panpan@itcast.cn</email>
    </contact>
    <contact id="2">
        <name>武松</name>
        <gender>男</gender>
        <email>wusong@itcast.cn</email>
    </contact>
    <contact id="3">
        <name>武大狼</name>
        <gender>男</gender>
        <email>wuda@itcast.cn</email>
    </contact>
    <user>
        <name>dlei</name>
    </user>
</contactList>

这里还故意放了两个”坑”:<name> 潘金莲 </name> 用来验证 Trim 系列方法;多出来的 <user> 标签用来验证 elements("contact") 会按名字过滤,而 element("user") 能越过前面的 contact 精确取到。

4.6 解析案例:把联系人封装成 List 集合

课件第 30 页的需求:“利用 Dom4J 框架,将 contacts.xml 文件中的联系人数据解析出来,封装成 List 集合,并遍历输出。”

![contacts.xml 联系人数据(contact 上带 id、vip 属性)](https://studio.shenziggy.top/media/d636cde5-7c22-4921-a123-5ad11e0ba714)

按课件案例的数据形态,contact 上带 id、vip 属性:

<?xml version="1.0" encoding="UTF-8"?>
<contactList>
    <contact id="1" vip="true">
        <name>张无忌</name>
        <gender>男</gender>
        <email>wuji@itcast.cn</email>
    </contact>
    <contact id="2" vip="false">
        <name>小昭</name>
        <gender>女</gender>
        <email>xiaozhao@itcast.cn</email>
    </contact>
    <contact id="3" vip="false">
        <name>灭绝师太</name>
        <gender>女</gender>
        <email>miejue@itcast.cn</email>
    </contact>
</contactList>

封装用的实体类:

package com.itheima.d2_xml;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Contact {
    private int id;
    private String name;
    private String email;
    private char gender;
}

解析并封装的代码(配套示例 Dom4JTest2):

package com.itheima.d2_xml;

import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.util.ArrayList;
import java.util.List;

public class Dom4JTest2 {
    public static void main(String[] args) throws Exception {
        // 目标:解析 XML 文件:使用 Dom4j 框架
        // 1、创建一个 SaxReader 解析器对象
        SAXReader saxReader = new SAXReader();

        // 2、把 xml 文件读成一个 Document 文档对象
        Document document = saxReader.read("day11-special-file-log-code\\src\\Contact.xml");

        // 3、文档对象中包含了 XML 的全部数据,提供了方法获取数据
        Element rootElement = document.getRootElement();

        // 4、准备一个联系人集合存储联系人对象
        List<Contact> contacts = new ArrayList<>();

        // 5、提取全部一级联系人元素对象
        List<Element> sonEles = rootElement.elements("contact");

        // 6、遍历子元素对象
        for (Element sonEle : sonEles) {
            // 7、每个子元素是一个联系人对象,创建联系人对象,封装数据
            Contact contact = new Contact();
            contact.setId(Integer.valueOf(sonEle.attributeValue("id")));
            contact.setName(sonEle.elementTextTrim("name"));
            contact.setGender(sonEle.elementTextTrim("gender").charAt(0));
            contact.setEmail(sonEle.elementTextTrim("email"));
            // 8、存入到集合中去
            contacts.add(contact);
        }

        System.out.println(contacts);
    }
}

4.7 用程序把数据写出到 XML 文件中

课件第 31 页的提问是”如何使用程序把数据写出到 XML 文件中去?“,紧接着的两页大字给出结论:“不建议用 dom4j 做”、“推荐直接把程序里的数据拼接成 XML 格式”,然后(用 IO 流)写出去。

配套示例 Dom4JTest3 就是”拼字符串 + PrintStream”的写法:

package com.itheima.d2_xml;

import java.io.PrintStream;

public class Dom4JTest3 {
    public static void main(String[] args) throws Exception {
        // 目标:写一个 xml 的数据出去
        StringBuilder sb = new StringBuilder();
        sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n");
        sb.append("<user>\r\n");
        sb.append("\t<name>").append("张三").append("</name>\r\n");
        sb.append("\t<age>").append("12").append("</age>\r\n");
        sb.append("\t<sex>").append("男").append("</sex>\r\n");
        sb.append("</user>\r\n");

        PrintStream ps = new PrintStream("day11-special-file-log-code/src/users3.xml");
        ps.println(sb);
        ps.close();
    }
}

写出的结果:

<?xml version="1.0" encoding="UTF-8"?>
<user>
	<name>张三</name>
	<age>12</age>
	<sex>男</sex>
</user>

5. 补充知识:约束 XML 文件的编写(了解)

5.1 什么是约束

课件第 33 页:“什么是约束 XML 文件的书写?就是限制 XML 文件只能按照某种格式进行书写。”

第 34 页进一步说明:约束文档就是专门用来限制 xml 书写格式的文档,比如限制标签、属性应该怎么写。约束文档分两类:DTD 文档和 Schema 文档。

5.2 DTD 的使用

课件第 35 页的需求:“利用 DTD 约束文档,约束一个 XML 文件的编写。“步骤:

  1. 编写 DTD 约束文档,后缀必须是 .dtd;
  2. 在需要编写的 XML 文件中导入该 DTD 约束文档;
  3. 然后 XML 文件就必须按照 DTD 约束文档指定的格式进行编写,否则报错!

DTD 本体写起来很简单(配套 xml/data.dtd):

<!ELEMENT 书架 (书+)>
<!ELEMENT 书 (书名,作者,售价)>
<!ELEMENT 书名 (#PCDATA)>
<!ELEMENT 作者 (#PCDATA)>
<!ELEMENT 售价 (#PCDATA)>

在 XML 里导入的方式是加一行 <!DOCTYPE ... SYSTEM "data.dtd">:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE 书架 SYSTEM "data.dtd">
<书架>
    <书>
        <书名>从入门到跑路</书名>
        <作者>dlei</作者>
        <售价>9.9</售价>
    </书>
    <书>
        <书名>从删库到跑路</书名>
        <作者>啊猫</作者>
        <售价>很便宜</售价>
    </书>
</书架>
![受 DTD 约束的书架/书 XML 示例](https://studio.shenziggy.top/media/1a02e1fa-dab2-4925-a266-593f996375aa)

课件的结论是两句话:DTD 可以约束 XML 文件的编写,但不能约束具体的数据类型。上面 售价 写 9.9 合法,写 很便宜 也合法,DTD 管不了。

5.3 Schema 的使用

课件第 36 页的需求:“利用 schema 文档约束,约束一个 XML 文件的编写。“步骤:

  1. 编写 schema 约束文档,后缀必须是 .xsd,具体的形式到代码中观看;
  2. 在需要编写的 XML 文件中导入该 schema 约束文档;
  3. 按照约束内容编写 XML 文件的标签。

配套 xml/data.xsd(带课件注释):

<?xml version="1.0" encoding="UTF-8" ?>
<schema xmlns="http://www.w3.org/2001/XMLSchema"
        targetNamespace="http://www.itcast.cn"
        elementFormDefault="qualified" >
    <!-- targetNamespace:申明约束文档的地址(命名空间) -->
    <element name='书架'>
        <!-- 写子元素 -->
        <complexType>
            <!-- maxOccurs='unbounded':书架下的子元素可以有任意多个! -->
            <sequence maxOccurs='unbounded'>
                <element name='书'>
                    <complexType>
                        <sequence>
                            <element name='书名' type='string'/>
                            <element name='作者' type='string'/>
                            <element name='售价' type='double'/>
                        </sequence>
                    </complexType>
                </element>
            </sequence>
        </complexType>
    </element>
</schema>

导入方式是在根标签上声明命名空间:

<?xml version="1.0" encoding="UTF-8" ?>
<书架 xmlns="http://www.itcast.cn"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.itcast.cn data.xsd">
    <!-- xmlns="http://www.itcast.cn"  基本位置
         xsi:schemaLocation="http://www.itcast.cn data.xsd" 具体的位置 -->
    <书>
        <书名>从入门到跑路</书名>
        <作者>dlei</作者>
        <售价>9.9</售价>
    </书>
    <书>
        <书名>从删库到跑路</书名>
        <作者>啊猫</作者>
        <售价>19.98</售价>
    </书>
</书架>

5.4 DTD 与 Schema 对比

对比项DTDSchema
文件后缀.dtd.xsd
约束 XML 的编写可以可以
约束具体的数据类型不能可以(如 type='double'、type='string')

这一节课件标了[了解],重点是知道”约束是什么、有哪两类”,不必背 DTD 与 XSD 的写法。

6. 日志技术

6.1 什么是日志,为什么需要日志

课件第 38 页的类比是”好比生活中的日记,可以记录你生活中的点点滴滴”:

  • 程序中的日志,通常就是一个文件,里面记录的是程序运行过程中的各种信息。

第 41 页给出的正式定义是:用来记录程序运行过程中的信息,并可以进行永久存储。它要回答的三个现实问题(课件第 38 页):

  1. 希望系统能记住某些数据是被谁操作的,比如被谁删除了?
  2. 想分析用户浏览系统的具体情况,以便挖掘用户的具体喜好?
  3. 当系统在开发中或者上线后出现了 bug、崩溃了,该通过什么去分析、定位 bug?

6.2 目前记录日志的方案:输出语句的弊端

课件第 39 页先给了一段”用输出语句记日志”的代码:

public static void test(String number) {
    try {
        int result = Integer.parseInt(number);
        System.out.println("输入的数字为" + result);
    } catch (NumberFormatException e) {
        System.out.println("输入的数字有误,请输入一个整数");
    }
}

然后列出 System.out 的弊端:

弊端说明
位置受限日志会展示在控制台
去向单一不能更方便地将日志记录到其他的位置(文件、数据库)
无法开关想取消日志,需要修改源代码才可以完成

6.3 日志技术应该具备的特点和优势

课件第 40 页正面回答”日志技术应该具备哪些特点和优势”:

  • 可以将系统执行的信息,方便地记录到指定的位置(控制台、文件中、数据库中);
  • 可以随时以开关的形式控制日志的启停,无需侵入到源代码中去进行修改。

这正是后面要引入”日志框架 + 配置文件”的原因:位置和开关都交给配置,代码只负责”在什么地方该记一条什么级别的日志”。

6.4 日志技术的体系结构

课件第 43 页把日志生态分成两层:

分类含义常见代表
日志接口设计日志框架的一套标准,日志框架需要实现这些接口Commons Logging(JCL)、Simple Logging Facade for Java(SLF4J)
日志框架牛人或者第三方公司已经做好的实现代码,后来者直接可以拿去使用Log4j、JUL(java.util.logging)、Logback 等

课件给的两条”注意”很关键:

  • 注意 1:因为对 Commons Logging 接口不满意,有人就搞了 SLF4J;因为对 Log4j 的性能不满意,有人就搞了 Logback。
  • 注意 2:Logback 是基于 slf4j 的日志规范实现的框架。

6.5 Logback 的模块

课件第 44 页给出官网 https://logback.qos.ch/index.html,并列出模块:

模块定位是否必须
slf4j-api日志接口必须有
logback-core基础模块,是其他两个模块依赖的基础必须有
logback-classic完整实现了 slf4j API 的模块必须有
logback-access与 Tomcat 和 Jetty 等 Servlet 容器集成,以提供 HTTP 访问日志的功能可选(以后再接触)

也就是说,想使用 Logback 日志框架,至少需要在项目中整合 slf4j-api + logback-core + logback-classic 三个模块(配套 类库/logback/ 下就是 slf4j-api-1.7.26.jar、logback-core-1.2.3.jar、logback-classic-1.2.3.jar 加一份 logback.xml)。

6.6 Logback 快速入门

课件第 47 页的需求:使用 Logback 日志框架,记录系统的运行信息。实现步骤(第 47、48 页):

  1. 在项目下新建文件夹 lib,导入 Logback 的相关 jar 包到该文件夹下,并添加到项目库中去;
  2. 必须将 Logback 的核心配置文件 logback.xml 直接拷贝到 src 目录下(必须是 src 下);
  3. 在代码中获取日志的对象:
public static final Logger LOGGER = LoggerFactory.getLogger("类名");
  1. 调用日志对象的方法记录日志信息。

配套示例 Test:

package com.itheima.d3_logback;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Test {
    // 1、创建一个 Logback 框架的 Logger 日志对象,来记日志
    public static final Logger LOGGER = LoggerFactory.getLogger("Test.class");

    public static void main(String[] args) {
        try {
            LOGGER.info("除法开始了。。。。。");
            chu(10, 0);
            LOGGER.info("除法执行成功了。。。。。");
        } catch (Exception e) {
            LOGGER.error("除法执行失败了:" + e.getMessage());
        }
    }

    public static void chu(int a, int b) {
        LOGGER.debug("参数a:" + a);
        LOGGER.debug("参数b:" + b);
        int c = a / b;
        LOGGER.info("结果c是:" + c);
    }
}

注意 LOGGER 声明为 public static final:Logger 对象应当在整个类里共享一个,不要在方法内部反复 getLogger()。

6.7 logback.xml:日志的输出位置、输出格式

课件第 49 页说明:通常可以设置 2 个输出日志的位置——一个控制台、一个系统文件中;logback.xml 就是”对 Logback 日志框架进行控制的”核心配置文件,里面用 <appender> 定义输出位置,用 <root> 把 appender 挂到根日志器上。

appender 名字class输出位置
CONSOLEch.qos.logback.core.ConsoleAppender控制台
FILEch.qos.logback.core.rolling.RollingFileAppender文件(支持按大小/时间切分)

配套工程的 src/logback.xml 全文:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!--
        CONSOLE :表示当前的日志信息是可以输出到控制台的。
    -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <!--输出流对象 默认 System.out 改为 System.err-->
        <target>System.out</target>
        <encoder>
            <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度
                %msg:日志消息,%n是换行符-->
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level]  %c [%thread] : %msg%n</pattern>
        </encoder>
    </appender>

    <!-- File是输出的方向通向文件的 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>utf-8</charset>
        </encoder>
        <!--日志输出路径-->
        <file>D:/log/itheima-data.log</file>
        <!--指定日志文件拆分和压缩规则-->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!--通过指定压缩文件名称,来确定分割文件方式-->
            <fileNamePattern>D:/log/itheima-data-%i-%d{yyyy-MM-dd}-.log.gz</fileNamePattern>
            <!--文件拆分大小-->
            <maxFileSize>1MB</maxFileSize>
        </rollingPolicy>
    </appender>

    <!--
        1、控制日志的输出情况:如,开启日志,取消日志
        all 代表打印全部日志
        off 关闭全部日志
        debug info error
    -->
    <root level="all">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE" />
    </root>
</configuration>

三个要点:

  • <root> 里的 level 控制日志级别,课件把它归成两句话:“开启日志(ALL),取消日志(OFF)”;
  • 想让哪个 appender 生效,就要在 <root> 里用 <appender-ref ref="..."/> 引用它;
  • FILE 用的是 RollingFileAppender + SizeAndTimeBasedRollingPolicy:日志写到 D:/log/itheima-data.log,按 1MB 拆分并压缩成 .log.gz(课件配置里的路径写死了 Windows 盘符,实际用时应改成本机路径)。

6.8 日志级别

课件第 51 页的规则:“只有日志的级别是大于或等于核心配置文件配置的日志级别,才会被记录,否则不记录。”

第 52、53 页把级别和默认规则讲清楚(级别表按课件题目整理,课件第 51 页原表在抽取文本里只剩占位):

级别说明
ALL打开全部日志
TRACE追踪,最低级别
DEBUG调试信息
INFO常规信息
WARN警告
ERROR错误
OFF关闭全部日志

要记住的四条结论:

  1. 设置日志输出级别的作用:用于控制系统中哪些日志级别是可以输出的。
  2. ALL 和 OFF 分别是打开全部日志和关闭全部日志。
  3. 级别程度依次是:TRACE < DEBUG < INFO < WARN < ERROR。
  4. 默认级别是 debug(忽略大小写),只输出当前级别及高于该级别的日志。

配置文件里就是把级别写在根节点上,例如只保留 info 及以上的日志:

<root level="info">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="FILE" />
</root>

对照 6.6 的 Test 类理解:logback.xml 里是 level="all" 时,LOGGER.debug(...)、LOGGER.info(...)、LOGGER.error(...) 全都会打印;一旦把 level 改成 info,两条 LOGGER.debug("参数a:10")、LOGGER.debug("参数b:0") 就不再输出了,而代码一行都不用改。

7. 作业与练习

本日无配套作业文档。(已核对 day11-特殊文件、日志技术/ 目录,其中只有 PPT/、代码/、类库/ 与 Contact.xml,没有作业题目文件。)

可自行练习的三条主线:

  1. 用 Properties 读一个 users.properties(load() + getProperty() + stringPropertyNames() 遍历),再改一个键的值并用 store() 写回原文件。
  2. 用 Dom4j 解析 Contact.xml 或 contacts.xml,把每条 contact 封装成对象存进 List 并遍历输出;顺手验证 elementText() 与 elementTextTrim() 对 <name> 潘金莲 </name> 的区别。
  3. 搭一个 Logback 工程(三个 jar 放 lib 并 Add as Library,logback.xml 放 src),分别观察 level="all"、level="info"、level="off" 时控制台与日志文件输出的差异。

8. 复习要点

  1. 属性文件的三个特点是”只能键值对、键不能重复、后缀一般 .properties”;普通 .txt 因为格式随意,程序无法可靠地区分字段,这就是要用特殊文件的原因。
  2. Properties 是 Map 的实现类(和 HashMap/TreeMap 同族),但课件明确说”一般不会当集合使用”——它的核心作用是代表属性文件,读写属性文件里的内容。
  3. 两个核心签名:加载用 void load(Reader reader),写出用 void store(Writer writer, String comments);load() 的重载可以传字节流(FileInputStream),说明它直接接在 day09/day10 的 IO 流体系上。
  4. store() 会自动在文件头部写入一行时间戳注释,我们传的 comments 写在它上面;写中文时可能被转义成 \uXXXX,读回来能自动还原。
  5. 取值要用 getProperty(String) 而不是 Map.get(Object);stringPropertyNames() 拿全部键,containsKey() 判断键是否存在。
  6. XML 的五条特点:标签成对出现、标签名可自定义但必须正确嵌套、有且只有一个根标签、标签可以有属性、后缀一般 .xml;写法上还有”文档声明必须写在第一行”。
  7. 转义与 CDATA:&lt; &gt; &amp; &apos; &quot; 分别替代 < > & ' ";<![CDATA[ … ]]> 里的内容可以随便写、不用转义。
  8. Dom4j 的文档对象模型:SAXReader 是解析器(代表整个框架),read() 得到 Document,getRootElement() 得到 Element,标签上的 id 是 Attribute,标签内的文字用 elementText()/getText() 取;elements("contact") 按名过滤一级子元素,element("user") 默认拿第一个。
  9. 写出 XML 时课件不建议用 Dom4j,推荐直接把数据拼成 XML 格式字符串,再用 IO 流(如 PrintStream)写出去。
  10. 约束 XML 的文档分 DTD(.dtd,能约束编写、不能约束数据类型)和 Schema(.xsd,两者都能约束),这一节为了解内容。
  11. 为什么用日志框架:System.out.println 只能打在控制台、换位置要改代码、取消日志必须改源码;日志技术能记录到控制台/文件/数据库,并且用配置开关控制启停、不改源代码。
  12. 日志体系分两层:日志接口(Commons Logging、SLF4J)只是规范,日志框架(Log4j、JUL、Logback)是具体实现;Logback 基于 slf4j 规范实现。
  13. Logback 最少要三个模块:slf4j-api(接口)+ logback-core(基础)+ logback-classic(完整实现 slf4j);logback-access 是可选的 Servlet 容器集成模块。
  14. 快速入门四步:jar 放进 lib 并 Add as Library → logback.xml 必须放在 src 下 → LoggerFactory.getLogger(...) 拿到 Logger(声明为 public static final)→ 调用 info/debug/error 等方法记录日志。
  15. 日志级别顺序为 TRACE < DEBUG < INFO < WARN < ERROR,ALL 打开全部、OFF 关闭全部,默认级别是 debug(忽略大小写);规则是只输出”大于或等于所配置级别”的日志。

day12|java.lang 包下的常用 API:多线程

本日围绕 java.lang.Thread 展开:先掌握线程的三种创建方式与 Thread 的常用 API,再重点理解「多个线程同时修改同一共享资源」为什么会出现业务错误,以及用同步代码块、同步方法、Lock 锁解决它的原理与区别;最后是线程通信(生产者消费者模型)、线程池(ExecutorService/ThreadPoolExecutor/Executors)、并发与并行、线程的生命周期。学完后应能判断一段多线程代码是否存在线程安全问题,并写出正确的加锁版本。

1. 线程与多线程是什么

1.1 线程与单线程程序

线程(Thread)是一个程序内部的一条执行流程。

public static void main(String[] args) {
    // 代码…
    for (int i = 0; i < 10; i++) {
        System.out.println(i);
    }
    // 代码...
}

上面的 main 方法本身由一条「主线程」负责执行。程序中如果只有一条执行流程,那这个程序就是单线程的程序。

(补充说明)main 方法由名为 main 的主线程执行,这也解释了为什么后面可以直接用 Thread.currentThread().getName() 打印出 main。

1.2 多线程是什么,用在哪里

多线程是指从软硬件上实现的多条执行流程的技术(多条线程由 CPU 负责调度执行)。

  • 好处:多条流程可以「同时」推进,充分利用 CPU,提升吞吐与响应速度。
  • 应用场景:下载软件的「同时下载、同时上传」、消息通信、淘宝/京东这类高并发系统,都离不开多线程技术。

下载工具中同时进行下载与上传

12306 这类高并发系统依赖多线程

Java 是通过 java.lang.Thread 类的对象来代表线程的,因此「创建多条线程」在代码里就是「创建多个 Thread 对象并启动」。

2. 多线程的创建方式一:继承 Thread 类

2.1 步骤

  1. 定义一个子类 MyThread 继承线程类 java.lang.Thread,重写 run() 方法;
  2. 创建 MyThread 类的对象;
  3. 调用线程对象的 start() 方法启动线程(启动后还是执行 run() 方法)。

2.2 代码

public class ThreadDemo1 {
    // 注意:main 方法本身就是由一条主线程负责执行的。
    public static void main(String[] args) {
        // 目标:掌握线程的创建方式一:继承 Thread 类。
        // 3、创建线程对象代表具体的线程
        Thread t = new MyThread();
        // 4、启动线程:会自动执行线程的 run 方法
        // 如果直接调用 run,CPU 不会注册新线程执行,此时相当于还是单线程
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
        }
    }
}

// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
    // 2、重写 run 方法,声明线程要干的事情
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

2.3 两个必须注意的点

  1. 启动线程必须是调用 start() 方法,不是调用 run() 方法。直接调用 run() 会当成普通方法执行,此时相当于还是单线程执行;只有调用 start() 才是启动一个新的线程执行。否则主线程一直是先跑完的,相当于是一个单线程的效果了。
  2. 不要把主线程任务放在启动子线程之前。

2.4 方式一的优缺点

维度说明
优点编码简单
缺点线程类已经继承 Thread,无法继承其他类,不利于功能的扩展(单继承的局限性)

3. 多线程的创建方式二:实现 Runnable 接口

3.1 步骤

  1. 定义一个线程任务类 MyRunnable 实现 Runnable 接口,重写 run() 方法;
  2. 创建 MyRunnable 任务对象;
  3. 把 MyRunnable 任务对象交给 Thread 处理;
  4. 调用线程对象的 start() 方法启动线程。

3.2 代码

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 目标:掌握多线程的创建方式二:实现 Runnable 接口的方式。
        // 3、创建任务类的一个对象
        Runnable target = new MyRunnable();
        // 4、把任务对象交给线程对象
        // public Thread(Runnable target)
        Thread t = new Thread(target, "1号");
        // 5、启动线程
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
        }
    }
}

// 1、定义一个任务类实现 Runnable 接口
class MyRunnable implements Runnable {
    // 2、重写 run 方法
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

3.3 匿名内部类写法

可以创建 Runnable 的匿名内部类对象,再交给 Thread 线程对象,再调用线程对象的 start() 启动线程。

public class ThreadDemo2_2 {
    public static void main(String[] args) {
        // 1、创建任务类的一个对象
        Runnable target = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 4; i++) {
                    System.out.println("子线程1输出:" + i);
                }
            }
        };
        Thread t = new Thread(target);
        t.start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 4; i++) {
                    System.out.println("子线程2输出:" + i);
                }
            }
        }).start();

        // (补充说明)Runnable 是函数式接口,也可以用 Lambda 简化
        new Thread(() -> {
            for (int i = 0; i < 4; i++) {
                System.out.println("子线程3输出:" + i);
            }
        }).start();
    }
}

3.4 方式二的优缺点

维度说明
优点任务类只是实现接口,可以继续继承其他类、实现其他接口,扩展性强
缺点需要多一个 Runnable 对象;线程有执行结果时不能直接返回

4. 多线程的创建方式三:Callable 接口 + FutureTask 类

4.1 为什么需要方式三

假如线程执行完毕后有一些数据需要返回,方式一、方式二重写的 run() 方法均不能直接返回结果。JDK 5.0 提供了 Callable 接口和 FutureTask 类来实现(多线程的第三种创建方式),这种方式最大的优点:可以返回线程执行完毕后的结果。

4.2 步骤

  1. 定义一个类实现 Callable 接口,重写 call() 方法,封装要做的事情和要返回的数据;
  2. 把 Callable 类型的对象封装成 FutureTask(线程任务对象);
  3. 把线程任务对象交给 Thread 对象;
  4. 调用 Thread 对象的 start() 方法启动线程;
  5. 线程执行完毕后,通过 FutureTask 对象的 get() 方法去获取线程任务执行的结果。

4.3 FutureTask 的 API

成员说明
FutureTask(Callable<V> callable)把 Callable 对象封装成未来任务对象
V get()获取线程执行完毕后的结果;如果线程还没执行完,会在这里等待直到执行完毕再取结果

4.4 代码

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class ThreadDemo3 {
    public static void main(String[] args) {
        // 目标:掌握多线程的创建方式三:可以返回线程执行完毕后的结果。
        // 3、创建 Callable 对象
        Callable<String> call = new MyCallable(100);
        // 4、把 Callable 对象,封装成 FutureTask 对象
        //    未来任务对象有两个作用:
        //      它是一个 Runnable 对象
        //      它可以获取线程执行后的结果
        FutureTask<String> task = new FutureTask<>(call);
        // 5、把未来任务对象交给线程对象
        Thread t = new Thread(task);
        // 6、启动线程
        t.start();

        Callable<String> call2 = new MyCallable(200);
        FutureTask<String> task2 = new FutureTask<>(call2);
        Thread t2 = new Thread(task2);
        t2.start();

        try {
            // 如果第一个线程没有执行完毕,会在这里等待第一个线程执行完毕后再取结果
            String rs1 = task.get();
            System.out.println(rs1);
        } catch (Exception e) {
            e.printStackTrace();
        }

        try {
            String rs2 = task2.get();
            System.out.println(rs2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

// 1、定义一个类实现 Callable 接口
class MyCallable implements Callable<String> {
    private int n;

    public MyCallable(int n) {
        this.n = n;
    }

    // 2、重写 call 方法,声明任务和返回的结果
    @Override
    public String call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += i;
        }
        return "子线程求和1-" + n + "的结果是:" + sum;
    }
}

4.5 方式三的优缺点

维度说明
优点线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强;可以在线程执行完毕后去获取线程执行的结果
缺点编码复杂一点

4.6 三种创建方式对比

方式入口方法能否返回结果能否再继承其他类代码复杂度
继承 Thread重写 run()不能不能(已单继承)简单
实现 Runnable重写 run()不能能较简单(多一个任务对象)
实现 Callable + FutureTask重写 call()能,通过 FutureTask.get()能复杂一点

5. Thread 的常用方法

5.1 常用方法一览

方法说明
String getName()获取当前线程的名称,默认线程名称是 Thread-索引
void setName(String name)设置线程名称
public static Thread currentThread()返回对当前正在执行的线程对象的引用
public static void sleep(long time)让当前线程休眠指定的时间,单位为毫秒,时间到了会自动醒来继续执行
public final void join()让调用该方法的线程插队,执行完毕后其他线程再执行

5.2 线程名称与 currentThread

public class ThreadDemo1 {
    public static void main(String[] args) {
        Thread t1 = new MyThread("1号线程");
        t1.start();

        Thread t2 = new MyThread("2号线程");
        t2.start();

        // 这个代码是哪个线程在执行,就会得到哪个线程对象。
        Thread m = Thread.currentThread();
        m.setName("最牛的");
        System.out.println(m.getName()); // main

        for (int i = 0; i < 4; i++) {
            System.out.println(m.getName() + "线程输出:" + i);
        }
    }
}

// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
    public MyThread(String name) {
        super(name);
    }

    // 2、重写 run 方法,声明线程要干的事情
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            // 当前线程对象的名字
            System.out.println(Thread.currentThread().getName() + " => 线程输出:" + i);
        }
    }
}

5.3 线程休眠 sleep

public class ThreadDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:掌握线程休眠,线程 join。
        for (int i = 1; i <= 5; i++) {
            System.out.println("输出:" + i);
            // 作用:让当前线程执行的慢一点
            Thread.sleep(1000); // 休眠 1s 后再执行
        }
    }
}

5.4 线程插队 join

public class ThreadDemo3 {
    public static void main(String[] args) throws Exception {
        // 目标:join 线程
        Thread t = new MyThread2();
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
            if (i == 2) {
                t.join(); // 让 t 插队先执行完毕!
            }
        }
    }
}

class MyThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

5.5 其他控制方法

Thread 类还提供了诸如 yield()、interrupt()、守护线程、线程优先级等线程的控制方法,在开发中很少使用,这些方法会后续需要用到的时候再讲解。

6. 线程安全:问题是什么

6.1 什么是线程安全问题

场景:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元。如果小明和小红同时来取钱,并且 2 人各自都在取钱 10 万元,可能会出现什么问题呢?

多个线程,同时操作同一个共享资源的时候,可能会出现业务安全问题。

6.2 取钱问题的执行过程

两个线程对同一个共享账户(余额 100000)分别执行下面三步:

步骤小明线程小红线程
1判断余额是否足够 → true判断余额是否足够 → true
2吐出 100000 元吐出 100000 元
3更新账户余额更新账户余额

结果:2 人都取钱 10 万,银行亏了 10 万。原因是两个线程都基于「取钱前的余额」做出了判断,CPU 在两条线程之间调度时把复合操作交错了。

6.3 出现线程安全问题的原因

  1. 存在多个线程在同时执行;
  2. 同时访问一个共享资源;
  3. 存在修改该共享资源。

三个条件同时满足时才会出现线程安全问题。

6.4 用程序模拟线程安全问题

需求:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元,模拟 2 人同时去取钱 10 万。

分析:

  1. 需要提供一个账户类,接着创建一个账户对象代表 2 个人的共享账户;
  2. 需要定义一个线程类(用于创建两个线程,分别代表小明和小红);
  3. 创建 2 个线程,传入同一个账户对象给 2 个线程处理;
  4. 启动 2 个线程,同时去同一个账户对象中取钱 10 万。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        if (this.money >= money) {
            System.out.println(name + "取钱成功,吐出:" + money);
            // 更新余额
            this.money -= money;
            System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
        } else {
            System.out.println(name + "来取钱:余额不足!");
        }
    }
}
// 取钱线程类
public class DrawThread extends Thread {
    private Account acc;

    public DrawThread(String name, Account acc) {
        super(name);
        this.acc = acc;
    }

    // 小明 小红
    @Override
    public void run() {
        // 小明 小红拿同一个账户对象 acc 取钱
        acc.drawMoney(100000);
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:模拟线程安全问题。
        // 1、创建一个账户对象,两个人共享
        Account acc = new Account("ICBC-110", 100000);

        // 2、创建两个线程代表小明和小红,同时去同一个账户取钱。
        new DrawThread("小明", acc).start();
        new DrawThread("小红", acc).start();
    }
}

(补充说明)该演示代码的结果不确定:可能出现「两人都取钱成功、账户余额为 -10 万」的错误数据,也可能其中一个线程先执行完而显示「余额不足」。

7. 线程同步

7.1 认识线程同步

线程同步是解决线程安全问题的方案,其思想是:让多个线程实现先后依次访问共享资源,这样就解决了安全问题。

线程同步的常见方案是「锁对象 + 加锁」:每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能再加锁进来。

7.2 方式一:同步代码块

作用:把访问共享资源的核心代码给上锁,以此保证线程安全。

synchronized (同步锁) {
    访问共享资源的核心代码
}

原理:每次只允许一个线程加锁后进入,执行完毕后自动解锁,其他线程才可以进来执行。

同步锁的注意事项:对于当前同时执行的线程来说,同步锁必须是同一把(同一个对象),否则会出 bug。

锁对象的使用规范:

  • 锁对象不要随便选择一个唯一的对象,不好,会影响其他无关线程的执行;
  • 建议使用共享资源作为锁对象,对于实例方法建议使用 this 作为锁对象;
  • 对于静态方法建议使用字节码(类名.class)对象作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        synchronized (this) {
            if (this.money >= money) {
                System.out.println(name + "取钱成功,吐出:" + money);
                // 更新余额
                this.money -= money;
                System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
            } else {
                System.out.println(name + "来取钱:余额不足!");
            }
        }
    }
}

小结:对出现问题的核心代码使用 synchronized 进行加锁,每次只能一个线程占锁进入访问。

7.3 方式二:同步方法

作用:把访问共享资源的核心方法给上锁,以此保证线程安全。

修饰符 synchronized 返回值类型 方法名称(形参列表) {
    操作共享资源的代码
}

原理:每次只能一个线程进入,执行完毕以后自动解锁,其他线程才可以进来执行。

同步方法的底层原理:同步方法其实底层也是有隐式锁对象的,只是锁的范围是整个方法代码。

  • 如果方法是实例方法:同步方法默认用 this 作为锁对象;
  • 如果方法是静态方法:同步方法默认用 类名.class 作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public synchronized void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        if (this.money >= money) {
            System.out.println(name + "取钱成功,吐出:" + money);
            // 更新余额
            this.money -= money;
            System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
        } else {
            System.out.println(name + "来取钱:余额不足!");
        }
    }
}

7.4 同步代码块还是同步方法

  • 范围上:同步代码块锁的范围更小,同步方法锁的范围更大;
  • 可读性:同步方法更好。

选择原则是「锁的范围尽量小」,同时保证被锁的范围覆盖了完整的复合操作(判断余额 + 扣款 + 写回余额)。

7.5 方式三:Lock 锁

Lock 锁是 JDK 5 开始提供的一个新的锁定操作,通过它可以创建出锁对象进行加锁和解锁,更灵活、更方便、更强大。Lock 是接口,不能直接实例化,可以采用它的实现类 ReentrantLock 来构建 Lock 锁对象。

Lock 的常用方法:

方法说明
void lock()获得锁
void unlock()释放锁
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Data
@NoArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额
    private final Lock lk = new ReentrantLock();

    public Account(String cardId, double money) {
        this.cardId = cardId;
        this.money = money;
    }

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        try {
            lk.lock(); // 加锁
            if (this.money >= money) {
                System.out.println(name + "取钱成功,吐出:" + money);
                // 更新余额
                this.money -= money;
                System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
            } else {
                System.out.println(name + "来取钱:余额不足!");
            }
        } finally {
            lk.unlock(); // 解锁
        }
    }
}

(补充说明)unlock() 必须放在 finally 中,保证加锁后的代码出现异常时锁依然会被释放;锁对象应作为共享资源(账户对象)的成员,不能让每条线程各自 new 一把。

8. 线程通信【了解】

8.1 什么是线程通信

当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以相互协调,并避免无效的资源争夺。

线程通信的常见模型是「生产者与消费者模型」:生产者线程负责生产数据,消费者线程负责消费生产者生产的数据。

注意:生产者生产完数据应该等待自己,通知消费者消费;消费者消费完数据也应该等待自己,再通知生产者生产!

8.2 案例:包子铺

需求:3 个生产者线程负责生产包子,每个线程每次只能生产 1 个包子放在桌子上;2 个消费者线程负责吃包子,每人每次只能从桌子上拿 1 个包子吃。

@Data
public class Desk {
    private String data; // 放包子数据的地方

    // 消费者:小懒猪 小萝莉
    public synchronized void get() throws Exception {
        String name = Thread.currentThread().getName();
        if (data == null) {
            // 没有包子。暂停自己,唤醒别人
            this.notifyAll();
            this.wait();
        } else {
            // 有包子,开始吃
            System.out.println(name + "吃了:" + data);
            data = null;
            this.notifyAll();
            this.wait();
        }
    }

    // 生产者:大肚婆 小李子 狗不理
    public synchronized void put() throws Exception {
        String name = Thread.currentThread().getName();
        if (data == null) {
            // 没有包子。做包子
            data = name + "做的肉包子";
            System.out.println(name + "生产了一个肉包子!");
            this.notifyAll();
            this.wait();
        } else {
            // 有包子
            this.notifyAll();
            this.wait();
        }
    }
}
// 生产者线程
public class MakeThread extends Thread {
    private Desk desk;

    public MakeThread(Desk desk, String name) {
        super(name);
        this.desk = desk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
                desk.put();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
// 消费者线程
public class ConsumerThread extends Thread {
    private Desk desk;

    public ConsumerThread(Desk desk, String name) {
        super(name);
        this.desk = desk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
                desk.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:线程通信。
        // 1、竞争一个桌子
        Desk desk = new Desk();

        // 2、创建 2 个消费者线程
        new ConsumerThread(desk, "小懒猪").start();
        new ConsumerThread(desk, "小萝莉").start();

        // 3、创建 3 个生产者线程
        new MakeThread(desk, "大肚婆").start();
        new MakeThread(desk, "小李子").start();
        new MakeThread(desk, "狗不理").start();
    }
}

8.3 Object 类的等待和唤醒方法

方法说明
void wait()让当前线程等待并释放所占锁,直到另一个线程调用 notify() 或 notifyAll()
void notify()唤醒正在等待的单个线程
void notifyAll()唤醒正在等待的所有线程

注意:上述方法应该使用当前同步锁对象进行调用。

(补充说明)wait()/notify()/notifyAll() 定义在 Object 中,原因是它们必须由「持有该锁的线程」调用,而任意对象都可以用作同步锁;因此上面的代码写作 this.wait()、this.notifyAll()(this 即同步锁对象)。

9. 线程池

9.1 认识线程池

不使用线程池的问题:用户每发起一个请求,后台就需要创建一个新线程来处理,下次新任务来了肯定又要创建新线程处理,而创建新线程的开销是很大的,并且请求过多时,肯定会产生大量的线程出来,这样会严重影响系统的性能。

线程池就是一个可以复用线程的技术。

线程池的工作原理:任务(Runnable/Callable)提交后先进入任务队列(WorkQueue),线程池中的工作线程(WorkThread)从队列中取任务执行,线程执行完任务后不销毁,而是回到池中复用。

9.2 如何得到线程池对象

JDK 5.0 起提供了代表线程池的接口:ExecutorService。

  • 方式一:使用 ExecutorService 的实现类 ThreadPoolExecutor 自创建一个线程池对象;
  • 方式二:使用 Executors(线程池的工具类)调用方法返回不同特点的线程池对象。

9.3 ThreadPoolExecutor 的构造器与七个参数

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
                          BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
参数含义课件的形象比喻
corePoolSize指定线程池的核心线程的数量正式工:3
maximumPoolSize指定线程池的最大线程数量最大员工数:5(正式工 3 + 临时工 2)
keepAliveTime指定临时线程的存活时间临时工空闲多久被开除
unit指定临时线程存活的时间单位(秒、分、时、天)—
workQueue指定线程池的任务队列客人排队的地方
threadFactory指定线程池的线程工厂负责招聘员工的(HR)
handler指定线程池的任务拒绝策略(线程都在忙,任务队列也满了的时候,新任务来了该怎么处理)忙不过来咋办?

9.4 线程池的注意事项

  1. 临时线程什么时候创建?新任务提交时发现核心线程都在忙,任务队列也满了,并且还可以创建临时线程,此时才会创建临时线程。
  2. 什么时候会开始拒绝新任务?核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务。

9.5 线程池处理 Runnable 任务

使用 ExecutorService 的方法:void execute(Runnable target)。

public class ThreadPoolExecutorDemo1 {
    public static void main(String[] args) {
        // 目标:创建线程池对象,处理 Runnable 任务
        // 1、创建线程池
        ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 2、处理任务
        Runnable target = new MyRunnable();
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 到了临时线程的时机了
        pool.execute(target); // 到了临时线程的时机了
        pool.execute(target); // 到了任务拒绝策略了!

        // 3、线程池没有死亡。
        // pool.shutdownNow(); // 立即关闭,不管任务是否完成!!返回没有执行完的任务
        // pool.shutdown();    // 等待全部任务执行完毕后,再关闭
    }
}
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            System.out.println(Thread.currentThread().getName() + "输出:" + i);
            System.out.println(Thread.currentThread().getName() + "线程进入休眠!~");
            try {
                Thread.sleep(Integer.MAX_VALUE);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

9.6 线程池处理 Callable 任务

使用 ExecutorService 的方法:Future<T> submit(Callable<T> command),返回的未来任务对象用于获取任务执行完后的结果。

public class ThreadPoolExecutorDemo2 {
    public static void main(String[] args) {
        // 1、创建线程池
        ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 2、处理任务
        Future<String> f1 = pool.submit(new MyCallable(100));
        Future<String> f2 = pool.submit(new MyCallable(200));
        Future<String> f3 = pool.submit(new MyCallable(300));

        try {
            String s = f1.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
        try {
            String s = f2.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
        try {
            String s = f3.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
// 1、定义一个类实现 Callable 接口
public class MyCallable implements Callable<String> {
    private int n;

    public MyCallable(int n) {
        this.n = n;
    }

    // 2、重写 call 方法,声明任务和返回的结果
    @Override
    public String call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += i;
        }
        return Thread.currentThread().getName() + "求和1-" + n + "的结果是:" + sum;
    }
}

9.7 ExecutorService 的常用方法

方法说明
void execute(Runnable command)执行 Runnable 任务,没有返回值
Future<T> submit(Callable<T> task)执行 Callable 任务,返回未来任务对象用于获取线程执行结果
void shutdown()等任务执行完毕后关闭线程池

9.8 新任务拒绝策略

策略行为
ThreadPoolExecutor.AbortPolicy丢弃任务并抛出 RejectedExecutionException 异常(默认策略)
ThreadPoolExecutor.DiscardPolicy丢弃任务,但是不抛出异常(不推荐)
ThreadPoolExecutor.DiscardOldestPolicy抛弃队列中等待最久的任务,然后把当前任务加入队列
ThreadPoolExecutor.CallerRunsPolicy由主线程负责调用任务的 run() 方法,从而绕过线程池直接执行

9.9 Executors 工具类实现线程池

Executors 是一个线程池的工具类,提供了很多静态方法用于返回不同特点的线程池对象。

方法特点
public static ExecutorService newFixedThreadPool(int nThreads)创建固定线程数量的线程池,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程替代它
public static ExecutorService newSingleThreadExecutor()创建只有一个线程的线程池对象,如果该线程出现异常而结束,那么线程池会补充一个新线程
public static ExecutorService newCachedThreadPool()线程数量随着任务增加而增加,如果线程任务执行完毕且空闲了 60s 则会被回收掉
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)创建一个线程池,可以实现在给定的延迟后运行任务,或者定期执行任务

注意:这些方法的底层,都是通过线程池的实现类 ThreadPoolExecutor 创建的线程池对象。

public class ExecutorsDemo3 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(3);

        // 2、处理任务
        Future<String> f1 = pool.submit(new MyCallable(100));
        Future<String> f2 = pool.submit(new MyCallable(200));
        Future<String> f3 = pool.submit(new MyCallable(300));

        try {
            System.out.println(f1.get());
            System.out.println(f2.get());
            System.out.println(f3.get());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

9.10 Executors 使用可能存在的陷阱

大型并发系统环境中使用 Executors 如果不注意可能会出现系统风险。对此,阿里巴巴 Java 开发手册中有强制要求:

阿里巴巴 Java 开发手册对 Executors 创建线程池的限制

  • 【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
  • FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

结论:Executors 不适合做大型互联网场景的线程池方案,建议使用 ThreadPoolExecutor 来指定线程池参数,这样可以明确线程池的运行规则,规避资源耗尽的风险。

10. 其它细节:并发与并行

10.1 进程与线程

正在运行的程序(软件)就是一个独立的进程。线程是属于进程的,一个进程中可以同时运行很多个线程。进程中的多个线程其实是并发和并行执行的。

任务管理器中一个进程下挂着多条线程

10.2 并发的含义

进程中的线程是由 CPU 负责调度执行的,但 CPU 能同时处理线程的数量有限,为了保证全部线程都能往前执行,CPU 会轮询为系统的每个线程服务,由于 CPU 切换的速度很快,给我们的感觉这些线程在同时执行,这就是并发。并发对应的是单核 CPU 的场景。

10.3 并行的理解

并行指的是在同一个时刻上,同时有多个线程在被 CPU 调度执行(例如 4 核 CPU 上同时调度 4 条线程)。并行需要多核 CPU 的支持。

10.4 小结

  • 并发:CPU 分时轮询的执行线程;
  • 并行:同一个时刻同时在执行。

11. 线程的生命周期

11.1 Java 线程的状态

线程的生命周期,也就是线程从生到死的过程中,经历的各种状态及状态转换。理解线程这些状态有利于提升并发编程的理解能力。Java 总共定义了 6 种状态,6 种状态都定义在 Thread 类的内部枚举类中。

public class Thread {
    // ...
    public enum State {
        NEW,
        RUNNABLE,
        BLOCKED,
        WAITING,
        TIMED_WAITING,
        TERMINATED;
    }
    // ...
}

11.2 6 种状态的互相转换

起点触发条件到达状态
New 新建start()Runnable 可运行
Runnable 可运行执行完毕 / 出现异常Terminated 被终止
Runnable 可运行未获得锁对象Blocked 锁阻塞
Blocked 锁阻塞获得锁对象Runnable 可运行
Runnable 可运行获得锁对象后调用了 wait()Waiting 无限等待
Waiting 无限等待被其他线程 notify 并获得锁对象Runnable 可运行
Waiting 无限等待被其他线程 notify,但未获得锁对象Blocked 锁阻塞
Runnable 可运行1、sleep(毫秒) 2、wait(毫秒)Timed Waiting 计时等待
Timed Waiting 计时等待1、sleep 时间到 2、wait 时间到,并得到锁对象 3、wait 时间没到,被其他线程 notify,并得到锁对象Runnable 可运行
Timed Waiting 计时等待1、wait 时间到,未获得锁对象 2、wait 时间没到,被其他线程 notify,未获得锁对象Blocked 锁阻塞

11.3 6 种状态总结

状态名称说明
NEW新建线程刚被创建,还没有调用 start()
RUNNABLE可运行线程已经调用了 start(),正在等待 CPU 调度或正在执行
BLOCKED锁阻塞线程等待获取锁对象(想进入同步代码块/同步方法但锁被别的线程持有)
WAITING无限等待线程调用了 wait(),需要其他线程 notify 唤醒并抢到锁才能继续
TIMED_WAITING计时等待线程调用了 sleep(毫秒) 或 wait(毫秒),时间到了会自动转入下一状态
TERMINATED被终止线程执行完毕或出现异常

(补充说明)课件把 RUNNABLE 归为「可运行」,它涵盖了操作系统层面的就绪与运行两种情形;Java 线程状态不区分这两者。

12. 作业与练习

作业文档:day12-多线程/作业/03_题目/day09每日练习.md(文件名沿用 day09,内容是多线程的 5 道加强训练/综合扩展练习,均要求使用同步方法或同步代码块保证数据安全)。

题目要求训练点
题目 1(加强训练)模拟简单银行系统:账户初始余额 1000 元,两个线程各存 10 次、每次存 100 元,不允许出现错误数据账户类 Account、任务类 Transfer implements Runnable、synchronized 同步方法/同步代码块
题目 2(加强训练)小明和小王一起抄写 100 遍 HelloWorld,每人的惩罚次数与内容可变Runnable 方式创建线程 + 共享资源计数 + 同步
题目 3(综合扩展)抽奖箱中有 10 个奖品,「第一组」「第二组」两条线程随机抽取List 共享集合 + 死循环内嵌套 synchronized + 随机下标移除
题目 4(综合扩展)年会两个入口模拟 100 名员工入场并各发一张双色球彩票,统计每个入口人数工具类生成双色球、任务类计数、同步保护共享集合
题目 5(综合扩展)4 条线程:2 条打印 1-52,2 条打印 A-Z,各自单独看为升序一个任务类交给多条线程、同步代码块的范围设计

题目 1 参考要点:

public class Transfer implements Runnable {
    private Account account;

    public Transfer(Account account) {
        this.account = account;
    }

    public void run() {
        for (int i = 0; i < 10; i++) { // 向账户中存入 10 次钱
            transfer();                // 调用同步方法
        }
    }

    // 同步方法
    public synchronized void transfer() {
        account.deposit(100); // 向账户中存入 100 块钱
        System.out.println(Thread.currentThread().getName()
                + " 存入100元后,账户的余额是: " + account.getLeftMoney());
    }
}
public class Test01 {
    public static void main(String[] args) {
        Account account = new Account(1000);
        Transfer transfer = new Transfer(account);
        Thread t1 = new Thread(transfer, "小王");
        Thread t2 = new Thread(transfer, "小李");
        t1.start();
        t2.start();
    }
}

(片段)题目 1 也可改写为同步代码块,把 account.deposit(100) 与打印语句一起放进 synchronized (this) { ... } 中。

13. 复习要点

  1. 线程是程序内部的一条执行流程,Thread 对象代表线程,多条线程由 CPU 负责调度;main 方法由主线程执行。
  2. 创建线程三种方式:继承 Thread(重写 run())、实现 Runnable(任务对象交给 Thread)、实现 Callable + FutureTask(用 get() 取结果)。启动线程必须调用 start(),直接调 run() 等于单线程。
  3. 三种方式取舍:Thread 编码简单但有单继承局限;Runnable 扩展性强但不能返回结果;Callable 能返回结果但编码复杂。
  4. Thread 常用方法:getName()/setName()、currentThread()、sleep(long)、join();yield、interrupt、守护线程、线程优先级在开发中很少使用。
  5. 线程安全问题出现的三个条件:多条线程同时执行 + 访问同一共享资源 + 存在修改该资源;「取钱」案例说明复合操作被交错会写坏数据。
  6. 同步代码块 synchronized (锁对象) { ... }:锁对象对同时执行的线程必须是同一把;实例方法建议用 this,静态方法建议用 类名.class。
  7. 同步方法:在修饰符上加 synchronized,隐式锁对象同样是 this 或 类名.class;锁的范围比同步代码块大,但可读性更好。
  8. Lock 锁(JDK 5 起,实现类 ReentrantLock):lock()/unlock() 手动加解锁,unlock() 要放在 finally 中,更灵活但容易忘记释放。
  9. 线程通信(生产者消费者模型):wait()/notify()/notifyAll() 来自 Object,必须用当前同步锁对象调用。
  10. 线程池:ExecutorService 代表线程池,可用 ThreadPoolExecutor 自建(7 个参数:核心线程数、最大线程数、临时线程存活时间与单位、任务队列、线程工厂、拒绝策略),也可用 Executors 返回常用线程池;execute() 处理 Runnable,submit() 处理 Callable。
  11. 拒绝策略有 AbortPolicy(默认)、DiscardPolicy、DiscardOldestPolicy、CallerRunsPolicy;Executors 创建的线程池队列或线程数可能无界,大型互联网场景应改用 ThreadPoolExecutor 明确规则。
  12. 并发是 CPU 分时轮询执行线程(单核场景),并行是同一时刻多条线程同时被执行(多核);线程有 6 种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。

day13|java.net 包下的常用 API:网络编程

本日进入 java.net 包:先补上”网络通信要什么”的前置概念(CS/BS 架构、IP、端口、协议),再用两个协议把程序真正连起来——UDP 用 DatagramSocket/DatagramPacket 发数据包(无连接、不可靠、一次 64KB 内),TCP 用 Socket/ServerSocket 建可靠连接(三次握手、全双工、字节流)。学完后应能独立写出四类程序:UDP 一发一收与多发多收、TCP 一发一收与多发多收、支持多客户端并发的服务端(主线程 accept() + 子线程/线程池)、以及用 TCP 手写一个能被浏览器识别的简易 BS 服务端(按 HTTP 响应格式返回数据)。

本日 PPT 目录(第 9 页给出总纲,第 19/23/37/43/47/55/58/62/65 页作为进度锚点反复出现):网络通信三要素(IP 地址、端口号、协议)→ UDP 通信(快速入门、多发多收)→ TCP 通信(快速入门、多发多收、支持与多个客户端同时通信、综合案例:即时通信-群聊 / 简易版 BS 架构),最后附加拓展知识”用线程池优化服务端”。

三点说明(供复习时核对):

  • 本日共 73 页幻灯片。第 17、25、39、49、52 页的”常用方法”表格在抽取文本里只剩 [表格] 占位、没有表体,这些表格本文按课件提问页与配套示例工程(day13-网络编程/代码/day13-net,包 com.itheima.d1~d8)还原,还原处标注”(表格按课件提问页与配套示例整理)”。
  • PPT 抽出的 56 张图片全部逐张查看过:其中 4 张是损坏/空白占位图(day13-s48-44/45.png 162/244 字节、day13-s72-54/55.png 250/164 字节),其余绝大多数是装饰文字图(“客户端""服务器端""通信效率高!""视频直播”)、卡通人物、表情包与图库照片。本日没有任何代码截图、结构图或控制台截图,因此只归档了 8 张有说明价值的(第 2、6、29、38 页),这是本日配图偏少的原因。
  • 代码按课件顺序重新拼接并规范缩进,示例类名沿用课件的命名(Client/Server、Client1/Server1、ServerReaderThread)。

1. 什么是网络编程

1.1 定义与落点

课件第 2 页的定义:网络编程可以让设备中的程序与网络上其他设备中的程序进行数据交互(实现网络通信)。课上配的例子是两台手机上的聊天软件互发消息——真正在交换数据的是”一个设备里的程序”和”另一个设备里的程序”。

第 3 页给出落点:java.net.* 包下提供了网络编程的解决方案,本日所有类都来自这个包(InetAddress、DatagramSocket、DatagramPacket、Socket、ServerSocket)。

两台设备上的聊天程序在互发消息,这就是网络编程

全球设备互联示意,网络编程的目标是让不同设备上的程序交换数据

1.2 网络编程与 CS/BS 的关系

第 8 页一句话总结:无论是 CS 架构、还是 BS 架构的软件,都必须依赖网络编程。

2. 基本的通信架构:CS 与 BS

课件第 4–7 页:基本的通信架构有 2 种形式——CS 架构(Client 客户端 / Server 服务端)、BS 架构(Browser 浏览器 / Server 服务端)。两者的差别只在”客户端那一端是谁”:

架构客户端侧客户端侧是否需要程序员开发客户端侧用户需要安装什么服务端
CS(Client-Server)Client 客户端需要程序员开发实现需要安装客户端程序需要程序员开发实现
BS(Browser-Server)Browser 浏览器不需要程序员开发实现需要安装浏览器需要程序员开发实现

一句话记忆:BS 省掉了客户端开发(浏览器是现成的,用户装了浏览器就能用),而两边架构的服务端都得自己写——所以本日的重点全是服务端。

课件第 6 页用三个真实的浏览器页面举例说明”BS 的客户端就是一个浏览器”:

浏览器访问京东页面,BS 架构中客户端就是浏览器

浏览器访问 bilibili,同一台电脑上的浏览器可以访问不同服务端

浏览器访问企业内部 OA 系统(oa.itcast.cn),BS 架构的典型企业应用

3. 网络通信三要素

3.1 三要素的分工

课件第 10 页:

要素作用(课件原文)
IP设备在网络中的地址,是唯一的标识
端口应用程序在设备中唯一的标识
协议连接和数据在网络中传输的规则

第 11 页用一张示意图说明三者如何配合:一台设备(IP 地址 1)上某个程序用端口(6666/7777/8888)发出数据”你瞅啥?“,数据里必须写明目的地 IP(IP 地址 2)+ 目的地端口(8888),并且收发双方都按同一个协议来解释这串数据。缺任何一样,数据都到不了或读不懂。

第 16 页小结问答:

问题答案
网络通信至少需要几个要素IP、端口、协议
IP 地址是做什么的,具体有几种定位网络上的设备;有 IPv4、IPv6
如何查看本机 IP 地址,如何看是否与对方互通ipconfig;ping 192.168.10.23
本机 IP 是谁127.0.0.1 或者是 localhost

3.2 IP 地址:IPv4 与 IPv6

IP(Internet Protocol):全称”互联网协议地址”,是分配给上网设备的唯一标志。IP 地址有两种形式:

版本长度表示法例子
IPv432bit(4 字节)点分十进制192.168.1.66
IPv6128 位冒分十六进制(分 8 段,每段每 4 位编码成一个十六进制位,段间用冒号分开)2001:0db8:0000:0023:0008:0800:200c:417a

课件第 12 页给了 IPv4 的二进制到点分十进制的换算示例:11000000 10101000 00000001 01000010 → 192.168.1.66。第 13 页对 IPv6 的评价是”号称可以为地球每一粒沙子编号”。

3.3 域名与 DNS

课件第 14 页:域名是为了让人不必记 IP。在浏览器里输入 http://www.itcast.cn(或 http://www.itheima.com)时,计算机先把域名交给 DNS 服务器解析成 IP 地址,再访问对应的服务器,服务器返回的数据最终展示在浏览器上。

(补充说明)课件这一页画的是”计算机 → DNS 服务器 → 目标服务器”的链路;DNS 只负责”域名换成 IP”这一步,换到 IP 之后的通信仍然要遵守 IP、端口、协议三要素。

3.4 特殊 IP 与常用命令

课件第 15 页:

  • 127.0.0.1、localhost:代表本机 IP,只会寻找当前所在的主机。
  • 公网 IP:可以连接互联网的 IP 地址;内网 IP(也叫局域网 IP):只能组织机构内部使用。
  • 192.168. 开头的就是常见的局域网地址,范围即 192.168.0.0--192.168.255.255,专门为组织机构内部使用。
  • ipconfig:查看本机 IP 地址。
  • ping IP地址:检查网络是否连通。

3.5 InetAddress 类

课件第 17–18 页:InetAddress 代表 IP 地址。

常用方法(表格按课件提问页与配套示例整理):

方法说明
public static InetAddress getLocalHost()获取本机 IP 地址对象
public static InetAddress getByName(String host)按域名(或 IP 文本)获取对方主机的 IP 地址对象
public String getHostAddress()获取 IP 地址字符串
public String getHostName()获取主机名
public boolean isReachable(int timeout)判断与该 IP 地址对象是否互通,单位毫秒(相当于 ping)

配套示例 InetAddressDemo1:

package com.itheima.d1_inetAddress;

import java.net.InetAddress;

public class InetAddressDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:InetAddress类代表IP地址对象,用来操作IP地址。
        // 1、获取本机IP地址对象
        InetAddress ip = InetAddress.getLocalHost();
        System.out.println(ip.getHostAddress());
        System.out.println(ip.getHostName());

        // 2、指定获取对方主机的IP地址对象
        InetAddress ip2 = InetAddress.getByName("www.baidu.com");
        System.out.println(ip2.getHostAddress());
        System.out.println(ip2.getHostName());

        // 3、判断本机与该主机是否能够联通:ping
        System.out.println(ip2.isReachable(5000));
    }
}

课件第 18 页的两个考点:如何获取本机 IP 对象 → getLocalHost();如何判断与该 IP 地址对象是否互通 → isReachable(int timeout)。

3.6 端口

课件第 21 页:端口是标记正在计算机设备上运行的应用程序的,被规定为一个 16 位的二进制,范围是 0~65535。

分类范围说明
周知端口0~1023被预先定义的知名应用占用(如 HTTP 占用 80,FTP 占用 21)
注册端口1024~49151分配给用户进程或某些应用程序
动态端口49152~65535一般不固定分配某种进程,而是动态分配

注意(课件原文):我们自己开发的程序一般选择使用注册端口,且一个设备中不能出现两个程序的端口号一样,否则出错。

第 22 页小结问答:

问题答案
端口号的作用是什么唯一标识正在计算机设备上运行的进程(程序)
一个设备中能否出现 2 个应用程序的端口号一样,为什么不可以,如果一样会出现端口冲突错误

后面所有示例都把服务端端口写在代码里(UDP 用 8888,TCP 用 9999,BS 用 8080),都属于注册端口;客户端创建 DatagramSocket()/Socket(...) 时由系统分配本地端口。

3.7 通信协议

课件第 24 页:网络上通信的设备,事先规定的连接规则,以及传输数据的规则被称为网络通信协议。

第 25 页对比两个模型:

  • OSI 网络参考模型:全球网络互联标准。
  • TCP/IP 网络模型:事实上的国际标准。

(补充说明)课件此页的对比表格未随文本导出,只保留了上面两句结论。补充常见的对应关系:OSI 自上而下为应用层、表示层、会话层、传输层、网络层、数据链路层、物理层;TCP/IP 模型通常压成应用层、传输层、网际层、网络接口层。本日讲的 UDP/TCP 位于其中的传输层。

4. 传输层的两个通信协议:UDP 与 TCP

课件第 26、27、33 页反复讲同一段内容,是后面所有代码的共同前提。

4.1 UDP 协议

UDP(User Datagram Protocol):用户数据报协议。特点:无连接、不可靠通信。

  • 不事先建立连接,数据按照包发。一包数据包含:自己的 IP、程序端口,目的地 IP、程序端口和数据(限制在 64KB 内)等。
  • 发送方不管对方是否在线,数据在中间丢失也不管,如果接收方收到数据也不返回确认,故是不可靠的。
  • 课件第 26 页把 UDP 的典型场景标为”视频直播""语音通话”,配的评价是”通信效率高!”。

4.2 TCP 协议

TCP(Transmission Control Protocol):传输控制协议。特点:面向连接、可靠通信。

  • TCP 的最终目的:要保证在不可靠的信道上实现可靠的传输。
  • 课件说法:TCP 主要有三个步骤实现可靠传输——三次握手建立连接,传输数据进行确认,四次挥手断开连接。
  • 第 33 页对 TCP 的评价是”通信效率相对不高!”。

4.3 三次握手建立可靠连接

课件第 28、30 页给出的三步:

  1. 发出连接请求;
  2. 返回一个响应;
  3. 再次发出确认信息,连接建立。

可靠连接的含义(课件原文):确定通信双方,收发消息都是正常无问题的(全双工)。

第 29 页用生活化的比喻讲这三步:你瞅啥?→ 瞅你咋地?→ 走,咱俩唠唠 → 于是两人关系确立——先两次互相确认”你在、我也在”,第三步才真正建立关系。

课件用生活化对话比喻 TCP 三次握手,确认双方都在线后才建立连接

4.4 四次挥手断开连接

课件第 31 页给出的四步:

  1. 发出断开连接请求;
  2. 返回一个响应:稍等(服务器将最后的数据处理完毕);
  3. 发出正式确认断开连接;
  4. 返回一个响应:确认断开。

目的(课件原文):确保双方数据的收发都已经完成!

第 32 页的比喻:我要走啦 / 好的,稍等 / 拿着苹果路上吃 / 爱过,溜啦! / 给你塞了两个苹果。

课件说法:这一步叫”四次握手断开连接”;更准确的表述:断开连接的过程通常称为”四次挥手”,“握手”一般专指建立连接。

4.5 第 34–36 页小结

问题课件答案
通信协议是什么计算机网络中,连接和通信数据的规则被称为网络通信协议
TCP 通信协议的特点面向连接的可靠通信协议;传输前采用”三次握手”方式建立连接,点对点的通信,所以可靠;连接中可进行大数据量的传输;通信效率较低
UDP 协议的特点用户数据报协议;面向无连接、不可靠传输的通信协议;速度快,有大小限制一次最多发送 64K,数据不安全,易丢失数据

课件第 35 页把 TCP 说成”点对点的通信”;更准确的表述是”端到端”(点对点通常指物理链路上的直接连接)。

5. UDP 通信

5.1 特点与两个核心类

第 38 页:UDP 无连接、不可靠通信。不事先建立连接;发送端每次把要发送的数据(限制在 64KB 内)、接收端 IP 等信息封装成一个数据包,发出去就不管了。Java 提供了 java.net.DatagramSocket 类来实现 UDP 通信。

课件用”扔韭菜”贯穿这一节,对照关系是:

角色比喻类/对象
发送端(客户端)扔韭菜的人DatagramSocket(无参构造)
接收端(服务端)接韭菜的人DatagramSocket(int port)
数据包韭菜盘子DatagramPacket

课件用'扔韭菜的人'比喻 UDP 的发送端(客户端)

课件用'韭菜'比喻 UDP 中的数据包,扔出去就不管了

DatagramSocket:用于创建客户端、服务端(表格按课件提问页与配套示例整理):

构造器/方法说明
public DatagramSocket()创建发送端的 Socket 对象(默认会分配端口)
public DatagramSocket(int port)创建接收端的 Socket 对象(注册端口)
public void send(DatagramPacket dp)发送数据包
public void receive(DatagramPacket dp)接收数据包(没有收到数据时会暂停等待)
public void close()释放资源

DatagramPacket:创建数据包(表格按课件提问页与配套示例整理):

构造器/方法说明
public DatagramPacket(byte[] buf, int length)创建接收数据用的数据包对象(准备一个缓冲区)
public DatagramPacket(byte[] buf, int length, InetAddress address, int port)创建发送数据用的数据包对象(写明目的地 IP 与端口)
public int getLength()获取本轮收到的字节数
public InetAddress getAddress()获取发送端的 IP 地址对象
public int getPort()获取发送端的端口

第 42 页提问页同样落在这几个点上:创建发送端 → DatagramSocket();创建接收端 → DatagramSocket(int port);数据包对象 → DatagramPacket;发送/接收 → send(DatagramPacket) / receive(DatagramPacket)。

5.2 一发一收

客户端(发送端)步骤(第 40 页):

  1. 创建 DatagramSocket 对象(客户端对象)——扔韭菜的人;
  2. 创建 DatagramPacket 对象封装需要发送的数据(数据包对象)——韭菜盘子;
  3. 使用 DatagramSocket 对象的 send 方法,传入 DatagramPacket 对象——开始抛出韭菜;
  4. 释放资源。
package com.itheima.d2_udp1;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
 * 需求:一发 一收。
 */
public class Client {
    public static void main(String[] args) throws Exception {
        // 目标:客户端实现(发送端)
        // 1、创建发送端对象(抛韭菜的人)
        DatagramSocket socket = new DatagramSocket(); // 默认会分配端口

        // 2、创建一个数据包对象,负责封装要发送的数据。(盘子)
        /**
         * 参数一:要发送的数据,字节数组
         * 参数二:发送的数据大小
         * 参数三:目的地IP地址
         * 参数四:接收端端口号
         */
        byte[] buffer = "今晚一起啤酒、龙虾、小烧烤,约吗??".getBytes();
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length, InetAddress.getLocalHost(),
                8888); // 盘子

        // 3、把这一包数据发出去(抛出去韭菜)
        socket.send(packet);

        // 4、释放资源
        socket.close();

        System.out.println("客户端已经发送完毕!");
    }
}

服务端(接收端)步骤(第 41 页):

  1. 创建 DatagramSocket 对象并指定端口(服务端对象)——接韭菜的人;
  2. 创建 DatagramPacket 对象接收数据(数据包对象)——韭菜盘子;
  3. 使用 DatagramSocket 对象的 receive 方法,传入 DatagramPacket 对象——开始接收韭菜;
  4. 释放资源。
package com.itheima.d2_udp1;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class Server {
    public static void main(String[] args) throws Exception {
        System.out.println("===服务器启动成功===");
        // 目标:开始开发一个服务端程序。(接收端)
        // 1、创建接收端对象,注册端口(接收端人)
        DatagramSocket socket = new DatagramSocket(8888);

        // 2、创建一个数据包对象(韭菜盘子)
        byte[] buffer = new byte[1024 * 64];
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

        // 3、接收数据(接收韭菜)
        socket.receive(packet); // 暂停等待!!

        // 4、把数据输出
        // 获取本轮收到多少字节。
        int len = packet.getLength();

        String msg = new String(buffer, 0, len);
        System.out.println(msg);

        // 获取发送端的IP和端口!!
        InetAddress ip = packet.getAddress();
        System.out.println("对方ip:" + ip.getHostAddress());
        System.out.println("对方端口:" + packet.getPort());

        // 5、释放资源
        socket.close();
    }
}

两个必须记住的细节:

  • receive(packet) 会暂停等待(阻塞),直到真的收到一个数据包才继续往下走。
  • 还原字符串必须用 packet.getLength() 取本轮实际字节数,再 new String(buffer, 0, len);直接用 buffer.length 会把缓冲区里的多余内容(\0)一起读出来。

5.3 多发多收

第 44 页:客户端可以反复发送数据

  1. 创建 DatagramSocket 对象(发送端对象);
  2. 使用 while 死循环不断接收用户的数据输入,如果用户输入的 exit 则退出程序;
  3. 如果用户输入的不是 exit,把数据封装成 DatagramPacket;
  4. 使用 DatagramSocket 对象的 send 方法将数据包对象进行发送;
  5. 释放资源。
package com.itheima.d3_udp2;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.util.Scanner;

/**
 * 需求:多发多收
 */
public class Client {
    public static void main(String[] args) throws Exception {
        // 目标:客户端实现(发送端)
        // 1、创建发送端对象(抛韭菜的人)
        DatagramSocket socket = new DatagramSocket(); // 默认会分配端口

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 2、创建一个数据包对象,负责封装要发送的数据。(盘子)
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            byte[] buffer = msg.getBytes();
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length, InetAddress.getLocalHost(),
                    8888); // 盘子

            // 3、把这一包数据发出去(抛出去韭菜)
            socket.send(packet);
        }
    }
}

第 45 页:接收端可以反复接收数据

  1. 创建 DatagramSocket 对象并指定端口(接收端对象);
  2. 创建 DatagramPacket 对象接收数据(数据包对象);
  3. 使用 DatagramSocket 对象的 receive 方法传入 DatagramPacket 对象;
  4. 使用 while 死循环不断的进行第 3 步。
package com.itheima.d3_udp2;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class Server {
    public static void main(String[] args) throws Exception {
        System.out.println("===服务器启动成功===");
        // 目标:开始开发一个服务端程序。(接收端)
        // 1、创建接收端对象,注册端口(接收端人)
        DatagramSocket socket = new DatagramSocket(8888);

        // 2、创建一个数据包对象(韭菜盘子)
        byte[] buffer = new byte[1024 * 64];
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

        while (true) {
            // 3、接收数据(接收韭菜)
            socket.receive(packet); // 暂停等待!!

            // 4、把数据输出
            // 获取本轮收到多少字节。
            int len = packet.getLength();
            String msg = new String(buffer, 0, len);
            System.out.println("收到:" + msg);

            // 获取发送端的IP和端口!!
            InetAddress ip = packet.getAddress();
            System.out.println("对方ip:" + ip.getHostAddress());
            System.out.println("对方端口:" + packet.getPort());
            System.out.println("-------------------------------------------------------------");
        }
    }
}

第 46 页提问:UDP 的接收端为什么可以接收很多发送端的消息? 课件答案:接收端只负责接收数据包,无所谓是哪个发送端的数据包——每个数据包里自带发送端的 IP 与端口,接收端靠 packet.getAddress() / packet.getPort() 事后才知道是谁发的,所以 UDP 接收端天然没有”客户端列表”的概念。

6. TCP 通信

6.1 特点与 Socket 管道

第 48 页:TCP 面向连接、可靠通信。通信双方事先会采用”三次握手”方式建立可靠连接,实现端到端的通信;底层能保证数据成功传给服务端。Java 提供了 java.net.Socket 类来实现 TCP 通信。

课件示意图把一条连接画成两端各有一个 socket(端点),中间是通道,并且每一端都有字节输出流(发)和字节输入流(收)——这正是第 28 页说的”全双工”:同一时刻双方都能发也能收。

6.2 客户端:Socket

Socket(表格按课件提问页与配套示例整理):

构造器/方法说明
public Socket(String host, int port)创建客户端 Socket 对象,同时请求与服务端建立连接
public OutputStream getOutputStream()获得字节输出流对象(发)
public InputStream getInputStream()获得字节输入流对象(收)
public InetAddress getInetAddress()获取连接的对端 IP 地址对象
public int getPort()获取连接的对端端口
public void close()关闭 socket 管道,释放资源

客户端实现步骤(第 50 页):

  1. 创建客户端的 Socket 对象,请求与服务端的连接;
  2. 使用 socket 对象调用 getOutputStream() 方法得到字节输出流;
  3. 使用字节输出流完成数据的发送;
  4. 释放资源:关闭 socket 管道。

第 51 页考点:TCP 通信客户端的代表类 → Socket;创建方式 → public Socket(String host, int port);发/收数据 → getOutputStream()(发)、getInputStream()(收)。

6.3 服务端:ServerSocket

ServerSocket(表格按课件提问页与配套示例整理):

构造器/方法说明
public ServerSocket(int port)注册服务端端口
public Socket accept()阻塞等待客户端的连接请求,并得到 Socket 管道对象

服务端实现步骤(第 53 页):

  1. 创建 ServerSocket 对象,注册服务端端口;
  2. 调用 ServerSocket 对象的 accept() 方法,等待客户端的连接,并得到 Socket 管道对象;
  3. 通过 Socket 对象调用 getInputStream() 方法得到字节输入流、完成数据的接收;
  4. 释放资源:关闭 socket 管道。

第 54 页考点:服务端用的类 → ServerSocket(注册端口),调用 accept() 阻塞等待接收客户端连接,得到 Socket 对象。

一发一收的完整代码(配套示例 d4_tcp1):

package com.itheima.d4_tcp1;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 一发一收
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        // 4、发数据出去给服务端
        dos.writeInt(12);
        dos.writeUTF("服务端您好,我是骚气的客户端,请问约吗?6666~~~~");
        dos.flush();

        // 5、释放资源
        socket.close();
    }
}
package com.itheima.d4_tcp1;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        System.out.println("==服务端启动===");
        // 目标:掌握TCP通信服务端的开发。
        // 1、注册端口
        ServerSocket serverSocket = new ServerSocket(9999);

        // 2、阻塞等待客户端的socket链接请求
        Socket socket = serverSocket.accept();

        // 3、从通信管道socket中得到一个字节输入流
        InputStream is = socket.getInputStream();

        // 4、把字节输入流包装成特殊数据输入流
        DataInputStream dis = new DataInputStream(is);

        // 5、收数据
        System.out.println("收到1:" + dis.readInt());
        System.out.println("收到2:" + dis.readUTF());

        // 6、释放资源
        socket.close();
    }
}

要点:客户端与服务端必须使用成套的读写方法——客户端 writeInt/writeUTF,服务端就要 readInt/readUTF(DataInputStream/DataOutputStream 在上一天已经学过);accept() 会阻塞等待,没有客户端连上时程序就停在这一行;os/is 都是 socket 管道里的字节流,数据要 flush() 才会真正发出去。

6.4 多发多收

第 56 页:客户端使用死循环,让用户不断输入消息;服务端也使用死循环,控制服务端收完消息,继续等待接收下一个消息。

第 57 页小结:客户端使用循环反复地发送消息;服务端使用循环反复地接收消息。

package com.itheima.d5_tcp2;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 多发多收
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 4、发数据出去给服务端
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            dos.writeUTF(msg);
            dos.flush();
        }
    }
}
package com.itheima.d5_tcp2;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            // 2、阻塞等待客户端的socket链接请求
            Socket socket = serverSocket.accept();
            System.out.println("上线了~~~");

            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();

            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");
            }
        } catch (Exception e) {
            System.out.println("下线了~~~");
        }
    }
}

注意这里的”多发多收”其实只有一条连接(一个客户端对一个服务端):循环让同一条连接上可以反复收发。客户端断开时服务端会抛异常,所以课件用 catch 打印”下线了~~~”。

6.5 支持与多个客户端同时通信

第 59 页提问:目前我们开发的服务端程序,是否可以支持与多个客户端同时通信?不可以。因为服务端现在只有一个主线程,只能处理一个客户端的消息。

第 60 页给出改造后的结构:主线程负责接收客户端连接,每来一个客户端就分配一个子线程去处理它那条 socket。

第 61 页小结:主线程定义了循环负责接收客户端 Socket 管道连接;每接收到一个 Socket 通信管道后分配一个独立的线程负责处理它。

package com.itheima.d6_tcp3;

import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");

                // 3、把这个客户端管道交给一个独立的子线程来处理。
                new ServerReaderThread(socket).start();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d6_tcp3;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderThread extends Thread {
    private Socket socket;

    public ServerReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");
            }

        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
        }
    }
}

分线程带来的一件事:主线程只做 accept(),收数据的死循环被搬进了子线程——因为 accept() 和 readUTF() 都是阻塞方法,放在同一个线程里必然互相挡住。客户端示例与 6.4 完全一样,可以开多个 Client1 去连同一个服务端。

7. 综合案例

7.1 即时通信-群聊(端口转发)

第 64 页:群聊是指一个客户端把消息发出去,其他在线的全部客户端都可以收到消息。需要用到端口转发的设计思想:服务端需要把在线的 socket 管道存储起来,一旦收到一个消息要推送给其他管道。

第 63 页的结构图:主线程负责接收客户端连接(socket14),每个 socket 交给子线程 14;另外用一个集合存储在线 socket,子线程收到消息后遍历集合转发给其他管道。

package com.itheima.d7_tcp4;

import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;

public class Server1 {
    public static List<Socket> allSockets = new ArrayList<>();

    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");
                allSockets.add(socket);
                // 3、把这个客户端管道交给一个独立的子线程来处理。
                new ServerReaderThread(socket).start();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d7_tcp4;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderThread extends Thread {
    private Socket socket;

    public ServerReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                String msg = dis.readUTF();
                System.out.println("收到:" + msg);
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");

                // 马上把这个消息分发给当前全部在线的socket
                sendMsgToAll(msg);
            }

        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
            Server1.allSockets.remove(socket);
        }
    }

    private void sendMsgToAll(String msg) throws Exception {
        // 遍历全部在线socket,把消息分出去
        for (Socket sk : Server1.allSockets) {
            DataOutputStream dos = new DataOutputStream(sk.getOutputStream());
            dos.writeUTF(socket.getInetAddress().getHostAddress() + "说了:" + msg);
            dos.flush();
        }
    }
}

客户端在这基础上多了一步:上线后马上开一个线程专门读服务端推过来的消息,否则主线程卡在 sc.nextLine() 上,永远看不到别人说的话。

package com.itheima.d7_tcp4;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 一发多收 群聊
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);
        // 客户端上线后,马上找个线程负责读取他的消息
        new ClientReaderThread(socket).start();

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 4、发数据出去给服务端
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            dos.writeUTF(msg);
            dos.flush();
        }
    }
}
package com.itheima.d7_tcp4;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ClientReaderThread extends Thread {
    private Socket socket;

    public ClientReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
            }

        } catch (Exception e) {
            System.out.println(" ==> 客户端自己下线了~~~");
        }
    }
}

三个容易踩的点:

  • 转发循环里如果某个 socket 已经断开,writeUTF 会抛异常,所以下线时要把该 socket 从集合里 remove。
  • 转发时拼上发送方 IP(socket.getInetAddress().getHostAddress() + "说了:"),否则群里看不出是谁说的。
  • 集合必须是所有子线程共享的(课件写成 public static List<Socket> allSockets)。

7.2 实现一个简易版的 BS 架构

第 66 页需求:要求从浏览器中访问服务器,并立即让服务器响应一个很简单的网页给浏览器展示,网页内容就是”黑马程序员666”。

第 67 页:浏览器访问 http://服务器IP:服务器端口,例如 http://127.0.0.1:8080。结构上仍然是”主线程 accept() + 子线程”,只是连接方从自己写的客户端换成了浏览器。注意:服务器必须给浏览器响应 HTTP 协议规定的数据格式,否则浏览器不识别返回的数据。

第 68 页给出 HTTP 响应的固定格式:

部分格式课件示例
状态行协议版本 + 空格 + 状态码 + 空格 + 状态符 + 回车换行HTTP/1.1 200 OK
响应头头部字段名 + : + 值 + 回车换行(可有多行)Content-Type : text/html;charset=UTF-8
空行回车换行必须换行
响应正文真正给浏览器展示的网页数据<div style="color: red;font-size: 120px;text-align: center">黑马程序员磊哥</div>

第 69 页小结:客户端使用浏览器发起请求(不需要开发客户端);服务端必须按照 HTTP 协议响应数据。

package com.itheima.d8_bs;

import java.io.PrintStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderRunnable implements Runnable {
    private Socket socket;

    public ServerReaderRunnable(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            PrintStream ps = new PrintStream(socket.getOutputStream());
            ps.println("HTTP/1.1 200 OK");
            ps.println("Content-Type:text/html;charset=UTF-8");
            ps.println(); // 必须换行
            ps.println("<div style='color:red;font-size:120px;'>牛逼的就业班147期~</div>");
            ps.println("<img src='https://nimg.ws.126.net/?url=...'></img>");
            ps.flush();

            ps.close();
            socket.close();
        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
        }
    }
}

课件说法:状态行的第三部分叫”状态符”;更准确的表述:它是原因短语(reason phrase),如 OK,只给人看,程序判断响应结果靠的是状态码(200 表示成功)。

7.3 延伸:文件上传为什么需要 shutdownOutput()

PPT 本日没有专门讲文件上传(这块在配套作业里),但它是把上面所有知识拼起来后最容易踩坑的地方,结论只有一条:服务端要一直 read() 到流末尾才认为文件收完,而客户端发完文件后一直在等”上传成功”回执,双方互等,谁都不动。

解决方式是客户端写完文件字节后调用 socket.shutdownOutput():只关闭输出方向的流(半关闭),不关闭 socket 管道。这样服务端读到的 read() 会返回 -1,结束接收循环,再写出”文件上传成功”回执,客户端才读得到。

对应的服务端骨架(与 7.2 的结构一致,只是把写 HTTP 换成”先收文件、再回执”):

// 服务端线程中(片段):接收文件 + 回执
try (InputStream is = socket.getInputStream();
     BufferedOutputStream bos = new BufferedOutputStream(
             new FileOutputStream("D:/upload/" + fileName))) {
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {   // 客户端 shutdownOutput() 后这里才会读到 -1
        bos.write(buffer, 0, len);
    }
}

// 收到文件后再回执,否则双方互相等待
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("文件上传成功");
bw.newLine();
bw.flush();

8. 服务端的并发优化:线程池

第 70–73 页属于”拓展知识”:每次请求都开一个新线程,到底好不好? 课件给的答案是——高并发时,容易宕机! 因此使用线程池进行优化。

第 72 页的结构图:主线程仍然只负责 accept(),拿到的 socket 不再 new Thread(...).start(),而是包装成一个 Runnable 任务提交给线程池(图中标注了线程池、任务队列、核心线程),由池中的线程去执行任务。

package com.itheima.d8_bs;

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.*;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(8080);

            ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                    new ArrayBlockingQueue<>(10), Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.AbortPolicy());

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");

                // 3、把这个客户端管道交给线程池里的线程来处理。
                pool.submit(new ServerReaderRunnable(socket));
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

课件用的线程池参数:核心线程 3、最大线程 5、临时线程空闲存活 1 分钟、任务队列 ArrayBlockingQueue<>(10)、默认线程工厂、拒绝策略 AbortPolicy。服务端线程数量因此可控,避免高并发时线程无限创建把机器压垮。

(补充说明)课件没有展开讲线程池参数的含义,这部分在第 12 天已经学过,这里只是把同一套线程池直接搬到网络服务端:Runnable 换成”读 socket”的任务即可,ServerReaderRunnable 与前面 ServerReaderThread 的逻辑完全一样。

9. 作业与练习

作业文档:day12作业/作业13/day13作业.md(相对 day13-网络编程/)。

一、选择题(4 题):TCP 描述、Internet 中的主要通信协议、IPv4 地址长度、TCP 的特点(重点辨析”传输速度快”不属于 TCP 特点)。

二、今日方法:要求写出 InetAddress(getByName()/getHostName()/getHostAddress()/getLocalHost()/isReachable(int timeout))、UDP(DatagramSocket、send()、receive()、getLength())、TCP(Socket、getOutputStream()、getInputStream()、ServerSocket、accept())三类 API。

三、简答题:TCP 特点、UDP 特点、三次握手过程、四次挥手过程。

四、排错题:给定”客户端上传 .txt 文件、服务端多线程接收并回执’文件上传成功‘“的代码,找出 5 处错误。作业文档给出的修改点是:

位置修改
第 9 行Socket s = ss.accept();
第 13 行new Thread(() -> { ... })
第 64 行if (!file.exists() || !file.isFile() || !file.getName().endsWith(".txt"))
第 70 行BufferedOutputStream bos = new BufferedOutputStream(s.getOutputStream());
第 79 行s.shutdownOutput()——统一回执与文件字节的先后顺序

五、代码题:分别创建客户端与服务端并绑定端口。服务端要求用多线程侦听客户端请求、主动给每位客户端发送”欢迎光临!请上传文件(文本文件)!“、收到文件保存到本地并回复上传成功、把文件中的敏感词”操”替换成 ** 后再保存;客户端要求创建套接字向服务端侦听端口发请求、从本地选择一个带敏感词的文本文件上传、并把服务端响应的数据在控制台输出。

10. 复习要点

  1. 网络通信三要素是什么?各自解决什么问题?(IP 定位设备、端口定位设备上的程序、协议规定连接与传输规则)
  2. CS 与 BS 架构的区别到底是什么?(只在客户端那一端:客户端是否需要程序员开发、用户是否需要安装客户端;BS 的客户端就是浏览器)
  3. IPv4 与 IPv6 的长度、表示法分别是什么?127.0.0.1/localhost、192.168.x.x、ipconfig、ping 各表示什么?
  4. InetAddress 的常用方法有哪些?getLocalHost()、getByName()、getHostAddress()、getHostName()、isReachable(int timeout) 分别做什么?
  5. 端口的取值范围是多少(16 位、0~65535)?周知端口、注册端口、动态端口如何分段?为什么自研程序要选注册端口,两个程序能否用同一个端口?
  6. UDP 与 TCP 的特点各自是什么?(UDP 无连接、不可靠、一包最多 64KB、效率高;TCP 面向连接、可靠、可传大数据量、效率相对不高)TCP 为什么要在不可靠的信道上做可靠传输?
  7. 三次握手与四次挥手分别干什么?“全双工”在这里指什么?为什么断开连接要四步?
  8. UDP 通信的四个固定步骤、用到的两个类是哪些?receive() 为什么是阻塞的?为什么要用 packet.getLength() 而不是缓冲区长度?
  9. UDP 的接收端为什么能接收多个发送端的数据包?多发多收在客户端和服务端分别靠什么实现?
  10. TCP 通信的客户端与服务端步骤、Socket 与 ServerSocket 各自的关键方法是什么?为什么客户端 writeInt/writeUTF 时服务端必须用 readInt/readUTF?
  11. 一个服务端如何同时处理多个客户端?(主线程循环 accept() + 每个 socket 一个子线程/一个线程池任务)为什么 accept() 和 readUTF() 不能放在同一个线程里?
  12. 群聊的”端口转发”是怎么做的?在线 socket 为什么要放进共享集合,下线时又要做什么?
  13. 浏览器为什么能访问你写的服务端?HTTP 响应必须包含哪四部分?空行能不能省?
  14. 文件上传时服务端和客户端为什么会互相等待?shutdownOutput() 解决的是什么问题?为什么每次请求都 new Thread 在高并发下容易宕机?

day14|Junit 单元测试与 java.lang.reflect 包 API

本日学完 JavaSE 的最后四块内容:Junit 单元测试(怎么写测试、测试前后做什么)、反射(Class/Constructor/Field/Method 四件套)、注解(自定义注解、元注解、注解解析)与动态代理(Proxy + InvocationHandler)。这四块在日常业务代码里用得少,但它们是 Spring、JUnit 等框架的底层机制——学完应能读懂”框架凭什么能自动帮我们做事”,并能自己写出一个简易框架。

1. 单元测试与 JUnit 快速入门

1.1 什么是单元测试

单元测试就是:针对最小的功能单元(方法),编写测试代码对其进行正确性测试。

1.2 在 main 方法里测试有什么问题

以前的测试方式只有一种:在 main 方法中写测试代码,去调用其他方法进行测试。这种方式有三个问题:

  1. 无法实现自动化测试:一个方法测试失败,可能影响其他方法的测试(后面的代码根本走不到)。
  2. 无法得到测试的报告:需要程序员自己去观察测试是否成功。
  3. 测试代码和业务代码混在一起,没有统一的组织方式。

以前在 main 方法里测试所有业务方法的写法

1.3 JUnit 单元测试框架

JUnit 是第三方公司开源出来的、用来对方法进行测试的框架(很多开发工具已经集成了 JUnit 框架,比如 IDEA)。

相比在 main 方法中测试,它的优点:

  • 可以灵活地编写测试代码:可以针对某个方法单独执行测试,也支持一键完成对全部方法的自动化测试,且各自独立(一个失败不影响其他)。
  • 不需要程序员去分析测试结果,会自动生成测试报告。

JUnit 测试报告,红色感叹号表示失败、绿色对勾表示通过

1.4 快速入门步骤

需求:某系统有多个业务方法,请使用 Junit 单元测试框架,编写测试代码完成对这些方法的正确性测试。

具体步骤:

  1. 将 JUnit 框架的 jar 包导入到项目中(注意:IDEA 集成了 JUnit 框架,不需要我们手工导入)。
  2. 为需要测试的业务类,定义对应的测试类;并为每个业务方法编写对应的测试方法(必须:公共、无参、无返回值)。
  3. 测试方法上必须声明 @Test 注解,然后在测试方法中编写代码调用被测试的业务方法进行测试。
  4. 开始测试:选中测试方法,右键选择 “JUnit 运行”;如果测试通过则是绿色,如果测试失败则是红色。
public class StringUtil {
    public static void printNumber(String name) {
        System.out.println("名字长度:" + name.length());
    }
}

public class StringUtilTest {
    @Test
    public void testPrintNumber() {
        StringUtil.printNumber(null);
        StringUtil.printNumber("");
        StringUtil.printNumber("admin");
    }
}

测试某个方法直接右键该方法启动测试;测试全部方法则选择类或者模块启动。红色失败,绿色通过。

1.5 断言(Assert)

(以下内容来自本日讲义补充,PPT 未展开。)断言的意思是:程序员可以提前预测程序的运行结果,检查运行结果是否与预期一致。

public class StringUtilTest {
    @Test
    public void testGetMaxIndex() {
        int i1 = StringUtil.getMaxIndex(null);
        Assert.assertEquals("null测试失败!", -1, i1);

        int i2 = StringUtil.getMaxIndex("");
        Assert.assertEquals("空字符串测试失败!", -1, i2);

        int i3 = StringUtil.getMaxIndex("admin");
        Assert.assertEquals("admin测试失败!", 4, i3);

        System.out.println("测试通过!");
    }
}

Assert.assertEquals(消息, 预期值, 实际值):三者不一致时测试失败,并给出预期值与实际值的对比。

讲义配图:断言失败的报告,Expected 预期值 4、Actual 实际值 5

2. JUnit 框架的常用注解

2.1 JUnit 4 版本的常用注解

在测试方法执行前执行的方法,常用于初始化资源;在测试方法执行完后再执行的方法,常用于释放资源。

注解说明
@Test测试类中的方法必须用它修饰才能成为测试方法,才能启动执行
@Before用来修饰一个实例方法,该方法会在每一个测试方法执行之前执行一次
@After用来修饰一个实例方法,该方法会在每一个测试方法执行之后执行一次
@BeforeClass用来修饰一个静态方法,该方法会在所有测试方法之前只执行一次
@AfterClass用来修饰一个静态方法,该方法会在所有测试方法之后只执行一次

JUnit 4 常用注解表

2.2 JUnit 5 版本的常用注解

JUnit 5 对 JUnit 4 的注解做了改名,作用完全一样。

注解说明
@Test测试类中的方法必须用它修饰才能成为测试方法,才能启动执行
@BeforeEach用来修饰一个实例方法,该方法会在每一个测试方法执行之前执行一次
@AfterEach用来修饰一个实例方法,该方法会在每一个测试方法执行之后执行一次
@BeforeAll用来修饰一个静态方法,该方法会在所有测试方法之前只执行一次
@AfterAll用来修饰一个静态方法,该方法会在所有测试方法之后只执行一次

JUnit 5 常用注解表

(补充说明)JUnit 4 的注解在 org.junit 包下,JUnit 5 的在 org.junit.jupiter.api 包下,两套注解不能混用。

2.3 注解方法的执行顺序

public class StringUtilTest {
    @Before
    public void before() {
        System.out.println("===========before===========");
    }

    @After
    public void after() {
        System.out.println("===========after===========");
    }

    @BeforeClass
    public static void beforeClass() {
        System.out.println("===========beforeClass===========");
    }

    @AfterClass
    public static void afterClass() {
        System.out.println("===========afterClass===========");
    }

    @Test
    public void testPrintNumber() {
        StringUtil.printNumber(null);
        StringUtil.printNumber("");
        StringUtil.printNumber("admin");
    }

    @Test
    public void testGetMaxIndex() {
        // 断言测试...
    }
}

执行结果的特点:

  1. 被 @BeforeClass 标记的方法,执行在所有方法之前。
  2. 被 @AfterClass 标记的方法,执行在所有方法之后。
  3. 被 @Before 标记的方法,执行在每一个 @Test 方法之前。
  4. 被 @After 标记的方法,执行在每一个 @Test 方法之后。

2.4 应用场景:测试前后的资源初始化与释放

假设每个测试方法都要用同一个 Socket 对象,用完还要关闭。就可以把”开一次、关一次”的工作交给 @BeforeClass / @AfterClass:

public class StringUtilTest {
    private static Socket socket;

    @BeforeClass
    public static void beforeClass() {
        System.out.println("===========beforeClass===========");
        // 初始化 Socket 对象
        socket = new Socket();
    }

    @AfterClass
    public static void afterClass() {
        System.out.println("===========afterClass===========");
        // 关闭 Socket
        socket.close();
    }
}

3. 反射:认识反射与获取 Class 对象

3.1 反射是什么

Java API 文档中对反射的说明翻译成人话就是:反射(Reflection)就是加载类,并允许以编程的方式解剖类中的各种成分(成员变量、方法、构造器等)。

3.2 反射学什么

先充分认识”获取类的信息、操作它们”,再了解它的作用和应用场景。学习路线共四步:

  1. 反射第一步:加载类,获取类的字节码 Class 对象。
  2. 获取类的构造器:Constructor 对象。
  3. 获取类的成员变量:Field 对象。
  4. 获取类的成员方法:Method 对象。

反射学什么——Class、Constructor、Field、Method 四件套

3.3 获取 Class 对象的三种方式

反射的第一步是将字节码加载到内存,获取字节码对象(Student.java → Student.class → 加载进内存)。有三种写法:

方式代码说明
方式一Class c1 = 类名.class类名点 class
方式二Class c2 = Class.forName("全类名")调用 Class 提供的 public static Class forName(String package)
方式三Class c3 = 对象.getClass()调用 Object 提供的 public Class getClass()
public class Test1Class {
    public static void main(String[] args) throws Exception {
        // 1、方式一:类名.class
        Class c1 = Student.class;
        System.out.println(c1);

        // 2、方式二:Class.forName(全类名)
        Class c2 = Class.forName("com.itheima.d2_reflect.Student");
        System.out.println(c2);

        // 3、方式三:对象.getClass();
        Student s1 = new Student();
        Class c3 = s1.getClass();
        System.out.println(c3);

        System.out.println(c1 == c2); // true
        System.out.println(c2 == c3); // true
    }
}

三种方式获取到的字节码对象是同一个(== 为 true)。此外 c1.getName() 获取全类名、c1.getSimpleName() 获取简单类名。

Student.java → Student.class → 加载进内存

课件说法:反射第一步是”加载类,获取类的字节码”。更准确的表述:类的加载由虚拟机按需触发(第一次使用该类、执行 Class.forName() 等时),反射拿到的只是同一个字节码对象在内存中的引用。

3.4 反射就在身边:IDEA 的代码提示

用对象调用方法时,IDEA 会把该对象能调用的方法列出来供选择。IDEA 怎么知道对象有这些方法?因为对象能调用的方法全都来自类,IDEA 通过反射技术获取类中有哪些方法,再以提示框形式显示出来。

IDEA 通过反射列出对象可调用的方法与成员

4. 反射获取类的构造器

4.1 获取构造器的 API

Class 提供了从类中获取构造器的方法:

方法说明
Constructor<?>[] getConstructors()获取全部构造器(只能获取 public 修饰的)
Constructor<?>[] getDeclaredConstructors()获取全部构造器(只要存在就能拿到)
Constructor<T> getConstructor(Class<?>... parameterTypes)获取某个构造器(只能获取 public 修饰的)
Constructor<T> getDeclaredConstructor(Class<?>... parameterTypes)获取某个构造器(只要存在就能拿到)

记忆规律:

  • get:获取。
  • Declared:有这个单词表示可以获取任意一个,没有这个单词表示只能获取 public 修饰的。
  • Constructor:构造方法的意思。
  • 后缀 s:表示可以获取多个,没有后缀 s 只能获取一个。

4.2 获取全部构造器与单个构造器

public class Test2Constructor {
    @Test
    public void testGetConstructors() {
        // 1、反射第一步:必须先得到这个类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部构造器
        // Constructor[] constructors = c.getConstructors(); // 只能拿 public 的
        Constructor[] constructors = c.getDeclaredConstructors(); // 存在即可拿
        // 3、遍历构造器
        for (Constructor con : constructors) {
            System.out.println(con.getName() + " ===> " + con.getParameterCount());
        }
    }

    @Test
    public void testGetConstructor() throws Exception {
        // 1、反射第一步:必须先得到这个类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的某个构造器:无参数构造器
        Constructor con1 = c.getDeclaredConstructor();                        // 定位无参数的
        Constructor con2 = c.getDeclaredConstructor(String.class, int.class); // 定位有参数的
    }
}

4.3 构造器的作用:初始化对象并返回

获取到类的构造器,作用依然是初始化对象返回。使用 Constructor 中的方法:

方法说明
T newInstance(Object... initargs)调用此构造器对象表示的构造器完成对象初始化(返回对象)
void setAccessible(boolean flag)设置禁用访问控制检查(暴力反射),非 public 构造器必须先打开权限
// 3、得到构造器的目的依然是初始化对象返回
Cat c1 = (Cat) con1.newInstance();
System.out.println(c1);

con2.setAccessible(true);   // 禁止检查访问权限(暴力反射)
Cat c2 = (Cat) con2.newInstance("叮当猫", 30);
System.out.println(c2);

5. 反射获取类的成员变量

5.1 获取成员变量的 API

方法说明
public Field[] getFields()获取类的全部成员变量(只能获取 public 修饰的)
public Field[] getDeclaredFields()获取类的全部成员变量(只要存在就能拿到)
public Field getField(String name)获取类的某个成员变量(只能获取 public 修饰的)
public Field getDeclaredField(String name)获取类的某个成员变量(只要存在就能拿到)

命名规律同上:有 Declared 表示”任意权限都能拿”,带后缀 s 表示”拿多个”。

5.2 成员变量的作用:取值与赋值

方法说明
void set(Object obj, Object value)给某个对象的该成员变量赋值
Object get(Object obj)取出某个对象中该成员变量的值
void setAccessible(boolean flag)非 public 成员变量需要先打开权限(暴力反射),再取值、赋值
public class Test3Field {
    @Test
    public void testGetFields() throws Exception {
        // 1、反射第一步:必须先得到类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部成员变量
        Field[] fields = c.getDeclaredFields();
        // 3、遍历这个成员变量数组
        for (Field field : fields) {
            System.out.println(field.getType() + "==>" + field.getName());
        }
        // 4、定位某个成员变量
        Field fname = c.getDeclaredField("name");

        // 5、成员变量的作用依然是:取值和赋值
        Cat cat = new Cat();
        fname.setAccessible(true);             // 暴力反射
        fname.set(cat, "哆啦A梦");              // 赋值
        String name = (String) fname.get(cat); // 取值
        System.out.println(name);
    }
}

6. 反射获取类的成员方法

6.1 获取成员方法的 API

方法说明
Method[] getMethods()获取类的全部成员方法(只能获取 public 修饰的)
Method[] getDeclaredMethods()获取类的全部成员方法(只要存在就能拿到)
Method getMethod(String name, Class<?>... parameterTypes)获取类的某个成员方法(只能获取 public 修饰的)
Method getDeclaredMethod(String name, Class<?>... parameterTypes)获取类的某个成员方法(只要存在就能拿到)

6.2 成员方法的作用:执行它

方法说明
Object invoke(Object obj, Object... args)在某个对象上触发该方法执行,返回方法返回值
void setAccessible(boolean flag)非 public 成员方法需要先打开权限(暴力反射),再触发执行
public class Test4Method {
    @Test
    public void testGetMethods() throws Exception {
        // 1、反射第一步:先得到 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部成员方法
        Method[] methods = c.getDeclaredMethods();
        // 3、遍历这个数组中的每个方法对象
        for (Method method : methods) {
            System.out.println(method.getName() + " " + method.getParameterCount());
        }

        // 4、定位单个方法
        Method eat1 = c.getDeclaredMethod("eat");
        Method eat2 = c.getDeclaredMethod("eat", String.class);

        // 5、定位方法的目的是:依然是执行它!
        Cat cat = new Cat();
        Object result = eat1.invoke(cat);
        System.out.println(result);

        eat2.setAccessible(true);
        Object result2 = eat2.invoke(cat, "鱼儿");
        System.out.println(result2);
    }
}

无返回值的方法(如 run())执行后 invoke() 返回 null;有返回值的方法返回真实结果。

7. 反射的作用与应用场景

7.1 反射的作用

  • 基本作用:可以得到一个类的全部成分然后操作。
  • 可以破坏封装性(很突出)。
  • 也可以破坏泛型的约束性(很突出)。
  • 最重要的用途是:适合做 Java 的框架——基本上主流框架都会基于反射设计出一些通用的功能。

7.2 应用:用反射做一个简易版框架

需求:对于任意一个对象,该框架都可以把对象的字段名和对应的值,保存到文件中去。

实现步骤:

  1. 定义一个方法,可以接收任意对象。
  2. 每收到一个对象后,使用反射获取该对象的 Class 对象,然后获取全部的成员变量。
  3. 遍历成员变量,然后提取成员变量在该对象中的具体值。
  4. 把成员变量名和其值,写出到文件中去。
public class ObjectFrame {
    // 目标:保存任意对象的字段和其数据到文件中去
    public static void saveObject(Object obj) throws Exception {
        // 这个对象中有多少个字段我们不清楚,谁清楚呢?只有反射可以解决。
        PrintWriter pw = new PrintWriter(new FileWriter("day14-junit-reflect-annotation-proxy/src/obj.txt", true));
        Class c = obj.getClass();

        // 0.还得知道是啥对象
        // String className = c.getName();      // com.itheima.d2_reflect.Student
        String className = c.getSimpleName();   // Student
        pw.println("==================" + className + "====================");

        // 1、提取这个 Class 对象中的全部字段
        Field[] fields = c.getDeclaredFields();
        for (Field field : fields) {
            String name = field.getName();
            field.setAccessible(true);
            String value = field.get(obj) + "";
            pw.println(name + "=" + value);
        }
        pw.close();
    }
}
public class Test5Frame {
    @Test
    public void save() throws Exception {
        Student s1 = new Student("柳岩", 40, '女', 167.5, "女星");
        Teacher s2 = new Teacher("波妞", 6000);

        ObjectFrame.saveObject(s1);
        ObjectFrame.saveObject(s2);
    }
}

运行后生成的文件内容如下(框架对任意对象都能工作,对象的字段名与值一一对应):

简易框架把 Student、Teacher 的字段名与值写入文件的结果

8. 注解(Annotation)

8.1 认识注解

注解就是 Java 代码里的特殊标记,比如 @Override、@Test 等,作用是:让其他程序根据注解信息来决定怎么执行该程序。

注意:注解可以用在类上、构造器上、方法上、成员变量上、参数上等位置。

@Test 标记的方法被当作测试方法执行,未标记的 test2() 不会被执行

@Override 拼写错误时 IDEA 直接报错——注解让其他程序做检查

8.2 自定义注解

自定义注解的格式:

public @interface 注解名称 {
    public 属性类型 属性名() default 默认值;
}
public @interface MyTest1 {
    String aaa();
    boolean bbb() default true;   // default true 表示默认值为 true,使用时可以不赋值
    String[] ccc();
}

使用注解时需要加 @ 符号:

@MyTest1(aaa = "牛魔王", ccc = {"HTML", "Java"})
public class AnnotationTest1 {
    @MyTest1(aaa = "铁扇公主", bbb = false, ccc = {"Python", "前端", "Java"})
    public void test1() {
    }
}

特殊属性名 value:如果注解中只有一个 value 属性,使用注解时 value 名称可以不写。

public @interface MyTest2 {
    String value();          // 特殊属性
    int age() default 10;
}

@MyTest2("孙悟空")           // 等价于 @MyTest2(value = "孙悟空")
public class AnnotationTest1 {
}

8.3 注解的本质

对注解的字节码进行反编译可以看到:

// 源码里定义的
public @interface MyTest1 {
    String aaa();
    boolean bbb();
    String[] ccc();
}

// 反编译后等价于
public interface MyTest1 extends Annotation {
    public abstract String aaa();
    public abstract boolean bbb();
    public abstract String[] ccc();
}

结论:

  1. 注解本质是一个接口,Java 中所有注解都继承了 Annotation 接口。
  2. 注解中的属性,本质上是抽象方法。
  3. @注解(...) 其实就是一个实现类对象,实现了该注解以及 Annotation 接口。
  4. 注解里的属性值(如 aaa = "孙悟空"),可以通过调用 aaa()、bbb()、ccc() 方法获取到——解析注解时会用到。

8.4 注解的作用

对 Java 中类、方法、成员变量做标记,然后进行特殊处理。

例如:JUnit 框架中,标记了注解 @Test 的方法就可以被当成测试方法执行,而没有标记的就不能当成测试方法执行。

9. 元注解

9.1 什么是元注解

元注解指的是修饰注解的注解。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Test {
}

9.2 @Target:声明注解只能在哪些位置使用

取值位置
ElementType.TYPE类、接口
ElementType.FIELD成员变量
ElementType.METHOD成员方法
ElementType.PARAMETER方法参数
ElementType.CONSTRUCTOR构造器
ElementType.LOCAL_VARIABLE局部变量
@Target(ElementType.TYPE)   // 声明 @MyTest3 注解只能用在类上
public @interface MyTest3 {
}

如果把 @MyTest3 用在方法上、变量上,IDEA 会直接报错;写成数组形式就可以声明多个位置:

// 声明 @MyTest3 注解只能用在类上和方法上
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MyTest3 {
}

9.3 @Retention:声明注解的保留周期

取值含义
RetentionPolicy.SOURCE只作用在源码阶段,字节码文件中不存在
RetentionPolicy.CLASS保留到字节码文件阶段,运行阶段不存在(默认值)
RetentionPolicy.RUNTIME一直保留到运行阶段(开发常用)
@Target({ElementType.TYPE, ElementType.METHOD})   // 只能用类上和方法上
@Retention(RetentionPolicy.RUNTIME)               // 保留到运行时期
public @interface MyTest3 {
}

@Target 约束自定义注解可以标记的范围,@Retention 用来约束自定义注解的存活范围。

10. 注解的解析

10.1 什么是注解的解析

注解的解析就是:判断类上、方法上、成员变量上是否存在注解,并把注解里的内容解析出来。

指导思想:要解析谁上面的注解,就应该先拿到谁。

  • 要解析类上面的注解,先获取该类的 Class 对象,再通过 Class 对象解析其上面的注解。
  • 要解析成员方法上的注解,先获取该成员方法的 Method 对象,再通过 Method 对象解析其上面的注解。

Class、Method、Field、Constructor 都实现了 AnnotatedElement 接口,它们都拥有解析注解的能力。

10.2 AnnotatedElement 接口提供的解析注解方法

方法说明
public Annotation[] getDeclaredAnnotations()获取当前对象上面的注解
public T getDeclaredAnnotation(Class<T> annotationClass)获取指定的注解对象
public boolean isAnnotationPresent(Class<Annotation> annotationClass)判断当前对象上是否存在某个注解

10.3 解析案例

需求:定义注解 MyTest4(含 String value()、double aaa() 默认值 100、String[] bbb(),只能用在类和成员方法上,保留到运行时);定义类 Demo,在类与其 test1 方法上使用该注解;定义测试类 AnnotationTest3 解析 Demo 类中的全部注解。

① 定义注解

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTest4 {
    String value();
    double aaa() default 100;
    String[] bbb();
}

② 使用注解

@MyTest4(value = "蜘蛛侠", aaa = 99.9, bbb = {"至尊宝", "黑马"})
public class Demo {
    @MyTest4(value = "孙悟空", aaa = 199.9, bbb = {"紫霞", "牛夫人"})
    public void test1() {
    }
}

③ 解析注解

public class AnnotationTest3 {
    @Test
    public void parseClass() {
        // 1.先获取 Class 对象
        Class c = Demo.class;
        // 2.解析 Demo 类上的注解
        if (c.isAnnotationPresent(MyTest4.class)) {
            MyTest4 myTest4 = (MyTest4) c.getDeclaredAnnotation(MyTest4.class);
            System.out.println(myTest4.value());
            System.out.println(myTest4.aaa());
            System.out.println(myTest4.bbb());
        }
    }

    @Test
    public void parseMethods() {
        // 1.先获取 Class 对象
        Class c = Demo.class;
        // 2.解析 Demo 类中 test1 方法上的注解
        Method m = c.getDeclaredMethod("test1");
        if (m.isAnnotationPresent(MyTest4.class)) {
            MyTest4 myTest4 = (MyTest4) m.getDeclaredAnnotation(MyTest4.class);
            System.out.println(myTest4.value());
            System.out.println(myTest4.aaa());
            System.out.println(myTest4.bbb());
        }
    }
}

11. 注解的应用场景:模拟 JUnit 框架

需求:定义若干个方法,只要加了 MyTest 注解,就会触发该方法执行。

分析:

  1. 定义一个自定义注解 MyTest,只能注解方法,存活范围是一直都在。
  2. 定义若干个方法,部分方法加上 @MyTest 注解修饰,部分方法不加。
  3. 模拟一个 JUnit 程序,可以触发加了 @MyTest 注解的方法执行。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTest {
}
public class AnnotationTest4 {
    @MyTest
    public void test1() {
        System.out.println("=====test1====");
    }

    @MyTest
    public void test2() {
        System.out.println("=====test2====");
    }

    public void test3() {
        System.out.println("=====test3====");
    }

    public static void main(String[] args) throws Exception {
        AnnotationTest4 a = new AnnotationTest4();

        // 1.先获取 Class 对象
        Class c = AnnotationTest4.class;

        // 2.解析 AnnotationTest4 类中所有的方法对象
        Method[] methods = c.getDeclaredMethods();
        for (Method m : methods) {
            // 3.判断方法上是否有 MyTest 注解,有就执行该方法
            if (m.isAnnotationPresent(MyTest.class)) {
                m.invoke(a);
            }
        }
    }
}

这段代码就是 JUnit 的核心思路:注解负责标记,反射负责找到标记并执行。代码工程里还有一个扩展版:注解 @TestDlei 带一个 int value() default 1 属性,表示该方法重复执行的次数(片段)。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestDlei {
    int value() default 1;
}

// 解析时读出注解属性,决定执行次数
if (method.isAnnotationPresent(TestDlei.class)) {
    TestDlei dlei = method.getDeclaredAnnotation(TestDlei.class);
    int count = dlei.value();
    for (int i = 0; i < count; i++) {
        method.invoke(t);
    }
}

12. 动态代理

12.1 程序为什么需要代理

以大明星为例:唱歌、跳舞是明星真正擅长的事,但每次唱歌前要准备话筒、收钱,跳舞前要准备场地、收钱——这些繁琐的事明星不想干,于是找一家中介公司(代理)来处理。

唱歌() {
    真正开始唱歌
}
跳舞() {
    真正开始跳舞
}

对象如果嫌身上干的事太多,可以通过代理来转移部分职责。

代理结构——代理对象与真实对象都实现 Star 接口

12.2 代理长什么样:由接口决定

中介公司怎么知道要派一个有唱歌、跳舞方法的代理呢?对象有什么方法想被代理,代理就一定要有对应的方法——Java 用接口来保证这一点:

public interface Star {
    String sing(String name);
    void dance(String name);
}

明星类实现这个接口,Proxy 就能根据该接口生成一个同样具有这些方法的代理对象:

public class SuperStar implements Star {
    private String name;

    public SuperStar(String name) {
        this.name = name;
    }

    @Override
    public String sing(String name) {
        System.out.println(this.name + "唱歌一首:" + name);
        return "谢谢!谢谢!";
    }

    @Override
    public void dance(String name) {
        System.out.println(this.name + "跳了一支:" + name);
    }
}

12.3 生成代理对象:Proxy.newProxyInstance()

java.lang.reflect.Proxy 类提供了为对象产生代理对象的方法:

public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)
参数含义
参数一 loader用于指定用哪个类加载器去加载生成的代理类
参数二 interfaces指定接口,这些接口用于指定生成的代理长什么,也就是有哪些方法
参数三 h用来指定生成的代理对象要干什么事情

12.4 InvocationHandler.invoke():代理到底干什么

public class ProxyUtil {
    // 为实现类对象创建代理对象,并返回
    public static Star createProxy(SuperStar zrn) {
        Star proxy = (Star) Proxy.newProxyInstance(ProxyUtil.class.getClassLoader(),
                zrn.getClass().getInterfaces(), new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        // proxy:代理对象本身,一般我们不用
                        // method:正在代理的方法,哪个方法被调用,method 就代表哪个方法
                        // args:正在被代理的方法的参数
                        if ("sing".equals(method.getName())) {
                            System.out.println("准备话筒,收钱20万!");
                        } else if ("dance".equals(method.getName())) {
                            System.out.println("准备场地,收钱300万!");
                        }
                        // 其他的行为都是真实明星对象自己去做!
                        Object result = method.invoke(zrn, args);
                        return result;
                    }
                });
        return proxy;
    }
}

调用方式:以后找代理唱歌、跳舞即可。

public class Test {
    public static void main(String[] args) {
        // 1、创建一个明星对象
        SuperStar zrn = new SuperStar("章若楠");
        // 2、创建章若楠的代理对象
        Star proxy = ProxyUtil.createProxy(zrn);
        System.out.println(proxy.sing("好日子"));
        proxy.dance("科目三");
    }
}

12.5 代理的执行流程

每次用代理对象调用接口中的方法时,都会回调 InvocationHandler 中的 invoke() 方法,在其中执行”代理要干的事”,再通过 method.invoke(真实对象, args) 把真正的工作交回给真实对象。

说明:本日讲义中引用的”代理执行流程图”(讲义 assets 中的 1669622605490.png)在讲义 assets 目录里缺失,未能归档,此处以文字描述流程。

12.6 应用:用代理优化用户管理类

场景:某系统有一个用户管理类,包含用户登录、删除用户、查询用户等功能,系统要求统计每个功能的执行耗时情况,以便后期观察程序性能。

原来的实现里每个方法都自己写”记录开始时间 → 业务 → 记录结束时间 → 打印耗时”,重复代码多,而且这些代码不属于业务本身:

/**
 * 用户业务接口
 */
public interface UserService {
    // 登录功能
    void login(String loginName, String passWord) throws Exception;
    // 删除用户
    void deleteUsers() throws Exception;
    // 查询用户,返回数组的形式
    String[] selectUsers() throws Exception;
}

改造思路:把计算耗时的操作交给代理对象来做,业务实现类只保留业务代码。

public class ProxyUtil {
    public static UserService createProxy(UserServiceImpl obj) {
        UserService proxy = (UserService) Proxy.newProxyInstance(ProxyUtil.class.getClassLoader(),
                obj.getClass().getInterfaces(), new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        long startTime = System.currentTimeMillis();

                        // 真正走业务对象的方法
                        Object result = method.invoke(obj, args);

                        long end = System.currentTimeMillis();
                        System.out.println(method.getName() + "方法耗时:" + (end - startTime) / 1000.0 + "s");

                        return result;
                    }
                });
        return proxy;
    }
}
public class Test {
    public static void main(String[] args) throws Exception {
        // 1、创建用户业务对象(实际上是代理对象)
        UserService userService = ProxyUtil.createProxy(new UserServiceImpl());

        // 2、调用用户业务的功能
        userService.login("admin", "123456");
        System.out.println("----------------------------------------------------");

        userService.deleteUsers();
        System.out.println("----------------------------------------------------");

        String[] names = userService.selectUsers();
        System.out.println("查询到的用户是:" + Arrays.toString(names));
    }
}

这样:业务实现类只关心业务,公共的耗时统计被集中到代理的 invoke() 中——这就是代理最典型的好处(Spring 的事务、日志、权限校验也是同样的套路)。

13. 代码工程与作业

  • 本日配套讲义(兼作业与校对材料):day14-Java高级技术/讲义/day14-单元测试、反射、注解、动态代理/day14-单元测试、反射、注解、动态代理.md
  • 本日代码工程:day14-Java高级技术/代码/day14-junit-reflect-annotation-proxy/,包结构与今天的四块内容一一对应:
    • com.itheima.d1_junit:StringUtil、StringUtilTest(@Before / @After / @BeforeClass / @AfterClass + 断言)
    • com.itheima.d2_reflect:Test1Class、Test2Constructor、Test3Field、Test4Method、ObjectFrame、Test5Frame、Cat、Student、Teacher
    • com.itheima.d3_annotation:MyTestMyTest4、AnnotationDemo1AnnotationDemo4、TestDlei
    • com.itheima.d4_proxy:Star、SuperStar、ProxyUtil、Test
    • com.itheima.d5_proxy2:UserService、UserServiceImpl、ProxyUtil、Test
  • 练习要点:把 ObjectFrame.saveObject() 改成同时输出字段类型;给 MyTest 版模拟 JUnit 框架加上”统计通过/失败个数并打印报告”。

14. 复习要点

  1. 单元测试的定义(针对最小功能单元”方法”),以及在 main 中测试的三个问题(无法自动化、失败会互相影响、拿不到测试报告)。
  2. JUnit 的两个优点与快速入门四步;测试方法的三条硬性要求:公共、无参、无返回值,且必须用 @Test 标记;红色失败、绿色通过。
  3. 断言 Assert.assertEquals(消息, 预期值, 实际值) 的作用与失败报告形式。
  4. JUnit 4 与 JUnit 5 注解的对应关系(@Before→@BeforeEach、@After→@AfterEach、@BeforeClass→@BeforeAll、@AfterClass→@AfterAll)与执行时机(实例方法每个测试方法执行一次,静态方法整体只执行一次)。
  5. 反射的定义(加载类并以编程方式解剖类中各种成分)与学习四步:Class → Constructor → Field → Method。
  6. 获取 Class 对象的三种方式(类名.class、Class.forName(全类名)、对象.getClass()),三者是同一个对象。
  7. 获取构造器/成员变量/成员方法的 API 与命名规律:有 Declared 能拿任意权限、没有只能拿 public;带 s 拿多个、不带拿一个。
  8. 三件套的作用:构造器 newInstance() 初始化对象、成员变量 set()/get() 取值赋值、成员方法 invoke() 触发执行;非 public 成员需要 setAccessible(true)(暴力反射)。
  9. 反射的作用:获取类的全部成分并操作、破坏封装性、破坏泛型约束性,最重要的用途是做 Java 框架(简易框架 ObjectFrame、IDEA 代码提示)。
  10. 注解的定义与作用;自定义注解的格式、value 属性可省略名称的规则;注解本质是继承 Annotation 接口的接口,@注解(...) 是实现类对象。
  11. 元注解 @Target(ElementType 各取值)与 @Retention(SOURCE / CLASS / RUNTIME)的作用。
  12. 注解解析的指导思想(注解在谁身上就先拿到谁)与 AnnotatedElement 的三个方法;模拟 JUnit 框架的完整思路(注解标记 + 反射执行)。
  13. 动态代理三要素:Proxy.newProxyInstance(loader, interfaces, handler) 三个参数的含义、invoke(proxy, method, args) 三个参数的含义与回调时机、接口决定代理有哪些方法;能说出用代理消除耗时统计重复代码的好处。