JAVA編程實踐

  1. 引言

本培訓的主要目的是幫助被培訓者瞭解如何使用JAVA語言構造出高效的、不易出錯的、可維護的程序。同傳統的程序設計語言相比,JAVA語言通過語法上的精心設計,例如通過引用替換指針,已經避免了很多使用C++或者其他語言容易導致錯誤的地方。但是,程序設計語言本身的語法本身並不能夠保證程序正確無誤,我們發現,即使程序開發人員已經熟悉了JAVA語言的語法和函數庫,要寫出健壯的、高效的程序,也需要經過長時間的經驗積累。使用同樣的JAVA,要完成一個程序,可能會有10種編碼方法,但是可能有7種都是低效的、笨拙的、可讀性差、難以維護的編碼方法。這是因爲程序開發人員儘管已經掌握了語法規則和函數庫,但是沒有掌握正確的、高效的方式來編寫代碼。更爲重要的是,開發人員有時候根本不知道什麼是好的程序,什麼是壞的程序,錯誤的使用一些技巧,使得程序複雜而且難以維護。

我們通過在統一網管平臺開發的實踐,收集了一些最容易出錯的編碼問題,從中總結出一些編碼的方式,這就是本次培訓的目的。通過本次培訓,我們希望能夠幫助被培訓者瞭解一些編碼中最可能出現錯誤的地方,寫出簡單的、高效的程序,使得程序更不容易出錯,使得其他人能夠更好的理解這些程序,也使得在發生錯誤的時候,你能夠更快的找到錯誤的原因,在需要進行功能增強的時候,更容易的修改他們的程序。

  1. 異常處理
    1. 爲什麼要使用異常

對於一個大型的軟件系統,特別是那些需要連續運行幾個月的服務器軟件,錯誤處理通常會消耗程序員極大的精力。一箇中等水平的程序員,一般說來都應該能夠正確完成基本的事件處理流程,而對於錯誤處理,就很難考慮周全。我們經常看到,程序員能夠很快的完成一個所謂演示系統,或者原型系統,或者他報告說已經完成了全部功能。但是,真正的要得到一個健壯的、穩定的商用軟件,還需要花費程序員很長的時間和精力。對於錯誤處理的估計不足,也是導致軟件項目計劃延期的重要原因。因此,有必要對錯誤處理加以特別的重視。

在C、C++或者其他早期的語言中,通常採用返回值或者設置標誌位的方式來處理錯誤。典型情況下,錯誤的發現函數設置一個錯誤碼返回值/標誌位,調用者檢查這些返回值/標誌位,判斷髮生的具體情況,並進行不同的處理流程。許多標準的C庫函數也是採用這種方式來處理錯誤。

這種方式使用了很多年,實際應用中發現了很多問題,其中最大的問題是,對於返回值的檢查,不是依賴於語法來保證的,而是依賴於程序員的個人素質來保證的。程序員可以不檢查這些錯誤的返回值/標誌位,而在編譯和運行期間,沒有任何語法的措施來發現這一點。通常,正確的處理流程只有一個,而發生錯誤的機會卻非常之多,程序員也很難對全部的錯誤情況考慮周全。一種情況下,錯誤非常的隱蔽,程序員可能考慮不到。另一種情況下,錯誤非常的低級,程序員會產生麻痹情緒,這種錯誤如此愚蠢,怎麼可能發生呢?所以就沒有進行條件檢查。

即使程序員非常有經驗,水平很高。但是,當函數調用層次很深的時候,如果在最下層的函數中發生了一個錯誤,上層的每一級調用者必須層層檢查這些返回值。這樣導致代碼非常的龐大,也難以閱讀,降低了軟件的可維護性。有時候,一個服務器程序中,錯誤處理的代碼要佔到50%以上。我們發現,採用這種方式開發一個大型的、穩定的、又易於維護的系統,是一件非常困難的事情。

因此,JAVA中提供了異常處理機制,專門用於錯誤的處理。這種機制,將錯誤處理的方式作爲程序設計語言的一部分,強制程序員進行錯誤處理。當發生異常的時候,程序員必須停下來,捕獲該異常並進行處理,否則編譯器就會報錯:未捕獲的異常。這樣,就通過編譯器保證了程序員必須處理錯誤。

另一方面,異常處理機制使得錯誤處理的代碼大大簡化。編譯器保證了一定會有一個地方來處理異常,這樣,程序員不必層層檢查返回值/標誌位,而是隻需要在“應該處理”的地方來處理錯誤。處理錯誤的代碼和正常處理邏輯的代碼很好的分離開,也有助於代碼更加有條理易於維護。

 

規則:使用異常處理機制來處理錯誤,而不是使用返回值或者標誌位。

 

    1. 基本語法

異常是一個較新的語法,很多程序員,特別是原來的C/C++程序員,沒有完全掌握異常的語法,因此有必要在這裏複習一下語法。

      1. throw

throw關鍵字用於“拋出”一個異常。使用該語句通常存在於兩種情況:一種是拋出一個新的異常,一種是拋出一個已經存在的異常。如:

 

      1. throws

對於一個提供其他方法調用的方法,需要告訴調用者該方法會拋出什麼異常,這樣,調用者才能夠有針對性地進行錯誤控制。因此,JAVA引入了一個關鍵字:throws。該關鍵字用於方法聲明,在該關鍵字後跟隨所有的可能拋出的異常類型。

