面對上面的問題,你該怎麼回答?
典型回答
int 是我們常說的整形數字,是 Java 的 8 個原始數據類型(Primitive Types,boolean、byte、short、char、int、float、double、long)之一。Java 語言雖然號稱一切都是對象,但原始數據類型是例外。
Integer 是 int 對應的包裝類,它有一個 int 類型的字段存儲數據,並且提供了基本操作,比如數學運算、int 和字符串之間轉換等。在 Java 5 中,引入了自動裝箱和自動拆箱功能(boxing/unboxing),Java 可以根據上下文,自動進行轉換,極大地簡化了相關編程。
關於 Integer 的值緩存,這涉及 Java 5 中另一個改進。構建 Integer 對象的傳統方式是直接調用構造器,直接 new 一個對象。但是根據實踐,我們發現大部分數據操作都是集中在有限的、較小的數值範圍,因而,在 Java 5 中新增了靜態工廠方法 valueOf,在調用它的時候會利用一個緩存機制,帶來了明顯的性能改進。按照 Javadoc,這個值默認緩存是 -128 到 127 之間。
這個問題涵蓋了 Java 裏的兩個基礎要素:原始數據類型、包裝類。談到這裏,就可以非常自然地擴展到自動裝箱、自動拆箱機制,進而考察封裝類的一些設計和實踐。坦白說,理解基本原理和用法已經足夠日常工作需求了,但是要落實到具體場景,還是有很多問題需要仔細思考才能確定。
我們可以結合其他方面,來考察面試者的知識掌握程度和思考邏輯,比如:
- 自動裝箱 / 自動拆箱是發生在什麼階段?編譯階段、運行時?
- 使用靜態工廠方法 valueOf 會使用到緩存機制,那麼自動裝箱的時候,緩存機制起作用嗎?
- 爲什麼我們需要原始數據類型,Java 的對象似乎也很高效,應用中具體會產生哪些差異?
- 閱讀過 Integer 源碼嗎?分析下類或某些方法的設計要點。
似乎有太多內容可以探討,我們一起來分析一下。
知識擴展
1. 理解自動裝箱、拆箱自動裝箱實際上算是一種語法糖。
什麼是語法糖?可以簡單理解爲 Java 平臺爲我們自動進行了一些轉換,保證不同的寫法在運行時等價,它們發生在編譯階段,也就是生成的字節碼是一致的。
像前面提到的整數,javac 替我們自動把裝箱轉換爲 Integer.valueOf(),把拆箱替換爲Integer.intValue(),這似乎這也順道回答了另一個問題,既然調用的是 Integer.valueOf,自然能夠得到緩存的好處啊。
如何程序化的驗證上面的結論呢?
你可以寫一段簡單的程序包含下面兩句代碼,然後反編譯一下。當然,這是一種從表現倒推的方法,大多數情況下,我們還是直接參考規範文檔會更加可靠,畢竟軟件承諾的是遵循規範,而不是保持當前行爲。
Integer integer = 1; int unboxing = integer ++; |
反編譯輸出:
1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 8: invokevirtual #3 // Method java/lang/Integer.intValue:()I |
這種緩存機制並不是只有 Integer 纔有,同樣存在於其他的一些包裝類,比如:
- Boolean,緩存了 true/false 對應實例,確切說,只會返回兩個常量實例Boolean.TRUE/FALSE。
- Short,同樣是緩存了 -128 到 127 之間的數值。
- Byte,數值有限,所以全部都被緩存(-128 到 127 )。
- Character,緩存範圍 '\u0000' 到 '\u007F'。
- Long只緩存了 -128 到 127 之間的數值。
自動裝箱 / 自動拆箱似乎很酷,在編程實踐中,有什麼需要注意的嗎?
原則上,建議避免無意中的裝箱、拆箱行爲,尤其是在性能敏感的場合,創建 10 萬個 Java 對象和 10 萬個整數的開銷可不是一個數量級的,不管是內存使用還是處理速度,光是對象頭的空間佔用就已經是數量級的差距了。
我們其實可以把這個觀點擴展開,使用原始數據類型、數組甚至本地代碼實現等,在性能極度敏感的場景往往具有比較大的優勢,用其替換掉包裝類、動態數組(如 ArrayList)等可以作爲性能優化的備選項。一些追求極致性能的產品或者類庫,會極力避免創建過多對象。當然,在大多數產品代碼裏,並沒有必要這麼做,還是以開發效率優先。以我們經常會使用到的計數器實現爲例,下面是一個常見的線程安全計數器實現。
class Counter { private final AtomicLong counter = new AtomicLong(); public void increase() { counter.incrementAndGet(); } } |
如果利用原始數據類型,可以將其修改爲
class CompactCounter { private volatile long counter; private static final AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class,"counter"); public void increase() { updater.incrementAndGet(this); } } |
2. 源碼分析
考察是否閱讀過、是否理解 JDK 源代碼可能是部分面試官的關注點,這並不完全是一種苛刻要求,閱讀並實踐高質量代碼也是程序員成長的必經之路,下面我來分析下 Integer 的源碼。整體看一下 Integer 的職責,它主要包括各種基礎的常量,比如最大值、最小值、位數等;前面提到的各種靜態工廠方法 valueOf();獲取環境變量數值的方法;各種轉換方法,比如轉換爲不同進制的字符串,如 8 進制,或者反過來的解析方法等。我們進一步來看一些有意思的地方。首先,繼續深挖緩存,Integer 的緩存範圍雖然默認是 -128 到 127,但是在特別的應用場景,比如我們明確知道應用會頻繁使用更大的數值,這時候應該怎麼辦呢?
緩存上限值實際是可以根據需要調整的,JVM 提供了參數設置:
- XX:AutoBoxCacheMax=N
這些實現,都體現在java.lang.Integer源碼之中,並實現在 IntegerCache 的靜態初始化塊裏。
private static class IntegerCache { static final int low = -128; static final int high; static final Integer cache[]; static { // high value may be configured by property int h = 127; String integerCacheHighPropValue = sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high"); if (integerCacheHighPropValue != null) { try { int i = parseInt(integerCacheHighPropValue); i = Math.max(i, 127); // Maximum array size is Integer.MAX_VALUE h = Math.min(i, Integer.MAX_VALUE - (-low) -1); } catch( NumberFormatException nfe) { // If the property cannot be parsed into an int, ignore it. } } high = h; cache = new Integer[(high - low) + 1]; int j = low; for(int k = 0; k < cache.length; k++) cache[k] = new Integer(j++); // range [-128, 127] must be interned (JLS7 5.1.7) assert IntegerCache.high >= 127; } private IntegerCache() {} } .... } |
第二,我們在分析字符串的設計實現時,提到過字符串是不可變的,保證了基本的信息安全和併發編程中的線程安全。如果你去看包裝類裏存儲數值的成員變量“value”,你會發現,不管是Integer 還是Boolean 等其他基本類型封裝類,其value都被聲明爲“private final”,所以,它們同樣是不可變類型!
3. 原始類型線程安全
前面提到了線程安全設計,你有沒有想過,原始數據類型操作是不是線程安全的呢?
這裏可能存在着不同層面的問題:
- 原始數據類型的變量,顯然要使用併發相關手段,才能保證線程安全,這些我會在專欄後面的併發主題詳細介紹。如果有線程安全的計算需要,建議考慮使用類似 AtomicInteger、AtomicLong 這樣的線程安全類。
- 特別的是,部分64位的數據類型,long、double,甚至不能保證寫操作的原子性,可能出現程序讀取到只更新了一半數據位的數值!
“深入java虛擬機”中提到,int等不大於32位的基本類型的操作都是原子操作,但是某些jvm對long和double類型的操作並不是原子操作,這樣就會造成錯誤數據的出現。
錯誤數據出現的原因是:
在32位的虛擬機中,對於long和double變量,把它們作爲2個原子性的32位值來對待,而不是一個原子性的64位值,
這樣將一個long型的值保存到內存的時候,可能是2次32位的寫操作,
2個競爭線程想寫不同的值到內存的時候,可能導致內存中的值是不正確的結果。
1、寫入高位32位值(線程2)
2、寫入高位32位值(線程1)
3、寫入低位32位值(線程1)
4、寫入低位32位值(線程2)
這樣內存中的值變成線程1的高32位值和線程2的低32位值的組合,是個錯誤的值。
書中還提到,上面出現問題的long和double變量是沒有聲明爲volatile的變量。
volatile本身不保證獲取和設置操作的原子性,僅僅保持修改的可見性。
但是java內存模型保證聲明爲volatile的long和double變量的get和set操作是原子的。
jvm spec引用中的最後一段說到,jvm可以很輕易的將變量操作變成原子性的,但是卻受到了當前硬件的約束,因爲流行的微處理器還是32bit居多,因此64bit的變量需要拆分成兩次,但如果是64bit處理器就能滿足64bit變量的原子性操作了。
針對這個問題,找到了網上的一個總結:
- 對於64位的long和double,如果沒有被volatile修飾,那麼對其操作可以不是原子的。在操作的時候,可以分成兩步,每次對32位操作;
- 如果使用volatile修飾long和double,那麼其讀寫都是原子操作;
- 在實現JVM時,可以自由選擇是否把讀寫long和double作爲原子操作;
- java中對於long和double類型的寫操作不是原子操作,而是分成了兩個32位的寫操作。讀操作是否也分成了兩個32位的讀呢?在JSR-133之前的規範中,讀也是分成了兩個32位的讀,但是從JSR-133規範開始,即JDK5開始,讀操作也都具有原子性;
- java中對於其他類型的讀寫操作都是原子操作(除long和double類型以外);
- 對於引用類型的讀寫操作都是原子操作,無論引用類型的實際類型是32位的值還是64位的值;
- 對於long類型的不恰當操作可能讀取到從未出現過的值。而對於int的不恰當操作則可能讀取到舊的值;