tomcat启动时检测到循环继承而栈溢出的问题

一个用户在使用tomcat7054版本启动的时候遇到的错误:

Caused by: java.lang.IllegalStateException: 
Unable to complete the scan for annotations for web application [/test] 
due to a StackOverflowError. Possible root causes include a too low setting 
for  -Xss and illegal cyclic inheritance dependencies. 

The class hierarchy being processed was 

[org.jaxen.util.AncestorAxisIterator->
org.jaxen.util.AncestorOrSelfAxisIterator->
org.jaxen.util.AncestorAxisIterator]

at org.apache.catalina.startup.ContextConfig.checkHandlesTypes(ContextConfig.java:2112)
at org.apache.catalina.startup.ContextConfig.processAnnotationsStream(ContextConfig.java:2059)
at org.apache.catalina.startup.ContextConfig.processAnnotationsJar(ContextConfig.java:1934)
at org.apache.catalina.startup.ContextConfig.processAnnotationsUrl(ContextConfig.java:1900)
at org.apache.catalina.startup.ContextConfig.processAnnotations(ContextConfig.java:1885)
at org.apache.catalina.startup.ContextConfig.webConfig(ContextConfig.java:1317)
at org.apache.catalina.startup.ContextConfig.configureStart(ContextConfig.java:876)
at org.apache.catalina.startup.ContextConfig.lifecycleEvent(ContextConfig.java:374)
at org.apache.catalina.util.LifecycleSupport.fireLifecycleEvent(LifecycleSupport.java:117)
at org.apache.catalina.util.LifecycleBase.fireLifecycleEvent(LifecycleBase.java:90)
at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:5355)
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:150)

这是在tomcat解析servlet3注释时进行类扫描的过程,发现了两个类的继承关系存在循环继承的情况而导致了栈溢出。

排查了一下,是因为应用所依赖的 dom4j-1.1.jar 里存在 AncestorAxisIterator 和子类 AncestorOrSelfAxisIterato

% javap org.jaxen.util.AncestorAxisIterator

Compiled from "AncestorAxisIterator.java"
public class org.jaxen.util.AncestorAxisIterator extends org.jaxen.util.StackedIterator {
    protected org.jaxen.util.AncestorAxisIterator();
    public org.jaxen.util.AncestorAxisIterator(java.lang.Object, org.jaxen.Navigator);
    protected java.util.Iterator createIterator(java.lang.Object);
}

% javap org.jaxen.util.AncestorOrSelfAxisIterator

Compiled from "AncestorOrSelfAxisIterator.java"
public class org.jaxen.util.AncestorOrSelfAxisIterator extends org.jaxen.util.AncestorAxisIterator {
    public org.jaxen.util.AncestorOrSelfAxisIterator(java.lang.Object, org.jaxen.Navigator);
    protected java.util.Iterator createIterator(java.lang.Object);
} 

同时应用所依赖的 sourceforge.jaxen-1.1.jar 里面也存在这两个同名类,但继承关系正好相反:

% javap org.jaxen.util.AncestorAxisIterator

Compiled from "AncestorAxisIterator.java"
public class org.jaxen.util.AncestorAxisIterator extends org.jaxen.util.AncestorOrSelfAxisIterator {
    public org.jaxen.util.AncestorAxisIterator(java.lang.Object, org.jaxen.Navigator);
}

% javap org.jaxen.util.AncestorOrSelfAxisIterator

Compiled from "AncestorOrSelfAxisIterator.java"
public class org.jaxen.util.AncestorOrSelfAxisIterator implements java.util.Iterator {
    public org.jaxen.util.AncestorOrSelfAxisIterator(java.lang.Object, org.jaxen.Navigator);
    public boolean hasNext();
    public java.lang.Object next();
    public void remove();
}

简单的说,在第1个jar里存在 B继承自A,在第2个jar里存在同名的A和B,但却是A继承自B。其实也能运行的,只是可能出现类加载时可能加载的不一定是你想要的那个,但tomcat做类型检查的时候把这个当成了一个环。

ContextConfig.processAnnotationsStream方法里,每次解析之后要对类型做一次检测,然后才获取注释信息:

ClassParser parser = new ClassParser(is, null);
JavaClass clazz = parser.parse();
checkHandlesTypes(clazz);
...
AnnotationEntry[] annotationsEntries = clazz.getAnnotationEntries();
...

再看这个用来检测类型的checkHandlesTypes方法里面:

populateJavaClassCache(className, javaClass);
JavaClassCacheEntry entry = javaClassCache.get(className);
if (entry.getSciSet() == null) {
    try {
        populateSCIsForCacheEntry(entry); // 这里
    } catch (StackOverflowError soe) {
        throw new IllegalStateException(sm.getString(
            "contextConfig.annotationsStackOverflow",context.getName(),
            classHierarchyToString(className, entry)));
    }
}

每次新解析出来的类(tomcat里定义了JavaClass来描述),会被populateJavaClassCache放入cache,这个cache内部是个Map,所以对于key相同的会存在把以前的值覆盖了的情况,这个“环形继承”的现象就比较好解释了。

Map里的key是String类型即类名,value是JavaClassCacheEntry类型封装了JavaClass及其父类和接口信息。我们假设第一个jar里B继承自A,它们被放入cache的时候键值对是这样的:

"A" -> [JavaClass-A, 父类Object,父接口]"
"B" -> [JavaClass-B, 父类A,父接口]

然后当解析到第2个jar里的A的时候,覆盖了之前A的键值对,变成了:

"A" -> [JavaClass-A, 父类B,父接口]
"B" -> [JavaClass-B, 父类A,父接口]

这2个的继承关系在这个cache里被描述成了环状,然后在接下来的populateSCIsForCacheEntry方法里找父类的时候就绕不出来了,最终导致了栈溢出。

这个算是cache设计不太合理,没有考虑到不同jar下面有相同的类的情况。问题确认之后,让应用方去修正自己的依赖就可以了,但应用方说之前在7026的时候,是可以正常启动的。这就有意思了,接着一番排查之后,发现在7026版本里,ContextConfig.webConfig的时候先判断了一下web.xml里的版本信息,如果版本>=3才会去扫描类里的servlet3注释信息。

// Parse context level web.xml
InputSource contextWebXml = getContextWebXmlSource();
parseWebXml(contextWebXml, webXml, false);

if (webXml.getMajorVersion() >= 3) {
    // 扫描jar里的web-fragment.xml 和 servlet3注释信息
    ...
}

而在7054版本里是没有这个判断的。搜了一下,发现是在7029这个版本里去掉的这个判断。在7029的changelog里:

As per section 1.6.2 of the Servlet 3.0 specification and clarification from the Servlet Expert Group, the servlet specification version declared in web.xml no longer controls if Tomcat scans for annotations. Annotation scanning is now always performed – regardless of the version declared in web.xml – unless metadata complete is set to true.

之前对servlet3规范理解不够清晰;之所以改,是因为在web.xml里定义的servlet版本,不再控制tomcat是否去扫描每个类里的注释信息。也就是说不管web.xml里声明的servlet版本是什么,都会进行注释扫描,除非metadata-complete属性设置为true(默认是false)。

所以在7029版本之后改为了判断 webXml.isMetadataComplete() 是否需要进行扫描注释信息。

Pandora的启动优化

pandora3进行了很多重构,上周花了几天时间对启动过程做了一些优化。官方的tomcat只部署一个及简单的servlet应用,在我的机器上启动过程大概在1秒左右,如果应用使用了spring-mvc框架,启动过程大约在2000-2400毫秒左右。

如果使用Ali-Tomcat,启动过程中要先启动pandora容器,同一个spring-mvc的样例应用,总启动时间在4-5秒左右。优化的手段主要有2点:1) 部分模块异步化加载(前提是其它模块以及应用的启动对该模块没有依赖)。 2) 在自定义的classloader.loadClass方法里针对java7开启并行加载(在我的场景下所加载的类大约1000-2000个,使用4个线程,性能上约有20-30%的提升)。

java7的classloader所支持的并行加载,实质是把原先loadClass方法原先粗粒度的锁synchronized(this)换成了细粒度的锁synchronized (getClassLoadingLock(name)),为每个class都分配一个锁。这部分特性其实可以移植到自定义的Classloader实现里来,使得jdk6上也可以享用并行加载。

自定义的classloader要使用jdk7的并行加载机制,需要在静态构造块里注册为可并行加载:

static {
    ClassLoader.registerAsParallelCapable();
}

注意它要求当前classloader所有的父类都进行注册才行,比如我们在自定义的 MyClassLoader中覆盖了loadClass方法, 假设它的父类是 ClassLoaderBase,而ClassLoaderBase又继承自系统的URLClassLoader,那么要求URLClassLoaderClassLoaderBase 都必须在静态块中有注册声明可并行加载才行(URLClassLoader及其父类在jdk代码中是有注册的)。

另外,使用spring-mvc框架的应用,之所以比普通的servlet启动时慢,很大一部分原因是在于servlet3的web-fragment.xml合并的过程比较耗时。这个过程是在Pandora3启动后,输出应用启动日志过程中体会到的一个明显的“卡顿”:

2014/06/18 10:36:09:588 [INFO] Pandora - Container started. time elapsed: 439 ms
2014-06-18 10:36:10,740 org.springframework.web.servlet.FrameworkServlet initServletBean

这中间居然耗费了将近1.2秒,通过开启日志debug,发现主要的耗时在 WebXml.merge 方法上。原因是servlet3引入了web模块化配置,对原先的web.xml可以由多个“片段”组成,这些片段使用web-fragment.xml来描述,可以对一组servlet/Filter/Listener + web-fragment.xml 打成一个jar,即一个web模块。

这种做法带来一定灵活性,但要求应用在启动时,必须对WEB-INF/lib下的jar,以及其它路径下classloader可访问的jar(这部分可配置,默认包含)进行检查,合并web-fragment.xml片段。

Tomcat对jar中的web-fragment.xml的扫描主要通过JarScanner实现的,它也提供了配置来忽略哪些jar文件;在conf/catalina.properties里定义了tomcat.util.scan.DefaultJarScanner.jarsToSkip对哪些jar文件忽略。

使用spring-mvc框架的应用,依赖了一堆spring的jar,而这些jar没有在默认的忽略列表里,在扫描和合并web-fragment.xml的过程中会比较耗时。