面試官:小伙子,你給我講一下java類加載機制和內存模型吧


類加載機制

虛擬機把描述類的數據從 Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的java類型,這就是虛擬機的類加載機制。

類的生命周期

加載(Loading)
驗證(Verification)
准備(Preparation)
解析(Resolution)
初始化(Initialization)
使用(Using)
卸載(Unloading)

類加載的過程

類的加載過程包括了加載,驗證,准備,解析,初始化
類的加載主要分為以下三步:

1. 加載:根據路徑找到對應的.class文件

這一步會使用到類加載器。
加載是類加載的一個階段,注意不要混淆。

加載過程完成以下三件事:

通過類的完全限定名稱獲取定義該類的二進制字節流。
將該字節流表示的靜態存儲結構轉換為方法區的運行時存儲結構。
在內存中生成一個代表該類的 Class對象,作為方法區中該類各種數據的訪問入口。

2. 連接:

驗證:檢查待加載的class正確性;
准備:給類的靜態變量分配空間,此時靜態變量還是零值(還沒到初始化的階段)
解析:將常量池的符號引用轉為直接引用
符號引用:
符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時可以無歧義的定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經加載到內存中。
直接引用:
直接引用可以是直接指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局相關的,同一符號引用在不同虛擬機實例上翻譯出來的直接引用一半不會相同,如果有了直接引用,那引用目標必定已經在內存中存在。
注意:實例變量不會在這階段分配內存,它會在對象實例化時隨着對象一起被分配在堆中。應該注意到,實例化不是類加載的一個過程,類加載發生在所有實例化操作之前,並且類加載只進行一次,實例化可以進行多次。

3. 初始化:對靜態變量和靜態代碼塊執行初始化工作

初始化階段才真正開始執行類中定義的 Java 程序代碼。初始化階段是虛擬機執行類構造器 () 方法的過程。在准備階段,類變量已經賦過一次系統要求的初始值,而在初始化階段,根據程序員通過程序制定的主觀計划去初始化類變量和其它資源。

總結

在Java中,類裝載器把一個類裝入Java虛擬機中,要經過三個步驟來完成:加載、連接和初始化,其中鏈接又可以分成校驗、准備和解析三 步,除了解析外,其它步驟是嚴格按照順序完成的,各個步驟的主要工作如下:

裝載:查找和導入類或接口的二進制數據;
鏈接:執行下面的校驗、准備和解析步驟,其中解析步驟是可以選擇的;
校驗:檢查導入類或接口的二進制數據的正確性;
准備:給類的靜態變量分配並初始化存儲空間;
解析:將符號引用轉成直接引用
初始化:激活類的靜態變量的初始化Java代碼和靜態Java代碼塊

類初始化的時機

創建類的實例。new,反射,反序列化
使用某類的類方法–靜態方法
訪問某類的類變量,或賦值類變量
反射創建某類或接口的Class對象。Class.forName(“Hello”);—注意:loadClass調用ClassLoader.loadClass(name,false)方法,沒有link,自然沒有initialize
初始化某類的子類
直接使用java.exe來運行某個主類。即cmd java 程序會先初始化該類。

類的加載器(ClassLoader)

類加載器雖然只用於實現類的加載動作,但是還起到判別兩個類是否相同的作用。
對於任何一個類,都需要由加載它的類加載器和這個類本身一同確立其在java虛擬機中的唯一性。
一個java程序由若干個.class文件組成,當程序在運行時,會調用該程序的一個入口函數來調用系統的相關功能,而這些功能都被封裝在不同的class文件中。

程序在啟動時,並不會一次性加載程序所要用的所有class文件,而是根據程序的需要,通過java的類加載機制來動態加載某個.class文件到內存當中,從而只有class文件被載入到了內存之后,才能被其他class引用,所以類的加載器就是用來動態加載class文件到內存當中用的。

類加載器如何判斷是同樣的類

java中一個類用 全限定類名標識——包名+類名
jvm中一個類用其 全限定類名+加載器標識——包名+類名+加載器名

類加載器的種類

從虛擬機的角度來分:
一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現(HotSpot虛擬機中),是虛擬機自身的一部分;
另一種就是所有其他的類加載器,這些類加載器都有Java語言實現,獨立於虛擬機外部,並且全部繼承自java.lang.ClassLoader。
從開發者角度來分:
啟動(Bootstrap)類加載器:負責將Java_Home/lib下面的類庫加載到內存中(比如rt.jar)。由於引導類加載器涉及到虛擬機本地實現細節,開發者無法直接獲取到啟動類加載器的引用,所以==不允許直接通過引用進行操作。==加載java核心類

擴展(Extension)類加載器:它負責將Java_Home /lib/ext或者由系統變量 java.ext.dir指定位置中的類庫加載到內存中。開發者可以直接使用標准擴展類加載器。

