Java网络IO演变史

Java网络IO演变史

前置知识

操作系统的几个概念

1.用户态和内核态 Linux操作系统的体系架构分为用户态和内核态,内核本质上是一个特殊的软件程序,它控制着计算机的硬件资源,例如协调CPU资源、分配内存资源,并为上层应用程序提供稳定的运行环境。​

用户态即为上层应用程序,它的运行依赖于内核。为了使用户程序也能够访问到内核管理的资源,所以内核必须提供通用的访问接口,这些接口被称为「系统调用」。

2.系统调用和软中断 系统调用是操作系统提供给应用程序访问的一组通用接口,它使得运行在用户态的进程可以访问内核管理的资源,例如新线程的创建、内存的申请等等。​

当应用程序发起一个系统调用时,会导致一次「软中断」,过程如下:

  1. CPU停止执行当前程序流,将CPU寄存器的值保存到栈中。
  2. 找到系统调用的函数地址,执行函数,得到执行结果。
  3. CPU恢复寄存器的值,继续运行应用程序。

综上所述,系统调用会导致应用程序从用户态切到内核态,发生一次软中断,这需要额外的开销,如果要提高应用程序的性能,应该要尽量减少系统调用的次数。​

Bio

Bio全称「Blocking IO」阻塞IO,它的IO操作如accept、read、write都是阻塞的,这也是Java网络编程最早的IO模型。​

当线程在处理Socket的IO操作时,它是阻塞的,如果服务是单线程运行,它会直接卡死,直到IO操作完成。为了避免这种情况,只能给每个连接分配一个线程。​

连接数少的话,这么做倒不会有什么太大的问题,一旦面临十万、百万级的客户端连接,Bio就无能为力了,主要原因如下:

  1. 线程是非常宝贵的资源,线程的创建和销毁成本很高,在Linux系统中,线程本质上就是一个进程,创建和销毁线程是一个很重的系统函数。
  2. 线程本身占用内存资源,创建一个线程需要分配1MB左右的栈空间,创建一千个线程就已经很可怕了。
  3. 线程间的切换成本高,操作系统需要保存线程运行的上下文环境,将寄存器的值暂存到线程栈,调用系统函数进行线程的切换。如果线程数过多,很可能线程切换的时间比线程运行的时间都多。

未命名文件 (2).jpg 如下是一个简单的Bio版本的EchoServer:

