【JAVA】Final 点睛之笔

这个 BNBU 大一的 OOP Final 到底会考啥?

我做了一个课件的 review 进行了一些知识点的整理

# 章节 核心考点
1 Intro to OOP 类 vs 对象、class diagram、JVM/字节码、JDK/JRE
2 Java Essentials 基本类型、运算符、控制流、String、方法、Scanner
3 Creating Java Classes (+Cont) 字段/方法、构造器、this、封装、static、重载
4 Inheritance (+Cont) extendssuper、覆盖、多态、Objectfinal
5 Abstract & Interfaces 抽象类、interface、抽象 vs 接口的取舍
6 Arrays & Generics 数组、二维数组、泛型类/方法、ArrayList
7 Exception Handling try/catch/finally、checked vs unchecked、throw/throws
8 IO(DB 部分跳过) 文件读写、流、Scanner/BufferedReader

接下来我只提一点易错点, 基本概念我不再提


有关 JDK JRE JVM

大体上来说是这样的 JDK = JRE + 编译器/工具(能开发) = JVM + 类库(能运行) + 编译器/工具(能开发) 所以说 JDK > JRE > JVM

为什么说 java 一次编译到处运行? 要搞清楚 java 的编译运行逻辑我们先看看 c 是怎么做的 在 c 中你的代码经过 C 编译器(如 gcc) 输出特定平台的机器码, 再由 CPU 进行执行

如果你是计算机小白, 我这里简单讲讲为什么我把特定平台的机器码给加粗. 机器码 是CPU 的指令集, 是能直接执行的二进制, 但是不同平台的 CPU 指令集是不一样的, 常见的平台比如x86-64、ARM64……

除了CPU 指令集不同(比如x86 的 mov/add 编码和 ARM 完全不是一回事), 还有系统调用约定、ABI(应用二进制接口)、链接的系统库都不同

这就是一份 .c 在 x86 Linux 上编译出的 a.out,拿到 ARM 的 Mac 上跑不起来的原因. 所以 C 想要在不同平台运行的办法就是在直接在不同平台重新编译

那 java 是怎么做到的呢? 便想起一个计算机科学中的至理名言 “没有什么是加一层中间层解决不了的” 我们耳熟能详的 Redis 是这样的, 其实 java 也是这么做的

你写好的 .java 文件通过 javac 的编译产出的是字节码(bytecode) 文件后缀 .class , 而这个却不是真正的二进制, 他还需要 JVM 的解释执行, 由 JVM 把他编译成机器码

流程是.java ->(javac 编译)-> .class 字节码 -> (不同平台各自的 JVM 解释执行) -> 机器码

哦, 那这有啥用 用处很明显, 你在自己的电脑上完成了编译之后便可以把字节码文件分给不同平台的其他用户, 由他们自己的 JVM 来完成编译. 不同平台的 JVM 对外都遵守同一套 JVM 规范,对内则各自知道怎么把字节码翻译成自己这台机器的指令。平台差异这件脏活,从你(应用开发者)手里,转移到了 JVM 的实现者(Oracle、OpenJDK 等)手里! 他们替你把 JVM 移植到了每个平台,你只管产出一份字节码就行。

字节码跨平台,正是因为 JVM 本身不跨平台

有关 JIT 事情似乎没有你想象的这么简单, 如果是 JVM 单纯的一条一条翻译字节码的话, 那运行起来未免也太慢, 有啥好方法进行优化呢? 字节码进了 JVM 之后,实际发生的是:

  1. 一开始解释执行:JVM 逐条读字节码、逐条翻译执行。启动快,但慢(每次都要翻译)。
  2. JIT 即时编译:JVM 在运行时统计哪些代码被反复执行(“热点 hot spot”——HotSpot 这个 JVM 名字就是这么来的),把这些热点字节码在运行时编译成本地机器码,之后直接跑机器码,不再解释
  3. 因为是运行时编译,JIT 甚至能利用运行时的真实信息(比如这个分支实际上从没走过、这个方法的参数实际上总是某类型)做出比 C 的静态编译更激进的优化。所以你会发现有些成熟的 Java 服务端程序,长时间运行后性能能逼近甚至局部超过 C

与 C 对比是:**启动有比较慢(刚启动时是解释模式),而且 JVM 本身要占内存。C 没有这个负担,启动即巅峰速度 _这正是 C 在嵌入式、命令行小工具、对启动延迟敏感的场景里仍然不可替代的原因


四种访问修饰符

修饰符 同类 同包 子类 其他
private
无修饰符(default)
protected
public

static表

# static(类的) 非 static(实例的)
归属 整个类,全类共享一份 每个对象各一份
调用 类名.方法(),无需对象 对象.方法()
例子 Math.sqrt()main person1.getName()
  • static 方法不能访问实例变量/实例方法(因为 static 方法可能在没有任何对象时就被调用,实例变量都还不存在)

来道小题目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Account {
private double balance;
private String owner;

public Account(String owner, double balance) { // (构造器A)
this.owner = owner;
this.balance = balance;
}

public void Account(String owner) { // (构造器B)
this.owner = owner;
}

public double getBalance() { return balance; }

public static void printBalance() { // 静态方法
System.out.println("Balance: " + balance);
}

public static void main(String[] args) {
Account a = new Account(); // (行1)
Account b = new Account("Bob", 500.0); // (行2)
System.out.println(b.balance); // (行3)
}
}

请找出4 处问题,每处说明:错在哪、为什么、怎么改(提示:注意构造器B、static 方法、行1、行3) 答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 构造器B `public void Account(String owner)`
因为它带了 `void`,它根本不是构造器,而是一个"碰巧叫 Account 的普通方法"。Java 里构造器的唯一标志就是"和类同名 + 没有任何返回类型"一旦写了 `void`,它就降级成普通方法了。所以这个类实际上只有一个构造器(构造器A)

2. static 方法访问实例变量
`printBalance()` 是 static,`balance` 是实例变量。static 方法可能在没有对象时被调用,`balance` 不存在,编译错误。改法:要么把 `balance` 改 static,要么 `printBalance` 改成非 static 实例方法

3. 行1 `new Account()`
因为已经定义了构造器A(带参),Java 不再提供无参默认构造器 → `new Account()` 找不到匹配 → 编译错误。改法:要么补一个 `public Account() {}`,要么调用时传参

易错:
行3 `b.balance` —— 这其实是合法的
- `private` 是"类级别"的访问控制,不是"对象级别"的
- 只要代码写在 Account 类内部,就能访问任何 Account 对象的 private 成员——包括别的对象 `b` 的 `b.balance`。
- 而 `main` 方法正好就在 Account 类里面, 所以 `b.balance` 完全可以访问
- 如果这行代码出现在另一个类(比如一个独立的 `Test` 类)里,那才会因为 private 报错


继承 Inheritance

class Student extends Person

  • 子类自动拥有父类所有非 private 的字段和方法
  • Java 不支持多继承, 多层继承可以
  • 子类不”继承”父类的 private 成员, 不能直接按名访问
# 覆盖 Override 重载 Overload
位置 子类 vs 父类 同一个类
签名 完全相同(名+参数) 同名、参数不同
关系 继承关系 同类

