对象池 的设计和应用

1、“对象池”的设计思想

用所谓的“对象池”来管理Java小对象可以让多个用户进程共享这些对象,以减少大量创建对象带来的内存开销。这种技巧适用于多个进程在不同时间对一些“行为相似”的小对象有大量需求的情况。它所带来的好处主要有以下两点:

1、 进程不再需要创建对象,节省了加载时间(Load Time);

2、 进程在使用完对象之后将其归还给“对象池”继续保存管理,于是减少了“垃圾收集”(Garbage Collection)的开销。

“对象池”的思路类似于“图书馆借书”,当我们需要一本图书的时候,我们知道从图书馆借阅要比自己购买经济得多。同样,当一个进程需要一个对象时,它从一个已有的存储对象的容器中“借来”一个使用,比创建一个新对象要节省很多系统开销。也就是说,图书馆中的书相当于程序中的对象;现实中的借阅者相当于程序中需要对象的用户进程。当一个进程需要一个对象的时候,它从对象池中借出一个对象,使用完毕后将其交还对象池继续对其进行保存管理。

当然,对象池是为了更好地保证程序的鲁棒性而设计的,不能为了节省系统开销而完全照搬图书馆借书的思路。在现实中,如果我们要借阅的书已经全部借出(包括副本),我们不得不等其他读者将其归还再借。然而,在程序中有可能一个迫切需要该类对象的进程没有耐心等待其他进程将对象归还,这时,就要由对象池来创建一个新对象。

2、“对象池”的实现

对象池(ObjectPool)的内部数据结构由两个HashTable对象来维护,它们分别是“locked”和“unlocked”。前者用于存储和管理已经“外借”的对象,后者用于存储和管理“在库”对象。它们的key值就是对象本身的引用,value值为“上次使用时间”(Last-Usage Time)。这里注意,对于locked,value值为“上次外借时间”;对于unlocked,value值为“上次归还时间”。

通过保存对象的“上次使用时间”信息,对象池在对象的上次使用时间与当前时间之差超出消亡期限的情况下,将该对象“消灭”,以减少保存“不再使用对象”带来的内存开销。这个消亡期限作为ObjectPool的一个成员变量在子类的构造器中初始化(在这里为了使程序简化,我们采用硬编码的方式人工给出消亡期限的值)。

下面是ObjectPool的程序骷架:

import java.util.*;

public abstract class ObjectPool{

       private long expirationTime;

       protected HashTable locked, unlocked;

       abstract Object create( );

       abstract boolean validate(Object o);

       abstract void expire(Object o);

      

       ObjectPool(long time){

              expirationTime = time;

              locked = new HashTable( );

              unlocked = new HashTable( );

       }

             

       synchronized Object checkOut( ){

              long now = System.currentTimeMillis( );

              Object o;

              //如果unlocked队列不为空,则遍历

              if (unlocked.size ( ) > 0){

                     Enumeration keys = unlocked.keys();

                     while (keys.hasMoreElements()){

                            o = keys.nextElement();

                            //如果当前对象超出“消亡期限”,则将其消灭

                            if (now - ((Long)unlocked.get(o)).longValue( ) > expirationTime){

                                   unlocked.remove(o);

                                   expire(o);

                                   //将Object o的引用赋空值是为了触发垃圾收集器对其回收

                                   o = null;

                            }

                            //如果当前对象未超出“消亡期限”,则检查其是否满足用户进程的需求

                            else{

                                   //如果满足,将其外借

                                   if (validate(o)){

                                          unlocked.remove(o);

                                          locked.put(o, new Long(now));

                                          return o;

                                   }

                                   else{

                                          //???如果不满足,将其消灭

                                          unlocked.remove(o);

                                          expire(o);

                                          o = null;

                                   }

                            }

                     }

              }

              //若到此为止还未找出可用对象,则创建新的对象实例

              o = create( );

              locked.put(o, new Long(now));

              return(o);

       }

       synchronized void checkIn(Object o){

              locked.remove(o);

              unlocked.put(o, new Long(System.currentTimeMillis( )));

       }

}

3、说明:

checkout()方法是对象池的主要方法,它的作用是将用户进程需要的对象“外借”。它首先检查unlocked队列中是否有对象可借,如果有,它就遍历这些对象并找出其中一个可用的(validate)对象作为方法返回值。一个对象对于用户进程是否可用取决于两个因素。

首先,对象池检查它是否超出了消亡期限,如果是,则由该方法调用expire()方法(由子类定义)将其消灭;其次,对于每一个在消亡期限内的对象,该方法调用validate()方法(由子类定义),检查其是否满足用户进程的要求。

如果在非空的unlocked队列中找出一个对象满足上述两点要求,就将其传递给用户进程进行使用,并将其从unlocked队列中删除,同时添加到locked队列中(表明该对象已外借);如果unlocked队列为空,或者其中没有一个对象满足上述的借出条件,则我们需要实例化一个新对象并将其引用作为整个方法的返回值。

checkIn()方法相对简单,它的工作只是将用户进程返还的对象回收,并将其从locked队列中删除,同时添加到unlocked队列中,注意这里记录的是返回时间。

另外三个方法都要由继承自抽象类ObjectPool的子类来定义,下面我们举一个例子来证明使用对象池这种编程技巧是高效的。

4、模拟测试

下面模拟一个蜂群工作的例子,我们设计一个蜂窝,里面住满了蜜蜂。每只蜜蜂如果在2秒的时间间隔内没有外出工作,则认为它已经丧失了工作能力(即“消亡期限”为2秒),在它的生命周期内,只能外出工作三次,每次工作采蜜量最多为10mg。很多个进程(由线程模拟)在一段时间内让蜜蜂外出工作。为了简单明了,我们的进程借出一只蜜蜂后只是简单地打印出一个随机生成的位移偏移量(假设在方圆100平方米内工作),并等待一段时间后(表示正在外出工作)将其返还给蜂窝。消灭一只蜜蜂对象时仅是打印出“该蜜蜂生命结束”的提示信息。当总采蜜量达到一定总量时程序结束。(程序代码省略)

注意:只要有若干蜜蜂对象进行了多次工作(不超过3次),就简单明了使用“对象池”技巧提高了程序的效率。

我们在模拟测试的时候发现了这样一个现象:如果很多线程BeeThread在很短的时间段内向对象池提出外借蜜蜂对象的申请,由于之前借出的蜜蜂对象处于外出工作状态,尚未返回蜂窝,所以不得不新创建蜜蜂对象。一段时间过后,一些工作完毕的蜜蜂飞回了蜂窝,但是此时总体工作已经完成,不再有进程外借蜜蜂,也就是说checkOut()方法不再被调用(销毁过期对象和失效对象的工作是在checkOut方法中进行的,见上面代码),我们观察统计结果发现,在对象池中并没有销毁任何超出消亡期限的蜜蜂对象。

一般来说,对象池经常使用在持续时间较长的、由用户进程提出外借要求的程序中,以提高程序的效率。因此上述的这种反而“高消耗”情况出现的几率较小,但由于我们的测试硬件条件有限,只能用线程来模拟进程,因此出现这种情况也就不足为奇了。但这恰好说明了这种方法还存在缺陷。那就是我们将过期对象的消亡工作放在了checkOut()方法中进行,也就是说消亡工作要依赖于用户进程直接或间接调用checkOut()方法。如果出现了上述那种用户进程外借对象经历较长时间后才将其归还给对象池,而以后再也没有其他进程调用checkOut()方法的情况时,过期对象得不到及时消灭的现象就很可能出现。

5、改进

要解决上述问题,比较直观的想法是模仿JVM的垃圾收集器,将消亡过期对象的工作从对象池的checkOut()方法中独立出来,交由一个线程去处理。这样就保证了在程序运行过程中,总有一个“清理线程”定时地针对过期对象进行清理工作。这个线程的工作周期就可以定为对象的“消亡期限”。它需要在ObjectPool的构造器中进行初始化。

