# 前言

关于内存结构的第二篇:包含堆内存,方法区。

# 堆内存

Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。

JVM 把堆内存逻辑上划分为三个区域(划分的唯一理由就是优化 GC 性能):

  • 年轻代:新对象和没达到一定年龄的对象都在新生代。
  • 老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大。
  • 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存。

主流虚拟机都是可扩展的(通过 -Xmx-Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

# 年轻代

年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分 —— 伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为 from/to 或 s0/s1),默认比例是 8:1:1

  • 大多数新创建的对象都位于 Eden 内存空间中,此时 JVM 会给对象定义一个对象年轻计数器。( -XX:MaxTenuringThreshold )。
  • 当 Eden 空间被对象填(空间不足)时,执行 Minor GC,并将所有幸存者对象移动到一个幸存者空间中。
  • Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
  • 经过多次 GC 循环(每次 Minor GC 对象年龄 + 1)后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代。

对象超过了 -XX:PetenureSizeThreshold ,对象会直接被分配到老年代。默认是 15 次回收标记。

# 老年代

旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主 GC(Major GC),通常需要更长的时间。

大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝

在老年代,如果内存不足,触发 Major GC,进行内存清理。之后发现依然无法进行对象的保存,就会产生 OOM 异常。

# 元空间

不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。

虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开

# 设置堆内存大小和 OOM

堆的大小可以在 JVM 启动时确定,通过 -Xmx-Xms 设定:

  • -Xms 表示堆的起始内存,等价于 -XX:InitialHeapSize
  • -Xmx 表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

通常会将 -Xmx-Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能

  • 默认情况下,初始堆内存大小为:电脑内存大小 / 64
  • 默认情况下,最大堆内存大小为:电脑内存大小 / 4

也可能出现偏差,导致下面代码的内存并不符合这种计算。

可以通过代码获取到我们的设置值,当然也可以模拟 OOM:

public static void main(String[] args) {
  // 返回 JVM 堆大小
  long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
  // 返回 JVM 堆的最大内存
  long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
  System.out.println("-Xms : "+initalMemory + "M");
  System.out.println("-Xmx : "+maxMemory + "M");
  System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
  System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
}

# 查看 JVM 堆内存分配

  1. 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小
  2. 默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置
    • 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 来配置
  3. 若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy ,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄。此时 –XX:NewRatio-XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启 -XX:+UseAdaptiveSizePolicy 。在 JDK 8 中,不要随意关闭 -XX:+UseAdaptiveSizePolicy ,除非对堆内存的划分有明确的规划。

所以记住了比例也是然并卵对吗?

每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小

java -XX:+PrintFlagsFinal -version | grep HeapSize
    uintx ErgoHeapSizeLimit                         = 0                                   {product}
    uintx HeapSizePerGCThread                       = 87241520                            {product}
    uintx InitialHeapSize                          := 134217728                           {product}
    uintx LargePageHeapSizeThreshold                = 134217728                           {product}
    uintx MaxHeapSize                              := 2147483648                          {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

# 扩展:逃逸分析

随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么 “绝对” 了。 ——《深入理解 Java 虚拟机》

逃逸分析 (Escape Analysis) 是目前 Java 虚拟机中比较前沿的优化技术。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。
public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}
//sb 就可以被外部方法访问,即方法逃逸,如果还被外部线程访问了,就是线程逃逸。
// 如果不想 sb 逃逸出去,可以写成 sb.toString (),当然方法返回类型改为 String

使用逃逸分析,编译器可以对代码做优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配(这个可以有,而且优化效果会非常明显)。

JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

  • 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
public void keep() {
  Object keeper = new Object();
  synchronized(keeper) {
    System.out.println(keeper);
  }
}
// 当然,这种更考验开发人员写代码的水平,因为本来就不该给 keeper
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器

分离对象(标量替换)说直白点就是将对象拆分为标量。

标量(Scalar)是指一个无法再分解成更小的数据的数据,其他的就是聚合量。

通过 -XX:+EliminateAllocations 可以开启标量替换, -XX:+PrintEliminateAllocations 查看标量替换情况。

public static void main(String[] args) {
   alloc();
}
private static void alloc() {
   Point point = new Point1,2;
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

总结

** 虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。** 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK 1.6 才有实现,而且这项技术到如今也并不是十分成熟的。

# 方法区

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本 / 字段 / 方法 / 接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern() 方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryErro r 异常。

# 解惑

你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?

  • 方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT 编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
  • 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生 OOM(都会有溢出异常)。
  • Java7 中我们通过 -XX:PermSize-xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 用来设置元空间参数。
  • 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
  • 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出 OutOfMemoryError
  • JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)。

所以对于方法区,Java8 之后的变化:

  • 移除了永久代(PermGen),替换为元空间(Metaspace);
  • 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
  • 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
  • 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

# 方法区内部结构

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

# 类型信息

对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息

  • 这个类型的完整有效名称(全名 = 包名。类名)
  • 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.Object,都没有父类)
  • 这个类型的修饰符(public,abstract,final 的某个子集)
  • 这个类型直接接口的一个有序列表

# 域信息

  • JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
  • 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

# 方法信息

JVM 必须保存所有方法的

  • 方法名称
  • 方法的返回类型
  • 方法参数的数量和类型
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  • 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  • 异常表(abstract 和 native 方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

# 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,在加载类和结构到虚拟机后,就会创建对应的运行时常量池。

  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern() 方法就是这样的。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。

# 方法区的 GC

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

判定一个类型是否属于 “不再被使用的类”,需要同时满足三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是 “被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

# 参考与感谢

  • 作者:海星
  • 来源于:JavaKeeper

原作者参考内容如下

算是一篇学习笔记,共勉,主要来源:

《深入理解 Java 虚拟机 第三版》

宋红康老师的 JVM 教程

https://docs.oracle.com/javase/specs/index.html

https://www.cnblogs.com/wicfhwffg/p/9382677.html

https://www.cnblogs.com/hollischuang/p/12501950.html