Java 进阶特性

参考指导书 2.4 Java 进阶特性

学习清单

  • 异常处理(try-catch-finally, throws, 自定义异常)
  • IO 流(File, InputStream, OutputStream, Reader, Writer)
  • 多线程(Thread, Runnable, 线程池 ExecutorService)
  • Lambda 表达式(Java 8+)
  • Stream API(Java 8+)
  • 注解(Annotation)
  • 反射(Reflection)

1. 异常处理

1.1 为什么需要异常

没有异常机制的语言(比如 C)靠返回值来判断错误:

int result = doSomething();
if (result == -1)  { /* 错误 1 */ }
if (result == -2)  { /* 错误 2 */ }
// 正常逻辑和错误处理混在一起,层层 if 嵌套,可读性极差

Java 的异常机制把正常逻辑错误处理分开——代码沿着"快乐路径"写,错误交给 catch 块处理。

1.2 异常体系

Throwable
├── Error             严重错误,程序无法处理(OutOfMemoryError、StackOverflowError)
└── Exception         可处理的异常
    ├── RuntimeException(运行期异常)  不强制处理,如 NullPointerException、ArrayIndexOutOfBoundsException
    └── 其他(编译期异常)               必须显式处理,如 IOException、SQLException

编译期异常 vs 运行期异常

编译期异常 (Checked) 运行期异常 (Unchecked)
父类 Exception(非 RuntimeException) RuntimeException
编译器 强制你处理(try-catch 或 throws) 不强制
典型例子 IOException, SQLException NullPointerException, ArrayIndexOutOfBounds
设计意图 外部因素导致,可以预见和恢复 程序 bug,应该修复代码而非捕获

1.3 基本语法

try {
    // 可能出异常的代码
    int result = 10 / 0;
} catch (ArithmeticException e) {
    // 捕获特定异常
    System.out.println("除数不能为 0!");
    e.printStackTrace();       // 打印调用栈,调试用
} finally {
    // 无论是否异常,都会执行(常用于关闭资源)
    System.out.println("finally 一定会执行");
}

执行流程:try 出异常 → 跳过 try 剩余代码 → 匹配 catch → 执行 finally。try 没出异常 → 跳过 catch → 执行 finally。

1.4 finally 的一个特例

// 唯一不执行 finally 的情况:JVM 退出
try {
    System.exit(0);          // JVM 直接退出
} finally {
    System.out.println("这行不会执行");
}

1.5 throws:把异常往上抛

// 自己不处理,声明"我可能抛这个异常",让调用方处理
public void readFile(String path) throws IOException {
    FileReader reader = new FileReader(path);
    // ...
}

选择原则:当前方法能处理就用 try-catch;处理不了(不知道该怎么恢复)就用 throws 往上抛。

1.6 自定义异常

class AgeException extends Exception {
    public AgeException(String msg) { super(msg); }
}

public void setAge(int age) throws AgeException {
    if (age < 0 || age > 150) {
        throw new AgeException("年龄不合法:" + age);
    }
    this.age = age;
}

自定义异常的目的:让异常名本身就表达语义。AgeExceptionIllegalArgumentException 更有表达力。

1.7 try-with-resources(Java 7+)

// 旧写法:手动关闭,嵌套冗长
FileReader reader = null;
try {
    reader = new FileReader("file.txt");
    // ...
} finally {
    if (reader != null) reader.close();    // 关闭也很啰嗦
}

// 新写法:自动关闭
try (FileReader reader = new FileReader("file.txt")) {
    // ...
}   // 自动调用 reader.close(),不用写 finally

任何实现了 AutoCloseable 接口的类都可以用在 try-with-resources 中。


2. IO 流

2.1 流的基本概念

Java 把数据的输入/输出抽象为"流(Stream)":

程序  ←──InputStream/Reader──  【数据源:文件、网络、键盘...】
程序  ──→OutputStream/Writer──  【数据目标:文件、网络、屏幕...】
字节流(二进制) 字符流(文本)
输入 InputStream Reader
输出 OutputStream Writer
文件实现 FileInputStream / FileOutputStream FileReader / FileWriter

为什么分字节流和字符流? 字节流按 byte 读写,字符流按 char 读写 + 自动处理字符编码。读文本文件用字符流,读图片/视频用字节流。

2.2 File 类

File file = new File("test.txt");

file.exists()          // 文件是否存在
file.getName()         // "test.txt"
file.getAbsolutePath() // 绝对路径
file.length()          // 文件大小(字节)
file.isFile()          // 是文件还是目录
file.isDirectory()

file.createNewFile();   // 创建文件
file.delete();          // 删除
file.mkdir();           // 创建单层目录
file.mkdirs();          // 创建多层目录

2.3 文件读写(字符流)

// 写文件
try (FileWriter writer = new FileWriter("output.txt")) {
    writer.write("Hello, Java IO!");
}

// 读文件(逐字符)
try (FileReader reader = new FileReader("output.txt")) {
    int ch;
    while ((ch = reader.read()) != -1) {
        System.out.print((char) ch);
    }
}

2.4 缓冲流:提高效率

// 不加缓冲:每读一个字符就访问一次磁盘 → 极慢
// 加缓冲:一次读一大块到内存缓冲区 → 快得多

try (BufferedReader reader = new BufferedReader(new FileReader("large.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {   // 逐行读
        System.out.println(line);
    }
}

BufferedReaderBufferedWriter 就是给普通 Reader/Writer 套了一层缓冲区,减少磁盘访问次数。


3. 多线程

3.1 为什么需要多线程

单线程程序同一时间只能做一件事。如果主线程在等待网络响应(500ms),整个程序就卡住 500ms。

多线程让程序"同时"做多件事——一个线程等网络,另一个线程继续响应用户操作。这就是为什么你的手机不会因为下载文件而卡死。

3.2 创建线程的方式

方式一:继承 Thread

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("新线程运行中: " + Thread.currentThread().getName());
    }
}

MyThread t = new MyThread();
t.start();                           // 启动线程(JVM 会调用 run())
// t.run(); 错误!直接调用 run() 只是普通方法调用,不会启动新线程

方式二:实现 Runnable(推荐)

class MyTask implements Runnable {
    @Override
    public void run() {
        System.out.println("任务执行中...");
    }
}

Thread t = new Thread(new MyTask());
t.start();

用 Runnable 而不是 Thread 的原因:Java 单继承——继承 Thread 就不能再继承别的类。实现 Runnable 更灵活,还能把任务提交给线程池。

3.3 start() vs run()

t.start();  // 启动一个新线程,在新线程中执行 run()
t.run();    // 在当前线程中调用 run(),没有新线程!

这是初学者最常犯的错误。start() 是新线程并行执行;run() 是同步调用。

3.4 线程常用方法

Thread.sleep(1000);        // 让当前线程休眠 1 秒(可能抛 InterruptedException)
t.join();                  // 等待线程 t 执行完毕
Thread.currentThread()     // 获取当前线程对象
t.setPriority(Thread.MAX_PRIORITY); // 设置优先级(1~10,只是建议,不保证)

3.5 线程安全问题

两个线程同时操作同一个变量时,结果不可预测:

class Counter {
    private int count = 0;

    public void increment() {
        count++;    // 这行代码实际分三步:读 → 加 1 → 写回
    }               // 线程 A 读完后线程 B 插进来改 → A 的写入覆盖了 B 的结果
}

// 两个线程各执行 10000 次 increment,最终 count 可能小于 20000

用 synchronized 加锁

class Counter {
    private int count = 0;

    public synchronized void increment() {   // 同一时刻只能一个线程进入
        count++;
    }
}

synchronized 保证了原子性——方法内的操作作为一个整体,不会被其他线程打断。

3.6 线程池(ExecutorService)

每次都 new Thread().start() 的问题:创建/销毁线程开销大、线程数不可控(来 10000 个请求就建 10000 个线程 → 系统崩)。

线程池 = 固定数量的"常驻"线程,任务来了排队等

ExecutorService pool = Executors.newFixedThreadPool(3);  // 创建 3 个常驻线程

for (int i = 0; i < 10; i++) {
    final int taskId = i;
    pool.submit(() -> {
        System.out.println("任务 " + taskId + " 由 " + Thread.currentThread().getName() + " 执行");
        Thread.sleep(1000);
        return null;
    });
}

pool.shutdown();    // 不再接收新任务,已有任务执行完就关闭

10 个任务,3 个线程,每个线程排队轮流执行——线程数可控,不会无限膨胀。


4. Lambda 表达式

4.1 Lambda 解决什么问题

