
简介面向Java初学者的网络编程实战项目通过模拟银行分布式业务系统讲解套接字通信与多线程的综合运用。项目以中央总行对接多家支行支行再连接各自自助终端设备客户可进行本行或跨行的存款、取款、余额查询等操作当账户资金变动时支行会实时向总行汇总保证整体现金平衡。此外还预留了行内、跨行转账的扩展设计转账信息可暂存于总行待支行上线后自动转发适合理解身份认证、请求转发及数据一致性等分布式关键问题。压缩包共五十个文件主体为二十八份Java源码另包含十五份字节码文件、六份属性配置文件和一份需求说明文档整包仅五十八千字节结构精简、便于逐类阅读。已有一百九十三人学习浏览适合作为网络编程课程设计或毕业设计的参考样例。1. 为什么新手J2SE实战选银行项目而不是聊天室Socket网络编程入门最常见的练手是聊天室或文件传输。但如果你和我一样目标是“写完能放简历、能讲清楚线程和协议”的项目聊天室有点太松了——消息格式随意断线重连不用管并发只体现在多人同时说话根本碰不到事务边界。反而是网络编程银行项目表面上是个“J2SE Socket”的老套组合实际上把网络协议、多线程共享、数据一致性全压在一个够小的范围里特别适合新手在几千行代码内建立完整认知。这个项目解决的核心问题是用纯 Java SE不碰框架实现一个C/S架构的银行存取款系统客户端发指令服务端解析、校验余额、更新账户再把结果回给客户端。难点不在Socket API本身而在“多个客户端同时操作同一个账户”时你写的代码会不会出现余额负数、重复扣款这类事故。它适合已经掌握Java基础语法和集合框架、想往网络编程和多线程再走一步的人。本文从项目拆解说起一直写到通信协议、并发控制、持久化最后给你一份可以直接跑的代码骨架和避坑清单。2. 把银行项目拆成网络编程教学骨架三个模块与启动顺序2.1 项目边界学的是Socket不是Spring做这个项目之前你必须先画一条线这是J2SE实战项目重点在java.net和java.io/java.nio不在数据库和框架。 我见过太多新手一上来就接MySQL、上Spring Boot最后项目变成“用框架写CRUD”Socket编程反而没练到。正确做法是数据层用内存集合模拟比如ConcurrentHashMap来存账户把精力全压在网络交互和并发上。项目文件结构我建议按模块分好bank-server/ src/com/bank/server/ BankServer.java // 启动入口监听端口 ClientHandler.java // 每个连接一个线程处理指令循环 AccountService.java // 账户存取款业务逻辑加锁 src/com/bank/common/ Protocol.java // 协议常量、报文解析 Message.java // 指令和响应的封装类 bank-client/ src/com/bank/client/ BankClient.java // 连接服务端发指令这个结构最大好处是把“网络”和“业务”分开。ClientHandler负责读socket、解析指令AccountService负责改余额两者通过Message传递数据。新手最容易犯的错误是把业务逻辑写在Handler里最后加锁、校验、回滚全混在一起排查问题时根本无从下手。2.2 启动顺序与服务端生命周期服务端的启动顺序也有讲究常见做法是先初始化账户数据再绑定端口最后进入accept循环。反过来容易出问题端口已经监听但账户数据还没准备好客户端连上来查询就会扑空。public class BankServer { private static final int PORT 8088; private AccountService accountService; private ServerSocket serverSocket; public void start() throws IOException { // 实际数据可以从文件反序列化加载这里先放三条测试账户 accountService new AccountService(); accountService.deposit(10001, 10000.00); accountService.deposit(10002, 20000.00); // 绑定端口第二参数是backlog表示等待队列长度 serverSocket new ServerSocket(PORT, 50); System.out.println(Bank server listening on port PORT); while (true) { Socket socket serverSocket.accept(); // 每个客户端连接交给一个独立线程处理 new Thread(new ClientHandler(socket, accountService)).start(); } } public static void main(String[] args) throws IOException { new BankServer().start(); } }这段代码里有三个参数值得新手停下来理解端口号、backlog、accept。端口选8000以上的测试端口能避开常见服务backlog表示OS内核里等待accept的队列最大长度设50是给高并发留余量但如果同时有100个客户端连进来多余连接会被内核拒绝。accept是阻塞方法没有连接时线程挂起所以服务端主线程只干这一件事业务全交给子线程这是Socket服务端最朴素但最正确的骨架。2.3 客户端怎么配合短指令循环而不是GUI客户端我建议做成命令行的短指令循环不要一上来写Swing界面。原因有二一是GUI会分散你对网络编程的注意力二是自动化测试时命令行客户端可以直接被脚本驱动。下面是最小可用的客户端交互核心public class BankClient { public static void main(String[] args) throws IOException { try (Socket socket new Socket(127.0.0.1, 8088); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); BufferedReader console new BufferedReader(new InputStreamReader(System.in))) { System.out.println(Connected. 指令格式: DEPOSIT|WITHDRAW|BALANCE 账号 金额); String line; while ((line console.readLine()) ! null) { out.println(line); String response in.readLine(); System.out.println(server response); } } } }PrintWriter的autoFlush参数必须设true否则println后数据积压在缓冲区服务端半天收不到指令。这个细节我第一次写时忽略了表现是客户端输入命令毫无反应实际上是数据没发出去。同理读响应用readLine的前提是服务端每条响应都以换行结尾这就是协议要约定的第一个基本规则。到这里你已经能跑通“连接-发字符串-收字符串”但要真正做成银行系统下一步必须自定义一套可解析的通信协议。3. 自定义银行通信协议为什么不能用对象流以及报文的三个要素3.1 对象流是黑匣子文本协议才是教学正道J2SE里最常见的传输做法是用ObjectOutputStream直接传Java对象我第一版也确实这么干了代码确实少——一个writeObject发过去另一头readObject收回来。但上线联调时会遇到两个扎心问题第一Java的序列化格式绑死了语言以后客户端如果换成Python或前端就废了第二readObject会阻塞你不知道对方到底发完了没有报文边界怎么判断对象流把这问题藏起来了一旦出问题就是黑匣子。所以在这个项目里不要用对象流走文本协议。 文本协议的眼可见、可断点调试、可telnet手工模拟——你甚至能用系统自带的telnet连上服务端手工发指令这在联调时是巨大的效率优势。真正的生产系统也大多是文本协议比如HTTP、Redis的RESP学这个方向不亏。协议设计很简单三个要素指令类型、账号、金额。格式如下DEPOSIT 10001 500.00 WITHDRAW 10001 200.00 BALANCE 10001每行是一条完整指令字段间用空格分隔行尾用换行符。服务端readLine读到一行就拆出一个完整请求天然解决了报文边界问题。3.2 协议解析类新手唯一需要的“解码器”解析逻辑我建议收敛到一个独立的Protocol类里不要散落在Handler中。代码如下public class Protocol { public static final String DEPOSIT DEPOSIT; public static final String WITHDRAW WITHDRAW; public static final String BALANCE BALANCE; public static final String SUCCESS SUCCESS; public static final String FAILURE FAILURE; // 解析客户端发来的一行指令返回Message对象 public static Message parse(String line) { if (line null || line.trim().isEmpty()) { return Message.invalid(Empty command); } String[] parts line.trim().split(\\s); // parts长度只校验最小需求BALANCE只有2个字段 if (parts.length 2) { return Message.invalid(Invalid format: line); } String type parts[0].toUpperCase(); if (type.equals(BALANCE)) { return new Message(type, parts[1], 0.0); } if (parts.length 3) { return Message.invalid(Missing amount for type); } try { double amount Double.parseDouble(parts[2]); if (amount 0) { return Message.invalid(Amount must be positive); } return new Message(type, parts[1], amount); } catch (NumberFormatException e) { return Message.invalid(Bad amount: parts[2]); } } }这个类的关键在于“尽早失败”原则格式不对、金额非法、字段缺失全部返回fail包装的Message不让脏数据流到业务层。Message类本身是个普通POJO三个字段加一个invalid静态工厂方法。注意解析时的split(\s)能容忍多个空格避免客户端多敲一个空格就解析失败。3.3 服务端处理循环读一行、解析、执行业务、回一行有了协议Handler的网络循环就非常清爽了结构永远是这四步public class ClientHandler implements Runnable { private final Socket socket; private final AccountService accountService; public ClientHandler(Socket socket, AccountService accountService) { this.socket socket; this.accountService accountService; } Override public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)) { String line; while ((line in.readLine()) ! null) { if (line.trim().equalsIgnoreCase(EXIT)) break; Message request Protocol.parse(line); Message response accountService.execute(request); out.println(response.toProtocolLine()); // 每个响应一行 } System.out.println(Client disconnected: socket.getRemoteSocketAddress()); } catch (IOException e) { System.out.println(Connection error: e.getMessage()); } } }这里的try-with-resources不是摆设客户端断线、异常抛出时socket、输入输出流全部自动关闭子线程自然结束。每个响应都println一行与客户端readLine对应这就是最简单的“请求-响应”模式。readLine返回null表示对方关闭了连接循环退出线程回收——这个退出路径必须在第一版就写对不然服务端会积累一堆死线程。到这一步你已经有了“网络编程的最小银行系统”——能存取、能查询、能并发连接。但别急着庆祝现在开始进入这个项目真正的深水区并发安全。4. 多线程并发处理账户Map的锁策略与三种错误写法4.1 需求决定了设计写锁比CAS更合适先看业务控制的核心——账户数据存储与存取款操作。我的第一版是这么写的也是最典型的反面教材public class AccountService { private MapString, Double accounts new HashMap(); // 线程不安全put和get不加锁多线程下数据错乱 public double getBalance(String account) { return accounts.get(account); } }这个写法在单线程测试时完全正常两个客户端同时操作就翻车。HashMap在多线程put时可能丢数据double读写也不是原子的。新手第一反应是把Map换成Hashtable或ConcurrentHashMap确实能解决一部分问题但光换容器还不够——“检查余额再扣款”这个复合操作依然是两步中间可能被另一个线程插进来。正确姿势是按账户粒度加锁把“读-判断-写”变成一个原子操作import java.util.concurrent.ConcurrentHashMap; public class AccountService { private final MapString, Object locks new ConcurrentHashMap(); private final MapString, Double accounts new ConcurrentHashMap(); public Message execute(Message request) { String type request.getType(); String account request.getAccount(); double amount request.getAmount(); // 不同账户操作不同锁互不阻塞同一账户串行化 synchronized (locks.computeIfAbsent(account, k - new Object())) { if (Protocol.DEPOSIT.equals(type)) { return deposit(account, amount); } else if (Protocol.WITHDRAW.equals(type)) { return withdraw(account, amount); } else if (Protocol.BALANCE.equals(type)) { return new Message(Protocol.SUCCESS, account, getBalance(account)); } return Message.invalid(Unknown type: type); } } private Message deposit(String account, double amount) { double balance accounts.getOrDefault(account, 0.0); accounts.put(account, balance amount); return new Message(Protocol.SUCCESS, account, balance amount); } private Message withdraw(String account, double amount) { Double balance accounts.get(account); if (balance null) { return Message.invalid(Account not exist: account); } if (balance - amount 0) { // 余额不足校验 return Message.invalid(Insufficient balance: current balance); } accounts.put(account, balance - amount); return new Message(Protocol.SUCCESS, account, balance - amount); } }4.2 三个参数选择说明锁粒度、ConcurrentHashMap、computeIfAbsent这段代码里有三个点值得你反复推敲。第一为什么不是lock整个service因为那样所有客户端的所有操作全部串行A账户取款会阻塞B账户查询并发意义就没了。按账户加锁让不同账户并行同一账户串行这是银行系统最基本的需求。第二为什么锁对象用ConcurrentHashMap来存而不是直接锁accounts里的value因为Double是不可变对象你put一个新值后锁对象就变了锁就错了。用独立的locks Map按账户存锁对象锁的生命周期和账户余额分离这是没做过并发的人容易踩的大坑。第三computeIfAbsent在多线程下是原子的两个线程同时给同一账户取锁只会创建一个锁对象不会重复。4.3 别忘了“锁顺序”转账功能才是死锁考场上面的存取款都是单账户操作还不涉及死锁。但银行项目迟早要加转账一旦涉及“锁A账户再锁B账户”两个线程同时做转出和转入就可能死锁。新手往往会问我没有显式用Lock怎么也会死锁synchronized嵌套两个锁对象照样死锁——线程1持锁A等锁B线程2持锁B等锁A。常见解法是“全序锁”转账前先把两个账号按Hash值排序永远先锁小后锁大。这也是为什么我认为这个项目适合新手——它让你在几百行代码里真实遇到“并发不是玄学是有确定解法”的问题。到这一步功能已经完整但服务一重启所有账户数据全丢了下一章解决持久化和重放问题。5. 账户数据持久化写文件的三处踩坑与恢复策略5.1 为什么不用数据库教学项目的持久化越简单越好J2SE实战项目加数据库最常见的结果是JDBC代码比Socket代码还多偏离了“网络编程”主题。所以持久化这层我建议用最简单的文件快照方案服务端在内存里维护账户数据每次状态变更后或定期把整个Map写到本地文件启动时再加载回来。这本质上是“写时快照”不适合生产但对500行代码的教学项目完全够用。文件格式要求人类可读首选Properties或CSV不要用ObjectOutputStream序列化Map理由跟协议那节一样——序列化格式绑定Java版本JDK版本一升级可能读不回来。用CSV是更稳妥的做法账户,余额 10001,10500.00 10002,19800.005.2 加载与保存的完整实现public class AccountStore { private static final String FILE accounts.csv; // 启动时加载文件不存在就返回空Map public static MapString, Double load() { MapString, Double result new ConcurrentHashMap(); Path path Paths.get(FILE); if (!Files.exists(path)) { return result; } try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { reader.readLine(); // 跳过表头 String line; while ((line reader.readLine()) ! null) { String[] parts line.split(,); result.put(parts[0].trim(), Double.parseDouble(parts[1].trim())); } } catch (IOException e) { System.out.println(Load accounts error, start with empty: e.getMessage()); } return result; } // 保存时写临时文件再rename避免写一半进程崩溃留下半截文件 public static void save(MapString, Double accounts) { Path temp Paths.get(FILE .tmp); Path target Paths.get(FILE); try (BufferedWriter writer Files.newBufferedWriter(temp, StandardCharsets.UTF_8)) { writer.write(account,balance); writer.newLine(); for (Map.EntryString, Double e : accounts.entrySet()) { // 金额保留2位小数 writer.write(e.getKey() , String.format(%.2f, e.getValue())); writer.newLine(); } } catch (IOException e) { System.out.println(Save accounts error: e.getMessage()); return; } try { Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { System.out.println(Replace accounts file error: e.getMessage()); } } }5.3 三个最容易翻车的细节第一加载时不能用HashMap必须用ConcurrentHashMap——加载后服务端立刻对外服务如果加载过程还没结束就有线程进来HashMap又会被并发写坏。第二保存不是随时调而是在AccountService每次业务操作完成后调用一次这个方案简单但写盘频繁、性能差而且save在锁内执行会阻塞其他账户操作我一般建议让save跑在独立线程里用更新标志位控制“是否需要保存”每隔5秒落一次盘真正要退出时再强制保存一次。第三write和rename之间不是原子操作rename在同一文件系统内基本原子但你得保证临时文件在同一个目录下跨目录rename在某些系统上会失败。持久化这件事新手最容易犯的错是追求“无懈可击”最后把简单项目搞复杂。教学项目的标准是崩溃时不丢全部数据就行丢最后几笔完全可以接受因为你还没学到WAL和刷盘策略那层。做成一版再说别贪。6. 避坑手册网络编程银行项目四个高频事故的血泪经验6.1 端口被占用Address already in use现象服务端重启时报BindException端口起不来。 原因上次程序没正常退出CtrlZ或者IDE强制终止操作系统的socket还处于TIME_WAIT状态。多数人第一反应是改端口测试治标不治本。解决启动前先查端口占用。# Linux / macOS lsof -i :8088 # Windows netstat -ano | findstr 8088找到PID后kill掉或者等几十秒TIME_WAIT自然消失。更省事的办法是开发时用ServerSocket构造参数setReuseAddress(true)但这只能缓解不能根治——习惯是启动前查端口当成启动流程第一步。6.2 readLine永远读不到东西编码与行尾的坑现象客户端println了服务端readLine返回null或一直阻塞。 原因多半是编码不一致客户端用GBK服务端用UTF-8中文乱码后可能吞掉换行符或客户端用了print而不是println没有行尾符readLine永远等不到完整一行。解决两端统一用UTF-8代码里显式指定Charset不要依赖平台默认编码。另一个隐蔽点是PrintWriter的autoFlush——设false时println后数据还在缓冲区网络对端感知不到。排查这类问题先telnet 127.0.0.1 8088手工输入一行指令回车如果telnet都有响应就说明服务端正常问题在客户端。6.3 多线程下余额错误锁的粒度错了现象开10个线程同时对同一账户取款最终余额偶尔正确偶尔不对。 原因你把同步加在了方法上public synchronized void execute()锁是this所有账户共享一个锁线程全部串行性能虽然差但结果正确。或者你把同步加在accounts Map上锁的是对象引用一旦put新valueMap的引用没变锁有效但取款操作里“get余额再put余额”还是两个步骤中间线程切换就会算错。解决回到第4章按账户粒度加锁。验证方法写一个并发测试类开20个线程每个线程连续对同一账户执行100次deposit 100元最后断言余额等于20000假设起始0。跑不过这个用例锁就是错的。6.4 客户端闪退但服务端没感知线程资源泄漏现象客户端异常关闭服务端一直没输出日志连接数越积越多最终OOM。 原因BufferedReader.readLine在客户端断线时会抛IOException如果你在catch块里没做任何处理线程可能卡在某些中间状态更常见的是你没用try-with-resourcesSocket没有关闭子线程和文件描述符全泄漏。解决ClientHandler的run方法里用try-with-resources包住socket和所有流Java 7后的标准做法。这是Socket连接资源释放的唯一正确答案不要在finally里手动close代码又长又容易漏。7. 进阶与验证并发压测脚本和三个让简历加分的改造项目做到能存取款、有并发锁、有文件持久化已经完成70%。剩下的30%决定了它能不能从“作业”变成“作品”——这部分我说三件事怎么验证你写的锁是对的、怎么把项目改出区分度、以及哪些改造属于画蛇添足。先写并发验证。不依赖JUnit直接在main里开线程跑这是新手最容易接受的验证方式public class ConcurrencyTest { public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(20); // 假设服务端预置账户10001余额0 CountDownLatch latch new CountDownLatch(20); for (int i 0; i 20; i) { pool.submit(() - { try (Socket s new Socket(127.0.0.1, 8088); PrintWriter out new PrintWriter(s.getOutputStream(), true); BufferedReader in new BufferedReader(new InputStreamReader(s.getInputStream()))) { for (int j 0; j 100; j) { out.println(DEPOSIT 10001 100.00); in.readLine(); // 读响应确保上一次已完成 } } catch (IOException e) { e.printStackTrace(); } finally { latch.countDown(); } }); } latch.await(); System.out.println(All threads done. Check balance on server.); } }这个脚本的巧妙之处在于“发一条等一条”既保证了请求不积压又能从响应里判断每一次是否成功。跑完去客户端查BALANCE如果等于20000你的锁基本没问题如果小于20000说明有请求失败或锁失效。两个我给自己的改造方向第一加一个TRANSFER指令A转账给B这会把死锁问题逼出来倒逼你去实现全序锁第二服务端用线程池替代裸new Thread例如fixed pool size16并观察队列积压——这能让你真正理解“线程池不是银弹只是对资源上限的控制”。日志方面不要引入Log4j用System.out加时间戳就够了因为项目体量决定了它的可观测性需求也小。有一件事我不建议做给客户端加Swing界面。 一是界面代码最少占你30%工作量挤占网络编程本身的调试时间二是Swing程序在断点调试时线程模型复杂容易把并发问题引到UI线程上。命令行客户端没有任何毛病真实世界里Redis的redis-cli也是命令行。最后说一个我的教训第一版我急着加“转账”功能结果死锁问题调了两天最后发现2013年的旧帖子里就写了全序锁解法血亏。后来我学乖了每加一个功能前先在纸上画出它涉及的锁和操作顺序再动手。做法不花哨但确实帮我躲掉了不少麻烦话。希望帮到你。本文还有配套的精品资源点击获取