java版本:jdk1.8

IDE:idea 18

IO的意思是Input和Output,即输入输出,我们通常所说的IO一般包括文件IO和网络IO。文件IO就是对于电脑文件的读取和写入,网络IO就是对网络数据的读取和写入,也就是网络通信。我们需要关注的是网络IO。

在网络通信上,java目前包含三种io模型:

BIO:blocking i/o,阻塞io模型,是jdk1.4以前的io模型。

NIO:non-blocking i/o或者new i/o,同步非阻塞io,是jdk1.4以后的io模型。

AIO:Asynchronous i/o,异步非阻塞io,是jdk1.7之后的io模型。

具体的原理,我们通过代码来理解。

首先是BIO编程,写一个服务端的java小程序,代码如下:

public class BIOServer
{
    ServerSocket server;
    public BIOServer(int port) throws Exception
    {
            server=new ServerSocket(port);
            System.out.println("BIO服务启动");
    }

    public void  listener() throws Exception
    {
        while (true)
        {
            Socket client=server.accept();
            InputStream is=client.getInputStream();
            byte[] buff=new byte[1024];
            int len=is.read(buff);
            if (len>0)
            {
                String msg=new String(buff,0,len);
                System.out.println(msg);
            }
        }
    }

    public static void  main(String[] args)
    {
        try {
            new BIOServer(8080).listener();
        }
        catch (Exception ex)
        {
            System.out.println(ex.toString());
        }
    }
}

这段是通过ServerSocket 监听来自8080端口发来的数据。收到信息后打印到控制台。这段代码是阻塞的,也就是while循环那里,当执行Socket client=server.accept();这段代码后,线程就会阻塞,直到收到消息,才会继续执行。执行完之后,继续下一次循环。

客户端的代码如下:

public class BIOClient {

    public static void  main(String[] args)
    {
        try {
            Socket client=new Socket("localhost",8080);
            OutputStream os=client.getOutputStream();
            os.write("测试".getBytes());
            os.close();
            client.close();
        }
        catch (Exception ex)
        {
            System.out.println(ex.toString());
        }
    }
}

先启动服务端,然后启动客户端,客户端向服务端发送一段文字“测试”,服务端接收到信息后输出到控制台。

这段代码是单客户端,现在我们在两台机器上,同时启动这两个客户端,其中一个客户端在发送消息前使用sleep休眠20s来模仿大数据下通道被占用的情况:

public class BIOClient {

    public static void main(String[] args) {
        try {
            Socket client = new Socket("localhost", 8080);
            OutputStream os = client.getOutputStream();
            Thread.sleep(20000);
            os.write(("测试").getBytes());
            os.close();
            client.close();
        } catch (Exception ex) {
            System.out.println(ex.getStackTrace());
        } }
}

你会发现,第二个客户端要等20s之后消息才能发过来。这样显然不合理,我们把服务端改造一下,利用多线程来接收消息:

public class BIOServer
{
    ServerSocket server;
    public BIOServer(int port) throws Exception
    {
            server=new ServerSocket(port);
            System.out.println("BIO服务启动");
    }

    public void  listener() throws Exception
    {
        while (true)
        {
            Socket client=server.accept();
            new Thread(new MsgHandle(client)).start();

        }
    }
    class MsgHandle implements Runnable
    {
        private Socket client;
        public MsgHandle(Socket client)
        {
            this.client=client;
        }

        @Override
        public void run() {
            try {
                InputStream is=client.getInputStream();
                byte[] buff=new byte[1024];
                int len=is.read(buff);
                if (len>0)
                {
                    String msg=new String(buff,0,len);
                    System.out.println(msg);
                }
            }
            catch (Exception ex)
            {
                System.out.println(ex.getStackTrace());
            }
        }
    }

    public static void  main(String[] args)
    {
        try {
            new BIOServer(8080).listener();
        }
        catch (Exception ex)
        {
            System.out.println(ex.toString());
        }
    }
}

这样的话,我们的第二台机器发来的消息就不会被阻塞了,但是现在有问题了,虽然不阻塞了,可是每收到一条消息就要开一个线程,这对于服务器来说压力太大了。为了解决这种一个客户端一个线程的问题,我们这里采用线程池来降低线程的的数量:

public class BIOServer
{
    ServerSocket server;
    private static ExecutorService executorService= Executors.newFixedThreadPool(10);
    public BIOServer(int port) throws Exception
    {
            server=new ServerSocket(port);
            System.out.println("BIO服务启动");
    }

    public void  listener() throws Exception
    {
        while (true)
        {
            Socket client=server.accept();
            executorService.execute(new MsgHandle(client));
        }
    }
    class MsgHandle implements Runnable
    {
        private Socket client;
        public MsgHandle(Socket client)
        {
            this.client=client;
        }

        @Override
        public void run() {
            try {
                InputStream is=client.getInputStream();
                byte[] buff=new byte[1024];
                int len=is.read(buff);
                if (len>0)
                {
                    String msg=new String(buff,0,len);
                    System.out.println(msg);
                }
            }
            catch (Exception ex)
            {
                System.out.println(ex.getStackTrace());
            }
        }
    }
    
    public static void  main(String[] args)
    {
        try {
            new BIOServer(8080).listener();
        }
        catch (Exception ex)
        {
            System.out.println(ex.toString());
        }
    }
}

加入线程池之后,可以在一定程序上解决线程滥用的问题,是一种伪异步的解决方案,这里面定的线程池最大线程是10,当同一时间来的消息数据大于10时,没有空闲线程,则排队等待。当然你可以使用CachedThreadPool来不限制最大线程数。

上面说的伪异步,还有一些优化空间,这里不再赘述,下面说一下NIO,看它是怎么解决这个问题的。先通过代码说一下nio的工作步骤,客户端的步骤和服务端的差不多。后面会贴上完整的代码。

1)服务端打开通道:

serverSocketChannel=ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(this.port));

selector=Selector.open();

serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

System.out.println("服务器就绪!");

2)服务端开启监听:

private void listen() throws IOException
{
    while (true)
    {
        int i=selector.select();
        if (i==0) continue;
        Set<SelectionKey> keys=selector.selectedKeys();
        Iterator<SelectionKey> iterator=keys.iterator();
        while (iterator.hasNext())
        {
            handle(iterator.next());
            iterator.remove();
        }
    }
}

int i=selector.select();是阻塞的,一旦客户端有新的消息进来,就会进入到下面的代码,通过selector.selectedKeys();获取到所有的连接的key,根据key的状态不同做不同的处理。

3)根据key的状态做不同的处理:

private void handle(SelectionKey key) throws IOException
    {
        if (key.isAcceptable())
        {
            SocketChannel client=serverSocketChannel.accept();
            client.configureBlocking(false);
            client.register(selector,SelectionKey.OP_READ);
        }
        else if (key.isReadable())
        {
            buffer.clear();
            SocketChannel client=(SocketChannel)key.channel();
            int len=client.read(buffer);
            if (len>0)
            {
                String msg=new String(buffer.array(),0,len);
                System.out.println("获取客户端发送来的消息:"+msg);
            }
        }
        else if (key.isWritable())
        {
            System.out.println("可写");
        }
        else
        {
            System.out.println("其他");
        }
    }
}

这里面我们只接收客户端发来的数据,所以只做key.isReadable()=true的代码处理。当它为true时,表示可以读取里面的信息到缓存区。

服务端完整的代码如下:

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.Set;

public class NIOServer {
    private int port;
    private ServerSocketChannel serverSocketChannel;
    private Selector selector;
    public NIOServer(int port) throws IOException
    {
        this.port=port;
        serverSocketChannel=ServerSocketChannel.open();//服务端打开通信通道,只是打开了,并未绑定到具体的端口
        serverSocketChannel.configureBlocking(false);//将通信通道设置为非阻塞
        serverSocketChannel.socket().bind(new InetSocketAddress(this.port));//将通道绑定到具体的端口

        selector=Selector.open();//打开一个轮询器,轮询器的作用时巡逻服务器的通道serverSocketChannel,不断地查询有没有新的channel过来,以及channel的状态:连接、可读、可写等等。

        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);//将轮询器注册给服务器的通道,这个时候轮询器才能真正开始巡逻服务器的channel

        System.out.println("服务器就绪!");
    }

    public void start() throws IOException
    {
        listen();
    }

    private void listen() throws IOException
    {
        while (true)//建立一个无限循环,让轮询器不停地扫描
        {
            int i=selector.select();//这个方法时阻塞的,直到selector发现有channel连接到服务器的channel,才会进到下面的代码,
                                    // 这个方法可以设置轮询间隔的时间比如int i=selector.select(100),每隔100ms扫描iyci
            if (i==0) continue;//当i>0,表示有通道channel连接
            Set<SelectionKey> keys=selector.selectedKeys();//轮询器会给它查询到的每个channel一个key,有了这个key,就可以操作channle了。注意这里不要用Set<SelectionKey> keys=selector.keys()
            Iterator<SelectionKey> iterator=keys.iterator();//这里面的代码就是挨个处理selector查询到的来自于客户端的通道
            while (iterator.hasNext())
            {
                handle(iterator.next());//具体的处理方法
                iterator.remove();
            }
        }
    }

    private ByteBuffer buffer=ByteBuffer.allocate(1024);
    private void handle(SelectionKey key) throws IOException
    {
        if (key.isAcceptable())//表示当前的的通道是可以接受连接的,当客户端的channel调用connect方法时,它的状态就是isAcceptable。
        {
            SocketChannel client=serverSocketChannel.accept();//接受客户端的连接,然后就可以拿到客户端的channel了
            client.configureBlocking(false);//配置非阻塞
            client.register(selector,SelectionKey.OP_READ);//我们拿到客户端连接后,把它设置为可读的,这样下次selector轮询的时候,拿到的这个channel就是可读的channel了。
        }
        else if (key.isReadable())//如果客户端可读,就尝试读取里面的信息到缓冲区,并专程string输出到控制台
        {
            buffer.clear();
            SocketChannel client=(SocketChannel)key.channel();
            int len=client.read(buffer);
            if (len>0)
            {
                String msg=new String(buffer.array(),0,len);
                System.out.println("获取客户端发送来的消息:"+msg);
            }
        }
        else if (key.isWritable())
        {
            System.out.println("可写");
        }
        else
        {
            System.out.println("其他");
        }
    }
}

