6

引言

学习Java也有一段时间了,总感觉有些东西学的不是很精通。例如Java内存区域到底是怎么样的?程序是怎么跑的?对象是怎么存放的?这些都影响了我对自己的程序运行的熟悉程度。

一. 运行时数据区域

图片描述

Java虚拟机在执行java程序的过程中,会把它所管理的内存划分成若干个不同的数据区域(每当运行一个java程序都会启动一个虚拟机)。有一本书叫做《Java虚拟机规范》,讲述了Sun公司对Java虚拟机实现的相关规范,其中讲了虚拟机将所管理的内存分为以下几个部分:

程序计数器
虚拟机栈

本地方法区

方法区

其中方法区和堆是由所有线程共享的,例如使用ThreadPoolExecutor创建多个线程时,堆与方法区都可以被多个线程读取。

程序计数器 学过计算机组成原理的人都会知道在CPU的寄存器中有一个PC寄存器,存放下一条指令地址,这里,虚拟机不使用CPU的程序计数器,自己在内存中设立一片区域来模拟CPU的程序计数器。只有一个程序计数器是不够的,当多个线程切换执行时,那就单个程序计数器就没办法了,虚拟机规范中指出,每一条线程都有一个独立的程序计数器。注意,Java虚拟机中的程序计数器指向正在执行的字节码地址,而不是下一条。

虚拟机栈线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的时候都会创建一个栈帧(我觉得可以把它看作是一个快照,记录下进入方法前的一些参数,实际上是方法运行时的基础数据结构),用于存放局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直到执行完成的过程都对应着一个栈帧在虚拟机中的入栈到出栈的过程。我们平时把内存分为堆内存和栈内存,其中的栈内存就指的是虚拟机栈的局部变量表部分。局部变量表存放了编译期可以知道的基本数据类型,对象引用,和返回后所指向的字节码的地址。

本地方法区虚拟机栈 所发挥的作用很类似,但是要注意一下,虚拟机规范中没有对本地方法区中的方法作强制规定,虚拟机可以自由实现,即可以不是字节码。但是也可以是字节码,这样虚拟机栈和本地方法区就可以合二为一,事实上,OpenJDKSunJDK所自带的HotSpot虚拟机就直接将虚拟机栈和本地方法区合二为一。

这个概念应该很多人都很熟悉,例如初学C语言的时候,老师就会讲malloc方法会在堆中分配空间,这里也一样。这个区域是用来存放对象实例的,几乎所有对象实例都会在这里分配内存,虚拟机规范中讲:所有对象的实例以及数组都要在堆上分配。但是随着JIT(Just-in-time) 编译期的发展,有些时候也有可能在栈上分配(这里我也不是很明白其中的道理)。堆是java垃圾收集器管理的主要区域(很多时候会称为GC堆,不叫垃圾堆),垃圾收集器实现了对象的自动销毁。

方法区 也是各个线程共享的区域,它用于存储已经被虚拟机加载过的类信息,常量,静态变量,及时编译期编译后的代码(类方法)等数据。这里要讲一下运行时常量池,它是方法区的一部分,用于存放编译期生成的各种字面量和符号引用(其实就是八大基本类型的包装类型和String类型数据)。

最后还有一个直接内存,在JDK1.4版本中加入了NIO类,引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式,也就是说通过这种方式,不会在运行时数据区域分配内存,这样就避免了在运行时数据区域来回复制数据,直接调用外部内存。

二. 对象的创建

对于面向对象的一门语言,我们无时不在通过new关键字创建对象,那么这个过程又是怎样的呢?

当虚拟机遇到一条new指令的时候,首先会去检查所new的类是否已经被加载,在哪里检查?当然在方法区,方法区存放了加载过的类信息。如果没有加载,那么先执行类的加载。

通过类加载检查后,虚拟机开始为新生对象分配内存,对象所需要的内存大小在类加载完成后已经可以确定,这时候只要在堆中分配空间即可。分配内存有两种方式,第一种,我们假设内存绝对规整,那么只要在用过的内存和没用过的内存间放置一个指针即可,每次分配空间的时候只要把指针向空闲空间移动相应距离即可。第二种,我们假设空闲内存和非空闲内存夹杂在一起,实际上就是这种情况,那么就需要一个列表,去记录堆内存的使用情况,操作系统对内存的管理就是这样的。

那么,我们还要考虑一个问题,即在多线程的情况下,只有一个指针怎么能确保一个线程分配了内存指针没修改的时候另一个线程又分配内存不会覆盖之前的内存呢?这里有一种方法,让每一个线程在堆中先预分配一小块内存(TLAB本地线程分配缓冲),每个线程只在自己的内存中分配内存。

最后,对象被成功分配内存。我们知道通过一个对象,我们可以通过getClass()方法获取类,默认比较两个对象实际比较的是对象内存的哈希值,这又是怎么实现的呢?其实在分配完内存后,虚拟机会对对象进行必要的设置,对象的类,对象的哈希码等信息都存放在对象的对象头中,所以分配的内存大小绝不止属性的总和。

三. 对象的内存布局

对象在堆中的布局分为三个区域:对象头实例数据对齐填充

  • 对象头 包括两个部分,第一部分用于存储自身运行时的数据例如GC标志位,MonirGC次数,哈希码,锁状态,哪个线程可以拥有等被称为MarkWord(标记字)。第二部分存放指向方法区类数据的指针。在32位系统中,class指针大小为4字节,标记字大小为4字节。在64位系统中标记字大小为8字节。

  • 实例数据 存放类的属性信息,包括父类的属性信息。数组的实例部分还包括数组的长度。实例信息按类分别4字节对齐。

  • 对齐填充 这是虚拟机要求对象起始地址必须是8字节的整数倍,可以说对齐填充没有什么特别的含义。

四. 对象的访问定位

我们知道,引用是引用,对象实例是对象实例。引用存放在虚拟机栈中,数据类型为reference,对象实例存放在堆中。那么引用是如何指向对象实例的呢?

主流的访问方式有两种,第一种是通过句柄池,如果使用句柄池,那么java堆中将会划分出一部分内存作为句柄池,句柄包含对象类型指针指向方法区的类型信息,还有对象实例指针,指向堆中的实例地址。

第二种是reference引用直接指向堆中的对象实例,对象实例的对象头存放对象类型指针。

两种方法各有优势,第一种可以在对象实例在GC时移动的时候只改变句柄池中的对象实例指针,而不用改变reference引用本身。第二种方法就是访问速度快,减少了一次指针定位的时间开销。目前HotSpot虚拟机就采用的第二种方式。

总结

了解java内存区域是对java的深入学习,以前只知道有堆和栈的区分,现在我们了解到了具体的堆栈的作用。内存是怎么划分的,对象是怎么存储的,方法和属性的存放区别。通过对这些内容的了解,会让我们写java程序更加游刃有余,有的放矢。
更多文章:http://blog.gavinzh.com


Gavin要加油
1.9k 声望60 粉丝

test