运行时数据区域
程序计数器
- 可以看作是当前线程所执行的字节码的行号指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器来完成。
- Java虚拟机的多线程通过线程轮流切换并分配处理器执行时间来实现,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间的计数器互不影响。因而程序计数器是线程隔离的。
- 程序计数器是唯一一个在Java虚拟机规范中没有规定任OutOfMemoryError情况的区域。
Java虚拟机栈
- 虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。是线程隔离的。
- 局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
- Java虚拟机规范对Java虚拟机栈规定了两种异常:
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
- OutOfMemoryError:虚拟机栈动态拓展时无法申请到足够的内存。
本地方法栈
- 与虚拟机栈作用相似,区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,本地方法栈为虚拟机使用到的Native方法服务。
- Sun HotSpot虚拟机将本地方法栈和虚拟机栈合二为一。
- 也会抛出StackOverflowError和OutOfMemoryError
Java堆
- Java堆被所有线程共享,在虚拟机启动时创建。唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。
- Java堆是垃圾收集器管理的主要区域,因此也被称作“GC堆“。从内存回收角度看可以细分为新生代和老年代。从内存分配角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
- Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。主流的虚拟机Java堆是可扩展的(通过-Xmx和-Xms控制)。如果堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出OutOfMemoryError。
方法区
- 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java虚拟机规范把方法区描述为堆的一个逻辑部分。
- 相对而言方法区的垃圾收集行为较少出现,该区域内存回收目标主要是针对常量池的回收和对类型的卸载。
- 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
- 运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池具有动态性,Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,用的比较多的是String类的intern()方法。
直接内存
- 直接内存不是虚拟机运行时数据区的一部分,但这部分内存也被频繁使用,也可能导致OutOfMemoryError异常。其不会受到Java堆大小的限制,但会受到本机总内存大小以及处理器寻址空间的限制。实际中经常忽略直接内存导致各个区域内存总和大于物理内存限制导致动态拓展时出现OutOfMemoryError异常。
HotSpot虚拟机的Java堆
对象的创建
- 类加载检查:虚拟机遇到一条new指令时,首先检查该指令参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有以下两种:
指针碰撞:假设Java堆中内存是绝对规整的,用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存时把指针像空闲空间那边挪动一段与对象大小相等的距离。
空闲列表:假设Java堆中内存不是规整的,已使用内存和空闲内存相互交错,虚拟机必须维护一个列表记录哪些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例并更新列表记录。
选择哪种分配方式取决于Java堆是否规整,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此使用Serial、ParNew等带Compact过程的收集器时,系统采用指针碰撞进行分配。使用CMS基于Mark-Sweep算法的收集器时,通常采用空闲列表。
对象创建在虚拟机中非常频繁,在并发情况下保证线程安全的两种方式如下:
对分配内存空间的动作进行同步处理:采用CAS配上失败重试的方式保证更新操作的原子性。
TLAB:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB可以通过-XX:+/-UseTLAB参数来设定。 - 内存空间初始化:虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB这一工作过程也可以提前至TLAB分配时进行。这一步操作保证对象实例字段在Java代码中可以不赋初始值就直接使用。
- 设置:虚拟机对对象进行必要设置,譬如对象是哪个类的实例、如何能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头(Object Header)之中。
上面工作都完成后,从虚拟机的角度看一个新的对象已经产生了。但从Java程序来看,对象创建才刚开始。执行new指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化。
对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为3个区域:对象头、实例数据和对齐填充。
对象头:包含两部分信息:
第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的对象。另外如果对象是Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。实例数据部分:对象真正存储的有效信息,无论从父类继承下来的还是在子类中定义的,都需要记录下来。分配策略为相同宽度的字段总是被分配到一起,父类中定义的变量会出现在子类之前。
- 对齐填充:并不是必然存在的。起着占位符的作用。HotSpot虚拟机内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。对象头部分正好是8字节的倍数(32位、64位两种),因此当对象实例数据部分没对齐时,就需要通过对齐填充来补全。
对象的访问定位
目前主流的访问方式有使用句柄和直接指针两种:
- 句柄访问:Java堆中额外划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 直接指针访问:Java堆对象的布局中放置访问类形数据的相关信息,reference中存储的直接就是对象地址。
使用句柄的好处是对象被移动时(GC时很普遍)只会改变句柄中的实例数据指针,而reference本身不需要修改。直接指针访问速度更快,节省了一次指针定位的时间开销。HotSpot虚拟机使用直接指针的方式对对象进行访问。