在客户端-服务端通信模型中,Socket(套接字)是两台主机之间的一个连接,对程序员掩盖了网络复杂的底层细节。一个 Socket 由一个 IP 地址或主机名,一个端对端协议(TCP 或 UDP)以及一个端口号唯一确定。不同类型的 Socket 与不同类型的底层协议簇以及同一协议簇中的不同协议栈相关联,其中 TCP/IP 协议簇使用的 Socket 类型为流套接字(Stream Socket)和数据报套接字(Datagram Socket)。

流套接字将 TCP 作为其端对端协议,提供了一个可信赖的字节流服务。一旦建立了 socket 连接,本地和远程主机就从这个 socket 得到输入流和输出流,使用这两个流相互发送数据。连接是全双工(full-duplex)的,即两台主机都可以同时发送和接收数据。数据报套接字使用 UDP 协议,提供了一个 “尽力而为” 的数据报服务。

Java 为 TCP 协议提供了两个类:java.net.Socket 类和 java.net.ServerSocket 类。一个 TCP 连接是一条抽象的双向信道,两端分别由 IP 地址和端口号确定。在开始通信前要建立一个 TCP 连接,由客户端向服务端先发送连接请求,服务端的 ServerSocket 实例则监听客户端发送的 TCP 连接请求并为每个请求创建新的 Socket 实例进行通信。
image.png

客户端 Socket

客户端向服务器发起连接请求后,就被动地等待服务器的响应。典型的 TCP 客户端要经过下面三步:

  • 创建一个 Socket 实例,指定远程主机的 IP 地址和端口
  • 通过套接字的输入输出流通信,每个 Socket 实例都关联了一个 InputStream 和 OutputStream,通过将字节写入 OutputStream 来发送数据,通过从 InputStream 读取字节来接收数据
  • 使用 Socket 类的 close() 方法关闭连接

完整示例代码如下:

  1. public class SocketEchoClient {
  2. private static final String IP_ADDRESS = "127.0.0.1";
  3. private static final int PORT = 7777;
  4. public static void main(String[] args) {
  5. try (Socket socket = new Socket(IP_ADDRESS, PORT)) {
  6. socket.setSoTimeout(15000);
  7. BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  8. PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
  9. writer.println("Hello Socket");
  10. // 阻塞等待服务器端发送过来数据进行读取
  11. String message = reader.readLine();
  12. if (message != null) {
  13. System.out.println("Receive Server Response:" + message);
  14. }
  15. } catch (Exception e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. }

下面我们来介绍 java.net.Socket 类的使用:

1. 创建连接

Socket 的构造方法有以下几种重载形式:

  1. public Socket()
  2. public Socket(String host, int port) throws UnknownHostException, IOException
  3. public Socket(InetAddress address, int port) throws IOException
  4. public Socket(String host, int port,
  5. InetAddress localAddr, int localPort) throws IOException
  6. public Socket(InetAddress address, int port,
  7. InetAddress localAddr, int localPort) throws IOException

第一个不带参数的构造方法创建了一个尚未连接的套接字,在使用它进行通信之前,必须进行显示连接。

其他四个构造方法在创建时都会试图建立与服务器的连接,如果连接成功就返回 Socket 对象,如果因为某些原因失败就抛出 IOException 异常。其中,前两个构造方法没有指定本地地址和端口号,因此将采用默认地址并由操作系统随机分配可用端口号。后两个构造方法可以指定本地网络接口和端口来连接,如果 localPort 传入 0 会随机选择 1024~65535 之间的一个可用端口。

当创建 Socket 与服务器连接时,受底层网络传输速度的影响,可能会处于长时间的等待状态。默认 Socket 会一直等待下去,直到连接成功或出现异常,如果希望限定等待连接的时间,可以在 connect() 中指定等待连接的毫秒数,默认值为 0 表示永远等待。示例如下:

  1. Socket socket = new Socket();
  2. SocketAddress address = new InetSocketAddress("10.58.10.73", 9200);
  3. socket.connect(address, 1000);

如果在 1 秒钟后还没有连接成功,也没有出现异常则会抛出 SocketTimeoutException。

在建立连接之后,最好再使用 setSoTimeout() 方法为连接设置一个超时时间,这个超时时间表示对这个 socket 的每一个读、写最多耗费一定的毫秒数,用于防止一个有问题的服务器接受了连接却一直没有响应。

  1. socket.setSoTimeout(1000);

2. 获取信息

在一个 Socket 对象中同时包含了远程服务器的 IP 地址和端口信息,以及本地客户端的 IP 地址和端口信息,这些信息是在 Socket 连接成功后确定下来的,并且不会被修改。

  1. public InetAddress getInetAddress()
  2. public int getPort()
  3. public InetAddress getLocalAddress()
  4. public int getLocalPort()

此外,从 Socket 对象中还可以获得输入输出流,分别用于向服务器发送数据以及接收从服务端发送来的数据。不过大多数协议都设计为客户端只读取 socket 或只写入 socket,而不是二者同时进行。

  1. public InputStream getInputStream() throws IOException
  2. public OutputStream getOutputStream() throws IOException

在获取输入输出流时,如果 Socket 还没有连接或者已经关闭,则会抛出 IOException 异常。Socket 还提供了三个测试方法用来检测当前状态:

  1. public boolean isClosed()
  2. public boolean isConnected()
  3. public boolean isBound()

如果 Socket 是关闭的,isClosed 方法会返回 true 否则返回 false。但如果 Socket 从一开始就未连接,该方法也会返回 false,尽管 Socket 实际上根本没有打开过。

isConnected 这个方法有一点误导,它并不指示 Socket 当前是否连接到一个远程主机,而是指出 Socket 是否从未连接过一个远程主机。如果这个 Socket 能够连接远程主机,该方法就会返回 true,即使这个 Socket 已经关闭。要查看一个 Socket 当前是否打开,需要 isConnected 返回 true 且 isClosed 返回 false。

isBound 方法会告诉我们 Socket 是否成功地绑定到本地系统上的出站端口。

3. 设置 Socket 选项

Socket 选项指定了 Socket 类所依赖的原生 socket 如何发送和接收数据。对于客户端 Socket,Java 支持如下九个选项:

  • TCP_NODELAY
  • SO_TIMEOUT
  • SO_LINGER
  • SO_SNDBUF
  • SO_RCVBUF
  • SO_KEEPALIVE
  • SO_REUSEADDR
  • SO_OOBINLINE
  • IP_TOS

这些选项的名字来自 UNIX 所使用的 C 头文件中的命名常量,作者在编写 Socket 类时直接遵循了经典 UNIX C 命名规则,而不是更清晰的 Java 命名规则。

TCP_NODELAY

  1. public void setTcpNoDelay(boolean on) throws SocketException
  2. public boolean getTcpNoDelay() throws SocketException

设置 TCP_NODELAY 值为 true 可确保包会尽可能快地发送,而无论包的大小。默认情况下,发送数据采用 Negale 算法,发送方发送的数据不会立刻发出,而是先放在缓冲区内,等缓冲区满了再发出。并且发送完一批数据后会等待接收方对这批数据的响应,然后再发送下一批数据。Negale 算法适用于发送方需要发送大批量数据且接收方会及时响应的场合,通过减少传输数据的次数来提高通信效率。

但如果发送方持续地发送小批量数据且接收方不一定会立即响应,那么 Negale 算法由于采用了缓冲会使发送方运行很慢。设置 TCP_NODELAY 为 true 可以打破这种缓冲模式,这样所有包一旦就绪就会发送。如果 Socket 的底层实现不支持 TCP_NODELAY 选项,则会抛出 SocketException 异常。

SO_TIMEOUT

  1. public synchronized void setSoTimeout(int timeout) throws SocketException
  2. public synchronized int getSoTimeout() throws SocketException

正常情况下,尝试从 Socket 读取数据时,read() 调用会阻塞尽可能长的时间来得到足够的字节。设置该选项可以确保阻塞的时间不会超过某个固定的毫秒数。当时间到期时就会抛出一个 InterruptedIOException 异常,不过 Socket 仍然是连接的,可以再次尝试读取该 Socket。

该选项的默认值为 0,解释为无限超时。

SO_LINGER

  1. public void setSoLinger(boolean on, int linger) throws SocketException
  2. public int getSoLinger() throws SocketException

SO_LINGER 选项指定了 Socket 关闭时如何处理尚未发送的数据报。默认情况下,close() 方法会立即返回,但底层的 Socket 实际上并不会立即关闭,它会尝试发送完剩余的数据,才真正关闭 Socket,断开连接,即执行完 TCP 连接的四次挥手过程。

如果 SO_LINGER 选项开启且延迟时间设置为 0,那么当 Socket 关闭时,所有未发送的数据包都将被丢弃。底层会立刻发送一个 RST 标志给对端,表示该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接强行关闭。在这种情况下,未发送的数据包不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在 read() 调用上时,接受到 RST 时,会立刻得到一个 connet reset by peer 的异常。设置为 0 是一个非常危险的行为,不值得提倡。

如果延迟时间设置为任意正数(秒),close() 方法会阻塞直到数据被发送出去和接收确认,或者直到设置的时间结束。

SO_SNDBUF 和 SO_RCVBUF

  1. public synchronized void setSendBufferSize(int size) throws SocketException
  2. public synchronized int getSendBufferSize() throws SocketException
  3. public synchronized void setReceiveBufferSize(int size) throws SocketException
  4. public synchronized int getReceiveBufferSize() throws SocketException

TCP 使用缓冲区提升网络性能。当 TCP 三次握手成功建立连接后,操作系统内核会为每一个连接创建配套的基础设施,比如发送和接收缓冲区。当我们的应用程序调用 write 方法时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去,write 方法会阻塞直到数据全部拷贝到内核缓冲区中才返回。返回时应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去,对我们而言完全都是透明的。

SO_RCVBUF 选项控制用于网络输入的建议的接收缓冲区大小,SO_SNDBUF 选项控制用于网络输入的建议的发送缓冲区大小。尽管可以独立地设置发送和接收缓冲区,但实际上在底层的 TCP 栈中,缓冲区通常会设置为二者中较小的一个。

当传输连续的大数据块时(如 FTP 和 HTTP)可以从大缓冲区中受益;而对于交互式会话的小数据量传输(如 Telnet 和游戏)而言,大缓冲区则没有多大帮助。如果你发现应用不能充分利用可用带宽,那么可以试着增加缓冲区大小。但如果存在丢包和拥塞现象,则要减少缓冲区大小。不过大多数情况下,默认值就很合适,操作系统使用 TCP 滑动窗口来动态调整缓冲区大小以适应网络情况。

SO_KEEPALIVE

  1. public void setKeepAlive(boolean on) throws SocketException
  2. public boolean getKeepAlive() throws SocketException

如果打开了 SO_KEEPALIVE 选项,客户端会定时(默认 2 小时一次)通过一个空闲连接发送一个数据包以确保服务器未崩溃。如果服务器没能响应这个包,客户端会持续尝试 11 分钟多(默认探测间隔 75 秒,默认探测次数 9 次)的时间,直到接收到响应为止。如果超时还未收到响应,客户端就会自动关闭 socket。

如果没有 SO_KEEPALIVE,不活动的客户端可能会永远存在下去,而不会注意到服务器已经崩溃。

SO_REUSEADDR

  1. public void setReuseAddress(boolean on) throws SocketException
  2. public boolean getReuseAddress() throws SocketException

一个 Socket 关闭时,可能不会立即释放本地端口(TCP 四次挥手),有时会等待一小段时间,确保接收到所有要发送到这个端口的延迟数据包,Socket 关闭时这些数据包可能仍在网络上传输。系统不会对接收的延迟包做任何处理,只是希望确保这些数据不会意外地传入绑定到同一端口的新进程。

如果开启 SO_REUSEADDR(默认关闭,阻止其他 Socket 同时使用这个端口)选项就允许另一个 Socket 绑定到这个还未释放的端口上,即可以复用处于 TIME_WAIT 状态的 TCP 连接以避免未释放的连接过多导致新创建连接时没有可用端口,即使此时仍有可能存在前一个 Socket 未接收的数据。需要使用无参构造函数以非连接状态进行创建,然后进行设置,再使用 connect() 方法连接 Socket。

SO_OOBINLINE

  1. public void setOOBInline(boolean on) throws SocketException
  2. public boolean getOOBInline() throws SocketException

当 SO_OOBINLINE 设置为 true 时,表示支持发送一个字节的 TCP 紧急数据。这个数据会立即发送,并且当接收方收到紧急数据时会得到通知,在处理其他已收到的数据之前可以选择先处理这个紧急数据。默认情况下,Java 会忽略从 Socket 接收的紧急数据。

IP_TOS 服务类型
不同类型的 Internet 服务有不同的性能需求,比如视频需要相对较高的带宽和较短的延迟,而电子邮件可以通过低带宽的连接传递,不同种类的服务有不同的定价,用户可以根据自己的需求选择不同的服务类型。

服务类型存储在 IP 首部中一个名为 IP_TOS 的 8 位字段中,在 Java 中可通过如下两个方法进行设置:

  1. public void setTrafficClass(int tc) throws SocketException
  2. public int getTrafficClass() throws SocketException

由于这个值要复制到 TCP 首部中的一个 8 位字段,所以只使用这个 int 的低字节,超出 0~255 的范围会抛出一个参数异常。在 JDK 1.5 中,Socket 类还提供了如下方法为连接时间、延迟和带宽指定相对优先性:

  1. public void setPerformancePreferences(int connectionTime, int latency, int bandwidth)

可以为这三个参数赋予任意的整数,这些整数之间的相对大小就决定了相应参数的相对重要性。

4. 关闭连接

当客户端与服务器端的通信结束,应及时关闭 Socket 以释放其占用的资源。通过 close() 方法可以关闭 Socket 及其关联的输入输出流,从而阻止对其进一步的操作。

  1. public synchronized void close() throws IOException

有时我们希望只关闭连接的一半,即输入或输出:

  1. public void shutdownInput() throws IOException
  2. public void shutdownOutput() throws IOException

这两个方法并不会关闭 Socket。实际上,它会调整与 Socket 连接的流,使它认为已经到了流的末尾。关闭输入流后任何没有读取的数据都将被舍弃,包括正在传输的数据,后续再读取输入流会返回 -1。关闭输出流后再写入则会抛出 IOException 异常。

注意,即使半关闭了连接或将连接的两半都关闭,使用结束后仍需要调用 close 关闭该 Socket。shutdown 方法只影响 Socket 的流,它们并不释放与 Socket 关联的资源,如占用的端口等。

服务端 ServerSocket

客户端就是向监听连接的服务器打开一个 Socket 的程序,不过只有客户端 socket 还不能与服务器对话,并没有什么用处。对于接受连接的服务器,Java 提供了 ServerSocket 类表示服务器 Socket,服务器 Socket 的任务就是建立一个通信终端,并被动地等待客户端的连接。典型的 TCP 服务器执行如下四步工作:

  • 创建一个 ServerSocket 实例并指定要监听的本地端口。
  • 循环调用 ServerSocket 的 accept() 方法阻塞获取下一个客户端连接。基于新建立的客户端连接,创建一个 Socket 实例。
  • 使用所返回的 Socket 实例的 InputStream 和 OutputStream 与客户端进行通信。
  • 通信完成后,使用 Socket 类的 close() 方法关闭该客户端套接字连接。

完整示例代码如下:

  1. public class SocketEchoServer {
  2. private static final int PORT = 7777;
  3. public static void main(String[] args) {
  4. try (ServerSocket serverSocket = new ServerSocket(PORT)) {
  5. while (true) {
  6. // 阻塞等待客户端的连接请求
  7. Socket socket = serverSocket.accept();
  8. System.out.println("接收到客户端连接请求 " + socket.getInetAddress() + ":" + socket.getPort());
  9. BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  10. PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
  11. String message = reader.readLine();
  12. if (message != null) {
  13. System.out.println("Receive Client Request:" + message);
  14. }
  15. writer.println(message);
  16. socket.close();
  17. }
  18. } catch (Exception e){
  19. e.printStackTrace();
  20. }
  21. }
  22. }

ServerSocket 实例的唯一目的,就是为新的 TCP 连接请求提供一个新的已连接的 Socket 实例。当服务器端已经准备好处理客户端请求时,就调用 accept() 方法。该方法将阻塞等待,直到有向 ServerSocket 实例指定端口的新的连接请求到来。accept() 方法将返回一个已连接到远程客户端的 Socket 实例,并已准备好读写数据。

1. 创建

ServerSocket 的构造方法有以下几种重载形式:

  1. public ServerSocket() throws IOException
  2. public ServerSocket(int port) throws IOException
  3. public ServerSocket(int port, int backlog) throws IOException
  4. public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException

第一个构造函数创建一个没有关联任何本地端口的 ServerSocket 实例,在使用该实例前必须调用 bind() 方法为其绑定端口。该方法的主要用途是,允许程序在绑定端口之前设置服务器 Socket 选项。其他构造函数都会使服务器与特定端口绑定,端口有效范围为 1~65533。如果运行时无法绑定到指定端口,则抛出一个 IOException 异常。如果把参数 port 设为 0 表示由操作系统来为服务器分配一个任意可用端口。

当服务器进程运行时,可能会同时监听到多个客户的连接请求。操作系统把这些连接请求存储在一个先进先出的队列中,许多操作系统限定了队列的最大长度,一般是 50,可通过 backlog 显式设置。当队列中的连接请求达到队列最大容量时,服务器进程所在的主机就会拒绝新的连接请求。只有当服务器进程通过 ServerSocket 的 accept() 方法从队列中取出连接请求,使队列腾出空位时,队列才能继续加入新的连接请求。

如果主机只有一个 IP 地址,那么默认情况下,服务器程序就与该 IP 地址绑定。如果主机有多个 IP 地址,可以通过 bindAddr 参数显式指定服务器要绑定的 IP 地址。

2. 获取信息

ServerSocket 类提供了两个 get 方法,可以获取这个服务器 Socket 占用的本地地址和端口:

  1. public InetAddress getInetAddress()
  2. public int getLocalPort()

在创建 ServerSocket 时,如果把端口设为 0,那么将由操作系统为服务器分配一个可用端口(又称为匿名端口),此时通过 getLocalPort 就能获取这个匿名端口号。

  1. public Socket accept() throws IOException

ServerSocket 的 accept() 方法从连接请求队列中取出一个客户的连接请求,然后创建与客户端连接的 Socket 对象并将它返回。如果队列中没有连接请求,accept() 会一直阻塞直到接收到了连接请求。

3. 设置 ServerSocket 选项

Socket 选项指定了 ServerSocket 类所依赖的原生 socket 如何发送和接收数据。对于服务器 Socket,Java 支持如下三个选项:

  • SO_TIMEOUT
  • SO_REUSEADDR
  • SO_RCVBUF

SO_TIMEOUT

  1. public synchronized void setSoTimeout(int timeout) throws SocketException
  2. public synchronized int getSoTimeout() throws IOException

SO_TIMEOUT 是 accept() 在抛出 InterruptedIOException 异常前等待入站连接的时间,单位为毫秒。默认该选项的值为 0,表示永远不会超时。

SO_REUSEADDR

  1. public void setReuseAddress(boolean on) throws SocketException
  2. public boolean getReuseAddress() throws SocketException

服务器 Socket 的 SO_REUSEADDR 选项与客户端 Socket 的 SO_REUSEADDR 选项非常类似。它确定了是否允许一个新的 Socket 绑定到之前使用过的一个端口,而此时可能还有一些发送到原 Socket 的数据正在网络上传输。

SO_RCVBUF

  1. public synchronized void setReceiveBufferSize(int size) throws SocketException
  2. public synchronized int getReceiveBufferSize() throws SocketException

SO_RCVBUF 选项设置了服务器 Socket 接受的客户端 Socket 默认接收缓冲区大小,功能等同于在 accpet() 返回的各个 Socket 上调用 setReceiveBufferSize() 方法。

4. 关闭连接

  1. public void close() throws IOException

如果使用完一个服务器 Socket 就应当将它关闭,可以通过调用 close 方法使服务器释放占用的端口,并且断开与该 ServerSocket 已经接受的目前处于打开状态的所有 Socket。当关闭一个服务器 socket 后就不能再重新连接了,即使是同一个端口也不可以。

注意:不要把关闭 ServerSocket 与关闭 Socket 混淆。程序结束时 ServerSocket 会自动关闭,所以如果不再需要 ServerSocket,在很短时间后程序就会结束。尽管如此,还是建议进行手动关闭,这也没有坏处。

ServerSocket 还提供了如下方法进行关闭检测:

  1. public boolean isBound()
  2. public boolean isClosed()

如果 ServerSocket 已经关闭,isClosed 方法返回 true。对于用无参 ServerSocket() 构造函数创建而且尚未绑定到某个端口的 ServerSocket 对象,并不认为它们是关闭的,对这些对象调用 isClosed 会返回 false。

isBound 方法会指出 ServerSocket 是否已经绑定到一个端口,这个方法名有些误导性,如果 ServerSocket 曾经绑定到某个端口,即使它目前已经关闭,isBound 仍会返回 true。如果要判断 ServerSocket 是否打开,就必须要同时检查 isBound 返回 true 且 isClosed 返回 false。