2020 Java相關面試題整理

----點擊查看更多2020面試題系列文章----

JVM運行時數據區

線程共享部分:方法區、堆內存

線程獨佔部分:虛擬機棧、本地方法棧、程序計數器

方法區

用來存儲加載的類信息、常量、靜態變量、編譯後的代碼等數據

堆內存

用來存放對象的區域,可以細分爲:老年代、新生代(Eden、From Survivor、To Survivor)

虛擬機棧

每個線程都在這個空間有一個私有的空間,線程棧由多個棧幀組成。一個線程會執行一個或者多個方法,一個方法對應一個棧幀。

棧幀內容包括:局部變量表、操作數棧、動態鏈接、方法返回地址、附加信息等,棧內存默認最大是1M。

本地方法棧

和虛擬機棧功能類似,虛擬機棧是爲虛擬機執行JAVA方法而準備的,本地方法棧是爲虛擬機使用Native本地方法而準備的

程序計數器

記錄當前線程執行字節碼的位置,存儲的是字節碼指令地址,如果執行Native方法,則計數器值爲空。

CPU同一時間,只會執行一條線程中的指令。JVM多線程會輪流切換並分配CPU執行時間,爲了線程切換後,需要通過程序計數器來恢復正確的執行位置。

Java類加載過程

過程包括加載、驗證、準備、解析、初始化

加載是類加載的第一個過程,在這個階段,將完成一下三件事情:

  • 通過類的全限定名獲取類的二進制流

  • 將該二進制流中的靜態存儲結構轉化爲方法區運行時數據結構

  • 內存中生成該類的 java.lang.Class 對象,作爲該類的數據訪問入口。

驗證的目的是爲了確保 Class 文件的字節流中的信息不回危害到虛擬機.在該階段主要完成以下四鍾驗證:

  • 文件格式驗證:驗證字節流是否符合 Class 文件的規範,如主次版本號是否在當前虛擬機範圍內,常量池中的常量是否有不被支持的類型。

  • 元數據驗證:對字節碼描述的信息進行語義分析,如這個類是否有父類,是否集成了不被繼承的類等。

  • 字節碼驗證:是整個驗證過程中最複雜的一個階段,通過驗證數據流和控制流的分析,確定程序語義是否正確,主要針對方法體的驗證。如:方法中的類型轉換是否正確,跳轉指令是否正確等。

  • 符號引用驗證:這個動作在後面的解析過程中發生,主要是爲了確保解析動作能正確執行。

準備階段是爲類的靜態變量分配內存並將其初始化爲默認值,這些內存都將在方法區中進行分配。準備階段不分配類中的實例變量的內存,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中。

解析階段主要完成符號引用到直接引用的轉換動作。解析動作並不一定在初始化動作完成之前,也有可能在初始化之後。

初始化是類加載的最後一步,前面的類加載過程,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼。

描述一下JVM加載class文件的原理機制?

JVM中類的裝載是由類加載器(ClassLoader)和它的子類來實現的,Java中的類加載器是一個重要的Java運行時系統組件,它負責在運行時查找和裝入類文件中的類。

由於Java的跨平臺性,經過編譯的Java源程序並不是一個可執行程序,而是一個或多個類文件。當Java程序需要使用某個類時,JVM會確保這個類已經被加載、連接(驗證、準備和解析)和初始化。

類的加載是指把類的.class文件中的數據讀入到內存中,通常是創建一個字節數組讀入.class文件,然後產生與所加載類對應的Class對象。加載完成後,Class對象還不完整,所以此時的類還不可用。當類被加載後就進入連接階段,這一階段包括驗證、準備(爲靜態變量分配內存並設置默認的初始值)和解析(將符號引用替換爲直接引用)三個步驟。

最後JVM對類進行初始化,包括:

1)如果類存在直接的父類並且這個類還沒有被初始化,那麼就先初始化父類;

2)如果類中存在初始化語句,就依次執行這些初始化語句。

類的加載是由類加載器完成的,類加載器包括:

啓動類加載器(Bootstrap ClassLoader)、

擴展類加載器(Extension ClassLoader)、

應用程序類加載器(Application ClassLoader)、

用戶自定義類加載器(java.lang.ClassLoader的子類)。

類加載過程採取了雙親委派模型機制(PDM)。PDM更好的保證了Java平臺的安全性,在該機制中,JVM自帶的Bootstrap是根加載器,其他的加載器都有且僅有一個父類加載器。類的加載首先請求父類加載器加載,父類加載器無能爲力時才由其子類加載器自行加載。JVM不會向Java程序提供對Bootstrap的引用。下面是關於幾個類加載器的說明:

Bootstrap:一般用本地代碼實現,負責加載JVM基礎核心類庫(rt.jar);

Extension:從java.ext.dirs系統屬性所指定的目錄中加載類庫,它的父加載器是Bootstrap;

Application:應用類加載器,其父類是Extension。它是應用最廣泛的類加載器。它從環境變量classpath或者系統屬性java.class.path所指定的目錄中記載類,是用戶自定義加載器的默認父加載器。

破壞雙親委派模型:線程上下文類加載器、OSGi類加載器

線程上下文類加載器:Java中所有涉及SPI的加載動作基本上都採用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

OSGi類加載器:作代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)等用途。

Java內存模型

Java內存模型的主要目標是定義程序中變量的訪問規則。即在虛擬機中將變量存儲到主內存或者將變量從主內存取出這樣的底層細節。

1)Java內存模型將內存分爲了主內存工作內存。類的狀態,也就是類之間共享的變量,是存儲在主內存中的,每次Java線程用到這些主內存中的變量的時候,會讀一次主內存中的變量,並讓這些內存在自己的工作內存中有一份拷貝,運行自己線程代碼的時候,用到這些變量,操作的都是自己工作內存中的那一份。在線程代碼執行完畢之後,會將最新的值更新到主內存中去

2)定義了幾個原子操作,用於操作主內存和工作內存中的變量

3)定義了volatile變量的使用規則

4)happens-before,即先行發生原則,定義了操作A必然先行發生於操作B的一些規則,比如在同一個線程內控制流前面的代碼一定先行發生於控制流後面的代碼、一個釋放鎖unlock的動作一定先行發生於後面對於同一個鎖進行鎖定lock的動作等等,只要符合這些規則,則不需要額外做同步措施,如果某段代碼不符合所有的happens-before規則,則這段代碼一定是線程非安全的

延伸閱讀

Java內存模型中涉及到的概念有:

主內存:java虛擬機規定所有的變量(不是程序中的變量)都必須在主內存中產生,爲了方便理解,可以認爲是堆區。可以與前面說的物理機的主內存相比,只不過物理機的主內存是整個機器的內存,而虛擬機的主內存是虛擬機內存中的一部分。

工作內存:java虛擬機中每個線程都有自己的工作內存,該內存是線程私有的爲了方便理解,可以認爲是虛擬機棧。可以與前面說的高速緩存相比。線程的工作內存保存了線程需要的變量在主內存中的副本。虛擬機規定,線程對主內存變量的修改必須在線程的工作內存中進行,不能直接讀寫主內存中的變量。不同的線程之間也不能相互訪問對方的工作內存。如果線程之間需要傳遞變量的值,必須通過主內存來作爲中介進行傳遞。

內存間交互操作

關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作來完成:

lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。

unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用

load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。

use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。

assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。

store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。

write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

如果要把一個變量從主內存中複製到工作內存,就需要按順尋地執行read和load操作,如果把變量從工作內存中同步回主內存中,就要按順序地執行store和write操作。Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間,store和write之間是可以插入其他指令的,如對主內存中的變量a、b進行訪問時,可能的順序是read a,read b,load b, load a。Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

不允許read和load、store和write操作之一單獨出現

不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中。

不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。

一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。

一個變量在同一時刻只允許一條線程對其進行lock操作,lock和unlock必須成對出現

如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值

如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。

對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。

Jvm垃圾收集算法

  • 標記-清除算法(基礎算法)

    算法分爲“標記”和“清除”兩個階段。首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。

    不足:1.效率問題,標記和清除的效率都不高;2.空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能導致以後程序運行過程中,需要分配較大對象時,無法找到足夠的連續的內存而不得不提前觸發另一次垃圾收集動作。

  • 複製算法

    爲了解決效率問題,將內存劃分爲大小相等的兩塊,每次只使用其中的一塊,當這塊內存快用完的時候,就將還存活的對象複製到令一塊,然後把已使用的內存空間一次性清理掉。這樣每次都是對一半的內存區域進行回收,也沒有了內存碎片的問題。只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。缺點就是內存只能使用一半,空間上浪費。

    在java堆中新生代中對象朝生夕死存在的情況比較多,大約98%的對象需要回收。因此新生代是將內存分爲Eden(伊甸園)空間和兩塊survivor(倖存者)空間,每次使用Eden和一塊survivor空間。當進行垃圾回收時,將eden和一塊survivor中還存活的對象,都複製到另一塊survivor空間,然後將eden和survivor直接清除。HotSpot虛擬機,默認eden和survivor比例爲8:1:1。98%的對象需要回收,只是大多數情況,我們沒辦法保證每次回收,都不多於10%的對象能夠存活,當survivor空間不夠用時,需要依賴其他內存進行分配擔保(老年代)。如果另一塊survivor空間沒有足夠的內存,存放上一次eden和survivor存活下來的對象,這些對象講直接通過內存擔保機制進入老年代。

  • 標記-整理算法

    針對老年代中對象的特點,回收率低。因此採用標記整理算法。和標記清除算法類似,在清除完成後,將存活的對象往一端移動,然後直接清理掉邊界以外的內存區域。

  • 分代收集

    就是指的是,在新生代採用複製算法。在老年代採用標記清除或者標記整理算法。

JVM常用基本配置參數

-Xms 初始大小內存,默認爲物理內存1/64,等價於-XX:InitialHeapSize

-Xmx 最大分配內存,默認爲物理內存1/4,等價於-XX:MaxHeapSize

-Xss 設置單個線程棧的大小,一般默認爲512k~1024k,等價於-XX:ThradStackSize

-Xmn 設置年輕代大小

-XX:MetaspaceSize 設置元空間大小

元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地內存。因此,默認情況下,元空間的大小僅受本地內存限制

-Xms10m -Xmx10m -XX:MetespaceSize=1024m -XX:+PrintFlagsFinal

-XX:+PrintGCDetails 輸出詳細GC收集日誌信息

-XX:SurvivorRatio 設置新生代中eden和s0/s1空間的比例,默認-XX:SurvivorRatio=8,Eden:s0:s1=8:1:1,假如-XX:SurvivorRatio=4,Eden:s0:s1=4:1:1,SurvivorRatio值就是設置eden區的比例佔多少,s0/s1相同

