JVM内存区域

  • 前言
    这篇是JVM系列的第一篇文章,主要是介绍一下在JVM在运行期间内存区域的划分情况。

  • 先给一张图:


    jvm运行时内存结构.png

OK,图上大家简单的看一下,我们可以把JVM运行时内存结构分为三部分。第一部分称之为数据私有区域(程序计数器、虚拟机方法栈、本地方法栈),第二部分称之为数据共享区域(堆内存、方法区),第三部分称之为直接内存(直接内存本身并不受JVM虚拟机)


大体上的划分区域以后,我们再来细说各个区域的情况:

线程私有区域

  • 程序计数器

每个线程都有着属于自己的线程计数器,用来记录着当前线程执行到哪个字节码地址。可以把它当成是当前线程所执行的字节码的\color{red}{行号指示器}当程序执行的是java方法的时候,记录的是虚拟机的字节码指令地址,但如果执行的native方法,那么记录的就是个空值这个内存区域也是JVM规定不会抛出OOM的区域。当然因为程序计数器属于线程私有,所以它的生命周期是随着线程的创建而诞生,也会随着线程的结束而消亡。
再深一点,为什么要有这个计数器呢?因为多线程情况下,CPU是不会去记录线程的执行位置的,所以只能由线程自己去记录自己的执行位置。
再深一点,为什么该区域是不会抛出OOM的?因为保存的字节码指令地址的值大小是固定的,因此可以在创建之初分配一个绝对不可能溢出的内存。
再深一点,为什么执行native方法保存的值是空呢?native方法是C/C++所写,由系统调用,不会产生字节码。因此,也就没有指令偏移地址可供记录。

  • 虚拟机方法栈

用来描述java方法执行的内存模型,它会为每一个即将执行的java方法创建一个栈帧,这个栈帧是一个用来存储局部变量表、操作数栈、动态链接、方法出口等信息的数据结构。
而java方法的从执行到结束,对应的正好是一个栈帧从入栈到出栈的过程。所以栈帧的生命周期是随着方法的执行而创建,随着方法的结束而销毁。同样,我们也可以知道虚拟机方法栈是随着线程的创建而诞生,也会随着线程的结束而消亡
为了更加形象点,给个虚拟机方法栈的图吧:

虚拟机方法栈.png

对了,在虚拟机方法栈这里的话,可能会报俩种异常:StackOverFlowError和OutOfMemeryError。
栈溢出异常指的是程序请求的栈深度超过了JVM允许的最大栈深度。
内存溢出异常指的是在JVM允许栈可以动态扩展的时候(目前大部分的虚拟机都运行动态扩展),无法为栈申请到足够的内存空间,则会抛出OOM。
\color{red}{关于栈帧里存储的数据信息,后面我会在这个系列里,专门写一篇文章来探讨}

  • 本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverFlowError和OutOfMemoryError异常。
————————————————

线程共享区域

用来存放对象的空间,几乎所有的对象都会被分配到这里进行存储,也是JVM里内存区域最大的一块。这里也是GC作用的主要区域,在GC的概念里(由于GC采用的是分代收集算法)在堆里也会划分为新生代、老年代。而新生代也被分为了Eden区、From Survivor区和To Survivor区。

我们刚刚在上面的描述里用到了几乎所有这个词,那就是说明还是有对象是不会被分配到堆内存里的,那么是哪些对象呢?
随着JIT技术的发展以及使用逃逸分析,JVM来判断方法里的对象是否会发生逃逸(可以理解为该对象不会被该方法之外的其他方法引用),如果没有发生逃逸,则可能会为这个对象分配到栈内存上而非堆内存里(当然也不是绝对是这样的),这样的好处很明显:减少了堆的内存分配压力,同时也会减少GC发生的次数

该区域会发生OOM,导致的原因由俩个:

  1. 由于发生内存泄露导致对象无法被GC,已经分配出去的内存空间没有办法收回,导致可分配内存空间越来越小,最终的结果是就报OOM异常。
  2. 内存中的对象确实还应该存活,但由于内存不够用产生的异常。
  • 方法区

与堆内存一样,也是属于线程共享区域。主要是用来存放一些被JVM加载过的类的信息,静态变量、常量、JIT编译后的代码数据。当然方法区在JDK1.7与1.8的时候实现方式也是有所不同的。
1.7:
方法区的实现主要是以永久代的形式来实现,当然这里的永久代已经与之前的版本里的永久代有所区别了,原先永久代里的字符串常量池已经被移动到堆内存里了。
1.8:
永久代的概念已经被元空间(元数据空间)给取代了,而原先是存储在堆内存里的也变成与直接内存(堆外内存)一样存放在了本地内存里。同时原先的静态变量与运行时常量池也被移动到了堆内存里。这样元数据的分配只受本地内存的大小,就不会受到OOM的影响了

  • 运行时常量池

首先,它是方法区的一部分,在Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员用的比较多的是String类的intern()方法
既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

————————————————

直接内存

堆外内存,又被称为直接内存。它并不是虚拟机运行时数据区的一部分,也不是JAVA虚拟机规范中定义的内存区域。这部分内存不是由jvm管理和回收的。需要我们手动的回收。

堆内内存是属于jvm的,由jvm进行分配和管理,属于"用户态",而推外内存是由操作系统管理的,属于"内核态"
在JDK1.4中加入了NIO类,引入了一种基于通道(Channel)缓冲区(Buffer)的I/O方式,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在JAVA堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在JAVA堆中和Native堆中来回复制数据。

NIO申请直接内存总结:
我们用NIO类申请的内存其实是由jvm进行回收的,并不像unsave那样要我们自己对内存进行管理。这时候系统是不断回收直接内存的,由NIO申请的直接内存是需要System.gc()来进行内存回收的。系统会帮助我们回收直接内存的。不过为了提高gc的利用率,我们可能会在代码中加入-XX:+DisableExplicit禁止代码中显示调用gc(System.gc)。采取并行GC,就是由jvm来自动管理内存回收,而jvm主要是管理堆内内存,也就是当对堆内对象回收的时候,才有可能回收直接内存,这种不对称性很有可能产生直接内存内存泄漏。需要注意的是当我们没有指向堆外内存的引用的时候,也会把直接内存回收

采用直接内存的优点:

  1. 对于频繁的io操作,我们需要不断把内存中的对象复制到直接内存。然后由操作系统直接写入磁盘或者读出磁盘。这时候用到直接内存就减少了堆的内外内存来回复制的操作。
  2. 我们在运行程序的过程中可能需要新建大量对象,对于一些声明周期比较短的对象,可以采用对象池的方式。但是对于一些生命周期较长的对象来说,不需要频繁调用gc,为了节省gc的开销,直接内存是必备之选。
  3. 扩大程序运行的内存,由于jvm申请的内存有限,这时候可以通过堆外内存来扩大内存。

——————————————

  • 写在最后

JVM系列文章,主要还是偏理论居多,写起来也得翻很多资料,看起来也比较难以理解。所以如果真的要好好了解这块东西,需要大家还是得耐着性子看下去。

推荐阅读更多精彩内容