JVM技术之旅-了解分析内存布局区域

1,068 阅读11分钟

每日一句

我们始于迷惘,终于更高水平的迷惘。 —— A·F·查尔莫斯

前提概要

本文主要针对于JVM的内存布局以及相关的关联性和特性进行相关的分析,后续会针对于更加详细以及深入的分析文字作为补充。针对于每个“Java爱好者”,如果希望可以探究其本质,减少内心的疑惑,最好的办法就是研究底层的原理,而JVM的内存管理既是Java的独特魅力之处,又是开发人员 “未知的圣地”,接下来我们就慢慢迈入这个领域。

JVM内存布局

如果想要了解JVM的内存管理,那么首先我们要知道JVM内存都由什么组成,如下图所示 image.png

从图中 我们可以清晰的了解到相关的内存分布结构,发现整体体系里面主要由运行时数据区域和其他几个子系统组成,那我们就先来看看这个运行时数据区

运行时数据区域

JVM虚拟机在运行时java程序的时候,会把它所管理的内存划分成若干个不同的数据区域。其中jdk1.8前后版本有差别。

jdk1.6版本结构模型

8582adfbc2dc481991816cc0a1980e18_tplv-k3u1fbpfcp-watermark.jpg

jdk1.8版本结构模型

v2-2a535f23a7994b45663f66d4fd9abab6_1440w.jpg


从上面的两个版本的细节图可以看出来,整体的运行时数据区主要可分为:

PC Register(程序计数器)、VM Stack(虚拟机栈)、Native Method Stack(本地方法栈)、Heap(堆)、Method Area(方法区)和Direct Memory(直接内存)。

  • 整个内存数据区域是属于当前进程的,当前进程拥有所有的资源和数据。而直接内存是所有进程共享的
  • 其中栈和程序计数器是线程私有的,也就是每一个线程拥有自己独立的区域。互相不干扰

程序计数器(PC Register)

代码在程序执行之前就被编译成字节码,而程序计数器不是我们计算机组成原理的程序计数器(存放的计算机指令地址),而JVM的PC是字节码解释器的指示器存放的是字节码的地址。基本上会指向方法区元数据以及对象的首地址进行计算

如果执行的是java方法,这里存储的就是正在执行的字节码的地址,如果执行的是本地方法存储的就是undefined。

字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

在多线程的环境下,pc还能保证恢复到原来线程的位置。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈

描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的,虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压人栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈

栈帧:

  • 操作数栈(Operand Stack):操作变量的内存模型,操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)。操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。
  • 动态链接(Dynamic Linking):栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接
  • 方法出口信息(return Adress 指向了一个字节码指令的地址):如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向方法调用指令后面的一条指令地址
  • 局部变量表(Local Variable table):包含了方法执行过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。(写入方法区code属性的max_locals项)
  • 程序计数器:指向当前线程正在执行的字节码指令,线程私有的。

各种基本数据类型和引用类型 Java栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。在java方法中y有两钟返回方法:抛出异常和return语句。两种方式都回将栈帧弹出。

Java堆内存

Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。这段区域也是垃圾回收器经常光顾的地方。

Java 堆还可以细分为:新生代和老年代、永久代(方法区):再细致一点有:Eden 空间、From Survivor、To Survivor 空间(这三个都是新生代)

image.png

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来

对于方法区的实现,不同虚拟机中策略也不同。以我们常用的HotSpot虚拟机为例,其设计团队使用永久带来实现方法区,并把GC的分代收集扩展至永久带。这样设计的好处就是能够省去专门为方法区编写内存管理的代码。但是在实际的场景中,这样的实现并不是一个好的方式,因为永久带有MAX上限,所以这样做会更容易遇到内存溢出问题

另外还需要注意的是在HotSpot虚拟机中永久带和堆虽然相互隔离,但是他们的物理内存是连续的。而且老年代和永久带的垃圾收集器进行了捆绑,因此无论谁满了都会触发永久带和老年的GC

在jdk1.8之后HotSpot虚拟机已经将方法区(永久带)移除,取而代之的就是元空间(移入直接内存)

运行时常量池

JDK1.8版本的JVM已经将字符串常量池从方法区中移了出来,在元数据空间(Metadataspace)中开辟了一块区域存放运行时常量池,用于存放类的信息、常量信息、常量池信息、包括类数据常量和数字常量。常用的反射就是从这个方法区里读取的类信息,此外heap堆中开辟了内存空间存放字符串常量池

image.png


image.png

对象的创建过程

v2-3f84c7a14640dc39ed577c1bd938c1b0_1440w.jpg

Java8的运行时数据区域如图所示。永久代已经不见了踪影,多出来的是叫做元数据区的区域。元空间在1.8中不在与堆是连续的物理内存,而是改为使用本地内存(Native memory)。元空间使用本地内存也就意味着只要本地内存足够,就不会出现OOM的错误。 默认情况下元空间大小是无限的,但是JVM同样提供了参数来控制它的使用

-XX:MetaspaceSize
    class metadata的初始空间配额,以bytes为单位,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize(如果设置了的话),适当的提高该值。
-XX:MaxMetaspaceSize
    可以为class metadata分配的最大空间。默认是没有限制的。
-XX:MinMetaspaceFreeRatio
    在GC之后,最小的Metaspace剩余空间容量的百分比,减少为class metadata分配空间导致的垃圾收集。
-XX:MaxMetaspaceFreeRatio
    在GC之后,最大的Metaspace剩余空间容量的百分比,减少为class metadata释放空间导致的垃圾收集。
  • (1)首先jvm遇到一个new的指令时,咱们的常量池去查询是否有这个类的符号引用(全限定类名),如果没有表示未加载解析过这个类,就需要加载解析,同时在常量池中添加符号引用,在方法区添加类的信息
  • (2)然后分配内存,分配的方式有两种(指针碰撞和空闲列表)
  • (3)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
  • (4)设置对象头,这个对象头哦存储的是:这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息和否启用偏向锁等
  • (5)执行init方法,把对象按照程序员的意愿进行初始化(也就是构造函数之类的开始初始化)

对象访问定位

(1)句柄方式:如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息; image.png

(2)直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

image.png 这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

image.png

内存溢出

  • StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈最大深度的时候,抛出StackOverFlowError错误
  • OutOfMemoryError:若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误

类加载系统

负责从文件系统或是网络中加载class信息,加载的信息存放在一个称之为方法区的内存空间

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但这部分也是被频繁的读写使用,也可能会导致OutOfMemoryError异常的出现。 Java的NIO中的allocateDirect方法是可以直接使用直接内存的,能显著的提高读写的速度。Java8的MetadataSpace元数据空间使用的就是对直接内存,之前的方法区还逻辑属于heap的一部分,采用的是jvm的内存空间。

本地方法栈

本地方法栈和Java栈不同之处在于,可以直接调用Java本地方法,即JDK中用native修饰的方法,调用本地native的内存模型,线程私有

虚拟机栈,本地方法栈以及程序计数器为线程隔离。方法区和堆是所有线程共享的数据区域。