Java小對象的解決之道——對象池(Object Pool)的設計與應用


一、概述

面向對象編程是軟件開發中的一項利器,現已經成為大多數編程人員的編程思路。很多高級計算機語言也對這種編程模式提供了很好的支持,例如C++Object PascalJava等。曾經有大量的軟件工程師使用C語言作為他們的謀生工具,隨着面向對象的深入人心,微軟公司也對其C語言進行了擴充,形成了C++語言,全面支持面向對象的軟件開發模式。

“面向對象”的主角即是“對象”,其良好的可充用性和對數據邏輯的封裝成了它在當今計算機軟件開發領域一炮走紅的主要因素。程序開發人員也正是利用了對象的這些特點在程序中大量創建對象,以至於他們往往忽略了這種創建對象以及以后銷毀對象是帶來的系統開銷之大是多么不可想象。特別是對於現在十分流行的Java語言,這更是一個不能避免的問題。下面,我們就從分析Java的內存管理機制入手,首先看看造成Java程序有些時候效率低下的症結所在,然后再討論一下如何利用編程技巧改進我們的程序。

 

二、Java的內存管理機制

1、垃圾回收與小對象

C/C++語言相比,Java語言不需要程序員顯式地為每個對象定義“析構函數”,Java虛擬機(JVMJava Virtual Machine)會利用自身的“垃圾回收”機制在后台啟動一個守護線程負責對不再被利用的對象進行內存清理工作。這種思路並不是Java的“原創”,早在20世紀60年代,自動垃圾回收就已經被提出並在其他語言中得到了應用。所有的垃圾回收機制都遵循一條統一的原則“利用不同的方法來判定程序中不再被引用的對象,銷毀它們釋放內存。”其不同之處也就在於“判定”過程(Java使用的是一種稱為“可達性分析”的方法,這里不作主要介紹)。

Java的自動垃圾回收機制確實可以讓程序員從繁重的開發工作中得以解脫,使他們不再關心自己曾經創造的對象在不用時如何處理,從而將精力主要集中在業務邏輯的開發上。但是一句富有哲理的中國的老話在它身上再次應驗:“Java垃圾回收是一把雙刃劍!”

首先我們注意到,“垃圾回收”是JVM支持的一項后台工作,是“時事”進行的。當它發現某個對象處於“不可達”狀態時,理論上會去自動回收它而不必程序顯式調用任何方法。注意我們所說的是“理論上”。也就是說並不能保證一個對象處於“不可達”狀態時馬上會被垃圾收集線程發現並對其進行回收,雖然Java API中提供了一些觸發垃圾回收的方法,如System.gc()等,但是即使我們顯式調用了,還是不能保證垃圾回收器被觸發工作,這點要特別注意。所以這就會導致一種極端情況出現,如果們的程序中在某個時刻突然出現了大量廢棄對象,然而垃圾回收並沒有及時對其作相應的處理,很可能造成垃圾對象充滿內存,造成“OutOfMemoryError”。

其次,由於回收線程在后台是時事進行的,很可能在沒有垃圾的時候做無謂的檢查工作(更糟糕的情況就是像上面提到的那樣有垃圾時反倒不能及時被觸發),造成了時間上的浪費,其檢查不可達對象的過程也勢必造成了性能耗損。

除了造成時間上的浪費和無謂的性能耗損,垃圾回收器最讓人擔憂的一個隱患就是,為了保證垃圾回收的順利進行,Java不得不為已創建的對象加上一些內部信息加以識別。另外,為了將這種回收機制對於每一個對象都同步,還需要一些額外的信息。因此,當JVM開始啟動一個程序的時候,對象在內存中比我們實際創建時認為其所占的空間要大得多。下表顯示了幾種不同類型的Java對象的“內容空間”(也就是該類型的數據所占理論空間)與在JVM中實際空間的對比:

 

User-Accessible

Actual Object Memory Size

JVMs

Types

Contentbytes

JRE 1.1.8

Sun

JRE 1.1.8

IBM

JRE 1.2.2

Classic

JRE 1.2.2

HotSpot

java.lang.Object

0

26

31

28

18

java.long.Integer

4

26

31

28

26

int[0]

4

26

31

28

26

Java.lang.String

4 characters

12

58

63

60

58

上面這組數字是針對每個對象平均而言的,所以對象越大,每個對象的這些額外信息的百分比也會越小。對於大對象來說,也許這些額外信息所占的比重微不足道,但是如果程序中有大量的小對象被創建,有多少空間是不在我們掌握之中的阿!而隨着經濟信息的發展,在實際的科學研究中,特別是在計算機仿真這一領域,我們的模擬環境中經常要出現大量小對象的情況,因此這是一個亟待解決的缺陷。

 

2、與C++對比

