传统的网络开发
一、Socket 通信
我们,首先来用 Java 实现一个简单的 Socket 通信的程序
java
@Slf4j
public class Server {
public static void main(String[] args) throws IOException {
try(ServerSocket serverSocket = new ServerSocket(9099);) {
while(true) {
Socket socket = serverSocket.accept();
log.info("接受到客户端的请求");
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int read = -1;
while((read = inputStream.read(bytes)) != -1) {
String message = new String(bytes,0,read);
log.info("接受到客户端的信息为:{}",message);
}
socket.close();
}
} catch (Exception e) {
log.error("通信异常",e);
}
}
}
java
@Slf4j
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1",9099);
Scanner scanner = new Scanner(System.in);
String message = scanner.next();
socket.getOutputStream().write(message.getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().close();
socket.close();
}
}
对应的流程如下:
对于服务端而言,只会有一个 ServerSocket
用来等待接受客户端的连接,当客户端连接上来之后,服务端就会创建 Socket 与其进行通信。
运行完成上述代码之后,能够发现客户端程序运行完成之后,服务端并没有结束(不仅仅是因为 while 循环),而是一直阻塞在了 accept 这里。
现在,我们修改一下客户端的代码
java
@Slf4j
public class Client1 {
public static void main(String[] args) {
try (Socket socket = new Socket("127.0.0.1", 9091)) {
new CountDownLatch(1).await();
} catch (Exception e) {
log.error("启动服务失败", e);
}
}
}
java
@Slf4j
public class Client2 {
public static void main(String[] args) {
try (Socket socket = new Socket("127.0.0.1", 9091)) {
String message = "hello,server";
socket.getOutputStream().write(message.getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().close();
new CountDownLatch(1).await();
} catch (Exception e) {
log.error("启动服务失败", e);
}
}
}
很明显,一个客户端发送了消息,另一个客户端并没有发送消息。服务端代码并没有变化
此时启动服务端之后,服务端的日志如下:
java
00:17:48.043 [main] INFO com.coding.demo2.Server - 服务端启动成功~
00:17:52.649 [main] INFO com.coding.demo2.Server - 接受到客户端连接
此时的阻塞点,实际上是在于等客户端的数据
java
InputStream inputStream = socket.getInputStream();
因为,现在服务端是一个单线程的,接受连接 和 处理请求实际上是在一个线程运行的。这个时候,其实能够发现,能够发现:
- 如果说一个服务器启动就绪,那么主线程就一直等待客户端的连接,这个等待过程中,主线程就一直阻塞
- 在连接建立之后,在读取到Socket信息之前,线程也是一直等待,一直处于阻塞状态,不能做其他的事情
二、多线程
这个时候我们尝试修改服务端的代码,每当接受到一个连接之后,我们将 Socket 放入线程里面去执行,来避免单线程的问题
java
@Slf4j
public class Server {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(9091)) {
log.info("服务端启动成功~");
while (true) {
Socket socket = serverSocket.accept();
new Thread(() -> {
log.info("接受到客户端连接");
try {
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int length = -1;
while ((length = inputStream.read(bytes)) != -1) {
log.info("服务端接受到的数据为:{}", new String(bytes, 0, length));
}
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}).start();
}
} catch (Exception e) {
log.error("启动服务失败", e);
}
}
}
但是,每次接受到一个 Socket 连接,都会创建线程去进行处理,但是,这样做会带来如下问题:
- 每次创建线程,启动线程都要陷入内核态,这种操作是非常消耗性能的
- 每次进来都会创建线程,如果连接数少的话,还好说,如果说连接数大了,就会导致内存占用很高
三、池化
既然连接数过多,最为常见的做法就是复用,就像 Druid,线程池等,采用 池化 的思想。接下来,我们将处理请求的逻辑,放入到线程池之中
java
@Slf4j
public class Server {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
try (ServerSocket serverSocket = new ServerSocket(9091)) {
log.info("服务端启动成功~");
while (true) {
Socket socket = serverSocket.accept();
executorService.execute(() -> {
log.info("接受到客户端连接");
InputStream inputStream = null;
try {
inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int length = -1;
while ((length = inputStream.read(bytes)) != -1) {
log.info("服务端接受到的数据为:{}", new String(bytes, 0, length));
}
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
} catch (Exception e) {
log.error("启动服务失败", e);
} finally {
executorService.shutdownNow();
}
}
}
那么,通过这样的方式,是否已经解决了现有的问题呢?在这里,我们调整一下服务端的代码,在处理请求的时候,让其等待 20s
java
@Slf4j
public class Server {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
try (ServerSocket serverSocket = new ServerSocket(9091)) {
log.info("服务端启动成功~");
while (true) {
Socket socket = serverSocket.accept();
log.info("接受到客户端连接");
executorService.execute(() -> {
log.info("executorServer execute.......");
// 进行网络通信
InputStream inputStream = null;
try {
inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int length = -1;
while ((length = inputStream.read(bytes)) != -1) {
log.info("服务端接受到的数据为:{}, {}", new String(bytes, 0, length), length);
}
TimeUnit.SECONDS.sleep(20);
socket.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
} catch (Exception e) {
log.error("启动服务失败", e);
} finally {
executorService.shutdown();
}
}
}
测试过程如下:
- 启动服务端
- 启动客户端1
- 启动客户端2
- 启动客户端2(启动多份)
当我们执行到第四步的时候,能够发现线程池其实核心线程数已经满了,已经将任务放入到了等待队列里面去。所以,这种模式实际上也是有问题的。比如说:
- 线程池的资源是有限的,如果说来了 N 多个客户端连接,你的线程池还能够用吗?
- 如果说每个线程都需要等待别的资源,他其实没有空去别的事,就被阻塞在哪了
所以,只去考虑增加线程池,没有解决线程阻塞的问题。
接下来,我们就来看一下 NIO 是如何解决这些问题的