Java 虚拟机在执行 Java 程序时会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途及创建和销毁的时间,有的区域随虚拟机进程的启动而一直存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:
image.png
JVM 规范:https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-2.html#jvms-2.5

堆(Heap)是虚拟机所管理的内存中最大的一块,它是被所有线程共享的一块内存区域。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都会在这里分配内存。在《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例及数组都应当在堆上分配,但从实现的角度来看,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换等优化手段已经导致一些微妙的变化悄然发生,所以说 Java 对象实例都分配在堆上也不是那么绝对了(不同虚拟机要考虑其对象分配的实现)。

从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以在堆中经常会出现:新生代、老年代、永久代、Eden、Survivor 等名词,但这些区域划分仅仅是一部分垃圾收集器的共同特性或设计风格而已,而非 Java 虚拟机具体实现的固有内存布局。

根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。对于大对象(典型的如数组对象),多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。堆的大小通常被设计为可扩展的,通过参数 -Xmx 和 -Xms 设定,ms 表示 memory start,mx 表示 memory max,分别代表最小堆容量和最大堆容量。如果在堆中没有足够内存来完成实例的分配,并且堆也无法再扩展时,Java 虚拟机将抛出 OutOfMemoryError 异常。

1. TLAB

通常来说,当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。但由于堆空间是线程共享的区域,直接在里边划空间是需要进行同步的,否则有可能出现两个对象共用一段内存的情况。Java 虚拟机为解决这个问题提供了 TLAB(Thread Local Allocation Buffer)技术。对应参数为 -XX:+UseTLAB,默认开启。

具体来说就是每个线程可以向 Java 虚拟机申请一段连续的内存作为线程私有的 TLAB。这个申请的操作是需要加锁的,线程会维护两个指针,一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。之后 new 指令便可以直接通过指针加法来实现内存分配了,即把指向空余内存位置的指针加上所请求的字节数。
image.png
从图中可以看出 TLAB 仍然在堆上。其中 start、end 就是起始地址,top 表示已经分配到哪里了。当我们分配新对象时 JVM 就会移动 top,当 top 和 end 相遇则表示该缓存已满,JVM 会试图再从堆里分配一块儿。

TLAB 的作用主要是用来优化多线程下的对象分配效率。但无论怎么划分区域,堆中存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

2. Java 对象都创建在堆上吗?

有一些观点认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是这取决于 JVM 设计者的选择。目前 Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确在 Hotspot 虚拟机中,所有的对象实例都是创建在堆上的。

在 JDK 7 以前,Intern 字符串的缓存和静态变量曾经都被分配在永久代上,而在 JDK 7 以后永久代已经被元数据区所取代。但 Intern 字符串缓存和静态变量没有被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

方法区

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

1. 永久代

在 JDK 8 以前,很多人会把方法区称呼为永久代(Permanent Generation)或将两者混为一谈。但本质上这两者并不是等价的,仅仅是当时的 HotSpot 虚拟机设计团队使用永久代来实现方法区而已,目的是让 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。对于其他虚拟机实现,如 JRockit、IBM J9 等来说,是不存在永久代的概念的。

现在回头来看,当年使用永久代来实现方法区并不是一个好主意,这导致了 Java 应用更容易遇到内存溢出的问题,因为永久代有 -XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限就不会出问题。

2. 元空间

在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量移到了堆中,而到了 JDK 8 则完全废弃了永久代的概念,改用本地内存实现的 元空间(Metaspace)来代替,并把 JDK 7 中永久代剩余的内容(主要是类元信息)全部移到元空间中。

关于元空间的 JVM 参数有两个:-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize,其中 MaxMetaspaceSize 用于设置元空间的最大值,由于元空间采用本地内存实现,若不指定大小,默认会耗尽所有系统可用内存。

3. 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。既然运行时常量池是方法区的一部分,自然也受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,即并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

字符串常量池的实现:

  1. public static void main(String[] args) {
  2. String str1 = new StringBuilder("计算机").append("软件").toString();
  3. System.out.println(str1.intern() == str1);
  4. }

这段代码在 JDK 6 中运行会得到 false,而在 JDK 7 中运行会得到 true。原因在于 JDK 6 中,intern() 方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由 StringBuilder 创建的字符串对象实例在 Java 堆上,所以必然不可能是同一个引用,结果将返回 false。

而 JDK 7 的 intern() 方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到 Java 堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例就是同一个。

4. 回收方法区

《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集。通常垃圾收集行为在这个区域是比较少出现的,因为方法区垃圾收集的性价比通常比较低:在 Java 堆尤其是在新生代中,进行一次垃圾收集通常可回收 70%~99% 的内存空间,而方法区回收由于苛刻的判定条件,其回收比率往往远低于此。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,主要回收两部分内容:废弃的常量不再使用的类型。回收废弃常量与回收 Java 堆中的对象非常类似:假如一个字符串“java”曾经进入常量池中,但当前系统已没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个常量。如果此时发生内存回收且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。

判定一个常量是否是废弃常量还是相对简单的,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了,需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并非和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息。如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值应为空。此内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

1. 虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程生命周期相同。Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行时,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在虚拟机执行引擎运行时,所有指令都只能针对当前栈帧进行操作。
image.png
虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束肯定会跳转到另一个栈帧上。在执行过程中,如果出现异常会进行异常回溯,返回地址通过异常处理表确定。

在《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展(HotSpot 虚拟机的栈容量是不可以动态扩展的),当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。

2. 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。与虚拟机栈一样,本地方法栈也会在栈溢出或栈扩展失败时抛出 StackOverflowErrorOutOfMemoryError 异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域,但这部分内存也会被频繁使用,而且也可能导致 OutOfMemoryError 异常,所以放在一起讲解。

在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制。在配置虚拟机参数时,可通过 -XX:MaxDirectMemorySize 参数来控制直接内存的大小。该值默认与 Java 堆最大值(由 -Xmx 指定)一致。

通常的垃圾收集日志并不包含 Direct Buffer 等信息,在 JDK 8 之后的版本,我们可以方便地使用 Native Memory Tracking(NMT)特性来进行诊断,你可以在程序启动时加上下面参数:

  1. -XX:NativeMemoryTracking={summary|detail}

注意,激活 NMT 通常都会导致 JVM 出现 5%~10% 的性能下降。

首先通过 -XX:NativeMemoryTracking=summary 开启 NMT 并选择 summary 模式,为了能够方便地获取和对比 NMT 输出,选择在应用退出时打印 NMT 统计信息。

  1. -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

然后,执行一个简单的在标准输出打印 HelloWorld 的程序,就可以得到下面的输出:

  1. Native Memory Tracking:
  2. Total: reserved=5706356KB, committed=462432KB
  3. - Java Heap (reserved=4194304KB, committed=262144KB)
  4. (mmap: reserved=4194304KB, committed=262144KB)
  5. - Class (reserved=1066114KB, committed=14210KB)
  6. (classes #526)
  7. (malloc=9346KB #189)
  8. (mmap: reserved=1056768KB, committed=4864KB)
  9. - Thread (reserved=20597KB, committed=20597KB)
  10. (thread #20)
  11. (stack: reserved=20480KB, committed=20480KB)
  12. (malloc=60KB #110)
  13. (arena=56KB #37)
  14. - Code (reserved=249635KB, committed=2571KB)
  15. (malloc=35KB #322)
  16. (mmap: reserved=249600KB, committed=2536KB)
  17. - GC (reserved=163627KB, committed=150831KB)
  18. (malloc=10383KB #131)
  19. (mmap: reserved=153244KB, committed=140448KB)
  20. - Compiler (reserved=133KB, committed=133KB)
  21. (malloc=2KB #28)
  22. (arena=131KB #7)
  23. - Internal (reserved=9490KB, committed=9490KB)
  24. (malloc=9458KB #1607)
  25. (mmap: reserved=32KB, committed=32KB)
  26. - Symbol (reserved=1512KB, committed=1512KB)
  27. (malloc=960KB #156)
  28. (arena=552KB #1)
  29. - Native Memory Tracking (reserved=46KB, committed=46KB)
  30. (malloc=4KB #45)
  31. (tracking overhead=42KB)
  32. - Arena Chunk (reserved=899KB, committed=899KB)
  33. (malloc=899KB)
  • 第一部分是 Java 堆,不再赘述。

  • 第二部分是 Class 内存占用,它所统计的就是 Java 类元数据所占用的空间,JVM 可以通过 -XX:MaxMetaspaceSize 参数调整其大小。

  • 第三部分是 Thread,这里既包括 Java 线程,如程序主线程、Cleaner 线程等,也包括 GC 等本地线程。

  • 第四部分是 Code 统计信息,显然这是 CodeCache 相关内存,也就是 JIT compiler 存储编译热点方法等信息的地方,JVM 提供了 -XX:InitialCodeCacheSize 和 -XX:ReservedCodeCacheSize 等参数来进行控制。

  • 第五部分是 GC,像 G1 等垃圾收集器其本身数据结构非常复杂和庞大,例如 Remembered Set 通常都会占用 20%~30% 的堆空间。

  • Compiler 部分,就是 JIT 的开销,显然关闭 TieredCompilation 会降低内存使用。

  • Internal(JDK 11 以后在 Other 部分)部分,其统计信息包含着 Direct Buffer 的直接内存,这是堆外内存中比较敏感的部分,原则上 Direct Buffer 是不推荐频繁创建或销毁的。