-XX:NewRatio 配置年輕代與老年代在堆結構的佔比,默認-XX:NewRatio=2,新生代佔1,老年代佔2,年輕代佔整個堆的1/3;假如-XX:NewRatio=4新生代佔1,老年代佔4,年輕代佔整個堆的1/5,NewRatio值就是設置老年代的佔比,剩下的1給新生代

-XX:MaxTenuringThreshold 設置垃圾最大年齡,默認值爲15,有效值在0到15之間,-XX:MaxTenuringThreshold=0:設置垃圾最大年齡。如果設置爲0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置爲一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象在年輕代的存活時間,增加在年經代即被回收的概率。

強引用、軟引用、弱引用、虛引用

強引用:當內存不足,JVM開始垃圾回收,對於強引用對象,就算是出現了OOM也不會對該對象進行回收,死都不收。

軟引用:是一種相對強引用弱化了一些的引用,需要用java.lang.ref.SoftReference類來實現,可以讓對象豁免一些垃圾收集。對於只有軟引用的對象來說,當系統內存充足時它不會被回收,當系統內存不足時會被回收。軟引用通常用在對內存敏感的程序中,比如高速緩存就有和到軟引用,內存夠用的時候就保留,不夠用就回收。

弱引用:需要用java.lang.ref.WeakReference類來實現,它比軟引用的生存週期更短,對於只有弱引用的對象來說,只要垃圾回收機制一運行,不管JVM的內存空間是否足夠,都會回收該對象佔用的內存

虛引用:需要java.lang.ref.PhantomReference類來實現。顧名思義,就是形同虛設,與其它幾種引用都不同,虛引用並不會決定對象的生命週期。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。它不能單獨使用也不能通過它訪問對象,虛引用必須和引用隊列(ReferenceQueue聯合實用)。設置虛引用關聯 唯一目的,就是在這個對象被收集器回收的時候收到一個系統通知或者後續添加進一步的處理。

垃圾收集器

串行垃圾收集器(Serial):它爲單線程環境設計且只使用一個線程進行垃圾回收,會暫停所有的用戶線程。所以不適合服務器環境。

並行垃圾收集器(Parallel):多個垃圾收集線程並行工作,此時用戶線程是暫停的,適用於科學計算/大數據處理後臺處理等弱交互場景

併發垃圾收集器(CMS):用戶線程和垃圾收集線程同時執行(不一定是並行,可能交替執行),不需要停頓用戶線程,互聯網公司多用它,適用對響應時間有要求的場景

G1垃圾回收器:G1垃圾回收器將堆內存分割成不同的區域然後併發的對其進行垃圾回收

併發標記清除GC(CMS)的過程

初始標記(CMS initial mark):只是標記一下GC Roots能直接關聯的對象,速度很快,仍然需要暫停所有的工作線程。

併發標記(CMS concurrent mark):和用戶線程一起,進行GC Roots跟蹤的過程,和用戶線程一起工作,不需要暫停工作線程。主要標記過程,標記全部對象。

重新標記(CMS remark):爲了修正在併發標記期間,因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,仍然需要暫停所有的工作線程。由於併發標記時,用戶線程依然運行,因此在正式清理前,再做修正。

併發清除(CMS concurrent sweep):和用戶線程一起,清除GC Roots不可達對象,和用戶線程一起工作,不需要暫停工作線程。基於標記結果,直接清理對象。由於耗時最長的併發標記和併發清除過程中,垃圾收集線程可以和用戶線程一起併發工作,所以總體上來看CMS收集器的內存回收和用戶線程是一起併發地執行。

CMS的缺點

浮動垃圾:由於CMS併發清理階段用戶線程還在運行着,伴隨程序運行自然會有新垃圾產生,這部分垃圾得標記過程之後,所以CMS無法在當收集中處理掉他們,只好留待下一次GC清理掉,這一部分垃圾稱爲浮動垃圾。在jdk1.5默認設置下,CMS收集器當老年代使用了68%的空間就會被激活,可以通過-XX:CMSInitialOccupancyFraction的值來提高觸發百分比,在jdk1.6中CMS啓動閾值提升到了92%,要是CMS運行期間預留的內存無法滿足程序的需要,就會出現”Concurrent Mode Failure“,然後降級臨時啓用Serial Old收集器進行老年代的垃圾收集,這樣停頓時間就很長了,所以-XX:CMSInitialOccupancyFraction設置太高容易導致大量”Concurrent Mode Failure“。

有空間碎片:CMS是一款基於“標記-清除”算法實現的,所以會產生空間碎片。爲了解決這個問題,CMS提供了-XX:UseCMSCompactAtFullCollection開發參數用於開啓內存碎片的合併整理,由於內存整理是無法並行的,所以停頓時間會變長。還有-XX:CMSFullGCBeforeCompaction,這個參數用於設置多少次不壓縮Full GC後,跟着來一次帶壓縮的(默認爲0)。

併發消耗CPU資源,CMS默認啓動的回收線程數是(cpu數量+3)/4。所以CPU數量少會導致用戶程序執行速度降低較多。

GC安全點一般選擇的位置

  • 循環的末尾
  • 方法臨返回前
  • 調用方法之後
  • 拋異常的位置

GC發生時,線程到最近的安全點的方式

搶斷式中斷:在GC發生時,首先中斷所有線程,如果發現線程未執行到safe point,就恢復線程讓其運行到safe point 上

