Java 运行时数据区域

1.1 为什么要进行内存区域划分

JVM规范 规定,JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途。以及创建和销毁的时间,有的区域随着虚拟机进程的启动就存在了,而有些区域则依赖用户线程的启动和结束而建立和销毁。JVM 规范对 JVM 定义了运行时统一的内存划分规范,统一了标准,类似于 JDBC 规范一样。JVM 也有许多厂商的不同产品。比如下面的这些:

厂商 JVM
Oracle-SUN Hotspot
Oracle JRocket
IBM J9 JVM
阿里 Taobao JVM

其内存区域划分规范对于 JVM 的含义类似于我们 Java 中的接口,都是起到了规范的作用,JVM 是一台可以运行 Java 应用程序的抽象的计算机。在 JVM 中存在三个重要的概念:

  • JVM 规范:它定义了虚拟机运行的规范,但是由 Oracle(SUN)或者其它厂商实现
  • Java 运行时环境(JRE:Java Runtime Environment):它是 JVM 规范的具体实现
  • JVM 实例:编写好 Java 代码之后,运行 Java 程序,此时就会创建 JMV 实例

对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个对象去编写内存释放的代码,不要像 C 或者 C++ 要时刻注意着内存泄漏和内存溢出的问题,这种由虚拟机去管理一切看起来都很美好。不过,也正是因为 Java 设计者把内存控制全部交给了 JVM,一旦出现了内存泄漏和溢出方面的问题,如果不了解虚拟机是怎么分配运行时内存的,那么排查错误将是一项非常艰难的工作。

1.2 运行时数据区域的组成

为什么我们经常把运行时数据区叫做 Java 内存模型(JMM:Java Memory Model),是因为运行时数据区太过于分散,没有联系,所以才会有 JVM 内存模型这个词,让我们把这些东西联系起来,方便记忆。JVM 运行时数据区中有些数据是一直存在的,被所有线程所共享。而有些区域则是线程私有的,伴随着线程的开始而创建,线程的结束而销毁。所以我们可以把JMM 分为两类:线程共享的线程私有的。根据 JVM 虚拟机规范的规定,JVM 虚拟机运行时数据区划分如下图所示:

jmm-structure.png 运行时数据区主要分为以下几个部分:

  • 方法区
  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

其中,按照线程在各个区域的数据是否共享划分为:

  • 线程共享部分:方法区、Java 堆以及运行时常量池(归属于方法区)
  • 线程私有部分:虚拟机栈、本地方法栈、程序计数器

接下来看看 Java 运行时数据区中各个部分的用途和特点:

方法区

1.1 什么是方法区

在 JVM 中,方法区是可供各个线程共享运行时的内存区域。方法区与传统语言中的编译代码存储区或者操作系统进程的正文段的作用非常类似,它存储了每一个类的结构信息,例如运行时常量池、字段和方法数据、类的构造函数和普通方法的字节码内容、还包括一些类、实例、接口初始化的时候用到的特殊方法。在 Hotspot 虚拟机中,JDK 1.7 版本称作永久代(Permanent Generation),而在 JDK 1.8 则称为 元空间(Metapace)
方法区有个别名叫做非堆(Non-Heap),用于区别于 Java 堆区。默认最小值为 16 MB,最大值为 64 MB,可通过 -XX:PermSize-XX:MaxPermSize 参数设置方法的大小。
JDK 1.7 及之前的版本设置为:

1
2
-XX:PermSize=10m
-XX:MaxPermSize=55m

JDK 1.8 及之后的版本设置为:

1
2
-XX:MetaspaceSize=10m
-XX:MaxMetaspaceSize=55m
1.2 方法区的特点
  • 线程共享:方法区是堆的一个逻辑部分,因此和对一样是线程共享的。整个虚拟机中只有一个方法区。
  • 永久代:方法区中的信息一般要长期存在,而且它又是堆的逻辑部分,因此用堆的划分方法,我们把方法区称作永久代(方法区是规范,永久代是实现)。
  • 内存回收低:方法区中的信息一般需要长期存在,回收一遍内存之后可能之后少量信息无效。对方法区的内存回收主要是 对常量池的回收和对类型的卸载
  • JVM 规范对方法区的定义比较宽松:和堆一样,允许固定大小,也允许可扩展大小,还允许不实现垃圾回收。

方法区是所有都线程共享的,在一定的条件下它也会被 GC,当方法区域需要使用的内存超过其允许的大小时,会抛出 OOM(OutOfMemory)错误信息。

1.3 运行时常量池

类加载后,Class 文件结构中常量池中的数据将被存储在运行时常量池中。我们一般在一个类中通过 public static final 来声明一个常量或者声明一个字符串 String str = "abc"。这个类编译后产生的 Class 文件,这个类的所有信息都存储在这个 class 文件中,当这个类被 JVM 加载之后,class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池添加新的常量。比如,String 类的 intern() 方法就能在运行期间向常量池中添加新的常量。当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用时,那么就需要垃圾收集器回收。JVM 为每个已加载的类型维护一个常量池,常量池就是这个类型用到的常量的一个有序集合。其包括直接常量(基本类型,String)和对其他类型、方法、字段的符号引用。即字面量和符号引用,其中字面量指的是整个类中的字面量。包含成员变量、静态方法、非静态方法等中的字面量。池中的数据和数组一样通过索引访问。

虚拟机栈

1.1 什么是虚拟机栈

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。Java 虚拟机栈会为每一个即将运行的方法创建一块叫做 栈帧 的区域,这块区域用于存储用于方法在运行时所需要的一些信息,这些信息具体包括:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • 其它信息

当一个方法即将被运行时,Java 虚拟机栈首先会在 Java 虚拟机栈中为该方法创建一块”栈帧”,栈帧中包含局部变量表,操作数栈,动态链接,方法出口信息等。当方法在运行过程中需要创建局部变量时,就将局部变量的值存入栈帧的局部变量表中。当这个方法执行完毕后,这个方法所对应的栈帧将会出栈,并释放内存空间。Java 虚拟机栈上数据都是私有的,其他线程都不能访问该线程的栈数据。在函数中定义的一些基本类型的变量数据和对象的引用变量都在函数的栈内存中分配。当在一段代码块中定义一个变量时,Java 就会在栈中为这个变量分配内存空间,当该变量退出该作用域后,Java 会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

1.2 Java 虚拟机栈的特点
  • 局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。局部变量表的大小在程序的编译期间就确定下来了,在创建的时候需要事先指定好大小,在方法运行的过程中局部变量表的大小是不会发生改变的。
  • Java虚拟机栈会出现两种错误(StackOverFlowError 和 OutOfMemoryError),StackOverFlowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候就会抛出 StackOverFlowError。OutOfMemoryError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展了,此时就会抛出 StackOverFlowError。
  • 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
  • 栈中的数据在线程内部是共享的,要注意这种数据的共享与两个对象引用同 时指向一个对象的这种共享是不同的。它是由编译器完成的,它有利于节省空间。

本地方法栈

本地方法指的是使用 Java 以外的其他语言编写的代码,因为有些时候 Java 无法直接操作一些底层资源,只能通过 C 或汇编操作。因此需要通过本地方法来实现。而本地方法栈就是设计用来调用这些非 Java 语言方法的。会存放对应的局部变量信息、返回结果等。本地方法栈和 Java 虚拟机栈实现的功能类似,只不过本地方法栈是本地方法运行的内存模型。区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则是为虚拟机用到的 Native 方法服务,本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接以及出口信息等。方法执行完毕后相应的栈帧也会出栈并释放内存空间。也会抛出两种错误,StackOverFlowError 和 OutOfMemoryError。

1.1 什么是堆

堆是用来存放对象(类、接口、数组)的内存空间。几乎所有的对象都存储在堆中(实例创建后,成员变量也随对象存在堆中,随着垃圾回收进行释放)。堆是一个运行时数据区,在程序运行时动态分配内存。
在堆中产生了一个数组对对象后,还可以在栈中定义一个特殊的变量,让栈用这个变量的取值等于数组或对象在堆地址内存中的首地址,栈中的这个变量就成了数组或对象的引用变量。引用变量就相当于是为数组和对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中数组或对象。
引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域外后释放。而数组和对象本身在堆中分配,即使程序运行到使用 new 产生数组或者对象的语句所在的代码之外,数组和对象本身占据的内存空间不会被释放,数组和对象在没有引用指向它的时候才会变为垃圾,不能再被使用。仍然占据内存空间不放,在随后的一个不确定的时期被 GC 垃圾回收收走。这也是 Java 比较占用内存的原因之一,实际上,栈中的变量指向堆内存的变量,这就是 Java 中的指针。

