javaNIO:Socket通道

Socket通道

上文讲述了通道、文件通道,这篇文章来讲述一下Socket通道,Socket通道与文件通道有着不一样的特征,分三点说:

1、NIO的Socket通道类可以运行于非阻塞模式并且是可选择的,这两个性能可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性,因此,再也没有为每个Socket连接使用一个线程的必要了。这一特性避免了管理大量线程所需的上下文交换总开销,借助NIO类,一个或几个线程就可以管理成百上千的活动Socket连接了并且只有很少甚至没有性能损失

2、全部Socket通道类(DatagramChannel、SocketChannel和ServerSocketChannel)在被实例化时都会创建一个对应的Socket对象,就是我们所熟悉的来自java.net的类(Socket、ServerSocket和DatagramSocket),这些Socket可以通过调用socket()方法从通道类获取,此外,这三个java.net类现在都有getChannel()方法

3、每个Socket通道(在java.nio.channels包中)都有一个关联的java.net.socket对象,反之却不是如此,如果使用传统方式(直接实例化)创建了一个Socket对象,它就不会有关联的SocketChannel并且它的getChannel()方法将总是返回null

概括地讲,这就是Socket通道所要掌握的知识点知识点,不难,记住并通过自己写代码/查看JDK源码来加深理解。


ps:

1.Socket其实就是一个基于tcp,udp实现的协议名称(套接字),你也可以基于tcp,udp来实现一个类似socket的协议,可以参考文件句柄,文件描述符,套接字 这篇笔记加深对套接字的理解


Channel是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。

正如前面提到的,所有数据都通过 Buffer 对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。

 

nio Channel  和传统io的比较

1.传统io是没办法使用nio的第一个特性Buffer缓冲区来快速读取,nio channel可以利用Buffer第一个特性来快速读取数据文件

2.nio channel 使用的Buffer 修改文件不用 flush等其他多余的代码了,更加简洁了

3.传统io使用的是stream或者reader , nio channel就是从stream或者reader开出来的一个扩展,专注通道传入,和Buffer打交道,操作方式不一样

4.nio 的 soketchannel 支持非阻塞接受客户端连接。但是其他的filechannel是不能非阻塞的 ,还是跟传统io一样,其实nio 在网络 soketchannel 和 server 上非阻塞已经可以节省大量线程资源的开销浪费(nio不会阻塞即可去做其他事,具体不阻塞体现在accept和read的时候,但是这里会有一点缺点,就是你要隔一段时间就判断是不是客户端有数据到来,当然这是在没使用selector的soketchannel中,如果使用了selector就可以完全使用一个单独的线程来维护所有channel上注册的selector的key状态,其实使用了selector就是便于统一管理所有的Channel),在这里以前的传统io如果想效率的接受客户端请求 ,就必须开多个线程来处理,所以nio比传统io还要是更加节省系统开销浪费

5.通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类), 而 通道 可以用于读、写或者同时用于读写。因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。

6.所有nio里面的非阻塞都是同步非阻塞,包括什么观察者,反应器模式和selector内部实现都是同步非阻塞,自己编写代码也是,channel自己主动判断是否有数据,seletor都是要自己去主动获取Key的状态,具体非阻塞和异步的可以看javaNIO:Socket通道最下面的总结

以上参考通道和文件通道 和本篇笔记加深理解


非阻塞模式

前面第一点说了,NIO的Socket通道可以运行于非阻塞模式,这个陈述虽然简单却有着深远的含义。传统Java Socket的阻塞性质曾经是Java程序可伸缩性的最重要制约之一,非阻塞I/O是许多复杂的、高性能的程序构建的基础。

要把一个Socket通道置于非阻塞模式,要依赖的是Socket通道类的父类SelectableChannel,下面看一下这个类的简单定义:

public abstract class SelectableChannel extends AbstractInterruptibleChannel implements Channel
{
    ...
    public abstract void configureBlocking(boolean block) throws IOException;
    public abstract boolean isBlocking();
    public abstract Object blockngLock();
    ...
}

