标签归档:垃圾回收

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--

Erlang运行时之进程

本文试图从进程角度解释Erlang之所以高效的原因,大部分资料来源于论文『Characterizing the Scalability of Erlang VM on Many-core Processors』,并且带有自己的理解,不当之处请多包涵。

Erlang作为一门面向并发的语言(Concurrent Oriented Programming, COP),进程扮演着重要的作用,可以说Erlang就是一门面向进程的语言。归根到底,Erlang的核心概念无非就是进程、模式匹配、消息传递三大法宝。

目前主流的Erlang虚拟机是BEAM(Bogdan/Bjrn’s Erlang Abstract Machine),早期的JAM, old BEAM现都已经废弃不用。Erlang虚拟机是运行在操作系统中的一个多线程进程。Linux下,用POSIX线程库(pthread)实现,多线程共享进程(VM)的内存空间。一般来说,Erlang虚拟机会为每个CPU核分配两个线程,一个负责IO,一个作为调度器负责调度Erlang进程。

Erlang进程是虚拟机级别的进程,它非常轻量,初始化时只有2K左右,Erlang官方文档有给出测试初始进程占用内存大小的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Eshell V6.1  (abort with ^G)
1> Fun = fun() -> receive after infinity -> ok end end.
#Fun<erl_eval.20.90072148>
2> Pid = spawn(Fun).
<0.35.0>
3> {_,Bytes} = process_info(Pid, memory).
{memory,2680}
4> Bytes div erlang:system_info(wordsize).
335
5> erlang:process_info(Pid).
 ......
 {total_heap_size,233},
 {heap_size,233},
 {stack_size,9},
 ......

可以看到,一个进程包含堆栈在内只需2680B内存,其中堆(含栈)大小为233个字,64位系统下一个字等于8个字节,堆栈占用1864B。实际上,如果只计算PCB,大约只占300B,相比Linux PCB的1K也轻量不小。另外,Joe Armstrong在『Progamming Erlang』中也有过示范,物理内存充足的情况下,spawn一个进程只需花费微秒数量级的时间。因此,Erlang系统中允许同时存在成千上万的进程。

Erlang Process
图1

图1是Erlang进程的内部组成,每个进程都由独立的进程控制块(PCB, process control block)、栈和私有堆三部分组成。PCB包含的信息有进程ID、堆栈起始地址、mailbox、程序寄存器(PC)、参数寄存器等等,完整定义可以参考Erlang运行时(erts)源代码头文件erlang/erts/emulator/beam/erl_process.h中的process结构体,以下是几个主要字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct process {
    Eterm* htop;    /* Heap top */
    Eterm* stop;    /* Stack top */
    Eterm* heap;    /* Heap start */
    Eterm* hend;    /* Heap end */
    Uint heap_sz;   /* Size of heap in words */
    Uint min_heap_size; /* Minimum size of heap (in words). */
    Eterm* i;       /* Program counter for threaded code. */
    Uint32 status;  /* process STATE */
    Eterm id;       /* The pid of this process */
    Uint reds;      /* No of reductions for this process  */
    Process *next;  /* Pointer to next process in run queue */
    Process *prev;  /* Pointer to prev process in run queue */
    ErlMessageQueue msg;/* Message queue */
#ifdef ERTS_SMP
    ErlMessageInQueue msg_inq;
#endif
    ......
};

进程堆和栈共同占用一块连续的内存空间,堆空间由低地址向高地址增长,栈空间由高地址向低地址增长,当堆顶和栈顶一样时,可以判定堆栈空间已满,需要通过垃圾回收空间和或者增长空间。在Erlang进程看来,这块堆栈内存是独占的,进程间彼此隔离;在操作系统进程看来,所有Erlang进程的堆栈空间都在自己的堆空间里。Erlang进程里堆空间和栈空间存放数据类型有所区分,前者主要是一些复合数据,比如元组、列表和大数等,后者主要存放一些简单数据类型以及堆中复合数据的引用。

图2是以列表和元组为例展示了Erlang进程中堆栈的内存布局:

Heap Layout
图2

Erlang是动态类型语言,变量类型需要到运行时才能确定,因此,堆栈中每个数据都有一个Type标签表示其类型。元组在堆中是以Array的形式存储,有字段表示元组大小,并且在栈中有一个指针(引用)指向这块堆空间,因为是连续空间,要取出元组中的数据只需O(1)的复杂度计算内存偏移量即可。对于列表来说,列表元素在堆中是以链表形式存在的,由栈中的一个指针指向列表的第一个元素。相邻列表元素在内存中并不连续,也没有字段表示列表大小,因此要获取列表大小只能通过遍历,这个操作是个O(N)的时间复杂度。对于lists:append(ListB, ListA)这个操作,Erlang做的事情是先复制ListB,遍历ListB,找到列表尾部元素,将下一元素指针从NIL改为ListA第一个元素地址。由此可知,要提高性能,最好是将较长的列表追加到较短的列表上,以减少遍历时间。另外,也不要试图在列表尾部追加元素,原因同上,之前的一篇『Erlang列表操作性能分析』对此已做过分析。如果将ListC当做消息内容发送给其他进程,则整个ListC列表都会复制一份,即使往同一个节点发送多次,复制也会进行多次,这往往会导致消息接收进程占用的内存空间比发送进程大,因为对接收进程来说,每次接到的ListC会被当成不同的数据。

从图2还可以看出,ListA和ListC共享了部分数据,也就说,在一个Erlang进程内部,是存在内存共享的情况的。在Erlang里,变量拥有不变性,一次赋值(模式匹配)成功,它就不会再变,因此,ListA和ListC可以永远安全地共享这些数据。当然,Erlang中的内存共享在其他场景下也会出现,在图1中所示的有两块内存共享区域,一块是二进制数据共享区,用于存储大于64K的二进制数据;另一块是存储ETS表用的,ETS可以供每个进程访问,相比真正的共享内存有一些不同,它基于消息复制以记录为单位进行存取;相比数据库,它弱了很多,不支持事务机制。

当进程堆栈空间满时,会触发调度器对进程进行GC,如果GC结束堆栈空间仍然不足,则会分配新空间。Erlang的GC是以进程为单位,对某个进程GC不会影响其他进程的执行,虽然对单个进程来说,存在stop the world的现象,但是从全局来看,其他进程不会受影响,这个特性使得Erlang能够应付大规模高并发的业务场景,基于Elrang的业务系统可以达到软实时的级别。另外,一旦进程生命周期结束,GC可以非常方便地直接回收这个进程占用的所有内存。

综上,从进程的角度的来看,使得Erlang高效主要是以下方面:
1. 进程本身轻量。所以线程池的概念在Erlang语言层面根本不存在。
2. 变量不变性避免了很多无谓的数据复制。如List操作,直接通过修改指针实现append操作。
3. 以进程为单位进行GC。

References:
[1] Characterizing the Scalability of Erlang VM on Many-core Processors
[2] Erlang does have shared memory
[3] Erlang vs Java memory architecture
[4] Erlang - Programming the Parallel World

--EOF--

几种JVM垃圾回收类型及特点

1. 引用计数方式
堆中每个对象都有一个引用计数器,创建对象时,该对象的引用计数置1,此后,当有其他变量引用该对象时,引用计数都会加1。当一个变量被设置成新值或者引用超时后,引用计数减1。当引用计数器值为0时,该对象会被回收。这种gc方式可避免STW问题,但是它无法检测出循环引用的对象,因此会造成内存泄露,而且维护计算器也会造成一定开销。

2. 标记-收集-压缩(mark-sweep-compact)方式
垃圾收集器从root开始,追踪对象的引用图,并标记为活的对象,此为mark过程。遍历整个堆空间,将未被标记为活的对象清理掉,此为sweep过程。对sweep过后的堆空间进行压缩,去除内存碎片,此为compact方式。经过一次mark-sweep-compact处理之后,当前所有活的对象都被转移到一块连续的堆空间中。这种方式的gc不会出现内存泄露问题,但是compact的过程会导致程序暂停运行,而且sweep是对整个堆空间进行扫描,存在性能问题。

3. 拷贝回收
这种方式的垃圾回收是在内存中开辟堆空间两倍大小的空间,每次只使用一半空间。每一次gc都会把这一半中活的对象拷贝到另一半中。这种gc方式效率高,不必遍历整个堆空间,但是gc期间会造成程序停止运行,而且会造成一些生命周期较长的对象反复来回拷贝,并且,它需要的堆大小是其他方式的两倍。

4. 分代收集
分代收集的理论基础是:大部分对象的生命周期很短,但是总有一些对象的生命周期很长。这种gc方式会将堆空间按代分成多个区域,不同区域可采用不同的gc算法。年轻代的堆空间存放年轻对象,这个区域gc较频繁,当一个对象经过多次gc仍旧存活时,就被拷贝到老年代堆空间,这个区域gc不会太频繁。分代收集很好的在性能和程序可用性之间取了平衡,在各jvm的实现中较常见。

--EOF--

JVM运行时数据区

当一个Java程序要启动时,操作系统会启动一个Java虚拟机(JVM)的实例来运行这个java程序。每个Java程序的运行总有一个JVM在支撑着它。

一个JVM的运行时数据区结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                  +---------------+
  class文件 ----> |类装载器子系统 |
                  +---------------+
                      |        |
 +-------------------------------------------+
 |   ______   ______   ______   ________     |
 |  |      | |      | |      | |        |    |
 |  |方法区| |  堆  | |Java栈| |PC寄存器|    |
 |  |______| |______| |______| |________|    |
 |                  运行时数据区             |
 +-------------------------------------------+
                      |        |
                  +------------------+
                  |    执行引擎      |
                  +------------------+

图1 JVM运行时数据区
(Download ASCII Picture)

首先,JVM会将编译后的.class文件通过类装载子系统进行装载、连接和初始化。它会分析这个装载的类,并把类中不同的代码块按规则在内存中分配好。图中的运行时数据区就是由JVM管理的Java程序所使用的内存区域,也是我今天想要理清的内容。它的各部分功能介绍如下:

1、方法区
方法区中存储的是一个类的类型信息,包括public、private、protected等限定信息,以及当前类及其父类的全限定(路径)名等。此外,方法区中还存储着一个类的静态变量、实例变量、常量池、方法信息等。
2、堆
堆中存储的是Java程序中用关键字new出来的对象和数组。这部分内存的回收是由JVM的GC(Garbage Collection)来完成,堆中的对象可被当前程序的所有线程所共享。存在堆中的对象都有一个指向方法区中该对象所属类型的指针,这样才方便与运行时去执行该对象的方法。依据JVM的具体实现,方法区与堆可以在同一片内存区域,JVM会管理彼此之间的边界。
3、Java栈
Java栈中存储的是方法的局部变量和对堆中对象的引用,每个Java线程都会在这个Java栈中有一个属于自己的方法调用栈,栈中的内容以栈帧为单位进行存储,每个栈顶栈帧代表着一个线程当前正在执行的方法。
4、PC寄存器
PC寄存器其实不是寄存器,JVM中没有寄存器,它只是功能类似于PC寄存器,用于指示下一条执行代码的位置所在。每个线程都在这个PC寄存器中有一个属于自己的PC寄存器。

以一个实例说明Java程序运行过程中运行时数据区的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
 * 衔山的博客 - http://fengchj.com
 * Copyright (c) 2011 All Rights Reserved.
 */
package cn.edu.zju.jvm;
 
/**
 * @author 衔山
 * @version $Id: JvmDataSegTest.java, v 0.1 2011-9-27 下午08:48:26
 */
class Demo {
    public static int static_var = 1;
    public String     non_static_var;
 
    public Demo(String var) {
        this.non_static_var = var;
    }
 
    public void echo() {
        System.out.println(non_static_var);
    }
}
 
public class JvmDataSegTest {
 
    public static void main(String[] args) {
        Demo demo = new Demo("hello world!");
        demo.echo();
    }
}

这里先略去PC寄存器,也不考虑多线程,因此,Java栈中仅存储着main方法所在的主线程的调用栈。运行Demo demo = new Demo("hello world!")语句后,运行时数据区的当前状态如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 +-----------------------------------------------------------------------------+
 |   ______________________________   ________________   ____________________  |
 |  |方法区                        | |堆              | |Java栈              | |
 |  |                              | |                | |                    | |
 |  | +--JvmDataSegTest类型信息--+ | | +------------+ | | +----------------+ | |
 |  | |      main()方法          | | | |  Demo实例  | | | | 指向Demo的引用 | | |
 |  | |    "hello world!"        | | | +------------+ | | +----------------+ | |
 |  | |        ……              | | |                | |                    | |
 |  | +--------------------------+ | |                | |                    | |
 |  |                              | |                | |                    | |
 |  | +-------Demo类型信息-------+ | |                | |                    | |
 |  | |      static_var=1        | | |                | |                    | |
 |  | |      non_static_var      | | |                | |                    | |
 |  | |        echo()方法        | | |                | |                    | |
 |  | |          ……            | | |                | |                    | |
 |  | +--------------------------+ | |                | |                    | |
 |  |______________________________| |________________| |____________________| | 
 |                                                                             |
 |                               运行时数据区                                  |
 +-----------------------------------------------------------------------------+

图2 测试程序的运行时数据区布局
(Download ASCII Picture)

从上图中应该可以很直观的看到这个运行时数据区的布局了。Demo类的类型信息和JvmDataSegTest类的类型信息都存在方法区中,其中还包括Demo类的静态变量(static_var)和实例变量(non_static_var)。main()方法中用于构造Demo类的字符串"hello world!"也是存储在方法区的常量池中。main()方法中new出来的Demo对象存储在堆中。此时的Java栈中,栈顶应该是主线程的main()方法栈帧,里面存着一个引用demo,它指向堆中的Demo对象。当main()方法执行到demo.echo()语句时,JVM先从栈中的demo引用出发,找到位于堆中的Demo实例,然后从这个Demo实例中找到echo()方法的引用,继而定位到方法区中Demo类型信息部分,从中获取echo()方法的字节码,执行echo()方法的指令。

Reference:
[1] Bill Venners 著, 曹晓钢 蒋靖 译. 深入Java虚拟机[M]. 机械工业出版社. 2003-09.

--EOF--