1. 程式人生 > >一個神奇的bug:OOM?優雅終止執行緒?系統記憶體佔用較高?

一個神奇的bug:OOM?優雅終止執行緒?系統記憶體佔用較高?

摘要:該專案是DAYU平臺的資料開發(DLF),資料開發中一個重要的功能就是ETL(資料清洗)。ETL由源端到目的端,中間的業務邏輯一般由使用者自己編寫的SQL模板實現,velocity是其中涉及的一種模板語言。

Velocity之OOM

Velocity的基本使用

Velocity模板語言的基本使用程式碼如下:

1. 初始化模板引擎

2. 獲取模板檔案

3. 設定變數

4. 輸出

在ETL業務中,Velocity模板的輸出是使用者的ETL SQL語句集,相當於.sql檔案。這裡官方提供的api需要傳入一個java.io.Writer類的物件用於儲存模板的生成的SQL語句集。然後,這些語句集會根據我們的業務做SQL語句的拆分,逐個執行。

java.io.Writer類是一個抽象類,在JDK1.8中有多種實現,包括但不僅限於以下幾種:

由於雲環境對使用者檔案讀寫建立等許可權的安全性要求比較苛刻,因此,我們使用了java.io.StringWriter,其底層是StringBuffer物件,StringBuffer底層是char陣列。


簡單模板Hellovelocity.vm:

#set($iAMVariable = 'good!')
#set($person.password = '123')
Welcome ${name} to velocity.com
today is ${date}
#foreach($one in $list)
    $one
#end
Name:       ${person.name}
Password:   ${person.password}

HelloVelocity.java

package com.xlf;

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;

import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class HelloVelocity {

    public static void main(String[] args) {
        // 初始化模板引擎
        VelocityEngine ve = new VelocityEngine();
        ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath");
        ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName());
        ve.init();
        // 獲取模板檔案
        Template template = ve.getTemplate("Hellovelocity.vm");
        VelocityContext ctx = new VelocityContext();

        // 設定變數
        ctx.put("name", "velocity");
        ctx.put("date", (new Date()));

        List temp = new ArrayList();
        temp.add("Hey");
        temp.add("Volecity!");
        ctx.put("list", temp);

        Person person = new Person();
        ctx.put("person", person);
        // 輸出
        StringWriter sw = new StringWriter();
        template.merge(ctx, sw);
        System.out.println(sw.toString());
    }
}

控制檯輸出

OOM重現

大模板檔案BigVelocity.template.vm

(檔案字數超出部落格限制,稍後在附件中給出~~)

模板檔案本身就379kb不算大,關鍵在於其中定義了一個包含90000多個元素的String陣列,陣列的每個元素都是”1”,然後寫了79層巢狀迴圈,迴圈的每一層都是遍歷該String陣列;最內層迴圈呼叫了一次:

show table;

這意味著這個模板將生成包含96372的79次方個SQL語句,其中每一個SQL語句都是:

show table;

將如此巨大的字元量填充進StringWriter物件裡面,至少需要10的380多次方GB的記憶體空間,這幾乎是不現實的。因此OOM溢位是必然的。

BigVelocity.java

package com.xlf;

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;

import java.io.StringWriter;

public class BigVelocity {

    public static void main(String[] args) {
        // 初始化模板引擎
        VelocityEngine ve = new VelocityEngine();
        ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath");
        ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName());
        ve.init();
        // 獲取模板檔案
        Template template = ve.getTemplate("BigVelocity.template.vm");
        VelocityContext ctx = new VelocityContext();
        StringWriter sw = new StringWriter();
        template.merge(ctx, sw);
    }
}

控制檯輸出

OOM原因分析

Velocity模板生成的結果寫入StringWriter物件中,如前面分析,其底層是一個char陣列。直接產生OOM的程式碼在於java.util.Array.copyOf()函式:

StringWriter底層char陣列容量極限測試

StringWriterOOMTest.java

package com.xlf;

import java.io.StringWriter;

public class StringWriterOOMTest {
    public static void main(String[] args) {
        System.out.println("The maximum value of Integer is: " + Integer.MAX_VALUE);
        StringWriter sw = new StringWriter();
        int count = 0;
        for (int i = 0; i < 100000; i++) {
            for (int j = 0; j < 100000; j++) {
                sw.write("This will cause OOM\n");
                System.out.println("sw.getBuffer().length(): " + sw.getBuffer().length() + ", count: " + (++count));
            }
        }
    }
}

