深入理解Java虚拟机-第二章 Java内存区域与内存溢出异常

第二章 Java内存区域与内存溢出异常

2.1 概述

2.2 运行时数据区域

2.2.1 程序计数器

程序计数器在Java内存模型中是一个较小的内存空间,作用是记录程序所执行的字节码的行数,就是说告诉CPU下一个语句该执行什么了。而字节码解释器工作时就是通过更改这个值来选取下一条应该执行的指令。
因为Java可以多线程编程,而所谓的多线程实际上是CPU快速切换多个线程实现的,即每个线程都被分配了极短的时间片,看上去像是并发执行。这样就带来一个问题,就是A线程执行完后,怎么知道B线程当前执行到哪了。从头再来肯定是不现实的,这时程序计数器就显示了它的作用。所以为了保持多个线程之间记录的行数不冲突,程序计数器是“线程私有”的内存,仅记录本线程当前执行的行数。
程序计数器是唯一一个在Java虚拟机规范中没有规定任何OOM(OutOfMemoryError)的内存区域

2.2.2 Java 虚拟机栈

Java 虚拟机栈实际上就是普遍意义上的 “堆栈”二字中的“栈”字。他描述的是一个 Java 方法执行的内存模型。内部存储包括操作数栈、动态链接、局部变量表、方法出口等信息。每当一个方法开始执行,则有一个栈帧入栈。相应的,每完成一个方法,则该方法的栈帧出栈。
局部变量表中存放了编译器可知的各类基本类型(int、long等)、对象引用和 returnAddress 类型(对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针)。其中 long 类型和 double 类型这种32位长度的类型占2个局部变量空间,其余的都是一个。说这个的目的是局部变量表所需的空间在编译期间就完成分配,在方法执行期间是不会更改的。也就是说当进入一个方法时,这个方法需要在栈帧中分配多少的局部变量空间是完全确定的。
在 Java 虚拟机规范中,针对这个区域有两种异常情况。一种是栈帧太多,超过了虚拟机栈的最大允许长度(例如无限递归),此时会抛出 StackOverflowError 。当然这种情况是针对栈深度固定来说的,当前大部分的 Java 虚拟机都可以动态扩展,即可无限深(栈长度无限扩大),但是当申请不到足够内存的时候,就会抛出 OOM 。

2.2.3 本地方法栈

同虚拟机栈所发挥的作用是非常相似的,不过是说本地方法栈是专门给Native方法用的。有的虚拟机直接就把这两个合二为一了,这里并不做强制规定。同理,这里也会抛出 OOM 和 StackOverflowError 。

2.2.4 Java 堆

上面刚讲了“堆、栈”中的栈,本节就说下堆。这里说的堆实际上是一个概念性的东西,堆里面还有很多东西,可以说堆是 JVM 所管理的内存中最大的一块,堆在虚虚拟机启动时创建。堆存在的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。JVM规范中是如此描述的:

The heap is the runtime data area from which memory for all class instances and arrays is allocated(堆是运行时数据区,从中分配所有类实例和数组的内存)

堆再细分又可以分为新生代、老年代,新生代又可以分为 Eden 区,From Survivor 区和 To Survivor 区等。堆是被所有线程共享的,Java堆中可能划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB)。不过无论如何划分,存储的东西不变,都仍然是对象实例。划分的目的是为了更好的垃圾回收或更快的分配内存。
JVM规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时即可以实现成固定大小的,也可以是可扩展的(主流JVM都是按照可扩展来实现的,通过 -Xmx 和 -Xms 控制)。如果在堆中已经没有内存给实例进行分配了,并且堆也没办法扩展的时候,将会抛出OOM。

2.2.5 方法区

所谓方法区,其实就是我们平时所说的永久代,但是这个永久代仅在HotSpot虚拟机上有,JVM规范中的正规名称还是叫方法区。方法区是干啥的呢,它实际上就是存储已被虚拟机加载的类信息、常量(static final)、静态变量(static)、即时编译器编译后的代码等数据。它跟堆一样,是各个线程共享的内存区域。JDK8 将其彻底移除,更迭为 Metaspace(元空间)。

2.2.6 运行时常量池

运行时常量池是方法区的一部分。前文说过方法区内存储着常量、静态变量等类信息,而这些数据有一部分就存在运行时常量池中。具体存了些啥呢,存的编译器生成的各种字面量 (Literal) 和符号引用量 (Symbolic References)。说的很玄乎,字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了 类和接口的全限定名、字段名称和描述符、方法名称和描述符 三种类型的常量。因为是方法区的一部分,所以它天然的受到方法区内存的限制,当常量池无法申请到内存时,会抛出 OOM 。
运行时常量池相对 Class 文件常量池的另外一个重要的特性就是动态性。Java并不要求所有常量一定要在编译期才能生成,也就是说不是只有事先放入Class文件常量池中的常量才能够在类加载的时候放到运行时常量池中。在程序运行的过程中也可以有常量被置入,例如 String 的 intern() 方法。这里先借用别人家博客一用,抽空我再专门写一个,具体介绍请戳我
具体方法区和常量池有几个博客讲的非常好,这里也贴上链接如下:

https://blog.csdn.net/wangbiao007/article/details/78545189
http://www.pianshen.com/article/7368729217/
https://www.cnblogs.com/xiaotian15/p/6971353.html

2.2.7 直接内存

直接内存其实并不属于JVM运行时内存的一部分,它属于用户通过 Native 方法直接在计算机内存中分配的一块堆外内存区域,并通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用直接进行操作。这样可以在某些场景显著的提高性能(因为避免了 Java 堆和 Native 堆之间的 Copy 工作),但是也有隐患。因为用户在配置内存大小时可能会忽略这一部分,而使得配置的内存总和大于物理内存,导致在动态扩展时因为申请不到内存而造成OOM。

2.3 HotSpot 虚拟机对象探秘

2.3.1 对象的创建

本节主要讲述了普通对象创建(new)的整个大体流程,简述如下:

  1. 预检查,先检查这个指令的参数是否能在常量池(方法区中)中定位到一个类的符号引用,并且检查是否已经加载、解析和初始化过。没有的话就执行类加载过程,有的话进行下一步
  2. 检查过后需要给对象实例分配内存空间,即将一块完全大小完全确定(在类加载的过程中确认大小)的空间从 Java 堆中划分出来。这里有两种方式分别称为“指针碰撞(Bump the Pointer)”和“空闲列表(Free List)”。为什么分开呢,这取决于划分的堆空间是否规整。如果是规整连续的,中间有个指针,一边是空闲一边是用过的。那么划分一个空间只需要挪动指针即可,这叫指针碰撞。如果是非规整的,那么就需要有个表记录哪些空间是空闲的,等分配的时候,在列表上找一个大小合适的空闲空间分配给对象使用并更新列表的内容,这种叫空闲列表。而空间的规整连续与否,取决于垃圾收集器是否带有压缩整理算法,有压缩的就用指针碰撞,没压缩就用空闲列表。
    分配的时候还需要考虑一个问题就是并发执行,如果正在给A分配内存时,B过来也要分配。不妥善处理极容易出现安全性问题。这里有两种解决方案,第一种是虚拟机采用 CAS(Compare and Swap)配合失败重试机制,保证分配空间这一操作的原子性。另一种则是前文提到的 TLAB ,即给每个线程分配一个本地线程栈分配缓冲区。哪个线程需要分配时,就在自己的TLAB中分配,这样仅在申请 TLAB 时保证安全性即可。
  3. 分配空间后需要将分配的空间清空,初始化为0值。这步操作保证了对象值初始化后无需赋初始值即可使用。此操作可提至申请 TLAB 分配时进行。
  4. 分配、初始化内存空间完毕后,虚拟机要对对象进行必要的设置,例如这个是哪个类的实例、元数据信息、对象GC年龄等,这些信息统统被设置入对象头里。
  5. 前四步操作后,针对虚拟机的对象初始化完毕了,但是从Java程序的视角来看,初始化才刚刚开始。此时执行完对象的 init 方法(如构造方法、为成员变量设置初始化值等)后,对象才算真正创建完毕。
2.3.2 对象的内存布局

本节主要以HotSpot虚拟机为例讲了对象在内存中的布局,主要分为三个:对象头、实例数据、对齐填充
首先是对象头,对象头主要分为两部分。一部分是存储对象自身的运行时数据,例如锁信息、GC分代年龄等,又被称为Mark Word
对象运行时的数据其实很多,远大于规范所涉及的32位和64位,所以此处对象头不固定数据结构,可重用空间来存储尽量多的信息。具体存储内容如下表所示(64位虚拟机)。网上搜罗了一篇Mark Word详解的文章,感觉甚好,有想深入了解的,请戳链接:https://blog.csdn.net/dufufd/article/details/81985236

偏向锁标识位锁标识位锁状态存储内容
001未锁定hash code(31),年龄(4)
101偏向锁线程ID(54),时间戳(2),年龄(4)
00轻量级锁栈中锁记录的指针(64)
10重量级锁monitor的指针(64)
11GC标记空,不需要记录信息

对象头的另一部分存储的是类型指针,即对象指向他类元数据(类元数据放哪里来着,对,就是方法区)的指针。虚拟机通过指针来确定这个对象实例属于哪个类。
如果对象是一个数组,那么对象头中需要有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据确认对象大小,但数组的元数据不行
第二部分就是实例数据,这部分主要记录的是对象的字段内容。无论是从父类继承下来的还是在子类中定义的。
第三部分对其填充并不是必然存在的。因为HotSpot VM 的自动内存管理系统要求对象大小必须是8的倍数(而恰巧对象头的大小正好是8字节的倍数),如果对象实例数据部分没有对齐的话,就需要通过对其补充来补全。

2.3.3 对象的访问定位

对象访问方式目前主要有两种,一种是句柄访问,另一种则是直接访问(目前 HotSpot 所用的即是直接访问)。
句柄访问其实就是在堆中再维护一个句柄池,里面放的是一个个句柄。每个句柄存两个东西,一个是指向堆里面对象实例的指针,还有一个是指向方法区里类的元信息的指针。这样栈中的引用存的就是句柄地址,访问时通过句柄访问实例。
直接访问就是Java栈中引用直接存堆中的实例地址,访问的时候直接访问实例。不同的是,通过这样访问的话,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息(HotSpot 放在了实例的对象头中)。
两种访问方式各有优劣,例如句柄访问,当对象移动的时候(垃圾回收时移动对象是非常普遍的),仅需维护句柄池即可,针对引用来说无需关心对象变化,只需要知道句柄地址即可。但是这种访问增加了一次指针定位的时间(句柄地址找句柄)。一次两次无所谓,但是在Java中对象访问是非常频繁的,这种开销积少成多也是非常可观的,而直接访问的最大优势就是快。

Question & Answer

Q1:方法如何访问对象
A:访问对象时,通过 Java 虚拟机栈的栈帧中局部变量表锁所记录的对象引用找到对象实例进行操作。

Q2:类中的成员变量引用放在哪
A:类里面的成员变量信息,统一在实例化后存在对象的实例数据中,在方法执行的时候,将这个成员变量引用放入栈帧中的局部变量表中,再通过操作数栈进行使用。

Q3:类中的常量存在哪
A:类中的常量存在方法区的运行时常量区里。

本章基本为开篇,所以很多东西都是简单的一笔带过(例如操作数栈、动态链接等),在后续的学习博客中会详细写出。

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值