Java 万字长文:从零基础到高级应用的完整教程——把面向对象讲透

从一杯咖啡说起:Java 的诞生故事

1990 年的某个下午,Sun 公司的工程师 James Gosling 正在为一个大项目发愁。他的团队接到了一个棘手的任务------为下一代交互式电视机顶盒开发一套软件系统。这个项目代号叫"Green Project"(绿色项目),目标是让家电设备变得智能起来:电视、烤箱、遥控器,全都用一套软件串起来。

问题是,当时主流的 C++ 语言虽然强大,但太"锋利"了------指针、内存泄漏、多重继承,每一个都像厨房里没放好的菜刀,随时可能伤到使用者。C++ 给程序员自由的同时,也给了他们无数种犯错的方式。James Gosling 需要一种更安全、更简单、跨平台的语言。于是他在办公室里搓着手,开始设计一门新语言,给它起名叫 Oak(橡树)------因为他窗外正好能看到一棵。橡树象征着力量和坚韧,他希望这门语言也能如此。

但命运很快开了个玩笑:Oak 这个名字已经被另一家公司注册了商标。团队只好重新起名。一群工程师坐在一家咖啡店里喝着爪哇咖啡(Java coffee)------这是一种产自印度尼西亚爪哇岛的咖啡豆,浓烈而醇厚------有人灵机一动:"就叫 Java 怎么样?"大家觉得不错,名字简短有力,还带着咖啡的香气。于是 1995 年 5 月 23 日,Java 在 SunWorld 大会上正式对外发布。

Java 这个名字,从一开始就带着咖啡的香气。这也是为什么 Java 的标志是一杯冒着热气的咖啡,而不是一棵橡树。编程社区里,Java 程序员常常自嘲为"咖啡师"。

Java 之所以能在众多语言中脱颖而出,靠的不是某一个杀手锏功能,而是几个关键设计理念的组合:

• 跨平台 :同一份代码可以在不同操作系统上运行,这得益于 JVM 的"中间编译"策略

• 面向对象:以"对象"为核心组织代码,更贴近人类思维方式

• 自动内存管理 :垃圾回收器(GC)自动回收不再使用的内存,程序员不用手动管理。在 C/C++ 中,程序员需要手动 malloc 和 free 内存,一旦忘记释放就会导致内存泄漏;而在 Java 中,GC 会在后台自动追踪哪些对象不再被使用,然后回收它们占用的内存。这大大降低了内存管理的负担,也让程序员能更专注于业务逻辑本身。当然,GC 不是万能的------如果对象一直被引用着(比如放在静态集合里忘了清理),GC 也回收不了,这同样会导致内存泄漏。

• 安全性:没有指针,字节码校验器在执行前检查安全性

• 丰富的标准库:自带大量工具类,开箱即用

Java 的核心哲学可以用一句口号概括:

Write Once, Run Anywhere(一次编写,到处运行)

这得益于 JVM(Java 虚拟机)的架构设计。你写的 Java 代码不是直接编译成机器码,而是先编译成一种叫"字节码"的中间格式,然后由 JVM 来解释执行。这就好比你写了一份菜谱(字节码),不管你走进哪国的厨房(Windows、Linux、Mac 的 JVM),只要有这本菜谱,厨师就能做出同样的菜。

但这句口号也不是完美无缺的。实际开发中,"到处运行"的前提是目标机器上装了合适版本的 JVM。而且在某些平台特性(如文件路径、系统调用)上,仍然需要做适配。不过对于绝大多数应用场景来说,Java 的跨平台能力已经足够强大了。

JVM 架构:一杯咖啡的旅程 Hello.java 源代码文件 javac Hello.class 字节码文件 JVM(Java 虚拟机) 类加载器 字节码校验器 运行时数据区 方法区 堆 虚拟机栈 本地方法栈 程序计数器 解释器 JIT 编译器 Windows Linux macOS 同一份字节码,到处运行

上图展示了 Java 代码从源文件到运行的完整旅程。你的 .java 文件经过 javac 编译器变成 .class 字节码,然后交给 JVM 处理------类加载器把它加载进内存,字节码校验器做安全检查,最后由执行引擎(解释器 + JIT 编译器)来运行。JVM 屏蔽了底层操作系统的差异,这就是"一次编写,到处运行"的奥秘。

其中 JIT(Just-In-Time)编译器是一个精妙的设计:它在程序运行过程中,把频繁执行的热点代码编译成本地机器码,后续直接执行机器码而不再解释。这结合了解释器的启动速度和编译型语言的运行效率------就好比咖啡师刚开始按菜谱一步步做(解释执行),做多了之后手法烂熟于心,直接一气呵成(JIT 编译)。JIT 会通过"热点探测"机制找出被频繁调用的方法或循环体,把它们编译成本地代码并缓存起来。这意味着 Java 程序在运行一段时间后,性能会逐渐提升到接近 C/C++ 的水平------这就是所谓的"预热"过程。

好了,故事讲完了,让我们卷起袖子,开始写代码。


第一部分:入门基础------咖啡店的杯子

Java 本身就是"咖啡"的意思,那我们不妨把 Java 程序想象成一家咖啡店。在这家店里,你需要杯子来装咖啡,需要菜单来记录品类,需要收银台来处理订单------这些在 Java 里分别对应变量、数据类型和输入输出。

1.1 第一个 Java 程序:点亮招牌

每个咖啡店都有一块招牌,在 Java 世界里,招牌就是 main 方法。这是每个 Java 程序的起点,JVM 启动时会寻找这个方法并执行它:

csharp 复制代码
public class CoffeeShop {
    public static void main(String[] args) {
        System.out.println("欢迎来到 Java 咖啡店!");
        System.out.println("今天的第一杯咖啡,为你而准备。");
    }
}

来拆解一下这段代码中的每个关键词:

• public class CoffeeShop:声明一个公开的类,类名叫 CoffeeShop。Java 要求文件名必须和公开类名一致,所以这个文件必须叫 CoffeeShop.java。类名使用大驼峰命名法(每个单词首字母大写)。

• public static void main(String[] args):这是程序的入口方法,每个关键词都有含义:

• public 表示谁都能访问这个方法------就像大门敞开,JVM 可以直接调用它

• static 表示这是一个静态方法,不需要创建对象就能调用------你不需要先"开店"才能点亮招牌

• void 表示这个方法不返回任何值------它只负责执行,不生产产品

• String[] args 是命令行参数数组------可以从外部传入信息,就像顾客在门口的留言板写需求

• System.out.println():往标准输出(控制台)打印一行文字,打印完自动换行。就像在招牌上写一行字。

如果用 System.out.print() 而不是 println(),则不会换行。这个细节在格式化输出时很重要。

1.2 变量:咖啡店的杯子

咖啡店里,你得用不同的杯子来装不同的饮品------小杯装浓缩(Espresso),大杯装美式(Americano),超大杯装冰拿铁(Iced Latte)。你不能拿试吃杯装 500 毫升的咖啡,也不能拿汤碗装一口浓缩------大小要匹配。

Java 里的变量 就是这些杯子,而数据类型 就是杯子的规格。声明变量时必须指定类型,这就是 Java 被称为"强类型语言"的原因------编译器在编译阶段就会检查类型是否匹配,而不是等到运行时才发现问题。

Java 有 8 种基本数据类型(就像 8 种规格的杯子),它们不是对象,而是最原始的值类型:

| 类型 | 大小 | 范围 | 类比 |

|------|------|------|------|

| byte | 1 字节 | 到 | 小试吃杯 |

| short | 2 字节 | 到 | 小杯 |

| int | 4 字节 | 到 | 中杯 |

| long | 8 字节 | 到 | 大杯 |

| float | 4 字节 | 约 | 带刻度的小杯 |

| double | 8 字节 | 约 | 带刻度的大杯 |

| char | 2 字节 | 0 到 65535 | 装一个字的杯 |

| boolean | 1 位 | true 或 false | 开关杯 |

其中最常用的是 int(整数)和 double(小数),日常编程中 80% 以上的场景用这两个就够了。

csharp 复制代码
public class CoffeeCups {
    public static void main(String[] args) {
        // 整数类型
        int smallCoffee = 350;        // 中杯 350ml
        long largeCoffee = 700L;      // 大杯 700ml,注意加 L

        // 浮点类型
        double temperature = 85.5;    // 咖啡温度 85.5°C
        float price = 29.9f;          // 价格 29.9 元,注意加 f

        // 字符类型
        char grade = 'A';             // 评级 A

        // 布尔类型
        boolean isOpen = true;        // 咖啡店是否营业

        System.out.println("中杯容量:" + smallCoffee + "ml");
        System.out.println("大杯容量:" + largeCoffee + "ml");
        System.out.println("咖啡温度:" + temperature + "°C");
        System.out.println("价格:" + price + " 元");
        System.out.println("评级:" + grade);
        System.out.println("营业中?" + isOpen);
    }
}

这里有几个初学者容易踩的坑,务必注意:

• long 类型的字面量要以 L 结尾(大写推荐),否则编译器会把它当成 int。如果数值超过 int 范围还不加 L,会直接编译报错。

• float 类型的字面量要以 f 结尾,因为 Java 默认所有小数字面量都是 double 类型。写 float price = 29.9; 会导致编译错误------你在试图把一个 double 塞进 float 杯子里,可能会溢出。

• char 用单引号 且只能放一个字符,String(字符串)用双引号可以放任意长度------这是初学者最常混淆的。

• boolean 只有 true 和 false 两个值,不能写 0 和 1,也不能像 C 语言那样用整数代替布尔值。

1.3 类型转换:换杯子

有时候你需要把小杯的咖啡倒进大杯里------这叫**自动类型转换**(widening),从小类型到大类型,Java 自动帮你搞定,不会有任何数据丢失:

ini 复制代码
int intVal = 100;
double doubleVal = intVal;    // int -> double,自动转换
System.out.println(doubleVal); // 输出 100.0

但反过来,把大杯倒进小杯,可能会溢出------这叫强制类型转换(narrowing),你得明确告诉 Java"我知道可能会丢失数据":

ini 复制代码
double doubleVal = 3.99;
int intVal = (int) doubleVal;  // 强制转换,小数部分直接截断
System.out.println(intVal);     // 输出 3,不是 4!

就像你把大杯咖啡倒进小杯,溢出来的部分就没了。Java 不会四舍五入,而是直接截断小数部分。如果你需要四舍五入,应该用 Math.round() 方法。

1.4 输入与输出:接单和出单

咖啡店需要接顾客的订单(输入),也需要出杯并告知顾客(输出)。输出我们已经用过了------System.out.println()。输入需要用 Scanner 类:

csharp 复制代码
import java.util.Scanner;

public class OrderSystem {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.print("请输入您的名字:");
        String name = scanner.nextLine();

        System.out.print("请输入您要的咖啡杯数:");
        int cups = scanner.nextInt();

        System.out.print("请输入每杯价格:");
        double price = scanner.nextDouble();

        double total = cups * price;
        System.out.println("====================");
        System.out.println("顾客:" + name);
        System.out.println("杯数:" + cups);
        System.out.println("总价:" + total + " 元");
        System.out.println("====================");

        scanner.close();
    }
}

Scanner 就像一个点单机,你告诉它你要读什么类型的数据(nextInt()、nextDouble()、nextLine()),它就从键盘输入里帮你取出来。有个初学者常见的坑:在 nextInt() 之后直接调用 nextLine() 会读到空字符串,因为 nextInt() 读取整数后留下了换行符,nextLine() 会把这个换行符当作一行空字符串读掉。解决办法是在两者之间多调用一次 nextLine() 来消耗掉换行符。


第二部分:运算符与控制流------交通指挥

程序如果只会从头到尾顺序执行,那就像一辆只能直行的车------碰到路口就傻眼了。现实中,程序需要根据条件做判断、根据需要重复执行某些步骤。我们需要运算符 来做判断,需要控制流来指挥程序的行进方向,就像交警站在路口指挥交通。

2.1 运算符:路口的信号灯

算术运算符------最基本的加减乘除:

csharp 复制代码
int a = 10, b = 3;

System.out.println(a + b);   // 13,加法
System.out.println(a - b);   // 7,减法
System.out.println(a * b);   // 30,乘法
System.out.println(a / b);   // 3,整数除法,直接截断小数部分
System.out.println(a % b);   // 1,取余数(模运算)
System.out.println(a / (double)b); // 3.333...,转成浮点再除

// 自增自减
int count = 5;
System.out.println(count++); // 5,先输出再+1(后缀:先用后加)
System.out.println(++count); // 7,先+1再输出(前缀:先加后用)

注意 count++ 和 ++count 的区别:前者是"先用后加"(先出杯再涨价),后者是"先加后用"(先涨价再出杯)。在单独使用时两者效果相同,区别只在表达式中。

关系运算符 ------比较大小,结果是 boolean:

ini 复制代码
int speed = 60;
int limit = 80;

System.out.println(speed > limit);   // false
System.out.println(speed < limit);   // true
System.out.println(speed == limit);  // false
System.out.println(speed != limit);  // true
System.out.println(speed >= 60);     // true

注意:比较字符串是否相等不能用 ==,要用 .equals() 方法。== 比较的是引用地址(两个变量是否指向同一个对象),而不是内容。这是初学者最容易犯的错误之一:

ini 复制代码
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2);       // false!虽然内容一样,但是两个不同对象
System.out.println(s1.equals(s2));   // true!比较的是内容

逻辑运算符------组合多个条件:

ruby 复制代码
boolean redLight = true;
boolean emergency = false;

System.out.println(redLight && emergency); // false,两个都为 true 才 true
System.out.println(redLight || emergency);  // true,至少一个为 true 就 true
System.out.println(!redLight);              // false,取反

有个重要的特性叫**短路求值**:

ini 复制代码
int x = 0;
if (x == 0 || 10 / x > 1) {  // x==0 为 true,右边不执行,避免了除以零
    System.out.println("安全通过!");
}

|| 发现左边为 true 就直接返回 true,不再计算右边------这就是"短路"。就像交警看到救护车来了,不用再看红灯就直接放行。

2.2 条件语句:分岔路口

if-else------最基本的路口决策:

csharp 复制代码
int score = 85;

if (score >= 90) {
    System.out.println("优秀!奖励一杯免费拿铁");
} else if (score >= 80) {
    System.out.println("良好!打八折");
} else if (score >= 60) {
    System.out.println("及格,正常价");
} else {
    System.out.println("不及格,去刷杯子吧");
}
// 输出:良好!打八折

switch------多路分岔,适合"一个值对应多种情况":

csharp 复制代码
int dayOfWeek = 3;

switch (dayOfWeek) {
    case 1: System.out.println("星期一:美式咖啡日"); break;
    case 2: System.out.println("星期二:拿铁日"); break;
    case 3: System.out.println("星期三:卡布奇诺日"); break;
    case 4: System.out.println("星期四:摩卡日"); break;
    case 5: System.out.println("星期五:焦糖玛奇朵日"); break;
    default: System.out.println("周末:想喝什么喝什么"); break;
}

break 很重要!不写 break 的话,程序会"穿透"到下一个 case 继续执行------这叫 fall-through。

Java 14+ 引入了增强版 switch 表达式,用箭头语法,不需要写 break,还能直接返回值:

arduino 复制代码
String coffee = switch (dayOfWeek) {
    case 1 -> "美式";
    case 2 -> "拿铁";
    case 3 -> "卡布奇诺";
    default -> "随便";
};

2.3 循环:绕环岛

for 循环------知道要跑几圈:

csharp 复制代码
for (int i = 1; i <= 5; i++) {
    System.out.println("第 " + i + " 杯咖啡冲泡完成!");
}

while 循环------只要条件满足就继续:

csharp 复制代码
int coffeeLeft = 3;
while (coffeeLeft > 0) {
    System.out.println("倒了一杯,还剩 " + (coffeeLeft - 1) + " 杯");
    coffeeLeft--;
}

do-while 循环------先做一次再说(至少执行一次):

ini 复制代码
int satisfaction;
do {
    System.out.println("递上一杯试饮...");
    satisfaction = 80;
} while (satisfaction < 60);

三种循环的流量控制 for 循环 初始化 i=0 i < n? 循环体 i++ while 循环 条件为真? 循环体 否 -> 退出 do-while 循环 循环体(先执行一次) 条件为真? 否 -> 退出

还有两个循环控制关键字------break(跳出整个循环)和 continue(跳过本次,继续下一次):

ini 复制代码
for (int i = 1; i <= 10; i++) {
    if (i == 5) break;     // 到第5杯就停了
    System.out.print(i + " ");  // 输出:1 2 3 4
}

for (int i = 1; i <= 10; i++) {
    if (i % 3 == 0) continue;  // 跳过3的倍数
    System.out.print(i + " ");  // 输出:1 2 4 5 7 8 10
}

第三部分:数组与字符串------火车车厢

3.1 数组:一列火车

想象一列火车,每节车厢都有编号(从 0 开始),每节车厢里装着同样类型的货物。Java 里的数组就是这列火车------它是存储同类型数据的固定长度容器。

ini 复制代码
public class TrainArray {
    public static void main(String[] args) {
        // 声明并初始化一列有5节车厢的火车
        int[] carriages = new int[5];
        carriages[0] = 10;
        carriages[1] = 20;
        carriages[2] = 30;
        carriages[3] = 40;
        carriages[4] = 50;

        // 也可以在声明时直接赋值
        int[] express = {100, 200, 300, 400, 500};

        // 遍历------从车头走到车尾
        for (int i = 0; i < carriages.length; i++) {
            System.out.println("第" + i + "节车厢:" + carriages[i] + "吨");
        }

        // 增强for循环
        for (int cargo : express) {
            System.out.println("载货:" + cargo + "吨");
        }
    }
}

几个关键点:数组长度一旦确定就不能改变 ------就像火车编组完成后不能中途加车厢。数组下标从 0 开始,到 length - 1 结束,访问越界会抛出 ArrayIndexOutOfBoundsException。

3.2 二维数组:多列火车

ini 复制代码
// 3行4列的二维数组------3列火车,每列4节车厢
int[][] fleet = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

for (int i = 0; i < fleet.length; i++) {
    for (int j = 0; j < fleet[i].length; j++) {
        System.out.printf("%3d", fleet[i][j]);
    }
    System.out.println();
}

3.3 字符串深入:不可变的列车

Java 的 String 底层是一个 final char 数组(Java 9 之后是 byte 数组加编码标记),但它有自己独特的行为。最需要注意的一点是:String 是不可变的(immutable)------一旦创建,内容就不能修改。任何看起来在"修改"字符串的操作,实际上都是创建了一个新的 String 对象。

ini 复制代码
String s = "Java";
s = s + " is fun";  // 看起来在修改 s,实际上创建了一个新对象

当你需要频繁拼接字符串时,用 StringBuilder 更高效:

ini 复制代码
// 低效方式:每次拼接都创建新对象
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;  // 每次循环都创建新 String
}

// 高效方式:StringBuilder 就像可扩展的列车
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);  // 在原有基础上追加
}
String result2 = sb.toString();

如果你在循环里拼接字符串,一定要用 StringBuilder。用 + 拼接 1000 次和用 StringBuilder 的性能差距可能达到 100 倍以上。

常用字符串方法:

less 复制代码
String str = "Hello, Java!";
System.out.println(str.length());           // 12
System.out.println(str.charAt(0));          // H
System.out.println(str.substring(7));       // Java!
System.out.println(str.substring(7, 11));   // Java
System.out.println(str.indexOf("Java"));    // 7
System.out.println(str.replace("Java", "World")); // Hello, World!
System.out.println(str.split(", ")[0]);     // Hello
System.out.println("  trim me  ".trim());   // trim me
System.out.println(str.equals("hello, java!"));           // false
System.out.println(str.equalsIgnoreCase("hello, java!"));  // true

数组 vs String:内存中的"火车" int\[\] arr = {10, 20, 30, 40, 50} 数组:连续内存空间,可通过下标直接访问 10 0 20 1 30 2 40 3 50 4 arr.length = 5,下标范围 0, 4 String s = "Java" String 底层是 char 数组,但不可变(final) J a v a -> 不可修改!s = s + "!" 会创建新对象


第四部分:方法------咖啡师的工作手册

在咖啡店里,咖啡师不需要每次做拿铁都从头思考步骤------他们有一本工作手册 ,上面写着每种咖啡的标准做法。Java 里的方法就是这本工作手册上的一条条操作指南:把一段可复用的逻辑封装起来,起个名字,需要时直接调用。

4.1 定义和调用方法

typescript 复制代码
public class BaristaManual {
    static String makeCoffee(String type, int sugar) {
        String coffee = type + "咖啡,加" + sugar + "块糖";
        return coffee;
    }

    static void serve(String coffee) {
        System.out.println("端上一杯:" + coffee);
    }

    public static void main(String[] args) {
        String myCoffee = makeCoffee("拿铁", 2);
        serve(myCoffee);
        // 端上一杯:拿铁咖啡,加2块糖
    }
}

方法的结构:返回类型、方法名、参数列表、方法体、return 语句。void 表示不返回任何东西。

4.2 方法重载:一本手册多个版本

咖啡师手册上可以有多个"制作咖啡"的条目------参数不同,这叫方法重载(Overloading):

typescript 复制代码
static String makeCoffee(String type) {
    return type + "咖啡,不加糖";
}

static String makeCoffee(String type, int sugar) {
    return type + "咖啡,加" + sugar + "块糖";
}

static String makeCoffee(String type, int sugar, boolean milk) {
    String milkStr = milk ? "加奶" : "不加奶";
    return type + "咖啡,加" + sugar + "块糖," + milkStr;
}

重载的规则:方法名相同,但参数列表不同(参数个数、类型或顺序不同)。返回类型不同不算重载。

4.3 递归:工作手册引用自己

最经典的例子是计算阶乘:
n!=n×(n−1)!其中0!=1 n! = n \times (n-1)! \quad \text{其中} \quad 0! = 1 n!=n×(n−1)!其中0!=1

arduino 复制代码
static long factorial(int n) {
    if (n <= 1) return 1;       // 基准情况
    return n * factorial(n - 1); // 递归调用
}
// factorial(5) = 120

递归就像俄罗斯套娃------打开一层里面还有一层,直到最里面那个最小的娃娃才停下来。

递归必须有一个终止条件 ,否则会导致 StackOverflowError(栈溢出)。

4.4 可变参数

arduino 复制代码
static int totalPrice(int... prices) {
    int sum = 0;
    for (int p : prices) sum += p;
    return sum;
}
// totalPrice(25) = 25, totalPrice(25, 30, 35) = 90

int... prices 在方法内部就是一个 int[] 数组。可变参数必须是参数列表的最后一个参数,一个方法最多只能有一个可变参数。

4.5 方法调用的内存机制

理解方法调用时内存发生了什么,有助于你写出更高效的代码。每次调用一个方法,JVM 会在"栈内存"中开辟一块空间叫栈帧 (Stack Frame),里面存放方法的局部变量、参数和返回地址。方法执行完毕后,栈帧被销毁,内存自动回收。

java 复制代码
static int add(int a, int b) {
    int result = a + b;
    return result;
}

static int compute(int x, int y) {
    int sum = add(x, y);  // 调用 add 时,栈上多了一层栈帧
    int product = x * y;
    return sum + product;
}

// 调用 compute(3, 4) 时的栈变化:
// 栈帧1: compute -> x=3, y=4, sum=?, product=?
//   栈帧2: add -> a=3, b=4, result=7  (add 返回后栈帧2被销毁)
// 栈帧1: compute -> sum=7, product=12, 返回 19

这就是为什么递归深度太大会导致 StackOverflowError------每层递归都会在栈上开辟一个栈帧,栈的容量是有限的(通常几百 KB 到几 MB),层数太多就会把栈撑爆。

方法调用与栈帧 JVM 栈内存 栈帧1:compute(x=3, y=4) 局部变量 sum=7, product=12 栈帧2:add(a=3, b=4) result=7 -> 返回后栈帧销毁 调用时压入 返回后弹出 main 方法的栈帧 程序入口,最底层栈帧


第五部分:面向对象编程------建筑设计师的蓝图

这是 Java 的核心 。前面四部分都是在"写脚本"------一步步告诉计算机做什么,这叫面向过程编程。从现在开始,我们要像建筑设计师一样思考了------先设计蓝图(类),再按图纸施工(创建对象),通过蓝图之间的关系(继承、多态)来组织复杂的系统。

面向对象编程(OOP)的核心思想是:把现实世界中的事物抽象为"对象",每个对象有自己的属性(状态)和行为(方法),对象之间通过消息传递来协作。这种思维方式更接近人类认知世界的方式------我们看到一辆车,自然知道它有颜色、品牌、速度(属性),可以启动、刹车、转弯(方法)。

5.1 类和对象:蓝图与建筑

想象你是一个建筑设计师。你画了一张住宅楼的设计图(图纸上标着几层、几户、有没有电梯),这张设计图就是类 (Class)。然后施工队按照你的图纸盖了一栋真实的楼,这栋楼就是对象(Object)。

一张图纸可以盖很多栋楼------一个类可以创建很多个对象。每栋楼的住户不同、装修不同,但结构都是按同一张图纸来的。图纸本身不是楼,你不能住进图纸里;同样,类本身不是对象,你必须先创建对象才能使用。

csharp 复制代码
// 这就是"设计图纸"------类的定义
public class CoffeeShop {
    // 属性------楼里有什么
    String name;
    String address;
    int tables;
    boolean isOpen;

    // 构造方法------盖楼时的"初始配置"
    public CoffeeShop(String name, String address, int tables) {
        this.name = name;
        this.address = address;
        this.tables = tables;
        this.isOpen = false;
    }

    // 方法------楼里能做什么
    public void openShop() {
        isOpen = true;
        System.out.println(name + " 开门营业了!欢迎光临!");
    }

    public void closeShop() {
        isOpen = false;
        System.out.println(name + " 打烊了,明天见。");
    }

    public void describe() {
        System.out.println("店名:" + name);
        System.out.println("地址:" + address);
        System.out.println("桌数:" + tables);
        System.out.println("状态:" + (isOpen ? "营业中" : "已打烊"));
    }
}
// 按照图纸"盖楼"------创建对象
CoffeeShop shop1 = new CoffeeShop("晨光咖啡", "南京路100号", 10);
CoffeeShop shop2 = new CoffeeShop("夜猫咖啡", "淮海路200号", 6);

shop1.describe();
shop1.openShop();
shop1.describe();
// 晨光咖啡 开门营业了!欢迎光临!

来深入理解几个关键概念:

• this 关键字 :指"当前对象自己"。当构造方法的参数名和类的属性名相同时,this.name = name 表示"把传进来的参数赋给当前对象的属性"。如果不加 this,编译器会认为两个 name 都指参数,等于自己赋值给自己。

• 构造方法 :名字和类名完全相同,没有返回类型(连 void 都不能写),在 new 的时候自动调用。如果你不写构造方法,Java 会给你一个默认的无参构造方法;但一旦你写了有参构造方法,默认的就没了。

• new 关键字 :在堆内存中分配空间,创建一个新对象,然后调用构造方法初始化它。对象存在堆内存中,而变量(如 shop1)存储的是对象的引用(地址),存在栈内存中。

• 引用 vs 对象 :CoffeeShop shop1 = new CoffeeShop(...) 做了两件事------右边创建对象,左边声明引用变量指向这个对象。可以把引用想象成遥控器,对象想象成电视机------一个遥控器控制一台电视,但多台电视可以共用同型号的遥控器。

类与对象:从蓝图到建筑 类(Class)= 设计图纸 class CoffeeShop 属性 String name String address int tables boolean isOpen 方法 openShop() closeShop() describe() CoffeeShop(...) 构造方法 new 对象1:shop1 name = "晨光咖啡" address = "南京路100号" tables = 10 isOpen = true 对象2:shop2 name = "夜猫咖啡" address = "淮海路200号" tables = 6 isOpen = false 一个类 -> 多个对象,各持独立状态

5.2 封装:给建筑装上门锁

想象你设计了一栋写字楼。大厅里的沙发谁都能坐(公开属性),但财务室的保险柜必须上锁(私有属性),只有持授权卡的人才能进入(通过公开方法访问)。你不能让任何人直接打开保险柜拿钱,但可以设立一个前台,由前台代为处理存取款的请求,前台会核实身份、检查金额是否合理。

这就是封装 ------把数据藏在内部,只通过受控的接口让外部访问。封装是面向对象的第一大特性,它的核心目标是保护数据完整性。

csharp 复制代码
public class BankAccount {
    // 私有属性------上了锁的保险柜
    private String owner;
    private double balance;

    public BankAccount(String owner, double initialBalance) {
        this.owner = owner;
        if (initialBalance >= 0) {
            this.balance = initialBalance;
        } else {
            this.balance = 0;
            System.out.println("初始余额不能为负,已设为0");
        }
    }

    // 公开方法------持授权卡才能操作
    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            System.out.println(owner + " 存入 " + amount + ",余额 " + balance);
        } else {
            System.out.println("存入金额必须大于0!");
        }
    }

    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            System.out.println(owner + " 取出 " + amount + ",余额 " + balance);
        } else if (amount > balance) {
            System.out.println("余额不足!当前余额:" + balance);
        } else {
            System.out.println("取款金额必须大于0!");
        }
    }
}
BankAccount account = new BankAccount("张三", 1000);
account.deposit(500);    // 张三 存入 500.0,余额 1500.0
account.withdraw(200);   // 张三 取出 200.0,余额 1300.0
account.withdraw(2000);  // 余额不足!当前余额:1300.0
// account.balance = 999999;  // 编译错误!balance 是 private 的

如果我们把 balance 设为 public,那任何人都可以直接写 account.balance = -999 或者 account.balance = 999999,数据就完全失去了控制。而通过封装,所有的修改都必须经过 deposit() 和 withdraw() 方法,我们可以在这些方法里加入各种校验逻辑。

封装的三大好处:

  1. 数据保护:外部不能直接修改内部数据,只能通过你提供的方法来操作

  2. 逻辑校验:在方法里可以加入验证逻辑,防止非法数据进入

  3. 实现灵活性:内部实现可以随时改变,只要对外接口不变,调用方的代码就不用改

举一个实际例子说明第三点的好处:假设你的 BankAccount 类一开始用 double 存余额,后来发现 double 有精度问题(浮点数不能精确表示某些小数),你想改用 BigDecimal。如果没有封装,所有直接访问 balance 的地方都要改;有了封装,你只需要修改 getBalance()、deposit()、withdraw() 的内部实现,调用方完全无感知。这就是封装带来的"实现可变、接口不变"的灵活性。

Java 社区的惯例:属性用 private,访问属性通过 getXxx() 和 setXxx() 方法。这些方法叫 getter 和 setter。不过并非所有属性都需要 setter------如果某个属性只能在构造时设置、之后不可修改,那就只提供 getter 不提供 setter,实现"只读"效果。这在设计不可变对象(Immutable Object)时特别有用。

5.3 继承:站在巨人的肩膀上

建筑设计师很少从零开始画图。如果你已经设计了一栋"住宅楼"的图纸,现在要设计"公寓楼",你不需要重新画一遍------你只需要在住宅楼的基础上加几层、改个外观就行了。已有的住宅楼图纸就是"父类",新的公寓楼图纸就是"子类"。

这就是继承 (Inheritance):子类继承父类,自动获得父类的所有非私有属性和方法,然后可以在此基础上扩展新功能或修改已有行为。继承解决的是代码复用问题。

csharp 复制代码
// 父类:基础咖啡
class Coffee {
    String name;
    int size;

    public Coffee(String name, int size) {
        this.name = name;
        this.size = size;
    }

    public void brew() {
        System.out.println("冲泡一杯 " + size + "ml 的" + name);
    }

    public void serve() {
        System.out.println("端上 " + name);
    }
}

// 子类:拿铁,继承自 Coffee
class Latte extends Coffee {
    int milkShots;  // 拿铁特有的属性

    public Latte(int size, int milkShots) {
        super("拿铁", size);  // 调用父类构造方法,必须放在第一行
        this.milkShots = milkShots;
    }

    @Override
    public void brew() {
        System.out.println("冲泡 " + size + "ml 拿铁:先加" + milkShots + "份浓缩,再倒入蒸奶...");
    }

    public void addLatteArt() {
        System.out.println("在拿铁上画了一个爱心拉花!");
    }
}

// 另一个子类:浓缩咖啡
class Espresso extends Coffee {
    int intensity;

    public Espresso(int size, int intensity) {
        super("浓缩咖啡", size);
        this.intensity = intensity;
    }

    @Override
    public void brew() {
        System.out.println("冲泡一杯 " + size + "ml 的浓缩咖啡,强度" + intensity + ",油脂丰厚");
    }
}
Latte latte = new Latte(350, 2);
latte.brew();        // 冲泡 350ml 拿铁:先加2份浓缩,再倒入蒸奶...
latte.serve();       // 端上 拿铁(继承自父类)
latte.addLatteArt(); // 在拿铁上画了一个爱心拉花!

几个关键概念:

• extends :表示继承关系。Java 是单继承------一个类只能继承一个直接父类。这避免了 C++ 中多重继承带来的"钻石问题"。但一个类可以实现多个接口。

• super :指"父类的引用"。super(...) 调用父类构造方法(必须放在子类构造方法的第一行),super.method() 调用父类的普通方法。

• @Override :注解,告诉编译器"我要重写父类的方法"。如果方法名拼写错了,编译器会报错------这是好习惯,一定要写。

• 方法重写(Override)vs 方法重载(Overload) :重写是子类重新定义 从父类继承来的方法(相同方法名、相同参数列表),运行时根据对象实际类型决定调用哪个版本;重载是同一个类里多个同名方法(参数列表不同),编译时就确定了调用哪个版本。

• 不能继承的东西 :构造方法不能继承;private 成员不能直接访问(但存在于子类对象中);final 修饰的类不能被继承(如 String)。

继承关系:咖啡家族的家谱 Coffee(父类) + name: String + size: int + brew() / serve() extends Latte + milkShots: int + brew() <- 重写 + addLatteArt() Espresso + intensity: int + brew() <- 重写 + addCrema() Mocha + chocolateShots: int + brew() <- 重写 + addWhippedCream() 子类自动继承父类的 name、size、serve(),并可重写 brew()、添加自己的属性和方法

5.4 多态:同一张图纸,不同的建筑

多态 (Polymorphism)是面向对象最精妙的特性。它的字面意思是"多种形态"------同一个方法调用,作用于不同的对象,会表现出不同的行为。

想象一个场景:咖啡师喊一声"冲咖啡!",做拿铁的机器会出拿铁,做美式的机器会出美式------同样的指令,不同的执行结果。咖啡师不需要知道每台机器的具体工作原理,只需要喊"冲咖啡"就行了。

scss 复制代码
public class PolymorphismDemo {
    public static void main(String[] args) {
        // 父类引用指向子类对象------多态的核心写法
        Coffee[] orders = {
            new Latte(350, 2),
            new Espresso(30, 3),
            new Mocha(400, 2)
        };

        // 同一个 brew() 调用,不同的行为
        for (Coffee c : orders) {
            c.brew();  // 运行时根据实际类型决定调用哪个 brew()
        }
    }
}

输出:

erlang 复制代码
冲泡 350ml 拿铁:先加2份浓缩,再倒入蒸奶...
冲泡一杯 30ml 的浓缩咖啡,强度3,油脂丰厚
冲泡 400ml 摩卡:加浓缩、蒸奶、2份巧克力酱...

注意看这个例子的精妙之处:循环里 c 的声明类型是 Coffee(父类),但 c.brew() 执行的是 Latte、Espresso、Mocha 各自重写后的 brew() 方法。编译器在编译时只知道 c 是 Coffee 类型;但运行时 JVM 发现 c 实际指向的是 Latte 对象,就执行了 Latte 版本的 brew()。这就是"动态绑定"。

多态的三个前提条件:

  1. 继承关系:子类必须继承父类(或实现接口)

  2. 方法重写:子类必须重写父类的方法

  3. 父类引用指向子类对象 :Coffee c = new Latte(...),这是多态的写法

多态的本质是"编译看左,运行看右 "。编译器只关心引用类型(左边 Coffee),决定你能不能调用某个方法;JVM 在运行时根据实际对象类型(右边 Latte)决定执行哪个版本的方法。注意:多态只能调用父类中声明过的方法------如果某个方法只有子类有、父类没有,那通过父类引用是调不了的。如果要调用子类特有的方法,需要先做强制类型转换:((Latte) c).addLatteArt()。但转换前最好用 instanceof 检查一下,避免 ClassCastException。

多态最大的好处是灵活性和可扩展性 。未来如果要增加一种新的咖啡(比如 ColdBrew),只需要让它继承 Coffee 并重写 brew(),上面那段循环代码一行都不用改就能自动适配新类型。这就是"对扩展开放,对修改关闭"------开闭原则,面向对象设计的核心原则之一。

多态在实际开发中无处不在。比如 Java 标准库中,List 接口有 ArrayList 和 LinkedList 两个实现类,你写代码时用 List 接口声明变量,具体用哪个实现类可以随时切换,不影响调用方的代码。这就是多态在工程实践中的价值------解耦。

scss 复制代码
// 这个方法可以接受任何 Coffee 的子类
static void makeOrder(Coffee coffee) {
    coffee.brew();
    coffee.serve();
}

makeOrder(new Latte(350, 2));
makeOrder(new Espresso(30, 3));
// 未来新增 ColdBrew,这个方法不用改

5.5 抽象类与接口:蓝图的不同层级

有时候你的设计图还没画完------你知道"所有咖啡都要能冲泡",但冲泡方式各不相同,没法写一个通用的实现。这时候就需要抽象类 或接口。

抽象类------半成品的蓝图,有设计但还没完全实现:

arduino 复制代码
abstract class Beverage {
    String name;
    int size;

    public Beverage(String name, int size) {
        this.name = name;
        this.size = size;
    }

    // 具体方法------子类可以直接用
    public void pour() {
        System.out.println("将" + name + "倒入杯中...");
    }

    // 抽象方法------只有声明,没有实现,强制子类去实现
    abstract void brew();
    abstract String getDescription();
}

接口------纯粹的设计规范,只规定"能做什么":

csharp 复制代码
interface Orderable {
    double getPrice();
    void printReceipt();
}

interface Customizable {
    void addTopping(String topping);
    void removeTopping(String topping);
}

一个类可以实现多个接口(但只能继承一个类):

typescript 复制代码
class FancyLatte extends Beverage implements Orderable, Customizable {
    private double price;
    private java.util.List<String> toppings = new java.util.ArrayList<>();

    public FancyLatte(int size, int milkShots, double price) {
        super("花式拿铁", size);
        this.price = price;
    }

    @Override
    void brew() { System.out.println("精心冲泡花式拿铁..."); }

    @Override
    String getDescription() { return "花式拿铁 - " + size + "ml"; }

    @Override
    public double getPrice() { return price; }

    @Override
    public void printReceipt() {
        System.out.println("收据:" + name + " - " + price + " 元");
    }

    @Override
    public void addTopping(String topping) {
        toppings.add(topping);
        System.out.println("加了配料:" + topping);
    }

    @Override
    public void removeTopping(String topping) {
        toppings.remove(topping);
    }
}

| 特性 | 抽象类 | 接口 |

|------|--------|------|

| 关键字 | abstract class | interface |

| 继承/实现 | extends(单继承) | implements(可多个) |

| 构造方法 | 有 | 没有 |

| 属性 | 可以有各种类型 | 只能有常量(public static final) |

| 方法 | 可以有具体方法和抽象方法 | Java 8 前全抽象,之后可有 default 和 static 方法 |

| 设计含义 | "是什么"(is-a) | "能做什么"(can-do) |

简单记忆:抽象类是"有血有肉但缺胳膊少腿的蓝图"------它有自己的属性和部分实现,只是有些方法留给子类补全。接口是"只有规矩没有实体的合同"------它只定义了规范,不关心实现者是谁。

面向对象三大特性 1 封装 Encapsulation 把数据藏起来 用方法做接口 private 属性 public getter/setter 内部逻辑校验 -> 数据安全 2 继承 Inheritance 子类复用父类 可扩展可重写 extends 关键字 @Override 重写 super 调用父类 -> 代码复用 3 多态 Polymorphism 同指令不同行为 运行时动态绑定 父类引用->子类对象 编译看左运行看右 通用代码适配 -> 灵活扩展


第六部分:集合框架------各种容器

回到咖啡店的比喻:数组就像一列固定编组的火车------一旦确定长度就不能改。但现实中,你常常需要更灵活的容器:排队叫号的队列、不能重复的会员卡号集合、用名字查电话的通讯录......Java 集合框架就是一套各种容器的工具箱,每种容器都针对特定场景做了优化。

6.1 List:排队

List 是有序、可重复的容器------就像排队取餐,每个人按顺序排好,可以有同名的人。

ArrayList------基于数组的 List,查询快,增删慢:

csharp 复制代码
import java.util.ArrayList;
import java.util.List;

List<String> queue = new ArrayList<>();

queue.add("张三");
queue.add("李四");
queue.add("王五");
queue.add("赵六");

System.out.println(queue);                // [张三, 李四, 王五, 赵六]
System.out.println("第2位:" + queue.get(1));  // 李四
System.out.println("共" + queue.size() + "人"); // 共4人

// 插队
queue.add(1, "紧急客户");
System.out.println(queue);  // [张三, 紧急客户, 李四, 王五, 赵六]

// 有人走了
queue.remove("王五");
System.out.println(queue);  // [张三, 紧急客户, 李四, 赵六]

// 是否包含
System.out.println("张三在队列中?" + queue.contains("张三"));  // true

// 遍历
for (String person : queue) {
    System.out.println("  - " + person);
}

LinkedList------基于链表的 List,增删快,查询慢。使用方法和 ArrayList 完全一样(因为都实现了 List 接口),但底层实现不同。

日常开发中,如果没有特殊的增删需求,默认用 ArrayList 就行。只有当你需要频繁在头部或中间插入删除时,才考虑 LinkedList。

6.2 Set:去重

Set 是不可重复的容器------就像会员卡号,每个人只能有一张。

java 复制代码
import java.util.HashSet;
import java.util.Set;

Set<String> vipMembers = new HashSet<>();

boolean added1 = vipMembers.add("VIP001");  // true
boolean added2 = vipMembers.add("VIP002");  // true
boolean added3 = vipMembers.add("VIP001");  // false!重复

System.out.println(vipMembers);        // [VIP001, VIP002]
System.out.println("会员数:" + vipMembers.size());  // 2

vipMembers.remove("VIP001");
System.out.println("VIP001 还是会员?" + vipMembers.contains("VIP001"));  // false

HashSet 不保证顺序。如果需要排序,用 TreeSet;如果需要按插入顺序,用 LinkedHashSet。

6.3 Map:字典

Map 是键值对容器------就像字典,通过"词条"(key)查"释义"(value)。key 不能重复,每个 key 对应一个 value。

less 复制代码
import java.util.HashMap;
import java.util.Map;

Map<String, Integer> menu = new HashMap<>();

menu.put("美式", 25);
menu.put("拿铁", 30);
menu.put("摩卡", 35);
menu.put("美式", 28);  // 覆盖原来的值

System.out.println("拿铁价格:" + menu.get("拿铁"));     // 30
System.out.println("美式价格:" + menu.get("美式"));     // 28(被覆盖了)
System.out.println("卡布奇诺价格:" + menu.get("卡布奇诺")); // null

System.out.println(menu.getOrDefault("卡布奇诺", 0));  // 0,安全获取

System.out.println("有拿铁?" + menu.containsKey("拿铁"));  // true
System.out.println("有25元的?" + menu.containsValue(25));   // false(已改为28)

// 遍历
for (Map.Entry<String, Integer> entry : menu.entrySet()) {
    System.out.println(entry.getKey() + " -> " + entry.getValue() + " 元");
}

// 只遍历 key 或 value
for (String key : menu.keySet()) { System.out.println(key); }
for (Integer price : menu.values()) { System.out.println(price + " 元"); }

6.4 集合的时间复杂度