因为这篇文章是讲述Socket通道的,因此省略了和选择器相关的方法,这些省略的内容将在下一篇文章中说明。

从SelectableChannel的API中可以看出,设置或重新设置一个通道的阻塞模式是很简单的,只要调用configureBlocking()方法即可,传递参数值为true则设为阻塞模式,参数值为false则设为非阻塞模式,就这么简单。同时,我们可以通过调用isBlocking()方法来判断某个Socket通道当前处于哪种模式中。

偶尔,我们也会需要放置Socket通道的阻塞模式被更改,所以API中有一个blockingLock()方法,该方法会返回一个非透明对象引用,返回的对象是通道实现修改阻塞模式时内部使用的,只有拥有此对象的锁的线程才能更改通道的阻塞模式,对于确保在执行代码的关键部分时Socket通道的阻塞模式不会改变以及在不影响其他线程的前提下暂时改变阻塞模式来说,这个方法是非常方便的。

 

Socket通道服务端程序

OK,接下来先看下Socket通道服务端程序应该如何编写:

 1 public class NonBlockingSocketServer
 2 {
 3     public static void main(String[] args) throws Exception
 4     {
 5         int port = 1234;
 6         if (args != null && args.length > 0)
 7         {
 8             port = Integer.parseInt(args[0]);
 9         }
10         ServerSocketChannel ssc = ServerSocketChannel.open();
11         ssc.configureBlocking(false);
12         ServerSocket ss = ssc.socket();
13         ss.bind(new InetSocketAddress(port));
14         System.out.println("开始等待客户端的数据!时间为" + System.currentTimeMillis());
15         while (true)
16         {
17             SocketChannel sc = ssc.accept();
18             if (sc == null)
19             {
20                 // 如果当前没有数据,等待1秒钟再次轮询是否有数据,在学习了Selector之后此处可以使用Selector
21                 Thread.sleep(1000);
22             }
23             else
24             {
25                 System.out.println("客户端已有数据到来,客户端ip为:" + sc.socket().getRemoteSocketAddress() 
26                         + ", 时间为" + System.currentTimeMillis()) ;
27                 ByteBuffer bb = ByteBuffer.allocate(100);
28                 sc.read(bb);
29                 bb.flip();
30                 while (bb.hasRemaining())
31                 {
32                     System.out.print((char)bb.get());
33                 }
34                 sc.close();
35                 System.exit(0);
36             }
37         }
38     }
39 }

整个代码流程大致上就是这样,没什么特别值得讲的,注意一下第18行~第22行,由于这里还没有讲到Selector,因此当客户端Socket没有到来的时候选择的处理办法是每隔1秒钟轮询一次。


这里声明一遍,所有nio里面所有的非阻塞其实是同步非阻塞,具体关系可以看本篇笔记的最后

这里注意的都是 SocketChannel虽然是非阻塞的,但是当数据来了我们不会主动接受到信息,只能被动的轮询来接受就很坑爹了,nio还有个selector它的优点就是可以同时管理多个channel的状态通知,这个可以参考javaNIO:选择器--实践 Selector,其实还是被动轮询,程序要自己去主动获取selector的key的状态,只是selector它可以管理多个通道,比不用selector更加优化,还有本篇笔记最下面会写到soketchannel和selector的形象比喻观察者和反映者模式,最后还有异步和阻塞的形象比喻

Socket通道客户端程序

