考察对类加载的理解(答案篇)

独立进程篇

首先需要知道类加载器是怎么回事? 在Java里边,类加载器就是用来加载类的,然后才是执行代码。
Java里边默认有启动类加载器(boot),扩展类加载器(ext),和应用类加载器(app)。
其中boot就是用来加载最开始的虚拟机和最基本的java类,ext是用来加载一些扩展类,默认是在jre/lib/ext目录下的。
最后一个才是你真正会用到的。cp参数就是用来指定应用类加载器找类的地方,java.ext.dirs就是用来指定 扩展类加载器找类的地方。 这三种类加载器是有层级关系的,类似于继承关系(父子关系)。

另外一个需要知道的概念是,类加载器在找类的时候,默认是采用双亲委派机制的。
例如应用类加载器加载类的时候,会先叫ext去加载,ext就会叫boot去加载,只有加载不到才尝试自己去加载。
这种做法是有个重要的因素,就是为了安全性考虑。

最后需要知道的是,TestServlet.class.getClassLoader()获取到的时候,加载TestServlet类所使用的实际的类加载器。

回到上面的问题。

第一个很简单,指定了应用类加载器加载的路径,所以main方法能找到,TestServlet也能找到,config.properties也能被应用类加载器找到。 启动是正常的。

第二个问题,TestServlet是被扩展类加载器加载的,所以通过它来找config.properties会找不到(在应用类加载器中才能被加载到)。

第三个问题,调整目录后,这个时候扩展类加载器的加载路径上也有config.properties,所以启动也会正常。

现在调整了代码,换成了Thread.currentThread().getContextClassLoader()的实现。
这个是有一点不一样的,上下文类加载器默认就是应用类加载器(如果通过代码进行修改的话)。 上下文类加载器还是一个很有用的技术, 可以用在JDBC这种SPI(Service Provider Interface)接口与实现分离的技术上,有兴趣可以去找找资料。

在这种情况下,后面的三个命令都是能够正常的,因为config.properties能够被正常识别的。

再说一点,用eclipse可以运行的程序,用命令行不一定可以,这点必须要认准最后的启动参数,通过这个来确认。 我们这边写独立进程的时候,贪方便喜欢用java.ext.dirs这个虚拟机参数,但这个有时候会有奇怪的问题。 大家要注意区分这个参数和cp参数的区别,这样就分析有思路,找问题很happy。

Web应用服务器篇

像tomcat这种应用服务器,本身也是一个java程序,但它可以把我们放上去的各个web应用隔离开来。 你没法调用其他web应用中的类,看上去好像是完全不相干的。这种技巧就充分利用了自定义类加载器的功能。 像tomcat会对每个应用单独定义一个类加载器(继承应用类加载器),并且修改双亲委派机制。 而是采用先从应用中的lib目录、classes目录尝试加载,没找到才到上面去找。(像was这种就跟复杂了,加载顺序也是可选的)

所以通常我们会有共享库的概念,在tomcat中对应的就是tomcat_home/lib这个目录(老版本的话还有更多目录可选)。 把一些第三方库放到这里,可以减少加载类的数量,从而减少内存占用。

这里要说明的是,类可以被不同的类加载器加载,虽然是在同一个jvm里边,但是是被当成不同的类(唯一标识是类加载器+类名)。

现在回到问题。虽然一开始就有人告诉我们,servlet是属于单例运行的。但是在这里有点小变化。

第一个问题,这个应该最常规的做法了,appa和appb是不相干的,所以访问appb,输出的是”1 1”,因为两个类是通过不同的类加载器加载的(就是说不一样的类),肯定生成的servlet实例是不一样的。

第二个问题,这种是共享库的做法,所以实际上他们使用的是同一个类,但是对于不同的app,用的是不同的servlet实例。 所以会出现静态变量有影响,当实例变量是独立的。所以最后会输出”3 1”。所以有维护静态变量的话,使用共享库是有不一样的。

第三个问题,这种其实在生产中也很常见,上新程序的时候就可能变成这样了。其实这个跟第一种情况是一样的。 不过,在was上加载顺序是可选的,所以情况也可能变成第二种情况。

后话

这里讲解的只是皮毛,有兴趣的童鞋,可以google更多资料和书籍,加以研究。

考察对类加载的理解(问题篇)

类加载和程序运行是有些关系的,不妨来测试一下。
难度:中级

独立进程篇

假设有下面的类文件:

// Main.java
package com.github.mccxj.test;

public class Main {
  public static void main(String[] args){
    new TestServlet().test();
  }
}

// TestServlet.Java
package com.github.mccxj.test;

public class TestServlet { 
  public void test() {
    InputStream is = TestServlet.class.getClassLoader().getResourceAsStream("config.properties");
    if(is == null){
      throw new RuntimeException("couId not found config.properties");
    }
  }
}

假设目录结构是这样的,其中jar下面的表示是在jar包里边的内容:

