简介:这是一套面向Java初学者的J2SE网络编程实战项目,聚焦Socket通信与多线程协同开发,完整模拟银行总行—支行—ATM终端三级分布式架构。项目涵盖账户管理、跨行交易验证、实时资金同步等核心业务逻辑,帮助学习者深入理解客户端/服务器模型、线程安全控制及网络异常处理。资源包共50个文件,含28个Java源码(覆盖Server、Client、ThreadHandler等模块)、15个编译后class文件、6个properties配置文件(用于支行标识、端口、初始账户参数)及1份RTF项目需求文档,整体仅58KB,轻量易读,结构清晰便于逐层调试。已有194人学习下载,适合课堂实训、课程设计或自学进阶——可直接运行总行CCH服务端,再依次启动支行与ATM终端,直观观察报到注册、委托验证、余额同步等全过程,附带完整目录组织与可运行代码,是少有的兼顾原理性、工程性与教学性的新手级网络编程范例。
1. 这不是银行系统,而是一把拆开 TCP 连接黑匣子的螺丝刀:用 Socket + 多线程手搓一个可调试、可打断、可日志追踪的银行账户模拟器
你肯定见过那种“银行系统实训项目”——界面花里胡哨,数据库连着 MySQL,后台写着 Spring Boot,但一问“客户端断网时余额扣多了怎么办”,回答是“加个 try-catch”。这不是工程实践,这是 UI 演示。真正卡住新手的,从来不是 Swing 界面怎么画按钮,而是当Socket的InputStream.read()突然卡死 30 秒、当两个线程同时对同一个Account对象执行withdraw(100)、当服务端accept()后没做超时控制导致连接堆积——这些瞬间,没有日志、没有堆栈、没有可复现路径,只剩一个黑屏和一句“程序崩了”。这个 J2SE 银行项目,专治这种玄学翻车:它不封装、不抽象、不跳过底层细节,所有ServerSocket.setSoTimeout()、Thread.interrupt()、synchronized(this)都裸露在源码里;它不追求高并发吞吐,但保证每笔转账都能被System.out.println打印出完整上下文;它甚至故意留了 3 个典型阻塞点,让你亲手jstack抓线程快照、用telnet手动发指令验证状态。适合刚写完“Hello World”、正对着java.net.SocketException: Connection reset发呆的开发者,也适合想带学生看清线程竞争本质的某高校导师。
2. 从零启动:Socket 通信骨架与多线程服务模型的硬核对齐
这个项目拒绝“先搭框架再填业务”的幻觉。我们从最薄的一层开始:一个能稳定收发字符串的 TCP 通道,再一层层叠上银行语义。关键不是功能多全,而是每个bind()、connect()、readLine()调用背后,你都清楚它在操作系统哪一层触发了什么动作。
2.1 基础通信协议设计:为什么只用\n分隔,而不用 JSON 或自定义二进制头?
很多新手一上来就想搞“专业协议”,结果卡在字节序、粘包、解包上。本项目采用极简文本协议:客户端发送纯文本命令(如DEPOSIT:1001:500.0\n),服务端响应OK:12345\n或ERROR:Insufficient balance\n。换行符\n是唯一分隔符,原因有三:
第一,BufferedReader.readLine()内置缓冲与换行检测,省去手动处理粘包的复杂逻辑;
第二,所有命令可直接用telnet 127.0.0.1 8080手动测试,无需额外客户端;
第三,避免 JSON 解析引入org.json依赖,保持 J2SE 纯净性。
提示:
readLine()会自动丢弃\n,所以服务端收到的字符串是DEPOSIT:1001:500.0,不是DEPOSIT:1001:500.0\n。这点必须写死在注释里,否则后续调试时你会怀疑split(":")为什么长度是 1。
2.2 ServerSocket 初始化:超时、重用、队列长度的三个生死参数
服务端不是new ServerSocket(8080)就完事。以下初始化代码是血泪经验凝结:
ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress("127.0.0.1", 8080), 50); // backlog=50 serverSocket.setSoTimeout(30000); // accept() 最长阻塞30秒 serverSocket.setReuseAddress(true); // 允许TIME_WAIT状态端口立即重用backlog=50:指定内核连接等待队列长度。若设为 0 或默认值(通常 50),高并发压测时新连接可能被内核直接拒绝(Connection refused),而非排队等待。本项目设为 50 是平衡内存占用与抗突发能力;setSoTimeout(30000):这是防“假死”的后悔药。accept()默认无限阻塞,一旦网络异常或管理线程卡住,整个服务就僵住。设 30 秒超时后,主线程可捕获SocketTimeoutException,执行健康检查(如打印当前活跃连接数)再继续循环;setReuseAddress(true):解决“Address already in use”问题。服务异常退出后,端口常处于TIME_WAIT状态(Linux 默认 60 秒),不设此参数则重启必报错。开发阶段高频启停,此参数是刚需。
2.3 客户端连接管理:为什么每个请求都新建 Socket,而不是长连接池?
本项目客户端(BankClient.java)每次操作都执行:
Socket socket = new Socket("127.0.0.1", 8080); // 发送命令 // 接收响应 socket.close(); // 关键!必须显式关闭理由很现实:
- 新手最容易犯的错误是忘记
close(),导致文件描述符耗尽(Linux 默认 1024)。每次新建+关闭,让错误暴露得更快、更明确; - 长连接需维护心跳、重连、连接有效性检测,对 J2SE 新手属于超纲内容;
- 银行业务天然具备事务边界(一笔转账就是一个完整请求),无状态短连接反而更贴合语义。
注意:
socket.close()会同时关闭InputStream和OutputStream。若提前调用out.close(),再调socket.close()会抛IOException。本项目所有close()都放在finally块中,且只调一次socket.close()。
2.4 多线程服务模型:ExecutorServicevsnew Thread().start()的取舍
服务端接收连接后,必须为每个客户端分配独立线程处理,否则串行处理会变成单用户玩具。本项目采用ThreadPoolExecutor而非裸new Thread():
ExecutorService threadPool = new ThreadPoolExecutor( 4, // corePoolSize:保底4个线程常驻 10, // maxPoolSize:最多10个线程并发 60L, // keepAliveTime:空闲线程存活60秒 TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), // 阻塞队列容量100 new BankThreadFactory() // 自定义线程工厂,命名便于jstack识别 );corePoolSize=4:预热常用线程,避免频繁创建销毁开销;maxPoolSize=10:硬性限制并发上限,防止突发大量连接打垮 JVM;LinkedBlockingQueue(100):当 10 个线程全忙时,新任务排队,满 100 则触发拒绝策略(本项目设为AbortPolicy,直接抛RejectedExecutionException,强制暴露负载瓶颈);BankThreadFactory:重写newThread()方法,将线程名设为"Bank-Worker-" + count.getAndIncrement(),jstack时一眼识别业务线程。
3. 账户核心:多线程安全的 Account 类与事务边界的显式声明
银行系统的核心不是界面,而是账户余额变更的原子性。本项目把Account类做成最小可验证单元,所有同步逻辑裸露,不依赖任何框架事务。
3.1 Account 类结构:字段、构造、基础方法的极简主义
public class Account { private final String accountId; // 不可变ID,构造时赋值 private volatile double balance; // volatile 仅保证可见性,不保证原子性! private final Object lock = new Object(); // 显式锁对象,避免this锁引发意外同步 public Account(String accountId, double initialBalance) { this.accountId = accountId; this.balance = initialBalance; } public String getAccountId() { return accountId; } public double getBalance() { return balance; } // volatile 读,安全 // 关键:所有变更方法必须加锁 public boolean withdraw(double amount) { synchronized (lock) { if (balance >= amount) { balance -= amount; return true; } return false; } } public void deposit(double amount) { synchronized (lock) { balance += amount; } } }volatile double balance:仅用于getBalance()读取,确保其他线程能看到最新值。但它不能保证balance -= amount的原子性(该操作包含读-改-写三步),所以withdraw()必须加synchronized;private final Object lock:比synchronized(this)更安全。若外部类误用synchronized(accountInstance),会导致锁竞争范围失控。显式私有锁对象,锁粒度完全可控;deposit()不返回布尔值:因存款无失败条件,简化接口。而withdraw()返回boolean,强制调用方处理“余额不足”分支。
3.2 转账事务:跨账户操作的两阶段锁与死锁预防
转账transfer(fromId, toId, amount)是经典多线程陷阱。本项目实现严格按账户 ID 字典序加锁,彻底规避死锁:
public boolean transfer(String fromId, String toId, double amount) { Account from = accounts.get(fromId); Account to = accounts.get(toId); if (from == null || to == null) return false; // 死锁预防:始终按 accountId 字典序加锁 Account firstLock = fromId.compareTo(toId) < 0 ? from : to; Account secondLock = fromId.compareTo(toId) < 0 ? to : from; synchronized (firstLock.getLock()) { // 先锁字典序小的 synchronized (secondLock.getLock()) { // 再锁字典序大的 if (from.withdraw(amount)) { to.deposit(amount); return true; } return false; } } }fromId.compareTo(toId) < 0:字符串字典序比较,确保所有线程对同一对账户加锁顺序绝对一致;- 两层
synchronized嵌套:外层锁释放前,内层锁已获取,形成原子事务边界; withdraw()成功才执行deposit():若withdraw()失败,deposit()不会被调用,避免“只扣不存”的脏数据。
3.3 账户管理器:HashMap 的线程安全包装与初始化陷阱
AccountManager管理所有账户,其accounts字段声明为:
private final Map<String, Account> accounts = new ConcurrentHashMap<>();- 为何不用
Collections.synchronizedMap()?因为ConcurrentHashMap支持高并发get/put,且computeIfAbsent()等原子操作更安全; - 但
ConcurrentHashMap不保证复合操作原子性。例如if (!accounts.containsKey(id)) accounts.put(id, new Account(id, 0));仍是竞态。本项目所有账户创建均通过initAccounts()方法一次性加载,运行时只读不增删; - 初始化陷阱:
initAccounts()中若用accounts.put("1001", new Account("1001", 1000.0)),必须确保Account构造函数无副作用(如发起网络请求),否则并发初始化可能出错。本项目Account构造函数纯内存操作,安全。
3.4 日志与审计:每笔操作的完整上下文记录
真正的银行系统,每一行日志都是法律证据。本项目BankService中所有业务方法均记录结构化日志:
private static final Logger logger = LoggerFactory.getLogger(BankService.class); public String handleCommand(String command) { long startTime = System.currentTimeMillis(); String[] parts = command.split(":", -1); // -1 保留空字符串,防"TRANSFER::1001:1002:200.0"解析失败 String result; try { result = processCommand(parts); } catch (Exception e) { logger.error("Command failed: {}, error: {}", command, e.getMessage(), e); result = "ERROR:System exception"; } long duration = System.currentTimeMillis() - startTime; logger.info("Command: {}, Result: {}, Duration: {}ms", command, result, duration); return result; }split(":", -1):-1参数确保分割结果包含空字符串,避免TRANSFER::1001解析成长度为 2 的数组(实际应为["TRANSFER", "", "1001"]);logger.info()记录耗时:Duration: 12ms直观反映性能瓶颈;logger.error()带完整异常堆栈:e作为第三个参数传入,SLF4J 会自动打印堆栈,而非只打e.getMessage()。
4. 避坑指南:新手必踩的 5 个 Socket 与多线程组合陷阱
这些坑,我都在某跨平台系统调试中亲手踩过,每一条都对应一个真实jstack快照和tcpdump抓包分析。别等线上事故,现在就记牢。
4.1 现象:客户端socket.getOutputStream().write()后服务端readLine()一直阻塞,CPU 占用 0%
原因:客户端未发送换行符\n,BufferedReader.readLine()会一直等待行结束符。常见于手动telnet测试时敲完命令直接回车,但忘了命令末尾加\n(telnet回车发送的是\r\n,而readLine()只认\n或\r\n,但某些 JDK 版本对\r\n处理不稳定)。
解决:客户端PrintWriter初始化时强制启用自动刷新,并用println()替代print():
PrintWriter out = new PrintWriter(socket.getOutputStream(), true); // true=autoFlush out.println("DEPOSIT:1001:500.0"); // println() 自动加 \n4.2 现象:jstack显示大量Bank-Worker-*线程处于WAITING状态,top看 CPU 很低但连接数飙升
原因:服务端BufferedReader.readLine()在客户端异常断开(如kill -9 java)后,不会立即抛异常,而是进入WAITING等待数据,导致线程无法释放。readLine()的阻塞不响应Thread.interrupt()。
解决:为Socket设置SO_TIMEOUT,并在读取前检查连接状态:
socket.setSoTimeout(10000); // 读取超时10秒 try { String line = reader.readLine(); if (line == null) { // 客户端正常关闭,readLine() 返回 null logger.info("Client closed connection"); break; } } catch (SocketTimeoutException e) { logger.warn("Read timeout, closing client socket"); break; // 主动关闭此连接 }4.3 现象:并发转账时,getBalance()返回值偶尔“跳变”,如从 1000 突然变 800 再变 900
原因:balance字段虽为volatile,但getBalance()读取的是瞬时值。若线程 A 读到 1000,线程 B 执行withdraw(200)后变为 800,线程 C 执行deposit(100)后变为 900,线程 A 再次读取就是 900。这不是 bug,而是最终一致性体现。但新手误以为“余额丢失”。
解决:向开发者明确文档:“getBalance()返回当前快照值,非事务内一致值。如需强一致性,请在同步块内调用”。本项目BankService的handleCommand方法内部已包裹同步逻辑,对外提供的是事务性视图。
4.4 现象:ThreadPoolExecutor队列满后,新连接被拒绝,但telnet显示“Connected”,然后卡住
原因:telnet连接成功只表示 TCP 握手完成,ServerSocket.accept()返回Socket对象。若此时线程池已满,execute()抛RejectedExecutionException,但Socket已建立,服务端未close(),客户端仍在等待响应。
解决:在execute()外层catch拒绝异常,并主动关闭Socket:
try { threadPool.execute(new ClientHandler(socket)); } catch (RejectedExecutionException e) { logger.warn("Thread pool rejected, closing client socket"); try { socket.close(); } catch (IOException ignored) {} }4.5 现象:AccountManager初始化后,部分账户getBalance()返回 0.0,而非初始化值
原因:ConcurrentHashMap的put()操作本身线程安全,但若Account构造函数中调用了非线程安全的静态方法(如某个全局计数器),仍可能出错。本项目虽无此设计,但曾见某模拟项目在Account构造时调用LoggerFactory.getLogger(),而 SLF4J 绑定过程在首次调用时存在竞态。
解决:账户初始化必须在单线程中完成,且Account构造函数严禁任何外部依赖。本项目initAccounts()方法由main()线程调用,且Account构造函数仅赋值,100% 安全。
5. 实战验证:用 telnet + jstack + tcpdump 三件套定位真实问题
工具链的价值不在炫技,而在把“程序好像不对”变成“第 3 行synchronized未生效”。下面是一个完整排障案例:某次压测发现转账成功率骤降至 80%,我们用三步锁定根因。
5.1 第一步:用 telnet 模拟最小复现场景
不启动任何 GUI 客户端,纯命令行复现:
# 开启两个终端 # 终端1:持续转账 for i in {1..100}; do echo "TRANSFER:1001:1002:10.0" | telnet 127.0.0.1 8080 2>/dev/null | grep "OK" sleep 0.1 done # 终端2:监控响应 for i in {1..100}; do echo "BALANCE:1001" | telnet 127.0.0.1 8080 2>/dev/null | grep "BALANCE" sleep 0.1 done2>/dev/null屏蔽telnet连接提示,只留响应;grep "OK"和grep "BALANCE"过滤有效输出,快速统计成功率;sleep 0.1控制节奏,避免压垮服务端。
5.2 第二步:jstack 抓取线程快照,定位阻塞点
当发现响应延迟 > 5 秒时,立即执行:
# 查找Java进程PID jps -l | grep BankServer # 抓取线程快照(替换为实际PID) jstack 12345 > jstack_$(date +%s).txt # 关键搜索:看哪些线程在 WAITING 或 BLOCKED grep -A 5 -B 5 "java.lang.Thread.State: WAITING\|BLOCKED" jstack_*.txt典型输出:
"Bank-Worker-7" #12 prio=5 os_prio=0 tid=0x00007f8b4c0a2000 nid=0x2f1a in Object.wait() ... at java.lang.Object.wait(Native Method) at java.io.BufferedInputStream.read(BufferedInputStream.java:344) - locked <0x000000071a2a1230> (a java.io.BufferedInputStream)这说明Bank-Worker-7卡在BufferedInputStream.read(),即未设置SO_TIMEOUT的原始阻塞。立刻检查ClientHandler.run()中socket.setSoTimeout()是否被注释。
5.3 第三步:tcpdump 抓包验证网络层行为
当telnet显示 “Connected” 但无响应,怀疑网络层问题:
# 抓取本地回环口 8080 端口流量 sudo tcpdump -i lo port 8080 -w bank.pcap # 用 Wireshark 打开 bank.pcap,过滤: # tcp.stream eq 0 # 查看第一个TCP流 # 查看客户端是否发送了 `\n`,服务端是否返回了 `OK` 或 `ERROR`- 若抓包显示客户端发送
DEPOSIT:1001:500.0\r\n(CRLF),而服务端无响应,则问题在应用层(如readLine()未处理\r\n); - 若抓包显示服务端返回
OK:12345\n,但telnet未显示,问题在客户端解析(如telnet缓冲区未刷新)。
5.4 参数调优表格:根据压测结果调整的关键阈值
| 参数 | 当前值 | 压测现象 | 调优建议 | 依据 |
|---|---|---|---|---|
ServerSocket.backlog | 50 | 100 并发时 20% 连接Connection refused | 提至 200 | Linuxnet.core.somaxconn默认 128,需匹配 |
Socket.SO_TIMEOUT | 10000ms | jstack显示大量WAITING线程 | 降至 5000ms | 缩短故障线程滞留时间,提升资源周转率 |
ThreadPoolExecutor.maxPoolSize | 10 | CPU 使用率 30%,但吞吐量已达瓶颈 | 提至 16 | 本机 CPU 核心数为 8,线程数略高于核心数可掩盖 I/O 等待 |
ConcurrentHashMap初始容量 | 16 | initAccounts()加载 100 账户后,put()触发多次扩容 | 设为 128 | 避免运行时 rehash,new ConcurrentHashMap<>(128) |
提示:所有调优必须基于实测数据。不要盲目调大
backlog,若net.core.somaxconn未同步调大,超出部分无效。查看当前值:sysctl net.core.somaxconn。
6. 生产就绪技巧:如何让这个 J2SE 项目扛住真实压力并留下可追溯证据
项目交付不是java -jar bank-server.jar就结束。真正的生产就绪,是当凌晨 3 点告警响起时,你能 5 分钟内定位到是Account 1001的withdraw()被并发击穿,还是ClientHandler的SO_TIMEOUT设置过短。以下是我从某图像处理 Demo 迁移过来的硬核习惯。
6.1 启动脚本增强:JVM 参数与环境隔离
start-server.sh不只是java -cp ...,它必须固化关键参数:
#!/bin/bash # JVM 参数:开启详细GC日志,设置堆内存,启用JFR JAVA_OPTS="-Xms512m -Xmx512m \ -XX:+UseG1GC \ -XX:+PrintGCDetails -XX:+PrintGCDateStamps \ -Xloggc:logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M \ -XX:+UnlockCommercialFeatures -XX:+FlightRecorder \ -XX:StartFlightRecording=duration=60s,filename=logs/recording.jfr" # 环境变量隔离:不同环境用不同配置文件 CONFIG_FILE="config/${ENV:-dev}.properties" java $JAVA_OPTS -Dconfig.file=$CONFIG_FILE -jar bank-server.jar-Xms512m -Xmx512m:堆内存固定大小,避免 GC 时动态扩容抖动;-XX:+FlightRecorder:JDK 自带性能分析器,jfr文件可导入 JDK Mission Control,查看线程状态、锁竞争、I/O 等;logs/目录预先创建:脚本开头加mkdir -p logs,避免因目录不存在导致日志丢失。
6.2 连接泄漏自检:在ClientHandler中埋点统计
ClientHandler构造时记录时间,run()结束时计算耗时并上报:
public class ClientHandler implements Runnable { private final Socket socket; private final long createTime = System.currentTimeMillis(); public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { long startTime = System.currentTimeMillis(); try { // 处理逻辑... } finally { long duration = System.currentTimeMillis() - startTime; long age = System.currentTimeMillis() - createTime; // 上报指标:连接存活时长、处理耗时、是否异常关闭 Metrics.reportConnectionMetrics(age, duration, socket.isClosed()); try { socket.close(); } catch (IOException e) { logger.warn("Failed to close socket", e); } } } }Metrics类用ConcurrentHashMap统计:
activeConnections:当前活跃连接数(socket.isClosed()==false);avgProcessTime:滑动窗口平均处理耗时;abnormalCloseCount:异常关闭次数(socket.isClosed()==true但未走正常流程)。
从那以后我每次上线新版本,都强制走一遍
curl http://localhost:8080/metrics(本项目预留/metrics端点,返回 JSON 格式统计),确认activeConnections不持续增长,abnormalCloseCount为 0。这成了我的上线 checklist 第一条。希望帮到你。
本文还有配套的精品资源,点击获取