Java的客户/服务器通信模式中,服务器端需要创建监听特定端口的ServerSocket,ServerSocket负责接收客户连接请求。本实验提供线程池的一种实现方式,线程池包括一个工作队列和若干工作线程,服务器程序向工作队列中加入与客户通信的任务,工作线程不断从工作队列中取出任务并执行它。
一、构造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地址。
二、绑定端口
除了第一个不带参数的构造方法以外,其他构造方法都会使服务器与特定端口绑定,该端口由参数port指定。例如,以下代码创建了一个与80端口绑定的服务器:
ServerSocket serverSocket = new ServerSocket(80);
如果运行时无法绑定到80端口,以上代码会抛出IOException,更确切地说,是抛出BindException,它是IOException的子类。BindException一般是由以下原因造成的:
- ① 端口已经被其他服务器进程占用;
- ② 在某些操作系统中,如果没有以超级用户的身份来运行服务器程序,那么操作系统不允许服务器绑定到1~1023之间的端口。
如果把参数port设为0,表示由操作系统来为服务器分配一个任意可用的端口。由操作系统分配的端口也称为匿名端口。对于多数服务器,会使用明确的端口,而不会使用匿名端口,因为客户程序需要事先知道服务器的端口,才能方便地访问服务器。
三、设定客户连接请求队列的长度
当服务器进程运行时,可能会同时监听到多个客户的连接请求。例如,每当一个客户进程执行以下代码:
Socket socket = new Socket(www.baidu.com,80);
这意味着在远程www.baidu.com主机的80端口上,监听到了一个客户的连接请求。管理客户连接请求的任务是由操作系统来完成的。操作系统把这些连接请求存储在一个先进先出的队列中。许多操作系统限定了队列的最大长度,一般为50。当队列中的连接请求达到了队列的最大容量时,服务器进程所在的主机会拒绝新的连接请求。只有当服务器进程通过ServerSocket的accept()方法从队列中取出连接请求,使队列腾出空位时,队列才能继续加入新的连接请求。
对于客户进程,如果它发出的连接请求被加入到服务器的队列中,就意味着客户与服务器的连接建立成功,客户进程从Socket构造方法中正常返回。如果客户进程发出的连接请求被服务器拒绝,Socket构造方法就会抛出ConnectionException异常。
ServerSocket构造方法的backlog参数用来显式设置连接请求队列的长度,它将覆盖操作系统限定的队列的最大长度。值得注意的是,在以下几种情况中,仍然会采用操作系统限定的队列的最大长度:
- (1) backlog参数的值大于操作系统限定的队列的最大长度;
- (2) backlog参数的值小于或等于0;
- (3) 在ServerSocket构造方法中没有设置backlog参数。
四、接收和关闭与客户的连接
ServerSocket的accept()方法从连接请求队列中取出一个客户的连接请求,然后创建与客户连接的Socket对象,并将它返回。如果队列中没有连接请求,accept()方法就会一直等待,直到接收到了连接请求才返回。
接下来,服务器从Socket对象中获得输入流和输出流,就能与客户交换数据。当服务器正在进行发送数据的操作时,如果客户端断开了连接,那么服务器端会抛出一个IOException的子类SocketException异常:
java.net.SocketException: Connection reset by peer
这只是服务器与单个客户通信中出现的异常,这种异常应该被捕获,使得服务器能继续与其他客户通信。以下程序显示了单线程服务器采用的通信流程:
public static void main(String[] args) throws IOException {
// 服务器监听80端口
ServerSocket serverSocket = new ServerSocket(80);
while (true) {
Socket socket = null;
try {
//从连接请求队列中取出一个连接
socket = serverSocket.accept();
//接收和发送数据
//...
}catch (IOException e) {
//与单个客户通信时遇到的异常,可能是由于客户端过早断开连接引起的
//这种异常不应该中断整个while循环
e.printStackTrace();
}finally {
try{
//与一个客户通信结束后,要关闭Socket
if(socket != null){
socket.close();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
}
与单个客户通信的代码放在一个try代码块中,如果遇到异常,该异常被catch代码块捕获。try代码块后面还有一个finally代码块,它保证不管与客户通信正常结束还是异常结束,最后都会关闭Socket,断开与这个客户的连接。
五、关闭ServerSocket
ServerSocket的close()方法使服务器释放占用的端口,并且断开与所有客户的连接。当一个服务器程序运行结束时,即使没有执行ServerSocket的close()方法,操作系统也会释放这个服务器占用的端口。因此,服务器程序并不一定要在结束之前执行ServerSocket的close()方法。
在某些情况下,如果希望及时释放服务器的端口,以便让其他程序能占用该端口,则可以显式调用ServerSocket的close()方法。例如,以下代码用于扫描1~65535之间的端口号。如果ServerSocket成功创建,意味着该端口未被其他服务器进程绑定,否者说明该端口已经被其他进程占用:
try{
ServerSocket serverSocket = new ServerSocket(80);
serverSocket.close(); //及时关闭ServerSocket
}catch(IOException e){
System.out.println("80端口已经被其他服务器进程占用");
}
ServerSocket的isClosed()方法判断ServerSocket是否关闭,只有执行了ServerSocket的close()方法,isClosed()方法才返回true;否则,即使ServerSocket还没有和特定端口绑定,isClosed()方法也会返回false。
ServerSocket的isBound()方法判断ServerSocket是否已经与一个端口绑定,只要ServerSocket已经与一个端口绑定,即使它已经被关闭,isBound()方法也会返回true。
如果需要确定一个ServerSocket已经与特定端口绑定,并且还没有被关闭,则可以采用以下方式:
boolean isOpen=serverSocket.isBound() && !serverSocket.isClosed();
六、创建多线程的服务器
6.1 概述
例如上述四的代码中是常规的单线程类。Server每次只能接收一个客户连接,只有与该客户连接通信完毕,断开连接释放资源之后才能接收下一个客户连接。因此,假如同时有多个客户请求连接,这些客户就必须排队等待上一个客户通信完毕。
许多实际应用要求服务器具有同时为多个客户提供服务的能力。HTTP服务器就是最明显的例子。任何时刻,HTTP服务器都可能接收到大量的客户请求,每个客户都希望能快速得到HTTP服务器的响应。如果长时间让客户等待,会使网站失去信誉,从而降低访问量。
可以用并发性能来衡量一个服务器同时响应多个客户的能力。一个具有好的并发性能的服务器,必须符合两个条件:
- Ⅰ.能同时接收并处理多个客户连接;
- Ⅱ.对于每个客户,都会迅速给予响应。
服务器同时处理的客户连接数目越多,并且对每个客户作出响应的速度越快,就表明并发性能越高。
用多个线程来同时为多个客户提供服务,这是提高服务器的并发性能的最常用的手段。下面案例将按照已下三中方式来实现具备多线程的处理能力。
- Ⅰ.为每个客户分配一个工作线程。
- Ⅱ.创建一个线程池,由其中的工作线程来为客户服务。
- Ⅲ.利用JDK的Java类库中现成的线程池,由它的工作线程来为客户服务。
6.2 案例解析
① 为每个客户分配一个线程
服务器的主线程负责接收客户的连接,每次接收到一个客户连接,就会创建一个工作线程,由它负责与客户的通信。案例代码如下所示:
while (true) {
Socket socket = null;
try {
socket = serverSocket.accept();
// 为当前连接对象创建并启动输出流工作线程
new Thread(new InThread(socket)).start();
} catch (IOException e) {
e.printStackTrace();
}
}
以上工作线程执行InThread类的run()方法。InThread类实现了Runnable接口,它的run()方法负责与单个客户通信,与客户通信结束后,就会断开连接,执行InThread的run()方法的工作线程也会自然终止。案例程序如下所示。
public class InThread implements Runnable {
private Socket socket;
public InThread (Socket socket) {
this.socket = socket;
}
public void run() {
try {
DataInputStream in = new DataInputStream(socket.getInputStream());
System.out.println(in.readUTF());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (socket != null){
socket.close(); // 断开连接
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
② 创建线程池
线程池额外知识:https://baijiahao.baidu.com/s?id=1622998393605590757&wfr=spider&for=pc
前面所介绍的实现方式中,对每一个连接的客户Socket都分配一个工作线程。当工作线程与客户端通信结束之后,这个线程就被销毁。但是这种实现方式有以下不足之处。
- Ⅰ.服务器创建和销毁工作线程的开销很大。如果服务器需要与许多客户通信,并且与每个客户的通信时间都很短,那么有可能服务器为客户创建新线程的开销比实际与客户通信的开销还要大。
- Ⅱ.除了创建和销毁线程的开销之外,活动的线程也消耗系统资源。每个线程本身都会占用一定的内存(每个线程需要大约1M内存),如果同时有大量客户连接服务器,就必须创建大量工作线程,它们消耗了大量内存,可能会导致系统的内存空间不足。
- Ⅲ.如果线程数目固定,并且每个线程都有很长的生命周期,那么线程切换也是相对固定的(这里所说的线程切换是指在Java虚拟机,以及底层操作系统的调度下,线程之间转让CPU的使用权)。如果频繁创建和销毁线程,那么将导致频繁地切换线程,因为一个线程被销毁后,必然要把CPU转让给另一个已经就绪的线程,使该线程获得运行机会。在这种情况下,线程之间的切换不再遵循系统的固定切换周期,切换线程的开销甚至比创建及销毁线程的开销还大。
线程池为线程生命周期开销问题和系统资源不足问题提供了解决方案。线程池中预先创建了一些工作线程,它们不断从工作队列中取出任务,然后执行该任务。当工作线程执行完一个任务时,就会继续执行工作队列中的下一个任务。线程池具有以下优点:
- Ⅰ.减少了创建和销毁线程的次数,每个工作线程都可以一直被重用,能执行多个任务。
- Ⅱ.可以根据系统的承载能力,方便地调整线程池中线程的数目,防止因为消耗过量系统资源而导致系统崩溃。
如下面的范例程序所示,ThreadPool类提供了线程池的一种实现方案
public class ThreadPool extends ThreadGroup {
private int threadID; // 表示工作线程ID
private static int threadPoolID; // 表示线程池ID
private boolean isClosed = false; // 线程池是否关闭
private LinkedList<Runnable> workQueue; // 表示工作队列
public ThreadPool(int poolSize) { // poolSize指定线程池中的工作线程数目
super("ThreadPool-" + (threadPoolID++));
setDaemon(true);
workQueue = new LinkedList<Runnable>(); // 创建工作队列
for (int i = 0; i < poolSize; i++) {
new WorkThread().start(); // 创建并启动工作线程
}
}
/** 内部类:工作线程 */
private class WorkThread extends Thread {
public WorkThread() {
// 加入到当前ThreadPool线程组中
super(ThreadPool.this, "WorkThread-" + (threadID++));
}
public void run() {
// isInterrupted()方法继承自Thread类,判断线程是否被中断
while (!isInterrupted()) {
Runnable task = null;
try { // 取出任务
task = getTask();
} catch (InterruptedException ex) {
ex.printStackTreace();
}
// 如果getTask()返回null或者线程执行getTask()时被中断,则结束此线程
if (task == null) {
return;
}
// 运行任务,异常在catch代码块中捕获
try {
task.run();
} catch (Throwable t) {
t.printStackTrace();
}
}
}
}
/** 向工作队列中加入一个新任务,由工作线程去执行该任务 */
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()方法中等待任务的工作线程
}
Thread[] threads = new Thread[activeCount()];
// enumerate()方法继承自ThreadGroup类,获得线程组中当前所有活着的工作线程
int count = enumerate(threads);
for (int i = 0; i < count; i++) { // 等待所有工作线程运行结束
try {
threads[i].join(); // 等待工作线程运行结束
} catch (InterruptedException ex) {
}
}
}
}
在ThreadPool类中定义了一个LinkedList类型的workQueue成员变量,它表示工作队列,用来存放线程池要执行的任务,每个任务都是Runnable实例。主程序调用ThreadPool类的execute(Runnable task)方法,就能向线程池提交任务。在该方法中,先判断线程池是否已经关闭。如果线程池已经关闭,就不再接收任务,否则就把任务加入到工作队列中,并且唤醒正在等待任务的工作线程。
在ThreadPool类的构造方法中,会创建并启动若干工作线程,工作线程的数目由构造方法的参数poolSize决定。WorkThread类表示工作线程,它是ThreadPool类的内部类。工作线程从工作队列中取出一个任务,接着执行该任务,然后再从工作队列中取出下一个任务并执行它,如此反复。
工作线程从工作队列中取任务的操作是由ThreadPool类的getTask()方法实现的,它的处理逻辑如下:
- Ⅰ.如果队列为空并且线程池已关闭,那就返回null,表示已经没有任务可以执行了
- Ⅱ.如果队列为空并且线程池没有关闭,就此等待,直到其他线程将其唤醒或者中断
- Ⅲ.如果队列中有任务,就取出第一个任务并将其返回
线程池的join()和close()方法都可用来关闭线程池。join()方法确保在关闭线程池之前,工作线程把队列中的所有任务都执行完。而close()方法则立即清空队列,并且中断所有的工作线程。
接下来,我们创建ServerThread类测试ThreadPool类的用法,案例如下:
public class ServerThread {
public static void main(String[] args) {
int numTasks = 5; // 任务数目
int poolSize = 3; // 线程池中的线程数目
// 创建线程池
ThreadPool threadPool = new ThreadPool(poolSize);
// 创建任务线程并运行
for (int i = 0; i < numTasks; i++){
threadPool.execute(createTask(i));
}
// 等待工作线程完成所有的任务
threadPool.join();
// 关闭线程池
// threadPool.close();
}
/** 创建任务线程(打印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) {
ex.printStackTrace();
}
System.out.println("Task " + taskID + ": end");
}
};
}
}
ServerThread类的main()方法指定创建线程池的工作线程数目,通过循环调用createTask()方法创建多个任务线程传递给ThreadPool类的execute()方法执行线程,最后调用线程池的join()方法,等待线程池把所有的任务执行完毕或者强制关闭线程池中所有的任务(包括正在执行中的任务)。
执行join()方法的运行结果如下所示。
Task 0: start
Task 2: start
Task 1: start
Task 2: end
Task 3: start
Task 0: end
Task 4: start
Task 1: end
Task 4: end
Task 3: end
执行close()方法的运行结果如下所示。
Task 1: start
Task 0: start
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at Group.Server$1.run(Server.java:26)
at Group.ThreadPool$WorkThread.run(ThreadPool.java:44)
Task 1: end
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at Group.Server$1.run(Server.java:26)
at Group.ThreadPool$WorkThread.run(ThreadPool.java:44)
Task 0: end
服务器利用ThreadPool类完成与客户端通信的程序代码如下所示。
public class Server {
private int port = 8000;
private ServerSocket serverSocket;
private ThreadPool threadPool; // 线程池
private final int POOL_SIZE = 3; // 单个CPU时线程池中工作线程的数目
public Server() throws IOException {
System.err.println("服务器启动,监听" + port + "端口。");
serverSocket = new ServerSocket(port);
// 创建线程池
// Runtime的availableProcessors()方法返回当前系统的CPU的数目
// 系统的CPU越多,线程池中工作线程的数目也越多
threadPool = new ThreadPool(Runtime.getRuntime().availableProcessors() * POOL_SIZE);
}
public static void main(String args[]) throws IOException {
new Server().service();
}
public void service() {
while (true) {
try {
Socket socket = serverSocket.accept();
// 获取客户端消息的任务提交给线程池
threadPool.execute(new InThread(socket));
} catch (IOException e) {
e.printStackTrace();
}
}
}
class InThread implements Runnable {
private Socket socket;
public InThread(Socket socket) {
this.socket = socket;
}
public void run() {
try {
while(true){
DataInputStream in = new DataInputStream(socket.getInputStream());
System.out.println(in.readUTF());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (socket != null) {
socket.close(); // 断开连接
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
最终的结果如下图所示。
参考资料:
① 介绍几种Java中网络通信的方式
地址:https://blog.csdn.net/mjm_49/article/details/77461322
② 深入理解线程和线程池(图文详解)
地址:https://blog.csdn.net/weixin_40271838/article/details/79998327
③ 如何设计一个使用的线程池
地址:https://baijiahao.baidu.com/s?id=1622998393605590757&wfr=spider&for=pc