Java 语言入门教程
从 Hello World 到面向过程编程
没有伴随着痛苦的教训是毫无意义的,因为人若不牺牲些什么,是什么都无法得到的。
但当忍受过这种痛楚,并将其克服的时候,人就会得到不输给任何事的坚韧的心。
没错,钢铁般坚韧的心。
——爱德华·艾尔利克 《钢之炼金术师》
Java程序基础
从这节课开始,我们就可以正式进入到Java语言的学习当中了。
Java是一门面向对象的语言,但在那之前,我们得先学会如何面向过程编程。
程序代码基本结构
正如所有的语言教学都是从Hello World开始一样,这里我们也来开始编写第一个Hello World!
首先,在IDEA中通过 file -> 新建 -> 项目
创建一个名为MyHelloWorldProject的项目。
程序代码基本结构 (续)
接着,在src/main/java目录右键,选择new -> class,命名为MainApp。IDEA会自动生成基础代码:
public class MainApp {
}
编写第一个程序:Hello World
在MainApp的花括号中写入main方法,这是应用程序的入口。
public class MainApp {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
就像C语言的int main()一样,请先记住这个固定写法。
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
代码规范与错误
Java语言严格区分大小写。如果大小写错误,IDEA会用红色波浪线提示:
只要源代码中存在报错,就无法完成编译,会提示构建失败。
语句和分号
Java用分号 ; 来分隔语句。如果没有分号,即使换行,编译器也会认为是同一句代码。
相反,如果添加了分号,即使写在同一行,也会被认为是两句独立的代码。
注释
注释是给程序员看的笔记,编译器会忽略它们。
单行注释
使用双斜杠 //,从斜杠开始到行尾都是注释。
// 这是一个单行注释
System.out.println("Hello World!");
多行注释
使用 /* 和 */ 包围多行内容。
/*
这里面的内容
无论多少行
都可以
*/
文档注释
使用 /** 和 */ 包围,可以用来自动生成API文档。
/**
* 这是一个文档注释
* @author YourName
*/
变量与常量
变量,指值可以变的量。在Java中,我们需要声明一个变量才能使用它。
声明变量
[数据类型] [变量名称];
例如,声明一个整数变量a并初始化:
int a = 10; // 声明一个整数类型变量a,并赋值为10
变量命名:规则、约定与实践
变量命名是编程的基本功。一个好的名字能让代码更易读。Java的命名规范可分为三类:
必须遵守的硬性规则
如果违反,代码将无法编译。
- ✅ 只能包含:字母、数字、下划线(
_)、美元符($) - ❌ 不能以数字开头 (如
1stPlace) - ❌ 不能是Java关键字 (如
class,public) - ✅ 大小写敏感 (
age和Age是两个不同变量)
推荐遵循的社区约定
大家默认的“潜规则”,让代码风格统一。
- ✅ 驼峰命名法 (CamelCase):首字母小写,后续单词首字母大写,如
myScore,userName。 - ✅ 见名知意:使用有意义的完整单词,如
studentName而不是sName。 - ❌ 避免使用下划线开头 (如
_tmp) 或美元符 (price$),虽然语法允许,但通常有特殊含义。
应该避免的不良实践
语法上可能正确,但会降低代码质量。
- ❌ 拼音或中英混合:如
nianling,userXingming。 - ❌ 变量名过短或无意义:如
a,b,temp(除非在极小的作用域内)。 - ❌ 使用中文:虽然Java支持 (如
String 姓名 = "张三";),但在团队协作和跨平台时可能引发问题,强烈不推荐。
使用变量
变量的值可以被修改,也可以参与运算。
修改变量值
int a = 666;
System.out.println(a); // 输出 666
a = 777;
System.out.println(a); // 输出 777
变量间的赋值
int a = 10;
int b = a; // b的值现在也是10
System.out.println(b);
变量参与运算
int a = 9;
a = a + 1; // a的新值是 9 + 1 = 10
System.out.println(a);
常量
有时候我们希望一个变量的值在初始化后不能被修改,这时可以使用 final 关键字将其声明为常量。
final int PI_APPROXIMATION = 3; // 常量名通常大写
// PI_APPROXIMATION = 4; // 这行代码会报错,因为常量的值不能被修改
尝试修改一个 final 变量会导致编译错误:
基本数据类型
程序中需要表示各种各样的数据,比如整数、小数、字符等。Java提供了八大基本数据类型来满足这些需求。
计算机中的二进制表示
计算机内部所有数据都以二进制(0和1)形式存储。
- bit (位): 最小的数据单位,一个0或1。
- Byte (字节): 8个bit组成一个字节。
1 Byte = 8 bit。
为了表示负数,计算机使用补码的方式。这解决了正负零问题,并简化了加减法运算。
编码方式演进
- 原码:最高位为符号位(0正1负),其余为数值。简单但计算麻烦。
- 反码:正数不变,负数符号位不变,其余位取反。解决了部分计算问题但存在+0和-0。
- 补码:正数不变,负数反码+1。消除了-0,是现代计算机的标准。
补码示例 (以byte为例)
求 -5 的补码:
- 写出原码:
5的二进制是00000101。所以-5的原码是10000101。 - 计算反码: 符号位不变,其余位取反,得到
11111010。 - 计算补码: 反码加1,得到
11111011。
所以,-5 在计算机中就以 11111011 的形式存储。
整数类型
Java提供四种整数类型,用于表示不同范围的整数。
byte: 1字节 (-128 ~ 127)short: 2字节 (-32,768 ~ 32,767)int: 4字节 (约-21亿 ~ 21亿) - 最常用long: 8字节 (非常大)
定义一个long类型的常量,需要在数字后面加上L或l。
long bigNumber = 9223372036854775807L;
int population = 1_000_000; // 可以用下划线分隔,提高可读性
隐式类型转换 (整数)
当把一个表示范围小的类型赋值给一个表示范围大的类型时,Java会自动进行类型转换。
short a = 10;
int b = a; // 合法,short可以自动转换为int
反之则会报错,因为可能导致数据丢失。
整数溢出
当一个整数超出了其类型的最大表示范围时,会发生“溢出”,结果会像时钟一样回绕。
int a = 2147483647; // int类型的最大值
a = a + 1;
System.out.println(a); // 输出结果会是 -2147483648 (int的最小值)
这是因为二进制的最高位(符号位)发生了变化。
浮点类型
用于表示小数,也叫浮点数。
float: 4字节 (单精度,约7位有效数字)double: 8字节 (双精度,约15-17位有效数字) - 更常用
小数在计算机中采用 IEEE 754 标准存储,由符号位、指数位和尾数位组成。
示例:float f = 9.625f;
- 符号位:
0(正数) - 转为二进制:
9.625→1001.101 - 科学计数法:
1.001101 * 2^3 - 指数位: 偏移量为127,所以指数为
3 + 127 = 130,二进制为10000010 - 尾数位: 小数部分
001101,补齐23位 →00110100000000000000000
最终的32位表示:
0 10000010 00110100000000000000000
- -------- -----------------------
S Exponent Fraction
这个例子展示了浮点数如何被编码为二进制形式。理解这个过程有助于我们认识到为什么浮点数会有精度限制。
注意:浮点数运算存在精度误差,不适合用于金融等需要精确计算的场景。下面是一个经典的例子:
// 浮点数精度问题
System.out.println(0.1 + 0.2); // 输出 0.30000000000000004
// 在金融计算中,应使用 BigDecimal
java.math.BigDecimal bd1 = new java.math.BigDecimal("0.1");
java.math.BigDecimal bd2 = new java.math.BigDecimal("0.2");
System.out.println(bd1.add(bd2)); // 输出 0.3
使用浮点类型
小数常量默认为double类型。要定义float常量,需在数字后加F或f。
double pi = 3.1415926535;
float price = 9.99F;
隐式类型转换规则:byte→short(char)→int→long→float→double
long可以隐式转换为float,因为float的表示范围更大,但可能会丢失精度。
long l = 21731371236768L;
float f = l; // 编译通过,但f的值可能不完全精确
System.out.println(f);
字符类型 char
char类型用于表示单个字符,占2个字节,无符号,范围 0 ~ 65535。
字符常量需要用单引号 ' ' 括起来。
char c1 = 'A';
char c2 = 65; // 效果同上,65是'A'的ASCII码
System.out.println(c2); // 输出 A
字符编码的演进:从ASCII到Unicode
- ASCII的局限: 计算机最初只在美国使用,所以设计了ASCII码,用1个字节(8位)中的7位表示128个字符(英文字母、数字、符号)。当计算机走向世界,ASCII无法表示中文、日文等成千上万的字符。
- Unicode的诞生: 为了解决这个问题,Unicode字符集应运而生。它像一本巨大的字典,为世界上每个字符分配了一个唯一的数字编号(码点/Code Point)。它只定义了“字符”与“数字”的对应关系,不关心如何存储。
- 编码的实现 (UTF-8/UTF-16/GBK): 如何将Unicode码点高效地存入计算机?这就是编码(Encoding)要解决的问题。
- UTF-8: 变长编码,用1-4个字节表示字符。兼容ASCII,节省空间,是最广泛使用的编码。
- UTF-16: Java运行时内部使用的编码,通常用2个或4个字节表示字符。
- GBK: 中国国家标准,用于兼容早期的中文软件,使用1-2个字节。
示例:“你好”在不同编码下的样子
Unicode码点: 你 (U+4F60), 好 (U+597D)
| 编码方式 | 二进制表示 (十六进制) |
|---|---|
| UTF-8 | E4 BD A0 E5 A5 BD |
| UTF-16 (BE) | 4F 60 59 7D |
| GBK | C4 E3 BA C3 |
结论: 同一个“字符”,在不同“编码”规则下,存储为完全不同的二进制内容。这就是乱码的根源。
字符串类型 String
String类型用于表示一串字符。它不是基本数据类型,而是一个类(对象)。
字符串常量需要用双引号 " " 括起来。
String greeting = "Hello World!";
String name = "张三";
String empty = ""; // 空字符串
由于Java强大的编码支持,变量名甚至可以是中文(但不推荐)。
布尔类型 boolean
boolean类型只有两个值:true (真) 和 false (假)。
它通常用作条件判断的结果。
boolean isStudent = true;
boolean hasPassed = false;
System.out.println(isStudent);
不能将整数(如0或1)赋值给boolean变量,这和C语言不同。
与用户交互:从命令行读取输入
到目前为止,我们的程序都是“自说自话”。为了让程序能接收用户的输入,我们需要使用 Scanner 类。这能让我们的程序变得可交互,更加动态。
如何使用 Scanner
- 导入 Scanner 类: 在代码文件的最顶端,使用
import java.util.Scanner;来告诉编译器我们要使用这个工具。 - 创建 Scanner 对象: 在
main方法中,通过Scanner scanner = new Scanner(System.in);来创建一个实例。System.in代表标准输入流,也就是我们的键盘。 - 调用方法读取数据:
scanner对象有很多方法来读取不同类型的数据,例如:nextInt(): 读取一个整数。nextDouble(): 读取一个双精度浮点数。nextLine(): 读取一行文本(字符串)。
import java.util.Scanner; // 1. 导入工具
public class UserInputExample {
public static void main(String[] args) {
// 2. 创建对象并连接到键盘
Scanner scanner = new Scanner(System.in);
System.out.print("请输入您的名字: ");
String name = scanner.nextLine(); // 3. 读取一行文字
System.out.print("请输入您的年龄: ");
int age = scanner.nextInt(); // 3. 读取一个整数
System.out.println("\n你好, " + name + "! 你今年 " + age + " 岁了。");
}
}
Scanner 常见陷阱:被“吃掉”的换行符
当混合使用 nextInt(), nextDouble() 等方法和 nextLine() 时,初学者会遇到一个非常普遍的“坑”。
// 问题复现代码
Scanner scanner = new Scanner(System.in);
System.out.print("请输入您的年龄: ");
int age = scanner.nextInt(); // 用户输入 25 然后敲回车
System.out.print("请输入您的姓名: ");
String name = scanner.nextLine(); // 这句代码会立刻执行,根本不给用户输入的机会!
System.out.println("年龄: " + age + ", 姓名: '" + name + "'");
// 输出结果:
// 年龄: 25, 姓名: ''
问题根源
这就像吃东西,nextInt() 只吃了“数字”这个正餐,但把“回车”这个餐盘留下了。当 nextLine() 来的时候,它看到有个现成的“餐盘”(回车符),就直接收走了,导致它认为已经读取了一行(一个空行)。
解决方案
- 多吃一口: 在
nextInt()之后,再加一句scanner.nextLine();,专门用来“吃掉”那个被剩下的回车符。 - 最佳实践(推荐): 永远只用
nextLine()读取一整行。如果需要数字,再用Integer.parseInt()或Double.parseDouble()来转换。这样最稳妥。
// 最佳实践代码
Scanner scanner = new Scanner(System.in);
System.out.print("请输入您的年龄: ");
int age = Integer.parseInt(scanner.nextLine()); // 读取整行,再转成整数
System.out.print("请输入您的姓名: ");
String name = scanner.nextLine();
System.out.println("年龄: " + age + ", 姓名: '" + name + "'");
Scanner 进阶:一次读取多个数据
import java.util.Scanner;
public class MultiInputExample {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入两个整数 (用空格隔开): "); // 例如输入: 100 200
int a = scanner.nextInt();
int b = scanner.nextInt();
System.out.println("第一个数是: " + a);
System.out.println("第二个数是: " + b);
System.out.println("它们的和是: " + (a + b));
// 输出:
// 第一个数是: 1
// 第二个数是: 2
// 它们的和是: 3
}
}
为什么我们可以输入 1 2 然后按回车,就能给两个变量赋值?这揭示了 Scanner 更强大的工作原理。
核心概念:标记 (Token) 与分隔符 (Delimiter)
Scanner 并不只是按行读取,它实际上是在寻找“标记”。默认情况下,它使用任何空白字符(空格、Tab键、回车符)作为“分隔符”来切分用户的输入。
当你输入 1 2 并回车时,输入流中是 "1 2\n"。Scanner 会看到两个有效的数字标记:"1" 和 "2"。
nextInt() 的工作方式
当你调用 scanner.nextInt() 时,它会:
- 跳过所有开头遇到的分隔符。
- 开始读取,直到再次遇到分隔符为止,然后将这部分内容解析为整数。
所以,第一次 nextInt() 读取了 "1",第二次 nextInt() 跳过了中间的空格,读取了 "2"。
这个特性非常方便,但也再次解释了为什么 nextInt() 会留下换行符——因为它只关心找到下一个数字标记,而把作为分隔符的换行符留在了原地。
运算符
有了数据类型,我们还需要运算符来对这些数据进行操作和计算。
算术运算符
用于进行基本的数学运算:+, -, *, /, % (取模)。
整数除法:结果会舍去小数部分。
System.out.println(8 / 5); // 输出 1
字符串拼接:+号用于连接字符串。
System.out.println("分数: " + 95); // 输出 "分数: 95"
运算符有优先级,* / % 高于 + -,与数学一致。
括号与强制类型转换
括号 ()
用于提升运算优先级,与数学中用法一致。
System.out.println((10 + 2) * 5); // 输出 60
强制类型转换
用于将一个表示范围大的类型强制转换为小的类型。
int a = 8, b = 5;
double c = (double)a / b; // 将a转为double, 整个表达式结果为double
System.out.println(c); // 输出 1.6
注意:(double)(a/b) 是错误的,因为它先计算了整数除法 a/b 得到1,再将1转为1.0。
自增/自减运算符 (++, --)
这是Java中用于快速将变量值加一或减一的快捷方式。
- 前缀 (
++a,--a): 先变,后用。先完成变量的自增/自减,然后将新值用于表达式的计算。 - 后缀 (
a++,a--): 先用,后变。先使用变量的当前值参与表达式计算,然后才对变量进行自增/自减。
// 前缀示例
int a = 5;
int b = ++a; // a先变成6, 然后b被赋值为6
// 结果: a = 6, b = 6
// 后缀示例
int x = 5;
int y = x++; // 先将x的值(5)赋给y, 然后x再变成6
// 结果: x = 6, y = 5
理解“先变后用”与“先用后变”是掌握它们的关键。
面试题陷阱:请远离“奇技淫巧”
有些代码利用了++/--的运算顺序,写出了极其晦涩的表达式。请看下面的例题:
int a = 1;
int b = a++ + ++a + a * 2;
// 问:b 的值是多少?
运算过程拆解:
a++: 先用后变。表达式的这部分使用a的当前值1。之后,a变为2。++a: 先变后用。a先从2变为3。然后表达式的这部分使用新值3。a * 2: 此时a的值是3,所以这部分是3 * 2 = 6。- 最终计算:
b = 1 + 3 + 6,所以b的结果是10。
结论:绝对不要写这样的代码! 它严重破坏了代码的可读性,是典型的“炫技”而非工程实践。在团队协作中,清晰易懂的代码远比所谓的“技巧”重要得多。
复合赋值运算符
复合赋值运算符是算术运算符和赋值运算符的简写,能让代码更简洁。
int a = 8;
a += 4; // 等价于 a = a + 4; // a 现在是 12
a -= 2; // 等价于 a = a - 2; // a 现在是 10
a *= 3; // 等价于 a = a * 3; // a 现在是 30
a /= 5; // 等价于 a = a / 5; // a 现在是 6
a %= 4; // 等价于 a = a % 4; // a 现在是 2
一个重要的特性:自动类型转换
复合赋值运算符会自动进行强制类型转换,这在处理不同数据类型时非常方便。
short s = 1;
// s = s + 1; // 编译错误!因为 s + 1 的结果是 int 类型,不能直接赋给 short
s += 1; // 编译通过!等价于 s = (short)(s + 1);
使用复合赋值运算符不仅能简化代码,还能避免一些繁琐的类型转换问题。
关系与逻辑运算符
关系运算符
用于比较,结果为boolean:>, <, >=, <=,
==, !=
逻辑运算符
用于连接布尔表达式:&& (与), || (或), ! (非)
短路特性:&&中如果左边为false,则不计算右边;||中如果左边为true,则不计算右边。
int a = 10;
boolean b = a++ > 10 && ++a == 12; // a++ > 10 为 false
// 由于短路,&& 右边的 ++a 没有执行
System.out.println("a = "+a + ", b = "+b);
按位与移位:让你“看见二进制”
按位运算符直接对二进制位进行操作,常用于:权限位、掩码、性能敏感的低层逻辑、哈希与编码。
| 运算符 | 含义 | 例子(十进制) |
|---|---|---|
& |
按位与 | 6 & 3 == 2 |
| |
按位或 | 6 | 3 == 7 |
^ |
按位异或 | 6 ^ 3 == 5 |
~ |
按位取反 | ~0 == -1 |
<< |
左移(补 0) | 1 << 3 == 8 |
>> |
算术右移(补符号位) | -8 >> 1 == -4 |
>>> |
逻辑右移(补 0) | -1 >>> 1 变成一个很大的正数 |
最常见的坑:& 不是 &&
&&/||有短路;&/|即使是 boolean 也会“左右都算”- 想检查“奇偶”:
(x & 1) == 1(奇数)
int x = 6; // 110
int y = 3; // 011
System.out.println(x & y); // 010 -> 2
System.out.println(x | y); // 111 -> 7
System.out.println(x ^ y); // 101 -> 5
三元运算符
是if-else语句的简化形式。
判断条件 ? 结果1 : 结果2
如果判断条件为true,则表达式的结果为“结果1”;否则为“结果2”。
int score = 50;
String result = score >= 60 ? "及格" : "不及格";
System.out.println(result); // 输出 "不及格"
流程控制
程序默认从上到下执行。流程控制语句可以改变代码的执行顺序,实现条件判断和循环。
代码块与作用域
用花括号 { } 包围起来的一段代码称为一个代码块。
变量的作用域(Scope)指的是变量能够被访问的范围。一个变量只在它被定义时所在的代码块及其子块中有效。
public static void main(String[] args) {
int a = 10; // a的作用域是整个main方法
{
int b = 20; // b的作用域仅限于这个内部代码块
System.out.println(a); // 可以访问a
System.out.println(b); // 可以访问b
}
// System.out.println(b); // 错误!超出了b的作用域
}
选择结构:if-else-if
根据条件是否成立来决定执行哪段代码。
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
适用于对一个变量进行多个“等值”判断的情况。
支持的类型: switch 支持的变量类型随JDK版本演进:
- Java 5之前: 支持
byte,short,char,int。 - Java 5: 增加了对
enum(枚举) 类型的支持。 - Java 7: 增加了对
String类型的支持。
现代Java版本(12+)还引入了功能更强大的 "switch表达式",这是更高级的用法。
char level = 'B';
switch (level) {
case 'A':
System.out.println("优秀");
break; // break用于跳出switch, 否则会“穿透”到下一个case
case 'B':
System.out.println("良好");
break;
case 'C':
System.out.println("及格");
break;
default: // 如果以上都不匹配
System.out.println("不及格");
break;
}
switch 细节:fall-through 与 switch 表达式
fall-through(穿透)就是“没写 break,继续执行下一个 case”。有时是技巧,多数时候是坑。
int x = 1;
switch (x) {
case 1:
System.out.println("one");
case 2:
System.out.println("two"); // 会被打印(因为 case 1 没 break)
break;
default:
System.out.println("other");
}
Java 12+ 引入 switch 表达式:可以直接得到一个值(不用手写临时变量)。
String level = "B";
int score = switch (level) {
case "A" -> 95;
case "B" -> 85;
case "C" -> 70;
default -> 50;
};
建议:新代码优先使用“箭头”形式,减少 break 穿透带来的维护风险。
带标签 break/continue:跳出多层循环
普通的 break/continue 只能作用于最内层循环。带标签语法可以直接控制“跳到哪一层”。
public class LabeledLoopDemo {
public static void main(String[] args) {
int[][] grid = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
int target = 5;
boolean found = false;
outer:
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[i].length; j++) {
if (grid[i][j] == target) {
found = true;
break outer;
}
}
}
System.out.println(found ? "找到了" : "没找到");
}
}
建议:带标签控制流非常强,但也更“跳跃”。多数场景可以用“提取方法 + return”或“设置标志位”来替代,代码更容易维护。
循环结构:for 与调试器
System.out.println("准备就绪!"); }学习使用调试器 (Debugger):在行号旁单击设置断点,然后点击像“甲虫”一样的调试按钮运行。程序会在断点处暂停,你可以观察变量的值。
核心技能:代码调试 (Debugging)
学会调试是程序员最重要的能力之一。
它让你拥有“全知之眼”,可以暂停、审查、控制程序的每一步执行,是定位和修复问题的最强武器。
忘掉 System.out.println 吧! 调试器提供了更强大、更高效的手段。
调试第一步:设置断点 (Breakpoint)
断点是告诉调试器“当代码执行到这里时,请暂停”。
- 设置断点: 在代码编辑区的行号旁边,单击鼠标左键,会出现一个红点。
- 启动调试: 点击工具栏上的“甲虫”图标 (Debug),而不是“播放”图标 (Run)。
程序将正常运行,直到执行到你设置断点的那一行时,它会自动暂停,IDEA将切换到调试视图。
认识调试器面板
当程序暂停时,调试器面板会显示所有关键信息:
- Frames (调用栈): 显示了程序是如何一步步调用到当前位置的。你可以点击不同的栈帧来查看该层级的变量状态。
- Variables (变量): 显示当前作用域内所有变量的值。这是最有用的窗口,你可以实时看到每个变量的变化。
- Watches (监视): 可以手动添加你想持续观察的变量或表达式。
核心操作:单步执行 (Stepping)
单步执行允许你逐行控制代码的运行,是调试的精髓。
- Step Over (F8): 最常用 。执行当前行,然后移动到下一行。如果当前行有方法调用,它会执行完整个方法,而不会进入方法内部。
- Step Into (F7): 如果当前行有方法调用,此操作会进入该方法的内部,让你能逐行调试方法内的代码。
- Step Out (Shift+F8): 如果你已经在一个方法内部,此操作会执行完该方法余下的所有代码,然后返回到调用它的地方。
- Resume Program (F9): 继续执行程序,直到遇到下一个断点或程序结束。
把鼠标悬停在这些图标上,可以看到它们的名称和快捷键。
高级技巧:条件断点
有时候,你只想在特定条件下才暂停程序,例如在一个循环了1000次的for循环中,你只想在 `i == 999` 时暂停。
- 在断点(红点)上单击右键。
- 在出现的输入框中,填写你的条件,例如
i > 50。
这样,只有当这个布尔表达式为 true 时,程序才会在该断点处暂停。
高级技巧:表达式求值
在调试暂停时,你不仅可以查看变量,还可以动态执行代码片段来验证逻辑。
- 方法一:Watches窗口: 在Watches窗口中添加你想观察的表达式,例如
a + b > c。 - 方法二:Evaluate Expression (Alt+F8): 按下快捷键或右键菜单选择“Evaluate Expression”,会弹出一个窗口,你可以在里面输入任何合法的Java代码并立即看到结果。
这是验证复杂逻辑、测试边界条件、甚至在运行时“热修复”代码的强大工具。
循环结构:while 和 do-while
while 循环
只要循环条件为true,就一直执行循环体。适用于不确定循环次数的场景。
int i = 100;
while (i > 0) {
System.out.println(i);
i /= 2;
}
do-while 循环
与while类似,但它保证循环体至少执行一次。
do {
// 循环体
} while (循环条件);
循环控制:break 和 continue
break
立即终止并跳出当前循环。
continue
跳过本次循环的剩余部分,直接进入下一次循环。
带标签的 break
可以跳出指定的嵌套外层循环。
outer: for (int i = 1; i < 4; ++i) {
for (int j = 1; j < 4; ++j) {
if(i == j) break outer; // 当i==j时,直接终止外层循环
System.out.println(i + ", " + j);
}
}
带标签的 continue
可以跳过指定嵌套外层循环的本次迭代,直接进入外层循环的下一次迭代。
outer: for (int i = 1; i <= 3; ++i) {
for (int j = 1; j <= 3; ++j) {
if(i == 2) continue outer; // 当i==2时,跳过外层循环的本次迭代
System.out.println(i + ", " + j);
}
System.out.println("外层循环结束,i = " + i);
}
调试练习:调试登录失败
这个练习更有挑战性。下面的代码模拟了一个登录程序,预设的密码是 "password123"。然而,无论你输入多么正确的密码,程序总是提示“登录失败”。
这段代码在逻辑上看起来没有问题,但它隐藏着一个Java初学者最常犯的经典错误。请运用调试器,找到原因
import java.util.Scanner;
public class LoginFailure {
public static void main(String[] args) {
String correctPassword = "password123";
Scanner scanner = new Scanner(System.in);
System.out.print("请输入密码: ");
String inputPassword = scanner.nextLine();
if (inputPassword == correctPassword) {
System.out.println("登录成功!");
} else {
System.out.println("登录失败!密码不正确。");
}
}
}
调试提示
- 在
if (inputPassword == correctPassword)这一行设置断点。 - 以调试模式运行程序,并在控制台输入正确的密码 "password123"。
- 当程序在断点处暂停时,仔细观察
inputPassword和correctPassword这两个变量。 - 你会发现,尽管它们的值(字符序列)看起来完全一样,但它们的ID(内存地址)却不同。
- 核心问题:在Java中,
==用于比较对象的内存地址。对于字符串等对象,比较其内容是否相等应该使用.equals()方法。
这个练习告诉你:代码的“表面逻辑”和“底层实现”可能存在差异。调试器是看透这一切的“X光”。
调试练习:数组去重
给定一个有序数组,请原地删除重复元素,使每个元素只出现一次,并返回去重后的长度。
下面的代码“看起来”像是一个正确的双指针去重方法,但结果总是错。
请你用调试器找到 bug,并修复它。
public class RemoveDuplicatesDebug {
public static void main(String[] args) {
int[] nums = {1, 1, 2, 2, 3, 3, 4};
int len = removeDuplicates(nums);
System.out.println("新长度: " + len);
System.out.print("去重后数组: ");
for (int i = 0; i < len; i++) {
System.out.print(nums[i] + " ");
}
}
public static int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
int slow = 0;
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] == nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
}
调试提示
- 在
if (nums[fast] == nums[slow])这一行打断点,观察fast、slow和数组内容。 - 用样例
{1,1,2,2,3,3,4}单步执行,记录每次进入if的条件是否正确。 - 思考:双指针法里,什么时候才应该移动
slow并写入新值?是“相等”还是“不相等”时? - 修复后应输出:
新长度: 4,前四个元素为1 2 3 4。
参考修复代码
public static int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
int slow = 0;
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] != nums[slow]) { // 只在遇到新值时前进 slow
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
这个练习的核心是循环不变式:任意时刻,区间 [0, slow] 都是已经去重完成的有序结果。调试时要盯住这个不变式是否被破坏。
实战练习
现在,让我们用所学的知识来解决一些经典问题吧!
练习一:寻找水仙花数
任务:找出所有1000以内的“水仙花数”。
定义:一个3位数,它的每个位上的数字的3次幂之和等于它本身。例如:153 = 1³ + 5³ + 3³。
提示
- 使用
for循环遍历所有3位数 (100到999)。 - 在循环中,获取每个数的个位、十位、百位。
- 个位:
num % 10 - 十位:
(num / 10) % 10 - 百位:
num / 100
- 个位:
- 判断立方和是否等于原数,如果是,则打印。
练习二:打印九九乘法表
任务:在控制台打印出标准的九九乘法表。
提示
- 使用嵌套的
for循环。 - 外层循环控制行数 (从1到9)。
- 内层循环控制列数,注意内层循环的结束条件与外层循环的变量有关 (
j <= i)。 - 使用
System.out.print()打印每个表达式,并用制表符\t分隔。 - 在外层循环的末尾,使用
System.out.println()进行换行。
练习三:斐波那契数列
任务:获取斐波那契数列中第 n 位的值。
定义:一个数列,从第三项开始,每一项都等于前两项之和。数列:1, 1, 2, 3, 5, 8, 13, 21, ...
public static void main(String[] args) {
int target = xxx; // xx是从键盘读取的整数
int result = 0;
// 请在这里实现算法
System.out.println(result);
}
提示
- 处理特殊情况(如 target=1 或 target=2)。
- 定义两个变量,
a=1,b=1,表示数列的前两项。 - 使用
for循环,从第3项开始计算到第target项。 - 在循环中,计算
sum = a + b,然后更新a和b的值 (a = b; b = sum;)。 - 循环结束后,
b的值即为所求结果。
面向过程部分学习完毕
下一章,我们将正式进入面向对象的世界!