人获得自由,究竟意味着什么?难道就是从一个牢笼里巧妙地逃出来,其实只是置身于另一个更大的牢笼吗? ——村上春树 《1Q84》
在《一文带你了解Java虚拟机(JVM)组成原理》中介绍了Java虚拟机是由类加载(ClassLoad)、运行时数据区(Runtime Data Area)、执行引擎(Execution Engine)、本地方法接口(Native Interface)四大块组成,还了解了Java运行主要在运行时数据区(Runtime Data Area)中执行,那么当我们在Java程序中new一个对象时Java虚拟机是如何将这个对象进行内存分配的呢?
1.程序运行时内存分配存储策略
我们在开始前先探讨一下程序运行时的三种内存分配存储策略,即静态存储、栈式存储、堆式存储。
首先是静态存储,静态存储是指在编译时就能够确定每个数据目标在运行时的存储空间要求,因而在编译时就可以给他们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构的存在,也不允许有嵌套或者递归的结果出现,因为他们都会编译程序无法计算准确的存储空间。
第二种是栈式存储,该分配策略是一个动态的存储,分配是由一个类似于堆栈的运行栈来实现的,和静态存储的分配方式相反。在栈式存储方案中,程序对数据区的要求在编译时是完全未知的,只有到了运行时才能知道。但是规定在运行中进入一个程序模块的时候,必须知道该程序模块所需要的数据区的大小才能分配,其内存和我们在数据结构中所熟知的栈一样。栈式存储分配按照先进后出的原则进行分配。
第三个是堆式存储,堆式存储分配者专门负责在编译时或运行时,模块入口处都无法确定存储要求的数据结构的内存分配。比如可变长度串和对象实例堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
2.堆、栈、方法区之间的联系
在了解了静态、栈式、堆式三种存储方式之后,再来探讨一下在Java虚拟机中堆、栈、方法区之间的联系。我们通过下面的代码段进行解析:
public class HelloWorld {
private String name;
public void setName(String name) {
this.name = name;
}
public void sayHi() {
System.out.println("Hello," + name)
}
public static void main(String[] args) {
int a = 1;
HelloWorld helloWorld = new HelloWorld();
helloWorld.setName("Huang Zaizai");
helloWorld.sayHi();
}
}
在上面的代码片段中我们通过new
创建HelloWorld
对象时,该对象实例会被保存到堆中,当我们需要引用堆里面的HelloWorld
实例时,可以在栈中定义一个特殊的变量,让栈中的变量的取值等于HelloWorld
对象在内存中的首地址(在示例代码中该特殊变量就是helloWorld
),栈中的helloWorld
变量就变成了HelloWorld
实例的引用变量,以后就可以在程序中使用栈中的引用变量来访问堆中的HelloWorld
对象实例。如图:
引用变量相当于是为数组或对象起了一个别名。引用变量是一个普通变量,定义式在栈中分配,引用变量在程序运行到其作用域之外后就会被释放掉。而数组和对象在堆中分配,即使程序运行到用于产生数组或者对象语句所在的代码块之外,数组和对象本身占据的内存都不会被释放。只有等到在没有应用变量指向的时候才会变为垃圾,需要等待一个不确定的时间被垃圾回收器释放掉(GC)
。
在搞清楚堆和栈之间的联系之后,我们继续来探讨一下方法区(Method Area)
在这段代码中的联系。在《一文带你了解Java虚拟机(JVM)组成原理》说到过方法区
是一个共享区块,这块区域的存储方法是静态存储,在内存分配策略中我们提到,静态存储的内容必需是编译时就能够确定每个数据目标在运行时的存储空间,因此方法区作用主要用于存储已被加载的类信息、常量池、静态变量、即时编译后的代码等关键数据,如图:
通过上图所示,在示例代码段中,方法区主要存储的中的是HelloWorld
的类信息、以及HelloWorld
类下的setName
、sayHi
、main
方法的定义以及name
字段信息。
结合上面的代码示例分析,我们可以得出结论:
- 栈采用的是栈式存储,存储的是局部变量,方法在调用是会进入栈内存,方法调用完毕会从栈内存销毁
- 堆采用的是堆式存储,存储的是new出来的实体对象,对象在该内存中存在地址值,对象在使用完毕,会在内存中作为垃圾,最后被垃圾回收器回收
- 方法区采用的是静态存储,存储已被加载的类信息、常量池、静态变量、即时编译后的代码等信息数据,可以理解为存储字节码对象的地方
3.堆内存结构及分配
3.1 堆内存结构
说完堆、栈、方法区之间的联系后我们来解析一下堆内的内存模型结构,先配上一个堆的内存结构图:
通过上图可以看到目前Java堆的分代,分成老年代
和新生代
两大部分,当新创建一个对象时会放入新生代的Eden区,在Eden区
后面有分别有From
和To
两个内存区,统称为存活区(Survivor)
,这一块区域主要是来存放Eden区
存活下来的对象,当新生代的对象经过多次回收之后依然存活或者虚拟机判断的对象(比如大对象)则会进入老年代。
通常来讲,老年代存储的对象比新生代存储对象的年龄大得多,另外老年代会存储一些大对象
3.2 对象的内存布局
当我们new一个对象时,这个对象是怎么存的呢?在HotSpot虚拟机为例来说,对象在堆内存中存储主要分为:对象头、实例数据和对齐填充。
HotSpot虚拟机是目前比较主流的一个Java虚拟机实现,这里以HotSpot虚拟机为例是因为不同的Java虚拟机实现可能会有所不同
对象头主要包含两个部分:一个是Mark Word
存储的是对象自身运行的数据,如:HashCode
、GC分代年龄
、锁状态标志
等。另外一个是类型指针
,这是对象指向它类元数据的指针。
实例数据这一块比较好理解,就是真正存放对象实例数据的地方,即对象的值
对齐填充这部分不一定存在,且没有特别的含义,仅仅是占位符,因为HotSpot要求对象其实地址都是8字节的整数倍,如果不是,就对齐。
3.3 对象的访问定位
当一个对象放入到内存中,我们该如何去访问到这个对象呢?这就需要我们去了解对象的访问机制了,在Java虚拟机规范中值规定了reference类型是一个指向对象的引用,但没有规定这个引用具体如何去定位、访问堆中对象的具体位置,这使得虚拟机的实现留下了很多的空间,让虚拟机厂商可以自由的发挥实现。
目前在主流的虚拟机是实现中,主要有:使用句柄或者使用指针两种方式
使用句柄这种方式时,Java堆中会划分出一块内存来做为句柄池,reference中存储句柄的地址,句柄中存储对象的实例数据和类元数据的地址,如下图所示:
使用指针的方式时,Java堆中会存放访问类元数据的地址,reference中存储的就是直接是对象的地址,如下图所示:
那么这两种访问定位的方式的优缺点是什么呢? 使用句柄这种方式的优点是当对象里的地址发生改变时,只需要修改句柄池里的对象实例数据的指针即可,不会影响到reference的指向,实际上是一个间接引用,这就导致了在运行时会比较慢,因为需要两次指向才能访问到对象数据。对于指针的方式来说,他的优点是速度快,因为他直接引用的对象的数据地址,所以在HotSpot虚拟机中采用的也是使用指针的方式。
4. 总结
在本篇内容中我们对Java虚拟机内存分配的进行了详细介绍。首先介绍了三种内存分配存储策略,分别是静态存储、栈式存储和堆式存储,并分别罗列了它们的特点和适用场景。深入探讨了Java虚拟机中的堆、栈、方法区之间的联系,并通过具体的代码示例进行了详细的讲解。最后,还介绍了对象的内存布局和访问定位等内容,帮助大家理解和掌握Java虚拟机内存分配的相关知识。在下一篇内容中我们将会继续讲解Java虚拟机的垃圾回收机制,欢迎关注~
关注公众号,内容更多更及时~
本文由mdnice多平台发布