1. jvm运行时数据区
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
(1) 程序计数器
指向下一条将要执行的字节码指令。线程隔离的区域,每个线程都有自己的程序计数器。
(2) java虚拟机栈
虚拟机栈也是线程私有的,每个线程有自己的虚拟机栈。每调用一次方法,都会在虚拟机栈中建立一个栈帧,用于存放局部变量表、操作数栈、动态链接、方法返回地址等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在栈中入栈到出栈的过程。如果线程请求的栈深度大于允许的值,则会抛出StackOverflowError异常;如果栈扩展时无法申请到足够的内存,则会抛出OutOfMemoryError异常。
(3) 本地方法栈
线程隔离,和虚拟机栈类似,是虚拟机调用
Native方法时使用的。有的虚拟机将虚拟机栈和本地方法栈合二为一。本地方法栈也会抛出StackOverflowError异常
和OutOfMemoryError异常
。
(4) 堆
线程共享的区域,用来存放对象实例,几乎所有的对象实例都在这里分配内存。
给数组分配的内存区域也在这里。堆是垃圾收集器管理的主要区域。从垃圾回收的角度看,堆分为新生代和老年代两部分,新生代包括Eden空间、From Survivor空间、To Survivor空间三部分。从内存分配的角度看,堆中可能划分出多个线程私有的分配缓冲区。
通过-Xms设置初始堆大小,-Xmx设置可扩展的最大堆大小。当堆无法扩展时,会抛出OutOfMemoryError异常。
(5) 方法区
线程共享的区域,存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区的内存也是可以回收的,回收的目标主要针对常量池的回收和对类型的卸载。
(6) 运行时常量池
运行时常量池是方法区的一部分。Class文件中的常量池,用于存放编译期生成的各种字面量和符号引用,在类加载后进入方法区的运行时常量池。
(7) 直接内存
不是虚拟机运行时数据区的一部分,不会受到Java堆大小的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。
NIO可以使用Native函数库直接分配堆外内存。
2. 对象的创建
当我们通过new创建一个类的实例对象时,jvm首先看这个类是否已经加载,如果没有,则先将类加载到内存中。随后按以下步骤创建对象:
(1)在堆中为对象分配内存:
指针碰撞:
假设Java堆内存是绝对规整的,所有用过的内存放在一边,空闲的在另一边,中间有一个指针作为分界指示器,那么所谓分配内存就是将指针移动和对象大小相等的距离。
空闲列表:
如果Java堆不是规整的,虚拟机需要维护一个列表,记录哪些内存块可用,在分配的时候找到足够大的空间划分给对象实例,并更新列表上的记录。
分配内存的操作不是线程安全的,可能正在给A对象分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。因此,对象分配内存空间的动作要进行同步。在并发情况下,虚拟机通过CAS加上失败重试的方式来保证更新操作的原子性。
TLAB(Thread Local Allocation Buffer):
为了防止并发情况下分配内存的冲突,虚拟机还会为每个线程预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),线程在自己的TLAB中分配内存,只有当TLAB用完并分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数设定。
(2)虚拟机将分配到的内存空间初始化为零值。
如果使用TLAB,初始化为零值这一步骤也可以提前至TLAB分配时进行。
(3)设置对象头。
例如对象是哪儿个类的实例、如何找到类的元数据信息、对象的哈希值、对象的GC年代等。
(4)执行<init>方法
,按照程序员的意愿初始化对象,这样一个真正的对象才算完全可用。<init>方法包括类的实例初始化块代码以及类的构造函数。
3.对象的内存布局
一个对象在内存中存储的内容分为3个部分:
对象头(Header)
、
实例数据(Instance Data)
和
对齐填充(Padding)
。
(1)对象头
对象头包括两部分:
Mark Word和
类型指针
- Mark Word
mark word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。在32位和64位虚拟机中,mark word的长度分别是32位和64位。对象的运行时数据不止32位,因为mark word中的值是复用的,其中2位为标志,用于标记mark word中存放的是什么信息。
- 类型指针
类型指针指向对象所属的类元数据,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外,如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据。
(2)实例数据
对象的实例数据部分存储的才是程序代码中所定义的类的各个字段的值。虚拟机默认的分配策略是相同宽度的字段总是被存储到一起(比如long是64位的,double也是64位的,它们会存储到一起),在满足这个条件的前提下,在父类中定义的变量会出现在子类之前。子类中较窄的变量也可能会插入到父类变量的空隙之中。
(3)对齐填充
虚拟机中对象的起始地址是8字节对齐的,对象头部分正好是8字节的整数倍,因此,如果对象的实例数据部分不是8字节整数倍时,在实例数据的后面会填充对齐。
4.对象的访问定位
Java中通过栈上的引用来操作堆上的具体对象。根据实现的不同,分为2种方式:
句柄
和
直接指针
。
(1)句柄
对象的引用指向一个句柄,句柄中包含2个指针,一个指向堆中的对象,另一个指向方法区中的类元数据。另外Java堆中还需划分出一块专门的内存来作为句柄池。如下图所示:
使用句柄方式的优点是引用中存储的是稳定的句柄地址,对象移动时只会改变句柄中的实例数据指针,引用本身不需要被修改;缺点是每次通过引用访问对象时需要经过两次指针定位。
(2)直接指针
这种方式下,引用中存放的就是对象的地址。如前所述,对象的对象头中的类型指针指向方法区中的类元数据。如下图所示:
使用直接指针的好处:速度快,因为它指向的就是对象的地址,节省了一次指针定位的时间开销,随着对象访问的频繁,这种开销积少成多很可观。