1. 程式人生 > >hibernate 延遲載入與動態代理

hibernate 延遲載入與動態代理

Hibernae 的延遲載入是一個非常常用的技術,實體的集合屬性預設會被延遲載入,實體所關聯的實體預設也會被延遲載入。Hibernate 通過這種延遲載入來降低系統的記憶體開銷,從而保證 Hibernate 的執行效能。

下面先來剖析 Hibernate 延遲載入的“祕密”。

當 Hibernate 從資料庫中初始化某個持久化實體時,該實體的集合屬性是否隨持久化類一起初始化呢?如果集合屬性裡包含十萬,甚至百萬的記錄,在初始化持久化實體的同時,完成所有集合屬性的抓取,將導致效能急劇下降。完全有可能系統只需要使用持久化類集合屬性中的部分記錄,而完全不是集合屬性的全部,這樣,沒有必要一次載入所有的集合屬性。

對於集合屬性,通常推薦使用延遲載入策略。所謂延遲載入就是等系統需要使用集合屬性時才從資料庫裝載關聯的資料。

例如下面 Person 類持有一個集合屬性,該集合屬性裡的元素的型別為 Address,該 Person 類的程式碼片段如下:


清單 1. Person.java
				 
 public class Person 
 { 
 // 標識屬性
 private Integer id; 
 // Person 的 name 屬性
 private String name; 
 // 保留 Person 的 age 屬性
 private int age; 
 // 使用 Set 來儲存集合屬性
 private Set<Address> addresses = new HashSet<Address>(); 
 // 下面省略了各屬性的 setter 和 getter 方法
 ... 
 } 

為了讓 Hibernate 能管理該持久化類的集合屬性,程式為該持久化類提供如下對映檔案:


清單 2. Person.hbm.xml
				 
 <?xml version="1.0" encoding="GBK"?> 
 <!DOCTYPE hibernate-mapping PUBLIC 
"-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd"> 
 <hibernate-mapping package="org.crazyit.app.domain"> 
 <!-- 對映 Person 持久化類 --> 
 <class name="Person" table="person_inf"> 
 <!-- 對映標識屬性 id --> 
 <id name="id" column="person_id"> 
 <!-- 定義主鍵生成器策略 --> 
 <generator class="identity"/> 
 </id> 
 <!-- 用於對映普通屬性 --> 
 <property name="name" type="string"/> 
 <property name="age" type="int"/> 
 <!-- 對映集合屬性  --> 
 <set name="addresses" table="person_address" lazy="true"> 
 <!-- 指定關聯的外來鍵列 --> 
 <key column="person_id"/> 
 <composite-element class="Address"> 
 <!-- 對映普通屬性 detail --> 
 <property name="detail"/> 
 <!-- 對映普通屬性 zip --> 
 <property name="zip"/> 
 </composite-element> 
 </set> 
 </class> 
 </hibernate-mapping> 

從上面對映檔案的程式碼可以看出,Person 的集合屬性中的 Address 類只是一個普通的 POJO。該 Address 類裡包含 detail、zip 兩個屬性。由於 Address 類程式碼非常簡單,故此處不再給出該類的程式碼。

上面對映檔案中 <set.../> 元素裡的程式碼指定了 lazy="true"(對於 <set.../> 元素來說,lazy="true"是預設值),它指定 Hibernate 會延遲載入集合屬性裡 Address 物件。

例如通過如下程式碼來載入 ID 為 1 的 Person 實體:

 Session session = sf.getCurrentSession(); 
 Transaction tx = session.beginTransaction(); 
 Person p = (Person) session.get(Person.class, 1);  //<1> 
 System.out.println(p.getName()); 

上面程式碼只是需要訪問 ID 為 1 的 Person 實體,並不想訪問這個 Person 實體所關聯的 Address 物件。此時有兩種情況:

  • 如果不延遲載入,Hibernate 就會在載入 Person 實體對應的資料記錄時立即抓取它關聯的 Address 物件。
  • 如果採用延遲載入,Hibernate 就只加載 Person 實體對應的資料記錄。

很明顯,第二種做法既能減少與資料庫的互動,而且避免了裝載 Address 實體帶來的記憶體開銷——這也是 Hibernate 預設啟用延遲載入的原因。

