深入Java TCP流套接字编程:高效服务器构建与高并发实战优化指南
一、TCP流套接字编程
1.1、API介绍
ServerSocket是创建TCP服务端Socket的API
构造方法
ServerSocket方法
Socket
Socket方法
1.2、模拟实现服务器
1.2.1、构造方法
public ServerSocket serverSocket=null;
public TcpEchoServer(int port) throws IOException {
serverSocket=new ServerSocket(port);
}
上述初始化serversocket对象并绑定端口号
1.2.2、服务启动流程
TCP是有连接的,第一步应优先处理连接客户端和服务端,启动服务后,打印一个上线通知,Socket对象接受serversocket接收到的对象,建立连接后处理连接...
public void start() throws IOException {
System.out.println("服务器上线!");
while(true){
Socket socket=serverSocket.accept();
processConnect(socket);
}
}
public void processConnect(Socket socket){
//处理连接
}
处理连接
上述start方法已经建立了连接,对于用户的一次连接可能会有多个请求,使用在处理连接的方法中,我们再次套用while循环实现上述功能~~
public void processConnect(Socket socket) throws IOException {
System.out.printf("[%s:%d]客户端上线
",socket.getInetAddress(),socket.getPort());
try(InputStream inputStream=socket.getInputStream();
OutputStream outputStream=socket.getOutputStream()){
while(true) {
byte[] bytes = new byte[4048];
inputStream.read(bytes);
//将字符数组转化为字符串
}
}
}
为了使用更方便,使用Scanner来封装InputStream,并通过scanner.next()读取输入。Scanner默认使用空白作为分隔符,因此会逐个读取以空白分隔的字符串。当不再读取到数据,说明连接断开,结束循环即可。使用PrintWriter 封装OutputStream ,用于发送响应
关于InputStream和OutputStream内容知识的回顾链接如下:
新手必看!用Java玩转文件读写:File类+字节流核心技巧解析
public void processConnect(Socket socket){
System.out.printf("[%s:%d]客户端上线
",socket.getInetAddress(),socket.getPort());
try(InputStream inputStream=socket.getInputStream();
OutputStream outputStream=socket.getOutputStream()){
Scanner scanner=new Scanner(inputStream);
PrintWriter printWriter=new PrintWriter(outputStream);
while(true){
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 客户端下线
",socket.getInetAddress(),socket.getPort());
break;
}
String request=scanner.next();
String response=procsee(request);
printWriter.println(response);
System.out.printf("[%s:%d] req:%s rep:%s",socket.getInetAddress(),socket.getPort(),request,response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
注意:Scanner 构造方法中的参数原本即为 InputStream 对象
使用在通信过程中,我们可以通过InputStream调用read方法读取到字符数组中,也可以借助Scanner直接读取
抽象理解ServerSocket和Scoket关系和职责
1.2.3、处理连接解析
1.3、模拟实现客户端
1.3.1、完整代码
package TcpEcho;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
public Socket socket=null;
public TcpEchoClient(String ip,int port) throws IOException {
socket=new Socket(ip,port);
}
public void start() {
Scanner sc=new Scanner(System.in);
try(InputStream inputStream=socket.getInputStream();
OutputStream outputStream=socket.getOutputStream()) {
Scanner scanner=new Scanner(inputStream);
PrintWriter printWriter=new PrintWriter(outputStream);
while(true) {
System.out.println("请输入您要输入的内容:");
String request=sc.next();
printWriter.println(request);
printWriter.flush();
String response=scanner.next();
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient=new TcpEchoClient("127.0.0.1",8776);
tcpEchoClient.start();
}
}
1.3.2、通信流程
客户端构造方法中传入IP和端口号实例化Socket对象后,在底层会与TCP对端建立连接,建立链接后,服务器也会保存客户端的信息
1.3.3、数据传递流程
1.3.4、区分两端口Socket对象
服务器和客户端的两个 socket 对象,分别在不同进程中,甚至在不同主机中,因此绝对不是同一个对象;
这两个对象存在密切的关联关系,可以把这两个 socket 对象理解为两部电话:
- 接通这两部电话后,从A听筒说话,B可以听见;从B听筒说话,A可以听见(从一边对Socket对象写数据,另一边的 Socket 对象就可以读到,不挂断可以一直读写“通话”);
- 但是这两个对象绝对不是同一部电话;
二、TCP协议编程中的问题处理与优化
2.1、冲刷缓存区
前篇我们讲解IO流时涉及到过缓存区的知识,忘记可以回顾一下:
新手必看!用Java玩转文件读写:File类+字节流核心技巧解析https://blog.csdn.net/2302_81247414/article/details/148050227?spm=1011.2415.3001.10575&sharefrom=mp_manage_link
我们基于TCP协议,编写完服务端和客户端代码后,在尝试运行时,发现当客户端和服务端连接成功以后,在客户端输入消息后,服务端却接收不到任何请求:
其实此时看似发送了“111”请求,实则并未发出去~~,println()这个操作只是把数据放到“发送缓存区” ,还没有真正写入到网卡。
为了解决此问题,我们在执行完println()方法之后,要执行flush()方法,将数据强制写入IO设备里面。当然服务端和客户端println()方法之后都要刷新。
加上刷新操作后:
此情况为PrintWriter对象的行为,如果使用OutputStream对象调用方法是可以直接发送的~~
2.2、对一个完整请求和响应设置标识符
在我们编写代码发送请求或返回响应时,其实PrintWriter对象调用的println()方法写入数据时会自动加上一个" "换行,当我们去掉换行时:
发送请求时,会发现服务端没有返回响应:
其实当客户端发送请求时,数据确实发过去了, 服务器也收到了,但是服务器没有真正处理,因为服务器代码有一个判断:
hasNext()方法会自动判断定收到的数据中是否包含“空白符”,如换行,回车,制表符,翻页符等~,在遇到空白符之前,都会阻塞,所以我们发送请求时去掉换行符,此处会一直阻塞,所以我们当初加上换行符就是暗中约定,一个请求/响应使用" "作为结束标记,读的时候也是读到“ ”就结束(认为是一个完整的请求了),相对于UDP协议而言:
UDP 是以 DatagramPacket 作为单位的
TCP 则是以字节为单位,一个请求往往是多个字节构成的
2.3、Socket对象的生命周期
在服务端中,我们除了处理IO流的两个对象会通过了try-with-resource自动关闭外,还有图下两个对象
serverSocket对象伴随整个服务器,所以不用我们自己关闭,但是Socket对象是与每一个客户端连接之后都会创建一个,客户端终止,但是服务端的该对象会一直存在,连接的次数多了,会把文件描述表耗尽,所以我们要手动关闭,仅在try和catch后面加上即可

2.4、客户端-服务端 1对1 到 1对多
2.4.1、发现问题
我们都知道,一个服务器是能够同时给多个客户端提供服务的~,我们要创建多个客户端与服务端进行通信,我们先设置一下如何使多个线程运行同一个程序,如下:
我们启动服务器,创建两个客户端后:
当我们把第一个客户端关闭后,第二个客户端才正常接收到响应
所以当前代码,基于TCP协议实现的服务器同一时刻只能处理一个客户端的请求,原因是:
无法同时等待accept和等待用户请求
2.4.2、引入多线程
为了能够同时处理多个客户端与服务器的通信,我们引入多线程,创建一个主线程负责进行accept ,每当accept 到一个客户端时,就创建一个线程,由新线程来负责执行客户端的请求
基于此方法,我们再次启动服务器:
2.4.3、引入线程池
通过引入多线程我们可以同时进行多个客户端的通信,但缺点是有客户端就需要创建一个新的线程,客户断开就销毁,实际场景中,客户端的数量是十分庞大的,我们频繁创建和销毁线程在时间和资源上的消耗是十分庞大的~~,所以我们要引入线程池。
public void start() throws IOException {
System.out.println("服务器上线!");
ExecutorService executorService=Executors.newCachedThreadPool();
while(true){
Socket socket=serverSocket.accept();
executorService.submit(()->{
processConnect(socket);
});
}
}
对于服务器引入线程池时,我们一般不会使用 newFixedThreadPool(固定线程数的线程池),因为创建固线程数量的线程池意味着同时处理的客户端连接数目就固定了。
三、长短连接
扩展知识
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能⼀次收发数据。
长连接:不关闭连接,⼀直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
对比以上长短连接,两者区别如下:
• 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第⼀次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。
• 主动发送请求不同:短连接⼀般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
• 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。