流與字符流轉換)
1. 一次線上亂碼事故關于字節(jié)流與字符流的轉換很多坑其實都是這里埋下的上一份工作里線上出了個典型的“錕斤拷”事故。簡單說是一個定時任務去讀取某個合作方同步過來的GBK編碼文件然后做數(shù)據(jù)解析入庫結果所有中文字段全部變成了“錕斤拷錕斤拷”。定位后發(fā)現(xiàn)問題就出在讀取文件時直接用了FileReader而線上JVM默認編碼是UTF-8源文件卻是GBK編碼兩邊沒對上解碼全亂。這類問題在Java開發(fā)里實在太常見了。Java的IO體系明明很簡單但一旦牽扯到字節(jié)流和字符流的轉換很多人就開始迷糊InputStreamReader和OutputStreamWriter是干什么的為什么不能直接拿FileInputStream讀文本為什么同樣的代碼本地跑得好好的換臺機器就亂碼如果你也有過這些疑問或者正在準備Java基礎面試那這篇文章就是給你準備的。下面我會從字節(jié)流和字符流的設計邏輯說起把轉換原理、實操代碼、亂碼排查完整過一遍。內容不繞彎盡量用我在實際項目里踩過的坑來對照著講。2. 字節(jié)流與字符流的分工邏輯同樣是讀寫為什么非要兩套體系2.1 字節(jié)流以字節(jié)為最小單位解決一切二進制數(shù)據(jù)的讀寫Java的IO流從設計上就分了兩條線一條是操作字節(jié)的InputStream/OutputStream另一條是操作字符的Reader/Writer。先看字節(jié)流它代表的是最底層的數(shù)據(jù)傳輸數(shù)據(jù)單位是byte也就是8位二進制數(shù)。圖片、音頻、壓縮包、Word文檔這些文件本質上都是二進制內容讀取的時候必須以字節(jié)為單位原樣搬運不做任何編碼轉換才能保證內容不被破壞。InputStream和OutputStream是所有字節(jié)流的頂層抽象JDK里常見的子類有很多FileInputStream用來讀文件、FileOutputStream用來寫文件、BufferedInputStream給文件讀加緩沖、FilterInputStream提供裝飾能力。字節(jié)流的核心方法很簡單就是read()和write(byte[])讀出來一個byte就處理一個byte寫進去一個byte就傳走一個byte。它不關心這些字節(jié)是什么含義字符也好、像素也好、壓縮數(shù)據(jù)也好對它來說都是二進制。打個比方字節(jié)流就像物流公司里的搬運工只管按照包裹數(shù)量來搬運不關心包裹里面裝的是衣服還是書。這個設計在操作系統(tǒng)向下傳輸數(shù)據(jù)時非常合理因為磁盤、網(wǎng)卡、內存對外呈現(xiàn)的都是流式的字節(jié)序列字節(jié)流是跟底層打交道最直接的方式。2.2 字符流面向人類可讀文本天然要處理編碼字符流則是Reader/Writer體系的產(chǎn)物它解決的是“人類可讀文本”的讀寫問題。Java里一個char占據(jù)兩個字節(jié)也就是UTF-16編碼下的一個編碼單元。但文本文件在磁盤上卻不是以char為單位存放的而是按某種字符集編碼后的字節(jié)序列。比如UTF-8編碼下英文字母占1個字節(jié)中文漢字占3個字節(jié)GBK編碼下中文占2個字節(jié)。字符流的作用就是把這些編碼后的字節(jié)序列翻譯成一個個Java能夠識別的char。這個“翻譯”動作在底層需要依賴一個字符集也就是Charset。當你從文件里讀文本時操作系統(tǒng)給你的是一堆字節(jié)字符流拿著字節(jié)按照你指定的字符集去解碼變成char給你用。寫出去的時候反過來把char按字符集編碼成字節(jié)再通過底層字節(jié)流寫出去。很多初學者會覺得字符流比字節(jié)流高級這不算錯。但要注意字符流同樣離不開字節(jié)流因為最終數(shù)據(jù)落盤、走網(wǎng)絡都必須變成字節(jié)。這就是為什么字符流內部往往包裝了一個字節(jié)流比如FileReader你以為它直接操作文件其實它內部就是把FileInputStream包了一層再加默認字符集解碼。2.3 我的選型原則與對比總結項目的業(yè)務場景決定了你該用哪條流。我的經(jīng)驗是凡是文本內容優(yōu)先選字符流凡是二進制內容必須選字節(jié)流。判斷標準也很簡單如果文件打開后你能直接用文本編輯器讀懂內容那就用字符流如果打開后是一堆亂碼、特殊符號或者文件本身就是圖片、視頻、音視頻、壓縮包等格式那就老老實實用字節(jié)流。下面這組對比是我在帶新人的時候經(jīng)常給他們做的總結對比維度字節(jié)流字符流頂層抽象InputStream / OutputStreamReader / Writer最小數(shù)據(jù)單位byte8位char16位是否處理編碼完全不處理按字節(jié)原樣讀寫必須指定或隱式依賴字符集典型場景圖片、音視頻、壓縮包、網(wǎng)絡傳輸文本文件、JSON、日志、配置文件緩沖包裝類BufferedInputStream / BufferedOutputStreamBufferedReader / BufferedWriter常用文件類FileInputStream / FileOutputStreamFileReader / FileWriter這里要特別提醒FileReader和FileWriter看起來方便但它們存在一個我踩過很多次坑的設計問題——構造方法里不提供指定字符集的參數(shù)只能使用JVM默認字符集。也就是說同樣的代碼在Windows上跑用GBK在Linux上跑用UTF-8環(huán)境一變讀同一個文件的輸出就完全不一樣。所以如果代碼要在多平臺部署或者文件編碼不確定我更推薦用InputStreamReader、OutputStreamWriter這兩個轉換流顯式聲明編碼避免隱患。3. 轉換橋梁的原理InputStreamReader與OutputStreamWriter到底做了什么3.1 為什么說轉換流是字節(jié)與字符之間的“翻譯官”字節(jié)流和字符流是兩套體系但數(shù)據(jù)總要從一個流向另一個。于是InputStreamReader和OutputStreamWriter就成了中間那座橋。InputStreamReader本質上是一個字符流因為它繼承了Reader但它構造時接收的是一個InputStream對象內部再通過CharsetDecoder把從字節(jié)流里讀出來的字節(jié)解碼成字符。反過來OutputStreamWriter繼承自Writer構造時接收一個OutputStream寫入字符時內部用CharsetEncoder把字符編碼成字節(jié)再交給字節(jié)流處理。看一下JDK源碼里的字段就一目了然public class InputStreamReader extends Reader { private final CharsetDecoder decoder; private final InputStream in; // ... } public class OutputStreamWriter extends Writer { private final CharsetEncoder encoder; private final OutputStream out; // ... }轉換流的核心價值在于它讓你在寫業(yè)務代碼時不需要關心底層字節(jié)是怎么排列的只需要面對字符。你讀到的是一行一行或一個字符一個字符的字符串寫出的也是字符串編碼轉換細節(jié)全部被封裝在內部了。這個過程和“翻譯官”很像。兩個不同語言的人要對話翻譯官先把甲的話翻譯成乙的語言乙回答后再翻譯回去。翻譯官決定了兩端能不能順利溝通而轉換流里的Charset就是雙方共同認定的“翻譯規(guī)則”兩端必須約定同一套字符編碼翻譯才準確不然就是雞同鴨講。3.2 Charset 與 CharsetDecoder/CharsetEncoder 的關系Charset是字符集的抽象類常見實現(xiàn)有UTF-8、UTF-16、GBK、ISO-8859-1、US-ASCII等。每個Charset對象都提供了兩個關鍵方法newDecoder()返回一個CharsetDecodernewEncoder()返回一個CharsetEncoder。解碼和編碼的具體工作實際上是由Decoder和Encoder完成的。以讀取為例當你調用InputStreamReader.read()時它會從底層的InputStream里拿到一批字節(jié)然后交給CharsetDecoder去解析。CharsetDecoder內部維護了狀態(tài)對于UTF-8這種變長編碼它會持續(xù)緩沖字節(jié)直到能夠組成完整的字符才返回一個char。如果遇到非法字節(jié)序列還會觸發(fā)CodingErrorAction默認是REPLACE也就是替換成\uFFFD這個特殊字符。這就是為什么有些文件讀出來會出現(xiàn)一些奇怪的問號或替換符。寫方向也是同樣道理。OutputStreamWriter.write()內部拿到char數(shù)組后會根據(jù)當前字符集把這些字符編碼成字節(jié)再通過底層的OutputStream寫出。這個過程同樣有編碼錯誤處理如果某個字符在當前字符集里不存在比如把中文寫進ISO-8859-1編碼的流里默認會替換成問號。還有一點要特別注意InputStreamReader和OutputStreamWriter的默認構造函數(shù)會使用Charset.defaultCharset()也就是JVM啟動時根據(jù)file.encoding系統(tǒng)屬性確定的字符集。這個默認值不跨平臺也不跨啟動方式。所以在生產(chǎn)環(huán)境里我基本不會用無參構造方法而是一定顯式傳入字符集。這個習慣幫我擋掉了很多后續(xù)的麻煩。3.3 標準轉換模板與包裝順序下面給出一個常見的轉換組合方式。模板雖然簡單但包裝順序很關鍵我拆開來說一下。讀取方向InputStream in new FileInputStream(source.txt); Reader reader new InputStreamReader(in, StandardCharsets.UTF_8); BufferedReader bufferedReader new BufferedReader(reader); String line; while ((line bufferedReader.readLine()) ! null) { System.out.println(line); }寫入方向OutputStream out new FileOutputStream(target.txt); Writer writer new OutputStreamWriter(out, StandardCharsets.UTF_8); BufferedWriter bufferedWriter new BufferedWriter(writer); bufferedWriter.write(你好Java); bufferedWriter.newLine(); bufferedWriter.write(第二行);包裝順序是字節(jié)流在外層還是內層以讀取為例最內側必須是字節(jié)流因為數(shù)據(jù)源頭是字節(jié)中間用InputStreamReader做字節(jié)到字符的轉換最外層再加一層BufferedReader目的是提供readLine()、減少磁盤IO次數(shù)、提高讀寫性能。很多初學者搞不清為什么還要多包一層Buffer。原因很簡單如果直接一個字符一個字符地調read()每調一次就可能觸發(fā)一次操作系統(tǒng)IO開銷非常大。加了BufferedReader之后它會一次性從底層流中讀入一大塊數(shù)據(jù)到內存緩沖區(qū)之后業(yè)務代碼在緩沖區(qū)里取字符速度要快很多。寫方向同理BufferedWriter會先攢一批字符再一次性刷到底層。還有一個很多人忽略的點FileReader和FileWriter其實并不是一個獨立的新流實現(xiàn)。翻源碼就能看到FileReader不過是InputStreamReader的一個簡化子類讀文件時自動構造一個FileInputStream但字符集用的是JVM默認值。FileWriter對應OutputStreamWriter。所以你在用FileReader時等于隱式地做了一次轉換只是沒得選字符集這就是它容易埋雷的原因。4. 從文件讀到內存再寫回一次完整的轉換實操4.1 一個常見的實際場景前陣子有個朋友問我一個問題合作方給了一個CSV文件文件是GBK編碼的里面有幾萬條中文數(shù)據(jù)他們希望轉成UTF-8編碼后再導入數(shù)據(jù)庫因為數(shù)據(jù)庫連接層統(tǒng)一用的是UTF-8直接導GBK文件會亂碼。這個需求本質上就是一次“讀GBK字節(jié)流 - 轉字符 - 轉UTF-8字節(jié)流”的過程。我給他寫的示例大概是這樣public void convertGbkToUtf8(String sourcePath, String targetPath) { try (BufferedReader reader new BufferedReader( new InputStreamReader( new FileInputStream(sourcePath), Charset.forName(GBK))); BufferedWriter writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(targetPath), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 這里可以對每行數(shù)據(jù)做清洗或轉換 writer.write(line); writer.newLine(); } } catch (IOException e) { e.printStackTrace(); } }這段代碼里有兩個字符集參數(shù)讀取側用Charset.forName(GBK)寫入側用StandardCharsets.UTF_8。為什么不能統(tǒng)一因為源文件就是GBK編碼的字節(jié)序列你必須用GBK去解碼才能把字節(jié)正確地映射成字符。而目標文件要求UTF-8編碼所以寫入時用UTF-8重新編碼。這里就牽扯到一個關鍵認知亂碼并不一定是文件壞了而往往是“寫入時用的編碼”和“讀取時用的解碼”不一致。編碼和解碼就是一對密鑰密鑰不匹配內容就會錯亂。我第一次排查亂碼時總以為是文件內容損壞后來才明白文件里的字節(jié)本沒有錯錯的是我用了錯誤的字符集去解釋它。4.2 BufferedReader.readLine與中文邊界問題很多人用BufferedReader.readLine()時很放心覺得按行讀文本永遠不會出問題。但有一個細節(jié)值得想一想當文件里某個中文字符恰好跨越緩沖區(qū)邊界時流內部怎么保證不把一個完整的多字節(jié)字符拆開答案是InputStreamReader內部維護了一個字節(jié)緩沖區(qū)。當它發(fā)現(xiàn)當前累積的字節(jié)還不構成一個完整字符時它會繼續(xù)向底層流讀取更多字節(jié)直到能解碼出一個完整的char再交給上層BufferedReader。這個過程對上層是透明的所以readLine()不會在字符中間截斷。但這也就意味著如果原始文件里有非法字節(jié)序列CharsetDecoder在REPLACE模式下會把它們各自替換為\uFFFD再返回給你。處理返回的字符串時要注意可能出現(xiàn)的替換符。另外要提醒BufferedReader.readLine()會丟棄行尾的換行符所以在寫回文件時一定要記得補newLine()就像上面示例代碼里那樣。4.3 小文件場景下更直接的轉換方式如果文件不大也可以用Files工具類配合String做一步到位轉換這種方式代碼更短也更適合快速驗證byte[] gbkBytes Files.readAllBytes(Paths.get(sourcePath)); String content new String(gbkBytes, Charset.forName(GBK)); byte[] utf8Bytes content.getBytes(StandardCharsets.UTF_8); Files.write(Paths.get(targetPath), utf8Bytes);new String(byte[], charset)完成的是解碼String.getBytes(charset)完成的是編碼本質上和轉換流的邏輯完全一致。這段代碼唯一的問題是一次性把整個文件讀入內存如果文件非常大比如幾個GB內存會扛不住。所以大文件必須走流式逐行處理小批量可以這么寫。Files.readAllBytes還有個潛在問題如果文件帶BOM讀出來的字符串開頭會多一個\uFEFF字符。導入數(shù)據(jù)庫或寫回文件時這個字符可能會引發(fā)后續(xù)處理異常。遇到帶BOM的UTF-8文件時建議先做一次剝離if (content.startsWith(\uFEFF)) { content content.substring(1); }4.4 流的關閉順序為什么要分內外再聊一個高頻面試題多個流嵌套時關閉順序是怎么樣的關鍵原則是“先關外層再關內層”。外層是后包裝的流內層是更原始的流。比如BufferedReader - InputStreamReader - FileInputStream這個鏈條最外層是BufferedReader。關閉它的時候它會把內部持有的InputStreamReader也關閉而InputStreamReader關閉時又會把FileInputStream關閉。所以實際業(yè)務代碼里你只需要關最外層的流整個鏈條的資源就都會被釋放。如果非要從內層往外層手動關反而會出現(xiàn)問題比如先關FileInputStream再調用外層流的read()就會拋IOException因為底層的字節(jié)輸入源頭已經(jīng)斷了。在Java 7之后最好的做法其實是try-with-resources上面示例代碼就是這個寫法。它會保證在多資源對象按逆序自動關閉不需要自己在finally里寫一堆close()判斷。我建議項目中新寫的代碼一律用try-with-resources既簡潔又不容易泄漏資源。5. 亂碼排查與避坑實錄5.1 常見亂碼問題速查表整理一下我在工作中最常遇到的亂碼現(xiàn)象、原因和對策這張表可以收藏備用現(xiàn)象可能原因解決思路文件打開是亂碼“錕斤拷”、“燙燙燙”讀取用的字符集和文件實際編碼不一致先確認文件真實編碼再改用正確字符集解碼中文字符變成“??”寫入字符集不支持中文常見于ISO-8859-1寫入流顯式指定UTF-8或GBK中文變成“???”等怪符號UTF-8字節(jié)被當成GBK或Latin-1解碼統(tǒng)一讀取和寫入的字符集控制臺亂碼但文件本身正常IDE或控制臺默認編碼問題檢查IDE文件編碼、控制臺輸出編碼設置文件開頭出現(xiàn)單獨亂碼字符UTF-8文件帶BOM且代碼沒處理解碼后剝離\uFEFF某些字符變成“”替換符字節(jié)序列非法Decoder默認替換用正確編碼讀取或用CodingErrorAction.REPORT定位5.2 排查亂碼的正確思路排查亂碼的第一原則不要靠猜先確認文件真實編碼。在Windows下可以用Notepad或VS Code打開文件看右下角編碼提示在Linux下用file -i命令file -i input.csv輸出結果類似text/plain; charsetgbk或text/plain; charsetutf-8。如果拿不到類似信息可以用十六進制工具查看文件頭或者某段文本的字節(jié)內容再對照字符集的編碼規(guī)則判斷。比如UTF-8編碼的“你”在十六進制下是E4 BD A0GBK編碼下是C4 E3。確認源文件編碼之后再看JVM啟動時的默認編碼??梢詫懸恍写a打印System.out.println(Charset.defaultCharset());這一步能幫你判斷是不是代碼沒顯式指定字符集而讓JVM默認值去代勞了。很多時候排查到這一步就會發(fā)現(xiàn)問題所在。5.3 我踩過的幾次坑與調整記錄第一個坑是參數(shù)傳遞導致的編碼丟失。早期項目里有人把從接口拿到的文件名直接拼到路徑里然后用FileInputStream讀文件再丟給一個沒有指定字符集的InputStreamReader。本地開發(fā)Windows默認GBK沒問題部署到Linux就亂碼。那次之后我定了個規(guī)矩凡是構造字符流強制要求顯式傳入Charset參數(shù)代碼評審時發(fā)現(xiàn)無參構造直接打回。第二個坑是嵌套流轉發(fā)時的誤編碼。有一次把某個二進制文件上傳到對象存儲先用InputStreamReader包裝了InputStream再轉成字符串保存。結果文件上傳后下載下來完全打不開。問題就在于對象存儲需要的是原始字節(jié)流而我多此一舉把字節(jié)解碼成字符再編碼回字節(jié)中間走了個不需要的Charset轉換內容雖然沒丟但對二進制文件來說完全是多余的。后來我就把這類接口全部改成直接傳InputStream不再做字符轉換。第三個坑和CodingErrorAction有關。默認情況下Decoder遇到非法字節(jié)會把問題字符替換成\uFFFD程序不報錯數(shù)據(jù)悄悄變臟。如果數(shù)據(jù)質量要求高建議自定義解碼策略InputStreamReader reader new InputStreamReader( new FileInputStream(data.txt), Charset.forName(UTF-8).newDecoder() .onMalformedInput(CodingErrorAction.REPORT) );這樣設置之后一旦碰到非法字節(jié)序列讀取過程會直接拋出MalformedInputException而不是默默替換。雖然可能讓程序中斷但至少不會在不知情的情況下把臟數(shù)據(jù)寫進數(shù)據(jù)庫。5.4 給新人的幾個實用建議最后分享幾條我這些年攢下來的經(jīng)驗可能比上面的代碼更值錢第一寫工具類時務必把Charset作為參數(shù)暴露出來不要自己在內部寫死字符集也不要用默認值。這樣同一個工具類既能處理UTF-8也能處理GBK將來換需求不用改代碼。第二讀取文本后如果要做字符串處理盡量一次性完成不要多次反復解碼編碼。每經(jīng)過一次“字節(jié)-字符-字節(jié)”的轉換都可能引入未知風險尤其是遇到特殊字符時。第三如果項目是基于Spring Boot的注意配置文件里的server.servlet.encoding和HTTP請求響應的編碼設置。很多Web項目里的亂碼并不是IO流的問題而是HTTP報文頭里沒有正確聲明Content-Type的charset兩者容易混淆排查時要先分清層次。第四能不用FileReader就不用FileReader。這個類在設計上確實沒有提供指定字符集的構造方法在生產(chǎn)代碼里用它的唯一后果就是讓編碼變得不可控。InputStreamReader多寫幾個字母換來的確定性是完全值得的。字節(jié)流與字符流的轉換表面上就是一個橋接模式的應用真正難的是理解你手上這份數(shù)據(jù)到底是誰、從哪來、要往哪去。編碼一致亂碼四散編碼錯位千奇百怪。把這些原理吃透以后遇到任何亂碼問題排查起來都會快很多。