作者:valar
前言
長文預(yù)警。該文主要介紹因線上OOM而引發(fā)的問題定位、分析問題的原因、以及如何解決問題。在分析問題原因時候為了能更詳細(xì)的呈現(xiàn)出引發(fā)問題的原因,去翻了hdfs 提供的JAVA Api主要的類FileSystem的部分代碼。由于這部分源代碼的分析實在是太太太長了,可以直接跳過看最后的結(jié)論,當(dāng)然有興趣的可以看下。
風(fēng)起
一日,突然收到若干線上告警。于是趕緊查看日志,在日志中大量線程報出OOM錯誤:
Exception in thread "http-nio-8182-exec-29" java.lang.OutOfMemoryError: Java heap space
于是使用jstat命令查看該進(jìn)程內(nèi)存使用情況:jstat -gcutil 12492 1000 100
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 100.00 99.89 96.78 94.41 200 1.272 2925 328.850 330.122 0.00 0.00 99.89 99.89 96.78 94.41 200 1.272 2935 329.908 331.180 0.00 0.00 100.00 99.89 96.78 94.41 200 1.272 2944 330.853 332.125 0.00 0.00 99.89 99.89 96.78 94.41 200 1.272 2955 332.002 333.274 0.00 0.00 100.00 99.89 96.78 94.41 200 1.272 2964 332.940 334.212 0.00 0.00 100.00 99.89 96.78 94.41 200 1.272 2973 333.924 335.196
可以看出,該進(jìn)程老年代內(nèi)存耗盡,導(dǎo)致OOM,且引發(fā)了頻繁的FGC。而在對堆參數(shù)配置中是完全能滿足項目運(yùn)行的,于是查看了其他幾個節(jié)點的內(nèi)存使用情況,老年代使用率都高達(dá)98以上且FGC次數(shù)也在增加。
由于線上環(huán)境影響業(yè)務(wù),便dump出內(nèi)存快照,然后臨時重啟了節(jié)點,重啟之后查看內(nèi)存使用情況: jstat -gcutil 18190 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 1.04 0.00 50.39 22.87 95.96 93.41 1680 20.542 4 0.136 20.679 1.04 0.00 50.39 22.87 95.96 93.41 1680 20.542 4 0.136 20.679 1.04 0.00 50.39 22.87 95.96 93.41 1680 20.542 4 0.136 20.679
雖然暫時業(yè)務(wù)恢復(fù),但該問題還是需要解決的。從上能初步分析出問題是由于內(nèi)存泄漏,導(dǎo)致在運(yùn)行一段時間之后OOM。
定位
在將dump出的快照導(dǎo)入MAT中查看,并沒有找到特別大的對象,但是看見很多個org.Apache.hadoop.conf.Configuration實例。在代碼中使用了hdfs的API操作hdfs,該類為連接hdfs的配置類。如下:
于是在本地debug啟動一個與線上相同代碼的進(jìn)程,并dump出該內(nèi)存快照。在MAT中查看該Configuration類的實例,僅一個實例。到此,差不多能定位是通過Java Api與hdfs交互時,導(dǎo)致某些對象不能回收出現(xiàn)的問題。
然后在本地編寫測試接口,通過測試接口訪問hdfs,發(fā)現(xiàn)該Configuration類實例在增加,且在執(zhí)行GC的時候并不能回收。
至此,內(nèi)存泄漏的源頭可以說找到了,至于為什么會出現(xiàn)問題則需要查看這段代碼了。
原因
大致能確認(rèn),導(dǎo)致內(nèi)存泄漏的原因是與hdfs交互時某段代碼bug。于是翻開了項目中與hdfs交互的類,發(fā)現(xiàn)了等價于下面的代碼的訪問hdfs代碼:
public Path createDir(String name) throws IOException, InterruptedException { Path path = new Path(name); Configuration configuration = new Configuration(); FileSystem fileSystem = FileSystem.get(URI.create("hdfs://***:8020"), configuration, "hdfs");; if (fileSystem.mkdirs(path)) { return path; } return null; }
也就是說,在每次與hdfs交互時,都會與hdfs建立一次連接,并創(chuàng)建一個FileSystem對象。但在使用完之后并未調(diào)用close()方法釋放連接。
此處可能會有疑問,此處的Configuration實例和FileSystem實例都是局部變量,在該方法執(zhí)行完成之后,這兩個對象都應(yīng)該是會被回收的,怎么會導(dǎo)致內(nèi)存泄漏呢?
FileSystem是怎樣獲取的
在此,如果想知道該問題,就需要去翻FileSystem類的代碼了。FileSystem的get方法如下:
public static FileSystem get(URI uri, Configuration conf) throws IOException { String scheme = uri.getScheme(); String authority = uri.getAuthority(); if (scheme == null && authority == null) { // use default FS return get(conf); } if (scheme != null && authority == null) { // no authority URI defaultUri = getDefaultUri(conf); if (scheme.equals(defaultUri.getScheme()) // if scheme matches default && defaultUri.getAuthority() != null) { // & default has authority return get(defaultUri, conf); // return default } } String disableCacheName = String.format("fs.%s.impl.disable.cache", scheme); if (conf.getBoolean(disableCacheName, false)) { return createFileSystem(uri, conf); } return CACHE.get(uri, conf); }
重點看一下最后的6行代碼,其中String.format("fs.%s.impl.disable.cache", scheme)在連接hdfs時候該參數(shù)名為fs.hdfs.impl.disable.cache,可以從倒數(shù)第5行代碼看出該參數(shù)默認(rèn)值為false。也就是默認(rèn)情況下會通過CACHE對象返回FileSystem。
那接下來看一下CACHE.get方法:
FileSystem get(URI uri, Configuration conf) throws IOException{ Key key = new Key(uri, conf); return getInternal(uri, conf, key); } private FileSystem getInternal(URI uri, Configuration conf, Key key) throws IOException{ FileSystem fs; synchronized (this) { fs = map.get(key); } if (fs != null) { return fs; } fs = createFileSystem(uri, conf); synchronized (this) { // refetch the lock again FileSystem oldfs = map.get(key); if (oldfs != null) { // a file system is created while lock is releasing fs.close(); // close the new file system return oldfs; // return the old file system } // now insert the new file system into the map if (map.isEmpty() && !ShutdownHookManager.get().isShutdownInProgress()) { ShutdownHookManager.get().addShutdownHook(clientFinalizer, SHUTDOWN_HOOK_PRIORITY); } fs.key = key; map.put(key, fs); if (conf.getBoolean("fs.automatic.close", true)) { toAutoClose.add(key); } return fs; } }
從這段代碼中可以看出:
- 在Cache類內(nèi)部維護(hù)了一個Map,該Map用于緩存已經(jīng)連接好的FileSystem對象,Map的Kep為Cache.Key對象。每次都會通過Cache.Key獲取FileSystem,如果未獲取到,才會繼續(xù)創(chuàng)建的流程。
- 在Cache類內(nèi)部維護(hù)了一個Set(toAutoClose),該Set用于存放需自動關(guān)閉的連接。在客戶端關(guān)閉時會自動關(guān)閉該集合中的連接。
在看完了上面的代碼之后,在看一下CACHE這個變量在FileSystem中是怎樣引用的:
/** FileSystem cache */ static final Cache CACHE = new Cache();
也就是說,該CACHE對象會一直存在不會被回收。而每次創(chuàng)建的FileSystem都會以Cache.Key為key,F(xiàn)ileSystem為Value存儲在Cache類中的Map中。那至于在緩存時候是否對于相同hdfs URI是否會存在多次緩存,就需要查看一下Cache.Key的hashCode方法了,如下:
@Override public int hashCode() { return (scheme + authority).hashCode() + ugi.hashCode() + (int)unique; }
可見,schema和authority變量為String類型,如果在相同的URI情況下,其hashCode是一致。unique在FilSystem.getApi下也不用關(guān)心,因為每次該參數(shù)的值都是0。那么此處需要重點關(guān)注一下ugi.hashCode()。
至此,來小結(jié)一下:
- 在獲取FileSystem時,F(xiàn)ileSystem內(nèi)置了一個static的Cache,該Cache內(nèi)部有一個Map,用于緩存已經(jīng)獲取的FileSystem連接。
- 參數(shù)fs.hdfs.impl.disable.cache,用于控制FileSystem是否需要緩存,默認(rèn)情況下是false,即緩存。
- Cache中的Map,Key為Cache.Key類,該類通過schem,authority,UserGroupInformation,unique 4個參數(shù)來確定一個Key,如上Cache.Key的hashCode方法。
但還有一個問題,既然FileSystem提供了Cache來緩存,那么在本例中對于相同的hdfs連接是不會出現(xiàn)每次獲取FileSystem都往Cache的Map中添加一個新的FileSystem。唯一的解釋是Cache.key的hashCode每次計算出來了不一樣的值,在Cache.Key的hashCode方法中決定相同的hdfs URI計算hashCode是否一致是由UserGroupInformation的hashCode方法決定的,接下來看一下該方法。
UserGroupInformation.hashCode
其方法定義如下:
@Override public int hashCode() { return System.identityHashCode(subject); }
該方法調(diào)用了本地方法identityHashCode,identityHashCod方法對不同的對象返回的hashCode將會不一樣,即使是實現(xiàn)了hashCode()的類。那么此處問題關(guān)鍵就轉(zhuǎn)化為UserGroupInformation類的subject是否在每次計算hashCode的時候是同一個對象。
由于該hashCode是計算Cache.key的hashCode時調(diào)用的,因此需要看Cache.Key初始化時候,是如何初始化UserGroupInformation該對象的,如下:
Key(URI uri, Configuration conf, long unique) throws IOException { scheme = uri.getScheme()==null ? "" : StringUtils.toLowerCase(uri.getScheme()); authority = uri.getAuthority()==null ? "" : StringUtils.toLowerCase(uri.getAuthority()); this.unique = unique; this.ugi = UserGroupInformation.getCurrentUser(); }
繼續(xù)看UserGroupInformation的getCurrentUser()方法,如下:
public static AccessControlContext getContext() { AccessControlContext acc = getStackAccessControlContext(); if (acc == null) { // all we had was privileged system code. We don't want // to return null though, so we construct a real ACC. return new AccessControlContext(null, true); } else { return acc.optimize(); } }
其中比較關(guān)鍵的是getStackAccessControlContext方法,該方法調(diào)用了Native方法,如下:
private static native AccessControlContext getStackAccessControlContext();
該方法會返回當(dāng)前堆棧的保護(hù)域權(quán)限的AccessControlContext對象。(關(guān)于該方法更多細(xì)節(jié)未深究,懂的大佬可指出來一下)
那么此處為什么會返回不同的Subject對象呢?由于在本例中是通過get(final URI uri, final Configuration conf,final String user) Api獲取的,因此折回去看一下這個方法,如下:
public static FileSystem get(final URI uri, final Configuration conf, final String user) throws IOException, InterruptedException { String ticketCachePath = conf.get(CommonConfigurationKeys.KERBEROS_TICKET_CACHE_PATH); UserGroupInformation ugi = UserGroupInformation.getBestUGI(ticketCachePath, user); return ugi.doAs(new PrivilegedExceptionAction<FileSystem>() { @Override public FileSystem run() throws IOException { return get(uri, conf); } }); }
在該方法中,先通過UserGroupInformation.getBestUGI方法獲取了一個UserGroupInformation對象,然后在通過UserGroupInformation的doAs方法去調(diào)用了get(URI uri, Configuration conf)方法。
先看一下UserGroupInformation.getBestUGI方法的實現(xiàn),此處關(guān)注一下傳入的兩個參數(shù)ticketCachePath,user。ticketCachePath是獲取配置hadoop.security.kerberos.ticket.cache.path的值,在本例中該參數(shù)未配置,因此ticketCachePath為空。user參數(shù)由于是本例中傳入的用戶名,因此該參數(shù)不會為空。實現(xiàn)如下:
public static UserGroupInformation getBestUGI( String ticketCachePath, String user) throws IOException { if (ticketCachePath != null) { return getUGIFromTicketCache(ticketCachePath, user); } else if (user == null) { return getCurrentUser(); } else { return createRemoteUser(user); } }
getBestUGI參數(shù)的兩個參數(shù),如上所分析ticketCachePath為空,user不為空,因此最終會執(zhí)行createRemoteUser方法。實現(xiàn)如下:
public static UserGroupInformation createRemoteUser(String user) { return createRemoteUser(user, AuthMethod.SIMPLE); } public static UserGroupInformation createRemoteUser(String user, AuthMethod authMethod) { if (user == null || user.isEmpty()) { throw new IllegalArgumentException("Null user"); } Subject subject = new Subject(); subject.getPrincipals().add(new User(user)); UserGroupInformation result = new UserGroupInformation(subject); result.setAuthenticationMethod(authMethod); return result; }
從代碼中,可以看出會通過createRemoteUser方法,來創(chuàng)建一個UserGroupInformation對象。在createRemoteUser方法中,創(chuàng)建了一個新的Subject對象,并通過該對象創(chuàng)建了UserGroupInformation對象。至此,UserGroupInformation.getBestUGI方法執(zhí)行完成。
接下來看一下UserGroupInformation.doAs方法(FileSystem.get(final URI uri, final Configuration conf, final String user)執(zhí)行的最后一個方法),如下:
public <T> T doAs(PrivilegedExceptionAction<T> action ) throws IOException, InterruptedException { try { logPrivilegedAction(subject, action); return Subject.doAs(subject, action); ………… 省略多余的
然后在調(diào)用Subject.doAs方法,如下:
public static <T> T doAs(final Subject subject, final java.security.PrivilegedExceptionAction<T> action) throws java.security.PrivilegedActionException { java.lang.SecurityManager sm = System.getSecurityManager(); if (sm != null) { sm.checkPermission(AuthPermissionHolder.DO_AS_PERMISSION); } if (action == null) throw new NullPointerException (ResourcesMgr.getString("invalid.null.action.provided")); // set up the new Subject-based AccessControlContext for doPrivileged final AccessControlContext currentAcc = AccessController.getContext(); // call doPrivileged and push this new context on the stack return java.security.AccessController.doPrivileged (action, createContext(subject, currentAcc)); }
最后在調(diào)用AccessController.doPrivileged方法,如下:
public static native <T> T doPrivileged(PrivilegedExceptionAction<T> action, AccessControlContext context) throws PrivilegedActionException;
該方法為Native方法,該方法會使用指定的AccessControlContext來執(zhí)行PrivilegedExceptionAction,也就是調(diào)用該實現(xiàn)的run方法。即FileSystem.get(uri, conf)方法。
至此,就能夠解釋在本例中,通過get(final URI uri, final Configuration conf,final String user) 方法創(chuàng)建FileSystem時,每次存入FileSystem的Cache中的Cache.key的hashCode都不一致的情況了,小結(jié)一下:
- 在通過get(final URI uri, final Configuration conf,final String user)方法創(chuàng)建FileSystem時,由于每次都會創(chuàng)建新的UserGroupInformation和Subject對象。
- 在Cache.Key對象計算hashCode時,影響計算結(jié)果的是調(diào)用了UserGroupInformation.hashCode方法。
- UserGroupInformation.hashCode方法,計算為:System.identityHashCode(subject)。即如果Subject是同一個對象則返回相同的hashCode,由于在本例中每次都不一樣,因此計算的hashCode不一致。
- 綜上,就導(dǎo)致每次計算Cache.key的hashCode不一致,便會重復(fù)寫入FileSystem的Cache。
FileSystem的兩個get方法
在FileSystem中,有兩個重載的get方法,如下:
public static FileSystem get(final URI uri, final Configuration conf, final String user) public static FileSystem get(URI uri, Configuration conf)
在前面已經(jīng)詳細(xì)的解讀了第一個方法,從代碼中可以看第一個最終還是會調(diào)用第二個方法。唯一不同的地方就是在初始化Cache.key獲取UserGroupInformation對象的時候,如下:
Key(URI uri, Configuration conf, long unique) throws IOException { scheme = uri.getScheme()==null ? "" : StringUtils.toLowerCase(uri.getScheme()); authority = uri.getAuthority()==null ? "" : StringUtils.toLowerCase(uri.getAuthority()); this.unique = unique; this.ugi = UserGroupInformation.getCurrentUser(); }
該方法會調(diào)用UserGroupInformation.getCurrentUser方法,如下:
public synchronized static UserGroupInformation getCurrentUser() throws IOException { AccessControlContext context = AccessController.getContext(); Subject subject = Subject.getSubject(context); if (subject == null || subject.getPrincipals(User.class).isEmpty()) { return getLoginUser(); } else { return new UserGroupInformation(subject); } }
在直接調(diào)用get(URI uri, Configuration conf)方法時,由于未像get(final URI uri, final Configuration conf, final String user)方法創(chuàng)建Subject對象,因此此處Subject會返回空,會繼續(xù)執(zhí)行g(shù)etLoginUser方法。如下:
public synchronized static UserGroupInformation getLoginUser() throws IOException { if (loginUser == null) { loginUserFromSubject(null); } return loginUser; }
由代碼可見,loginUser成員變量是關(guān)鍵,查看一下該成員定義,如下:
/** * Information about the logged in user. */ private static UserGroupInformation loginUser = null;
也就是說,一旦該loginUser對象初始化成功,那么后續(xù)會一直使用該對象。如上一節(jié)所示,UserGroupInformation.hashCode方法將會返回一樣的hashCode值。也就是能成功的使用到緩存在FileSystem的Cache。
解決
- 使用public static FileSystem get(URI uri, Configuration conf): 該方法是能夠使用到FileSystem的Cache的,也就是說對于同一個hdfs URI是只會有一個FileSystem連接對象的。 使用此Api可通過System.setProperty("HADOOP_USER_NAME", "hive")方式設(shè)置訪問用戶。(如果有更優(yōu)雅方式,望大佬指出) 默認(rèn)情況下fs.automatic.close=true,即所有的連接都會通過ShutdownHook關(guān)閉。
- 使用public static FileSystem get(final URI uri, final Configuration conf, final String user): 該方法如上分析,會導(dǎo)致FileSystem的Cache失效,且每次都會添加至Cache的Map中,導(dǎo)致不能被回收。 在使用時,一種方案是:保證對于同一個hdfs URI只會存在一個FileSystem連接對象。 另一種方案是:在每次使用完FileSystem之后,調(diào)用close方法,該方法會將Cache中的FileSystem刪除。
在FileSystem中,還提供了了newInstance等Api。該系列Api每次都會返回一個新的FileSystem,具體實現(xiàn)參見FileSystem代碼。
反思
- 在使用開源包時,需詳細(xì)了解其實現(xiàn),否則可能因為一時疏忽出現(xiàn)問題。
- code review 是很有必要的。
- 完善線上的監(jiān)控機(jī)制。
~~以上為個人理解,由于水平有限,如有疏漏,望多多指教 ~~