Java虚拟机 - 内存区域与内存溢出异常

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

Java虚拟机内存分区

运行时数据区主要分为以下五个:

  • 包括线程私有的:
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  • 线程共享的有:
    • 方法区

1. 程序计数器

程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器.字节码解释器工作时通过改变计数器的值来选取下一条需要执行的字节码指令.

由于Java虚拟机的多线程是通过线程轮换,分配处理器执行时间的方式实现,所以在任何一个确定的时刻,一个处理器(多核处理器则是一个内核)都只会执行一条线程中的指令.为了保证线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,保证个线程之间的计数器不会互相影响,独立存储,提高运行效率.这样的区域也被称为"线程私有的内存"

  • 如果线程正在执行Java方法,那么计数器记录的就是正在执行的虚拟机字节码指令的地址
  • 如果线程执行的是本地方法,那么计数器的值则为空
  • 程序计数器是唯一一个在《Java虚拟机规范》中没有规定任何OOM(Out Of Memory)情况的区域

2. Java虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型,同程序计数器,这一块内存区域也是线程私有的.

每个方法被执行时,Java虚拟机都会同步的创建一个栈帧,每一个方法被调用至方法执行完毕的过程,对应着一个栈帧在虚拟机栈从入栈到出栈的过程.

栈帧是构成栈的基本元素,包含如下信息:

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法返回地址(方法出口)
  • 附加信息

局部变量表说明:

  • 局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用和returnAddress类型(指向了一条字节码指令的地址).
  • 局部变量表的存储单位以局部变量槽表示,longdouble类型的数据占用2个变量槽,其余的数据占用一个变量槽.
  • 局部变量表所需的内存空间在编译期间完成分配(指槽的数量)

此部分的内存区域包含了两个异常:

  • 如果线程请求的栈的深度大于了虚拟机所允许的深度,将会抛出 StackOverFlowError异常
  • 如果Java虚拟机栈的容量可以动态拓展,当栈无法申请到足够的内存时,会抛出OutOfMemoryError异常

    注:HostSpot虚拟机的栈容量不可以动态拓展.但是在申请栈空间时,如果失败了还是会抛出OOM异常.


3. 本地方法栈

类似于虚拟机栈,本地方法栈发挥的作用与虚拟机栈非常相似,区别在于虚拟机栈执行的是Java方法,而本地方法栈则执行的是为虚拟机使用到的本地(native)方法

本地方法栈抛出的异常同样包含了虚拟机栈所可能抛出的两个异常.

在HostSpot虚拟机中,直接将虚拟机栈与本地方法栈合二为一


4. Java堆

Java堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域

此块内存的唯一目的:存放对象实例,几乎Java中的所有的对象实例都在这里分配内存

The heap is the runtime data area from which memory for all class instances and arrays is allocated.

-《Java虚拟机规范》

由于当前即时编译技术的进步,尤其是逃逸分级技术的日渐强大,栈上分配,标量替换等优化手段使对象存放在其他区域成为了可能

Java堆是垃圾收集器管理的内存区域

  • 经典分代:
    • 新生代
      • Eden
      • Survivor0
      • Survivor1
    • 老年代
  • 线程共享的堆中可以划分出多个线程私有的分配缓冲区(TLAB),可提升对象分配的效率.

分代的目的是为了能更好,更高效的回收内存

Java堆物理上可以处于不连续的内存空间,但逻辑上应该被视为连续的.Java堆的大小可以通过参数 -Xmx-Xms来设定

如果在Java堆中无法分配实例,并且堆也无法再被拓展时,将抛出OOM异常


5. 方法区

方法区与堆一样,是各线程共享的区域,用于存放被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据.再《Java虚拟机》中把它描述为堆的一个逻辑部分,但却应该与Java堆分开来.

JDK8以前,通常会称呼方法区为永久代,原因是因为当时HotSpot虚拟机设计时将垃圾收集器的分代设计拓展至方法区,省区专门为方法去编写内存管理代码.但是这并不是一个好的注意,这使得Java程序更容易产生内存溢出问题.

JDK6之后方法区的改变:

  • JDK6,准备放弃永久代,逐步改用本地内存来实现方法区.
  • JDK7,把原本放在永久代的字符串常量池,静态变量等移出
  • JDK8,废弃了永久代的概念,使用元空间(Metaspace)来代替,把JDK7中剩余的内容(主要为类型信息)全部移到了元空间

方法区的垃圾收集:

  • 可以选择不实现垃圾收集
  • 若回收,回收目标主要针对常量池的回收以及对类型的卸载
  • 回收效果比较难令人满意
  • 有时候又是必要的

如果方法区无法满足新的内存分配需求时,将会抛出OOM异常


运行时常量池

运行时常量池是方法区的一部分.在字节码文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项为常量池表,用于存放编译期间生成的各种字面量与符号引用,这部分内容将在类加载后放到方法区的运行时常量池中.

运行时常量池相对于字节码文件常量池的一个重要特种就是具有动态性,不仅字节码文件的常量池内容能进入方法区运行时常量池,运行期间也可以将新的常量放入运行时常量池.

