Java网络编程核心技术详解(视频微课版)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

4.1 线程阻塞的概念

在生活中,最常见的阻塞现象是公路上汽车的堵塞。汽车在公路上快速行驶,如果前方交通受阻,就只好停下来等待,等到公路顺畅,才能恢复行驶。

线程在运行中也会因为某些原因而阻塞。所有处于阻塞状态的线程的共同特征是:放弃CPU,暂停运行,只有等到导致阻塞的原因消除,才能恢复运行;或者被其他线程中断,该线程会退出阻塞状态,并且抛出InterruptedException。

4.1.1 线程阻塞的原因

导致线程阻塞的原因主要有以下方面。

·线程执行了Thread.sleep(int n)方法,线程放弃CPU,睡眠n ms,然后恢复运行。

·线程要执行一段同步代码,由于无法获得相关的同步锁,只好进入阻塞状态,等到获得了同步锁,才能恢复运行。

·线程执行了一个对象的wait()方法,进入阻塞状态,只有等到其他线程执行了该对象的notify()或notifyAll()方法,才可能将其唤醒。

·线程执行I/O操作或进行远程通信时,会因为等待相关的资源而进入阻塞状态。例如当线程执行System.in.read()方法时,如果用户没有向控制台输入数据,则该线程会一直等读到了用户的输入数据才从read()方法返回。

进行远程通信时,在客户程序中,线程在以下情况下可能进入阻塞状态。

·请求与服务器建立连接时,即当线程执行Socket的带参数的构造方法,或执行Socket的connect()方法时,会进入阻塞状态,直到连接成功,此线程才从Socket的构造方法或connect()方法返回。

·线程从Socket的输入流读入数据时,如果没有足够的数据,就会进入阻塞状态,直到读到了足够的数据,或者到达输入流的末尾,或者出现了异常,才从输入流的read()方法返回或异常中断。输入流中有多少数据才算足够呢?这要看线程执行的read()方法的类。

(1)int read():只要输入流中有1字节,就算足够。

(2)int read(byte[] buff):只要输入流中的字节数目与参数buff数组的长度相同,就算足够。

(3)String readLine():只要输入流中有1行字符串,就算足够。值得注意的是,InputStream类并没有readLine()方法,在过滤流BufferedReader类中才有此方法。

·线程向Socket的输出流写一批数据时,可能会进入阻塞状态,等到输出了所有的数据,或者出现异常,才从输出流的write()方法返回或异常中断。

·如果调用Socket的setSoLinger()方法设置了关闭Socket的延迟时间,那么当线程执行Socket的close()方法时,会进入阻塞状态,直到底层Socket发送完所有剩余数据,或者超过了setSoLinger()方法设置的延迟时间,才从close()方法返回。

在服务器程序中,线程在以下情况下可能会进入阻塞状态。

·线程执行ServerSocket的accept()方法,等待客户的连接,直到接收到了客户连接,才从accept()方法返回。

·线程从Socket的输入流读入数据时,如果输入流没有足够的数据,就会进入阻塞状态。

·线程向Socket的输出流写一批数据时,可能会进入阻塞状态,等到输出了所有的数据,或者出现异常,才从输出流的write()方法返回或异常中断。

由此可见,无论是在服务器程序还是客户程序中,当通过Socket的输入流和输出流来读写数据时,都可能进入阻塞状态。这种可能出现阻塞的输入和输出操作被称为阻塞I/O。与此对照,如果执行输入和输出操作时,不会发生阻塞,则称为非阻塞I/O。

4.1.2 服务器程序用多线程处理阻塞通信的局限

本书3.6节(创建多线程的服务器)已经介绍了服务器程序用多线程来同时处理多个客户连接的方式。服务器程序的处理流程如图4-1所示。主线程负责接收客户的连接。在线程池中有若干工作线程,它们负责处理具体的客户连接。每当主线程接收一个客户连接,主线程就会把与这个客户交互的任务交给一个空闲的工作线程去完成,主线程继续负责接收下一个客户连接。

图4-1 服务器程序用多线程处理阻塞通信

图4-1 gui.EchoClient类创建的用户界面

在图4-1中,用粗体框标识的步骤为可能引起阻塞的步骤。从图中可以看出,当主线程接收客户连接,以及工作线程执行I/O操作时,都有可能进入阻塞状态。

服务器程序用多线程来处理阻塞I/O,尽管能满足同时响应多个客户请求的需求,但是有以下局限。

(1)Java虚拟机会为每个线程都分配独立的堆栈空间,工作线程数目越多,系统开销就越大,而且增加了Java虚拟机调度线程的负担,增加了线程之间同步的复杂性,提高了线程死锁的可能性。

(2)工作线程的许多时间都浪费在阻塞I/O操作上,Java虚拟机需要频繁地转让CPU的使用权,使进入阻塞状态的线程放弃CPU,再把CPU分配给处于可运行状态的线程。

由此可见,工作线程并不是越多越好。如图4-2所示,保持适量的工作线程,会提高服务器的并发性能,但是当工作线程的数目到达某个极限,超出了系统的负荷时,反而会降低并发性能,使得多数客户无法快速得到服务器的响应。

图4-2 工作线程数目与并发性能的关系

图4-2 BarDemo类创建的图形用户界面

4.1.3 非阻塞通信的基本思想

假如同时要做两件事:烧开水和煮粥。烧开水的步骤如下:

煮粥的步骤如下:

为了同时完成两件事,一种方案是同时请两个人分别做其中的一件事,这相当于采用多线程来同时完成多个任务。还有一种方案是让一个人同时完成两件事,这个人应该善于利用一件事的空闲时间去做另一件事,这个人一刻也不应该闲着。

这个人不断监控烧水和煮粥的状态,如果发生了“水烧开”“粥煮开”或“粥煮熟”事件,就去处理这些事件,处理完一件事后继续监控烧水和煮粥的状态,直到所有的任务都完成。

以上工作方式也可以被运用到服务器程序中,服务器程序只需要一个线程就能同时接收客户的连接、接收各个客户发送的数据,以及向各个客户发送响应数据。服务器程序的处理流程如下:

以上处理流程采用了轮询的工作方式,当某一种操作就绪,就执行该操作,否则就查看是否还有其他就绪的操作可以执行。线程不会因为某一个操作还没有就绪,就进入阻塞状态,一直傻傻地在那里等待这个操作就绪。

为了使轮询的工作方式顺利进行,接收客户的连接、从输入流读数据,以及向输出流写数据的操作都应该以非阻塞的方式运行。所谓非阻塞,指当线程执行这些方法时,如果操作还没有就绪,就立即返回,而不会一直等到操作就绪。例如当线程接收客户连接时,如果没有客户连接,就立即返回;再例如当线程从输入流中读数据时,如果输入流中还没有数据,就立即返回,或者如果输入流还没有足够的数据,那么就读取现有的数据,然后返回。值得注意的是,以上while循环条件中的操作还是按照阻塞方式进行的,如果未发生任何事件,就会进入阻塞状态,直到接收连接就绪事件、读就绪事件或写就绪事件中至少有一个事件发生,此时才会执行while循环体中的操作。