服务器端经常会使用非阻塞Socket通达,因为它们使同时管理很多Socket通道变得更容易,客户端却并不强求,因为客户端发起的Socket操作往往比较少,且都是一个接着一个发起的。但是,在客户端使用一个或几个非阻塞模式的Socket通道也是有益处的,例如借助非阻塞Socket通道,GUI程序可以专注于用户请求并且同时维护与一个或多个服务器的会话。在很多程序上,非阻塞模式都是有用的,所以,我们看一下客户端应该如何使用Socket通道:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP= "127.0.0.1";
 5 6     public static void main(String[] args) throws Exception
 7     {
 8         int port = 1234;
 9         if (args != null && args.length > 0)
10         {
11             port = Integer.parseInt(args[0]);
12         }
13         SocketChannel sc = SocketChannel.open();
14         sc.configureBlocking(false);
15         sc.connect(new InetSocketAddress(REMOTE_IP, port));
16         while (!sc.finishConnect())
17         {
18             System.out.println("同" + REMOTE_IP+ "的连接正在建立,请稍等!");
19             Thread.sleep(10);
20         }
21         System.out.println("连接已建立,待写入内容至指定ip+端口!时间为" + System.currentTimeMillis());
22         ByteBuffer bb = ByteBuffer.allocate(STR.length());
23         bb.put(STR.getBytes());
24         bb.flip(); // 写缓冲区的数据之前一定要先反转(flip)
25         sc.write(bb);
26         bb.clear();
27         sc.close();
28     }
29 }

总得来说和普通的Socket操作差不多,通过通道读写数据,非常方便。不过再次提醒,通道只能操作字节缓冲区也就是ByteBuffer的数据

 

运行结果展示

上面的代码,为了展示结果的需要,在关键点上都加上了时间打印,这样会更清楚地看到运行结果。

首先运行服务端程序(注意不可以先运行客户端程序,如果先运行客户端程序,客户端程序会因为服务端未开启监听而抛出ConnectionException),看一下:

看到红色方块,此时程序是运行的,接着运行客户端程序:

看到客户端已经将"Hello World!"写入了Socket并通过通道传到了服务器端,方框变灰,说明程序运行结束了。此时看一下服务器端有什么变化:

看到服务器端打印出了字符串"Hello World!",并且方框变灰,程序运行结束,这和代码是一致的。

注意一点,客户端看到的时间是XXX10307,服务器端看到的时间是XXX10544,这是很正常的,因为前面说过了,服务器端程序是每隔一秒钟轮询一次是否有Socket到来的。

当然,由于服务端程序的作用是监听1234端口,因此完全可以写客户端的代码,可以直接访问http://127.0.0.1:1234/a/b/c/d/?e=5&f=6&g=7就可以了,看一下效果:

有了这个基础,我们就可以自己解析HTTP请求,甚至可以自己写一个Web服务器。

 

客户端Socket通道复用性的研究

这个是我今天上班的时候想到的一个问题,补充到最后。

服务器端程序不变,客户端现在是单个线程发送了一次数据到服务端的,假如现在我的客户端有多条线程同时通过Socket通道发送数据到服务端又会是怎么样的现象?首先将服务端端的代码稍作改变,让服务端SocketChannel在拿到客户端的数据之后程序不会停止运行而是会持续监听来自客户端的Socket,由于服务器端的代码比较多,这里只列一下改动的地方,:

...
bb.flip();
while (bb.hasRemaining())
{
    System.out.print((char)bb.get());
}
System.out.println();
//sc.close();
//System.exit(0);
...

接着看一下对客户端代码的启动,把写数据的操作放到线程的run方法中去:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         private SocketChannel sc;
10
11         public NonBlockingSocketThread(SocketChannel sc)
12         {
13             this.sc = sc;
14         }
15
16         public void run()
17         {
18             try
19             {
20                 System.out.println("连接已建立,待写入内容至指定ip+端口!时间为" + System.currentTimeMillis());
21                 String writeStr = STR + this.getName();
22                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
23                 bb.put(writeStr.getBytes());
24                 bb.flip(); // 写缓冲区的数据之前一定要先反转(flip)
25                 sc.write(bb);
26                 bb.clear();
27             } 
28             catch (IOException e)
29             {
30                 e.printStackTrace();
31             }
32         }
33     }
34
35     public static void main(String[] args) throws Exception
36     {
37         int port = 1234;
38         if (args != null && args.length > 0)
39         {
40             port = Integer.parseInt(args[0]);
41         }
42         SocketChannel sc = SocketChannel.open();
43         sc.configureBlocking(false);
44         sc.connect(new InetSocketAddress(REMOTE_IP, port));
45         while (!sc.finishConnect())
46         {
47             System.out.println("同" + REMOTE_IP + "的连接正在建立,请稍等!");
48             Thread.sleep(10);
49         }
5051         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
52         for (int i = 0; i < THREAD_COUNT; i++)
53             nbsts[i] = new NonBlockingSocketThread(sc);
54         for (int i = 0; i < THREAD_COUNT; i++)
55             nbsts[i].start();
56         // 一定要join保证线程代码先于sc.close()运行,否则会有AsynchronousCloseException
57         for (int i = 0; i < THREAD_COUNT; i++)
58             nbsts[i].join();
59
60         sc.close();
61     }
62 }

启动了5个线程,我们可能期待服务端能有5次的数据到来,实际上是:

原因就是客户端的五个线程共用了同一个SocketChannel,这样相当于五个线程把数据轮番写到缓冲区,写完之后再把数据通过通道传输到服务器端。ByteBuffer的write方法放心,是加锁的,反编译一下sun.nio.ch.SocketChannelImpl就知道了,因此不会出现"Hello World!Thread-X"这些字符交叉的情况。

所以有了这个经验,我们让每个线程都new一个自己的SocketChannel,于是客户端程序变成了:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         public void run()
10         {
11             try
12             {
13                 int port = 1234;
14                 SocketChannel sc = SocketChannel.open();
15                 sc.configureBlocking(false);
16                 sc.connect(new InetSocketAddress(REMOTE_IP, port));
17                 while (!sc.finishConnect())
18                 {
19                     System.out.println("同" + REMOTE_IP + "的连接正在建立,请稍等!");
20                     Thread.sleep(10);
21                 }
22                 System.out.println("连接已建立,待写入内容至指定ip+端口!时间为" + System.currentTimeMillis());
23                 String writeStr = STR + this.getName();
24                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
25                 bb.put(writeStr.getBytes());
26                 bb.flip(); // 写缓冲区的数据之前一定要先反转(flip)
27                 sc.write(bb);
28                 bb.clear();
29                 sc.close();
30             } 
31             catch (IOException e)
32             {
33                 e.printStackTrace();
34             } 
35             catch (InterruptedException e)
36             {
37                 e.printStackTrace();
38             }
39         }
40     }
41
42     public static void main(String[] args) throws Exception
43     {
44         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
45         for (int i = 0; i < THREAD_COUNT; i++)
46             nbsts[i] = new NonBlockingSocketThread();
47         for (int i = 0; i < THREAD_COUNT; i++)
48             nbsts[i].start();
49         // 一定要join保证线程代码先于sc.close()运行,否则会有AsynchronousCloseException
50         for (int i = 0; i < THREAD_COUNT; i++)
51             nbsts[i].join();
52     }
53 }

此时再运行,观察结果:

看到没有问题,服务器端分五次接收来自客户端的请求了。

当然,这也是有一定问题的:

1、如果服务器端开放多线程使用ServerSocket通道去处理来自客户端的数据的话,面对成千上万的高并发很容易地就会耗尽服务器端宝贵的线程资源

2、如果服务器端只有一条ServerSocket通道线程处理来自客户端的数据的话,一个客户端的数据处理得慢将直接影响后面线程的数据处理

这么一说似乎又回到了非阻塞I/O的老问题了。不过,Socket通道讲解到此,大体的概念我们已经清楚了,接着就轮到NIO的最后也是最难、最核心的部分----选择器,将在下一篇文章进行详细的讲解。


ps:Java NIO非堵塞技术实际是采取反应器模式,或者说是观察者(observer)模式为我们监察I/O端口,如果有内容进来,会自动通知我们,这样,我们就不必开启多个线程死等,从外界看,实现了流畅的I/O读写,不堵塞了。 (这里再声明一遍,所有nio里面的非阻塞都是同步非阻塞,包括什么观察者,反应器模式和selector内部实现都是同步非阻塞,自己编写代码也是,channel自己主动判断是否有数据,seletor都是要自己去主动获取Key的状态,具体非阻塞和异步的可以看javaNIO:Socket通道最下面的总结)


