Java学习篇(五)—— 第一个Java程序:Hello, 多人聊天室!
在源代码的基础上添加了服务器NIO,但是客户端的Jackson部分没有实现,只能运行测试代码,还增加了一个历史记录功能,进入服务器后能看到服务器的历史记录,过程中有一些问题放在了最后。
Java项目结构
原作者的项目写的很规范,项目结构是三层结构。用户的请求和客户端的响应都进行了封装,有了以下几个类:ResponseType, ResponseStatus,Request,Response。基本功能是聊天和传送文件,所以有两个用户发送内容的封装:Message、FileInfo。用户的属性包括:用户名、ID、密码、头像、性别。项目UI使用的是Java swing,所以UI和实体是直接绑定的。
服务器端实现对已经注册的用户和在线用户的新增,删除,登录,加载等,RequestProcessor实现了Runnable接口,对应一个客户端socket连接,Server的主进程开了一个单独的线程监听客户端请求。客户端的请求封装成一个Request,Controller接收Request,处理完后包装成Response返回。
客户端在UI里直接发送了Request,然后开了一个单独的线程处理服务器的回应。客户端可以选择用户进行私聊,还可以传输文件。
性能测试
源代码的服务器端,每来一个socket链接就开启一个新线程,简单易用,但在高并发下效率低。使用代码测试服务器端的性能:
展开代码
```java public class ClientTest { private static final int TOTAL_CLIENTS = 5000; private static final String HOST = "localhost"; private static final int PORT = 6666; private static final AtomicInteger completed = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {
// 创建固定线程池(防止线程数过多导致系统资源耗尽)
ExecutorService executor = Executors.newFixedThreadPool(5000);
// 全局时间
long globalStart = System.currentTimeMillis();
for (int i = 0; i < TOTAL_CLIENTS; i++) {
int clientId = i;
executor.submit(() -> {
try {
// 客户端的开始时间
long connectStart = System.nanoTime();
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(true); // 也可以设置非阻塞
socketChannel.connect(new InetSocketAddress(HOST, PORT));
// 结束时间
long connectEnd = System.nanoTime();
long connectDurationMs = (connectEnd - connectStart) / 1_000_000;
System.out.println("Client " + clientId + " connected in " + connectDurationMs + " ms");
// 模拟长时间连接
Thread.sleep(60_000); // 保持 60 秒
socketChannel.close();
int done = completed.incrementAndGet();
if (done == TOTAL_CLIENTS) {
long globalEnd = System.currentTimeMillis();
System.out.println("🚀 全部连接任务提交完成,用时:" + (globalEnd - globalStart) + " ms");
}
} catch (Exception e) {
System.out.println("Client " + clientId + " failed: " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(2, TimeUnit.MINUTES);
System.out.println("测试完成");
}
}
</details>
结果显示:

这里可能的连接失败和连接方式没关心,Java的socket底层调用的是操作系统的socket,将最大连接数`backlog`传给操作系统的`listen(fd, backlog)`调用,不同的操作系统有不同的限制,并且最终使用的参数是`somaxconn`和`backlog`的最小值,所以即使设置的很大也不会是真实的,测试设置的是5000(现代linux的`somaxconn`是4096),测试结果看这个最大连接数大概在1000。
使用Java的性能分析工具,监督服务器的资源消耗如图所示:

可以看到,500个连接就是五百个线程,每一个线程都有用户栈,随着用户操作的增加,可分配的栈空间会越来越少。
# NIO + 线程池 + Jackson 实现服务器端
## 工具类
### common.util.SocketUtil
功能:将Jackson包装了一下,可以将Object写到buffer,也可以从buffer中读取object。
<details>
<summary>代码展开</summary>
```java
public class SocketUtil {
/** 关闭Socket */
public static void close(Socket socket) {
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/** 关闭ServerSocket */
public static void close(ServerSocket ss) {
if (ss != null && !ss.isClosed()) {
try {
ss.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
Server
ClientContext
功能:客户端通道的上下文,包括读写缓冲区和通道。
代码展开
public class ClientContext {
public SocketChannel channel; // 关联的客户端通道
public ByteBuffer readBuffer; // 读缓冲区,和数组相比的优势是有状态指针position, limit, capacity
public Queue<ByteBuffer> writeQueue; // 待发送数据队列
public boolean writeInProgress; // 是否正在写数据
// 构造函数,初始化缓冲区和队列
public ClientContext(SocketChannel channel) {
this.channel = channel;
this.readBuffer = ByteBuffer.allocate(8192); // 8KB读缓冲
this.writeQueue = new LinkedList<>();
this.writeInProgress = false;
}
// 添加要写的数据到队列
public void enqueueWrite(byte[] data) {
if (data != null && data.length > 0) {
writeQueue.add(ByteBuffer.wrap(data));
}
}
// 判断是否还有数据待写
public boolean hasPendingWrites() {
return !writeQueue.isEmpty();
}
// 从队列获取当前写缓冲
public ByteBuffer getCurrentWriteBuffer() {
return writeQueue.peek();
}
// 写完当前缓冲后调用,弹出已写完的缓冲
public void popCurrentWriteBuffer() {
writeQueue.poll();
}
}
server.controller.RequestProcessorJson
功能:对客户端信息的处理,包括注册,登录,抖动,传输文件。
代码展开
public class RequestProcessorJson implements Runnable{
private final ClientContext context;
private final SelectionKey key;
public RequestProcessorJson(ClientContext context, SelectionKey key) {
this.context = context;
this.key = key;
}
public void run() {
try {
if(key.isReadable()){
processRead();
}else if(key.isWritable()){
processWrite();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 通道读
public void processRead( ) throws IOException {
SocketChannel channel = context.channel;
ByteBuffer buffer = context.readBuffer;
buffer.clear();
int len = channel.read(buffer);
if (len == -1) {
channel.close();
System.out.println("客户端断开");
return;
}
Request request = JacksonUtil.readBuffer2Obj(buffer, Request.class);
try{
//从请求输入流中读取到客户端提交的请求对象
String actionName = request.getAction(); //获取请求中的动作
switch (actionName) {
case "userRegiste" -> //用户注册
registe(request);
case "userLogin" -> //用户登录
login(request);
case "exit" -> //请求断开连接
logout(request);
case "chat" -> //聊天
chat(request);
case "shake" -> //振动
shake(request);
case "toSendFile" -> //准备发送文件
toSendFile(request);
case "agreeReceiveFile" -> //同意接收文件
agreeReceiveFile(request);
case "refuseReceiveFile" -> //拒绝接收文件
refuseReceiveFile(request);
}
}catch(Exception e){
e.printStackTrace();
}
// 切换成写状态
context.writeInProgress = true;
}
// 通道写
public void processWrite() throws IOException {
SocketChannel channel = context.channel;
while (true) {
ByteBuffer buffer = context.getCurrentWriteBuffer();
if (buffer == null) {
// 没数据可写了,切回读
context.writeInProgress = false;
key.interestOps(SelectionKey.OP_READ);
return;
}
channel.write(buffer); // 写数据(可能只写一部分)
if (buffer.hasRemaining()) {
// hasRemaining判断buffer有没有读取完
// 说明本轮写没写完,等下一轮 selector 再触发
break;
} else {
// 本 buffer 写完了,出队
context.popCurrentWriteBuffer();
}
}
// 写队列还有数据,继续监听写事件
if (!context.hasPendingWrites()) {
context.writeInProgress = false;
key.interestOps(SelectionKey.OP_READ);
}
}
/** 注册 */
public void registe(Request request) throws IOException {
User user = (User)request.getAttribute("user");
UserService userService = new UserService();
userService.addUser(user);
Response response = new Response(); //创建一个响应对象
response.setStatus(ResponseStatus.OK);
response.setData("user", user);
JacksonUtil.writeObj2Buffer(response, context.writeQueue);
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
//把新注册用户添加到RegistedUserTableModel中
DataBuffer.registedUserTableModel.add(new String[]{
String.valueOf(user.getId()),
user.getPassword(),
user.getNickname(),
String.valueOf(user.getSex())
});
}
/** 拒绝接收文件 */
private void refuseReceiveFile(Request request) throws IOException {
FileInfo sendFile = (FileInfo)request.getAttribute("sendFile");
Response response = new Response(); //创建一个响应对象
response.setType(ResponseType.REFUSERECEIVEFILE);
response.setData("sendFile", sendFile);
response.setStatus(ResponseStatus.OK);
//向请求方的输出流输出响应
ClientContext receiveContext = DataBuffer.onlineUserContextMap.get(sendFile.getToUser().getId());
JacksonUtil.writeObj2Buffer(response, receiveContext.writeQueue);
DataBuffer.onlineUserKeyMap.get(sendFile.getToUser().getId()).interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
/** 同意接收文件 */
private void agreeReceiveFile(Request request) throws IOException {
FileInfo sendFile = (FileInfo)request.getAttribute("sendFile");
//向请求方(发送方)的输出流输出响应
Response response = new Response(); //创建一个响应对象
response.setType(ResponseType.AGREERECEIVEFILE);
response.setData("sendFile", sendFile);
response.setStatus(ResponseStatus.OK);
ClientContext sendContext = DataBuffer.onlineUserContextMap.get(sendFile.getFromUser().getId());
JacksonUtil.writeObj2Buffer(response, sendContext.writeQueue);
DataBuffer.onlineUserKeyMap.get(sendFile.getFromUser().getId()).interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
//向接收方发出接收文件的响应
Response response2 = new Response(); //创建一个响应对象
response2.setType(ResponseType.RECEIVEFILE);
response2.setData("sendFile", sendFile);
response2.setStatus(ResponseStatus.OK);
ClientContext receiveContext = DataBuffer.onlineUserContextMap.get(sendFile.getToUser().getId());
JacksonUtil.writeObj2Buffer(response2, receiveContext.writeQueue);
DataBuffer.onlineUserKeyMap.get(sendFile.getToUser().getId()).interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
/** 客户端退出 */
public void logout( Request request) throws IOException{
User user = (User)request.getAttribute("user");
//把当前上线客户端的IO从Map中删除
DataBuffer.onlineUserIOCacheMap.remove(user.getId());
//从在线用户缓存Map中删除当前用户
DataBuffer.onlineUsersMap.remove(user.getId());
Response response = new Response(); //创建一个响应对象
response.setType(ResponseType.LOGOUT);
response.setData("logoutUser", user);
JacksonUtil.writeObj2Buffer(response, context.writeQueue);
// 关闭channel
context.channel.close();
DataBuffer.onlineUserTableModel.remove(user.getId()); //把当前下线用户从在线用户表Model中删除
iteratorResponse(response);//通知所有其它在线客户端
}
/** 登录 */
public void login(Request request) throws IOException {
String idStr = (String)request.getAttribute("id");
String password = (String) request.getAttribute("password");
UserService userService = new UserService();
User user = userService.login(Long.parseLong(idStr), password);
Response response = new Response(); //创建一个响应对象
if(null != user){
if(DataBuffer.onlineUsersMap.containsKey(user.getId())){ //用户已经登录了
response.setStatus(ResponseStatus.OK);
response.setData("msg", "该 用户已经在别处上线了!");
JacksonUtil.writeObj2Buffer(response, context.writeQueue);
}else { //正确登录
DataBuffer.onlineUsersMap.put(user.getId(), user); //添加到在线用户
//设置在线用户
response.setData("onlineUsers",
new CopyOnWriteArrayList<User>(DataBuffer.onlineUsersMap.values()));
response.setStatus(ResponseStatus.OK);
response.setData("user", user);
// response里设置历史聊天记录
response.setData("history", MainServer.chatRecords);
JacksonUtil.writeObj2Buffer(response, context.writeQueue);
//通知其它用户有人上线了
Response response2 = new Response();
response2.setType(ResponseType.LOGIN);
response2.setData("loginUser", user);
iteratorResponse(response2);
//把当前上线的用户IO添加到缓存Map中
DataBuffer.onlineUserContextMap.put(user.getId(), context);
DataBuffer.onlineUserKeyMap.put(user.getId(), key);
//把当前上线用户添加到OnlineUserTableModel中
DataBuffer.onlineUserTableModel.add(
new String[]{String.valueOf(user.getId()),
user.getNickname(),
String.valueOf(user.getSex())});
}
}else{ //登录失败
response.setStatus(ResponseStatus.OK);
response.setData("msg", "账号或密码不正确!");
JacksonUtil.writeObj2Buffer(response, context.writeQueue);
}
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
/** 聊天 */
public void chat(Request request) throws IOException {
Message msg = (Message)request.getAttribute("msg");
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.CHAT);
response.setData("txtMsg", msg);
if(msg.getToUser() != null){ //私聊:只给私聊的对象返回响应
ClientContext toContext = DataBuffer.onlineUserContextMap.get(msg.getToUser().getId());
JacksonUtil.writeObj2Buffer(response, toContext.writeQueue);
DataBuffer.onlineUserKeyMap.get(msg.getToUser().getId()).interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}else{ //群聊:给除了发消息的所有客户端都返回响应
// 记录所有的公共聊天记录
MainServer.chatRecords.add(msg);
for(Long id : DataBuffer.onlineUserIOCacheMap.keySet()){
if(msg.getFromUser().getId() == id ){ continue; }
JacksonUtil.writeObj2Buffer(response, DataBuffer.onlineUserContextMap.get(id).writeQueue);
DataBuffer.onlineUserKeyMap.get(id).interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
}
}
/*广播*/
public static void board(String str) throws IOException {
User user = new User(1,"admin");
Message msg = new Message();
msg.setFromUser(user);
msg.setSendTime(new Date());
DateFormat df = new SimpleDateFormat("HH:mm:ss");
StringBuffer sb = new StringBuffer();
sb.append(" ").append(df.format(msg.getSendTime())).append(" ");
sb.append("系统通知\n "+str+"\n");
msg.setMessage(sb.toString());
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.BOARD);
response.setData("txtMsg", msg);
for (Long id : DataBuffer.onlineUserIOCacheMap.keySet()) {
JacksonUtil.writeObj2Buffer(response, DataBuffer.onlineUserContextMap.get(id).writeQueue);
SelectionKey tokey = DataBuffer.onlineUserKeyMap.get(id);
tokey.interestOps(tokey.interestOps() | SelectionKey.OP_WRITE);
}
}
/*踢除用户*/
public static void remove(User user_) throws IOException{
User user = new User(1,"admin");
Message msg = new Message();
msg.setFromUser(user);
msg.setSendTime(new Date());
msg.setToUser(user_);
StringBuffer sb = new StringBuffer();
DateFormat df = new SimpleDateFormat("HH:mm:ss");
sb.append(" ").append(df.format(msg.getSendTime())).append(" ");
sb.append("系统通知您\n "+"您被强制下线"+"\n");
msg.setMessage(sb.toString());
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.REMOVE);
response.setData("txtMsg", msg);
// 发送消息,并开启写监听
ClientContext toContext = DataBuffer.onlineUserContextMap.get(msg.getToUser().getId());
JacksonUtil.writeObj2Buffer(response, toContext.writeQueue);
SelectionKey tokey = DataBuffer.onlineUserKeyMap.get(msg.getToUser().getId());
tokey.interestOps(tokey.interestOps() | SelectionKey.OP_WRITE);
}
/*私信*/
public static void chat_sys(String str,User user_) throws IOException{
User user = new User(1,"admin");
Message msg = new Message();
msg.setFromUser(user);
msg.setSendTime(new Date());
msg.setToUser(user_);
DateFormat df = new SimpleDateFormat("HH:mm:ss");
StringBuffer sb = new StringBuffer();
sb.append(" ").append(df.format(msg.getSendTime())).append(" ");
sb.append("系统通知您\n "+str+"\n");
msg.setMessage(sb.toString());
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.CHAT);
response.setData("txtMsg", msg);
ClientContext toContext = DataBuffer.onlineUserContextMap.get(msg.getToUser().getId());
JacksonUtil.writeObj2Buffer(response, toContext.writeQueue);
}
/** 发送振动 */
public void shake(Request request)throws IOException {
Message msg = (Message) request.getAttribute("msg");
DateFormat df = new SimpleDateFormat("HH:mm:ss");
StringBuffer sb = new StringBuffer();
sb.append(" ").append(msg.getFromUser().getNickname())
.append("(").append(msg.getFromUser().getId()).append(") ")
.append(df.format(msg.getSendTime())).append("\n 给您发送了一个窗口抖动\n");
msg.setMessage(sb.toString());
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.SHAKE);
response.setData("ShakeMsg", msg);
iteratorResponse(response);
}
/** 准备发送文件 */
public void toSendFile(Request request)throws IOException{
Response response = new Response();
response.setStatus(ResponseStatus.OK);
response.setType(ResponseType.TOSENDFILE);
FileInfo sendFile = (FileInfo)request.getAttribute("file");
response.setData("sendFile", sendFile);
//给文件接收方转发文件发送方的请求
ClientContext toContext = DataBuffer.onlineUserContextMap.get(sendFile.getToUser().getId());
SelectionKey tokey = DataBuffer.onlineUserKeyMap.get(sendFile.getToUser().getId());
JacksonUtil.writeObj2Buffer(response, toContext.writeQueue);
tokey.interestOps(tokey.interestOps() | SelectionKey.OP_WRITE);
}
/** 给所有在线客户都发送响应 */
private void iteratorResponse(Response response) throws IOException {
for (Long id : DataBuffer.onlineUserIOCacheMap.keySet()) {
ClientContext toContext = DataBuffer.onlineUserContextMap.get(id);
SelectionKey tokey = DataBuffer.onlineUserKeyMap.get(id);
JacksonUtil.writeObj2Buffer(response, toContext.writeQueue);
tokey.interestOps(tokey.interestOps() | SelectionKey.OP_WRITE);
}
}
}
MainServerNIO
功能:使用NIO和线程池,获取客户端连接。
代码展开
public class MainServerNIO {
public static List<Message> chatRecords;
public static void main(String[] args) {
// 历史记录
chatRecords = new ArrayList<Message>();
int port = Integer.parseInt(DataBuffer.configProp.getProperty("port"));
// 线程池
ExecutorService threadPool = Executors.newFixedThreadPool(100);
new Thread(new Runnable() {
@Override
public void run() {
try (ServerSocketChannel serverChannel = ServerSocketChannel.open()) {
serverChannel.bind(new InetSocketAddress(port), 5000);
serverChannel.configureBlocking(false);
// 实例化一个selector
Selector selector = Selector.open();
// selector和severChannel关联
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while(true){
// selector.select()将请求注册到SelectionKey集合,并返回selectionKey数量
int readyChannels = selector.select();
if (readyChannels == 0) continue;
// 数量不为0说明有请求
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// set的迭代器
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历每一个selectionKey
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 判断是什么类型的请求
if(key.isAcceptable()){
// 连接请求构建新的客户端通道
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
// 注册通道到selector
socketChannel.register(selector, SelectionKey.OP_READ, new ClientContext(socketChannel));
}else if(key.isReadable() || key.isWritable()){
// 读数据请求
ClientContext context = (ClientContext) key.attachment();
threadPool.submit(new RequestProcessorJson(context, key));
}
}
}
}catch (Exception e) {
e.printStackTrace();
System.out.println(e.getMessage());
}
}
}).start();
//设置外观感觉
JFrame.setDefaultLookAndFeelDecorated(true);
JDialog.setDefaultLookAndFeelDecorated(true);
try {
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
} catch (Exception e) {
e.printStackTrace();
}
//启动服务器监控窗体
new ServerInfoFrame();
}
}
NIO性能监督

同样还是只能连接1000个线程,多的线程连接会refuse掉,线程池只有20个线程在处理任务,Threads上可以看出来。由于每一个Channel都需要分配缓冲区,所以可以看到堆的空间占用相比于BIO要更高,GC在Minor GC或Full GC被触发,GC会回收堆中所有“不可达对象”,垃圾清除完后堆使用量迅速下降。
一些问题
代码小白,这里记录一些自己问GPT的问题。
文件持久化和数据库相比会带来哪些问题、原因是什么、怎么验证它们确实存在?
- 性能瓶颈,每一次要把所有的用户信息都加载,但是数据库只需要一个
SELECT语句。 2. 并发写入冲突,两个线程一起写入文件,会带来冲突,数据库可以实现并发。3. 结构变更兼容性差,修改User类,加一个新字段,再去读取旧文件,会读入失败。4. 不灵活,无法筛选。 5. 过程中一旦中断,就会发生报错。
代码将所有用户信息加载到了内存里,企业会怎么做?
放在分布式内存系统中,加载信息包括:用户ID,用户当前连接的设备,socket 连接信息,最近活动时间,会话状态(在线、离开、隐身等),客户端类型、IP、位置信息等。中大型系统的缓存使用Redis,可共享,跨服务访问,适合分布式。
如何判断一个类应不应该是内部类,成员变量需不需要get和set方法?
仅仅在外部类中使用,不会在类的外部访问,需要访问外部类成员的可以写成内部类;变量需要对外公开访问/修改,提供getter和setter,类是JavaBean(如实体类、DTO),也需要;只读变量,变量不能被随便改动,只提供getter。
怎么判断我是应该使用线程池还是NIO+线程池呢?
NIO的特点是一个线程可以处理多个连接,所以对于连接数少、任务处理耗时长的用 BIO + 线程池,而连接数多、任务处理快或IO密集型,使用 NIO + 线程池。
-
并发连接数:少于几百:BIO 就够了;几千~上万:使用 NIO 更合适。
-
连接的“活跃性”:很多连接是“长连接但空闲”(比如聊天软件):推荐 NIO;连接一来就立刻处理任务然后断开(如 HTTP 请求):BIO 或 NIO 都可。
-
处理任务的复杂程度:如果是图像处理、数据库读写等“阻塞型任务”,BIO + 线程池更简单;如果是简单读写转发、转发消息、推送通知,NIO 更适合,CPU 利用率更高。
-
是否追求高性能、低延迟:如果你追求极限性能(如聊天室上万人同时在线),那么选择 NIO + Selector + 线程池 是趋势;如果是中小项目、原型开发,BIO + 线程池更简单直接。
如何确定线程池的大小?
资料二,目前有点看不懂。
为什么使用JSON传输数据,而不是用Java原生的字节流?
安全性和跨平台兼容性是JSON最大优势。现代分布式系统几乎都弃用Java原生序列化,转向JSON、Protobuf、Avro等格式。
到底一个变量该放在类中,还是放在方法(函数)中?
你应该将变量声明在它被使用的最小作用域内,如果只在某个方法中使用就放在方法里,如果跨多个方法或对象需要用就放在类里(作为成员变量)。

浙公网安备 33010602011771号