回顾匿名内部类的痛点——为了实现一个简单逻辑,被迫写一堆样板代码:

// 匿名内部类:就为了排序逻辑,写了 5 行
list.sort(new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.compareTo(b);
    }
});

// Lambda:同样的事,1 行
list.sort((a, b) -> a.compareTo(b));

Lambda 就是"把方法当参数传"——让代码更简洁,只保留核心逻辑,去掉样板。

4.2 语法

(参数) -> { 方法体 }

演变过程:

// 完整版
(String a, String b) -> { return a.compareTo(b); }

// 去掉参数类型(编译器能推断)
(a, b) -> { return a.compareTo(b); }

// 方法体只有一行时,去掉 {} 和 return
(a, b) -> a.compareTo(b);

// 如果只有一个参数,连 () 都可以省略
name -> System.out.println(name);

// 无参数
() -> System.out.println("Hello");

4.3 Lambda 的条件

Lambda 只能用在函数式接口上—— 有且仅有一个抽象方法的接口:

@FunctionalInterface             // 注解:编译器检查是否只有一个抽象方法
interface Calculator {
    int calc(int a, int b);      // 只有一个抽象方法 → 可以用 Lambda
}

Calculator add = (a, b) -> a + b;
Calculator mul = (a, b) -> a * b;
System.out.println(add.calc(3, 5));   // 8
System.out.println(mul.calc(3, 5));   // 15

JDK 内置的常见函数式接口:RunnableComparatorActionListener。这些都是单抽象方法接口,都可以用 Lambda 简化。


5. Stream API

5.1 为什么需要 Stream

对集合做"过滤 + 转换 + 汇总"这种操作,传统的 for 循环又长又啰嗦:

// 任务:从成绩表中筛选出及格(≥60)的学生姓名,按字母排序
List<Student> students = ...;

// 传统方式:6 步,中间变量一堆
List<String> passed = new ArrayList<>();
for (Student s : students) {
    if (s.getScore() >= 60) {
        passed.add(s.getName());
    }
}
Collections.sort(passed);

// Stream 方式:一条流水线
List<String> passed = students.stream()
    .filter(s -> s.getScore() >= 60)      // 筛选
    .map(Student::getName)                // 提取姓名
    .sorted()                             // 排序
    .collect(Collectors.toList());        // 收集结果

Stream 把"对集合的多次操作"变成一条流水线——数据从上游流到下游,每经过一个操作就变换一次。

5.2 三种操作

数据源  ──▶  中间操作(懒执行)  ──▶ 终端操作(触发执行)  ──▶  结果
.stream()   filter/map/sorted...       collect/count/forEach...

中间操作不触发计算——它们只是"记下要做什么"。只有终端操作出现时,数据才真正开始流动。这叫惰性求值,避免中间结果的浪费。

5.3 常用操作速查

List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5, 6);

// 中间操作
nums.stream()
    .filter(n -> n % 2 == 0)          // 过滤出偶数 → [2, 4, 6]
    .map(n -> n * 10)                  // 每个×10    → [20, 40, 60]
    .sorted((a, b) -> b - a)           // 降序排列    → [60, 40, 20]
    .limit(2)                          // 只要前2个   → [60, 40]
    .forEach(System.out::println);     // 终端操作:打印每个元素
操作 类型 作用 示例
filter 中间 保留满足条件的 .filter(n -> n > 0)
map 中间 转换每个元素 .map(s -> s.length())
sorted 中间 排序 .sorted()
distinct 中间 去重 .distinct()
limit 中间 截断前 N 个 .limit(10)
collect 终端 收集为集合 .collect(Collectors.toList())
count 终端 计数 .count()
forEach 终端 遍历每个元素 .forEach(System.out::println)

6. 注解

6.1 什么是注解

注解 = 给代码贴标签。它本身不改变程序逻辑,但编译器和框架会读取这些标签做额外处理。

@Override           // 告诉编译器:我在重写父类方法,请帮我检查
@Deprecated         // 标记这个方法已过时,调用会出编译警告
@FunctionalInterface // 标记这是函数式接口,有多个抽象方法就报错

6.2 自定义注解

// 定义一个注解
@Retention(RetentionPolicy.RUNTIME)  // 保留到运行时(否则编译后就丢了) 
@Target(ElementType.METHOD)          // 只能用在方法上
@interface MyTest {
    String value() default "";       // 注解属性,default 表示可选
}

