如今,Docker 等容器早已不是新生事物,正在逐步成為日常開發、部署環境的一部分。Java 能否無縫地運行在容器環境,是否符合微服務、Serverless 等新的軟件架構和場景,在一定程度上也會影響未來的技術棧選擇。當然,Java 對 Docker 等容器環境的支持也在不斷增強,自然地,Java 在容器場景的實踐也逐漸在面試中被涉及。我希望通過專欄今天這一講,能夠幫你能做到胸有成竹。
今天我要問你的問題是,Java 程序運行在 Docker 等容器環境有哪些新問題?
## 典型回答
對于 Java 來說,Docker 畢竟是一個較新的環境,例如,其內存、CPU 等資源限制是通過 CGroup(Control Group)實現的,早期的 JDK 版本(8u131 之前)并不能識別這些限制,進而會導致一些基礎問題:
* 如果未配置合適的 JVM 堆和元數據區、直接內存等參數,Java 就有可能試圖使用超過容器限制的內存,最終被容器 OOM kill,或者自身發生 OOM。
* 錯誤判斷了可獲取的 CPU 資源,例如,Docker 限制了 CPU 的核數,JVM 就可能設置不合適的 GC 并行線程數等。
從應用打包、發布等角度出發,JDK 自身就比較大,生成的鏡像就更為臃腫,當我們的鏡像非常多的時候,鏡像的存儲等開銷就比較明顯了。
如果考慮到微服務、Serverless 等新的架構和場景,Java 自身的大小、內存占用、啟動速度,都存在一定局限性,因為 Java 早期的優化大多是針對長時間運行的大型服務器端應用。
## 考點分析
今天的問題是個針對特定場景和知識點的問題,我給出的回答簡單總結了目前業界實踐中發現的一些問題。
如果我是面試官,針對這種問題,如果你確實沒有太多 Java 在 Docker 環境的使用經驗,直接說不知道,也算是可以接受的,畢竟沒有人能夠掌握所有知識點嘛。
但我們要清楚,有經驗的面試官,一般不會以純粹偏僻的知識點作為面試考察的目的,更多是考察思考問題的思路和解決問題的方法。所以,如果有基礎的話,可以從操作系統、容器原理、JVM 內部機制、軟件開發實踐等角度,展示系統性分析新問題、新場景的能力。畢竟,變化才是世界永遠的主題,能夠在新變化中找出共性與關鍵,是優秀工程師的必備能力。
今天我會圍繞下面幾個方面展開:
* 面試官可能會進一步問到,有沒有想過為什么類似 Docker 這種容器環境,會有點“欺負”Java?從 JVM 內部機制來說,問題出現在哪里?
* 我注意到有種論調說“沒人在容器環境用 Java”,不去爭論這個觀點正確與否,我會從工程實踐出發,梳理問題原因和相關解決方案,并探討下新場景下的最佳實踐。
## 知識擴展
首先,我們先來搞清楚 Java 在容器環境的局限性來源,**Docker 到底有什么特別**?
雖然看起來 Docker 之類容器和虛擬機非常相似,例如,它也有自己的 shell,能獨立安裝軟件包,運行時與其他容器互不干擾。但是,如果深入分析你會發現,Docker 并不是一種完全的**虛擬化**技術,而更是一種輕量級的**隔離**技術。

上面的示意圖,展示了 Docker 與虛擬機的區別。從技術角度,基于 namespace,Docker 為每個容器提供了單獨的命名空間,對網絡、PID、用戶、IPC 通信、文件系統掛載點等實現了隔離。對于 CPU、內存、磁盤 IO 等計算資源,則是通過 CGroup 進行管理。如果你想了解更多 Docker 的細節,請參考相關[技術文檔](https://medium.freecodecamp.org/a-beginner-friendly-introduction-to-containers-vms-and-docker-79a9e3e119b)。
Docker 僅在類似 Linux 內核之上實現了有限的隔離和虛擬化,并不是像傳統虛擬化軟件那樣,獨立運行一個新的操作系統。如果是虛擬化的操作系統,不管是 Java 還是其他程序,只要調用的是同一個系統 API,都可以透明地獲取所需的信息,基本不需要額外的兼容性改變。
容器雖然省略了虛擬操作系統的開銷,實現了輕量級的目標,但也帶來了額外復雜性,它限制對于應用不是透明的,需要用戶理解 Docker 的新行為。所以,有專家曾經說過,“幸運的是 Docker 沒有完全隱藏底層信息,但是不幸的也是 Docker 沒有隱藏底層信息!”
對于 Java 平臺來說,這些未隱藏的底層信息帶來了很多意外的困難,主要體現在幾個方面:
第一,容器環境對于計算資源的管理方式是全新的,CGroup 作為相對比較新的技術,歷史版本的 Java 顯然并不能自然地理解相應的資源限制。
第二,namespace 對于容器內的應用細節增加了一些微妙的差異,比如 jcmd、jstack 等工具會依賴于“/proc//”下面提供的部分信息,但是 Docker 的設計改變了這部分信息的原有結構,我們需要對原有工具進行[修改](https://bugs.openjdk.java.net/browse/JDK-8179498)以適應這種變化。
**從 JVM 運行機制的角度,為什么這些“溝通障礙”會導致 OOM 等問題呢?**
你可以思考一下,這個問題實際是反映了 JVM 如何根據系統資源(內存、CPU 等)情況,在啟動時設置默認參數。
這就是所謂的[Ergonomics](https://docs.oracle.com/javase/10/gctuning/ergonomics.htm#JSGCT-GUID-DB4CAE94-2041-4A16-90EC-6AE3D91EC1F1)機制,例如:
* JVM 會大概根據檢測到的內存大小,設置最初啟動時的堆大小為系統內存的 1/64;并將堆最大值,設置為系統內存的 1/4。
* 而 JVM 檢測到系統的 CPU 核數,則直接影響到了 Parallel GC 的并行線程數目和 JIT complier 線程數目,甚至是我們應用中 ForkJoinPool 等機制的并行等級。
這些默認參數,是根據通用場景選擇的初始值。但是由于容器環境的差異,Java 的判斷很可能是基于錯誤信息而做出的。這就類似,我以為我住的是整棟別墅,實際上卻只有一個房間是給我住的。
更加嚴重的是,JVM 的一些原有診斷或備用機制也會受到影響。為保證服務的可用性,一種常見的選擇是依賴“-XX:OnOutOfMemoryError”功能,通過調用處理腳本的形式來做一些補救措施,比如自動重啟服務等。但是,這種機制是基于 fork 實現的,當 Java 進程已經過度提交內存時,fork 新的進程往往已經不可能正常運行了。
根據前面的總結,似乎問題非常棘手,那我們在實踐中,**如何解決這些問題呢?**
首先,如果你能夠**升級到最新的 JDK 版本**,這個問題就迎刃而解了。
* 針對這種情況,JDK 9 中引入了一些實驗性的參數,以方便 Docker 和 Java“溝通”,例如針對內存限制,可以使用下面的參數設置:
~~~
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
~~~
注意,這兩個參數是順序敏感的,并且只支持 Linux 環境。而對于 CPU 核心數限定,Java 已經被修正為可以正確理解“–cpuset-cpus”等設置,無需單獨設置參數。
* 如果你可以切換到 JDK 10 或者更新的版本,問題就更加簡單了。Java 對容器(Docker)的支持已經比較完善,默認就會自適應各種資源限制和實現差異。前面提到的實驗性參數“UseCGroupMemoryLimitForHeap”已經被標記為廢棄。
與此同時,新增了參數用以明確指定 CPU 核心的數目。
~~~
-XX:ActiveProcessorCount=N
~~~
如果實踐中發現有問題,也可以使用“-XX:-UseContainerSupport”,關閉 Java 的容器支持特性,這可以作為一種防御性機制,避免新特性破壞原有基礎功能。當然,也歡迎你向 OpenJDK 社區反饋問題。
* 幸運的是,JDK 9 中的實驗性改進已經被移植到 Oracle JDK 8u131 之中,你可以直接下載相應[鏡像](https://store.docker.com/images/oracle-serverjre-8),并配置“UseCGroupMemoryLimitForHeap”,后續很有可能還會進一步將 JDK 10 中相關的增強,應用到 JDK 8 最新的更新中。
但是,如果我暫時只能使用老版本的 JDK 怎么辦?
我這里有幾個建議:
* 明確設置堆、元數據區等內存區域大小,保證 Java 進程的總大小可控。
例如,我們可能在環境中,這樣限制容器內存:
~~~
$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk
~~~
那么,就可以額外配置下面的環境變量,直接指定 JVM 堆大小。
~~~
-e JAVA_OPTIONS='-Xmx300m'
~~~
* 明確配置 GC 和 JIT 并行線程數目,以避免二者占用過多計算資源。
~~~
-XX:ParallelGCThreads
-XX:CICompilerCount
~~~
除了我前面介紹的 OOM 等問題,在很多場景中還發現 Java 在 Docker 環境中,似乎會意外使用 Swap。具體原因待查,但很有可能也是因為 Ergonomics 機制失效導致的,我建議配置下面參數,明確告知 JVM 系統內存限額。
~~~
-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`
~~~
也可以指定 Docker 運行參數,例如:
~~~
--memory-swappiness=0
~~~
這是受操作系統[Swappiness](https://en.wikipedia.org/wiki/Swappiness)機制影響,當內存消耗達到一定門限,操作系統會試圖將不活躍的進程換出(Swap out),上面的參數有顯式關閉 Swap 的作用。所以可以看到,Java 在 Docker 中的使用,從操作系統、內核到 JVM 自身機制,需要綜合運用我們所掌握的知識。
回顧我在專欄第 25 講 JVM 內存區域的介紹,JVM 內存消耗遠不止包括堆,很多時候僅僅設置 Xmx 是不夠的,MaxRAM 也有助于 JVM 合理分配其他內存區域。如果應用需要設置更多 Java 啟動參數,但又不確定什么數值合理,可以試試一些社區提供的[工具](https://github.com/cloudfoundry/java-buildpack-memory-calculator),但要注意通用工具的局限性。
更進一步來說,對于容器鏡像大小的問題,如果你使用的是 JDK 9 以后的版本,完全可以使用 jlink 工具定制最小依賴的 Java 運行環境,將 JDK 裁剪為幾十 M 的大小,這樣運行起來并不困難。
今天我從 Docker 環境中 Java 可能出現的問題開始,分析了為什么容器環境對應用并不透明,以及這種偏差干擾了 JVM 的相關機制。最后,我從實踐出發,介紹了主要問題的解決思路,希望對你在實際開發時有所幫助。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?今天的思考題是,針對我提到的微服務和 Serverless 等場景 Java 表現出的不足,有哪些方法可以改善 Java 的表現?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?