一、Java内存区域
Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。根据《Java虚拟机规范 SE7》规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。
1.1 PC寄存器
PC寄存器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。每个线程都会有自己的PC寄存器空间。
1.2 Java虚拟机栈
Java 虚拟机栈也是线程私有的空间,它的生命周期与线程相同。 虚拟机栈描述了Java方法的执行时的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)。栈帧用于存储这个函数执行时的局部变量表,操作数栈,动态链接,方法出口等信息。局部变量存放的是各种基本数据类型、对象引用和returnAddress类型的数据(可以认为是函数入口)。
在Java虚拟机规范中,对这个区域规定了2种异常状况,如果线程请求的栈深度大于虚拟机所允许的深度,将跑出StackOverflow异常。 如果虚拟机栈可以动态扩展(当前的虚拟机实现基本都可以动态扩展),当扩展时无法申请到足够的内存,则跑出OutOfMemoryError异常。
1.3 本地方法栈
Native Method Stack与虚拟机栈的作用非常相似,不过本地方法栈是为虚拟机使用到的Native方法服务。
Java虚拟机规范并没有规定,对本地方法栈中方法使用的语言,使用方式和数据结构,虚拟机实现可以自由的实现它。有的虚拟机直接把本地方法栈和虚拟机栈合二为一。
1.4 Java堆
对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例。
Java堆是垃圾收集器管理的主要区域,很多时候也被称为GC堆。 Java堆在物理上可以在不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可实现成固定大小的,也可以是可扩展的。主流虚拟机基本都是可扩展的,可以用 -Xmx
和 -Xms
来控制。
-Xms
是指设定程序启动时占用内存大小。一般来讲,大点程序会启动的快一点,但是也可能会导致机器暂时间变慢。
-Xmx
是指设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出OutOfMemory异常。
1.5 方法区
Method Area也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap
。
对于习惯在HotSpot虚拟机上开发的程序员来说,很多人愿意把它称为永久代(Permanent Generation),这是因为 HotSpot虚拟机把GC分代收集扩展至方法区,使用永久代来实现方法区。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展之外,还可以选择不实现垃圾收集。 这区域内的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收效果难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实有必要,特别是在这个反射遍地的年代。在Sun公司的BUG列表中,曾出现过的若干个严重的BUG就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄露。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,也会跑出OutOfMemoryError异常。
1.5.1 运行时常量池
运行时常量池是方法区的一部分。 Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译期生成的各种字面量和符号引用。
Java虚拟机规范 对运行时常量池没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。
1.6 直接内存
直接内存并不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常。
在JDK1.4中,新加入了NIO(New Input/Output)类,引入了一种基于通道与缓冲区的IO方式,它可以使用native函数直接分配堆外内存,然后通过一个存储在Java堆中得DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了Java堆和Native的堆中来回复制数据。
直接内存的分配不受到Java堆大小的限制,但是必然也会受到物理限制和操作系统限制。另外,我们可以使用-Xmx来设置JVM虚拟机设置可占用的最大内存,这个最大内存也包括了直接内存。
二、HotSpot虚拟机对象简介
2.1 对象的创建
虚拟机遇到一条new指令时,将首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经加载,解析和初始化过。如果没有则先执行相应地类加载过程。
在类加载检查通过后,接下来虚拟机将会为新生对象分配内存。对象所需的内存大小在类加载完成之后便可完全确定,为对象分配内存的任务 本职上 就是从Java堆中划分一块内存。 事实上内存分配以后,内存分配器必须要能够记录内存的使用,以便于将来垃圾回收器的释放。 一般的虚拟机实现中,这个分配的过程也是由垃圾回收器来负责。 内存分配和回收时需要考虑到内存回收和分配产生的内存漏洞。 另外需要考虑的问题是线程安全性。 由于Java堆是线程共享的,某个时刻可能有多个线程需要分配内存,这时候就会有同步问题。 对于该问题,一般有2种方法,第一种是对分配内存空间的操作进行同步,保证其原子性;另一种方式是 为每个线程预先在Java堆中分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。这时候,只有 TLAB
用完分配新的 TLAB
的时候,才需要同步。 很明显,采用 TLAB
和 同步TLAB
的分配的方式,效率会大大提升。 虚拟机是否采用 TLAB
,可以通过 -XX:+/-UseTLAB
参数来设定。
内存分配完成并置零后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象哈希值,对象的GC分代年龄等。这些对象存放在对象的 Object Header
中。
至此,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角,对象创建才刚刚开始--init方法还没有执行。接下来,虚拟机会执行init方法,来调用类的构造函数,并初始化成员。
下面的代码是HotSpot虚拟机的bytecodeInterpreter.cpp中得代码片段(这个解释器很少有机会使用,因为大部分平台上都使用模板解释器,而且很多代码会通过JIT编译执行。)
if (!constants->tag_at(index).is_unresolved_klass()) {
oop entry = (klassOop) * constants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry = (klassOop) entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
instanceKlass * ik = (instanceKlass *) k_entry->klass_part();
if (ik->is_initialized() && ik->can_be_fastpath_allocated()) {
size_t obj_size = ik->size_helper();
oop result = nullptr;
bool need_zero = !ZeroTLAB;
if (UseTLAB) {
result = (oop) THREAD->tlab().allocate(obj_size);
}
if (result == nullptr) {
need_zero = true;
retry:
HeapWord * compare_to = *Universe::heap()->top_addr();
HeapWord * new_top = compare_to + obj_size;
if (new_top <= *Universe::heap()->end_addr()) {
if (Atomic::cmpxchg_ptr(new_top,
Universe::heap()->top_addr(),
compare_to) != compare_to) {
goto retry;
}
result = (oop) compare_to;
}
}
if (result != nullptr) {
if (need_zero) {
HeadWord* to_zero = (HeapWord *) result + sizeof(oopDesc) / oopSize;
obj_size -= sizeof(oopDesc) / oopSize;
if (obj_size > 0) {
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
if (UseBiasedLocking) {
result -> set_mark(ik->prototype_header());
} else {
result -> set_mark(markOopDesc::prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
2.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局分为三块:对象头,实例数据,对齐填充。
对象头包括2个部分,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标记,线程持有的锁,偏向线程ID,偏向时间戳等。这个部分数据长度在32和64位的虚拟机中,分别是32bit和64bit,官方称它为 Mark Word
。
对象头的另一个部分是类型指针。虚拟机通过该指针来确定对象是哪个类的实例。另外,如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息来确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
对象头部的Padding并不是必然存在的。多数虚拟机实现的内存管理系统都要求对象起始地址要4字节或者8字节对齐。 所以有时候需要padding来对齐。
2.2 对象的访问定位
访问对象的方式主要有2种,一种是使用句柄,另一种是直接指针。
句柄访问
如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池。句柄中包含了对象实例数据。
直接访问
如果使用直接指针访问,那么Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
这两种访问方式,各有优势,使用句柄来访问的最大好处是reference中存储的是稳定的句柄指针,在对象被移动时,只会改变句柄中的实例数据,而reference本身不用改变。
使用直接指针访问方式的最大好处是速度更快,少了一次指针存取的时间开销。由于Java对象的访问在运行过程中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot虚拟机使用的是直接指针访问。
三、 垃圾收集器
3.1 对象存活统计
在堆里面存放着Java世界中几乎所有的对象实例,在进行回收之前,我们第一件事情就是判断哪些对象不需要了,哪些对象还需要。
引用计数算法
引用计数法的实现原理非常简单:当有一个地方引用对象的时候,计数器就加1;引用被删除时候,计数器就减1;当计数器减为0的时候,就没有任何引用指向该内存区域了,这时候对象就会被回收。
有很多算法和实现采用的是引用计数法来统计对象的存活,事实证明它还算是一个效率较高的方法。比如有Python,AS3的FlashPlayer,COM组件,ObjectC等。
引用计数法有一个非常大的缺陷,那就是它很难解决对象之间的循环引用问题。
可达性分析算法
在主流的商用语言比如Java,C#,Lisp等中,都是采用该方法来判断对象是否存活。 这个算法的基本思想是通过一系列的GCRoots节点作为起始点,开始路径标记搜索。如果一个对象不能到达GC Roots,则它是已经死亡的对象。
在Java虚拟机中,可作为GCRoots的对象包括:
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
引用
在JDK1.2以后,Java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用。
- 强引用就是普通的引用。 只要强引用存在,则该对象永远不会被回收
- 软引用用来描述一些还有用但不是必需的对象。在系统将要发生内存溢出之前,将会对这些对象进行回收
- 弱引用,也是用描述非必需对象。但是被弱引用的对象只能生存到下一次垃圾收集发生之前。
- 虚引用,也成为幽灵引用或者幻影引用。它是最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能够在这个对象被回收的时候,收到一个系统通知。
JVM回收流程
如果对象在进行可达性分析时,发现没有与GC Roots相连接,那么它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()
方法。 当对象没有覆盖 finalize()
方法,或者 finalize()
方法已经被虚拟机调用过,虚拟机将这两种情况都是为不需要执行。
如果这个对象被判定为有必要执行 finalize()
方法,那么对象将会被放置在一个叫做 F-Queue
的队列中,并稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。