Jvm引數設定(參考硬體配置)

環境:JDK8 + Windows10桌上型電腦 + 32GB記憶體 + 1TB SSD + i7-8700

如果你的硬體配置不充分,請勿輕易嘗試!

測試結果

StringWriterOOMTest執行時的整個程序記憶體大小在Windows工作管理員中達10300多MB時,程式停止。

控制檯輸出

測試結果分析

char陣列元素最大值不會超過Integer.MAX_VALUE,回事非常接近的一個值,我這裡相差20多。網上搜索了一番,比較靠譜的說法是:確實比Integer.MAX_VALUE小一點,不會等於Integer.MAX_VALUE,是因為char[]物件還有一些別的空間佔用,比如物件頭,應該說是這些空間加起來不能超過Integer.MAX_VALUE。如果有讀者感興趣,可以自行探索下別的型別陣列的元素個數。我這裡也算是一點拙見,拋磚引玉。

OOM解決方案

原因總結

通過上面一系列重現與分析,我們知道了OOM的根本原因是模板檔案渲染而成的StringWriter物件過大。具體表現在:

  1. 如果系統沒有足夠大的記憶體空間分配給JVM,會導致OOM,因為這部分記憶體並不是無用記憶體,JVM不能回收
  2. 如果系統有足夠大的記憶體空間分配給JVM,char陣列中的元素個數在接近於MAX_VALUE會丟擲OOM錯誤。

解決方案

前面分析過,出於安全的原因,我們只能用StringWriter物件去接收模板渲染結果的輸出。不能用檔案。所以只能在StringWriter本身去做文章進行改進了:

繼承StringWriter類,重寫其write方法為:

StringWriter sw = new StringWriter() {
    public void write(String str) {
        int length = this.getBuffer().length() + str.length();
        // 限制大小為10MB
        if (length > 10 * 1024 * 1024) {
            this.getBuffer().delete(0, this.getBuffer().length());
            throw new RuntimeException("Velocity template size exceeds limit!");
        }
        this.getBuffer().append(str);
    }
};

其他程式碼保持不變

BigVelocitySolution.java

package com.xlf;

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;

import java.io.StringWriter;

public class BigVelocitySolution {

    public static void main(String[] args) {
        // 初始化模板引擎
        VelocityEngine ve = new VelocityEngine();
        ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath");
        ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName());
        ve.init();
        // 獲取模板檔案
        Template template = ve.getTemplate("BigVelocity.template.vm");
        VelocityContext ctx = new VelocityContext();
        StringWriter sw = new StringWriter() {
            public void write(String str) {
                int length = this.getBuffer().length() + str.length();
                // 限制大小為10MB
                if (length > 10 * 1024 * 1024) {
                    this.getBuffer().delete(0, this.getBuffer().length());
                    throw new RuntimeException("Velocity template size exceeds limit!");
                }
                this.getBuffer().append(str);
            }
        };
        template.merge(ctx, sw);
    }
}

控制檯輸出

如果velocity模板渲染後的sql語句集大小在允許的範圍內,這些語句集會根據我們的業務做SQL語句的拆分,逐句執行。

如何優雅終止執行緒

在後續逐句執行sql語句的過程中,每一句sql都是呼叫的周邊服務(DLI,OBS,MySql等)去執行的,結果每次都會返回給我們的作業開發排程服務(DLF)後臺。我們的DLF平臺支援及時停止作業的功能,也就是說假如這個作業在排程過程中要執行10000條SQL,我要在中途停止不執行後面的SQL了——這樣的功能是支援的。

在修改上面提到OOM那個bug並通過測試後,測試同學發現我們的作業無法停止下來,換句話說,我們作業所在的java執行緒無法停止。

執行緒停止失敗重現

一番debug與程式碼深入研讀之後,發現我們專案中確實是呼叫了對應的執行緒物件的interrupt方法thread.interrupt();去終止執行緒的。

那麼為什麼呼叫了interrupt方法依舊無法終止執行緒?

TestForInterruptedException.java

package com.xlf;

