在前面的文章 Java IO概述 主要介绍了Java的文件IO。在这一篇,我将继续介绍Java的网络IO编程。
Prequirement
- 在继续阅读这篇文章之前,请务必先阅读前面这篇Java IO概述,因为Java把所有的IO都统一成流(Stream)了。
- TCP/IP协议栈。知道IP、端口、DNS、Socket、URL、TCP、UDP、HTTP等网络相关知识。
IP地址: InetAddress
java.net.InetAddress类是Java对IP地址(包括IPv4和IPv6)的封装。一般来说,它同时包含主机名(hostname)和IP地址。
1. 创建方式(工厂方法)
- public static InetAddress getByName(String hostName) throws UnknownHostException;
- public static InetAddress[] getAllByName(String hostName) throws UnknownHostException;
- public static InnetAddress getLocalHost() throws UnknownHostException;
说明
- 所有这三个方法都可能在必要的时候连接本地DNS服务器,进行域名解析。
- 由于DNS查找成本相对比较高,InetAddress类会缓存查找的结果。不成功的DNS查找缓存默认是10s。这两个缓存时间可以通过networkaddress.cache.ttl和networkaddress.cache.negative.ttl控制。
- 虽然名称写着getByName(hostName),看起来是使用DNS查找给定hostName对应的IP地址。但是其实这个方法是可以接收包含点分四段或者十六进制形式的IP地址字符串的。如
InetAddress address = InetAddress.getByName("8.8.8.8");
。JDK1.4及以后的版本,对这种情况提供了单独的接口: 1. public static InetAddress getByName(byte[] address) throws UnknownHostException; 2. public static InetAddress getByName(String hostName, byte[] address) throws UnknownHostException; 数组的长度必须是4或者16(即IPv4或者IPv6)。 - 当使用IP地址字符串作为参数调用getByName()时,是不需要检查DNS的。这表示可能为实际上不存在也无法连接的主机创建InetAddress对象。但是,当显式地通过getHostName()请求此主机名时,会进行实际主机名的DNS查询。但是这时候DNS查找失败,不会抛UnknownHostException异常。
- getAllByname(hostName)返回所有对应hostName的地址。虽然一个域名绑定多个IP并不少见,但是对于客户端来说往往只需要连接其中的一个即可,所以这个方法并不常用。
- getLocalHost()方法返回运行机器的InetAddress。如果本机没有固定IP地址或者域名,可能会得到localhost作为域名,127.0.0.1作为IP地址的InetAddress对象。
2. 常用的方法
前面说过InetAddress类是Java对IP地址(包括IPv4和IPv6)的封装。一般来说,它同时包含主机名(hostname)和IP地址。所以通过InetAddress我们可以得到hostName或者ip地址:
- public String getHostName()
- public String getHostAddress()
- public byte[] getAddress()
说明
- 没有setter方法,原因很明显,不多说
- getHostName()方法一般返回主机名,如果这台机器没有主机名或者安全管理器阻止确定主机名,就返回点分四段格式的数字IP地址。
- 如果InetAddress是通过getByName(数字IP地址字符串)构造的,那么getHostName会在这里进行DNS查找。
- getHostAddress()返回包括点分四段格式IP地址的字符串。
- getAddress()返回网络字节顺序的IP地址。返回的字节是无符号的,因为Java都是有符号的。值大于127的字节会被当作负数。因此,如果要对getAddress()返回的字节数值进行操作,需要把字节提升为int,进行适当的调整。比如:
int unsignedByte = signedByte < 0 ? signedByte + 256 : signedByte;
3. Inet4Address 和 Inet6Address
Java 1.4引入了两个新类,Inet4Address和Inet6Address,以此区分IPv4和IPv6地址:
public final class Inet4Address extends InetAddress
public final class Inet6Address extends InetAddress
不过基本上你不需要考虑一个地址是IPv4还是IPv6地址。这两个类属于JDK本身实现细节,你只需要关注基类InetAddress即可,面向对象编程的好处在这里体现出来。
网络接口: NetworkInterface
Java 1.4 添加了一个java.net.NetworkInterface类,表示计算机与网络的互联点。一个NetworkInterface一般是指网卡地址(Network Interface Card(NIC)),但是不一定是硬件的形式。软件模拟的网络接口也是用NetworkInterface表示。例如loopback interface(127.0.0.1 for IPv4或者::1 for IPv6)。总而言之,NetworkInterface用来表示物理和虚拟的网卡地址。下面这段代码:
Socket soc = new java.net.Socket();
soc.connect(new InetSocketAddress(address, port));
操作系统会为我们选择一个网络接口发送和接收数据。但是我们也可以告诉操作系统使用哪个网卡:
NetworkInterface nif = NetworkInterface.getByName("eth0");
Enumeration<InetAddress> nifAddresses = nif.getInetAddresses();
Socket soc = new java.net.Socket();
soc.bind(new InetSocketAddress(nifAddresses.nextElement(), 0));
soc.connect(new InetSocketAddress(address, port));
工厂方法
- public static NetworkInterface getByName(String name) throws SocketException
- public static NetworkInterface getByInetAddress(InetAddress address) throws SocketException
- public static Enumeration getNetworkInterfaces() throws SocketException
说明
- 网络接口名称与平台相关,unix系统一般命名为eth0、eth1等等,本地回路地址名可能是类似于lo的。
- 记住下面的映射关系:一台机器可能有多个网络接口(NetworkInterface),一个网络接口(NetworkInterface)可能绑定了多个IP地址(InetAddress)。
TCP: Socket和ServerSocket
TCP是为可靠传输而设计的。它主要有如下特点:
- 面向连接
- 面向字节流
- 丢包重传和和乱序重排
- 流量控制
Socket类
Socket是两台主机之间的一个连接。它可以进行七项基本操作:
- 连接远程机器
- 发送数据
- 接收数据
- 关闭连接
- 绑定端口
- 监听入站数据
- 在所绑定的端口上接收来自远程机器的连接
说明
- Java的Socket类可同时用于客户端和服务器,它有对应于前四项操作的方法。后三项只有服务器才需要,这些操作通过ServerSocket类实现。
- TCP是面向字节流的协议,所以数据的发送和接收通过socket关联的输入输出流进行,操作起来跟文件是类似的。
java.net.Socket类是Java执行客户端TCP操作的基础类。其他进行TCP网络连接的面向客户端的类,如URL、URLConnection等,最终都会调用到Socket类的方法。
Socket在数据结构上,是 <IP, port> 的组合。其中IP可以通过InetAddress进行主机名和IP地址的转换和表示,port是端口号,必须在0到65535之间。
构造函数
- public Socket(String host, int port) throws UnknownHostException, IOException
- public Socket(InetAddress host, int port) throws IOException
- public Socket(String host, int port, InetAddress interface, int localPort) throws IOException, UnknownHostException
- public Socket(InetAddress host, int port, InetAddress interface, int localPort) throws IOException, UnknownHostException
- public Socket()
- public Socket(SocketImpl impl)
- public Socket(Proxy proxy)
说明
- 上面前四个构造函数,在创建Socket的时候就会尝试连接指定的服务器。后三个构造函数用于创建未连接的socket对象。
- 第三和第四个构造函数,连接到前两个参数指定的主机和端口,从后两个参数指定的本机网络接口和端口进行连接。如果0传递给localPort参数,Java会随机选择一个1024~65535之间的可用端口。
常用方法
- public InetAddress getInetAddress()
- public int getPort()
- public int getLocalPort()
- public InetAddress getLocalAddress()
- public InputStream getInputStream() throws IOException
- public OutputStream getOutputStream() throws IOException
- public void close() throws IOException
- public void shutdownInput() throws IOException
- public void shutdownOutput() throws IOException
说明
- TCP是面向字节流的协议,Socket其实就是一个文件句柄,所以通过它关联的输入输出流实现数据的读写。
- shutdownInput和shutdownOutput仅仅关闭连接的一半。需要注意的是shutdown方法只影响socket的流,并不释放与socket相关的资源,如占用的端口等。所以仍然需要在结束使用后close掉该socket。
设置Socket选项
- TCP_NODELAY
- SO_BINDADDR
- SO_TIMEOUT
- SO_LINGER
- SO_REUSEADDR
- SO_SNDBUF
- SO_RCVBUF
- SO_KEEPALIVE
- OOBINLINE
说明
- 正常情况下,小的包在发送前会组合为大点的包。在发送另一个包之前,本地主机要等待远程系统对前一个包的回应,这称之为Nagle算法。Nagle算法主要是为了解决“糊涂窗口综合症”的。但是Nagle算法也会带来一些问题。如果远程系统没有尽可能快地将回应发送会本地系统,那么依赖于小数据量信息稳定传播的应用程序会变得很慢。设置TCP_NODELAY为true可以打破这种缓冲模式,这样所有的包一就绪就能发送。
- SO_LINGER选项规定,当socket关闭时如何处理尚未发送的数据包。默认情况下,close()方法将立即返回,但系统仍会尝试发送剩余的数据。如果延迟时间设置为0,那么当socket关闭时,所有为发送的数据将都被丢弃。如果延迟hi见设置为任意正数,那么close()方法将会阻塞指定秒数,等待数据发送和接收回应,然后socket就会被关闭,所有剩余的数据都不会发送,也不会收到回应。这个参数和SO_REUSEADDR选项可以在一定程度上解决time_wait状态占用端口问题,但是同样会带来一些问题。具体可以参考笔者前面写的一篇文章如何解决time_wait状态占用端口问题。
- 当socket关闭时,为了确保收到所有寻址到此端口的延迟数据,会等待一段时间,也就是进入所谓的time_wait状态。系统不会对后接收的任何包进行操作,只是希望确保这些数据不会偶然地传入绑定于相同端口的新进程。如果开启SO_REUSEADDR(默认情况是关闭),就允许另一个socket绑定到一个尚未释放的端口,尽管此时仍有可能存在前一个socket未接收的数据。
- 如果启用SO_KEEPALIVE,客户端会偶尔通过一个空闲连接发送一个数据包(一般两小时一次),以确保服务器为崩溃。如果服务器没有响应此包,客户端会尝试11分钟多的时间,知道接收到响应为止。如果在12分钟内未收到响应,客户端就关闭socket。没有SO_KEEPALIVE,不活动的客户端可能会永久存在下去,而不会注意到服务器已经崩溃。SO_KEEPALIVE默认值是false。
SocketAddress
SocketAddress类的主要用途是为暂时的socket连接信息(IP地址和端口)提供方便的存储,这些信息可以重用以创建新的socket,即使最初的socket已断开并被垃圾回收。为此,Socket类提供了两个返回SocketAddress的方法:
- getRemoteSocketAddress
- getLocalSocketAddress
对于未连接的socket,通过connect()方法进行连接时就必须使用SocketAddress:
public void connect(SocketAddress endpoint) throws IOException
public void connect(SocketAddress endpoint, int timeout) throws IOException
ServerSocket
对于接收连接的服务器,Java提供了服务器Socket的ServerSocket类。
构造函数
- public ServerSocket(int port) throws BindException, IOException
- public ServerSocket(int port, int queueLength) throws BindException, IOException
- public ServerSocket(int port, int queueLenght, InetAddress bindAddress) throws IOException
- public ServerSocket() throws IOException
无参构造函数需要在以后使用bind()进行绑定:
- public void bind(SocketAddress endpoint) throws IOException
- public void bind(SocketAddress endpoint, int queueLength) throws IOException
主要用途是,允许程序在绑定端口前设置服务器Socket的选项。
ServerSocket ss = new ServerSocket();
// 设置socket选项
SocketAddress http = new InetSocketAddress(80);
ss.bind(http);
接受和关闭连接
- public Socket accept() throws IOException
- public void close() throws IOException
UDP: DatagramPacket和DatagramSocket
UDP速度很快,但是不可靠。它没有连接的概念。其次,不想TCP是面向字节流的,UDP是面向数据包的,是有报文边界的。
TIPS
TCP和UDP的区别一般可以通过电话系统和邮局来做对照解释。TCP就像电话系统,当你拨号时,电话会得到应答,在双方之间建立起一个连接。当你拨号时,你知道另一方会以你说的顺序听到你说的话。如果电话忙或者没有人应答,你会马上发现。相反,UDP就像邮局系统。你向一个地址发送邮件包,大多数信件都会到达,但有些可能会在路上丢失。信件可能以发送的顺序到达,但无法保证这点。离接收方越远,邮件就越有可能丢失或者乱序到达。如果这是个问题,你可以在信封上写上序号,然后要求接收方以正确的顺序排列,并向你发邮件来告诉哪些邮件已到达,这样可以重新发送丢失的邮件。但是,你和对方需要预先约定协商好此协议,邮局不会为你做这件事情。
Java中UDP的实现分为两个类:DatagramPacket和DatagramSocket。DatagramPacket类将数据字节填充到称为数据报(datagram)的UDP包中。而DatagramSocket可以收发DatagramPacket数据报。
DatagramPacket
由于端口号是以2字节无符号整数给出,因此每台主机有65536个不同的UDP端口可以使用。因为TCP端口和UDP端口没有关联,所以TCP和UDP是可以使用相同的端口号的。
因为长度也是以2字节无符号整数给出,所以数据报中的字节数限制为65536-8个字节(首部要用8个字节)。不过,这与IP首部中的数据报长度字段是冗余的,IP首部将数据报限制在65467~65507字节之间(具体是多少取决于IP首部的大小)。
虽然UDP包中的数据的理论最大数量是65507字节,但实际上几乎总是比这少得多。在许多平台下,实际的限制是8192字节(8K)。因此,如果程序依赖于发送长于8K数据的UDP包,要对这些程序多加小心。大多数时候,更大的包会被简单地截取到8K数据,Java程序将得不到任何通知(毕竟UDP是一种不可靠的协议)。
在Java中,UDP数据报用DatagramPacket类的实例表示:
public final class DatagramPacket extends Object
接收数据报的构造函数
- public DatagramPacket(byte[] buffer, int length)
- public DatagramPacket(byte[] buffer, int offset, int length)
说明 length 必须 <= buffer.length - offset。否则会抛IllegalArgumentException。
示例代码:
byte[] buffer = new byte[8192];
DatagramPacket dp = new DategramPacket(buffer, buffer.length);
发送数据报的构造函数
- public DatagramPacket(byte[] data, int length, InetAddress destination, int port)
- public DatagramPacket(byte[] data, int offset, int length, InetAddress destination, int port) // Java 1.2
- public DatagramPacket(byte[] data, int length, SocketAddress destination, int port) // Java 1.4
- public DatagramPacket(byte[] data, int offset, int length, SocketAddress destination, int port) // Java 1.4
获取和设置数据包中的数据
- public byte[] getData()
- public void setData(byte[] data)
TIPS
可以看到,虽然UDP是面向报文的,但是实际上操作的也是字节数组。发送和获取UDP数据都是如此。所以如何与byte数组打交道才是最重要的。一般来说,如果是文本内容,可以将byte[]与String进行转换,注意编码问题:
String s = new String(datagramPacket.getData(), "UTF-8");
如果是二进制内容,那么可以转换为ByteArrayInputStream:
InputStream in = new ByteArrayInputStream(packet.getData(), packet.getOffset(), packet.getLength());
然后ByteArrayInputStream可以链接到DataInputStream:
DataInputStream din = new DataInputStream(in);
接下来,就可以使用DataInputStream的readLong()、readInt()、readChar()及其他方法读取数据了。当然,这是假定数据报的发送方使用的数据格式与Java使用的数据格式相同的情况的做法。如果不是,那么不能这样子反序列化数据。
注意:当构造ByteArrayInputStream时,必须指明offset和length。因为packet.getData()返回的数组可能包括没有拿到网络数据填充的额外空间。这些空间包含的数据即为构造DatagramPacket时该数组相应部分中包含的任意随机值。
DatagramSocket
构造函数
- public DatagramSocket() throws SocketException // 绑定匿名端口
- public DatagramSocket(int port) throws SocketException // 指定端口
- public DatagramSocket(int port, InetAddress interface) throws SocketException
- public DatagramSocket(SocketAddress interface) throws SocketException // Java 1.4
- public DatagramSocket(DatagramSocketImpl imp) throws SocketException // Java 1.4
收发数据报
- public void send(DatagramPacket dp) throws IOException
- public void receive(DatagramPacket dp) throws IOException
说明
- receive()方法会阻塞调用线程,直到数据报到达。可以通过SO_TIMEOUT设置超时时间。
- 数据报的缓冲区应当足够大,以保存接收的数据。否则,receive()会在缓冲区中放置能保存的尽可能多的数据;其他数据就会丢失。因为UDP数据报的数据部分最长为65507字节,所以最多需要分配65507字节空间就可以了。具体的值可以协商确定。
非阻塞IO
这块是个大头,而且基本是全新的内容。所以不在这里讨论,放在后面的博文中介绍。