test
    -lib
        -test.jar
          -com/github/mccxj/test/Main.class
    -main.jar
        -com/github/mccxj/test/TestServlet.class
        -config.properties

请问:

  1. 执行java -cp main.jar;lib/test.jar com.github.mccxj.test.Main会出错么?
  2. 执行java -cp main.jar -Djava.ext.dirs=./lib com.github.mccxj.test.Main结果是怎样?

继续调整目录结果如下:

test
    -lib
        -test.jar
            -com/github/mccxj/test/Main.class
        -main.jar
            -com/github/mccxj/test/TestServlet.class
            -config.properties

再请问

  1. 执行java -Djava.ext.dirs=./lib com.github.mccxj.test.Main结果是怎样?

继续调整一下TestServlet的代码:

// TestServlet.Java
package com.github.mccxj.test;

public class TestServlet { 
  public void test() {
-    InputStream is = TestServlet.class.getClassLoader().getResourceAsStream("config.properties");
+    InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream("config.properties");
    if(is == null){
      throw new RuntimeException("couId not found config.properties");
    }
  }
}

把目录结构恢复成:

test
    -lib
        -test.jar
          -com/github/mccxj/test/Main.class
    -main.jar
        -com/github/mccxj/test/TestServlet.class
        -config.properties

请问:

  1. 执行java -cp main.jar;lib/test.jar com.github.mccxj.test.Main会出错么?
  2. 执行java -cp main.jar -Djava.ext.dirs=./lib com.github.mccxj.test.Main结果又是怎样?

最后调整目录结果如下:

test
    -lib
        -test.jar
            -com/github/mccxj/test/Main.class
        -main.jar
            -com/github/mccxj/test/TestServlet.class
            -config.properties
  1. 执行java -Djava.ext.dirs=./lib com.github.mccxj.test.Main结果是怎样?

Web应用服务器篇

下面的例子,以tomcat为例。 假设有下面的Servlet文件,并打包成test.jar:

// TestServlet.java
package com.github.mccxj.test;

public class TestServlet extends HttpServlet {
    private static Atomiclnteger al = new AtomicInteger();
    private Atomiclnteger a2 = new AtomicInteger();

    @Override
    public void service(ServletRequest arg0, ServletResponse arg1) throws Servlet Exception, IOException {
        System.out.printIn(String.valueOf(al.incrementAndGet()));
        System.out.printIn(String.valueOf(a2.incrementAndGet()));
    }
}

并部署两个应用程序appa、appb,在他们的WEB_INF/web.xml添加了下面的内容

<servlet>
  <servlet-name>test</servlet-name>
  <display-name>test servlet</display-name>
  <servlet-class>com.huawei.test.TestServlet</servlet-class>
</servlet>
<servlet-mapping>
  <servlet-name>test</servlet-name>
  <url-pattern>/test</url-pattern>
</servlet-mapping>

大家应该听说过Servlet是单例的概念,也可能听过Web应用服务器有共享类的机制。那么,请问:

  1. 现在把test.jar扔到appa和appb的WEB_INF/lib目录中,启动tomcat,先访问/appa/test两次,再访问/appb/test, 此时会输出什么?
  2. 继续把test.jar都移除掉,只添加到TOMCAT_HOME/lib目录中,启动tomcat,先访问/appa/test两次,再访问/appb/test, 此时会输出什么?
  3. 最后把test.jar拷贝一份到appa的WEB_INF/lib目录中,启动tomcat,先访问/appa/test两次,再访问/appb/test, 此时会输出什么?

对UC文章《实时监控Android设备网络包》的补充

补充的内容,主要是一些细节的问题,备忘.

编译netcat

android上自己好像带了一个,不过也可以自己编译一个。 我这里使用cygwin来编译的,首先去下载源码。

# cygwin
cd /cygdrive/d/
mkdir -p netcat/toolchain

export NDK=/cygdrive/d/android-ndk-r8e
/cygdrive/d/android-ndk-r8e/build/tools/make-standalone-toolchain.sh --platform=android-8 --install-dir=netcat/toolchain
export PATH='pwd'/netcat/toolchain/bin:$PATH
export CC=arm-linux-androideabi-gcc
export RANLIB=arm-linux-androideabi-ranlib
export AR=arm-linux-and roideabi-ar
export LD=arm-linux-androideabi-ld

# 开始编译源码
cd netcat-0.7.1/
./configure —host=arm-linux
make

# 用file进行检测一下
file src/netcat
src/netcat: ELF 32-bit LSB executable, ARM, version1 (SYSV), ...

# 发到android上去
adb push src/netcat /data/local/netcat
adb shell chmod 777 /data/local/netcat

tcpdump的使用

如果只是监听所有的包,可以用下面的:

adb shell "tcpdump -n -s 0 -w - | nc -I -p 11233"

如果先监听端口的话,又想转发的话,写法有点特别。

