日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

当我们在谈论内存时,我们在谈论什么

發布時間:2024/4/11 编程问答 49 豆豆
生活随笔 收集整理的這篇文章主要介紹了 当我们在谈论内存时,我们在谈论什么 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

點擊上方“朱小廝的博客”,選擇“設為星標”

后臺回復”加群“獲取公眾號專屬群聊入口

來源:阿里巴巴中間件

內存,是程序員繞不過的一道坎。寫過 C 和 C++ 的人想必都會對內存的手動分配和釋放難以忘懷,在 Java 中,得益于 JVM 的自動垃圾回收( GC )機制,大部分情況下編程并不需要關心內存的分配與回收。當然,有了 GC 并不意味著就完事大吉了,如果不了解其中的原理,以錯誤的姿勢濫用 GC ,很有可能翻車釀成不可估量的損失。

在經歷過一次嚴重的線上故障之后,本文試圖深入分析 JVM 的內存管理機制,探索如何監控和避免內存使用量過高的場景出現。難免有錯誤之處,還請各位指正。

內存是什么?


這個問題看似很好回答:內存不就是一塊存放運行時數據的空間么。但,真的只是這么簡單嗎?

當你在編寫代碼時,你是否真正感受到過它的存在?當你不知不覺創建出一個巨大的緩存對象時,是否思考過它會占用多少內存,又將在何時被回收?我相信大多數的 Java 程序員在編寫代碼時不會思考這些問題,這一方面證明了 JVM 自動內存管理機制設計的成功,但另一方面也說明 GC 正在不知不覺中被濫用。

對于程序員而言,內存究竟是什么呢?在編寫代碼時(開發態),內存是指針,是引用,也是偏移地址,這是我們在代碼中能夠直接與內存打交道的三種最常見的方式;在代碼運行時(運行態),內存是 GC 頻率,是 GC 時長,也是機器的水位,這是實際運維過程中最需要關注的三個指標。這些便是內存在實際開發中的存在形式,不管你是否注意的到,都必須承認,內存無處不在。

基礎:Java內存結構


回到 Java 本身,要想真正了解內存,必須先從 JVM 本身的內存機制入手,首先簡單地回顧下 JVM 內存結構。

JVM內存分區

JVM中將運行時數據(內存)劃分為五個區域:

1、程序計數器
程序計數器是一塊線程私有的內存區域,它是當前線程所執行的字節碼的行號指示器。簡單來說,它記錄的是當前線程正在執行的虛擬機字節碼指令(如果是 Native 方法,該值為空)。


一般我們很少關心這個區域

2、 Java 虛擬機棧
Java 虛擬機棧是一塊線程私有的內存區域,它總是和某個線程關聯在一起,每當創建一個線程時, JVM 就會為其創建一個對應的 Java 虛擬機棧,用于存儲 Java 方法執行時用到的局部變量表、操作數棧、動態鏈接、方法出口等信息。


一般我們也不怎么需要關心這個區域

3、本地方法棧
本地方法棧是為 JVM 運行 Native 方法使用的空間,它也是線程私有的內存區域,它的作用與上一小節的Java虛擬機棧的作用是類似的。除了代碼中包含的常規的 Native 方法會使用這個存儲空間,在 JVM 利用 JIT 技術時會將一些 Java 方法重新編譯為 NativeCode 代碼,這些編譯后的本地方法代碼也是利用這個棧來跟蹤方法的執行狀態。


這也是一個不怎么需要關注的區域

4、 Java 堆
JVM 管理內存中最大的一塊,也是 JVM 中最最最核心的儲存區域,被所有線程所共享。我們在 Java 中創建的對象實例就儲存在這里,堆區也是 GC 主要發生的地區。


這是我們最核心關注的內存區域

5、方法區
用于儲存類信息、常量、靜態變量等可以被多個對象實例共享的數據,這塊區域儲存的信息相對穩定,因此很少發生 GC 。在 GC 機制中稱其為“永生區”( Perm,Java 8 之后改稱元空間 Meta Space )。


