简介这是基于Java实现的ATM柜员机模拟程序适合Java初学者、计算机专业学生及需要完成相似编程作业的开发者。程序通过控制台交互还原ATM的基本使用流程可帮助读者在实践中掌握对象封装、循环菜单、分支判断等核心知识点。压缩包为ZIP格式共包含2个Java源文件整体大小仅3KB结构非常精简两个文件分别承担用户配置管理与主业务流程控制职责边界清晰便于直接阅读和修改。该资源已有3223人学习适合用作课程设计参考或日常练习。运行后可体验从插入卡片到完成存取款操作的完整模拟过程代码量少但逻辑完整对理解真实ATM系统的简化模型以及后续扩展图形界面都具有一定启发配套博文另有详细解说可搭配使用。1. 为什么 java ATM模拟 作业是道“看着容易、做起来翻车”的分水岭很多人在课程作业里看到“ATM模拟”第一反应是这不就是个控制台小例程两节课就能交差。实际批过代码的人知道java ATM模拟 作业是典型的“语法全会、落地全废”有人把所有逻辑堆进 main有人把 nextInt 和 nextLine 混用有人改完密码旧密码照样能登录。这份 ATM模拟 java实现 zip 包把面向对象的类拆分、控制台交互、五个核心业务的参数边界都整理了出来适合正在赶 Java 课程设计的同学也适合拿现成案例练代码重构的开发者。你要做的不是背代码而是照着这套结构把“谁能动余额”想清楚。2. 先把三个角色拆出来Account、Bank、ATM 的数据边界拿到这份 ATM 模拟 java 实现源码包我建议第一件事不是急着编译而是先看它把功能分成了几层。很多同学一打开工程就找 main 方法改到第三个文件才发现数据被放在了不该放的地方。先花十分钟把类关系摸清楚后面每一步都会顺很多。2.1 为什么要拆类30 行 main 方法跑起来的代码改不动有些同学把所有东西塞进 main——界面输出、输入读取、账户列表、业务逻辑全在一起。这个写法在第一次作业里能过但当你发现需要加一个“转账”功能时要么复制粘贴一大段要么冒险重构。拆类不是为了炫技而是为了让“谁的数据、谁的业务、谁的界面”各归各位。常见做法是最少拆成三个类Account 负责账户数据Bank 负责账户集合ATM 负责交互流程。先搭好这个骨架再往里面填业务方法后面加功能就是往对应类里加方法而已。一个完整可运行的 ATM 模拟项目入口类放在最外层启动方法里只做两件事初始化 Bank再把 Bank 交给 ATM 对象。之后所有操作都发生在 ATM 里main 里不出现业务逻辑。2.2 Account字段私有化与余额精度选择Account 是整个模型里唯一的数据实体字段只有三个卡号、密码、余额。先给字段私有化public class Account { private String cardNo; private String password; private double balance; public Account(String cardNo, String password, double balance) { this.cardNo cardNo; this.password password; this.balance balance; } public String getCardNo() { return cardNo; } public String getPassword() { return password; } public void setPassword(String password) { this.password password; } public double getBalance() { return balance; } public void setBalance(double balance) { this.balance balance; } }逻辑说明卡号相当于数据库里的主键一旦创建就不提供 setCardNo防止业务代码把卡号改掉导致同一账户出现两份。密码是登录校验依据提供 setPassword 而不暴露直接修改字段的入口。余额不直接公开字段后续所有金额变化都通过 ATM 里的加减逻辑处理而不是让外部随便调用 setBalance(999999)。参数说明balance 用 double是课程作业阶段的常见选择因为很多课程还没系统讲 BigDecimal。如果你在演示中涉及类似“10000.23 100.77”这样的场景double 可能打印出一长串小数。简单处理是显示时用 String.format(%.2f, balance) 保留两位这也是模拟项目包里常用的处理方式。这里有个配套动作一旦金额显示统一保留两位所有加减运算的入参也应该按两位处理否则 0.1 0.2 这类经典误差会在后续测试里冒出来。2.3 Bank 与 ATM谁管数据、谁管流程Bank 管账户列表和查找逻辑。列表用 ArrayList 就够因为作业里的账户量通常在十来个左右顺序查找的时间损耗可以忽略。关键是查找方法要定义在 Bank 里而不是让 ATM 直接遍历列表。原因有二第一ATM 只关心“我能不能登录”不关心数据是怎么存、怎么遍历的第二将来把存储从 ArrayList 换成数据库只需要动 BankATM 一行不用改。public class Bank { private ListAccount accounts; public Bank(ListAccount accounts) { this.accounts new ArrayList(accounts); } public Account findByCardNo(String cardNo) { for (Account acc : accounts) { if (acc.getCardNo().equals(cardNo)) { return acc; } } return null; } public Account findAccount(String cardNo, String password) { for (Account acc : accounts) { if (acc.getCardNo().equals(cardNo) acc.getPassword().equals(password)) { return acc; } } return null; } }逻辑说明构造器里用 new ArrayList(accounts) 做一份防御性复制避免外部在创建 Bank 后继续往原列表里塞数据绕过 Bank 的控制。findByCardNo 是给转账用的只按卡号找不校验密码findAccount 是给登录用的卡号和密码必须同时匹配。参数说明cardNo 和 password 都是 String比较必须用 equals 而不是 具体原因放到避坑章展开。返回的是 Account 对象引用调用方拿到之后修改余额实际修改的是列表里的同一个对象这正好是取款、改密能生效的前提。ATM 类的结构更简单它持有 Bank 引用但自己不存任何账户数据public class ATM { private Bank bank; public ATM(Bank bank) { this.bank bank; } public void start() { System.out.println(欢迎使用银行 ATM 模拟系统); // 登录和主菜单在这里 } }逻辑说明ATM 的所有查询都委托给 bank。如果把账户列表也塞进 ATM就回到了“一个类干所有事”的老路。参数说明构造器接收 Bank 接口是为了以后方便传入带持久化能力的子类或代理对象这也是“面向接口设计”在作业里最简单的体现。这一层想清楚之后数据流就明确了账户数据在 Bank.accounts 里登录走 findAccount业务方法修改 Account 的 balance。数据归属在 Bank业务归属在 ATM实体归属在 Account。这个三角关系立住了后面加功能就是填方法的事。3. 把五个核心业务跑通登录、取款、存款、转账、改密的参数设计类的骨架有了接下来是交互层。ATM 模拟的核心不是界面多漂亮而是用户操作路径要符合真实 ATM 的习惯先登录再进主菜单每个业务做完回到主菜单直到退出登录。这一章按“主循环 → 登录 → 五个业务 → 主菜单组装”的顺序来。3.1 主循环与控制台输入先解决“退不出去”和“输入错位”主循环的设计决定了整个程序的生命周期。简单版本是这样public void start() { Scanner sc new Scanner(System.in); while (true) { System.out.println( ATM 模拟 ); System.out.println(1. 登录); System.out.println(2. 退出); System.out.print(请选择); int choice sc.nextInt(); if (choice 2) { System.out.println(已安全退出。); break; } if (choice 1) { Account acc login(sc); if (acc ! null) { mainMenu(sc, acc); } } else { System.out.println(输入无效请重新选择。); } } }逻辑说明这是标准的 while(true) break 模式。登录成功后进入 mainMenumainMenu 退出登录时回到外层循环只有 choice 2 才会真正结束程序。这里有一个容易混淆的点break 在 switch 里是跳出 switch在 while 里是跳出循环如果 mainMenu 里误把 return 写成 break退出登录按钮就会直接退出整个程序行为链就乱了。建议明确约定主循环 break 是结束程序mainMenu 的 return 是回到主循环。参数说明菜单选项用 nextInt 读取整数因为菜单项只有 1 和 2用 if 判断足够。进入业务菜单后再用 switch 处理多分支。3.2 登录与余额查询字符串比较和空对象判定的两个细节登录方法负责把卡号和密码交给 Bank 校验private Account login(Scanner sc) { System.out.print(卡号); String cardNo sc.next(); System.out.print(密码); String pwd sc.next(); Account acc bank.findAccount(cardNo, pwd); if (acc null) { System.out.println(卡号或密码错误登录失败。); return null; } System.out.println(登录成功欢迎 cardNo 。); return acc; }逻辑说明这里故意把“卡号不存在”和“密码错误”合并成同一句提示避免通过提示信息猜出合法卡号这是真实银行系统的基本习惯。acc null 的判定放在 return 之前保证后续 mainMenu 拿到的账户对象一定非空不会出现空指针。参数说明用 sc.next() 读取卡号和密码因为卡号和密码中间不允许有空格。如果你希望卡号允许空格就得改用 nextLine但那样必须先处理上一次 nextInt 留下的换行符这个小坑第 4 章专门展开。余额查询最简单在 mainMenu 的 case 1 里直接打印 acc.getBalance() 就行。这里建议统一用第 2 章提到的 String.format(%.2f, acc.getBalance()) 输出避免 double 的尾巴数字暴露在演示画面上。3.3 取款与存款金额边界顺序决定安全性取款和存款是一对对称操作但判断顺序不同会导致完全不同的行为public boolean withdraw(Account acc, double amount) { if (amount 0) { System.out.println(取款金额必须大于 0。); return false; } if (amount acc.getBalance()) { System.out.println(余额不足当前余额 acc.getBalance()); return false; } acc.setBalance(acc.getBalance() - amount); System.out.println(取款成功本次取出 amount 剩余 acc.getBalance()); return true; }逻辑说明先判断 amount 0再判断余额不足顺序不能反。如果先判断余额输入 -100 时虽然也会被拦下来但提示会变成“余额不足”给用户造成数据状态误判。负数金额能通过检查的问题在避坑章里会有更详细的记录。参数说明这里没有设计单笔限额如果你想更接近真实 ATM可以在余额判断之前加一个常量 MAX_WITHDRAW 5000例如if (amount MAX_WITHDRAW)直接拒绝。单笔限额属于业务约束适合放在 ATM 层而不是 Account 层这样以后调整规则不需要动实体类。存款方法对称public boolean deposit(Account acc, double amount) { if (amount 0) { System.out.println(存款金额必须大于 0。); return false; } if (amount 10000) { System.out.println(单笔存款不能超过 10000。); return false; } acc.setBalance(acc.getBalance() amount); System.out.println(存款成功。); return true; }逻辑说明单笔上限 10000 是演示参数化边界的常用值真实业务通常还有“存款张数限制”。控制台输出的提示在这里扮演的是回单角色演示时别嫌它啰嗦。参数说明amount 是 double调用方从 sc.nextDouble() 读取。注意 Scanner 的 nextDouble 和 nextInt 一样会把换行符留在缓冲区第 4 章会一起处理。3.4 转账与改密对象引用同步与旧密码校验转账是 ATM 模拟里最容易出“幽灵金额”的功能public boolean transfer(Account from, String toCardNo, double amount) { if (amount 0) { System.out.println(转账金额必须大于 0。); return false; } Account target bank.findByCardNo(toCardNo); if (target null) { System.out.println(收款账户不存在。); return false; } if (target from) { System.out.println(不能给自己转账。); return false; } if (from.getBalance() amount) { System.out.println(余额不足。); return false; } from.setBalance(from.getBalance() - amount); target.setBalance(target.getBalance() amount); System.out.println(转账成功。); return true; }逻辑说明target from 判断两个引用是否指向同一个 Account 对象比用卡号比较更可靠因为运行时只有一个登录对象来自 bank 的同一个实例。先查 target 是否存在再做余额判断可以尽早返回减少嵌套层级。参数说明from 是在登录时拿到的 Account。如果登录阶段你直接使用 bank.findAccount 的返回值它就是列表里的同一个对象如果曾经 new 了一个新 Account 再复制字段那 from 就是副本这里的扣款操作只改了副本转账就会“凭空扣钱”。这个问题防不胜防必须在登录方法里保证引用一致。改密方法的关键是旧密码校验public boolean changePassword(Account acc, String oldPwd, String newPwd) { if (!acc.getPassword().equals(oldPwd)) { System.out.println(原密码错误。); return false; } if (newPwd null || newPwd.length() 6) { System.out.println(新密码长度至少 6 位。); return false; } if (oldPwd.equals(newPwd)) { System.out.println(新密码不能与原密码相同。); return false; } acc.setPassword(newPwd); System.out.println(密码修改成功下次请使用新密码登录。); return true; }逻辑说明newPwd 为 null 的判断放在 length 之前避免空指针异常。密码长度规则和“新旧密码不能相同”属于业务约束放在 ATM 层而不是 Account 层因为不同题目要求不一样将来改规则只动这一处。参数说明oldPwd 一定要传进方法里和 acc 当前密码比对不要在外层菜单用临时变量判断完再调用否则“判断用的密码”和“改密用的对象”可能不是同一个旧的逻辑错误就是这么产生的。主菜单组装用 switch 足够private void mainMenu(Scanner sc, Account acc) { while (true) { System.out.println(1. 查询余额 2. 取款 3. 存款 4. 转账 5. 修改密码 6. 退出登录); int op sc.nextInt(); switch (op) { case 1 - System.out.println(余额 String.format(%.2f, acc.getBalance())); case 2 - { System.out.print(取款金额); withdraw(acc, sc.nextDouble()); } case 6 - { System.out.println(已退出登录。); return; } default - System.out.println(无效选项。); } } }逻辑说明switch 的箭头语法是 JDK 12 之后引入的如果你用的 JDK 还是 8就改成传统 case 加 break 的写法。case 6 的 return 是退出 mainMenu 并回到外层主循环而不是结束程序。参数说明每个业务方法都返回 boolean但菜单层暂时忽略返回值只依赖方法内部的提示输出。这个返回值在第 5 章的测试用例里会发挥大作用。4. 避坑交作业前最容易踩的五个坑功能写完之后真正耗时间的不是写代码而是查那些看起来“哪都对跑起来就是不对”的玄学问题。这一章直接列五个高频坑每一条按“现象 → 原因 → 解决”展开。如果这份 ATM 模拟 java 实现包里的代码你也跑出了类似现象优先去看对应位置。4.1 Scanner 的 nextInt 和 nextLine 混用导致输入错位现象菜单里输入“1”回车程序没有等待输入卡号直接提示卡号或密码错误甚至跳过了登录环节。原因nextInt() 只读取数字把回车产生的换行符留在缓冲区紧接着调用的 nextLine() 读到的是空字符串于是卡号变量变成了空串。解决同一次会话里统一用 nextLine 读字符串再手动 Integer.parseInt或者在每次 nextInt 之后加一行 sc.nextLine() 吞掉换行。int op sc.nextInt(); sc.nextLine(); // 吞掉回车 String cardNo sc.nextLine();逻辑说明这行sc.nextLine()没有接收任何变量目的只有一个消耗掉 nextInt 留下的换行符。如果你已经写了一大堆 nextInt 菜单最简单的方法是在每个 nextInt 后补这一行而不是把所有读取方式改一遍。参数说明如果使用Integer.parseInt(sc.nextLine())方案输入非数字时会抛 NumberFormatException需要配合 4.5 的输入校验一起用。4.2 修改密码后旧密码仍然能登录现象运行改密功能提示“密码修改成功”退出登录后用旧密码却还是能进系统。原因改密时操作的不是 Bank 列表里的同一个 Account 对象。常见于登录时用new Account(cardNo, pwd, balance)创建临时账户传给业务方法或者在测试代码里复制了对象。解决保证所有业务方法操作的对象都来自 bank.findAccount 的返回值不要 new 副本。如果确实有副本改密完成后把列表里原对象的密码覆盖掉。Account original bank.findByCardNo(acc.getCardNo()); if (original ! null) { original.setPassword(newPwd); }逻辑说明这段兜底代码的作用是即使某段业务代码里用的是副本 acc也通过卡号把真正的原对象找出来同步。它能救急但不建议把它当首选方案根源还是引用不一致。参数说明findByCardNo 在 Bank 里按卡号查找返回的一定是集合里的对象所以在它上面做 setPassword 是有效的。4.3 负数金额导致余额越取越多现象在取款界面输入 -50控制台提示取款成功余额反而增加了 50。原因业务方法里只判断amount acc.getBalance()没有先判断amount 0。负数小于当前余额自然通过了余额判断然后执行balance - (-50)把余额加大。解决所有金额入参统一先拒绝amount 0取款和存款都用同一套边界。if (amount 0) { System.out.println(金额必须大于 0。); return false; }逻辑说明这是第 3 章 3.3 节里强调过的判断顺序的典型翻车现场。把这条规则写成所有方法的统一入口就不会出现“取款能拒绝负数、存款反而增加”的不对称行为。参数说明如果你想同时限制单笔上限把金额边界全部放在amount 0检查之后形成“先校验合法性再校验业务上限”的顺序。4.4 用 比较卡号字符串现象两个卡号明明完全一样转账却提示“收款账户不存在”。原因 比较 String 时比较的是引用不是内容。Java 的字符串常量偶尔会复用同一个引用所以某些时候1001 1001会阴差阳错地返回 true而用户输入从 Scanner 产生的字符串是运行时创建的几乎不会复用字面量引用导致结果随机。解决开发环境里全局搜索把银行卡号、密码、卡号比较的地方全部换成 equals。if (acc.getCardNo().equals(cardNo)) { // 正确 }逻辑说明equals 是 String 类重写后的内容比较方法是字符串比较的唯一标准写法。参数说明如果 acc.getCardNo() 可能为 null把常量放前面写成cardNo.equals(acc.getCardNo())更安全但在练习代码里卡号字段几乎不会为 null两种写法都行。4.5 输入字母导致程序直接抛异常崩溃现象菜单让输入 1-6用户手滑输入字母 x控制台刷出 InputMismatchException程序直接退出。原因nextInt 遇到非数字字符立刻抛异常没有捕获。解决读取前先用 hasNextInt 校验或者用 try-catch 包住并清空缓冲区。if (sc.hasNextInt()) { int op sc.nextInt(); } else { sc.next(); // 丢弃非法输入 System.out.println(请输入数字。); }逻辑说明hasNextInt 在缓冲区为空时会阻塞等待输入所以实际体验和 nextInt 一样。sc.next() 的作用是把非法字符串读掉防止死循环。参数说明try-catch 方案里 catch 到异常后同样要调 sc.next() 清空缓冲否则异常输入会一直卡住。5. 交作业前的升级数据持久化、测试用例与图形界面的边界基础功能能跑只是及格想让这份 ATM 模拟 java 实现拿高分通常还要解决三个问题数据重启后还在不在、业务代码能不能被自动测试、要不要加图形界面。这一章按性价比排序讲清楚。5.1 内存数据落到文件ObjectStream 读写与重启恢复作业阶段账户数据存在 ArrayList 里程序一关数据就没了。想演示“重启后余额还在”最通用的做法是用 ObjectOutputStream 把整个 List 序列化到本地文件。前提是 Account 类实现 java.io.Serializable这个标记接口不需要写任何方法。public class FileStore { public static void save(ListAccount accounts, String path) { try (ObjectOutputStream out new ObjectOutputStream(new FileOutputStream(path))) { out.writeObject(accounts); } catch (IOException e) { e.printStackTrace(); } } SuppressWarnings(unchecked) public static ListAccount load(String path) { File f new File(path); if (!f.exists()) return null; try (ObjectInputStream in new ObjectInputStream(new FileInputStream(path))) { return (ListAccount) in.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); return null; } } }逻辑说明try-with-resources 会自动关闭流不需要手动 flush 和 close。save 的 path 是含文件名的完整路径调用方自己决定目录常用路径是项目目录下的 data/accounts.dat。load 返回 null 表示没有存档调用方可以初始化默认账户。参数说明ObjectStream 方案写起来快但有一个代价如果 Account 类添加字段后读取旧存档会报序列化版本不匹配。所以它更适合课程演示不适合当真实生产方案。想避免这个坑可以改用 CSV 文本存储成本是手动实现解析但换来的好处是文件能直接用文本编辑器或 Excel 打开检查。存储方案对比如下方案优点缺点适用场景ObjectStream代码少、结构完整类结构一改旧档失效课程演示CSV 文本肉眼可读、好排查需要手动解析想展示数据细节数据库接近真实系统环境配置复杂课程大项目5.2 稳定可测的业务接口把五个方法从 main 里解放出来第二个重要升级是让业务方法返回 boolean。很多同学的存款取款方法把结果打印在方法内部菜单调用后只靠肉眼判断是否成功排错效率很低。把业务结果返回出来控制台输出留在菜单层代码职责就清晰了而且能直接写测试。public static void main(String[] args) { Account a new Account(1001, 123456, 500.0); Bank bank new Bank(Arrays.asList(a)); ATM atm new ATM(bank); boolean ok1 atm.withdraw(a, 200.0); boolean ok2 atm.withdraw(a, 1000.0); boolean ok3 atm.withdraw(a, -1.0); System.out.println(正常取款 ok1); System.out.println(余额不足 ok2); System.out.println(负数拒绝 ok3); }逻辑说明这个测试类不依赖 Scanner不依赖菜单直接构造三个固定场景。ok1 应该为 trueok2 和 ok3 应该为 false。写完一个业务方法就跑一遍这段代码比等整个菜单拼好后用眼睛找问题快得多。参数说明这种测试用的还是 main 方法严格说不是 JUnit。如果你的课程要求用 JUnit把每个断言换成 assertEquals(true, atm.withdraw(a, 200.0)) 即可业务方法本身不需要任何改动。这里的关键是所有业务方法的入参和返回值必须稳定不要和 Scanner 绑死。5.3 Swing 可以上但别让它成为答辩问倒你的点有些课程明确要求图形界面有些同学为了效果主动加 Swing。我的建议是如果题目没强制要求别上。JFrame、按钮事件监听、SwingUtilities.invokeLater 一旦混进来代码量翻倍答辩时老师一句“你这个按钮监听跑在哪个线程上”就能问住不少人。如果确实需要图形界面保持业务方法现状不变图形层只负责把用户输入封装成参数调用原来的方法。例如 JButton 的 actionPerformed 里只写atm.withdraw(acc, amount)提示文本继续依赖业务方法内部的 System.out再想办法把控制台日志同步显示到 JTextArea。这样做的好处是图形界面坏了业务逻辑仍然可以独立测试老师问业务代码时你还能切换回控制台版演示。Swing 不是不能学而是不要在作业答辩前为了“看起来高级”临时硬塞一个黑匣子进去。6. 一个能救命的验证技巧先写用例表再写实现ATM 模拟这类作业功能点越多越容易在最后混成一团。我的习惯是动手写业务方法之前先把用例表列出来每个功能覆盖正常路径和至少两个异常路径。这张表不写进报告的章节但它是整个开发过程里的“后悔药”。功能操作输入期望结果登录正确卡号密码1001 / 123456进入主菜单登录错误密码1001 / 000000提示卡号或密码错误取款正常取款余额 500取 200余额变 300取款余额不足取 600提示余额不足余额不变取款负数取 -100拒绝余额不变转账收款方不存在卡号 9999提示收款账户不存在改密新旧密码相同旧 123456新 123456提示不能相同做法很简单每写完一个业务方法就对照表跑一遍对应的三行用例不要等所有代码写完再集中测。边界问题在写的时候暴露比最后堆在一起排错要省几倍时间。这套方法还能帮你提前发现一个隐蔽问题当你把用例表按“正常 / 边界 / 异常”三个维度展开时会发现很多业务方法其实少了一个判断分支。比如取款只有amount balance判断负数这条用例一跑就翻车你能立刻补上amount 0而不是等到答辩时由老师替你踩一遍。最后说个真实教训有一回我交模拟项目前偷懒只测了正常路径。答辩演示时老师输入了一个负数取款余额不减反增当时的尴尬场面我现在还记得。从那以后我每次写完业务方法都强制对照用例表至少跑一遍正常路径和一条异常路径。希望帮到你。本文还有配套的精品资源点击获取