主動式中斷:在GC發生時,不直接操作線程中斷,而是簡單地設置一個標誌,讓各個線程執行時主動輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。

JVM 採取的就是主動式中斷。輪詢標誌的地方和安全點是重合的。

什麼是GC安全域

Safe Region 是指在一段代碼片段中,引用關係不會發生變化。在這個區域內的任意地方開始 GC 都是安全的。

線程在進入 Safe Region 的時候先標記自己已進入了 Safe Region,等到被喚醒時準備離開 Safe Region 時,先檢查能否離開,如果 GC 完成了,那麼線程可以離開,否則它必須等待直到收到安全離開的信號爲止。

如何選擇垃圾收集器

單CPU或小內存,單機程序 -XX:+UseSerialGC

多CPU,需要最大吞吐量,如後臺計算型應用 -XX:+UseParllelGC 或者 -XX:+UseParllelOldGC

多CPU,追求低停頓時間,需快速響應如互聯網應用 -XX:+UseConcMarkSweepGC -XX:+ParNewGC

併發收集器和並行收集器的區別

  • 並行收集器(parallel) :多條垃圾收集線程同時進行工作,此時用戶線程處於等待狀態
  • 併發收集器(concurrent):指多條垃圾收集線程與用戶線程同時進行(但不一定是並行的,有可能交替進行工作)

JVM自動內存管理,Minor GC 與 Full GC的觸發機制

**Minor GC觸發條件:**當Eden區滿時,觸發Minor GC。

Full GC觸發條件:
(1)調用System.gc時,系統建議執行Full GC,但是不必然執行
(2)老年代空間不足
(3)方法區空間不足
(4)通過Minor GC後進入老年代的平均大小大於老年代的可用內存

(5)由Eden區、From Survivor區向To Survivor區複製時,對象大小大於To Survivor可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小

對象什麼時候會進入老年代

  • 根據對象年齡
    JVM會給對象增加一個年齡(age)的計數器,對象每“熬過”一次GC,年齡就要+1,待對象到達設置的閾值(默認爲15歲)就會被移移動到老年代,可通過-XX:MaxTenuringThreshold調整這個閾值。一次Minor GC後,對象年齡就會+1,達到閾值的對象就移動到老年代,其他存活下來的對象會繼續保留在新生代中。
  • 動態年齡判斷
    根據對象年齡有另外一個策略也會讓對象進入老年代,不用等待15次GC之後進入老年代,他的大致規則就是,假如當前放對象的Survivor,一批對象的總大小大於這塊Survivor內存的50%,那麼大於這批對象年齡的對象,就可以直接進入老年代了。
  • 大對象直接進入老年代
    如果設置了-XX:PretenureSizeThreshold這個參數,那麼如果你要創建的對象大於這個參數的值,比如分配一個超大的字節數組,此時就直接把這個大對象放入到老年代,不會經過新生代。這麼做就可以避免大對象在新生代,屢次躲過GC,還得把他們來複制來複制去的,最後才進入老年代,這麼大的對象來回複製,是很耗費時間的。

JVM調優基本思路

基本思路就是讓每一次GC都回收儘可能多的對象

對於CMS收集器來說,最重要的是合理地設置年輕代和年老代的大小。年輕代太小的話,會導致頻繁的Minor GC,並且很有可能存活期短的對象也不能被回收,GC的效率就不高。而年老代太小的話,容納不下從年輕代過來的新對象,會頻繁觸發單線程Full GC,導致較長時間的GC暫停,影響Web應用的響應時間。

對於G1收集器來說,不推薦直接設置年輕代的大小,這一點跟CMS收集器不一樣,這是因爲G1收集器會根據算法動態決定年輕代和年老代的大小。因此對於G1收集器,需要關心的是Java堆的總大小(-Xmx)。此外G1還有一個較關鍵的參數是-XX:MaxGCPauseMillis = n,這個參數是用來限制最大的GC暫停時間,目的是儘量不影響請求處理的響應時間

如何確定年輕代、老年代內存大小?

這是一個迭代的過程,可以先採用JVM的默認值,然後通過壓測分析GC日誌。

如果看年輕代的內存使用率處在高位,導致頻繁的Minor GC,而頻繁GC的效率又不高,說明對象沒那麼快能被回收,這時年輕代可以適當調大一點。

如果看年老代的內存使用率處在高位,導致頻繁的Full GC,這樣分兩種情況:如果每次Full GC後年老代的內存佔用率沒有下來,可以懷疑是內存泄漏;如果Full GC後年老代的內存佔用率下來了,說明不是內存泄漏,要考慮調大年老代。

對於G1收集器來說,可以適當調大Java堆,因爲G1收集器採用了局部區域收集策略,單次垃圾收集的時間可控,可以管理較大的Java堆。

synchronized底層怎麼實現的

synchronized代碼塊是由一對monitorenter/monitorexit指令實現的, Monitor對象是同步的基本實現單元。Jdk1.6之前synchronized只有重量級鎖的一種實現方式,jdk1.6之後進行了鎖優化,細分分爲偏向鎖輕量級鎖重量級鎖

偏向鎖本質就是無鎖,如果沒有發生過任何多線程爭搶鎖的情況,JVM認爲就是單線程,無需做同步。輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只依賴一次CAS原子指令置換ThreadID

在發生爭搶鎖的情況下,偏向鎖會變爲輕量級鎖或者未鎖定狀態。在輕量級鎖的狀態下,線程會通過自旋的方式搶鎖(也就是自旋鎖,自旋鎖是輕量級鎖的一種實現方式),在自旋到一定次數後,還沒搶到鎖,就會升級爲重量級鎖。

重量級鎖機制包含三個比較重要的感念:EntryList隊列、WaitSet隊列、Owner。EntryList裏存有等待搶鎖的線程,其狀態爲Blocked;當線程調用對象的wait()方法後,線程會進入WaitSet隊列,其狀態爲Waiting;Owner的值則表示當前持有鎖的線程,若當前沒有線程持有鎖,則爲null

ReentrantLock和Synchronized區別

相同點:阻塞式同步加鎖,如果一個線程獲得了對象鎖,進入了同步塊,其他訪問該同步塊的線程都必須阻塞在同步塊外面等待

不同點

Synchronized:它是java語言的關鍵字,是原生語法層面的互斥,由jvm實現

ReentrantLock:它是JDK 1.5之後提供的API層面的互斥鎖,需要lock()和unlock()方法配合try/finally語句塊來完成

便利性:很明顯Synchronized的使用比較方便簡潔,並且由編譯器去保證鎖的加鎖和釋放,而ReenTrantLock需要手工聲明來加鎖和釋放鎖,爲了避免忘記手工釋放鎖造成死鎖,所以最好在finally中聲明釋放鎖。

鎖的細粒度和靈活度:很明顯ReenTrantLock優於Synchronized

延伸閱讀:

1.Synchronized

Synchronized進過編譯,會在同步塊的前後分別形成monitorenter和monitorexit這個兩個字節碼指令。在執行monitorenter指令時,首先要嘗試獲取對象鎖。如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象鎖,把鎖的計算器加1,相應的,在執行monitorexit指令時會將鎖計算器就減1,當計算器爲0時,鎖就被釋放了。如果獲取對象鎖失敗,那當前線程就要阻塞,直到對象鎖被另一個線程釋放爲止。

2.ReentrantLock

由於ReentrantLock是java.util.concurrent包下提供的一套互斥鎖,相比Synchronized,ReentrantLock類提供了一些高級功能,主要有以下3項:

等待可中斷:持有鎖的線程長期不釋放的時候,正在等待的線程可以選擇放棄等待,這相當於Synchronized來說可以避免出現死鎖的情況。通過lock.lockInterruptibly()來實現這個機制。

公平鎖:多個線程等待同一個鎖時,必須按照申請鎖的時間順序獲得鎖,Synchronized鎖非公平鎖,ReentrantLock默認的構造函數是創建的非公平鎖,可以通過參數true設爲公平鎖,但公平鎖表現的性能不是很好。

鎖綁定多個條件:一個ReentrantLock對象可以同時綁定多個對象。ReenTrantLock提供了一個Condition(條件)類,用來實現分組喚醒需要喚醒的線程們,而不是像synchronized要麼隨機喚醒一個線程要麼喚醒全部線程

ReenTrantLock實現的原理:

ReenTrantLock的實現是一種自旋鎖,通過循環調用CAS操作來實現加鎖。它的性能比較好也是因爲避免了使線程進入內核態的阻塞狀態。想盡辦法避免線程進入內核的阻塞狀態是我們去分析和理解鎖設計的關鍵鑰匙。

序列化底層怎麼實現的

什麼是序列化和反序列化

序列化就是把實體對象狀態按照一定的格式寫入到有序字節流,反序列化就是從有序字節流重建對象,恢復對象狀態。

爲什麼需要序列化與反序列化

  • 永久性保存對象,保存對象的字節序列到本地文件或者數據庫中;
  • 通過序列化以字節流的形式使對象在網絡中進行傳遞和接收;
  • 通過序列化在進程間傳遞對象;

序列化算法一般步驟

  • 將對象實例相關的類元數據輸出。
  • 遞歸地輸出類的超類描述直到不再有超類。
  • 類元數據完了以後,開始從最頂層的超類開始輸出對象實例的實際數據值。
  • 從上至下遞歸輸出實例的數據

JDK類庫中序列化的步驟

  • 創建一個對象輸出流,它可以包裝一個其它類型的目標輸出流,如文件輸出流

    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\object.out"));
    
  • 通過對象輸出流的writeObject()方法寫對象

    oos.writeObject(new User("xuliugen", "123456", "male"));
    

JDK類庫中反序列化的步驟

  • 創建一個對象輸入流,它可以包裝一個其它類型輸入流,如文件輸入流

    ObjectInputStream ois= new ObjectInputStream(new FileInputStream("object.out"));
    
  • 通過對象輸入流的readObject()方法讀取對象

    User user = (User) ois.readObject();
    

Java對象頭實現

HotSpot虛擬機中,對象在內存中的佈局分爲三塊區域:對象頭實例數據對齊填充
對象頭
對象頭包括:Mark Word類型指針數組長度(只有數組對象纔有)。

Mark Word
Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,佔用內存大小與虛擬機位長一致。

類型指針
類型指針指向對象的類元數據,虛擬機通過這個指針確定該對象是哪個類的實例。

hash: 保存對象的哈希碼
age: 保存對象的分代年齡
biased_lock: 偏向鎖標識位
lock: 鎖狀態標識位
JavaThread:保存持有偏向鎖的線程ID
epoch: 保存偏向時間戳

int的取值範圍

int的取值範圍爲: -2^31 ~ 2^31-1,即 -2147483648 ~ 2147483647

HashMap原理

(1)HashMap是一種散列表,採用(數組 + 鏈表 + 紅黑樹)的存儲結構,數組的一個元素又稱作桶;

(2)HashMap的默認初始容量爲16(1<<4),默認裝載因子爲0.75f,容量總是2的n次方;

(3)HashMap擴容時每次容量變爲原來的兩倍;

(4)當桶的數量小於64時不會進行樹化,只會擴容;

(5)當桶的數量大於64且單個桶中元素的數量大於8時,進行樹化;

(6)當單個桶中元素數量小於6時,進行反樹化;

(7)HashMap是非線程安全的容器;

(8)HashMap查找添加元素的時間複雜度都爲O(1);

ConcurrentHashMap原理

(1)ConcurrentHashMap是HashMap的線程安全版本;

(2)ConcurrentHashMap採用(數組 + 鏈表 + 紅黑樹)的結構存儲元素;

(3)ConcurrentHashMap相比於同樣線程安全的HashTable,效率要高很多;

(4)ConcurrentHashMap採用的鎖有 synchronized,CAS,自旋鎖,分段鎖,volatile等;

(5)ConcurrentHashMap中沒有threshold和loadFactor這兩個字段,而是採用sizeCtl來控制;

(6)sizeCtl = -1,表示正在進行初始化;

(7)sizeCtl = 0,默認值,表示後續在真正初始化的時候使用默認容量;

(8)sizeCtl > 0,在初始化之前存儲的是傳入的容量,在初始化或擴容後存儲的是下一次的擴容門檻;

(9)sizeCtl = (resizeStamp << 16) + (1 + nThreads),表示正在進行擴容,高位存儲擴容郵戳,低位存儲擴容線程數加1;

(10)更新操作時如果正在進行擴容,當前線程協助擴容;

(11)更新操作會採用synchronized鎖住當前桶的第一個元素,這是分段鎖的思想;

(12)整個擴容過程都是通過CAS控制sizeCtl這個字段來進行的,這很關鍵;

(13)遷移完元素的桶會放置一個ForwardingNode節點,以標識該桶遷移完畢;

(14)元素個數的存儲也是採用的分段思想,類似於LongAdder的實現;

(15)元素個數的更新會把不同的線程hash到不同的段上,減少資源爭用;

(16)元素個數的更新如果還是出現多個線程同時更新一個段,則會擴容段(CounterCell);

(17)獲取元素個數是把所有的段(包括baseCount和CounterCell)相加起來得到的;

(18)查詢操作是不會加鎖的,所以ConcurrentHashMap不是強一致性的;

(19)ConcurrentHashMap中不能存儲key或value爲null的元素;

HashMap爲什麼是線程不安全的

  • JDK1.7中,在多線程環境下,擴容時會造成環形鏈數據丟失
  • JDK1.8中,在多線程環境下,會發生數據覆蓋的情況。

Hashmap擴容時每個entry需要再計算一次hash嗎?

不需要

CAS(Compare And Swap)的三個問題

  • 循環+CAS,自旋的實現讓所有線程都處於高頻運行,爭搶CPU執行時間的狀態。如果操作長時間不成功,會帶來很大的CPU資源消耗。
  • 僅針對單個變量的操作,不能用於多個變量來實現原子操作。
  • ABA問題

樂觀鎖

樂觀鎖假設數據一般情況下不會造成衝突,所以在數據進行提交更新的時候,纔會正式對數據的衝突與否進行檢測,如果發現衝突了,則返回給用戶錯誤的信息,讓用戶決定如何去做。

使用樂觀鎖就不需要藉助數據庫的鎖機制了。

樂觀鎖的概念中其實已經闡述了它的具體實現細節。主要就是兩個步驟:衝突檢測和數據更新。其實現方式有一種比較典型的就是CAS(Compare and Swap)。

CAS是項樂觀鎖技術,當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。

使用版本號的概念可以解決CAS的ABA問題。

悲觀鎖

對一個數據庫中的一條數據進行修改的時候,爲了避免同時被其他人修改,最好的辦法就是直接對該數據進行加鎖以防止併發。這種藉助數據庫鎖機制,在修改數據之前先鎖定,再修改的方式被稱之爲悲觀併發控制(又名“悲觀鎖”,Pessimistic Concurrency Control,縮寫“PCC”)

悲觀鎖的流程:

  • 在對記錄進行修改前,先嚐試爲該記錄加上排他鎖(exclusive locking)。
  • 如果加鎖失敗,說明該記錄正在被修改,那麼當前查詢可能要等待或者拋出異常。具體響應方式由開發者根據實際需要決定。
  • 如果成功加鎖,那麼就可以對記錄做修改,事務完成後就會解鎖了。
  • 期間如果有其他對該記錄做修改或加排他鎖的操作,都會等待我們解鎖或直接拋出異常

用的MySql Innodb引擎舉例,如何使用悲觀鎖:

要使用悲觀鎖,我們必須關閉MySQL數據庫的自動提交屬性。因爲MySQL默認使用autocommit模式,也就是說,當我們執行一個更新操作後,MySQL會立刻將結果進行提交。(sql語句:set autocommit=0)

使用扣減庫存的需求說明一下悲觀鎖的使用:

// 開始事務
begin;
// 查詢出商品庫存信息
select quantity from items where id=1 for update// 修改商品庫存爲2
update items set quantity=2 where id = 1;
// 提交事務
commit;