假如一個方法中調用了另一個可能拋出異常的方法,那麼一般情況下,該方法要麼捕獲這些異常,並加以處理;要麼也需要在自己的聲明中throws這個異常。否則,編譯器會報告一個錯誤。

 

前面提到,異常處理機制和返回值/標誌位處理方式的不同在於異常處理機制從語法上強制了程序員必須進行錯誤處理。這實際上表現爲兩個方面:

首先,一個方法會發生什麼錯誤,是在該方法的聲明中通過throws語句告訴程序員的,而不是通過在註釋中告訴程序員的。如果一個方法拋出了一個異常,又不在方法聲明中寫明,那麼編譯就無法通過。而編譯器是無法控制是否在註釋中寫清楚返回值的含義的。

其次,對於一個調用一個方法的程序員,他必須處理該異常,要麼捕獲,要麼在繼續向外拋出,否則編譯也無法通過。

      1. try、catch & finally

try、catch、finally關鍵字用於捕獲並處理異常。

這裏需要特別提到的是finally關鍵字。從語法上,在finally關鍵字作用範圍之內的語句是正常和異常的處理流程中都需要執行的。一般情況下,這是指資源的釋放。當申請一個資源之後,不論是否處理正確,處理完成之後,都必須釋放該資源。將這些語句集中到finally語句塊之中,有助於增加程序的可讀性,並減少不釋放資源的危險。在關於資源的培訓中,將會有關於這一點的詳細說明。

    1. 異常分類

 

      1. java.lang.Throwable

所有能夠“throw”出來的對象的虛基類。實際應用中,應用不能夠直接使用Throwable,也不能夠直接從它繼承,而應該繼承它的兩個子類。

      1. java.lang.Error

Error表明嚴重的錯誤,通常當發生了這種錯誤的時候,程序已經無法在運行下去,只能夠中斷運行退出。應用程序不應該捕獲並處理Error,這些工作應該由虛擬機完成。除了非常底層的程序之外,一般應用程序不需要繼承Error,或者拋出Error。在公司的JAVA編程規範中,禁止直接從Error繼承(可以從Error的子類繼承)。

      1. java.lang.Exception

Exception表明普通的錯誤,應用程序可以在程序中捕獲這些異常並進行處理,保證程序能夠繼續運行。應用程序自定義的異常,都是Exception的子類。我們在通常的情況下,處理的也都是這種類型的異常。

      1. java.lang.RuntimeException

RuntimeException是Exception的一種特殊子類,其特殊性在於:編譯器不強制進行RuntimeException的處理。回顧2.2.2節,如果TestException是一個RuntimeException的子類,那麼2.2節中的錯誤例子也能夠通過編譯器的檢查。

前面提到,異常處理機制的好處在於強制程序員進行異常處理。而如果使用了RuntimeException,則程序員可以完全不處理這些異常。這裏JAVA爲了編程的方便而提供了一些靈活性。那麼RuntimeException一般應用於什麼情況下呢?

根據我的理解,RuntimeException用於表示這樣一種異常:該異常只在調試期間產生,而在程序交付使用之後,根本不會出現的異常(好像名字正好取反了?)。導致該錯誤發生的原因是程序員的編程錯誤,而不是其他諸如通訊中斷、磁盤錯誤、用戶輸入錯誤等。通常,一些公共函數庫會拋出這類異常。看一個例子,假設有一個公共函數對於一個數組進行排序,參數爲一個數組引用,如果傳入的引用爲一個空引用,那麼該方法會拋出一個NullPointerException。這種錯誤會在什麼時候產生呢?實際只有一種情況:調用該函數的程序寫錯了。也就是說,當調用程序調試正確之後,該異常永遠不會被拋出。

 

上述的程序能夠通過編譯器的檢查,但是會發生了一個NullPoinrtException,應用程序不進行處理,則這個異常會由JVM進行處理,從而中斷正常的處理流程。這正好給程序員一個強烈的提示,此種情況表示了一個編程錯誤。當程序員的程序調試正確之後,這種情況就永遠不會發生。

注意,不需要自己檢查NullPointerException。如以下的代碼:

 

    1. finally的特別說明
      1. 什麼都逃不過finally的掌心

我們看以下的代碼:

 

在代碼中的3種情況,無論是自己拋出一個異常,還是直接返回,或者是產生一個NullPointerException,都逃不出finally語句。在代碼中發現一種情況,少數代碼認爲只要寫finally,就必須寫catch,結果導致了很多不必要的catch語句塊。

      1. finally語句中不要拋出異常

如果在finally語句中拋出異常,會掩蓋真正需要拋出的異常,編程的時候特別需要注意這一點。假如在catch語句塊和finally語句塊中都拋出了異常,那麼程序將不會拋出catch語句塊中的異常,而是會拋出finally語句塊中的異常。我們來看下面的例子:

 

在以上的代碼中,如果讀寫文件正常結束,執行到關閉文件的時候出了錯,那麼就會拋出異常,但是這種情況下,功能應該都完成了的,這會給客戶端錯誤的信息。如果讀寫文件中發生異常,那麼程序的原意是關閉文件,將讀寫文件中發生的異常拋出。但是如果在關閉文件中,也發生了異常,那麼最終拋出的是關閉文件的異常,而不是讀寫文件的異常。這就會給客戶端程序錯誤的信息。而且調用者的到這個關閉異常之後,也無法判斷文件的讀寫是否已經正常完成。

    1. Exception的特別說明
      1. 是否需要捕獲Exception

