1. 程式人生 > >Java多執行緒程式設計學習總結(二)

Java多執行緒程式設計學習總結(二)

 

(尊重勞動成果,轉載請註明出處:https://blog.csdn.net/qq_25827845/article/details/84894463冷血之心的部落格)

系列文章:

Java多執行緒程式設計學習總結(一)

Java多執行緒程式設計學習總結(二)

前序:

       距離上一篇多執行緒的總結:Java多執行緒程式設計學習總結(一)已經很長一段時間了,在這半年的工作與學習中,我對Java多執行緒程式設計的認識又得到了一些提升,將一些概念實際運用到了工作中,做到了學以致用。在這篇部落格中,我們來分析一個由於執行緒池使用不當所造成記憶體溢位OutOfMemoryError

的故障。

場景:

       在一個場景中,服務端接收來自一個客戶端的請求,並且去查詢N個業務方所提供的介面去查詢資料,並且將資料返回給客戶端進行顯示。

分析:

       由於業務方的數量N較大,並且使用者需要獲取到最新的資料,這部分資料涉及到了隱私,拒絕長時間的快取。所以,這近乎一個實時的資料查詢,給服務端帶來了一定的壓力。

解決方案:

      我們可以使用多執行緒去獲取資料,加快資料的返回速度。

問題:

       N個業務方提供的介面質量參差不齊,並且可能涉及到跨機房跳轉的問題,導致客戶端出現請求超時的問題頻現,使用者體驗較差。

解決方案:

       我們將多執行緒返回的資料存入一個快取中,客戶端在獲取資料請求發起之後,開始輪詢開始發起非同步請求,服務端收到請求之後,將當前快取中已經準備的資料返回,客戶端將資料顯示,這樣給使用者一種資料逐漸被獲取的感覺,解決了請求超時的問題,提升了使用者體驗。

---------------------------------------------------------------------------------------------------------------------

       好了,問題得到了完美的解決,接下來便是搬磚(開發),聯調,測試,上線。

然而好景不長,該服務出現了記憶體不足,並且自動crash掉的問題。錯誤檔案沒有儲存下來,大概意思是 OutOfMemoryError: unable to create new native thread. 無法建立新的執行緒,並且分析了可能的原因並且給出瞭解決方案。

解決方法1:

(1)故障恰好發生在週末(無心檢查程式碼>_<),既然是記憶體溢位,那麼我的第一選擇是看看當前的JVM引數配置,如下所示:

-Xms2048m -Xmx2048m   -XX:NewSize=1024m -XX:NewRatio=3 

好,既然記憶體不足,考慮到crash發生在QPS比較高的時刻,我懷疑是建立的執行緒太多導致記憶體溢位了。那麼我們暫且將記憶體調大到4G來觀察下效果。

---------------------------------------------------------------------------------------------------------------

     但是,好景還是不長,同樣的問題又出現了。我頭特別鐵,還是懷疑是QPS太高導致建立的執行緒太多,才會出現記憶體溢位的問題。繼續解決。

解決方法2:

(1)查詢資料後,我進一步調整了JVM的啟動引數,設定了-Xss 每個執行緒的Stack大小,從預設的1M,調整到了512K

(2)修改程式碼邏輯,減小了一個請求的建立次數,犧牲了使用者的體驗效果。

----------------------------------------------------------------------------------------------------------------

       就這樣開開心心的過了兩天,以為已經完美解決了該問題。然後兩天後,同樣的問題繼續出現 (撓頭......)這個時候我已經頭破血流了,懷疑自己的程式碼是否足夠優雅&高效,應該是有某些關鍵的地方存在記憶體洩漏的問題,最後導致記憶體溢位(多大的記憶體都不好使)。

      檢查程式碼,發現服務端收到請求之後,會發起一個HTTP的Get請求,去業務方的介面中去獲取資料,其中肯定涉及到了一些關鍵的資源。就是一個普通的Get請求方法。

     /**
     * get請求
     * @return
     */
   public static String doGet(String url) throws IOException {

        String strResult = "";
        HttpClient client = HttpClients.custom().build();
        //傳送get請求
        HttpGet request = new HttpGet(url);
        HttpResponse response = client.execute(request);

        /**請求傳送成功,並得到響應**/
        if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
            /**讀取伺服器返回過來的json字串資料**/
            strResult = EntityUtils.toString(response.getEntity());
        }
        return strResult;
    }

        查閱了一些參考資料,給出的一致結論是:在高併發請求下,如果HTTPClient和response在使用完畢之後,不進行close,那麼將會導致記憶體溢位。醍醐灌頂呀,彷彿這就是為我這次量身打造的解決方案一樣。突然覺得自己在寫這些方法的時候不應該直接去copy網上的程式碼,應該檢查才對。

