目录


一、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 class7{
    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("游戏结束");
    }
}

两个细节值得注意:

  1. Scanner不要写在循环里面,循环外创建一个就够了。
  2. 判断"机会用完"不用额外的计数器,直接用循环变量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.5price * 6.5是涨价650%,别写错了。


三、方法

方法就是一段可以被重复调用的代码块。当代码写到一定程度,你会发现很多逻辑重复了,这时候就该抽方法了。

方法的定义和调用

// 无返回值的方法
public static void printHello() {
    System.out.println("Hello");
}

// 有返回值的方法
public static int add(int a, int b) {
    return a + b;
}
  • 形参 vs 实参:定义时的参数叫形参,调用时传的值叫实参。
  • 返回值:有返回值用return把结果返出去;没返回值用voidvoid方法里也能写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。这个模式写查找类方法时非常常见。
  • getMaxmax初始值用了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}

这道题用到双指针的思想——两个指针同时从数组两端往中间走,交换它们指向的元素。

思路

  • 定义两个变量iji从头部开始(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循环的初始化部分同时声明了ij两个变量,这是合法的写法。终止条件是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)。动手在纸上画个索引对照图就不会搞错了。

另外charint不能直接强转(那样得到的是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生态里的一种约定写法:

  1. 成员变量用private
  2. 每个成员变量配public的getter和setter
  3. 必须有无参构造
  4. 按需提供有参构造

用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里MathArrays等系统工具类的设计思路。

继承——"是"的关系

继承描述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("猫吃鱼");
}

重写规则:子类访问权限不能比父类低,privatestatic的方法不能被重写。

多态——同一个行为,不同表现

写法上就是父类引用指向子类对象

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 + 1mid - 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 = 0a ^ 0 = a。可以用来找数组中只出现一次的数
  • 按位或/按位与:常用于权限标志位的设置和判断

前缀和与双指针

前缀和:预处理出前n项和,之后查任意区间和只需O(1)。
双指针:两个下标同时遍历,常用于有序数组的配对、反转等问题。


十三、编程技巧积累

学习过程中攒了不少实用的编程技巧,不是语法知识,但每次写代码都会用到:

计数器思想:定义一个count = 0,满足条件就count++,最后看count的值。统计类问题基本都这么解决。

循环布尔返回思想:在循环里检查条件,一满足就返回对应结果。循环跑完说明没找到,返回另一种结果。这是写查找方法的万能模板。

查表法:提前把各种情况的对应值存进数组,通过下标直接获取。比如金额转换——把所有中文大写数字存入数组{"零","壹","贰",...},然后通过数字下标去取对应中文。可以避免写一堆if-else。

链式编程:方法返回this,就能一直.下去。StringBuilder是典型。

假索引思想:用int变量模拟指针,在不改变原数据的情况下跟踪状态。写算法题时常用。

递归:方法自己调自己,一定要有出口条件(终止条件),否则栈溢出。代码简洁但大量递归会有性能开销。

一些小技巧也很有用——forwhileif可以省略大括号写成一行(但可读性会下降)、int类型的除法是直接截断不是四舍五入、以及写if判断时if (flag)if (flag == true)更简洁。


十四、综合练习实战

前面每个模块下的练习都是针对单个知识点的。下面这几个是把多个知识点串起来的小项目,完整走一遍下来,前面的知识才能真正变成手上的功夫。

学生管理系统

这是课程里最重要的综合项目。需求很简单,但写出来需要用到JavaBean、ArrayList、Scanner、switch、方法封装等一堆知识点。

需求:做一个学生信息管理系统,支持以下功能:

  1. 添加学生(学号不能重复)
  2. 删除学生(按学号删除)
  3. 修改学生信息
  4. 查询所有学生
  5. 退出系统

设计思路

  • 先写一个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类内部,测试类的调用代码完全不用动。

双色球系统

模拟双色球彩票系统,代码量比较大,综合练习了数组、随机数、循环、排序等多个知识点的配合使用。

需求

  1. 系统随机生成一注双色球号码(6个红球1~33不重复 + 1个蓝球1~16)
  2. 用户可以自己选号
  3. 开奖后对比,显示中了几个红球和蓝球

核心代码——生成随机号码

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的过程中反复遇到的一些问题,列出来提醒自己,也希望能帮读者少踩一次:

  1. ==equals用错:刚开始老用==比较字符串,结果有时候对有时候错,很懵。后来搞明白了——==比的是栈里的值(基本类型的数值,引用类型的地址),equals()才是比内容。字符串比较永远用equals。

  2. 数组的.length和字符串的.length():数组是没括号的属性,字符串是有括号的方法。每次写混的时候编译器都不会告诉你——只会悄悄报错。

  3. 写了有参构造后没写无参构造:Java就不自动补了。后面框架用反射调无参构造创建对象时会报错。标准JavaBean永远两个构造都写。

  4. 方法传参的理解:基本类型传参改不了原值(传的是值拷贝),引用类型传参改的是同一个堆里的对象。搞清楚了Java只有值传递(引用类型传的是地址值的拷贝,但指向同一个对象)。

  5. 多态不能调子类特有方法Animal a = new Cat(),a调不了meow()——因为编译看左边。解决方式是向下转型,转之前用instanceof判断。

  6. 空指针异常NullPointerException:Java里最常见的运行时异常。用了一个null的引用去调方法就报这个错。防御方式:调之前判null,或者用Objects.equals()

  7. 循环里用+=拼字符串效率低:每次拼接都创建新对象。循环拼字符串用StringBuilder。

  8. BigDecimal除法不指定精度:直接抛异常。除不尽时必须指定精度和舍入模式,比如divide(bd, 2, RoundingMode.HALF_UP)保留两位小数四舍五入。

  9. switch穿透:传统switch忘写break,case会一直往下执行。增强switch(箭头语法)没有这个问题。

  10. 接口和抽象类的选择:抽象类用来提取"是什么"的共性,接口用来定义"能做什么"的能力。不确定的时候先想清楚要表达的关系。

上半部课程学到的内容都在这了。下半部的总结后续更新。

更多推荐