在代碼中常常發現,有些人爲了確保程序正確,往往寫一個catch(Exception ex),用這種方法來確保程序能夠繼續運行。其實這是不對的,因爲這樣實際上意味着寫程序的人並沒有認真地考慮可能發生的異常情況,寫一個大而寬泛的catch(Exception ex),看起來很保險,實際上可能會掩蓋很多編程上的問題。因爲我們在前面已經提過,實際上在Exception的所有子類中,除了RuntimeException之外,其他所有可能拋出的異常,如果你沒有[z1] catch,編譯器都會發現並報錯。而對於RuntimeException,我們認爲是編程的錯誤,就是要讓它暴露出來。

當然,這也有一些例外情況,一個是在finally語句塊中,因爲不允許拋出異常,所以我們允許直接catch(Exception ex)。

另一種情況是,爲了確保程序在真正運行的時候不出問題,在線程的run方法中要捕獲所有的異常進行處理,防止這個線程退出運行。因爲實際上所有的代碼都在線程中運行,只要控制住了這一點,就不會發生這些異常影響程序運行的現象發生。

問題:出現異常的情況下,我需要釋放資源,如果不捕獲RuntimeException,不會出現資源不被釋放的情況發生嗎?

      1. 不要在函數聲明中throws Exception

前面提到,不要捕獲Exception,那麼如果在函數的聲明中寫throws Exception,就強迫了使用者要catch Exception。

所以,在函數聲明中,一定要寫清楚throws的具體的異常類。

    1. 如何定義異常
      1. 異常的層次和異常鏈

如果高層方法調用了低層方法,在低層方法中拋出了一個異常,如果高層方法中不加處理的拋出該異常,那麼對於該方法的使用者來說,可能會感到無法理解。因爲高層方法的使用者爲了理解這個異常,就必須瞭解高層方法的實現細節。假如高層方法的實現發生改變,就有可能導致拋出的異常發生變化,從而導致需要修改高層方法的客戶端程序,因爲需要捕獲的異常發生變化了。

爲了避免這種情況發生,高層方法應該自己捕獲低層方法的異常,將其轉換成爲按照高層解釋的新的異常。這種方法稱爲異常轉換。

也就是說,拋出的異常語意應該和方法的語意層次相一致。例如,一個處理某項業務邏輯的方法,目前該方法實現中使用文件作數據存儲,但是未來有可能改變爲使用數據庫做數據存儲。那麼該方法拋出的異常,其語意應該表明數據存儲失敗,而不是文件操作失敗。否則,現在客戶端程序捕獲處理文件操作失敗異常,當方法實現改爲數據庫時,客戶端程序必須修改爲捕獲數據庫操作失敗異常。

進行了異常轉換之後,爲了能夠在調試程序的時候,能夠追根溯源到異常的原始發生地,那麼需要在高層異常中保留低層異常。高層異常中保留了低層異常,低層異常中又保留了更低層的異常,這種情況稱爲異常鏈。

JAVA的Exception類提供了對於異常鏈的支持:

Constructor Summary

Exception()
          Constructs a new exception with null as its detail message.

 

Exception(String message)
          Constructs a new exception with the specified detail message.

 

Exception(String message, Throwable cause)
          Constructs a new exception with the specified detail message and cause.

 

Exception(Throwable cause)
          Constructs a new exception with the specified cause and a detail message of (cause==null ? null : cause.toString()) (which typically contains the class and detail message of cause).

 

 

使用了以上3、4的構造方法的時候,當打印異常堆棧的時候,會把cause也一起打印出來,如:

java.lang.Exception: aaa

        at zq.sample.TestExceptionChain.openFile2(TestExceptionChain.java:40)

        at zq.sample.TestExceptionChain.main(TestExceptionChain.java:47)

Caused by: java.io.FileNotFoundException: aaa (系統找不到指定的文件。)

       at java.io.FileInputStream.open(Native Method)

        at java.io.FileInputStream.<init>(FileInputStream.java:103)

        at java.io.FileInputStream.<init>(FileInputStream.java:66)

        at zq.sample.TestExceptionChain.openFile1(TestExceptionChain.java:22)

        at zq.sample.TestExceptionChain.openFile2(TestExceptionChain.java:37)

 

      1. 我們系統的情況

對於統一網管平臺,我們將程序分爲兩類:公共函數,和應用程序。這兩類程序的層次不同,拋出的異常類型所屬的層次不一樣。在平臺中,一般只需要兩層異常層次就可以了,低層異常由公共函數拋出,我們稱爲原始異常;高層異常由處理業務邏輯的應用程序拋出,稱爲應用異常。應用程序捕獲原始異常,將其轉換成爲應用異常。

公共函數提供一系列函數庫,供其它程序使用。對於這一類函數,拋出的原始異常包括兩類:

RuntimeException。這些異常主要是由於調用者的程序書寫不當造成的。由於JDK已經定義了絕大多數由編程錯誤導致的異常,所以寫函數庫的程序員一般情況下不需要自定義異常類型,只需要直接使用JAVA已經定義的異常類型即可。

普通的異常。例如:處理IO的函數,由於磁盤硬件錯誤導致的異常。程序應當繼承Exception,或者繼承Exception的子類,定義一些特殊的異常類來表示。如果JDK中存在可用的異常類型,也可以直接使用。

對於應用程序,推薦首先整理所有可能發生的錯誤類型,使用一種通用的異常類型來表示所有的錯誤。所有可能發生的錯誤類型,都通過這個異常的屬性來表示。例如:通過一個錯誤代碼和一個表示詳細描述的字符串,來表示不同的錯誤。例如:

    1. 拋出和捕獲異常的時機
      1. 理論

