幾百萬資料放入記憶體不會把系統撐爆嗎?

不學無數的程式設計師發表於2019-12-21

在公司有一個需求是要核對一批資料,之前的做法是直接用SQL各種複雜操作給懟出來的,不僅時間慢,而且後期也不好維護,就算原作者來了過一個月估計也忘了SQL什麼意思了,於是有一次我就想著問一下之前做這個需求的人為什麼不將這些資料查出來後在記憶體裡面做篩選呢?直接說了你不怕把記憶體給撐爆嗎?此核算伺服器是單獨的伺服器,配置是四核八G的,配置堆的大小是4G。本著懷疑的精神,就想要弄清楚幾百萬條資料真的放入記憶體的話會佔用多少記憶體呢?

計算機的儲存單位

計算機的儲存單位常用的有bitByteKBMBGBTB後面還有但是我們基本上用不上就不說了,我們經常將bit稱之為位元或者位、將Byte簡稱為B或者位元組,將KB簡稱為K,將MB稱之為M或者兆,將GB簡稱為G。那麼他們的換算單位是怎樣的呢?

換算關係

首先我們得知道在計算機中所有資料都是由0 1來組成的,那麼儲存0 1這些二進位制資料是由什麼存放呢?就是由bit存放的,一個bit存放一位二進位制數字。所以bit是計算機最小的單位

大部分計算機目前都是使用8位的塊,就是我們上面稱之為的位元組Byte,來作為計算機容量的基本單位。所以我們一般稱一個字元或者一個數字都是稱之為佔用了多少位元組。

瞭解了上面關於位和位元組的關係後,我們可以看一下其他的單位換算關係

11B(Byte 位元組) = 8bit(位)
21KB = 1024B
31MB = 1024KB
41GB = 1024MB
51TB = 1024GB
複製程式碼

Java中物件佔用多少記憶體

在瞭解了上面的換算關係後,我們來了解一下新建一個Java物件需要多少記憶體。

Java基本型別

我們知道Java型別分為基本型別和引用型別,八大基本型別有int、short、long、byte、float、double、boolean、char

資料型別 佔用記憶體(單位為Byte)
boolean 1
byte 1
short 2
char 2
int 4
float 4
long 8
double 8

至於為什麼Java中的char無論是中英文數字都佔用兩個位元組,是因為Java中使用Unicode字元,所有的字元均以兩個位元組儲存。

Java引用型別

在一個物件中除了有基本資料型別以外,我們也會有一些引用型別,引用型別的物件比較特殊,因為這些物件真正儲存在虛擬機器中的堆記憶體中,物件中只是儲存了一個引用而已,如果是引用型別那麼就會儲存一個指向該引用的指標。指標預設情況下是佔用4位元組,是因為開啟了指標壓縮,如果沒有開的話,那麼一個引用就佔用8個位元組。

物件在記憶體中的佈局

在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為三個區域:物件頭(Header)、例項資料(Instance Data)、對齊填充(Padding)。

物件頭

在物件頭中儲存了兩部分資料

  • 執行時資料:儲存了物件自身執行時的資料,例如雜湊碼、GC分代的年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID等等。這部分資料在32位和64位的虛擬機器中分別為32bit和64bit
  • 型別指標:物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。如果物件是一個Java陣列的話,那麼物件頭中還必須有一塊用於記錄陣列長度的資料(佔用4個位元組)。所以這是一個指標,預設JVM對指標進行了壓縮,用4個位元組儲存。

我們以虛擬機器為64位的機器為例,那麼物件頭佔用的記憶體是8(執行時資料)+4(型別指標)=12Byte。如果是陣列的話那麼就是16Byte

例項資料

例項資料中也擁有兩部分資料,一部分是基本型別資料,一部分是引用指標。這兩部分資料我們在上面已經講了。具體佔用多少記憶體我們需要結合具體的物件繼續分析,下面我們會有具體的分析。

從父類中繼承下來的變數也是需要進行計算的

對齊填充

對齊填充並不是必然存在的,也沒有特別的含義。它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是物件的大小必須是8位元組的整數倍。而如果物件頭加上例項資料不是8的整數倍的話那麼就會通過對其填充進行補全。

實戰演練

我們在上面分析一大堆,那麼是不是就如我們分析的一樣,新建一個物件在記憶體中的分配大小就是如此呢?我們可以新建一個物件。

1class Animal{
2
3    private int age;
4
5}
複製程式碼