現在的問題是,延遲載入到底是如何實現的呢? Hibernate 在載入 Person 實體時,Person 實體的 addresses 屬性值是什麼呢?

為了解決這個問題,我們在 <1>號程式碼處設定一個斷點,在 Eclipse 中進行 Debug,此時可以看到 Eclipse 的 Console 視窗有如圖 1 所示的輸出:


圖 1. 延遲載入集合屬性的 Console 輸出
圖 1. 延遲載入集合屬性的 Console 輸出

正如圖 1 輸出所看到的,此時 Hibernate 只從 Person 實體對應的資料表中抓取資料,並未從 Address 物件對應的資料表中抓取資料,這就是延遲載入。

那麼 Person 實體的 addresses 屬性是什麼呢?此時可以從 Eclipse 的 Variables 視窗看到如圖 2 所示的結果:


圖 2. 延遲載入的集合屬性值
圖 2. 延遲載入的集合屬性值

從圖 2 的方框裡的內容可以看出,這個 addresses 屬性並不是我們熟悉的 HashSet、TreeSet 等實現類,而是一個 PersistentSet 實現類,這是 Hibernate 為 Set 介面提供的一個實現類。

PersistentSet 集合物件並未真正抓取底層資料表的資料,因此自然也無法真正去初始化集合裡的 Address 物件。不過 PersistentSet 集合裡持有一個 session 屬性,這個 session 屬性就是 Hibernate Session,當程式需要訪問 PersistentSet 集合元素時,PersistentSet 就會利用這個 session 屬性去抓取實際的 Address 物件對應的資料記錄。

那麼到底抓取那些 Address 實體對應的資料記錄呢?這也難不倒 PersistentSet,因為 PersistentSet 集合裡還有一個 owner 屬性,該屬性就說明了 Address 物件所屬的 Person 實體,Hibernate 就會去查詢 Address 對應資料表中外來鍵值參照到該 Person 實體的資料。

例如我們單擊圖 2 所示視窗中 addresses 行,也就是告訴 Eclipse 要除錯、輸出 addresses 屬性,這就是要訪問 addresses 屬性了,此時就可以在 Eclipse 的 Console 視窗看到輸出如下 SQL 語句:

    select 
        addresses0_.person_id as person1_0_0_, 
        addresses0_.detail as detail0_, 
        addresses0_.zip as zip0_ 
    from 
        person_address addresses0_ 
    where 
        addresses0_.person_id=? 

這就是 PersistentSet 集合跟據 owner 屬性去抓取特定 Address 記錄的 SQL 語句。此時可以從 Eclipse 的 Variables 視窗看到圖 3 所示的輸出:


圖 3. 已載入的集合屬性值
圖 3. 已載入的集合屬性值

從圖 3 可以看出,此時的 addresses 屬性已經被初始化了,集合裡包含了 2 個 Address 物件,這正是 Person 實體所關聯的兩個 Address 物件。

通過上面介紹可以看出,Hibernate 對於 Set 屬性延遲載入關鍵就在於 PersistentSet 實現類。在延遲載入時,開始 PersistentSet 集合裡並不持有任何元素。但 PersistentSet 會持有一個 Hibernate Session,它可以保證當程式需要訪問該集合時“立即”去載入資料記錄,並裝入集合元素。

與 PersistentSet 實現類類似的是,Hibernate 還提供了 PersistentList、PersistentMap、PersistentSortedMap、PersistentSortedSet 等實現類,它們的功能與 PersistentSet 的功能大致類似。

熟悉 Hibernate 集合屬性讀者應該記得:Hibernate 要求宣告集合屬性只能用 Set、List、Map、SortedSet、SortedMap 等介面,而不能用 HashSet、ArrayList、HashMap、TreeSet、TreeMap 等實現類,其原因就是因為 Hibernate 需要對集合屬性進行延遲載入,而 Hibernate 的延遲載入是依靠 PersistentSet、PersistentList、PersistentMap、PersistentSortedMap、PersistentSortedSet 來完成的——也就是說,Hibernate 底層需要使用自己的集合實現類來完成延遲載入,因此它要求開發者必須用集合介面、而不是集合實現類來宣告集合屬性。

