概述
JVM会将Java进程所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途、创建 、销毁时间。
对象和类的数据存储在3个不同的内存区域,堆、方法区、本地区。
堆内存存放对象以及数组的数据,方法区存放类的信息(包括类名、方法、字段)、静态变量、编译器编译后的代码,本地区包含线程栈、本地方法栈等存放线程。所有对象在实例化后的整个运行周期内,都被存放在堆内存中,堆内存又被划分为不同的部分:Eden,Survivor,老年代。方法的执行都是伴随这线程的。原始类型的本地变量以及引用都存放在线程栈中,而引用关联的对象存放在堆中。
线程私有区域
线程私有数据区域生命周期与线程相同,依赖用户线程的启动/结束而创建/销毁(在Hotspot VM内,每个线程都与操作系统的本地变量直接映射,因此这部分内存区域的存/否跟随本地线程的生/死)。
1、Program Counter Register(程序计数器)
一块较小的内存空间,作用是当前线程锁执行字节码的行号指示器(类似于传统CPU模型中的PC),PC在每次指令执行后自增,维护下一个将要执行指令的地址。在JVM模型中,字节码解释器就是通过改变PC值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖PC完成(仅限于Java方法,native方法该计数器值为undefined)。
不同于OS以进程为单位调度,JVM中的并发是通过线程切换并分配时间片执行来实现的。在任何一个时刻,一个处理器内核只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确位置,每条线程都需要一个独立的程序计数器,这类内存被称为“线程私有”内存。
2、Java Stack(虚拟机栈)
虚拟机栈描述的是Java方法执行的内存模型。每个方法被执行时会创建一个栈帧用于存储局部变量表,操作数栈、动态链接、方法出口等信息。每个方法被调用至返回的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表
存放了编译期可知的各种数据类型、对象引用(可能是一个指向对象起始地址的指针,也可能指向一个代表对象的句柄或其他对象相关的位置)和returnAddress类型(指向一条字节码的地址)。
运行环境区
在运行环境中包含的信息可以实现动态链接、正常的方法返回与异常和错误传播。
动态链接
运行环境包括对指向当前类和当前方法的解释器符号表的指针,用于支持方法代码的动态链接。方法的class文件代码在引用要调用的方法和要访问的变量时使用符号。动态链接把符号形式的方法调用翻译成实际方法调用,装载必要的类以解释还没有定义的符号,并把变量访问翻译成与这些变量运行时的存储结构相应的偏移地址。动态链接方法和变量使得方法中使用的其他类的变化不会影响到本程序的代码。
正常的方法返回
如果当前方法正常地结束了,在执行了一条具有正确类型的返回指令时,调用的方法会得到一个返回值。执行环境在正常返回的情况下用于恢复调用者的寄存器,并把调用者的程序计数器增加一个适当的数值,以跳过已执行过的方法调用指令,然后在调用者的执行环境中继续执行下去。
异常和错误传播
异常情况在Java中被称为Error(错误)或Expection(异常),是Throwable类的子类,在程序中的原因主要是动态链接错或运行时错造成。
当发生异常时,Java虚拟机采用如下措施解决:
- 检查当前方法相联系的catch子句表。每个catch子句包含其有效指令范围,能够处理异常类型,以及处理异常的代码块地址。
- 与异常相匹配的catch子句应该符合下面条件:造成异常的指令在其指令范围内,发生的异常类型是其能处理的异常类型的子类型。如果找到了匹配的catch子句,那么系统将转移到指定的异常处理块处执行。如果没有找到异常代码块,则重复寻找匹配的catch子句的过程,直到当前方法的所有嵌套的catch子句都被检查过。
- 由于虚拟机从第一个匹配的catch子句处继续执行,所以catch子句表中的顺序是很重要的。因为Java代码是结构化的,因此总可以把某个方法中所有的异常处理器都按顺序列到一个表中,对任意可能的程序计数器的值,都可以用线性的顺序找到合适的异常处理块,以处理在该程序计数器值下发生的异常情况。
- 如果找不到匹配的catch子句,那么当前方法得到一个“未截获异常”的结果并返回到当前方法的调用者,好像异常刚刚在其调用者中发生一样。如果在调用者中仍然没有找到相应的异常处理块,那么这种错误传播将被继续下去。如果错误被传播到最顶层,那么系统将调用一个缺省的异常代码块。
操作数栈区
机器指令只能从操作数栈中取操作数,对它们进行操作,并把结果返回到栈中。选择栈结构的原因:在只有少量寄存器或非通用寄存器的机器上,也能够高效地模范虚拟机的行为。操作数栈是32位的,用于给方法传递参数,并从方法接收结果,也用于支持操作的参数,并保存操作的结果。
3、Native Method Stack(本地方法栈)
与Java Stack作用类似,区别是Java Stack为执行Java方法服务,而本地方法栈则为native方法服务,如果一个VM实现使用C-linkage模型来支持native调用,那么该栈将会是一个C栈。
线程共享区域
线程共享数据区域生命周期与虚拟机相同,随着虚拟机的启动/关闭而创建/销毁。
1、Heap(Java堆)
几乎所有对象实例和数组都要在堆上分配(栈上分配、标量替换除外),因此是VM管理的最大的一块内存,也是垃圾收集器的主要活动区域。由于现代VM采用分代收集算法,因此Java堆从GC的角度还可以细分为:新生代和老年代;而从内存分配的角度来看,线程共享的Java堆还可以划分出多个线程私有的分配缓冲区。而进一步划分的目的是为了更好地回收内存和更好地分配内存。
2、Method Area(方法区)
即我们常说的永久代,用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Hotspot VM把GC分代收集扩展至方法区,即使用Java堆的永久代来实现方法区,这样Hotspot的垃圾收集器就可以向管理Java堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久代的内存回收的主要目标是对常量池的回收和类型的卸载,因此收益一般很小)。
在Java1.7时已经将放在永久代的字符串常量池移出,而在Java1.8中,永久代已经被彻底移除,取而代之的是元数据区Meta Space,与永久代不同,如果不指定Meta Space大小,如果方法区持续增长,VM会默认消耗所有系统内存。
运行时常量池
方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项常量池用于存放编译期生成的各种字面量和符号引用。这部分内容会存放到方法区的运行时常量池。但Java语言并不要求常量一定只能在编译期产生,即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。
对象存储布局
Hotspot VM内,对象在内存中的存储布局可以划分为3块区域:对象头、实例数据和对齐填充。
对象头包括两部分:
一部分是指针类型,即是对象指向它的类元数据的指针:VM通过该指针确定该对象属于哪个类实例。另外,如果对象是一个数组,那在对象头中还必须有一块数据用于记录数组长度。
另一部分用于存储对象自身的运行时数据:HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的VM中分别为32bit和64bit,官方称之为“Mark Word”,其存储格式如下:
| 状态 | 标志位 | 存储内容 |
| ——— | —- | ——————- |
| 未锁定 | 01 | 对象哈希吗、对象分代年龄 |
| 轻量级锁定 | 00 | 指向锁记录的指针 |
| 膨胀(重量级锁定) | 10 | 执行重量级锁定的指针 |
| GC标志 | 11 | 空(不需要记录信息) |
| 可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
实例数据部分是对象真正存储的有效信息,也就是我们在代码里所定义的各种类型的字段内容(无论是从父类继承下来的,还是在子类中定义的都需要记录下来)。这部分的存储顺序会收到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。Hotspot默认的分配策略为longs / doubles、ints、shorts / chars、bytes / booleans、oops相同宽度的字段总是被分配到一起,在满足这个前提条件下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认),那子类中较窄的变量也可能会插入到父类变量的空隙中。
对齐填充部分并不是必然存在的,仅起到占位符的作用,原因是Hotspot自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。
对象定位
建立对象是为了使用对象,Java程序需要通过栈上的reference来操作堆上的具体对象。主流的有句柄和直接指针两种方式去定位和访问堆上的对象。
句柄:Java堆中将会划分出一块内存来作为句柄池,reference中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。
直接指针:该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址。
这两种对象访问方式各有优势:使用句柄来访问的最大好处是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不变。而使用直接指针最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。
对象创建
当我们new一个Java Object(包括数组和Class对象),在JVM会发生如下步骤:
- VM遇到new指令:首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化,如果没有,必须先执行相应的类加载过程
- 类加载检查通过后:VM将为新生对象分配内存(对象所需的内存的大小是在类加载完成后便可以完全确定),VM采用指针碰撞或空闲链表方式将一块确定大小的内存从Java堆中划分出来
- 除了考虑如何划分可用空间外,由于在VM上创建对象的行为比较频繁,因此需要考虑内存分配的并发问题,解决方案有两个:
- 对分配内存空间的动作进行同步。即采用CAS配上失败重试方式保证在更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行。每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲TLAB,各线程首先在TLAB上分配,只有TLAB用完,分配新的TLAB时才需要同步锁定
- 接下来将分配到内存空间初始化为0值。这一步保证了对象的实例字段可以不赋初值就直接使用
- 然后要对对象进行必要的设置:如该对象所属的类实例、如何能访问到类的元数据信息、对象的哈希码、对象的GC分代年龄等,这部分信息放在对象头中
- 上面工作都完成之后,在虚拟机角度一个新对象已产生,但在Java视角对象的创建才刚刚开始(< init >方法尚未执行,所有字段还都为0)。所以new指令之后一般会接着执行< init >方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来