很多人学Java,学到面向对象就开始迷糊,数组和集合还没分清,ArrayList和HashMap更是背了又忘。然后去刷面试题,背八股文,越学越空。我带了这么多年学生,每次遇到这种状态,给的建议都一样:别急着往后赶进度,先动手做一个完整的小系统。学生信息管理系统就是我反复推荐的项目,没有之一。它足够简单,不需要任何框架和数据库,一个类、一个数组、几个方法就能跑起来;又足够完整,增删改查、面向对象、流程控制、输入输出全都能练到。这篇文章我会从零开始,带你把这个系统一点一点写出来,每一步都解释为什么这么做,连环境变量都帮你配好,最后再告诉你普通版本和进阶版本之间差了哪些东西。收藏这一篇就够用,但更重要的是跟着敲一遍。
1. 为什么第一个项目应该做学生信息管理系统
1.1 这个项目到底在练什么
很多入门教程会把知识点拆得特别碎:今天讲变量,明天讲循环,后天讲数组。学的时候觉得都懂了,合上书却什么也写不出来。问题就出在没有一个载体把这些知识串起来。学生信息管理系统恰好是那个最合适的载体。
一个标准的学生信息管理系统,至少要包含这些功能:
| 功能 | 对应知识点 | 难度 |
|---|---|---|
| 添加学生 | 对象创建、数组存储、方法调用 | 基础 |
| 查看所有学生 | 遍历数组、循环输出 | 基础 |
| 按学号查找 | 条件判断、break语句 | 入门 |
| 修改学生信息 | 数组索引定位、重新赋值 | 中等 |
| 删除学生 | 数组元素前移、size边界控制 | 中等偏难 |
| 主菜单交互 | Scanner输入、switch分支、while循环 | 基础 |
看见没有,平时学的那些零散语法,全部被这一个项目串起来了。而且它的规模刚好卡在一个临界点上:代码量在200到300行左右,一个下午能写完,不至于中途放弃;逻辑又足够让你体会到“设计”是怎么回事,不是无脑敲代码。
1.2 为什么控制台版本比带界面的版本更适合入门
我见过不少初学者一上来就搜“Java学生管理系统带界面”,然后用Swing拖了几个文本框,感觉挺像回事,其实核心逻辑根本没碰。等面试官问一句“你删除学生的时候数组是怎么处理的”,就答不上来了。
我的建议是:第一遍必须做纯控制台版本。控制台版本强制你把所有注意力放在业务逻辑上——怎么存数据、怎么找数据、怎么删数据。界面是锦上添花的东西,不是雪中送炭。等你能把一个纯控制台的学生管理系统讲得清清楚楚,再考虑加界面、加数据库,那是完全不同的层次。
2. 开发环境准备:三步搞定JDK和IDEA
2.1 JDK安装与环境变量配置(Window系统为例)
这一步卡住过大量零基础同学。配环境变量的本质,就是告诉操作系统“java这个命令在哪个文件夹里”。你下载完JDK,双击安装,默认路径一般是C:\Program Files\Java\jdk-17。接下来做三件事:
- 新建系统变量
JAVA_HOME,值填你的JDK安装路径。 - 编辑
Path变量,新增一行%JAVA_HOME%\bin。 - 打开命令行窗口,输入
java -version,能看到版本号就成功了。
提示:
%JAVA_HOME%\bin这一行一定要加在Path变量的最前面,避免和系统自带的旧版Java冲突。很多人配完还是提示“java不是内部或外部命令”,九成是这一步没做对。
有个细节容易被忽略:配置完环境变量后,必须重新打开命令行窗口才生效。如果你在一个旧的命令行窗口里敲java -version,还是报错,不是配置错了,是窗口没刷新。
2.2 IDEA安装与第一个项目创建
IDE我这里推荐IntelliJ IDEA。社区版对学习完全够用,不需要破解。安装好之后,创建项目的路径是:
- File -> New -> Project
- 语言选Java,构建工具可以不选(直接选IntelliJ原生就行)
- JDK选你刚装好的版本,没有的话点Add JDK手动指定路径
项目创建出来之后,在src目录上右键新建Java类,随便写一句System.out.println("hello");运行一下。能打印出来,说明整个链路已经通了。接下来所有代码都在这个项目里写。
顺便说一句,好多教程一上来就让你搞Maven、搞Spring Boot,然后建出来的项目一堆文件,你根本分不清哪个是干嘛的。零基础阶段,老老实实用最朴素的Java项目就够了,把精力全部留给逻辑本身。
3. 开始写代码前的设计:从需求到类
3.1 面向对象的建模:Student类的设计
很多人学面向对象时最困惑的一件事情:类和对象到底什么关系?其实特别简单。类是模板,对象是拿模板造出来的具体东西。你说“学生”的时候,它是一个抽象概念,没法操作;但你说“学号为2024001、名叫张三、考了90分的那个人”,你就可以操作他了。前者是类,后者是对象。
在这个系统里,Student类只需要四个字段:
public class Student { private String id; // 学号 private String name; // 姓名 private int age; // 年龄 private double score; // 成绩 public Student() { } public Student(String id, String name, int age, double score) { this.id = id; this.name = name; this.age = age; this.score = score; } 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 double getScore() { return score; } public void setScore(double score) { this.score = score; } public String toString() { return "学号:" + id + ",姓名:" + name + ",年龄:" + age + ",成绩:" + score; } }字段为什么要用private私有?这叫封装。外面的人不能直接改你的数据,必须通过你提供的方法。好处是什么?你可以在setAge里加逻辑:
public void setAge(int age) { if (age < 0 || age > 150) { System.out.println("年龄不合法!"); return; } this.age = age; }这就保证了数据不会被随随便便赋值成离谱的值。这种设计思想,在你将来写任何系统时都用得上。构造函数里搞一个无参构造和一个全参构造,是JavaBean的规范写法,现在你只要记住“最好两个都写”就行,后面学习框架时会经常和这种类打交道。
3.2 数据存储方案:为什么先用数组而不是数据库
很多零基础同学一听说管理信息系统,第一反应是“是不是要装MySQL?”我明确告诉你,这个阶段不要碰数据库。
原因有两点。第一,数据库的引入会牵扯出JDBC、驱动、SQL语法一大堆新概念,你的注意力会被严重分散。第二,数据库在物理上是一个独立的东西,它和“Java代码如何操作数据”这件事是解耦的。零基础阶段,你应该先把“在内存里操作数据”练熟,再过渡到“在磁盘上操作数据”。
所以这个版本我们就用数组。但这里要留个心眼:数组长度一旦确定就不可变。往里面不断加学生,总有满的一天。所以光定义一个数组还不够,还要有一个变量size,记录当前实际存了多少个学生。
public class StudentManager { private Student[] students = new Student[10]; private int size = 0; }students数组用来存放学生对象,size表示已存入的学生数量。注意,数组长度是10,但size才表示真实数量。为什么这么设计?因为数组里下标0到size-1的位置才算有效数据,size之后的都是“空位子”,等着被填充。
这是整个系统最关键的一个设计。你后面写的所有方法,本质都是在维护这个数组和size之间的关系。很多人写崩了,就是没搞清“数组长度”和“实际存储数量”是两个不同的东西。
4. 核心功能实现:从菜单到增删改查
4.1 主菜单与程序入口:让系统先跑起来
任何一个系统,先别急着写细节,先把骨架搭出来。主菜单就是骨架的入口。用户进来能看到什么、能选择什么操作,第一眼要清楚。
public class StudentManager { private Student[] students = new Student[10]; private int size = 0; private Scanner scanner = new Scanner(System.in); public static void main(String[] args) { StudentManager manager = new StudentManager(); manager.run(); } public void run() { 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.println("6. 退出系统"); System.out.println("===================================="); System.out.print("请输入你的选择:"); int choice = scanner.nextInt(); switch (choice) { case 1: addStudent(); break; case 2: listStudents(); break; case 3: findStudentById(); break; case 4: updateStudent(); break; case 5: deleteStudent(); break; case 6: System.out.println("感谢使用,再见!"); return; default: System.out.println("输入有误,请重新选择!"); } } } }这里用while (true)加switch的组合是控制台菜单的经典写法。while(true)表示菜单会不停地显示,除非遇到case 6里的return,直接跳出整个run方法,程序才终止。用return而不是break的原因要记住:break只能跳出switch,跳不出while循环。
4.2 添加学生与数组扩容问题
添加学生是第一个真正有业务逻辑的功能。思路很简单:创建学生对象,放进数组,size加一。但有个前提要先处理——数组满了怎么办?
public void addStudent() { if (size >= students.length) { expandCapacity(); } System.out.print("请输入学号:"); String id = scanner.next(); System.out.print("请输入姓名:"); String name = scanner.next(); System.out.print("请输入年龄:"); int age = scanner.nextInt(); System.out.print("请输入成绩:"); double score = scanner.nextDouble(); Student student = new Student(id, name, age, score); students[size] = student; size++; System.out.println("添加成功!当前共有 " + size + " 名学生。"); } public void expandCapacity() { Student[] newStudents = new Student[students.length * 2]; System.arraycopy(students, 0, newStudents, 0, students.length); students = newStudents; System.out.println("数组容量已扩展到 " + students.length); }expandCapacity这个方法我单独拎出来说。数组长度固定,扩容的本质就是“另找一个更大的数组,把数据搬过去,然后让原来的变量指向新数组”。我见过有同学在add方法里反复创建数组,那是在制造垃圾对象。正确做法是封装成一个独立方法,只有满了才调用。
扩多少合适?我用的两倍。你想想,如果每次满了只扩一个位置,那每次插入都要搬一次数据,效率很低。扩两倍,空间换时间,是很多集合底层扩容策略的雏形。Java的ArrayList底层也是类似的思路,只不过细节更多。你现在把这个逻辑写明白,之后学集合源码会轻松得多。
4.3 查询功能:遍历、条件判断、断点思维
查询分两种:列出所有学生,和按学号查找单个学生。后者是前者的基础上加了一个条件判断。
列出所有学生最简单,就是遍历数组的有效区间:
public void listStudents() { if (size == 0) { System.out.println("当前没有任何学生!"); return; } for (int i = 0; i < size; i++) { System.out.println(students[i]); } }这里注意一个小细节:System.out.println(students[i])直接打印对象,之所以能输出好看的文本而不是一串看不懂的地址,是因为我们重写了toString()方法。这就是为什么Student类里那个方法必须有——没有它,打印出来的就是Student@1b6d3586这种垃圾信息。
按学号查找,核心是一个循环加一个equals判断:
public void findStudentById() { System.out.print("请输入要查找的学号:"); String id = scanner.next(); boolean found = false; for (int i = 0; i < size; i++) { if (students[i].getId().equals(id)) { System.out.println(students[i]); found = true; break; } } if (!found) { System.out.println("未找到学号为 " + id + " 的学生!"); } }字符串比较要用equals()而不能用==。这是一个老生常谈但永远有人错的点。简单理解:==比较的是“是不是同一个东西”,equals比较的是“内容相不相等”。学号是内容,当然用equals。你输入一个"2024001"和数组里存的"2024001",是两个不同的字符串对象,内容相同而已。
4.4 修改与删除:数组操作里最考验逻辑的操作
修改学生信息的套路是先找到那个人的下标,再重新赋值。我把查找的价值放在这里体现,因为修改和删除都需要“定位”。
public void updateStudent() { System.out.print("请输入要修改的学号:"); String id = scanner.next(); int index = -1; for (int i = 0; i < size; i++) { if (students[i].getId().equals(id)) { index = i; break; } } if (index == -1) { System.out.println("未找到该学生!"); return; } System.out.print("请输入新姓名:"); String name = scanner.next(); System.out.print("请输入新年龄:"); int age = scanner.nextInt(); System.out.print("请输入新成绩:"); double score = scanner.nextDouble(); students[index].setName(name); students[index].setAge(age); students[index].setScore(score); System.out.println("修改成功!"); }index初始值是-1,这个设计叫哨兵值。因为数组下标最小是0,所以用-1表示“没找到”。找到之后,直接对students[index]这个对象调用setter方法修改字段。注意不需要重新new一个Student对象,因为我们只是修改已有对象的属性。
删除是整个系统里最考验逻辑的操作。数组是连续存储的,删掉中间一个元素,后面的元素都得往前挪一位,而且size要减一。如果直接删掉最后一个,就不用挪。
public void deleteStudent() { System.out.print("请输入要删除的学号:"); String id = scanner.next(); int index = -1; for (int i = 0; i < size; i++) { if (students[i].getId().equals(id)) { index = i; break; } } if (index == -1) { System.out.println("未找到该学生!"); return; } for (int i = index; i < size - 1; i++) { students[i] = students[i + 1]; } students[size - 1] = null; size--; System.out.println("删除成功!"); }重点看那个前移的循环。假设数组是[A, B, C, D, E],要删除下标1也就是B,循环从i = 1开始,students[1] = students[2],C挤到B的位置;students[2] = students[3],D挤到C的位置;students[3] = students[4],E挤到D的位置。循环结束后,数组是[A, C, D, E, E]。
最后一步students[size - 1] = null把最后一个位置清空,防止多余的引用残留在数组里,然后再size--。这一步很容易被漏掉。漏掉的话,数组最后会残留一个已经“被删除”的对象的引用,虽然size不包含它,但后续扩容时它会被一起复制到新数组里,造成内存泄漏的隐患。零基础阶段就要习惯性地把这种细节处理好。
5. 零基础最容易踩的几个坑
5.1 数组越界:一半的报错都源于它
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 10——这段报错,可以说每个写这个系统的人都会遇到。原因也很直白:你往数组里塞第11个元素,但数组只能装10个。刚才的扩容方法就是解决这个问题的。
但还有一个隐蔽的越界点,出现在删除方法里。删除循环的终止条件是i < size - 1,不是i < size。如果写成i < size,当删除的是最后一个元素时,循环会尝试执行students[size - 1] = students[size],直接越界。我自己让学生写这个系统时,十个人里有七八个会在这里卡一次。这个坑踩过之后你就不怕了,因为以后所有集合删除操作,本质都是类似的边界控制,思路完全相通。
5.2 Scanner的nextLine和next混用导致的“吞回车”
这是个经典中的经典。在你输入学号姓名时,如果用next()都没事,但如果你某个字段用了nextLine(),并且它排在某个nextInt()后面,你会发现程序会异常地跳过这次输入,直接执行下一行。
原因是nextInt()读取完数字后,会在输入缓冲区里留下一个回车符,紧接着的nextLine()会把那个回车符读出来,当成一个空字符串。解决的办法有两种:
- 全部使用
next(),不接受带空格的输入。学生信息一般不会有空格,这是最简单的方案。 - 在每次
nextInt()之后补一行scanner.nextLine();把残留的回车符消费掉。
我建议初学者直接采用方案1,省事且对当前场景足够。等你以后真正接触到需要读取一行文本的场景,再来研究nextLine的细节也不迟。
5.3 死循环与退出逻辑不清
最后一个小坑,是菜单里的选择输入了非数字类型,比如字母a。scanner.nextInt()会直接抛出InputMismatchException,程序崩溃。这在初学阶段很烦人,因为你辛苦运行起来的程序,居然被一个字母弄死了。
最简单的兜底处理是做一次输入判断:
if (!scanner.hasNextInt()) { System.out.println("请输入数字!"); scanner.next(); continue; }hasNextInt()先判断下一个输入是不是整数,不是就提示并清空缓冲区,然后continue让用户重新选择。这个写法能挡住九成的手误。它不算多高级的技巧,但能让你后续写工具类程序时显得专业得多。
6. 进阶优化:从能用走向优雅
6.1 用ArrayList替换数组,到底好在哪
当你把数组版本写顺了之后,我第一次建议升级的方向,是用ArrayList替换Student[]。你会发现代码量骤减,因为扩容和边界控制都不用手动做了:
public class StudentManager { private List<Student> students = new ArrayList<>(); private Scanner scanner = new Scanner(System.in); public void addStudent() { // ...输入代码省略 students.add(new Student(id, name, age, score)); } public void deleteStudentById(String id) { for (int i = 0; i < students.size(); i++) { if (students.get(i).getId().equals(id)) { students.remove(i); return; } } System.out.println("未找到该学生!"); } }ArrayList内部其实还是数组,但它把扩容、搬移这些脏活累活全部封装好了,你只需要调用add和remove。用起来舒服,是因为Java类库替你把复杂度处理掉了。但我要提醒你:不要为了用集合而把数组的理解丢掉。面试官问你ArrayList的扩容机制时,如果你能结合刚才自己写的expandCapacity来讲“它也是新建一个更大的数组再拷贝”,这个回答是会加分的。这也是我坚持让你先写数组版本的原因——你对底层的理解,是直接背答案的人永远比不了的。
6.2 程序退出后数据还在吗?聊聊持久化
控制台版本有个致命缺陷:每次关闭程序,数据全没了。因为数据都存在内存里,内存断电即失。要让数据保留下来,至少得做到把数据写入文件。
最简单的方案是文本文件存储。每次退出前遍历students数组,一行一个学生打印到文件里;下次启动时再按行读回来。不过这里有一个新的麻烦:字符串转对象。怎么把一行文本2024001,张三,18,90还原成一个Student对象呢?答案是split(",")方法:
String[] parts = line.split(","); String id = parts[0]; String name = parts[1]; int age = Integer.parseInt(parts[2]); double score = Double.parseDouble(parts[3]); Student student = new Student(id, name, age, score); students.add(student);一旦你学会了这种“把对象转成字符串存文件,再读回来还原成对象”的操作,你对数据存储的理解就又上了一个台阶。文件本身就是最朴素的数据库,CSV格式就是最简朴的数据交换格式。
6.3 分层架构与面向接口:这个系统还能长多大
最后,如果你想继续深入,把目光投向项目的结构组织。现在的代码所有功能都堆在StudentManager一个类里,小项目没问题,但你不能一辈子写这种“上帝类”。
一个经典的三层结构是:
| 层次 | 职责 | 类名示例 |
|---|---|---|
| 界面交互层 | 控制台菜单、输入输出 | StudentUI |
| 业务逻辑层 | 增删改查规则、数据校验 | StudentService |
| 数据存储层 | 操作数组或集合,后续可换数据库 | StudentRepository |
每一层只负责自己的事,层与层之间通过方法调用交互。这样做的好处是:今天用数组存数据,明天想换成数据库,只需要改数据存储层,界面层和业务层一行不用动。这就是为什么企业级项目哪怕逻辑比这复杂一百倍,结构也遵循同样的套路。你在学生管理系统里打下的分层意识,会直接迁移到以后学Spring框架时的Controller、Service、Mapper三层架构上。
注意:升级分层架构时,建议先把一个功能完整跑通,比如只做“添加学生”的UI -> Service -> Repository调用链,再逐步把其他功能迁移过去。一次全改很容易出各种耦合问题,小步快跑反而更稳。
如果看完这些,你能从零把这个系统写出来,并且清楚知道每一行代码在干什么,那我可以告诉你,你的Java基础已经稳了。下一个项目,大胆去尝试带文件存储的版本,或者给系统加一个简单的登录验证功能。你会发现自己已经具备了一种能力:看到一个需求,脑海里能自动拆出几个类、几条链路、几个接口。这种能力,就是写代码经验的本质——它不是背出来的,是敲出来的。