以上,在對id = 1的記錄修改前,先通過for update的方式進行加鎖,然後再進行修改。這就是比較典型的悲觀鎖策略。

如果以上修改庫存的代碼發生併發,同一時間只有一個線程可以開啓事務並獲得id=1的鎖,其它的事務必須等本次事務提交之後才能執行。這樣我們可以保證當前的數據不會被其它事務修改。

上面我們提到,使用select…for update會把數據給鎖住,不過我們需要注意一些鎖的級別,MySQL InnoDB默認行級鎖。行級鎖都是基於索引的,如果一條SQL語句用不到索引是不會使用行級鎖的,會使用表級鎖把整張表鎖住,這點需要注意。

ThreadPoolExecutor構造參數解釋

完整的線程池構造函數:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize:核心線程數,線程池創建後就會初始化的線程數量

  • maximumPoolSize:最大線程數,當等待隊列已滿,就會增加線程,但新增後線程總量不會超過maximumPoolSize

  • keepAliveTime:超出核心線程數之外的線程存活時間

  • unit:線程存活時間格式,見枚舉TimeUnit

  • workQueue:線程的等待隊列,當沒有空閒線程處理任務時,任務會進入該等待隊列對待執行

  • threadFactory:線程工廠,可以自定義創建線程的工廠,該參數一般可以不配置,選擇其他構造函數

  • handler:拒絕策略,在最大線程數已滿,等待隊列已滿的情況下,新來的任務會執行拒絕策略。

JDK線程池自帶的拒絕策略

AbortPolicy(丟棄拋異常策略)

DiscardPolicy(丟棄不拋異常策略)

DiscardOldestPolicy(丟棄隊列最前任務策略)

CallerRunsPolicy(線程調度者執行策略)

線程池任務執行過程

線程池任務執行過程

怎麼實現一個線程池?

  • 首先得有一個集合保存線程,可以使用HashSet
  • 需要一個隊列來存儲提交給線程池的任務,可以使用ArrayBlockingQueue
  • 需要一個初始化線程池的大小屬性
  • 需要一個屬性保存已經工作的線程數量
  • 最後就是編寫任務的執行方法。主要邏輯包含兩點:如果線程池未滿,每加入一個任務則開啓一個線程;線程池已滿,放入任務隊列,等待有空閒線程時執行

同步阻塞、同步非阻塞、異步阻塞、異步非阻塞區別

同步異步阻塞非阻塞

Java線程池,execute跟submit的區別

  • 對返回值的處理不同
    execute方法不關心返回值。
    submit方法有返回值Future。
  • 對異常的處理不同
    excute方法會拋出異常。
    sumbit方法不會拋出異常,除非你調用Future.get()。

什麼情況下會出現OutOfMemoryError

  • Java堆溢出
  • 虛擬機棧和本地方法棧溢出
  • 方法區和運行時常量池溢出
  • 本地直接內存溢出

常見的阻塞隊列有哪些?它的特點是什麼?

  • ArrayBlockingQueue

    這是一個由數組結構組成的有界阻塞隊列

  • LinkedBlockingQueue

    這是一個由鏈表結構組成的有界阻塞隊列。LinkedBlockingQueue 可以不指定隊列的大小,默認值是 Integer.MAX_VALUE 。但是,最好不要這樣做,建議指定一個固定大小。因爲,如果生產者的速度比消費者的速度大的多的情況下,這會導致阻塞隊列一直膨脹,直到系統內存被耗盡(此時,還沒達到隊列容量的最大值)。此外,LinkedBlockingQueue 實現了讀寫分離,可以實現數據的讀和寫互不影響,這在高併發的場景下,對於效率的提高無疑是非常巨大的。

  • SynchronousQueue

    這是一個沒有緩衝的無界隊列。什麼意思,看一下它的 size 方法:總是返回 0 ,因爲它是一個沒有容量的隊列。當執行插入元素的操作時,必須等待一個取出操作。也就是說,put元素的時候,必須等待 take 操作。那麼,有的同學就好奇了,這沒有容量,還叫什麼隊列啊,這有什麼意義呢。我的理解是,這適用於併發任務不大,而且生產者和消費者的速度相差不多的場景下,直接把生產者和消費者對接,不用經過隊列的入隊出隊這一系列操作。所以,效率上會高一些。可以去查看一下 Excutors.newCachedThreadPool 方法用的就是這種隊列。這個隊列有兩個構造方法,用於傳入是公平還是非公平,默認是非公平。

  • PriorityBlockingQueue

    這是一個支持優先級排序的無界隊列。可以指定初始容量大小(注意初始容量並不代表最大容量),或者不指定,默認大小爲 11。也可以傳入一個比較器,把元素按一定的規則排序,不指定比較器的話,默認是自然順序。PriorityBlockingQueue 是基於二叉樹最小堆實現的,每當取元素的時候,就會把優先級最高的元素取出來。

  • DelayQueue

    這是一個帶有延遲時間的無界阻塞隊列。隊列中的元素,只有等延時時間到了,才能取出來。此隊列一般用於過期數據的刪除,或任務調度。

    特點:當阻塞隊列爲空的時候,從隊列中取元素的操作就會被阻塞。當阻塞隊列滿的時候,往隊列中放入元素的操作就會被阻塞。而後,一旦空隊列有數據了,或者滿隊列有空餘位置時,被阻塞的線程就會被自動喚醒。

線程池如何銷燬超過核心線程數之外的線程?