public class TestForInterruptedException {

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < 10; i++) {
            sb.append("show tables;\n");
        }
        int i = 0;
        for (String str : sb.toString().split("\n")) {
            if (i > 4) {
                Thread.currentThread().interrupt();
                System.out.println(i + " after interrupt");
            }
            System.out.println(str);
            System.out.println(i++);
        }

    }
}

控制檯輸出

測試結果分析

TestForInterruptedException.main函式中做的事情足夠簡單,先產生一個大一點的字串,拆分成10段小字串,for迴圈中逐段列印小字串;並企圖從第5段(初始段為0)開始,去終止執行緒。結果發現執行緒並沒有終止!

這是怎麼回事?為什麼呼叫了執行緒的interrupt方法並沒有終止執行緒?或者說是因為jvm需要一點時間去響應這個方法?其實並非如此,感興趣的同學可以把迴圈次數加的更大一些,在迴圈開始幾次就進行interrupt,你會發現結果還是這樣。

經過一番探索,執行緒終止的方法無外乎兩種:

  • 使用該Thread物件的stop()方法能讓執行緒馬上停止,但是這種方法太過於暴力,實際上並不會被使用到,詳見JDK1.8的註釋:
    • Deprecated. This method is inherently unsafe. Stopping a thread with Thread.stop causes it to unlock all of the monitors that it has locked (as a natural consequence of the unchecked ThreadDeath exception propagating up the stack). If any of the objects previously protected by these monitors were in an inconsistent state, the damaged objects become visible to other threads, potentially resulting in arbitrary behavior. Many uses of stop should be replaced by code that simply modifies some variable to indicate that the target thread should stop running. The target thread should check this variable regularly, and return from its run method in an orderly fashion if the variable indicates that it is to stop running. If the target thread waits for long periods (on a condition variable, for example), the interrupt method should be used to interrupt the wait…
  • 第二種方法就是上面JDK註釋中提到的設定標誌位的做法。這類做法又分為兩種,無論哪一種都需要去被終止的執行緒本身去“主動”地判斷該標誌位的狀態:
  1. 設定一個常規的標誌位,比如:boolean型別變數的true/ false, 根據變數的狀態去決定執行緒是否繼續執行——程式碼裡去主動判斷變數狀態。這種一般用在迴圈中,檢測到相應狀態就break, return或者throw exception。
  2. 使用Thead類的例項方法interrupt去終止該thread物件代表的執行緒。但是interrupt方法本質上也是設定了一箇中斷標識位,而且該標誌位一旦被捕獲(讀取),“大部分時候”就會被重置(失效)。因此它並不保證執行緒一定能夠停止,而且不保證馬上能夠停止,有如下幾類情況:
    1. interrupt方法設定的中斷標識位後,如果該執行緒往後的程式執行邏輯中執行了Object類的wait/join/sleep,這3個方法會及時捕獲interrupt標誌位,重置並丟擲InterruptedException。
    2. 類似於上一點,java.nio.channels包下的InterruptibleChannel類也會去主動捕獲interrupt標誌位,即執行緒處於InterruptibleChannel的I/O阻塞中也會被中斷,之後標誌位同樣會被重置,然後channel關閉,丟擲java.nio.channels.ClosedByInterruptException;同樣的例子還有java.nio.channels.Selector,詳見JavaDoc
    3. Thread類的例項方法isInterrupted()也能去捕獲中斷標識位並重置標識位,這個方法用在需要判斷程式終止的地方,可以理解為主動且顯式地去捕獲中斷標識位。
    4. 值得注意的是:丟擲與捕獲InterruptedException並不涉及執行緒標識位的捕獲與重置
    5. 怎麼理解我前面說的中斷標識位一旦被捕獲,“大部分時候”就會被重置?Thread類中有private native boolean isInterrupted(boolean ClearInterrupted);當傳參為false時就能在中斷標識位被捕獲後不重置。然而一般情況它只會用於兩個地方
      1. Thread類的static方法:此處會重置中斷標識位,而且無法指定某個執行緒物件,只能是當前執行緒去判斷

        1. Thread類的例項方法:這個方法也是常用的判斷執行緒中斷標識位的方法,而且不會重置標識位。

小結

要終止執行緒,目前JDK中可行的做法有:

  1. 自己設定變數去標識一個執行緒是否已中斷
  2. 合理利用JDK本身的執行緒中斷標識位去判斷執行緒是否中斷

這兩個做法都需要後續做相應處理比如去break迴圈,return方法或者丟擲異常等等。

執行緒何時終止?

執行緒終止原因一般來講有兩種:

  1. 執行緒執行完他的正常程式碼邏輯,自然結束。
  2. 執行緒執行中丟擲Throwable物件且不被顯式捕獲,JVM會終止執行緒。眾所周知:Throwable類是Exception和Error的父類!

執行緒異常終止ExplicitlyCatchExceptionAndDoNotThrow.java

package com.xlf;

public class ExplicitlyCatchExceptionAndDoNotThrow {

    public static void main(String[] args) throws Exception {
        boolean flag = true;
        System.out.println("Main started!");
        try {
            throw new InterruptedException();
        } catch (InterruptedException exception) {
            System.out.println("InterruptedException is caught!");
        }
        System.out.println("Main doesn't stop!");
        try {
            throw new Throwable();
        } catch (Throwable throwable) {
            System.out.println("Throwable is caught!");
        }
        System.out.println("Main is still here!");
        if (flag) {
            throw new Exception("Main is dead!");
        }
        System.out.println("You'll never see this!");
    }
}

控制檯輸出

測試結果分析

這個測試驗證了前面關於執行緒異常終止的結論:

執行緒執行中丟擲Throwable物件且不被顯式捕獲,JVM會終止執行緒。

優雅手動終止執行緒

執行緒執行中需要手動終止,最好的做法就是設定標識位(可以是interrupt也可以是自己定義的),然後及時捕獲標識位並丟擲異常,在業務邏輯的最後去捕獲異常並做一些收尾的清理動作:比如統計任務執行失敗成功的比例,或者關閉某些流等等。這樣,程式的執行就兼顧到了正常與異常的情況並得到了優雅的處理。

TerminateThreadGracefully.java

package com.xlf;

public class TerminateThreadGracefully {

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < 10; i++) {
            sb.append("show tables;\n");
        }
        int i = 0;
        try {
            for (String str : sb.toString().split("\n")) {
                if (i > 4) {
                    Thread.currentThread().interrupt();
                    if (Thread.currentThread().isInterrupted()) {
                        throw new InterruptedException();
                    }
                    System.out.println(i + " after interrupt");
                }
                System.out.println(str);
                System.out.println(i++);
            }
        } catch (InterruptedException exception) {
            // TODO:此處可能做一些清理工作
            System.out.println(Thread.currentThread().isInterrupted());
        }
        System.out.println("Thread main stops normally!");
    }
}

控制檯輸出

為何專案中的執行緒終止失敗?

我們專案中確實是呼叫了對應的執行緒物件的interrupt方法thread.interrupt();去終止執行緒的。

那麼為什麼執行緒不能相應中斷標識位並終止呢?

回到我們專案的業務邏輯:

整個job分為模板讀取、渲染以及SQL執行三個階段,一般而言前兩個階段時間會比較快。在後續逐句執行sql語句的過程中,每一句sql都是呼叫的周邊服務(DLI,OBS,MySql等)去執行的,結果每次都會返回給我們的作業開發排程服務(DLF)後臺。我們的DLF平臺支援及時停止作業的功能,也就是說假如這個作業在排程過程中要執行10000條SQL,我要在中途停止不執行後面的SQL了——這樣的功能是支援的。

因此問題就出在了SQL執行的過程。經過多次debug發現:在SQL執行過程中需要每次都往OBS(華為自研,第三方包)中寫log,該過程不可略去。呼叫該執行緒物件的interrupt方法thread.interrupt(),interrupt標識位最早被OBS底層用到的java.util.concurrent. CountDownLatch類的await()方法捕獲到,重置標識位並丟擲異常,然後在一層層往上拋的時候被轉變成了別的異常型別,而且不能根據最終拋的異常型別去判斷是否是由於我們手動終止job引起的。

對於第三方包OBS根據自己的底層邏輯去處理CountDownLatch拋的異常,這本無可厚非。但是我們的程式終止不了!為了達到終止執行緒的做法,我在其中加入了一個自定義的標誌變數,當呼叫thread.interrupt()的時候去設定變數的狀態,並在幾個關鍵點比如OBS寫log之後去判斷我的自定義標識位的狀態,如果狀態改變了就丟擲RuntimeException(可以不被捕獲,最小化改動程式碼)。並且為了能重用執行緒池裡的執行緒物件,在每次job開始的地方去從重置這一自定義標識位。最終達到了優雅手動終止job的目的。

這一部分的原始碼涉及專案細節就不貼出來了,但是相關的邏輯前面已經程式碼展示過。

系統記憶體佔用較高且不準確

線上程中執行過程中定義的普通的區域性變數,非ThreadLocal型,一般而言會隨著執行緒結束而得到回收。我所遇到的現象是上面的那個執行緒無法停止的bug解決之後,執行緒停下來了,但是在linux上執行top命令相應程序記憶體佔用還是很高。

  1. 首先我用jmap -histo:alive pid命令對jvm進行進行了強制GC,發現此時堆記憶體確實基本上沒用到多少(不關老年帶還是年輕帶都大概是1%左右。)但是top命令看到的佔用大概在18% * 7G(linux總記憶體)左右。
  2. 其次,我用了jcmd命令去對對外記憶體進行分析,排斥了堆外記憶體洩露的問題
  3. 然後接下來就是用jstack命令檢視jvm程序的各個執行緒都是什麼樣的狀態。與job有關的幾個執行緒全部是waiting on condition狀態(執行緒結束,執行緒池將他們掛起的)。
  4. 那麼,現在得到一個初步的結論就是:不管是該jvm程序用到的堆記憶體還是堆外記憶體,都很小(相對於top命令顯式的18% * 8G佔用量而言)。所以是否可以猜想:jvm只是向作業系統申請了這麼多記憶體暫時沒有歸還回去,留待下次執行緒池有新任務時繼續複用呢?本文最後一部分試驗就圍繞著一點展開。

現象重現

在如下試驗中

設定jvm引數為:

-Xms100m -Xmx200m -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

其意義在於:

限制jvm初始記憶體為100M,最大堆記憶體為200M。並在jvm發生垃圾回收時及時列印詳細的GC資訊以及時間戳。而我的程式碼裡要做的事情就是重現jvm記憶體不夠而不得不發生垃圾回收。同時觀察作業系統層面該java程序的記憶體佔用。

SystemMemoryOccupiedAndReleaseTest.java

package com.xlf;

import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class SystemMemoryOccupiedAndReleaseTest {
    public static void main(String[] args) {

        try {
            System.out.println("start");
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3,
            30, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(),
            new ThreadFactory() {
                public Thread newThread(Runnable r) {
                    return new Thread(r);
                }
            },
            new ThreadPoolExecutor.AbortPolicy());

        try {
            System.out.println("(executor已初始化):");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t1 = new Thread(new Runnable() {
            {
                System.out.println("t1 已經初始化");
            }
            @Override
            public void run() {
                byte[] b = new byte[100 * 1024 * 1024];
                System.out.println("t1分配了100M空間給陣列");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    throw new RuntimeException("t1 stop");
                }
                System.out.println("t1 stop");
            }
        }, "t1");

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(new Runnable() {
            {
                System.out.println("t2 已經初始化");
            }
            @Override
            public void run() {
                byte[] b = new byte[100 * 1024 * 1024];
                System.out.println("t2分配了100M空間給陣列");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    throw new RuntimeException("t2 stop");
                }
                System.out.println("t2 stop");
            }
        }, "t2");


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t3 = new Thread(new Runnable() {
            {
                System.out.println("t3 已經初始化");
            }
            @Override
            public void run() {
                byte[] b = new byte[100 * 1024 * 1024];
                System.out.println("t3分配了100M空間給陣列");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    throw new RuntimeException("t3 stop");
                }
                System.out.println("t3 stop");
            }
        }, "t3");


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        executor.execute(t1);
        System.out.println("t1 executed!");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }
        executor.execute(t2);
        System.out.println("t2 executed!");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }
        executor.execute(t3);
        System.out.println("t3 executed!");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }

        System.out.println("jmap -histo:live pid by cmd:");
        try {
            Thread.sleep(20000);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }
        System.out.println("After jmap!");
        // You may run jmap -heap pid using cmd here
        // executor.shutdown();
    }
}