由于方法區的內存很難被 GC ,因此如果使用不當,很有可能導致內存過載。


這是一塊常常被忽略,但卻很重要的內存區域。

6、堆外內存
堆外內存不是由 JVM 管理的內存,但它也是 Java 中非常重要的一種內存使用方式, NIO 等包中都頻繁地使用了堆外內存來實現“零拷貝”的效果(在網絡 IO 處理中,如果需要傳輸儲存在 JVM 內存區域中的對象,需要先將它們拷貝到堆外內存再進行傳遞,會造成額外的空間和性能浪費),主要通過 ByteBuffer 和 Unsafe 兩種方式來進行分配和使用。


但是在使用時一定要注意,堆外內存是完全不受 GC 控制的,也就是說和 C++ 一樣,需要我們手動去分配和回收內存。

Java 對象的內存結構


進一步的,我們需要了解一下在 JVM 中,一個對象在內存中是如何存放的,如下圖:

可以看到,一個 Java對象在內存中被分為4個部分:

1、Mark Word(標記字段):
對象的 Mark Word 部分占4個字節,其內容是一系列的標記位,比如輕量級鎖的標記位,偏向鎖標記位等等。

2、Class Pointer( Class 對象指針):
指向對象所屬的 Class 對象,也是占用 4 個字節( 32 位JVM)。

3、對象實際數據:
包括對象的所有成員變量(注意 static 變量并不包含在內,因為它是屬于 class 的),其大小由具體的成員變量大小決定,如 byte 和 boolean 是一個字節,int 和 float 是 4 個字節,對象的引用則是 4 個字節( 32 位 JVM )。

4、對齊填充:
為了對齊 8 個字節而增設的填充區域,這是為了提升 CPU 讀取內存的效率,詳細請看:什么是字節對齊,為什么需要字節對齊?

下面來舉一個簡單的例子:

public class Int { public int val; }

這個類實際占用的內存是 4 (mark word) + 4 (class ref)+ 4(int)+ 4(padding)= 16 字節。這其實正是Integer自動裝箱的對象所占用的內存空間大小,可以看到封裝成對象后,其占用的內存體積相比原來增加了 4 倍。

在了解了這些知識之后,讓我們來思考一個問題:

議題:如何計算一個對象占用的內存大小?


在編寫代碼的過程中我們會創建大量的對象,但你是否考慮過某個對象到底占用了多少內存呢?

在 C/C++ 中,我們可以通過 sizeof() 函數方便地計算一個變量或者類型所占用的內存大小,不過在 Java 中并沒有這樣的系統調用,但這并不意味著在 Java 中就無法實現類似的效果,結合上一節中分析的 Java 對象內存結構,只要能夠按照順序計算出各個區域所占用的內存并求和就可以了。當然這里面是有非常多的細節問題要考慮的,我們一個一個來分析。

首先需要說明的一點是,在不同位數的 JRE 中,引用的大小是不一樣的(這個很好理解,因為引用儲存的就是地址偏移量),32Bit 的 JRE 中一個引用占用 4 個字節,而 64Bit 的 JRE 中則是 8 個字節。

先看對象頭,在不開啟 JVM 對象頭壓縮的情況下, 32Bit JRE 中一個對象頭的大小是8個字節(4+4), 64Bit 的 JRE 中則是 16 個字節(8+8)。

接下來就是實例數據,這里包括所有非靜態成員變量所占用的數據,成員變量主要包括兩種:基本類型和引用類型。在確定的 JRE 運行環境中,基本類型變量和引用類型占用的內存大小都是確定的,因此只需要簡單的通過反射做個加法似乎就可以了。不過實際情況并沒有這么簡單,讓我們做一個簡單的實驗來看一看:

實驗:對象的實際內存布局

通過jol工具可以查看到一個對象的實際內存布局,現在我們創建了一個如下所示的類:

class Pojo {public int a;public String b;public int c;public boolean d;private long e; // e設置為私有的,后面講解為什么public Object f;Pojo() { e = 1024L;} }

使用 jol 工具查看其內存布局如下:

OFFSET SIZE TYPE DESCRIPTION VALUE0 12 (object header) N/A12 4 int Pojo.a N/A16 8 long Pojo.e N/A24 4 int Pojo.c N/A28 1 boolean Pojo.d N/A29 3 (alignment/padding gap)32 4 java.lang.String Pojo.b N/A36 4 java.lang.Object Pojo.f N/A

這里由于我的本地環境開啟了對象頭壓縮,因此對象頭所占用的大小為(4+8)=12字節。從這個內存布局表上不難看出,成員變量在實際分配內存時,并不是按照聲明的順序來儲存的,此外在變量 d 之后,還出現了一塊用于對齊內存的 padding gap ,這說明計算對象實際數據所占用的內存大小時,并不是簡單的求和就可以的。

考慮到這些細節問題,我們需要一些更有力的工具來幫助我們精確的計算。

Unsafe & 變量偏移地址

在上面的內存布局表中,可以看到 OFFSET 一列,這便是對應變量的偏移地址,如果你了解 C/C++ 中的指針,那這個概念就很好理解,它其實是告訴了 CPU 要從什么位置取出對應的數據。舉個例子,假設 Pojo 類的一個對象p存放在以 0x0010 開始的內存空間中,我們需要獲取它的成員變量 b ,由于其偏移地址是 32(轉換成十六進制為20),占用大小是 4 ,那么實際儲存變量b的內存空間就是 0 x0030 ~ 0x0033 ,根據這個 CPU 就可以很容易地獲取到變量了。

實際上在反射中,正是通過這樣的方式來獲取指定屬性值的,具體實現上則需要借助強大的 Unsafe 工具。Unsafe 在 Java 的世界中可謂是一個“神龍不見首”的存在,借助它你可以操作系統底層,實現許多不可意思的操作(比如修改變量的可見性,分配和回收堆外內存等),用起來簡直像在寫 C++ 。不過也正因為其功能的強大性,隨意使用極有可能引發程序崩潰,因此官方不建議在除系統實現(如反射等)以外的場景使用,網上也很難找到Unsafe的詳細使用指南(一些參考資料),當然這并不影響我們揭開它的神秘面紗,接下來就看看如何通過變量偏移地址來獲取一個變量。

@Test public void testUnsafe() throws Exception {Class<?> unsafeClass = null;Unsafe unsafe = null;try {unsafeClass = Class.forName("sun.misc.Unsafe");final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");unsafeField.setAccessible(true);unsafe = (Unsafe) unsafeField.get(null);} catch (Exception e) {// Ignore.}Pojo p = new Pojo();Field f = Pojo.class.getDeclaredField("e");long eOffset = unsafe.objectFieldOffset(f); // eOffset = 16if (eOffset > 0L) {long eVal = unsafe.getLong(p, eOffset);System.out.println(eVal); // 1024} }

出于安全起見,一般情況下在正常的代碼中是無法直接獲取 Unsafe 的實例的,這里我們通過反射的方式hack了一把來拿到 unsafe 實例。接著通過調用 objectFieldOffset 方法獲取到成員變量 e 的地址偏移為 16(和 jol 中的結果一致),最終我們通過 getLong() 方法,傳入 e 的地址偏移量,便獲取到了 e 的值。可以看到盡管 Pojo 類中 e 是一個私有屬性,通過這種方法依然是可以獲取到它的值的。

有了 objectFieldOffset 這個工具,我們就可以通過代碼精確的計算一個對象在內存中所占用的空間大小了,代碼如下(參考自 apache luence )

計算 shallowSize??

public long shallowSizeOf(Object o) { Clazz<?> c = o.getClass(); // 對應的類 // 初始大小:對象頭long shallowInstanceSize = NUM_BYTES_OBJECT_HEADER; for (Class<?> c = clazz; c != null; c = c.getSuperclass()) { // 需要循環獲取對象所繼承的所有類以遍歷其包含的所有成員變量 final Field[] fields = c.getDeclaredFields(); for (final Field f : fields) { // 注意,f的遍歷順序是按照聲明順序,而不是實際儲存順序 if (!Modifier.isStatic(f.getModifiers())) { // 靜態變量不用考慮 final Class<?> type = f.getType(); // 成員變量占用的空間,如果是基本類型(int,long等),直接是其所占空間,否則就是當前JRE環境下引用的大小 final int fsize = type.isPrimitive() ? primitiveSizes.get(type) : NUM_BYTES_OBJECT_REF; // 通過unsafe方法獲取當前變量的偏移地址,并加上成員變量的大小,得到最終成員變量的偏移地址結束值(注意不是開始值) final long offsetPlusSize =((Number) objectFieldOffsetMethod.invoke(theUnsafe, f)).longValue() + fsize; // 因為儲存順序和遍歷順序不一致,所以不能直接相加,直接取最大值即可,最終循環結束完得到的一定是最后一個成員變量的偏移地址結束值,也就是所有成員變量的總大小shallowInstanceSize = Math.max(shallowInstanceSize, offsetPlusSize);}}} // 最后進行內存對齊,NUM_BYTES_OBJECT_ALIGNMENT是需要對齊的位數(一般是8)shallowInstanceSize += (long) NUM_BYTES_OBJECT_ALIGNMENT - 1L; return shallowInstanceSize - (shallowInstanceSize % NUM_BYTES_OBJECT_ALIGNMENT); }

到這里我們計算出了一個對象在內存布局上所占用的空間大小,但這并不是這個對象所占用的實際大小,因為我們還沒有考慮對象內部的引用所指向的那些變量的大小。類比Java 中深淺拷貝的概念,我們可以稱這個內存大小為 shallowSize,即“淺內存占用”。

計算 deepSize

計算出一個對象占用的shallowSize之后,想要計算它的deepSize就很容易了,我們需要做的便是遞歸遍歷對象中所有的引用并計算他們指向的實際對象的shallowSize,最終求和即可。考慮到會有大量重復的類出現,可以使用一個數組來緩存已經計算過shallowSize的class,避免重復計算。

特別地,如果引用指向了數組或者集合類型,那么只需要計算其基本元素的大小,然后乘以數組長度/集合大小即可。

具體實現代碼在此不過多贅述,可以直接參考源代碼( from Apache luence ,入口方法為 sizeOf ( Object ))。

源代碼:

https://github.com/MarkLux/Java-Memory-Monitor/blob/master/src/main/java/cn/marklux/memory/RamUsageEstimator.java

需要注意的是,這種計算對象內存的方法并不是毫無代價的,由于使用了遞歸、反射和緩存,在性能和空間上都會有一定的消耗。

基礎:JVM GC


研究完了開發態的內存,我們再來看看運行態的內存,對于 Java 程序員而言,運行態我們核心關注的就是 JVM 的 GC 了,先來回顧一些基本知識:

可回收對象的標記

GC 的第一步是需要搞明白,當前究竟有哪些對象是可以被回收的。由于引用計數法在存在循環引用時無法正常標記,所以一般是采用 可達性分析算法 來標記究竟有哪些對象可以被回收,如下圖所示:

垃圾回收器會從一系列的 GC Root 對象出發,向下搜索所有的對象,那些無法通過 GC ?Root 對象達到的對象就是需要被回收的對象。GC Root 對象主要包括以下幾種:

  • 方法中局部變量區中的對象引用

  • Java 操作棧中對象引用

  • 常量池中的對象引用

  • 本地方法棧中的對象引用

  • 類的 Class 對象

垃圾收集算法

GC 的第二步是將所有標記為可回收的對象所占用的空間清理掉,這里有幾種算法:

標記 - 清除法
掃描一遍所有對象,并標記哪些可回收,然后清除,缺點是回收完會產生很多碎片空間,而且整體效率不高。

復制法
將內存劃分為相等的兩塊,每次只使用其中一塊。當這一塊內存用完時,就將還存活的對象復制到另一塊上面,然后將已經使用過的內存空間一次清理掉。缺點是對內存空間消耗較大(實際只用了一半),并且當對象存活概率較高的時候,復制帶來的額外開銷也很高。

標記 - 整理法
將原有標記-清除算法進行改造,不是直接對可回收對象進行清理,而是讓所有存活對象都向另一端移動,然后直接清理掉端邊界以外的內存。

對象分代

在 JVM ,絕大多數的對象都是 朝生夕死 的短命對象,這是 GC 的一個重要假設。對于不同生命周期的對象,可以采用不同的垃圾回收算法,比如對壽命較短的對象采用復制法,而對壽命比較長的對象采用標記-整理法。為此,需要根據對象的生命周期將堆區進行一個劃分:

1、新生代( Young 區)
儲存被創建沒多久的對象,具體又分為 Eden 和 Survivor 兩個區域。所有對象剛被創建時都存在 Eden 區,當 Eden 區滿后會觸發一次 GC ,并將剩余存活的對象轉移到 Survivor 區。為了采用復制法,會有兩個大小相同的 Survivor 區,并且始終有一個是空的。


新生代發生的 GC 被稱為 Young GC 或 Minor GC,是發生頻率最高的一種 GC 。

2、老年代( Old 區)
存放 Young 區 Survivor 滿后觸發 minor GC 后仍然存活的對象,當 Eden 區滿后會將存活的對象放入 Survivor 區域,如果 Survivor 區存不下這些對象, GC 收集器就會將這些對象直接存放到 Old 區中,如果 Survivor 區中的對象足夠老,也直接存放到 Old 區中。


如果 Old 區滿了,將會觸發 Major GC 回收老年代空間。

3、永生代( Perm 區, Java 8 后改為 MetaSpace 元空間)
主要存放類的 Class 對象和常量,以及靜態變量,這塊內存不屬于堆區,而是屬于方法區。Perm 區的 GC 條件非常苛刻,以一個類的回收為例,需要同時滿足以下條件才能夠將其回收:

  • 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例;

  • 加載該類的 ClassLoader 已經被回收;

  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

GC指標

如果你查閱過 JVM GC 相關的文章,會發現 GC 經常被分為三種:發生在新生代的 Minor GC(Young GC)、發生在老年代的 Major GC、和發生在整個內存區域的 Full GC。事實上JVM官方并沒有對 Full GC 和 Major GC 這兩種 GC 進行明確的定義,所以也沒有必要糾結。

不論是 Minor GC 還是 Full GC ,絕大多數的GC算法都是會暫停所有應用線程的(STW),只不過 Minor GC 暫停的時間很短,而 Full GC 則比較長。

由于 GC 對實際應用線程是存在影響的,所以在實際運維中,我們需要一些外部指標來評估 GC 的情況,以判斷當前應用是否“健康”。一般來說, GC 的兩個重要指標是:

  • GC 時間:由于 GC 是 STW 的,所以在 GC 時間內整個應用是處于暫停狀態的。

  • GC 頻率:單位時間內 GC 發生的次數。

那么對于一個應用而言, GC 時間和 GC 頻率處于什么水平才是正常的?這由應用本身的需要來決定,我們可以從下面三個維度來評估:

1、延遲(latency):一次完整操作完成的時間。


比如某交易系統,要求所有的請求在 1000ms 內得到響應。假設 GC 的時間占比不超過總運行時間的 10% ,那就要求 GC 時間都不能超過 100ms 。

2、吞吐量(Throughput):單位時間內需要處理完成的操作數量。


仍然以上面的交易系統為例,要求每分鐘至少可以處理 1000 個訂單,并且 GC 的時間占比不能超過總運行時間的 10% ,那就意味著每分鐘的 GC 時間總和不能超過 6s 。假設單次 GC 的耗時為 50ms ,進一步轉換即可得到對 GC 頻率的要求為每分鐘不超 120 次。


因為每分鐘需要完成 1000 次操作,那就意味著平均每9次操作可以觸發一次 GC ,這就進一步轉換成了對局部變量產生速率的要求。

3、系統容量(Capacity):是在達成吞吐量和延遲指標的情況下,對硬件環境的額外約束。


一般來說是硬件指標,比如某系統要求必須能夠部署在 2 核 4G 的服務器實例上,且能夠滿足延遲和吞吐量的需要。結合具體的硬件指標和 JVM 特性可以進一步估算得到對 GC 的要求。

議題:如何回收對象占用的內存?


這是一個很有意思的問題,通過上面的分析不難看出,在 JVM 中內存的回收是自動的,并不受程序員手動控制,這是由 GC 本身的特性所決定的。那么在日常編程中,有什么辦法可以讓對象的內存被回收掉呢?

有關這個問題可以看下知乎上的討論:

https://www.zhihu.com/question/21663879?spm=ata.13261165.0.0.46c63700YRVfps

清除引用

根據 GC 算法的原則,只要一個對象處于“不可達”的狀態,就會被回收,因此想要回收一個對象,最好的辦法就是將指向它的引用都置為空。當然,這意味著在編碼時你需要清晰地知道自己的對象都被哪些地方所引用了。

從這個角度出發,我們在日常編寫代碼的時候要盡量避免創建不必要的引用。

那么,為了達到清除引用的效果,是不是應該在不需要對象的后,手動將引用置為null呢?讓我們看下面這段代碼:

public void refTest() {Pojo p = new Pojo(); // ... do something with p // help the gc (?)p = null; }

實際上,由于 p 是在 refTest() 域內聲明的局部變量,方法執行完畢后就會被自動回收了,并沒有必要將 p 特意設置為 null ,這樣做對 GC 的幫助微乎其微。

盡量使用局部變量

想要讓一個對象盡快被回收,那就需要盡可能地縮短它的生命周期,最好讓它能夠在 Young 區的 Minor GC 中被銷毀,而不是存活到 suvivor 區甚至是老年代。從這個角度出發,能夠使用局部變量的時候就盡量使用局部變量,縮小變量的作用域,以便其能被快速回收。

盡量少用靜態變量

靜態變量是一種特殊的存在,因為它并不存放在堆區,而是被存放在方法區。通過上文的分析可以看到方法區的 GC 條件是十分苛刻的,所以靜態變量一旦被聲明了,就 很難被回收,這要求我們在代碼中盡量克制地使用靜態變量。

一般來說,靜態變量本身不會占用很多的空間,但它可能包含很多指向非靜態變量的引用,這就會導致那些被引用的變量也無法被回收,久而久之引發內存不足。如果你定義的靜態變量中包含了數組和集合類,那就要格外注意控制它的大小,因為這些內存都是很難被回收掉的。

System.gc() ?

這似乎是目前Java中唯一一個可以由代碼主動觸發 GC 的調用,不過這個調用并不一定會真的發起gc。來看一下官方對于 System.gc() 的定義:

Calling this method suggests that the Java virtual machine expendeffort toward recycling unused objects in order to make the memorythey currently occupy available for quick reuse. When controlreturns from the method call, the virtual machine has madeits best effort to recycle all discarded objects.

?expend effort 說明這個調用并不會保證一定發生 GC。此外,System.gc() 調用所觸發的 GC 是一次 Full GC ,如果在代碼中頻繁調用 Full GC ,那么后果可想而知。

因此,我們的建議是,除非真的有必要,否則永遠不要使用 System.gc() 。

結論


綜合上述內容,可以分析得到下面的結論:

  • Java的內存是被自動管理的

  • 無法通過手動的方式回收內存,因為這違反了Java語言設計的初衷

  • 可以通過減少變量作用域等方式幫助GC更好地工作

總結

以上是生活随笔為你收集整理的当我们在谈论内存时,我们在谈论什么的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。