1.JVM 内存区域

该结构图 JDK 版本:JDK 1.7

【JVM】JVM内存区域你了解吗?_堆

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【Java 堆、方法区】、直接内存。

线程私有数据区域生命周期与线程相同,依赖用户线程的启动/结束而创建/销毁(在 Hotspot VM 内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存/否跟随系统本地线程的生/死对应)。

线程共享区随虚拟机的启动/关闭而创建/销毁。

直接内存并不是 JVM 运行时数据区的一部分,但也会被频繁的使用:在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作(详见:Java I/O扩展),这样就避免了在 Java 堆和 Native 堆中来回复制数据,因此在一些场景中可以显著提高性能。

2.内存区域思维导图

【JVM】JVM内存区域你了解吗?_Java虚拟机_02

 

3.程序计数器(线程私有)

程序计数器是一个记录着当前线程所执行的字节码的行号指示器。

只占用一块较小的内存空间(在进行 JVM 计算时,可以忽略不计),每条线程都有一个独立的程序计数器,这类内存也称为“线程私有”的内存。

画外音:假设程序永远只有一个线程,我们就不需要程序计数器。

JVM 的多线程是通过 CPU 时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。

也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。

当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。

正在执行 Java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果是执行 Native 方法,则它的值为空。

这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。

4.虚拟机栈(线程私有)

描述 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Statkc Frame)用于存储【局部变量表】、【操作数栈帧】、【动态链接】、【方法出口】等信息。

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

如下图所示:

【JVM】JVM内存区域你了解吗?_Java虚拟机_03

画外音:栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未捕获的异常)都算作方法结束。

栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。

栈帧结构图:

【JVM】JVM内存区域你了解吗?_2020Java面试题库_04

5.本地方法栈(线程私有)

本地方法栈和虚拟机栈(Java Stack)作用类似,区别是虚拟机栈为执行 Java 方法服务,而本地方法栈则为 Native 方法服务,如果一个 VM 实现使用 C-likage 模型来支持 Native 调用,那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

画外音:在HotSpot 虚拟机中未对本地方法栈和虚拟机栈作区分,统称为栈。

6. 堆(Heap-线程共享)-运行时数据区

堆是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。

由于现代 VM 采用分代收集算法,因此 Java 堆从 GC 的角度还可以细分为:新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

如下图:

【JVM】JVM内存区域你了解吗?_堆_05

画外音:新生代划分也有这样的叫法:伊甸园(Eden space),幸存者0区(Survivor 0 space)和幸存者1区(Survivor 1 space)

7.方法区(线程共享)

JDK1.7 及之前版本的方法区和 Java 堆一样,是各个线程共享的内存区域,也称非堆(Non-Heap),用于存储已经被虚拟机加载的【类信息】、【常量】、【静态常量】、【即时编译器(JIT)编译后的代码】等数据,它同样存在垃圾回收,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

画外音:对于方法区,很多人更愿意称为:“永久代(Permanent Generation)”,不过本质上两者并不等价,仅仅是因为习惯使用 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存,能够省去专门为方法区变编写内存管理代码的工作。不过对于其他虚拟机(如BEA JRockit、IBM J9等)来说并不存在永久代的概念。

方法区存储的是每个 class 的信息:

【JVM】JVM内存区域你了解吗?_java_06

画外音:类型的常量池,也叫运行时常量池,每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 。

运行时常量池

除了保存已加载的类信息,还有一个特殊的部分——运行时常量池(Runtime Constant Pool)。

运行时常量是方法区一个特殊的部分,相对于常量来说的,它具备一个重要特征是:动态性。也就是说,除了类加载时将常量池写入其中,Java 程序运行期间也可以向其中写入常量:

//使用StringBuilder在堆上创建字符串abc,再使用intern将其放入运行时常量池
String str = new StringBuilder("abc");
str.intern();

//直接使用字符串字面量xyz,其被放入运行时常量池
String str2 = "xyz";

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

  • 节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
  • 节省运行时间:比较字符串时,==比equals()快。对于两个引用变量,只用==判断引用是否相等,也就可以判断实际值是否相等。