super 关键字(有两种用法)

  • super.方法() - 调用父类被覆盖的方法(如 getParentInfo() { return super.getInfo(); }
  • super(参数) - 调用父类的构造器

构造器调用链

  • 每个构造器都会自动先调用父类构造器。如果你没写 super(...),Java 自动加一个无参 super()
  • super(...) 如果显式写,必须是构造器的第一句
  • 注意点:如果父类定义了带参构造器(没有无参的),子类构造器又没有显式 super(参数) → Java 自动加的 super() 找不到父类无参构造器 → 编译错误
  • this(...) → 调用本类的另一个构造器,也必须是第一句, 所以 this(...) 和 super(...) 不能同时出现

小测验:

1
2
3
4
5
6
7
8
9
10
11
12
// 已知
class Animal {
private String name;
public Animal(String name) { this.name = name; }
public String getName() { return name; }
public String sound() { return "some sound"; }
}
class Dog extends Animal {
public Dog(String name) { super(name); }
@Override
public String sound() { return "Woof"; }
}

  • Q1 下面这个 Dog 构造器,如果改成 public Dog(String name) { }(去掉 super 调用),能编译吗?为什么?

  • Q2 Dog d = new Dog("Rex"); System.out.println(d.sound()); 输出什么?d.getName() 输出什么?

  • Q3 判断对错:sound() 在 Dog 里是重载(overload)还是覆盖(override)?说明依据。

  • Q4 判断对错并改正:有人在 Dog 里写 public String sound(String x) { return "Bark"; } 还加了 @Override。这能编译吗?

答案:

1
2
3
4
5
6
7
8
9
10
11
12
1. 父类只有带参 `Animal(String)`,没有无参构造器;子类去掉 `super(name)` 后 Java 自动插入 `super()` 找不到匹配会导致编译错误

2. `Woof`(覆盖后调子类版本)/ `Rex`(通过继承的 `getName()` 拿到父类 private 字段,`super(name)` 已经把它初始化好了)。

3. override,依据:子类与父类同名同参(`sound()` 无参),签名完全一致

易错点:
4. 它不能编译!

加了`@Override`就编译不过。原因:`@Override` 是在向编译器承诺"我这个方法覆盖了父类的某个方法"。编译器一检查——父类根本没有`sound(String)` 这个签名的方法可被覆盖导致编译报错

改正方法:去掉 `@Override` 就能编译,此时它是一个合法的重载方法及Dog 同时有 `sound()` 和 `sound(String)` 两个方法


抽象类 & 接口 Abstract & Interfaces

可参考文章[[【JAVA】抽象类与接口|【JAVA】抽象类与接口]] - 抽象方法:只有声明、没有方法体的方法:public abstract double area(); - 规则: - 一个类只要有一个抽象方法 → 这个类必须声明为 abstract - abstract 类不能 new 创建对象new Shape() ,因为缺代码)。 - 子类继承抽象方法后,全部覆盖则变成普通类,可实例化;没全覆盖则子类仍是 abstract - 抽象类仍可以有:构造器、实例变量、普通(具体)方法

接口 interface

  • 用 interface 声明;类用 implements 实现;实现类必须提供接口里所有(抽象)方法的代码
  • 不能 new 接口对象
  • 接口可以含常量public static final
  • 接口方法默认 public(不写编译器自动加)
  • Java 8+:接口可有 staticdefault 方法;Java 9+:可有 private 方法

抽象类 vs 接口对比表

特性 抽象类 接口
关键字 abstract class interface
一个类可有几个 单继承(extends 1个) 多实现(implements 多个)
实例变量 ✅ 可以(任意修饰符) ❌ 只能 public static final常量
构造器 ✅ 有 ❌ 没有
普通(具体)方法 ✅ default 方法(Java8+)
能否 new 对象
关系语义 “is-a”(是一个),共享代码 “can-do”(能做某事),契约

数组与泛型 Arrays&Generics

数组基础

1
2
3
int[] a;            // 声明(int[] a 或 int b[] 都行)
a = new int[3]; // 创建,长度固定,元素默认值 0
int[] c = {1,2,3}; // 字面量,一步到位
  • 长度固定,创建后不能改;a.length 是属性不是方法(String 的 .length() 才是方法—注意点!)
  • 索引 0 ~ length-1,越界抛 ArrayIndexOutOfBoundsException
  • 基本类型数组 new 完即可用(默认值);对象数组 new 完每个元素还是 null,必须再逐个 new

ArrayList 基础

1
2
3
4
import java.util.ArrayList;
ArrayList a = new ArrayList(); // 默认元素类型 Object
a.add(new Student("x")); // 自动 upcast 成 Object
Student s = (Student)a.get(i); // 取出要 downcast
  • 方法 add/get/size不能用 a[i]a.length
  • 初始 size 为 0,可动态增长
  • 装箱/拆箱a.add(5) 自动把 int 装箱成 Integer;取出 (int)a.get(i) 自动拆箱 注意一定要 downcast 才行

泛型基础(参数化多态)

1
2
3
ArrayList<Student> a = new ArrayList<Student>();
a.add(new Student("x"));
Student s = a.get(i); // 不用 downcast 了!

