Revision 20 as of 2009-12-29 00:49:05

Clear message
Locked History Actions

wicket/SlowAndHeavy

Wicketアプリが重くて遅い

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

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

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

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

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

現象

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

gcviewer1.png

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

原因の追加

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の形になったものについて最終変更時刻を取得してもほとんど意味がないはずなのだが、Wicketアプリ実行中に編集が可能な「file:...」との統一的な扱いのためにこうしていると思われる。

対策の方針

問題の根本は、

  • 一つのリソースが要求されたとき、そのリソースが存在する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以下の使用にとどまっている。もちろんアプリケーションの動作も極めて軽快になる。

gcviewer2.png

具体的なコード

※以下はまだ試行錯誤の段階であるので注意のこと。特に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にまとめた後の実行では大きく実行速度が異なるため、この問題に気がつかないと何が原因なのか悩むことになりかねない。