Java Socket与多线程实战:从零构建并发银行系统
简介:这是一套面向Java初学者的J2SE网络编程实战项目,聚焦Socket通信与多线程协同开发,完整模拟银行总行—支行—ATM终端三级分布式架构。项目涵盖账户管理、跨行交易验证、实时资金同步等核心业务逻辑,帮助学习者深入理解客户端/服务器模型、线程安全控制及网络异常处理。资源包共50个文件,含28个Java源码(覆盖Server、Client、ThreadHandler等模块)、15个编译后class文件、6个properties配置文件(用于支行标识、端口、初始账户参数)及1份RTF项目需求文档,整体仅58KB,轻量易读,结构清晰便于逐层调试。已有194人学习下载,适合课堂实训、课程设计或自学进阶——可直接运行总行CCH服务端,再依次启动支行与ATM终端,直观观察报到注册、委托验证、余额同步等全过程,附带完整目录组织与可运行代码,是少有的兼顾原理性、工程性与教学性的新手级网络编程范例。
1. 这不是写个“Hello World”就完事的银行系统:它用最基础的 Java,把网络通信和多任务同时教给你
你有没有试过写一个 Java 程序,运行完就退出,像按一下开关灯就亮、再按一下就灭?那叫“单机小练习”。但真正的银行系统——哪怕只是模拟——从来不是一个人在电脑前敲完回车就结束的。它得让“柜台”(客户端)随时能连上“金库服务器”,多个客户同时取钱、查余额,服务器不能卡死、不能算错、不能把张三的钱转进李四的账户里……这些事,靠System.out.println是搞不定的。这个“网络编程银行项目”,就是专为零基础新手设计的一套可运行、可调试、可打断点的 J2SE 实战项目。它不碰 Spring、不搭 Tomcat、不用数据库,只用 JDK 自带的java.net.Socket和java.lang.Thread,就把「两个程序怎么隔着网线说话」「一个程序怎么同时处理十个人的请求」这两件最让人发怵的事,掰开、揉碎、装进一个能立刻跑起来的 ATM 模拟器里。适合刚学完 Java 基础语法、还不知道“端口”是门把手还是插头、分不清“线程”和“进程”谁管谁的新手——你不需要懂 TCP 三次握手,但你会亲手写出第一行new Socket("localhost", 8080);你不需要背诵线程状态图,但你会看到自己改一行代码,服务器就从“只能服务一个人”变成“五个人排队取钱都不卡”。这不是玩具,是真正踩在地上的第一步。
2. 先搞懂两个核心:Socket 是电话线,多线程是五个柜员同时上班
2.1 Socket 不是魔法,它就是 Java 给你配好的“网络电话机”
想象你要给朋友打电话:你得知道对方号码(IP 地址)、对方得开着手机(服务器监听端口)、你拨号后得等对方“喂”一声(建立连接)、然后你们才能说话(收发数据)。Socket就是 Java 把这套流程打包好的工具类。它不负责造基站、不管理信号塔,只管“你怎么拨号、怎么听声音、怎么挂断”。在这个银行项目里:
- 服务器端用
ServerSocket,就像银行总机——它守着一个固定号码(比如8080),谁打进来都接,接通后立刻生成一个专属Socket分配给这个客户; - 客户端用
Socket("localhost", 8080),就像你拿起家里的电话,拨通银行总机; - 一旦连上,双方就拿到一对“输入流”和“输出流”:
socket.getInputStream()是听筒(读服务器发来的消息),socket.getOutputStream()是话筒(往服务器发指令)。
提示:
localhost不是域名,是本机代号,相当于“打给自己家的分机”。你不用配路由器、不用改 hosts,只要项目跑起来,客户端和服务端就在同一台电脑上“隔墙对话”。
2.2 多线程不是炫技,是解决“一个柜员忙不过来”的现实问题
假设银行只雇了一个柜员(单线程服务器):张三来取钱,柜员开始查账、扣款、出凭条,全程耗时 3 秒;这时李四也来了,他得站在门口干等——直到张三办完。如果 10 个人同时到,第 10 个要等 30 秒。这显然不行。多线程就是银行立刻招了 5 个柜员(5 个线程),每人接待一个客户。项目里,服务器一接到新连接,就立刻new Thread(new ClientHandler(socket)).start()—— 不是让主线程去处理客户,而是“派个新人过去专职伺候”。这个ClientHandler就是柜员的工牌,它封装了“读指令→查余额→更新数据→回结果”的整套动作。主线程只干一件事:继续守着总机,等下一个电话。
2.3 它们合体之后,银行系统才真正“活”了
单有 Socket,系统是“串行”的:接一个客户、办完、再接下一个;单有多线程,没有 Socket,线程们根本没东西可处理。只有两者结合,才实现“并发响应”。项目结构非常干净:
BankServer.java ← 总机(ServerSocket + 循环 accept) ClientHandler.java ← 柜员(实现 Runnable,处理单个 socket 的全流程) BankClient.java ← 客户端(Socket + 控制台交互) Account.java ← 账户模型(余额、账号,纯 POJO)所有逻辑都在这 4 个文件里,没有 XML 配置、没有注解、没有 Maven 依赖——JDK 1.8+ 直接javac *.java && java BankServer就能启动。你甚至可以打开两个命令行窗口:一个运行java BankServer,另一个运行java BankClient,然后亲眼看到“客户”输入withdraw:100,服务器控制台立刻打印Processing withdraw request for account 1001...,紧接着客户端收到Success: new balance is 900。这不是 demo,是真实发生的通信。
2.4 第一个可运行例子:三步启动你的“银行总机”
我们不从最复杂的存取款开始,先跑通最底层的“连接心跳”。这是验证 Socket 和线程是否真正工作的黄金标准。
步骤 1:准备服务器端(BankServer.java)
import java.io.*; import java.net.*; public class BankServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); System.out.println("🏦 银行总机已启动,监听端口 8080..."); while (true) { Socket clientSocket = serverSocket.accept(); // 阻塞等待来电 System.out.println("📞 接入新客户:" + clientSocket.getRemoteSocketAddress()); // 每来一个客户,就派一个柜员(新线程)去服务 new Thread(new ClientHandler(clientSocket)).start(); } } }这段代码的核心就三件事:①new ServerSocket(8080)—— 占住 8080 号电话线;②serverSocket.accept()—— 坐等拨号(这行会卡住,直到有人连);③new Thread(...).start()—— 来电就派新人,主线程立刻回去继续等下一通。
步骤 2:写一个最简柜员(ClientHandler.java)
import java.io.*; import java.net.*; public class ClientHandler implements Runnable { private final Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try ( BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true) ) { // 向客户问好 out.println("👋 欢迎光临模拟银行!请输入 'quit' 退出"); String inputLine; while ((inputLine = in.readLine()) != null) { System.out.println("💬 客户说:" + inputLine); if ("quit".equalsIgnoreCase(inputLine)) { out.println("✅ 已退出,感谢使用!"); break; } // 回复客户:原样返回 + 时间戳 out.println("📨 收到:" + inputLine + " | 时间:" + System.currentTimeMillis()); } } catch (IOException e) { System.err.println("⚠️ 与客户通信异常:" + e.getMessage()); } finally { try { socket.close(); System.out.println("🚪 客户已离开"); } catch (IOException e) { System.err.println("❌ 关闭客户连接失败:" + e.getMessage()); } } } }关键点解释:
BufferedReader in是听筒:in.readLine()会一直等客户发来一行文字(阻塞);PrintWriter out是话筒:out.println(...)发消息,true参数表示自动 flush(不用手动out.flush());try-with-resources确保无论成功失败,输入/输出流和 socket 都会被关闭,避免资源泄漏;System.currentTimeMillis()是为了让你亲眼看到:每次回复时间都不同,证明线程确实在独立运行。
步骤 3:写一个极简客户(BankClient.java)
import java.io.*; import java.net.*; public class BankClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("localhost", 8080); System.out.println("✅ 已连接至银行总机"); try ( BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in)) ) { // 先读服务器欢迎语 System.out.println("🏦 服务器:" + in.readLine()); String userInput; while ((userInput = stdIn.readLine()) != null) { out.println(userInput); // 发送给服务器 String response = in.readLine(); // 等待服务器回复 System.out.println("🏦 服务器:" + response); if ("✅ 已退出,感谢使用!".equals(response)) { break; } } } finally { socket.close(); System.out.println("👋 客户端已断开"); } } }运行顺序:
- 打开终端 A,执行
javac *.java && java BankServer→ 看到🏦 银行总机已启动... - 打开终端 B,执行
java BankClient→ 看到✅ 已连接...和欢迎语 - 在终端 B 输入
hello→ 终端 A 打印💬 客户说:hello,终端 B 显示服务器回复 - 再开一个终端 C,同样
java BankClient→ 终端 A 会打印第二条📞 接入新客户,两个客户端互不影响
这就是并发的实感:两个客户同时说话,服务器两条日志交替出现,而不是一个办完才轮到下一个。你亲手写的代码,正在做操作系统层面的调度。
3. 新手必踩的 5 个坑:别让它们毁掉你刚燃起的兴趣
3.1 现象:客户端死活连不上,报Connection refused
原因:服务器根本没启动,或者端口号对不上。Connection refused是操作系统说“你打的这个号没人接”,不是网络不通。
解决:先确认BankServer是否已在运行(看终端是否有🏦 银行总机已启动);再检查客户端new Socket("localhost", 8080)的8080是否和服务端new ServerSocket(8080)一致;Windows 用户注意:某些安全软件会拦截localhost,临时关闭试试。
3.2 现象:服务器启动后,第一个客户能连,第二个就卡住不动
原因:ClientHandler.run()方法里,in.readLine()是阻塞的,如果客户端没发任何内容(比如直接关了窗口),线程就会永远卡在这里,无法退出。而ServerSocket.accept()是阻塞的,但Thread.start()后主线程继续循环,所以第二个客户其实能连上——问题出在第一个客户的线程没释放资源,导致后续操作混乱。
解决:必须用try-with-resources包裹流,并在finally块中显式socket.close()。项目里ClientHandler的finally块就是干这个的。漏掉它,线程会一直占着 socket,系统句柄耗尽后,新连接就会超时或拒绝。
3.3 现象:客户端输入后,服务器收不到,或者收到乱码(如????)
原因:字符编码不一致。InputStreamReader默认用系统编码(Windows 是 GBK,Mac/Linux 是 UTF-8),而PrintWriter发送时没指定编码,导致“说方言,听普通话”。
解决:统一强制指定 UTF-8:
// 服务器端读取 BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); // 服务器端发送 PrintWriter out = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);客户端同理。加这一行,全世界的中文都能正常显示。
3.4 现象:服务器控制台疯狂刷屏📞 接入新客户,但客户端没反应
原因:客户端socket.getOutputStream()没flush(),或者PrintWriter构造时没传true。TCP 是缓冲协议,数据攒够一包才发,不强制刷新,消息就卡在内存里。
解决:两种方式任选其一:①PrintWriter out = new PrintWriter(socket.getOutputStream(), true);(推荐,自动 flush);② 手动out.println("xxx"); out.flush();。项目里全部采用方式①,所以你看到out.println(...)后消息立刻到达。
3.5 现象:关闭一个客户端后,服务器报java.net.SocketException: Connection reset
原因:客户端进程被强制杀死(如 Ctrl+C 或关窗口),TCP 连接突然中断,服务器还在试图in.readLine(),底层 socket 就抛异常。这是正常现象,不是 bug。
解决:把catch (IOException e)里的日志级别从System.err.println改成System.out.println,并加一句说明:“客户非正常断开,已清理资源”。这样你就知道——不是你的代码崩了,是客户“挂了电话”,而你的柜员(线程)已经礼貌地收拾好桌子走人了。
4. 从“能连上”到“真银行”:三步扩展你的实战能力
4.1 第一步:把“回声测试”升级为“账户操作”(加业务逻辑)
现在你有了稳定通信骨架,下一步是注入银行血肉。Account.java是核心数据载体:
public class Account { private final String accountId; private double balance; public Account(String accountId, double initialBalance) { this.accountId = accountId; this.balance = initialBalance; } // 同步方法:确保多线程下余额计算不打架 public synchronized boolean withdraw(double amount) { if (amount <= 0 || amount > balance) { return false; } balance -= amount; return true; } public synchronized boolean deposit(double amount) { if (amount <= 0) return false; balance += amount; return true; } public synchronized double getBalance() { return balance; } public String getAccountId() { return accountId; } }关键点:
synchronized是锁:当张三线程在withdraw,李四线程调deposit,必须等张三算完再轮到李四,避免“读余额→扣款→写余额”中间被插队;Account对象不能全局 static,否则所有客户共享一个账户!正确做法是在BankServer启动时创建一个Map<String, Account>存所有账户,每个ClientHandler通过账号查对应对象。
4.2 第二步:定义清晰的通信协议(让客户端知道怎么“说话”)
目前客户端发hello,服务器回收到 hello,这叫“裸聊”。真实银行需要结构化指令。我们约定简单文本协议:
| 指令格式 | 示例 | 说明 |
|---|---|---|
login:1001:1234 | login:1001:1234 | 登录,账号:密码 |
balance | balance | 查询余额 |
withdraw:500 | withdraw:500 | 取款 500 元 |
deposit:200 | deposit:200 | 存款 200 元 |
ClientHandler.run()里加解析逻辑:
String[] parts = inputLine.split(":"); switch (parts[0]) { case "login": if (parts.length == 3 && validateLogin(parts[1], parts[2])) { currentAccount = accounts.get(parts[1]); out.println("✅ 登录成功,欢迎 " + parts[1]); } else { out.println("❌ 账号或密码错误"); } break; case "balance": if (currentAccount != null) { out.println("💰 当前余额:" + currentAccount.getBalance()); } else { out.println("❗ 请先登录"); } break; // ... 其他 case }这样,客户端输入balance,服务器就查当前登录账户余额并返回。协议越简单,越容易调试;等你熟练了,再升级成 JSON 或自定义二进制协议。
4.3 第三步:加入日志和异常反馈(让问题不再“静默消失”)
新手常犯的错:catch (Exception e) { }—— 把异常吞掉,程序看似运行,实则逻辑失效。银行系统绝不允许。改造ClientHandler的异常处理:
} catch (IOException e) { // 网络层异常:客户断开、超时等,记录但不中断 System.out.println("⚠️ 客户 " + socket.getRemoteSocketAddress() + " 通信中断:" + e.getMessage()); } catch (NumberFormatException e) { // 业务层异常:客户输错数字,明确提示 out.println("❌ 金额格式错误,请输入数字,如:withdraw:100"); } catch (Exception e) { // 兜底异常:代码逻辑错误,必须暴露! System.err.println("💥 严重错误(请联系开发者):" + e); e.printStackTrace(); // 打印完整堆栈,定位到哪一行 out.println("❌ 系统繁忙,请稍后再试"); }注意:
e.printStackTrace()必须保留,尤其在开发阶段。它会告诉你NullPointerException是在Account.withdraw()第 23 行发生的,而不是让你猜“为什么取不了钱”。
4.4 一个完整可运行的存取款流程(动手试试)
现在,把上面三步组合起来,跑一次真实操作:
- 启动
BankServer(确保accountsMap 已预加载账号1001,初始余额1000) - 启动
BankClient - 输入
login:1001:1234→ 收到✅ 登录成功 - 输入
balance→ 收到💰 当前余额:1000.0 - 输入
withdraw:300→ 收到✅ 取款成功,新余额:700.0 - 再开一个
BankClient,同样登录1001,输入balance→ 仍显示700.0(数据共享) - 第一个客户端输入
deposit:100→ 余额变800.0,第二个客户端再查,也是800.0
你刚刚完成了一次跨进程、多线程、带状态同步的银行核心操作。没有框架,没有配置,只有Socket和Thread,但逻辑完整、数据准确、并发安全。
5. 下一步学什么:别急着跳坑,先夯实这三条“地基线”
5.1 地基线一:把“控制台交互”换成“图形界面”,让银行看得见
你现在所有的输入输出都在黑框里,客户得记指令、怕输错。下一步,把BankClient升级成 Swing 界面——不是为了炫技,是为了暴露更多底层细节。当你用JTextField接收用户输入,用JTextArea显示服务器回复时,你会被迫思考:
- 如何让界面不卡死?
SwingUtilities.invokeLater()是必须的,否则 GUI 线程被in.readLine()阻塞,整个窗口就冻结了; - 如何安全更新界面?不能在
ClientHandler线程里直接textArea.append(...),必须用SwingUtilities.invokeLater()把更新操作“投递”回 GUI 线程; - 如何处理连接中状态?加一个
JLabel status = new JLabel("未连接"),连上变绿,断开变红,这逼你理解Socket.isClosed()和Socket.isConnected()的区别。
我第一次做这个升级时,在
ClientHandler里直接status.setText("已连接"),结果程序崩溃报AWT thread violation。翻了两小时文档才明白:GUI 更新必须由事件分发线程(EDT)执行。从那以后,我每次写 Swing 网络客户端,都强制在run()开头加SwingUtilities.isEventDispatchThread()断言,不是为了防错,是强迫自己记住这条铁律。
5.2 地基线二:用ExecutorService替代裸Thread,学会“管人”而不只是“招人”
你现在是new Thread(...).start(),像老板每天亲自面试、发工牌、安排工位。但银行旺季要招 100 个柜员,你不可能天天重复。ExecutorService就是 HR 部门:
// 启动时创建线程池(5 个柜员常驻) ExecutorService executor = Executors.newFixedThreadPool(5); // 有客户来,只管派活,不用管线程创建销毁 executor.submit(new ClientHandler(socket));好处立竿见影:
- 线程复用:客户走了,柜员不下班,等着下一个;
- 数量可控:
newFixedThreadPool(5)保证最多 5 个并发,避免系统被撑爆; - 统一管理:
executor.shutdown()优雅关闭所有柜员,比遍历Thread列表靠谱得多。
我第二次重构这个项目时,把
new Thread全换成executor.submit,结果发现高并发下 CPU 占用从 95% 降到 40%。因为线程创建销毁本身很重,池化后省下的开销,全转化成了处理能力。希望帮到你。
5.3 地基线三:加一层“消息队列”缓冲,理解“削峰填谷”的真实含义
现在服务器是“来一个客户,立刻派一个柜员”。但如果瞬间涌进 1000 个客户,你开 1000 个线程,内存直接 OOM。真实银行怎么办?设一个“叫号机”(消息队列):
// 服务器启动时 BlockingQueue<Socket> connectionQueue = new LinkedBlockingQueue<>(100); // accept 循环里,不直接 new Thread,而是入队 while (true) { Socket clientSocket = serverSocket.accept(); connectionQueue.put(clientSocket); // 塞进队列,不阻塞 } // 另起 5 个“取号员”线程,从队列取客户 for (int i = 0; i < 5; i++) { executor.submit(() -> { while (true) { try { Socket s = connectionQueue.take(); // 阻塞取号 new ClientHandler(s).run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); }这不再是“客户来了就服务”,而是“客户来了先拿号,柜员按号服务”。队列长度100是熔断阀值——超过 100 个号,put()就会阻塞,accept暂停接新客,保护服务器不死。这才是生产环境的思维:不追求无限扛压,而追求可控、可退、可恢复。
这三条线,每一条都直指网络编程的核心矛盾:通信可靠性 vs. 系统复杂性、并发能力 vs. 资源消耗、功能完整 vs. 容错能力。它们不教你最新框架,但教会你判断——什么时候该用线程池,什么时候该加队列,什么时候该换 UI 线程。当你能把这个银行项目从黑框做到图形界面、从裸线程做到线程池、从直连做到带队列,你就真正拿到了网络编程的入门钥匙。不是靠背概念,是靠亲手拧紧每一颗螺丝。
本文还有配套的精品资源,点击获取