需要在ObjectPool类中加入一个新的同步方法cleanUp()来说明具体怎么清理:

       synchronized void cleanUp( ){

              Object o;

              long now = System.currentTimeMillis( );

              Enumeration keys = unlocked.keys();

              while(keys.hasMoreElements()){

                     o = keys.nextElement();

                     if ((now - ((Long)unlocked.get(o)).longValue( ) )> expirationTime) {

                            unlocked.remove(o);

                            expire(o);

                            o = null;

                     }

              }

              System.gc( );

       }

这时,被提炼出消亡工作的checkOut()方法变为:

synchronized Object checkOut( ){

       long now = System.currentTimeMillis( );

       Object o;

       //如果unlocked队列不为空,则遍历

       if (unlocked.size( ) > 0){

              Enumeration keys = unlocked.keys();

              while (keys.hasMoreElements()){

                     o = it.next( );

                     if (validate(o)){

                            unlocked.remove(o);

                            locked.put(o, new Long(now));

                            return o;

                     }

                     else{

                            unlocked.remove(o);

                            expire(o);

                            o = null;

                     }

              }

              }

       //若到此为止还未找出可用对象,则创建新的对象实例

       o = create( );

       locked.put(o, new Long(now));

       return(o);

}

另外,还需要一个线程类CleanUpThread:

public class CleanUpThread extends Thread {

       private ObjectPool pool;

       //执行清理工作的周期

       private long sleepTime;

      

       CleanUpThread(ObjectPool pool, long sleepTime){

              this.pool = pool;

              this.sleepTime = sleepTime;

       }

      

       public void run( ){

              while(true){

                     try{

                            sleep(sleepTime);

                     }catch(InterruptedException e){};

                     pool.cleanUp( );

              }

       }

}

这样,我们只需在ObjectPool的构造函数中添加初始化清理收集器的代码即可:

ObjectPool(long time){

       expirationTime = time;

       locked = new Hashtable();

       unlocked = new Hashtable();

      

       cleanT = new CleanUpThread(this, expirationTime);

       cleanT.setDaemon(true);

       cleanT.start();

}

注意一点这里的CleanUpThread线呈应该设置为“守护线程”,这是因为在这个测试中我们希望当所有的用户线呈结束后,即蜂群完成最终任务时,统计出目前尚留在对象池中的对象数目,用之与BeeThread的总调用次数作对比,因此希望此时的清理线呈不再做任何清理工作。否则可能会因为它对清理工作的过分“负责”,让我们的试验的不到预想的效果。

经过测试证明,一些过期的蜜蜂对象确实在程序执行的过程中被消灭。这样就大大节省了内存的开销。

6、程序探讨:

看完了整个程序,可能不少人会对ObjectPool的validate()方法耿耿于怀。对象是否有效实际上是取决于外部的调用进程对使对象状态发生的变化。例如,对象池的一种典型应用是维护数据库连接对象(当然,这个对象相对于“小”蜜蜂来说“大”得许多,这里只是为了举例方便),如果外部进行关闭了曾经调用的数据库连接(这也证明了其不再有用),并将其返还给对象池,下次再有其他进程要求对象池分配一个数据库连接对象的时候调用checkOut()方法检查到刚才那个数据库连接已经关闭,为“不可用”的。实际上由checkOut()方法对其管理、判断、删除使得我们的对象池的整体性能受到了影响。我们为什么不将这个判断删除过程交给外部进程去做而非要“多管闲事”呢?

实际上,这正是一个矛盾所在。如果交由外部进程判断处理,会减轻“对象池”的管理成本,我们在checkOut()方法中不必再遍历unlocked队列,因为我们可以保证只要unlocked中有对象,就一定是“有效”的。但是这么做却破坏了程序的封装性。比如如果外部进程将从对象池中借出的对象判定为失效并将其销毁,就不得不通知对象池将其从locked队列中删除,由此看来一个销毁对象的功能模块跨越了外部进程和对象池本身两个实体,它们之间的同步通信会带来棘手的问题,而且这其中的开销也许更大(假设在网络环境下)。

你可能感兴趣的:(对象池 的设计和应用)