我從 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 的第一周周報,希望對你有用,后續會繼續更新,如果想看日更內容歡迎關注公眾號。
歡迎關注公眾號「渡碼」,分享更多高質量內容