好处:省去 downcast、编译期就查类型错误、消除代码重复

泛型类定义:

1
2
3
4
5
6
public class Box<T> {
private T data;
public Box(T data){ this.data = data; }
public T getData(){ return data; }
}
Box<Integer> b = new Box<Integer>(1); // 用时才定 T

进阶:<K,V> 多参数、<T extends Animal> 有界类型

类型擦除

Java 泛型只在编译期存在;编译器查完类型后,会把所有 <T> 擦掉,T 替换成它的边界(无边界就是 Object),并自动插入向下转型。运行时的字节码里没有任何泛型信息

1
2
3
4
5
6
7
8
9
你写的源码                编译器擦除后(概念上)
───────────────── ─────────────────
class Box<T> class Box
T data; ───► Object data; // T → Object
T getData(){} Object getData(){}

Box<String> b; Box b;
String x = String x =
b.getData(); (String) b.getData(); // 编译器自动插入转型!

注意最后一行:你以为泛型帮你省了 downcast,其实是编译器偷偷帮你写了 downcast。只不过因为它在编译期已确认类型安全,这个转型保证不会失败

有边界时,擦成边界类型:

1
class Box<T extends Animal>   →   擦成 Animal(不是 Object)

泛型不构成重载:

1
2
void f(ArrayList<String> a) {}
void f(ArrayList<Integer> a) {} // 编译错误!

因为擦除后两个方法签名都变成 f(ArrayList a),撞名了

为什么 Java 要用”擦除”这种别扭的方式做泛型?  - 兼容 - 泛型是 Java 5(2004)才加的,在此之前几亿行代码都用 ArrayList(存 Object)擦除让”带泛型的新代码”编译后和”老的 Object 代码”字节码一致,新老代码能混用、老 JVM 也能跑

和 C#/C++ 的 “具体化泛型(reified)” 是不一样的 – C++ 里 vector<int> 和 vector<string> 运行时是真不同的类 ***

异常处理 Exception Handling

1.异常体系

# Unchecked(非受检) Checked(受检)
是谁 RuntimeException 及其子类 Exception 子类,但排除 RuntimeException 一支
含义 bug(除零、越界、空指针) 外部意外(文件没找到、网络断)
编译器 不强制处理 强制处理(要么 try-catch,要么 throws)
典型 ArithmeticException, IndexOutOfBounds, NumberFormatException, NullPointer IOException, FileNotFoundException

小提示:NumberFormatException 看着像”输入错误是外部问题”,但它继承自 IllegalArgumentException → RuntimeException,所以是 unchecked!这就是为什么 slide 里 Integer.parseInt 不强制你 try-catch。

2. try-catch-finally 规则

1
2
3
4
try { ... }
catch (具体异常1 e) { ... } // 0 个或多个
catch (具体异常2 e) { ... }
finally { ... } // 最多 1 个, 必须在最后
  • try 后面至少要跟一个 catch  finally(不能单单一个 try)
  • 异常一旦抛出,try 块里它后面的代码全部被跳过
  • catch 顺序:子类在前,父类在后(most specific 到 least specific)。如果反了会导致编译错误  , 比如 unreachable code(slide:catch(Exception) 放在 catch(ArithmeticException) 前面就报错)
  • finally 永远执行——即使 try/catch 里有 return 也照样先执行 finally

catch 顺序为什么必须”子在前父在后”? 

因为 catch 是从上往下找第一个匹配的(多态:父类型能接住子类对象)。如果 catch(Exception e) 在最前面,它能接住一切,后面任何具体 catch 都永远轮不到, 则编译器判定 unreachable 直接报错

finally 与 return : 如果 try 里 return a;,JVM 会先算好返回值、再去执行 finally、最后才真正返回。但如果 finally 里也有 return,它会覆盖掉前面的 - “finally 的 return/break 决定最终走向” ### 3. throw vs throws

# throw throws
位置 方法体内,语句 方法签名上,声明
作用 真的抛一个异常对象 声明本方法可能抛、我不处理,甩给调用者
语法 throw new XxxException("msg"); void f() throws XxxException {
个数 一次抛一个 可列多个,逗号分隔

4. 自定义异常(三步)

1
2
3
4
5
6
7
8
9
// 继承 Exception
public class NotPositiveException extends Exception {
public NotPositiveException(String msg) {
super(msg); // 把消息传给父类, 之后 getMessage() 能取出
}
}
// 抛
if (i <= 0) throw new NotPositiveException(i + " <= 0 !");
// 方法签名 throws + 调用处 try-catch

5. throws + 方法重写/继承(高频陷阱)

子类重写方法时,对 checked 异常 的 throws 只能收窄,不能放宽:

  • ✅ 不声明任何 checked 异常 — 可以
  • ✅ 声明父类异常的子类(更具体) — 可以(FileNotFoundException ⊂ IOException)
  • ❌ 声明更宽/不相关的 checked 异常 — 编译错误
  • ✅ unchecked 异常随便抛 — 不受此限制

输入/输出(IO)

1. 三个标准流

方向 代表
System.in 输入 键盘
System.out 输出 屏幕(正常信息)
System.err 输出 屏幕(错误信息,独立于 out,可能和 out 混在一起)

2. 键盘输入 —— Scanner

1
2
3
4
import java.util.Scanner;
Scanner s = new Scanner(System.in); // 包住 System.in
s.nextLine(); s.nextInt(); s.nextDouble(); s.nextBoolean();
s.hasNext(); // (Ctrl-D/Ctrl-Z 触发 EOF)EOF 时返回 false

输入类型不匹配(该给 int 却输入文字)→ 抛 InputMismatchException

3. 文件输出 —— PrintWriter

1
2
3
4
5
6
7
8
9
10
import java.io.PrintWriter;
import java.io.FileNotFoundException;
try {
PrintWriter out = new PrintWriter("mydata.txt"); // 不存在则创建; 已存在则【清空覆盖】!
out.println("hello");
out.printf("x=%-4d y=%10.4f%n", 2, 3.4);
out.close(); // 必须 close, 否则数据可能没真正写入磁盘
} catch (FileNotFoundException ex) {
System.err.println(ex.getMessage());
}

4. 文件输入 —— File + Scanner

1
2
3
4
5
import java.io.File;
File f = new File("mydata.txt");
Scanner s = new Scanner(f); // 注意: 用 File 对象, 不是字符串!
while (s.hasNext()) { ... s.nextLine() ... }
s.close();

文件不存在, 抛 FileNotFoundException(读时必须 try-catch)。

5. 对象的文本输出 —— toString()

直接 println(对象) 只会打印 Circle@70dea4e(类名@地址)。重写 toString() 后才能输出可读内容:

1
2
@Override
public String toString() { return "(Circle " + x + " " + y + " " + radius + ")"; }

toString 只解决”写”,不解决”读”——要把字符串还原成对象,得手动逐字段 next/nextInt/nextDouble 再调构造器,复杂时甚至要写 parser。

6. 对象序列化 —— Serializable

解决”对象存盘/读盘”的优雅方案:

1
2
3
4
5
6
7
8
9
10
11
public class Circle implements Serializable { ... }   // ① 只需实现这个空接口

// ② 写: 二进制文件
FileOutputStream fo = new FileOutputStream("mydata.bin");
ObjectOutputStream out = new ObjectOutputStream(fo);
out.writeObject(c1);
out.close(); fo.close();

// ③ 读: downcast 是必须的!
ObjectInputStream in = new ObjectInputStream(new FileInputStream("mydata.bin"));
Circle c2 = (Circle) in.readObject(); // readObject 返回 Object
  • 写存到二进制(binary)文件,不是文本, 用记事本打不开
  • readObject() 返回 Object,必须向下转型(又见 downcast!)
  • 要 catch 两个异常:IOException + ClassNotFoundException
  • 自动递归:对象内部引用的其它对象(如 Circle 里的 Point)、ArrayList 等,Java 会自动一并序列化

为什么序列化只要 implements 一个空接口就行?  - Serializable 是个 “标记接口(marker interface)” – 里面没有任何方法,纯粹给 JVM 一个信号:“这个类允许被转成字节流”。真正的读写逻辑由 ObjectOutputStream 用反射自动完成。这里的接口=许可标签