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

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

觉得书上有一句话很有意思

Java与C++之间有一堵有内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

运行时数据区域

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

根据Java虚拟机规范,Java虚拟机所管理的内存将会包括以下几个运行时数据区域

Java内存区域与内存溢出异常
Java虚拟机运行时数据区

下面将大致介绍Java内存区域的分类和不同以及抛出内存溢出异常的原因

内存泄漏是造成内存溢出的主要原因

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分之、循环、跳转、异常处理、线程恢复等基础功能都需要通过这个计数器来完成

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

  • 如果线程正在执行的是一种Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果正在执行的是Native方法,这个计数器则为空

这个内存区域是唯一一个没有规定任何内存溢出异常的区域

Java虚拟机栈

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

这里需要注意,我们说到的这个Java虚拟机栈与Java方法栈栈是两个概念,每当一个方法执行的时候,会创建一个栈帧,栈帧中包含了该方法的操作数栈和局部变量表,动态链接和出口,这个方法的执行和退出伴随着这个栈帧在Java栈中的入栈和出栈操作

经常有人把Java内存分为堆内存和栈内存,这种分法比较粗糙,其中的栈就是我们现在说到的虚拟机栈,或者说是虚拟机栈中的局部变量表部分,局部变量表存放了编译期可知的各种基本数据类型对象引用(他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向一条字节码指令的地址)

局部变量表所需的内存空间在编译期就完成分配了,当进入一个方法的时候,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

什么是句柄?

Windows是一个以虚拟内存为基础的操作系统,在这种环境下,Windows内存管理器经常在内存中来回移动对象,以此来满足各种应用程序的需要。对象被移动意味着它的地址变化了。由于地址总是如此变化,所以Windows操作系统为各应用程序腾出一些内存地址,用来专门登记各应用对象在内存中的地址变化,而这地址(存储单元的位置)本身是不变的。Windows内存管理器在移动对象在内存中的位置后,把对象新的地址告知这个句柄地址来保存。这样我们只需记住这个句柄地址就可以间接地知道对象具体在内存中的哪个位置。这个地址是在对象装载(Load)时由系统分配给的,当系统卸载时(Unload)又释放给系统。

因此,Windows程序中并不是用物理地址来标识一个内存块,文件,任务,或动态装入模块的,相反,WINDOWS API给这些项目分配确定的句柄,并将句柄返回给应用程序,然后通过句柄来进行操作。

Java虚拟机规范规定

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈溢出异常
  • 如果虚拟机栈可以动态扩展,如果扩展时不能申请到足够的内存,就会抛出内存溢出异常

本地方法栈

虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务,Sun HotSpot直接把本地方法栈和虚拟机栈合二为一
本地方法栈同样也会抛出内存溢出异常

Java堆

Java堆是JAva虚拟机所管理的内存中的最大的一部分,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,次内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存,在Java虚拟机规范中说道:所有对象实例以及数组都要在堆上进行分配

其实上面的最后一句话不对,在学习Java的过程中,一般认为new出来的对象都是被分配在堆上的,其实这个结论不完全正确,因为是大部分new出来的对象被分配在堆上,而不是全部。通过对Java对象分配的过程分析,可以知道有另外两个地方也是可以存放对象的。这两个地方分别是 栈 (涉及逃逸分析相关知识)和TLAB(Thread Local Allocation Buffer)。我们首先对这两者进行介绍,而后对Java对象分配过程进行介绍。

栈上分配

在JVM中,堆是线程共享的,因此堆上的对象对于各个线程都是共享和可见的,只要持有对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但对于垃圾收集器来说,无论筛选可回收对象,还是回收和整理内存都需要耗费时间。

如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,无须通过垃圾收集器回收,可以减小垃圾收集器的负载。

JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。

栈上分配的技术基础:

  • 逃逸分析:逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。
  • 标量替换:允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。
    1.标量和聚合量标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
    2.替换过程通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。

什么是逃逸

"发布(Publish)"一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存在其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中

当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape),也叫逃逸

JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。

Java堆是垃圾收集器的主要区域,很多时候也被称为GC堆,从内存回收的角度来看GC基本都采用分代算法,所以Java堆中还可以细分为新生代、老生带,细分一下还有Eden空间,From Survivor空间, To Survivor空间

从内存分配角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区,不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快的分配内存

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存中,只要逻辑上是连续的即可,就像我们的磁盘空间一样,可以是固定大小的,也可是可扩展的

