JVM初探 -JVM内存模型

JVM是每一个Java开发天天都会接触到的东西, 其相关知识也应该是每一个人都要深刻了解的. 但接触了不少人发现: 或了解片面或知识体系陈旧. 所以最近抽时间研读了几本评价较高的JVM入门书籍, 算是总结于此. 本系列博客的主体来自 深刻理解Java虚拟机(第二版)实战Java虚拟机 两部书, 部份内容参考 HotSpot实战深刻理解计算机系统 以及网上大量的文章. 若文内有引文未注明出处的, 还请联系做者修改.html


JVM 虚拟机架构(图片来源: 浅析Java虚拟机结构与机制)java

JVM 内存区域

JVM会将Java进程所管理的内存划分为若干不一样的数据区域. 这些区域有各自的用途、建立/销毁时间:程序员


(图片来源: JAVA的内存模型及结构)算法

一. 线程私有区域

线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束而建立/销毁(在Hotspot VM内, 每一个线程都与操做系统的本地线程直接映射, 所以这部份内存区域的存/否跟随本地线程的生/死).数组

1. Program Counter Register(程序计数器):

一块较小的内存空间, 做用是当前线程所执行字节码的行号指示器(相似于传统CPU模型中的PC), PC在每次指令执行后自增, 维护下一个将要执行指令的地址. 在JVM模型中, 字节码解释器就是经过改变PC值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都须要依赖PC完成(仅限于Java方法, Native方法该计数器值为undefined).
不一样于OS以进程为单位调度, JVM中的并发是经过线程切换并分配时间片执行来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令. 所以, 为了线程切换后能恢复到正确的执行位置, 每条线程都须要有一个独立的程序计数器, 这类内存被称为“线程私有”内存.架构

2. Java Stack(虚拟机栈)

虚拟机栈描述的是Java方法执行的内存模型: 每一个方法被执行时会建立一个栈帧(Stack Frame)用于存储局部变量表操做数栈动态连接方法出口等信息. 每一个方法被调用至返回的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程(VM提供了-Xss来指定线程的最大栈空间, 该参数也直接决定了函数调用的最大深度).并发

  • 局部变量表(对应咱们常说的‘堆栈’中的‘栈’)存放了编译期可知的各类基本数据类型(如boolean、int、double等) 、对象引用(reference : 不等同于对象自己, 多是一个指向对象起始地址的指针, 也可能指向一个表明对象的句柄或其余与此对象相关的位置, 见下: HotSpot对象定位方式) 和 returnAddress类型(指向一条字节码指令的地址). 其中longdouble占用2个局部变量空间(Slot), 其他只占用1个. 以下Java方法代码可使用javap命令或javassist等字节码工具读到:
public String test(int a, long b, float c, double d, Date date, List<String> list) {
    StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date);

    for (String str : list) {
        sb.append(str);
    }

    return sb.toString();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

注: javap/javassist读到的实际上是静态数据, 而局部变量表内存储的倒是运行时动态加载的动态数据, 但由于局部变量表所需的内存空间在编译期间便可完成分配, 当进入一个方法时, 这个方法须要在帧中分配多大的局部变量空间是彻底肯定的,在方法运行期间大小不会改变, 所以能够在概念上认定这两部份内容存储的数据格式相同.app

3. Native Method Stack(本地方法栈)

Java Stack做用相似, 区别是Java Stack为执行Java方法服务, 而本地方法栈则为Native方法服务, 若是一个VM实现使用C-linkage模型来支持Native调用, 那么该栈将会是一个C栈(详见: JVM学习笔记-本地方法栈(Native Method Stacks)), 但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一.函数

二. 线程共享区域

随虚拟机的启动/关闭而建立/销毁.工具

1. Heap(Java堆)

几乎全部对象实例和数组都要在堆上分配(栈上分配、标量替换除外), 所以是VM管理的最大一块内存, 也是垃圾收集器的主要活动区域. 因为现代VM采用分代收集算法, 所以Java堆从GC的角度还能够细分为: 新生代(Eden区From Survivor区To Survivor区)和老年代; 而从内存分配的角度来看, 线程共享的Java堆还还能够划分出多个线程私有的分配缓冲区(TLAB). 而进一步划分的目的是为了更好地回收内存和更快地分配内存.

2. Method Area(方法区)

即咱们常说的永久代(Permanent Generation), 用于存储被JVM加载的类信息常量静态变量即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot的垃圾收集器就能够像管理Java堆同样管理这部份内存, 而没必要为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收类型的卸载, 所以收益通常很小)

不过在1.7的HotSpot已经将本来放在永久代的字符串常量池移出:

而在1.8中, 永久区已经被完全移除, 取而代之的是元数据区Metaspace(这一点在查看GC日志和使用jstat -gcutil查看GC状况时能够观察到),与永久代不一样, 若是不指定Metaspace大小, 若是方法区持续增加, VM会默认耗尽全部系统内存.

  • 运行时常量池
    方法区的一部分. Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项常量池(Constant Pool Table)用于存放编译期生成的各类字面量和符号引用, 这部份内容会存放到方法区的运行时常量池中(如前面从test方法中读到的signature信息). 但Java语言并不要求常量必定只能在编译期产生, 即并不是预置入Class文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 如Stringintern()方法.