public static String doGet(String url) {

        String strResult = "";
        CloseableHttpClient client = HttpClients.custom().build();
        //傳送get請求
        HttpGet request = new HttpGet(url);
        CloseableHttpResponse response = null;
        try {
            response = client.execute(request);
            /**請求傳送成功,並得到響應**/
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                /**讀取伺服器返回過來的json字串資料**/
                strResult = EntityUtils.toString(response.getEntity());
            }
        } catch (IOException e) {
            LOGGER.error("There is IOException where doGet, Exception:{}",e.toString());
        }finally {
            // 注意,此處必須close掉連線,否則高併發下會導致服務crash
            try {
                response.close();
                client.close();
            } catch (IOException e) {
                LOGGER.error("There is IOException where doGet, Exception:{}",e.toString());
            }

        }
        return strResult;
    }

接著,我將記憶體重新調回了2048M, 執行緒大小使用了預設的1M,而不是512K。

---------------------------------------------------------------------------------------------------------

        美滋滋,我感覺已經這次應該差不多了,因為記憶體洩漏的地方已經被我修復了,沒有理由還有問題。然後,同樣的問題繼續出現 (繼續撓頭......內心崩潰ing...)好,我繼續尋找程式碼中記憶體洩漏的點。

        這次我先上伺服器,通過命令  ps hH p 59178|wc -l (59178是pid)查看了當前服務中的執行緒個數詳細命令大家可以參考文章:http://www.cnblogs.com/kevingrace/p/5252919.html  。不查不知道,一查嚇一跳,當前服務的執行緒個數竟然是10000+,這是一個恐怖的執行緒個數,別的服務執行緒大概都在500以下。很明顯,問題發現了,當前服務建立了太多的執行緒,並且執行緒個數依然在逐漸上漲之中,應該是程式中的執行緒建立方式有問題,導致空閒的執行緒沒有被回收。

       說到了執行緒池,我們繼續來看執行緒池的概念和使用。

ThreadPoolExecutor是ExecutorService的預設實現類。這個類是一個執行緒池,我們只需要呼叫該類物件的submit或者execute方法,並且傳入相應的RunnableTask或者CallableTask即好。

       那麼我們如何建立執行緒池呢?

我們先來看看ThreadPoolExecutor的建構函式:

解釋下建構函式中涉及到的重要引數:

    corePoolSize:執行緒池中的核心執行緒數

    maximumPoolSize:執行緒池中允許的最大執行緒數

(ThreadPoolExecutor 將根據 corePoolSize和 maximumPoolSize設定的邊界自動調整池大小。當新任務在方法 execute(java.lang.Runnable) 中提交時,如果執行的執行緒少於 corePoolSize,則建立新執行緒來處理請求,即使其他輔助執行緒是空閒的。如果執行的執行緒多於 corePoolSize 而少於 maximumPoolSize,則僅當佇列滿時才建立新執行緒。)

    keepAliveTime:當執行緒數大於核心執行緒數時,終止多餘的空閒執行緒等待新任務的最長時間,超過該時間的執行緒將被回收。

    unit:該引數表示keepAliveTime的時間單位

    workQueue:用於表示任務的佇列

執行緒池可以解決兩個不同問題:由於減少了每個任務呼叫的開銷,它們通常可以在執行大量非同步任務時提供增強的效能,並且還可以提供繫結和管理資源(包括執行任務集時使用的執行緒)的方法。每個 ThreadPoolExecutor 還維護著一些基本的統計資料,如完成的任務數。

       儘管我們可以通過調整建構函式中的值來建立一個執行緒池,但是,我們也可以使用較為方便的 Executors 工廠方法 Executors.newCachedThreadPool()(無界執行緒池,可以進行自動執行緒回收)、Executors.newFixedThreadPool(int)(固定大小執行緒池)和 Executors.newSingleThreadExecutor()(單個後臺執行緒),它們均為大多數使用場景預定義了設定。

       如果你瞭解Array和Arrays,Collection和Collections的關係,那麼你一定會猜到Java提供了Executor框架的同時也提供了工具類Executors,使用該工具類可以非常方便的建立不同型別的執行緒池,我們通過ThreadPoolThread的建構函式中的核心執行緒數以及最大執行緒數來說明下。

Executors.newCachedThreadPool():無界執行緒池,將 maximumPoolSize 設定為基本的無界值(如 Integer.MAX_VALUE),則允許池適應任意數量的併發任務。來一個建立一個執行緒,適合用來執行大量耗時較短且提交頻率較高的任務。

Executors.newFixedThreadPool(int):固定大小執行緒池,設定的 corePoolSize 和 maximumPoolSize 相同,則建立了固定大小的執行緒池。當執行緒池大小達到核心執行緒池大小,就不會增加也不會減小工作者執行緒的固定大小的執行緒池。

Executors.newSingleThreadExecutor( ):便於實現單(多)生產者-消費者模式。

接下來,我們說一下引數workQueue,也就是任務佇列,既然是佇列,那麼對於任務來說,肯定會存在一個排隊策略。

  • 如果執行的執行緒少於 corePoolSize,則 Executor 始終首選新增新的執行緒,而不進行排隊。
  • 如果執行的執行緒等於或多於 corePoolSize,則 Executor 始終首選將請求加入佇列,而不新增新的執行緒。
  • 如果無法將請求加入佇列,則建立新的執行緒,除非建立此執行緒超出 maximumPoolSize,在這種情況下,任務將被拒絕。

