分类目录归档:Java

Java IO Stream句柄泄露分析

Java io包封装了常用的I/O操作,流式操作被统一为InputStream、OutputStream、Reader、Writer四个抽象类,其中InputStream和OutputStream为字节流,Reader和Writer为字符流。流式抽象的好处在于程序员无需关心数据如何传输,只需按字节或字符依次操作即可。

在使用流式对象进行操作时,特别是文件操作,使用完毕后千万不能忘记调用流对象的close()方法,这也是老生常谈的话题,但是偏偏很容易忘记,或者没忘记但是使用不当导致close()方法没调用到。正确的做法是把close()方法放在finally块中调用,比如这样:

InputStream in = ...;
OutputStream out = ...;
try {
    doSth(in, out);
} catch (Exception e) {
    handleException();
} finally {
    try {
        in.close();
    } catch (Exception e1){
    }
    try {
        out.close();
    } catch (Exception e2){
    }
}

Java流式操作在便捷了I/O操作的同时,也带来了错误处理上复杂性,这也是Java被人诟病的理由之一。Golang在这块的处理就非常优雅,它提供了defer关键字来简化流程。

当文件流未被显式关闭时,会产生怎样的后果?结果就是会引起文件描述符(fd)耗尽。以下代码会打开一个文件10次,向系统申请了10个fd。

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    System.in.read(b); // pause
}

到/proc/{pid}/fd目录下确定已打开的文件描述符:

root@classa:/proc/16333/fd# ls -l
total 0
lrwx------ 1 root root 64 Aug  2 20:43 0 -> /dev/pts/3
lrwx------ 1 root root 64 Aug  2 20:43 1 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 10 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/ext/sunjce_provider.jar
lr-x------ 1 root root 64 Aug  2 20:43 11 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 12 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 13 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 14 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 15 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 16 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 17 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 18 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 19 -> /root/file
lrwx------ 1 root root 64 Aug  2 20:43 2 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 20 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 3 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar

但是根据以往经验,即使流未显式关闭,也没见过文件描述符耗尽的情况。这是因为Java文件流式类做了保护措施,FileInputStream和FileOutputStream类利用Java的finalizer机制向GC注册了资源回收的回调函数,当GC回收对象时,实例对象的finalize()方法被调用。以FileInputStream为例,看看它是怎么处理的:

/**
 * Ensures that the close method of this file input stream is
 * called when there are no more references to it.
 *
 * @exception  IOException  if an I/O error occurs.
 * @see        java.io.FileInputStream#close()
 */
protected void finalize() throws IOException {
    if ((fd != null) &&  (fd != FileDescriptor.in)) {

        /*
         * Finalizer should not release the FileDescriptor if another
         * stream is still using it. If the user directly invokes
         * close() then the FileDescriptor is also released.
         */
        runningFinalize.set(Boolean.TRUE);
        try {
            close();
        } finally {
            runningFinalize.set(Boolean.FALSE);
        }
    }
}

当fd未释放时,finalize()方法会调用close()方法关闭文件描述符。有了这一层保障后,即使程序员粗心忘了关闭流,也能保证流最终会被正常关闭了。以下程序可以验证:

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    byte[] b = new byte[2];
    System.in.read(b); // pause1
    System.gc();
    System.in.read(b); // pause2
}

Java运行参数加上GC信息便于观察:

# java -verbose:gc -XX:+PrintGCDetails -Xloggc:gc.log -XX:+PrintGCTimeStamps StreamTest

程序在pause1处打开了10个fd,接着强制通过System.gc()触发一次GC,等gc.log中GC日志输出后再观察/proc/{pid}/fd目录,发现已打开的文件描述符均已经关闭。

但是即便如此,依然存在资源泄漏导致程序无法正常工作的情况,因为JVM规范并未对GC何时被唤起作要求,而对象的finalize()只有在其被回收时才触发一次,因此完全存在以下情况:在两次GC周期之间,文件描述符被耗尽!这个问题曾经在生产环境中出现过的,起因是某同事在原本只需加载一次的文件读取操作写成了每次用户请求加载一次,在一定的并发数下就导致too many open file的异常:

Exception in thread "main" java.io.FileNotFoundException: file (Too many open files)
        at java.io.FileInputStream.open(Native Method)
        at java.io.FileInputStream.(FileInputStream.java:146)
        ......

--EOF--

一次线程同步问题的Bugfix

前两天碰到一个Java多线程未同步引起的Bug。Bug本身并没多少槽点,无非是本该同步的方法没加同步,造成最后执行结果非预期结果,但是在解决Bug的过程中发现还是有些知识点值得记录下来的。

本文分成两部分:1. Bug分析与解决。 2. synchronized注意事项。

1. Bug分析与解决

Watcher

这是出现Bug的业务场景和关键代码。Wathcer Service监听Etcd中产生的事件,然后提交给线程池处理。假设XJob是负责处理相关Etcd事件的类,其业务逻辑如代码所示,问题出在注释3-5处,当Etcd在很短的时间间隔内连续产生同一个操作对象的两个相同事件(该场景是我在设计之初未考虑到)时,会有多个XJob实例(线程)同时执行到注释3所在的区块。由于注释4处的rpc调用是个比较耗时的操作,因此可能会造成某个Entry在第一个线程中尚未写回到DB时,又被第二个线程取出来操作,导致数据不一致。

分析出问题所在后,最简单的方法就是通过synchronized关键字对相关代码进行同步。修复后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void run() {
    String id = getId(context);
    ......
    if (condition) {
        ......
    } else {
        synchronized(id.intern()) {
            Entry e = getFromDb(id, DEAD);
            rpc(e);
            update2Db(e, ALIVE);
            ......
        }
    }
}

这里有两个问题需在后面解答。

1). 在run()方法前加synchronized来修复Bug是否可行?

2). 为什么要通过synchronized(id.intern()){}而不是synchronized(id){}?

2. synchronized注意事项

1). synchronized关键字可以修饰方法(类方法/实例方法),也可以修饰代码块。本质上,进入synchronized修饰的区块都会得到一把锁,一块代码到底会不会同步执行,关键就是看多线程竞争的是否同一把锁。如此来理解所谓的对象锁和类锁就容易多了。先说对象锁,顾名思义,锁的粒度是对象。比如当synchronized修饰实例方法,它等同于用synchronized(this){}来修饰这个方法内的代码,也就是说除非是多个线程调用同一个实例(对象)的方法,否则synchronized不生效。以此类推,相同实例(对象)的不同synchronized方法也可以同步执行,因为他们的锁对象都是当前对象地址(this指针)。再说类锁,锁的粒度是类,一般修饰类方法或类变量,或者称为静态(static)方法或静态(static)变量。当synchronized修饰static方法时,它等同于用synchronized(类名.class){}来修饰这个静态方法内的代码。

举个栗子。某Test类有静态方法m1(),非静态方法m2()和m3(),三个方法都被synchronized关键字修饰,现有一个Test类的实例test。两个线程T1和T2,执行m2()和m3()时会同步,因为它们都要获得对象锁test。但是它们可以同时调用m1()和m2(),或者m1()和m3(),因为执行m1()是获得类锁Test.class,而执行m2()/m3()是获得对象锁test。

至此第1部分的两个问题原因显而易见了:

(1). run()方法前加synchronized来修复Bug是否可行?不行。因为提交给线程池执行的XJob对象不是单例的,XJob有很多个对象,不能用对象锁的方式。

(2). 为什么不是直接用synchronized(id){}?因为id对象是运行时生成的,这个String对象肯定分配在堆里。既然分配在堆里,即使id相同(equals()返回true),他们也属于不同的对象,不能用对象锁的方式。再看看String对象的intern()方法做了什么,intern()方法表示返回当前String对象在常量池中的地址,如果常量池中尚未存在该对象的值,那么就会将值放入常量池后再返回其地址。例如,两个String对象s1和s2,如果他们的值相同s1.equals(s2),那么s1.intern()==s2.intern()。当然它也有副作用,比如说频繁调用之后会引起Full GC。关于intern()实际上还有很多要注意的地方,涉及到GC性能,而且在不同的JDK版本下表现还不同,等有时间可以写一篇文章整理一下。