對於函數庫,在發現錯誤的時刻拋出異常。

對於應用程序,相對複雜一些。如果應用程序能夠直接檢測到錯誤,那麼直接拋出異常即可。

捕獲異常的時機比較複雜。有兩種做法,一種使用較小的try語句塊,精確定位所捕獲的異常,另一種使用很長的try語句塊,在最後捕獲所有的異常。這兩種做法各有優缺點,前者代碼比較長,但是有助於精確定位,使程序員保持對於錯誤的敏感。後者代碼比較簡潔,屬於偷懶的做法。

我習慣的做法是:對於原始異常,要立刻檢測,檢測到之後,將其轉換成應用異常拋出。對於應用異常,可以忽略,直到需要捕獲的時候再處理。

這裏所謂需要捕獲的時候,通常會有這麼幾種類型:

輸出到用戶界面的時候。

忽略該次錯誤,繼續進行以下的處理時。

其他需要採取錯誤處理措施的時候,如斷鏈重連等。

其他情況下,應用程序幾乎不需要做任何事情,只需要在方法聲明的throws關鍵字後增加AppException即可。有些程序員喜歡在每一個方法中都使用一個大的try語句,並在最後捕獲一個通用的Exception。特別是在程序不穩定的情況下,以爲這樣可以增加程序的穩定性。這樣,不僅喪失了異常處理機制使程序簡潔的優點,也容易隱藏真正的異常發源地和產生原因。

異常處理機制的一個優點就是,使得程序員可以一直考慮正常的處理流程,在發生錯誤的時候,只需要拋出異常即可。

總之,可以把握一個原則,如果程序員捕獲了一個異常,那麼他一定需要對這個異常做一些處理,例如:一些異常處理措施(如一個告警池滿之後,清除老的告警)、轉換成應用異常拋出、在界面上輸出錯誤提示等等。唯一的例外是,該錯誤可以忽略,繼續進行以下的處理。這種情況下,會將其打印出來,用以提示發生了錯誤。那種捕獲了一個異常,僅僅是將其繼續向外拋出,這種做法是沒有任何用處的。

規則:不允許捕獲異常之後,不做任何處理,僅僅將其繼續外拋。如果僅將其打印,需要在註釋中寫明。

 

      1. 我們系統的情況

對於統一網管平臺,應該在處理業務邏輯的Bean的方法中,只允許向外拋出應用異常。在這裏,必須捕獲所有的原始異常,將其轉換爲應用異常。

      1. 什麼時候輸出調試信息

同捕獲異常一樣,有的程序員喜歡將異常輸出的到處都是,經常一個錯誤的發生,會在調試打印的輸出中看到好幾個異常的堆棧信息。這樣,反而使得調試者無法正確定位異常的真正發生原因。

推薦的輸出原則是:

對於函數庫,不輸出異常的調試打印信息,只需要把異常往外拋就是了。

對於應用程序,誰處理該異常誰輸出。

注意:以上是指在正常運行之後的輸出原則,如果出於程序調試階段,則可以不受此限制。

  1. 線程和共享控制
    1. 概述

爲了提高程序的處理效率,我們需要進行多線程的編程。但是,多線程是一把雙刃劍,一方面可以提供編程的極大靈活性,另一方面又非常容易導致錯誤,特別是在多線程的數據共享互斥和線程的執行順序控制上,我們就曾經發現過JDK的一個Bug導致程序死鎖。爲了幫助程序員編寫多線程的程序,JAVA函數庫提供了多種函數和工具類,在我們看來,有一些函數是非常有害的,一不小心就會導致錯誤。

我們這裏介紹一下可以用於線程共享控制的函數,把它們分爲幾類:

底層控制函數:包括Thread類和ThreadGroup類。

低級函數:如Mutex、semephone、Condition等。

高級函數:如讀寫鎖、channel、Excecutor、Barriar等。

    1. 底層控制函數

關於線程控制方面最古老的函數是線程類Thread的一些方法:destroy、interrupt、jion、yield、suspend、resume以及線程的優先級和各類屬性的設置等方法。使用這些函數會導致其大的危險,一般的開發人員要花很長的時間才能夠掌握這些函數。而且,即使你掌握了這些函數的正確用法,在編碼的時候也要仔細計算程序的邏輯,不同線程的執行順序等等,非常容易出錯。使用這些函數編程,對於腦細胞也是極大的破壞,一小段程序就要反覆斟酌。

類似的還有ThreadGroup類,該類也是極不可靠的,最終執行的結果可能和你設想的有十萬八千里的差異。

我們在程序中禁止使用這兩個類,因爲所有需要使用他們完成的控制,都可以通過後面介紹的高級函數來完成。

    1. 低級函數

低級函數包括用於數據共享保護的方法和用於線程控制的方法。在JAVA裏面,提供了synchronize關鍵字用於共享數據保護,此外還有一些第3方的函數庫提供如Mutex、semephorne、CriticalSection等類。下面分別介紹一下。

      1. Mutex

Synchronized提供了基本的共享數據保護方法。可以將Synchronized理解爲一個公共類,提供兩個方法,lock和unlock(或者叫做accqure和release等)。但是爲什麼JAVA要把這作爲一個關鍵字而不是提供一個公共類呢?我們來看兩段代碼的樣例:

   

如上,假如使用工具類,必須注意調用unlock,而且注意最好在finally語句中調用,以避免異常情況下無法釋放。而通過Synchronized關鍵字,就可以從語法上避免可能出現的不釋放的問題。

Mutex提供了更精細一些的互斥控制,主要是當一個線程對共享數據區的訪問結束以後,操作系統究竟讓哪個等待的線程來訪問。一般有:

  • 隨機,即有操作系統隨機的選擇一個等待的線程來訪問。這就等於synchronized關鍵字。
  • 按順序調度。
  • 按線程的優先級調度。

此外,Mutex還提供了當線程無法訪問共享數據時候的行爲控制,可以是:

  • 無限制的等待,等於synchronized關鍵字
  • 立即返回
  • 等待一段時間

當需要一些精細的控制,synchronized無法滿足要求的時候,可以使用Mutex。如果使用Mutex,就需要注意在finally語句中調用unlock方法。

      1. Semephorne

Semephorne內部保存了一個stoken池,在初始化的時候有一個stoken池中的stoken數目,假如設置stoken數目爲3,則可以有3個線程同時訪問共享數據區,第4個就被掛起。每個線程訪問共享數據庫區的時候,從Semephorne的stoken池中取出一個stoken,取得就可以訪問,訪問完成以後將stoken放回Semephorne的stoken池中。如果初始化的設置stoken數目爲1,就蛻化爲Mutex。

Semephorne的主要用處在於對訪問數量作限制。

      1. CriticalSection和Condition

CriticalSection,也叫做臨界區,概念和Mutex類似。

Condition,條件變量,作爲yield、suspend、resume等方法的升級版本,用於線程調度和控制。他的基本原語有:wait、notify和notifyAll,已經作爲JAVA Object的最基本方法。雖然看起來這幾個原語非常簡單,但是真正用起來,非常的困難,也是問題百出。所以我們也不提倡在程序中使用Condition。

    1. 高級函數

高級函數包括讀寫鎖、消息隊列、Excecutor、Barriar。

      1. 讀寫鎖

讀寫鎖用於共享數據控制。它將對內存數據的訪問分爲讀寫兩種,並遵循以下規則:

  • 讀可以同時進行。
  • 讀和寫不能夠同時進行。
  • 寫不能夠同時進行。

通過讀寫鎖,可以提高共享數據區的效率,提高同時存在大量的讀的情況。

也存在多種類型的讀寫鎖,以做一些更加精細的控制,如寫優先鎖等。

      1. 消息隊列和Exceutor

消息隊列提供了產生請求和處理請求的解偶,用於處理產生和處理效率不匹配的情況。請求的生產者產生請求後,將其寫到消息隊列中,而消費者則偵聽這個消息隊列,從中取出請求來處理。絕大多數需要使用Condition的情況,實際上都可以用消息隊列來處理,不僅簡單的多,而且不容易出錯。

對於請求的處理,通常可以採取幾種策略:

  • 單線程策略:使用一個線程處理所有的請求,適用於請求的處理有順序要求的情況。
  • 每請求一個線程:對於每一個請求,都使用一個線程來處理,當該請求處理完畢之後,線程就消亡。這種策略不利於控制整個系統的線程數量,我們不提倡採取該策略。對於效率要求高,需要並行處理的情況,推薦使用下面的線程池策略。
  • 線程池:使用一個線程池來處理請求。當收到一個請求後,從線程池中取出一個空閒的線程來處理,處理完畢之後,將該線程回收到線程池。如果沒有空閒的線程,則請求掛起/拒絕。

Exceutor封裝了這幾種情況的線程管理,使得我們可以簡單的設置策略,就可以管理線程。

Barrire使用情況比較少,可以忽略,我也不懂。

      1. 實現

在統一網管平臺中,集成了一個concurrent.jar,可以提供以上所有的函數。在實際應用中,我們最可能用到的是Synchronized、MessageQueue、Executor,對於這幾個類的用法,都非常簡單,可以後面自己學習。

 

  1. 資源
    1. 概述

所謂資源,包括線程、內存、數據庫連接、socket連接、文件句柄等。所有這些東西有一個共同特點,它們的數量是有限制的,不能夠無限制的獲取和使用。這通常有兩種情況,某些資源,例如同時打開的文件數目、socket連接等,操作系統允許的數量很少,一個應用程序如果佔用這些資源,其他應用程序就不能夠正常工作,這將較快的導致系統的不正常。另外一種情況,如內存或者JAVA中的線程數量,系統允許的數量較多,應用程序佔用這些資源的後果不會立即顯現出來,剛開始僅僅造成系統性能的下降,它的嚴重影響需要一個緩慢的過程(如持續運行幾天或者幾個月)才能夠顯現。

對於資源的使用,要注意兩件事情:

1)不要泄漏,使用完了以後要關閉。這是最基本的要求。

2)資源使用要有總量控制,每個模塊要對自己使用的資源有估計,整個系統要進行總量控制。

對於我們的系統,主要考慮的是以下幾類資源:

  • 數據庫連接
  • 消息服務器連接
  • 對象遠程引用

有經驗的程序員都知道,如果由於編程不正確導致資源處理不正確,那麼將是調試程序的一個噩夢。同功能性錯誤相比,這些Bug引起的故障,常常無法重現,或者需要很長時間才能夠重現。而且,在查找這些Bug的過程中,調試工具通常也起不了太大的作用。當一個系統的功能基本調通以後,資源的問題就成爲一個主要的調試內容。因此,有必要從編程開始,就對這些方面的內容引起高度重視。

    1. 資源的使用方式
      1. 永久性使用

