java實戰--JVM終極總結

 CPU和內存的交互

瞭解jvm內存模型前,瞭解下cpu和計算機內存的交互情況。【因爲Java虛擬機內存模型定義的訪問操作與計算機十分相似】有篇很棒的文章,從cpu講到內存模型:什麼是java內存模型在計算機中,cpu和內存的交互最爲頻繁,相比內存,磁盤讀寫太慢,內存相當於高速的緩衝區。但是隨着cpu的發展,內存的讀寫速度也遠遠趕不上cpu。因此cpu廠商在每顆cpu上加上高速緩存,用於緩解這種情況。現在cpu和內存的交互大致如下。

cpu、緩存、內存

cpu上加入了高速緩存這樣做解決了處理器和內存的矛盾(一快一慢),但是引來的新的問題 - 緩存一致性

在多核cpu中,每個處理器都有各自的高速緩存(L1,L2,L3),而主內存確只有一個 。

CPU要讀取一個數據時,首先從一級緩存中查找,如果沒有找到再從二級緩存中查找,如果還是沒有就從三級緩存或內存中查找,每個cpu有且只有一套自己的緩存。

如何保證多個處理器運算涉及到同一個內存區域時,多線程場景下會存在緩存一致性問題,那麼運行時保證數據一致性?

爲了解決這個問題,各個處理器需遵循一些協議保證一致性。【如MSI,MESI啥啥的協議。。】

多核CPU

多核CPU即多個CPU組成,這些CPU集成在一個芯片裏,可以通過內部總線來交互數據,共享數據,這些CPU中分配出一個獨立的核執行操作系統,每個核都有自己的寄存器,alu運算單元等(這些都是封裝在cpu內部的),但是一級二級緩存是共享的,這些CPU通過總線來交互數據,並且工作是並行的,資源分配是由操作系統來完成的,操作系統來決定程序cpu的控制權分配,所以一個多核cpu的工作效率大多體現在操作系統的分配上,因爲一個CPU基本上可以執行很多個程序,通過PCB進程控制塊的方式存儲當前代碼段,然後來回跳轉,所以當你的CPU核過多時,操作系統在分配時可能會導致部分CPU閒置!

大概如下

cpu與內存.png

在CPU層面,內存屏障提供了個充分必要條件

 內存屏障(Memory Barrier)

CPU中,每個CPU又有多級緩存【上圖統一定義爲高速緩存】,一般分爲L1,L2,L3,因爲這些緩存的出現,提高了數據訪問性能,避免每次都向內存索取,但是弊端也很明顯,不能實時的和內存發生信息交換,分在不同CPU執行的不同線程對同一個變量的緩存值不同。

  • 硬件層的內存屏障分爲兩種:Load BarrierStore Barrier即讀屏障和寫屏障。【內存屏障是硬件層的】

爲什麼需要內存屏障

由於現代操作系統都是多處理器操作系統,每個處理器都會有自己的緩存,可能存再不同處理器緩存不一致的問題,而且由於操作系統可能存在重排序,導致讀取到錯誤的數據,因此,操作系統提供了一些內存屏障以解決這種問題.
簡單來說:
1.在不同CPU執行的不同線程對同一個變量的緩存值不同,爲了解決這個問題。
2.用volatile可以解決上面的問題,不同硬件對內存屏障的實現方式不一樣。java屏蔽掉這些差異,通過jvm生成內存屏障的指令。
對於讀屏障:在指令前插入讀屏障,可以讓高速緩存中的數據失效,強制從主內存取。

內存屏障的作用

cpu執行指令可能是無序的,它有兩個比較重要的作用
1.阻止屏障兩側指令重排序
2.強制把寫緩衝區/高速緩存中的髒數據等寫回主內存,讓緩存中相應的數據失效。

volatile型變量

當我們聲明某個變量爲volatile修飾時,這個變量就有了線程可見性,volatile通過在讀寫操作前後添加內存屏障。

用代碼可以這麼理解

//相當於讀寫時加鎖,保證及時可見性,併發時不被隨意修改。
public class SynchronizedInteger {
  private long value;
  public synchronized int get() {
    return value;
  }
  public synchronized void set(long value) {
    this.value = value;
  }
}

volatile型變量擁有如下特性

1.可見性,對於一個該變量的讀,一定能看到讀之前最後的寫入。
2.原子性,對volatile變量的讀寫具有原子性,即單純讀和寫的操作,都不會受到干擾。

Java內存區域

前提:本文講的基本都是以Sun HotSpot虛擬機爲基礎的,Oracle收購了Sun後目前得到了兩個【Sun的HotSpot和JRockit(以後可能合併這兩個),還有一個是IBM的IBMJVM】

之所以扯了那麼多計算機內存模型,是因爲java內存模型的設定符合了計算機的規範。Java程序內存的分配是在JVM虛擬機內存分配機制下完成。java內存模型(Java Memory Model ,JMM)就是一種符合內存模型規範的,屏蔽了各種硬件和操作系統的訪問差異的,保證了Java程序在各種平臺下對內存的訪問都能保證效果一致的機制及規範。

簡要言之,jmm是jvm的一種規範,定義了jvm的內存模型。它屏蔽了各種硬件和操作系統的訪問差異,不像c那樣直接訪問硬件內存,相對安全很多,它的主要目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。可以保證併發編程場景中的原子性、可見性和有序性。

從下面這張圖可以看出來,Java數據區域分爲五大數據區域。這些區域各有各的用途,創建及銷燬時間。

其中方法區和堆是所有線程共享的,棧,本地方法棧和程序虛擬機則爲線程私有的。

jvm五大區作用簡介

  1. 計數器:保證線程執行切換的時候就可以在上次執行的基礎上繼續執行(每個線程獨有計數器也是唯一一個沒有OOM異常的區域。
  2. 方法區:方法區同堆一樣,是所有線程共享的內存區域,爲了區分堆,又被稱爲非堆。
  3. 用於存儲已被虛擬機加載的類信息、常量、靜態變量,如static修飾的變量加載類的時候就被加載到方法區中。
  4. 本地方法區:  本地方法棧是與虛擬機棧發揮的作用十分相似,區別是虛擬機棧執行的是Java方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的native方法服務
  5. 堆:對於大多數應用來說,堆是java虛擬機管理內存最大的一塊內存區域,因爲堆存放的對象是線程共享的,所以多線程的時候也需要同步機制,所有new出來的對象都存在堆中。
  6. 虛擬機棧(棧):每個方法被執行的時候都會創建一個棧幀用於存儲局部變量表,操作棧,動態鏈接,方法出口等信息。每一個方法被調用的過程就對應一個棧幀在虛擬機棧中從入棧到出棧的過程(對象的引用)

根據java虛擬機規範,java虛擬機管理的內存將分爲下面五大區域。

五大內存區域

 程序計數器

程序計數器是一塊很小的內存空間,它是線程私有的,可以認作爲當前線程的行號指示器。

爲什麼需要程序計數器

             在多線程的情況下,爲了讓每個線程正常工作就提出了程序計數器(Programe Counter Register),每個線程都有自己的程序計數器這樣當線程執行切換的時候就可以在上次執行的基礎上繼續執行,僅僅從一條線程線性執行的角度而言,代碼是一條一條的往下執行的,這個時候就是程序計數器;JVM就是通過讀取程序計數器的值來決定下一條需要執行的字節碼指令,進而進行選擇語句、循環、異常處理等;我們熟悉的分支操作、循環操作、跳轉、異常處理和線程恢復等基礎模型都需要依賴這個計數器來完成。

我們知道對於一個處理器(如果是多核cpu那就是一核),在一個確定的時刻都只會執行一條線程中的指令,一條線程中有多個指令,爲了線程切換可以恢復到正確執行位置,每個線程都需有獨立的一個程序計數器,不同線程之間的程序計數器互不影響,獨立存儲。

注意:如果線程執行的是個java方法,那麼計數器記錄虛擬機字節碼指令的地址。如果爲native【底層方法】,那麼計數器爲空。這塊內存區域是虛擬機規範中唯一沒有OutOfMemoryError的區域

 Java棧(虛擬機棧)

          同計數器也爲線程私有,生命週期與相同,就是我們平時說的棧,棧描述的是Java方法執行的內存模型

每個方法被執行的時候都會創建一個棧幀用於存儲局部變量表,操作棧,動態鏈接,方法出口等信息。每一個方法被調用的過程就對應一個棧幀在虛擬機棧中從入棧到出棧的過程。【棧先進後出,下圖棧1先進最後出來】

棧幀: 是用來存儲數據和部分過程結果的數據結構。
棧幀的位置:  內存 -> 運行時數據區 -> 某個線程對應的虛擬機棧 -> here[在這裏]
棧幀大小確定時間: 編譯期確定,不受運行期數據影響。

通常有人將java內存區分爲棧和堆,實際上java內存比這複雜,這麼區分可能是因爲我們最關注,與對象內存分配關係最密切的是這兩個。

平時說的棧一般指局部變量表部分。

局部變量表:一片連續的內存空間,用來存放方法參數,以及方法內定義的局部變量,存放着編譯期間已知的數據類型(八大基本類型和對象引用(reference類型),returnAddress類型。它的最小的局部變量表空間單位爲Slot,虛擬機沒有指明Slot的大小,但在jvm中,long和double類型數據明確規定爲64位,這兩個類型佔2個Slot,其它基本類型固定佔用1個Slot。

reference類型:與基本類型不同的是它不等同本身,即使是String,內部也是char數組組成,它可能是指向一個對象起始位置指針,也可能指向一個代表對象的句柄或其他與該對象有關的位置。

returnAddress類型:指向一條字節碼指令的地址【深入理解Java虛擬機】怎麼理解returnAddress

棧幀

     需要注意的是,局部變量表所需要的內存空間在編譯期完成分配,當進入一個方法時,這個方法在棧中需要分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表大小。

Java虛擬機棧可能出現兩種類型的異常:

  1. 線程請求的棧深度大於虛擬機允許的棧深度,將拋出StackOverflowError。
  2. 虛擬機棧空間可以動態擴展,當動態擴展是無法申請到足夠的空間時,拋出OutOfMemory異常。

本地方法區

         本地方法棧是與虛擬機棧發揮的作用十分相似,區別是虛擬機棧執行的是Java方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的native方法服務,可能底層調用的c或者c++,我們打開jdk安裝目錄可以看到也有很多用c編寫的文件,可能就是native方法所調用的c代碼。

           對於大多數應用來說,堆是java虛擬機管理內存最大的一塊內存區域,因爲堆存放的對象是線程共享的,所以多線程的時候也需要同步機制,所有new出來的對象都存在堆中。

java虛擬機規範對這塊的描述是:所有對象實例及數組都要在堆上分配內存,但隨着JIT編譯器的發展和逃逸分析技術的成熟,這個說法也不是那麼絕對,但是大多數情況都是這樣的。

即時編譯器:可以把把Java的字節碼,包括需要被解釋的指令的程序)轉換成可以直接發送給處理器的指令的程序)