Hibernate 對集合屬性預設採用延遲載入,在某些特殊的情況下,為 <set.../>、<list.../>、<map.../> 等元素設定 lazy="false"屬性來取消延遲載入。

預設情況下,Hibernate 也會採用延遲載入來載入關聯實體,不管是一對多關聯、還是一對一關聯、多對多關聯,Hibernate 預設都會採用延遲載入。

對於關聯實體,可以將其分為兩種情況:

  • 關聯實體是多個實體時(包括一對多、多對多):此時關聯實體將以集合的形式存在,Hibernate 將使用 PersistentSet、PersistentList、PersistentMap、PersistentSortedMap、PersistentSortedSet 等集合來管理延遲載入的實體。這就是前面所介紹的情形。
  • 關聯實體是單個實體時(包括一對一、多對一):當 Hibernate 載入某個實體時,延遲的關聯實體將是一個動態生成代理物件。

當關聯實體是單個實體時,也就是使用 <many-to-one.../> 或 <one-to-one.../> 對映關聯實體的情形,這兩個元素也可通過 lazy 屬性來指定延遲載入。

下面例子把 Address 類也對映成持久化類,此時 Address 類也變成實體類,Person 實體與 Address 實體形成一對多的雙向關聯。此時的對映檔案程式碼如下:


清單 3. Person.hbm.xml
				 
 <?xml version="1.0" encoding="GBK"?> 
 <!-- 指定 Hibernate 的 DTD 資訊 --> 
 <!DOCTYPE hibernate-mapping PUBLIC 
"-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd"> 
 <hibernate-mapping package="org.crazyit.app.domain"> 
 <!-- 對映 Person 持久化類 --> 
 <class name="Person" table="person_inf"> 
 <!-- 對映標識屬性 id --> 
 <id name="id" column="person_id"> 
 <!-- 定義主鍵生成器策略 --> 
 <generator class="identity"/> 
 </id> 
 <!-- 用於對映普通屬性 --> 
 <property name="name" type="string"/> 
 <property name="age" type="int"/> 
 <!-- 對映集合屬性,集合元素是其他持久化實體
沒有指定 cascade 屬性,指定不控制關聯關係 --> 
 <set name="addresses" inverse="true"> 
 <!-- 指定關聯的外來鍵列 --> 
 <key column="person_id"/> 
 <!-- 用以對映到關聯類屬性 --> 
 <one-to-many class="Address"/> 
 </set> 
 </class> 

 <!-- 對映 Address 持久化類 --> 
 <class name="Address" table="address_inf"> 
 <!-- 對映標識屬性 addressId --> 
 <id name="addressId" column="address_id"> 
 <!-- 指定主鍵生成器策略 --> 
 <generator class="identity"/> 
 </id> 
 <!-- 對映普通屬性 detail --> 
 <property name="detail"/> 
 <!-- 對映普通屬性 zip --> 
 <property name="zip"/> 
 <!-- 必須指定列名為 person_id, 
與關聯實體中 key 元素的 column 屬性值相同 --> 
 <many-to-one name="person" class="Person"
 column="person_id" not-null="true"/> 
 </class> 
 </hibernate-mapping> 

接下來程式通過如下程式碼片段來載入 ID 為 1 的 Person 實體:

 // 開啟上下文相關的 Session 
 Session session = sf.getCurrentSession(); 
 Transaction tx = session.beginTransaction(); 
 Address address = (Address) session.get(Address.class , 1); //<1> 
 System.out.println(address.getDetail()); 

為了看到 Hibernate 載入 Address 實體時對其關聯實體的處理,我們在 <1>號程式碼處設定一個斷點,在 Eclipse 中進行 Debug,此時可以看到 Eclipse 的 Console 視窗輸出如下 SQL 語句:

    select 
        address0_.address_id as address1_1_0_, 
        address0_.detail as detail1_0_, 
        address0_.zip as zip1_0_, 
        address0_.person_id as person4_1_0_ 
    from 
        address_inf address0_ 
    where 
        address0_.address_id=? 

從這條 SQL 語句不難看出,Hibernate 載入 Address 實體對應的資料表抓取記錄,並未從 Person 實體對應的資料表中抓取記錄,這是延遲載入發揮了作用。

從 Eclipse 的 Variables 視窗看到如圖 4 所示的輸出:


圖 4. 延遲載入的實體
圖 4. 延遲載入的實體

從圖 4 可以清楚地看到,此時 Address 實體所關聯的 Person 實體並不是 Person 物件,而是一個 Person_$$_javassist_0 類的例項,這個類是 Hibernate 使用 Javassist 專案動態生成的代理類——當 Hibernate 延遲載入關聯實體時,將會採用 Javassist 生成一個動態代理物件,這個代理物件將負責代理“暫未載入”的關聯實體。

只要應用程式需要使用“暫未載入”的關聯實體,Person_$$_javassist_0 代理物件會負責去載入真正的關聯實體,並返回實際的關聯實體——這就是最典型的代理模式。

單擊圖 4 所示 Variables 視窗中的 person 屬性(也就是在除錯模式下強行使用 person 屬性),此時看到 Eclipse 的 Console 視窗輸出如下的 SQL 語句:

    select 
        person0_.person_id as person1_0_0_, 
        person0_.name as name0_0_, 
        person0_.age as age0_0_ 
    from 
        person_inf person0_ 
    where 
        person0_.person_id=? 

上面 SQL 語句就是去抓取“延遲載入”的關聯實體的語句。此時可以看到 Variables 視窗輸出圖 5 所示的結果:


圖 5. 已載入的實體
圖 5. 已載入的實體

Hibernate 採用“延遲載入”管理關聯實體的模式,其實就在載入主實體時,並未真正去抓取關聯實體對應資料,而只是動態地生成一個物件作為關聯實體的代理。當應用程式真正需要使用關聯實體時,代理物件會負責從底層資料庫抓取記錄,並初始化真正的關聯實體。

在 Hibernate 的延遲載入中,客戶端程式開始獲取的只是一個動態生成的代理物件,而真正的實體則委託給代理物件來管理——這就是典型的代理模式。

代理模式

代理模式是一種應用非常廣泛的設計模式,當客戶端程式碼需要呼叫某個物件時,客戶端實際上也不關心是否準確得到該物件,它只要一個能提供該功能的物件即可,此時我們就可返回該物件的代理(Proxy)。

在這種設計方式下,系統會為某個物件提供一個代理物件,並由代理物件控制對源物件的引用。代理就是一個 Java 物件代表另一個 Java 物件來採取行動。在某些情況下,客戶端程式碼不想或不能夠直接呼叫被呼叫者,代理物件可以在客戶和目標物件之間起到中介的作用。

對客戶端而言,它不能分辨出代理物件與真實物件的區別,它也無須分辨代理物件和真實物件的區別。客戶端程式碼並不知道真正的被代理物件,客戶端程式碼面向介面程式設計,它僅僅持有一個被代理物件的介面。

總而言之,只要客戶端程式碼不能或不想直接訪問被呼叫物件——這種情況有很多原因,比如需要建立一個系統開銷很大的物件,或者被呼叫物件在遠端主機上,或者目標物件的功能還不足以滿足需求……,而是額外建立一個代理物件返回給客戶端使用,那麼這種設計方式就是代理模式。

下面示範一個簡單的代理模式,程式首先提供了一個 Image 介面,代表大圖片物件所實現的介面,該介面程式碼如下:


清單 3. Image.java
				 
 public interface Image 
 { 
 void show(); 
 } 

該介面提供了一個實現類,該實現類模擬了一個大圖片物件,該實現類的構造器使用 Thread.sleep() 方法來暫停 3s。下面是該 BigImage 的程式程式碼。


清單 4. BigImage.java
				 
 // 使用該 BigImage 模擬一個很大圖片
 public class BigImage implements Image 
 { 
 public BigImage() 
 { 
 try 
 { 
 // 程式暫停 3s 模式模擬系統開銷 
				 Thread.sleep(3000); 
 System.out.println("圖片裝載成功 ..."); 
 } 
 catch (InterruptedException ex) 
 { 
 ex.printStackTrace(); 
 } 
 } 
 // 實現 Image 裡的 show() 方法
 public void show() 
 { 
 System.out.println("繪製實際的大圖片"); 
 } 
 } 