adb shell "tcpdump -X -n -s 0 -w - port 5000 | nc -l -p 11233"

另外,如果像我这样,有cygwin的话,就已经有nc命令了,可以像下面一样进行转发。

adb forward tcp:11333 tcp:11233 && nc -v 127.0.0.1 | /cygdriver/d/Wireshark/Wireshark.exe -k -S -i -

由于MANIFEST.MF不规范导致程序无法启动的问题

昨晚发现某个jar程序启动不了,包类没找到。 这个是由于我增加了一个新的jar包,并且依赖于xwork.jar。 所以在build.xml里边增加这个jar包。

		<fatjar.build output="ibossproc.jar">
      ...
	    <fatjar.jarsource file="${buildlib}/jsse.jar" relpath=""/>
	    <fatjar.jarsource file="${buildlib}/cipher14.jar" relpath=""/>
	    <fatjar.jarsource file="${buildlib}/xwork-2.0.4.jar" relpath=""/>
    </fatjar.build>

不过还是出错,出错信息挺诡异的(用星号对某些信息进行屏蔽):

mccxj@XXX:/work/procs/log> cat *
线程 "main" 中发生异常java.lang.NoClassDefFoundError: com.*.*.*.*Entry
Caused by: java.lang.ClassNotFoundException: com.*.*.*.*Entry
        at java.net.URLClassLoader.findClass(URLClassLoader.java:421)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:652)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:346)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:618)
Could not find the main class: com.*.*.*.*Entry.  Program will exit.

这个类是程序里边的代码,应该是存在的。把jar解压了,也的确看到jar包里边有这个类。

于是把以前能跑的jar拿来进行对比,发现META-INF/MANIFEST.MF稍稍有点不同,新的在最后多了2个空行。 简单调整了一下打包脚本(用的一个fatjar.jar的工具),最后两行进行调整顺序,发现就可以了。

		<fatjar.build output="xx.jar">
      ...
	    <fatjar.jarsource file="${buildlib}/jsse.jar" relpath=""/>
	    <fatjar.jarsource file="${buildlib}/xwork-2.0.4.jar" relpath=""/>
	    <fatjar.jarsource file="${buildlib}/cipher14.jar" relpath=""/>
    </fatjar.build>

一开始感觉是fatjar.jar的bug,不过回过头来看,其实是cipher14.jar这个包的问题。
因为这个包的META-INF/MANIFEST.MF的最后一样是个问号,根据规范文档, 这个是不规范的。如:

Manifest-Version: 1.0

?

而fatjar会把所有的MANIFEST.MF进行合并,但是奇怪的地方就在这里了。如果问号是在最后一样,能够正常启动。
如果问号后面还有内容,就启动不了。所以调整顺序之后,能够正常启动。
后来,我把cipher14.jar的MANIFEST.MF中的问号去掉,不需要调整顺序也是可以正常的。

这个包应该是第三方厂商提供的,又踩地雷了。

连接池泄露定位案例

前阵子,其他项目组里边有个某项目频繁出现获取不到连接的问题,基本上每一天都出现一次。

打出来的javacore里面,有大量的线程等待获取连接。并且现场反映,从数据库那边监控到大量的空闲连接没有释放。

事实上,这个系统会用到两个数据源,其中有个是正常的。有意思的是,这几个月都没有新版本上载。

走读了java里边的连接获取释放逻辑,虽然获取和关闭的调用到处都是(没封装好),但最后还是调用一些静态方法来完成的,并且都在finally块中进行了处理,感觉不会存在泄露的问题。

尝试看看能不能重现,运气还不错,在开发环境模拟生产的业务场景(业务功能不多,但逻辑处理流程比较长)进行了压力测试,也的确出现了这种情况。

既然能够重现问题,定位就方便许多了。我知道有Btrace这类神器可以帮助定位,但我没有使用过(下次再尝试一下)。我是通过修改代码来定位的,基本原理就是在获取连接时记录堆栈信息,在关闭时清除,这样出问题的时候就可以找到哪些地方有问题。其实用工具也是类似的做法。

大体上是这样的:

// 用来存储堆栈信息
private static final Map<Connection, Exception> conns = new ConcurrentHashMap<Connection, Exception>();

// 当获取链接的时候
public Connection getConnection(){
	Connection conn = dataSource.getConnection();
	conns.put(conn, new Exception());
	return conn;
}

// 当释放链接的时候
public void releaseConnection(Connection conn){
	conns.remove(conn);
	dataSource.close(conn);
}

修改后重新压力测试后,打开记录的堆栈一看,居然是在jsp里边获取的,真是顿时无语。

后来,虽然听说这个问题是其他原因引起的,导致非正常情况下走到这段逻辑,但这个地雷还是以前埋进去的,也怨不了别人。

其实,关于资源释放的逻辑封装,可以参考spring的jdbc封装(回调的方式),又或许用ThreadLocal、拦截器等方式在整个应用上进行处理。