1 引言
在软件项目开发中,许多后台服务程序的处理动作流程都具有一个相同点,就是:接受客户端发来的请求,对请求进行一些相关的处理,最后将处理结果返回给客户 端。这些请求的来源和方式可能会各不相同,但是它们常常都有一个共同点:数量巨大,处理时间短。这类服务器在实际应用中具有较大的普遍性,如web服务 器,短信服务器,DNS服务器等等。因此,研究如何提高此类后台程序的性能,如何保证服务器的稳定性以及安全性都具有重要的实用价值。
2 后台服务程序设计
2.1 关于设计原型
构建服务器应用程序的一个简单的模型是:启动一个无限循环,循环里放一个监听线程监听某个地址端口。每当一个请求到达就创建一个新线程,然后新线程为请求服务,监听线程返回继续监听。
//简单举例如下:
import java.net.*;
public class MyServer extends Thread{
public void run(){
try{
ServerSocket server=null;
Socket clientconnection=null;
server = new ServerSocket(8008);//监听某地址端口对
while(true){进入无限循环
clientconnection =server.accept();//收取请求
new ServeRequest(clientconnection).start();//启动一个新服务线程进行服务
……
}
}catch(Exception e){
System.err.println("Unable to start serve listen:"+e.getMessage());
e.printStackTrace();
}
}
}
实际上,这只是个简单的原型,如果试图部署以这种方式运行的服务器应用程序,那么这种方法的严重不足就很明显。
首先,为每个请求创建一个新线程的开销很大,为每个请求创建新线程的服务器在创建和销毁线程上花费的时间和消耗的系统资源, 往往有时候要比花在处理实际的用户请求的时间和资源更多。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提 高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数。这样综合看来,系统的性能瓶颈就在于线程的创建开销。
其次,除了创建和销毁线程的开销之外,活动的线程也消耗系统资源。在一个 JVM 里创建太多的线程可能会导致系统由于过度消耗内存而用完内存或“切换过度”。为了防止资源不足,服务器应用程序需要一些办法来限制任何给定时刻运行的处理 线程数目,以防止服务器被“压死”的情况发生。所以在设计后台程序的时候,一般需要提前根据服务器的内存、CPU等硬件情况设定一个线程数量的上限值。
如果创建和销毁线程的时间相对于服务时间占用的比例较大,那末假设在一个较短的时间内有成千上万的请求到达,想象一下,服务器的时间和资源将会大量的花在 创建和销毁线程上,而真正用于处理请求的时间却相对较少,这种情况下,服务器性能瓶颈就在于创建和销毁线程的时间。按照这个模型写一个简单的程序测试一下 即可看出,由于篇幅关系,此处略。如果把(服务时间/创建和销毁线程的时间)作为衡量服务器性能的一个参数,那末这个比值越大,服务器的性能就越高。
应此,解决此类问题的实质就是尽量减少创建和销毁线程的时间,把服务器的资源尽可能多地用到处理请求上来,从而发挥多线程的优点(并发),避免多线程的缺点(创建和销毁的时空开销)。
线程池为线程生命周期开销问题和资源不足问题提供了解决方案。通过对多个任务重用线程,线程创建的开销被分摊到了多个任务上。其好处是,因为在请求到达时 线程已经存在,所以无意中也消除了线程创建所带来的延迟。这样,就可以立即为请求服务,使应用程序响应更快。而且,通过适当地调整线程池中的线程数目,也 就是当请求的数目超过某个阈值时,就强制其它任何新到的请求一直等待,直到获得一个线程来处理为止,从而可以防止资源不足。
3 JAVA线程池原理
3.1 原理以及实现
在实践中,关于线程池的实现常常有不同的方法,但是它们的基本思路大都是相似的:服务器预先存放一定数目的“热”的线程,并发程序需要使用线程的时候,从 服务器取用一条已经创建好的线程(如果线程池为空则等待),使用该线程对请求服务,使用结束后,该线程并不删除,而是返回线程池中,以备复用,这样可以避 免对每一个请求都生成和删除线程的昂贵操作。
一个比较简单的线程池至少应包含线程池管理器、工作线程、任务队列、任务接口等部分。其中线程池管理器(ThreadPool Manager)的作用是创建、销毁并管理线程池,将工作线程放入线程池中;工作线程是一个可以循环执行任务的线程,在没有任务时进行等待;任务队列的作 用是提供一种缓冲机制,将没有处理的任务放在任务队列中;任务接口是每个任务必须实现的接口,主要用来规定任务的入口、任务执行完后的收尾工作、任务的执 行状态等,工作线程通过该接口调度任务的执行。下面的代码实现了创建一个线程池:
public class ThreadPool
{
private Stack threadpool = new Stack();
private int poolSize;
private int currSize=0;
public void setSize(int n)
{
poolSize = n;
}
public void run()
{
for(int i=0;i
4.2 框架与结构
下面让我们来看看util.concurrent的框架结构。关于这个工具包概述的e文原版链接地址是http: //gee.cs.oswego.edu/dl/cpjslides/util.pdf。该工具包主要包括三大部分:同步、通道和线程池执行器。第一部分 主要是用来定制锁,资源管理,其他的同步用途;通道则主要是为缓冲和队列服务的;线程池执行器则提供了一组完善的复杂的线程池实现。
--主要的结构如下图所示
4.2.1 Sync
acquire/release协议的主要接口
- 用来定制锁,资源管理,其他的同步用途
- 高层抽象接口
- 没有区分不同的加锁用法
实现
-Mutex, ReentrantLock, Latch, CountDown,Semaphore, WaiterPreferenceSemaphore, FIFOSemaphore, PrioritySemaphore
还有,有几个简单的实现,例如ObservableSync, LayeredSync
举例:如果我们要在程序中获得一独占锁,可以用如下简单方式:
try {
lock.acquire();
try {
action();
}
finally {
lock.release();
}
}catch(Exception e){
}
程序中,使用lock对象的acquire()方法获得一独占锁,然后执行您的操作,锁用完后,使用release()方法释放之即可。呵呵,简单吧,想 想看,如果您亲自撰写独占锁,大概会考虑到哪些问题?如果关键的锁得不到怎末办?用起来是不是会复杂很多?而现在,以往的很多细节和特殊异常情况在这里都 无需多考虑,您尽可以把精力花在解决您的应用问题上去。
4.2.2 通道(Channel)
为缓冲,队列等服务的主接口
具体实现
LinkedQueue, BoundedLinkedQueue,BoundedBuffer, BoundedPriorityQueue, SynchronousChannel, Slot
通道例子
在后台服务器中,缓冲和队列都是最常用到的。试想,如果对所有远端的请求不排个队列,让它们一拥而上的去争夺cpu、内存、资源,那服务器瞬间不当掉才怪。而在这里,成熟的队列和缓冲实现已经提供,您只需要对其进行正确初始化并使用即可,大大缩短了开发时间。
4.2.3执行器(Executor)
Executor是这里最重要、也是我们往往最终写程序要用到的,下面重点对其进行介绍。
类似线程的类的主接口
- 线程池
- 轻量级运行框架
- 可以定制调度算法
只需要支持execute(Runnable r)
- 同Thread.start类似
实现
- PooledExecutor, ThreadedExecutor, QueuedExecutor, FJTaskRunnerGroup
PooledExecutor(线程池执行器)是个最常用到的类,以它为例:
可修改得属性如下:
- 任务队列的类型
- 最大线程数
- 最小线程数
- 预热(预分配)和立即(分配)线程
- 保持活跃直到工作线程结束
-- 以后如果需要可能被一个新的代替
- 饱和(Saturation)协议
-- 阻塞,丢弃,生产者运行,等等
可不要小看上面这数条属性,对这些属性的设置完全可以等同于您自己撰写的线程池的成百上千行代码。下面以笔者撰写过得一个GIS服务器为例:
该GIS服务器是一个典型的“请求-服务”类型的服务器,遵循后端程序设计的一般框架。首先对所有的请求按照先来先服务排入一个请求队列,如果瞬间到达的 请求超过了请求队列的容量,则将溢出的请求转移至一个临时队列。如果临时队列也排满了,则对以后达到的请求给予一个“服务器忙”的提示后将其简单抛弃。这 个就够忙活一阵的了。
然后,结合链表结构实现一个线程池,给池一个初始容量。如果该池满,以x2的策略将池的容量动态增加一倍,依此类推,直到总线程数服务达到系统能力上限, 之后线程池容量不在增加,所有请求将等待一个空余的返回线程。每从池中得到一个线程,该线程就开始最请求进行GIS信息的服务,如取坐标、取地图,等等。 服务完成后,该线程返回线程池继续为请求队列离地后续请求服务,周而复始。当时用矢量链表来暂存请求,用wait()、 notify() 和 synchronized等原语结合矢量链表实现线程池,总共约600行程序,而且在运行时间较长的情况下服务器不稳定,线程池被取用的线程有异常消失的 情况发生。而使用util.concurrent相关类之后,仅用了几十行程序就完成了相同的工作而且服务器运行稳定,线程池没有丢失线程的情况发生。由 此可见util.concurrent包极大的提高了开发效率,为项目节省了大量的时间。
Code
使用PooledExecutor例子
import java.net.*;
public class MainServer {
//初始化常量
public static final int MAX_CLIENT=100; //系统最大同时服务客户数
//初始化线程池
public static final PooledExecutor pool =
new PooledExecutor(new BoundedBuffer(10), MAX_CLIENT); //chanel容量为10,
//在这里为线程池初始化了一个
//长度为10的任务缓冲队列。
public MainServer() {
//设置线程池运行参数
pool.setMinimumPoolSize(5); //设置线程池初始容量为5个线程
pool.discardOldestWhenBlocked();//对于超出队列的请求,使用了抛弃策略。
pool.createThreads(2); //在线程池启动的时候,初始化了具有一定生命周期的2个“热”线程
}
public static void main(String[] args) {
MainServer MainServer1 = new MainServer();
new HTTPListener().start();//启动服务器监听和处理线程
new manageServer().start();//启动管理线程
}
}
类HTTPListener
import java.net.*;
public class HTTPListener extends Thread{
public HTTPListener() {
}
public void run(){
try{
ServerSocket server=null;
Socket clientconnection=null;
server = new ServerSocket(8008);//服务套接字监听某地址端口对
while(true){//无限循环
clientconnection =server.accept();
System.out.println("Client connected in!");
//使用线程池启动服务
MainServer.pool.execute(new HTTPRequest(clientconnection));//如果收到一个请求,则从线程池中取一个线程进行服务,任务完成后,该线程自动返还线程池
}
}catch(Exception e){
System.err.println("Unable to start serve listen:"+e.getMessage());
e.printStackTrace();
}
}
}
class Service { //
final Channel msgQ = new LinkedQueue();
public void serve() throws InterruptedException {
String status = doService();
msgQ.put(status);
}
public Service() { // start background thread
Runnable logger = new Runnable() {
public void run() {
try {
for(;;)
System.out.println(msqQ.take());
}
catch(InterruptedException ie) {} }
};
new Thread(logger).start();
}
}
关于util.concurrent工具包就有选择的介绍到这,更详细的信息可以阅读这些java源代码的API文档。Doug Lea是个很具有“open”精神的作者,他将util.concurrent工具包的java源代码全部公布出来,有兴趣的读者可以下载这些源代码并细 细品味。
5 结束语
以上内容介绍了线程池基本原理以及设计后台服务程序应考虑到的问题,并结合实例详细介绍了重要的多线程开发工具包util.concurrent的构架和使用。结合使用已有完善的开发包,后端服务程序的开发周期将大大缩短,同时程序性能也有了保障。