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++ 中,程序员需要手动 mallocfree 内存,一旦忘记释放就会导致内存泄漏;而在 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 位 | truefalse | 开关杯 |

其中最常用的是 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 只有 truefalse 两个值,不能写 01,也不能像 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() 执行的是 LatteEspressoMocha 各自重写后的 brew() 方法。编译器在编译时只知道 cCoffee 类型;但运行时 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 接口有 ArrayListLinkedList 两个实现类,你写代码时用 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 前全抽象,之后可有 defaultstatic 方法 |

| 设计含义 | "是什么"(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. 捕获具体的异常类型,而不是笼统的 Exceptioncatch (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);  // 不需要强转

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

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 的操作分为两类:中间操作filtermapsorted 等)返回新的 Stream,可以继续链式调用,它们是"延迟执行"的------只有当终端操作触发时才真正开始计算;终端操作collectforEachreducecountsum 等)触发实际计算并产生结果。

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 的高级特性。下面是建议的进阶路线:

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

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

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

第二步:深入核心

• 学习多线程与并发(ThreadRunnablesynchronizedLock线程池

• 研究 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()

• 子类 CoffeeTeaJuice,各自实现 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!

相关推荐
孓最求完美2 小时前
实战经验:JT808/JT809 车联网高并发服务端性能优化指南
后端
shengjk12 小时前
深度拆解:从 LC-3 汇编到 Java/Rust,数组越界与硬件中断的底层真相
后端
Gorway2 小时前
理解 Spring 依赖注入:从构造器注入到集合与条件 Bean
java·后端
颜进强2 小时前
Claude Code - 26 效率三件套:cc-switch 切模型 · codeburn 算成本 · claude-hud 看状态
前端·后端·ai编程
掘金者阿豪2 小时前
时序数据库选型被写入峰值带偏了?金仓超表让我少加了不少班
后端
大黄评测3 小时前
jQuery 遍历方法 each 实战:循环处理列表数据
后端
大勇前进3 小时前
jQuery 事件委托原理:彻底解决动态生成元素绑定失效问题
后端
苍何3 小时前
做了个微信聊天爆款视频 Skill, Codex / WorkBuddy一键复刻使用
后端·aigc