了解Java内存布局和垃圾回收机制

Author Avatar
Ethan Hua 12月 10, 2017

我们知道 Java 与 C/C++ 有一个显著的不同就是 Java 中不需要程序员去管理内存的销毁, Java 虚拟机中自有一套自动内存管理机制,虽然如此,但是对于我们开发者来说理解 Java 的内存布局和管理机制对于在开发中能写出更健壮、性能更好的代码具有重大的作用。

Java 运行时内存数据区域

Java 虚拟机在执行程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户现场的启动和结束而建立和销毁。根据《Java 虚拟机规范(Java SE 7版)》的规定, Java 虚拟机所管理的内存将会包括以下几个运行时数据区域。

程序计数器(PC Register)

简单来说程序计数器是线程粒度的,是线程所执行字节码的行号指示器,在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,是 “线程私有” 的内存。

Java 虚拟机栈(JVM Stack)

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  • 栈帧
    每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的 Code 属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
    一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:
  • 局部变量表
    局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置,具体的对象会存储在堆上)和返回地址类型(returnAddress 指向了一条字节码指令的地址)在函数执行的时候,函数内部的局部变量就会在栈上创建,函数执行结束的时候这些存储单元会被自动释放,局部变量中的创建的对象会存放在堆上,垃圾回收器负责在其没有被任何地方引用的条件下回收释放。

本地方法栈(Native Method Stack)

简单来说本地方法栈为虚拟机使用到的 Native 方法提供内存空间。

Java堆(Java Heap Space)

  • 线程共享的一块内存区域
  • 几乎所有的对象实例都在这里分配内存
  • 垃圾收集器管理的主要区域

Java堆细分

从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为: 新生代老年代。再细致一点有 Eden 空间、From Survivor空间 、 To Survivor空间等。

  • 新生代(Young Generation)
    存放的是最近被创建的对象,此区域最大的特点是创建得快,被销毁得也快。当对象在Young Generation区域停留的时间到达一定的程度的时候,它就会被移动到Old Generation区域中。
  • 老年代(Old Generation)
    相比较于新生代,此区域存放的是存活时间较长的对象

方法区

同 Java 堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。

运行时常量池

运行时常量池也是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。运行时常量池除了编译期产生的 Class 文件的常量池,还可以在运行期间,将新的常量加入常量池,比较常见的是String类的intern()方法。

字面量:与 Java 语言层面的常量概念相近,包含文本字符串、声明为 final 的常量值等。
符号引用:编译语言层面的概念,包括以下3类:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

整体概括图

例子:

    public class HelloWorld {
        private static Logger LOGGER = Logger.getLogger(HelloWorld.class.getName());
        public void sayHello(String message) {
            SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YYYY");
            String today = formatter.format(new Date());
            LOGGER.info(today + ": " + message);
        }
    }

这段程序的数据在内存中的存放如下:

Java 垃圾回收机制

说起垃圾回收,我们先思考这三个问题

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

哪些内存需要回收?

简单来说就是那些已经”死掉“的内存,那么垃圾回收器是如何判断一个内存是”死掉”的状态呢?

引用计数法

定义:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的,就被认为是”死掉“的对象
缺陷:它很难解决对象之间互相循环引用的问题。例如: 对象A中有B的引用,而B中又有A的引用,但是A,B同时都没有被其它任何对象引用的情况。

可达性分析算法

这种方案是目前主流语言里采用的对象存活性判断方案。基本思路是把所有引用的对象想象成一棵树,从树的根结点 GC Roots 出发,持续遍历找出所有连接的树枝对象,这些对象则被称为“可达”对象,或称“存活”对象。其余的对象则被视为“死亡”的“不可达”对象,或称“垃圾”。
上图中,object5, object6 和 object7 便是不可达对象,视为“死亡状态”,应该被垃圾回收器回收。

再谈引用

Java引用并非只有一种类型,要么有,要么无未免太简单,如果有这样一种需求:当内存空间还足够时,则能保留这些引用对象在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。

在JDK1.2之后,Java对引用的概念进行了补充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phanotom Reference)4种,这四种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似”Object obj= new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用(SoftReference)软引用是用来描述一些还有用但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出异常。
  • 弱引用(WeakReference)也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用(PhantomReference),是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的即是能在这个对象被收集器回收时受到一个系统通知。

什么时候回收?

即使在可达性分析算法中不可达的对象,也并非是 “非死不可”的,这时候它们暂时处于 ”缓刑“ 阶段,要真正宣告一个对象的死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots像连接的引用链,那它将会被第一次标记且进行一次筛选(筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都是为”没有必要执行“),当第一次标记后第二次标记任为”死掉”的状态时,那么它就真的”死掉了”,并会被回收。

如何回收?

参考下图,黑色的表示垃圾,灰色表示存活对象,绿色表示空白空间。

标记-清理

所谓“标记”就是利用可达性遍历堆内存,把“存活”对象和“垃圾”对象进行标记,得到的结果如上图;
既然“垃圾”已经标记好了,那我们再遍历一遍,把所有“垃圾”对象所占的空间直接 清空 即可。
结果如下:

这便是 标记-清理 方案,简单方便 ,但是容易产生内存碎片。

标记-整理

既然上面的方法会产生内存碎片,那好,我在清理的时候,把所有 存活 对象扎堆到同一个地方,让它们待在一起,这样就没有内存碎片了。

结果如下:

这两种方案适合 存活对象多,垃圾少 的情况,它只需要清理掉少量的垃圾,然后挪动下存活对象就可以了。

复制

这种方法比较粗暴,直接把堆内存分成两部分,一段时间内只允许在其中一块内存上进行分配,当这块内存被分配完后,则执行垃圾回收,把所有 存活 对象全部复制到另一块内存上,当前内存则直接全部清空。


起初时只使用上面部分的内存,直到内存使用完毕,才进行垃圾回收,把所有存活对象搬到下半部分,并把上半部分进行清空。

这种做法不容易产生碎片,也简单粗暴;但是,它意味着你在一段时间内只能使用一部分的内存,超过这部分内存的话就意味着堆内存里频繁的 复制清空。

这种方案适合存活对象少,垃圾多 的情况,这样在复制时就不需要复制多少对象过去,多数垃圾直接被清空处理。

Java中采用那种方法呢?

我们先来回忆一下,一块 Java 堆空间一般分成三部分,这三部分用来存储三类数据:

  • 刚刚创建的对象。在代码运行时会持续不断地创造新的对象,这些新创建的对象会被统一放在一起。因为有很多局部变量等在新创建后很快会变成 不可达 的对象,快速死去 ,因此这块区域的特点是 存活对象少,垃圾多 。即为新生代;
  • 存活了一段时间的对象。这些对象早早就被创建了,而且一直活了下来。我们把这些 存活时间较长 的对象放在一起,它们的特点是 存活对象多,垃圾少 。即为老年代;
  • 永久存在的对象。比如一些静态文件,这些对象的特点是不需要垃圾回收,永远存活。即为永久代 。(永久代并不在 java 堆中,并且在 Java 8 里已经把 永久代 删除了。)

也就是说,常规的 Java 堆至少包括了 新生代 和 老年代 两块内存区域,而且这两块区域有很明显的特征:

  • 新生代:存活对象少、垃圾多
  • 老年代:存活对象多、垃圾少

新生代-复制 回收机制
对于新生代区域,由于每次 GC 都会有大量新对象死去,只有少量存活。因此采用 复制 回收算法,GC时把少量的存活对象复制过去即可。

将新生代区域分成8:1:1,依次取名为 Eden、Survivor A、Survivor B 区,其中 Eden 意为伊甸园,形容有很多新生对象在里面创建;Survivor区则为幸存者,即经历 GC 后仍然存活下来的对象。

工作原理如下:

  • 首先,Eden区最大,对外提供堆内存。当 Eden 区快要满了,则进行 Minor GC,把存活对象放入 Survivor A 区,清空 Eden 区;
  • Eden区被清空后,继续对外提供堆内存;
  • 当 Eden 区再次被填满,此时对 Eden 区和 Survivor A 区同时进行 Minor GC,把存活对象放入 Survivor B 区,同时清空 Eden 区和Survivor A 区;
  • Eden区继续对外提供堆内存,并重复上述过程,即在 Eden区填满后,把 Eden 区和某个 Survivor 区的存活对象放到另一个 Survivor 区;
  • 当某个 Survivor 区被填满,且仍有对象未被复制完毕时,或者某些对象在反复 Survive 15 次左右时,则把这部分剩余对象放到Old 区;
  • 当 Old 区也被填满时,进行 Major GC,对 Old 区进行垃圾回收。

那么,所谓的 Old 区垃圾回收,或称Major GC,应该如何执行呢?

老年代-标记整理 回收机制
根据上面我们知道,老年代一般存放的是存活时间较久的对象,所以每一次 GC 时,存活对象比较较大,也就是说每次只有少部分对象被回收。
因此,根据不同回收机制的特点,这里选择 存活对象多,垃圾少 的标记整理 回收机制,仅仅通过少量地移动对象就能清理垃圾,而且不存在内存碎片化

最后

谢谢阅读!
thanks:
Android基础之Java内存模型
Android 性能优化 - 详解内存优化的来龙去脉
《深入理解Java虚拟机》