2). 虽然synchronized不可以修饰构造方法,但还是可以通过synchronized(this){}的方式修饰构造方法中代码块,然而这并没有什么意义,synchronized不会生效。想象一下,什么场景下多个线程需要初始化出相同地址的一个对象?如果构造方法中的初始化代码真的需要同步,可以通过类锁或其他的方式,但绝不会是synchronized(this)。

3). 进入synchronized代码块时获得的锁是由JVM负责释放的,这意味着无论代码是正常结束还是异常(checked, unchecked)退出,都不必关心锁未释放的问题。

--EOF--

Java ClassLoader

Java的字节码文件(.class)加载到JVM,形成class对象的过程称为Java类加载过程。将类加载过程进行细分的话,可以分为:
1. 装载:找到Java字节码,加载到JVM。
2. 链接:进一步细分为校验、准备、解析三个过程。对字节码进行格式验证(校验过程),为类变量分配内存,设置默认初始值(准备过程,基本类型为0,引用类型为null),将类、接口、字段和方法中的符号引用替换成直接引用(解析过程,可选)。
3. 初始化:类静态属性和静态块代码初始化。

『Java类加载器运行机制』一文中描述了Java类加载器的层次结构和工作原理(委托模式),也提到ClassLoader类的作用。本文会介绍ClassLoader类中几个主要方法的作用以及自定义一个类加载器需要注意什么。

ClassLoader类提供了findLoadedClass(), defineClass(), resolveClass(), loadClass(), findClass()等几个核心方法。

1. findLoadedClass()
此方法的作用是返回被当前ClassLoader加载的类,带final限定词,意味着它不能被子类重写,子类中直接调用即可。

2. defineClass()
此方法的作用是将Java字节码转换成Class对象,带final限定词,意味着它不能被子类重写,子类中直接调用即可。

3. resolveClass()
此方法的作用是对Class对象进行解析。解析的过程就是将类、接口、字段和方法中的符号引用替换成直接引用的过程。它的限定词为final,意味着它不能被子类重写,子类中直接调用即可。

4. loadClass()
此方法作用为加载指定名字的类。由于loadClass()方法不带final限定词,所以它能被子类重写。在ClassLoader的默认实现中,loadClass加载类有三个步骤:
1). 调用findLoadedClass(),检查该类是否已经被加载。如果已加载过,直接返回。
2). 调用父类加载器的loadClass()方法。如果已经被父类加载器加载,直接返回。
3). 调用findClass()方法尝试加载。

这三个步骤规定了一个Java类的加载顺序,这种默认顺序以一种安全的委托方式加载类,同样适用于大部分自定义类加载器的使用场景。除非要改变类加载的顺序,才会在子类中去重写。

另外,loadClass()方法还提供了一个boolean型的resolve参数,如果resolve为true,表示需要对类进行解析,loadClass的默认实现里,会调用resolveClass()方法。以下是ClassLoader的loadClass源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected synchronized Class<?>loadClass(String name, boolean resolve)
                                         throws ClassNotFoundException {     
        Class c = findLoadedClass(name);
        if (c ==null) {
            try {
                if (parent !=null)
                    c = parent.loadClass(name,false);
                else
                    c = findBootstrapClass0(name);
            }
            catch (ClassNotFoundException e) {             
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
}

5. findClass()
此方法的作用为返回待加载的类的Class对象,它不带final限定词,可被子类重写。ClassLoader默认实现中,findClass()方法什么都没做,就是抛出一个ClassNotFoundException。这么设计的原因有两个:
1). 根据loadClass()方法加载类的顺序,如果自己及自己的父类加载器都无法找到目标类的话,足以证明classpath中未包含该类。
2). 提供了一个安全的方便子类重写的方法入口。自定义类加载器可重写该方法。

