1、运行时数据区域
从上图可以看出来,Java虚拟机运行时数据区域整体上可以分成5大块:
1.1、程序计数器
程序计数器是一块较小的内存空间。它可以看做当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条所需要执行的字节码指令。它是程序控制流的指示器、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。在任何一个时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条字节码执行。因此,为了线程切换后能恢复到正确的执行位置。每个线程都需要一个独立的私有的程序计数器。各个线程间互不影响,独立存储。我们称这种类型的内存区域为“线程私有”的内存。
如果线程正在执行一个Java方法,那么这个计数器记录的就是正在执行的字节码指令的地址。如果正在执行的是本地(Native)方法,那么这个计数器的值为空。同时,此内存区域是唯一一个在**《Java虚拟机规范》中没有规定任何OutOfMemoryError**情况的区域。
1.2、Java虚拟机栈
与程序计数器类似,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行时,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中入栈出栈的过程。
经常有人把Java内存区域笼统的换分为堆内存和栈内存。这种划分方式直接继承自C,C++程序内存布局结构。但是对于Java这种划分方式就显得比较粗糙。实际的内存区域划分比这更复杂。不过这种划分方式流行,也间接说明了程序员最关注的内存区域就是“堆”和“栈”。这里面的“栈”通常指的就是虚拟机栈。或者更多的情况下指的是虚拟机栈中的局部变量表部分。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一个字节码指令的地址)。
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位的long和double类型占用两个局部变量槽,其余的数据类型占用一个变量槽。局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请注意,这里的**“大小”指的是变量槽的数量**。虚拟机真正使用多大的内存空间(例如一个变量槽占用32位还是64位)来实现一个变量槽,完全是由具体的虚拟机实现自行决定。
在《Java虚拟机规范》中,对这个内存区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机栈允许的最大深度,将抛出StackOverflowError异常;如果,Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存就会抛出OutOfMemoryError异常。
1.3、本地方法栈
本地方法栈与虚拟机栈所发挥的作用非常相似。不过虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈为虚拟机执行本地方法服务。
《Java虚拟机规范》对本地方法栈中方法使用的语言。使用方式与数据结构并没有任何强制规定。也就是说具体的虚拟机可以根据需要自由的实现它。甚至有的虚拟机(Hot-Spot虚拟机)直接将本地方法栈和虚拟机栈合二为一。与虚拟机栈一样本地方法栈也会在栈深度超出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
PS:在HotSpot虚拟机中,并不区分虚拟机栈和本地方法栈,因此只能通过-Xss来设置栈的大小。
对于栈中的OutOfMemoryError,Java虚拟机规范中规定实现者自主选择是否支持栈动态扩展。如果不支持栈的动态扩展,那么在运行时是不会出现OutOfMemoryError异常错误的。只有在创建线程时,申请内存时就无法获取到足够内存才会出现OutOfMemoryError异常。在运行时只会出现由于栈容量无法容下新的栈帧而出现StackOverflowError。
验证出现StackOverflowError异常的方法:
1、使用-Xss来设置栈的容量
2、定义大量的本地变量,增大此方法栈中本地变量表的长度。
1.4、Java堆
Java堆是虚拟机管理的最大的一块内存区域。Java对是被所有线程共享的一块内存区域,在虚拟机启动时创建。在《Java虚拟机规范》中对Java堆的描述是“所有的对象实例以及数组都应当在堆上分配”。在《深入理解Java虚拟机》中作者是这样说的:“在Java世界里,几乎所有的对象实例都在Java堆上分配内存”。这里作者用的是“几乎”是指从实现角度上来说的。随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持。即使在现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化。所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
如果从分配内存的角度来看Java堆,所有线程共享的Java堆可以划分外出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配的效率。但是无论怎么划分,都不会改变Java堆存储内容的共性,无论哪个区域存储的都是对象实例。
在《Java虚拟机规范》中规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上应被视为连续的。
Java堆既可以被实现成固定大小,也可以被实现为可扩展的。可以通过**-Xms,-Xmx**来设置堆的最小,最大内存(如果Xms和Xmx一样大,则不能动态扩展)。如果Java堆没有足够内存用来新实例创建,且无法扩展时,将会抛出OutOfMemoryError异常。
验证OutOfMemoryError异常方法
1、通过-Xms,-Xmx来设置堆的最小和最大容量,然后创建大量的类。
1.5、方法区
方法区与Java堆一样,也是线程间共享的内存区域。用于存储已被虚拟机加载的类型信息、常量、静态变量、以及即时编译器编译后的代码缓存等数据。虽然**《Java虚拟机规范》中把方法区描述为堆的一部分**,但是它却有一个别名叫“非堆(Non-Heap)”,目的是与Java堆区分开。
在JDK8以前,由于很多人习惯在HotSpot虚拟机上开发,很多人更愿意把方法区称之为“永久代(Permanent Generation)”,或将两者混为一谈。但是本质上两者还是有区别的。因为仅仅是当时的HotSpot虚拟机的开发团队选择把收集器的分代设计扩展至方法区,或者使用永久代实现方法区而已。这样就可以让垃圾收集器管理方法区的内存回收。
在JDK6之后,HotSpot虚拟机,就有了放弃永久代,逐步采用本地内存(Native Memory)来实现方法区的计划。在JDK7的HotSpot虚拟机中,已经将方法区中的字符串常量池、静态变量等移出(放到堆中)。到了JDK8,已完全放弃永久代的概念,用在本地内存中实现的元空间(Meta-space)来替代,把JDK7中剩余的内容(主要是类型信息)全移动到元空间。
《Java虚拟机规范》中规定,当方法区无法满足新的内存分配时,将抛出OutOfMemoryError异常。
MetaSpace VM 参数
1、-XX:MaxMetaspaceSize:设置元空间最大值,默认时-1,表示没有限制,或者说只受限于本地内存大小。
2、-XX:MetaspaceSize:设置元空间的初始大小。以字节为单位,达到该值就会触发垃圾收集进行类型卸载,,同时收集器会调整该值:如果释放了大量的空间,就适当降低该值;如果释放了少量空间,在不超过MaxMetaspaceSize情况下,适当提高该值。
3、-XX:MinMetaspaceFreeRatio:在垃圾收集之后控制最小的元空间剩余容量百分比。可以减少由于元空间不足导致的垃圾收集频率。相应的还有-XX:MaxMetaspaceFreeRatio控制元空间最大的剩余容量百分比。
1.6、运行时常量池
运行时常量池是是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息。还有一项信息就是常量池表,用于存放在编译期生成的各种字面量与符号引用,这部分内容在类加载好之后存放到运行时常量池中。
Java虚拟机对Class文件的每一部分(自然包括常量池)都有严格的规定。如每一个字节用来存储哪种数据都必须符合规范上的要求才能被虚拟机认可、加载和执行。但是对于运行时常量池,《Java虚拟机规范》并没有作任何细节上的要求。
运行时常量池相对于Class文件中的常量池的区别在于,其具备动态性。Java语言并没有要求常量只能在编译期才能产生。也就是说,并非内置于Class文件常量池中的内容,在类加载后才能进入运行时常量池中。在运行期间也可以将新的常量放入到池中。就是String类的intern方法。
运行时常量池也是方法区的一部分,所以其在没有足够内存进行新的内存分配时,也会抛出OutOfMemoryError异常。
验证OutOfMemoryError异常方法
1、在JDK7之前,常量池是放在永久代中的。因此可以使用-XX:PermSize=6m和-XX:MaxPermSize=6m来变相限制常量池的大小。
2、在JDK7之后,永久代逐渐被metaSpace取代,在JDK8中,永久代就已经不存在了。而常量池而迁移到到了堆中。因此只能通过限制堆的大小来限制常量池的大小。
1.7、直接内存
直接内存既不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也频繁的使用,且也可能产生OutOfMemoryError异常。
在JDK1.4中新加入的NIO类,引入了一种基于通道与缓冲区的I/O方式。它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不受Java堆大小的限制。但是,既然是内存,那肯定还是会受到本机总内存大小的限制,一般服务器管理人员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,是得各个内存区域的总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。
直接内存可以通过-XX:MaxDirectmemorySize来控制大小,如果不指定大小,则默认与Java堆最大值(-Xmx)一致。
PS:由直接内存导致的内存溢出,一个明显的特征是在HeapDump文件中不会看到有什么明显的异常情况。如果发现内存溢出之后产生的Dump文件很小,而程序中又间接使用了DirectMemory(典型的间接使用就是NIO),那就可以重点检查下直接内存方面的原因。
2、对象创建过程
- 代码执行到new指令位置时,首先去检查这个指令的参数能否在常量池定位到一个类的符号引用,并检查这个类的符号是否已被加载,如果没有加载将执行相应的类加载过程。
- 在类加载检查通过后,进行内存分配。为对象分配空间等同于将一块固定大小的内存从Java堆上划分开来。内存分配的方式有两种:
- 指针碰撞方式:假设Java堆是规整的,已经使用的内存放到一边,没使用的放到另一边,中间放着一个指针作为分界点的指示器。那么分配内存就是将指针向空闲的一方移动一段与对象大小相等的距离。这种分配方式为“指针碰撞”。
- 空闲列表:如果Java堆是不规整的。那么就需要一个列表存储哪些内存是已经使用的,哪些是没使用的。内存分配就是从空闲列表中找到一块足够大小的区域划分出与对象相同大小的内存区域,并更新列表上的记录。
选择哪种方式取决于Java堆是否规整,而Java堆是否规整,取决于采用的垃圾收集器是否带有空间压缩整理功能。因此当采用Serial、ParNew等带有压缩整理功能的收集器时,系统采用的就是指针碰撞方法;而当使用CMS这种基于清除算法的收集器时,系统采用的就是空闲列表方法。
除如何划分空间外,还有一个问题需要考虑:对象创建在虚拟机是非常频繁的操作,即使是移动指针的方式,在多线程下也不是线程安全的。虚拟机采用两种解决方案:一种是采用CAS和失败重试的方法来保证更新操作的原子性;另一种是把内存分配的动作按照线程划分到不同的空间之中进行。即每个线程在java堆中先预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程中的本地缓冲区中分配,只有当本地缓冲区用完了,分配新的缓存区时,才会进行同步锁定。虚拟机是否使用TLAB,通过**-XX:+/-UseTLAB**参数来设定。
- 内存分配之后,虚拟机将分配到的内存空间(不包括对象头)初始化零值,如果使用了TLAB的话,这一步也可以提前到TLAB执行。这步操作,保证Java对象的实例字段在不赋初始值时就能使用。读取到的就是各个数据类型的零值。
- 接下来,Java虚拟机对对象进行必要的设置,比如这个对象是哪个类的实例、如何才能找到元数据信息、对象的哈希码(实际上延迟到真正调用Object::hashCode()方法才会进行计算)、对象的GC分代年龄等。
- 到这一步,从Java虚拟机的角度来看,一个新的对象已经产生,但是从程序的角度来看,对象创建才刚刚开始——构造函数。即Class文件的**()**方法还未执行,这时对象的所有字段为默认的零值,对象需要的其他信息还未按照约预定的意图构造好。等执行()方法之后,一个真正的对象才算完全构造出来。
3、对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
3.1、对象头
HotSpot虚拟机对象头部分主要包括两部分数据:一个是Mark Word(标记字段),另一个就是类型指针(Klass Point)。其中如果Java对象是一个数组的话,那么还需要一块内存用来存储数组的长度。虚拟机介意通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息确定数组的大小。对象头的结构如下图:
长度 | 内容 | 说明 |
---|---|---|
32/64位 | 对象头 | 存储hashCoe或者锁信息等 |
32/64位 | 类型指针 | 存储到对象类型数据的指针 |
32/32位 | 数组长度 | 数组的长度 |
3.1.1、Mark Word
Mark Word 用来存储对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向锁线程ID、偏向时间戳等。这部分的数据在32位和64位的虚拟机(未开启压缩指针)中的长度分别为32位和64位。官方称之为“Mark Word”。在32位的虚拟机中Mark Word结构如下:
3.1.2、类型指针
类型指针即对象指向它的类型元数据的指针,Java通过这个指针来确定这个对象属于哪个类的实例。并不是所有虚拟机的实现都必须在对象数据上保留类型指针。
3.2、实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的还是子类中定义的字段都必须记录下来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义的顺序有关。HotSpot虚拟机默认的分配顺序为:longs/doubles、ints、shorts/chars、bytes/booleans、oops,从以上默认分配策略中可以看到,相同宽度的字段总是被分配到一起。在满足这个条件的情况下,在父类中定义的字段会出现子类之前。
3.3、对齐填充
对齐填充并不是必然存在的,也没有特别的含义,只是起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,话句话说就是任何对象的大小都必须是8字节的整数倍。
4、对象的访问定位
创建对象自然是为了访问对象,我们Java程序通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置。因此对象的访问方式也是有虚拟机进行实现。主流的访问方式主要有:使用句柄和直接指针。
- 使用句柄:如果使用句柄访问的话,Java堆中将可能会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 直接指针:如果使用直接指针的方式的话,Java堆中对象的内存布局就必须考虑如何放置访问类型的的相关信息,reference中存储的直接就是对象的地址,如果只是访问对象本身的话,就不要多一次间接访问的开销。
通过句柄访问对象
直接指针访问对象
以上两种访问方式各有优势。使用句柄的方式的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。
使用指针访问的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此此类开销积小成多也是一项可观的成本。HotSpot虚拟机主要使用的就是此种方式访问对象。