Java Socket银行系统:从TCP连接到事务安全的实战指南
发布时间:2026/9/29 19:12:06 作者:尧图编辑部 阅读量:1,286

简介这是一份面向Java初学者的J2SE网络编程实战项目资源聚焦Socket通信与多线程协同开发能力训练。项目模拟真实银行系统架构总行CCH作为中心服务器管理CIBC、TD等多家支行及其ATM终端支持存款、取款、查询余额、跨行操作及后续扩展的行内/跨行转账功能完整覆盖客户端-服务器交互、账户身份验证、跨行委托处理、状态同步与容错报到机制等核心场景。资源包共50个文件含28个Java源码涵盖Server、Client、Account、Protocol等模块、15个编译后class文件、6个properties配置文件用于参数管理以及1份详细需求说明rtf文档整体仅58KB轻量易解压学习。已有193人下载学习代码结构清晰、注释充分附带完整运行逻辑与启动时序说明适合新手通过调试理解网络通信生命周期、线程安全控制与分布式事务协调思想。1. 为什么一个“银行项目”要死磕 Socket 和 J2SE——新手绕不开的网络编程黑匣子你写过System.out.println(Hello World)也用过 ArrayList 和 HashMap但当第一次尝试让两个 Java 程序在不同电脑上“说话”比如客户端输个“转账 1000 给张三”服务端真把账户余额改了——那一刻J2SE 就从语法练习册跳进了真实世界。这个“网络编程银行项目”不是模拟 ATM 界面的 Swing 小玩具而是用纯 J2SEJava SE 8/11、不依赖 Spring Boot、不碰 Tomcat、只靠java.net.Socket和ServerSocket搭建的双端可运行、状态可验证、断连可恢复的最小闭环系统。它解决的不是“怎么画按钮”而是“字节流怎么变成业务动作”“连接断了钱有没有丢”“多个客户同时操作账户会不会算错”。适合刚学完 IO 流和多线程、正卡在“知道概念但不敢写服务端”的人——别急着卷 Netty 或 gRPC先把 TCP 连接建立、请求解析、线程隔离、异常回滚这四块砖码稳。项目跑起来后你会突然懂什么叫“阻塞式 IO 的呼吸感”什么叫“粘包不是玄学是字节边界没守好”。2. 从零搭起银行服务端用 ServerSocket 实现可并发、可中断、可查账的最小核心2.1 服务端骨架主线程监听 工作线程池的硬核组合新手常犯的错误是写个while(true) { accept() }就完事结果一来十个客户端服务端直接卡死。真正的 J2SE 网络服务必须把“接受连接”和“处理业务”彻底拆开。我们用ExecutorService管理工作线程主线程只干一件事等新连接。// BankServer.java public class BankServer { private static final int PORT 8080; private static final ExecutorService threadPool Executors.newFixedThreadPool(10, r - { Thread t new Thread(r, BankWorker- System.currentTimeMillis()); t.setDaemon(false); // 关键不能设为守护线程否则 JVM 退出时任务被杀 return t; }); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(银行服务已启动监听端口 PORT); while (!Thread.currentThread().isInterrupted()) { try { Socket clientSocket serverSocket.accept(); // 阻塞在此直到有连接 System.out.println(新连接接入 clientSocket.getRemoteSocketAddress()); threadPool.submit(new ClientHandler(clientSocket)); } catch (IOException e) { if (!serverSocket.isClosed()) { System.err.println(accept 失败 e.getMessage()); } break; // socket 关闭或异常时退出循环 } } serverSocket.close(); threadPool.shutdown(); try { if (!threadPool.awaitTermination(5, TimeUnit.SECONDS)) { threadPool.shutdownNow(); } } catch (InterruptedException e) { threadPool.shutdownNow(); Thread.currentThread().interrupt(); } } }逻辑说明ServerSocket.accept()是阻塞调用但它只负责“开门”不参与“算账”。每个ClientHandler被提交到线程池后独立处理该连接的全部读写互不干扰。setDaemon(false)是血泪经验——设成 true 后主线程结束时所有工作线程被强制终止转账中途断电钱就没了。参数说明线程池大小设为 10 是保守值。实际中可根据服务器 CPU 核数 × 2 估算如 4 核机器用 8但银行类业务建议宁小勿大——线程太多反而因上下文切换拖慢响应且每个线程需维护 Socket 连接状态内存开销不可忽视。2.2 客户端处理器用 BufferedReader PrintWriter 做协议解析拒绝 raw byte 操作别一上来就DataInputStream.readUTF()——它底层用modified UTF-8编码遇到中文或特殊字符极易乱码。我们用BufferedReader读行\n分隔、PrintWriter写行协议约定为纯文本命令清晰、可调试、易排查。// ClientHandler.java public class ClientHandler implements Runnable { private final Socket socket; private final BufferedReader in; private final PrintWriter out; public ClientHandler(Socket socket) throws IOException { this.socket socket; // 关键设置自动 flush否则 write 后不发出去 this.out new PrintWriter(socket.getOutputStream(), true); this.in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); } Override public void run() { try { // 发送欢迎消息 out.println(【银行服务】已连接。支持命令QUERY 账号 | TRANSFER from to amount | EXIT); String line; while ((line in.readLine()) ! null) { line line.trim(); if (line.isEmpty()) continue; String[] parts line.split(\\s); if (parts.length 0) continue; switch (parts[0].toUpperCase()) { case QUERY: handleQuery(parts); break; case TRANSFER: handleTransfer(parts); break; case EXIT: out.println(再见); return; // 正常关闭连接 default: out.println(错误未知命令 parts[0] 。输入 HELP 查看帮助); } } } catch (IOException e) { // 客户端异常断开如关窗口、网络中断 System.err.println(客户端断开 socket.getRemoteSocketAddress() - e.getMessage()); } finally { try { socket.close(); } catch (IOException ignored) {} } } private void handleQuery(String[] parts) { if (parts.length ! 2) { out.println(错误QUERY 需要 1 个参数例如 QUERY 1001); return; } String account parts[1]; BigDecimal balance BankDatabase.getBalance(account); if (balance null) { out.println(错误账户 account 不存在); } else { out.println(账户 account 余额 balance); } } private void handleTransfer(String[] parts) { if (parts.length ! 4) { out.println(错误TRANSFER 需要 3 个参数例如 TRANSFER 1001 1002 500.00); return; } String from parts[1], to parts[2]; BigDecimal amount; try { amount new BigDecimal(parts[3]); } catch (NumberFormatException e) { out.println(错误金额格式不正确请输入数字如 100.50); return; } // 执行转账含事务性校验 boolean success BankDatabase.transfer(from, to, amount); if (success) { out.println(成功从 from 转出 amount 到 to); } else { out.println(失败转账未执行可能余额不足或账户不存在); } } }逻辑说明PrintWriter构造时传入true表示自动 flush这是关键。若不设out.println()只写入缓冲区客户端永远收不到响应。readLine()自动按\n截断天然解决粘包问题——只要客户端每条命令以\n结尾标准 Telnet 行为服务端就能准确切分。BankDatabase是内存账户表后续章节详述此处重点是命令解析与业务路由分离handleQuery和handleTransfer只管调用不碰 IO。参数说明StandardCharsets.UTF_8显式指定编码避免 Windows/Linux 默认编码差异导致中文乱码。socket.getRemoteSocketAddress()在日志中打印客户端 IP端口是排障第一手信息。3. 银行核心数据层用 ConcurrentHashMap ReentrantLock 实现线程安全的内存账户系统3.1 账户模型设计BigDecimal 是唯一选择double 是定时炸弹银行项目里double balance 100.0; balance 0.1;运行 1000 次后结果不是 200.0而是 199.99999999999997 ——浮点误差在金融场景是致命的。BigDecimal虽重但它是唯一能精确表示货币的类型。我们定义账户实体// Account.java public class Account { private final String accountId; private volatile BigDecimal balance; // volatile 保证可见性但不保证原子性 public Account(String accountId, BigDecimal initialBalance) { this.accountId Objects.requireNonNull(accountId); this.balance Objects.requireNonNull(initialBalance).setScale(2, RoundingMode.HALF_UP); } public String getAccountId() { return accountId; } public BigDecimal getBalance() { return balance; } // 注意此方法不保证线程安全仅用于内部调用 void setBalance(BigDecimal newBalance) { this.balance newBalance.setScale(2, RoundingMode.HALF_UP); } Override public String toString() { return String.format(Account{id%s, balance%.2f}, accountId, balance.doubleValue()); } }逻辑说明setScale(2, RoundingMode.HALF_UP)强制保留两位小数避免new BigDecimal(100.0)和new BigDecimal(100.00)被视为不同值。volatile保证一个线程修改后其他线程能立即看到最新值但不能替代锁——balance.add(amount)是读改写三步操作必须加锁。3.2 内存数据库实现ConcurrentHashMap 存账户ReentrantLock 控制转账临界区不用 MySQL用纯内存模拟但必须保证并发安全。ConcurrentHashMap提供分段锁适合高频读转账涉及两个账户需全局锁协调。// BankDatabase.java public class BankDatabase { // 账户存储线程安全的 Map private static final ConcurrentHashMapString, Account accounts new ConcurrentHashMap(); // 转账锁用账户 ID 字符串做 lock key避免锁整个 DB private static final ConcurrentMapString, Lock accountLocks new ConcurrentHashMap(); static { // 初始化测试账户 accounts.put(1001, new Account(1001, new BigDecimal(5000.00))); accounts.put(1002, new Account(1002, new BigDecimal(3000.00))); accounts.put(1003, new Account(1003, new BigDecimal(1000.00))); } public static BigDecimal getBalance(String accountId) { Account acc accounts.get(accountId); return acc ! null ? acc.getBalance() : null; } public static boolean transfer(String fromId, String toId, BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) 0) return false; // 获取两个账户的锁按字典序加锁防止死锁 String lockKey1 fromId.compareTo(toId) 0 ? fromId : toId; String lockKey2 fromId.compareTo(toId) 0 ? toId : fromId; Lock lock1 getOrCreateLock(lockKey1); Lock lock2 getOrCreateLock(lockKey2); try { lock1.lock(); lock2.lock(); Account fromAcc accounts.get(fromId); Account toAcc accounts.get(toId); if (fromAcc null || toAcc null) return false; BigDecimal fromBalance fromAcc.getBalance(); if (fromBalance.compareTo(amount) 0) return false; // 余额不足 // 执行转账先扣后加保证中间态不出现负余额 fromAcc.setBalance(fromBalance.subtract(amount)); toAcc.setBalance(toAcc.getBalance().add(amount)); return true; } finally { lock2.unlock(); lock1.unlock(); } } private static Lock getOrCreateLock(String key) { return accountLocks.computeIfAbsent(key, k - new ReentrantLock()); } }逻辑说明ConcurrentHashMap本身线程安全但transfer涉及两次get和两次setBalance且需原子性判断余额——所以必须加锁。lockKey1/lockKey2按字典序排序加锁是经典防死锁策略所有线程都先锁小 ID、再锁大 ID不会出现 A 锁 1001 等 1002、B 锁 1002 等 1001 的循环等待。computeIfAbsent确保每个账户 ID 对应唯一锁对象避免为同一账户创建多个锁。参数说明RoundingMode.HALF_UP是银行四舍五入标准如 1.235 → 1.24。BigDecimal.ZERO比new BigDecimal(0)更高效。accounts.get(fromId)返回null时直接返回false不抛异常——网络服务要优雅降级不能因单个错误崩溃。4. 客户端实战用 Socket 连接服务端实现带超时、重连、命令历史的终端交互4.1 基础客户端用 Scanner 读用户输入Socket 通信手动管理连接生命周期客户端不是简单telnet localhost 8080而是 Java 程序需处理连接失败、服务端宕机、输入非法等场景。// BankClient.java public class BankClient { private static final String HOST localhost; private static final int PORT 8080; private static final int CONNECT_TIMEOUT_MS 5000; public static void main(String[] args) { Scanner scanner new Scanner(System.in); Socket socket null; PrintWriter out null; BufferedReader in null; try { System.out.print(正在连接银行服务...); socket new Socket(); socket.connect(new InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS); System.out.println(✓ 已连接); out new PrintWriter(socket.getOutputStream(), true); in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); // 读取服务端欢迎消息 String welcome in.readLine(); if (welcome ! null) System.out.println(welcome); // 主循环读用户输入发命令读响应 while (true) { System.out.print( ); String input scanner.nextLine().trim(); if (input.isEmpty()) continue; if (EXIT.equalsIgnoreCase(input)) { out.println(input); break; } out.println(input); String response in.readLine(); if (response null) { System.out.println(错误服务端无响应可能已断开); break; } System.out.println(response); } } catch (ConnectException e) { System.err.println(✗ 连接失败服务端未启动或地址错误 HOST : PORT ); } catch (SocketTimeoutException e) { System.err.println(✗ 连接超时 CONNECT_TIMEOUT_MS ms 内未响应); } catch (IOException e) { System.err.println(✗ 通信异常 e.getMessage()); } finally { closeQuietly(socket, out, in, scanner); } } private static void closeQuietly(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignored) {} } } } }逻辑说明socket.connect(..., timeout)显式设置连接超时避免new Socket(host, port)卡死几十秒。closeQuietly是健壮性必备——无论哪一步出错都要确保 Socket 和流被关闭防止句柄泄漏。in.readLine()返回null表示服务端已关闭连接此时客户端应主动退出。参数说明CONNECT_TIMEOUT_MS 5000是经验值。局域网内 1~2 秒足够公网环境可设为 10~15 秒。StandardCharsets.UTF_8与服务端保持一致杜绝编码错位。4.2 进阶客户端添加命令历史History和自动重连机制纯Scanner输入体验差。我们用jline3库轻量、纯 Java、无 JNI实现命令历史↑↓ 键翻阅、自动补全暂略、以及断连后自动重试。!-- pom.xml 添加依赖 -- dependency groupIdorg.jline/groupId artifactIdjline/artifactId version3.21.0/version /dependency// SmartBankClient.java部分核心 public class SmartBankClient { private static final String HOST localhost; private static final int PORT 8080; private static final int MAX_RECONNECT_ATTEMPTS 3; private static final long RECONNECT_DELAY_MS 2000; public static void main(String[] args) throws IOException { Terminal terminal TerminalBuilder.builder() .system(true) .build(); LineReader reader LineReaderBuilder.builder() .terminal(terminal) .completer(new NoOpCompleter()) // 暂不实现补全 .build(); Socket socket null; PrintWriter out null; BufferedReader in null; for (int attempt 0; attempt MAX_RECONNECT_ATTEMPTS; attempt) { try { socket new Socket(); socket.connect(new InetSocketAddress(HOST, PORT), 5000); out new PrintWriter(socket.getOutputStream(), true); in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); System.out.println(✓ 连接成功); break; // 成功则跳出重试循环 } catch (Exception e) { if (attempt MAX_RECONNECT_ATTEMPTS) { System.err.println(✗ 重试 MAX_RECONNECT_ATTEMPTS 次后仍连接失败 e.getMessage()); return; } System.err.println(⚠ 第 (attempt 1) 次连接失败 RECONNECT_DELAY_MS ms 后重试...); try { Thread.sleep(RECONNECT_DELAY_MS); } catch (InterruptedException ie) { return; } } } // 读取欢迎消息 String welcome in.readLine(); if (welcome ! null) System.out.println(welcome); // 主交互循环 String line; while ((line reader.readLine( )) ! null) { line line.trim(); if (line.isEmpty()) continue; if (EXIT.equalsIgnoreCase(line)) { out.println(line); break; } out.println(line); String response in.readLine(); if (response null) { System.out.println(⚠ 服务端断开尝试重连...); // 关闭旧连接 closeQuietly(socket, out, in); // 重连逻辑同上省略重复代码 break; } System.out.println(response); } closeQuietly(socket, out, in, terminal, reader); } }逻辑说明jline3的LineReader自动支持历史记录默认保存.jline-history文件用户按 ↑ 键即可调出上一条命令。TerminalBuilder创建终端适配器兼容 Windows CMD 和 Linux bash。重连逻辑封装在 for 循环中失败后 sleep 再试避免高频轮询打爆服务端。参数说明MAX_RECONNECT_ATTEMPTS 3是平衡用户体验与资源消耗的合理值。RECONNECT_DELAY_MS 2000避免瞬间重连风暴。NoOpCompleter是占位符后续可扩展为TRANSFER命令自动补全账户 ID。5. 避坑指南新手在 Socket 银行项目中踩过的 5 个真实血泪坑5.1 现象客户端发送 TRANSFER 1001 1002 100服务端收到 TRANSFER 1001 1002 100\n\n多一个空行原因客户端用PrintWriter.println()发送命令而println()在 Windows 下写\r\nLinux 下写\n服务端readLine()会把\r\n当作一行、\n当作另一行导致解析错位。解决统一用PrintWriter.print(line \n)替代println()并确保服务端readLine()后trim()去除可能的\r。更彻底方案是协议层约定换行符为\n并在InputStreamReader构造时指定StandardCharsets.UTF_8避免平台差异。5.2 现象多客户端同时转账账户余额出现“幽灵钱”如转出 100余额只减 99.99原因BigDecimal构造时用了double参数如new BigDecimal(100.0)而100.0在二进制中无法精确表示导致精度丢失。解决所有BigDecimal必须用字符串构造new BigDecimal(100.00)。转账金额解析时new BigDecimal(parts[3])已是字符串安全但初始化账户余额时new BigDecimal(5000.00)不可写成new BigDecimal(5000.00)。5.3 现象服务端运行几小时后java.lang.OutOfMemoryError: unable to create new native thread原因线程池未正确 shutdown每次客户端断开ClientHandler线程结束但线程对象未被 GC线程栈持续占用内存或ExecutorService被设为newCachedThreadPool()无限创建线程。解决严格使用newFixedThreadPool(n)并在main方法结尾调用shutdown()awaitTermination()。检查ClientHandler.run()的finally块是否确保socket.close()执行——未关闭的 Socket 会持有线程和文件句柄。5.4 现象客户端输入 QUERY 1001服务端日志显示NullPointerException原因BankDatabase.getBalance(1001)返回null但handleQuery中直接调用balance.toString()未判空。解决所有外部输入账户 ID、金额必须做空值和存在性校验。getBalance()返回null是正常流程handleQuery应显式检查if (balance null)并返回友好提示而非让 NPE 穿透到顶层。5.5 现象服务端重启后客户端仍显示“已连接”但发命令无响应原因客户端未检测 TCP 连接状态。Socket.isClosed()和Socket.isConnected()只反映本地状态不探测对端是否存活socket.getOutputStream().write()即使对端已死也不会立即报错直到缓冲区满或超时。解决实现心跳机制。服务端每 30 秒向客户端发PING客户端收到后回PONG若连续 2 次未收到PING主动断开重连。或客户端发命令后设定readLine()超时用Socket.setSoTimeout(5000)超时即判定连接失效。6. 让银行项目真正“活”起来集成 JUnit 测试、日志审计、以及生产级加固技巧6.1 用 JUnit 5 写可信赖的转账测试覆盖并发、异常、边界值光手动测不够。我们为BankDatabase.transfer()写白盒测试验证线程安全和业务逻辑。// BankDatabaseTest.java class BankDatabaseTest { Test void should_transfer_successfully_when_balance_sufficient() { // given String from 1001; String to 1002; BigDecimal amount new BigDecimal(100.00); // when boolean result BankDatabase.transfer(from, to, amount); // then assertTrue(result); assertEquals(new BigDecimal(4900.00), BankDatabase.getBalance(from)); assertEquals(new BigDecimal(3100.00), BankDatabase.getBalance(to)); } Test void should_fail_transfer_when_insufficient_balance() { // given String from 1003; // 余额 1000.00 String to 1001; BigDecimal amount new BigDecimal(1001.00); // 超出余额 // when boolean result BankDatabase.transfer(from, to, amount); // then assertFalse(result); assertEquals(new BigDecimal(1000.00), BankDatabase.getBalance(from)); // 余额不变 } Test void should_be_thread_safe_under_concurrent_transfers() throws InterruptedException { // given int threadCount 10; CountDownLatch latch new CountDownLatch(threadCount); ExecutorService executor Executors.newFixedThreadPool(threadCount); // when: 10 个线程同时从 1001 转 10.00 到 1002 for (int i 0; i threadCount; i) { executor.submit(() - { try { BankDatabase.transfer(1001, 1002, new BigDecimal(10.00)); } finally { latch.countDown(); } }); } latch.await(); // then: 总共转出 100.001001 余额应为 4900.00 assertEquals(new BigDecimal(4900.00), BankDatabase.getBalance(1001)); assertEquals(new BigDecimal(3100.00), BankDatabase.getBalance(1002)); } }逻辑说明第三个测试是关键——CountDownLatch确保所有线程同时启动模拟真实并发压力。assertEquals断言最终余额验证锁机制是否真正生效。若去掉ReentrantLock此测试必 fail。6.2 加入 SLF4J 日志记录每一笔交易满足审计要求银行操作必须留痕。用slf4j-simple无需配置开箱即用记录关键事件。dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.7/version /dependency// 在 BankDatabase.transfer() 中添加 private static final Logger logger LoggerFactory.getLogger(BankDatabase.class); public static boolean transfer(String fromId, String toId, BigDecimal amount) { // ... 前置校验 ... boolean success false; try { // ... 执行转账 ... success true; logger.info(TRANSFER_SUCCESS: from{} to{} amount{} | balance_from{} balance_to{}, fromId, toId, amount, fromAcc.getBalance(), toAcc.getBalance()); } catch (Exception e) { logger.error(TRANSFER_FAILED: from{} to{} amount{} | error{}, fromId, toId, amount, e.getMessage()); throw e; } return success; }参数说明logger.info()用占位符{}比字符串拼接高效且异常时不会触发 toString()。日志级别区分INFO记成功操作ERROR记失败DEBUG可开启详细流程如加锁过程。日志输出到控制台也可重定向到文件-Dorg.slf4j.simpleLogger.logFilebank.log。6.3 生产级加固添加连接数限制、命令频率限制、以及优雅关闭钩子教学项目到生产还有距离。三个加固点连接数限制在BankServer.main()中用AtomicInteger计数超过阈值拒绝新连接。private static final AtomicInteger connectionCount new AtomicInteger(0); private static final int MAX_CONNECTIONS 100; // accept 后 if (connectionCount.incrementAndGet() MAX_CONNECTIONS) { System.err.println(拒绝连接已达最大连接数 MAX_CONNECTIONS); clientSocket.close(); continue; }命令频率限制为每个 IP 维护ConcurrentHashMapInetAddress, Long记录上次命令时间间隔小于 1 秒拒绝。private static final ConcurrentMapInetAddress, Long lastCommandTime new ConcurrentHashMap(); // 在 ClientHandler.run() 开头 InetAddress remoteAddr socket.getInetAddress(); long now System.currentTimeMillis(); if (now - lastCommandTime.getOrDefault(remoteAddr, 0L) 1000) { out.println(操作太频繁请稍后再试); return; } lastCommandTime.put(remoteAddr, now);优雅关闭钩子JVM 退出前确保所有转账完成、连接关闭。Runtime.getRuntime().addShutdownHook(new Thread(() - { System.out.println(正在关闭银行服务...); try { if (serverSocket ! null !serverSocket.isClosed()) { serverSocket.close(); } threadPool.shutdown(); if (!threadPool.awaitTermination(10, TimeUnit.SECONDS)) { threadPool.shutdownNow(); } } catch (Exception e) { System.err.println(关闭失败 e); } System.out.println(银行服务已安全退出); }));我带过 7 届实习生所有人第一次写ServerSocket都在readLine()和flush上栽过跟头第二课永远是BigDecimal字符串构造第三课才明白ReentrantLock的lock()和unlock()必须成对出现在try/finally。这个银行项目不是让你造轮子而是亲手把 TCP 连接、线程调度、事务一致性、异常传播这些黑匣子一层层打开——当你能看着日志里TRANSFER_SUCCESS一行行刷出来就知道 J2SE 网络编程的底气在哪。希望帮到你。本文还有配套的精品资源点击获取