應用程序(Application)類加載器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般稱為系統類加載器。它負責加載用戶類路徑(CLASSPATH)中指定的類庫。開發者可以直接使用系統類加載器。默認使用

雙親機制

這里的類加載器不是以繼承的關系來實現,都是以組合關系復用父類加載器的代碼。

定義:
某個特定的類加載器在接到加載類的請求時,首先將加載任務委托給父類加載器,依次遞歸,如果父類加載器可以完成類加載任務,就成功返回;只有父類加載器無法完成此加載任務時,才自己去加載。

使用雙親委派機制好處在於java類隨着它的類加載器一起具備了一種帶有優先級的層次關系。

具體的,首先由最頂層的類加載器Bootstrap ClassLoader試圖加載,如果沒加載到,則把任務轉交給Extension ClassLoader試圖加載,如果也沒加載到,則轉交給App ClassLoader 進行加載,如果它也沒有加載得到的話,則返回給委托的發起者,由它到指定的文件系統或網絡等URL中加載該類。

如果它們都沒有加載到這個類時,則拋出ClassNotFoundException異常。否則將這個找到的類生成一個類的定義,並將它加載到內存當中,最后返回這個類在內存中的Class實例對象。

為什么要使用雙親委托這種模型?

雙親委托機制可以避免重復加載,當父親已經加載了該類的時候,就沒有必要子ClassLoader再加載一次。

考慮到安全因素,我們試想一下,如果不使用這種委托模式,那我們就可以隨時使用自定義的String來動態替代java核心api中定義的類型,這樣會存在非常大的安全隱患,而雙親委托的方式,就可以避免這種情況,因為String已經在啟動時就被引導類加載器(Bootstrcp ClassLoader)加載,所以用戶自定義的ClassLoader永遠也無法加載一個自己寫的String,除非你改變JDK中ClassLoader搜索類的默認算法。

JVM在搜索類的時候,又是如何判定兩個class是相同的呢

JVM在判定兩個class是否相同時,不僅要判斷兩個類名是否相同,而且要判斷是否由同一個類加載器實例加載的。只有兩者同時滿足的情況下,JVM才認為這兩個class是相同的。就算兩個class是同一份class字節碼,如果被兩個不同的ClassLoader實例所加載,JVM也會認為它們是兩個不同class。

JAVA內存模型JMM

Java虛擬機規范試圖定義一種Java內存模型(JMM),來屏蔽掉各種硬件和操作系統的內存訪問差異,讓Java程序在各種平台上都能達到一致的內存訪問效果。內存模型的作用就是控制一個線程的變量,什么時候對其他線程可見。

Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在JVM中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量與Java編程里面的變量有所不同步,它包含了實例字段、靜態字段和構成數組對象的元素,但不包含局部變量和方法參數,因為后者是線程私有的,不會共享,當然不存在數據競爭問題。

JMM規定了所有的變量都存儲在主內存(MainMemory)中。每個線程還有自己的工作內存(WorkingMemory),線程的工作內存中保存了該線程使用到的變量的主內存的副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量(volatile變量仍然有工作內存的拷貝,但是由於它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般)。不同的線程之間也無法直接訪問對方工作內存中的變量,線程之間值的傳遞都需要通過主內存來完成。
Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。
從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。

內存間交互操作

java內存模型中定義了以下8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的。

lock鎖定:作用於主內存的變量。它把一個變量標識為一條線程獨占的狀態。
unlock解鎖:作用於主內存的變量
read讀取:作用於主內存的變量,把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
load載入:作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作中內存的變量副本中。
use使用:作用於工作內存的變量,它把工作內存中的一個變量的值傳遞給執行引擎。每當虛擬機遇到一個需要使用該變量的值的字節碼指令時會執行這個操作。
assign賦值:作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
store存儲:作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨后的write操作使用。
write:作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

volatile原理

變量對線程的可見性,比synchronized性能好
一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之后,那么就具備了兩層語義:

1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
不能認為,使用了volatile關鍵字,就認為並發安全。在一些運算中,由於運算並非原子操作,還是會出現同步的問題。

2)禁止進行指令重排序。
  普通變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。
  volatile修飾后,會加入內存屏障(指重排序時不能把后面的指令重排序到內存屏障之前的位置)。執行“lock addl $0x0,(%esp)”,這個操作是一個空操作,作用是使得本cpude Cache寫入了內存,該寫入動作也會引起別的cpu或別的內核無效化其cache,這種操作相當於對cache中的變量store 和write操作,使得對volatile變量的修改對其他cpu立即可見。

內部原理

處理器為了提高處理速度,不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存(L1,L2或其他)后再進行操作,但操作完之后不知道何時會寫到內存,如果對聲明了Volatile變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。

但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存里把數據讀到處理器緩存里。

volatile關鍵字

被volatile修飾的共享變量,就具有了以下兩點特性:

保證了不同線程對該變量操作的內存可見性;
禁止指令重排序;

JMM三大特性

原子性

原子性即一個操作或一系列是不可中斷的。即使是在多個線程的情況下,操作一旦開始,就不會被其他線程干擾。

比如,對於一個靜態變量int x兩條線程同時對其賦值,線程A賦值為1,而線程B賦值為2,不管線程如何運行,最終x的值要么是1,要么是2,線程A和線程B間的操作是沒有干擾的,這就是原子性操作,不可被中斷的。

由jmm來直接保證的原子性變量操作包括read,load,assign,use,store,write,我們大致可以認為基本數據類型的訪問讀寫是具備原子性的,(double和long例外)。此外,synchronized塊之間的代碼也具有原子性

可見性

可見性指的是,當一個線程修改了共享變量的值后,其他線程能夠立即得知這個修改。volatile變量、synchronized,final三個關鍵字修飾的變量都可保證原子性。

有序性

在Java內存模型中有序性可歸納為這樣一句話:如果在本線程內觀察,所有操作都是有序的,如果在一個線程中觀察另一個線程,所有操作都是無序的。

有序性是指對於單線程的執行代碼,執行是按順序依次進行的。但在多線程環境中,則可能出現亂序現象,因為在編譯過程會出現“指令重排”,重排后的指令與原指令的順序未必一致。因此,上面歸納的前半句指的是線程內保證串行語義執行,后半句則指“指令重排”現象和“工作內存與主內存同步延遲”現象。

java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排的語義,而synchronized則是由一個變量在同一時刻只允許一條線程對其進行lock操作這條規則獲得的。

指令重排

CPU和編譯器為了提升程序執行的效率,會按照一定的規則允許進行指令優化。但代碼邏輯之間是存在一定的先后順序,並發執行時按照不同的執行邏輯會得到不同的結果。

volatile關鍵詞修飾的變量,會禁止指令重排的操作,從而在一定程度上避免了多線程中的問題

volatile不能保證原子性,它只是對單個volatile變量的讀/寫具有原子性,但是對於類似i++這樣的復合操作就無法保證了。

剛提到synchronized,能說說它們之間的區別嗎

volatile本質是在告訴JVM當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取;synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。
volatile僅能使用在變量級別;synchronized則可以使用在變量、方法和類級別的;
volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性;
volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞。
volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化。

ABA問題

比如說線程one從內存位置V中取出A,這時候另一個線程two也從內存中取出A,並且two進行了一些操作變成了B,然后two又將V位置的數據變成A,這時候線程one進行CAS操作發現內存中仍然是A,然后one操作成功。盡管線程one的CAS操作成功,但是不代表這個過程就是沒有問題的。如果鏈表的頭在變化了兩次后恢復了原值,但是不代表鏈表就沒有變化

要解決"ABA問題",我們需要增加一個版本號,在更新變量值的時候不應該只更新一個變量值,而應該更新兩個值,分別是變量值和版本號

原子變量

原子變量不使用鎖或其他同步機制來保護對其值的並發訪問。所有操作都是基於CAS原子操作的。他保證了多線程在同一時間操作一個原子變量而不會產生數據不一致的錯誤,並且他的性能優於使用同步機制保護的普通變量,譬如說在多線程環境 中統計次數就可以使用原子變量。

多線程的使用場景

有時候使用多線程並不是為了提高效率,而是使得CPU能夠同時處理多個事件。

為了不阻塞主線程,啟動其他線程來做好事的事情,比如APP中耗時操作都不在UI中做.
實現更快的應用程序,即主線程專門監聽用戶請求,子線程用來處理用戶請求,以獲得大的吞吐量.感覺這種情況下,多線程的效率未必高。 這種情況下的多線程是為了不必等待,可以並行處理多條數據。比如JavaWeb的就是主線程專門監聽用戶的HTTP請求,然后啟動子線程去處理用戶的HTTP請求。
某種雖然優先級很低的服務,但是卻要不定時去做。
比如Jvm的垃圾回收。
某種任務,雖然耗時,但是不耗CPU的操作時,開啟多個線程,效率會有顯著提高。
比如讀取文件,然后處理。磁盤IO是個很耗費時間,但是不耗CPU計算的工作。 所以可以一個線程讀取數據,一個線程處理數據。肯定比一個線程讀取數據,然后處理效率高。因為兩個線程的時候充分利用了CPU等待磁盤IO的空閑時間。

最后

歡迎大家關注我的公眾號:前程有光,金三銀四跳槽面試季,整理了1000多道將近500多頁pdf文檔的Java面試題資料,文章都會在里面更新,整理的資料也會放在里面。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM