阿里毕玄-测试Java编程能力-我的回答(一)

毕玄老师发表了一篇公众号文章:来测试下你的Java编程能力,本系列文章为其中问题的个人解答。

第一个问题:

基于BIO实现的Server端,当建立了100个连接时,会有多少个线程?如果基于NIO,又会是多少个线程? 为什么?

说实话,如果面试被问到这个问题,也不敢保证能完全答对。那么就回炉重造一下吧。

最简单的BIO Server

服务端
package com.xetlab.javatest.question1;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;

public class ServerMain1 {

    private static final Logger logger = LoggerFactory.getLogger(ServerMain1.class);

    public static void main(String[] args) {
        logger.info("0.主线程启动");
        try {
            //服务端初始化,在9999端口监听
            ServerSocket serverSocket = new ServerSocket(9999);
            while (true) {
                //等待客户端连接,如果没有连接就阻塞当前线程
                Socket clientSocket = serverSocket.accept();
                logger.info("1.客户端 {}:{} 已连接", clientSocket.getInetAddress().getHostAddress(), clientSocket.getPort());

                //向客户端发消息
                logger.info("2.向客户端发欢迎消息");
                clientSocket.getOutputStream().write("你好,请报上名来!".getBytes("UTF8"));
                clientSocket.getOutputStream().flush();

                //从客户端读取消息
                StringBuffer msgBuf = new StringBuffer();
                byte[] byteBuf = new byte[1024];
                clientSocket.getInputStream().read(byteBuf);
                msgBuf.append(new String(byteBuf, "UTF8"));
                logger.info("5.收到客户端消息:{}", msgBuf);

                //向客户端发消息
                logger.info("6.向客户端发退出消息");
                clientSocket.getOutputStream().write(String.format("退下,%s!", msgBuf.toString()).getBytes(Charset.forName("UTF8")));
                clientSocket.getOutputStream().flush();
            }
        } catch (IOException e) {
            logger.error("server error", e);
            System.exit(1);
        }
    }
}

客户端
package com.xetlab.javatest.question1;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.Socket;

public class ClientMain1 {

    private static final Logger logger = LoggerFactory.getLogger(ClientMain1.class);

    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1", 9999);
            while (true) {
                StringBuffer msgBuf = new StringBuffer();
                byte[] byteBuf = new byte[1024];
                socket.getInputStream().read(byteBuf);
                msgBuf.append(new String(byteBuf, "UTF8"));
                logger.info("3.收到服务端消息:{}", msgBuf);

                logger.info("4.向服务端发送名字消息");
                socket.getOutputStream().write("Mr Nobody.".getBytes("UTF8"));
                socket.getOutputStream().flush();

                msgBuf = new StringBuffer();
                byteBuf = new byte[1024];
                socket.getInputStream().read(byteBuf);
                msgBuf.append(new String(byteBuf, "UTF8"));
                logger.info("7.收到服务端消息:{}", msgBuf);
                if (msgBuf.toString().startsWith("退下")) {
                    socket.close();
                    logger.info("8.客户端退出");
                    break;
                }
            }
        } catch (IOException e) {
            logger.error("client error", e);
            System.exit(1);
        }
    }
}

对应的输出(已按顺序组织)
2019-03-23 23:36:39,480 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主线程启动
2019-03-23 23:36:44,883 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 1.客户端 127.0.0.1:7473 已连接
2019-03-23 23:36:44,884 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 2.向客户端发欢迎消息
2019-03-23 23:36:44,888 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服务端消息:你好,请报上名来!  
2019-03-23 23:36:44,891 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 4.向服务端发送名字消息
2019-03-23 23:36:44,891 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 5.收到客户端消息:Mr Nobody.                     
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 6.向客户端发退出消息
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 7.收到服务端消息:退下,Mr Nobody.                
2019-03-23 23:36:44,892 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 8.客户端退出

如果我们按上面的方式实现Server端,答案会是:BIO Server端,一个线程就够了。我们来分析下这种实现方式的优缺点。

优点
  1. 简单,适合java socket编程入门。
  2. 好像只有简单了。
