Locked History Actions

wicket/SlowAndHeavy

Wicketアプリが重くて遅い

Wicketで作成したアプリが重くて遅く、とても実用にならない場合がある。これは

  • アプリケーションをスタンドアロンのjarとしてまとめ、それを実行したとき。つまり、アプリそれ自体に例えばJettyを内蔵させてスタンドアロンのアプリケーションとして実行した場合。

確認していないが、おそらくwarファイルの場合は発現しないものと思われる(そうであれば、既に皆が騒いでいるはず)。また、例えばEclipse等の環境でアプリを開発中の場合は顕在化しない。これはなぜかというと。

  • プログラムをjarファイルの形にまとめた時にだけ問題が現れる

からである。jarファイルもwarファイルも似たようなものであるが、サーブレットコンテナ上で実行される場合は、warファイルは展開された形で実行される(たしかそうだったと思う)ので現れない(後述するように、実際は若干あると思われるのだが、気がつかない)と思われる。

現象

以下は、あるアプリを数分間動作させ、数枚のページ遷移を実行したものである(VM引数として-Xloggcをつけ、gcviewerで表示)。 すぐにデフォルトの64Mのヒープ領域を使いきってしまうことがわかる。もちろんアプリの動作は非常に重く、操作を続行するとメモリ不足が起こる場合もある。

gcviewer1.png

また、ここには示さないが、このときの状態をEclipse Memory Analyzerを使って観察してみると、 「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のヒープではメモリ不足となる場合があった(こちらでテストしたアプリの場合)。

原因の追加

WicketのデフォルトのIResourceStreamLocatorとして、org.apache.wicket.util.resource.locator.ResourceStreamLocatorが使われている(これはApplication.init()中で変更可能)。このクラス内部の処理として、リソース取得の際にはクラスローダに対してリソースの位置をURLで返してもらっている(ClassLoader.getResource())。クラスローダは.classファイルをロードする(つまり、プログラムをロードする)ためのものなので、おそらくここまでは高速に行われると思われる。

問題はここから先である。Wicket側は、返ってきたURLをorg.apache.wicket.util.resource.UrlResourceStreamに渡してその中で一律にURLの示すリソースをロードしようとする。これが「file:....」のようなOSファイルの場合ならそのファイルをロードするだけであるが、「jar:file:....」と言う「jarファイル中にあるリソース」の場合には、結局のところそのjarファイルをいちいちオープンしてその中のリソースを取得しなければならない。

ならば、最初からクラスローダのgetResource()でURLのみを取得するより、getResourceAsStream()でそのリソースの入力ストリームを取得すればよいと思われるのだが、これはWicketでは使えないようだ。

なぜなら、getResourceAsStream()ではリソースのデータ自体は得られるものの、最終変更時刻が得られない。もちろんjarの形になったものについて最終変更時刻を取得しても意味がないはずなのだが(jarファイルの中のリソースを実行時に変更する手段があるなら別だが)、Wicketアプリ実行中に編集が可能な「file:...」との統一的な扱いのためにこうしていると思われる。

しかも、このリソースの最終変更時刻の確認はorg.apache.wicket.util.watch.ModificationWatcherで定期的に行われており(DEVELOPMENTモードのときのみ?)、UrlResourceStream側は、どうやらlastModifiedTime()が呼び出されるたびに、わざわざjarファイル内のリソースの変更時刻を読みにいくようである。ことリソースがjarファイル内にある場合のこの部分の処理は、信じられないほどの膨大な無駄に満ち満ちている。

参考:

対策の方針

問題の根本は、

  • 一つのリソースが要求されたとき、そのリソースが存在するjarファイルがまるまる読み込まれてしまう(なおかつ、ModificationWathcerでの最終変更時刻確認のたびにも同じことが行われる)。

ことにある。これを避けるためには、Wicketのリソース取得部分を変更する。

方針1

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ファイルからいかに軽く・速く取得するかである。

方針2

クラスローダはりソースに素早くアクセスできるはずなので、getResourceAsStream()を使ってリソースを取得する。 この場合、リソースの最終変更時刻が得られないが、どうせ変更されないので適当なものを返す。こちらの方が簡単と思われる。

改良結果

具体的な改良方法を示す前に、この改良の結果は以下の通り(方針1による)。 改良前は、数ページの遷移ですぐにデフォルトのヒープ領域64Mを使い切ってしまうが(メモリ最大値を引き上げるとその分だけ使用してしまう)、改良後は30M以下の使用にとどまっている。もちろんアプリケーションの動作も極めて軽快になる。

gcviewer2.png

方針2による改良結果は以下の通り。操作内容が異なるのでいちがいに比較はできないが、方針1と似たようなものである。 正確な計測はしていないが、操作感としては方針1の方が上のように感じられる。

gcviewer3.png

ただし、基本的にWicketは一度ロードしたリソースをキャッシュしているはずなので、必要なリソースをすべてロードした後では、方針1・2で違いは無いと考えられる。この改良を行わない場合にはリソース取得に時間がかかることと、必要なリソースを取得する前にメモリ不足になってしまう可能性があることが問題なのである。

方針1による具体的なコード

※以下はまだ試行錯誤の段階であるので注意のこと。特に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();
    }
  }  
}

方針2による具体的なコード

こちらの方が簡単である。また、方針1のコードではコンテンツタイプの判定をいい加減にやっていたが、本来は以下のようにするのが「正しい」ようだ(※)。最終変更時刻は 1970/1/1 00:00:00 GMTに固定である。もしかしたらnullを返してしまってもよいのかもしれないが、よくわからないのでこうしておく。ブラウザ側がリソースの更新を検出できなくなってしまうので、新しい変更時刻を返すようにした方がよい。

※この方法もかなりいい加減ではある。特にcssの判定ができずにcontent/unknownとなってしまうことがある。この場合、Firefoxではスタイルシートが無視されてしまうので注意。Firefoxの場合は、正しくtext/cssとなっていないと認識できないようだ。

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.*;

public class ExResourceStreamLocator2 extends ResourceStreamLocator {

  ClassLoader classLoader;
  
  public ExResourceStreamLocator2() {
    classLoader = getClass().getClassLoader();
  }
  
  public IResourceStream locate(final Class<?> clazz, String path) {
    
    URL url = classLoader.getResource(path);
    
    if (url == null || !url.getProtocol().equals("jar")) {
      return super.locate(clazz, path);    
    }
    
    InputStream rawIn = classLoader.getResourceAsStream(path);
    if (rawIn == null) {
      return super.locate(clazz, path);
    }

    BufferedInputStream bufIn = new BufferedInputStream(rawIn);
    
    // コンテントタイプを決定
    String contentType = null;
    try {
      // このメソッドは先頭から12バイト程度を読み込んでタイプ決定し、巻き戻す
      contentType = URLConnection.guessContentTypeFromStream(bufIn);    
    } catch (IOException ex) {      
    }
    if (contentType == null) {
      contentType = URLConnection.guessContentTypeFromName(url.toString());
    }
    if (contentType == null) {
      contentType = "content/unknown";
    }
    
    // リソースデータを取得
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try {      
      while (true) {
        byte[]buffer = new byte[1024];
        int size = bufIn.read(buffer);
        if (size <= 0) break;
        out.write(buffer, 0, size);
      }
    } catch (IOException ex) {
      return super.locate(clazz, path);
    } finally {
      try { bufIn.close(); } catch (IOException ex) {}
      try { out.close(); } catch (IOException ex) {}
    }   
    

    return new ResourceEntryStream(url, out.toByteArray(), contentType);
  }    

  /**
   * IResourceStreamの実装。
   * どのような理由かわからないが、IFixedLocationResourceStreamも実装する必要がある。
   *
   */
  public static class ResourceEntryStream extends AbstractResourceStream 
    implements IFixedLocationResourceStream {

    protected URL url;
    protected byte[]contentBytes;
    protected String contentType;
    protected ByteArrayInputStream inputStream;
    
    protected ResourceEntryStream(URL url, byte[]contentBytes, String contentType) {
      this.url = url;
      this.contentBytes = contentBytes;
      this.contentType = contentType;
    }

    /** コンテンツタイプを取得 */
    public String getContentType() { return contentType; }
    
    /** リソース長さを取得 */
    public long length() { return contentBytes.length; }

    /** 入力ストリームを取得 */
    public InputStream getInputStream() {
      if (inputStream == null) inputStream = new ByteArrayInputStream(contentBytes);
      return inputStream;
    }

    /** 入力ストリームをクローズ */
    public void close() {
      if (inputStream != null) {
        try { inputStream.close(); } catch (IOException ex) {}
        inputStream = null;
      }
    }

    /** クラスロード時に最終変更時刻を生成 */
    static Time lastModified = Time.milliseconds(System.currentTimeMillis());

    /** 最終変更時刻を取得。nullでもよいのか? */
    public Time lastModifiedTime() {
      //return Time.milliseconds(0); これではだめ
      return lastModified;
    }

    /** IFixedLocationResourceStreamの実装はこのメソッドだけ。 */
    public String locationAsString() {
      return url.toExternalForm();
    }
  }  
}

これをApplication.init()の中で

    getResourceSettings().setResourceStreamLocator(new ExResourceStreamLocator2());  

とすればよい。

元々のJavaの問題

Wicketほど問題がひどくはならないものの、元々のJavaのリソース取得でも同様の問題が発生している可能性がある(まだ追いきれていない)。つまり、jarファイル中のリソース(画像ファイルでも、今回のようなhtmlファイルでもよい)を取得する場合に、

  URL url = getClass().getResource("sample.gif");
  InputStream in = url.openStream();

とした場合と、

  InputStream in = getClass().getResource("sample.gif");

とした場合では挙動が異なる(はず)。

前者ではリソースを取得するたびにいちいちjarファイルをオープンしてその中の所望のリソースの位置を確認したのちに読込みを行わなくてはいけない。jarファイル中に大量のリソースを保存してそれを利用しようとする場合は、極めて遅くなると思われる。

また、この場合もEclipseのような開発環境上での実行と、jarにまとめた後の実行では大きく実行速度が異なるため、この問題に気がつかないと何が原因なのか悩むことになりかねない。