--------------------------------------------------------------------------------------------------------------

我是華麗的分割線,好了執行緒池的概念先說到這裡,接下來,我們繼續看看我遇到的記憶體溢位問題。

---------------------------------------------------------------------------------------------------------------

       在我的程式中,建立了固定大小的執行緒池,也就是使用了Executors.newFixedThreadPool(50) 。由於我的錯誤用法,將建立執行緒池的步驟放在了局部,並不是一個全域性的固定大小的執行緒池,導致客戶端的每一個請求都會建立50個執行緒。固定大小的執行緒池沒有設定其過期回收時間,也就是keepAliveTime=0. 看看原始碼如下:

/**
     * Creates a thread pool that reuses a fixed number of threads
     * operating off a shared unbounded queue.  At any point, at most
     * {@code nThreads} threads will be active processing tasks.
     * If additional tasks are submitted when all threads are active,
     * they will wait in the queue until a thread is available.
     * If any thread terminates due to a failure during execution
     * prior to shutdown, a new one will take its place if needed to
     * execute subsequent tasks.  The threads in the pool will exist
     * until it is explicitly {@link ExecutorService#shutdown shutdown}.
     *
     * @param nThreads the number of threads in the pool
     * @return the newly created thread pool
     * @throws IllegalArgumentException if {@code nThreads <= 0}
     */
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

也就是說,我們當前的程序所擁有的執行緒個數在不斷的增加,空閒執行緒並不會被自動回收,導致JVM記憶體溢位。大概相當於下邊的程式碼執行結果:

package com.ywq;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {


    public static void main(String[] args){
        while (true) {
            createThread(100000);
        }
    }
    private static void createThread(int num) {
        ExecutorService executorService = Executors.newFixedThreadPool(num);
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println("Ok");
            }
        });
    }
}

我們在IDEA中設定JVM引數,給當前服務分配2G的記憶體,-Xms2048m -Xmx2048m -XX:NewSize=1024m -XX:NewRatio=3,執行程式,結果如下:

這就是我在程式中所犯的錯誤,改進程式碼如下:

package com.ywq;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {

    private static ExecutorService executorService = Executors.newCachedThreadPool();
    public static void main(String[] args) {
        while (true) {
            createThread(100000);
        }
    }
    private static void createThread(int num) {
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println("Ok");
            }
        });
    }
}

我們定義了一個全域性的執行緒池,並且是一個newCachedThreadPool,我們看以下原始碼:

/**
     * Creates a thread pool that creates new threads as needed, but
     * will reuse previously constructed threads when they are
     * available.  These pools will typically improve the performance
     * of programs that execute many short-lived asynchronous tasks.
     * Calls to {@code execute} will reuse previously constructed
     * threads if available. If no existing thread is available, a new
     * thread will be created and added to the pool. Threads that have
     * not been used for sixty seconds are terminated and removed from
     * the cache. Thus, a pool that remains idle for long enough will
     * not consume any resources. Note that pools with similar
     * properties but different details (for example, timeout parameters)
     * may be created using {@link ThreadPoolExecutor} constructors.
     *
     * @return the newly created thread pool
     */
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

空閒執行緒超過60s之後,會進行空閒執行緒的回收,但是我們執行上邊的測試程式碼,依然會出現記憶體不足的問題。為什麼呢?

答案:當然會出現了,我們的測試程式碼的併發太高了,相當於在60s之內會建立N個執行緒,根本來不及回收就記憶體溢位了。

為了演示效果,我們使用自己的引數來定義一個執行緒池,1ms就進行一次空閒執行緒的回收。講道理,這樣就不會記憶體溢位。

package com.ywq;

import java.util.concurrent.*;

public class Test {

    private static ExecutorService executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE,
            1L, TimeUnit.MILLISECONDS,
            new SynchronousQueue<Runnable>());;
    public static void main(String[] args) {
        while (true) {
            createThread(100000);
        }
    }
    private static void createThread(int num) {
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println("Ok");
            }
        });
    }
}

        所以,我的這一次JVM記憶體溢位的原因就是因為執行緒池的使用不當造成的。阿里巴巴的開發手冊上強烈建議大家在使用執行緒池的時候進行自定義,這樣才會對各個引數更加敏感,寫出的程式碼才會更加優雅而可靠。

 

總結:

     多執行緒程式設計,變化莫測,詭計多端,我們需要在理解多執行緒概念的基礎上與實際工作進行有效結合,養成一種對引數的敏感性。另外,出現記憶體溢位,十有八九是有記憶體洩漏的地方,各位千萬不要像我一樣頭鐵,堅持覺得自己程式碼無Bug >_<

 

如果對你有幫助,記得點贊哦~歡迎大家關注我的部落格,我會持續更新後續學習筆記,如果有什麼問題,可以進群824733818一起交流學習哦~