上述程式碼裡我先定義了三個Thread物件,這三個物件都是在run()方法裡分配了100M大小的char[],然後執行緒休眠(sleep)5秒。然後new一個執行緒池,並將這三個執行緒物件依次交給執行緒池去execute。執行緒池每兩次execute之間相隔10秒,這是為了給足時間給上一個執行緒跑完並讓jvm去回收這部分記憶體(200M的最大堆記憶體,一個執行緒物件要佔用100多M,要跑下一個執行緒必然會發生GC),這樣就能把GC資訊列印下來便於觀察。最後等到三個執行緒都執行完畢sleep一段時間(大概20秒),讓我有時間手動在cmd執行jmap -histo live pid,該命令會強制觸發FullGC,jmap命令之後你也可以試著執行jmap -heap pid,該命令不會觸發gc,但是可以看下整個jvm堆的佔用詳情.

控制檯輸出

在jmp -histo:live執行之前程序在作業系統記憶體佔用:

執行jmp -histo:live之後

執行jmap -heap pid的結果:

測試結果分析/win10工作管理員不準確

t1分配了100M空間給陣列之後,t2結束:

記憶體佔用:107042K,總可用堆空間大小:166400K

無法給t2分配100M,觸發FullGC:

103650K->1036K(98304K)

t2分配了100M空間給陣列之後,t2結束:

記憶體佔用:104461K,總可用堆空間大小:166400K

無法給t3分配100M,觸發FullGC:

103532K->1037K(123904K)

t3分配了100M空間給陣列之後,t3結束.

jmap -histo:live pid by cmd:

103565K->997K(123904K)

最後jmap -heap pid結果中堆大小也是123M。

這一過程中,作業系統層面jvm程序記憶體佔用不會超過122M,jmap -histo:live pid觸發FullGC之後維持在87M左右(反覆幾次試驗都是這個結果)

那麼為什麼jvm的堆疊資訊大小與資源管理器對應的不一致呢?

這個問題在網上搜了一圈,結論如下:

提交記憶體指的是程式要求系統為程式執行的最低大小,如果得不到滿足,就會出現記憶體不足的提示。

工作集記憶體才是程式真正佔用的記憶體,而工作集記憶體=可共享記憶體+專用記憶體

可共享記憶體的用處是當你開啟更多更大的軟體時,或者進行記憶體整理時,這一部分會被分給其他軟體,所以這一塊算是為程式執行預留下來的記憶體專用記憶體,專用記憶體指的是目前程式執行獨佔的記憶體,這一塊和可共享記憶體不一樣,無論目前系統記憶體多麼緊張,這塊專用記憶體是不會主動給其他程式騰出空間的

所以總結一下就是,工作管理員顯示的記憶體,實際上是顯示的程式的專用記憶體而程式真正佔用的記憶體,是工作集記憶體

上面兩張圖能對的上:

如下兩張圖“勉強”能對的上:

但是和jmap觸發gc之後的堆記憶體123904K還有點差距,這部分差距不大,暫時網上找不到比較靠譜的回答,筆者猜想可能這一部分用的是別的程序的可共享記憶體。我去linux上試了一把,也有這個記憶體算不準的問題。這個問題留待下次填坑吧~~

結論

  1. 執行緒結束可以是正常結束,也可以是丟擲不被catch的Throwable物件而異常終止
  2. 執行緒結束後,執行緒所佔記憶體空間會在jvm需要空間時進行回收利用,這些空間主要包括:分配在堆上的物件,其唯一引用只存在於該執行緒中
  3. JVM在進行FullGC後雖然堆空間佔用很小,但並不會僅僅向作業系統申請xms大小的記憶體,這部分看似很大的可用記憶體,實際上會在有新的執行緒任務分配時得到利用
  4. JVM程序堆記憶體佔用比作業系統層面統計的該程序記憶體佔用稍高一些,可能是共享記憶體的原因,這點留待下次填坑!

寫在最後

附上本文中描述的所有程式碼以及對應資原始檔,供大家參考學習!也歡迎大家評論提問!

 VelocityExperiment.zip 19.40KB

本文分享自華為雲社群《一個神奇的bug:OOM?優雅終止執行緒?系統記憶體佔用較高?》,原文作者:UnstoppableRock。

 

點選關注,第一時間瞭解華為雲新鮮技