理解每种集合的效率,才能在不同场景选对工具。下面用大 O 表示法分析常见操作的时间复杂度,其中 是集合中元素的数量:

| 集合 | 查找 | 插入 | 删除 | 特点 |

|------|------|------|------|------|

| ArrayList | (按下标) / (按值) | (尾部) / (中间) | | 随机访问快 |

| LinkedList | | (头尾) | (头尾) | 增删快 |

| HashSet | | | | 去重,无序 |

| HashMap | | | | 键值查找 |

注意:HashMap 的时间复杂度 是平均情况。当发生大量哈希冲突时,最坏情况会退化到 。Java 8 之后,冲突超过一定阈值时链表会转为红黑树,最坏情况降到 。这也是为什么正确实现 hashCode() 和 equals() 方法很重要。

6.5 迭代器与遍历方式

Java 集合提供了多种遍历方式,各有适用场景:

csharp 复制代码
List<String> list = new ArrayList<>(List.of("A", "B", "C"));

// 方式1:传统 for 循环(需要下标时用)
for (int i = 0; i < list.size(); i++) {
    System.out.println(i + ": " + list.get(i));
}

// 方式2:增强 for 循环(最简洁,不需要下标时首选)
for (String item : list) {
    System.out.println(item);
}

// 方式3:迭代器(需要删除元素时必须用)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String item = it.next();
    if (item.equals("B")) {
        it.remove();  // 安全删除!用 list.remove() 会抛 ConcurrentModificationException
    }
}

// 方式4:forEach + Lambda(Java 8+,最现代)
list.forEach(item -> System.out.println(item));
list.forEach(System.out::println);  // 方法引用

在遍历集合时删除元素是一个经典陷阱。如果你用 for-each 循环遍历的同时调用 list.remove(),会抛出 ConcurrentModificationException------因为迭代器检测到集合被外部修改了。正确的做法是使用迭代器的 it.remove() 方法。

6.6 Collections 工具类

Java 还提供了一个 Collections 工具类,包含各种集合操作的静态方法:

java 复制代码
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;

List<Integer> nums = new ArrayList<>(List.of(5, 3, 8, 1, 9));

Collections.sort(nums);           // 排序:[1, 3, 5, 8, 9]
Collections.reverse(nums);        // 反转:[9, 8, 5, 3, 1]
Collections.shuffle(nums);        // 随机打乱
int max = Collections.max(nums);  // 最大值
int min = Collections.min(nums);  // 最小值
Collections.frequency(nums, 5);   // 元素出现次数

// 不可变集合------防止修改
List<String> immutable = Collections.unmodifiableList(
    new ArrayList<>(List.of("A", "B", "C"))
);
// immutable.add("D");  // 抛出 UnsupportedOperationException

Java 集合框架层次结构 Collection List Set Queue ArrayList LinkedList HashSet TreeSet PriorityQueue ArrayDeque Map<K,V>(独立体系) HashMap TreeMap Map 不继承 Collection 接口 子接口 实现类


第七部分:异常处理------安全网

咖啡师工作时,难免会遇到意外:牛奶过期了、咖啡机坏了、客人突然要退款、网络支付失败......好的咖啡店不会因为这些意外就倒闭关门,而是有一套应急预案。Java 的异常处理机制就是程序的安全网------让程序在遇到意外时不会直接崩溃退出,而是优雅地处理问题。

7.1 try-catch:接住掉下来的东西

csharp 复制代码
public class SafetyNet {
    public static void main(String[] args) {
        try {
            int[] arr = {1, 2, 3};
            System.out.println(arr[5]);  // 数组越界!
            System.out.println("这行不会执行");
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("出问题了:" + e.getMessage());
        } finally {
            System.out.println("清理工作完成");
        }
        System.out.println("程序继续运行...");
    }
}

• try :把可能出问题的代码放在这里------就像在高空作业区铺安全网

• catch :接住特定类型的异常并处理------安全网接住你之后的处理

• finally :无论是否发生异常都会执行------通常用于关闭资源(文件、数据库连接等)

可以 catch 多种异常,顺序很重要------先具体后通用:

csharp 复制代码
try {
    String s = null;
    System.out.println(s.length());
} catch (NullPointerException e) {
    System.out.println("空指针:" + e.getMessage());
} catch (Exception e) {
    System.out.println("其他异常:" + e.getMessage());
}

Java 7+ 支持在一个 catch 里捕获多种异常:

csharp 复制代码
try {
    // ...
} catch (NullPointerException | ArithmeticException e) {
    System.out.println("空指针或算术异常:" + e.getMessage());
}

7.2 异常体系

Java 的异常有一个庞大的家族树,所有异常都继承自 Throwable:

Java 异常体系 Throwable Error Exception OutOfMemoryError StackOverflowError 不该 catch,应该修 bug RuntimeException 其他 Exception NullPointerException IndexOutOfBounds ArithmeticException ClassCastException IOException SQLException 非检查异常(可不 catch) 检查异常(必须处理)

两大分类:

• 检查异常 (Checked Exception):除了 RuntimeException 以外的 Exception 子类。编译器会强制 你处理------要么 try-catch,要么在方法签名上用 throws 声明抛出。比如 IOException------文件可能不存在,你必须处理这种可能性。

• 非检查异常 (Unchecked Exception):RuntimeException 及其子类。编译器不强制处理。比如 NullPointerException------这是编程错误,应该修 bug 而不是 catch。

7.3 抛出异常与自定义异常

你还可以主动抛出异常------当检测到不合法的状态时,用 throw 关键字:

arduino 复制代码
static double divide(double a, double b) {
    if (b == 0) {
        throw new ArithmeticException("除数不能为零!");
    }
    return a / b;
}

当 Java 内置的异常类型不足以描述你的业务问题时,可以创建自定义异常类:

scala 复制代码
// 自定义异常
class OutOfCoffeeException extends Exception {
    public OutOfCoffeeException(String message) {
        super(message);
    }
}

class CoffeeMachine {
    private int coffeeBeans = 10;

    // throws 声明这个方法可能抛出 OutOfCoffeeException
    public void makeCoffee(int cups) throws OutOfCoffeeException {
        if (cups > coffeeBeans) {
            throw new OutOfCoffeeException(
                "咖啡豆不够!需要" + cups + "杯,只剩" + coffeeBeans + "杯的量"
            );
        }
        coffeeBeans -= cups;
        System.out.println("制作了" + cups + "杯咖啡,剩余" + coffeeBeans + "杯量");
    }
}

// 使用
CoffeeMachine machine = new CoffeeMachine();
try {
    machine.makeCoffee(5);   // 成功
    machine.makeCoffee(10);  // 抛出异常
} catch (OutOfCoffeeException e) {
    System.out.println("提醒店长:" + e.getMessage());
}
// 输出:
// 制作了5杯咖啡,剩余5杯量
// 提醒店长:咖啡豆不够!需要10杯,只剩5杯的量

7.4 try-with-resources

Java 7 引入了 try-with-resources 语法,自动关闭实现了 AutoCloseable 接口的资源,不需要手动写 finally 来 close:

java 复制代码
// 传统写法------需要手动关闭
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader("menu.txt"));
    String line = reader.readLine();
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (reader != null) {
        try { reader.close(); } catch (IOException e) { e.printStackTrace(); }
    }
}

// try-with-resources------自动关闭
try (BufferedReader reader2 = new BufferedReader(new FileReader("menu.txt"))) {
    String line = reader2.readLine();
} catch (IOException e) {
    e.printStackTrace();
}
// reader2 在 try 块结束后自动关闭,不管是否发生异常

这不是语法糖那么简单------它消除了资源泄漏的风险。在传统写法中,如果 try 块和 finally 块都抛出异常,finally 块的异常会覆盖 try 块的异常,导致真正的错误被隐藏。try-with-resources 解决了这个问题。

7.5 异常处理的最佳实践

异常处理看似简单,但用好它需要经验。以下是几条最佳实践:

  1. 不要捕获你处理不了的异常。如果一个异常你只是 catch 了然后什么都不做(空 catch 块),那等于把问题藏起来了------这比不 catch 还糟糕,因为问题被掩盖了,调试时会很难找到。

  2. 捕获具体的异常类型,而不是笼统的 Exception 。catch (Exception e) 会捕获所有异常,包括你没想到的,这可能会掩盖真正的 bug。应该尽量 catch 具体的异常类型。

  3. finally 块里不要 return。finally 块中的 return 会覆盖 try 块中的 return,而且会吞掉 try 块中未抛出的异常------这是一个极其隐蔽的 bug。

  4. 自定义异常要有意义。不要为了用自定义异常而用------只有在业务逻辑确实需要区分不同类型的错误时,才创建自定义异常。自定义异常的类名应该能清楚表达是什么错误。

  5. 异常不是控制流工具。不要用异常来做正常的流程控制(比如用抛异常来代替 if-else 判断),异常的处理开销比条件判断大得多。

php 复制代码
// 反面教材:空 catch 块
try {
    riskyOperation();
} catch (Exception e) {
    // 什么都不做 -> 问题被掩盖了!
}