三. 直接内存

直接内存并非JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可使用Native函数库直接分配堆外内存, 而后使用DirectByteBuffer对象做为这块内存的引用进行操做(详见: Java I/O 扩展), 这样就避免了在Java堆和Native堆中来回复制数据, 所以在一些场景中能够显著提升性能.
显然, 本机直接内存的分配不会受到Java堆大小的限制(即不会遵照-Xms、-Xmx等设置), 但既然是内存, 则确定仍是会受到本机总内存大小及处理器寻址空间的限制, 所以动态扩展时也会出现OutOfMemoryError异常.

HotSpot对象

对象新建

  • new一个Java Object(包括数组和Class对象), 在JVM会发生以下步骤:

    1. VM遇到new指令: 首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个符号引用表明的类是否已被加载、解析和初始化过. 若是没有, 必须先执行相应的类加载过程.
    2. 类加载检查经过后: VM将为新生对象分配内存(对象所需内存的大小在类加载完成后即可彻底肯定), VM采用指针碰撞(内存规整: Serial、ParNew等有内存压缩整理功能的收集器)或空闲链表(内存不规整: CMS这种基于Mark-Sweep算法的收集器)方式将一块肯定大小的内存从Java堆中划分出来.
    3. 除了考虑如何划分可用空间外, 因为在VM上建立对象的行为很是频繁, 所以须要考虑内存分配的并发问题. 解决方案有两个:
      • 对分配内存空间的动做进行同步 -采用 CAS配上失败重试 方式保证更新操做的原子性;
      • 把内存分配的动做按照线程划分在不一样的空间之中进行 -每一个线程在Java堆中预先分配一小块内存, 称为本地线程分配缓冲TLAB, 各线程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB时才须要同步锁定(使用-XX:+/-UseTLAB参数设定).
    4. 接下来将分配到的内存空间初始化为零值(不包括对象头, 且若是使用TLAB这一个工做也能够提早至TLAB分配时进行). 这一步保证了对象的实例字段能够不赋初始值就直接使用(访问到这些字段的数据类型所对应的零值).
    5. 而后要对对象进行必要的设置: 如该对象所属的类实例如何能访问到类的元数据信息对象的哈希码对象的GC分代年龄等, 这部分息放在对象头中(详见下).
    6. 上面工做都完成以后, 在虚拟机角度一个新对象已经产生, 但在Java视角对象的建立才刚刚开始(<init>方法还没有执行, 全部字段还都为零). 因此new指令以后通常会(由字节码中是否跟随有invokespecial指令所决定-Interface通常不会有, 而Class通常会有)接着执行<init>方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算彻底产生出来.

对象存储布局

HotSpot VM内, 对象在内存中的存储布局能够分为三块区域:对象头、实例数据和对齐填充:

  • 对象头包括两部分:
    • 一部分是类型指针, 便是对象指向它的类元数据的指针: VM经过该指针肯定该对象属于哪一个类实例. 另外, 若是对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.

      注意: 并不是全部VM实现都必须在对象数据上保留类型指针, 也就是说查找对象的元数据并不是必定要通过对象自己(详见下面句柄定位对象方式).

    • 一部分用于存储对象自身的运行时数据: HashCodeGC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳等, 这部分数据的长度在32位和64位的VM(暂不考虑开启压缩指针)中分别为32bit和64bit, 官方称之为“Mark Word”; 其存储格式以下:
状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 执行重量级锁定的指针
GC标记 11 空(不须要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄
  • 实例数据部分是对象真正存储的有效信息, 也就是咱们在代码里所定义的各类类型的字段内容(不管是从父类继承下来的, 仍是在子类中定义的都须要记录下来). 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响. HotSpot默认的分配策略为longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers), 相同宽度的字段老是被分配到一块儿, 在知足这个前提条件下, 在父类中定义的变量会出如今子类以前. 若是CompactFields参数值为true(默认), 那子类中较窄的变量也可能会插入到父类变量的空隙中.
  • 对齐填充部分并非必然存在的, 仅起到占位符的做用, 缘由是HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍, 即对象的大小必须是8字节的整数倍.

对象定位

创建对象是为了使用对象, Java程序须要经过栈上的reference来操做堆上的具体对象. 主流的有句柄直接指针两种方式去定位和访问堆上的对象:

  • 句柄: Java堆中将会划分出一块内存来做为句柄池, reference中存储对象的句柄地址, 而句柄中包含了对象实例数据与类型数据的具体各自的地址信息:

  • 直接指针(HotSpot使用): 该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址:

这两种对象访问方式各有优点: 使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是很是广泛的行为)时只会改变句柄中的实例数据指针,而reference自己不变. 而使用直接指针最大的好处就是速度更快, 它节省了一次指针定位的时间开销,因为对象访问很是频繁, 所以这类开销积小成多也是一项很是可观的执行成本.

相关文章
相关标签/搜索