上面的程式程式碼暫停了 3s,這表明建立一個 BigImage 物件需要 3s 的時間開銷——程式使用這種延遲來模擬裝載此圖片所導致的系統開銷。如果不採用代理模式,當程式中建立 BigImage 時,系統將會產生 3s 的延遲。為了避免這種延遲,程式為 BigImage 物件提供一個代理物件,BigImage 類的代理類如下所示。


清單 5. ImageProxy.java
				 
 public class ImageProxy implements Image 
 { 
 // 組合一個 image 例項,作為被代理的物件
 private Image image; 
 // 使用抽象實體來初始化代理物件
 public ImageProxy(Image image) 
 { 
 this.image = image; 
 } 
 /** 
 * 重寫 Image 介面的 show() 方法
 * 該方法用於控制對被代理物件的訪問,
 * 並根據需要負責建立和刪除被代理物件
 */ 
 public void show() 
 { 
 // 只有當真正需要呼叫 image 的 show 方法時才建立被代理物件
 if (image == null) 
 { 
  image = new BigImage(); 
  } 
 image.show(); 
 } 
 } 

上面的 ImageProxy 代理類實現了與 BigImage 相同的 show() 方法,這使得客戶端程式碼獲取到該代理物件之後,可以將該代理物件當成 BigImage 來使用。

在 ImageProxy 類的 show() 方法中增加了控制邏輯,這段控制邏輯用於控制當系統真正呼叫 image 的 show() 時,才會真正建立被代理的 BigImage 物件。下面程式需要使用 BigImage 物件,但程式並不是直接返回 BigImage 例項,而是先返回 BigImage 的代理物件,如下面程式所示。


清單 6. BigImageTest.java
				 
 public class BigImageTest 
 { 
 public static void main(String[] args) 
 { 
 long start = System.currentTimeMillis(); 
 // 程式返回一個 Image 物件,該物件只是 BigImage 的代理物件
 Image image = new ImageProxy(null); 
 System.out.println("系統得到 Image 物件的時間開銷 :" + 
 (System.currentTimeMillis() - start)); 
 // 只有當實際呼叫 image 代理的 show() 方法時,程式才會真正建立被代理物件。
 image.show(); 
 } 
 } 

上面程式初始化 image 非常快,因為程式並未真正建立 BigImage 物件,只是得到了 ImageProxy 代理物件——直到程式呼叫 image.show() 方法時,程式需要真正呼叫 BigImage 物件的 show() 方法,程式此時才真正建立 BigImage 物件。執行上面程式,看到如圖 6 所示的結果。


圖 6. 使用代理模式提高效能
圖 6. 使用代理模式提高效能

看到如圖 6 所示的執行結果,讀者應該能認同:使用代理模式提高了獲取 Image 物件的系統性能。但可能有讀者會提出疑問:程式呼叫 ImageProxy 物件的 show() 方法時一樣需要建立 BigImage 物件啊,系統開銷並未真正減少啊?只是這種系統開銷延遲了而已啊?

我們可以從如下兩個角度來回答這個問題:

  • 把建立 BigImage 推遲到真正需要它時才建立,這樣能保證前面程式執行的流暢性,而且能減少 BigImage 在記憶體中的存活時間,從巨集觀上節省了系統的記憶體開銷。
  • 有些情況下,也許程式永遠不會真正呼叫 ImageProxy 物件的 show() 方法——意味著系統根本無須建立 BigImage 物件。在這種情形下,使用代理模式可以顯著地提高系統執行效能。

與此完全類似的是,Hibernate 也是通過代理模式來“推遲”載入關聯實體的時間,如果程式並不需要訪問關聯實體,那程式就不會去抓取關聯實體了,這樣既可以節省系統的記憶體開銷,也可以縮短 Hibernate 載入實體的時間。

小結

Hibernate 的延遲載入(lazy load)本質上就是代理模式的應用,我們在過去的歲月裡就經常通過代理模式來降低系統的記憶體開銷、提升應用的執行效能。Hibernate 充分利用了代理模式的這種優勢,並結合了 Javassist 或 CGLIB 來動態地生成代理物件,這更加增加了代理模式的靈活性,Hibernate 給這種用法一個新名稱:延遲載入。無論怎樣,充分分析、瞭解這些開源框架的實現可以更好的感受經典設計模式的優勢所在。