線程池內的每個線程都會循環調用獲取任務的函數getTask(),這個函數的返回值有兩種情況:一是返回任務,二是返回null。如果返回任務則繼續執行任務,當返回null的時候表明當前線程可以銷燬了,因爲沒有任務可以執行,會調用銷燬線程函數processWorkerExit。在getTask()函數內會判斷當前是否允許銷燬線程,如果允許則調用workQueue.poll,這個函數會等待keepAliveTime的時長,如果在這個時長內無法返回任務,則返回null;如果不允許銷燬線程,則會調用workQueue.take這個函數,阻塞直到有任務爲止,這也是爲什麼線程池能保證線程存活的原因。

Runnable接口和Callable接口的區別

Runnable接口中的run()方法的返回值是void,它做的事情只是純粹地去執行run()方法中的代碼而已;

Callable接口中的call()方法是有返回值的,是一個泛型,和Future、FutureTask配合可以用來獲取異步執行的結果。

CyclicBarrier和CountDownLatch的區別

  • CyclicBarrier的某個線程運行到某個點上之後,該線程即停止運行,直到所有的線程都到達了這個點,所有線程才重新運行;CountDownLatch則不是,某線程運行到某個點上之後,只是給某個數值-1而已,該線程繼續運行。

  • CyclicBarrier只能喚起一個任務,CountDownLatch可以喚起多個任務。

  • CyclicBarrier可重用,CountDownLatch不可重用,計數值爲0該CountDownLatch就不可再用了。

Semaphore有什麼作用

Semaphore就是一個信號量,它的作用是限制某段代碼塊的併發數。Semaphore有一個構造函數,可以傳入一個int型整數n,表示某段代碼最多隻有n個線程可以訪問,如果超出了n,那麼請等待,等到某個線程執行完畢這段代碼塊,下一個線程再進入。由此可以看出如果Semaphore構造函數中傳入的int型整數n=1,相當於變成了一個synchronized了。

volatile關鍵字的作用

  • 多線程主要圍繞可見性和原子性兩個特性而展開,使用volatile關鍵字修飾的變量,保證了其在多線程之間的可見性,即每次讀取到volatile變量,一定是最新的數據。

  • 現實中,爲了獲取更好的性能JVM可能會對指令進行重排序,多線程下可能會出現一些意想不到的問題。使用volatile則會對禁止語義重排序,當然這也一定程度上降低了代碼執行效率。

從實踐角度而言,volatile的一個重要作用就是和CAS結合,保證了原子性

如何保證內存可見性

  • volatile通過內存屏障
  • synchronized通過修飾的程序段同一時間只能由同一線程運行,釋放鎖前會刷新到主內存

Java中如何獲取到線程dump文件

1)獲取到進程程的pid,可以通過使用jps命令,在Linux環境下還可以使用ps -ef | grep java

2)打印線程堆棧,可以通過使用jstack pid命令,在Linux環境下還可以使用kill -3 pid

一個線程如果出現了運行時異常會怎麼樣

如果這個異常沒有被捕獲的話,這個線程就停止執行了。另外重要的一點是:如果這個線程持有某個某個對象的監視器,那麼這個對象監視器會被立即釋放

sleep方法和wait方法有什麼區別

sleep方法和wait方法都可以用來放棄CPU一定的時間,不同點在於如果線程持有某個對象的監視器,sleep方法不會放棄這個對象的監視器,wait方法會放棄這個對象的監視器

生產者消費者模型的作用是什麼

  • 通過平衡生產者的生產能力和消費者的消費能力來提升整個系統的運行效率,這是生產者消費者模型最重要的作用

  • 解耦,這是生產者消費者模型附帶的作用,解耦意味着生產者和消費者之間的聯繫少,聯繫越少越可以獨自發展而不需要收到相互的制約

怎麼檢測一個線程是否持有對象監視器

Thread類提供了一個holdsLock(Object obj)方法,當且僅當對象obj的監視器被某條線程持有的時候纔會返回true,注意這是一個static方法,這意味着"某條線程"指的是當前線程。

Java中用到的線程調度算法是什麼

搶佔式。一個線程用完CPU之後,操作系統會根據線程優先級、線程飢餓情況等數據算出一個總的優先級並分配下一個時間片給某個線程執行。

Thread.sleep(0)的作用是什麼

由於Java採用搶佔式的線程調度算法,因此可能會出現某條線程常常獲取到CPU控制權的情況,爲了讓某些優先級比較低的線程也能獲取到CPU控制權,可以使用Thread.sleep(0)手動觸發一次操作系統分配時間片的操作,這也是平衡CPU控制權的一種操作。

什麼是AQS

簡單說一下AQS,AQS全稱爲AbstractQueuedSychronizer,翻譯過來應該是抽象隊列同步器

如果說java.util.concurrent的基礎是CAS的話,那麼AQS就是整個Java併發包的核心了,ReentrantLock、CountDownLatch、Semaphore等等都用到了它。AQS實際上以雙向隊列的形式連接所有的Entry,比方說ReentrantLock,所有等待的線程都被放在一個Entry中並連成雙向隊列,前面一個線程使用ReentrantLock好了,則雙向隊列實際上的第一個Entry開始運行。

AQS定義了對雙向隊列所有的操作,而只開放了tryLock和tryRelease方法給開發者使用,開發者可以根據自己的實現重寫tryLock和tryRelease方法,以實現自己的併發功能。

----點擊查看更多2020面試題系列文章----

學而時習之

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章