NIO 有一个主要的类Selector,这个类似一个观察者(observer),只要我们把需要探知的socketchannel告诉Selector,我们接着做别的事情,当有事件发生时,他会通知我们,传回一组SelectionKey,我们读取这些Key,就会获得我们刚刚注册过的socketchannel,然后,我们从这个Channel中读取数据,接着我们可以处理这些数据。

反应器模式与观察者模式在某些方面极为相似:当一个主体发生改变时,所有依属体都得到通知。不过,观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。 这里可以参考后面的文章选择器--实践   (这里再声明一遍,所有nio里面的非阻塞都是同步非阻塞,包括什么观察者,反应器模式和selector内部实现都是同步非阻塞,自己编写代码也是,channel自己主动判断是否有数据,seletor都是要自己去主动获取Key的状态,具体非阻塞和异步的可以看javaNIO:Socket通道最下面的总结)








------------------------------------------------
异步和非阻塞的区别 和比喻

同步和异步区别:有无通知(是否轮询)
堵塞和非阻塞区别:操作结果是否等待(是否马上又返回值),只是设计方式的不同



讲讲异步和非阻塞的区别(不组合)

同步:你到饭馆点餐,然后在那等着,还要一边喊:好了没啊!

异步:遛狗的时候,接到饭馆电话,说饭做好了,让您亲自去拿。

阻塞:你到饭馆点餐,然后在那等着,还要一边喊:好了没啊!

非阻塞:遛狗的时候,接到饭馆电话,说饭做好了,让您亲自去拿。



讲讲异步和非阻塞阻塞的区别(异步和阻塞组合)

如果你想吃一份宫保鸡丁盖饭:

同步阻塞:你到饭馆点餐,然后在那等着,还要一边喊:好了没啊!

同步非阻塞:在饭馆点完餐,就去遛狗了。不过溜一会儿,就回饭馆喊一声:好了没啊!

异步阻塞:遛狗的时候,接到饭馆电话,说饭做好了,让您亲自去拿。

异步非阻塞:饭馆打电话说,我们知道您的位置,一会给你送过来,安心遛狗就可以了。






可以看出来异步和非阻塞是不相同的一个概念,两者可以一起组合形成以上4种情况

注意几点:

1.但是如果不组合的情况,比如同步和阻塞就没什么区别,但是异步和非阻塞就有区别了(在系统实现的其实还是有区别的,阻塞是直接放弃cpu资源,而同步的是不停的轮询检测状态)

2.反之组合的情况,那就是都有区别了,这里要特别理解上面4种情况组合和组合的4种情况组合(个人理解最大的一个重点就是,自身去维护,还是由等待的事件去维护)


异步和非阻塞的区别在于:

1.非阻塞(非阻塞其实就是组合情况的异步阻塞情况)是你自己同时干的啥事,然后自己主动去确定等待的事件是否完成(在代码的体现就是轮询)

2.异步就是你自己同时干啥事,然后等待事件来通知我们ok了,自身不用担心,也不用浪费性能去处理


这里感觉同步和非阻塞是差不多,个人觉得其实不然,同步是轮询不能做其它事,系统内部已经实现好的,用户不可更改,而非阻塞的是用户实现的可以顺便干点其它事,都是用轮询的

还有就是不用关注其内部实现,只用了解这个概念即可


总结一点各有好处,异步的话虽然不用自己去确认,但是如果你等待的事件不通知你呢,不能确保绝对有一个值,而非阻塞是自己去确认的,总是能拿到一个绝对的值


在nio这两点体现给调用的方式感觉是一样的,其实底层是不一样的,只不过不用去深究






posted on 2021-02-24 17:43  signheart  阅读(395)  评论(0编辑  收藏  举报

导航