那麼怎麼知道這個物件在記憶體中佔用多少記憶體呢?JDK提供了一個工具jol-core可以給我們分析出來一個物件在記憶體中佔用的記憶體大小。直接在專案中引入包即可。

1--Gradle
2compile 'org.openjdk.jol:jol-core:0.9'
3
4--Maven
5<dependency>
6    <groupId>org.openjdk.jol</groupId>
7    <artifactId>jol-core</artifactId>
8    <version>0.9</version>
9</dependency>
複製程式碼

然後我們在main函式中呼叫如下

1public class AboutObjectMemory {
2
3    public static void main(String[] args{
4        System.out.print(ClassLayout.parseClass(Animal.class).toPrintable());
5    }
6}
複製程式碼

就可以檢視到輸出的內容了,可以看到輸出結果佔用的記憶體是16位元組,和我們分析的一樣。

1aboutjava.other.Animal object internals:
2 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
3      0    12        (object header)                           N/A
4     12     4    int Animal.age                                N/A
5Instance size: 16 bytes
6Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
複製程式碼

String佔用多少記憶體

String字串在Java中是個特殊的存在,比如一個字串"abcdefg"這樣一個字串佔用多少位元組呢?相信會有人回答說是7個位元組或者是14個位元組,這兩個答案都是不準確的,我們先看一下String類在記憶體中佔用的記憶體是多少。
我們先自己進行分析一下。在String類中有兩個屬性,其中物件頭固定了是12位元組,int是4位元組,char[]陣列其實在這裡相當於引用物件存的,所以存的是地址,因此佔用4個位元組,所以大小為物件頭12Byte+例項資料8Byte+填充資料4Byte=24Byte這裡的物件頭和例項資料加起來不是8的倍數,所以需要填充資料進行填充。

1    private final char value[];
2
3    private int hash; // Default to 0
複製程式碼

那麼我們分析的到底對不對呢,我們還是用上面的工具進行分析一下。可以看到我們算出的結果和我們分析的結果是一致的。

1java.lang.String object internals:
2 OFFSET  SIZE     TYPE DESCRIPTION                               VALUE
3      0    12          (object header)                           N/A
4     12     4   char[] String.value                              N/A
5     16     4      int String.hash                               N/A
6     20     4          (loss due to the next object alignment)
7Instance size: 24 bytes
複製程式碼

那麼一個空字串佔用多少記憶體呢?我們剛才得到的是一個String物件佔用了24位元組,其實char[]陣列還是會佔用記憶體的,我們在上面講物件頭的時候說過,陣列物件也是一個例項物件,它的物件頭比一般的物件多出來4位元組,用來描述此陣列的長度,所以char[]陣列的物件頭長度為16位元組,由於此時是空字串,所以例項資料長度為0。因此一個空char[]陣列佔用記憶體大小為物件頭16Byte+例項資料0Byte=16Byte。一個空字串佔用記憶體為String物件+char[]陣列物件=40Byte

那麼我們上面舉的例子abcdefg佔用多少記憶體呢?其中String物件佔用的記憶體是不會變了,變化的是char[]陣列中的內容,這裡我們需要知道字串是存放於char[]陣列中的,而一個char佔用2個位元組,所以abcdefg的char[]陣列大小為物件頭16Byte+例項資料14Byte+對齊填充2Byte=32Byte。那麼abcdefg佔用記憶體大小就是String物件+char[]陣列物件=56Byte

用List儲存物件

那麼我們在記憶體中放入二千萬個這個物件的話,需要佔用多少記憶體呢?根據上面的知識我們能大概估算一下。我們定義一個List陣列用於存放此物件,不讓其回收。

1List<Animal> animals = new ArrayList<>(20000000);
2for (int i = 0; i < 20000000; i++) {
3    Animal animal = new Animal();
4    animals.add(animal);
5}
複製程式碼

注意這裡我是直接將集合的大小初始化為了二千萬的大小,所以程式在正常啟動的時候佔用記憶體是100+MB,正常程式啟動僅僅佔用30+MB的,所以多出來的60+MB正好是我們初始化的陣列的大小。至於為什麼要初始化大小的原因就是為了消除集合在擴容時對我們觀察結果的影響

這裡我貼一張,集合未初始化大小和初始化大小記憶體佔用對比圖,大家可以看到是有記憶體上的差異,在ArrayList陣列中用於存放資料的是transient Object[] elementData;Object陣列,所以它裡面存放的是指向物件的指標,一個指標佔用4個位元組,所以就有兩千萬個指標,那麼就是76M。我們可以看到差異圖和我們預想的一樣。

上面我們已經算出來了一個Animal物件佔用16個位元組,所以兩千萬個佔用大概是305MB,和集合加起來就是將近380MB的空間大小,接下來我們就啟動程式來看一下我們結果是不是對的呢,接下來我用的jconsole工具檢視記憶體佔用情況。

我們可以看到和我們預算的結果是相吻合的。

那麼以後如果有大量的物件需要從資料庫中查詢出來放入記憶體的話,那麼如果是使用物件來接的話,那麼我們就應該儘量減少物件中的欄位,因為即使你不賦值,其實他也是佔用著記憶體的,我們接下來再舉個例子看一下對個屬性值的話佔用記憶體是不是又高了。我們將Animal物件改造如下

1class Animal{
2
3    private int age;
4    private int age1;
5    private int age2;
6    private int age3;
7    private int age4;
8
9}
複製程式碼

此時我們能夠計算得到一個Animal物件佔用的記憶體大小是(物件頭12Byte+例項資料20Byte=32Byte)此時32由於是8的倍數所以無需進行填充補齊。那麼此時如果還是二千萬條資料的話,此物件佔用記憶體應該是610MB,加上剛才集合中指標的資料76MB,那麼加起來將近佔用686MB,那麼預期結果是否和我們的一樣呢,我們重新啟動程式觀察,可以看到下圖。可以看到和我們分析的資料是差不多的。

用Map儲存物件

用Map儲存物件計算記憶體大小有些麻煩了,眾所周知Map的結構是如下圖所示。

它是一個陣列加連結串列(或者紅黑樹)的結構,而陣列中存放的資料是Node物件。

1static class Node<K,Vimplements Map.Entry<K,V{
2        final int hash;
3        final K key;
4        V value;
5        Node<K,V> next;
6}
複製程式碼

我們舉例定義下面一個Map物件

1Map<Animal,Animal> map
複製程式碼

此時我們可以自己計算一下一個Node物件需要的記憶體大小物件頭12Byte+例項資料16Byte+對其填充4Byte=32Byte,當然這裡的key和value的值還需要另算,因為Node物件此時存放的僅僅是他們的引用而已。一個Animal物件所佔用記憶體大小我們上面也說了是16Byte,所以這裡一個Node物件佔用的大小為32Byte+16Byte+16Byte=64Byte。

下面我們用實際例子來驗證下我們的猜想

1Map<Animal,Animal> map = new HashMap<>(20000000);
2for (int i = 0; i < 20000000; i++) {
3    map.put(new Animal(),new Animal());
4}
複製程式碼

上面的例子在一個Map物件中存放二千萬條資料,計算大概在記憶體中佔用多少記憶體。

  • 陣列佔用記憶體大小:我們先來計算一下陣列佔了多少,這裡有個小知識點,在HashMap中初始化大小是按照2的倍數來的,比如你定義了大小為60,那麼系統會給你初始化大小為64。所以我們定義為二千萬,系統其實是會給我們初始化為33554432,所以此時僅僅HashMap中陣列就佔用了將近132MB
  • 資料佔用記憶體大小:我們上面計算了一個Node節點佔用了64Byte,那麼兩千萬條資料就佔用了1280MB

兩個佔用記憶體大小相加我們可以知道大概系統中佔用了1.4G記憶體的大小。那麼事實是否是我們想象的呢?我們執行程式可以看到記憶體大小如圖所示。可以看到結果確實和我們猜想的一樣。

總結

迴歸到上面所說的需求,幾百萬資料放到記憶體中會把記憶體撐爆嗎?這時候你可以通過自己的計算得到。最終我們那個需求經過我算出來其實佔用記憶體量幾百兆,對於4個G的堆記憶體來說其實遠遠還沒達到撐爆的地步。所以有時候我們對任何東西都要存在懷疑的態度。大家可以到GitHub中下載程式碼自己在本地跑一下監測一下,並且可以自己定義幾個物件然後計算看是不是和圖中的記憶體大小一致。這樣才能記憶更深刻。送給大家一句話從來如此,便對嗎?。其實我寫的文章裡面也留了一個小坑,大家可以試著找找,是在對集合進行初始化計算那一塊。

專案原始碼地址

有感興趣的可以關注一下我新建的公眾號,搜尋[程式猿的百寶袋]。或者直接掃下面的碼也行。

參考

相關文章