Java NIO4: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源码来加深理解。
非阻塞模式
前面第一点说了,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秒钟轮询一次。
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 }
50
51 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的最后也是最难、最核心的部分----选择器,将在下一篇文章进行详细的讲解。