Spring容器在Oracle JDK和OpenJDK中的类装载差异导致的自动装箱bug问题
一次偶然的机会,为了测试公司项目新代码需要,把svn上的代码检出到了一台新的测试机器上,机器上的环境都是自己通过yum安装的,本文发生的原因也是因此而起。
在测试的过程中我点了一个链接,地址是http://10.69.67.203/exception/list/1 ,结果抛出了匪夷所思的500错误,报错内容如下(项目采用的是Spring MVC+Hibernate+Velocity架构):
org.hibernate.AssertionFailure: Interceptor.onPrepareStatement() returned null or empty string. at org.hibernate.jdbc.AbstractBatcher.getSQL(AbstractBatcher.java:490) at org.hibernate.jdbc.AbstractBatcher.getPreparedStatement(AbstractBatcher.java:510) at org.hibernate.jdbc.AbstractBatcher.getPreparedStatement(AbstractBatcher.java:452) at org.hibernate.jdbc.AbstractBatcher.prepareQueryStatement(AbstractBatcher.java:161) at org.hibernate.loader.Loader.prepareQueryStatement(Loader.java:1700) at org.hibernate.loader.Loader.doQuery(Loader.java:801) at org.hibernate.loader.Loader.doQueryAndInitializeNonLazyCollections(Loader.java:274) at org.hibernate.loader.Loader.doList(Loader.java:2542) at org.hibernate.loader.Loader.listIgnoreQueryCache(Loader.java:2276) at org.hibernate.loader.Loader.list(Loader.java:2271) at org.hibernate.loader.custom.CustomLoader.list(CustomLoader.java:316) at org.hibernate.impl.SessionImpl.listCustomQuery(SessionImpl.java:1842) at org.hibernate.impl.AbstractSessionImpl.list(AbstractSessionImpl.java:165) at org.hibernate.impl.SQLQueryImpl.list(SQLQueryImpl.java:157) at org.hibernate.impl.AbstractQueryImpl.uniqueResult(AbstractQueryImpl.java:890) at com.yun.dba.exception.dao.ExceptionDaoImpl.countExceptionInfoByCondition(ExceptionDaoImpl.java:83)
大致看了下报错原因,猜测应该是Hibernate在执行SQL查询时提交了一个空字符串导致了执行失败,这个异常就显得比较诡异了,毕竟线上环境运行的也是同一套代码,不可能是代码问题,而且将代码部署在本机上运行也是好好的。没法,只得开放jvm远程调试端口进行代码步进跟踪,结果不出所料地跟到了Dao层,果然是SQL拼接语句为空,然后继续跟进,贴出相关代码:
public List<ExceptionInfo> getExceptionInfoByCondition(List<SearchSupport> searchList, Integer targetType, Integer pageNo, Integer pageSize) { ...... if (targetType == TARGET_TYPE_CUSTINS || targetType == TARGET_TYPE_SPACE || targetType == TARGET_TYPE_DNS) { lastSql += " and ci.is_deleted=0 order by excp.gmt_created desc "; sql = makeSearchListToSql(searchList, GET_CUSTINS_BY_CONDITION, lastSql, false); } else if (targetType == TARGET_TYPE_HOST) { lastSql += " order by excp.gmt_created desc "; sql = makeSearchListToSql(searchList, GET_HOST_BY_CONDITION, lastSql, false); } ...... }
此时,targetType传入的值是1,理应进入到else if这个判断里,其中TARGET_TYPE_HOST的定义是“public final static Integer TARGET_TYPE_HOST = 1”。但神奇的是它根本没有跳入判断里。也许看到这里你会说Integer是一个对象,应该使用equals()方法进行数值比较,没错,但Java从5.0起就有一种自动装箱、拆箱的机制,下面就大概介绍下这种特性:
装箱:把基本类型用它们相应的引用类型包装起来,使其具有对象的性质。int包装成Integer、float包装成Float;拆箱:和装箱相反,将引用类型的对象简化成值类型的数据。
Integer a1 = 100; //这是自动装箱 (编译器调用的是static Integer valueOf(int i)) Integer a2 = 100; int b = new Integer(100); //这是自动拆箱
上面的例子中a1和a2如果用==运算符进行判断的话,其返回值为true,因为a1和a2代表的是同一个对象,我们可以看下valueOf()的具体实现:
public static Integer valueOf(int i) { final int offset = 128; if (i >= -128 && i <= 127) { // must cache return IntegerCache.cache[i + offset]; } return new Integer(i); }
很明显,jvm定义了在[-128,127]之间的数字,valueOf返回的是缓存中的对象,所以两次调用返回的是同一个对象,所以所有在这个范围内的值相等的Integer对象都会共用一块内存,而不会开辟多个,超出这个范围内的值对应的Integer对象有多少个就开辟多少个内存。说白了,这是一种语法糖。所以再来看出错的代码,理应就属于我们提到的a1==a2这种情况,可为什么还是判断为不属于同一个对象呢?没办法,我们只能再跟踪上层的代码,所以来到了Control层:
@RequestMapping(value = UrlPatternConsts.EXCEPTION_LIST, method = RequestMethod.GET) public ModelAndView instancelist(HttpServletRequest request, ModelAndView mav, @PathVariable Integer targetType) { Integer page = 1; innerInstanceList(mav, page, targetType); return mav; }
Dao层中的targetType就是来自于这里,targetType这个对象是通过Http请求,利用Spring的@PathVariable注解而来,这里会有一个反射机制。到这里事情开始变的有些眉目起来。反射机制指的是程序在运行时能够获取自身的信息,只要给定类的名字,那么就可以通过反射机制来获得类的所有信息。反射机制的优点就是可以实现动态创建对象和编译,体现出很大的灵活性,特别是在J2EE的开发中它的灵活性就表现的十分明显。正因为反射机制会有动态类装载的行为,因此我有理由怀疑是不是因为jvm在类的装载顺序上面有问题才会导致注解而得的Integer对象不同于自动装箱而得的同值Integer对象。因此我增加了几行测试代码来验证我的判断:
@RequestMapping(value = UrlPatternConsts.EXCEPTION_LIST, method = RequestMethod.GET) public ModelAndView instancelist(HttpServletRequest request, ModelAndView mav, @PathVariable Integer targetType) { Integer page = 1; Integer test=1; System.out.println("test\n"); System.out.println(targetType==test); System.out.println(page==test); System.out.println("end test\n"); innerInstanceList(mav, page, targetType); return mav; }
将上面的改动应用在了测试机器上,发送了Http请求后在控制台打印出来了结果:
test false true end test
果然targetType和test是不等于的,同样地,我把代码又应用在了本机,打印的结果又是这样的:
test true true end test
问题终于显现!注解反射生成后的Integer对象与自动装包生成的同值对象在不同的机器上会出现两种截然不同的表现,那接下来就是查看各自的jvm版本了,在本机的jvm版本如下:
java version "1.6.0_45" Java(TM) SE Runtime Environment (build 1.6.0_45-b06) Java HotSpot(TM) 64-Bit Server VM (build 20.45-b01, mixed mode)
测试机器的jvm版本如下:
java version "1.6.0_30" OpenJDK Runtime Environment (IcedTea6 1.13.1) (rhel-3.1.13.1.el6_5-x86_64) OpenJDK 64-Bit Server VM (build 23.25-b01, mixed mode)
结果很明显,CentOS下采用yum安装的是OpenJDK的jvm,在它运行的Sping容器内,注解反射后的Integer装载类与自动装箱的Integer装载类不属于同一个内存区域,而Oracle的jvm却属于同一个内存区域,Oracle的做法更贴合于Java想要表达的语法糖这个特性,而OpenJDK的做法我更倾向于是一个类装载bug。最终的处理方式就是卸载测试机上的OpenJDK改用Oracle JDK后,500报错现象解除。
最后总结一下,为了调试这个错误,花了我半天的时间才找到原因,究其根本就是太过依赖于Java带给我们的语法糖特性,有时候在不知道原理的情况下任意使用,很可能会碰到一些坑。保险的用法就是在遇到任何对象的等值比较时,使用equals()方法是最最稳妥的。
Google Chrome 34.0.1847.131 Windows 7 x64 Edition
我觉得很多人和我一样一直有这样的误区,以为eclipse使用jdk来编译java文件的,其实不然,eclipse只需要jre环境,本身有javac-api.jar/javac-impl.jar的编译器实现api,因此还是推荐使用ant或者maven进行项目编译打包导出,以避免不必要的麻烦
[回复]