☰
Java Socket 银行排号系统:BIO 多线程与 Swing 实战
2026/10/1 6:08:32 网站建设 项目流程

简介:这份资源面向计算机相关专业学生与Java初学者,提供一套基于Java Socket通信与Java GUI界面开发的银行排号系统完整实现方案,可用于课程设计、毕业设计或项目实训参考。压缩包整体约292.61MB,内含项目全套源码与配套文档,源码经测试校正后可百分百成功运行,涵盖客户端与服务端通信、界面交互、排队叫号逻辑等核心模块,文档部分则对系统设计思路与实现过程进行说明。目前已有475人浏览学习,说明该案例在同类项目中具有一定参考价值。读者可从中获取完整的项目结构、Socket网络编程与Swing界面开发的实际应用范例,以及数据库连接与业务逻辑处理的实现细节,便于快速理解银行排号系统的整体架构,并在此基础上进行二次开发或功能扩展,适合作为Java综合实践的学习素材。

1. 银行排号系统:从窗口排队到 Socket 通信的完整落地

去银行办业务最烦的是什么?不是等,是不知道要等多久。大厅里坐满了人,屏幕上跳动的号码像蜗牛爬,你手里攥着 A047 的纸条,抬头一看才叫到 A012。这套体验背后其实就是一个典型的 C/S 架构系统在支撑:柜员端点击“叫号”,客户端屏幕立刻更新,语音播报同步响起。这个标题要拆的就是这么一套东西——用 Java 的 Socket 做通信骨架,用 Java GUI(Swing)做柜员端和客户端的界面,用 Oracle 存号源、窗口、业务类型和日志,最终交付一个能跑起来的银行排号系统。

它适合谁?正在做 Java 课程设计、需要一套完整 C/S 项目练手的人;想搞明白 Socket 长连接到底怎么在真实业务里落地的人;以及被“Java 面试八股文”里 BIO/NIO 问烦了、想亲手写一遍阻塞式多线程服务器的人。整套东西不依赖 Spring 全家桶,纯 JDK + JDBC 驱动就能跑,对理解网络编程和 GUI 事件模型非常直接。下面按“先跑通通信、再搭界面、最后接数据库”的顺序,把每个环节的参数和坑都摊开讲。

2. Socket 通信骨架:BIO 多线程服务器怎么扛住 20 个柜员端

2.1 为什么选 BIO 而不是 NIO

银行排号系统的并发量其实很低。一个网点撑死 8 个窗口、2 台取号机、1 块综合屏,总连接数不会超过 20 个。这种量级下,Java 原生ServerSocket+ 每连接一线程的 BIO 模型完全够用,代码量还少。NIO 的Selector虽然能扛万级连接,但引入 ByteBuffer 的读写翻转、半包粘包处理之后,调试成本陡增,对课程设计来说属于过度设计。

我一般会这样定:连接数 < 200 且消息频率 < 100 QPS,直接上 BIO。真正要关注的是消息边界,不是并发模型。Socket 是字节流协议,read()返回的字节数跟你write()出去的不一定对齐,这就是“为什么 socket 接收到奇数字节后面会补一个随机数”这类玄学问题的根源——其实是缓冲区没读干净,跟奇偶无关。

2.2 服务器端最小可运行代码

import java.io.*; import java.net.*; import java.util.concurrent.*; public class TicketServer { // 线程池固定 30 个线程,够 20 个客户端用,留余量 private static final ExecutorService POOL = Executors.newFixedThreadPool(30); // 用 CopyOnWriteArrayList 存在线客户端,读多写少 private static final java.util.List<ClientHandler> CLIENTS = new java.util.concurrent.CopyOnWriteArrayList<>(); public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(); // SO_REUSEADDR 让重启时端口能立刻复用,避免 TIME_WAIT 卡住 server.setReuseAddress(true); server.bind(new InetSocketAddress(9527), 50); // backlog=50 System.out.println("排号服务器已启动,端口 9527"); while (true) { Socket socket = server.accept(); // 阻塞等待柜员端/取号机连接 socket.setSoTimeout(0); // 长连接不设读超时 socket.setTcpNoDelay(true); // 关 Nagle,叫号消息要即时 ClientHandler handler = new ClientHandler(socket); CLIENTS.add(handler); POOL.execute(handler); } } // 广播给所有在线客户端,比如“A013 请到 3 号窗口” static void broadcast(String msg) { for (ClientHandler c : CLIENTS) { c.send(msg); } } }

ClientHandler实现Runnable,核心是BufferedReader按行读、PrintWriter按行写。这里有个关键约定:所有消息以\n结尾,这样readLine()就能天然解决粘包问题,不需要自己算长度前缀。参数上,setTcpNoDelay(true)必须开,否则叫号消息可能被 Nagle 算法攒 40ms 再发,柜员点了叫号屏幕要愣一下。backlog=50是等待队列长度,超过会拒绝连接,20 个客户端场景给 50 足够。

2.3 消息协议设计:一行一条,字段用竖线分隔

不要用 Java 对象序列化,跨版本容易炸,而且抓包看不懂。我一般用纯文本协议:

CALL|A013|3|20250101 143022 LOGIN|T001|柜员张三 TICKET|A|普通业务

第一个字段是命令字,后面跟参数。服务器收到CALL就广播给所有客户端,收到TICKET就写库并返回新号码。解析时String.split("\\|")即可,注意|在正则里要转义。这种协议的好处是 telnet 上去就能手工测,排查问题时不用写客户端。

提示:BufferedReader.readLine()遇到客户端异常断开会抛SocketException: Connection reset,这是正常的,捕获后从CLIENTS列表移除该 handler 即可,不要让它把线程池打满。

3. Java GUI 双端界面:Swing 事件线程与 Socket 读线程怎么不打架

3.1 柜员端和客户端的功能切分

柜员端要有的控件:登录框、业务类型下拉、叫号按钮、完成按钮、当前号码显示。客户端(取号机)要有的:业务类型按钮组、出票显示、等待人数。综合屏最简单,一个大号 JLabel 显示当前叫号,加一个语音播报。

Swing 的铁律:所有 UI 更新必须在 Event Dispatch Thread(EDT)上执行。而 Socket 的readLine()跑在独立线程里,读到消息后不能直接label.setText(),必须SwingUtilities.invokeLater()包一层。这是新手最容易翻车的地方——界面偶尔更新、偶尔卡死,就是线程模型搞混了。

3.2 柜员端叫号按钮的完整事件链

callButton.addActionListener(e -> { String ticket = currentTicketField.getText(); // 如 A013 String window = windowField.getText(); // 如 3 if (ticket.isEmpty()) return; // 1. 发消息给服务器(网络 IO 不能阻塞 EDT,丢到线程池) new Thread(() -> { try { out.println("CALL|" + ticket + "|" + window + "|" + now()); out.flush(); // PrintWriter 必须 flush,否则消息卡在缓冲区 } catch (Exception ex) { SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(this, "发送失败:" + ex.getMessage())); } }).start(); // 2. 本地 UI 立即反馈,不等服务器回包 statusLabel.setText("已呼叫 " + ticket + " 到 " + window + " 号窗口"); });

这里有两个参数级细节。第一,PrintWriter构造时如果用new PrintWriter(socket.getOutputStream()),默认不自动 flush,必须手动flush()或者构造时传autoFlush=true。第二,网络发送放到新线程,但 UI 反馈可以立即做,这叫乐观更新,用户体验比等服务器 ACK 再刷新好得多。如果发送失败,再用invokeLater弹窗提示。

3.3 客户端接收线程的标准写法

// 在客户端启动时开一个守护线程专门收消息 Thread reader = new Thread(() -> { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8"))) { String line; while ((line = in.readLine()) != null) { final String msg = line; SwingUtilities.invokeLater(() -> handleMessage(msg)); } } catch (IOException ex) { SwingUtilities.invokeLater(() -> statusLabel.setText("连接已断开")); } }); reader.setDaemon(true); // 主窗口关闭时自动结束 reader.start();

handleMessage里按CALL、TICKET等命令字分支更新界面。注意InputStreamReader要显式指定UTF-8,否则中文柜员名会乱码,Windows 默认 GBK 会坑你半天。守护线程setDaemon(true)保证窗口关了 JVM 能退出,不然进程会挂在后台。

注意:Swing 的JTextField在 EDT 外读取虽然不报错,但可能读到中间状态。稳妥做法是把要发送的数据在点击时先getText()存到局部变量,再进新线程。

4. Oracle 侧落地:号源表设计、序列与 JDBC 连接池

4.1 三张核心表与序列

Oracle 建表跟 MySQL 最大的区别是没有AUTO_INCREMENT,得用序列(Sequence)+ 触发器,或者应用层先查序列再插入。我一般选后者,逻辑清晰好调试。

-- 号源表:每天每种业务类型的号码 CREATE TABLE T_TICKET ( TICKET_ID NUMBER(10) PRIMARY KEY, BIZ_TYPE VARCHAR2(20) NOT NULL, -- 普通/贵宾/对公 TICKET_NO VARCHAR2(10) NOT NULL, -- A001 STATUS NUMBER(1) DEFAULT 0, -- 0等待 1已叫 2完成 CREATE_TIME DATE DEFAULT SYSDATE, WINDOW_NO NUMBER(2) ); -- 窗口表 CREATE TABLE T_WINDOW ( WINDOW_NO NUMBER(2) PRIMARY KEY, WINDOW_NAME VARCHAR2(30), STATUS NUMBER(1) DEFAULT 1 -- 1开放 0关闭 ); -- 序列 CREATE SEQUENCE SEQ_TICKET START WITH 1 INCREMENT BY 1 NOCACHE;

NOCACHE是关键参数。默认CACHE 20会在数据库重启后跳号,排号系统跳号客户会投诉。虽然NOCACHE性能略低,但排号场景每秒最多几十次插入,完全无感。STATUS用数字而不是字符,索引更小,查询更快。

4.2 JDBC 连接与取号逻辑

public class TicketDao { private static final String URL = "jdbc:oracle:thin:@127.0.0.1:1521:ORCL"; private static final String USER = "bank"; private static final String PWD = "bank123"; public String nextTicket(String bizType) throws SQLException { String prefix = "普通".equals(bizType) ? "A" : "V"; try (Connection conn = DriverManager.getConnection(URL, USER, PWD)) { // 1. 取序列下一个值 long id; try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT SEQ_TICKET.NEXTVAL FROM DUAL")) { rs.next(); id = rs.getLong(1); } // 2. 拼号码,补零到 3 位 String no = prefix + String.format("%03d", id); // 3. 插入 try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO T_TICKET(TICKET_ID,BIZ_TYPE,TICKET_NO) VALUES(?,?,?)")) { ps.setLong(1, id); ps.setString(2, bizType); ps.setString(3, no); ps.executeUpdate(); } return no; } } }

连接串jdbc:oracle:thin:@host:port:SID是 SID 模式,如果是服务名模式要写成@//host:port/SERVICE_NAME,这是 Oracle 连接最常见的翻车点,报ORA-12505基本都是这里写错。生产环境别每次DriverManager.getConnection,要上连接池,课程设计用 Druid 或 HikariCP 都行,配initialSize=5, maxActive=20就够。

4.3 叫号时的状态更新与查询

柜员点叫号,服务器收到CALL消息后要更新T_TICKET的STATUS=1, WINDOW_NO=?,同时查下一位等待客户。这里用一条UPDATE加一条SELECT ... WHERE STATUS=0 AND BIZ_TYPE=? ORDER BY TICKET_ID FETCH FIRST 1 ROWS ONLY。Oracle 12c 以上支持FETCH FIRST,11g 得用ROWNUM嵌套,这是“oracle分页”热搜里最常问的写法差异。

提示:CREATE_TIME用DATE类型够到秒级,如果要做等待时长统计精确到毫秒,换TIMESTAMP。别用字符串存时间,排序会出错。

5. 避坑排查:从连接被拒到中文乱码的 5 个真实翻车现场

5.1 现象:客户端连不上,报 Connection refused

原因通常是服务器没启动,或者防火墙拦了 9527 端口。还有一种隐蔽情况:服务器绑定了127.0.0.1而不是0.0.0.0,导致同网段其他机器连不上。new InetSocketAddress(9527)默认绑所有网卡,但如果你写成new InetSocketAddress("127.0.0.1", 9527)就只监听本地。解决:确认服务器进程在跑,netstat -an | grep 9527看监听地址,跨机测试时绑0.0.0.0。

5.2 现象:界面卡死,点按钮没反应

原因是在 EDT 里做了阻塞 IO。比如在actionPerformed里直接socket.getInputStream().read(),EDT 被挂起,整个界面就冻住了。解决:所有网络读写丢到独立线程,UI 更新用SwingUtilities.invokeLater回 EDT。记住 EDT 只干画界面的事,别让它等网络。

5.3 现象:中文柜员名显示成问号或方块

原因:InputStreamReader没指定字符集,用了平台默认编码。Windows 中文版默认 GBK,服务器发 UTF-8,两边对不上。解决:读写两端都显式new InputStreamReader(in, StandardCharsets.UTF_8)和new OutputStreamWriter(out, StandardCharsets.UTF_8)。数据库连接串也可以加?useUnicode=true&characterEncoding=UTF-8,虽然 Oracle 的 JDBC 对这两个参数支持不如 MySQL 直接,但字符集统一到 AL32UTF8 最稳。

5.4 现象:叫号消息发出去了,客户端偶尔收不到

原因:PrintWriter没 flush,或者多条消息粘在一起被当成一条。前者加flush()或autoFlush,后者靠\n分隔 +readLine()解决。还有一种情况是客户端readLine阻塞时 socket 被对端关闭,抛SocketException,需要捕获后重连。解决:发送端每条消息末尾加\n并 flush,接收端用BufferedReader.readLine(),异常时清理资源并提示重连。

5.5 现象:Oracle 报 ORA-12505 或 ORA-28001

ORA-12505是连接描述符里的 SID 或服务名不对,检查tnsnames.ora或连接串。ORA-28001是密码过期,Oracle 11g 默认 180 天过期,课程设计环境经常放几个月后突然连不上。解决:ALTER USER bank IDENTIFIED BY bank123;重置密码,或者ALTER PROFILE DEFAULT LIMIT PASSWORD_LIFE_TIME UNLIMITED;关掉过期策略(仅限测试环境)。

6. 进阶技巧:用观察者模式解耦广播,加一个心跳保活

前面broadcast是直接遍历CLIENTS列表,柜员端和综合屏处理逻辑不同,硬编码分支会越来越乱。我一般会引入一个极简观察者:每个ClientHandler注册自己关心的命令字,服务器收到消息后按命令字分发。这样加一块“评价屏”只需要新写一个 handler,不用动服务器主逻辑。

interface MessageListener { void onMessage(String cmd, String[] args); } // ClientHandler 实现该接口,构造时注册 class ClientHandler implements Runnable, MessageListener { private final Socket socket; private PrintWriter out; // ... @Override public void onMessage(String cmd, String[] args) { if ("CALL".equals(cmd)) { send("CALL|" + args[0] + "|" + args[1]); } } }

心跳保活也值得加。银行网点网络偶尔抖动,TCP 连接可能变成半开状态,服务器以为客户端还在,实际早断了。做法很简单:客户端每 30 秒发一行PING,服务器收到回PONG,连续 3 次没收到就清理该连接。参数上,心跳间隔别小于 10 秒,太频繁浪费;也别大于 60 秒,否则断线发现太慢。socket.setKeepAlive(true)是 TCP 层保活,默认 2 小时才探测,对业务来说太慢,应用层心跳更可控。

验证方法:开两个客户端,一个柜员端一个综合屏,柜员端点叫号,综合屏应在 100ms 内更新。用telnet 127.0.0.1 9527手工发CALL|A001|1,看综合屏是否响应,能快速定位是协议问题还是界面问题。最后说个血泪经验:Socket 编程里 90% 的“玄学”问题,最后都落在“没 flush”“没按行读”“编码不一致”这三件事上,先把这三样检查一遍,能省下大量抓包时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询