StackOverflow 周報 – 這些高關注的問題你是否都會
- 2019 年 10 月 3 日
- 筆記
我從 Stack Overflow 上找的了一些高關注度且高贊的問題。這些問題可能平時我們遇不到,但既然是高關注的問題和高點贊的回答說明是被大家普遍認可的,如果我們提前學到了以後不管工作中還是面試中處理起來就會更得心應手。本篇文章是第一周的內容,一共 5 個題目。我每天都會在公眾號發一篇,你如果覺得這個系列對你有價值,歡迎文末關注我的公眾號。
DAY1. 複合運算符中的強制轉換
今天討論的問題是“符合運算符中的強制轉換”。以 += 為例,我編寫了如下程式碼,你可以先考慮下為什麼會出現下面這種情況。
int i = 5; long j = 10; i += j; //正常 i = i+j; //報錯,Incompatible types.
這個問題可以從 “Java 語言手冊” 中找到答案,原文如下:
A compound assignment expression of the form E1 op= E2 is equivalent to E1 = (T) ((E1) op (E2)), where T is the type of E1, except that E1 is evaluated only once.
翻譯一下:形如 E1 op= E2 的複合賦值表達式等價於 E1 = (T)((E1) op (E2)), 其中,T 是 E1 的類型。所以,回到本例,i+j 的結果會強制轉換成 int 再賦值給 i。
其實驗證也比較容易,我們看下編譯後的 .class 文件就知道做了什麼處理。
從 .class 文件可以看出,有兩處強制轉換。第一處是 i+j 時,由於 j 是 long 類型,因此 i 進行類型提升,強轉為 long, 這個過程我們比較熟悉。第二處是我們今天討論的內容,i+j 的結果強轉成了 int 類型。
這裡面我們還可以在進一步思考,因為在這個例子中強轉可能會導致計算結果溢出,那你可以想想為什麼 Java 設計的時候不讓它報錯呢?
我的猜想是這樣的,假設遇到這種情況報錯,我們看看會有什麼樣的後果。比如在 byte 或者 short 類型中使用 += 運算符。
byte b = 1; b += 1;
按照我們的假設,這裡就會報錯,因為 i+1 返回的 int 類型。然而實際應用場景中這種程式碼很常見,因此,假設成立的話,將會嚴重影響複合賦值運算符的應用範圍,最終設計出來可能就是一個比較雞肋的東西。所以,為了普適性只能把判斷交給用戶,讓用戶來保障使用複合賦值運算符不會發生溢出。我們平時應用時一定要注意這個潛在的風險。
DAY2. 生成隨機數你用對了嗎
在 Java 中如何生成一個隨機數?如果你的答案是 Random 類,那就有必要繼續向下看了。Java 7 之前使用 Random 類生成隨機數,Java 7 之後的標準做法是使用 ThreadLocalRandom 類,程式碼如下:
ThreadLocalRandom.current().nextInt();
既然 Java 7 要引入一個新的類取代之前的 Random 類,說明之前生成隨機數的方式存在一定的問題,下面就結合源碼簡單介紹一下這兩個類的區別。
Random 類是執行緒安全的,如果多執行緒同時使用一個 Random 實例生成隨機數,那麼就會共享同一個隨機種子,從而存在並發問題導致性能下降,下面看看 next(int bits) 方法的源碼:
protected int next(int bits) { long oldseed, nextseed; AtomicLong seed = this.seed; do { oldseed = seed.get(); nextseed = (oldseed * multiplier + addend) & mask; } while (!seed.compareAndSet(oldseed, nextseed)); return (int)(nextseed >>> (48 - bits)); }
看到程式碼並不複雜,其中,隨機種子 seed 是 AtomicLong 類型的,並且使用 CAS 方式更新種子。
接下來再看看 ThreadLocalRandom 類,多執行緒調用 ThreadLocalRandom.current() 返回的是同一個 ThreadLocalRandom 實例,但它並不存在多執行緒同步的問題。看下它更新種子的程式碼:
final long nextSeed() { Thread t; long r; // read and update per-thread seed UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA); return r; }
可以看到,這裡面不存在執行緒同步的程式碼。猜測程式碼中使用了Thread.currentThread() 達到了 ThreadLocal 的目的,因此不存在執行緒安全的問題。使用 ThreadLocalRandom 還有個好處是不需要自己 new 對象,使用起來更方便。如果你的項目是 Java 7+ 並且仍在使用 Random 生成隨機數,那麼建議你切換成 ThreadLocalRandom。由於它繼承了 Random 類,因此不會對你現有的程式碼造成很大的影響。
DAY3. InputStream轉String有多少種方法
Java 中如果要將 InputStream 轉成 String,你能想到多少種方法?
String str = "測試"; InputStream inputStream = new ByteArrayInputStream(str.getBytes());
1. 使用 ByteArrayOutputStream 循環讀取
/** 1. 使用 ByteArrayOutputStream 循環讀取 */ BufferedInputStream bis = new BufferedInputStream(inputStream); ByteArrayOutputStream buf = new ByteArrayOutputStream(); int tmpRes = bis.read(); while(tmpRes != -1) { buf.write((byte) tmpRes); tmpRes = bis.read(); } System.out.println(buf.toString());
2. 使用 InputStreamReader 批量讀取
/** 2. 使用 InputStreamReader 批量讀取 */ final char[] buffer = new char[1024]; final StringBuilder out = new StringBuilder(); Reader in = new InputStreamReader(inputStream); for (; ; ) { int rsz = in.read(buffer, 0, buffer.length); if (rsz < 0) { break; } out.append(buffer, 0, rsz); } System.out.println(out.toString());
3. 使用 JDK Scanner
/** 3. 使用 JDK Scanner */ Scanner s = new Scanner(inputStream).useDelimiter("\A"); String result = s.hasNext() ? s.next() : ""; System.out.println(result);
4. 使用 Java 8 Stream API
/** 4. 使用 Java 8 Stream API */ result = new BufferedReader(new InputStreamReader(inputStream)) .lines().collect(Collectors.joining("n")); System.out.println(result);
5. 使用 IOUtils StringWriter
/** 5. 使用 IOUtils StringWriter */ StringWriter stringWriter = new StringWriter(); IOUtils.copy(inputStream, stringWriter); System.out.println(stringWriter.toString());
6. 使用 IOUtils.toString 一步到位
/** 6. 使用 IOUtils.toString 一步到位 */ System.out.println(IOUtils.toString(inputStream));
這裡我們用了 6 種方式實現,實際還會有更多的方法。簡單總結一下這幾個方法。
第一種和第二種方法使用原始的循環讀取,程式碼量比較大。第三和第四種方法使用了 JDK 封裝好的 API 可以明顯減少程式碼量, 同時 Stream API 可以讓我們將程式碼寫成一行,更方便書寫。最後使用 IOUtils 工具類(commons-io 庫), 聽名字就知道是專門做 IO 用的,它也提供了兩種方式,第五種框架提供了更加開放,靈活的方式叫做 copy 方法,也就是說除了 copy 到 String 還可以 copy 到其他地方。第六種就完全的訂製化,就是專門用來轉 String 的,當然訂製化的結果就是不靈活,但對於單純轉 String 這個需求來說卻是最方便、最省事的。其實我們平時編程也是一樣,對於一個產品需求有時候不需要暴露太多的開放性的選擇,針對需求提供一個簡單粗暴的實現方式也許是最佳選擇。
最後補充一句,我們平時可以多關注框架,用到的時候直接拿過來省時省力,減少程式碼量。當然有興趣的話我們也可以深入學習框架內部的設計和實現。
DAY4. 面試官:寫個記憶體泄漏的例子
我們都是知道 Java 自帶垃圾回收機制,記憶體泄漏這事好像跟 Java 程式設計師關係不大。所以,寫 Java 程式一般會比 C/C++ 程式輕鬆一些。記得前領導寫 C++ 程式碼時說過一句話,“寫 C++ 程式一定會漏的,只不過是能不能被發現而已”。所以看來 C/C++ 程式設計師還是比較苦逼的,雖然他們經常鄙視 Java 程式設計師,哈哈~~。
儘管 Java 程式出現出現記憶體泄漏的可能性較少,但不代表不會出現。如果你哪天去面試,面試官讓你用 Java 寫一個記憶體泄漏的例子,你有思路嗎?下面我就舉一個記憶體泄漏的例子。
public final class ClassLoaderLeakExample { static volatile boolean running = true; /** * 1. main 函數,邏輯比較簡單只是創建一個 LongRunningThread 執行緒,並接受停止的指令 */ public static void main(String[] args) throws Exception { Thread thread = new LongRunningThread(); try { thread.start(); System.out.println("Running, press any key to stop."); System.in.read(); } finally { running = false; thread.join(); } } /** * 2. 定義 LongRunningThread 執行緒,該執行緒做的事情比較簡單,每隔 100ms 調用 loadAndDiscard 方法 */ static final class LongRunningThread extends Thread { @Override public void run() { while(running) { try { loadAndDiscard(); } catch (Throwable ex) { ex.printStackTrace(); } try { Thread.sleep(100); } catch (InterruptedException ex) { System.out.println("Caught InterruptedException, shutting down."); running = false; } } } } /** * 3. 定義一個 class loader - ChildOnlyClassLoader,它在我們的例子中至關重要。 * ChildOnlyClassLoader 專門用來裝載 LoadedInChildClassLoader 類, * 邏輯比較簡單,讀取 LoadedInChildClassLoader 類的 .class 文件,返回類對象。 */ static final class ChildOnlyClassLoader extends ClassLoader { ChildOnlyClassLoader() { super(ClassLoaderLeakExample.class.getClassLoader()); } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { if (!LoadedInChildClassLoader.class.getName().equals(name)) { return super.loadClass(name, resolve); } try { Path path = Paths.get(LoadedInChildClassLoader.class.getName() + ".class"); byte[] classBytes = Files.readAllBytes(path); Class<?> c = defineClass(name, classBytes, 0, classBytes.length); if (resolve) { resolveClass(c); } return c; } catch (IOException ex) { throw new ClassNotFoundException("Could not load " + name, ex); } } } /** * 4. 編寫 loadAndDiscard 方法的程式碼,也就是在 LongRunningThread 執行緒中被調用的方法。 * 該方法創建 ChildOnlyClassLoader 對象,用來裝載 LoadedInChildClassLoader 類,將結果賦值給 childClass 變數, * childClass 調用 newInstance 方法來創建 LoadedInChildClassLoader 對象。 * 每次調用 loadAndDiscard 方法,都會載入一次 LoadedInChildClassLoader 類並創建其對象。 */ static void loadAndDiscard() throws Exception { ClassLoader childClassLoader = new ChildOnlyClassLoader(); Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName(), true, childClassLoader); childClass.newInstance(); } /** * 5. 定義 LoadedInChildClassLoader 類 * 該類中定義了一個 moreBytesToLeak 位元組數組,初始大小比較大是為了儘快模擬出記憶體泄漏的結果。 * 在類的構造方法調用 threadLocal 的 set 方法存儲對象本身的引用。 */ public static final class LoadedInChildClassLoader { static final byte[] moreBytesToLeak = new byte[1024 * 1024 * 10]; private static final ThreadLocal<LoadedInChildClassLoader> threadLocal = new ThreadLocal<>(); public LoadedInChildClassLoader() { threadLocal.set(this); } } }
這是完整的例子, 可以按照注釋中的序號的順序閱讀程式碼。最後運行程式碼,在 ClassLoaderLeakExample 類所在的目錄下執行以下命令
javac ClassLoaderLeakExample.java java -cp . ClassLoaderLeakExample
運行後會列印 “Running, press any key to stop.” 等一分鐘左右就會報記憶體不足的錯誤 “java.lang.OutOfMemoryError: Java heap space” 。
簡單梳理一下邏輯,loadAndDiscard 方法會不斷地被調用,每次被調用在該方法中都會載入一次 LoadedInChildClassLoader 類,每載入一次類就會創建一個新的threadLocal 和 moreBytesToLeak 屬性。雖然創建的 LoadedInChildClassLoader 對象是局部變數,但退出 loadAndDiscard 方法後該對象仍然不會被回收,因為 threadLocal 保存了該對象的引用,對象保存了對類的引用,而類保存了對類載入器的引用,類載入器反過來保存對它已載入的類的引用。因此雖然退出 loadAndDiscard 方法,該對象對我們不可見了,但是它永遠不會被回收。隨著每次載入的類越來越多,創建的 moreBytesToLeak 越來越多並且記憶體得不到清理,會導致 OutOfMemory 錯誤。
為了對比你可以去掉自定義類載入器這個參數,loadAndDiscard 方法中的程式碼修改如下:
Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName(), true, childClassLoader); //改為: Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName());
再運行就不會出現 OOM 的錯誤。修改之後,無論 loadAndDiscard 方法被調用多少次都只會載入一次 LoadedInChildClassLoader 類,也就是說只有一個 threadLocal 和 moreBytesToLeak 屬性。當再次創建 LoadedInChildClassLoader 對象時,threadLocal 會設置成當前的對象,之前 set 的對象就沒有任何變數引用它,因此之前的對象會被回收。
DAY5. 為什麼密碼用 char[] 存儲而不用String
周五,放鬆一下。一起來看一個無需寫程式碼的問題“為什麼 Java 程式中用 char[] 保存密碼而不用 String”。既然提到密碼,我們用腳指頭想想也知道肯定是出於安全性的考慮。具體的是為什麼呢?我這裡提供兩點答案供你參考。
先說第一點,也是最重要的一點。String 存儲的字元串是不可變的,也就是說用它存儲密碼後,這塊記憶體是無法被人為改變的。並且只能等 GC 將其清除。如果有其他進程惡意將記憶體 dump 下來,就可能會造成密碼泄露。
然而使用 char[] 存儲密碼對我們來說就是可控的,我們可以在任何時候將 char[] 的內容設置為空或者其他無意義的字元,從而保證密碼不會長期駐留記憶體。相對使用 String 存儲密碼來說更加安全。
再說說第二點,假設我們在程式中無意地將密碼列印到日誌中了。如果使用 String 存儲密碼將會被明文輸出,而使用 char[] 存儲密碼只會輸出地址不會泄露密碼。
這兩點都是從安全性的角度出發。
第一點更側重防止密碼駐留記憶體不安全,第二點則側重防止密碼駐留外存。雖然第二點發生的概率比較低,但也給了我們一個新的視角。
以上便是 Stack Overflow 的第一周周報,希望對你有用,後續會繼續更新,如果想看日更內容歡迎關注公眾號。
歡迎關注公眾號「渡碼」,分享更多高品質內容