Java在某些方面的性能低下使人不自覺地想到了它的主要競爭對手C++,兩者的性能比較也就在所難免。僅對為對象分配內存這一性能測試中,由於Java要為對象初始化一些方便垃圾收集時的附加信息,以及兩種語言本身的結構差異,Java處於了明顯的下風。下表列出了Java在不同JVM中為對象分配內存時所花費的時間與C++的比較結果:

JVMs

Allocations

JRE 1.1.8

Sun

JRE 1.1.8

IBM

JRE 1.2.2

Classic

JRE .1.2.2

HotSpot

C++

Short-term Allocations

7.5M blocks 331MB

30s

22s

26s

14s

9s

Long-term Allocations

7.6 M blocks 334MB

48s

28s

39s

33s

13s

通過上面對比,是不是就是說明Java在實際開發中沒有辦法克服上面的缺陷呢?實則不然,我們完全可以通過一些編程技巧對此加以性能優化。重要的是,作為一名程序員,必須注意對Java的這個“天生缺陷”時刻保持警惕。其實有很多簡單實用的方法,比如盡量在程序中使用Java提供的基本類型、盡量實現“對象重用”,還可以利用一些設計模式提供的思路,比如“單例模式”(需要在特定的需求下)、“享元模式”(FlyWeight)。它們的思路大體一致,就是盡量使程序共享小對象。下面我們探討一中解決方案——利用“對象池”來管理維護Java小對象。

 

三、“對象池”的設計和應用

1、“對象池”的設計思想

用所謂的“對象池”來管理Java小對象可以讓多個用戶進程共享這些對象,以減少大量創建對象帶來的內存開銷。這種技巧適用於多個進程在不同時間對一些“行為相似”的小對象有大量需求的情況。它所帶來的好處主要有以下兩點:

1、  進程不再需要創建對象,節省了加載時間(Load Time);

2、  進程在使用完對象之后將其歸還給“對象池”繼續保存管理,於是減少了“垃圾收集”(Garbage Collection)的開銷。

“對象池”的思路類似於“圖書館借書”,當我們需要一本圖書的時候,我們知道從圖書館借閱要比自己購買經濟得多。同樣,當一個進程需要一個對象時,它從一個已有的存儲對象的容器中“借來”一個使用,比創建一個新對象要節省很多系統開銷。也就是說,圖書館中的書相當於程序中的對象;現實中的借閱者相當於程序中需要對象的用戶進程。當一個進程需要一個對象的時候,它從對象池中借出一個對象,使用完畢后將其交還對象池繼續對其進行保存管理。

當然,對象池是為了更好地保證程序的魯棒性而設計的,不能為了節省系統開銷而完全照搬圖書館借書的思路。在現實中,如果我們要借閱的書已經全部借出(包括副本),我們不得不等其他讀者將其歸還再借。然而,在程序中有可能一個迫切需要該類對象的進程沒有耐心等待其他進程將對象歸還,這時,就要由對象池來創建一個新對象。

 

2、“對象池”的實現

對象池(ObjectPool)的內部數據結構由兩個HashTable對象來維護,它們分別是“locked”和“unlocked”。前者用於存儲和管理已經“外借”的對象,后者用於存儲和管理“在庫”對象。它們的key值就是對象本身的引用,value值為“上次使用時間”(Last-Usage Time)。這里注意,對於lockedvalue值為“上次外借時間”;對於unlockedvalue值為“上次歸還時間”。

通過保存對象的“上次使用時間”信息,對象池在對象的上次使用時間與當前時間之差超出消亡期限的情況下,將該對象“消滅”,以減少保存“不再使用對象”帶來的內存開銷。這個消亡期限作為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、程序探討:

看完了整個程序,可能不少人會對ObjectPoolvalidate()方法耿耿於懷。對象是否有效實際上是取決於外部的調用進程對使對象狀態發生的變化。例如,對象池的一種典型應用是維護數據庫連接對象(當然,這個對象相對於“小”蜜蜂來說“大”得許多,這里只是為了舉例方便),如果外部進行關閉了曾經調用的數據庫連接(這也證明了其不再有用),並將其返還給對象池,下次再有其他進程要求對象池分配一個數據庫連接對象的時候調用checkOut()方法檢查到剛才那個數據庫連接已經關閉,為“不可用”的。實際上由checkOut()方法對其管理、判斷、刪除使得我們的對象池的整體性能受到了影響。我們為什么不將這個判斷刪除過程交給外部進程去做而非要“多管閑事”呢?

實際上,這正是一個矛盾所在。如果交由外部進程判斷處理,會減輕“對象池”的管理成本,我們在checkOut()方法中不必再遍歷unlocked隊列,因為我們可以保證只要unlocked中有對象,就一定是“有效”的。但是這么做卻破壞了程序的封裝性。比如如果外部進程將從對象池中借出的對象判定為失效並將其銷毀,就不得不通知對象池將其從locked隊列中刪除,由此看來一個銷毀對象的功能模塊跨越了外部進程和對象池本身兩個實體,它們之間的同步通信會帶來棘手的問題,而且這其中的開銷也許更大(假設在網絡環境下)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM