(一)JAVA内存区域与内存溢出异常

目录

0、前沿

1、概述

2、运行时数据区域

2.1、程序计数器

2.2、JAVA虚拟机栈

2.3、本地方法栈

2.4、JAVA堆

2.5、方法区

2.5.1、运行时常量池

3、HotSpot虚拟机

3.1、对象的创建

3.2、对象的内存布局

3.3、对象的访问定位


0、前沿

        借用JVM书中一句话:JAVA和C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进来,墙里面的人想出去。

1、概述

        对于C++程序员而言,对于内存管理,他们既是拥有最高权力的“皇帝”,也是从事最基础工作的“劳动人民”。他拥有对每一个对象的所有权,同时也必须对每一个对象的生命周期负责到底。因此在获得对内存极大的使用权的同时,也会带来一些问题,容易出现内存泄漏和内存溢出等问题。

       对于JAVA程序员而言,在借助虚拟机JVM的自动内存管理机制的帮助下,内存管理不再需要JAVA程序员的亲力亲为,释放内存的权利都交给了JVM,因此极大的提供了JAVA程序员的生产力,同时也有效的减少了人为造成的内存泄漏等问题。

      但是事情都是有两面性的,JVM的内存自动管理机制带来的后果是弱化了JAVA程序员对内存的控制能力,它对于程序员而言,相当于一个黑盒,另外JVM的内存管理机制并不是完美无缺,也会有内存泄漏的可能,因此如果不了解JVM的内存管理机制,我们是没法定位分析JAVA中出现的内存泄漏等问题的。

       本文也是基于这个目的,将平时积累的知识尽可能全面的展示出来,以供分享。

2、运行时数据区域

       JVM在执行JAVA程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途以及创建和销毁的时间,有的区域是随着JVM进程的启动而存在,有的则是依赖于用户线程的启动和结束而创建和销毁。

       经常有人将JAVA内存区域简单的划分为堆内存和栈内存,这种分区方式很粗糙,它只关注了大多数程序员最关注的,与对象内存分配关系最密切的内存区域。另外一种更加精细的内存区域划分,如下图所示:

      下图是内存管理中5大区域。

如图所示:

(1)所有线程共享的区域:方法区、堆

(2)线程隔离的区域(属于单个线程所有):虚拟机栈、本地方法栈、程序计数器 

2.1、程序计数器

     程序计数器是一块较小的内存空间,他可以看做是当前线程所执行的字节码的行号指示器。

问题1:为什么需要程序计数器?

     由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

问题2:工作原理

     在虚拟机的概念模型,字节码解释器工作时就是通过改变计数器的值来选择下一条需要执行的字节码指令,分支、循环、跳转等基础功能都需要依赖这个计数器完成。

2.2、JAVA虚拟机栈

     与程序计数器一样,JAVA虚拟机栈也是线程私有的,它的生命周期和线程相同,虚拟机栈描述的是Java方法执行的内存模型,每一个方法(非本地方法)在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

      局部变量表存放了编译期可知的各种基本数据类型(8种),对象的引用和returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

2.3、本地方法栈

     本地方法栈与JAVA虚拟机栈发挥的作用很类似,它们之间的区别在于JAVA虚拟机栈为虚拟机执行JAVA方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

2.4、JAVA堆

     JAVA堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。换句话说,所有new出来的对象都在JAVA堆中存放。

     JAVA堆是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以JAVA堆中还可以细分为:新生代和老生代。

     JAVA堆可以是物理上不连续的内存空间,只要逻辑连续就可以,就如同我们的磁盘空间一样,在实现时,既可以实现固定大小的,也可以扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。

2.5、方法区

     方法区与JAVA堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。

    对于习惯上在HotSpot虚拟机上开发、部署程序的开发者来说,很多人更加愿意把方法区称作为“永久代”,本质上两者不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展到方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器就可以像管理JAVA堆一样来管理方法区这部分内存了,能够省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机而言(如BEA IBM等)来说是不存在永久代的概念的。

     原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但是使用永久代来实现方法区,现在看来不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限)。

     垃圾回收行为在方法区区域是比较少出现的,但并非数据进入到了方法区就不会被回收,这部分区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

2.5.1、运行时常量池

    运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。

3、HotSpot虚拟机

    介绍完JAVA虚拟机运行时数据区后,我们大致了解了JAVA内存管理的情况,如果想进一步了解细节,我们还需要拿具体的虚拟机来讲解,因为每种虚拟机的实现方式并不一样,基于实用优先的原则,本章打算拿HotSpot虚拟机和JAVA堆进行讲解。深入探讨HotSpot虚拟机在JAVA堆中对象分配、布局和访问的全过程。

3.1、对象的创建

    JAVA是一门面向对象的编程语言,在JAVA程序中无时无刻都有对象被创建,创建对象仅仅是new就可以了,哪在执行new的指令时是怎么一个过程呢?

  1、虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否被加载、解析和初始化、如果没有,则必须先执行相应的类加载过程。

2、在类加载检查通过后,接下来虚拟机将为新生对象分配内存。

     对象所需内存的大小是在类加载完成后便可以完全确定了,为对象分配空间的任务等同于把一块确定大小的内存从JAVA堆中处分出来。则会出现如下两种方法:

(1)指针碰撞

     如果JAVA堆中内存是规整的,所有用过的内存都放在一边,空闲的内存放到另一边,中间放置一个指针作为分界点的指示器,那么分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式就是“指针碰撞”,Serial、ParNew采用此方式。

(2)空闲列表

    如果JAVA堆中内存不规整,已使用的内存和未使用的内存相互交错,那就没办法采用简单的进行指针碰撞了,虚拟机必须维护一个列表,记录上哪些内存是可用的,在分配的时候,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”,CMS垃圾收集器就是采用的这种方式。

     除了如何划分可用空间之外,另外一个需要考虑的问题是对象创建过程,面对并发创建对象时的安全问题,例如利用移动指针创建对象,面对多个对象的创建时,共用指针,会出现并发问题。解决方案有两种:

(1)对分配内存空间的动作进行同步处理

     实际上虚拟机采用CAS配上失败重试的方法保证更新操作的原子性

(2)把内存分配的动作按照线程划分到不同的空间之中进行

     每个线程在JAVA堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步确定。

3、内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零只(不包括对象头)

4、接下来,虚拟机要对对象进行必要的设置。主要是对对象头,根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5、以上步骤完成后,一个新的对象就产生了,但是这只是对象创建的开始,<init>方法还没有执行,所有字段都为空,所以执行new之后,紧接着会执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3.2、对象的内存布局

    在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据、对齐填充。

1、对象头

     对象头可以分为Mark Word和类型指针。

(1)Mark Word

     用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志,线程持有的锁、偏向线程ID、偏向时间戳等。Mark Word被设计成一种非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间,例如对象的状态处于锁定、轻量级锁定、重量级锁定、GC标记等等

(2)类型指针

     对象指向它的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

3.3、对象的访问定位

    JAVA程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有:1、句柄;2、直接指针

(1)句柄

     JAVA堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含恶劣对象实例数据与类型数据各自的具体地址

(2)直接指针访问

     JAVA堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

至此本篇博客讲解完毕。

     

    

猜你喜欢

转载自blog.csdn.net/qq_35571554/article/details/84983483