Wicketアプリが重くて遅い
Wicketで作成したアプリが重くて遅く、とても実用にならない場合がある。これはどんな場合かというと、
- アプリケーションをスタンドアロンのjarとしてまとめ、それを実行したとき。つまり、アプリそれ自体に例えばJettyを内蔵させてスタンドアロンのアプリケーションとして実行した場合。
確認していないが、おそらくwarファイルの場合はあまり問題が出ないものと思われる(遅くなる可能性があるが)。また、例えばEclipse上でのそのアプリを開発中の場合は問題が顕在化しない。これはなぜかというと。
- プログラムをjarファイルの形にまとめた時にだけ問題が現れる。
からである。warファイルも同じようなものであるが、サーブレットコンテナ上で実行される場合は、それが展開された形で実行される(たしかそうだったと思う)ので、問題が現れない(後述するように、実際は若干問題があると思われるのだが、気がつかない)。
現象
以下は、あるアプリを数分間動作させ、数枚のページ遷移を実行したものである(VM引数として-Xloggcをつけ、gcviewerで表示)。 すぐにデフォルトの64Mのヒープ領域を使いきってしまうことがわかる。もちろんアプリの動作は非常に重く、操作を続行するとメモリ不足が起こる場合もある。
また、ここには示さないが、このときの状態をEclipse Memory Anlyzerを使って観察してみると、 「sun.net.www.protocol.jar.URLJarFile」というオブジェクト、あるいはそのfinalizeをするためのFinalizerに埋め尽くされていることがわかる。
http://www.docjar.com/html/api/sun/net/www/protocol/jar/URLJarFile.java.html
何らかの理由でURLJarFileというオブジェクトが大量発生するのだが、このクラスにはfinalizeが使われている。finalize付のオブジェクトの場合、そう簡単に廃棄されてはくれない。一説によると、二回のGCが必要とのことである。
※FinalizerとフルGCを参照のこと。
このため、不要になってもメモリ上に残ってしまいアプリのメモリを圧迫する。また、そもそもこのようにメモリを使うオブジェクトを大量に作成すること自体が問題である。
原因
Wicket自体のコードをまだ完全には追いきれていないのだが、これはWicketがリソースを取得する際に発生する。ここでリソースと呼ぶのは、主にはXXXPage.javaに対応するXXXPage.htmlというファイルである。
これらのファイルが、OSのファイルシステム中にある場合(warファイルが展開された状態や、Eclipse上での開発途中)では、Wicketは単純にそのファイルを読み込む。これに対して、Jarファイルの中に埋め込まれている場合には、Wicketはそのjarファイルをまるまるメモリにロードしてしまうようである。その際に、sun.net.www.protocol.jar.URLJarFileを使用している模様である。
もちろん、これはWicketが意図的にやっていることではなく、Wicketの作業の途中でJava-APIを呼び出すと結局、個々のリソース取得のたびに「sun.net.www.protocol.jar.URLJarFile」が使用されてしまうようである。
また、これは開発者側が作成したXXXPage.htmlというファイルに限らず、Wicketの配布jarファイル中にあるFeedbackPanel.htmlなどのリソースにも適用されてしまう。したがって、仮に開発アプリにhtml等のリソースが一つもなくとも、あるいは開発アプリをjarにまとめなくとも、wicketの配布jarをそのまま使う限りはこの問題が必ず発生する。
もちろん、Wicket側のgetConfigurationType()をApplication.DEPLOYMENTにする(あるいは他の措置?)ことで、Wicketは一度取得したリソースをキャッシュして使いまわすが、それでもフルGCを行わずにデフォルトの64Mのヒープではメモリ不足となる場合があった(こちらでテストしたアプリの場合)。
対策の方針
問題の根本は、
- 一つのリソースが要求されたとき、そのリソースが存在するjarファイルがまるまる読み込まれてしまう
ことにある。これを避けるためには、Wicketのリソース取得部分を変更する。
WicketはResourceStreamLoaderを使ってリソースを取得するが、これは差し替え可能である。自前のApplicationクラスのinit()の中で以下を記述する。
getResourceSettings().setResourceStreamLocator(new MyResourceStreamLocator());
MyResourceStreamLocatorを新規作成し、この中でWicketの要求するリソースストリームを返すようにする。 これを擬似的なコードで示す。
public class MyResourceStreamLocator extends ResourceStreamLocator { public IResourceStream locate(final Class<?> clazz, String path) { if (pathとして示されたリソースが何らかのjarファイル中にある) { // そのjarファイルから自前でリソースを取得し、IResourceStreamにして返す。 // 注意:ただしこのとき、IFixedLocationResourceStreamも実装したクラスでなければならない。 } // pathとして示されたリソースを自前で扱えない場合や存在しなかった場合、元の処理を呼び出す。 return super.locate(clazz, path); } }
肝となるのは、pathで示されたリソースを該当するjarファイルからいかに軽く・速く取得するかである。
改良結果
具体的な改良方法を示す前に、この改良の結果は以下の通り。 改良前は、数ページの遷移ですぐにデフォルトのヒープ領域64Mを使い切ってしまうが(メモリ最大値を引き上げるとその分だけ使用してしまう)、改良後は30M以下の使用にとどまっている。もちろんアプリケーションの動作も極めて軽快になる。
具体的なコード
※以下はまだ試行錯誤の段階であるので注意のこと。特にJarFileは内部でRandomAccessFileを使っているが、これをオープンしっぱなしにしてリソース取得が速くなるようにしている。何らかの制限(オープン数や時間制限)をつけるべきと思われる。
JarResourcesは、要求されたリソースがjarファイルに存在するならば、そのjarファイルをオープンし、その後オープンしっぱなしにして、次のリソース要求に備える。
import java.io.*; import java.net.*; import java.util.*; import java.util.jar.*; import java.util.zip.*; import com.google.inject.*; @Singleton // Guiceを使用している。これはシングルトンであることを示す。 public class JarResources { private Map<File, JarFile>jarFileMap = new HashMap<File, JarFile>(); public synchronized ResourceEntry get(URL url) throws Exception { // jarプロトコルでなければ無視 if (!url.getProtocol().equals("jar")) return null; // ファイルとリソースを示す文字列を作成 String fileAndResource = url.getFile(); int separator = fileAndResource.indexOf('!'); if (separator < 0) return null; // ファイル部分を取得 File file = new File(new URI(fileAndResource.substring(0, separator))); // リソース名称部分を取得 String resource = fileAndResource.substring(separator + 1); if (resource.startsWith("/")) resource = resource.substring(1); // JarFileを取得 JarFile jarFile; jarFile = jarFileMap.get(file); if (jarFile == null) { jarFile = new JarFile(file); jarFileMap.put(file, jarFile); } // リソース名からZIPエントリを取得 ZipEntry zipEntry = jarFile.getEntry(resource); if (zipEntry == null) return null; // リソースデータを取得 ByteArrayOutputStream out = new ByteArrayOutputStream(); InputStream in = jarFile.getInputStream(zipEntry); try { while (true) { byte[]buffer = new byte[1024]; int size = in.read(buffer); if (size <= 0) break; out.write(buffer, 0, size); } } finally { in.close(); } out.close(); String contentType = null; if (resource.endsWith(".htm") || resource.endsWith(".html")) contentType = "text/html"; else if (resource.endsWith(".css")) contentType = "text/css"; ResourceEntry resEntry = new ResourceEntry(out.toByteArray(), zipEntry.getTime(), contentType); return resEntry; } /** リソースエントリ */ public static class ResourceEntry { public final byte[]contentBytes; public final long lastModified; public final String contentType; public ResourceEntry(byte[]contentBytes, long lastModified, String contentType) { this.contentBytes = contentBytes; this.lastModified = lastModified; this.contentType = contentType; } @Override public String toString() { try { String s = new String(contentBytes, "MS932"); return "" + contentBytes.length + ", " + lastModified + ", " + contentType; } catch (Exception ex) { throw new InternalError(); } } } }
以下のMyResourceStreamLoaderを先述したように、Applicationのinit()にて指定する。
import java.io.*; import java.net.*; import org.apache.wicket.util.resource.*; import org.apache.wicket.util.resource.locator.*; import org.apache.wicket.util.time.*; import com.cm55.clman.*; import com.cm55.clman.web.JarResources.*; public class MyResourceStreamLocator extends ResourceStreamLocator { ClassLoader classLoader; public MyResourceStreamLocator() { classLoader = getClass().getClassLoader(); } public IResourceStream locate(final Class<?> clazz, String path) { URL url = classLoader.getResource(path); if (url != null) { try { // ※ServiceLocatorは単純にGuiceのinjector呼び出しと考えてよい。 // 要するにここは、ただ一つのJarResourceインスタンスを取得して、そのgetを呼び出す。 ResourceEntry entry = ServiceLocator.getInstance(JarResources.class).get(url); if (entry != null) { return new ResourceEntryStream(url, entry); } } catch (Exception ex) {} } return super.locate(clazz, path); } public static class ResourceEntryStream extends AbstractResourceStream implements IFixedLocationResourceStream { ResourceEntry entry; ByteArrayInputStream bytesIn; URL url; public ResourceEntryStream(URL url, ResourceEntry entry) { this.entry = entry; this.url = url; } public String getContentType() { return entry.contentType; } public long length() { return entry.contentBytes.length; } public InputStream getInputStream() throws ResourceStreamNotFoundException { if (bytesIn == null) { bytesIn = new ByteArrayInputStream(entry.contentBytes); } return bytesIn; } public void close() throws IOException { if (bytesIn != null) { bytesIn.close(); } } public Time lastModifiedTime() { return Time.milliseconds(entry.lastModified); } public String locationAsString() { return url.toExternalForm(); } } }
元々のJavaの問題
Wicketほど問題がひどくはならないものの、元々のJavaのリソース取得でも同様の問題が発生している可能性がある(まだ追いきれていない)。つまり、jarファイル中のリソース(画像ファイルでも、今回のようなhtmlファイルでもよい)を取得する場合には、
URL url = getClass().getResource("sample.gif"); InputStream in = url.openStream();
などとするのだが、これではリソースを取得するたびにいちいちjarファイルをオープンしてその中の所望のリソースの位置を確認したのちに読込みを行わなくてはいけない。jarファイル中に大量のリソースを保存する場合は、極めて遅くなると思われる。
また、この場合もEclipseのような開発環境での実行と、jarにまとめた後の実行では大きく実行速度が異なるため、この問題に気がつかないと何が原因なのか悩むことになりかねない。