(轉載)ES6、ES7、ES8、ES9、ES10新特性一覽
965
2025-03-31
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對象過大。具體表現在:
如果系統沒有足夠大的內存空間分配給JVM,會導致OOM,因為這部分內存并不是無用內存,JVM不能回收
如果系統有足夠大的內存空間分配給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注釋中提到的設置標志位的做法。這類做法又分為兩種,無論哪一種都需要去被終止的線程本身去“主動”地判斷該標志位的狀態:
設置一個常規的標志位,比如:boolean類型變量的true/ false, 根據變量的狀態去決定線程是否繼續運行——代碼里去主動判斷變量狀態。這種一般用在循環中,檢測到相應狀態就break, return或者throw exception。
使用Thead類的實例方法interrupt去終止該thread對象代表的線程。但是interrupt方法本質上也是設置了一個中斷標識位,而且該標志位一旦被捕獲(讀取),“大部分時候”就會被重置(失效)。因此它并不保證線程一定能夠停止,而且不保證馬上能夠停止,有如下幾類情況:
interrupt方法設置的中斷標識位后,如果該線程往后的程序執行邏輯中執行了Object類的wait/join/sleep,這3個方法會及時捕獲interrupt標志位,重置并拋出InterruptedException。
類似于上一點,java.nio.channels包下的InterruptibleChannel類也會去主動捕獲interrupt標志位,即線程處于InterruptibleChannel的I/O阻塞中也會被中斷,之后標志位同樣會被重置,然后channel關閉,拋出java.nio.channels.ClosedByInterruptException;同樣的例子還有java.nio.channels.Selector,詳見JavaDoc
Thread類的實例方法isInterrupted()也能去捕獲中斷標識位并重置標識位,這個方法用在需要判斷程序終止的地方,可以理解為主動且顯式地去捕獲中斷標識位。
值得注意的是:拋出與捕獲InterruptedException并不涉及線程標識位的捕獲與重置
怎么理解我前面說的中斷標識位一旦被捕獲,“大部分時候”就會被重置?Thread類中有private native boolean isInterrupted(boolean ClearInterrupted);當傳參為false時就能在中斷標識位被捕獲后不重置。然而一般情況它只會用于兩個地方
Thread類的static方法:此處會重置中斷標識位,而且無法指定某個線程對象,只能是當前線程去判斷
Thread類的實例方法:這個方法也是常用的判斷線程中斷標識位的方法,而且不會重置標識位。
小結
要終止線程,目前JDK中可行的做法有:
自己設置變量去標識一個線程是否已中斷
合理利用JDK本身的線程中斷標識位去判斷線程是否中斷
這兩個做法都需要后續做相應處理比如去break循環,return方法或者拋出異常等等。
線程何時終止?
線程終止原因一般來講有兩種:
線程執行完他的正常代碼邏輯,自然結束。
線程執行中拋出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命令相應進程內存占用還是很高。
首先我用jmap -histo:alive pid命令對jvm進行進行了強制GC,發現此時堆內存確實基本上沒用到多少(不關老年帶還是年輕帶都大概是1%左右。)但是top命令看到的占用大概在18% * 7G(linux總內存)左右。
其次,我用了jcmd命令去對對外內存進行分析,排斥了堆外內存泄露的問題
然后接下來就是用jstack命令查看jvm進程的各個線程都是什么樣的狀態。與job有關的幾個線程全部是waiting on condition狀態(線程結束,線程池將他們掛起的)。
那么,現在得到一個初步的結論就是:不管是該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
上述代碼里我先定義了三個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上試了一把,也有這個內存算不準的問題。這個問題留待下次填坑吧~~
結論
線程結束可以是正常結束,也可以是拋出不被catch的Throwable對象而異常終止
線程結束后,線程所占內存空間會在jvm需要空間時進行回收利用,這些空間主要包括:分配在堆上的對象,其唯一引用只存在于該線程中
JVM在進行FullGC后雖然堆空間占用很小,但并不會僅僅向操作系統申請xms大小的內存,這部分看似很大的可用內存,實際上會在有新的線程任務分配時得到利用
JVM進程堆內存占用比操作系統層面統計的該進程內存占用稍高一些,可能是共享內存的原因,這點留待下次填坑!
寫在最后
附上本文中描述的所有代碼以及對應資源文件,供大家參考學習!也歡迎大家評論提問!
附件: VelocityExperiment.zip 19.40KB 下載次數:0次
多線程 Java EI企業智能 數據湖治理中心 DGC
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。