我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Java Socket多线程银行系统实战:TCP通信+并发账户安全

Java Socket多线程银行系统实战:TCP通信+并发账户安全 简介这是一套面向Java初学者的J2SE网络编程实战项目聚焦Socket通信与多线程协同开发完整实现银行分布式系统架构包含总行CCH服务器、CIBC/TD等多家支行服务端及对应ATM终端客户端支持存款、取款、余额查询、跨行操作等核心业务并预留行内/跨行转账扩展接口。资源包共50个文件含28个Java源码涵盖Server、Client、Account、Protocol等模块、15个编译后class文件、6个properties配置文件用于服务地址、线程池等参数管理及1份需求说明rtf文档整体仅58KB轻量易读结构清晰便于逐层调试与理解。目前已有193人学习下载适合Java入门者通过可运行的完整案例深入掌握TCP连接管理、多线程安全处理、跨进程身份验证委托机制及分布式状态同步逻辑。1. 网络编程银行项目一个用 Socket 多线程跑通存取款全流程的 J2SE 实战靶场这不是一个“Hello World”式的 Socket 练手 Demo而是一个能真实模拟银行柜台并发服务的可运行系统——客户端发起存款、取款、查询余额请求服务端用多线程池处理多个客户连接每个连接独立维护会话状态关键操作加锁防并发冲突所有通信基于原始 TCP Socket不依赖 Spring Boot、Netty 或任何网络框架。它专为刚学完 Java 基础、正卡在“知道线程和 Socket 概念但写不出完整交互逻辑”的 J2SE 学习者设计。项目里没有花哨的 UI只有控制台日志滚动的真实感没有抽象的接口定义只有DataInputStream/DataOutputStream字节流读写的硬核细节更没有“自动重连”“心跳保活”这类进阶黑匣子——它只做一件事把“网络编程”四个字拆成你能逐行调试、改参数、看日志、亲手掐断连接再复现异常的实体。如果你正在被“多线程共享资源怎么同步”“Socket 断连后服务端怎么清理”“为什么客户端发了数据服务端收不到”反复暴击这个项目就是你的血泪调试沙盒。2. 项目结构与核心通信协议从 BankServer.java 到 BankClient.java 的端到端链路2.1 项目目录与文件职责划分JDK 8 可直接编译整个项目是纯 Java SE 工程无 Maven 依赖所有代码集中在src/下共 5 个核心.java文件文件名职责说明关键技术点BankServer.java主服务端入口监听 8080 端口接受客户端连接为每个连接创建ClientHandler线程ServerSocket.accept()阻塞等待、ExecutorService线程池管理ClientHandler.java每个客户端连接的专属处理器封装Socket、DataInputStream/DataOutputStream解析命令并调用BankServicesynchronized方法级锁、try-with-resources自动关闭流BankService.java银行业务逻辑单例持有一个ConcurrentHashMapString, Account模拟用户账户提供deposit()/withdraw()/queryBalance()ConcurrentHashMap线程安全读、synchronized块保护写操作Account.java账户 POJO含accountNo字符串主键、balancedouble、lastModified时间戳volatile修饰lastModified保证可见性BankClient.java客户端主类连接服务端提供命令行菜单1-存款 2-取款 3-查询 0-退出发送结构化指令PrintWriter自动 flush、BufferedReader.readLine()阻塞读响应提示所有Socket相关 I/O 操作均使用DataInputStream/DataOutputStream而非ObjectInputStream避免序列化兼容性问题BankService不暴露Account对象引用只返回balance值切断外部直接修改可能。2.2 客户端-服务端通信协议用 ASCII 字符串定义清晰边界协议设计极度轻量不用 JSON、不用二进制编码、不用自定义包头包尾仅靠换行符\n分隔字段降低新手理解门槛。每条请求/响应均为单行字符串格式如下客户端发送请求三段式用空格分隔CMD ACCOUNT_NO AMOUNTCMD命令码DEP存款、WDR取款、QRY查询ACCOUNT_NO账户号如ACC001AMOUNT金额仅DEP/WDR需填QRY时填0服务端返回响应两段式用冒号分隔STATUS:MESSAGESTATUSOK/ERR/NOT_FOUNDMESSAGE人类可读描述如balance1500.0或insufficient funds例如客户端输入DEP ACC001 1000.0→ 服务端返回OK:balance1500.0客户端输入WDR ACC001 2000.0→ 服务端返回ERR:insufficient funds该协议规避了粘包/半包问题DataInputStream.readLine()会严格等待\n只要客户端每次writeUTF()后紧跟flush()服务端就能完整读取整行。这是新手最容易踩坑的底层细节——不是协议不行是你没 flush。2.3 BankService 并发安全设计为什么用 synchronized 块而非方法BankService是整个系统的状态中心必须保证多线程并发调用时数据一致性。但注意不是所有方法都加synchronized就万事大吉。看关键代码片段public class BankService { private final ConcurrentHashMapString, Account accounts new ConcurrentHashMap(); public double queryBalance(String accountNo) { Account acc accounts.get(accountNo); return acc ! null ? acc.getBalance() : -1.0; } public boolean deposit(String accountNo, double amount) { // 读操作无需锁ConcurrentHashMap 保证线程安全 Account acc accounts.get(accountNo); if (acc null) return false; // 写操作必须加锁防止 A 线程读余额→B 线程读余额→A 加钱→B 加钱导致少加一次 synchronized (acc) { acc.setBalance(acc.getBalance() amount); acc.setLastModified(System.currentTimeMillis()); } return true; } }这里的关键决策queryBalance()不加锁ConcurrentHashMap.get()是线程安全的且Account.getBalance()是volatile double读无需同步deposit()中对acc对象加锁而非对整个BankService实例加锁若用synchronized void deposit(...)则所有账户操作串行化性能崩盘而锁在Account实例上不同账户可并行操作如ACC001存款与ACC002取款互不阻塞锁粒度精确到具体账户对象这是高并发场景下的黄金准则——宁可多写几行synchronized(acc)也不用粗粒度锁拖垮吞吐。3. 服务端启动与多线程连接处理从 ServerSocket 到 ClientHandler 的生命周期3.1 BankServer.java主线程监听 线程池派发的双层架构服务端采用经典的“主线程监听、工作线程处理”模型避免accept()阻塞导致新连接排队。核心逻辑如下public class BankServer { private static final int PORT 8080; private static final int MAX_THREADS 10; // 线程池最大线程数 public static void main(String[] args) throws IOException { ExecutorService threadPool Executors.newFixedThreadPool(MAX_THREADS); try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(BankServer started on port PORT); while (!Thread.currentThread().isInterrupted()) { Socket clientSocket serverSocket.accept(); // 阻塞在此等待连接 System.out.println(New connection from clientSocket.getRemoteSocketAddress()); // 将连接交给线程池处理主线程立即返回 accept 下一个 threadPool.submit(new ClientHandler(clientSocket)); } } finally { threadPool.shutdown(); } } }为什么用ExecutorService而非new Thread(...).start()new Thread每次创建开销大频繁 GC无上限创建线程会导致OutOfMemoryError: unable to create new native thread线程池可复用、可监控、可优雅关闭shutdown()等待任务完成。此处MAX_THREADS10是经验值J2SE 新手项目并发量低10 线程足以支撑 20 客户端轮询生产环境需根据 CPU 核心数、I/O 密集度压测调整常见公式为CPU核心数 × (1 平均等待时间/平均工作时间)。3.2 ClientHandler.java每个连接的独立生命体与资源闭环ClientHandler是连接的“灵魂”它持有Socket、输入输出流并负责完整处理一次会话。其构造函数和run()方法是关键public class ClientHandler implements Runnable { private final Socket socket; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try (DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { System.out.println(Handling client: socket.getRemoteSocketAddress()); String request; while ((request in.readLine()) ! null) { // 阻塞读一行 String[] parts request.trim().split(\\s); if (parts.length 2) continue; String cmd parts[0].toUpperCase(); String accountNo parts[1]; double amount parts.length 2 ? Double.parseDouble(parts[2]) : 0.0; String response processCommand(cmd, accountNo, amount); out.writeUTF(response); // 发送响应 out.flush(); // 关键不 flush 客户端永远收不到 } } catch (IOException e) { System.err.println(Client disconnected: socket.getRemoteSocketAddress()); } finally { try { socket.close(); // 连接关闭资源释放 } catch (IOException ignored) {} } } }重点逻辑说明try-with-resources确保in/out流在run()结束时自动关闭即使发生异常in.readLine()会一直阻塞直到收到\n或连接关闭因此客户端必须发送带换行的字符串writer.println(DEP ACC001 100.0)out.writeUTF()发送字符串但必须紧跟out.flush()—— 因为DataOutputStream内部有缓冲区不 flush 数据滞留在内存客户端readLine()永远等不到finally块中显式socket.close()这是 TCP 连接释放的最终动作避免文件描述符泄漏Linux 下ulimit -n限制进程打开文件数泄漏会导致Too many open files错误。3.3 多线程安全边界ClientHandler 如何与 BankService 协同ClientHandler本身是无状态的它只负责“翻译”协议和转发请求真正的状态变更发生在BankService。二者协作流程如下ClientHandler.run()解析出cmdDEP,accountNoACC001,amount100.0调用BankService.getInstance().deposit(ACC001, 100.0)BankService.deposit()内部accounts.get(ACC001)获取Account对象线程安全synchronized(acc)锁定该账户实例acc.setBalance(...)更新余额此时其他线程若也操作ACC001会被阻塞在此处deposit()返回trueClientHandler组装OK:balance1100.0响应out.writeUTF()发送并flush()。关键认知ClientHandler实例之间完全隔离每个线程一个实例它们共享的是BankService单例和其中的ConcurrentHashMap。锁的粒度在Account对象上而非ClientHandler或BankService这保证了不同账户的完全并发同一账户的强一致性。4. 客户端交互与命令行驱动BankClient.java 的健壮性设计4.1 BankClient.java如何让控制台交互不因异常崩溃客户端看似简单实则暗藏陷阱。一个健壮的BankClient必须处理网络中断、服务端宕机、非法输入、流关闭等。核心结构如下public class BankClient { private static final String SERVER_HOST localhost; private static final int SERVER_PORT 8080; public static void main(String[] args) { Scanner scanner new Scanner(System.in); Socket socket null; try { socket new Socket(SERVER_HOST, SERVER_PORT); DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream()); System.out.println(Connected to BankServer. Enter command:); System.out.println(1: Deposit 2: Withdraw 3: Query 0: Exit); while (true) { System.out.print( ); String input scanner.nextLine().trim(); if (0.equals(input)) break; String request buildRequest(input); if (request null) { System.out.println(Invalid command. Try again.); continue; } out.writeUTF(request); out.flush(); String response in.readLine(); System.out.println(Server: response); } } catch (UnknownHostException e) { System.err.println(Error: Cannot connect to server at SERVER_HOST : SERVER_PORT); } catch (IOException e) { System.err.println(Network error: e.getMessage()); } finally { if (socket ! null !socket.isClosed()) { try { socket.close(); } catch (IOException ignored) {} } scanner.close(); } } private static String buildRequest(String input) { switch (input) { case 1: return DEP getAccountNo() getAmount(); case 2: return WDR getAccountNo() getAmount(); case 3: return QRY getAccountNo() 0; default: return null; } } private static String getAccountNo() { Scanner sc new Scanner(System.in); System.out.print(Account No: ); return sc.nextLine().trim(); } private static double getAmount() { Scanner sc new Scanner(System.in); System.out.print(Amount: ); try { return Double.parseDouble(sc.nextLine().trim()); } catch (NumberFormatException e) { System.out.println(Invalid amount, using 0.0); return 0.0; } } }健壮性设计点try-catch-finally全覆盖捕获UnknownHostExceptionDNS 解析失败、IOException连接断开、读写异常并在finally中确保socket.close()buildRequest()返回null时跳过发送避免向服务端发送垃圾数据getAmount()内部try-catch处理非数字输入防止NumberFormatException崩溃客户端每次out.writeUTF()后必flush()与服务端要求严格对应。4.2 命令行交互的“伪会话”设计为何不维持长连接状态注意此客户端每次输入命令都复用同一个 Socket 连接而非每次命令新建连接。这是为了模拟真实银行柜台——客户坐下后连续办理多笔业务而不是每笔业务都起身排队。实现上main()中socket在while(true)循环外创建整个会话期间复用in.readLine()和out.writeUTF()在循环内反复调用服务端ClientHandler.run()的while((requestin.readLine())!null)也支持多次请求。这种设计带来两个必须面对的问题客户端异常退出时服务端ClientHandler线程如何感知答in.readLine()在连接断开时返回nullClientHandler的while循环自然退出线程结束finally关闭socket服务端重启后客户端仍持有旧socket如何重连答当前版本不自动重连避免复杂化但catch(IOException)会打印错误用户需手动重启客户端。这是刻意为之的教学设计——让你直面“连接失效”这一网络编程最常见现实。4.3 验证客户端行为用 telnet 手动模拟请求当客户端行为异常时绕过 Java 代码用系统工具验证服务端是否正常工作# Linux/macOS 终端 $ telnet localhost 8080 Trying ::1... Connected to localhost. Escape character is ^]. DEP ACC001 1000.0 # 手动输入回车 OK:balance1000.0 # 服务端响应 QRY ACC001 0 OK:balance1000.0 ^] # Ctrl] 进入 telnet 命令模式 telnet quit # 退出 Connection closed.为什么这步不可少若telnet能成功通信证明BankServer正常运行、端口开放、协议解析正确若telnet连不上问题在服务端启动、防火墙或端口占用若telnet能连但无响应问题在ClientHandler的readLine()/writeUTF()逻辑大概率缺flush()。这是网络编程调试的“后悔药”——用最原始的工具排除最高层的干扰。5. 避坑指南新手在 Socket 多线程银行项目中最常翻车的 5 个现场5.1 现象客户端发送命令后服务端控制台无任何日志ClientHandler.run()从未执行原因BankServer.java中serverSocket.accept()被阻塞但主线程未进入while循环或threadPool.submit()未被调用。常见于忘记在main()中启动ServerSocket如注释掉了new ServerSocket(PORT)ExecutorService被声明为局部变量但在try块外未初始化导致submit()调用空指针ServerSocket构造时端口被占用如 IDEA 重复运行抛出IOException但未打印堆栈程序静默退出。解决在BankServer.main()开头加System.out.println(Starting server...);在serverSocket.accept()前加System.out.println(About to accept...);捕获ServerSocket构造异常并e.printStackTrace()启动前用lsof -i :8080macOS/Linux或netstat -ano | findstr :8080Windows检查端口占用。5.2 现象客户端显示Server: null或直接抛NullPointerException原因服务端ClientHandler.run()中in.readLine()返回null但客户端未发送\n或服务端已关闭连接。根本原因是DataOutputStream.writeUTF()与DataInputStream.readLine()协议不匹配。writeUTF()写入的是 UTF 编码长度前缀 字节而readLine()期望\n结尾的 ASCII 字符串正确做法是客户端用PrintWriter.println()服务端用BufferedReader.readLine()或统一用DataInputStream.readUTF()/DataOutputStream.writeUTF()。解决统一使用readLine()/println()将BankClient中out.writeUTF(request)改为new PrintWriter(out, true).println(request)true表示自动 flush将ClientHandler中in.readLine()保持不变删除所有DataInputStream/DataOutputStream改用BufferedReader/PrintWriter代码更简洁且不易错。5.3 现象两个客户端同时对同一账户存款最终余额比预期少如存两次 100结果只加了 100原因BankService.deposit()方法未加锁或锁的对象错误。典型错误对BankService实例加锁synchronized(this)导致所有账户串行化对局部变量加锁synchronized(new Object())毫无意义仅对accounts.put()加锁但get()和setBalance()未受保护出现“读-改-写”竞态。解决严格按本文 2.3 节方案对Account实例加锁synchronized(acc)在deposit()开头添加日志System.out.println(Locking account: accountNo);观察多线程是否真在争抢同一把锁用jstack pid查看线程堆栈确认线程是否阻塞在synchronized代码块。5.4 现象服务端启动后客户端连接成功但输入命令后服务端无响应客户端卡在in.readLine()原因服务端ClientHandler.run()中out.writeUTF()后未调用flush()数据滞留在DataOutputStream缓冲区客户端永远等不到\n。这是 Socket 编程最经典、最高频的坑。解决在ClientHandler.run()中out.writeUTF(response)后必须紧跟out.flush()更彻底的方案改用PrintWriter并启用自动 flushnew PrintWriter(out, true)一劳永逸在服务端响应日志中加System.out.println(Sent: response);确认发送逻辑已执行。5.5 现象运行一段时间后服务端抛java.io.IOException: Broken pipe或Connection reset by peer原因客户端异常关闭如 CtrlC但服务端ClientHandler线程仍在尝试in.readLine()或out.writeUTF()此时底层 TCP 连接已断I/O 操作触发异常。解决ClientHandler.run()的catch(IOException e)中不要忽略异常应打印e.getMessage()并确保socket.close()在finally中执行在catch块中添加System.out.println(Client force closed: e.getMessage());这是正常网络现象不是 Bug而是系统健壮性的体现——你的代码必须能优雅处理连接中断。6. 进阶验证与压力测试用 JMeter 模拟百人并发揪出隐藏的线程安全漏洞6.1 为什么单元测试不够—— 并发 Bug 的“概率性”本质BankService的deposit()方法在单线程下 100% 正确但并发时可能因 JVM 指令重排序、CPU 缓存不一致等问题在百万次操作中才出现 1 次余额错误。这种 Bug 无法靠Test复现必须靠真实并发压力。我一般会用 JMeter 做三件事验证基础功能单用户循环执行存/取/查确认协议无误施加阶梯压力10 → 50 → 100 用户观察服务端 CPU、内存、错误率校验数据一致性压力结束后遍历所有账户检查balance是否等于初始值 所有存款 - 所有取款。6.2 JMeter 脚本配置用 TCP Sampler 模拟 BankClientJMeter 默认不支持自定义 Socket 协议需用TCP Sampler并配置TCPClient类。步骤如下添加线程组Threads50, Ramp-up10秒, Loop10添加TCP SamplerServer Name or IP:localhostPort Number:8080Reuse connection:True复用连接模拟真实会话Close connection:False由服务端断开TCP client classname:org.apache.jmeter.protocol.tcp.sampler.TCPClientImpl在TCP Sampler下添加BeanShell PreProcessor生成随机请求// 生成随机账户号和金额 String[] cmds {DEP, WDR, QRY}; String cmd cmds[(int)(Math.random()*cmds.length)]; String accNo ACC String.format(%03d, (int)(Math.random()*100)); double amount Math.random() * 1000; if (QRY.equals(cmd)) amount 0.0; String request cmd accNo String.format(%.1f, amount) \n; vars.put(request, request);在TCP Sampler的Text to send中填${request}添加TCP Sampler的Response Assertion检查响应是否包含OK:或ERR:。6.3 数据一致性校验脚本用 Python 快速验证百万级操作结果压力测试后服务端内存中的BankService.accounts是唯一真相。写一个 Python 脚本通过 JMX 或简单 HTTP 接口需扩展导出账户快照或直接修改BankService添加dumpAllAccounts()方法// 在 BankService.java 中添加 public String dumpAllAccounts() { StringBuilder sb new StringBuilder(); for (Map.EntryString, Account entry : accounts.entrySet()) { sb.append(entry.getKey()).append(:).append(entry.getValue().getBalance()).append(\n); } return sb.toString(); } // 在 ClientHandler 中添加命令 DUMP返回此字符串然后用 Python 读取并校验# verify_balance.py import re with open(accounts_dump.txt) as f: lines f.readlines() total_deposit 0.0 total_withdraw 0.0 final_balances {} for line in lines: match re.match(rACC(\d):(\d\.\d), line) if match: acc_id, balance match.groups() final_balances[acc_id] float(balance) # 假设初始所有账户余额为 0所有操作记录在 ops.log 中 with open(ops.log) as f: for line in f: if DEP in line: amt float(re.search(rDEP \w (\d\.\d), line).group(1)) total_deposit amt elif WDR in line: amt float(re.search(rWDR \w (\d\.\d), line).group(1)) total_withdraw amt expected_total total_deposit - total_withdraw actual_total sum(final_balances.values()) print(fExpected total balance: {expected_total:.2f}) print(fActual total balance: {actual_total:.2f}) print(fConsistency: {PASS if abs(expected_total - actual_total) 0.01 else FAIL})6.4 我的血泪经验从那以后我每次写多线程共享资源都强制走一遍“三问检查法”问作用域这个变量/对象被多少个线程访问全局静态单例成员局部变量问操作类型是只读还是读-改-写只读用final/volatile/ConcurrentXXX读-改-写必须加锁问锁粒度锁的是整个类还是某个业务对象优先锁业务对象如Account而非BankService这个习惯救了我无数次。比如在BankService中accounts是ConcurrentHashMap所以get()安全但acc.setBalance()是对Account实例的写操作必须锁acc。如果跳过“问操作类型”就会写出synchronized(accounts)这种无效锁。希望帮到你。本文还有配套的精品资源点击获取
返回列表