逃逸分析:通過逃逸分析來決定某些實例或者變量是否要在堆中進行分配,如果開啓了逃逸分析,即可將這些變量直接在棧上進行分配,而非堆上進行分配。這些變量的指針可以被全局所引用,或者其其它線程所引用。

參考逃逸分析

注意:它是所有線程共享的,它的目的是存放對象實例。同時它也是GC所管理的主要區域,因此常被稱爲GC堆,又由於現在收集器常使用分代算法,Java堆中還可以細分爲新生代和老年代,再細緻點還有Eden(伊甸園)空間之類的不做深究。

根據虛擬機規範,Java堆可以存在物理上不連續的內存空間,就像磁盤空間只要邏輯是連續的即可。它的內存大小可以設爲固定大小,也可以擴展。

當前主流的虛擬機如HotPot都能按擴展實現(通過設置 -Xmx和-Xms),如果堆中沒有內存內存完成實例分配,而且堆無法擴展將報OOM錯誤(OutOfMemoryError)

 方法區

方法區同堆一樣,是所有線程共享的內存區域,爲了區分堆,又被稱爲非堆。

用於存儲已被虛擬機加載的類信息、常量、靜態變量,如static修飾的變量加載類的時候就被加載到方法區中。

運行時常量池是方法區的一部分,class文件除了有類的字段、接口、方法等描述信息之外,還有常量池用於存放編譯期間生成的各種字面量和符號引用。