// Bio版本的Echo服务
public class BioEchoServer {
	public static void main(String[] args) throws IOException {
		ServerSocket serverSocket = new ServerSocket(9999);
		while (true) {
			final Socket accept = serverSocket.accept();
			new Thread(() -> {
				try {
					InputStream inputStream = accept.getInputStream();
					OutputStream outputStream = accept.getOutputStream();
					while (true) {
						byte[] bytes = new byte[1024];
						int size = inputStream.read(bytes);
						if (size <= -1) {
							accept.shutdownOutput();
							accept.close();
							break;
						}
						outputStream.write(bytes);
						outputStream.flush();
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}).start();
		}
	}
}
复制代码

优点

  1. 代码简单

缺点

  1. 一个线程只能处理一个客户端连接。
  2. 线程数量不可控,面对突发流量服务可能会崩溃。
  3. 线程的频繁创建和销毁需要额外的开销。
  4. 大量的线程会导致频繁的线程切换。

Nio

Nio全称「Non-Blocking IO」非阻塞IO,它是JDK1.4被引入的一套新的IO体系。​

Nio使用Channel来替代Stream,Stream是单向的,它要么是输入流、要么是输出流。而Channel是双向的,你可以通过它来同时进行数据的读写。​

Nio加入了数据缓冲区「ByteBuffer」,必须通过ByteBuffer来向Channel读写数据。ByteBuffer本身就是个字节数组,它内部有多个指针,随着数据的读取和写入,指针会不断移动。​

Nio还有一个核心组件,就是「Selector」多路复用器,这个下节再细说。​

Nio的特点就是「非阻塞」,例如调用ServerSocketChannel.accept(),线程不会阻塞直到有客户端连接了,它会立即得到结果。如果有客户端连接,则返回SocketChannel,否则返回null。 对于SocketChannel.read(),如果返回0则表示Channel当前无数据可读,返回-1代表客户端连接已断开,只有大于0时才代表读到了数据。

调用Channel.configureBlocking(false)将Channel设为非阻塞是关键,否则调用还是阻塞的,切记!!!

IO操作不阻塞,我们就可以在不开启新线程的情况下,合理的利用CPU资源了。​

例如,不需要将线程阻塞在那里死等客户端的连接了,而是每隔一段时间轮询一次是否有新的客户端接入,如果有,则将其添加到容器中,再轮询容器中的SocketChannel,是否有数据可读写,没有就跳过,有则进行IO读写。这样,即便只有一个线程,也可以处理大量的连接,严格控制了线程的数量。 未命名文件 (3).jpg 如下,是一个Nio版的EchoServer,代码明显比Bio版的稍复杂:

public class NioEchoServer {
	static List<SocketChannel> channels = new ArrayList<>();

	public static void main(String[] args) throws IOException, InterruptedException {
		ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
		serverSocketChannel.bind(new InetSocketAddress(9999));
		// 将Channel设为非阻塞!!!
		serverSocketChannel.configureBlocking(false);
		while (true) {
			SocketChannel socketChannel = serverSocketChannel.accept();
			if (socketChannel != null) {
				socketChannel.configureBlocking(false);
				channels.add(socketChannel);
			}
			// 处理数据读操作
			Iterator<SocketChannel> iterator = channels.iterator();
			while (iterator.hasNext()) {
				SocketChannel channel = iterator.next();
				ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
				int readSize = channel.read(byteBuffer);
				if (readSize > 0) {
					// 翻转,可读 > 可写
					byteBuffer.flip();
					channel.write(byteBuffer);
				} else if (readSize < 0) {
					iterator.remove();
					channel.close();
				}
			}
			// 避免CPU空转,sleep一会
			ThreadUtil.sleep(10);
		}
	}
}
复制代码

优点

  1. 单个线程即可处理大量连接。
  2. 线程数量可控。
  3. 无需阻塞,性能比Bio更高。

缺点

  1. 每次都需要轮询大量的SocketChannel,一万个连接就需要轮询一万次,每次轮询都是一个系统调用,会导致一次「软中断」,消耗性能。
  2. 轮询间隔的时间不好控制,设的太长会导致响应延迟,设的太短会消耗CPU资源。
  3. 大部分连接不活跃的情况下,无效轮询增多,无意义消耗CPU。

IO多路复用

Nio存在的主要问题是:面对海量连接,我们不知道哪些连接是准备就绪需要处理的,所以只能是每次都遍历所有连接,当只有少部分连接活跃时,每次轮询的效益就太低太低了。​

为了解决Nio的问题,引入了「IO多路复用」机制。在Java中,「Selector」接口就是多路复用器的抽象表示,在不同的平台它有不同的实现,一般来说select几乎是所有平台都支持的,在Linux中用的更多的是epoll。​

常见的IO多路复用实现

select

在Linux中,select函数定义为:

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);
复制代码

参数说明:

  • nfds:下面三种FD集合中最大值+1。
  • readfds:监听可读事件的FD集合。
  • writefds:监听可写事件的FD集合。
  • exceptfds:监听异常事件的FD集合。
  • timeout:超时时间。

调用select函数可以理解为:应用程序将所有待监听的SocketChannel集合传递给内核,由内核来轮询遍历所有SocketChannel,然后告诉应用程序哪些SocketChannel是准备就绪的。​

如果有一万个连接,应用程序自己遍历需要发起一万次系统调用,有了select,只需要一次系统调用。前面已经说过了,要想提高程序性能,就要尽量减少系统调用的次数。​

select的缺点:

  1. 每次调用都需要将FD集合从用户空间拷贝到内核空间。
  2. 内核需要遍历所有的FD。
  3. 支持的FD数量最大只有1024,太小了。

poll

在Linux系统中,poll的函数定义如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
复制代码

它的实现和select非常类似,只是描述FD集合的方式不同,select是fd_set结构,poll是pollfd结构。另外就是poll支持的FD数量没有1024的限制。​

poll的缺点:

  1. 每次调用都需要将FD集合从用户空间拷贝到内核空间。
  2. 内核需要遍历所有的FD。

epoll

在Linux系统中,epoll由三个函数组成,分别是: epoll的创建:epoll_create

int epoll_create(int size);
int epoll_create1(int flags);
复制代码

epoll FD的控制:epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
复制代码

等待epoll上的IO事件:epoll_wait

int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
                int maxevents, int timeout,
                const sigset_t *sigmask);
复制代码

使用selectpoll函数时,需要应用程序自己管理要监听的Socket FD,每次调用都需要将FD集合从用户空间拷贝到内核空间。​

使用epoll函数时,通过epoll_create创建一个epoll实例,调用epoll_ctl添加你要监听的Socket FD,由内核来帮助我们管理FD集合,调用epoll_wait时就无需再拷贝一次FD了。​

epoll的另一个改进就是:无需每次都遍历所有Socket FD。调用epoll_ctl添加FD时,会为每个FD指定一个回调函数,当FD准备就绪被唤醒时会触发该回调函数,它会将当前FD加入到一个「就绪链表」中,epoll_wait其实就是查看这个就绪链表中是否有FD。

epoll的两种触发模式

  1. 水平触发(LT):epoll_wait检测到事件后会通知应用程序,应用程序可以不处理,下次会继续通知。
  2. 边缘触发(ET):epoll_wait检测到事件后会通知应用程序,应用程序必须处理,下次不会再通知。

作为Java程序员,你可以不关心上面所述的三种实现,只需要关心Selector接口就行了,Java中的Selector接口就是多路复用器的一个抽象表示。​

如下是一个多路复用器版本的EchoServer:

public class MultiplexingIOEchoServer {

	public static void main(String[] args) throws IOException {
		ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
		serverSocketChannel.bind(new InetSocketAddress(9999)).configureBlocking(false);
		Selector selector = Selector.open();
		// 订阅ACCEPT事件
		serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
		while (true) {
			// 等待准备就绪的Channel
			if (selector.select() > 0) {
				Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
				while (iterator.hasNext()) {
					SelectionKey key = iterator.next();
					iterator.remove();
					if (key.isAcceptable()) {// 处理新的连接
						SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
						sc.configureBlocking(false);
						sc.register(selector, SelectionKey.OP_READ);
					} else if (key.isReadable()) {// 有数据可读
						SocketChannel channel = (SocketChannel) key.channel();
						ByteBuffer buffer = ByteBuffer.allocate(1024);
						if (channel.read(buffer) > -1) {
							buffer.flip();
							channel.write(buffer);
						} else {
							channel.close();
						}
					}
				}
			}
		}
	}
}
复制代码

调用Selector.select()就可以看作是调用了select()epoll()epoll_wait(),没有准备就绪的Channel时,该方法会阻塞,所以你大可放心在while(true)中调用。​

当有准备就绪的Channel时,Selector会将Channel封装成SelectionKeySelector.selectedKeys()方法会返回Key的HashSet容器,通过遍历这些Key来遍历准备就绪的所有Channel,通过SelectionKey.readyOps()来获取Channel的事件类型。​

Selector多路复用器解决了Nio的问题,即使是面对一万的客户端连接,只需一次系统调用即可知道准备就绪的连接。 它还解决了Nio轮询间隔时间不好设置的问题,有事件就处理事件,没事件就阻塞在系统调用上等待事件。 如果是epoll,还避免了每次轮询都要将FD集合从用户空间拷贝到内核空间的额外开销,进一步提升系统性能。

原创文章,作者:睿达君,如若转载,请注明出处:https://zrrd.net.cn/1649.html

发表回复

登录后才能评论
咨询电话
联系电话:0451-81320577

地址:哈尔滨市松北区中小企业总部基地13F

微信咨询
微信咨询
QQ咨询
分享本页
返回顶部