缺点
  1. 一次只能服务一个客户端,别的客户端只能等待,具体表现是:如果同时启动两个慢客户端,那么两个客户端的底层TCP连接是建立好的,先启动的客户端会先得到服务,但后启动的那个客户端会在读取数据时一直被阻塞,如下所示(windows):

    netstat -ano|find "9999"

     TCP    127.0.0.1:9999         127.0.0.1:29712        ESTABLISHED     16996
     TCP    127.0.0.1:9999         127.0.0.1:29740        ESTABLISHED     16996
    

    服务端输出

    2019-03-24 10:47:48,881 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主线程启动
    2019-03-24 10:47:52,549 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 1.客户端 127.0.0.1:29712 已连接
    2019-03-24 10:47:52,550 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 2.向客户端发欢迎消息
    

    客户端1收到消息后,休眠

    2019-03-24 10:47:52,555 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服务端消息:你好,请报上名来!
    

    客户端2

    //客户端2在此处被阻塞
    socket.getInputStream().read(byteBuf);
    
  1. 实现不了同时服务100个客户端。

因此这种方式实现的Server端,只能用于入门示例,不能用于生产环境。另外BIO全称是Blocking IO,即阻塞式IO,这个BIO体现在哪呢?体现在这两处:

//1.当客户端没发消息过来时,此时服务端读取消息时就会阻塞
//2.当读取的数据较多时,线程没有阻塞,但是读取数据的耗时会挺久
clientSocket.getInputStream().read(bytes);
//当给客户端发送的数据较多时,这里线程没有阻塞,但是写数据的耗时会挺久
clientSocket.getOutputStream().write(bytes);
Tips
  1. BIO其实包含两层含义:读取时数据未准备好,当前线程会阻塞;数据的读写是耗时的操作。
  2. server和client之间的通信通过socket的InputStream和OutputStream进行。
  3. server和client之间的通信需要预先定义好通信协议(如示例中就隐含了一个规定,大家每次发送的消息不超过1024个字节,读取时也是读取最多1024个字节,如果违反了这个规定,要吗数据乱了,要吗server或client在读取数据时被阻塞)。
  4. 写数据时要记得flush一下,不然数据只是写到缓存里,并没有发送出去。

引入多线程

服务端
package com.xetlab.javatest.question1;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;

public class ServerMain2 {

    private static final Logger logger = LoggerFactory.getLogger(ServerMain2.class);

    public static void main(String[] args) {
        logger.info("0.主线程启动");
        try {
            //服务端初始化,在9999端口监听
            ServerSocket serverSocket = new ServerSocket(9999);
            while (true) {
                //等待客户端连接,如果没有连接就阻塞当前线程
                Socket clientSocket = serverSocket.accept();
                String clientId = String.format("%s:%s", clientSocket.getInetAddress().getHostAddress(), clientSocket.getPort());
                logger.info("1.客户端 {} 已连接", clientId);

                new Thread(new Handler(clientSocket), clientId).start();
            }
        } catch (IOException e) {
            logger.error("server error", e);
            System.exit(1);
        }
    }

    static class Handler implements Runnable {
        private Socket clientSocket;

        public Handler(Socket clientSocket) {
            this.clientSocket = clientSocket;
        }

        public void run() {
            try {
                //向客户端发消息
                logger.info("2.向客户端发欢迎消息");
                clientSocket.getOutputStream().write("你好,请报上名来!".getBytes("UTF8"));
                clientSocket.getOutputStream().flush();

                //从客户端读取消息
                StringBuffer msgBuf = new StringBuffer();
                byte[] byteBuf = new byte[1024];
                clientSocket.getInputStream().read(byteBuf);
                msgBuf.append(new String(byteBuf, "UTF8"));
                logger.info("5.收到客户端消息:{}", msgBuf);

                //向客户端发消息
                logger.info("6.向客户端发退出消息");
                clientSocket.getOutputStream().write(String.format("退下,%s!", msgBuf.toString()).getBytes(Charset.forName("UTF8")));
                clientSocket.getOutputStream().flush();
            } catch (IOException e) {
                logger.error("io error", e);
            }
        }
    }
}

输出

客户端保持不变,只是把其中一个在回复名字前故意休眠很久,另一个保持正常。此时各端的输出如下:

服务端