永久性使用是指程序在初始化的時候申請資源,以後就一直使用,永遠不釋放,直到程序運行結束,由操作系統或者JVM強行釋放。

由於每一種資源在系統中的數量是有限制的,所以這種用法非常危險。一個模塊永久佔用一個資源,意味着其它模塊可使用的資源就少了一個。如果有很多模塊都這樣做,那麼各模塊單獨運行的時候,可能不會發生問題。但是當所有這些模塊集成在一起運行的時候,就有可能出現資源不足的情況。

永久性用法的另一個危險在於,多線程情況下,如果一項功能可能同時運行,程序員有時候會忘記考慮多線程的因素,而使用同一個資源,從而導致共享衝突的情況發生(如數據庫連接)。

對於某些資源,並不是申請之後就一直能夠正確使用的,有時候程序不得不去寫代碼維護這個資源。例如:數據庫連接在長時間不用之後,系統會自動將其釋放。當通訊中斷之後,消息服務器的連接需要重新建立。爲了維護這些資源,經常需要了解資源的內部情況,這是普通的程序員很難考慮周全的。

基於以上3個原因,我們一般情況下,不建議使用永久性方式。如果由於性能或者其他原因必須使用,則應該考慮以上3種情況,並在資源聲明的註釋中加以詳細說明程序是如何處理這3種情況。我們再次回顧一下需要注意的3個方面:

1)永久性使用要考慮資源的永久佔用是否會影響整個系統。

2)考慮共享衝突。

3)最重要的,要自己維護資源。

某些程序員認爲,永久性使用,或者類似的搞一個緩存,可以提高程序的運行效率。這也是某些人堅持使用永久性方式的一個“有力”的理由。有一句名言:“過早的優化是一切麻煩的根源”。正確的方式是:只優化那些需要優化的代碼。在進行程序設計的時候,除非已經知道此處將會成爲性能瓶頸,否則更多地考慮程序的簡單、可維護性,而不是進行復雜的優化而將其變得難以閱讀維護,使得潛在的錯誤更多。

即使需要進行資源緩存以提高效率,也應該遵循以下的原則:

  1. 整個系統應針對某項資源進行統一緩存,而不是每一個模塊都自己做緩存處理。
  2. 該緩存的維護代碼由這方面的專家來編寫,而不是具體應用程序的編寫者自己來做。
  3. 進行緩存和不進行緩存的情況下,應用編程者面對的接口不變。
      1. 一次性使用

一次性使用,是指每進行一次處理,程序就申請一個資源,處理完成之後立即釋放。同永久性使用方式相比,這種方式處理要簡單得多,首先,不必考慮多線程共享衝突的問題。因爲通常情況下,每次處理只可能在一個線程中進行。其次,應用程序不必考慮資源的維護。資源發生故障,需要進行維護的概率是比較小的,一般說來,一次請求處理非常短暫,在申請成功之後立即發生故障的概率極小,幾乎可以不考慮。其次,即使發生了故障,這些故障排除所需要的時間也比一次請求處理所需要的時間長的多,在請求期間基本上只能夠返回一個錯誤。這和不進行維護的效果是一樣的。

所以,對於一次性使用方式,只需要注意一個方面:資源的釋放。

儘管這聽起來簡單,但是卻發生了很多錯誤。對於資源需要釋放這一點,無論怎樣強調都不爲過。

      1. 資源的使用接口封裝

提供給應用的資源使用接口應該進行封裝,而不是採用示範代碼的方式。封裝的好處在於:

  1. 減少了應用出錯的機會。
  2. 簡化了以後可能發生的修改。

對於資源的封裝,最好能夠達到這樣幾個原則:

首先,對於一種資源,應用只需要和一個類打交道。爲了設計的靈活,或者採取各種各樣的設計模式,其結果是應用開發者不得不面對相當多的類。爲了完成一個簡單的獲取資源的功能,需要好幾條語句。我個人認爲,應當使用一個façade模式,提供給用戶一個類,封裝所有的操作。

其次,資源的獲取應當只調用一個函數open即可完成。如果以後發現該函數不能夠滿足要求,則增加其他的函數open即可。

第三,資源的釋放只調用一個函數free完成。

舉例:

class DBHandle {

        private Connection con = null;

        public DbHandle() {}

        public void open(String dsName) throws XxxException {

                …

     }

        public void free() throws XxxException{

        }

}

 

      1. 應用編程

有了以上的基礎,對於一次性使用方式,應用編程就非常簡單了。

DBHandle dbh = new DBHandle();

try {

        dbh.get(“…”);

}

catch(…) {

}

finally {

        dbh.free();

}

 

建議儘量採用以上的程序結構。如果無法採用,需要考慮兩個原則,首先,對於一次性使用的方式,建議該次處理所需要的所有資源統一在處理開始的時候申請。中間調用其他類方法的時候,將資源作爲參數傳入。其次,資源的釋放要統一在finally語句塊中完成。

對於統一網管平臺,最好統一在處理業務邏輯的Bean方法開始申請資源,在該方法的finally語句塊中釋放資源。其他公共函數在設計時,都不要自己去申請資源,而是應該將資源句柄作爲方法參數,要求客戶端程序傳入。

目前對於資源仍然使用J2EE標準接口的情況下,資源的申請和釋放應該使用標準的代碼。這部分代碼將後續提供。

    1. 資源的總量控制
      1. 效率和資源的平衡

