同步阻塞BIO与同步非阻塞NIO

本文深入探讨了BIO(阻塞I/O)和NIO(非阻塞I/O)的工作原理,通过对比分析,揭示了它们在处理大量并发连接时的优缺点。通过具体的代码示例,展示了BIO每连接每线程模型和NIO多路复用模型的实现,以及它们在实际场景中的性能表现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

//模拟服务器端
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;

public class BioServer {

    public static AtomicInteger counter = new AtomicInteger(0);
    public static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");

    public static void main(String[] args) {
        try {
            ServerSocket ss = new ServerSocket();
            ss.bind(new InetSocketAddress("localhost", 8888));
            while (true) {
                Socket s = ss.accept();  //阻塞点,等待客户端连接
                processWithNewThread(s);  //为每一个客户端创建一个线程
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

   public static void processWithNewThread(Socket s) {
        //lambada表达式写法
        Runnable run = () -> {
            InetSocketAddress rsa = (InetSocketAddress)s.getRemoteSocketAddress();
            System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + counter.incrementAndGet());
            try {
                String result = readBytes(s.getInputStream());   //阻塞点,等待读/写数据完成
                System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.getAndDecrement());
                s.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        };
        new Thread(run).start();
    }


   public static String readBytes(InputStream is) throws Exception {
        long start = 0;
        int total = 0;
        int count = 0;
        byte[] bytes = new byte[1024];
        //开始读数据的时间
        long begin = System.currentTimeMillis();
        while ((count = is.read(bytes)) > -1) {
            if (start < 1) {
                //第一次读到数据的时间
                start = System.currentTimeMillis();
            }
            total += count;
        }
        //读完数据的时间
        long end = System.currentTimeMillis();
        return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
    }

   public static String time() {
        return sdf.format(new Date());
    }
}

传统BIO

这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。其实这也是所有使用多线程的本质:

  • 利用多核。
  • 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。

现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。

不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很"贵"的资源,主要表现在:

  1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
  2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
  4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
//模拟客户端
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.Random;

public class Client {

    public static void main(String[] args) {
        try {
            for (int i = 0; i < 20; i++) {
                Socket s = new Socket();
                s.connect(new InetSocketAddress("localhost", 8888));
                processWithNewThread(s, i);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static void processWithNewThread(Socket s, int i) {
        Runnable run = () -> {
            try {
                //睡眠随机的5-10秒,模拟数据尚未就绪(阻塞等待)
                Thread.sleep((new Random().nextInt(6) + 5) * 1000);
                //写1M数据,为了拉长服务器端读数据的过程
                s.getOutputStream().write(prepareBytes());
                //睡眠1秒,让服务器端把数据读完
                //Thread.sleep(1000);  相当于模拟数据量大的情况下->可能要等1s
                s.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        };
        new Thread(run).start();
    }

    //模拟写1M数据
    static byte[] prepareBytes() {
        byte[] bytes = new byte[1024*1024];   //1048576bs
        //byte[] bytes = new byte[1024*1024*100];   //104857600bs
        for (int i = 0; i < bytes.length; i++) {
            bytes[i] = 1;
        }
        return bytes;
    }
}

BIO的运行结果:

20:00:15->127.0.0.1:50374->10:1
20:00:15->127.0.0.1:50383->18:3
20:00:15->127.0.0.1:50385->20:2
20:00:15->127.0.0.1:50377->12:4
20:00:15->127.0.0.1:50379->14:5
20:00:15->127.0.0.1:50381->16:6
20:00:15->127.0.0.1:50393->28:7
20:00:15->127.0.0.1:50391->26:8
20:00:15->127.0.0.1:50389->24:9
20:00:15->127.0.0.1:50387->22:10
20:00:15->127.0.0.1:50376->11:11
20:00:15->127.0.0.1:50378->13:12
20:00:15->127.0.0.1:50380->15:13
20:00:15->127.0.0.1:50382->17:14
20:00:15->127.0.0.1:50384->19:15
20:00:15->127.0.0.1:50386->21:16
20:00:15->127.0.0.1:50388->23:17
20:00:15->127.0.0.1:50390->25:18
20:00:15->127.0.0.1:50392->27:19
20:00:15->127.0.0.1:50394->29:20
20:00:21->wait=5014ms,read=1062ms,total=1048576bs->21:20
20:00:21->wait=5017ms,read=1059ms,total=1048576bs->25:19
20:00:22->wait=5965ms,read=1014ms,total=1048576bs->11:18
20:00:22->wait=5985ms,read=1012ms,total=1048576bs->19:17
20:00:23->wait=6978ms,read=1044ms,total=1048576bs->24:16
20:00:23->wait=6979ms,read=1046ms,total=1048576bs->28:15
20:00:23->wait=6979ms,read=1046ms,total=1048576bs->26:14
20:00:23->wait=6990ms,read=1042ms,total=1048576bs->13:13
20:00:23->wait=6988ms,read=1042ms,total=1048576bs->15:12
20:00:23->wait=6998ms,read=1034ms,total=1048576bs->17:11
20:00:23->wait=6998ms,read=1033ms,total=1048576bs->29:10
20:00:24->wait=7988ms,read=1024ms,total=1048576bs->18:9
20:00:24->wait=7974ms,read=1039ms,total=1048576bs->12:8
20:00:24->wait=7996ms,read=1015ms,total=1048576bs->27:7
20:00:25->wait=8974ms,read=1044ms,total=1048576bs->10:6
20:00:25->wait=8976ms,read=1042ms,total=1048576bs->16:5
20:00:25->wait=8990ms,read=1027ms,total=1048576bs->22:4
20:00:25->wait=8981ms,read=1039ms,total=1048576bs->20:3
20:00:26->wait=9973ms,read=1021ms,total=1048576bs->14:2
20:00:26->wait=9988ms,read=1008ms,total=1048576bs->23:1
NIO

NIO(Non-blocking I/O,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的。

//等待时间几乎为0,而服务器端的线程数明显下降,节约资源
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

public class NioServer {

    static int clientCount = 0;
    static AtomicInteger counter = new AtomicInteger(0);
    static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
	/**
     * 两种感兴趣的操作共用一个选择器,且选择器运行在主线程里,Worker(读取数据)线程是新的线程。
     * @param args
     */
    public static void main(String[] args) {
        try {
            Selector selector = Selector.open();
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false);  //设置非阻塞方式
            ssc.register(selector, SelectionKey.OP_ACCEPT); //注册客户端(连接)事件
            ssc.bind(new InetSocketAddress("localhost", 8888));  //(绑定)
            while (true) {
                selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = keys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();
                    if (key.isAcceptable()) {
                        ServerSocketChannel ssc1 = (ServerSocketChannel)key.channel();
                        SocketChannel sc = null;
                        while ((sc = ssc1.accept()) != null) {
                            sc.configureBlocking(false);
                            sc.register(selector, SelectionKey.OP_READ);
                            InetSocketAddress rsa = (InetSocketAddress)sc.socket().getRemoteSocketAddress();
                            System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + (++clientCount));
                        }
                    } else if (key.isReadable()) {
                        //先将“读”从感兴趣操作移出,待把数据从通道中读完后,再把“读”添加到感兴趣操作中
                        //否则,该通道会一直被选出来
                        key.interestOps(key.interestOps() & (~ SelectionKey.OP_READ));
                        processWithNewThread((SocketChannel)key.channel(), key);
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void processWithNewThread(SocketChannel sc, SelectionKey key) {
        Runnable run = () -> {
            counter.incrementAndGet();
            try {
                String result = readBytes(sc);
                //把“读”加进去
                key.interestOps(key.interestOps() | SelectionKey.OP_READ);
                System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.get());
                sc.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
            counter.decrementAndGet();
        };
        new Thread(run).start();
    }

    static String readBytes(SocketChannel sc) throws Exception {
        long start = 0;
        int total = 0;
        int count = 0;
        ByteBuffer bb = ByteBuffer.allocate(1024);
        //开始读数据的时间
        long begin = System.currentTimeMillis();
        while ((count = sc.read(bb)) > -1) {
            if (start < 1) {
                //第一次读到数据的时间
                start = System.currentTimeMillis();
            }
            total += count;
            bb.clear();
        }
        //读完数据的时间
        long end = System.currentTimeMillis();
        return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
    }

    static String time() {
        return sdf.format(new Date());
    }
}

NIO效果:

20:07:21->wait=2ms,read=1113ms,total=1048576bs->11:5
20:07:22->wait=3ms,read=1039ms,total=1048576bs->13:5
20:07:22->wait=38ms,read=1099ms,total=1048576bs->12:7
20:07:22->wait=0ms,read=1016ms,total=1048576bs->16:7
20:07:22->wait=0ms,read=1025ms,total=1048576bs->14:5
20:07:22->wait=11ms,read=1087ms,total=1048576bs->17:4
20:07:23->wait=11ms,read=1073ms,total=1048576bs->19:7
20:07:23->wait=0ms,read=1049ms,total=1048576bs->18:6
20:07:23->wait=0ms,read=1106ms,total=1048576bs->20:5
20:07:24->wait=1ms,read=1011ms,total=1048576bs->22:4
20:07:24->wait=0ms,read=1037ms,total=1048576bs->21:4
20:07:24->wait=0ms,read=1025ms,total=1048576bs->23:3
20:07:24->wait=0ms,read=1030ms,total=1048576bs->24:2
20:07:25->wait=0ms,read=1005ms,total=1048576bs->25:1

它的大致处理过程如下:

1、定义一个选择器,Selector。

相当于设立一个跑腿服务员。

2、定义一个服务器端套接字通道,ServerSocketChannel,并配置为非阻塞的。

相等于聘请了一位大堂经理。

3、将套接字通道注册到选择器上,并把感兴趣的操作设置为OP_ACCEPT。

相当于大堂经理给跑腿服务员说,帮我盯着门外(轮训),有客人来了告诉我。

4、进入死循环,选择器不时的进行选择。

相当于跑腿服务员一遍又一遍的去询问、去转悠。

5、选择器终于选择出了通道,发现通道是需要Acceptable的。

相当于跑腿服务员终于发现门外来客人了,客人是需要接待的。

6、于是服务器端套接字接受了这个通道,开始处理。

相当于跑腿服务员把大堂经理叫来了,大堂经理开始着手接待。

7、把新接受的通道配置为非阻塞的,并把它也注册到了选择器上,该通道感兴趣的操作为OP_READ。

相当于大堂经理把客人带到座位上,给了客人菜单,并又把客人委托给跑腿服务员,说客人接下来肯定是要点餐的,你不时的来问问。

8、选择器继续不时的进行选择着。

相当于跑腿服务员继续不时的询问着、转悠着。

9、选择器终于又选择出了通道,这次发现通道是需要Readable的。

相当于跑腿服务员终于发现了一桌客人有了需求,是需要点餐的。

10、把这个通道交给了一个新的工作线程去处理。

相当于跑腿服务员叫来了点餐服务员,点餐服务员开始为客人写菜单。

11、这个工作线程处理完后,就被回收了,可以再去处理其它通道。

相当于点餐服务员写好菜单后,就走了,可以再去为其他客人写菜单。

12、选择器继续着重复的选择工作,不知道什么时候是个头。

相当于跑腿服务员继续着重复的询问、转悠,不知道未来在何方。

相信你已经看出来了,大堂经理相当于服务器端套接字,跑腿服务员相当于选择器,点餐服务员相当于Worker线程。

  • BIO是阻塞IO,可以是同步阻塞,也可以是异步阻塞。

  • 第一种是阻塞IO,阻塞点有两个,等待数据就绪的过程和读取数据的过程。

  • 第二种是阻塞IO,阻塞点有一个,读取数据的过程。

  1. 只有一个线程,接受一个连接,读取数据,处理业务,写回结果,再接受下一个连接,这是同步阻塞。这种用法几乎没有。

  2. 一个线程和一个线程池,线程接受到连接后,把它丢给线程池中的线程,再接受下一个连接,这是异步阻塞。对应示例一。

  3. 一个线程和一个线程池,线程运行selector,执行select操作,把就绪的连接拿出来丢给线程池中的线程,再执行下一次的select操作,就是多路复用,这是异步阻塞。对应示例二。

NIO高级主题
  • Proactor与Reactor
  1. 标准/典型的Reactor:
    步骤1:等待事件到来(Reactor负责)。
    步骤2:将读就绪事件分发给用户定义的处理器(Reactor负责)。
    步骤3:读数据(用户处理器负责)。
    步骤4:处理数据(用户处理器负责)。

  2. 改进实现的模拟Proactor:
    步骤1:等待事件到来(Proactor负责)。
    步骤2:得到读就绪事件,执行读数据(现在由Proactor负责)。
    步骤3:将读完成事件分发给用户处理器(Proactor负责)。
    步骤4:处理数据(用户处理器负责)。

对于不提供异步I/O API的操作系统来说,这种办法可以隐藏Socket API的交互细节,从而对外暴露一个完整的异步接口。借此,我们就可以进一步构建完全可移植的,平台无关的,有通用对外接口的解决方案。

  • Buffer的选择
    通常情况下,操作系统的一次写操作分为两步:
  1. 将数据从用户空间拷贝到系统空间。
  2. 从系统空间往网卡写。

同理,读操作也分为两步:

  1. 将数据从网卡拷贝到系统空间;
  2. 将数据从系统空间拷贝到用户空间。

对于NIO来说,缓存的使用可以使用DirectByteBuffer和HeapByteBuffer。如果使用了DirectByteBuffer,一般来说可以减少一次系统空间到用户空间的拷贝。但Buffer创建和销毁的成本更高,更不宜维护,通常会用内存池来提高性能。

如果数据量比较小的中小应用情况下,可以考虑使用heapBuffer;反之可以用directBuffer。

NIO存在的问题

使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。

NIO并没有完全屏蔽平台差异,它仍然是基于各个操作系统的I/O系统实现的,差异仍然存在。使用NIO做网络编程构建事件驱动模型并不容易,陷阱重重。

推荐使用成熟的NIO框架,如Netty,MINA等。解决了很多NIO的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值