如果在堆中没有内存完成实例分配,并且堆也无法再进行扩展的时候,会抛出内存溢出的异常

方法区

Java内存区域与内存溢出异常
JVM内存结构图

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,虽然Java虚拟机把它描述为堆的一部分,但是它有一个别名叫非堆(Non-heap),在Hot Spot上部署开发的程序员更愿意把方法区称作"永久代",但是本质上两者不等价,因为Hot Spot设计团队把GC同样设计到了方法区上

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如同永久代的名字一样"永久"存在了,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收成绩比较难以令人满意,尤其是类型的卸载

方法区是什么?有哪些特点?

方法区是系统分配的一个内存逻辑区域,是用来存储类型信息的(类型信息可理解为类的描述信息)。方法区主要有以下几个特点:

  • 方法区是线程安全的。由于所有的线程都共享方法区,所以,方法区里的数据访问必须被设计成线程安全的。例如,假如同时有两个线程都企图访问方法区中的同一个类,而这个类还没有被装入JVM,那么只允许一个线程去装载它,而其它线程必须等待
  • 方法区的大小不必是固定的,JVM可根据应用需要动态调整。同时,方法区也不一定是连续的,方法区可以在一个堆(甚至是JVM自己的堆)中*分配。
  • 方法区也可被垃圾收集,当某个类不在被使用(不可触及)时,JVM将卸载这个类,进行垃圾收集

当方法区无法满足内存分配需求时,将抛出内存溢出的异常

运行时常量池

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

每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节要求,不过一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量中

运行时常量池相对与Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的最多的就是String类的intern()方法

既然运行时常量池是方法去的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存的时候就会抛出内存溢出异常

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存被频繁使用,而且也可能导致内存溢出异常的出现

在JDK1.4中加入了NIO引入了一种基于管道channel和缓冲区buffer的I/O方式,它可以直接使用Native函数库直接分配堆外内存(本地方发栈),然后通过一个存储在Java堆中的DriectByteBuffer对象作为这一块乃村的引用进行操作,这样的话,在一些场景能显著提高性能

虽然本机直接内存不收Java堆大小的限制,但是既然是内存,就会受到本机总内存大小以及处理器寻址空间的限制,有的时候根据实际内存设置-Xmx等参数信息,经常忽略直接内存,使得各个内存区域总和大于物理内存限制,抛出内存溢出的异常(除了堆上和栈上分配的内存,在本地方法栈可能存在堆上的引用直接内存)

虚拟机对象探秘

HotSpot虚拟机在Java堆中对对象分配、布局和访问的全过程

对象的创建(普通Java对象)

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

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可确定

可以想到为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来

  • 假设Java堆中内存是绝对规整的
    所有用过的内存放在一边,没有使用过的内存放在一边,中间放置着一个指针作为分界点的指示器,那所分配内存就是仅仅把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式被称为指针碰撞(Bump the Pointer)
  • 如果Java堆中的内存并不是工整的
    已使用的内存空间和未使用的内存空间相互交错,那就没有办法简单地使用指针碰撞这种分配内存的方式,虚拟机就必须维护一个列表,记录那些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式叫做空闲列表(Free List)

选择哪种分配方式取决Java堆是否规整,而Java堆是否规整又由所采用的垃圾收集器是否带有亚索整理功能决定,因此在使用Serial,ParNew等带Compact过程的收集器时,通常采用指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用的分配算法是空闲列表

分配空间的并发问题

实例化一个对象在JVM中是一个非常频繁的操作,如果在多线程情况下只是移动一个指针,在并发条件下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同事使用了原来的指针来分配内存的情况,解决这个问题有两种方案

  • 对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(TLAB),哪个线程需要分配内存就在哪个线程自己的TLAB上分配,只有TLAB用完并分配新的TLAB的时候才需要同步锁定

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行,这一步操作保证了对象实例字段在Java代码中可以不赋初始值就可以直接使用,程序能够访问到这些字段的数据类型所对应的零值

接下来,虚拟机要对对象进行必要的设置,例如:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头之中,根据虚拟机当前运行状态的不同,如是否使用偏向锁等,对象头会有不同的设置方式

上面工作都完成之后,从虚拟机的角度来看,一个新的对象就已经产生了,但是从Java程序的视角来看,对象创建才刚刚开始——init方法还没有执行,所有的字段都还为零,所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样之后才算是生产了一个完整的对象

上一篇:架设某大型网站服务器全部详细过程(郁闷少年)


下一篇:对于有志于成为架构师的开发者,支付宝架构团队有何建议?