当常量池无法申请到内存时,也会抛出OOM异常


直接内存

直接内存不是虚拟机运行时数据区的一部分,但也被频繁使用,并且也可能产生OOM异常

在JDK1.4中加入的NIO类,引入了一种基于通道与缓冲区的I/O方式,可以使用Native函数库直接分配堆外内存,并通过DirectByteBuffer对象为这块内存的引用进行操作,可显著提高I/O性能.(避免了在Java堆和Native堆中来回复制数据)

  • 本地直接内存的分配不受到Java堆大小的限制,但是收到本地总内存大小及处理器寻址空间的限制,在设置虚拟机参数时,不仅仅要考虑设置堆内存,同时也要考虑到直接内存,避免动态拓展时出现OOM异常

对象的分配,布局以及访问

对象的创建

Java对象创建的方式有5种,如下:

方式 构造方法
使用 new 关键字 构造方法会被调用
使用 Class 类的 newInstance() 构造方法会被调用
使用 Constructor 类的 newInstance() 构造方法会被调用
使用 clone() 方法 无构造方法调用
使用 deserialization 无构造方法调用

为对象分配内存的两种方式:

指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另外一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲的方向挪动一段与对象大小相等的距离,这种分配方式被称为"指针碰撞".

如果Java堆中的内存不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没办法进行指针碰撞了,虚拟机就必须维护一张表,用于记录哪块内存可用,分配内存时,在表中找一块足够大的空间为对象分配实例,并更新维护的表,这种分配方式被称为"空闲列表".

Java堆采用哪种分配方式取决于Java堆是否规整,如果所采用的垃圾收集器带有空间压缩整理能力,如使用Serial,ParNew等带有压缩整理过程的收集器时,将采用的指针碰撞的分配方式,当采用CMS这种基于清除算法的收集器时,理论上则只能采用较为复杂的空闲列表来分配空间.

对象分配过程

  1. 检查类是否被加载,解析及初始化,没有则执行类的加载过程
  2. 为新生对象分配内存,内存大小已经在类加载完成后确定
  3. 将分配的内存空间进行初始化处理(清零).

    如果使用了TLAB,这一步可在TLAB分配时进行.

    这一步也保证了对象的实例字段不赋初始值也能使用

  4. 对对象进行必要设置,既对象头(对象属于哪个类的实例,元数据信息,GC分代年龄等信息)
  5. 至此,虚拟机视角的对象已经分配完毕,而Java程序视角,对象刚刚开始创建,开始执行构造方法

对象分配内存时的并发问题

两个对象由两个线程分配时,如果只是修改指针所指向的位置,在并发情况下就不是线程安全的,在A线程分配内存时,指针还没来得及更改,B线程又使用原来的指针进行分配.

  • 同步处理
    CAS+失败重试的方式
  • TLAB(Thread Local Allocation Buffer, 本地线程分配缓冲)
    内存分配的动作根据线程划分成不同的空间,即先在Java堆中预先分配一小块内存(TLAB),哪个线程需要分配内存,就在哪个内存的TLAB中分配,当缓冲区用完了,分配新的缓冲区时才需要同步锁定

内存分配完成后虚拟机会将分配的内存空间全部都初始化为零值(如果由TLAB,可提前之TLAB分配时初始化),保证了对象可以不赋初始值也能直接使用.


对象的内存布局

  • 对象头
  • 实例数据
  • 对齐填充

对象头

  • 对象的自身运行时数据
    哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等
  • 类型指针
    对象指向它的类型元数据的指针,用于确定对象是哪个类的实例

实例数据

对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论从父类继承的还是子类中定义的,都必须被记录

存储顺序收到虚拟机分配策略参数以及在Java源代码中定义顺序的影响

longs/doubles->ints>shorts/chars->bytes/booleans->oops

相同宽度的字段总是会被分配在一起,父类定义的变量在子类之前.

如果开启参数 +XX:CompactFields (默认为true),则允许将子类中较窄的变量插入父类变量的空隙中,可节省一点空间

对齐填充

由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是任何对象的大小都必须是8字节的整数倍.对象头部分已经被精心设计为8字节的整数倍,因此,如果对象实例数据部分没有对齐的话,则需要通过对齐填充来补全至8字节的整数倍


对象的访问

对象访问有两种形式,《Java 虚拟机规范》没有规定怎样去定位对象引用,通常有两种方式去访问

  1. 使用句柄访问
    Java堆中会划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中宝航了对象的实例数据与类型数据各自具体的地址信息
    image-20201118212656214
    好处:reference中存储的是稳定的句柄地址,对象移动时(GC会造成对象移动)只需要改变句柄中的实例数据指针,而reference本身不用被更改.
  2. 直接通过指针访问(HotSpot虚拟机)
    直接通过指针去访问,Java堆中对象的内存布局就必须考虑如何放置类型数据的相关信息,reference中存储的直接就是对象地址,访问对象本身不需要一次间接访问的开销
    image-20201118212834978
    好处:速度更快,节省了一次指针定位的时间开销
最后修改:2021 年 02 月 23 日
如果觉得我的文章对你有用,请随意赞赏