Java源代码文件(.java)
会被 Java编译器
编译为 字节码文件(.class)
,然后由 JVM
中的 类加载器
加载各个类的 字节码文件
,加载完毕之后,交由 JVM执行引擎
执行。
JVM
在执行 Java
程序的过程中会把它所管理的 内存
划分为 若干个
不同的数据区域。
JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。
了解清楚JVM的内存结构会更有助于我们理解Java的内存模型。
我们可以把上图的 运行时数据区
分为 线程私有
和 共享数据区
两大类。
线程私有
的数据区包含 程序计数器
、虚拟机栈
、本地方法栈
,即为 本地区(native area)
线程共享
的数据区包含 Java堆
、方法区
,在 方法区
内有一个 常量池
。虚拟机字节码
的地址。和计算机组成原理中提到的程序计数器PC概念类似,是线程私有的,用来记录当前执行的字节码位置。虚拟机栈
中创建 栈帧
,用于存储局部变量表(局部变量表需要的内存在编译期间就确定了所以在方法运行期间不会改变大小),操作数栈,动态链接,方法出口等信息。每一个方法从调用开始至执行完成的过程,就对应着 栈帧
在 虚拟机栈
中从 入栈
到 出栈
的过程。每一个 栈帧
都包括了 局部变量表
,操作数栈
,动态连接
,方法返回地址
和一些额外的附加信息。
在编译代码的时候,栈帧
中需要 多大的局部变量表
,多深的操作数栈
都已经 完全确定
了,并且写入到了 方法表
的 Code属性
中,因此一个 栈帧
需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于 执行引擎
来讲,活动线程中,只有 虚拟机栈顶
的 栈帧
才是有效的,称为 当前栈帧(Current Stack Frame)
,这个 栈帧
所关联的方法称为 当前方法(Current Method)
。执行引用所运行的所有字节码指令都只针对 当前栈帧
进行操作。栈帧的概念结构如下图所示:
方法区有时被称为 持久代(PermGen)
。
所有的对象在实例化后的整个运行周期内,都被存放在堆内存中。堆内存又被划分成不同的部分:年轻代(Young Generation Space)
,老年代(Old Generation Space)
。
年轻代
又可以划分为:伊甸区(Eden)
,幸存者区域(Survivor Sapce)
整体如下图所示:
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);
}
}
这段程序的数据在内存中的存放如下:
堆
是在 JVM
启动时创建的,主要用来维护运行时数据,如运行过程中创建的对象和数组都是基于这块内存空间。Java 堆
是非常重要的元素,如果我们动态创建的对象没有得到及时回收,持续堆积,最后会导致堆空间被占满,内存溢出。
因此,Java
提供了一种 垃圾回收机制
,在后台创建一个守护进程。该进程会在内存紧张的时候自动跳出来,把堆空间的垃圾全部进行回收,从而保证程序的正常运行。
也就是说,我们负责创建对象,GC负责来回收,那么如何认定是否是垃圾呢?
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
这种方案是目前主流语言里采用的对象存活性判断方案。基本思路是把所有引用的对象想象成一棵树,从树的根结点 GC Roots 出发,持续遍历找出所有连接的树枝对象,这些对象则被称为“可达”对象,或称“存活”对象。其余的对象则被视为“死亡”的“不可达”对象,或称“垃圾”。
上图中,object5
, object6
和 object7
便是 不可达对象
,视为“死亡状态”,应该被垃圾回收器回收。
参考下图,黑色的表示垃圾,灰色表示存活对象,绿色表示空白空间。
结果如下:
这便是 标记-清理
方案,简单方便
,但是 容易产生内存碎片
。
既然上面的方法会产生内存碎片,那好,我在清理的时候,把所有 存活 对象扎堆到同一个地方,让它们待在一起,这样就没有内存碎片了。
结果如下:
这两种方案适合 存活对象多,垃圾少
的情况,它只需要清理掉少量的垃圾,然后挪动下存活对象就可以了。
这种方法比较粗暴,直接把堆内存分成两部分,一段时间内只允许在其中一块内存上进行分配,当这块内存被分配完后,则执行垃圾回收,把所有 存活 对象全部复制到另一块内存上,当前内存则直接全部清空。
起初时只使用上面部分的内存,直到内存使用完毕,才进行垃圾回收,把所有存活对象搬到下半部分,并把上半部分进行清空。
这种做法不容易产生碎片,也简单粗暴;但是,它意味着你在一段时间内只能使用一部分的内存,超过这部分内存的话就意味着堆内存里频繁的 复制清空。
这种方案适合 存活对象少,垃圾多
的情况,这样在复制时就不需要复制多少对象过去,多数垃圾直接被清空处理。
我们先来回忆一下,一块 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
时,存活对象比较较大,也就是说每次只有少部分对象被回收。
因此,根据不同回收机制的特点,这里选择 存活对象多,垃圾少
的 标记整理
回收机制,仅仅通过少量地移动对象就能清理垃圾,而且不存在内存碎片化。
至此,我们已经了解了 J ava 堆内存
的 分代原理
,并了解了不同代根据各自特点采用了不同的回收机制,即 新生代
采用 复制回收
机制,老年代
采用 标记整理
机制。
JAVA的内存模型及结构
Android面试一天一题(Day 44:实战美团--Java内存模型)
Android中高效的显示图片 - Bitmap的内存模型
Java虚拟机的堆、栈、堆栈如何去理解?
Java-技术之垃圾回收机制
JVM内存模型解析
深入理解Java虚拟机笔记---运行时栈帧结构