JVM 内存模型

JVM系列文章

  1. JVM 内存模型
  2. JVM 垃圾回收原理及实践

JVM 内存模型

本文讲述了

  1. JVM 运行时数据区的结构
  2. 如何模拟产生各种内存相关的异常: OutOfMemoryError 和 StackOverflowError
  3. 从 String.intern() 方法来看常量池的内存位置

JVM 的运行时数据区(内存模型)

Memory in JVM

  • 程序计数器

    • 每个线程独有
    • 当前线程的字节码的行号指示器
    • 如果执行 Native 方法的话,会是 undefined
    • 足够存放 returenAddress
  • Java 虚拟机栈

    • 每个线程独有
    • 描述的是Java方法执行的模型
    • 其占有的内存不必是连续的
    • JVM对栈的初始大小的实现可能是不同的
  • 本地方法栈

    • 每个线程独有
    • 为 JVM 执行的 native 方法服务
  • 方法区(元空间)

    • 所有线程共享
    • 存储单个类的结构信息
    • 较少发生垃圾收集行为
    • 非堆,通常与“堆”对应
    • JDK 8 之前也被称为永久代,但只是概念上的说法
  • 运行时常量池

    • 是方法区的一部分
    • 保存 Class 中的 constant_table 信息,主要是编译期生成的各种字面量和符号引用
    • 所有线程共享
    • 存放对象实例
    • 分代处理,主要是新生代与老年代等

内存异常模拟

在模拟之前先明确两个概念,在 JVM 里什么是内存泄漏和内存溢出。

内存泄漏

本意指的是用完了某对象但没有释放其空间。即借钱不还的情况。但是在Java中并不存在这种问题,因为JVM会通过垃圾回收清除掉不用的对象(不用指的是对象引用与GC Roots之间没有可达路径)。因此在 Java 中所说的内存泄漏通常指的是本该释放的对象因为使用不当致使其与GC Roots之间仍有可达路径,导致JVM一直无法回收这块内存,最终会产生 OutOfMemoryError。

内存溢出

本意指的是指使用了超出可使用范围内的内存空间。即银行只给100块,自己却非要1000块,这时会发生溢出。但此种类型的问题从表面上看并不会在Java中出现,因为 JVM 会检查溢出错误并以另一种形式抛出,比如 ArrayOutOfIndex。

JVM 参数

JVM 的参数配置可以在 JDK 的配置文档里找到,这里列出来几个我会用到的配置(部分配置在后续文章中会使用到,暂时可以先跳过),详细的参见文档:

  • -Xms 堆的初始大小
  • -Xmx 堆的最大空间
  • -Xss 线程的栈大小
  • -Xmn 堆的新生代大小
  • -XX:SurvivorRatio 新生代中 Eden 区与 Survivor 区的大小比
  • -XX:+UseSerialGC 使用Serial
  • -XX:MaxMetaspaceSize 元空间(即方法区,JDK6叫永久代)的最大值
  • -XX:MaxTenuringThreshold 对象晋升老年代的年龄阈值
  • -XX:PretenureSizeThreshold 对象可以直接在老年代分配的阈值

IDE 配置

使用 Intellij IDEA 可以在 Run Configuration 的 VM options 里进行设置

1.堆溢出

通过不断创建对象即可实现,注意对象须与GC Roots可达,即不会被GC回收掉
Code Gist

2.JVM栈溢出

通过增加方法的调用层数即可实现
Code Gist

3.方法区溢出

因为方法区市存放Class的相关信息,如类名/访问修饰符/常量池(JDK7之后已不在)/字段描述等,所以测试方法区溢出的思路是运行时产生大量的类去填满方法区,直到填满。

可以使用 CGLib 在运行时生成大量的类进行模拟
Code Gist

One one thing

关于 字符串常量池(String Pool)运行时常量池(Run-Time Constant Pool) 我认为在内容中指的是同一块地方,所以这里并不区分这两者的概念。

关于 字符串常量池的位置 到底在哪,网上的文章也是众说纷纭。大多持这样的观点:JDK6及以前,存放在永久代即方法区中,JDK7之后在堆中。

可是这种说法与 JVM 8 的规范却完全违背,JVM 8 的规范中是说明常量池为方法区的一部分,而方法区是堆的逻辑上的一部分(the method area is logically part of the heap)。所以比较可能的猜测应当是JDK6及之前常量池并没有实现为堆的一部分,而JDK7及之后放在了堆上。这种位置的变化导致了如下问题的出现:

1
2
3
4
5
6
7
public static void main(String[] args) {
String str1 = new StringBuilder("Test1").append("Test2").toString();
System.out.println(str1.intern() == str1);

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}

这段代码在 JDK8 上还输出 truefalse,即是因为:

String.intern() 方法会首先从常量池里找该字符串是否已出现过,若已有,则直接返回字符串的引用,若常量池中没有此字符串,则复制该对象的引用到常量池中并返回。 因为“java”是一个已经出现在常量池中的字符串(因为很常用),所以这里并不会使用对象的引用,而是使用了常量池中的引用,因此会返回false。

关于这个问题这篇文章描述的比较清楚。