
简介这是一份面向J2SE初学者的网络编程实战项目资料以银行系统为业务场景围绕Socket通信与多线程运用展开适合刚学完Java基础语法、希望把面向对象知识落到真实项目中的学习者。项目模拟总行CCH与CIBC、TD等支行及各自ATM终端的网络结构涵盖存款、取款、余额查询、跨行身份验证委托、账户变动向总行报备等流程并预留行内转账与跨行转账的扩展思路能帮助读者理解客户端与服务器报到机制、多线程并发处理及跨行交易的数据流转。资源包共50个文件以28个java源码和15个class文件为主体另含6个properties配置与1份rtf项目要求说明压缩包约58KB体量轻便便于本地运行调试。目前已有195人学习下载可作为课程设计或入门练手项目参考。1. 从一次“转账卡死”说起这个银行项目到底练什么很多新手学完 J2SE 语法写个控制台计算器就以为会了直到碰上“两个客户端同时转账服务端直接卡死”这种场面才明白差距在哪。这个网络编程银行项目就是冲着这个痛点来的用 Socket 做客户端与服务端通信用多线程处理并发请求把开户、存款、取款、转账、查询余额这些银行业务跑成一套能真实交互的 C/S 结构。它不适合想学框架的人而是给刚啃完集合、IO、异常、线程基础急需一个完整项目把知识串起来的开发者。跑通它你会真正理解一个请求从客户端发出、服务端接收、业务处理、结果回传的完整链路而不是停留在背 API 的层面。2. 架构与通信协议一个请求从发出到回传的完整链路2.1 整体分层C/S 结构怎么切这个项目是典型的客户端/服务端两层结构没有引入任何 Web 框架全部靠 JDK 自带能力撑起来。服务端负责监听端口、接收连接、分发线程、执行业务、维护数据客户端负责采集用户输入、组装请求、发送、接收响应、渲染结果。中间靠一套自定义的文本协议通信常见做法是用“命令码 参数”的格式比如LOGIN|账号|密码、TRANSFER|转出账号|转入账号|金额服务端按分隔符解析后路由到对应处理方法。分层上我一般会切成四块网络层ServerSocket、Socket 的收发封装、协议层请求的编解码、业务层账户、交易的具体逻辑、数据层内存账户表或文件持久化。这样切的好处是后面想把内存存储换成数据库只动数据层想把文本协议换成 JSON只动协议层。新手最容易犯的错是把所有逻辑塞进一个while(true)里读一行、判断、算、写回全糊在一起改一个功能要动全身。服务端启动的核心骨架大致是这样// 服务端入口监听端口每个连接交给独立线程处理 public class BankServer { private static final int PORT 8888; // 账户表用 ConcurrentHashMap避免多线程读写时出现并发问题 private static final MapString, Account ACCOUNTS new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(银行服务端已启动监听端口 PORT); // 线程池复用线程避免每个连接都 new Thread 造成资源耗尽 ExecutorService pool Executors.newFixedThreadPool(20); while (true) { Socket socket serverSocket.accept(); // 阻塞等待客户端连接 pool.execute(new ClientHandler(socket)); // 交给工作线程 } } }这段代码里几个参数值得说清楚PORT是监听端口客户端必须连同一个newFixedThreadPool(20)的 20 是并发处理上限新手项目里够用真实场景要结合压测调ConcurrentHashMap是关键普通HashMap在多线程下扩容会出问题这是血泪经验。accept()是阻塞的来一个连接才往下走所以主线程只干分发不干业务。2.2 协议设计请求和响应长什么样协议是这套项目的“黑匣子”设计得好坏直接决定后面好不好改。我一般用竖线分隔第一段是命令码后面是参数响应统一用“状态码|数据”的格式比如OK|余额:1500或ERR|余额不足。这样客户端拿到响应先看状态码再决定是展示数据还是弹错误提示逻辑清晰。// 请求解析把一行文本拆成命令和参数 public class Request { private final String command; // 命令码如 LOGIN、DEPOSIT private final String[] params; // 参数数组 public Request(String line) { // 按竖线切分limit 用 -1 保证末尾空串也保留 String[] parts line.split(\\|, -1); this.command parts[0].trim().toUpperCase(); this.params Arrays.copyOfRange(parts, 1, parts.length); } public String getCommand() { return command; } public String param(int i) { return i params.length ? params[i] : null; } }split(\\|, -1)里的-1是个容易翻车的点不加它末尾连续分隔符会被吞掉参数个数对不上解析就错位。toUpperCase()让命令码大小写不敏感客户端写小写也能认。参数越界时返回null而不是抛异常是为了让业务层统一做参数校验避免一个畸形请求把线程打挂。2.3 业务处理一个转账请求的完整流转拿转账举例这是最能体现多线程价值的功能。客户端发TRANSFER|A|B|100服务端解析后要校验两个账户是否存在、A 余额是否够、扣减 A、增加 B整个过程必须保证原子性否则并发下会出现“钱扣了没到账”或者“两边都加了”的玄学问题。// 转账业务加锁保证扣款和入账的原子性 public synchronized String transfer(String from, String to, double amount) { Account a ACCOUNTS.get(from); Account b ACCOUNTS.get(to); if (a null || b null) return ERR|账户不存在; if (amount 0) return ERR|金额必须大于0; if (a.getBalance() amount) return ERR|余额不足; a.setBalance(a.getBalance() - amount); // 先扣 b.setBalance(b.getBalance() amount); // 后加 return OK|转账成功当前余额: a.getBalance(); }这里用synchronized锁住整个方法简单粗暴但有效适合新手理解“临界区”概念。真实高并发场景会用更细粒度的锁或者数据库事务但在这个项目里方法级同步已经能挡住大部分并发问题。注意扣款和入账必须成对出现中间不能有 return 或异常跳出否则就是资金黑洞。我见过有人把校验写在扣款之后结果余额被扣成负数这种顺序错误排查起来很费劲。3. 目录结构与核心模块拆解从入口到数据层3.1 工程目录怎么摆一个能让人看懂的项目目录结构本身就是文档。这个项目我建议按职责分包而不是按文件类型一股脑堆在根目录bank-project/ ├── src/ │ ├── server/ │ │ ├── BankServer.java # 服务端入口监听与线程池 │ │ ├── ClientHandler.java # 单连接处理收请求发响应 │ │ └── service/ │ │ └── BankService.java # 业务逻辑开户、存取、转账 │ ├── client/ │ │ ├── BankClient.java # 客户端入口连接与交互 │ │ └── ui/ │ │ └── ConsoleUI.java # 控制台菜单与输入采集 │ ├── common/ │ │ ├── Request.java # 请求解析 │ │ └── Response.java # 响应封装 │ └── model/ │ └── Account.java # 账户实体 └── data/ └── accounts.dat # 可选的持久化文件common包放客户端和服务端都要用的协议类避免两边各写一份解析逻辑导致不一致model放实体service只放业务不碰 Socket。这样后面加功能比如加个“交易流水”只需要在 service 里加方法、在 model 里加实体网络层完全不用动。3.2 连接处理ClientHandler 的职责边界ClientHandler是每个连接的工作线程它的职责就三件事读一行、解析、调业务、写回。它不应该知道业务细节也不应该直接操作账户表所有业务都委托给BankService。// 单连接处理循环读取客户端请求直到连接关闭 public class ClientHandler implements Runnable { private final Socket socket; private final BankService service; public ClientHandler(Socket socket, BankService service) { this.socket socket; this.service service; } Override public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line in.readLine()) ! null) { // 读到 null 表示客户端断开 Request req new Request(line); String resp service.handle(req); // 业务分发 out.println(resp); // 自动 flush } } catch (IOException e) { System.out.println(连接异常 e.getMessage()); } finally { try { socket.close(); } catch (IOException ignored) {} } } }PrintWriter构造时第二个参数true表示自动 flush不加的话响应可能卡在缓冲区发不出去客户端一直等这是新手最常见的“发了没反应”原因。readLine()以换行符为界所以客户端发送时必须以\n结尾println正好满足。字符集统一用 UTF-8避免中文账户名乱码。finally里关 socket 是兜底防止连接泄漏。3.3 客户端交互菜单驱动怎么组织客户端是控制台菜单用户选数字、输入参数程序组装成协议串发出去。核心是把“采集输入”和“网络收发”分开UI 只管问网络层只管发。// 客户端发送请求并接收响应 public String send(String requestLine) throws IOException { out.println(requestLine); // 发送println 自带换行 return in.readLine(); // 阻塞等待服务端响应 }调用方拿到响应后按|切开第一段判断OK还是ERR再决定打印数据还是提示错误。这里要注意readLine()是阻塞的如果服务端处理慢客户端界面会“卡住”这是同步通信的正常表现不是 bug。想做成非阻塞就得引入独立接收线程那是进阶改造点后面会提。4. 跑起来与联调编译、启动、验证一条龙4.1 环境准备与编译项目只依赖 JDK建议 JDK 8 及以上不需要 Maven 也能跑。把源码按包结构放好在项目根目录执行编译# 编译全部源码到 out 目录 javac -encoding UTF-8 -d out $(find src -name *.java)-encoding UTF-8必须加否则源码里的中文注释和字符串在部分系统上会编译报错。-d out指定输出目录保持包结构。编译完out下会有server、client、common、model几个目录。4.2 启动顺序与验证先起服务端再起客户端顺序反了客户端会连接被拒。# 终端一启动服务端 java -cp out server.BankServer # 终端二启动客户端 java -cp out client.BankClient验证流程建议按这个顺序走先开户两个账户比如 A 和 B然后 A 存款 1000再 A 转账给 B 300最后分别查询 A、B 余额应该是 700 和 300。再开第三个终端起第二个客户端同时操作观察服务端是否稳定、余额是否一致。这一步就是验证多线程和同步是否生效的关键如果两个客户端同时转账出现余额对不上说明锁没加对。提示联调时把服务端日志打全一点每个请求进来打印命令码和参数响应发出打印结果出问题时能快速定位是解析错、业务错还是网络错。4.3 数据持久化怎么接纯内存的账户表一重启就清空演示够用但不完整。常见做法是在BankService里加一个save()和load()用对象序列化或简单的文本格式把账户表写到data/accounts.dat启动时读、每次变更后写。注意写文件也要考虑并发多个线程同时写会互相覆盖简单做法是给save()加同步或者用单线程的写队列。这一步不难但能显著提升项目的完整度面试时也拿得出手。5. 避坑与排查那些让新手卡半天的坑5.1 客户端发了请求服务端没反应现象是客户端readLine()一直阻塞界面像死机。原因多半是服务端PrintWriter没开自动 flush响应留在缓冲区没发出去。解决办法是构造PrintWriter时第二个参数传true或者每次println后手动flush()。另一个可能是客户端发送时没用println而是print没带换行符服务端readLine()等不到行结束。5.2 并发转账后余额对不上现象是两个客户端同时操作同一账户最终余额比预期少或多。原因是读-改-写不是原子操作两个线程同时读到旧余额各自加完再写回后写的覆盖先写的。解决办法是把整个业务方法加synchronized或者用AtomicDouble之类的原子类型配合 CAS。新手项目里方法级同步最省心别一上来就追求细粒度锁容易写出死锁。5.3 中文账户名变乱码现象是客户端输入中文服务端收到问号或乱码。原因是两端字符集不一致或者用了平台默认编码。解决办法是收发两端都显式指定StandardCharsets.UTF_8InputStreamReader和OutputStreamWriter都要带。编译时也别忘了-encoding UTF-8否则源码里的中文字符串在编译阶段就已经坏了。5.4 端口被占用启动失败现象是服务端抛BindException: Address already in use。原因是上一个服务端进程没退干净或者 8888 被别的程序占了。解决办法是先查占用进程并结束或者把PORT改成 8889 之类。开发时养成习惯服务端退出要正常关闭ServerSocket别直接关终端。5.5 客户端断开后服务端线程不释放现象是反复连断多次后服务端线程数只增不减。原因是ClientHandler的finally里没关 socket或者线程池用了无界队列。解决办法是确保finally关闭 socket线程池用固定大小并监控活跃线程数。这个坑在长时间运行时才暴露联调阶段容易被忽略。6. 二次开发改造从能跑到能拿得出手跑通只是起点真正让这个项目有价值的是改造。我一般会按“先稳后扩”的顺序动刀。第一步是把文本协议换成 JSON用轻量库序列化请求和响应好处是加字段不用改分隔逻辑参数里带竖线也不会解析错位。第二步是把内存存储换成 JDBC 接一个嵌入式数据库账户表和流水表分开转账用数据库事务保证原子性这样并发问题交给数据库代码反而更简单。第三步是给客户端加一个独立接收线程把同步阻塞改成异步界面不再卡顺便能支持服务端主动推送比如转账到账通知。改造时验证方法要跟上每改一层先跑一遍原来的手工用例开户、存取、转账、查询确认行为不变再加并发压测用多个客户端脚本同时打转账请求比对最终总余额是否守恒。总余额守恒是最硬的指标只要它不变说明锁和事务是对的。改造点改动位置验证方式协议换 JSONcommon 包参数含特殊字符不报错存储换数据库service 层重启后数据还在客户端异步接收client 网络层界面不卡、能收推送加交易流水model service每笔操作可追溯有个细节我踩过换 JSON 后忘了处理半包问题TCP 是流式的一次readLine不一定读到完整 JSON得按长度前缀或者分隔符切。文本协议用换行分隔天然规避了这个问题换格式时反而要补上。从那以后我每次改通信格式都强制先用一个“发大包、发连包”的脚本压一遍确认边界处理没问题再往下写业务。希望这个项目能帮你把 J2SE 那些零散知识点真正串成一条线跑通之后再去改它收获会比单纯看代码大得多。本文还有配套的精品资源点击获取