2019-03-24 12:50:56,514 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 0.主线程启动
2019-03-24 12:51:02,613 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 1.客户端 127.0.0.1:44334 已连接
2019-03-24 12:51:02,613 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44334] - 2.向客户端发欢迎消息
2019-03-24 12:51:08,331 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 1.客户端 127.0.0.1:44347 已连接
2019-03-24 12:51:08,331 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 2.向客户端发欢迎消息
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 5.收到客户端消息:Mr Nobody.                                   
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ServerMain2 [127.0.0.1:44347] - 6.向客户端发退出消息

慢客户端先连接,收到消息后,休眠

2019-03-24 12:51:02,619 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服务端消息:你好,请报上名来! 

正常客户端后连接

2019-03-24 12:51:08,336 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 3.收到服务端消息:你好,请报上名来!  
2019-03-24 12:51:08,338 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 4.向服务端发送名字消息
2019-03-24 12:51:08,339 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 7.收到服务端消息:退下,Mr Nobody.                   
2019-03-24 12:51:08,340 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 8.客户端退出

可以看到,引入多线程后,每个线程服务一个客户端,可以同时服务100个连接了,如果这样实现Server端,IO还是BIO,线程数需要101个,一个线程用于接受客户端连接,100个线程用于服务客户端。同样来分析下优缺点。

优点
  1. 简单,和最简单版本相比,只是把和客户端IO相关的处理放到了线程里处理。
  2. 可以同时服务N个连接。
缺点
  1. 每个线程都要占用内存,当客户端保持长连接,数量越来越多达到一定值时,就会出现错误:OutOfMemoryError:unable to create new native thread。
  2. 一个客户端分配一个线程,太浪费资源了,因为BIO的缘故,线程大部分时间都处于阻塞或等待读写状态。
  3. 即使机器性能高,内存大,当线程很多时,线程上下文切换也会带来很大的开销。
Tips

编写多线程任务时,可以把执行任务的逻辑使用Runnable接口来实现,这样任务可以直接放到Thread线程对象里执行,也可以提交到线程池中去执行。

NIO上场

有没有可能同时具备方式一和二的优点呢,具体来说就是,一个线程同时服务N个客户端?Yes,NIO就可以!那什么是NIO?NIO即New IO,更多时候我们是看成Non blocking IO,就是非阻塞IO。

具体NIO如何实现一个线程服务N个客户端,在深入代码细节前,我们先理一理。

回顾上面的BIO实现,我们知道有这几个点会阻塞或者响应慢:

  1. serverSocket.accept(),这里是服务端等待客户端连接。
  2. clientSocket.getInputStream().read(),这里是等待客户端传送数据过来。
  3. clientSocket.getOutputStream().write(),这里是往客户端写数据。

由于会阻塞或者响应慢BIO用了不同的线程去分别处理,如果可以只由一个线程去负责检查是否有客户端连接,客户端的数据是否可读,是否可以往客户端写数据,当有对应的事件已经准备好时,再由于当前线程去处理相应的任务,那就完美了。

NIO里有个对象是Selector,这个Selector就是用于注册事件,并检查事件是否已准备好。现在来看下具体代码。

package com.xetlab.javatest.question1;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
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.util.Iterator;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;

public class ServerMain3 {

    private static final Logger logger = LoggerFactory.getLogger(ServerMain3.class);

