Java基础语法到面向对象详解
目录
- 一、Java程序是怎么跑起来的
- 二、基础语法
- 三、方法
- 四、数组
- 五、字符串
- 六、面向对象基础
- 七、面向对象进阶
- 八、常用API
- 九、集合
- 十、异常处理
- 十一、GUI编程入门——JFrame
- 十二、基础算法与位运算
- 十三、编程技巧积累
- 十四、综合练习实战
- 十五、踩过的坑
一、Java程序是怎么跑起来的
第一行Java代码大家都写过:
public class Main {
public static void main(String[] args) {
System.out.println("Hello, Java!");
}
}
这一小段代码里其实包含了很多信息,搞清楚它们对后面学面向对象很有帮助:
public class Main—— Java里所有代码都必须写在类里面,类是代码的容器。public static void main(String[] args)—— 程序的入口方法。static表示这个方法属于类本身,不需要创建对象。JVM启动时直接通过类名找到它来执行。System.out.println()—— 这是在调用System类里out对象上的println方法。你第一天写Java时,其实已经在用别人写好的类和对象了。
JDK的安装和IDEA的使用就不在这展开了,网上的教程很多。断点调试(Debug) 倒是值得从一开始就学会——打断点、单步执行、看变量值,排查逻辑bug的时候比肉眼读代码快得多。IDEA里在行号旁边点一下就能打断点,右键选Debug模式运行即可。
二、基础语法
变量和数据类型
Java是强类型语言,每个变量必须先声明类型。基本数据类型有4类8种:
| 类型 | 包含 | 占用 |
|---|---|---|
| 整型 | byte(1字节)、short(2)、int(4)、long(8) | 整数默认int |
| 浮点 | float(4)、double(8) | 小数默认double |
| 字符 | char(2) | 单个字符,用单引号 |
| 布尔 | boolean(1) | true/false |
String是引用类型,但它用得太多,经常被当成基本类型。
关于内存:基本类型的数据直接存在栈内存里,引用类型的数据存在堆内存里,变量本身只存地址。 这个区别到了后面学字符串比较、方法传参、面向对象时你会反复遇到。
int age = 25;
double price = 9.99;
char letter = 'A';
boolean isOpen = true;
String name = "张三";
运算符
常见的算术、比较、逻辑运算符都还好理解。三元运算符值得留意一下:
int max = a > b ? a : b; // 等价于 if-else,但一行搞定
在简单的条件赋值场景里比写完整的if-else清爽很多。
流程控制
if-else:
if (score >= 90) {
System.out.println("优秀");
} else if (score >= 60) {
System.out.println("及格");
} else {
System.out.println("不及格");
}
switch——传统写法每个case后面要加break,否则会"穿透"到下一个case。这个特性有时候是故意的(多个case共用一个处理逻辑),但大多数时候忘加break是个bug:
switch (day) {
case 1:
System.out.println("周一");
break;
case 2:
System.out.println("周二");
break;
default:
System.out.println("其他");
}
JDK高版本支持了增强switch,用箭头语法,更简洁且不会自动穿透:
switch (day) {
case 1 -> System.out.println("周一");
case 2 -> System.out.println("周二");
default -> System.out.println("其他");
}
循环——for最常用,while先判断再执行,do-while至少执行一次:
// for
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
// while:可能一次都不执行
while (condition) {
// ...
}
// do-while:至少执行一次
do {
// ...
} while (condition);
break终止整个循环,continue跳过当前这一次迭代。无限循环用for(;;)或者while(true),必须配合break退出,否则程序停不下来。
练习:逢7过
题目:打印1到100,但遇到7的倍数或者数字里包含7的就跳过(打印"过"),其余正常打印数字。
这个练习特别适合练for循环和条件判断。
思路:
- 用for循环从1遍历到100
- 对每个数字判断:是不是7的倍数?
i % 7 == 0 - 个位是不是7?
i % 10 == 7 - 十位是不是7?
i / 10 == 7 - 满足任意一个条件就打印"过",否则打印数字本身
代码:
public class 逢7过 {
public static void main(String[] args) {
for (int i = 1; i <= 100; i++) {
// 判断:7的倍数 或 个位是7 或 十位是7
if (i % 7 == 0 || i % 10 == 7 || i / 10 == 7) {
System.out.println("过");
} else {
System.out.println(i);
}
}
}
}
这道题容易写错的点是:i % 7 == 7这个条件永远不会成立(取余的结果最大是6),应该是i % 10 == 7来判断个位。
练习:猜数字
题目:程序随机生成一个0到20之间的数字,用户有5次机会猜。每次猜完提示"大了"还是"小了",猜对就结束游戏。
这个练习练的是随机数 + for循环 + 条件判断 + break的综合运用。
思路:
- 用
Random类生成一个0~20的随机数:new Random().nextInt(21) - 循环5次,每次让用户输入一个数字
- 判断:等于就break;小于就提示"太小了";大于就提示"太大了"
- 循环结束如果还没猜对,提示"机会用完"
代码:
import java.util.Random;
import java.util.Scanner;
public class 猜数字 {
public static void main(String[] args) {
Random random = new Random();
int answer = random.nextInt(21); // 0~20的随机数
Scanner scanner = new Scanner(System.in);
System.out.println("猜数字游戏开始!范围是0~20,你一共有5次机会。");
int i;
for (i = 1; i <= 5; i++) {
System.out.println("第" + i + "次,请输入你猜的数字:");
int guess = scanner.nextInt();
if (guess == answer) {
System.out.println("恭喜你,猜对啦!");
break;
} else if (guess < answer) {
System.out.println("太小了");
} else {
System.out.println("太大了");
}
}
if (i > 5) { // 循环正常结束,说明5次都没猜对
System.out.println("机会用完了,正确答案是:" + answer);
}
System.out.println("游戏结束");
}
}
两个细节值得注意:
- Scanner不要写在循环里面,循环外创建一个就够了。
- 判断"机会用完"不用额外的计数器,直接用循环变量
i——如果for里的break没执行,i就会变成6,此时i > 5说明5次全用完了。
练习:回文数
题目:输入一个数字,判断它是不是回文数(正着读和反着读一样,比如121、12321)。
这个练习练的是取模运算和数字反转的算法思维。
思路:
- 核心算法:把一个数字反转过来,然后和原数字比较
- 反转数字的方法:每次取出个位(
% 10),拼到新数字的末尾(新数字* 10 + 个位),然后原数字去掉个位(/ 10) - 举例:反转123,第一轮取3拼到0后面得3,第二轮取2拼到3后面得32,第三轮取1拼到32后面得321
代码:
import java.util.Scanner;
public class 回文数 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字:");
int input = scanner.nextInt();
int original = input; // 保存原值,因为input后面会被改掉
int reversed = 0; // 用来存反转后的数
while (input != 0) {
int digit = input % 10; // 取出个位
input = input / 10; // 去掉个位
reversed = reversed * 10 + digit; // 拼到反转数后面
}
if (original == reversed) {
System.out.println(original + " 是回文数");
} else {
System.out.println(original + " 不是回文数");
}
}
}
关键点:一定要用original变量保存原始输入值,因为while循环会把input改得面目全非。
练习:买飞机票
题目:机票价格按淡旺季和舱位计算折扣。旺季(5-10月)头等舱9折、经济舱8.5折;淡季(11月~次年4月)头等舱7折、经济舱6.5折。输入机票原价、月份和舱位类型,输出最终价格。
这道题练的是分支判断 + 方法封装,而且很适合用来学习如何提高代码的复用性。
思路:
- 先用if-else判断旺季还是淡季
- 在对应的分支里判断舱位类型,计算折扣
- 更进一步:把折扣计算抽成一个独立方法,传入原价和两个折扣值,让代码复用性更高
代码(带方法封装的版本):
import java.util.Scanner;
public class 买飞机票 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入机票原价:");
double price = scanner.nextDouble();
System.out.println("请输入月份:");
int month = scanner.nextInt();
System.out.println("请输入舱位(1-头等舱,2-经济舱):");
int type = scanner.nextInt();
double finalPrice;
if (month >= 5 && month <= 10) {
// 旺季:头等舱9折,经济舱8.5折
finalPrice = getPrice(price, type, 0.9, 0.85);
} else if ((month >= 1 && month <= 4) || (month >= 11 && month <= 12)) {
// 淡季:头等舱7折,经济舱6.5折
finalPrice = getPrice(price, type, 0.7, 0.65);
} else {
System.out.println("月份输入有误");
return;
}
System.out.println("最终票价:" + finalPrice);
}
// 计算折扣票价:v1是第一种舱位的折扣,v2是第二种舱位的折扣
public static double getPrice(double price, int type, double v1, double v2) {
if (type == 1) {
return price * v1;
} else if (type == 2) {
return price * v2;
} else {
System.out.println("舱位输入有误");
return -1;
}
}
}
用IDEA的 Ctrl+Alt+M 快捷键可以自动抽取方法——选中一段代码,按快捷键,IDEA会自动帮你把这段代码变成一个独立方法。写重复代码的时候这个功能特别好用。
另外注意一个细节:折扣是0.65而不是6.5。price * 6.5是涨价650%,别写错了。
三、方法
方法就是一段可以被重复调用的代码块。当代码写到一定程度,你会发现很多逻辑重复了,这时候就该抽方法了。
方法的定义和调用
// 无返回值的方法
public static void printHello() {
System.out.println("Hello");
}
// 有返回值的方法
public static int add(int a, int b) {
return a + b;
}
- 形参 vs 实参:定义时的参数叫形参,调用时传的值叫实参。
- 返回值:有返回值用
return把结果返出去;没返回值用void。void方法里也能写return;,但只是提前结束方法,不带值。 main方法本身也是一个方法——JVM启动时通过类名直接调用它。
方法重载
同一个类里可以写多个同名方法,只要参数列表不同就行(个数不同、类型不同、顺序不同都可以)。注意:返回值类型不同不算重载,编译器只看参数列表,只看参数列表,只看参数列表。
public static int add(int a, int b) { return a + b; }
public static double add(double a, double b) { return a + b; }
public static int add(int a, int b, int c) { return a + b + c; }
方法的内存原理
每次调用方法,JVM会在栈内存里给这个方法分配一块独立的空间(叫栈帧)。方法执行完,这块空间就释放了。所以方法里的局部变量和方法外的变量同名也没问题,互不影响。
这个原理听起来简单,但它是后面理解递归、理解方法传参为什么是"值传递"的关键。
练习:把数组操作封装成方法
学完方法后,最直接的练习就是把之前写在main里的数组操作一个个封装成独立方法,提高复用性。
题目:写四个方法——遍历数组、判断值是否存在、找最大值、拷贝数组。
代码:
public class 数组工具方法 {
public static void main(String[] args) {
int[] arr = {11, 22, 33, 44, 55};
printArray(arr); // 遍历
System.out.println(contains(arr, 33)); // 是否存在
System.out.println(getMax(arr)); // 最大值
int[] newArr = copyArray(arr); // 拷贝
printArray(newArr);
}
// 遍历数组
public static void printArray(int[] arr) {
System.out.print("[");
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1) {
System.out.print(", ");
}
}
System.out.println("]");
}
// 判断数组中是否存在某个值
public static boolean contains(int[] arr, int target) {
for (int num : arr) {
if (num == target) {
return true;
}
}
return false; // 循环走完都没找到,返回false
}
// 找最大值
public static int getMax(int[] arr) {
int max = arr[0]; // 假设第一个最大
for (int i = 1; i < arr.length; i++) { // 从第二个开始比
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
// 拷贝数组(返回一个新数组,内容一样但地址不同)
public static int[] copyArray(int[] arr) {
int[] newArr = new int[arr.length];
for (int i = 0; i < arr.length; i++) {
newArr[i] = arr[i];
}
return newArr;
}
}
几个细节:
contains方法用了循环布尔返回思想:在循环里找到就返回true,循环正常结束说明没找到,返回false。这个模式写查找类方法时非常常见。getMax里max初始值用了arr[0]而不是0——如果数组里全是负数,初始值0会让结果永远为0(因为没有任何负数比0大)。copyArray返回的是新数组,新旧数组内容一样但地址不同,修改新数组不会影响旧数组。
四、数组
一维数组
数组是用来存多个同类型数据的容器,长度一旦确定就不能改。
静态初始化——知道具体元素时用:
int[] arr1 = {1, 2, 3, 4, 5};
动态初始化——知道长度但还不知道存什么时用:
int[] arr2 = new int[5]; // 元素默认值都是0
数组的默认值规律:int是0,double是0.0,boolean是false,引用类型(比如String、自定义类)是null。
访问元素通过下标(从0开始),获取长度用arr.length。注意这是属性不是方法,跟后面字符串的.length()写法不一样,经常会搞混。
遍历数组:
// 普通for:需要下标的场景用
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
// 增强for:不需要下标时更简洁
for (int num : arr) {
System.out.println(num);
}
增强for里拿到的值是拷贝,修改它不会影响原数组。
二维数组
本质是"数组的数组",每个元素又是一个一维数组:
int[][] matrix = {{1, 2, 3}, {4, 5, 6}}; // 静态
int[][] matrix2 = new int[3][4]; // 动态:3行4列
遍历用嵌套for循环。
练习:数组元素反转
题目:把数组{1, 2, 3, 4, 5}的元素顺序反转,变成{5, 4, 3, 2, 1}。
这道题用到双指针的思想——两个指针同时从数组两端往中间走,交换它们指向的元素。
思路:
- 定义两个变量
i和j,i从头部开始(0),j从尾部开始(length-1) - 每次交换
arr[i]和arr[j],然后i++、j-- - 当
i >= j时停止(已经相遇或交错,说明交换完了)
代码:
public class 数组反转 {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
// 双指针交换
for (int i = 0, j = arr.length - 1; i < j; i++, j--) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 打印验证
for (int num : arr) {
System.out.print(num + " ");
}
}
}
for循环的初始化部分同时声明了i和j两个变量,这是合法的写法。终止条件是i < j——当两个指针相遇或错开时就不需要再换了。
练习:数组求最值
题目:找出数组{33, 5, 22, 44, 55}中的最大值和最小值。
思路:
- 设第一个元素为"当前最大值",从第二个元素开始遍历
- 如果某个元素比当前最大值还大,就更新最大值
- 最小值同理
代码:
public class 数组求最值 {
public static void main(String[] args) {
int[] arr = {33, 5, 22, 44, 55};
// 求最大值
int max = arr[0];
for (int i = 1; i < arr.length; i++) { // 从第二个开始
if (arr[i] > max) {
max = arr[i];
}
}
System.out.println("最大值:" + max);
// 求最小值
int min = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] < min) {
min = arr[i];
}
}
System.out.println("最小值:" + min);
}
}
为什么max初始值用arr[0]而不用0?如果数组里全是负数比如{-10, -20, -30},初始值0会让max永远是0,结果不对。用数组自己的元素作为基准才安全。
练习:二维数组计算季度营业额
题目:用一个二维数组存每个季度每个月的营业额,计算全年总营业额。
int[][] sales = {
{22, 66, 44}, // 第一季度三个月
{77, 33, 88}, // 第二季度
{25, 45, 65}, // 第三季度
{11, 66, 99} // 第四季度
};
代码:
public class 季度营业额 {
public static void main(String[] args) {
int[][] sales = {
{22, 66, 44},
{77, 33, 88},
{25, 45, 65},
{11, 66, 99}
};
int total = 0;
for (int i = 0; i < sales.length; i++) {
int quarterTotal = 0; // 当前季度营业额
for (int j = 0; j < sales[i].length; j++) {
quarterTotal += sales[i][j];
}
System.out.println("第" + (i + 1) + "季度营业额:" + quarterTotal);
total += quarterTotal;
}
System.out.println("全年总营业额:" + total);
}
}
这个练习的要点是理解二维数组的双层遍历结构:外层遍历"行"(季度),内层遍历"列"(每个季度的三个月)。
五、字符串
字符串是Java里用得最多的类型,也是我初学时踩坑最多的地方。
String的核心——不可变性
String对象一旦创建,内容就不能改变。 所有看似"修改"字符串的操作(拼接、替换、截取),实际都是创建了一个新的String对象,原来的对象并没有变。
String s = "Hello";
s = s + " World";
// s现在指向了新对象,原来的"Hello"对象被丢弃了(没有被修改)
为什么循环里用+=拼接效率低?因为每次拼接都会创建新对象、丢弃旧对象,产生大量中间垃圾。后面要学的StringBuilder就是专门解决这个问题的。
字符串常量池与==的坑
用双引号直接创建的字符串会放进字符串常量池。如果池里已经有内容相同的,就直接复用,不会创建新对象。而new String()不管池里有没有,都在堆里开一块新内存。
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // true,常量池里同一个对象
String s3 = new String("abc");
System.out.println(s1 == s3); // false,不同的对象
System.out.println(s1.equals(s3)); // true,内容相同
核心结论:比较字符串内容永远用equals(),不要用==。 ==比的是地址值,equals()比的是内容。这条规则背下来能省掉一堆莫名其妙的bug。
常用String方法
String s = "Hello World";
s.length(); // 获取长度 → 11
s.charAt(0); // 获取索引0的字符 → 'H'
s.substring(0, 5); // 截取[0,5) → "Hello"
s.substring(3); // 从索引3截到末尾 → "lo World"
s.replace("World", "Java"); // 替换 → "Hello Java"
s.split(" "); // 按空格分割 → ["Hello", "World"]
s.toCharArray(); // 转为字符数组 → ['H','e','l','l','o',' ','W','o','r','l','d']
s.indexOf("o"); // 查找"o"第一次出现的位置 → 4
s.startsWith("He"); // 判断开头 → true
StringBuilder——高效拼接
StringBuilder内部维护一个可变的字符数组,拼接时直接往数组后面加,不会创建新对象:
StringBuilder sb = new StringBuilder();
sb.append("Hello").append(" ").append("World"); // 支持链式编程
String result = sb.toString();
为什么能链式编程?因为append()返回的是this(当前对象),所以能一直.下去。
StringBuilder有两个概念要分清:容量(capacity) 是内部数组的大小(不够会自动扩容),长度(length) 是实际存了多少字符。
StringJoiner——带分隔符的拼接
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("a").add("b").add("c");
System.out.println(sj); // [a, b, c]
三个参数:分隔符、前缀、后缀。和StringBuilder一样也支持链式编程。
三种字符串类型的区别:
- String:不可变,适合少量操作
- StringBuilder:可变,适合频繁拼接,线程不安全但速度快
- StringJoiner:可变,适合拼接带分隔符和前后缀的字符串
练习:模拟用户登录
题目:已知正确的用户名是root,密码是user123456。用户有3次登录机会,输入正确就提示成功并结束,3次都错就锁定账号。
这道题练的是equals比较字符串、for循环控制次数、以及用户体验细节的处理。
思路:
- 用for循环控制3次机会
- 用
equals()比较用户输入和预设值 - 登录成功就break跳出
- 第3次错误时提示"账号被锁定"
- 非最后一次错误时提示剩余次数
代码:
import java.util.Scanner;
public class 模拟登录 {
public static void main(String[] args) {
String correctName = "root";
String correctPassword = "user123456";
Scanner sc = new Scanner(System.in);
for (int i = 0; i < 3; i++) {
System.out.println("请输入用户名:");
String name = sc.next();
System.out.println("请输入密码:");
String password = sc.next();
if (correctName.equals(name) && correctPassword.equals(password)) {
System.out.println("登录成功!");
break;
} else {
if (i == 2) {
// 第三次错误(i从0开始,i==2是第三次)
System.out.println("用户名" + name + "被锁定,请联系管理员");
} else {
System.out.println("用户名或密码错误,剩余" + (2 - i) + "次机会");
}
}
}
}
}
一个容易忽略的细节:correctName.equals(name)而不是name.equals(correctName)。为什么?如果name是null,name.equals(...)直接空指针异常。把已知的非null字符串放前面调equals更安全。不过这里name是从Scanner读的,不会是null。但养成这个习惯没坏处。
练习:手机号屏蔽
题目:输入一个11位手机号,把中间四位换成****。例如13112349494变成131****9494。
这道题练的是substring()截取和拼接。
思路:
- 用
substring(0, 3)截取前三位 - 用
substring(7)截取后四位(从索引7到末尾) - 拼接:前三位 +
****+ 后四位
代码:
import java.util.Scanner;
public class 手机号屏蔽 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入11位手机号:");
String phone = scanner.next();
// 截取前三位和后四位
String start = phone.substring(0, 3);
String end = phone.substring(7);
// 拼接
String result = start + "****" + end;
System.out.println(result);
}
}
这个写法比用charAt逐个取字符再拼接简洁得多。substring(7)只传一个参数表示从索引7一直截取到字符串末尾。
练习:身份证信息查看
题目:输入18位身份证号,解析出出生年月日和性别。
思路(基于身份证编码规则):
- 第714位是出生日期:第710位是年份,第1112位是月份,第1314位是日期
- 第17位是性别:奇数是男性,偶数是女性
- 用
substring()截取对应的位 - 用
charAt()取第17位,转成数字判断奇偶
代码:
import java.util.Scanner;
public class 身份证信息 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入18位身份证号:");
String id = scanner.next();
// 出生日期:第7~14位
String year = id.substring(6, 10);
String month = id.substring(10, 12);
String day = id.substring(12, 14);
System.out.println("出生日期:" + year + "年" + month + "月" + day + "日");
// 性别:第17位
char genderChar = id.charAt(16);
int genderNum = genderChar - '0'; // 字符转数字
if (genderNum % 2 == 0) {
System.out.println("性别:女");
} else {
System.out.println("性别:男");
}
}
}
注意substring的索引计算:身份证号第一位是索引0,所以第7位对应索引6,第14位对应索引13,截取范围是substring(6, 14),其中年份用(6, 10)。动手在纸上画个索引对照图就不会搞错了。
另外char转int不能直接强转(那样得到的是ASCII码),这里用genderChar - '0'把字符数字转成真正的整数,也可以在Character.getNumericValue()来实现。
六、面向对象基础
从这里开始,Java的重点从"怎么写代码"变成了"怎么组织代码"。面向对象不是说多学几个语法就完了,它是一种看待问题的方式。
类与对象
类是模板,对象是模板造出来的实例。一个类最基本的两个成员是属性(成员变量)和行为(成员方法)。
public class Phone {
String brand;
double price;
public void call() {
System.out.println("打电话");
}
}
// 使用
Phone p = new Phone();
p.brand = "小米";
p.price = 2999;
p.call();
一个.java文件里可以写多个类,但只能有一个public类,且文件名必须和这个public类名一致。
封装
用private把成员变量藏起来,用public的getter/setter对外提供访问入口:
public class Girl {
private int age;
public void setAge(int age) {
if (age >= 0 && age <= 120) {
this.age = age;
} else {
System.out.println("年龄不合法");
}
}
public int getAge() {
return age;
}
}
封装的好处:一是可以在setter里加数据校验,二是以后你改了age的内部存储方式,外部所有用getAge()的代码都不需要改动。
this关键字
当局部变量和成员变量同名时,Java就近使用局部变量。this就是用来明确表示"当前对象的成员变量"的:
public void setName(String name) {
this.name = name; // this.name是成员,name是参数
}
this本质是指向调用该方法的那个对象的引用。
构造方法
new对象时自动执行,用来初始化对象:
public class Student {
private String name;
private int age;
public Student() {} // 无参构造
public Student(String name, int age) { // 有参构造
this.name = name;
this.age = age;
}
}
关键规则:如果你一个构造方法都不写,Java会自动送一个无参空构造。但只要你写了任何一个,它就不再送了。所以标准写法是两个都写。无参构造在框架反射创建对象时是必须的。
标准JavaBean
这是Java生态里的一种约定写法:
- 成员变量用
private - 每个成员变量配
public的getter和setter - 必须有无参构造
- 按需提供有参构造
用IDEA的Alt+Insert一键生成。
对象的内存图
- 栈内存:存局部变量和方法调用信息,方法执行完就释放
- 堆内存:存
new出来的对象和数组,不再被引用时由GC回收 - 方法区:存类的信息
理解了这个,基本类型和引用类型在方法传参时的区别就清楚了——基本类型传参不影响原值(传的是值拷贝),引用类型传参改的是同一个堆里的对象(传的是地址拷贝,但指向同一个对象)。
练习:用对象数组存商品信息
题目:定义一个商品类(Shopping),有id、名称、价格、库存四个属性。然后创建一个商品数组,存储三个商品对象,最后遍历输出每个商品的信息。
这道题是JavaBean定义 + 对象数组的综合练习。
思路:
- 先定义一个标准的Shopping类(JavaBean)
- 在测试类里创建Shopping类型的数组
- 创建三个Shopping对象存入数组
- 遍历数组输出信息
代码:
// Shopping.java —— 标准JavaBean
public class Shopping {
private String id;
private String name;
private double price;
private int stock;
public Shopping() {}
public Shopping(String id, String name, double price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
}
// ShoppingTest.java —— 测试类
public class ShoppingTest {
public static void main(String[] args) {
// 创建商品数组
Shopping[] goods = new Shopping[3];
// 存入商品对象
goods[0] = new Shopping("001", "笔记本", 5999.0, 50);
goods[1] = new Shopping("002", "键盘", 299.0, 200);
goods[2] = new Shopping("003", "鼠标", 149.0, 150);
// 遍历输出
System.out.println("编号\t名称\t价格\t库存");
for (int i = 0; i < goods.length; i++) {
Shopping s = goods[i];
System.out.println(s.getId() + "\t" + s.getName()
+ "\t" + s.getPrice() + "\t" + s.getStock());
}
}
}
这个写法里Shopping[] goods = new Shopping[3]创建的是引用类型的数组,数组里存的不是对象本身,而是对象的地址。初始时每个元素都是null,需要逐个new对象再放进去。
七、面向对象进阶
static——属于类的东西
static修饰的变量和方法属于类本身,不属于某个对象,所有对象共享同一份。
public class Student {
private String name;
private static String teacherName; // 所有学生共享同一个班主任
}
工具类的经典写法:构造方法私有化,所有方法静态化,让人只能用类名调用,不能创建对象:
public class ArrayUtil {
private ArrayUtil() {} // 私有构造,不让人new
public static int getMax(int[] arr) {
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
}
}
// 调用:ArrayUtil.getMax(arr),不用new对象
静态方法里不能直接访问非静态的成员——因为静态方法可以用类名直接调用,这时候可能根本没有对象存在。
练习:写一个数组工具类
把前面写的数组相关方法整合到工具类里,体会static的实际应用:
public class ArrayUtil {
private ArrayUtil() {} // 工具类不需要创建对象
public static void printArray(int[] arr) {
System.out.print("[");
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1) System.out.print(", ");
}
System.out.println("]");
}
public static boolean contains(int[] arr, int target) {
for (int num : arr) {
if (num == target) return true;
}
return false;
}
public static int getMax(int[] arr) {
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
}
}
测试类直接用ArrayUtil.getMax(arr)、ArrayUtil.printArray(arr)调用,非常方便。这就是Java里Math、Arrays等系统工具类的设计思路。
继承——"是"的关系
继承描述is-a关系。用extends关键字,Java只支持单继承(一个类只能有一个爹)。
public class Animal {
public void eat() { System.out.println("吃东西"); }
}
public class Cat extends Animal {
public void meow() { System.out.println("喵喵叫"); }
}
子类能继承父类的哪些东西?
- 构造方法:不能继承,但子类构造必须调用父类构造(默认调无参,想调有参用
super(参数)) - 成员变量:都能继承,但
private的不能直接访问,要通过getter/setter - 成员方法:
private的不能继承;非private、非static、非final的能继承和重写
成员变量和方法的访问特点:就近原则——局部->本类成员->父类成员。this明确找本类的,super明确找父类的。
方法重写(Override)
子类写一个和父类方法签名完全一样的方法:
@Override // 强烈建议加上!帮你检查是否真的重写了
public void eat() {
System.out.println("猫吃鱼");
}
重写规则:子类访问权限不能比父类低,private和static的方法不能被重写。
多态——同一个行为,不同表现
写法上就是父类引用指向子类对象:
Animal a = new Cat(); // 父类引用指向子类对象
a.eat(); // 执行的是Cat重写后的eat方法
多态调用成员的特点:
- 成员变量:编译看左边,运行也看左边(变量没有重写)
- 成员方法:编译看左边,运行看右边(执行子类重写版本)
多态的好处:方法参数写成父类类型,传任何子类对象都行,一个方法适配多种类型。
弊端和解决:父类引用调不了子类独有方法。需要调的话先instanceof判断,再向下转型:
if (a instanceof Cat) {
Cat c = (Cat) a;
c.meow();
}
抽象类
当父类某个方法没法写具体实现(每个子类的做法都不一样),就声明成抽象方法。有抽象方法的类必须是抽象类:
public abstract class Animal {
public abstract void eat(); // 只定义规范,不写实现
}
public class Dog extends Animal {
@Override
public void eat() {
System.out.println("狗吃骨头");
}
}
抽象类不能直接new,必须通过子类实例化。
接口——定义"能做什么"
接口只定义规则。一个类只能继承一个父类,但能实现多个接口:
public interface Swim {
void swim(); // 默认就是 public abstract
}
public class Frog extends Animal implements Swim {
@Override
public void eat() { System.out.println("青蛙吃虫子"); }
@Override
public void swim() { System.out.println("青蛙蛙泳"); }
}
JDK 8新特性:default方法(有方法体的默认方法,子类可直接用或重写)、static方法(直接用接口名调用)。
JDK 9新特性:private方法(给default或static方法内部复用)。
接口和抽象类的区别:抽象类描述"是什么",接口描述"能做什么"。一个类只能继承一个抽象类,但能实现多个接口。
练习:动物继承体系设计
题目:设计一个完整的动物继承体系。动物类有吃的方法,下分猫类和狗类,猫下面再分布偶猫和狸花猫,狗下面再分哈士奇和泰迪。
这道题的核心是练习多层继承结构的设计。
思路:
- 动物类:有
eat()方法 - 狗类继承动物,猫类继承动物
- 哈士奇和泰迪继承狗,布偶猫和狸花猫继承猫
- 每一层都可以重写
eat()方法,加入自己的特色
代码:
// 动物类(顶层父类)
public class DongWu {
private String name;
public DongWu() {}
public DongWu(String name) { this.name = name; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public void eat() {
System.out.println(name + "在吃东西");
}
}
// 狗类
public class Dog extends DongWu {
public Dog() {}
public Dog(String name) { super(name); }
@Override
public void eat() {
System.out.println(getName() + "在吃狗粮");
}
}
// 猫类
public class Cat extends DongWu {
public Cat() {}
public Cat(String name) { super(name); }
@Override
public void eat() {
System.out.println(getName() + "在吃鱼");
}
}
// 哈士奇
public class HaShiQi extends Dog {
public HaShiQi() {}
public HaShiQi(String name) { super(name); }
@Override
public void eat() {
System.out.println(getName() + "一边拆家一边吃");
}
}
// 狸花猫
public class LiHua extends Cat {
public LiHua() {}
public LiHua(String name) { super(name); }
@Override
public void eat() {
System.out.println(getName() + "抓老鼠吃");
}
}
测试类用多态来写:
public class DemoTest {
public static void main(String[] args) {
DongWu[] animals = {
new HaShiQi("二哈"),
new LiHua("大橘"),
new Dog("旺财"),
new Cat("咪咪")
};
for (DongWu animal : animals) {
animal.eat(); // 多态:实际执行的是各子类重写后的方法
}
}
}
输出:
二哈一边拆家一边吃
大橘抓老鼠吃
旺财在吃狗粮
咪咪在吃鱼
同一个eat()调用,不同的子类有不同表现——这就是多态。注意DongWu[]数组存的是父类类型,但每个元素都是子类对象,调用方法时执行的是子类重写后的版本。
内部类
类里面定义的类,分四种:
- 成员内部类:在类里方法外。
new Outer().new Inner() - 静态内部类:加了static。
new Outer.Inner() - 局部内部类:在方法里,作用域只在方法内
- 匿名内部类:最常用,创建对象的同时定义子类:
Swim s = new Swim() {
@Override
public void swim() {
System.out.println("匿名内部类的游泳方式");
}
};
s.swim();
匿名内部类在写事件监听器时特别常用,后面GUI部分会实际用到。
包、权限修饰符、final、代码块
包:管理类、避免重名。规范写法com.example.project。
权限修饰符:public(到处能用) > protected(本类+同包+不同包子类) > 默认(本类+同包) > private(只有本类)。实际开发public和private最常用。
final:修饰变量变常量、修饰方法不能重写、修饰类不能继承。
代码块:静态代码块(类加载时执行一次,初始化静态数据),实例代码块(每次new对象时在构造方法前执行)。
八、常用API
Math——数学运算
全是静态方法:
Math.abs(-5); // 绝对值 → 5
Math.ceil(3.3); // 向上取整 → 4.0
Math.floor(3.8); // 向下取整 → 3.0
Math.round(3.5); // 四舍五入 → 4
Math.max(3, 8); // 最大值
Math.pow(2, 3); // 2的3次方 → 8.0
Math.sqrt(16); // 开平方 → 4.0
Math.cbrt(27); // 开立方 → 3.0
Math.random(); // [0.0, 1.0) 随机小数
System——系统相关
System.exit(0); // 终止JVM,0正常退出
// 计算代码执行时间
long start = System.currentTimeMillis();
// ... 要计时的代码 ...
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
System.arraycopy(src, 0, dest, 0, length); // 数组拷贝
Runtime——运行环境
Runtime.getRuntime()获取单例对象,可以执行系统命令、查看JVM内存。
Object——所有类的老祖宗
equals:默认比地址(和==一样),自定义类通常要重写成比较内容。
clone:克隆对象。实现Cloneable接口,重写clone()方法。默认是浅克隆(只复制引用),深克隆要自己递归克隆引用指向的对象。
Objects——安全的工具类
Objects.equals(a, b); // 内部做了null判断,比a.equals(b)更安全
BigInteger和BigDecimal
BigInteger bi = new BigInteger("99999999999999999999"); // 超大整数
BigDecimal bd = new BigDecimal("0.1"); // 精确小数,金融计算用
bd.add(new BigDecimal("0.2")); // 精确的0.3
BigDecimal除法时如果除不尽要指定精度,否则抛异常。
九、集合
数组长度固定,动态数据用集合。ArrayList是最常用的。
ArrayList核心操作
ArrayList<String> list = new ArrayList<>();
list.add("元素"); // 添加
list.remove(0); // 按索引删
list.remove("元素"); // 按内容删
list.set(0, "新值"); // 修改
String s = list.get(0); // 查询
int size = list.size(); // 长度
// 遍历
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
for (String str : list) { // 增强for也行
System.out.println(str);
}
包装类与泛型
集合不能存基本类型,要包一层。int→Integer,char→Character,其余首字母大写。JDK 5之后自动装箱拆箱,写起来很方便。
ArrayList<Integer> list = new ArrayList<>();
list.add(10); // 自动装箱 int → Integer
int num = list.get(0); // 自动拆箱 Integer → int
ArrayList<String>里的<>就是泛型,限制集合只能放什么类型。不加泛型什么都能放但取出来全是Object,得手动强转,不安全。
如果集合不加泛型,取元素时是这样的:String s = (String) list.get(0); ——每次都要强转,而且编译期不会帮你检查类型,运行时如果类型不对就抛ClassCastException。
练习:往集合存学生对象并遍历
题目:定义Student类(JavaBean),创建ArrayList存三个学生对象,然后遍历输出。
import java.util.ArrayList;
public class StudentTest {
public static void main(String[] args) {
ArrayList<Student> list = new ArrayList<>();
list.add(new Student("001", "张三", 18, "北京"));
list.add(new Student("002", "李四", 19, "上海"));
list.add(new Student("003", "王五", 20, "广州"));
System.out.println("学号\t姓名\t年龄\t地址");
for (int i = 0; i < list.size(); i++) {
Student s = list.get(i);
System.out.println(s.getId() + "\t" + s.getName()
+ "\t" + s.getAge() + "\t" + s.getAddress());
}
}
}
练习:判断集合中是否存在某个用户
这个练习的关键点是搞清楚contains()方法到底是怎么判断"存在"的。
题目:往集合里添加用户对象,然后判断某个id的用户是否已存在。
contains()底层用的是equals()方法。如果User类没有重写equals(),那用的是Object的默认实现(比较地址值),两个内容相同但地址不同的对象会被判断为"不存在"。
所以必须重写User类的equals()方法,改成按id比较内容:
// User类中重写equals
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
User user = (User) obj;
return this.id.equals(user.id); // 按id比较
}
IDEA也可以用Alt+Insert一键生成equals和hashCode的重写代码。这个知识点在后面学HashMap的时候会再次遇到——hashCode和equals必须配套重写。
十、异常处理
程序运行中难免出问题,异常处理就是给这些问题兜底。
try {
int result = 10 / 0; // 可能出问题的代码
} catch (ArithmeticException e) {
System.out.println("除数不能为0"); // 问题发生了怎么处理
}
try里某行抛出异常后,后面的代码不执行,直接跳catch。catch可以写多个,分别捕获不同类型的异常。
处理不了的异常可以往上抛——方法签名加throws。但最外层一定要兜住,否则程序崩了用户看到的是难看的异常堆栈。
十一、GUI编程入门——JFrame
用Swing的JFrame做简单的图形界面。Java在桌面GUI领域已经不是主流了,但学这个的目的不是做桌面软件,而是理解事件驱动这种编程模型。
创建一个简单窗口
import javax.swing.*;
public class JFrameDemo {
public static void main(String[] args) {
JFrame frame = new JFrame("我的第一个窗口");
frame.setSize(400, 300);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
}
}
事件监听——让按钮有反应
GUI的核心是"用户做了什么,程序就反应什么",通过监听器实现。三种监听器:
ActionListener(动作监听)——点击按钮触发:
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class 事件监听Demo {
public static void main(String[] args) {
JFrame frame = new JFrame("事件监听");
frame.setSize(300, 200);
frame.setLayout(null);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
JButton button = new JButton("点我");
button.setBounds(100, 60, 100, 40);
// 匿名内部类写监听器
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("按钮被点击了!");
}
});
frame.add(button);
frame.setVisible(true);
}
}
这里用匿名内部类写监听器——直接在new ActionListener()的同时定义实现。这是匿名内部类最经典的应用场景,也是为什么前面说它是四种内部类里最常用的。
KeyListener(键盘监听)——监听按键事件,可以区分按下、释放、敲击。
MouseListener(鼠标监听)——监听鼠标点击、进入组件、离开组件。
十二、基础算法与位运算
二分搜索
在有序数组中查找目标值,每次砍掉一半,时间复杂度O(log n)。
public static int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (arr[mid] == target) {
return mid; // 找到了
} else if (arr[mid] < target) {
left = mid + 1; // 目标在右边
} else {
right = mid - 1; // 目标在左边
}
}
return -1; // 没找到
}
二分搜索的边界条件容易写错,关键是left <= right(不是left < right),以及每次移动时mid + 1和mid - 1(不能直接等于mid,否则会死循环)。
二分搜索还能扩展:查找目标值第一次出现的位置、最后一次出现的位置,只需要在找到目标后不立即返回,而是继续往左或往右搜索。
冒泡排序
每轮把最大值"冒"到最后面,O(n^2),实现简单适合入门:
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
内层循环的arr.length - 1 - i是因为每轮都确定了一个最大值,最后i个元素已经排好了,不需要再比。
快速排序
选基准值,小于它的放左边,大于的放右边,递归处理两边,平均O(n log n):
public static void quickSort(int[] arr, int left, int right) {
if (left >= right) return;
int pivot = arr[left]; // 基准值
int i = left, j = right;
while (i < j) {
while (i < j && arr[j] >= pivot) j--; // 从右找比基准小的
while (i < j && arr[i] <= pivot) i++; // 从左找比基准大的
if (i < j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 基准归位
arr[left] = arr[i];
arr[i] = pivot;
quickSort(arr, left, i - 1); // 递归左边
quickSort(arr, i + 1, right); // 递归右边
}
位运算
位运算直接操作二进制位,速度极快:
- 按位与判断奇偶:
n & 1结果为1是奇数,0是偶数。比n % 2快 - 移位做乘除:
n << 1相当于乘2,n >> 1相当于除2 - 异或:
a ^ a = 0,a ^ 0 = a。可以用来找数组中只出现一次的数 - 按位或/按位与:常用于权限标志位的设置和判断
前缀和与双指针
前缀和:预处理出前n项和,之后查任意区间和只需O(1)。
双指针:两个下标同时遍历,常用于有序数组的配对、反转等问题。
十三、编程技巧积累
学习过程中攒了不少实用的编程技巧,不是语法知识,但每次写代码都会用到:
计数器思想:定义一个count = 0,满足条件就count++,最后看count的值。统计类问题基本都这么解决。
循环布尔返回思想:在循环里检查条件,一满足就返回对应结果。循环跑完说明没找到,返回另一种结果。这是写查找方法的万能模板。
查表法:提前把各种情况的对应值存进数组,通过下标直接获取。比如金额转换——把所有中文大写数字存入数组{"零","壹","贰",...},然后通过数字下标去取对应中文。可以避免写一堆if-else。
链式编程:方法返回this,就能一直.下去。StringBuilder是典型。
假索引思想:用int变量模拟指针,在不改变原数据的情况下跟踪状态。写算法题时常用。
递归:方法自己调自己,一定要有出口条件(终止条件),否则栈溢出。代码简洁但大量递归会有性能开销。
一些小技巧也很有用——for和while和if可以省略大括号写成一行(但可读性会下降)、int类型的除法是直接截断不是四舍五入、以及写if判断时if (flag)比if (flag == true)更简洁。
十四、综合练习实战
前面每个模块下的练习都是针对单个知识点的。下面这几个是把多个知识点串起来的小项目,完整走一遍下来,前面的知识才能真正变成手上的功夫。
学生管理系统
这是课程里最重要的综合项目。需求很简单,但写出来需要用到JavaBean、ArrayList、Scanner、switch、方法封装等一堆知识点。
需求:做一个学生信息管理系统,支持以下功能:
- 添加学生(学号不能重复)
- 删除学生(按学号删除)
- 修改学生信息
- 查询所有学生
- 退出系统
设计思路:
- 先写一个Student类(JavaBean),四个属性:学号、姓名、年龄、地址
- 用
ArrayList<Student>存储所有学生数据 - 主界面用while(true)循环 + switch判断用户选择
- 每个功能抽成一个独立方法
- 额外写一个判断学号是否存在的方法,增删改都会用到
第一步:定义Student类(JavaBean)
public class Student {
private String id;
private String name;
private int age;
private String address;
public Student() {}
public Student(String id, String name, int age, String address) {
this.id = id;
this.name = name;
this.age = age;
this.address = address;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
}
第二步:写判断学号是否存在的方法
这个方法被增删改三个功能共用——添加时要判断学号不要重复,删除和修改时要判断学号确实存在:
// 判断学号是否已存在
public static boolean isIdExist(ArrayList<Student> list, String id) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i).getId().equals(id)) {
return true;
}
}
return false;
}
这就是前面说的循环布尔返回思想——找到了返回true,循环跑完说明没找到,返回false。
第三步:写添加学生的方法
public static void addStudent(ArrayList<Student> list) {
Scanner scanner = new Scanner(System.in);
Student s = new Student();
// 学号不能重复,用死循环直到用户输入一个不重复的学号
while (true) {
System.out.println("请输入学号:");
String id = scanner.next();
if (isIdExist(list, id)) {
System.out.println("学号已存在,请重新输入");
} else {
s.setId(id);
break;
}
}
System.out.println("请输入姓名:");
s.setName(scanner.next());
System.out.println("请输入年龄:");
s.setAge(scanner.nextInt());
System.out.println("请输入地址:");
s.setAddress(scanner.next());
list.add(s);
System.out.println("添加成功!");
}
第四步:写删除学生的方法
public static void deleteStudent(ArrayList<Student> list) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入要删除的学号:");
String id = scanner.next();
if (!isIdExist(list, id)) {
System.out.println("学号不存在,删除失败");
return; // 提前返回,不用写else套一大段代码
}
// 找到了,找出对应的索引并删除
for (int i = 0; i < list.size(); i++) {
if (list.get(i).getId().equals(id)) {
list.remove(i);
System.out.println("删除成功!");
break;
}
}
}
这里我学会了用return提前结束方法,避免用一大段if-else嵌套。if (!存在) { 打印错误; return; } 这种写法叫卫语句,能让代码读起来更顺畅。
第五步:写修改学生的方法
public static void updateStudent(ArrayList<Student> list) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入要修改的学号:");
String id = scanner.next();
if (!isIdExist(list, id)) {
System.out.println("学号不存在");
return;
}
// 找到对应的学生,修改除学号外的其他信息
for (int i = 0; i < list.size(); i++) {
Student s = list.get(i);
if (s.getId().equals(id)) {
System.out.println("请输入新姓名:");
s.setName(scanner.next());
System.out.println("请输入新年龄:");
s.setAge(scanner.nextInt());
System.out.println("请输入新地址:");
s.setAddress(scanner.next());
System.out.println("修改成功!");
break;
}
}
}
注意:修改时拿到的是集合里学生对象的引用,直接调set方法改的就是集合里的那个对象,不需要再list.set(i, xxx)。这是引用类型的特点——你在方法里拿到的是对象的地址,通过地址找到对象直接改,集合里存的也是同一个地址。
第六步:写查询学生的方法
public static void selectStudent(ArrayList<Student> list) {
if (list.isEmpty()) {
System.out.println("暂无学生信息,请先添加");
return;
}
System.out.println("学号\t姓名\t年龄\t地址");
for (int i = 0; i < list.size(); i++) {
Student s = list.get(i);
System.out.println(s.getId() + "\t" + s.getName()
+ "\t" + s.getAge() + "\t" + s.getAddress());
}
}
第七步:组装主界面
import java.util.ArrayList;
import java.util.Scanner;
public class StudentTest {
public static void main(String[] args) {
ArrayList<Student> list = new ArrayList<>();
// 主循环
while (true) {
System.out.println("-------------欢迎来到学生管理系统-------------");
System.out.println("1:添加学生");
System.out.println("2:删除学生");
System.out.println("3:修改学生");
System.out.println("4:查询学生");
System.out.println("5:退出");
System.out.print("请选择:");
Scanner scanner = new Scanner(System.in);
int choice = scanner.nextInt();
switch (choice) {
case 1 -> addStudent(list);
case 2 -> deleteStudent(list);
case 3 -> updateStudent(list);
case 4 -> selectStudent(list);
case 5 -> {
System.out.println("再见!");
return; // 直接结束main方法,退出程序
}
default -> System.out.println("输入有误,请重新选择");
}
}
}
// 上面第二步到第六步的方法写在这里
// ...
}
全部写完之后我做了两个版本的优化:
- 升级版:增加了账号密码登录功能。用User类存用户名和密码,登录成功才进入主界面。这意味着系统里有两类对象——User负责登录,Student负责数据管理,各司其职。
- 复盘版:从头到尾手写了一遍,不看原代码。这个过程让我发现了很多之前"眼熟但写不出来"的地方,特别是方法之间的参数传递和return的位置。
文字格斗游戏
用纯面向对象的方式做一个文字版格斗游戏。这个项目很好地示范了怎么把现实世界的事物抽象成类和对象。
需求:两个角色互相攻击,每回合造成1~20点随机伤害,血量降到0的一方输掉比赛。
设计思路:
- 角色抽成Role类:属性有姓名和血量
- Role类有一个攻击方法
attack(Role target)——攻击目标角色,造成随机伤害 - 主测试类RoleTest创建两个角色,用while循环模拟回合制战斗
Role类代码:
import java.util.Random;
public class Role {
private String name;
private int blood;
public Role() {}
public Role(String name, int blood) {
this.name = name;
this.blood = blood;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getBlood() { return blood; }
public void setBlood(int blood) { this.blood = blood; }
// 攻击方法:this攻击传入的role
public void attack(Role target) {
Random r = new Random();
int hurt = r.nextInt(20) + 1; // 1~20随机伤害
// 计算剩余血量,不能为负数
int remainBlood = target.getBlood() - hurt;
remainBlood = remainBlood < 0 ? 0 : remainBlood;
// 更新被攻击者的血量
target.setBlood(remainBlood);
// 输出战斗日志
System.out.println(this.getName() + "攻击了" + target.getName()
+ ",造成" + hurt + "点伤害,"
+ target.getName() + "剩余血量:" + remainBlood);
}
}
attack方法的设计很关键:调用者是攻击方(this),参数是被攻击方(target)。写成r1.attack(r2)就是r1攻击r2,直观。
测试类:
public class RoleTest {
public static void main(String[] args) {
Role r1 = new Role("乔峰", 100);
Role r2 = new Role("鸠摩智", 100);
System.out.println("===== 格斗开始 =====");
while (true) {
// r1攻击r2
r1.attack(r2);
if (r2.getBlood() == 0) {
System.out.println(r1.getName() + " K.O了 " + r2.getName());
break;
}
// r2攻击r1
r2.attack(r1);
if (r1.getBlood() == 0) {
System.out.println(r2.getName() + " K.O了 " + r1.getName());
break;
}
}
}
}
后来我还写了一个升级版:攻击力不再固定1~20,而是加上了攻击力属性,角色的攻击力会影响造成的伤害。扩展起来也很自然——在Role类加一个attackPower属性,attack方法里的随机伤害以攻击力为基准计算即可。这也说明了面向对象封装的好处:只改Role类内部,测试类的调用代码完全不用动。
双色球系统
模拟双色球彩票系统,代码量比较大,综合练习了数组、随机数、循环、排序等多个知识点的配合使用。
需求:
- 系统随机生成一注双色球号码(6个红球1~33不重复 + 1个蓝球1~16)
- 用户可以自己选号
- 开奖后对比,显示中了几个红球和蓝球
核心代码——生成随机号码:
import java.util.Random;
public class 双色球 {
public static void main(String[] args) {
int[] winningNumbers = generateWinningNumbers();
System.out.print("中奖号码:");
printNumbers(winningNumbers);
// 用户选号和对比逻辑省略,核心在于生成号码的部分
}
// 生成中奖号码:6个红球(1~33不重复)+ 1个蓝球(1~16)
public static int[] generateWinningNumbers() {
Random r = new Random();
int[] result = new int[7]; // [红球1, ..., 红球6, 蓝球]
// 生成6个不重复的红球
int count = 0;
while (count < 6) {
int red = r.nextInt(33) + 1;
// 检查是否重复
boolean isDuplicate = false;
for (int i = 0; i < count; i++) {
if (result[i] == red) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
result[count] = red;
count++;
}
}
// 生成蓝球
result[6] = r.nextInt(16) + 1;
return result;
}
public static void printNumbers(int[] numbers) {
System.out.print("红球:[");
for (int i = 0; i < 6; i++) {
System.out.print(numbers[i]);
if (i < 5) System.out.print(", ");
}
System.out.println("] 蓝球:[" + numbers[6] + "]");
}
}
生成不重复的随机红球是一个经典的小算法:用while循环,每次生成一个候选球,遍历已选球检查重复,不重复才放入。生成完6个红球后再生成1个蓝球。
抢红包
模拟微信红包的随机分配算法。
需求:输入红包总金额和红包个数,随机分配每个红包的金额(单位分,每人至少0.01元)。
思路:
- 把金额转成分(避免浮点误差,用整数计算)
- 生成N-1个随机的"分割点"
- 排序分割点后,相邻差值就是每个红包的金额
代码:
import java.util.Arrays;
import java.util.Random;
public class 抢红包 {
public static void main(String[] args) {
double totalMoney = 10.00; // 总金额
int count = 5; // 红包个数
int[] packets = splitRedPacket(totalMoney, count);
System.out.println("红包金额(分):" + Arrays.toString(packets));
double sum = 0;
for (int p : packets) {
double yuan = p / 100.0;
System.out.printf("%.2f元 ", yuan);
sum += yuan;
}
System.out.println("\n总金额验证:" + sum + "元");
}
public static int[] splitRedPacket(double total, int count) {
int totalFen = (int) (total * 100); // 转成分
int[] result = new int[count];
Random r = new Random();
// 生成count-1个随机分割点(范围1 ~ totalFen-1)
int[] cutPoints = new int[count - 1];
for (int i = 0; i < cutPoints.length; i++) {
cutPoints[i] = r.nextInt(totalFen - 1) + 1;
}
Arrays.sort(cutPoints); // 排序分割点
// 第一个红包 = 第一个分割点 - 0
result[0] = cutPoints[0];
// 中间红包 = 相邻分割点差值
for (int i = 1; i < cutPoints.length; i++) {
result[i] = cutPoints[i] - cutPoints[i - 1];
}
// 最后一个红包 = 总金额 - 最后一个分割点
result[count - 1] = totalFen - cutPoints[count - 1];
return result;
}
}
关键点是用**整数(分)**计算而不是浮点数(元),避免精度问题。这个思路在涉及金额计算的场景下是通用的。
数字加密
把数字的每一位变换后组成新的加密数字。
需求:输入一个数字,对每一位进行以下加密:先+5,再对10取余,最后反转顺序。例如:1983加密后应该是8346。
思路分析:
1983四位分别+5得6 14 13 8- 对10取余得
6 4 3 8 - 反转顺序得
8346
代码:
public class 数字加密 {
public static void main(String[] args) {
int number = 1983;
int encrypted = encrypt(number);
System.out.println(number + " 加密后:" + encrypted);
}
public static int encrypt(int number) {
// 1. 把数字的每一位存入数组
int temp = number;
int length = 0;
while (temp != 0) {
temp /= 10;
length++;
}
int[] digits = new int[length];
temp = number;
for (int i = length - 1; i >= 0; i--) {
digits[i] = temp % 10;
temp /= 10;
}
// 2. 每位+5,对10取余
for (int i = 0; i < digits.length; i++) {
digits[i] = (digits[i] + 5) % 10;
}
// 3. 反转数组
for (int i = 0, j = digits.length - 1; i < j; i++, j--) {
int t = digits[i];
digits[i] = digits[j];
digits[j] = t;
}
// 4. 数组拼回数字
int result = 0;
for (int digit : digits) {
result = result * 10 + digit;
}
return result;
}
}
加密和解密是一对镜像操作。解密就是把加密的规则反过来——先反转,再每位+5的逆操作(如果数字<5就+10,否则不变),再-5。我写了解密版本放在同一个工程里。这两道题对锻炼数组操作和取模运算的思维很有帮助。
十五、踩过的坑
学Java的过程中反复遇到的一些问题,列出来提醒自己,也希望能帮读者少踩一次:
-
==和equals用错:刚开始老用==比较字符串,结果有时候对有时候错,很懵。后来搞明白了——==比的是栈里的值(基本类型的数值,引用类型的地址),equals()才是比内容。字符串比较永远用equals。 -
数组的
.length和字符串的.length():数组是没括号的属性,字符串是有括号的方法。每次写混的时候编译器都不会告诉你——只会悄悄报错。 -
写了有参构造后没写无参构造:Java就不自动补了。后面框架用反射调无参构造创建对象时会报错。标准JavaBean永远两个构造都写。
-
方法传参的理解:基本类型传参改不了原值(传的是值拷贝),引用类型传参改的是同一个堆里的对象。搞清楚了Java只有值传递(引用类型传的是地址值的拷贝,但指向同一个对象)。
-
多态不能调子类特有方法:
Animal a = new Cat(),a调不了meow()——因为编译看左边。解决方式是向下转型,转之前用instanceof判断。 -
空指针异常NullPointerException:Java里最常见的运行时异常。用了一个null的引用去调方法就报这个错。防御方式:调之前判null,或者用
Objects.equals()。 -
循环里用
+=拼字符串效率低:每次拼接都创建新对象。循环拼字符串用StringBuilder。 -
BigDecimal除法不指定精度:直接抛异常。除不尽时必须指定精度和舍入模式,比如
divide(bd, 2, RoundingMode.HALF_UP)保留两位小数四舍五入。 -
switch穿透:传统switch忘写break,case会一直往下执行。增强switch(箭头语法)没有这个问题。
-
接口和抽象类的选择:抽象类用来提取"是什么"的共性,接口用来定义"能做什么"的能力。不确定的时候先想清楚要表达的关系。
上半部课程学到的内容都在这了。下半部的总结后续更新。
更多推荐
所有评论(0)