第二章 Java内存区域与内存溢出异常
2.1 运行时数据区
2.1.1 程序计数器
- 可以看做是当前线程所执行的字节码的行号指示器。
- 字节码解释器工作依赖计数器控制完成。
- 通过执行线程行号记录,让线程轮流切换各条线程之间计数器互不影响。
- 线程私有,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
- 线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址。
- 线程执行Native方法时,计数器记录为空(Undefined)。
- 唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。
2.1.2 Java虚拟机栈
- 虚拟机栈描述的Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用于存储
局部变量表
、操作数栈
、动态链接
、方法出口
等信息。 - 每个方法从调用直至执行完的过程,对应着一个栈帧在虚拟机中入栈到出栈的过程。
- 局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型。
对象引用:不等同于对象本身,可能是一个 对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。
returnAddress:指向一条字节码指令地址。
- 当线程请求栈深度超过java虚拟机的深度,将抛出StackOverflowError异常。
- 如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
2.1.3 本地方法栈
- 与Java虚拟机栈类似,不过虚拟机栈为执行java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用的Native方法服务。
- 也会抛出StackOverflowError异常和OutOfMemoryError异常。
2.1.4 Java堆
- 所有线程共享的一块内存区域。Java虚拟机所管理的内存中最大的一块,因为该内存区域的唯一目的就是存放对象实例。几乎所有的对象实例都在这里分配内存。
- 堆也是垃圾收集器管理的主要区域。因此很多时候被称为"GC堆"。
- 如果堆中没有内存可以用来完成实例分配并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
2.1.5 方法区
- 线程共享区域。
用于存储一杯虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
。- 相对而言,垃圾收集行为在这个区域比较少出现。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载上。
2.1.6 运行时常量池
- 运行时常量池属于方法区。
- Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量表,用于存放编译期生成的各种字面常量和符号引用,这部分内容将在类加载后进入方法区的
运行时常量池
中存放(JDK1.7开始,常量池已经被移到了堆内存中了)。
也就是说,这部分内容,在编译时只是放入到了常量池信息中,到了加载时,才会放到运行时常量池中去。
- 运行时常量池归于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的是String类的intern()方法。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常,常量池属于方法区,同样可能抛出OutOfMemoryError异常。
内存区域 | 线程私有 | 主要作用 | 溢出异常 |
---|---|---|---|
程序计数器 | 是 | 记录当前线程执行的位置 | 无异常 |
虚拟机栈 | 是 | 存储局部变量表(基本数据类型,对象的引用和returnAddress类型)、操作数栈、动态链接、方法出口等信息(java方法) | StackOverflowError和OutOfMemoryError |
本地方法栈 | 是 | 和虚拟机栈相似,区别本地方法栈为虚拟机使用到的Native方法服务 | StackOverflowError和OutOfMemoryError |
堆 | 否 | 存放对象实例和数组 | OutOfMemoryError |
方法区 | 否 | 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 | OutOfMemoryError |
2.1.7 直接内存
不属于虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分也被频繁的调用。
在JDK1.4中新加了NIO(new Input/Output)类,使用Native函数库直接分配堆外内存,然后通过Java堆中的一个DirectByteBuffer对象作为这块内存的引用进行操作。
2.2 HotSpot虚拟机对象探秘
2.2.1 对象的创建
- 虚拟机遇到一条new指令时,首先检查这条指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用类是否已经加载、解析和初始化过。如果没有还需要执行相应的类加载过程。
- 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
在分配内存时,如果对象频繁使用,在并发的情况下,仅仅修改指针所指向的位置也不安全。可能出现正在给A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题的方案:
1.对分配内存空间的动作进行同步处理—在虚拟机中采用CAS配上失败重试的方式保证更新操作的原子性;
2.把内存分配的动作按照线程划分在不同的空间进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲。
- 内存分配完后虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
如果是使用线程分配缓冲TABLE,可以提前就在TABLE分配时进行。这一操作保证了对象的实例在Java代码中可以不赋初值就可以直接使用。
- 虚拟机对对象进行必要的设置。
例如这个对象来自于哪个类的实例,如何才能找到类的源数据信息、对象的哈希码、对象的GC分代年龄等信息。
对于虚拟机来说,完成上述过程已经完成了对象的创建,但是对于Java程序来说,对象创建才刚刚开始,还没有执行init方法,一般来说执行new方法只会就会接着执行init方法,把对象按照程序员的意愿进行初始化。
2.2.2 对象的内存布局
HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(暂不考虑开启压缩指针的场景)中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25Bits 用于存储对象哈希码(HashCode),4Bits 用于存储对象分代年龄,2Bits 用于存储锁标志位,1Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表所示。
HotSpot 虚拟机对象头 Mark Word
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC 标记 |
偏向线程 ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
对象头的另外一部分是类型指针,即是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身,这点我们在下一节讨论。另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。
2.2.3 对象的访问定位
建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄
和直接指针
两种。
如果使用句柄访问的话,Java 堆
中将会划分出一块内存来作为句柄池
,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。如图下图所示。
如果使用直接指针访问的话,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如下图所示。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在 Java 中非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。从上一部分讲解的对象内存布局可以看出,就虚拟机 HotSpot 而言,它是使用第二种方式进行对象访问,但在整个软件开发的范围来看,各种语言、框架中使用句柄来访问的情况也十分常见。