    public static void main(String[] args) {
        logger.info("0.主线程启动");
        try {

            Map<SocketChannel, Queue> msgQueueMap = new ConcurrentHashMap<SocketChannel, Queue>();

            //创建channel管理器,用于注册channel的事件
            Selector selector = Selector.open();

            //服务端初始化,在9999端口监听,保留BIO初始化方式用于参照
            //ServerSocket serverSocket = new ServerSocket(9999);
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            //设置非阻塞
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.socket().bind(new InetSocketAddress(9999));

            //注册可accept事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                //NIO仅有的一个阻塞方法,当有注册的事件产生时,才会返回
                selector.select();
                //产生事件的事件源列表
                Set<SelectionKey> readyKeys = selector.selectedKeys();
                Iterator<SelectionKey> keyItr = readyKeys.iterator();
                while (keyItr.hasNext()) {
                    SelectionKey readyKey = keyItr.next();
                    keyItr.remove();
                    if (readyKey.isAcceptable()) {
                        ServerSocketChannel serverChannel = (ServerSocketChannel) readyKey.channel();
                        //接受客户端
                        SocketChannel clientChannel = serverChannel.accept();
                        String clientId = String.format("%s:%s", clientChannel.socket().getInetAddress().getHostAddress(), clientChannel.socket().getPort());
                        logger.info("1.客户端 {} 已连接", clientId);

                        msgQueueMap.put(clientChannel, new ArrayBlockingQueue(100));
                        logger.info("2.向客户端发欢迎消息");
                        //NIO发消息先放到消息队列里,等可写时再发
                        msgQueueMap.get(clientChannel).add("你好,请报上名来!");
                        
                        //设置非阻塞
                        clientChannel.configureBlocking(false);
                        //注册可读和可写事件
                        clientChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
                    } else if (readyKey.isReadable()) {
                        SocketChannel clientChannel = (SocketChannel) readyKey.channel();
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        int bytesRead = clientChannel.read(byteBuffer);
                        if (bytesRead <= 0) {
                            continue;
                        }
                        byteBuffer.flip();
                        byte[] msgByte = new byte[bytesRead];
                        byteBuffer.get(msgByte);
                        final String clientName = new String(msgByte, "UTF8");
                        logger.info("5.收到客户端消息:{}", clientName);
                        msgQueueMap.get(clientChannel).add(String.format("退下!%s", clientName));
                    } else if (readyKey.isWritable()) {
                        SocketChannel clientChannel = (SocketChannel) readyKey.channel();
                        Queue<String> msgQueue = msgQueueMap.get(clientChannel);
                        String msg = msgQueue.poll();
                        if (msg != null) {
                            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                            byteBuffer.put(msg.getBytes("UTF8"));
                            byteBuffer.flip();
                            clientChannel.write(byteBuffer);
                            logger.info("6.向客户端发退出消息");
                        }
                    }
                }

            }
        } catch (IOException e) {
            logger.error("server error", e);
            System.exit(1);
        }
    }

}

上面我们用NIO实现了和原来BIO一模一样的逻辑,NIO确实是只用一个线程高效的解决了问题,但是代码看起来复杂多了。不过我们用伪代码总结一下,会简单一点:

  1. 准备好Selector(源代码注释中叫channel多路复用器)。
  2. 准备好ServerSocketChannel(对应BIO里的ServerSocket)。
  3. ServerSocketChannel向Selector注册accept事件(即客户端连接就绪事件)
  4. 循环
    • 检查Selector是否有新的就绪事件,如果没有就阻塞等待,如果有就返回产生的就绪事件列表。
    • 如果是accept事件(客户端连接就绪事件),就接受客户端连接得到SocketChannel(对应BIO中的Socket),SocketChannel向Selector注册读写就绪事件。
    • 如果是读就绪事件,那么读取对应SocketChannel的数据,并进行相应的处理。
    • 如果是写就绪事件,那么就把数据写到对应的SocketChannel。
Tips

NIO中,由于是单线程,不能在连接就绪,读写就绪之后的事件处理逻辑执行耗时操作,那样将会让服务性能急剧下降,正确方法应该是把耗时的逻辑放在独立的线程中去执行,或放到专门的worker线程池中执行。

源代码

https://github.com/huangyemin/javatest
https://gitee.com/huangyemin/javatest
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 在一个方法内部定义的变量都存储在栈中,当这个函数运行结束后,其对应的栈就会被回收,此时,在其方法体中定义的变量将不...
    Y了个J阅读 4,412评论 1 14
  • NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也...
    Demon_code阅读 386评论 0 0
  • NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也...
    闪电是只猫阅读 3,082评论 0 7
  • 1. 前言 有一些概念总是Java I/O一块出现,比如同步与异步,阻塞与非阻塞,这些概念往往也是非常难以区分。在...
    WekingZhang阅读 529评论 0 2
  • 前言:在之前的面试中,每每问到关于Java I/O 方面的东西都感觉自己吃了大亏..所以这里抢救一下..来深入的了...
    我没有三颗心脏阅读 2,481评论 0 21