在老版jdk,方法區也被稱爲永久代【因爲沒有強制要求方法區必須實現垃圾回收,HotSpot虛擬機以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分區域,從而不需要專門爲這部分設計垃圾回收機制。不過自從JDK7之後,Hotspot虛擬機便將運行時常量池從永久代移除了。】

jdk1.7開始逐步去永久代。從String.interns()方法可以看出來
String.interns()
native方法:作用是如果字符串常量池已經包含一個等於這個String對象的字符串,則返回代表池中的這個字符串的String對象,在jdk1.6及以前常量池分配在永久代中。可通過 -XX:PermSize和-XX:MaxPermSize限制方法區大小。
public class StringIntern {
    //運行如下代碼探究運行時常量池的位置
    public static void main(String[] args) throws Throwable {
        //用list保持着引用 防止full gc回收常量池
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true){
            list.add(String.valueOf(i++).intern());
        }
    }
}
//如果在jdk1.6環境下運行 同時限制方法區大小 將報OOM後面跟着PermGen space說明方法區OOM,即常量池在永久代
//如果是jdk1.7或1.8環境下運行 同時限制堆的大小  將報heap space 即常量池在堆中

字符串常量池(String Constant Pool):

字符串常量池在Java內存區域的哪個位置?

  • 在JDK6.0及之前版本,字符串常量池是放在Perm Gen區(也就是方法區)中;
  • 在JDK7.0版本,字符串常量池被移到了堆中了。至於爲什麼移到堆內,大概是由於方法區的內存空間太小了。
  • JDK8以後也還是放在了Heap空間中,並沒有已到元空間

字符串常量池是什麼?

  • 在HotSpot VM裏實現的string pool功能的是一個StringTable類,它是一個Hash表,默認值大小長度是1009;這個StringTable在每個HotSpot VM的實例只有一份,被所有的類共享。字符串常量由一個一個字符組成,放在了StringTable上。
  • 在JDK6.0中,StringTable的長度是固定的,長度就是1009,因此如果放入String Pool中的String非常多,就會造成hash衝突,導致鏈表過長,當調用String#intern()時會需要到鏈表上一個一個找,從而導致性能大幅度下降;
  • 在JDK7.0中,StringTable的長度可以通過參數指定:
-XX:StringTableSize=66666

字符串常量池裏放的是什麼?

  • 在JDK6.0及之前版本中,String Pool裏放的都是字符串常量;
  • 在JDK7.0中,由於String#intern()發生了改變,因此String Pool中也可以存放放於堆內的字符串對象的引用。關於String在內存中的存儲和String#intern()方法的說明,可以參考我的另外一篇博客:

需要說明的是:字符串常量池中的字符串只存在一份! 
如:

String s1 = "hello,world!";
String s2 = "hello,world!";

即執行完第一行代碼後,常量池中已存在 “hello,world!”,那麼 s2不會在常量池中申請新的空間,而是直接把已存在的字符串內存地址返回給s2。(這裏具體的字符串如何分配就不細說了,可以看我的另一篇博客)

2.class常量池(Class Constant Pool):

2.1:class常量池簡介:

  • 我們寫的每一個Java類被編譯後,就會形成一份class文件;class文件中除了包含類的版本、字段、方法、接口等描述信息外,還有一項信息就是常量池(constant pool table),用於存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References);
  • 每個class文件都有一個class常量池。

2.2:什麼是字面量和符號引用:

  • 字面量包括:1.文本字符串 2.八種基本類型的值 3.被聲明爲final的常量等;
  • 符號引用包括:1.類和方法的全限定名 2.字段的名稱和描述符 3.方法的名稱和描述符。

3.運行時常量池(Runtime Constant Pool):

  • 運行時常量池存在於內存中,也就是class常量池被加載到內存之後的版本,不同之處是:它的字面量可以動態的添加(String#intern()),符號引用可以被解析爲直接引用
  • JVM在執行某個類的時候,必須經過加載、連接、初始化,而連接又包括驗證、準備、解析三個階段。而當類加載到內存中後,jvm就會將class常量池中的內容存放到運行時常量池中,由此可知,運行時常量池也是每個類都有一個。在解析階段,會把符號引用替換爲直接引用,解析的過程會去查詢字符串常量池,也就是我們上面所說的StringTable,以保證運行時常量池所引用的字符串與字符串常量池中是一致的。

idea設置相關內存大小設置

這邊不用全局的方式,設置main方法的vm參數。

做相關設置,比如說這邊設定堆大小。(-Xmx5m -Xms5m -XX:-UseGCOverheadLimit)

jdk8真正開始廢棄永久代,而使用元空間(Metaspace)

java虛擬機對方法區比較寬鬆,除了跟堆一樣可以不存在連續的內存空間,定義空間和可擴展空間,還可以選擇不實現垃圾收集。

內存溢出

兩種內存溢出異常[注意內存溢出是error級別的]
1.StackOverFlowError:當請求的棧深度大於虛擬機所允許的最大深度
2.OutOfMemoryError:虛擬機在擴展棧時無法申請到足夠的內存空間[一般都能設置擴大]

java -verbose:class -version 可以查看剛開始加載的類,可以發現這兩個類並不是異常出現的時候纔去加載,而是jvm啓動的時候就已經加載。這麼做的原因是在vm啓動過程中我們把類加載起來,並創建幾個沒有堆棧的對象緩存起來,只需要設置下不同的提示信息即可,當需要拋出特定類型的OutOfMemoryError異常的時候,就直接拿出緩存裏的這幾個對象就可以了。

比如說OutOfMemoryError對象,jvm預留出4個對象【固定常量】,這就爲什麼最多出現4次有堆棧的OutOfMemoryError異常及大部分情況下都將看到沒有堆棧的OutOfMemoryError對象的原因。

參考OutOfMemoryError解讀

Snip20180904_8.png

兩個基本的例子

public class MemErrorTest {
    public static void main(String[] args) {
        try {
            List<Object> list = new ArrayList<Object>();
            for(;;) {
                list.add(new Object()); //創建對象速度可能高於jvm回收速度
            }
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }
        try {
            hi();//遞歸造成StackOverflowError 這邊因爲每運行一個方法將創建一個棧幀,棧幀創建太多無法繼續申請到內存擴展
        } catch (StackOverflowError e) {
            e.printStackTrace();
        }
    }
    public static void hi() {
        hi();
    }
}

Java類加載機制

1.概述

       Class文件由類裝載器裝載後,在JVM中將形成一份描述Class結構的元信息對象,通過該元信息對象可以獲知Class的結構信息:如構造函數,屬性和方法等,Java允許用戶藉由這個Class相關的元信息對象間接調用Class對象的功能。

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

2.工作機制

      類裝載器就是尋找類的字節碼文件,並構造出類在JVM內部表示的對象組件。在Java中,類裝載器把一個類裝入JVM中,要經過以下步驟:

     (1) 裝載:查找和導入Class文件;

     (2) 鏈接:把類的二進制數據合併到JRE中;

        (a)校驗:檢查載入Class文件數據的正確性;

        (b)準備:給類的靜態變量分配存儲空間;

        (c)解析:將符號引用轉成直接引用;

     (3) 初始化:對類的靜態變量,靜態代碼塊執行初始化操作

   java程序經過編譯後形成*.class文件。通過類加載器將字節碼(*.class)加載入JVM的內存中。JVM將類加載

類加載器的分類

Java類加載器採用雙親委派模型:

 

1.啓動類加載器:這個類加載器負責放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的類庫。用戶無法直接使用。

2.擴展類加載器:這個類加載器由sun.misc.Launcher$AppClassLoader實現。它負責<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫。用戶可以直接使用。

3.應用程序類加載器:這個類由sun.misc.Launcher$AppClassLoader實現。是ClassLoader中getSystemClassLoader()方法的返回值。它負責用戶路徑(ClassPath)所指定的類庫。用戶可以直接使用。如果用戶沒有自己定義類加載器,默認使用這個。

4.自定義加載器:用戶自己定義的類加載器。

掃一掃加入大數據公衆號,瞭解更多大數據技術,還有免費資料等你哦

掃一掃加入大數據公衆號,瞭解更多大數據技術,還有免費資料等你哦

掃一掃加入大數據公衆號,瞭解更多大數據技術,還有免費資料等你哦

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