让一切的准备都完美演出,让所有的努力都美好落幕
分类: Java
2015-11-20 22:17:29
除了文件之外,另外一个在应用开发中经常需要处理的I/O实体是网络连接。Java从JDK 1.0开始就有了处理网络连接的相关API,包含在java.net包中。这其中比较重要的是套接字连接的相关API。通过套接字API可以很容易地实现自己的网络服务器和客户端程序。
如果需要实现一个网络客户端程序,只需要先创建一个java.net.Socket类的对象,再连接到远程服务器。当连接成功之后,可以从Socket类的对象中获取到套接字连接对应的输入流和输出流。通过输入流可以得到服务器端发送的数据,而通过输出流可以向服务器端发送数据。对服务器端程序来说,则可以创建一个java.net.ServerSocket类的对象并使用其accept方法在指定的端口进行监听。调用方法accept时会处于阻塞状态,等待客户端程序的连接请求。当有新的连接建立时,accept方法会返回一个与连接发起者进行通信时所使用的Socket类的对象。
从上面的简要描述可以看出,java.net包中的套接字的相关实现在使用时是比较简单的,所包含的概念也并不多,且结合了已有的I/O操作中流的概念。开发人员通过类比文件操作的方式,可以很容易理解套接字的使用。实际上,java.net包中的套接字实现的最大问题不是来自于API本身,而是出于性能方面的考虑。Socket类和ServerSocket类中提供的与建立连接和数据传输相关的方法都是阻塞式的,也就是说,如果操作没有完成,当前线程会处于等待状态。比如,通过调用Socket类的connect方法连接远程服务器时,如果由于网络的原因,连接一直没有办法成功建立,那么connect方法会一直阻塞下去,直到连接建立成功或出现超时错误。在这段等待时间内,其他代码是无法继续执行的。相对于同样是阻塞式的文件操作来说,网络操作的这个特性所带来的问题更为严重,这是因为网络操作的延迟远比文件操作中的延迟要长得多,而且影响网络操作速度的因素也更多。
为了提高网络服务器和客户端的性能和吞吐量,采用多线程的方式就成了解决这个问题的“银弹”。以服务器的实现为例,在一般情况下,会有一个线程专门用来调用ServerSocket类的accept方法来监听连接请求。一旦有新的连接建立,就会创建一个新的线程来专门处理这个请求。这种一个请求对应一个线程的方式,显然并不适合服务器负载压力比较大的情况,因为每个线程都要占用资源,创建线程也是有代价的。对此,一般又会引入线程池的实现,以能够复用已有的线程,从而减少每次都要新创建线程所带来的代价。采用多线程的方式确实能解决问题。当某个线程由于等待网络操作而阻塞时,其他线程还可以继续执行,整体的性能和吞吐量得到了提高。不过多线程方式的问题在于它太复杂了,而且容易出现非常多的隐含错误,多线程的相关内容会在第11章中进行讨论。
为了解决这些与网络操作相关的问题,Java NIO提供了非阻塞式和多路复用的套接字连接,并在Java 7中又进行了改进。与文件操作一样,网络操作也被抽象成通道的概念,接口java.nio.channels.NetworkChannel表示的是一个套接字所对应的通道。
1.阻塞式套接字通道
与Socket类和ServerSocket类相对应,Java NIO中也提供了SocketChannel类和ServerSocketChannel类两种不同的套接字通道实现。这两种通道都支持阻塞和非阻塞两种模式。阻塞模式的使用简单,但是性能不是很好;非阻塞模式则正好相反。开发人员可以根据自己的需要来选择合适的模式。一般来说,低负载的程序可以选择阻塞模式,实现起来简单且性能足够好。代码清单3-9中给出的保存网页内容的示例程序,也可以通过SocketChannel类来实现,如代码清单3-14所示。先通过SocketChannel类的open方法来打开对远程服务器的连接。如果在调用open方法时提供了远程服务器的地址作为参数,那么open方法会直接调用connect方法进行连接;否则还需要显式地调用connect方法进行连接。在默认情况下,open方法的调用是阻塞式的。当连接成功之后,就可以向套接字通道中写入数据。这里写入的是HTTP请求信息。写入完成之后可以进行读取操作,读取服务器端返回的HTTP响应的内容。这里把通道的内容传输到文件中。从这里可以看出通道相对于流的灵活性,是它不再需要显式地去获取输入流或输出流的对象,而是可以直接进行读写操作。
代码清单3-14 阻塞式客户端套接字的使用示例
public void loadWebPageUseSocket() throws IOException {
try (FileChannel destChannel = FileChannel.open(Paths.get("content.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
SocketChannel sc = SocketChannel.open(new InetSocketAddress("", 80))) {
String request = "GET / HTTP/1.1\r\n\r\nHost: \r\n\r\n";
ByteBuffer header = ByteBuffer.wrap(request.getBytes("UTF-8"));
sc.write(header);
destChannel.transferFrom(sc, 0, Integer.MAX_VALUE);
}
}
对于ServerSocketChannel类的阻塞模式的使用也比较直接。代码清单3-15给出了一个简单的使用ServerSocketChannel类的服务器端程序的示例。通过open方法打开一个新的套接字通道。当通道打开之后,需要通过调用bind方法将其绑定到某个地址上。这里绑定到了本机的10800端口上。绑定成功之后,就可以在这个端口上监听客户端的连接请求。ServerSocketChannel类的accept方法会阻塞直到有新的连接发生。当有新的连接建立时,可以通过从accept方法得到的SocketChannel类的对象来与发起连接的客户端进行数据传输。这里只简单地发送一个字符串给客户端之后就关闭连接。
代码清单3-15 阻塞式服务器端套接字的使用示例
public void startSimpleServer() throws IOException {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress("localhost", 10800));
while (true) {
try (SocketChannel sc = channel.accept()) {
sc.write(ByteBuffer.wrap("Hello".getBytes("UTF-8")));
}
}
}
套接字通道的阻塞模式总体来说与java.net包中的Socket类和ServerSocket类的使用方式非常类似,区别在于使用了NIO中新的通道的概念。
2.多路复用套接字通道
如果程序对网络操作的并发性和吞吐量的要求比较高,那么阻塞式的套接字通道就不能比较简单地满足程序的需求。这时比较好的办法是通过非阻塞式的套接字通道实现多路复用或者使用NIO.2中的异步套接字通道。
套接字通道的多路复用的思想比较简单,通过一个专门的选择器(selector)来同时对多个套接字通道进行监听。当其中的某些套接字通道上有它感兴趣的事件发生时,这些通道会变为可用的状态,可以在选择器的选择操作中被选中。选择器通过一次选择操作可以获取这些被选中的通道的列表,然后根据所发生的事件类型分别进行处理。这种基于选择器的做法的优势在于可以同时管理多个套接字通道,而且可用通道的选择一般是通过操作系统提供的底层系统调用来实现的,性能也比较高。
多路复用的实现方式的核心是选择器,即java.nio.channels.Selector类的对象。非阻塞式的套接字通道可以通过register方法注册到某个Selector类的对象上,以声明由该Selector类的对象来管理当前这个套接字通道。在进行注册时,需要提供一个套接字通道感兴趣的事件的列表。这些事件包括连接完成、接收到新连接请求、有数据可读和可以写入数据等。这些事件定义在java.nio.channels.SelectionKey类中。在完成注册之后,可以调用Selector类的对象的select方法来进行选择。选择操作完成之后,可以从Selector类的对象中得到一个可用的套接字通道的列表。对于这个列表中的套接字通道来说,至少有一个它注册时声明的感兴趣的事件发生了。接着就可以根据事件的类型来进行相应的处理。一个套接字通道只有在通过configureBlocking方法设置为非阻塞模式之后,才能被注册到选择器上。套接字通道在非阻塞模式下的读取和写入操作与阻塞模式下差别很大,在使用时需要格外注意。比如在进行读取操作时,非阻塞式套接字通道的read方法只会读取当时立即可以获取的数据,而不会等待数据的到来。因此,有可能在一次read方法调用中没有读取到任何数据。
下面用一个完整的示例来说明Selector类和非阻塞式SocketChannel类如何结合起来使用。示例的场景仍然是代码清单3-9中给出的通过套接字连接来一个网页的内容,只不过需求变成同时多个网页的内容。如果用传统的阻塞式套接字通道的方式,那么可以启动多个线程来完成。这里要介绍的是在一个线程中使用Selector类的做法。完整的实现如代码清单3-16所示。通过代码中LoadWebPageUseSelector类的load方法就可以下载多个网页的内容到本地。
代码清单3-16 选择器的使用示例
public class LoadWebPageUseSelector {
public void load(Set
Map
Selector selector = Selector.open();
for (SocketAddress address : mapping.keySet()) {
register(selector, address);
}
int finished = 0, total = mapping.size();
ByteBuffer buffer = ByteBuffer.allocate(32 * 1024);
int len = -1;
while (finished < total) {
selector.select();
Iterator
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isValid() && key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
InetSocketAddress address = (InetSocketAddress) channel.getRemoteAddress();
String filename = address.getHostName() + ".txt";
FileChannel destChannel = FileChannel.open(Paths.get(filename), StandardOpenOption.APPEND, StandardOpenOption.CREATE);
buffer.clear();
while ((len = channel.read(buffer)) > 0 || buffer.position() != 0) {
buffer.flip();
destChannel.write(buffer);
buffer.compact();
}
if (len == -1) {
finished++;
key.cancel();
}
} else if (key.isValid() && key.isConnectable()) {
SocketChannel channel = (SocketChannel) key.channel();
boolean success = channel.finishConnect();
if (!success) {
finished++;
key.cancel();
} else {
InetSocketAddress address = (InetSocketAddress) channel.getRemoteAddress();
String path = mapping.get(address);
String request = "GET " + path + " HTTP/1.0\r\n\r\nHost: " + address.getHostString() + "\r\n\r\n";
ByteBuffer header = ByteBuffer.wrap(request.getBytes("UTF-8"));
channel.write(header);
}
}
}
}
}
private void register(Selector selector, SocketAddress address) throws IOException {
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
channel.connect(address);
channel.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
}
private Map
Map
for (URL url : urls) {
int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
SocketAddress address = new InetSocketAddress(url.getHost(), port);
String path = url.getPath();
if (url.getQuery() != null) {
path = path + "?" + url.getQuery();
}
mapping.put(address, path);
}
return mapping;
}
}
在使用选择器之前,首先需要创建它。通过Selector类的open方法可以创建一个新的选择器。有了选择器之后,下一步是把套接字通道注册到选择器上,这一步在私有方法register中完成。在register方法中,会首先创建连接HTTP服务器的套接字通道,并通过configureBlocking方法将通道设置成非阻塞模式,最后再注册到选择器上。在注册时要指定套接字通道感兴趣的事件。由于程序只需要连接远程服务器并进行读取操作,这里指定了套接字通道只对连接完成(OP_CONNECT)和通道有数据可读(OP_READ)两种事件感兴趣。套接字通道注册完成之后,下一步就是调用Selector类的对象的select方法来进行通道选择操作。直接调用select方法是会阻塞的,直到所监听的套接字通道中至少有一个它们所感兴趣的事件发生为止。在执行完select方法之后,通过调用selectedKeys方法可以获取到表示被选中的通道的SelectionKey类的对象的集合。每个SelectionKey类的对象与一个被监听的通道相对应。接下来的操作就是对选中的每一个SelectionKey类的对象所发生的是什么类型的事件进行判断,再进行相应的处理。
以连接完成的事件来说,这次连接可能成功也可能失败。通过SocketChannel类的对象的finishConnect方法可以完成连接,同时判断连接是否成功建立。如果连接建立失败,那么通过SelectionKey类的对象的cancel方法可以取消选择器对此通道的管理;如果连接建立成功,那么应该向通道中写入HTTP的请求头信息,相当于向HTTP服务器发送HTTP请求。当HTTP服务器返回网页内容时,套接字通道会变成可读的状态。这个状态会在下一次调用select方法时被选中。在通道处于可读时的处理逻辑是读取通道中的数据,并写入到文件中。对于一个通道来说,由于数据是持续不断地传输的,所以通道可能多次处于可读的状态。如果read方法的返回值为0,说明本次没有数据可读,不需要额外的操作;如果read方法返回值为–1,说明该通道的所有数据已经读取完毕,应该通过对应的SelectionKey类的对象的cancel方法取消对此通道的监听。
代码清单3-16的示例虽然没有使用多线程,但是如果在运行时输出相关调试信息,会发现来自不同服务器的数据的读取操作是交错进行的。这是因为当某个通道的数据暂时还在传输中时,可能另外一个通道的数据已经准备就绪,可以进行读取了。通过这种多路复用的特性,使程序尽可能地利用网络操作本身的特性来提高性能和吞吐量,而不是依靠多线程带来的并发性。