若要创建自定义类加载器,需要继承自ClassLoader,然后重写或者调用以上方法来实现。一般来说重写findClass()方法即可,只需在findClass()方法的实现中,得到Java字节码内容后,调用defineClass()方法返回一个Class对象。

--EOF--

Java Unchecked Exception

Java中java.lang.Throwable类是一切异常的基类,它有两种类型(子类):Error类和Exception类。Error类通常表示系统级别异常,如IOError,OutOfMemeryError等。Exception类则表示应用级别中出现的异常,如IOException,NullPointerException等。这种分类是简单的按异常来源进行划分的。

然而,要知道Java支持异常机制的根本原因是为了提高程序的健壮性,因为程序受运行环境等因素影响,在运行过程中出现异常不可避免,如果编程人员能事先采取措施,当异常情况出现时让程序能继续运行下去,或者能从异常中恢复,那么对软件的鲁棒性提升很大。所以,关于异常一个更好的分类方式是分为检查型异常(Checked Exception)和非检查型异常(Unchecked Exception)。

所谓检查型异常,就是编译器要求此类异常必需被try…catch…语句包围进行异常处理,它所隐含的意思是编程人员必需知道此次方法调用过程中可能出现的异常,并提前对其进行处理,所以一般检查型异常用在一些能使程序从异常中恢复的场景,比如IOException,可能重试就能解决问题。检查型异常一般都继承自java.lang.Exception,当然Exception本身也是检查型异常。另一类是非检查型异常,这类异常的使用场景是程序无法从该异常中恢复,前文提到的Error类及其子类都属于此类异常,因为出现系统(底层)级别的错误后,程序不大可能还能恢复正常,例如OutOfMemeryError堆内存满。还有一类非检查型异常是RuntimeException类及其子类,它们所代表的是应用级别中出现的无法恢复的运行时异常,例如NullPointerException空指针异常,ArrayIndexOutOfBoundsException数组越界异常等。对应到Java的异常体系中,两种异常分别为:

1. 非检查型异常:java.lang.Error类及其子类,java.lang.RuntimeException类及其子类。
2. 检查型异常:java.lang.Exception类及其所有除java.lang.RuntimeException类(包括其子类)外的所有子类。

从编程角度来看,两者的最大区别是非检查型异常无需进行try…catch…语句包围,而检查型异常一定需要try…catch…语句包围。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void unchecked() throws RuntimeException {
}
 
public void checked() throws Exception {
}
 
public void test() {
    unchecked();
    try {
        checked();
    } catch (Exception e) {
    }
 
}

通常来说,检查型异常和非检查型异常的使用场景为:如果经过处理后程序能从异常中恢复,那么使用检查型异常,给编程人员一个机会进行异常处理;否则,使用非检查型异常。

这里还有两个新手存疑的问题:

1. 为什么Java编译器只要求检查型异常有try…catch…语句包围处理,而对非检查型异常则无此约束?
这是从程序的优雅性上考虑的,Java程序中充满了各种各样潜在的非检查型异常,如果都要用try…catch…语句处理异常,那么写出来的程序是庞大而冗余的。要知道即使连最简单的除法程序:

1
2
3
int foo(Integer a, Integer b) {
    return a / b;
}

可能也会存在NullPointerException,ArithmeticException,以及其他潜在的系统级Error异常。

当然,也不能为了使程序简洁而偷懒将一些本该是抛出检查型异常的方法改为抛出非检查型异常,这种编程行为完全浪费了Java设计人员的心血,影响程序健壮性。

另外,编译器虽然没有强制要求程序中捕捉非检查型异常,但是有时候对非检查型异常进行捕捉也有好处,例如Web项目中对数据库驱动抛出的SQLException进行捕捉能避免用户看到恼人的异常堆栈信息。

2. 是否所有异常都能捕捉?
可以。前文说过,java.lang.Throwable类是Java中所有异常的基类,要想程序万无一失(通常无此必要),只需捕捉Throwable异常即可:

1
2
3
4
5
6
void bar() {
    try {
        doAnyThing();
    } catch (Throwable t) {
  }
}

--EOF--

Java自动装箱/拆箱中的坑

Java的装箱/拆箱发生在包装类与其对应的基本类型之间,比如:

1
2
3
int int1 = 1;
Integer integer2 = new Integer(int1); //装箱
int int3 = integer2.intValue(); //拆箱

自JavaSE 5.0之后, Java支持了自动装箱/拆箱,所谓自动装箱/拆箱是指:

1
2
3
int int1 = 1;
Integer integer2 = int1; //自动装箱
int int3 = integer2;  //自动拆箱

以上来自维基。要说明的是,自动装箱/拆箱中的“自动”是编译器帮忙做的,以Integer类和int类型的转化为例,编译器会在int类型转Integer包装类时调用Integer的构造函数来进行实例化,会在Integer包装类转int类型时调用intValue()方法进行拆箱。

当我开始学习Java的时候早已是Java6的时代了,没有经历过装箱/拆箱的变迁,对自动装箱/拆箱的理解停留在理论上,所以实际中踩了坑也就不足为怪了。以下是我亲身经历的两次踩坑过程:

1. if(!null)表达式。
有次我写了段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Boolean check(){
    try{
        if(...){
            return true;
        }else{
            return false;
        }
    catch(Exception e){
    }
    return null;
}
 
public void doSth(){
    if(!check()){
        logger.error("error");
    }else{
         ...
    }
}

正常流程下check()方法返回true和false,但是偏偏我又想处理出现异常的情况,所以机智地在程序异常时返回null,试图用一个Boolean包装类来表示true,false和null三种状态。可能受C语言系语法影响吧,在外层doSth()方法的if语句中写下了!check()表达式,结果某天程序抛出一个运行时异常NullPointerException。因为编译器的自动拆箱机制,if语句处理后的代码变成if(!check().booleanValue())。如果check()方法返回null,if语句就会执行!null.booleanValue(),对一个null值调用任何方法显然都会得到一个NPE。

2. 包装类与基本类型进行关系运算。
不久前我大概写了以下代码:

1
2
3
4
5
6
JSONObject json = getHttpResponse(); // HTTP response text。
if (json.getInteger("code") != 112) {
    // error
    return;
}
...

getHttpResponse()方法调用一个HTTP restful接口,返回值是个JSONObject类型(fastjson),如果返回的json中有code属性,并且code值为112,则表示出现错误,否则,可以认为此次接口调用成功。实际上,上述程序在接口返回正确的情况下根本执行不下去,if语句中会抛出NullPointerException运行时异常。原因也是与编译器的自动拆箱有关,编译器拆箱后的if语句变为if (json.getInteger("code").intValue() != 112),显然,当HTTP接口返回值中不包含code属性时,json.getInteger("code")返回null,对null值调用intValue()会抛NPE异常。所以正确的做法是先判断json.getInteger("code")的返回值是否为null,如果不为null再去与112进行比较。

再细究下去,Java装箱/拆箱中还有一个坑,如果不知觉中写出了以下代码,那么实际上已经掉坑里了:

1
2
3
4
5
6
7
Integer i1 = getInteger1();
Integer i2 = getInteger2();
if(i1 == i2){
    System.out.println("equals");
}else{
    System.out.println("not equals");
}

其一,当==运算符的两个操作数都是对象时,它比较的是两个对象在内存中的地址。通常来说,两个对象的地址不会相同,所以要比较两个对象的值需要调用equals()方法。上述程序中编译器不会调用intValue()方法将i1和i2分别拆箱后再进行比较。其二,Java对包装类进行过一定优化,默认情况下,值为[-128,128)之间的Integer对象会缓存在内存中,也就是说,每个[-128,128)之间的Integer对象,如果它们的值相同,那么它们都是同一个对象(地址相同)。所以,上述程序中打印equals还是not equals完全视getInteger1()和getInteger2()方法的返回值而定。

--EOF--