爲了提高處理效率,採用多線程是最好,也是最省事的辦法。實際上,線程的使用是有限度的,一般情況下,線程越多,效率越高。但是線程也有副作用,一是系統對線程的管理有開銷,線程之間切換也有代價。另一方面,每多啓動一個線程,就會多一些資源的消耗,多出很多對象,多出很多的內存。當線程的數量到達一定程度以後,系統在管理線程的開銷,線程帶來的各種資源的消耗,就會大於線程帶來的好處,導致系統系統的急劇下降。可以用如下的曲線來描述:

 

所以,線程不是越多越好。特別是從某些模塊的角度來看,自己對資源的佔用並不過分,啓動10個線程也不多,但是我們是做一個平臺,一個模塊單獨運行可能沒有問題,集成到平臺中來就可能出現問題。平臺單獨運行沒有問題,加上應用之後就可能出現問題。所以,每一個模塊都要控制自己對資源的使用,控制線程的數量。

當然,具體數量是多少,我們也沒有總結出合適的數據來。但是至少每一個模塊要進行控制,不能夠無限制的啓動。

      1. 平臺的情況

從我們的系統來講,所有的代碼都是4種力量驅動的:

  • 用戶的操作驅動。
  • 定時器的驅動。
  • 網元或者其他系統上報的消息驅動。
  • 自己啓動的線程。

我們根據這4種驅動,就可以簡單的計算最大可能情況下的線程數量。超出規定的限制之後,要能夠“拒絕”這些請求。因爲,計算機的處理能力不是無限大,當請求超出了處理能力之後,就要將這些請求拒絕掉,直到目前的請求處理完成以後,能夠恢復回來。否則就會出現越忙越亂,越亂越忙的現象,最後導致了系統的崩潰。以前平臺的設計中沒有考慮到,導致了很多的問題:

  • JMS的設計。
  • PCS性能數據入庫的問題。

總之,從模塊設計的角度來看,應當有一種穩定優於服務質量,“try my best”的思想,不要因爲爲了無限制的提高服務質量而導致系統的不穩定。如果因爲這樣使得整個系統無法完成任務,就要考慮升級硬件配置。從這方面來講,類似數據庫的優化,平臺以後也應當強調部署者的概念,應該根據機器的實際配置情況,網絡管理的需求,在部署的時候調整各種參數,如:告警池的大小,允許併發訪問的客戶端數量等等。

根據以上的論述,應當對資源的使用進行控制。目前大部分模塊都沒有進行控制,少數的模塊作了控制,但是大部分也是在出現問題以後。對資源的控制方式,從一個大的系統來說,應當有一個統一的方式。

      1. 網元上報消息的控制

從網元上報的消息處理,一般是佔用資源最多的一種模式。處理這類消息,一定要有控制。一個最基本的控制模式就是採用生產者—消費者模式,將處理分解爲2個步驟,中間使用消息隊列來傳遞消息。如下圖:

        這實際上是一個採用J2EE實現的線城池的Exceutor模式。在這裏,我們做兩方面的控制:

  • 通過對消費者數量的控制,可以避免資源的使用超出限制,如每個消費者佔用一個數據庫連接,那麼就可以通過這個方式,控制總的數據庫連接數,從而避免無限制的啓動線程來處理,導致系統資源的枯竭而崩潰。
  • 通過對JMS消息主題最大數量的限制,可以控制消息的積壓,防止處理請求超出處理能力的時候,系統無法恢復。這種時候,應當丟棄消息。
      1. 客戶端調用發起操作的控制

對於客戶端來的調用來說,大部分情況下不需要進行控制,但是少部分情況下,如一個操作特別耗時,則需要做控制,限制同時訪問的個數,以避免多個請求同時訪問,使得處理時間更長的情況。

例如:在目前的平臺中存在這樣一種典型情況,某個耗時特別長的操作,如查詢一個數百萬條記錄的數據表,可能需要幾分鐘。這時很容易造成超時,但是我們目前的超時是假的,僅僅是客戶端不接受處理結果而已,真正服務器還在後臺進行處理。這樣,用戶再次發起操作,多操作幾次,就會導致服務器忙。

      1. 定時器的控制

定時器維護了一個線程池,每次從線程池中取得一個線程來執行某個特定的定時任務。這裏需要注意的是:如果一個定時任務的執行時間超出了定時間隔,系統如何處理?例如:我們假定有一個定時任務10秒鐘執行一次,但是這個任務很可能執行30秒,在1分的時候執行,這個任務還沒有執行完,在1分10秒的時候是否執行新的任務呢?

從整個系統來看,如果放任這種情況的發生,是非常危險的,也很容易導致系統處理堆積。所以,這裏一定要有所控制。可能的策略是,如果到了定時任務執行的時間,而上一次執行還沒有完,那麼就不要執行新的任務。

這種情況下,要有一個通知的機制,使得應用知道發生了這種問題,好做一些善後的處理工作。最好的方法還是,對於處理時間長的操作,要通過生產者-消費者模式解藕,定時任務作爲生產者,把任務的執行等耗時較長的操作放到消費者去做。

而且,經過測試發現,JAVA的定時器是極不準確的,最大可能性會相差幾十秒。例如,你調用一個sleep(10毫秒),由於調度的關係,可能會睡10秒。所以,對於這種定時間隔差別在分鐘之內的任務,無論任務的執行時間是多少,都可能被亂調度。所以,定時任務的定時間隔應該是分鐘級。

    1. 資源的監控

