在計算機中存儲信息的最小單位是1個字節,即8bit,因此能標識的最大字符範圍是0~255,而人類天然語言中例如漢語、日語要表示的符號太多,沒法單純用一個字節來徹底表示,爲了解決這個矛盾必需要有一個新的人類可識別的數據存儲結構字符,而從char到byte必須編碼。javascript
總共128個,使用一個字節的低7位表示,0~31是控制字符如換行、回車、刪除等,32~126是打印字符,能夠經過鍵盤輸入而且可以顯示出來html
ISO組織在ASCII碼的基礎上利用了單個字節的全部位定製了一系列標準擴展了ASCII編碼,ISO-8859-1仍然是單字節編碼,總共能夠表示256個字符前端
GB2312全稱是《信息技術中文編碼字符集》,它採用了雙字節編碼,總的編碼範圍是A1~F7,其中A1~A9是符號區,總共包含682個符號;B0~F7是漢字區,包含6763個漢字。java
GBK全稱是《漢字內碼擴展規範》是國家技術監督局位windows 95制定的新的內碼規範,它的出現是爲了擴展GB2312,並加入更多的漢字,它的編碼範圍是8140~FEFE,總共有23940個碼位能夠表示21003個漢字,它的編碼與GB2312兼容,也就是說採用GB2312編碼的漢字都可以經過GBK去解碼,而且不會有亂碼。mysql
UTF-16定義了Unicode字符在計算機中的存取方法,UTF-16使用兩個字節表示Unicode的轉化格式,這是一個定長的表示方法,不管什麼字符UTF-16都用兩個字節表示,2個字節就是16位,因此稱之爲UTF-16,UTF-16表示字符很是方便,每兩個字節表示一個字符,在字符串操做的時候大大簡化了操做,這個也是Java以UTF-16做爲內存的字符存儲格式的一個很重要的緣由。web
Unitcode使用UTF-16編碼規則以下:算法
UTF-16編碼以16位無符號整數爲單位,咱們sql
UTF-16統一採用兩個字節表示一個字符,雖然在表示上很是簡單方便,可是也有其缺點,有很大一部分字符使用一個字節就能夠表示的如今須要兩個字節表示,存儲空間增大了一倍,在如今網絡帶寬還很是有限的今天,這樣作無疑會增大網絡傳輸的流量,並且也沒什麼必要,而UTF-8採用的是一種變長的技術,每種編碼區域都會有不一樣的字節長度,不一樣類型的字符能夠有1~6個字節組成。數據庫
UTF-8有如下編碼規則apache
1.若是是1個字節,最高爲(第8位)爲0,則表示這是1個ASCII字符(00~7F)。可見,全部ASCII編碼已是UTF-8了。
2.若是是1個字節,以11開頭,則連續的1的個數暗示這個字符的字節數,例如:110xxxxx表明它是雙字節UTF-8字符的首字節。
3.若是是1個字節,以10開始,表示它不是首字節,則須要向前查找才能獲得當前字符的首字節。
咱們知道在涉及編碼的地方通常都在從字符到字節或者是從字節到字符的轉化過程當中,而須要這種轉化的場景主要是I/O,而這個IO主要包括磁盤IO和網絡IO。
在Java中負責在IO過程當中處理字節到字符轉換的是InputStreamReader,它繼承自Reader,在類建立時關聯了一個字節輸入流InputStream對象,對具體字節到字符的轉換它主要委託給內部的StreamDecode去作,StreamDecoder在解碼過程當中須要基於指定的Charset獲取對應的CharsetDecoder將字節流爲字符,若是沒有指定將使用操做系統默認的編碼方式,在中文環境中一般時GBK。相反在java中關聯字符到字節轉換的橋樑是OutputStreamWriter,他繼承自Writer,在類建立時關聯了字節輸出流OutputStream對象,對具體字符到字節的轉換它主要委託給內部的StreamEncoder,StreamEncoder獲取對應Charset的CharsetEncoder編碼器將字符編碼位字節,若是沒有指定字符集Charset一般採用本地操做系統默認的字符集進行編碼。
在咱們的應用程序中涉及I/O操做時,只要注意指定統一的編解碼Charset字符集,通常不會出現亂碼問題。
對有些應用程序若是不注意指定字符編碼,則在中文環境中會使用操做系統默認編碼。若是編解碼都在中文環境中,一般也沒有問題,但仍是不推薦使用操做系統的默認編碼,由於這樣會使你的應用程序的編碼格式和運行環境綁定起來,在跨環境時極可能出現亂碼問題。
在Java開發中除I/O涉及編碼外,最經常使用的應該就是在內存中進行從字符到字節的數據類型轉換,在Java中用String表示字符串,因此String類就提供了轉換到字節的方法,也支持將字節轉換爲字符串的構造函數。
String s = "這是一段中文字符串"; byte[] b = s.getBytes("UTF-8"); String str = new String(b,"UTF-8");
在程序中這三行代碼一共經歷瞭如下過程:
1)UTF-16輸入流到Unicode的解碼(在JVM中發生)
2)Unicode到UTF-8編碼的輸出流
3)UTF-8輸入流到Unicode的解碼
4)Unicode到UTF-16的編碼(在JVM中發生)
思考下代碼最終執行結果字節數組a與b是否相同,結合Java默認的編碼方式以及JVM內部默認採用存儲漢字的編碼方式,分析這一過程
byte[] a = new byte[]{(byte) 0xc6, (byte) 0xd0}; String s = new String(a); byte[] b = s.getBytes();
Charset提供encode與decode,分別對應char[]到byte[]的編碼和byte[]到char[]的解碼。
String s = "這是一段中文字符串"; Charset charset = Charset.forName("UTF-8"); ByteBuffer byteBuffer = charset.encode(s); CharBuffer charBuffer = charset.decode(byteBuffer);
Java中涉及編碼的類圖以下:
下圖是String.getBytes(String charsetName)對應的時序圖
由圖可知,String.getBytes(String charsetName)編碼基本流程以下:
1)根據charsetName找到Charset類,而後根據這個字符集編碼生成CharsetEncoder,這個類是全部字符編碼的父類,針對不一樣的字符編碼集在charset中定義了獲取對應CharsetEncoder的方法;
2)基於獲取到的編碼器CharsetEncoder對當前字符串進行編碼
字符串「I am 君山」用ISO-8859-1編碼時,編碼結果如圖:
能夠看出,7個 char 字符通過 ISO-8859-1 編碼轉變成7個 byte 數組,ISO-8859-1 是單字節編碼,中文「君山」被轉化成值是 3f 的 byte。3f 也就是「?」字符,因此常常會出現中文變成「?」,極可能就是錯誤的使用了 ISO-8859-1 這個編碼致使的。中文字符通過 ISO-8859-1 編碼會丟失信息,一般咱們稱之爲「黑洞」,它會把不認識的字符吸取掉。因爲如今大部分基礎的 Java 框架或系統默認的字符集編碼都是 ISO-8859-1,因此很容易出現亂碼問題。
字符串「I am 君山」用GB2312編碼時,編碼結果如圖:
GB2312 對應的 Charset 是 sun.nio.cs.ext.EUC_CN,而對應的 CharsetEncoder是 sun.nio.cs.ext.DoubleByte.Encoder,咱們進入該類encodeLoop方法的源碼:
protected CoderResult encodeLoop(CharBuffer var1, ByteBuffer var2) { return var1.hasArray() && var2.hasArray() ? this.encodeArrayLoop(var1, var2) : this.encodeBufferLoop(var1, var2); }
這裏只是簡單的作了基本要素的判空繼續進入encodeArrayLoop方法
protected CoderResult encodeArrayLoop(CharBuffer var1, ByteBuffer var2) { char[] var3 = var1.array(); int var4 = var1.arrayOffset() + var1.position(); int var5 = var1.arrayOffset() + var1.limit(); byte[] var6 = var2.array(); int var7 = var2.arrayOffset() + var2.position(); int var8 = var2.arrayOffset() + var2.limit(); try { while(true) { if (var4 < var5) { char var15 = var3[var4]; int var10 = this.encodeChar(var15); CoderResult var11; if (var10 != 65533) { //若大於255則證實是雙字節字符,雙字節字符高8位做爲第1個字節存儲,低8位做爲第2個字節存 //儲, if (var10 > 255) { if (var8 - var7 < 2) { var11 = CoderResult.OVERFLOW; return var11; } var6[var7++] = (byte)(var10 >> 8); var6[var7++] = (byte)var10; } else {//不然是單個字節字符,直接編碼做爲單個字節存儲 if (var8 - var7 < 1) { var11 = CoderResult.OVERFLOW; return var11; } var6[var7++] = (byte)var10; } ++var4; continue; } ...... }
這個方法代碼挺長,咱們略過非核心內容能夠看到真正進行字符編碼的地方是在圖中註釋處的代碼,咱們看到他調用了本類的encoderChar方法繼續進入該方法進行後續分析:
public int encodeChar(char var1) { return this.c2b[this.c2bIndex[var1 >> 8] + (var1 & 255)]; }
到這裏,Java中GB2312的大體編碼流程就很清晰了,GB2312 字符集有一個 char 到 byte 的碼錶,經過這個碼錶獲取每一個字符對應的碼位值var10,再經過對這個碼位值進行判斷,若是大於255,則基於高位編址法取它的高8位做爲第一個字節存放,低8位做爲第2個字節,因而可知GB2312的編解碼實際上是基於碼錶進行的。
字符串「I am 君山」用GBK編碼時,編碼結果如圖:
你可能已經發現,上圖與 GB2312 編碼的結果是同樣的,沒錯,GBK 與 GB2312 編碼結果是同樣的,由此能夠得出 GBK 編碼是兼容 GB2312 編碼的,它們的編碼算法也是同樣的。不一樣的是它們的碼錶長度不同,GBK 包含的漢字字符更多。因此只要是通過 GB2312 編碼的漢字均可以用 GBK 進行解碼,反過來則否則。
字符串「I am 君山」用UTF-16編碼時,編碼結果如圖
用 UTF-16 編碼將 char 數組放大了一倍,單字節範圍內的字符,在高位補 0 變成兩個字節,中文字符也變成兩個字節。從 UTF-16 編碼規則來看,僅僅將字符的高位和地位進行拆分變成兩個字節。特色是編碼效率很是高,規則很簡單,因爲不一樣處理器對 2 字節處理方式不一樣,Big-endian(高位字節在前,低位字節在後)或 Little-endian(低位字節在前,高位字節在後)編碼,因此在對一串字符串進行編碼是須要指明究竟是 Big-endian 仍是 Little-endian,因此前面有兩個字節用來保存 BYTE_ORDER_MARK 值,UTF-16 是用定長 16 位(2 字節)來表示的 UCS-2 或 Unicode 轉換格式,經過代理對來訪問 BMP 以外的字符編碼。
字符串「I am 君山」用UTF-8編碼時,編碼結果如圖:
UTF-16 雖然編碼效率很高,可是對單字節範圍內字符也放大了一倍,這無形也浪費了存儲空間,另外 UTF-16 採用順序編碼,不能對單個字符的編碼值進行校驗,若是中間的一個字符碼值損壞,後面的全部碼值都將受影響。而 UTF-8 這些問題都不存在,UTF-8 對單字節範圍內字符仍然用一個字節表示,對漢字採用三個字節表示。UTF-8 編碼與 GBK 和 GB2312 不一樣,不用查碼錶,因此在編碼效率上 UTF-8 的效率會更好,因此在存儲中文字符時 UTF-8 編碼比較理想。
1)對於中文字符,GB2312與GBK編碼規則相似,可是GBK範圍更大,它能處理全部漢字字符,因此將GB2312與GBK進行比較,應該選擇GBK。
2)UTF-16與UTF-8都是處理Unicode編碼,它們的編碼規則不太相同,相對來講,UTF-16的編碼效率較高,從字符到到字節的相互轉換更簡單,進行字符串操做也更好。它適合在本地磁盤和內存之間使用,能夠進行字符和字節之間的快速切換,如Java的內存編碼就採用UTF-16編碼。可是它不合適在網絡之間傳輸,由於網絡傳輸容易損壞字節流,一旦字節流損壞將很難恢復,因此相比較而言UTF-8更適合網絡傳輸。
3)UTF-8對ASCII字符采用單字節存儲,另外單個字符損壞也不會影響後面的其餘字符,在編碼效率上介於GBK和UTF-16之間,因此UTF-8在編碼效率上和編碼安全上作了平衡,是理想的中文編碼方式。
前面已經提到了I/O操做會引發編碼,而大部分I/O引發的亂碼都是網絡I/O,由於如今幾乎全部的應用程序都涉及網絡操做,而數據通過網絡傳輸時是以字節爲單位的,因此全部的數據都必須可以被序列化爲字節。在Java中數據要被序列化,必須繼承Serializable接口。
用戶從瀏覽器發起一個HTTP請求,存在編碼的地方是URL、Cookie、Parameter。服務器端接收到HTTP請求後要解析HTTP,其中URL、Cookie和Post表單參數須要解碼,服務器端可能還須要讀取數據庫中的數據——本地或網絡中其餘地方的文本文件,這些數據均可能存在編碼問題。當Servlet處理完全部請求的數據後,須要將這些數據再編碼,經過Socket發送到用戶請求的瀏覽器裏,再通過瀏覽器解碼成文本。這個過程如圖:
瀏覽器編碼URL是將非ASCII字符按照某種編碼格式編碼成16進制數字後將每一個16進制數字表示的字節前加上%。
用戶提交一個URL,在這個URL中可能存在中文,所以須要編碼,如圖爲用戶提交的一個URL:
以Tomcat做爲ServletEngine爲例,把他們分別對應到配置文件中,Port對應在Tomcat->Server.xml的<Connector port="8080" />中配置,而ContextPath在context.xml的<context path="/examples">中配置,ServletPath在Web應用的web.xml的<url-pattern>中配置,PathInfo是咱們請求的具體的Servlet,QueryString是要傳遞的參數
注意這裏是在瀏覽器裏直接輸入URL,因此是經過Get方法請求的,若是經過Post方法請求QueryString將經過表單方式提交到服務器端
<servlet-mapping> <servlet-name>junshangExample</servlet-name> <url-pattern>/servlets/servlet/*</url-pattern> </servlet-mapping>
當咱們在瀏覽器直接輸入這個URL時,在瀏覽器端和服務器端會如何編碼和解析這個URL呢
1)瀏覽器端編碼
咱們在fireFox瀏覽器上測試能夠發現瀏覽器對PathInfo呵呵QueryString採用的編碼方式是不同的,在Chrome中PathInfo是採用UTF-8編碼,而QueryString則是GBK,不一樣瀏覽器對與PathInfo的編碼方式可能還不同,這就爲服務端的解析帶來了困難。
2)服務器端解析
對於URL的URI部分進行解碼的字符集是在connector的<Connector URIEncoding="UTF-8" />中定義的,若是沒有定義,那麼默認將會採用默認的編碼ISO-8859-1進行解析(ISO-8859-1不包含中文)。因此有中文URL的時候最好把URIEncoding設置成UTF-8編碼。
對於QueryString的解析過程:以Get方式HTTP請求的QueryString與以POST方式的HTTP請求的表單參數都是做爲Parameters保存的,都是經過request.getParameter獲取參數值。對他們的解碼是在request.getParameter方法第一次被調用的時進行的。
QueryString的編碼字符集要麼是Header中ContentType定義的Charset,要麼是默認的ISO-8859-1,要使用ContentType中定義的編碼,就要將connector的<Connector URIEncoding="UTF-8" useBodyEncodingForURI="True" />中的useBodyEncodingForURI設置爲True。這個配置項容易令人產生混淆,他並非對整個URI都採用BodyEncoding進行解碼,而僅僅是對QueryString使用BodyEncoding解碼這一點須要特別注意。
從上面URL編碼和解碼過程來看,比較複雜並且編碼和解碼不是在咱們應用程序中能徹底控制的,在咱們的應用程序中,應該儘可能避免在URL中使用非ASCII字符,否則可能會遇到亂碼問題。當咱們的服務器端最好設置<Connector />中的URIEncoding和useBodyEncodingForURI這兩個參數
當客戶端發起一個HTTP請求的時候,除了URL以外還可能會在Header中傳遞其餘的參數,例如Cookie、redirectPath等,這些用戶設置的值可能也會存在編碼的問題。Tomcat對於他們是怎麼解碼的呢?
對於Header中的項進行解碼也是在調用request.getHeader時進行的。若是請求的Header項沒有解碼則調用MessageBytes的toString方法,這個方法對於從byte到char的轉化使用的默認編碼也是ISO-8859-1而咱們也不能設置Header的其餘編碼格式,因此若是你設置的Header中有非ASCII字符,解碼中確定會有亂碼。
咱們在添加Header時,若是必定要傳非ASCII字符,能夠先將這些字符使用org.apache.catalina.util.URLEncoder編碼,再添加到Header中,這樣在瀏覽器到服務器的傳遞過程當中就不會丟失信息了,咱們要訪問這些項時在按照相應的字符集解碼便可
POST表單提交的參數的解碼是在第一次調用request.getParameter時發生的,POST表單的參數傳遞方式與QueryString不一樣,它是經過HTTP的BODY傳遞到服務端的。
當咱們在頁面上單擊提交按鈕時,瀏覽器首先將根據ContentType的Charset編碼格式對錶單中填入的參數進行編碼,而後提交到服務器端,在服務器端一樣也是採用ContentType中的字符集進行解碼的,這個字符集咱們也能夠在服務端經過request.setCharacterEncoding(Charset)來進行設置。
另外針對multipart/for-data類型的參數,也就是上傳的文件編碼,一樣也是使用ContentType定義的字符集編碼。注意,上傳文件是用字節流的方式傳遞到服務器的本地臨時目錄,這個過程並無涉及字符編碼,而真正的編碼是將文件內容添加到Parameters中時,若是不能使用這種編碼方式則會使用默認編碼ISO-8859-1來編碼。
當用戶請求的資源已經成功獲取後,這些內容將會經過Response返回給客戶端瀏覽器。這個過程要先通過編碼,再到瀏覽器進行解碼。編碼字符集能夠經過response.setCharacterEncoding來設置,它將會覆蓋request.setCharacterEncoding的值,而且經過Header的Content-Type返回客戶端,瀏覽器接收到返回的Socket流時將經過Content-Type的charset來解碼
若是返回的HTTP Header中的Content-Type沒有設置charset,那麼瀏覽器將根據HTML的<meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" />中指定的charset來解碼。若是沒有定義,那麼瀏覽器將使用默認的編碼來解碼。
訪問數據庫都是經過客戶端JDBC驅動來完成的,使用JDBC來存取數據時要和數據的內置編碼保持一致,能夠經過設置JDBC URL來指定,如MySQL:url=」jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK」
在一個單獨的JS文件中包含中文字符串輸入的狀況,例如:
<html> <head> <script src="static/javascript/script.js" charset="gbk"></script>
若是引入一個script.js腳本,這個腳本中含有以下代碼:
docuemnt.write("這是一段中文");
這時若是script沒有設置charset,瀏覽器就會以當前這個頁面默認的字符集解析這個JS文件。若是外部的JS文件的編碼格式與當前頁面的編碼格式一致,那麼就能夠不設置這個charset。可是若是script.js文件與當前頁面的編碼格式不一致,如script.js是UTF-8編碼而頁面時GBK編碼,上面代碼中的中文輸入就會變成亂碼。
經過JS發起異步調用的URL默認的編碼也是受瀏覽器的影響,若是使用原始Ajax的http_request.open('GET',url,true)調用,URL的默認編碼在IE是操做系統的默認編碼而在Firefox下則是UTF-8編碼,另外不一樣的JS框架可能對於URL的編碼處理也不同。
處理JS的URL編碼問題:
1)encodeURI()與decodeURI()
JS用來對URL編碼的函數,他能夠將整個URL中的字符(一些特殊字符除外)進行UTF-8編碼,在每一個碼值前加上"%"。
2)encodeURIComponent()和decodeURIComponent()
encodeURIComponent()這個函數比encodeURI()編碼更爲完全。一般用於將一個URL當作一個參數放在另外一個URL中
3)Java與JS的編碼解碼問題。在Java端處理URL編碼解碼的有兩個類,分別是java.net.URLDecoder和java.net.URLEncoder。這兩個類能夠將全部「%」加UTF-8碼值使用UTF-8解碼,從而獲得原始的字符。Java端的URLEncode和URLDecoder與前端JS對應的是encodeURIComponent和decodeURIComponent。
注意:前端用encodeURIComponent編碼後,到服務端用URLDecoder解碼可能會出現亂碼,這必定是兩個字符編碼類型不一致致使的。JS編碼默認的是UTF-8編碼,而服務器中文解碼一段都是GBK或者GB2312,因此用encodeURIComponent編碼後是UTF-8,而java用GBK去解碼顯然不對。
解決的辦法是用encodeURIComponent兩次編碼,如encodeURIComponent(encodeURIComponent(str))。這樣在Java端經過request.getParamter()用GBK解碼後取得的就是UTF-8編碼的字符串,若是Java端須要使用這個字符串,則再用UTF-8解碼一次;若是是將這個結果直接經過JS輸出到前端,那麼這個UTF-8字符串能夠直接在前端正常顯示。
基於前面的瞭解的Java web編碼解碼知識以後咱們知道出現亂碼問題惟一的緣由就是在編碼解碼過程當中採用的字符集不一致致使的,由於在一次操做中常常涉及屢次編碼和解碼,所以出現亂碼問題的時候也給咱們排查帶來的難度,下面分析幾種常見的情景:
例如,字符串「淘!我喜歡!」變成了「Ì Ô £ ¡Î Ò Ï²»¶ £ ¡」編碼過程以下圖所示
字符串在解碼時所用的字符集與編碼字符集不一致致使漢字變成了看不懂的亂碼,並且是一個漢字字符變成兩個亂碼字符。這種情景在開發中常常發生,例如在瀏覽器中輸入一個帶有中文字符串參數的URL一些瀏覽器默認對QueryString採用的是GBK編碼方式,可是因爲在web中間件例如tomcat沒有作相關配置,在服務端讀取請求參數時也沒有指定編碼方式,因而默認使用ISO-8859-1進行解碼致使亂碼。這種出現亂碼且亂碼字符串長度是原編碼前字符串的兩倍的緣由多是採用2字節編碼例如GBK、UTF-16等而後使用單字節進行解碼例如ISO-8859-1致使的
例如,字符串「淘!我喜歡!」變成了「??????」編碼過程以下圖所示
將中文和中文符號通過不支持中文的 ISO-8859-1 編碼後,全部字符變成了「?」,這是由於用 ISO-8859-1 進行編解碼時遇到不在碼值範圍內的字符時統一用 3f 表示,這也就是一般所說的「黑洞」,全部 ISO-8859-1 不認識的字符都變成了「?」。
例如,字符串「淘!我喜歡!」變成了「????????????」編碼過程以下圖所示
這種狀況比較複雜,中文通過屢次編碼,可是其中有一次編碼或者解碼不對仍然會出現中文字符變成「?」現象,出現這種狀況要仔細查看中間的編碼環節,找出出現編碼錯誤的地方。
4 - 一種不正常的正確編碼解碼
還有一種狀況是在咱們經過 request.getParameter 獲取參數值時,當咱們直接調用下面代碼會出現亂碼
String value = request.getParameter(name);
可是若是用下面的方式解析時取得的 value 會是正確的漢字字符
String value = new String(request.getParameter(name).getBytes("ISO-8859-1"), "GBK");
這種狀況是怎麼形成的呢?看下圖:
這種狀況是這樣的,ISO-8859-1 字符集的編碼範圍是 0000-00FF,正好和一個字節的編碼範圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼能夠保持編碼數值「不變」。雖然中文字符在通過網絡傳輸時,被錯誤地「拆」成了兩個歐洲字符,但因爲輸出時也是用 ISO-8859-1,結果被「拆」開的中文字的兩半又被合併在一塊兒,從而又恰好組成了一個正確的漢字。雖然最終能取得正確的漢字,可是仍是不建議用這種不正常的方式取得參數值,由於這中間增長了一次額外的編碼與解碼,這種狀況出現亂碼時由於 Tomcat 的配置文件中 useBodyEncodingForURI 配置項沒有設置爲」true」,從而形成第一次解析式用 ISO-8859-1 來解析才形成亂碼的。
要解決中文編碼問題,首先要搞清楚哪些地方會引發字符到字節的編碼以及字節到字符的解碼,最多見的地方就是存儲數據到磁盤或者數據要通過網絡傳輸。其次應針對這些地方搞清楚操做這些數據的框架或系統是如何控制編碼的。最後正確設置編碼格式,避免使用軟件默認的或者操做系統平臺默認的編碼格式。
本文整理於《深刻分析Java Web技術內幕》