从一杯咖啡说起: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
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() 方法,我们可以在这些方法里加入各种校验逻辑。
封装的三大好处:
-
数据保护:外部不能直接修改内部数据,只能通过你提供的方法来操作
-
逻辑校验:在方法里可以加入验证逻辑,防止非法数据进入
-
实现灵活性:内部实现可以随时改变,只要对外接口不变,调用方的代码就不用改
举一个实际例子说明第三点的好处:假设你的 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()。这就是"动态绑定"。
多态的三个前提条件:
-
继承关系:子类必须继承父类(或实现接口)
-
方法重写:子类必须重写父类的方法
-
父类引用指向子类对象 :
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 异常处理的最佳实践
异常处理看似简单,但用好它需要经验。以下是几条最佳实践:
-
不要捕获你处理不了的异常。如果一个异常你只是 catch 了然后什么都不做(空 catch 块),那等于把问题藏起来了------这比不 catch 还糟糕,因为问题被掩盖了,调试时会很难找到。
-
捕获具体的异常类型,而不是笼统的 Exception 。
catch (Exception e)会捕获所有异常,包括你没想到的,这可能会掩盖真正的 bug。应该尽量 catch 具体的异常类型。 -
finally 块里不要 return。finally 块中的 return 会覆盖 try 块中的 return,而且会吞掉 try 块中未抛出的异常------这是一个极其隐蔽的 bug。
-
自定义异常要有意义。不要为了用自定义异常而用------只有在业务逻辑确实需要区分不同类型的错误时,才创建自定义异常。自定义异常的类名应该能清楚表达是什么错误。
-
异常不是控制流工具。不要用异常来做正常的流程控制(比如用抛异常来代替 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×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!