方法区的实现

方法区只是JVM规范定义,而永久代为具体的实现,方法区的实现在虚拟机规范中并未明确规定,目前有2种比较主流的实现方式:

(1)HotSpot 虚拟机1.7-:在 JDK1.6 及之前版本,HotSpot 使用“永久代(permanent generation)”的概念作为实现,即将 GC 分代收集扩展至方法区。这种实现比较偷懒,可以不必为方法区编写专门的内存管理,但带来的后果是容易碰到内存溢出的问题(因为永久代有 -XX:MaxPermSize 的上限)。

在 JDK1.7+ 之后,HotSpot 逐渐改变方法区的实现方式,如 1.7 版本移除了方法区中的字符串常量池,放到了堆中,符号引用(Symbols)转移到了 Native Heap;字面量(interned strings)转移到了 Java heap;类的静态变量(class statics)转移到了 Java heap。

画外音:什么是字符串常量池?

在 JAVA 语言中有8中基本类型和一种比较特殊的类型String。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。

8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

(2)HotSpot 虚拟机 1.8+:1.8 版本中移除了方法区并使用 MetaSpace(元数据空间)作为替代实现,MetaSpace 存储类的元数据信息。MetaSpace 占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。

画外音:什么是类的元数据?

元数据是指用来描述数据的数据,更通俗一点,就是描述代码间关系,或者代码与其他资源(例如数据库表)之间内在联系的数据。

在一些技术框架,如struts、EJB、hibernate就不知不觉用到了元数据。

对struts来说,元数据指的是struts-config.xml;对EJB来说,就是ejb-jar.xml和厂商自定义的xml文件;对hibernate来说就是hbm文件。

JDK 1.8 结构图如下:

【JVM】JVM内存区域你了解吗?_jvm_07

画外音:直接内存是什么?

它并不是虚拟机运行时的数据区的一部分。是在NIO中基于通道和缓冲区的I/O方式,使用Native函数库直接分配堆外内存。避免了JAVA堆和Native堆中来回复制数据。和(操作系统中内存页的用户空间和系统空间的虚拟映象类似)

我们思考一个问题,为什么使用“永久代”并将 GC 分代收集扩展至方法区这种实现方式不好,会导致OOM?

首先要明白方法区的内存回收目标是什么,方法区存储了类的元数据信息和各种常量,它的内存回收目标理应当是对这些类型的卸载和常量的回收。

但由于这些数据被类的实例引用,卸载条件变得复杂且严格,回收不当会导致堆中的类实例失去元数据信息和常量信息。

因此,回收方法区内存不是一件简单高效的事情,往往 GC 在做无用功。

另外随着应用规模的变大,各种框架的引入,尤其是使用了字节码生成技术的框架,会导致方法区内存占用越来越大,最终 OOM。

两者结构上的区别

【JVM】JVM内存区域你了解吗?_堆_08

为什么 JDK 1.8 要把方法区从 JVM 里移到直接内存?

原因一:因为直接内存,JVM将会在 IO 操作上具有更高的性能,因为它直接作用于本地系统的 IO 操作。而非直接内存,也就是堆内存中的数据,如果要作 IO 操作,会先复制到直接内存,再利用本地 IO 处理。

  • 从数据流的角度,非直接内存是下面这样的作用链:本地 IO --> 直接内存 --> 非直接内存 --> 直接内存 --> 本地 IO。
  • 而直接内存是:本地 IO --> 直接内存 --> 本地 IO。

原因二:整个永久代有一个 JVM 本身设置固定大小上线,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。

  • 可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。
  • -XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

画外音:而且应该为 PermGen 分配多大的空间很难确定,因为 PermSize 的大小依赖于很多因素,比如 JVM 加载的 class 总数,常量池的大小,方法的大小等。

原因三: PermGen 中类的元数据信息在每次 FullGC 的时候可能被收集,但成绩很难令人满意

原因四:官方文档表示,移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代。

 

不管做什么,只要坚持下去就会不一样!