// 正确做法:至少打印日志
try {
    riskyOperation();
} catch (SpecificException e) {
    logger.error("操作失败", e);  // 记录日志
    // 尝试恢复或重新抛出
    throw new BusinessException("操作失败,请稍后重试", e);
}

第八部分:高级应用------从熟练到精通

掌握了前面的基础之后,这一部分介绍 Java 的几个高级特性。它们不是日常编程的必需品,但一旦学会,你的代码会变得更简洁、更安全、更强大。

8.1 泛型:万能杯架

你已经用过泛型了------ArrayList<String> 里的 <String> 就是泛型。泛型让你写一份代码就能适配多种类型,就像一个万能杯架,既能放小杯也能放大杯,但同一个杯架里只能放同一种规格的杯子。

没有泛型的时候,集合可以放任何类型的对象,但这带来了两个问题:取出来需要强制类型转换(容易出错),而且编译器无法帮你检查类型安全:

ini 复制代码
// 没有泛型:什么都能放,但取出来要强转,容易出错
List list = new ArrayList();
list.add("hello");
list.add(123);          // 编译通过,但运行时会出问题
String s = (String) list.get(1);  // ClassCastException!

// 有泛型:编译时就能发现问题
List<String> safeList = new ArrayList<>();
safeList.add("hello");
// safeList.add(123);  // 编译错误!类型不匹配
String s2 = safeList.get(0);  // 不需要强转

自定义泛型类------使用类型参数(通常用 T、E、K、V 等大写字母)作为占位符:

csharp 复制代码
// 泛型类:T 和 U 是类型参数
public class Pair<T, U> {
    private T first;
    private U second;

    public Pair(T first, U second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() { return first; }
    public U getSecond() { return second; }

    @Override
    public String toString() {
        return "(" + first + ", " + second + ")";
    }
}

// 使用------指定具体类型
Pair<String, Integer> nameAge = new Pair<>("张三", 25);
Pair<Double, Double> coordinates = new Pair<>(39.9, 116.4);
System.out.println(nameAge);      // (张三, 25)
System.out.println(coordinates);  // (39.9, 116.4)

泛型方法------在返回类型前声明类型参数:

java 复制代码
// 泛型方法:在返回类型前用 <T> 声明类型参数
public static <T> T getMiddle(T... items) {
    if (items == null || items.length == 0) return null;
    return items[items.length / 2];
}

// 使用
String mid1 = getMiddle("A", "B", "C");   // B
Integer mid2 = getMiddle(1, 2, 3, 4, 5);  // 3

泛型还有通配符的概念。<?> 表示任意类型,<? extends Number> 表示 Number 或其子类(上界通配符,用于读取),<? super Integer> 表示 Integer 或其父类(下界通配符,用于写入)。这就是所谓的 PECS 原则------Producer Extends, Consumer Super。

8.2 Lambda 表达式:一行代码搞定

Lambda 是 Java 8 引入的重要特性,它让你用更简洁的方式表示"一个行为"------本质上是一个匿名函数,可以作为参数传递。在 Java 8 之前,要传递一个行为,你得写一个匿名内部类,代码冗长;Lambda 让这件事变得极其简洁。

要理解 Lambda 的价值,先看看"函数式接口"的概念。所谓函数式接口,就是只包含一个抽象方法的接口。Lambda 表达式本质上就是函数式接口的一个实例------你不需要写类名、方法名,只需要写出参数和方法体。

java 复制代码
// 函数式接口:只有一个抽象方法
interface Greeting {
    void sayHello(String name);
}

// 传统写法:匿名内部类
Greeting g1 = new Greeting() {
    @Override
    public void sayHello(String name) {
        System.out.println("你好," + name);
    }
};

// Lambda 写法:类型推断自动匹配到 Greeting 接口
Greeting g2 = (name) -> System.out.println("你好," + name);
// 进一步简化:单参数可省略括号
Greeting g3 = name -> System.out.println("你好," + name);

g1.sayHello("张三");  // 你好,张三
g2.sayHello("李四");  // 你好,李四
g3.sayHello("王五");  // 你好,王五

Lambda 表达式语法解析 (a, b) -> a + b 参数列表 类型可省略 箭头 分隔符 方法体 表达式或代码块 单参数可省括号:x -> x * 2 多语句需大括号:(a, b) -> { int c = a + b; return c; } 无参数:() -> System.out.println("hello")

传统写法(匿名内部类):

typescript 复制代码
List<String> names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.compareTo(b);
    }
});

Lambda 写法------从 6 行缩减到 1 行:

css 复制代码
Collections.sort(names, (a, b) -> a.compareTo(b));
// 方法引用------更简洁
Collections.sort(names, String::compareTo);
System.out.println(names);  // [Alice, Bob, Charlie]

Lambda 的语法:(参数列表) -> {方法体}。当方法体只有一行时,可以省略大括号和 return。

常见用法:

csharp 复制代码
// 遍历集合
names.forEach(name -> System.out.println(name));
names.forEach(System.out::println);  // 方法引用

// 创建线程
new Thread(() -> System.out.println("子线程运行")).start();

// 自定义函数式接口
interface MathOperation {
    int operate(int a, int b);
}

MathOperation add = (a, b) -> a + b;
MathOperation multiply = (a, b) -> a * b;
System.out.println(add.operate(3, 4));       // 7
System.out.println(multiply.operate(3, 4));  // 12

方法引用 (::)是 Lambda 的进一步简化。当 Lambda 的方法体只是调用某个已存在的方法时,可以用方法引用代替。比如 name -> System.out.println(name) 可以简写为 System.out::println。Java 提供了四种方法引用:静态方法引用(ClassName::staticMethod)、实例方法引用(instance::method)、类的实例方法引用(ClassName::method)和构造方法引用(ClassName::new)。

Java 8 内置了一批常用的函数式接口,位于 java.util.function 包中:

| 接口 | 方法签名 | 含义 | 示例 |

|------|----------|------|------|

| Predicate<T> | boolean test(T) | 判断真假 | x -> x > 0 |

| Consumer<T> | void accept(T) | 消费数据 | x -> System.out.println(x) |

| Function<T,R> | R apply(T) | 类型转换 | x -> x.toString() |

| Supplier<T> | T get() | 生产数据 | () -> new Random() |

| BiFunction<T,U,R> | R apply(T,U) | 双参转换 | (a,b) -> a + b |

8.3 Stream API:数据流水线

Stream API 是 Java 8 的另一个重磅特性。它让你像搭流水线一样处理集合数据------过滤、映射、排序、聚合,一行链式调用搞定。Stream 不是集合,它是对集合的更高层抽象,让你可以用声明式的方式描述"做什么"而不是"怎么做"。

scss 复制代码
import java.util.*;
import java.util.stream.*;

List<Integer> numbers = List.of(5, 3, 8, 1, 9, 2, 7, 4, 6, 10);

// 传统写法:找出偶数,排序,乘以2,收集到新列表
List<Integer> result = new ArrayList<>();
for (int n : numbers) {
    if (n % 2 == 0) result.add(n * 2);
}
Collections.sort(result);

// Stream 写法:链式调用,清晰优雅
List<Integer> result2 = numbers.stream()
    .filter(n -> n % 2 == 0)      // 过滤偶数
    .sorted()                      // 排序
    .map(n -> n * 2)              // 每个乘以2
    .collect(Collectors.toList()); // 收集为列表

System.out.println(result2);  // [4, 8, 12, 16, 20]

更复杂的例子------处理咖啡店订单:

csharp 复制代码
class Order {
    String customer;
    String coffee;
    double price;
    int rating;

    public Order(String customer, String coffee, double price, int rating) {
        this.customer = customer;
        this.coffee = coffee;
        this.price = price;
        this.rating = rating;
    }
    public double getPrice() { return price; }
    public int getRating() { return rating; }
    public String getCoffee() { return coffee; }
    public String getCustomer() { return customer; }
}

List<Order> orders = List.of(
    new Order("张三", "拿铁", 30, 5),
    new Order("李四", "美式", 25, 3),
    new Order("王五", "拿铁", 30, 4),
    new Order("赵六", "摩卡", 35, 5),
    new Order("钱七", "美式", 25, 2)
);

// 找出评分>=4的订单,按价格降序排列,提取客户名
List<String> topCustomers = orders.stream()
    .filter(o -> o.getRating() >= 4)
    .sorted(Comparator.comparingDouble(Order::getPrice).reversed())
    .map(Order::getCustomer)
    .collect(Collectors.toList());
System.out.println("高分客户:" + topCustomers);  // [赵六, 张三, 王五]

// 统计总营业额
double totalRevenue = orders.stream()
    .mapToDouble(Order::getPrice)
    .sum();
System.out.println("总营业额:" + totalRevenue + " 元");  // 145.0 元

// 按咖啡种类分组
Map<String, List<Order>> grouped = orders.stream()
    .collect(Collectors.groupingBy(Order::getCoffee));
grouped.forEach((coffee, orderList) ->
    System.out.println(coffee + ": " + orderList.size() + " 单"));
// 拿铁: 2 单 / 美式: 2 单 / 摩卡: 1 单

// 平均评分
double avgRating = orders.stream()
    .mapToInt(Order::getRating)
    .average()
    .orElse(0);
System.out.println("平均评分:" + avgRating);  // 3.8

// 按价格区间分组统计
Map<String, Long> priceRange = orders.stream()
    .collect(Collectors.groupingBy(
        o -> o.getPrice() <= 25 ? "经济型" : (o.getPrice() <= 35 ? "中档" : "高档"),
        Collectors.counting()
    ));
System.out.println(priceRange);  // {经济型=2, 中档=3}

Stream 的操作分为两类:中间操作 (filter、map、sorted 等)返回新的 Stream,可以继续链式调用,它们是"延迟执行"的------只有当终端操作触发时才真正开始计算;终端操作 (collect、forEach、reduce、count、sum 等)触发实际计算并产生结果。

Stream API:数据流水线 数据源 List/Set .stream() filter 过滤 条件判断 map 转换 类型映射 sorted 排序 比较器 collect 收集 toList/group 结果 List/Map /数值 中间操作(filter/map/sorted)可链式调用,延迟执行 终端操作(collect/forEach/reduce)触发实际计算

8.4 IO 与 NIO:读写文件

Java 的 IO 体系用于读写文件、网络数据等。早期使用 java.io(流式 IO),后来引入了 java.nio(New IO,支持缓冲区和通道)。两者的核心区别在于:传统 IO 是面向流的,一次读一个字节或一个字符,效率较低;NIO 是面向缓冲区的,可以一次读一块数据,而且支持非阻塞模式,在高并发场景下性能更好。

对于日常的文件读写操作,传统 IO 和 NIO 都可以胜任。但如果你需要处理大文件、高性能网络通信或者需要非阻塞 IO,NIO 是更好的选择。下面分别展示两种方式的用法。

文件读写------最常用的操作:

java 复制代码
import java.io.*;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.util.*;

// ===== 传统 IO 写文件 =====
try (BufferedWriter writer = new BufferedWriter(new FileWriter("menu.txt"))) {
    writer.write("=== 咖啡菜单 ===");
    writer.newLine();
    writer.write("美式 - 25元");
    writer.newLine();
    writer.write("拿铁 - 30元");
} catch (IOException e) {
    e.printStackTrace();
}

// ===== 传统 IO 读文件 =====
try (BufferedReader reader = new BufferedReader(new FileReader("menu.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

// ===== NIO 写文件(更简洁)=====
Files.write(Paths.get("menu_nio.txt"),
    List.of("=== NIO 菜单 ===", "美式 - 25元", "拿铁 - 30元"),
    StandardCharsets.UTF_8);

// ===== NIO 读文件(一行搞定)=====
List<String> lines = Files.readAllLines(Paths.get("menu_nio.txt"), StandardCharsets.UTF_8);
lines.forEach(System.out::println);

字节流 vs 字符流:

• 字节流 (InputStream / OutputStream):按字节读写,适合二进制文件(图片、视频)

• 字符流 (Reader / Writer):按字符读写,适合文本文件,能正确处理编码

ini 复制代码
// 复制图片文件(字节流)
try (InputStream in = new FileInputStream("source.jpg");
     OutputStream out = new FileOutputStream("copy.jpg")) {
    byte[] buffer = new byte[1024];
    int length;
    while ((length = in.read(buffer)) > 0) {
        out.write(buffer, 0, length);
    }
    System.out.println("复制完成!");
}

Java IO 体系分类 Java IO 体系 字节流 字符流 NIO InputStream / OutputStream 适合:图片、视频等二进制文件 Reader / Writer 适合:文本文件,自动处理编码 Channel + Buffer + Selector 适合:高性能、大文件、非阻塞IO


学习路线图:从咖啡学徒到大师

走到这里,你已经从零基础一路学到了 Java 的高级特性。下面是建议的进阶路线:

第一步:巩固基础(你现在在这里)

• 多写代码,多练习。把本文的每个示例都亲手敲一遍

• 理解面向对象思想,不要只停留在语法层面

第二步:深入核心

• 学习多线程与并发(Thread、Runnable、synchronized、Lock、线程池)

• 研究 JVM 原理(内存模型、垃圾回收、类加载机制)

• 掌握设计模式(单例、工厂、策略、观察者等)

第三步:工程实践

• 学习构建工具(Maven / Gradle)

• 掌握框架(Spring Boot、MyBatis)

• 了解数据库(MySQL、Redis)

• 学习版本控制(Git)

第四步:架构与进阶

• 微服务架构(Spring Cloud)

• 消息队列(Kafka、RabbitMQ)

• 分布式系统(分布式锁、分布式事务)

• 性能调优与排查

学编程就像学冲咖啡------一开始手忙脚乱,但只要坚持练习,总有一天你能站在吧台后面,行云流水地冲出一杯完美的拿铁。学习路线不是一成不变的,你可以根据自己的兴趣和职业方向调整。比如对后端开发感兴趣就深入 Spring 生态,对大数据感兴趣就学 Hadoop 和 Spark(它们都是 Java 写的),对 Android 开发感兴趣就学 Kotlin(Java 的近亲,可以无缝互操作)。关键是动手实践------看再多教程不如自己写一个项目,哪怕是小小的命令行工具。从实践中发现问题、解决问题,才是进步最快的方式。


练习题

以下练习分为三个难度,建议按顺序完成。

基础题(巩固语法)

题目 1:咖啡温度转换器

编写一个程序,输入一个摄氏温度(double),输出对应的华氏温度。公式为:
F=C×95+32 F = C \times \frac{9}{5} + 32 F=C×59+32

要求:如果输入的温度低于 (绝对零度),打印"温度不能低于绝对零度"。

题目 2:咖啡评分统计

编写一个程序,让用户输入 5 个咖啡评分(1-10 的整数),存储到数组中,然后计算并输出:平均分、最高分、最低分。

题目 3:简易菜单系统

使用 Map<String, Double> 存储咖啡菜单(至少 5 种咖啡),实现以下功能:输入咖啡名查价格、显示所有菜单、添加新咖啡、统计菜单总数。

进阶题(面向对象)

题目 4:咖啡店管理系统

设计一个类层次结构:

• 抽象类 Beverage(名称、容量、价格、抽象方法 getDescription())

• 子类 Coffee、Tea、Juice,各自实现 getDescription() 并有特有属性

• 接口 Discountable(有方法 double getDiscountedPrice(double discount)),让部分饮品实现该接口

• 编写 main 方法,创建多种饮品存入 List<Beverage>,用多态方式遍历打印描述和价格

题目 5:自定义异常练习

编写一个 BankAccount 类(封装余额、存取款方法),当取款金额超过余额时抛出自定义异常 InsufficientFundsException。编写测试代码验证异常处理。

挑战题(综合应用)

题目 6:订单分析器

使用 Stream API 处理一个包含 20+ 条订单的 List<Order>(包含客户名、咖啡类型、价格、评分、日期),完成以下任务:

• 统计每种咖啡的销量和总收入

• 找出评分最高和最低的订单

• 按客户分组,计算每个客户的消费总额

• 按价格区间(0-25、26-35、36+)统计订单数量

题目 7:文件读写练习

编写一个程序,将题目 6 的订单分析结果写入一个文本文件 order_report.txt,格式整齐。然后再写一个程序读取该文件并打印内容。


Java 的世界远比这篇教程广阔。但这杯咖啡,足够让你品味很久了。祝你编程愉快,Keep Brewing!

相关推荐
行百里er4 小时前
Redis 完结篇——Redis 热点问题在 Spring Boot 项目中实战
redis·后端
高频因子挖掘机5 小时前
只取最近 60 个交易日,却每次拉全历史:量化脚本变慢的原因与改法
后端·github·api
孟健6 小时前
从5000亿Token到83%字节匹配:AI逆向工程与重构的工程闭环实践
后端·架构·ai编程
码事漫谈7 小时前
丢得静悄悄,谈一谈 Agent 的记忆是怎么没的
后端
迪飞特科技7 小时前
智慧物业平台实战:Spring Boot 整合小程序实现报修巡检费用全流程
spring boot·后端·小程序
考虑考虑7 小时前
@JsonInclude注解使用
spring boot·后端·spring
IT_陈寒7 小时前
Vite的这个问题坑了我三天,原来如此解决!
前端·人工智能·后端
网易云信7 小时前
大模型能上岗做销售吗?我们让 12 款国产模型交了 420 份作业
前端·后端
CCblandy7 小时前
HTTP 长连接 3 类故障实测:[WinError 10053]、99 个 TIME_WAIT、69 次多余握手,4 种对策怎么选
后端·python·网络协议·http·云原生·性能优化·架构
蜗牛互联网8 小时前
Python Agent工具调用的白名单、参数校验与审批门禁
java·服务器·前端·人工智能·后端·python