ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Java Socket与多线程的安卓聊天服务端实现解析

基于Java Socket与多线程的安卓聊天服务端实现解析 简介面向刚开始接触移动开发与后端编程的读者这份基于Java的安卓简易聊天App服务端设计源码覆盖了聊天服务端的用户管理、消息转发、状态同步等核心模块可帮助学习从零搭建一个可运行的服务端项目。压缩包共38个文件以29个Java源文件为主体另含XML和properties配置、JAR包、Maven包装脚本、Git忽略文件及说明文档整包仅133KB体量小巧且目录结构清晰适合逐文件研读与二次修改。目前已有318人学习下载。通过源码分析读者能了解Java服务端的网络通信与并发请求处理方式学会如何配置数据库连接与端口等参数并借助Maven自动完成构建与部署。同时项目还附有基础说明文档与命令行脚本可帮助梳理从代码编写到服务启动的完整流程是入门安卓聊天应用服务端开发颇具性价比的参考资料。1. 简易聊天服务端为什么选 Java Socket 而不是直接上 Netty不少人一听说要写聊天 App 服务端第一反应是上 Netty、上 WebSocket再不行也要 Sping Boot WebSocket 起步。但如果你手里是一个课程设计、毕业设计或者个人练手项目需求只是“安卓端能登录、能群聊、能私聊、能看见上下线通知”那我强烈建议直接用 Java 原生 Socket 加多线程把服务端写出来。原生方案不引入额外框架调试起来每一步都看得见逻辑透明到老师问你线程模型时你能从头讲到尾而且后面要往 Netty 迁代码结构也不会浪费。这个标题里的“基于 Java 的安卓简易聊天 app 服务端”其实把选型边界划得很清楚客户端是安卓服务端是 Java难度是“简易”。它要解决的核心问题就是多个安卓客户端如何稳定地连上一个服务端互相转发聊天消息并且处理掉线、并发和消息格式这些基础问题。适合的人群是有 Java 基础、想把前后端链路完整跑通的学生以及想快速搭一个原型验证消息转发逻辑的开发者。下面我按自己做这类项目的完整思路来讲从线程模型到协议设计再到踩坑排查一步步落到能复现的代码上。2. 架构与线程模型把服务端拆成三层从 ServerSocket 到 ClientHandler2.1 先定边界这个服务端要管哪些事不管哪些事写聊天服务端第一步不是写代码是先划清边界。我做简易版时只会让服务端管四件事连接管理、登录与下线、消息转发、在线用户列表维护。不管的事也写清楚历史消息持久化先不做图片文件传输先不做断线重连的复杂状态机先不做。这不是偷懒而是把精力集中在最核心的链路上——一个客户端发消息其他客户端能收到这条链路跑通了后续往上加功能都有明确位置。边界定清楚后整个服务端的代码量可以控制在三百行以内。很多人在课程设计里翻车不是因为功能不够而是因为一开始就想做群文件发送、语音消息、离线消息补发代码写到一半自己都理不清了。我给你的建议是先做一个纯文本的在线聊天跑通后再按优先级往上加东西。代码量可控意味着你能在答辩前把每一行都读明白这比堆功能重要得多。2.2 线程模型BIO 线程池别自己造 NIO 轮子聊天的核心场景是多个客户端同时在线收发消息服务端要有能力为每个连接提供一条独立的处理通道。Java 里常见的有三种模型BIO阻塞 IO、NIO非阻塞 IO、AIO异步 IO。很多人一查资料发现 Netty 底层是 NIO就觉得 BIO 很落后想直接学 NIO 自己写一套。作为已经写过多次这种项目的人我很直白地告诉你简易聊天室用 BIO 线程池够了。BIO 的模型非常简单每个客户端连接上来服务端 accept 到这个 Socket就把这个 Socket 交给一个线程去处理这个线程里做阻塞式读写。它的瓶颈在“一个连接一个线程”上如果同时有一万个连接就要有一万个线程JVM 直接扛不住。但课程设计项目的并发量能有多少几十个并发连接已经算多就算真开了几十个线程现代处理器也毫无压力。NIO 的优势在于用少量线程管理大量连接代价是编程模型复杂你要处理 Selector 的注册、事件轮询、缓冲区管理调试起来能看到的数据流是断的。就“简易聊天”这个需求复杂度不应该花在这里。线程池参数我会用 ThreadPoolExecutor 手动配而不是用 Executors 的快捷方法。核心线程数设 4最大线程数设 20阻塞队列用 ArrayBlockingQueue 容量 100拒绝策略选 CallerRunsPolicy。参数含义拆开讲核心线程数 4 意味着即使空闲也保留 4 条线程等待任务最大线程数 20 决定了峰值连接数在 20 左右超过后任务会先排队队列容量 100 给突发连接一个缓冲CallerRunsPolicy 的好处是任务满时不让新连接直接抛异常而是让提交线程自己去跑这个任务。这些参数不是拍脑袋定的是课程设计要求里最常见的配置也是答辩时老师最喜欢追问的“为什么用这种线程池而不直接用 CachedThreadPool”——区别在于 CachedThreadPool 几乎无线程上限在高并发下会创建出几百条线程反而把服务端拖垮。2.3 工程骨架Maven fastjson目录建到位再动手写代码技术选型再补两句项目构建用 Maven理由是不用手动引 jar 包pom 里配好依赖任何一台机器拉下来都能跑消息格式用 JSON这里我选 fastjson 做解析库因为它 API 简单、JSONObject 转字符串只要一行特别适合这种小项目。不自己写协议解析是有原因的——聊天消息要带的字段多消息类型、发送者、接收者、内容、时间戳用 JSON 表达清晰直观测试时拿文本就能模拟一条消息。自己拼字符串协议容易出边界问题比如内容里带了分隔符就直接翻车。目录结构是标准的 Maven 结构加一个主包src/main/java/com/example/chatserver/ ├── ChatServer.java // 服务端入口绑定端口接收连接 ├── ClientHandler.java // 每个客户端连接的处理器核心逻辑 └── Message.java // 消息类型常量与 JSON 工具方法pom.xml 只加一个依赖注意我用的 Java 版本是 8这是安卓开发和课程设计环境里最常见的版本dependencies dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency /dependencies服务端源码的结构不需要复杂复杂了反而不像“简易聊天”。这个目录对应上面说的三层ChatServer 管连接创建ClientHandler 管单连接的消息读写Message 管消息结构的统一。我在写代码前会把这一层结构先想清楚然后才动手。很多人的服务端写着写着代码全堆在一个类里后面想要搞清楚谁在往哪写消息只能从头读代码那体验太难受了。3. 核心代码实现登录、群聊、私聊、上下线广播四条链路3.1 服务端入口端口绑定与线程池的初始化代码ChatServer 是服务端的门面职责很单一绑定端口、创建线程池、循环 accept 新连接然后丢给 ClientHandler 去处理。端口选择上我一般用 9090原因有两个8080 常年被各种开发工具占用3306 是 MySQL 的地盘挑一个不常被占用的端口能少很多启动冲突9090 在安卓模拟器里直接访问也不会遇到什么奇怪的沙箱限制。下面是完整的入口代码public class ChatServer { // 在线用户表key 是用户名value 是对应连接的 writer用来给指定用户推送消息 private static ConcurrentHashMapString, PrintWriter onlineUsers new ConcurrentHashMap(); // 线程池核心4最大20队列100拒绝策略是调用者自己跑 private static ExecutorService pool new ThreadPoolExecutor( 4, 20, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() ); public static void main(String[] args) throws IOException { int port 9090; ServerSocket serverSocket new ServerSocket(port); System.out.println(聊天服务端启动监听端口 port); while (true) { Socket socket serverSocket.accept(); System.out.println(新客户端接入 socket.getRemoteSocketAddress()); // 每个连接交给 ClientHandler 处理不阻塞主线程 pool.execute(new ClientHandler(socket, onlineUsers)); } } }这个入口把两条核心设计体现出来了第一在线用户表用 ConcurrentHashMap因为多个线程会同时往里写入和读取HashMap 在并发写时会出死循环这种玄学问题ConcurrentHashMap 是线程安全的第二accept 循环里不做任何业务处理只负责把 Socket 交出去这样新客户端接入不会被已有连接的阻塞读写拖慢。参数上要注意的坑是 ServerSocket 绑定端口后默认允许复用但如果上一次程序没正常退出重启时会报 Address already in use后面避坑章节我会专门写这个。3.2 ClientHandler一进 run 方法就拆消息按 type 字段判断走哪条链路ClientHandler 是整个服务端代码量最大、逻辑最密集的类。它干四件事读取客户端发来的登录信息并注册在线表、循环读取聊天消息、把群聊消息广播给所有人、把私聊消息只推给目标用户、客户端断开时清理在线表。我先把代码骨架给出来再逐步解释关键参数public class ClientHandler implements Runnable { private Socket socket; private BufferedReader reader; private PrintWriter writer; private String username; private ConcurrentHashMapString, PrintWriter onlineUsers; public ClientHandler(Socket socket, ConcurrentHashMapString, PrintWriter onlineUsers) throws IOException { this.socket socket; this.onlineUsers onlineUsers; // 统一 UTF-8 编码和客户端保持一致 this.reader new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); this.writer new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); } Override public void run() { try { // 第一条消息必须是登录消息 String firstLine reader.readLine(); JSONObject loginJson JSONObject.parseObject(firstLine); this.username loginJson.getString(username); // 如果同名用户已经在线顶掉旧连接 PrintWriter oldWriter onlineUsers.get(username); if (oldWriter ! null) { oldWriter.println({\type\:\system\,\content\:\你的账号在另一台设备登录\}); } // 注册当前连接进在线表 onlineUsers.put(username, writer); broadcast({\type\:\system\,\content\:\ username 上线了\}); String line; while ((line reader.readLine()) ! null) { JSONObject msg JSONObject.parseObject(line); String type msg.getString(type); if (chat.equals(type)) { String to msg.getString(to); String content msg.getString(content); // 群聊还是私聊只看 to 字段 if (ALL.equals(to)) { broadcast({\type\:\chat\,\from\:\ username \,\content\:\ content \}); } else { sendToUser(to, {\type\:\chat\,\from\:\ username \,\content\:\ content \}); } } } } catch (Exception e) { System.out.println(连接异常 e.getMessage()); } finally { // 断开时从在线表移除并通知其他人 onlineUsers.remove(username); broadcast({\type\:\system\,\content\:\ username 下线了\}); try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } private void broadcast(String json) { for (PrintWriter w : onlineUsers.values()) { w.println(json); } } private void sendToUser(String targetUser, String json) { PrintWriter w onlineUsers.get(targetUser); if (w ! null) { w.println(json); } } }这一段代码里有三个关键点值得展开讲。第一窗口期问题在登录处理这里很明显先查旧连接、再写入新连接有一个时间差但这个简易版本不做严格的事务处理靠 ConcurrentHashMap 的线程安全已经能避免数据错乱真正的极端并发下可能两个人同时登录同一用户名我后面会讲一个更稳的做法。第二readLine() 是阻塞读客户端不发消息时线程就停在这里等待不占 CPU代价是这条线程在整个连接生命周期内都被占用这是 BIO 模型的固有成本在我们限定的并发量下可接受。第三broadcast 遍历的是 values()意味着自己发的消息也会被自己收到安卓端收到后展示消息不需要做去重处理。如果你不想让发送者也收到自己的消息可以在这里判断 writer 是不是当前连接的 writer然后跳过。3.3 在线用户管理用 ConcurrentHashMap 和顶号策略处理并发登录在线用户管理的核心是这个 ConcurrentHashMapString, PrintWriter。它的 key 是用户名value 是对应连接的 PrintWriter直接用来往连接里写消息。用 PrintWriter 做 value 有个好处println(String) 方法自带换行和服务端的 readLine() 天然匹配消息边界靠换行符就解决了这是最简单的避免粘包的方案。坏处是 PrintWriter 对 Socket 的状态感知很弱它写数据时不会主动告诉你对端已断开真正发现连接断了要等下一次读操作抛 SocketException。这个特性直接导致了后面避坑章节要讲的线程泄漏问题。顶号策略我在这里用了一种最简单的方式新连接登录时如果发现用户名已在 onlineUsers 里就给旧连接推一条“你的账号在另一台设备登录”的系统消息但不主动 close 旧 Socketclose 交给旧连接的 run 方法自己收尾。严格说这个策略有隐患——旧连接不会立刻退出它还能继续发消息直到它的下一次读操作抛异常。更稳的做法是把旧连接的 Socket 直接 close强制它的 run 方法走 finally 块清理注册信息。我在改进版本里会换成后者这样 onlineUsers 里同一时刻不会出现同一个 key 对应两个 value 的情况。但作为演示理解顶号这个概念比纠结实现方式更重要。在线列表的另一个常见需求是客户端上线时拉取当前在线用户。这个简易版没有单独做因为广播的上线通知已经包含了用户名客户端自己累积就能拼出名单。如果你要一个更准的名单可以在登录响应的 JSON 里加上 onlineUsers 的 key 集合把在线用户列表一次性返回给客户端。这个改动不复杂但很讨巧答辩时能多一个可讲的点。4. 客户端对接验证安卓模拟器怎么连、协议怎么对4.1 一个能跑的测试客户端把协议先跑通服务端写完后先别急着写安卓端。最快的验证方式是直接写一个 Java 控制台测试客户端用 System.in 输入代替安卓上的输入框把整个收发链路的正确性验证完再去考虑安卓端怎么写。这也是服务端开发里标准的“服务端接口测试”思路——先用最简客户端把接口调通再接入真实客户端。下面是一个可以放进同一个 Maven 工程的测试客户端代码public class TestClient { public static void main(String[] args) throws Exception { // 连接本地服务端端口和 ChatServer 里的保持一致 Socket socket new Socket(127.0.0.1, 9090); BufferedReader reader new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter writer new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); Scanner scanner new Scanner(System.in); // 第一行发登录消息 System.out.print(输入用户名: ); String username scanner.nextLine(); writer.println({\type\:\login\,\username\:\ username \}); // 起一个线程专门读服务端推来的消息 new Thread(() - { try { String line; while ((line reader.readLine()) ! null) { System.out.println([服务端消息] line); } } catch (IOException e) { e.printStackTrace(); } }).start(); // 主线程循环读输入发给服务端 while (scanner.hasNextLine()) { String input scanner.nextLine(); // 群聊用 ALL私聊用具体用户名 if (input.startsWith()) { String[] parts input.split( , 2); writer.println({\type\:\chat\,\to\:\ parts[0].substring(1) \,\content\:\ parts[1] \}); } else { writer.println({\type\:\chat\,\to\:\ALL\,\content\:\ input \}); } } } }这个测试客户端模仿了安卓端的典型交互读消息是一个独立线程写消息是另一个线程这里是主线程两边互不阻塞。注意登录消息和服务端处理逻辑的对应关系服务端把每一条消息都当 JSON 解析type 字段判断是登录还是聊天所以客户端每条消息都必须带着合法的 JSON 结构。测试时我会开两个控制台窗口跑两个 TestClient一个发群聊消息另一个能收到、一个 另一个用户名能私聊收到这两个场景通过后服务端的核心功能就是可用的。4.2 安卓侧连接要点10.0.2.2 与网络权限安卓客户端对接服务端最容易在现场演示时翻车的不是代码逻辑是网络地址和权限配置。先说地址安卓模拟器里的 localhost 指的是模拟器自己不是你的开发机。访问宿主机上的服务端要用特殊地址 10.0.2.2这是安卓模拟器给宿主机回环地址预留的别名。真机调试的话模拟器不存在了要用电脑的局域网 IP手机和电脑连同一个 Wi-Fi服务端监听端口不能被防火墙拦掉。很多人在模拟器里用 127.0.0.1 连半天连不上就是这个地址没搞对。权限和线程模型是另外两个高频坑。安卓 6.0 之后网络请求必须在 AndroidManifest.xml 里声明 INTERNET 权限否则 Socket 连接直接抛权限异常这个权限是安装期权限不需要动态申请但名字要写对。线程模型上Android 主线程不能做网络 IO否则会报 NetworkOnMainThreadException所以连接 Socket、读消息这两件事必须放在子线程里。读消息用 while 循环阻塞读这也意味着要把它放进独立线程不能占用 UI 线程。还有一个容易被忽略的是 Intent 里传递 Socket 的风险。我是这么处理的在 Activity 里启动一个 Application 级别的单例连接管理器持有 Socket 和读写流Activity 只负责把要发的字符串交给这个管理器。聊天消息体里不能带 Activity 对象否则内存泄漏会直接拖垮 App。做到这一步安卓端就可以稳定对接服务端了协议字段完全复用测试客户端里的那套 JSON 格式。5. 避坑清单连接失败、乱码、粘包、线程泄漏的排查顺序5.1 安卓模拟器连不上宿主机服务端现象安卓端 connect 时抛 SocketTimeoutException 或 ConnectException服务端控制台没有任何“新客户端接入”打印。原因排查优先级是这样第一步看地址模拟器必须用 10.0.2.2不能用 127.0.0.1 或 localhost第二步看服务端是否真的启动了netstat -ano 或 lsof -i:9090 确认端口在监听第三步看宿主机防火墙拦截Windows 上要放行 9090 端口的入站规则第四步看客户端有没有 INTERNET 权限。解决方式按这个顺序来百分之九十的连不上都能在这个流程里定位。5.2 中文消息全部变成问号现象服务端收到客户端发来的中文打印出来全是“???”转发到安卓端看到的也是乱码。原因几乎都是编码不一致Socket 流上的字符编码在两端没有统一成 UTF-8。Java 的 BufferedReader 如果不指定编码会用平台默认编码Windows 下是 GBK安卓下是 UTF-8两边一对就乱了。解决方式是客户端和服务端在构造 Reader 和 Writer 时都显式指定 UTF-8代码在第 3 章里已经按这个标准写了。还有一层容易被忽视PrintWriter 构造时第二个参数传 true 表示 println 后自动 flush如果你的服务端忘记 flush消息会一直积压在缓冲区里迟迟不推给客户端客户端表现就是“消息收不到”。5.3 一条消息拆成两半或者两条消息黏在一起现象服务端用 readLine() 读出来的内容有时只有半条 JSON解析直接报错有时一次读出来是两条消息拼在一起的 JSONparseObject 也会报错或只解析出第一条。原因是在 TCP 报文里消息边界是操作系统分片决定的和你 println 的调用次数没有直接对应关系。解决方式在简易版里最实用的就是“一行一条消息”用换行符 \n 作为消息分隔符。readLine() 读取时会一直读到换行符才返回天然就按行切分消息了。如果你的消息体里本身会带换行符那就要转到更严谨的协议设计——消息头放长度字段消息体按字节读取这是 Netty 里 LengthFieldBasedFrameDecoder 干的事简易版不用做这么重。5.4 客户端强关后台服务端还留着死连接现象安卓端直接划掉 App服务端在线用户列表里还能看到这个用户名别人给他发消息也不报错但消息石沉大海。原因很典型客户端掉线时 TCP 断开通知服务端是不可靠的尤其没有正常走四次挥手的情况下服务端感知不到对端已消失。我的简易版里 run 方法是在下一次 readLine() 抛 SocketException 时候才清理在线表如果那条连接一直没消息死连接就会一直挂在 onlineUsers 里。解决方式分两层第一层服务端在线程里给 Socket 设 soTimeout例如 socket.setSoTimeout(60000)读超时后主动认为连接死亡并清理第二层客户端加心跳机制每 20 秒发一条轻量消息告诉服务端“我还活着”。这里是简易版不做也不影响演示但做上了就成了加分项。5.5 重启服务端时报 Address already in use现象服务端被 CtrlC 停掉后立刻重新启动报 java.net.BindException: Address already in use。原因是操作系统回收 Socket 端口有 TIMEWAIT 状态默认要等 60 到 120 秒才能完全释放。解决方式有两个一是启动前确认没有残留的 java 进程占着端口Windows 用 netstat -ano | findstr 9090 找到 PID 后杀掉二是代码里在创建 ServerSocket 之前设置端口复用ServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(port));这个参数是面试里容易被问到的一个点虽然它只在 TIME_WAIT 状态下才有效日常开发里熟悉它也能让你在处理端口问题时少走弯路。最后补一个提醒别用 8080 和 3306 这些常用端口能直接从源头上避开不少已经被占用的场景。6. 进阶验证与改造方向模拟并发、心跳保活、Netty 换底6.1 模拟并发用脚本一次性拉起 50 个客户端功能跑通后我建议做一次并发验证看看服务端线程池和 messages 处理会不会崩。最简单的做法是写一个 bash 脚本循环用系统自带的 ncnetcat命令创建多个连接for i in $(seq 1 50); do (echo {\type\:\login\,\username\:\user$i\}; sleep 5; echo {\type\:\chat\,\to\:\ALL\,\content\:\hello from user$i\}) | nc -w 5 127.0.0.1 9090 done wait这个脚本会一次性建 50 个 TCP 连接每个连接登录后发一条群聊消息然后保持 5 秒再断开。跑完看服务端控制台的日志50 个“新客户端接入”、50 个“上线了”、50 条 “hello from user$i”广播、50 个“下线了”全部输出不报异常说明线程池在 50 并发下扛得住。如果要测你的线程池边界可以把 50 改成 200看队列打满后 CallerRunsPolicy 的表现。这是服务端接口测试里很实用的压测思路不用引入 JMeter一条命令就能得出结论。6.2 心跳保活与 Netty 换底的判断标准并发验证通过后下一步要思考的是“这个简易版还能往哪改”。我的建议是先安心跳再考虑换 Netty。心跳在现有架构里的实现很轻量客户端启动一个 Timer每 20 秒发一条 {type:ping}服务端 ClientHandler 里记录 lastHeartbeatTime 字段另起一个守护线程每 10 秒扫描一次在线表把超过 60 秒没心跳的连接的 run 方法置为退出。这样即使客户端异常掉线服务端也能在三分钟内清理掉死连接避免在线用户表里的僵尸条目越积越多。要不要换 Netty判断标准不是“Netty 听起来更高级”而是你的需求是否超出了 BIO 的承受范围预期的并发连接数超过 500消息格式需要处理半包粘包的通用解码需要更精细的读写缓冲控制或者要同时支撑协议栈扩展——这些场景才值得换。Netty 带来的收益是线程模型从“一连接一线程”变成“少量线程承载大量连接”代价是 ChannelHandler 链、ByteBuf 生命周期、编解码器这些概念的学习成本。课程设计完全不需要工作后如果真要做高并发的 C 端聊天服务再来啃不迟。这套代码我写过不止一次每一轮的教训都差不多先把线程模型和协议定住再写业务逻辑遇到连接异常先看地址再看编码死连接清理永远要未雨绸缪。希望这篇笔记里的思路、参数和坑能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
返回列表