网络可伸缩性涉及以这样一种方式构建应用程序,即随着对应用程序的更多需求,它可以进行调整以处理压力。需求可能以更多用户、更多请求、更复杂的请求以及网络特性的变化的形式出现。
有几个值得关注的领域如下:
- 服务器容量
- 多线程
- 网络带宽和延迟
- 执行环境
可扩展性可以通过增加更多的服务器、使用适当数量的线程、提高执行环境的性能、增加网络带宽来消除瓶颈来实现。
添加更多服务器将有助于启用服务器之间的负载平衡。但是,如果网络带宽或延迟是问题,那么这将无济于事。只有这么多可以通过网络管道推送。
线程经常用于提高系统性能。为系统使用适当数量的线程允许一些线程执行而其他线程被阻塞。被阻塞的线程可能正在等待 IO 发生或等待用户响应。在某些线程被阻塞时允许其他线程执行可以增加应用程序吞吐量。
执行环境包括底层硬件、操作系统、JVM 和应用程序本身。这些领域中的每一个都是改进的候选者。我们不会解决硬件环境问题,因为这超出了我们的控制范围。操作系统也是如此。虽然可以实现一些性能改进,但我们不会解决这些领域。将识别可能影响网络性能的 JVM 参数。
我们将检查代码改进机会。我们的大部分讨论都与线程的使用有关,因为我们对这个架构特性有更多的控制权。我们将在本章中说明几种提高应用程序可伸缩性的方法。这些包括以下内容:
- 多线程服务器
- 线程池
- 期货和可赎回
- 选择器 (TCP/UDP)
我们将探讨使用简单线程/池的细节,因为您可能会在工作中遇到它们,并且可能由于平台限制而无法使用一些较新的技术。线程池提供了在许多情况下重用线程的优势。Futures 和 callables 是一种线程变体,其中数据可以从线程传递和返回。选择器允许单个线程处理多个通道。
多线程服务器概述
多线程服务器的主要优点是长时间运行的客户端请求不会阻止服务器接受其他客户端请求。如果未创建新线程,则将处理当前请求。只有在处理完请求后,才能接受新的请求。对请求使用单独的线程意味着可以并发处理连接及其相关请求。
使用多线程服务器时,有以下几种配置线程的方法:
- 每个请求的线程
- 每个连接的线程
- 每个对象的线程
在每个请求的线程模型中,每个到达服务器的请求都会被分配一个新的线程。虽然这是一种简单的方法,但它可能会导致创建大量线程。此外,每个请求通常意味着将创建一个新连接。
该模型在不需要保留先前客户端请求的环境中运行良好。例如,如果服务器的唯一目的是响应对特定股票报价的请求,则线程不需要知道任何先前的请求。
这种方法如下图所示。发送到服务器的每个客户端请求都分配给一个新线程。
在每个连接的线程模型中,在会话期间保持客户端连接。会话由一系列请求和响应组成。会话通过特定命令或超时时间结束后终止。这种方法允许在请求之间维护状态信息。
这种方法如下图所示。虚线表示来自同一客户端的多个请求由同一线程处理。
每个对象的线程方法将关联的请求与可以处理请求的特定对象进行排队。该对象及其方法放置在一个线程中,一次处理一个请求。请求与线程一起排队。虽然我们不会在这里演示这种方法,但它经常与线程池一起使用。
创建和删除连接的过程可能很昂贵。如果客户端提交多个请求,则打开和关闭连接的开销会很大,应该避免。
为了管理线程过多的问题,经常使用线程池。当需要处理请求时,会将请求分配给现有的未使用线程来处理请求。一旦发送了响应,线程就可以用于其他请求。这假设不需要维护状态信息。
每个请求的线程方法
在第1章,入门网络编程,我们说明一个简单的多线程服务器的回声。此处重新引入此方法,为本章剩余部分中线程的使用奠定基础。
每个请求的线程服务器
在此示例中,服务器将在给定零件名称时接受价格请求。该实现将使用ConcurrentHashMap
支持并发访问零件名称和价格的类。在多线程环境中,并发数据结构(例如ConcurrentHashMap
类)处理操作时不会造成数据损坏。此外,此映射是缓存的一个示例,可用于提高应用程序的性能。
我们从服务器的声明开始,如下所示。该映射被声明为静态,因为服务器只需要一个实例。静态初始化块初始化地图。该main
方法将使用ServerSocket
该类来接受来自客户端的请求。它们将在run
方法中处理。该clientSocket
变量将保存对客户端套接字的引用:
public class SimpleMultiTheadedServer implements Runnable { private static ConcurrentHashMap<String, Float> map; private Socket clientSocket; static { map = new ConcurrentHashMap<>(); map.put("Axle", 238.50f); map.put("Gear", 45.55f); map.put("Wheel", 86.30f); map.put("Rotor", 8.50f); } SimpleMultiTheadedServer(Socket socket) { this.clientSocket = socket; } public static void main(String args[]) { ... } public void run() { ... } }
该main
方法遵循服务器套接字等待客户端请求,然后创建一个新线程,将客户端套接字传递给线程来处理它。显示的消息显示正在接受连接:
public static void main(String args[]) { System.out.println("Multi-Threaded Server Started"); try { ServerSocket serverSocket = new ServerSocket(5000); while (true) { System.out.println( "Listening for a client connection"); Socket socket = serverSocket.accept(); System.out.println("Connected to a Client"); new Thread(new SimpleMultiTheadedServer(socket)).start(); } } catch (IOException ex) { ex.printStackTrace(); } System.out.println("Multi-Threaded Server Terminated"); }
该run
方法处理请求,如下所示。从客户端套接字获取输入流,并读取部件名称。地图的get
方法使用此名称来检索价格。输入流将价格发送回客户端,并显示操作进度:
public void run() { System.out.println("Client Thread Started"); try (BufferedReader bis = new BufferedReader( new InputStreamReader( clientSocket.getInputStream())); PrintStream out = new PrintStream( clientSocket.getOutputStream())) { String partName = bis.readLine(); float price = map.get(partName); out.println(price); NumberFormat nf = NumberFormat.getCurrencyInstance(); System.out.println("Request for " + partName + " and returned a price of " + nf.format(price)); clientSocket.close(); System.out.println("Client Connection Terminated"); } catch (IOException ex) { ex.printStackTrace(); } System.out.println("Client Thread Terminated"); }
现在,让我们为服务器开发一个客户端。
每个请求的线程客户端
客户端应用程序,如下所示,将连接到服务器,发送请求,等待响应,然后显示价格。对于这个例子,客户端和服务器位于同一台机器上:
public class SimpleClient { public static void main(String args[]) { System.out.println("Client Started"); try { Socket socket = new Socket("127.0.0.1", 5000); System.out.println("Connected to a Server"); PrintStream out = new PrintStream(socket.getOutputStream()); InputStreamReader isr = new InputStreamReader(socket.getInputStream()); BufferedReader br = new BufferedReader(isr); String partName = "Axle"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine()); socket.close(); } catch (IOException ex) { ex.printStackTrace(); } System.out.println("Client Terminated"); } }
现在,让我们看看客户端和服务器是如何交互的。
运行中的线程请求应用程序
首先启动服务器,将显示以下输出:
Listening for a client connection
接下来,启动客户端应用程序。将显示以下输出:
Client Started
Connected to a Server
Axle request sent
Response: 238.5
Client Terminated
然后服务器将显示以下输出。您会注意到Client Thread Started输出在Listening for a client connection输出之后。这是因为线程启动前有一点延迟:
Connected to a Client
Listening for a client connection
Client Thread Started
Request for Axle and returned a price of $238.50
Client Connection Terminated
Client Thread Terminated
客户端线程启动,处理请求,然后终止。
在关闭操作之前将以下代码添加到客户端应用程序,以向服务器发送第二个价格请求:
partName = "Wheel"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine());
执行客户端时,您将获得以下输出。第二个字符串的响应为空。这是因为在响应第一个请求后服务器的响应线程已终止:
Client Started
Connected to a Server
Axle request sent
Response: 238.5
Wheel request sent
Response: null
Client Terminated
要使用这种方法处理多个请求,您需要重新打开连接并发送单独的请求。以下代码说明了这种方法。删除发送第二个请求的代码段。关闭socket后在客户端添加如下代码。在这个序列中,套接字被重新打开,IO 流被重新创建,消息被重新发送:
socket = new Socket("127.0.0.1", 5000); System.out.println("Connected to a Server"); out = new PrintStream(socket.getOutputStream()); isr = new InputStreamReader(socket.getInputStream()); br = new BufferedReader(isr); partName = "Wheel"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine()); socket.close();
当客户端被执行时,它将产生以下输出,它反映了两个请求及其响应:
Client Started
Connected to a Server
Axle request sent
Response: 238.5
Connected to a Server
Wheel request sent
Response: 86.3
Client Terminated
在服务器端,我们将得到以下输出。创建了两个线程来处理请求:
Multi-Threaded Server Started
Listening for a client connection
Connected to a Client
Listening for a client connection
Client Thread Started
Connected to a Client
Listening for a client connection
Client Thread Started
Request for Axle and returned a price of $238.50
Client Connection Terminated
Client Thread Terminated
Request for Wheel and returned a price of $86.30
Client Connection Terminated
Client Thread Terminated
连接的打开和关闭可能很昂贵。在下一节中,我们将解决此类问题。但是,如果只发出单个请求,则每个请求的线程将起作用。
每个连接的线程方法
在这种方法中,使用单个线程来处理客户端的所有请求。这种方法将要求客户端发送某种通知,表明它没有进一步的请求。代替显式通知,可能需要设置超时以在经过足够长的时间后自动断开客户端连接。
每个连接的线程服务器
run
通过注释掉处理请求并将响应发送到客户端的大部分 try 块来修改服务器的方法。将其替换为以下代码。在无限循环中,读取命令请求。如果请求是quit
,则退出循环。否则,请求的处理方式与之前相同:
while(true) { String partName = bis.readLine(); if("quit".equalsIgnoreCase(partName)) { break; } float price = map.get(partName); out.println(price); NumberFormat nf = NumberFormat.getCurrencyInstance(); System.out.println("Request for " + partName + " and returned a price of " + nf.format(price)); }
这就是需要在服务器中修改的全部内容。
每个连接的线程客户端
在客户端中,使用以下代码替换创建缓冲读取器后的代码。这将向服务器发送三个请求:
String partName = "Axle"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine()); partName = "Wheel"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine()); partName = "Quit"; out.println(partName); socket.close();
所有三个请求只打开一个连接。
每个连接的线程应用程序正在运行
当客户端执行时,您将获得以下输出:
Connected to a Server
Axle request sent
Response: 238.5
Wheel request sent
Response: 86.3
Client Terminated
在服务器端,生成以下输出。您会注意到只创建了一个线程来处理多个请求:
Multi-Threaded Server Started
Listening for a client connection
Connected to a Client
Listening for a client connection
Client Thread Started
Request for Axle and returned a price of $238.50
Request for Wheel and returned a price of $86.30
Client Connection Terminated
Client Thread Terminated
当客户端发出多个请求时,这是一种更有效的架构。
线程池
当需要限制创建的线程数时,线程池很有用。使用池不仅可以控制创建的线程数,而且还可以消除重复创建和销毁线程的需要,而这通常是一项昂贵的操作。
下图描绘了一个线程池。请求被分配给池中的线程。如果没有可用的未使用线程,一些线程池将创建新线程。其他人将限制可用线程的数量。这可能会导致某些请求被阻止。
我们将使用ThreadPoolExecutor
该类演示线程池。此类还提供传递有关线程执行的状态信息的方法。
虽然ThreadPoolExecutor
该类拥有多个构造函数,但Executors
该类提供了一种创建ThreadPoolExecutor
该类实例的简单方法。我们将演示其中两种方法。首先,我们将使用该newCachedThreadPool
方法。此方法创建的池将重用线程。需要时将创建新线程。但是,这可能会导致创建过多线程。第二种方法是newFixedThreadPool
创建一个固定大小的线程池。
ThreadPoolExecutor 类的特性
当这个类的实例被创建时,它将接受新的任务,这些任务被传递到线程池。但是,池不会自动关闭。如果空闲,它将等待直到提交新任务。要终止池,需要调用shutdown
或shutdownNow
方法。后一种方法会立即关闭池并且不会处理挂起的任务。
所述ThreadPoolExecutor
类具有许多提供附加信息的方法。例如,该getPoolSize
方法返回池中的当前线程数。该方法返回活动线程的数量。该方法返回一次在池中的最大线程数。还有其他几种方法可用。 getActiveCountgetLargestPoolSize
简单的线程池服务器
我们将用于演示线程池的服务器在给定零件名称时将返回零件的价格。每个线程将访问一个ConcurrentHashMap
包含零件信息的实例。我们使用哈希映射的并发版本,因为它可以从多个线程访问。
在ThreadPool
类被声明为未来。该main
方法使用一个WorkerThread
类来执行实际工作。在main
方法中,newCachedThreadPool
调用方法创建线程池:
public class ThreadPool { public static void main(String[] args) { System.out.println("Thread Pool Server Started"); ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newCachedThreadPool(); ... executor.shutdown(); System.out.println("Thread Pool Server Terminated"); } }
接下来,try 块用于捕获和处理可能发生的任何异常。在 try 块中,创建了一个服务器套接字,它的accept
方法会阻塞,直到请求客户端连接。建立连接后,WorkerThread
会使用客户端套接字创建一个实例,如以下代码所示:
try { ServerSocket serverSocket = new ServerSocket(5000); while (true) { System.out.println( "Listening for a client connection"); Socket socket = serverSocket.accept(); System.out.println("Connected to a Client"); WorkerThread task = new WorkerThread(socket); System.out.println("Task created: " + task); executor.execute(task); } } catch (IOException ex) { ex.printStackTrace(); }
现在,让我们检查WorkerThread
接下来显示的类。该ConcurrentHashMap
实例被声明其中字符串用作密钥和所存储的所述对象是一个浮子。该哈希映射被初始化静态初始化块:
public class WorkerThread implements Runnable { private static final ConcurrentHashMap<String, Float> map; private final Socket clientSocket; static { map = new ConcurrentHashMap<>(); map.put("Axle", 238.50f); map.put("Gear", 45.55f); map.put("Wheel", 86.30f); map.put("Rotor", 8.50f); } ... }
该类的构造函数将客户端套接字分配给clientSocket
实例变量以供以后使用,如下所示:
public WorkerThread(Socket clientSocket) { this.clientSocket = clientSocket; }
该run
方法处理请求。从客户端套接字获取输入流并用于获取部件名称。该名称用作哈希映射get
方法的参数以获取相应的价格。该价格被发送回客户端,并显示一条消息显示响应:
@Override public void run() { System.out.println("Worker Thread Started"); try (BufferedReader bis = new BufferedReader( new InputStreamReader( clientSocket.getInputStream())); PrintStream out = new PrintStream( clientSocket.getOutputStream())) { String partName = bis.readLine(); float price = map.get(partName); out.println(price); NumberFormat nf = NumberFormat.getCurrencyInstance(); System.out.println("Request for " + partName + " and returned a price of " + nf.format(price)); clientSocket.close(); System.out.println("Client Connection Terminated"); } catch (IOException ex) { ex.printStackTrace(); } System.out.println("Worker Thread Terminated"); }
我们现在准备讨论客户端应用程序。
简单线程池客户端
此应用程序使用Socket
该类建立与服务器的连接。输入和输出流用于发送和接收响应。这种方法是在讨论第1章,入门网络编程。客户端应用程序如下。与服务器建立连接,并向服务器发送对零件价格的请求。获得并显示响应。
public class SimpleClient { public static void main(String args[]) { System.out.println("Client Started"); try (Socket socket = new Socket("127.0.0.1", 5000)) { System.out.println("Connected to a Server"); PrintStream out = new PrintStream(socket.getOutputStream()); InputStreamReader isr = new InputStreamReader(socket.getInputStream()); BufferedReader br = new BufferedReader(isr); String partName = "Axle"; out.println(partName); System.out.println(partName + " request sent"); System.out.println("Response: " + br.readLine()); socket.close(); } catch (IOException ex) { ex.printStackTrace(); } System.out.println("Client Terminated"); } }
我们现在准备好看看它们是如何协同工作的。
运行中的线程池客户端/服务器
首先启动服务器应用程序。您将看到以下输出:
Listening for a client connection
接下来,启动客户端。它将产生以下输出,其中发送了对车轴价格的请求,然后238.5
收到了响应:
Client Started
Connected to a Server
Axle request sent
Response: 238.5
Client Terminated
在服务器端,您将看到与以下类似的输出。创建线程,并显示请求和响应数据。然后线程终止。您会注意到线程的名称前面有字符串“packt”。这是应用程序包的名称:
Connected to a Client
Task created: packt.WorkerThread@33909752
Listening for a client connection
Worker Thread Started
Request for Axle and returned a price of $238.50
Client Connection Terminated
Worker Thread Terminated
如果您启动第二个客户端,服务器将产生与以下类似的输出。您会注意到为每个请求创建了一个新线程:
Thread Pool Server Started
Listening for a client connection
Connected to a Client
Task created: packt.WorkerThread@33909752
Listening for a client connection
Worker Thread Started
Request for Axle and returned a price of $238.50
Client Connection Terminated
Worker Thread Terminated
Connected to a Client
Task created: packt.WorkerThread@3d4eac69
Listening for a client connection
Worker Thread Started
Request for Axle and returned a price of $238.50
Client Connection Terminated
Worker Thread Terminated
带有 Callable 的线程池
使用和接口提供了另一种支持多线程的方法。该接口支持线程需要返回结果的线程。该接口的方法不返回一个值。对于某些线程,这可能是一个问题。该接口具有一个单一的方法,,它返回一个值,并且可以用来代替接口。 CallableFutureCallableRunnablerunCallablecallRunnable
所述Future
接口被结合使用一个Callable
对象。这个想法是call
调用该方法并且当前线程继续执行一些其他任务。当Callable
对象完成时,将使用一个get
方法来检索结果。如有必要,此方法将阻塞。
使用可调用
我们将使用该Callable
接口来补充WorkerThread
我们之前创建的类。我们不会将零件名称哈希映射放在WorkerThread
类中,而是将其移动到一个名为的类中,在该类WorkerCallable
中我们将覆盖该call
方法以返回价格。这实际上是此应用程序的额外工作,但它说明了使用Callable
界面的一种方式。它演示了我们如何从Callable
对象返回一个值。
WorkerCallable
接下来声明的类使用相同的代码来创建和初始化哈希映射:
public class WorkerCallable implements Callable<Float> { private static final ConcurrentHashMap<String, Float> map; private String partName; static { map = new ConcurrentHashMap<>(); map.put("Axle", 238.50f); map.put("Gear", 45.55f); map.put("Wheel", 86.30f); map.put("Rotor", 8.50f); } ... }
构造函数将初始化部件名称,如下所示:
public WorkerCallable(String partName) { this.partName = partName; }
该call
方法如下所示。地图获取价格,我们将其显示然后返回:
@Override public Float call() throws Exception { float price = map.get(this.partName); System.out.println("WorkerCallable returned " + price); return price; }
接下来,WorkerThread
通过删除以下语句来修改类:
float price = map.get(partName);
将其替换为以下代码。WorkerCallable
使用客户端请求的部件名称创建新实例。该call
方法会立即被调用并返回相应部分的价格:
float price = 0.0f; try { price = new WorkerCallable(partName).call(); } catch (Exception ex) { ex.printStackTrace(); }
应用程序将产生与以前相同的输出,除了您将看到指示WorkerCallable
类的call
方法已执行的消息。当另一个线程被创建时,我们将阻塞直到call
方法返回。
这个例子没有完全展示这种方法的威力。该Future
界面将改进这种技术。
使用未来
该Future
接口表示已完成call
方法的结果。通过这个接口,我们可以调用一个Callable
对象而不用等待它返回。假设计算零件价格的过程不仅仅是在表格中查找。可以想象,计算一个价格可能需要多个步骤,每个步骤都可能涉及并且可能需要一些时间来完成。还假设这些单独的步骤可以同时执行。
将前面的示例替换为以下代码。我们创建一个新ThreadPoolExecutor
实例,我们将为其分配两个Callable
代表两步价格确定过程的对象。这是使用submit
方法完成的,该方法返回一个Future
实例。call
方法的实现分别返回1.0
和2.0
以保持示例简单:
float price = 0.0f; ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newCachedThreadPool(); Future<Float> future1 = executor.submit(new Callable<Float>() { @Override public Float call() { // Compute first part return 1.0f; } }); Future<Float> future2 = executor.submit(new Callable<Float>() { @Override public Float call() { // Compute second part return 2.0f; } });
接下来,添加以下 try 块,它使用get
方法获取价格的两部分。这些用于确定零件的价格。如果对应的Callable
对象还没有完成,那么该get
方法会阻塞:
try { Float firstPart = future1.get(); Float secondPart = future2.get(); price = firstPart + secondPart; } catch (InterruptedException|ExecutionException ex) { ex.printStackTrace(); }
执行CallableFuture
此代码后,您将获得 3.0 的零件价格。和接口的组合提供了一种易于使用的技术来处理返回值的线程。
使用 HttpServer 执行器
我们引入了HTTPServer
类第4章,客户/服务器开发。当 HTTP Server 收到请求时,默认情况下,它使用调用该start
方法时创建的线程。但是,可以使用不同的线程。该setExecutor
方法指定如何将这些请求分配给线程。
此方法的参数是一个Executor
对象。对于这个参数,我们可以使用几种实现中的任何一种。在以下序列中,使用缓存的线程池:
server.setExecutor(Executors.newCachedThreadPool());
要控制服务器使用的线程数,我们可以使用大小5
为的固定线程池,如下所示:
server.setExecutor(Executors.newFixedThreadPool(5));
必须在调用 的start
方法之前调用此方法HTTPServer
。然后将所有请求提交给执行器。以下是从复制HTTPServer
这是在开发类第4章,客户/服务器开发,并为您展示使用的setExecutor
方法:
public class MyHTTPServer { public static void main(String[] args) throws Exception { System.out.println("MyHTTPServer Started"); HttpServer server = HttpServer.create( new InetSocketAddress(80), 0); server.createContext("/index", new OtherHandler()); server.setExecutor(Executors.newCachedThreadPool()); server.start(); } ... }
服务器将以与以前相同的方式执行,但它将使用缓存线程池。
使用选择器
选择器用于 NIO 应用程序并允许一个线程处理多个通道。选择器协调多个通道及其事件。它标识那些准备好进行处理的通道。如果我们要为每个通道使用一个线程,那么我们会发现自己经常在线程之间切换。这种切换过程可能很昂贵。使用单个线程来处理多个通道可以避免一些此类开销。
下图描述了这种架构。一个线程被一个选择器注册。选择器将识别准备处理的通道和事件。
选择器由两个主要类支持:
Selector
:这提供了主要功能SelectionKey
:这标识了准备处理的事件类型
要使用选择器,请执行以下操作:
- 创建选择器
- 使用选择器注册频道
- 选择可用的频道以供使用
让我们更详细地检查每个步骤。
创建选择器
有没有公共Selector
构造函数。要创建Selector
对象,请使用静态open
方法,如下所示:
Selector selector = Selector.open();
还有一种isOpen
方法可以确定选择器是否打开,以及在close
不再需要时关闭它的方法。
注册频道
该register
方法使用选择器注册通道。任何使用选择器注册的通道都必须处于非阻塞模式。例如,一个FileChannel
对象不能被注册,因为它不能被置于非阻塞模式。使用configureBlocking
withfalse
作为参数的方法将通道置于非阻塞模式,如下所示:
socketChannel.configureBlocking(false);
该register
方法如下所述。这是ServerSocketChannel
SocketChannel 和 SocketChannel 类的方法。在以下示例中,它与 一起使用SocketChannel
instance
:
socketChannel.register(selector, SelectionKey.OP_WRITE, null);
本Channel
类的register
方法具有三个参数:
- 要注册的选择器
- 感兴趣的事件类型
- 与通道关联的数据
事件类型指定应用程序有兴趣处理的通道事件的类型。例如,如果通道有准备好读取的数据,我们可能只想收到事件通知。
有四种可用的事件类型,如下表所列:
类型 | 事件类型常量 | 意义 |
连接 |
| 这表示通道已成功连接到服务器 |
接受 |
| 这表明服务器套接字通道已准备好接受来自客户端的连接请求 |
读 |
| 这表明通道有数据准备好被读取 |
写 |
| 这表明通道已准备好进行写操作 |
这些类型被称为兴趣集。在以下语句中,通道与读取兴趣类型相关联。该方法返回一个SelectionKey
实例,其中包含许多有用的属性:
SelectionKey key = channel.register(selector,
SelectionKey.OP_READ);
如果有多个感兴趣的事件,那么我们可以使用 OR 运算符创建这些事件的组合,如下所示:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE; SelectionKey key = channel.register(selector, interestSet);
本SelectionKey
类具有几个特性,将与渠道合作帮助。这些包括以下内容:
- 兴趣集:这包含感兴趣的事件。
- 就绪集:这是通道准备处理的操作集。
- Channel:该
channel
方法返回与选择键关联的通道。 - Selector:该
selector
方法返回与选择键关联的选择器。 - 附加对象:可以使用该方法附加更多信息。该方法稍后用于访问此对象。
attachattachment
该interestOps
方法返回一个表示感兴趣事件的整数,如下所示:
int InterestSet = selectionKey.interestOps();
我们将使用它来处理事件。
要确定哪些事件已准备就绪,我们可以使用以下任何一种方法:
readOps
: 这将返回一个包含就绪事件的整数isAcceptable
:这表示accept事件准备好了isConnectable
: 这表示连接事件准备好了isReadable
: 这表示读事件准备好了isWritable
: 这表示写事件准备好了
现在,让我们看看这些方法的实际效果。
使用选择器支持时间客户端/服务器
我们将开发一个时间服务器来说明Selector
类和相关类的使用。这种服务器和客户端的时间是改编自认为是在时间服务器和客户端应用程序第3章,NIO支持网络。这里的重点将放在选择器的使用上。通道和缓冲区操作不会在这里讨论,因为它们已经在前面讨论过了。
通道时间服务器
时间服务器将接受与客户端应用程序的连接,并每秒向客户端发送当前日期和时间。正如我们在讨论客户时会发现的那样,客户可能不会收到所有这些消息。
时间服务器使用内部静态类SelectorHandler
来处理选择器和发送消息。这个类实现了Runnable
接口并将成为选择器的线程。
在该main
方法中,服务器套接字接受新的通道连接并将它们注册到选择器。该Selector
对象被声明为静态实例变量,如下所示。这允许从SelectorHandler
线程和主应用程序线程访问它。共享此对象将导致潜在的同步问题,我们将解决这些问题:
public class ServerSocketChannelTimeServer { private static Selector selector; static class SelectorHandler implements Runnable { ... } public static void main(String[] args) { ... } }
让我们从main
方法开始。创建了一个使用 port 的服务器套接字通道5000
。在 try 块中捕获异常,如下所示:
public static void main(String[] args) { System.out.println("Time Server started"); try { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind( new InetSocketAddress(5000)); ... } } catch (ClosedChannelException ex) { ex.printStackTrace(); } catch (IOException ex) { ex.printStackTrace(); } }
创建选择器,并SelectorHandler
启动实例的线程:
selector = Selector.open(); new Thread(new SelectorHandler()).start();
无限循环将接受新连接。将显示一条消息,指示已建立连接:
while (true) { SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("Socket channel accepted - " + socketChannel); ... }
有了好的通道,configureBlocking
调用方法,唤醒选择器,将通道注册到选择器。线程可能被该select
方法阻塞。使用该wakeup
方法将导致该select
方法立即返回,从而允许该register
方法解除阻塞:
if (socketChannel != null) { socketChannel.configureBlocking(false); selector.wakeup(); socketChannel.register(selector, SelectionKey.OP_WRITE, null); }
一旦使用选择器注册了通道,我们就可以开始处理与该通道关联的事件。
该SelectorHandler
课程将使用选择对象,以确定事件发生时,并将它们与特定的频道相关联。它的run
方法完成了所有的工作。如下所示,无限循环使用该select
方法在事件发生时识别事件。该select
方法使用 的参数500
,该参数指定 500 毫秒的超时。它返回一个整数,指定准备处理多少键:
static class SelectorHandler implements Runnable { @Override public void run() { while (true) { try { System.out.println("About to select ..."); int readyChannels = selector.select(500); ... } catch (IOException | InterruptedException ex) { ex.printStackTrace(); } } } }
如果该select
方法超时,它将返回值0
。发生这种情况时,我们会显示一条消息,如下所示:
if (readyChannels == 0) { System.out.println("No tasks available"); } else { ... }
如果有准备好的密钥,则该selectedKeys
方法将返回此集合。然后使用迭代器一次处理每个键:
Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = keys.iterator(); while (keyIterator.hasNext()) { ... }
SelectionKey
检查每个实例以查看发生了哪种事件类型。在下面的实现中,只处理可写事件。处理后,线程休眠一秒钟。这将具有将日期和时间消息的发送延迟至少一秒的效果。remove
需要该方法来删除迭代器列表的事件:
SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // Connection accepted } else if (key.isConnectable()) { // Connection established } else if (key.isReadable()) { // Channel ready to read } else if (key.isWritable()) { ... } Thread.sleep(1000); keyIterator.remove();
如果是可写事件,则发送日期和时间,如下所示。该channel
方法返回事件的通道,并将消息发送到该客户端。将显示一条消息,表明消息已发送:
String message = "Date: " + new Date(System.currentTimeMillis()); ByteBuffer buffer = ByteBuffer.allocate(64); buffer.put(message.getBytes()); buffer.flip(); SocketChannel socketChannel = null; while (buffer.hasRemaining()) { socketChannel = (SocketChannel) key.channel(); socketChannel.write(buffer); } System.out.println("Sent: " + message + " to: " + socketChannel);
准备好服务器后,我们将开发我们的客户端应用程序。
日期和时间客户端应用程序
客户端应用程序是几乎相同的在开发的一个第3章,NIO支持网络。主要区别在于它将以随机间隔请求数据和时间。当我们在服务器上使用多个客户端时,就会看到这种效果。应用程序的实现如下:
public class SocketChannelTimeClient { public static void main(String[] args) { Random random = new Random(); SocketAddress address = new InetSocketAddress("127.0.0.1", 5000); try (SocketChannel socketChannel = SocketChannel.open(address)) { while (true) { ByteBuffer byteBuffer = ByteBuffer.allocate(64); int bytesRead = socketChannel.read(byteBuffer); while (bytesRead > 0) { byteBuffer.flip(); while (byteBuffer.hasRemaining()) { System.out.print((char) byteBuffer.get()); } System.out.println(); bytesRead = socketChannel.read(byteBuffer); } Thread.sleep(random.nextInt(1000) + 1000); } } catch (ClosedChannelException ex) { // Handle exceptions }catch (IOException | InterruptedException ex) { // Handle exceptions } } }
我们现在准备好看看服务器和客户端如何协同工作。
正在运行的日期和时间服务器/客户端
首先,启动服务器。它将产生以下输出:
About to select ...
No tasks available
About to select ...
No tasks available
About to select ...
No tasks available
...
此序列将重复自身,直到客户端连接到服务器。
接下来,启动客户端。在客户端,您将获得类似于以下内容的输出:
Date: Wed Oct 07 17:55:43 CDT 2015
Date: Wed Oct 07 17:55:45 CDT 2015
Date: Wed Oct 07 17:55:47 CDT 2015
Date: Wed Oct 07 17:55:49 CDT 2015
在服务器端,您将看到反映连接和请求的输出,如下所示。您会注意到端口号58907
标识此客户端:
...
Sent: Date: Wed Oct 07 17:55:43 CDT 2015 to: java.nio.channels.SocketChannel[connected local=/127.0.0.1:5000 remote=/127.0.0.1:58907]
...
Sent: Date: Wed Oct 07 17:55:45 CDT 2015 to: java.nio.channels.SocketChannel[connected local=/127.0.0.1:5000 remote=/127.0.0.1:58907]
启动第二个客户端。您将看到类似的连接消息,但具有不同的端口号。下面的一个可能的连接消息是显示一个带有端口号的客户端58908
:
然后,您将看到发送到两个客户端的日期和时间消息。
处理网络超时
在现实世界中部署应用程序时,可能会出现在 LAN 上开发该应用程序时不存在的新网络问题。网络拥塞、连接缓慢和网络链接丢失等问题可能会导致消息延迟或丢失。检测和处理网络超时很重要。
有几个套接字选项可以对套接字通信进行一些控制。该SO_TIMEOUT
选项用于设置读取操作的超时时间。如果经过了指定的时间量,则会SocketTimeoutException
引发异常。
在以下语句中,套接字将在 3 秒后过期:
Socket socket = new ... socket.setSoTimeout(3000);
该选项必须在阻塞读取操作发生之前设置。超时为零永远不会超时。处理超时是一个重要的设计考虑。
概括
在本章中,我们研究了几种解决应用程序可伸缩性的方法。可扩展性是指应用程序补偿其上增加的负载的能力。虽然我们的示例侧重于将这些技术应用于服务器,但它们同样适用于客户端。
我们介绍了三种线程架构,我们专注于其中两种架构:thread-per-request 和 thread-per-connection。每个请求的线程模型为到达服务器的每个请求创建一个新线程。这适用于客户端一次发出一个或可能几个请求的情况。
每个连接的线程模型将创建一个线程来处理来自客户端的多个请求。这避免了必须多次重新连接到客户端以及不得不承担创建多个线程的成本。这种方法适用于需要维护会话和可能的状态信息的客户端。
线程池支持一种避免创建和销毁线程的方法。线程集合由线程池管理。未使用的线程可以重新用于不同的请求。线程池的大小是可以控制的,因此可以根据应用程序和环境的要求进行限制。该Executor
班是用于创建和管理线程池。
NIO 的Selector
课程被说明。这个类使得使用线程和 NIO 通道更容易。通道和与通道相关的事件通过选择器注册。当事件(例如通道变为可用于读取操作)发生时,选择器提供对通道和事件的访问。这允许单个线程管理多个通道。
我们简要地重新审查了HttpServer
该年推出的类第4章,客户/服务器开发。我们演示了添加线程池以提高服务器性能是多么容易。我们还研究了网络超时的性质以及如何处理它们。当网络无法支持应用程序之间的及时通信时,就会发生这些情况。
在下一章中,我们将探讨网络安全威胁以及我们如何应对它们。