1.2 堆的特点
  • 线程共享:整个 JVM 只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个。
  • 在虚拟机启动的时候创建。
  • 垃圾回收的主要场所。
  • 堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的。
  • 堆可以分为:新生代和老年代
    新生代:新生代程序新创建的对象都在新生代分配的,新生代由 Eden Space 和两块大小相同的 Survivor Space(通常又称 S0 和 S1或 FROM 和 To )构成,可通过 -Xmn 参数来指定新生代的大小,也可以通过 -XX:SurvivorRation 来调整 Eden Space 及 Survivor Space 的大小,因此新生代又可被分为:Eden,From Survivor,To Survivor。
    老年代:老年代用户存放经过多次新生代垃圾回收仍然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代。主要有两种情况:一种是 大对象,可通过启动参数设置 -XX:PretenureSizeThreshold=1024(单位为字节,默认为 0)来代表超过多大时就不再在新生代分配,而是直接在老年代分配。另一种是 大的数组对象,且数组中无引用外部对象。老年代所占的内存大小为 -Xmx 对应的值减去 -Xmn(新生代)对应的值。不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,从而更加高效。
  • JDK 1.8 及之后版本堆的内存空间分配
    老年代:三分之二的堆空间
    年轻代:三分之一的堆空间
    • eden 区: 十分之八的年轻代空间
    • survivor 0:十分之一的年轻代空间
    • survivor 1:十分之一的年轻代空间

程序计数器

1.1 什么是程序计数器

程序计数器是一块比较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。当然,程序计数器是线程私有的。但是,如果当前线程执行的是一个线程本地的方法,那么此时这个线程的程序计数器为空。

本地方法为 Native Method,即由 native 修饰的方法。在定义一个 native 方法时,并不提供实现(类似 Java 中的接口或者抽象方法),因为其实现往往是由外面的 C 或者 C++ 等非 Java 语言实现的。

1.2 程序计数器的作用

程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来一次读取指令,从而实现代码的流程控制,如顺序执行、选择、循环、异常处理等。
  • 在多线程的条件下,程序计数器用来记录当前线程执行的位置,从而当线程被切换回来的时候能够知道这个线程上次运行到哪个地方了。
1.3 程序计数器的特点
  • 是一块比较小的存储空间
  • 是线程私有的,即每一个线程都有一个独立程序计数器
  • 是唯一一个不会出现 OOM(OutOfMemoryError)的内存区域
  • 声明周期随着线程的开始而创建,随着线程的终止而结束

方法区、永久代和元空间

1.1 方法区和永久代的关系

涉及到内存模型,往往都会提到永久代,那么它和方法区又是什么关系呢?
JVM 虚拟机规范 只是规定了有方法区这个概念和它的作用,并没有规定如何实现它。那么,在不同 JVM 上方法区的实现肯定是不同的。同时大多数公司用的 JVM 都是 Oracle 公司的 HotSpot。在 HotSpot 上把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区。因此,我们可以得到结论,永久代是 HotSpot 的概念,方式区是 JVM 规范的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其它的虚拟机实现并没有永久代这么一说。在 JDK 1.7 及之前的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC 分代来实现方法区内存回收,可以使用以下参数来调准方法区的大小:

1
2
-XX:PermSize     # 方法区初始大小
-XX:MaxPermSize # 方法区最大大小(超过这个值会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError:PermGen)
1.2 元空间

对于 Java 8,HotSpot 取消了永久代,那么是不是也就没有方法了吗?
当然不是,方法区是一个规范,规范没变,它就会一直在。那么取代永久代的就是元空间。它和永久代有什么不同呢?

  • 存储位置不同,永久代物理上是堆的一部分,和新生代、老年代地址是连续的,而元空间属于本地内存
  • 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。

1.3 总结

1.1 JVM 内存模型一共有两个“栈”,分别是 Java 虚拟机栈和本地方法栈

两个“栈”功能类似,都是方法运行过程的内存模型。并且两个“栈”内部构造相同,都是方法私有的。只不过 Java 虚拟机栈描述的是 Java 方法运行过程的内存模型,而本地方法栈是描述 Java 本地方法运行过程的内存模型。

1.2 JVM 内存模型中一共有两个“堆”,分别是原本的堆和方法区

方法区本质上还是属于堆的一个逻辑部分。堆中存放对象,方法区中存放类信息、常量、静态变量,即时编译器编译后的代码等。

1.3 堆是 JVM 中最大的一块内存区域,也是垃圾收集器主要工作的地方

在创建对象的时候,非静态成员会被加载到堆内存中,并完成成员变量的初始化。也就是说所有的非静态成员(成员变量、成员方法、构造方法、构造代码块和普通代码块)都是保存在堆内存中的。但是方法调用的时候,调用的方法会在栈内存中执行,构造代码块也会在栈内存中执行。

1.4 线程私有与共享

Java 虚拟机栈、程序计数器和本地方法栈都是线程私有的,也就是说每个线程都是各自的程序计数器、Java 虚拟机栈和本地方法栈。他们的生命周期和线程的生命周期一样。而堆、方法区则是线程共享的,在 JVM 中只有一个堆,一个方法区。并在 JVM 启动的时候就创建,直到 JVM 停止的时候才销毁。


参考文章

-------------本文结束感谢您的阅读-------------
mghio wechat
微信公众号「mghio」
赏作者☕️