客户端完整代码如下:

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.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;

public class NIOClient {
    private int port;
    private String ip;
    private SocketChannel clientSocketChannel;
    private Selector selector;
    public NIOClient(String ip,int port) throws IOException
    {
        this.ip=ip;
        this.port=port;
        clientSocketChannel=SocketChannel.open();//客户端的通道打开
        clientSocketChannel.configureBlocking(false);//配置这个通道为非阻塞
        clientSocketChannel.connect(new InetSocketAddress(this.ip,this.port));//尝试连接服务器,如果连接成功,那么服务端的selector就会轮询到一个channel,且状态是isAcceptable
        selector=Selector.open();//把轮询器打开
        clientSocketChannel.register(selector, SelectionKey.OP_CONNECT);//把轮询器和当前的通道绑定,让轮询器轮询当前的通道

        System.out.println("客户端连接服务器成功");

        clientSocketChannel.finishConnect();//完成连接,很重要,调用这个方法,就是让连接就绪,这个方法并不是关闭连接,而是完成连接的操作
        clientSocketChannel.register(selector, SelectionKey.OP_WRITE);//连接成功后设置通道为可以写入的,即可以向服务器发送数据

        System.out.println("现在可以向服务端发送消息了");
    }
    public void start() throws IOException
    {
        System.out.println("请在控制台输入消息:");
        Scanner scanner = new Scanner(System.in);//控制台等待用户输入,收到消息后,调用send()方法,发送数据
        while (scanner.hasNext()) {
            msg = scanner.next();
            send();
        }
    }

    private ByteBuffer buffer=ByteBuffer.allocate(1024);
    private String msg;
    private void send() throws IOException
    {
        int i = selector.select();//调用轮询器,查找可用的channel,这里面因为我们已经连接上服务端了,所以将会返回一个channel,这个channel是连接到服务端的channel
        if (i == 0) return;
        Set<SelectionKey> keys = selector.selectedKeys();//取出channel的key
        Iterator<SelectionKey> iterator = keys.iterator();
        while (iterator.hasNext()) {
            handle(iterator.next());//处理channel
            iterator.remove();
        }
    }

    private void handle(SelectionKey key) throws IOException
    {
        if (key.isWritable())//由于当前我们设置了channel可写,所以这个channel是可写的,先写到缓冲区,再发给服务端
        {
            buffer.clear();
            buffer.put(msg.getBytes());
            buffer.flip();//很重要,重置缓冲区的索引位置,让它回到起点
            clientSocketChannel.write(buffer);
        }
        else if (key.isReadable())
        {
            System.out.println("客户端可读");
        }
        else
        {
            System.out.println("其他");
        }
    }
}

分别写两个启动类:

public class ServerStart {
    public static void main(String[] args)
    {
        try {
            NIOServer server=new NIOServer(8080);
            server.start();
        }
        catch (Exception ex)
        {
            ex.printStackTrace();
        }
    }
}
public class ClientStart {
    public static void main(String[] args)
    {
        try {
            NIOClient client=new NIOClient("localhost",8080);
            client.start();
        }
        catch (Exception ex)
        {
            ex.printStackTrace();
        }
    }
}

先启动服务端,再启动客户端,然后在客户端的控制台输入“测试”,服务端就收到了消息:

io文件大小 java java 文件io 网络io_java

io文件大小 java java 文件io 网络io_NIO_02

这里面的代码只实现了客户端向服务端单向发数据,并不支持双向发数据。一般照着上面的代码跑通一遍,NIO的原理也就自然明白了。

至于AIO,先留坑,有时间再补充。

到现在为止,我们的客户端和服务端都是java代码实现的,但实际的场景,往往是服务器端用一种语言实现,客户端用另一种语言实现,比如现在我们希望服务端用java实现,客户端用c#实现,c#客户端的代码如下:

private void StartSocket(int port)
        {
            Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                socket.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"),port));
                socket.Send(Encoding.Default.GetBytes("I am C#"));
                socket.Shutdown(SocketShutdown.Both);
                socket.Close();
            }
            catch(Exception ex)
            {
                MessageBox.Show(ex.Message);
            }
        }

首先启动BIO的服务端,然后启动c#的客户端,在服务端会收到消息“I am C#”:

io文件大小 java java 文件io 网络io_java_03

关掉BIO服务端,c#客户端,启动NIO服务端,启动c#客户端,服务端正常收到消息:

io文件大小 java java 文件io 网络io_java_04

不管服务端用了什么代码实现,客户端的代码都没有改变,也就是说不管是BIO还是NIO或者是AIO,他们的基础都是socket,他们的区别不过就是在收到客户端的连接请求后使用的处理策略不同,以达到性能最大化。另外所谓NIO、AIO,是针对服务器而言,这里的服务器是指接受消息的一方,BIO接受消息或者连接,会导致大量线程的产生,他们要解决的就是大量线程产生这个问题。