摘要:本文將帶大家詳細地了解關于JVM的一些知識點,
本文分享自華為云社區《【JVM】關于JVM,你需要掌握這些 | 一文徹底吃透JVM系列》,作者: 冰 河 ,
JDK 是什么?
JDK 是用于支持 Java 程式開發的最小環境,
- Java 程式設計語言
- Java 虛擬機
- Java API類別庫
JRE 是什么?
JRE 是支持 Java 程式運行的標準環境,
- Java SE API 子集
- Java 虛擬機
Java歷史版本的特性?
Java Version SE 5.0
- 引入泛型;
- 增強回圈,可以使用迭代方式;
- 自動裝箱與自動拆箱;
- 型別安全的列舉;
- 可變引數;
- 靜態引入;
- 元資料(注解);
- 引入Instrumentation,
Java Version SE 6
- 支持腳本語言;
- 引入JDBC 4.0 API;
- 引入Java Compiler API;
- 可插拔注解;
- 增加對Native PKI(Public Key Infrastructure)、Java GSS(Generic Security Service)、Kerberos和LDAP(Lightweight Directory Access Protocol)的支持;
- 繼承Web Services;
- 做了很多優化,
Java Version SE 7
- switch陳述句塊中允許以字串作為分支條件;
- 在創建泛型物件時應用型別推斷;
- 在一個陳述句塊中捕獲多種例外;
- 支持動態語言;
- 支持try-with-resources;
- 引入Java NIO.2開發包;
- 數值型別可以用2進制字串表示,并且可以在字串表示中添加下劃線;
- 鉆石型語法;
- null值的自動處理,
Java 8
- 函式式介面
- Lambda運算式
- Stream API
- 介面的增強
- 時間日期增強API
- 重復注解與型別注解
- 默認方法與靜態方法
- Optional 容器類
運行時資料區域包括哪些?
- 程式計數器
- Java 虛擬機堆疊
- 本地方法堆疊
- Java 堆
- 方法區
- 運行時常量池
- 直接記憶體
程式計數器(執行緒私有)
程式計數器(Program Counter Register)是一塊較小的記憶體空間,可以看作是當前執行緒所執行位元組碼的行號指示器,分支、回圈、跳轉、例外處理、執行緒恢復等基礎功能都需要依賴這個計數器完成,
由于 Java 虛擬機的多執行緒是通過執行緒輪流切換并分配處理器執行時間的方式實作的,為了執行緒切換后能恢復到正確的執行位置,每條執行緒都需要一個獨立的程式計數器,各執行緒之間的計數器互不影響,獨立存盤,
- 如果執行緒正在執行的是一個 Java 方法,計數器記錄的是正在執行的虛擬機位元組碼指令的地址;
- 如果正在執行的是 Native 方法,這個計數器的值為空,
程式計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域,
Java 虛擬機堆疊(執行緒私有)
Java 虛擬機堆疊(Java Virtual Machine Stacks)是執行緒私有的,生命周期與執行緒相同,
虛擬機堆疊描述的是 Java 方法執行的記憶體模型:每個方法被執行的時候都會創建一個堆疊幀(Stack Frame),存盤
- 區域變數表
- 操作堆疊
- 動態鏈接
- 方法出口
每一個方法被呼叫到執行完成的程序,就對應著一個堆疊幀在虛擬機堆疊中從入堆疊到出堆疊的程序,
這個區域有兩種例外情況:
- StackOverflowError:執行緒請求的堆疊深度大于虛擬機所允許的深度
- OutOfMemoryError:虛擬機堆疊擴展到無法申請足夠的記憶體時
本地方法堆疊(執行緒私有)
虛擬機堆疊為虛擬機執行 Java 方法(位元組碼)服務,
本地方法堆疊(Native Method Stacks)為虛擬機使用到的 Native 方法服務,
Java 堆(執行緒共享)
Java 堆(Java Heap)是 Java 虛擬機中記憶體最大的一塊,Java 堆在虛擬機啟動時創建,被所有執行緒共享,
作用:存放物件實體,垃圾收集器主要管理的就是 Java 堆,Java 堆在物理上可以不連續,只要邏輯上連續即可,
方法區(執行緒共享)
方法區(Method Area)被所有執行緒共享,用于存盤已被虛擬機加載的類資訊、常量、靜態變數、即時編譯器編譯后的代碼等資料,
和 Java 堆一樣,不需要連續的記憶體,可以選擇固定的大小,更可以選擇不實作垃圾收集,
運行時常量池
運行時常量池(Runtime Constant Pool)是方法區的一部分,保存 Class 檔案中的符號參考、翻譯出來的直接參考,運行時常量池可以在運行期間將新的常量放入池中,
Java 中物件訪問是如何進行的?
Object obj = new Object();
對于上述最簡單的訪問,也會涉及到 Java 堆疊、Java 堆、方法區這三個最重要記憶體區域,
Object obj
如果出現在方法體中,則上述代碼會反映到 Java 堆疊的本地變數表中,作為 reference 型別資料出現,
new Object()
反映到 Java 堆中,形成一塊存盤了 Object 型別所有物件實體資料值的記憶體,Java堆中還包含物件型別資料的地址資訊,這些型別資料存盤在方法區中,
如何判斷物件是否“死去”?
- 參考計數法
- 根搜索演算法
什么是參考計數法?
給物件添加一個參考計數器,每當有一個地方參考它,計數器就+1,;當參考失效時,計數器就-1;任何時刻計數器都為0的物件就是不能再被使用的,
參考計數法的缺點?
很難解決物件之間的回圈參考問題,
什么是根搜索演算法?
通過一系列的名為“GC Roots”的物件作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為參考鏈(Reference Chain),當一個物件到 GC Roots 沒有任何參考鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可用的,
Java 的4種參考方式?
在 JDK 1.2 之后,Java 對參考的概念進行了擴充,將參考分為
- 強參考 Strong Reference
- 軟參考 Soft Reference
- 弱參考 Weak Reference
- 虛參考 Phantom Reference
強參考
Object obj = new Object();
代碼中普遍存在的,像上述的參考,只要強參考還在,垃圾收集器永遠不會回收掉被參考的物件,
軟參考
用來描述一些還有用,但并非必須的物件,軟參考所關聯的物件,有在系統將要發生記憶體溢位例外之前,將會把這些物件列進回收范圍,并進行第二次回收,如果這次回識訓是沒有足夠的記憶體,才會拋出記憶體例外,提供了 SoftReference 類實作軟參考,
弱參考
描述非必須的物件,強度比軟參考更弱一些,被弱參考關聯的物件,只能生存到下一次垃圾收集發生前,當垃圾收集器作業時,無論當前記憶體是否足夠,都會回收掉只被弱參考關聯的物件,提供了 WeakReference 類來實作弱參考,
虛參考
一個物件是否有虛參考,完全不會對其生存時間夠成影響,也無法通過虛參考來取得一個物件實體,為一個物件關聯虛參考的唯一目的,就是希望在這個物件被收集器回收時,收到一個系統通知,提供了 PhantomReference 類來實作虛參考,
有哪些垃圾收集演算法?
- 標記-清除演算法
- 復制演算法
- 標記-整理演算法
- 分代收集演算法
標記-清除演算法(Mark-Sweep)
什么是標記-清除演算法?
分為標記和清除兩個階段,首先標記出所有需要回收的物件,在標記完成后統一回收被標記的物件,
有什么缺點?
效率問題:標記和清除程序的效率都不高,
空間問題:標記清除之后會產生大量不連續的記憶體碎片,空間碎片太多可能導致,程式分配較大物件時無法找到足夠的連續記憶體,不得不提前出發另一次垃圾收集動作,
復制演算法(Copying)- 新生代
將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中一塊,當這一塊的記憶體用完了,就將存活著的物件復制到另一塊上面,然后再把已經使用過的記憶體空間一次清理掉,
優點
復制演算法使得每次都是針對其中的一塊進行記憶體回收,記憶體分配時也不用考慮記憶體碎片等復雜情況,只要移動堆頂指標,按順序分配記憶體即可,實作簡單,運行高效,
缺點
將記憶體縮小為原來的一半,在物件存活率較高時,需要執行較多的復制操作,效率會變低,
應用
商業的虛擬機都采用復制演算法來回收新生代,因為新生代中的物件容易死亡,所以并不需要按照1:1的比例劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中的一塊 Survivor,
當回收時,將 Eden 和 Survivor 中還存活的物件一次性拷貝到另外一塊 Survivor 空間上,最后清理掉 Eden 和剛才用過的 Survivor 空間,Hotspot 虛擬機默認 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80% + 10%),只有10%的記憶體是會被“浪費”的,
標記-整理演算法(Mark-Compact)-老年代
標記程序仍然與“標記-清除”演算法一樣,但不是直接對可回收物件進行清理,而是讓所有存活的物件向一端移動,然后直接清理掉邊界以外的記憶體,
分代收集演算法
根據物件的存活周期,將記憶體劃分為幾塊,一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點,采用最適當的收集演算法,
- 新生代:每次垃圾收集時會有大批物件死去,只有少量存活,所以選擇復制演算法,只需要少量存活物件的復制成本就可以完成收集,
- 老年代:物件存活率高、沒有額外空間對它進行分配擔保,必須使用“標記-清理”或“標記-整理”演算法進行回收,
Minor GC 和 Full GC有什么區別?
Minor GC:新生代 GC,指發生在新生代的垃圾收集動作,因為 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般回收速度較快,
Full GC:老年代 GC,也叫 Major GC,速度一般比 Minor GC 慢 10 倍以上,
Java 記憶體
為什么要將堆記憶體磁區?
對于一個大型的系統,當創建的物件及方法變數比較多時,即堆記憶體中的物件比較多,如果逐一分析物件是否該回收,效率很低,磁區是為了進行模塊化管理,管理不同的物件及變數,以提高 JVM 的執行效率,
堆記憶體分為哪幾塊?
- Young Generation Space 新生區(也稱新生代)
- Tenure Generation Space養老區(也稱舊生代)
- Permanent Space 永久存盤區
分代收集演算法
記憶體分配有哪些原則?
- 物件優先分配在 Eden
- 大物件直接進入老年代
- 長期存活的物件將進入老年代
- 動態物件年齡判定
- 空間分配擔保
Young Generation Space (采用復制演算法)
主要用來存盤新創建的物件,記憶體較小,垃圾回收頻繁,這個區又分為三個區域:一個 Eden Space 和兩個 Survivor Space,
- 當物件在堆創建時,將進入年輕代的Eden Space,
- 垃圾回收器進行垃圾回收時,掃描Eden Space和A Suvivor Space,如果物件仍然存活,則復制到B Suvivor Space,如果B Suvivor Space已經滿,則復制 Old Gen
- 掃描A Suvivor Space時,如果物件已經經過了幾次的掃描仍然存活,JVM認為其為一個Old物件,則將其移到Old Gen,
- 掃描完畢后,JVM將Eden Space和A Suvivor Space清空,然后交換A和B的角色(即下次垃圾回收時會掃描Eden Space和B Suvivor Space,
Tenure Generation Space(采用標記-整理演算法)
主要用來存盤長時間被參考的物件,它里面存放的是經過幾次在 Young Generation Space 進行掃描判斷過仍存活的物件,記憶體較大,垃圾回收頻率較小,
Permanent Space
存盤不變的類定義、位元組碼和常量等,
Class檔案
Java虛擬機的平臺無關性
Class檔案的組成?
Class檔案是一組以8位位元組為基礎單位的二進制流,各個資料專案間沒有任何分隔符,當遇到8位位元組以上空間的資料項時,則會按照高位在前的方式分隔成若干個8位位元組進行存盤,
魔數與Class檔案的版本
每個Class檔案的頭4個位元組稱為魔數(Magic Number),它的唯一作用是用于確定這個檔案是否為一個能被虛擬機接受的Class檔案,OxCAFEBABE,
接下來是Class檔案的版本號:第5,6位元組是次版本號(Minor Version),第7,8位元組是主版本號(Major Version),
使用JDK 1.7編譯輸出Class檔案,格式代碼為:
前四個位元組為魔數,次版本號是0x0000,主版本號是0x0033,說明本檔案是可以被1.7及以上版本的虛擬機執行的檔案,
- 33:JDK1.7
- 32:JDK1.6
- 31:JDK1.5
- 30:JDK1.4
- 2F:JDK1.3
類加載器
類加載器的作用是什么?
類加載器實作類的加載動作,同時用于確定一個類,對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,即使兩個類來源于同一個Class檔案,只要加載它們的類加載器不同,這兩個類就不相等,
類加載器有哪些?
- 啟動類加載器(Bootstrap ClassLoader):使用C++實作(僅限于HotSpot),是虛擬機自身的一部分,負責將存放在\lib目錄中的類別庫加載到虛擬機中,其無法被Java程式直接參考,
- 擴展類加載器(Extention ClassLoader)由ExtClassLoader實作,負責加載\lib\ext目錄中的所有類別庫,開發者可以直接使用,
- 應用程式類加載器(Application ClassLoader):由APPClassLoader實作,負責加載用戶類路徑(ClassPath)上所指定的類別庫,
類加載機制
什么是雙親委派模型?
雙親委派模型(Parents Delegation Model)要求除了頂層的啟動類加載器外,其余加載器都應當有自己的父類加載器,類加載器之間的父子關系,通過組合關系復用,
作業程序:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器完成,每個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有到父加載器反饋自己無法完成這個加載請求(它的搜索范圍沒有找到所需的類)時,子加載器才會嘗試自己去加載,
為什么要使用雙親委派模型,組織類加載器之間的關系?
Java類隨著它的類加載器一起具備了一種帶優先級的層次關系,比如java.lang.Object,它存放在rt.jar中,無論哪個類加載器要加載這個類,最終都是委派給啟動類加載器進行加載,因此Object類在程式的各個類加載器環境中,都是同一個類,
如果沒有使用雙親委派模型,讓各個類加載器自己去加載,那么Java型別體系中最基礎的行為也得不到保障,應用程式會變得一片混亂,
什么是類加載機制?
Class檔案描述的各種資訊,都需要加載到虛擬機后才能運行,虛擬機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換決議和初始化,最終形成可以被虛擬機直接使用的Java型別,這就是虛擬機的類加載機制,
虛擬機和物理機的區別是什么?
這兩種機器都有代碼執行的能力,但是:
- 物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面的,
- 虛擬機的執行引擎是自己實作的,因此可以自行制定指令集和執行引擎的結構體系,并且能夠執行那些不被硬體直接支持的指令集格式,
運行時堆疊幀結構
堆疊幀是用于支持虛擬機進行方法呼叫和方法執行的資料結構, 存盤了方法的
- 區域變數表
- 運算元堆疊
- 動態連接
- 方法回傳地址
每一個方法從呼叫開始到執行完成的程序,就對應著一個堆疊幀在虛擬機堆疊里面從入堆疊到出堆疊的程序,
Java 方法呼叫
什么是方法呼叫?
方法呼叫唯一的任務是確定被呼叫方法的版本(呼叫哪個方法),暫時還不涉及方法內部的具體運行程序,
Java的方法呼叫,有什么特殊之處?
Class檔案的編譯程序不包含傳統編譯的連接步驟,一切方法呼叫在Class檔案里面存盤的都只是符號參考,而不是方法在實際運行時記憶體布局中的入口地址,這使得Java有強大的動態擴展能力,但使Java方法的呼叫程序變得相對復雜,需要在類加載期間甚至到運行時才能確定目標方法的直接參考,
Java虛擬機呼叫位元組碼指令有哪些?
- invokestatic:呼叫靜態方法
- invokespecial:呼叫實體構造器方法、私有方法和父類方法
- invokevirtual:呼叫所有的虛方法
- invokeinterface:呼叫介面方法
虛擬機是如何執行方法里面的位元組碼指令的?
解釋執行(通過解釋器執行)
編譯執行(通過即時編譯器產生本地代碼)
解釋執行
當主流的虛擬機中都包含了即時編譯器后,Class檔案中的代碼到底會被解釋執行還是編譯執行,只有虛擬機自己才能準確判斷,
Javac編譯器完成了程式代碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的程序,因為這一動作是在Java虛擬機之外進行的,而解釋器在虛擬機的內部,所以Java程式的編譯是半獨立的實作,
基于堆疊的指令集和基于暫存器的指令集
什么是基于堆疊的指令集?
Java編譯器輸出的指令流,里面的指令大部分都是零地址指令,它們依賴運算元堆疊進行作業,
計算“1+1=2”,基于堆疊的指令集是這樣的:
iconst_1
iconst_1
iadd
istore_0
兩條iconst_1指令連續地把兩個常量1壓入堆疊中,iadd指令把堆疊頂的兩個值出堆疊相加,把結果放回堆疊頂,最后istore_0把堆疊頂的值放到區域變數表的第0個Slot中,
什么是基于暫存器的指令集?
最典型的是x86的地址指令集,依賴暫存器作業,
計算“1+1=2”,基于暫存器的指令集是這樣的:
mov eax, 1 add eax, 1
mov指令把EAX暫存器的值設為1,然后add指令再把這個值加1,結果就保存在EAX暫存器里,
基于堆疊的指令集的優缺點?
優點:
- 可移植性好:用戶程式不會直接用到這些暫存器,由虛擬機自行決定把一些訪問最頻繁的資料(程式計數器、堆疊頂快取)放到暫存器以獲取更好的性能,
- 代碼相對緊湊:位元組碼中每個位元組就對應一條指令
- 編譯器實作簡單:不需要考慮空間分配問題,所需空間都在堆疊上操作
缺點:
- 執行速度稍慢
- 完成相同功能所需的指令熟練多
頻繁的訪問堆疊,意味著頻繁的訪問記憶體,相對于處理器,記憶體才是執行速度的瓶頸,
Javac編譯程序分為哪些步驟?
- 決議與填充符號表
- 插入式注解處理器的注解處理
- 分析與位元組碼生成
什么是即時編譯器?
Java程式最初是通過解釋器進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會把這些代碼認定為“熱點代碼”(Hot Spot Code),
為了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,并進行各種層次的優化,完成這個任務的編譯器成為即時編譯器(Just In Time Compiler,JIT編譯器),
解釋器和編譯器
許多主流的商用虛擬機,都同時包含解釋器和編譯器,
- 當程式需要快速啟動和執行時,解釋器首先發揮作用,省去編譯的時間,立即執行,
- 當程式運行后,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼,可以提高執行效率,
如果記憶體資源限制較大(部分嵌入式系統),可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率,同時編譯器的代碼還能退回成解釋器的代碼,
為什么要采用分層編譯?
因為即時編譯器編譯本地代碼需要占用程式運行時間,要編譯出優化程度更高的代碼,所花費的時間越長,
分層編譯器有哪些層次?
分層編譯根據編譯器編譯、優化的規模和耗時,劃分不同的編譯層次,包括:
- 第0層:程式解釋執行,解釋器不開啟性能監控功能,可出發第1層編譯,
- 第1層:也成為C1編譯,將位元組碼編譯為本地代碼,進行簡單可靠的優化,如有必要加入性能監控的邏輯,
- 第2層:也成為C2編譯,也是將位元組碼編譯為本地代碼,但是會啟用一些編譯耗時較長的優化,甚至會根據性能監控資訊進行一些不可靠的激進優化,
用Client Compiler和Server Compiler將會同時作業,用Client Compiler獲取更高的編譯速度,用Server Compiler獲取更好的編譯質量,
編譯物件與觸發條件
熱點代碼有哪些?
- 被多次呼叫的方法
- 被多次執行的回圈體
如何判斷一段代碼是不是熱點代碼?
要知道一段代碼是不是熱點代碼,是不是需要觸發即時編譯,這個行為稱為熱點探測,主要有兩種方法:
- 基于采樣的熱點探測,虛擬機周期性檢查各個執行緒的堆疊頂,如果發現某個方法經常出現在堆疊頂,那這個方法就是“熱點方法”,實作簡單高效,但是很難精確確認一個方法的熱度,
- 基于計數器的熱點探測,虛擬機會為每個方法建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值,就認為它是熱點方法,
HotSpot虛擬機使用第二種,有兩個計數器:
- 方法呼叫計數器
- 回邊計數器(判斷回圈代碼)
方法呼叫計數器統計方法
統計的是一個相對的執行頻率,即一段時間內方法被呼叫的次數,當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會被減少一半,這個程序稱為方法呼叫計數器的熱度衰減,這個時間就被稱為半衰周期,
有哪些經典的優化技術(即時編譯器)?
- 語言無關的經典優化技術之一:公共子運算式消除
- 語言相關的經典優化技術之一:陣列范圍檢查消除
- 最重要的優化技術之一:方法行內
- 最前沿的優化技術之一:逃逸分析
公共子運算式消除
普遍應用于各種編譯器的經典優化技術,它的含義是:
如果一個運算式E已經被計算過了,并且從先前的計算到現在E中所有變數的值都沒有發生變化,那么E的這次出現就成了公共子運算式,沒有必要重新計算,直接用結果代替E就可以了,
陣列邊界檢查消除
因為Java會自動檢查陣列越界,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對于擁有大量陣列訪問的程式代碼,這無疑是一種性能負擔,
如果陣列訪問發生在回圈之中,并且使用回圈變數來進行陣列訪問,如果編譯器只要通過資料流分析就可以判定回圈變數的取值范圍永遠在陣列區間內,那么整個回圈中就可以把陣列的上下界檢查消除掉,可以節省很多次的條件判斷操作,
方法行內
行內消除了方法呼叫的成本,還為其他優化手段建立良好的基礎,
編譯器在進行行內時,如果是非虛方法,那么直接行內,如果遇到虛方法,則會查詢當前程式下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那么也可以行內,不過這種行內屬于激進優化,需要預留一個逃生門(Guard條件不成立時的Slow Path),稱為守護行內,
如果程式的后續執行程序中,虛擬機一直沒有加載到會令這個方法的接受者的繼承關系發現變化的類,那么行內優化的代碼可以一直使用,否則需要拋棄掉已經編譯的代碼,退回到解釋狀態執行,或者重新進行編譯,
逃逸分析
逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法里面被定義后,它可能被外部方法所參考,這種行為被稱為方法逃逸,被外部執行緒訪問到,被稱為執行緒逃逸,
如果物件不會逃逸到方法或執行緒外,可以做什么優化?
- 堆疊上分配:一般物件都是分配在Java堆中的,對于各個執行緒都是共享和可見的,只要持有這個物件的參考,就可以訪問堆中存盤的物件資料,但是垃圾回收和整理都會耗時,如果一個物件不會逃逸出方法,可以讓這個物件在堆疊上分配記憶體,物件所占用的記憶體空間就可以隨著堆疊幀出堆疊而銷毀,如果能使用堆疊上分配,那大量的物件會隨著方法的結束而自動銷毀,垃圾回收的壓力會小很多,
- 同步消除:執行緒同步本身就是很耗時的程序,如果逃逸分析能確定一個變數不會逃逸出執行緒,那這個變數的讀寫肯定就不會有競爭,同步措施就可以消除掉,
- 標量替換:不創建這個物件,直接創建它的若干個被這個方法使用到的成員變數來替換,
Java與C/C++的編譯器對比
- 即時編譯器運行占用的是用戶程式的運行時間,具有很大的時間壓力,
- Java語言雖然沒有virtual關鍵字,但是使用虛方法的頻率遠大于C++,所以即時編譯器進行優化時難度要遠遠大于C++的靜態優化編譯器,
- Java語言是可以動態擴展的語言,運行時加載新的類可能改變程式型別的繼承關系,使得全域的優化難以進行,因為編譯器無法看見程式的全貌,編譯器不得不時刻注意并隨著型別的變化,而在運行時撤銷或重新進行一些優化,
- Java語言物件的記憶體分配是在堆上,只有方法的區域變數才能在堆疊上分配,C++的物件有多種記憶體分配方式,
物理機如何處理并發問題?
運算任務,除了需要處理器計算之外,還需要與記憶體互動,如讀取運算資料、存盤運算結果等(不能僅靠暫存器來解決),
計算機的存盤設備和處理器的運算速度差了幾個數量級,所以不得不加入一層讀寫速度盡可能接近處理器運算速度的高速快取(Cache),作為記憶體與處理器之間的緩沖:將運算需要的資料復制到快取中,讓運算快速運行,當運算結束后再從快取同步回記憶體,這樣處理器就無需等待緩慢的記憶體讀寫了,
基于高速快取的存盤互動很好地解決了處理器與記憶體的速度矛盾,但是引入了一個新的問題:快取一致性,在多處理器系統中,每個處理器都有自己的高速快取,它們又共享同一主記憶體,當多個處理器的運算任務都涉及同一塊主記憶體時,可能導致各自的快取資料不一致,
為了解決一致性的問題,需要各個處理器訪問快取時遵循快取一致性協議,同時為了使得處理器充分被利用,處理器可能會對輸出代碼進行亂序執行優化,Java虛擬機的即時編譯器也有類似的指令重排序優化,
Java 記憶體模型
什么是Java記憶體模型?
Java虛擬機的規范,用來屏蔽掉各種硬體和作業系統的記憶體訪問差異,以實作讓Java程式在各個平臺下都能達到一致的并發效果,
Java記憶體模型的目標?
定義程式中各個變數的訪問規則,即在虛擬機中將變數存盤到記憶體和從記憶體中取出這樣的底層細節,此處的變數包括實體欄位、靜態欄位和構成陣列物件的元素,但是不包括區域變數和方法引數,因為這些是執行緒私有的,不會被共享,所以不存在競爭問題,
主記憶體與作業記憶體
所以的變數都存盤在主記憶體,每條執行緒還有自己的作業記憶體,保存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值)都必須在作業記憶體中進行,不能直接讀寫主記憶體的變數,不同的執行緒之間也無法直接訪問對方作業記憶體的變數,執行緒間變數值的傳遞需要通過主記憶體,
記憶體間的互動操作
一個變數如何從主記憶體拷貝到作業記憶體、如何從作業記憶體同步回主記憶體,Java記憶體模型定義了8種操作:
原子性、可見性、有序性
- 原子性:對基本資料型別的訪問和讀寫是具備原子性的,對于更大范圍的原子性保證,可以使用位元組碼指令monitorenter和monitorexit來隱式使用lock和unlock操作,這兩個位元組碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此synchronized塊之間的操作也具有原子性,
- 可見性:當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改,Java記憶體模型是通過在變數修改后將新值同步回主記憶體,在變數讀取之前從主記憶體重繪變數值來實作可見性的,volatile的特殊規則保證了新值能夠立即同步到主記憶體,每次使用前立即從主記憶體重繪,synchronized和final也能實作可見性,final修飾的欄位在構造器中一旦被初始化完成,并且構造器沒有把this的參考傳遞出去,那么其他執行緒中就能看見final欄位的值,
- 有序性:Java程式的有序性可以總結為一句話,如果在本執行緒內觀察,所有的操作都是有序的(執行緒內表現為串行的語意);如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的(指令重排序和作業記憶體與主記憶體同步延遲線性),
volatile
什么是volatile?
關鍵字volatile是Java虛擬機提供的最輕量級的同步機制,當一個變數被定義成volatile之后,具備兩種特性:
- 保證此變數對所有執行緒的可見性,當一條執行緒修改了這個變數的值,新值對于其他執行緒是可以立即得知的,而普通變數做不到這一點,
- 禁止指令重排序優化,普通變數僅僅能保證在該方法執行程序中,得到正確結果,但是不保證程式代碼的執行順序,
為什么基于volatile變數的運算在并發下不一定是安全的?
volatile變數在各個執行緒的作業記憶體,不存在一致性問題(各個執行緒的作業記憶體中volatile變數,每次使用前都要重繪到主記憶體),但是Java里面的運算并非原子操作,導致volatile變數的運算在并發下一樣是不安全的,
為什么使用volatile?
在某些情況下,volatile同步機制的性能要優于鎖(synchronized關鍵字),但是由于虛擬機對鎖實行的許多消除和優化,所以并不是很快,
volatile變數讀操作的性能消耗與普通變數幾乎沒有差別,但是寫操作則可能慢一些,因為它需要在本地代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行,
并發與執行緒
并發與執行緒的關系?
并發不一定要依賴多執行緒,PHP中有多行程并發,但是Java里面的并發是多執行緒的,
什么是執行緒?
執行緒是比行程更輕量級的調度執行單位,執行緒可以把一個行程的資源分配和執行調度分開,各個執行緒既可以共享行程資源(記憶體地址、檔案I/O),又可以獨立調度(執行緒是CPU調度的最基本單位),
實作執行緒有哪些方式?
- 使用內核執行緒實作
- 使用用戶執行緒實作
- 使用用戶執行緒+輕量級行程混合實作
Java執行緒的實作
作業系統支持怎樣的執行緒模型,在很大程度上就決定了Java虛擬機的執行緒是怎樣映射的,
Java執行緒調度
什么是執行緒調度?
執行緒調度是系統為執行緒分配處理器使用權的程序,
執行緒調度有哪些方法?
- 協同式執行緒調度:實作簡單,沒有執行緒同步的問題,但是執行緒執行時間不可控,容易系統崩潰,
- 搶占式執行緒調度:每個執行緒由系統來分配執行時間,不會有執行緒導致整個行程阻塞的問題,
雖然Java執行緒調度是系統自動完成的,但是我們可以建議系統給某些執行緒多分配點時間——設定執行緒優先級,Java語言有10個級別的執行緒優先級,優先級越高的執行緒,越容易被系統選擇執行,
但是并不能完全依靠執行緒優先級,因為Java的執行緒是被映射到系統的原生執行緒上,所以執行緒調度最侄訓是由作業系統說了算,如Windows中只有7種優先級,所以Java不得不出現幾個優先級相同的情況,同時優先級可能會被系統自行改變,Windows系統中存在一個“優先級推進器”,當系統發現一個執行緒執行特別勤奮,可能會越過執行緒優先級為它分配執行時間,
執行緒安全的定義?
當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在呼叫方法進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件就是執行緒安全的,
Java語言操作的共享資料,包括哪些?
- 不可變
- 絕對執行緒安全
- 相對執行緒安全
- 執行緒兼容
- 執行緒對立
不可變
在Java語言里,不可變的物件一定是執行緒安全的,只要一個不可變的物件被正確構建出來,那其外部的可見狀態永遠也不會改變,永遠也不會在多個執行緒中處于不一致的狀態,
如何實作執行緒安全?
虛擬機提供了同步和鎖機制,
- 阻塞同步(互斥同步)
- 非阻塞同步
阻塞同步(互斥同步)
互斥是實作同步的一種手段,臨界區、互斥量和信號量都是主要的互斥實作方式,Java中最基本的同步手段就是synchronized關鍵字,其編譯后會在同步塊的前后分別形成monitorenter和monitorexit兩個位元組碼指令,這兩個位元組碼都需要一個Reference型別的引數指明要鎖定和解鎖的物件,如果Java程式中的synchronized明確指定了物件引數,那么這個物件就是Reference;如果沒有明確指定,那就根據synchronized修飾的是實體方法還是類方法,去獲取對應的物件實體或Class物件作為鎖物件,
在執行monitorenter指令時,首先要嘗試獲取物件的鎖,
- 如果這個物件沒有鎖定,或者當前執行緒已經擁有了這個物件的鎖,把鎖的計數器+1;當執行monitorexit指令時將鎖計數器-1,當計數器為0時,鎖就被釋放了,
- 如果獲取物件失敗了,那當前執行緒就要阻塞等待,知道物件鎖被另外一個執行緒釋放為止,
除了synchronized之外,還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實作同步,ReentrantLock比synchronized增加了高級功能:等待可中斷、可實作公平鎖、鎖可以系結多個條件,
等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,對處理執行時間非常長的同步塊很有用,
公平鎖:多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖,synchronized中的鎖是非公平的,
非阻塞同步
互斥同步最大的問題,就是進行執行緒阻塞和喚醒所帶來的性能問題,是一種悲觀的并發策略,總是認為只要不去做正確的同步措施(加鎖),那就肯定會出問題,無論共享資料是否真的會出現競爭,它都要進行加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要被喚醒等操作,
隨著硬體指令集的發展,我們可以使用基于沖突檢測的樂觀并發策略,先進行操作,如果沒有其他執行緒征用資料,那操作就成功了;如果共享資料有征用,產生了沖突,那就再進行其他的補償措施,這種樂觀的并發策略的許多實作不需要執行緒掛起,所以被稱為非阻塞同步,
鎖優化是在JDK的那個版本?
JDK1.6的一個重要主題,就是高效并發,HotSpot虛擬機開發團隊在這個版本上,實作了各種鎖優化:
- 適應性自旋
- 鎖消除
- 鎖粗化
- 輕量級鎖
- 偏向鎖
為什么要提出自旋鎖?
互斥同步對性能最大的影響是阻塞的實作,掛起執行緒和恢復執行緒的操作都需要轉入內核態中完成,這些操作給系統的并發性帶來很大壓力,同時很多應用共享資料的鎖定狀態,只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒并不值得,先不掛起執行緒,等一會兒,
自旋鎖的原理?
如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時并行執行,讓后面請求鎖的執行緒稍等一會,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放,為了讓執行緒等待,我們只需讓執行緒執行一個忙回圈(自旋),
自旋的缺點?
自旋等待本身雖然避免了執行緒切換的開銷,但它要占用處理器時間,所以如果鎖被占用的時間很短,自旋等待的效果就非常好;如果時間很長,那么自旋的執行緒只會白白消耗處理器的資源,所以自旋等待的時間要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,那就應該使用傳統的方式掛起執行緒了,
什么是自適應自旋?
自旋的時間不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,
- 如果一個鎖物件,自旋等待剛剛成功獲得鎖,并且持有鎖的執行緒正在運行,那么虛擬機認為這次自旋仍然可能成功,進而運行自旋等待更長的時間,
- 如果對于某個鎖,自旋很少成功,那在以后要獲取這個鎖,可能省略掉自旋程序,以免浪費處理器資源,
有了自適應自旋,隨著程式運行和性能監控資訊的不斷完善,虛擬機對程式鎖的狀況預測就會越來越準確,虛擬機也會越來越聰明,
鎖消除
鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但被檢測到不可能存在共享資料競爭的鎖進行消除,主要根據逃逸分析,
程式員怎么會在明知道不存在資料競爭的情況下使用同步呢?很多不是程式員自己加入的,
鎖粗化
原則上,同步塊的作用范圍要盡量小,但是如果一系列的連續操作都對同一個物件反復加鎖和解鎖,甚至加鎖操作在回圈體內,頻繁地進行互斥同步操作也會導致不必要的性能損耗,
鎖粗化就是增大鎖的作用域,
輕量級鎖
在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的性能消耗,
偏向鎖
消除資料在無競爭情況下的同步原語,進一步提高程式的運行性能,即在無競爭的情況下,把整個同步都消除掉,這個鎖會偏向于第一個獲得它的執行緒,如果在接下來的執行程序中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要同步,
參考:《深入理解Java虛擬機:JVM高級特性與最佳實踐(第2版)》
點擊關注,第一時間了解華為云新鮮技術~
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/528703.html
標籤:Java
