
简介曾获吉林大学Java程序设计课程98分评价的MUD多用户虚拟游戏系统开发实战源码面向正在完成Java课程设计、期末综合实践或毕业设计的在校生也适合想了解多人在线游戏基础实现机制的Java初学者。项目以多用户虚拟游戏模拟为核心完整覆盖用户会话管理、实时数据交互等关键技术要点代码注释详尽系统经过完整调试、部署流程简便可直接投入运行架构采用模块化设计各功能组件耦合度低便于后续扩展与维护。压缩包共40个文件以10个Java源文件与16个class编译产物为主另含工程配置文件、备份文件及说明文档包体仅68KB目录结构清晰便于按模块阅读与二次开发。目前已有42人学习浏览该资源这份经过实际验证的高分方案既可作为课程设计参考模板也能帮助理解Java网络编程与面向对象设计的落地方法适合在此基础上扩展成更完整的小型网络游戏系统。1. 这个标题到底在做什么一门课程设计为什么值得按“MUD”来写如果你打开一个长得像“Java MUD多人在线游戏系统开发实战源码”的项目目录第一反应多半是“这又是一个被老师塞过来的答辩项目”。但只要你真在终端里跑起来看到两个终端窗口能相互喊话、打怪、捡装备对Java的“网络编程、多线程、集合类、面向对象封装”这几块的理解会立刻从背题变成手感。MUDMulti-User Dungeon多人地牢是最早的文本多人在线游戏形态没有图形、没有引擎全靠文本协议和服务器状态机。它不像“Java课程设计之图书管理系统”那样只堆CRUD也不像“宠物对战”那样需要美术资源。它天然需要你在一个核心循环里处理连接、消息、命令解析、世界状态和并发这正是课程设计想考察的东西。这篇笔记的读者是在GitHub或学长U盘里拿到“吉大课程设计高分项目”字样源码的人也可能是打算自己写一个MUD来交差的人。我会按从业者的习惯把这个项目拆成“服务器骨架—世界模型—命令与战斗—坑—加分项”五层来讲。你会看到可以直接抄走的Java代码、线程模型取舍、以及我踩过的那些连答辩老师都未必一次能看出来的问题。2. MUD系统的服务器骨架Socket、线程模型与文本协议先把“能连上”这件事做对2.1 为什么不能只开一个ServerSocket就喊“完成了”MUD的核心不是“能连”而是“很多人同时在一个世界里玩”。这就意味着你的服务器必须处理两类并发多个客户端同时连接以及同一个客户端在某个房间里的操作与其他人的操作交错。最直白的错误做法是“一个客户端一个线程线程里写while循环读输入”这样确实能跑但一旦世界里有NPC、定时刷怪、广播频道你就会发现广播需要遍历所有连接、同时向多个socket写数据而整个过程又和玩家读输入交叉进行。这时候你的程序会变成“谁先来谁就卡住全世界”的噩梦。我建议的最小可行架构是主线程接受新连接为每个客户端建立一个ClientHandler线程每个线程负责读自己socket上的命令、把命令交给中央的指令分发器分发器去操作世界状态然后通过GameServer的广播方法把结果写回给相关客户端。关键点是“读”和“写”分离——不要在一个线程里既读又写还改世界。否则你没法解释为什么两个玩家同时移动时房间描述会出现半个钟头不同步。下面是一个可以落地的核心骨架。先不碰世界逻辑只看连接与消息转发。public class GameServer { private ServerSocket serverSocket; private final MapString, PrintWriter clients new ConcurrentHashMap(); public void start(int port) throws IOException { serverSocket new ServerSocket(port); System.out.println(MUD server started on port port); while (true) { Socket socket serverSocket.accept(); String name player- System.currentTimeMillis(); PrintWriter out new PrintWriter(socket.getOutputStream(), true); clients.put(name, out); new ClientHandler(socket, name, this).start(); } } public void broadcast(String message, String exceptName) { for (Map.EntryString, PrintWriter entry : clients.entrySet()) { if (!entry.getKey().equals(exceptName)) { entry.getValue().println(message); } } } public void sendTo(String name, String message) { PrintWriter out clients.get(name); if (out ! null) { out.println(message); } } static class ClientHandler extends Thread { private final Socket socket; private final String name; private final GameServer server; ClientHandler(Socket socket, String name, GameServer server) { this.socket socket; this.name name; this.server server; } Override public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line in.readLine()) ! null) { server.broadcast([ name ] line, name); server.sendTo(name, You said: line); } } catch (IOException e) { System.out.println(name disconnected); } finally { server.removeClient(name); } } } }这段代码的用意是让你先把“连接管理”变成骨架accept()在一个循环里每来一个客户端就把它装进ClientHandler线程每个客户端线程只做“读一行命令、转交给服务器”这一件事输出由服务器统一管理。这里用了ConcurrentHashMap而不是普通HashMap是因为广播方法会被多个线程同时调用普通Map在遍历时被另一个线程写入会抛ConcurrentModificationException这种报错在答辩演示时出现一次整个项目印象分就没了。2.2 文本协议别用JSON用空格和关键词理由比你想的更实在很多第一次写MUD的人会自然想到用JSON传数据因为前端是网页方便解析。但MUD是纯文本终端交互输入是“look”“go north”“attack goblin”这种自然命令不是{cmd:attack,target:goblin}。如果你的解析器第一步先做JSON反序列化反而会在课程设计答辩时被追问“为什么实时游戏协议要引入这么重的序列化开销”。MUD协议的核心是用空格拆词第一个词是动作剩下的词是参数。我建议把协议定义成一个最小集中式解析而不是每个命令写一个if。因为游戏命令会越来越多可读性和扩展性比“省几行代码”重要得多。public class Command { public final String action; public final String target; public final String raw; private Command(String action, String target, String raw) { this.action action; this.target target; this.raw raw; } public static Command parse(String rawLine) { String trimmed rawLine null ? : rawLine.trim(); if (trimmed.isEmpty()) { return new Command(, , trimmed); } String[] parts trimmed.split(\\s, 2); // 只拆第一个词 String action parts[0].toLowerCase(); String target parts.length 1 ? parts[1].trim() : ; return new Command(action, target, trimmed); } }注意split(\\s, 2)里的第二个参数“2”它只把输入拆成“动作”和“其余”两块。你可能会疑惑为什么不留着再继续拆因为“attack goblin with sword”这种命令里“goblin with sword”应该作为整体交给具体命令来解释而不是在基础协议层拆成三个词。这个决定会让后面的命令实现省掉很多“参数个数判断”的脏逻辑。然后命令分发我用一个CommandHandler接口加一个注册表不要在ClientHandler里写switch。课程设计阶段你可能觉得自己写不了那么多命令但“高分项目”通常意味着代码结构有模式而不是全是if-else。public interface CommandHandler { String handle(Player player, Command cmd); } public class CommandDispatcher { private final MapString, CommandHandler handlers new HashMap(); public void register(String action, CommandHandler handler) { handlers.put(action, handler); } public String dispatch(Player player, Command cmd) { CommandHandler handler handlers.get(cmd.action); if (handler null) { return 未知命令输入 help 查看帮助\n; } return handler.handle(player, cmd); } }这里把“动作到处理函数”的映射做成了一张表好处有三一是以后加命令只需新增一个类、注册一行二是help命令可以直接遍历这张表的key来生成命令列表三是命令处理逻辑中不会出现“if在包里乱飞”的情况。你在答辩时讲到这里就可以自然地提到“面向对象编程Java”里的多态和组合而不是说“我用if处理了十几个命令”。这个点很容易成为加分项。2.3 线程模型里最容易被忽略的一个参数连接队列与超时很多教程写new ServerSocket(port)就结束了但课程设计现场经常是多人同时打开客户端连进来看演示如果网络有一点抖动accept()会抛异常或者某个客户端异常断开后readLine()直接返回null你的线程可能死掉却不通知世界。这里我一般会做三件事第一给ServerSocket设置一个合理的backlog参数。构造参数new ServerSocket(port, 50)让系统在同时涌入几十个连接时不会直接拒绝。第二给socket设置读超时比如socket.setSoTimeout(30000)这样如果客户端30秒不发任何消息服务器能主动把它踢掉防止僵尸连接占着线程。第三在ClientHandler.run()的finally里把客户端从clientsMap移除同时要把它的角色从在线列表里踢掉否则玩家下线后房间里的角色还“活着”别人攻击他会看到空响应。你还要注意PrintWriter的autoFlush参数。new PrintWriter(out, true)里第二个参数自动刷缓冲对文本实时交互是必须的。否则只有当缓冲满了才发出去玩家输入命令后服务器不回复你会以为是死锁其实是缓冲没刷。这个坑我见太多次了。3. 游戏世界模型与命令实现Room、Player、NPC、战斗把文本世界变成状态机3.1 Room与方向用四个方向的引用构建地图而不是二维数组MUD的地图不是网格坐标而是“房间—出口”结构。一个房间有四个方向的出口每个出口通向另一个房间。这种模型天然支持复杂地形、迷宫和单向门。用二维数组反而会被人追问“你打算怎么处理没有网格的洞穴结构”。所以我的第一版世界模型长这样public class Room { private String id; private String description; private Room north; private Room east; private Room south; private Room west; private final ListItem items new ArrayList(); private final ListNpc npcs new ArrayList(); public Room(String id, String description) { this.id id; this.description description; } public Room getExit(String direction) { return switch (direction) { case north - north; case east - east; case south - south; case west - west; default - null; }; } public void setExit(String direction, Room room) { switch (direction) { case north - this.north room; case east - this.east room; case south - this.south room; case west - this.west room; default - throw new IllegalArgumentException(bad direction direction); } } public String getDescriptionFrom(Player player) { StringBuilder sb new StringBuilder(); sb.append(description).append(\n); sb.append(可用的出口: ); if (north ! null) sb.append(north ); if (east ! null) sb.append(east ); if (south ! null) sb.append(south ); if (west ! null) sb.append(west ); sb.append(\n); if (!items.isEmpty()) { sb.append(这里的东西: ); for (Item item : items) { sb.append(item.getName()).append( ); } sb.append(\n); } return sb.toString(); } }这里的关键设计是房间之间通过对象引用相连而不是用一个MapString, Room再自己算坐标。好处在于做move命令时只需要player.getCurrentRoom().getExit(direction)如果返回null就告诉玩家“没有这条路”而地图编辑器也可以通过反向引用自动设置双向通道——setExit(north, room)之后让room的setExit(south, this)自动补上省得手动一个方向一个方向连。不过这里有个陷阱如果你把出口建成了双向引用保存和加载地图时会出现循环引用直接ObjectOutputStream序列化会保存整个引用链可能把地图对象越撑越大而且还原时会因为引用共享而丢拓扑。我建议课程设计阶段干脆别做地图持久化世界在服务器启动时按代码初始化就好。如果你想做“读取地图文件”用文本文件存房间和出口方向即可别用Java序列化。3.2 Player与在线状态把连接线程和游戏角色对象分开很多初学者会把“每个客户端线程”和“游戏里的玩家角色”混成一谈。比如直接在ClientHandler里写int hp 100然后用synchronized去保护它。这在只有20个命令的小项目里勉强能跑但一旦你想加“存档”“切换账号”“传送”“房主踢人”这种混在一起的设计会让你改不动。我习惯的做法是Player类只保存游戏状态名字、HP、MP、等级、当前房间、背包、装备不持有socketClientHandler线程只做连接和消息搬运。public class Player { private final String name; private int hp 100; private int maxHp 100; private int level 1; private int exp 0; private Room currentRoom; private final ListItem inventory new ArrayList(); public Player(String name, Room startRoom) { this.name name; this.currentRoom startRoom; } public void moveTo(Room room) { this.currentRoom room; } public boolean isAlive() { return hp 0; } public void takeDamage(int damage) { hp Math.max(0, hp - damage); } }那ClientHandler怎么知道当前是哪个Player我是在连接建立并完成“输入角色名”步骤后从GameServer的onlinePlayers表里取到Player对象传给ClientHandler。这样即使以后把一个客户端踢下线Player对象仍然可以被其他线程比如定时存档、NPC索敌访问。这个分离最直观的好处是你不需要在socket线程里写synchronized去保护HP而是把对HP的修改统一收口在Player对象的方法里配合synchronized或volatile做最小同步。在Java基础面试题里这其实就是在考“组合优于继承”和“单一职责”。你能在答辩时说清楚“我把连接和角色分开是为了让NPC模块不需要理解网络”比背定义有说服力得多。3.3 命令实现移动、查看、拾取、攻击的完整串法现在可以把命令分发器串起来。以下是MoveCommand和AttackCommand的完整实现也是课程设计里最常被老师让现场改的代码。public class MoveCommand implements CommandHandler { Override public String handle(Player player, Command cmd) { Room current player.getCurrentRoom(); Room target current.getExit(cmd.action); // action 是 north/east... if (target null) { return 那里没有路。\n; } player.moveTo(target); return 你去了 target.getDescriptionFrom(player); } } public class AttackCommand implements CommandHandler { Override public String handle(Player player, Command cmd) { if (cmd.target null || cmd.target.isEmpty()) { return 攻击谁\n; } for (Npc npc : player.getCurrentRoom().getNpcs()) { if (npc.getName().equalsIgnoreCase(cmd.target) npc.isAlive()) { npc.takeDamage(10); if (!npc.isAlive()) { player.gainExp(npc.getExpReward()); return 你击败了 npc.getName() 获得 npc.getExpReward() 经验。\n; } npc.attack(player); if (!player.isAlive()) { return 你被 npc.getName() 击败了。\n; } return 你对 npc.getName() 造成 10 点伤害它还有 npc.getHp() 点生命。\n; } } return 这里没有这个名字的生物。\n; } }MoveCommand里有个小技巧我没有让命令格式是go north而是直接用north作为动作。这样玩家看help时只需要看到“north/east/south/west”就懂。如果你要兼容go north可以注册go命令并在handle里再取参数但从输入体验来说MUD玩家自己都不爱敲go。AttackCommand里健康逻辑虽然简单但暴露了一个重要问题战斗是一回合制回合制玩家打一下NPC打一下。这里没有延迟也不会有并发问题吗如果两个玩家同时attack同一个NPC那么两个Player线程会同时遍历npc并调用takeDamageNpc的hp字段就需要同步。于是我们回到Java多线程的核心考点如何保证共享可变状态的线程安全。3.4 Npc的线程安全synchronized、锁对象还是直接改为“服务器主线程调度”最简单的做法是给Npc.takeDamage和Npc.attack加synchronized。因为一般课程设计的NPC数量不多性能根本不是瓶颈加锁带来的损失可以忽略。但要注意不要给Player的方法加synchronized——如果两个命令并发操作同一个Player比如一个战斗线程在扣HP同时另一个线程在处理存档synchronized的锁粒度会变成全对象锁反而把无关操作串行化。我倾向于这样写public class Npc { private int hp; public synchronized void takeDamage(int damage) { hp - damage; } public synchronized boolean isAlive() { return hp 0; } }你可能会问单靠synchronized方法能不能避免“一个玩家打死了NPC另一个玩家又去打它”的脏场景可以但需要在调用isAlive()和takeDamage()之间保持原子性。上面AttackCommand里的做法是先遍历查找名字再在找到后npc.takeDamage()这个“找到并攻击”的过程并不是原子的。更稳妥的模式是给Npc提供一个synchronized int attackAndGetState(int damage)方法把“扣血—判断死亡—返回状态”放到同一个锁里。这个细节在课程设计里属于“隐藏的高分点”。大部分同学的代码在低并发下跑不出问题只有老师让两个客户端同时打同一个怪时才会表现异常。把这里的坑提前填好演示时会稳定很多。3.5 完整命令注册流程与Help命令生成到了这一步整个服务器的启动流程应由这样几个阶段构成初始化世界地图→初始化NPC和物品→启动GameServer→对每个新来的客户端先做登录/注册然后注册命令表。我一般把CommandDispatcher的注册放在GameServer.start()里而不是每个客户端创建时注册一次因为命令表是全局共享的不依赖具体连接。public class Game { public static void main(String[] args) throws IOException { World world WorldFactory.createDefaultWorld(); CommandDispatcher dispatcher new CommandDispatcher(); dispatcher.register(look, new LookCommand()); dispatcher.register(north, new MoveCommand()); dispatcher.register(east, new MoveCommand()); dispatcher.register(south, new MoveCommand()); dispatcher.register(west, new MoveCommand()); dispatcher.register(attack, new AttackCommand()); dispatcher.register(take, new TakeCommand()); dispatcher.register(drop, new DropCommand()); dispatcher.register(help, new HelpCommand(dispatcher)); dispatcher.register(quit, new QuitCommand()); GameServer server new GameServer(dispatcher); server.start(4000); } }HelpCommand需要一个特别处理因为普通命令的handle只有一个Player与Command参数但HelpCommand需要遍历dispatcher里的所有命令名。我的做法是在HelpCommand构造时传入dispatcher的Map.keySet()副本或者在dispatcher里维护一个getActionNames()方法。因为注册表在启动后不会变所以直接返回keySet()的副本即可。这里还要注意一个设计选择quit命令不能让ClientHandler线程直接死掉否则socket的finally块会锁错资源。我一般的做法是让QuitCommand返回一个特殊字符串“QUIT”然后在ClientHandler里用dispatcher.dispatch()的返回值判断是否要退出循环。这种“以消息驱动生命周期”的方式比在命令处理函数里直接socket.close()清晰得多。4. 课程设计现场最容易踩的8个坑从启动崩溃到答辩翻车4.1 现象服务器一启动就“端口被占用”第二次运行永远失败原因上一次运行进程没被正常关闭尤其ServerSocket没有close()或者IDE里上一次运行的任务还在后台跑。解决写代码时给GameServer加一个stop()方法并ServerSocket.close()但更稳妥的是启动前先打印一下端口占用检查。boolean available false; try (ServerSocket test new ServerSocket(0)) { available true; } System.out.println(端口 port 可用: available);注意new ServerSocket(0)其实是让系统分配一个随机端口这只是用来测试绑定能力不是检测固定端口。检测固定端口是否被占用应该new ServerSocket(port)如果抛BindException就说明被占用。很多同学拿0端口测试完说“端口可用”然后依旧绑定失败就是因为这个混淆。4.2 现象客户端连接到服务器后输入任何命令都没有回复但服务端没报错原因没有autoFlushtrue或者使用了BufferedWriter但没有flush()。我用PrintWriter(OutputStream, true)写输出这是保险做法。如果你为了写日志用BufferedWriter包了一下千万记得每行flush。解决把所有输出通到PrintWriter并设置autoFlush不要自己到处手动flush。所有发给客户端的消息都走GameServer.sendTo()和broadcast()不要在ClientHandler里直接写socket不然你会忘记刷缓冲。4.3 现象两个客户端可以正常聊天但没一会儿某个客户端线程死掉提示“Connection reset”原因客户端socket被操作系统强制关闭通常是网络异常或客户端程序崩溃。但更隐蔽的是你在客户端读了服务器消息后没有正确处理“服务器关闭”的情况。readLine()返回null时循环退出这是正常逻辑。如果你没处理可能在finally里对已经关闭的socket再次close()导致异常被吞掉。解决在finally里只调用server.removeClient(name)不要直接socket.close()try-with-resources已经帮你在block结束后close了。另外把连接断开的信息也广播出去让别的玩家有感知而不是只有服务端日志有输出。4.4 现象玩家下线后再上线角色和背包全部丢失原因你没有做持久化。课程设计通常不需要做完整的存档系统但“高分”项目至少要有“下线保存、上线恢复”的呈现代码。最简单的实现是使用本地文件保存玩家状态每次玩家quit时写文件创建角色时读文件。别用ObjectOutputStream直接序列化Player因为对象里包含Room引用一序列化会把整个地图带进去下次加载时地图对象全变了。解决定义一个PlayerDataDTO只包含name, hp, level, exp, inventory items等的字符串列表用Gson或Jackson序列化成JSON文件。Java基础课程设计如果不想引第三方库可以用Properties或手动写文本文件。4.5 现象攻击同一个怪物时怪物血量出现负数或者击杀后还能继续被攻击原因Npc的操作没有原子化。上面已经提过isAlive()和takeDamage()分开调用两个线程可以同时通过isAlive()检查然后都执行扣血导致血量负值。解决把“攻击-判断死亡”整合进Npc的一个synchronized方法例如public synchronized boolean aliveAfterDamage(int damage) { hp - damage; return hp 0; }4.6 现象中文乱码特别是中文房间名和物品名显示为“???”原因两端字符集不一致。Windows终端默认GBK而你在代码里用UTF-8写字符串输出到socket时如果没有指定编码Java会用平台默认编码。处理方式统一是socket流的InputStreamReader和OutputStreamWriter都指定StandardCharsets.UTF_8客户端也一样。另外在windows上运行服务器时控制台需要执行chcp 65001切换到UTF-8代码页否则你服务端打印日志也是乱码。4.7 现象广播消息混淆玩家A和玩家B在同一个房间但A说了一句话B收到两次或者收不到原因广播逻辑中循环遍历clients时没有排除不该收到的人。比如“房间内广播”要遍历的是同房间的玩家但你的服务器只有全局广播或者你在遍历clients时并发修改了Map——这在ConcurrentHashMap中不会抛异常但可能遍历到旧值。解决先设计清楚广播的范围全局广播、房间广播、私聊。然后分别为它们实现对应的方法。不要只用全局广播否则玩家在隔壁房间也能听到这里的话答辩时老师一定会问“你有没有房间频道”你这样暴露是减分的。4.8 现象服务器运行超过一个小时后内存暴涨最后OOM原因每个玩家进入游戏时在clients和onlinePlayers里各存了一份离开时只从一个Map里移除另一个没移除。这种泄漏在课程设计验收时撑几个小时才会出现但负责任的老师会看你的日志。解决把移除逻辑写成一个统一的GameServer.handleDisconnect(ClientHandler handler)方法同时从clients、onlinePlayers、currentRoom的在线列表里删除。每写一个断开路径都调用这个方法不要只调clients.remove。5. 让这堆代码从“能跑”变成“高分”事件日志、心跳与存档以及我的一次答辩教训课程设计评审的时间通常只有5到10分钟老师不会逐行读你的代码他做的是“启动服务器→开两个终端→输入几个命令→看反应速度→问几个设计问题”。所以最后一章我建议你把精力集中在这三件事日志、心跳、快捷存档。第一日志。不要再用System.out.println到处打点在ClientHandler和GameServer里统一走一个GameLogger。我一般定义成最简单的静态方法public class GameLogger { public static void info(String tag, String message) { System.out.printf([%tF %tT] [%s] %s%n, new Date(), tag, message); } }这样做的好处是老师问你“你怎么知道玩家下线了”你可以展示日志文件或控制台输出。注意这里用%tF和%tT格式化日期是Java基础里的知识点但很少人真的会用用对了也算一个小亮点。第二心跳。MUD虽然是文本协议但TCP连接可能因为网络中间设备静默断开服务器端很久都感知不到。我建议每个客户端连接后启动一个HeartbeatTask每10秒检查一次lastActiveTime如果超过30秒没收到任何数据就主动关闭。这个机制不需要引入复杂的线程池只要一个ScheduledExecutorService。ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); for (String name : clients.keySet()) { scheduler.scheduleAtFixedRate(() - { long last lastActiveTimes.getOrDefault(name, Long.MAX_VALUE); if (System.currentTimeMillis() - last 30000) { clients.get(name).close(); } }, 10, 10, TimeUnit.SECONDS); }注意这里不要直接在定时任务里socket.close()应该把需要关闭的连接名放入一个队列让ClientHandler自己处理退出逻辑。因为close()会触发finally块而finally里会移除多个Map如果你在定时线程里做就可能和客户端请求线程发生竞态。第三快捷存档。不需要做复杂的数据库写一个ServerStateSaver每60秒把所有在线玩家状态写入本地文件。玩家上线时如果发现文件存在就加载并覆盖掉新建角色的默认值。这个功能在“确保答辩时不丢数据”这一点上非常实用——演示过程中如果服务器被误杀重启后玩家还在老师会对“可靠性”留下印象。最后讲一个我答辩时的教训。当年我做的是一个简化版MUD自认为处理好了并发结果老师问了一个致命问题“如果两个玩家同时进入同一个房间且同时拾取地上同一个物品这个物品会分给谁”我当时没处理因为我的拾取命令是先判断物品存在再删除两个请求都能看到物品存在。后来我把物品在Room里加入了一个inventory的synchronized方法Item takeItem(String itemName)内部做“查找—移除—返回”原子操作才把这个洞补上。所以你现在写代码时只要记住一个原则凡是“先检查再操作”的代码一定要让检查和操作在同一个锁里。这个原则我后来在写各种Java服务端时都在用算是一句血泪经验。希望帮到你。按这个思路把项目改完你收获的不只是一套源码而是以后再遇到多线程服务器时会本能地先画清楚哪些对象是共享的再动手写代码。本文还有配套的精品资源点击获取