如何儲存/恢復Java應用程式核心記憶體資料現場?

buildupchao發表於2019-02-02

0. 背景

不論是單體應用還是分散式應用,總是會有些許迭代或者緊急Fix bug上線的神操作。但是如果不是那麼幸運,當時還存在大量核心記憶體中資料在進行計算等邏輯,此時終止專案,就會出現核心資料或者狀態丟失的不利情況,後續即使上線完成也要儘快追加資料。

那是否存在某種技巧???:在需要終止應用的時候,能夠監聽到終止操作,並儲存核心資料現場,然後再終止應用,而後在應用恢復後,再進行核心資料恢復。

答案是肯定的。
複製程式碼

0.1 技術儲備

Runtime.getRuntime().addShutdownHook(Thread thread);
複製程式碼

我們可以藉助於JDK為我們所提供的上述鉤子方法。這個方法的意思就是在JVM中增加一個關閉的鉤子,當JVM關閉的時候,會執行系統中已經設定的所有通過方法addShutdownHook新增的鉤子,當系統執行完這些鉤子後,JVM才會關閉。所以這些鉤子可以在JVM關閉的時候進行記憶體清理、物件銷燬以及核心資料現場儲存等操作。

1. 假設一種場景

1.1 儲存現場,為應用保駕護航

我們應用程式執行中,在記憶體中儲存著Map<String, User>(使用者唯一識別符號和使用者資訊的對映關係),此時,突然需要緊急處理某個bug並打包上線。

使用者對映關係已經建立好了,我們總不能因為緊急上線就讓使用者重新登入一次,只是為了構建這個對映關係???這樣顯然不是很合理,其次還有使用者流失的風險,我們怎麼可以去冒著被大boss怒懟這般的大風險呢,搞不好年終獎還沒有,哈哈哈哈哈……

那我們換個思路,我們要解決的問題是什麼呢?因為Map<String, User>是在記憶體中儲存的,一但應用終止,記憶體資源釋放,記憶體中資料當然無存……所以,我們的目標就是儲存這個處於記憶體中的Map物件,對不對?那就簡單了,我們可以把這個物件序列化儲存到本地檔案裡面不就好了嗎?是不是很簡單?然後呢,只需要在應用程式被終止前序列化且儲存到本地檔案,就可以了。

理好了思路,那就開始Coding吧!

	private static final HashMap<String, User> cacheData = new HashMap<>();
    private static final String filePath = System.getProperty("user.dir")
				     			+ File.separator + "save_point.binary";

	Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                saveData();
            }
        });

	private static void saveData() {
        ObjectOutputStream oos = null;
        try {
            File cacheFile = new File(filePath);
            if (!cacheFile.exists()) {
                cacheFile.createNewFile();
            }
            oos = new ObjectOutputStream(new FileOutputStream(filePath));
            oos.writeObject(cacheData);
            oos.flush();
        } catch (IOException ex) {
            LOGGER.error("save memory data error", ex);
        } finally {
            try {
                if (oos != null) {
                    oos.close();
                }
            } catch (IOException ex) {
                LOGGER.error("close ObjectOutputStream error", ex);
            }
        }
    }
複製程式碼

這樣我們就可以保證Map<String, User>這個對映關係儲存好了。

1.2 恢復現場,讓應用快速飛翔

既然我們儲存了記憶體資料現場,那在應用啟動後,我們相應的也需要進行資料現場恢復,這樣才能保證應用平滑過渡到終止前狀態,同時使用者還能無感知。

繼續Coding...

	@PostConstruct
	public void resoverData() {
        ObjectInputStream ois = null;
        try {
            File cacheFile = new File(filePath);
            if (cacheFile.exists()) {
                ois = new ObjectInputStream(new FileInputStream(filePath));
                Map<String, User> cacheMap =
                					(Map<String, User>) ois.readObject();
                for (Map.Entry<String, User> entry : cacheMap.entrySet()) {
                    cacheData.put(entry.getKey(), entry.getValue());
                }
                LOGGER.info("Recover memory data successfully, cacheData={}"
                							, cacheData.toString());
            }
        } catch (Exception ex) {
            LOGGER.error("recover memory data error", ex);
        } finally {
            try {
                if (ois != null) {
                    ois.close();
                }
            } catch (IOException ex) {
                LOGGER.error("close ObjectInputStream error", ex);
            }
        }
    }
複製程式碼

是不是整個過程似曾相識?沒錯,就是Java IO流 ObjectInputStreamObjectOutputStream的應用。但是有一點需要注意,使用物件流的時候,需要保證被序列化的物件必須實現了Serializable介面,這樣才能正常使用。

應用整體呼叫邏輯如下(測試的時候,第一次需要正常呼叫generateAndPutData()方法,終止專案儲存現場後,需要把generateAndPutData()註釋掉,看看時候正確恢復現場了。):

	@SpringBootApplication
	public class SavePointApplication {

    private static final Logger LOGGER =
    				LoggerFactory.getLogger(SavePointApplication.class);

    private static final HashMap<String, User> cacheData = new HashMap<>();
    private static final String filePath = System.getProperty("user.dir")
    				+ File.separator + "save_point.binary";

    public static void main(String[] args) {
        SpringApplication.run(SavePointApplication.class, args);

        LOGGER.info("save_point filePath={}", filePath);
        generateAndPutData();

        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                saveData();
            }
        });
    }

	private static void generateAndPutData() {
        cacheData.put("test1", new User(1L, "testName1"));
        cacheData.put("test2", new User(2L, "testName2"));
        cacheData.put("test3", new User(3L, "testName3"));
    }
複製程式碼

2. Fuck! 沒有儲存現場?!

為什麼應用程式終止時沒有儲存現場狀態呢?那就要細說一下關閉鉤子(shutdown hooks)了。

  • 如果JVM因異常關閉,那麼子執行緒(Hook本質上也是子執行緒)將不會停止。但在JVM被強行關閉時,這些執行緒都會被強行結束。

  • 關閉鉤子本質是一個執行緒(也稱為Hook執行緒),用來監聽JVM的關閉。通過Runtime的addShutdownHook可以向JVM註冊一個關閉鉤子。Hook執行緒在JVM正常關閉才會執行,強制關閉時不會執行。

  • JVM中註冊的多個關閉鉤子是併發執行的,無法保證執行順序,當所有Hook執行緒執行完畢,runFinalizersOnExit為true,JVM會先執行終結器,然後停止。

所以,如果我們直接使用的kill -9 processId命令直接強制關閉的應用程式,JVM都被強制關閉了,還怎麼執行我們的Java程式碼呢?嘿嘿,所以我們可以嘗試著用如下命令替代kill -9 processId:

kill processId
kill -2 processId
kill -15 processId
複製程式碼

通過上述命令進行終止應用的時候,是不是我們看到我們專案下成功生成了 save_point.binary 檔案了,哈哈哈哈哈……

3. 使用關閉鉤子有哪些注意事項呢?

  • hook執行緒會延遲JVM的關閉時間,所以儘可能減少執行時間。
  • 關閉鉤子中不要呼叫system.exit(),會卡主JVM的關閉過程。但是可以呼叫Runtime.halt()
  • 不能在鉤子中進行鉤子的新增和刪除,會拋IllegalStateException
  • 在system.exit()後新增的鉤子無效,因為此時JVM已經關閉了。
  • 當JVM收到SIGTERM命令(比如作業系統在關閉時)後,如果鉤子執行緒在一定時間沒有完成,那麼Hook執行緒可能在執行過程中被終止。
  • Hook執行緒也會拋錯,若未捕獲,則鉤子的執行序列會被停止。

相關文章