JVM(二)—— Java内存区域与内存溢出异常

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/weixin_43635659/article/details/102692360

Java内存区域与内存溢出异常

2.1 运行时数据区

  • Java虚拟机在运行Java程序时会把它所管理的内存分配成不同的数据区,每个数据区都有自己的生存和销毁时间,有的是依赖于线程的创建和销毁,有的是依赖于虚拟机的启动和终止。
  • 运行时数据区分为Java堆、虚拟机栈、本地方法栈、方法区、程序计数器。
    Java运行时数据区

2.1.1 程序计数器

  • 程序计数器是一小块内存空间,也称之为当前线程执行字节码的行号指示器。字节码解释器在执行字节码指令的时候通过更改程序计数器的值来确定下一行执行的指令。
  • Java中多线程的执行是通过线程轮流切换和分配处理器时间来实现的。当执行多线程程序时,一般一个处理器只能执行一条线程,当切换到不同的线程时,每个线程需要有独立的程序计数器来记录执行的指令行号,相互独立,所以程序计数器是线程私有的内存区域
  • 当执行Java方法时,程序计数器的值是执行字节码指令的地址;执行Native方法时,程序计数器的值是null。
  • 程序计数器是运行时数据区唯一在Java虚拟机规范中规定的OutOfMemoryError异常区域

2.1.2 虚拟机栈

  • 虚拟机栈是线程私有的。
  • 虚拟机栈描述的是Java方法执行的内存模型:每个Java方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接和方法出口等信息。每个方法的执行到结束的过程都是栈帧进入虚拟机栈和退出虚拟机栈的过程。
  • 局部变量表存储的是编译器可知的8中基本数据类型(Boolean、char、short、int、float、long、double)、对象应用(reference类型,这个指的不是对象本身,而是指向对象位置起始地址的引用指针,或者是指向对象的句柄和其他与此对象有关的位置)、returnAddress类型(字节码地址)
  • 基本数据类型的long和double类型都占有两个局部变量表空间,其余的都是占有一个局部变量表空间。局部变量表空间在编译期确定的,在运行期间是不可以更改空间的大小的。
  • 虚拟机栈会出现两种异常:一种是当线程请求的栈的深度大于当前虚拟机栈允许的深度,会出现StackFlowError异常;第二种是Java虚拟机栈一般是可动态扩展的,如果扩展时没有申请到足够的内存空间,会出现OutOfMemoryError异常。

2.1.3 本地方法栈

  • 本地方法栈是和虚拟机栈功能类似的栈,也是线程私有的。
  • 和虚拟机栈的区别:本地方法栈是为执行native方法服务的,虚拟机栈是为执行Java方法服务的。
  • 同样和虚拟机栈异常出现两种异常:StackFlowError异常和OutOfMemoryError异常。

2.1.4 Java堆

  • 堆是Java虚拟机内存种占有内存最大的一块区域,它是线程共享的,随着虚拟机的启动而创建,它的目的就是存储对象实例。
  • 堆是垃圾收集器主要负责管理的区域,所以又称之为“GC堆"
  • 堆内存的分配方式:
    • 根据内存回收方式,可以粗略分为新生代和老年代,又可以细分为Eden空间、From Survivor空间、To Survivor空间。
    • 根据内存分配方式,又可以将线程共享区域分出来多个线程私有的区域。
  • 无论是哪种分配方式,堆中存储的都是对象的实例,都是为了更好的回收和更快的内存分配。
  • 根据Java虚拟机规范,堆可以是内存中物理上不连续的空间,但是逻辑上必须是连续的。堆空间可以是固定的额,也可以是动态扩展的。
  • 如果对象创建的时候没有在堆中申请到足够的内存空间或者是堆扩展时没有申请到足够的空间,就会出现OutOfMemoryError异常。

2.1.5 方法区

  • 方法区是线程共享的,是用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译器编译的代码等数据。
  • 方法区被虚拟机规范为堆的一个逻辑部分,但是又称为非堆用来和堆相区分。
  • 对于习惯于HotSpot虚拟机上开发的程序员,往往把方法区称为永久代,本质上两者不等价,仅仅是因为HotSpot虚拟机设计团队选择GC分代收集扩展至方法区,或者说是用永久代来实现方法区。HotSpot的垃圾收集器可以像管理堆那样管理方法区,但是会出现内存溢出的异常。例如在执行String类的intern()方法。
  • 方法区可以是物理上不连续的,可以是固定大小或者是可扩展的,还可以选择不同的垃圾收集。
  • 一般对于方法区的垃圾收集主要是对常量的回收和类型的卸载,尤其是对类型的卸载。
  • 如果在申请扩展内存空间的时候,出现了空间不足,也会出现OutOfMemoryError异常。

2.1.6 运行时常量池

  • 运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法和描述信息外,还有一个就是常量池,用于存放编译期产生的字面量和符号引用,这部分将在类加载后进入方法区的运行时常量池。
  • 对于运行时常量池Java虚拟机没有严格的规范,一般来说除了保存Class文件中编译产生的字面量和符号引用,还会保存翻译出来的直接引用。
  • 运行时常量池的一个重要特征就是动态性,Java语言不要求每一个常量只有在编译期才会进入常量池,在运行时也会可能有新常量进入常量池中,例如String类的intern()方法
  • 当常量池无法申请到足够的内存空间也会产生OutOfMemoryError异常。

2.1.7 直接内存

  • 直接内存不属于Java虚拟机管理的内存,也不是Java虚拟机规范规定的内存,它属于本地内存,但是会频繁的使用。
  • JDK1.4之后加入了NIO类,一种基于通道与缓冲区之间I/O的方式,它可以使用native函数库直接分配堆外内存,它会在堆中存储一个DirectByteBuffer对象,作为对native堆的引用,这样就减少了java堆和native堆中的数据复制。
  • 直接内存的分配不会受Java堆的大小的限制,但是会受到本地总内存大小以及处理器寻址空间的限制,如果是服务器管理员在配置虚拟机参数时,忽略了直接内存,使得总内存大于物理内存,则会导致OutOfMemoryError异常。

2.3 HotSpot对象探秘

2.3.1 对象的创建

  • 对象是如何创建的?
    • 首先当虚拟机遇到一个new指令时,会检查new指令的参数能都在常量池中找到对应的符号引用,以及符号引用对应的类是否已经完成了加载、解析和初始化。如果没有那么将进行类加载过程。
    • 在完成类加载过程之后,就是在堆中进行对象内存分配,对象内存大小在编译期可知,只需要在堆中划出一块内存大小和对象内存大小一致的区域即可。堆内存分配方式有两种:“指针碰撞”和“空闲列表”。决定使用哪种分配方式取决于管理堆的垃圾收集器是否带有压缩处理的功能。如果是使用Serial、ParNew等带有Compact功能的GC,那么使用指针碰撞。如果是使用CMS等带有Mark-Sweep功能GC,那么使用空闲列表方式。
      • 指针碰撞:假设Java堆是一块连续完成的区域,左边是已经有对象的区域,右边是空白区域,中间有个指针,在分配对象内存时,只需要将指针向右移动和所需内存大小一致的一段距离即可。
      • 空闲列表:此时如果Java堆内存不是一块连续的内存空间,那么虚拟机还需要维护一个列表,用于记录堆内存的使用情况,只需要在空闲区域找到一块能分配给对象内存大小的空间即可,并更新列表。
    • 除了划分内存空间之外,还需要考虑一个问题就是对象在虚拟机中创建是十分频繁的,而且还会出现正在给对象A分配内存,指着还没来得及更改,对象B此时也使用了指针分配内存带来的线程不安全问题
      • 一种方法是:虚拟机采用的是CAS配上失败重试方法保证更新操作的原子性。
      • 另一种方法是:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,当TLAB用完并分配新的TLAB时,就加上同步锁。
    • 内存分配完成之后,就要对内存空间进行初始化为零值(不包括对象头),如果使用TLAB,这一工作可一提前之TLAB分配内存之前进行(保证了对象的实例字段在Java代码中可以不用赋值就直接使用,程序能访问到这些字段的数据类型的对应的零值)。
    • 接下来,就是对对象进行一些必要的设置,例如这个对象对应的属于哪个类、如何才能找到这个类的实例信息、对象的哈希码值、对象的GC分代年龄等,存在于对象头之中。
    • 以上4个操作在虚拟机角度,对象的创建已经完毕了,但是对于程序而言,对象的创建才刚刚开始——init方法还没有执行,所有的字段还都是零值。所以一般来说(由字节码中是否跟随invokeSpecial指令所决定),执行new指令之后会接着执行方法,把对象按照程序员的设置进行初始化。此时一个真正的对象才算是创建完成。

2.3.2 对象的内存布局

  • 对象在内存中实际上是由对象头、实例数据和对齐填充三部分组成。
  • 对象头:对象头由对象自身的运行时数据和类型指针两部分。
    • 对象自身的运行时数据:
      • 包括有对象的哈希码值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据在32和64位虚拟上占有的位数不同。
      • 由于对象自身的运行时数据是和对象自身定义的数据额外的数据,并且其内存大小也是固定的,但是对象头中存储的信息超过了32位,所以对象头中复用了其内存空间,对于不同的标志位有不同的存储内容。如下表:
        在这里插入图片描述
    • 类型指针:
      • 对象的类型指针指向的是对象所属的类元数据,Java程序可以通过类型指针找到对象所属的类,但是这并不是唯一的途径,所以类型指针也不是必须的。
  • 实例数据:对象真正存储的有效的信息,也是在程序代码中所定义的各种类型的字段内容。
    • 无论是从父类继承的还是在子类中定义的,都需要记录起来
    • 记录的顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
      • 虚拟机分配策略:longs/doubles、ints、shorts/chars、bytes/booleans
      • 相同宽度的字段总是被分配到一起。此前提条件下,父类的变量会在子类之前
  • 对齐填充:对齐填充不是必须的,因为虚拟机中规定对象的起始地址必须是8字节的整数倍(1倍或者是2倍),所以对象的大小必须是8字节的整数倍,如果前两部分不是8字节的整数倍,那么对齐填充只是起到了一个占位符的作用。

2.3.3 对象的访问定位

  • Java程序通过虚拟机栈中存放的栈帧中的局部变量表中的reference类型来访问对象的实例数据和类型数据。通过句柄访问是会在Java堆中开辟一个空间叫做句柄池用于存储句柄,句柄中包含有对象的实例数据地址(Java堆中)和对象的类型数据地址(方法区的运行时常量池中)
  • 对象的访问定位分为两种方式:直接访问和句柄访问
    • 直接访问:reference类型存储的就是对象实例在堆中的地址,但是这个要考虑一下对象类型数据的存储问题,优点是访问速度块。
      在这里插入图片描述
    • 句柄访问:reference类型存储的是句柄池中句柄的地址,句柄中存储的有对象的实例数据地址(Java堆中)和对象的类型数据地址(方法区中运行时常量池中),优点是在更改对象地址时候,不需要更改reference中的数据,只需要更改其中的对象实例数据地址即可。
      在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_43635659/article/details/102692360