需要PSL做工作。作爲一個平臺來說,應當提供足夠的手段供應用進行監控,調試和診斷髮現問題。

  1. 內存
    1. JAVA的內存回收機制

在Java中所有對象都是在堆(Heap)中分配的,對象的創建通常都是採用new或者是反射的方式,但對象釋放卻沒有直接的手段,所以對象的回收都是由Java虛擬機通過垃圾回收線程(GC)去完成的。

Java中對象回收的原則是這個對象不再被引用,準確的說是不再被系統運行線程中的各種對象引用,具體後面會詳細介紹。垃圾回收線程怎麼知道一個對象不再使用需要回收呢?這就需要垃圾回收線程監控每一個對象的運行狀態,包括對象的申請、引用、被引用、賦值等

爲了更好理解GC的工作原理,我們可以將對象考慮爲有向圖的頂點,將引用關係考慮爲圖的有向邊,有向邊從引用者指向被引對象。另外,每個線程對象可以作爲一個圖的起始頂點,例如大多程序從main進程開始執行,那麼該圖就是以main進程頂點開始的一棵根樹。在這個有向圖中,根頂點可達的對象都是有效對象,GC將不回收這些對象。如果某個對象 (連通子圖)與這個根頂點不可達(注意,該圖爲有向圖),那麼我們認爲這個(這些)對象不再被引用,可以被GC回收。

以下,我們舉一個例子說明如何用有向圖表示內存管理。對於程序的每一個時刻,我們都有一個有向圖表示JVM的內存分配情況。以下右圖,就是左邊程序運行到第6行的示意圖。

 

 

 

前面是一個最簡單的例子,只是回收一個沒有任何引用對象,但大部分情況下都比這個複雜得多,GC往往需要進行復雜的計算來確定一組對象做爲整體沒有外部引用,而全部可以釋放。我們來看一個典型的Java程序的內存使用示意圖

 

 

上圖中最外面代表整個內存堆,中間灰色的框代表系統運行所需有效對象空間,從垃圾回收的角度看這裏有四類對象,

  1. 有效對象,在灰色框中的對象,這是系統運行需要的對象,不能被垃圾回收掉
  2. 獨立的無效對象,系統不需要再使用,而且沒有如何外部引用,這類對象很快就會被垃圾回收掉
  3. 一組無效的對象,從整體看系統已經不再需要這些對象,不過這些對象間存在相互依賴,這類對象的回收與垃圾回收線程算法相關,可能不會立刻回收掉,需要一段時間計算
  4. 被有些對象引用到的無效對象,這就是我們要解決的內存泄漏的對象

 

對於程序員來說,GC基本是透明的,只有幾個函數可以訪問GC,常見的是運行GC的函數System.gc(),但是根據Java語言規範定義, 該函數不保證JVM的垃圾收集器一定會執行。不同的JVM實現者可能使用不同的算法管理GC。通常GC的線程的優先級別較低。JVM調用GC的策略也有很多種,有的是內存使用到達一定程度時GC纔開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。通常來說,程序員不需要關心這些,除非在一些特定的場合,GC的執行影響應用程序的性能,例如對於基於Web的實時系統,如網絡遊戲等,用戶不希望GC突然中斷應用程序執行而進行垃圾回收,那麼我們需要調整GC的參數,讓GC能夠通過平緩的方式釋放內存,例如將垃圾回收分解爲一系列的小步驟執行。

 

    1. 注意內存泄漏

JAVA的內存回收機制已經極大的減輕了程序員的工作量。在大部分情況下,程序員不需要去考慮內存的顯式回收。但是仍然需要注意某些情況:特別注意凡是調用了addXXXListerner或者registerXXXListerner或者類似的方法,在退出的時候一定要仔細,看是否需要調用removeXXX方法或者unregisterXXX方法。

 

  1. JDBC
    1. Statement、Preparedstatement和CallableStatement

Statement每次在執行Statement對象上的SQL語句的時候,都將SQL語句傳給數據庫,在數據庫上執行前都需要進行解析和編譯,而這部分工作是比較費時的

相比之下Preparedstatement和CallableStatement採用的是預編譯緩存SQL語句的方式,不需要每次執行的時候去做SQL語句的解析和編譯工作,在需要頻繁或重複執行某個SQL語句的時候效率提升明顯。

 

這三種對象典型的使用場景

  • Statement:當系統中某條SQL語句只需要執行一次,或者很少的幾次的時候
  • Preparedstatement:頻繁使用一條SQL語句,數據庫將解析、編譯SQL語句後做臨時緩存
  • CallableStatement:調用存儲過程,這是效率最高的方式,對於頻繁使用的一些固定SQL語句,如果有效率上優化的需要可以考慮做爲存儲過程(當然這樣做會存在跨數據庫移植的問題)
    1. 數據庫佔用的內存

我們平時注意了程序運行的JVM的內存,但是數據庫的內存佔用也是一個關鍵的因素,數據庫操作設計不合理,會使得數據庫佔用大量的內存,也會導致內存不夠用的情況發生。

語句在相同的環境下,循環運行同一條語句(值不同):

  • select佔用大量內存,一般5分鐘可使內存從30增加到512上限,內存不足;
  • insert 大量操作,內存也會不斷增長,但比較緩慢。
  • update和delete,幾乎不會佔用多大內存。

 

所以建議:對於程序循環中或者頻繁使用的select語句要重點走查,一般情況下可以採用內存緩存方式,避免大量使用select語句;或者採用存儲過程。

  1. 參考文獻

略。



 [z1]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章