Java内存区域
与C++不同,在虚拟机自动内存管理机制下,对象不需要手动的delete/free,不容易出现内存泄露和内存溢出。但了解Java运行时数据区域仍然非常重要。
运行时数据区域
Java虚拟机在执行程序时会把内存分为若干个不同的数据区域,每个数据区域都有各自的用途以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有的区域则是依赖用户线程的启动和结束而建立和销毁。
JDK1.8之前:
JDK1.8:
其中,堆、方法区和直接内存(非运行时数据区)是线程共享的,程序计数器、虚拟机栈和本地方法栈是线程私有的。
程序计数器
程序计数器是一块较小的内存区间,其可以看成当前线程所执行的字节码的行号指示器。字节码指示器通过改变程序计数器的值来完成流程控制、异常处理和线程恢复等功能。
现代处理器是通过cpu时间片轮转的方式进行线程调度,每次上下文切换为了能恢复到正确的执行位置,每个线程都需要有一个自己私有的程序计数器,各个线程间的程序计数器互不影响,相互独立。
程序计数器可以看成是“线程私有”的内存,且这是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError
的区域。
Java虚拟机栈
与程序计数器一样,虚拟机栈也是线程私有的,其生命周期与线程相同。每个Java方法被执行时,虚拟机都会创建一个栈帧用于存储局部变量表、操作数表、动态连接、方法出口等信息。每个方法被调用到执行完毕对应了一个栈帧从入栈到出栈的过程。
如果线程请求的栈深度大于虚拟机允许的深度,就会跑出StackOverflowException
;
如果栈扩展到无法申请到足够的内存,就会抛出OutOfMemoryError
。
本地方法栈
本地方法栈与虚拟机栈类似,虚拟机栈是为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
同样,本地方法栈也会抛出StackOverflowException
和OutOfMemoryError
。
Java堆
Java堆是垃圾收集器管理的内存区域,也叫“GC堆”,是几乎所有对象实例分配内存的地方,被所有线程所共享,在虚拟机启动时创建。
由于现代垃圾收集器采用分代收集算法,所以堆又被分为“新生代”和“老年代”。其中,新生代中采用复制算法,又分为一个eden区和两个survivor区。
Java堆是可扩展的,可通过参数-Xms
和-Xmx
设置。如果堆中内存不够完成对象分配又无法扩展时会抛出OOM
异常。
方法区
方法区与堆一样,是线程共享的内存区域,用于存储已加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在Java8以前,方法区是用永久代实现的,这样收集器的分代设计就可以扩展至方法区。但是这种策略容易造成内存溢出,所以到Java8后,就改成了用本地内存来实现方法区,完全废弃了永久代的概念,直接用在本地内存上实现的元空间来替代。
方法区如果无法满足新的内存分配需求,也会抛出OOM
异常。
运行时常量池
运行时常量池是方法区的一部分。
虚拟机对象
对象的创建
类加载检查
当虚拟机遇到一条
new
指令后,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,则执行类加载过程。分配内存
通过类加载检查后,对象所需的内存大小即可确定,接下来为新生对象分配内存。分配内存分为指针碰撞和空闲列表两种形式。
- 前者堆中内存是规整的,即被使用过的内存放在一边,空闲内存放在另一边,中间使用一个指针作为分界点的指示器,分配内存时就是将指针向空闲内存方向移动对象大小的距离。
- 后者堆中内存不是规整的,已被使用的内存和空闲内存交错在一起,虚拟机维护一个列表记录那些内存块是可用的,在分配内存时从列表中找到一块足够大的空间划分给对象,并更新列表的记录。
内存分配方式由Java堆是否规整决定,而堆是否规整则是由垃圾收集器采用的回收算法决定。
分配内存的线程安全问题解决:
在多线程环境下,可能出现正在给对象A分配内存,指针还未修改,对象B又使用了原来的指针来进行分配内存的情况。有两种解决方案:
- 对分配内存动作进行同步操作,采用CAS加上失败重试来保证更新操作的原子性。
- 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在堆中预先分配一小块内存,称为本地线程分配缓冲:TLAB,线程在自己的本地缓冲区中分配内存,当本地缓冲区用完了再触发同步操作。可通过虚拟机参数:-XX:+/-UserTLAB参数来指定是否使用。
初始化零值
内存分配完成后,需要将分配到的内存空间(不包括对象头)进行初始化零值。这保证了Java代码可以不赋初值就可以使用。
对象头设置
接下来对对象进行必要的设置,如对象对应类的元数据信息、对象的哈希码、对象的GC分代年龄、是否启用偏向锁等。
进行初始化
最后进行构造函数的初始化,即执行Class文件中的
<init>()
方法,一个对象就构造完成。
对象的内存布局
对象在堆中的内存布局可分为三个部分:对象头、实例数据和对齐填充。
- 对象头分为两部分信息。第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向线程ID、偏向时间戳等,被称为“
Mark Word
”。第二类是类型指针,通过这个指针来确定该对象是哪个类的实例。如果是数组,还必须有一块用于记录数组长度的空间。 - 示例数据是对象真正存储的有效信息。
- 对齐填充则是起到占位符的作用,JVM的自动内存管理系统要求对象起始地址必须是8的整数倍,即任何对象的大小都为8字节的整数倍。
对象的访问定位
Java程序通过栈上的reference
数据来操作堆上的对象,这个引用有两种实现方式:
- 句柄访问,Java堆中划分出一块内存作为句柄池,reference中存储对象的句柄地址,而句柄中包含了对象示例数据与类型数据各自具体的地址信息。
- 直接指针访问,reference中保存的是对象地址,但必须要考虑如何访问类型数据的相关信息。
使用句柄访问的优势是reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄池中的实例数据指针,而reference本身不需要修改。
使用直接指针的好处就是速度更快,节省了一次指针定位的时间开销。