Java 套接字网络编程详解


Java 套接字网络编程 2007 年 8 月 2 日 一、Java套接字实现网络编程之基础篇 1 用 Java 开发网络软件非常方便和强大,Java 的这种力量来源于他独有的一套强大的用于 网络的 API,这些 API 是一系列的类和接口,均位于包 java.net 和 javax.net 中。在这篇文 章中我们将介绍套接字(Socket)慨念,同时以实例说明如何使 用 Network API 操纵套接字, 在完成本文后,你将具备编写网络低端通讯软件的能力。 1、什么是套接字(Socket)? Network API 是典型的用于基于 TCP/IP 网络 Java 程序与其他程序通讯,Network API 依靠 Socket 进行通讯。Socket 可以看成在两个程序进行通讯连接中的一个端点,一个程序将 一段信息写入 Socket 中,该 Socket 将这段信息发送给另外一个 Socket 中,使这段信息能传 送到其他程序中。如图 1 我们来分析一下图 1,Host A 上的程序 A 将一段信息写入 Socket 中,Socket 的内容被 Host A 的网络管理软件访问,并将这段信息通 过 Host A 的网络接口卡发送到 Host B,Host B 的网络接口卡接收到这段信息后,传送给 Host B 的网络管理软件,网络管理软件将这段信息 保 存在 Host B 的 Socket 中,然后程序 B 才能在 Socket 中阅读这段信息。 假设在图 1 的网络中添加第三个主机 Host C,那么 Host A 怎么知道信息被正确传送到 Host B 而不是被传送到 Host C 中了呢?基于 TCP/IP 网络中的每一个主机均被赋予了一个唯 一的 IP 地址,IP 地址是一个 32 位的无符号整数,由于没有转变成二进制,因此通常以小数点 1 分隔,如: 198.163.227.6,正如所见 IP 地址均由四个部分组成,每个部分的范围都是 0-255, 以表示 8 位地址。 值得注意的是 IP 地址都是 32 位地址,这是 IP 协议版本 4(简称 Ipv4)规定的,目前由 于 IPv4 地址已近耗尽,所以 IPv6 地址正逐渐代替 Ipv4 地址,Ipv6 地址则是 128 位无符号 整数。 假设第二个程序被加入图 1 的网络的 Host B 中,那么由 Host A 传来的信息如何能被正确 的传给程序 B 而不是传给新加入的程序呢?这是因为每一个基于 TCP/IP 网络通讯的程序都被 赋予了唯一的端口和端口号,端口是一个信息缓冲区,用于保留 Socket 中的输入/输出信息, 端口号是一个 16 位无符号整数,范围是 0-65535,以区别主机上的每一个程序(端口号就像 房屋中的房间号),低于 256 的短口号保留给标准应用程序,比如 pop3 的端口号就是 110, 每一个套接字都组合进了 IP 地址、端口、端口号,这样形成的整体就可以区别每一个套接字 t, 下面我们就来谈谈两种套接字:流套接字和自寻址数据套接字。 2、流套接字(Stream Socket) 无论何时,在两个网络应用程序之间发送和接收信息时都需要建立一个可靠的连接,流套 接字依靠 TCP 协议来保证信息正确到达目的地,实际上,IP 包有可能在网络中丢失或者在传送 过程中发生错误,任何一种情况发生,作为接受方的 TCP 将联系发送方 TCP 重新发送这个 IP 包。这就是所谓的在两个流套接字之间建立可靠的连接。 流套接字在 C/S 程序中扮演一个必需的角色,客户机程序(需要访问某些服务的网络应用 程序)创建一个扮演服务器程序的主机的 IP 地址和服务器程序(为客户端应用程序提供服务的 网络应用程序)的端口号的流套接字对象。 客户端流套接字的初始化代码将 IP 地址和端口号传递给客户端主机的网络管理软件,管理 软件将 IP 地址和端口号通过 NIC 传递给服务器端主机;服务器端主机读到经过 NIC 传递来的 数据,然后查看服务器程序是否处于监听状态,这种监听依然是通过套接字和端口来进行的;如 果服务器程序处于监听状态,那么服务器端网络管理软件就向客户机网络管理软件发出一个积极 的响应信号,接收到响应信号后,客户端流套接字初始化代码就给客户程序建立一个端口号,并 将这个端口号传递给服务器程序的套接字(服务器程序将使用这个端口号识别传来的信息是否是 属于客户程序)同时完成流套接字的初始化。 如果服务器程序没有处于监听状态,那么服务器端网络管理软件将给客户端传递一个消极 信号,收到这个消极信号后,客户程序的流套接字初始化代码将抛出一个异常对象并且不建立通 讯连接,也不创建流套接字对象。这种情形就像打电话一样,当有人的时候通讯建立,否则电话 将被挂起。 这部分的工作包括了相关联的三个类: InetAddress, Socket, 和 ServerSocket。 InetAddress 对象描绘了 32 位或 128 位 IP 地 址,Socket 对象代表了客户程序流套接字,ServerSocket 代表了服务程序流套接字,所有这 三个类均位于包 java.net 中。 3、InetAddress 类 InetAddress 类在网络 API 套接字编程中扮演了一个重要角色。参数传递给流套接字类和 自寻址套接字类构造器或非构造器方法。 InetAddress 描述了 32 位或 64 位 IP 地址,要完成 这个功能,InetAddress 类主要依靠两个支持类 Inet4Address 和 Inet6Address,这三个 类是继承关系,InetAddrress 是父类,Inet4Address 和 Inet6Address 是子类。 2 由于 InetAddress 类只有一个构造函数,而且不能传递参数,所以不能直接创建 InetAddress 对象,比如下面的做法就是错误的: InetAddress ia = new InetAddress (); 但我们可以通过下面的 5 个工厂方法创建来创建一个 InetAddress 对象或 InetAddress 数组: . getAllByName(String host)方法返回一个 InetAddress 对象的引用,每个对象包含一个 表示相应主机名的单独的 IP 地址,这个 IP 地址是通过 host 参数传递的,对于指定的主机如果 没有 IP 地址存在那么这个方法将抛出一个 UnknownHostException 异常对象。 . getByAddress(byte [] addr)方法返回一个 InetAddress 对象的引用,这个对象包含了 一个 Ipv4 地址或 Ipv6 地址,Ipv4 地址是一个 4 字节数组,Ipv6 地址是一个 16 字节地址数 组,如果返回的数组既不是 4 字节的也不是 16 字节的,那么方法将会抛出一个 UnknownHostException 异常对象。 . getByAddress(String host, byte [] addr)方法返回一个 InetAddress 对象的引用,这 个 InetAddress 对象包含了一个由 host 和 4 字节的 addr 数组指定的 IP 地址,或者是 host 和 16 字节的 addr 数组指定的 IP 地址,如果这 个数组既不是 4 字节的也不是 16 位字节的, 那么该方法将抛出一个 UnknownHostException 异常对象。 . getByName(String host)方法返回一个 InetAddress 对象,该对象包含了一个与 host 参数指定的主机相对应的 IP 地址,对于指定的主机如果没有 IP 地址存在,那么方法将抛出一 个 UnknownHostException 异常对象。 . getLocalHost()方法返回一个 InetAddress 对象,这个对象包含了本地机的 IP 地址,考 虑到本地主机既是客户程序主机又是服务器程序主机,为避免混乱,我们将客户程序主机称为客 户主机,将服务器程序主机称为服务器主机。 上面讲到的方法均提到返回一个或多个 InetAddress 对象的引用,实际上每一个方法都要 返回一个或多个 Inet4Address/Inet6Address 对象的引用,调用者不需要知道引用的子类型, 相反调用者可以使用返回的引用调用 InetAddress 对象的非静态方法,包括子类型的多态以确 保重载方法被调用。 InetAddress 和它的子类型对象处理主机名到主机 IPv4 或 IPv6 地址的转换,要完成这个 转换需要使用域名系统,下面的代码示范了如何通过调用 getByName(String host)方法获得 InetAddress 子类对象的方法,这个对象包含了与 host 参数相对应的 IP 地址: InetAddress ia = InetAddress.getByName ("www.javajeff.com")); 一但获得了 InetAddress 子类对象的引用就可以调用 InetAddress 的各种方法来获得 InetAddress 子类对象中的 IP 地址信 息,比如,可以通过调用 getCanonicalHostName() 从域名服务中获得标准的主机名;getHostAddress()获得 IP 地址, getHostName()获得主 机名,isLoopbackAddress()判断 IP 地址是否是一个 loopback 地址。 List1 是一段示范代码:InetAddressDemo 3 // InetAddressDemo.java import java.net.*; class InetAddressDemo { public static void main (String [] args) throws UnknownHostException { String host = "localhost"; if (args.length == 1) host = args [0]; InetAddress ia = InetAddress.getByName (host); System.out.println ("Canonical Host Name = " +ia.getCanonicalHostName ()); System.out.println ("Host Address = " +ia.getHostAddress ()); System.out.println ("Host Name = " +ia.getHostName ()); System.out.println ("Is Loopback Address = " +ia.isLoopbackAddress ()); } } 当无命令行参数时,代码输出类似下面的结果: Canonical Host Name = localhost Host Address = 127.0.0.1 Host Name = localhost Is Loopback Address = true InetAddressDemo 给了你一个指定主机名作为命令行参数的选择,如果没有主机名被指定, 那么将使用 localhost(客户机的), InetAddressDemo 通过调用 getByName(String host) 方法获得一个 InetAddress 子类对象的引用,通过这个引用 获得了标准主机名,主机地址, 主机名以及 IP 地址是否是 loopback 地址的输出。 4、Socket 类 当客户程序需要与服务器程序通讯的时候,客户程序在客户机创建一个 socket 对象, Socket 类有几个构造函数。两个常用的构造函数 是 Socket(InetAddress addr, int port) 和 Socket(String host, int port),两个构造函数 都创建了一个基于 Socket 的连接服务器端流套接字的流套接字。对于第一个 InetAddress 子 类对象通过 addr 参数获得服务器主机的 IP 地址,对于第二个函数 host 参数包被分配到 InetAddress 对象中,如果没有 IP 地址与 host 参数相一致,那么将抛出 UnknownHostException 异常对象。两个函数都通过参数 port 获得服务器的端口号。假设已 经建立连接了,网络 API 将在客户端基于 Socket 的流套接字中捆绑客户程序的 IP 地址和任意 一个端口号,否则两个函数都会抛出一个 IOException 对象。 4 如果创建了一个 Socket 对象,那么它可能通过调用 Socket 的 getInputStream()方法 从服务程序获得输入流读传送来的信息,也可能通过调用 Socket 的 getOutputStream()方法 获得输出流来发送消息。在读写活动完成之后,客户程序调用 close()方法关闭流和流套接字, 下面的代码创建了一个服务程序主机地址为 198.163.227.6,端口号为 13 的 Socket 对象, 然后从这个新创建的 Socket 对象中读取输入流,然后再关闭流和 Socket 对象。 Socket s = new Socket ("198.163.227.6", 13); InputStream is = s.getInputStream (); // Read from the stream. is.close (); s.close (); 接下面我们将示范一个流套接字的客户程序,这个程序将创建一个 Socket 对象,Socket 将访问运行在指定主机端口 10000 上的服务程序,如果访问成功客户程序将给服务程序发送一 系列命令并打印服务程序的响应。List2 是我们创建的程序 SSClient 的源代码: Listing 2: SSClient.java // SSClient.java import java.io.*; import java.net.*; class SSClient { public static void main (String [] args) { String host = "localhost"; // If user specifies a command-line argument, that argument // represents the host name. if (args.length == 1) host = args [0]; BufferedReader br = null; PrintWriter pw = null; Socket s = null; 5 try { // Create a socket that attempts to connect to the server // program on the host at port 10000. s = new Socket (host, 10000); // Create an input stream reader that chains to the socket's // byte-oriented input stream. The input stream reader // converts bytes read from the socket to characters. The // conversion is based on the platform's default character // set. InputStreamReader isr; isr = new InputStreamReader (s.getInputStream ()); // Create a buffered reader that chains to the input stream // reader. The buffered reader supplies a convenient method // for reading entire lines of text. br = new BufferedReader (isr); // Create a print writer that chains to the socket's byte- // oriented output stream. The print writer creates an // intermediate output stream writer that converts // characters sent to the socket to bytes. The conversion // is based on the platform's default character set. pw = new PrintWriter (s.getOutputStream (), true); 6 // Send the DATE command to the server. pw.println ("DATE"); // Obtain and print the current date/time. System.out.println (br.readLine ()); // Send the PAUSE command to the server. This allows several // clients to start and verifies that the server is spawning // multiple threads. pw.println ("PAUSE"); // Send the DOW command to the server. pw.println ("DOW"); // Obtain and print the current day of week. System.out.println (br.readLine ()); // Send the DOM command to the server. pw.println ("DOM"); // Obtain and print the current day of month. System.out.println (br.readLine ()); // Send the DOY command to the server. pw.println ("DOY"); // Obtain and print the current day of year. System.out.println (br.readLine ()); } 7 catch (IOException e) { System.out.println (e.toString ()); } finally { try { if (br != null) br.close (); if (pw != null) pw.close (); if (s != null) s.close (); } catch (IOException e) { } } } } 运行这段程序将会得到下面的结果: 8 Tue Jan 29 18:11:51 CST 2002 TUESDAY 29 29 SSClient 创建了一个 Socket 对象与运行在主机端口 10000 的服务程序联系,主机的 IP 地址由 host 变量确定。SSClient 将获得 Socket 的输入输出流,围绕 BufferedReader 的输 入流和 PrintWriter 的输出流对字符串进行读写操作就变得非常容易, SSClient 向服务程序发 出各种 date/time 命令并得到响应,每个响应均被打印,一旦最后一个响应被打印,将执行 Try/Catch/Finally 结构的 Finally 子串,Finally 子串将在关闭 Socket 之前关闭 BufferedReader 和 PrintWriter。 在 SSClient 源代码编译完成后,可以输入 java SSClient 来执行这段程序,如果有合适 的程序运行在不同的主机上,采用主机名/IP 地址 为参数的输入方式,比如 www.sina.com.cn 是运行服务器程序的主机,那么输入方式就是 java SSClient www.sina.com.cn。 二、Java套接字实现网络编程之基础篇 2 1、技巧 Socket 类包含了许多有用的方法。比如 getLocalAddress()将返回一个包含客户程序 IP 地址的 InetAddress 子类对象的引用;getLocalPort()将返回客户程序的端口 号;getInetAddress()将返回一个包含服务器 IP 地址的 InetAddress 子类对象的引用; getPort()将返回服务程序的端口号。 2、ServerSocket 类 由于 SSClient 使用了流套接字,所以服务程序也要使用流套接字。这就要创建一个 ServerSocket 对象, ServerSocket 有几个构造函数,最简单的是 ServerSocket(int port), 当使用 ServerSocket (int port)创建一个 ServerSocket 对象,port 参数传递端口号,这个 端口就是服务器监听连接请求的端口,如果在这时出现错误将抛出 IOException 异常对象,否 则将创建 ServerSocket 对象并开始准备接收连接请求。 接下来服务程序进入无限循环之中,无限循环从调用 ServerSocket 的 accept()方法开始, 在调用开始后 accept()方法将导致调用线程阻塞直到连接建立。在建立连接后 accept()返回一 个最近创建的 Socket 对象,该 Socket 对象绑定了客户程序的 IP 地址或端口号。 由于存在单个服务程序与多个客户程序通讯的可能,所以服务程序响应客户程序不应该花 很多时间,否则客户程序在得到服务前有可能花很多时间来等待通讯的建立,然而服务程序和客 户程序的会话有可能是很长的(这与电话类似),因此为加快对客户程序连接请求的响应,典型 的方法是服务器主机运行一个后台线程,这个后台线程处理服务程序和客户程序的通讯。 9 为了示范我们在上面谈到的慨念并完成SSClient程序,下面我们创建一个SSServer程序, 程序将创建一个 ServerSocket 对象来监听端口 10000 的连接请求,如果成功服务程序将等待 连接输入,开始一个线程处理连接,并响应来自客户程序的命令。下面就是这段程序的代码: Listing 3: SSServer.java // SSServer.java import java.io.*; import java.net.*; import java.util.*; class SSServer { public static void main (String [] args) throws IOException { System.out.println ("Server starting...\n"); // Create a server socket that listens for incoming connection // requests on port 10000. ServerSocket server = new ServerSocket (10000); while (true) { // Listen for incoming connection requests from client // programs, establish a connection, and return a Socket // object that represents this connection. Socket s = server.accept (); System.out.println ("Accepting Connection...\n"); // Start a thread to handle the connection. new ServerThread (s).start (); 10 } } } class ServerThread extends Thread { private Socket s; ServerThread (Socket s) { this.s = s; } public void run () { BufferedReader br = null; PrintWriter pw = null; try { // Create an input stream reader that chains to the socket's // byte-oriented input stream. The input stream reader // converts bytes read from the socket to characters. The // conversion is based on the platform's default character // set. InputStreamReader isr; isr = new InputStreamReader (s.getInputStream ()); // Create a buffered reader that chains to the input stream // reader. The buffered reader supplies a convenient method // for reading entire lines of text. 11 br = new BufferedReader (isr); // Create a print writer that chains to the socket's byte- // oriented output stream. The print writer creates an // intermediate output stream writer that converts // characters sent to the socket to bytes. The conversion // is based on the platform's default character set. pw = new PrintWriter (s.getOutputStream (), true); // Create a calendar that makes it possible to obtain date // and time information. Calendar c = Calendar.getInstance (); // Because the client program may send multiple commands, a // loop is required. Keep looping until the client either // explicitly requests termination by sending a command // beginning with letters BYE or implicitly requests // termination by closing its output stream. do { // Obtain the client program's next command. String cmd = br.readLine (); // Exit if client program has closed its output stream. if (cmd == null) break; 12 // Convert command to uppercase, for ease of comparison. cmd = cmd.toUpperCase (); // If client program sends BYE command, terminate. if (cmd.startsWith ("BYE")) break; // If client program sends DATE or TIME command, return // current date/time to the client program. if (cmd.startsWith ("DATE") || cmd.startsWith ("TIME")) pw.println (c.getTime ().toString ()); // If client program sends DOM (Day Of Month) command, // return current day of month to the client program. if (cmd.startsWith ("DOM")) pw.println ("" + c.get (Calendar.DAY_OF_MONTH)); // If client program sends DOW (Day Of Week) command, // return current weekday (as a string) to the client // program. if (cmd.startsWith ("DOW")) switch (c.get (Calendar.DAY_OF_WEEK)) { case Calendar.SUNDAY : pw.println ("SUNDAY"); break; case Calendar.MONDAY : pw.println ("MONDAY"); 13 break; case Calendar.TUESDAY : pw.println ("TUESDAY"); break; case Calendar.WEDNESDAY: pw.println ("WEDNESDAY"); break; case Calendar.THURSDAY : pw.println ("THURSDAY"); break; case Calendar.FRIDAY : pw.println ("FRIDAY"); break; case Calendar.SATURDAY : pw.println ("SATURDAY"); } // If client program sends DOY (Day of Year) command, // return current day of year to the client program. if (cmd.startsWith ("DOY")) pw.println ("" + c.get (Calendar.DAY_OF_YEAR)); // If client program sends PAUSE command, sleep for three // seconds. if (cmd.startsWith ("PAUSE")) try { Thread.sleep (3000); 14 } catch (InterruptedException e) { } } while (true); { catch (IOException e) { System.out.println (e.toString ()); } finally { System.out.println ("Closing Connection...\n"); try { if (br != null) br.close (); if (pw != null) pw.close (); if (s != null) s.close (); } 15 catch (IOException e) { } } } } 运行这段程序将得到下面的输出: Server starting... Accepting Connection... Closing Connection... SSServer 的源代码声明了一对类:SSServer 和 ServerThread;SSServer 的 main() 方法创建了一个 ServerSocket 对象来监听端口 10000 上的连接请求,如果成功, SSServer 进入一个无限循环中,交替调用 ServerSocket 的 accept() 方法来等待连接请求,同时启动 后台线程处理连接(accept()返回的请求)。线程由 ServerThread 继承的 start ()方法开始, 并执行 ServerThread 的 run()方法中的代码。 一旦 run()方法运行,线程将创建 BufferedReader, PrintWriter 和 Calendar 对象并进 入一个循环,这个循环由读(通过 BufferedReader 的 readLine())来自客户程序的一行文本 开始,文本(命令)存储在 cmd 引用的 string 对象中,如果客户程序过早的关闭输出流,会发 生什么呢?答案是:cmd 将得不到赋值。 注意必须考虑到这种情况:在服务程序正在读输入流时,客户程序关闭了输出流,如果没 有对这种情况进行处理,那么程序将产生异常。 一旦编译了 SSServer 的源代码,通过输入 Java SSServer 来运行程序,在开始运行 SSServer 后,就可以运行一个或多个 SSClient 程序。 三、Java网络编程精解之ServerSocket用法详解一 1 在客户/服务器通信模式中,服务器端需要创建监听特定端口的 ServerSocket, ServerSocket 负责接收客户连接请求。本章首先介绍 ServerSocket 类的各个构造方法,以 及成员方法的用法,接着介绍服务器如何用多线程来处理与多个客户的通信任务。 16 本章提供线程池的一种实现方式。线程池包括一个工作队列和若干工作线程。服务器程序 向工作队列中加入与客户通信的任务,工作线程不断从工作队列中取出任务并执行它。本章还介 绍了 java.util.concurrent 包中的线程池类的用法,在服务器程序中可以直接使用它们。 3.1 构造 ServerSocket ServerSocket 的构造方法有以下几种重载形式: ◆ServerSocket()throws IOException ◆ServerSocket(int port) throws IOException ◆ServerSocket(int port, int backlog) throws IOException ◆ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException 在以上构造方法中,参数 port 指定服务器要绑定的端口(服务器要监听的端口),参数 backlog 指定客户连接请求队列的长度,参数 bindAddr 指定服务器要绑定的 IP 地址。 3.1.1 绑定端口 除了第一个不带参数的构造方法以外,其他构造方法都会使服务器与特定端口绑定,该端 口由参数 port 指定。例如,以下代码创建了一个与 80 端口绑定的服务器: ServerSocket serverSocket=new ServerSocket(80); 如果运行时无法绑定到 80 端口,以上代码会抛出 IOException,更确切地说,是抛出 BindException,它是 IOException 的子类。BindException 一般是由以下原因造成的: ◆端口已经被其他服务器进程占用; ◆在某些操作系统中,如果没有以超级用户的身份来运行服务器程序,那么操作系统不允许服务 器绑定到 1~1023 之间的端口。 如果把参数 port 设为 0,表示由操作系统来为服务器分配一个任意可用的端口。由操作系 统分配的端口也称为匿名端口。对于多数服务器,会使用明确的端口,而不会使用匿名端口,因 为客户程序需要事先知道服务器的端口,才能方便地访问服务器。在某些场合,匿名端口有着特 殊的用途,本章 3.4 节会对此作介绍。 3.1.2 设定客户连接请求队列的长度 当服务器进程运行时,可能会同时监听到多个客户的连接请求。例如,每当一个客户进程 执行以下代码: Socket socket=new Socket(www.javathinker.org,80); 就意味着在远程www.javathinker.org主机的 80 端口上,监听到了一个客户的连接请求。 管理客户连接请求的任务是由操作系统来完成的。操作系统把这些连接请求存储在一个先进先出 的队列中。许多操作系统限定了队列的最大长度,一般为 50。当队列中的连接请求达到了队列 的最大容量时,服务器进程所在的主机会拒绝新的连接请求。只有当服务器进程通过 17 ServerSocket的accept()方法从队列中取出连接请求,使队列腾出空位时,队列才能继续加入 新的连接请求。 对于客户进程,如果它发出的连接请求被加入到服务器的队列中,就意味着客户与服务器 的连接建立成功,客户进程从 Socket 构造方法中正常返回。如果客户进程发出的连接请求被服 务器拒绝,Socket 构造方法就会抛出 ConnectionException。 ServerSocket 构造方法的 backlog 参数用来显式设置连接请求队列的长度,它将覆盖操 作系统限定的队列的最大长度。值得注意的是,在以下几种情况中,仍然会采用操作系统限定的 队列的最大长度: ◆backlog 参数的值大于操作系统限定的队列的最大长度; ◆backlog 参数的值小于或等于 0; ◆在 ServerSocket 构造方法中没有设置 backlog 参数。 以下例程 3-1 的 Client.java 和例程 3-2 的 Server.java 用来演示服务器的连接请求队列 的特性。 例程 3-1 Client.java import java.net.*; public class Client { public static void main(String args[])throws Exception{ final int length=100; String host="localhost"; int port=8000; Socket[] sockets=new Socket[length]; for(int i=0;i sockets[i]=new Socket(host, port); System.out.println("第"+(i+1)+"次连接成功"); } Thread.sleep(3000); for(int i=0;i sockets[i].close(); //断开连接 } } 18 } 例程 3-2 Server.java import java.io.*; import java.net.*; public class Server { private int port=8000; private ServerSocket serverSocket; public Server() throws IOException { serverSocket = new ServerSocket(port,3); //连接请求队列 的长度为 3 System.out.println("服务器启动"); } public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); //从连接请求队列中取出 一个连接 System.out.println("New connection accepted " + socket.getInetAddress() + ":" +socket.getPort()); }catch (IOException e) { e.printStackTrace(); }finally { try{ if(socket!=null)socket.close(); }catch (IOException e) {e.printStackTrace();} } 19 } } public static void main(String args[])throws Exception { Server server=new Server(); Thread.sleep(60000*10); //睡眠 10 分钟 //server.service(); } } Client 试图与 Server 进行 100 次连接。在 Server 类中,把连接请求队列的长度设为 3。 这意味着当队列中有了 3 个连接请求时,如果 Client 再请求连接,就会被 Server 拒绝。下面 按照以下步骤运行 Server 和 Client 程序。 (1)把 Server 类的 main()方法中的“server.service();”这行程序代码注释掉。这使得服务 器与 8000 端口绑定后,永远不会执行 serverSocket.accept()方法。这意味着队列中的连接 请求永远不会被取出。先运行 Server 程序,然后再运行 Client 程序,Client 程序的打印结果 如下: 第 1 次连接成功 第 2 次连接成功 第 3 次连接成功 Exception in thread "main" java.net.ConnectException: Connection refused: connect at java.net.PlainSocketImpl.socketConnect(Native Method) at java.net.PlainSocketImpl.doConnect(Unknown Source) at java.net.PlainSocketImpl.connectToAddress(Unknown Source) at java.net.PlainSocketImpl.connect(Unknown Source) at java.net.SocksSocketImpl.connect(Unknown Source) at java.net.Socket.connect(Unknown Source) at java.net.Socket.connect(Unknown Source) at java.net.Socket.(Unknown Source) at java.net.Socket.(Unknown Source) at Client.main(Client.java:10) 从以上打印结果可以看出,Client 与 Server 在成功地建立了 3 个连接后,就无法再创建其余 的连接了,因为服务器的队列已经满了。 (2)把 Server 类的 main()方法按如下方式修改: 20 public static void main(String args[])throws Exception { Server server=new Server(); //Thread.sleep(60000*10); //睡眠 10 分钟 server.service(); } 作了以上修改,服务器与 8 000 端口绑定后,就会在一个 while 循环中不断执行 serverSocket.accept()方法,该方法从队列 中取出连接请求,使得队列能及时腾出空位,以 容纳新的连接请求。先运行 Server 程序,然后再运行 Client 程序,Client 程序的打印结果如 下: 第 1 次连接成功 第 2 次连接成功 第 3 次连接成功 … 第 100 次连接成功 从以上打印结果可以看出,此时 Client 能顺利与 Server 建立 100 次连接。 3.1.3 设定绑定的 IP 地址 如果主机只有一个 IP 地址,那么默认情况下,服务器程序就与该 IP 地址绑定。 ServerSocket 的第 4 个构造方法 ServerSocket (int port, int backlog, InetAddress bindAddr)有一个 bindAddr 参数,它显式指定服务器 要绑定的 IP 地址,该构造方法适用于具有多 IP 地址的主机。假定一个主机有两个网卡,一个 网卡用于连接到 Internet, IP 地址为 222.67.5.94,还有一个网卡用于连接到本地局域网, IP 地址为 192.168.3.4。如果服务器仅仅被本地局域网中的客户访问,那么可以按如下方式创 建 ServerSocket: ServerSocket serverSocket=new ServerSocket(8000,10,InetAddress.getByName ("192.168. 3.4")); 3.1.4 默认构造方法的作用 ServerSocket 有一个不带参数的默认构造方法。通过该方法创建的 ServerSocket 不与 任何端口绑定,接下来还需要通过 bind()方法与特定端口绑定。 这个默认构造方法的用途是,允许服务器在绑定到特定端口之前,先设置 ServerSocket 的一些选项。因为一旦服务器与特定端口绑定,有些选项就不能再改变了。 在以下代码中,先把 ServerSocket 的 SO_REUSEADDR 选项设为 true,然后再把它与 8000 端口绑定: ServerSocket serverSocket=new ServerSocket(); serverSocket.setReuseAddress(true); //设置 21 ServerSocket 的选项 serverSocket.bind(new InetSocketAddress(8000)); //与 8000 端口绑定 如果把以上程序代码改为: ServerSocket serverSocket=new ServerSocket(8000); serverSocket.setReuseAddress(true); //设置 ServerSocket 的选项 那么 serverSocket.setReuseAddress(true)方法就不起任何作用了,因为 SO_ REUSEADDR 选项必须在服务器绑定端口之前设置才有效。 四、Java网络编程精解之ServerSocket用法详解一 2 3.2 接收和关闭与客户的连接 ServerSocket 的 accept()方法从连接请求队列中取出一个客户的连接请求,然后创建与 客户连接的 Socket 对象,并将它返回。如果队列中没有连接请求,accept()方法就会一直等 待,直到接收到了连接请求才返回。 接下来,服务器从 Socket 对象中获得输入流和输出流,就能与客户交换数据。当服务器正 在进行发送数据的操作时,如果客户端断开了连接,那么服务器端会抛出一个 IOException 的 子类 SocketException 异常: java.net.SocketException: Connection reset by peer 这只是服务器与单个客户通信中出现的异常,这种异常应该被捕获,使得服务器能继续与 其他客户通信。 以下程序显示了单线程服务器采用的通信流程: public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); //从连接请求队列中取出一 个连接 System.out.println("New connection accepted " + socket.getInetAddress() + ":" +socket.getPort()); //接收和发送数据 22 … }catch (IOException e) { //这只是与单个客户通信时遇到的异常,可能是由于客户端过早断开 连接引起的 //这种异常不应该中断整个 while 循环 e.printStackTrace(); }finally { try{ if(socket!=null) socket.close(); //与一个客户通信结束后,要关闭 Socket }catch (IOException e) {e.printStackTrace();} } } } 与单个客户通信的代码放在一个 try 代码块中,如果遇到异常,该异常被 catch 代码块捕 获。try 代码块后面还有一个 finally 代码块,它保证不管与客户通信正常结束还是异常结束, 最后都会关闭 Socket,断开与这个客户的连接。 3.3 关闭 ServerSocket ServerSocket 的 close()方法使服务器释放占用的端口,并且断开与所有客户的连接。当 一个服务器程序运行结束时,即使没有执行 ServerSocket 的 close()方法,操作系统也会释 放这个服务器占用的端口。因此,服务器程序并不一定要在结束之前执行 ServerSocket 的 close()方法。 在某些情况下,如果希望及时释放服务器的端口,以便让其他程序能占用该端口,则可以 显式调用 ServerSocket 的 close()方法。 例如, 以下代码用于扫描 1~65535 之间的端口号。 如果 ServerSocket 成功创建,意味着该端口未被其他服务器进程绑定,否者说明该端口已经 被其他进程占用: for(int port=1;port<=65535;port++){ try{ ServerSocket serverSocket=new ServerSocket(port); serverSocket.close(); //及时关闭 ServerSocket }catch(IOException e){ 23 System.out.println("端口"+port+" 已经被其他服务器进程占用 "); } } 以上程序代码创建了一个 ServerSocket 对象后,就马上关闭它,以便及时释放它占用的 端口,从而避免程序临时占用系统的大多数端口。 ServerSocket 的 isClosed()方法判断 ServerSocket 是否关闭,只有执行了 ServerSocket 的 close() 方法,isClosed()方法才返回 true;否则,即使 ServerSocket 还 没有和特定端口绑定,isClosed()方法也会返回 false。 ServerSocket 的 isBound()方法判断 ServerSocket 是否已经与一个端口绑定,只要 ServerSocket 已经与一个端口绑定,即使它已经被关闭,isBound()方法也会返回 true。 如果需要确定一个 ServerSocket 已经与特定端口绑定,并且还没有被关闭,则可以采用 以下方式: boolean isOpen=serverSocket.isBound() && !serverSocket.isClosed(); 3.4 获取 ServerSocket 的信息 ServerSocket 的以下两个 get 方法可分别获得服务器绑定的 IP 地址,以及绑定的端口: ◆public InetAddress getInetAddress() ◆public int getLocalPort() 前面已经讲到,在构造 ServerSocket 时,如果把端口设为 0,那么将由操作系统为服务 器分配一个端口(称为匿名端口),程序只要调用 getLocalPort()方法就能获知这个端口号。 如例程 3-3 所示的 RandomPort 创建了一个 ServerSocket,它使用的就是匿名端 口。 例程 3-4 TimeoutTester.java import java.io.*; import java.net.*; public class TimeoutTester{ public static void main(String args[])throws IOException{ ServerSocket serverSocket=new ServerSocket(8000); serverSocket.setSoTimeout(6000); //等待客户连接的时间不超过 6秒 Socket socket=serverSocket.accept(); socket.close(); System.out.println("服务器关闭"); 24 } } 运行以上程序,过 6 秒钟后,程序会从 serverSocket.accept()方法中抛出 Socket- TimeoutException: C:\chapter03\classes>java TimeoutTester Exception in thread "main" java.net.SocketTimeoutException: Accept timed out at java.net.PlainSocketImpl.socketAccept(Native Method) at java.net.PlainSocketImpl.accept(Unknown Source) at java.net.ServerSocket.implAccept(Unknown Source) at java.net.ServerSocket.accept(Unknown Source) at TimeoutTester.main(TimeoutTester.java:8) 如果把程序中的“serverSocket.setSoTimeout(6000)”注释掉,那么 serverSocket. accept()方法永远不会超时,它会一直等待下去,直到接收到了客户的连接, 才会从 accept()方法返回。 Tips:服务器执行 serverSocket.accept()方法时,等待客户连接的过程也称为阻塞。本书第 4 章的 4.1 节(线程阻塞的概念)详细介绍了阻塞的概念。 3.5.2 SO_REUSEADDR 选项 ◆设置该选项:public void setResuseAddress(boolean on) throws SocketException ◆读取该选项:public boolean getResuseAddress() throws SocketException 这个选项与 Socket 的 SO_REUSEADDR 选项相同,用于决定如果网络上仍然有数据向旧 的 ServerSocket 传输数据,是否允许新的 ServerSocket 绑定到与旧的 ServerSocket 同样 的端口上。SO_REUSEADDR 选项的默认值与操作系统有关,在某些操作系统中, 允许重用 端口,而在某些操作系统中不允许重用端口。 当 ServerSocket 关闭时,如果网络上还有发送到这个 ServerSocket 的数据,这个 ServerSocket 不会立刻释放本地端口,而是会等待一段时间,确保接收到了网络上发送过来的 延迟数据,然后再释放端口。 许多服务器程序都使用固定的端口。当服务器程序关闭后,有可能它的端口还会被占用一 段时间,如果此时立刻在同一个主机上重启服务器程序,由于端口已经被占用,使得服务器程序 无法绑定到该端口,服务器启动失败,并抛出 BindException: Exception in thread "main" java.net.BindException: Address already in use: JVM_Bind 为了确保一个进程关闭了 ServerSocket 后,即使操作系统还没释放端口,同一个主机上的其 他进程还可以立刻重用该端口,可以调用 ServerSocket 的 setResuse- Address(true)方法: 25 if(!serverSocket.getResuseAddress())serverSocket.setResuseAddress(true); 值得注意的是,serverSocket.setResuseAddress(true)方法必须在 ServerSocket 还 没有绑定到一个本地 端口之前调用,否则执行 serverSocket.setResuseAddress(true)方法 无效。此外,两个共用同一个端口的进程必须都调用 serverSocket.setResuseAddress(true) 方法,才能使得一个进程关闭 ServerSocket 后,另一个进程的 ServerSocket 还能够立刻重 用相同端口。 3.5.3 SO_RCVBUF 选项 ◆设置该选项:public void setReceiveBufferSize(int size) throws SocketException ◆读取该选项:public int getReceiveBufferSize() throws SocketException SO_RCVBUF 表示服务器端的用于接收数据的缓冲区的大小,以字节为单位。一般说来, 传输大的连续的数据块(基于 HTTP 或 FTP 协议的数据传 输)可以使用较大的缓冲区,这可以 减少传输数据的次数,从而提高传输数据的效率。而对于交互式的通信(Telnet 和网络游戏), 则应该采用小的缓冲区, 确保能及时把小批量的数据发送给对方。 SO_RCVBUF 的默认值与操作系统有关。例如,在 Windows 2000 中运行以下代码时, 显示 SO_RCVBUF 的默认值为 8192: ServerSocket serverSocket=new ServerSocket(8000); System.out.println(serverSocket.getReceiveBufferSize()); // 打印 8192 无论在 ServerSocket 绑定到特定端口之前或之后,调用 setReceiveBufferSize()方法都 有效。例外情况下是如果要设置 大于 64K 的缓冲区,则必须在 ServerSocket 绑定到特定端 口之前进行设置才有效。例如,以下代码把缓冲区设为 128K: ServerSocket serverSocket=new ServerSocket(); int size=serverSocket.getReceiveBufferSize(); if(size<131072) serverSocket.setReceiveBufferSize(131072); // 把缓冲区的大小设为 128K serverSocket.bind(new InetSocketAddress(8000)); //与 8 000 端口绑定 执行 serverSocket.setReceiveBufferSize()方法,相当于对所有由 serverSocket.accept()方法返回的 Socket 设置接收数据的缓冲区的大小。 3.5.4 设定连接时间、延迟和带宽的相对重要性 26 ◆ public void setPerformancePreferences(int connectionTime,int latency,int bandwid th) 该方法的作用与 Socket 的 setPerformancePreferences()方法的作用相同,用于设定连接时 间、延迟和带宽的相对重要性,参见本书第 2 章的 2.5.10 节(设定连接时间、延迟和带宽的相 对重要性)。 五、Java网络编程精解之ServerSocket用法详解二 1 3.6 创建多线程的服务器 在本书第 1 章的 1.5.1 节的例程 1-2 的 EchoServer 中,其 service()方法负责接收客户 连接,以及与客户通信。service()方法的处理流程如下: while (true) { Socket socket=null; try { socket = serverSocket.accept(); //接收客户连接 //从 Socket 中获得输入流与输出流,与客户通信 … }catch (IOException e) { e.printStackTrace(); }finally { try{ if(socket!=null)socket.close(); //断开连接 }catch (IOException e) {e.printStackTrace();} } } EchoServer 接收到一个客户连接,就与客户进行通信,通信完毕后断开连接,然后再接收下一 个客户连接。假如同时有多个客户请求连接,这些客户就必须排队等候 EchoServer 的响应。 EchoServer 无法同时与多个客户通信。 许多实际应用要求服务器具有同时为多个客户提供服务的能力。HTTP 服务器就是最明显的 例子。任何时刻,HTTP 服务器都可能接收到大量的客户请求,每个客户都希望能快速得到 HTTP 服务器的响应。如果长时间让客户等待,会使网站失去信誉,从而降低访问量。 27 可以用并发性能来衡量一个服务器同时响应多个客户的能力。一个具有好的并发性能的服 务器,必须符合两个条件: ◆能同时接收并处理多个客户连接; ◆对于每个客户,都会迅速给予响应。 服务器同时处理的客户连接数目越多,并且对每个客户作出响应的速度越快,就表明并发 性能越高。 用多个线程来同时为多个客户提供服务,这是提高服务器的并发性能的最常用的手段。本 节将按照 3 种方式来重新实现 EchoServer,它们都使用了多线程。 ◆为每个客户分配一个工作线程。 ◆创建一个线程池,由其中的工作线程来为客户服务。 ◆利用 JDK 的 Java 类库中现成的线程池,由它的工作线程来为客户服务。 3.6.1 为每个客户分配一个线程 服务器的主线程负责接收客户的连接,每次接收到一个客户连接,就会创建一个工作线程, 由它负责与客户的通信。以下是 EchoServer 的 service()方法的代码: public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); //接收客户连接 Thread workThread=new Thread(new Handler(socket)); // 创建一个工作线程 workThread.start(); //启动工作线程 }catch (IOException e) { e.printStackTrace(); } } } 以上工作线程 workThread 执行 Handler 的 run()方法。Handler 类实现了 Runnable 接口,它的 run()方法负责与单个客户通信,与客户通信结束后,就会断开连接,执行 Handler 的 run()方法的工作线程也会自然终止。如例程 3-5 所示是 EchoServer 类及 Handler 类的源 程序。 例程 3-5 EchoServer.java(为每个任务分配一个线程) 28 package multithread1; import java.io.*; import java.net.*; public class EchoServer { private int port=8000; private ServerSocket serverSocket; public EchoServer() throws IOException { serverSocket = new ServerSocket(port); System.out.println("服务器启动"); } public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); //接收客户连接 Thread workThread=new Thread(new Handler(socket)); //创建一个工 作线程 workThread.start(); //启动工作线程 }catch (IOException e) { e.printStackTrace(); } } } public static void main(String args[])throws IOException { new EchoServer().service(); } } class Handler implements Runnable{ //负责与单个客户的通信 private Socket socket; public Handler(Socket socket){ this.socket=socket; } 29 private PrintWriter getWriter(Socket socket)throws IOException{…} private BufferedReader getReader(Socket socket)throws IOException{…} public String echo(String msg) {…} public void run(){ try { System.out.println("New connection accepted " + socket.getInetAddress() + ":" +socket.getPort()); BufferedReader br =getReader(socket); PrintWriter pw = getWriter(socket); String msg = null; while ((msg = br.readLine()) != null) { //接收和发送数据,直到通信 结束 System.out.println(msg); pw.println(echo(msg)); if (msg.equals("bye")) break; } }catch (IOException e) { e.printStackTrace(); }finally { try{ if(socket!=null)socket.close(); //断开连接 }catch (IOException e) {e.printStackTrace();} } } } 3.6.2 创建线程池 30 在 3.6.1 节介绍的实现方式中,对每个客户都分配一个新的工作线程。当工作线程与客户 通信结束,这个线程就被销毁。这种实现方式有以下不足之处。 ◆服务器创建和销毁工作线程的开销(包括所花费的时间和系统资源)很大。如果服务器需要与 许多客户通信,并且与每个客户的通信时间都很短,那么有可能服务器为客户创建新线程的开销 比实际与客户通信的开销还要大。 ◆除了创建和销毁线程的开销之外,活动的线程也消耗系统资源。每个线程本身都会占用一定的 内存(每个线程需要大约 1M 内存),如果同时有大量客户连接服务器,就必须创建大量工作线 程,它们消耗了大量内存,可能会导致系统的内存空间不足。 ◆如果线程数目固定,并且每个线程都有很长的生命周期,那么线程切换也是相对固定的。不同 操作系统有不同的切换周期,一般在 20 毫秒左右。这里所说的线程切换是指在 Java 虚拟机, 以及底层操作系统的调度下,线程之间转让 CPU 的使用权。如果频繁创建和销毁线程,那么将 导致频繁地切换线程,因为一个 线程被销毁后,必然要把 CPU 转让给另一个已经就绪的线程, 使该线程获得运行机会。在这种情况下,线程之间的切换不再遵循系统的固定切换周期,切换线 程的 开销甚至比创建及销毁线程的开销还大。 线程池为线程生命周期开销问题和系统资源不足问题提供了解决方案。线程池中预先创建 了一些工作线程,它们不断从工作队列中取出任务,然后执行该任务。当工作线程执行完一个任 务时,就会继续执行工作队列中的下一个任务。线程池具有以下优点: ◆减少了创建和销毁线程的次数,每个工作线程都可以一直被重用,能执行多个任务。 ◆可以根据系统的承载能力,方便地调整线程池中线程的数目,防止因为消耗过量系统资源而导 致系统崩溃。 如例程 3-6 所示,ThreadPool 类提供了线程池的一种实现方案。 例程 3-6 ThreadPool.java package multithread2; import java.util.LinkedList; public class ThreadPool extends ThreadGroup { private boolean isClosed=false; //线程池是否关闭 private LinkedList workQueue; //表示工作队列 private static int threadPoolID; //表示线程池 ID private int threadID; //表示工作线程 ID public ThreadPool(int poolSize) { //poolSize 指定线程池中的工作线 程数目 super("ThreadPool-" + (threadPoolID++)); setDaemon(true); workQueue = new LinkedList(); //创建工作队列 for (int i=0; i new WorkThread().start(); //创建并启动工作线程 31 } /** 向工作队列中加入一个新任务,由工作线程去执行该任务 */ public synchronized void execute(Runnable task) { if (isClosed) { //线程池被关则抛出 IllegalStateException 异常 throw new IllegalStateException(); } if (task != null) { workQueue.add(task); notify(); //唤醒正在 getTask()方法中等待任务的工作线程 } } /** 从工作队列中取出一个任务,工作线程会调用此方法 */ protected synchronized Runnable getTask()throws InterruptedException{ while (workQueue.size() == 0) { if (isClosed) return null; wait(); //如果工作队列中没有任务,就等待任务 } return workQueue.removeFirst(); } /** 关闭线程池 */ public synchronized void close() { if (!isClosed) { isClosed = true; workQueue.clear(); //清空工作队列 interrupt(); //中断所有的工作线程,该方法继承自 ThreadGroup 类 } } /** 等待工作线程把所有任务执行完 */ public void join() { synchronized (this) { isClosed = true; notifyAll(); //唤醒还在 getTask()方法中等待任务的工作线程 32 } Thread[] threads = new Thread[activeCount()]; //enumerate()方法继承自 ThreadGroup 类,获得线程组中当前所有活着的工作 线程 int count = enumerate(threads); for (int i=0; i try { threads[i].join(); //等待工作线程运行结束 }catch(InterruptedException ex) { } } } /** 内部类:工作线程 */ private class WorkThread extends Thread { public WorkThread() { //加入到当前 ThreadPool 线程组中 super(ThreadPool.this,"WorkThread-" + (threadID++)); } public void run() { while (!isInterrupted()) { //isInterrupted()方法继承自 Thread 类,判断 线程是否被中断 Runnable task = null; try { //取出任务 task = getTask(); }catch (InterruptedException ex){} // 如果 getTask()返回 null 或者线程执行 getTask()时被中断,则结束此线程 if (task == null) return; try { //运行任务,异常在 catch 代码块中捕获 task.run(); } catch (Throwable t) { t.printStackTrace(); } } //#while } //#run() 33 } //#WorkThread 类 } 在 ThreadPool 类中定义了一个 LinkedList 类型的 workQueue 成员变量,它表示工作队 列,用来存放线程池要执行的任务,每个 任务都是 Runnable 实例。ThreadPool 类的客户程 序(利用 ThreadPool 来执行任务的程序)只要调用 ThreadPool 类的 execute (Runnable task)方法,就能向线程池提交任务。在 ThreadPool 类的 execute()方 法中,先判断线程池是否已 经关闭。如果线程池已经关闭,就不再接收任务,否则就把任务加 入到工作队列中,并且唤醒正在等待任务的工作线程。 六、Java网络编程精解之ServerSocket用法详解二 2 在 ThreadPool 类的构造方法中,会创建并启动若干工作线程,工作线程的数目由构造方 法的参数 poolSize 决定。WorkThread 类表示工作线程,它是 ThreadPool 类的内部类。工 作线程从工作队列中取出一个任务,接着执行该任务, 然后再从工作队列中取出下一个任务并 执行它,如此反复。 工作线程从工作队列中取任务的操作是由 ThreadPool 类的 getTask()方法实现的,它的 处理逻辑如下: ◆如果队列为空并且线程池已关闭,那就返回 null,表示已经没有任务可以执行了; ◆如果队列为空并且线程池没有关闭,那就在此等待,直到其他线程将其唤醒或者中断; ◆如果队列中有任务,就取出第一个任务并将其返回。 线程池的 join()和 close()方法都可用来关闭线程池。join()方法确保在关闭线程池之前, 工作线程把队列中的所有任务都执行完。而 close()方法则立即清空队列,并且中断所有的工作 线程。 ThreadPool 类是 ThreadGroup 类的子类。ThreadGroup 类表示线程组,它提供了一些 管理线程组中线程的方法。例如, interrupt()方法相当于调用线程组中所有活着的线程的 interrupt()方法。线程池中的所有工作线程都加入到当前 ThreadPool 对 象表示的线程组中。 ThreadPool 类在 close()方法中调用了 interrupt()方法: /** 关闭线程池 */ public synchronized void close() { if (!isClosed) { isClosed = true; workQueue.clear(); //清空工作队列 34 interrupt(); //中断所有的工作线程,该方法继承自 ThreadGroup 类 } } 以上 interrupt()方法用于中断所有的工作线程。interrupt()方法会对工作线程造成以下影响: ◆如果此时一个工作线程正在 ThreadPool 的 getTask()方法中因为执行 wait()方法而阻塞, 则会抛出 InterruptedException; ◆如果此时一个工作线程正在执行一个任务,并且这个任务不会被阻塞,那么这个工作线程会正 常执行完任务,但是在执行下一轮 while (!isInterrupted()) {…}循环时,由于 isInterrupted() 方法返回 true,因此退出 while 循环。 如例程 3-7 所示,ThreadPoolTester 类用于测试 ThreadPool 的用法。 例程 3-7 ThreadPoolTester.java package multithread2; public class ThreadPoolTester { public static void main(String[] args) { if (args.length != 2) { System.out.println( "用法: java ThreadPoolTest numTasks poolSize"); System.out.println( " numTasks - integer: 任务的数目"); System.out.println( " numThreads - integer: 线程池中的线程数目"); return; } int numTasks = Integer.parseInt(args[0]); 35 int poolSize = Integer.parseInt(args[1]); ThreadPool threadPool = new ThreadPool(poolSize); //创建 线程池 // 运行任务 for (int i=0; i threadPool.execute(createTask(i)); threadPool.join(); //等待工作线程完成所有的任务 // threadPool.close(); //关闭线程池 }//#main() /** 定义了一个简单的任务(打印 ID) */ private static Runnable createTask(final int taskID) { return new Runnable() { public void run() { System.out.println("Task " + taskID + ": start"); try { Thread.sleep(500); //增加执行一个任务的时间 } catch (InterruptedException ex) { } System.out.println("Task " + taskID + ": end"); } }; } 36 } ThreadPoolTester 类的 createTask()方法负责创建一个简单的任务。 ThreadPoolTester 类的 main()方 法读取用户从命令行输入的两个参数,它们分别表示任务 的数目和工作线程的数目。main()方法接着创建线程池和任务,并且由线程池来执行这些任务, 最后 调用线程池的 join()方法,等待线程池把所有的任务执行完毕。 运行命令“java multithread2.ThreadPoolTester 5 3”,线程池将创建 3 个工作线程, 由它们执行 5 个任务。程序的打印结果如下: Task 0: start Task 1: start Task 2: start Task 0: end Task 3: start Task 1: end Task 4: start Task 2: end Task 3: end Task 4: end 从打印结果看出,主线程等到工作线程执行完所有任务后,才结束程序。如果把 main()方 法中的“threadPool.join()”改为“threadPool.close()”,再运行程序,则会看到,尽管有一些 任务还没有执行,程序就运行结束了。 如例程 3-8 所示,EchoServer 利用线程池 ThreadPool 来完成与客户的通信任务。 例程 3-8 EchoServer.java(使用线程池 ThreadPool 类) package multithread2; import java.io.*; import java.net.*; public class EchoServer { private int port=8000; private ServerSocket serverSocket; private ThreadPool threadPool; //线程池 private final int POOL_SIZE=4; //单个 CPU 时线程池中工 作线程的数目 public EchoServer() throws IOException { serverSocket = new ServerSocket(port); //创建线程池 //Runtime 的 availableProcessors()方法返回当前系统的 CPU 的数 37 目 //系统的 CPU 越多,线程池中工作线程的数目也越多 threadPool= new ThreadPool( Runtime.getRuntime().availableProcessors() * POOL_SIZE); System.out.println("服务器启动"); } public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); threadPool.execute(new Handler(socket)); //把与客户通信的 任务交给线程池 }catch (IOException e) { e.printStackTrace(); } } } public static void main(String args[])throws IOException { new EchoServer().service(); } } /** 负责与单个客户通信的任务,代码与 3.6.1 节的例程 3-5 的 Handler 类相同 */ 38 class Handler implements Runnable{…} 在以上 EchoServer 的 service()方法中,每接收到一个客户连接,就向线程池 ThreadPool 提交一个与客户通信的任务。ThreadPool 把任务加入到工作队列中,工作线程会在适当的时候 从队列中取出这个任务并执行它。 3.6.3 使用 JDK 类库提供的线程池 java.util.concurrent 包提供了现成的线程池的实现,它比 3.6.2 节介绍的线程池更加健 壮,而且功能也更强大。如图 3-4 所示是线程池的类框图。 图 3-4 JDK 类库中的线程池的类框图 Executor 接口表示线程池,它的 execute(Runnable task)方法用来执行 Runnable 类 型的任务。Executor 的 子接口 ExecutorService 中声明了管理线程池的一些方法,比如用于 关闭线程池的 shutdown()方法等。Executors 类中包含一些 静态方法,它们负责生成各种类 型的线程池 ExecutorService 实例,如表 3-1 所示。 表 3-1 Executors 类生成的 ExecutorService 实例的静态方法 Executors 类的静态方法 创建的 ExecutorService 线程池的类型 newCachedThreadPool() 在有任务时才创建新线程,空闲线程被保留 60 秒 newFixedThreadPool(int nThreads) 线程池中包含固定数目的线程,空闲线程会一直保留。参数 nThreads 设 定线程池中线程的数目 newSingleThreadExecutor() 线程池中只有一个工作线程,它依次执行每个任务 newScheduledThreadPool(int corePoolSize) 线程池能按时间计划来执行任务,允许用户设定计划执行任务的时间。参 数 corePoolSize 设定线程池中线程的最小数目。当任务较多时,线程池可 能会创建更多的工作线程来执行任务 newSingleThreadScheduledExecutor() 线程池中只有一个工作线程,它能按时间计划来执行任务 如例程 3-9 所示,EchoServer 就利用上述线程池来负责与客户通信的任务。 例程 3-9 EchoServer.java(使用 java.util.concurrent 包中的线程池类) package multithread3; 39 import java.io.*; import java.net.*; import java.util.concurrent.*; public class EchoServer { private int port=8000; private ServerSocket serverSocket; private ExecutorService executorService; //线程池 private final int POOL_SIZE=4; //单个 CPU 时线程池中工 作线程的数目 public EchoServer() throws IOException { serverSocket = new ServerSocket(port); //创建线程池 //Runtime 的 availableProcessors()方法返回当前系统的 CPU 的数 目 //系统的 CPU 越多,线程池中工作线程的数目也越多 executorService= Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() * POOL_SIZE); System.out.println("服务器启动"); } public void service() { while (true) { Socket socket=null; try { socket = serverSocket.accept(); executorService.execute(new Handler(socket)); }catch (IOException e) { e.printStackTrace(); } } } public static void main(String args[])throws IOException { new EchoServer().service(); } } /** 负责与单个客户通信的任务,代码与 3.6.1 节的例程 3-5 的 40 Handler 类相同 */ class Handler implements Runnable{…} 在 EchoServer 的构造方法中,调用 Executors.newFixedThreadPool()创建了具有固 定工作线程数目的线程池。在 EchoServer 的 service()方法中,通过调用 executor- Service.execute()方法,把与客户通信的任务交给了 ExecutorService 线程池来 执行。 3.6.4 使用线程池的注意事项 虽然线程池能大大提高服务器的并发性能,但使用它也会存在一定风险。与所有多线程应 用程序一样,用线程池构建的应用程序容易产生各种并发问题,如对 共享资源的竞争和死锁。 此外,如果线程池本身的实现不健壮,或者没有合理地使用线程池,还容易导致与线程池有关的 死锁、系统资源不足和线程泄漏等问题。 1. 死锁 任何多线程应用程序都有死锁风险。造成死锁的最简单的情形是,线程 A 持有对象 X 的锁, 并且在等待对象 Y 的锁,而线程 B 持有对象 Y 的锁,并且在等待对象 X 的锁。线程 A 与线程 B 都不释放自己持有的锁,并且等待对方的锁,这就导致两个线程永远等待下去,死锁就这样产生 了。 虽然任何多线程程序都有死锁的风险,但线程池还会导致另外一种死锁。在这种情形下, 假定线程池中的所有工作线程都在执行各自任务时被阻塞,它们都在 等待某个任务 A 的执行结 果。而任务 A 依然在工作队列中,由于没有空闲线程,使得任务 A 一直不能被执行。这使得线 程池中的所有工作线程都永远阻塞下去,死锁 就这样产生了。 2.系统资源不足 如果线程池中的线程数目非常多,这些线程会消耗包括内存和其他系统资源在内的大量资 源,从而严重影响系统性能。 2. 并发错误 线程池的工作队列依靠 wait()和 notify()方法来使工作线程及时取得任务,但这两个方法 都难于使用。 如果编码不正确,可能会丢失通知,导致工作线程一直保持空闲状态,无视工作队列中需 要处理的任务。因此使用这些方法时,必须格外小心,即便是专家也 可能在这方面出错。最好 使用现有的、比较成熟的线程池。例如,直接使用 java.util.concurrent 包中的线程池类。 3. 线程泄漏 使用线程池的一个严重风险是线程泄漏。对于工作线程数目固定的线程池,如果工作线程 在执行任务时抛出 RuntimeException 或 Error,并 且这些异常或错误没有被捕获,那么这个 工作线程就会异常终止,使得线程池永久失去了一个工作线程。如果所有的工作线程都异常终止, 线程池就最终变为空,没 有任何可用的工作线程来处理任务。 导致线程泄漏的另一种情形是,工作线程在执行一个任务时被阻塞,如等待用户的输入数 据,但是由于用户一直不输入数据(可能是因为用户走开了),导致 这个工作线程一直被阻塞。 41 这样的工作线程名存实亡,它实际上不执行任何任务了。假如线程池中所有的工作线程都处于这 样的阻塞状态,那么线程池就无法处理新 加入的任务了。 4. 任务过载 当工作队列中有大量排队等候执行的任务时,这些任务本身可能会消耗太多的系统资源而 引起系统资源缺乏。 综上所述,线程池可能会带来种种风险,为了尽可能避免它们,使用线程池时需要遵循以下原则。 (1)如果任务 A 在执行过程中需要同步等待任务 B 的执行结果,那么任务 A 不适合加入到线 程池的工作队列中。如果把像任务 A 一样的需要等待其他任务执行结果的任务加入到工作队列 中,可能会导致线程池的死锁。 (2)如果执行某个任务时可能会阻塞,并且是长时间的阻塞,则应该设定超时时间,避免工作 线程永久的阻塞下去而导致线程泄漏。在服务器程序中,当线程等待客户连接,或者等待客户发 送的数据时,都可能会阻塞。可以通过以下方式设定超时时间: ◆调用 ServerSocket 的 setSoTimeout(int timeout)方法,设定等待客户连接的超时时间, 参见本章 3.5.1 节(SO_TIMEOUT 选项); ◆对于每个与客户连接的 Socket,调用该 Socket 的 setSoTimeout(int timeout)方法,设 定等待客户发送数据的超时时间,参见本书第 2 章的 2.5.3 节(SO_TIMEOUT 选项)。 (3)了解任务的特点,分析任务是执行经常会阻塞的 I/O 操作,还是执行一直不会阻塞的运算 操作。前者时断时续地占用 CPU,而后者对 CPU 具有更高的利用率。预计完成任务大概需要多 长时间?是短时间任务还是长时间任务? 根据任务的特点,对任务进行分类,然后把不同类型的任务分别加入到不同线程池的工作队列中, 这样可以根据任务的特点,分别调整每个线程池。 (4)调整线程池的大小。线程池的最佳大小主要取决于系统的可用 CPU 的数目,以及工作队 列中任务的特点。假如在一个具有 N 个 CPU 的系统上只 有一个工作队列,并且其中全部是运 算性质(不会阻塞)的任务,那么当线程池具有 N 或 N+1 个工作线程时,一般会获得最大 的 CPU 利用率。 如果工作队列中包含会执行 I/O 操作并常常阻塞的任务,则要让线程池的大小超过可用 CPU 的 数目,因为并不是所有工作线程都一直在工作。选择一个典 型的任务,然后估计在执行这个任 务的过程中,等待时间(WT)与实际占用 CPU 进行运算的时间(ST)之间的比例 WT/ST。 对于一个具有 N 个 CPU 的系 统,需要设置大约 N×(1+WT/ST)个线程来保证 CPU 得到充分 利用。 当然,CPU 利用率不是调整线程池大小过程中唯一要考虑的事项。随着线程池中工作线程数目 的增长,还会碰到内存或者其他系统资源的限制,如套接字、打开的文件句柄或数据库连接数目 等。要保证多线程消耗的系统资源在系统的承载范围之内。 (5)避免任务过载。服务器应根据系统的承载能力,限制客户并发连接的数目。当客户并发连 接的数目超过了限制值,服务器可以拒绝连接请求,并友好地告知客户:服务器正忙,请稍后再 试。 七、Java 网络编程精解之 ServerSocket 用法详解三 42 3.7 关闭服务器 前面介绍的 EchoServer 服务器都无法关闭自身,只有依靠操作系统来强行终止服务器程 序。这种强行终止服务器程序的方式尽管简单方便,但是会导致服务器中正在执行的任务被突然 中断。如果服务器处理的任务不是非常重要,允许随时中断,则可以依靠操作系统来强行终止服 务器程序;如果服务器处理的任 务非常重要,不允许被突然中断,则应该由服务器自身在恰当 的时刻关闭自己。 本节介绍的 EchoServer 服务器就具有关闭自己的功能。它除了在 8000 端口监听普通客 户程序 EchoClient 的连接外,还会在 8001 端口监听管理程序 AdminClient 的连接。当 EchoServer 服务器在 8001 端口接收到了 AdminClient 发送的“shutdown” 命令时, EchoServer 就会开始关闭服务器,它不会再接收任何新的 EchoClient 进程的连接请求,对于 那些已经接收但是还没有处理的客户连 接,则会丢弃与该客户的通信任务,而不会把通信任务 加入到线程池的工作队列中。另外,EchoServer 会等到线程池把当前工作队列中的所有任务执 行 完,才结束程序。 如例程 3-10 所示是 EchoServer 的源程序,其中关闭服务器的任务是由 shutdown- Thread 线程来负责的。 例程 3-10 EchoServer.java(具有关闭服务器的功能) package multithread4; import java.io.*; import java.net.*; import java.util.concurrent.*; public class EchoServer { private int port=8000; private ServerSocket serverSocket; private ExecutorService executorService; //线程池 private final int POOL_SIZE=4; //单个 CPU 时线程池中工 作线程的数目 private int portForShutdown=8001; //用于监听关闭服务器 命令的端口 private ServerSocket serverSocketForShutdown; private boolean isShutdown=false; //服务器是否已经关闭 private Thread shutdownThread=new Thread(){ //负责关闭服 务器的线程 public void start(){ this.setDaemon(true); //设置为守护线程( 也称为后台线程) 43 super.start(); } public void run(){ while (!isShutdown) { Socket socketForShutdown=null; try { socketForShutdown= serverSocketForShutdown.accept(); BufferedReader br = new BufferedReader( new InputStreamReader(socketForShutdown.getInputStream())); String command=br.readLine(); if(command.equals("shutdown")){ long beginTime=System.currentTimeMillis(); socketForShutdown.getOutputStream().write("服务器正在关闭 \r\n".getBytes()); isShutdown=true; //请求关闭线程池 //线程池不再接收新的任务,但是会继续执行完工作队列中现有的任 务 executorService.shutdown(); //等待关闭线程池,每次等待的超时时间为 30 秒 while(!executorService.isTerminated()) executorService.awaitTermination(30,TimeUnit.SECONDS); serverSocket.close(); //关闭与 EchoClient 客户通信的 44 ServerSocket long endTime=System.currentTimeMillis(); socketForShutdown.getOutputStream().write(("服务器已经关闭, "+"关闭服务器用了"+(endTime-beginTime)+"毫秒 \r\n").getBytes()); socketForShutdown.close(); serverSocketForShutdown.close(); }else{ socketForShutdown.getOutputStream().write("错误的命令 \r\n".getBytes()); socketForShutdown.close(); } }catch (Exception e) { e.printStackTrace(); } } } }; public EchoServer() throws IOException { serverSocket = new ServerSocket(port); serverSocket.setSoTimeout(60000); //设定等待客户连接的超 过时间为 60 秒 serverSocketForShutdown = new ServerSocket(portForShutdown); //创建线程池 executorService= Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() * POOL_SIZE); shutdownThread.start(); //启动负责关闭服务器的线程 45 System.out.println("服务器启动"); } public void service() { while (!isShutdown) { Socket socket=null; try { socket = serverSocket.accept(); //可能会抛出 SocketTimeoutException 和 SocketException socket.setSoTimeout(60000); //把等待客户发送数据的超时时 间设为 60 秒 executorService.execute(new Handler(socket)); //可能会抛出 RejectedExecutionException }catch(SocketTimeoutException e){ //不必处理等待客户连接时出现的超时异常 }catch(RejectedExecutionException e){ try{ if(socket!=null)socket.close(); }catch(IOException x){} return; }catch(SocketException e) { //如果是由于在执行 serverSocket.accept()方法时, //ServerSocket 被 ShutdownThread 线程关闭而导致的异常,就退出 service()方法 if(e.getMessage().indexOf("socket closed")!=-1)return; }catch(IOException e) { e.printStackTrace(); } } 46 } public static void main(String args[])throws IOException { new EchoServer().service(); } } /** 负责与单个客户通信的任务,代码与 3.6.1 节的例程 3-5 的 Handler 类相同 */ class Handler implements Runnable{…} shutdownThread 线程负责关闭服务器。它一直监听 8001 端口,如果接收到了 AdminClient 发送的“shutdown”命令, 就把 isShutdown 变量设为 true。shutdownThread 线程接着执行 executorService.shutdown()方法,该方 法请求关闭线程池,线程池将不再接 收新的任务,但是会继续执行完工作队列中现有的任务。shutdownThread 线程接着等待线程 池关闭: while(!executorService.isTerminated()) executorService.awaitTermination(30,TimeUnit.SECONDS); // 等待 30 秒 当线程池的工作队列中的所有任务执行完毕,executorService.isTerminated()方法就会 返回 true。 shutdownThread 线程接着关闭监听 8000 端口的 ServerSocket,最后再关闭监听 8001 端口的 ServerSocket。 shutdownThread 线程在执行上述代码时,主线程正在执行 EchoServer 的 service()方法。 shutdownThread 线程一系列操作会对主线程造成以下影响。 ◆如果 shutdownThread 线程已经把 isShutdown 变量设为 true,而主线程正准备执行 service()方法的下一轮 while(!isShutdown){…}循环时,由于 isShutdown 变量为 true, 就会退出循环。 ◆如果 shutdownThread 线程已经执行了监听 8 000 端口的 ServerSocket 的 close()方法, 而主线程正在执行该 ServerSocket 的 accept()方法,那么该方法会抛出 SocketException。 EchoServer 的 service()方法捕获 了该异常,在异常处理代码块中退出 service()方法。 ◆如果 shutdownThread 线程已经执行了 executorService.shutdown()方法,而主线程正 在执行 executorService.execute(…)方法,那么该方法会抛出 Rejected- ExecutionException。 EchoServer 的 service()方法捕获了该异常,在异常处 理代码块中退出 service()方法。 47 ◆如果 shutdownThread 线程已经把 isShutdown 变量设为 true,但还没有调用监听 8 000 端口的 ServerSocket 的 close()方法,而主线程正在执行 ServerSocket 的 accept()方法, 主线程阻塞 60 秒后会抛出 SocketTimeoutException。在准备执行 service()方法的下一轮 while(!isShutdown){…}循环时,由于 isShutdown 变量为 true,就会退出循环。 ◆由此可见,当 shutdownThread 线程开始执行关闭服务器的操作时,主线程尽管不会立即终 止,但是迟早会结束运行。 如例程 3-11 所示是 AdminClient 的源程序,它负责向 EchoServer 发送“shutdown”命 令,从而关闭 EchoServer。 例程 3-11 AdminClient.java package multithread4; import java.net.*; import java.io.*; public class AdminClient{ public static void main(String args[]){ Socket socket=null; try{ socket=new Socket("localhost",8001); //发送关闭命令 OutputStream socketOut=socket.getOutputStream(); socketOut.write("shutdown\r\n".getBytes()); //接收服务器的反馈 BufferedReader br = new BufferedReader( new InputStreamReader(socket.getInputStream())); String msg=null; while((msg=br.readLine())!=null) System.out.println(msg); }catch(IOException e){ e.printStackTrace(); }finally{ try{ if(socket!=null)socket.close(); }catch(IOException e){e.printStackTrace();} 48 } } } 下面按照以下方式运行 EchoServer、EchoClient 和 AdminClient,以 观 察 EchoServer 服务器的关闭过程。EchoClient 类的源程序参见本书第 1 章的 1.5.2 节的例程 1-3。 (1)先运行 EchoServer,然后运行 AdminClient。EchoServer 与 AdminClient 进程都结 束运行,并且在 AdminClient 的控制台打印如下结果: 服务器正在关闭 服务器已经关闭,关闭服务器用了 60 毫秒 (2)先运行 EchoServer,再运行 EchoClient,然后再运行 AdminClient。EchoServer 程 序不会立即结束,因为 它与 EchoClient 的通信任务还没有结束。在 EchoClient 的控制台中输 入“bye”, EchoServer、EchoClient 和 AdminClient 进程都会结束运行。 (3)先运行 EchoServer,再运行 EchoClient,然后再运行 AdminClient。EchoServer 程 序不会立即结束,因为 它与 EchoClient 的通信任务还没有结束。不要在 EchoClient 的控制台 中输入任何字符串,过 60 秒后,EchoServer 等待 EchoClient 的发送数据超时,结束与 EchoClient 的通信任务,EchoServer 和 AdminClient 进程结束运行。如果在 EchoClient 的控制台再输入字符串,则会抛出“连接已断开”的 SocketException。 3.8 小结 在 EchoServer 的构造方法中可以设定 3 个参数。 ◆参数 port:指定服务器要绑定的端口。 ◆参数 backlog:指定客户连接请求队列的长度。 ◆参数 bindAddr:指定服务器要绑定的 IP 地址。 ServerSocket 的 accept()方法从连接请求队列中取出一个客户的连接请求,然后创建与 客户连接的 Socket 对象,并将它返回。如 果队列中没有连接请求,accept()方法就会一直等 待,直到接收到了连接请求才返回。SO_TIMEOUT 选项表示 ServerSocket 的 accept()方法 等待客户连接请求的超时时间,以毫秒为单位。如果 SO_TIMEOUT 的值为 0,表示永远不会 超时,这是 SO_TIMEOUT 的默认 值。可以通过 ServerSocket 的 setSo- Timeout()方法来 设置等待连接请求的超时时间。如果设定了超时时间,那么当服务器等待的时 间超过了超时时 间后,就会抛出 SocketTimeoutException,它是 Interrupted- Exception 的子类。 许多实际应用要求服务器具有同时为多个客户提供服务的能力。用多个线程来同时为多个 客户提供服务,这是提高服务器的并发性能的最常用的手段。本章采用 3 种方式来重新实现 EchoServer,它们都使用了多线程: (1)为每个客户分配一个工作线程; (2)创建一个线程池,由其中的工作线程来为客户服务; (3)利用 java.util.concurrent 包中现成的线程池,由它的工作线程来为客户服务。 49 第一种方式需要频繁地创建和销毁线程,如果线程执行的任务本身很简短,那么有可能服 务器在创建和销毁线程方面的开销比在实际执行任务上的开销还要 大。线程池能很好地避免这 一问题。线程池先创建了若干工作线程,每个工作线程执行完一个任务后就会继续执行下一个任 务,线程池减少了创建和销毁线程的次 数,从而提高了服务器的运行性能。 50
还剩49页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 8 金币 [ 分享pdf获得金币 ] 1 人已下载

下载pdf

pdf贡献者

zhpeng

贡献于2013-06-08

下载需要 8 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf