(转)Java内存模型

Updated on with 3,115 views

什么是JVM内存

Java源代码文件(.java)会被 Java编译器编译为 字节码文件(.class),然后由 JVM中的 类加载器加载各个类的 字节码文件,加载完毕之后,交由 JVM执行引擎执行。

JVM在执行 Java程序的过程中会把它所管理的 内存划分为 若干个不同的数据区域。

JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。

java_MemoryModel-0.png
了解清楚JVM的内存结构会更有助于我们理解Java的内存模型。

我们可以把上图的 运行时数据区分为 线程私有共享数据区两大类。

  • 线程私有的数据区包含 程序计数器虚拟机栈本地方法栈,即为 本地区(native area)
  • 线程共享的数据区包含 Java堆方法区,在 方法区内有一个 常量池

java_MemoryModel-1.png

本地区(native area)

  • 程序计数器(PC Register)
    记录正在执行的 虚拟机字节码的地址。和计算机组成原理中提到的程序计数器PC概念类似,是线程私有的,用来记录当前执行的字节码位置。
  • 虚拟机栈(JVM Stack)
    也就是我们常常所说的栈。
    方法执行的内存区,每个方法执行时会在 虚拟机栈中创建 栈帧,用于存储局部变量表(局部变量表需要的内存在编译期间就确定了所以在方法运行期间不会改变大小),操作数栈,动态链接,方法出口等信息。每一个方法从调用开始至执行完成的过程,就对应着 栈帧虚拟机栈中从 入栈出栈的过程。
    java_MemoryModel-4.png
  • 本地方法栈(Native Method Stack)
    本地方法栈则为虚拟机使用到的Native方法提供内存空间。

栈帧

每一个 栈帧都包括了 局部变量表操作数栈动态连接方法返回地址和一些额外的附加信息。
在编译代码的时候,栈帧中需要 多大的局部变量表多深的操作数栈都已经 完全确定了,并且写入到了 方法表Code属性中,因此一个 栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。

一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于 执行引擎来讲,活动线程中,只有 虚拟机栈顶栈帧才是有效的,称为 当前栈帧(Current Stack Frame),这个 栈帧所关联的方法称为 当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对 当前栈帧进行操作。栈帧的概念结构如下图所示:
java_MemoryModel-6.png

方法区(method area)

  • 方法区(Method Area)
    主要存放的是已被虚拟机加载的类信息(包括类名、方法、字段)、常量、静态变量、编译器编译后的代码等数据。

方法区有时被称为 持久代(PermGen)

  • 常量池(Runtime Constant Pool)
    存放编译器生成的各种字面量和符号引用,是方法区的一部分。

堆(heap space)

  • Java堆一般是JVM管理的内存中最大的一块,堆在主内存中
  • 是被所有线程共享的一块内存区域,其随着JVM的创建而创建
  • 是用来存储对象本身的以及数组
  • 同时JAVA堆也是GC管理的主要区域。

所有的对象在实例化后的整个运行周期内,都被存放在堆内存中。堆内存又被划分成不同的部分:年轻代(Young Generation Space)老年代(Old Generation Space)
年轻代又可以划分为:伊甸区(Eden)幸存者区域(Survivor Sapce)

总结

整体如下图所示:
java_MemoryModel-2.png

例子

import java.text.SimpleDateFormat;
import java.util.Date;
import org.apache.log4j.Logger;

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_MemoryModel-5.png

垃圾回收

是在 JVM 启动时创建的,主要用来维护运行时数据,如运行过程中创建的对象和数组都是基于这块内存空间。Java 堆是非常重要的元素,如果我们动态创建的对象没有得到及时回收,持续堆积,最后会导致堆空间被占满,内存溢出。

因此,Java 提供了一种 垃圾回收机制,在后台创建一个守护进程。该进程会在内存紧张的时候自动跳出来,把堆空间的垃圾全部进行回收,从而保证程序的正常运行。

也就是说,我们负责创建对象,GC负责来回收,那么如何认定是否是垃圾呢?

哪些是垃圾呢?

引用计数算法

android-reference_gc_method1.png

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

  • 优点:简单,高效,现在的objective-c用的就是这种算法。
  • 缺点:很难处理循环引用,相互引用的两个对象则无法释放。

可达性分析算法

android-reference_gc_method2.png

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

上图中,object5, object6object7 便是 不可达对象,视为“死亡状态”,应该被垃圾回收器回收。

如何来回收这些垃圾呢?

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

标记-清理

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

结果如下:
java_gc_1.png

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

标记-整理

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

结果如下:
java_gc_2.png

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

复制

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

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

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

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

Java中采用那种方法呢?

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

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

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

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

新生代-复制 回收机制

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

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

工作原理如下:

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

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

老年代-标记整理 回收机制

根据上面我们知道,老年代一般存放的是存活时间较久的对象,所以每一次 GC 时,存活对象比较较大,也就是说每次只有少部分对象被回收。

因此,根据不同回收机制的特点,这里选择 存活对象多,垃圾少标记整理 回收机制,仅仅通过少量地移动对象就能清理垃圾,而且不存在内存碎片化。

总结

至此,我们已经了解了 J ava 堆内存分代原理,并了解了不同代根据各自特点采用了不同的回收机制,即 新生代 采用 复制回收 机制,老年代采用 标记整理机制。

参考

JAVA的内存模型及结构
Android面试一天一题(Day 44:实战美团--Java内存模型)
Android中高效的显示图片 - Bitmap的内存模型
Java虚拟机的堆、栈、堆栈如何去理解?
Java-技术之垃圾回收机制
JVM内存模型解析
深入理解Java虚拟机笔记---运行时栈帧结构

原文地址

Responses
取消