// 使用
@MyTest("这是一个测试方法")
public void testMethod() { ... }

为什么需要注解:注解提供了"在代码旁边放元数据"的能力。没有注解,JUnit 不知道哪些方法是测试;没有 @Override,你以为重写了其实没重写(方法签名不一样),编译器也不告诉你。注解把隐式的约定变成了显式的声明。


7. 反射

7.1 什么是反射

正常情况下,你写 obj.getName()——你知道类型是什么,编译器也知道。反射是反过来:运行时拿到一个未知的对象,动态查看它的类结构,动态调用它的方法

// 正常方式:编译时就知道类型
Student s = new Student();
s.study();          // 编译器知道 Student 有 study 方法

// 反射方式:运行时才知道类型
Object obj = ...;                     // 不知道具体是什么类
Class<?> clazz = obj.getClass();       // 运行时获取类的信息
Method method = clazz.getMethod("study");  // 按名字获取方法
method.invoke(obj);                    // 动态调用

7.2 基本用法

Class<?> clazz = Student.class;        // 方式 1:类名.class
Class<?> clazz2 = obj.getClass();      // 方式 2:对象.getClass()
Class<?> clazz3 = Class.forName("com.example.Student"); // 方式 3:全限定名

// 获取类信息
clazz.getName();                        // 类全名
clazz.getSimpleName();                  // 类简称
clazz.getDeclaredFields();              // 所有属性
clazz.getDeclaredMethods();             // 所有方法
// 动态创建对象
Object obj = clazz.getDeclaredConstructor().newInstance();

// 动态调用方法
Method method = clazz.getMethod("setName", String.class);
method.invoke(obj, "Zhang San");

// 动态访问私有字段(连 private 都拦不住)
Field field = clazz.getDeclaredField("name");
field.setAccessible(true);             // 暴力破解 private
String name = (String) field.get(obj);

7.3 为什么需要反射

你写的代码能调用你写的类,这不需要反射。但框架不知道你会写什么类

  • JUnit 不知道你写了哪些测试方法 —— 靠反射扫描所有 @Test 方法
  • Spring 不知道你写了哪些 Bean —— 靠反射扫描 @Component 并创建实例
  • Gson 不知道你的 User 类有什么字段 —— 靠反射把 JSON 字段映射到 Java 对象
  • Android 你在 XML 里写的 android:onClick="doSomething" —— 运行时靠反射找到 doSomething 方法

反射是框架的基石。没有反射,框架就需要你在配置文件中手动列出每个类——那是 Java 远古时代的做法。

7.4 反射的代价

  • 性能低:反射调用比直接调用慢几十倍(需要安全检查、包装参数)
  • 破坏封装setAccessible(true) 可以绕过 private
  • 编译期安全丢失:方法名拼错了,编译期不报错,运行时才崩

使用原则:业务代码不要用反射。写框架/库/工具类时才用它。日常开发中你更多的是"受益于反射"而不是"使用反射"。


关键记忆点

异常

  1. try-catch 把正常逻辑和错误处理分开;finally 一定会执行(除了 System.exit)
  2. 编译期异常(IOException)必须处理,运行期异常(NPE)不强制
  3. try-with-resources 自动关流,替代 finally 中手动 close

IO 流

  1. 字节流(InputStream/OutputStream)处理二进制,字符流(Reader/Writer)处理文本
  2. BufferedReader/Writer 是缓冲包装——大幅减少磁盘 IO 次数

多线程

  1. 创建线程用 Runnable 而非 Thread——灵活、可复用、能被线程池管理
  2. start() 启动新线程,run() 只是同步调用——不要搞混
  3. 多线程操作共享变量 → 竞态条件 → synchronized 保证原子性
  4. 线程池避免频繁创建销毁线程,控制并发数——Executors.newFixedThreadPool(n)

Lambda & Stream

  1. Lambda 只能在函数式接口(单抽象方法)上使用,本质是匿名内部类的语法糖
  2. Stream 中间操作是懒执行的,终端操作才触发计算

注解 & 反射

  1. 注解 = 给代码贴标签;反射 = 运行时读取这些标签并动态执行
  2. 反射是框架的基石(JUnit、Spring、Gson 都靠它),业务代码不要直接用