文章

JVM Memory Area

梳理 JVM 运行时数据区、堆外内存、常见 OOM 类型,以及 Java 内存模型里主内存和工作内存的可见性问题。

JVM Memory Area

Class文件是静态的协议,把里面的内容执行起来需要一个动态的环境。 JVM就像一个小型操作系统,把内部划分为了不同的区域,使用堆、栈等结构来保证程序的运行。

  1. Runtime Area
    1. PC - Program Counter
      1. 线程私有?true!
    2. Stack - 栈
      1. 线程私有?true!
      2. 报错
    3. Native Method Stack - 本地方法栈
      1. 线程私有?true!
      2. 报错
    4. Heap - 堆
      1. 线程私有?false!
      2. 报错
    5. Method Area - 方法区
      1. 线程私有?false!
      2. 报错
    6. 直接内存
      1. 报错
  2. 总内存占用
  3. Memory Usage
    1. 默认内存参数
    2. OOM
    3. OS OOM
  4. Memory Model

Runtime Area

先把大图放在这里,后面再挨个拆。JVM 运行时数据区最容易混的点不是名字,而是谁是线程私有的,谁是整个 JVM 共享的,以及各自 OOM 的姿势不一样

flowchart TB
    subgraph JVM["JVM Runtime Data Area"]
        subgraph T1["Thread-1 私有"]
            PC1["Program Counter\n当前线程执行到哪条字节码"]
            Stack1["Java Virtual Machine Stack\n方法调用栈/栈帧"]
            Native1["Native Method Stack\nnative 调用栈"]
        end

        subgraph T2["Thread-2 私有"]
            PC2["Program Counter"]
            Stack2["Java Virtual Machine Stack"]
            Native2["Native Method Stack"]
        end

        Heap["Heap\n对象实例,GC 主战场"]
        Method["Method Area / Metaspace\n类元数据、常量、静态信息"]
        Direct["Direct Memory\n堆外内存,属于 JVM 进程资源"]
    end

    T1 --> Heap
    T2 --> Heap
    T1 --> Method
    T2 --> Method
    Heap -. "DirectByteBuffer 引用" .-> Direct

    style T1 fill:#e3f2fd,stroke:#4dabf7
    style T2 fill:#e3f2fd,stroke:#4dabf7
    style Heap fill:#fff3bf,stroke:#f59f00
    style Method fill:#e8f5e9,stroke:#51cf66
    style Direct fill:#ffe3e3,stroke:#ff6b6b

线程私有的区域随着线程创建和销毁而来去;线程共享的区域则承载对象、类信息和堆外资源,也是排查内存问题时最常盯着看的地方。

PC - Program Counter

程序计数器。CPU的PC是一个Register,JVM的是一小块内存空间,但是功能是类似的,存储着线程当前执行的字节码的行号。

如果执行的是native方法,PC值为空。

PC是JVM中唯一不会OOM的区域……那毕竟就存一个地址。

线程私有?true!

PC是不是线程私有的?假设多个线程共享同一个PC,那当线程切换的时候,PC也得变。线程切回来,PC也得切回来。

JVM中,还是每个线程一个PC更合适。线程切换时,直接看这个线程的PC,就知道它该执行啥了。

这和CPU的PC不太一样。CPU就一个PC寄存器,存储着当前进程的下一条执行指令的位置,线程切换的时候,PC的值需要当做一个上下文保存起来。线程切换回来之后,再把之前保存的PC值恢复。CPU之所以这么搞,大概是因为CPU的PC是硬件,一个寄存器。CPU又不知道自己会运行多少个进程,也不可能在出厂的时候安装对应个数的PC寄存器。所以只能搞一个公用PC,运行时不停保存与切换,麻烦也没办法。

JVM维护着线程的数据结构,那么每个线程内部定义一个PC自然是比较科学且省事儿的设计。

所以JVM的PC和CPU的PC大概区别有:

  • JVM每个线程一个PC,CPU所有进程共用一个PC;
  • JVM的PC保存的是当前线程的当前执行字节码位置,CPU的PC保存的是当前进程的下一个要执行指令的位置;

Stack - 栈

栈就像 Linux 中的栈一样,用于保存该线程的私有信息。线程每调用一次方法,就创建一个栈帧(Stack Frame),保存该方法的局部变量等;方法结束,该栈帧也随之出栈。

JVM 的栈帧里保存有以下数据:

  • 局部变量表
  • 操作数栈(这个栈才是真正进行数值计算的操作栈)
  • 动态连接
  • 方法返回值

具体参考:Java Virtual Machine

线程私有?true!

栈必须是线程私有的。

报错

  • 线程请求的栈深度大于栈本身的大小 - StackOverflowError发生这种情况一般是程序写错了,导致无限递归。stackoverflow是stack特有的,因为它叫“stack” overflow……
  • 如果栈本身可动态拓展,但是无法从操作系统申请到足够的空间用于拓展 - OutOfMemoryError

栈容量由-Xss控制,每个线程都需要自己的栈空间,不停创建线程,就能让栈OutOfMemoryError: unable to create new native thread

Native Method Stack - 本地方法栈

和栈一样。只不过 Stack 是 JVM 调用 Java 方法(执行 Java 字节码)的时候用,而本地方法栈是调用 native 方法的时候用。

HotSpot JVM将本地方法栈和虚拟机栈合二为一了。

线程私有?true!

和Stack一样。

报错

  • StackOverflow
  • OutOfMemory

Heap - 堆

用于存放对象实例。所有创建的对象都在这里放着。

堆是GC的主要区域,所以也有了别名:Garbage Collected Heap。

堆的大小一般JVM都会实现为可扩展的,使用-Xms-Xmx来控制大小。(这两个参数只控制堆的大小,而不是整个JVM占用空间的大小

线程私有?false!

堆是所有线程共享的。

报错

OutOfMemoryError: java heap space。堆OOM比较容易,疯狂创建无法被立即释放的大对象即可。比如:

1
2
3
4
List<BigObject> list = ...;
while (true) {
    list.add(new BigObject());
}

如果设置了-XX:+HeapDumpOnOutOfMemoryError,会在oom时dump出当前内存的快照,用于事后分析。分析dump文件需要先加载dump文件,使用的内存比dump文件更大,所以需要在大内存机器上分析。在dump文件里,可以找到占内存最大的对象。

oom要分情况讨论:

  • 有可能是该释放的对象没释放导致了内存泄漏(Memory Leak)
  • 也有可能是本来就需要这么大的内存,只是单纯的内存溢出(Memory Overflow),此时需要把内存开大点儿

Method Area - 方法区

方法区用来存储虚拟机加载的类信息、常量、静态变量等等。class协议里的信息基本是都作为metadata放在这里了

JVM规范没有强制方法区进行垃圾回收,而且方法区回收的性价比不高,不像对Heap(尤其是Heap中的新生代)来一次垃圾回收,卓有成效,清理出非常多的空间。但是方法区并不是不回收,否则加载类太多了也会OOM,尤其是JSP、cglib等动态生成class的场景。

方法区(永久代)的回收主要涉及两部分内容:废弃常量,无用类。

运行时常量池之前也是方法区的一部分,对应class文件里的常量池,只不过class文件里的常量池是静态的内容,现在被加载到了内存里,变成了动态的。所以这里存放的内容和class文件一样,包括数值常量、字符串常量、符号引用(比如class info,引用字符串常量)

运行时常量池之所以一定要强调运行时,因为它是动态的,内容并不完全和class文件里的常量池一样。class文件里的常量池是编译时生成的,但是运行时常量池的常量也可以在运行时加入。比如String#intern(拘留,扣押),把字符串扣押到常量池内,好处是可以保证相同的string只创建一次,防止反复创建。到了Java1.7的时候,字符串常量池被移出方法区,放入堆中。

所以在jdk6的时候可以通过不断调用String#intern来导致方法区OOM:所有在堆中生成的字符串,都会被复制到方法区的运行时常量池,并返回它在方法区的引用。此时,运行时常量池里的字符串和堆里的字符串是两个地址不同的字符串

到了jdk7,方法区不再包含运行时常量池,后者也被放到了堆里。所有在堆中生成的字符串在调用String#intern时,只会把它的地址复制到运行时常量池,因此两个引用指向的是同一个字符串

线程私有?false!

方法区要放所有的类信息之类的,肯定是JVM里唯一的存在,被所有线程所共享。听起来有点儿像Heap,所以它有一个别名叫Non-Heap(非堆),用以说明“我虽然比较像heap,但不是heap”。

报错

在 JDK 6/7 的永久代实现里,方法区大小由-XX:PermSize/-XX:MaxPermSize控制,如果放的类信息过多,会OutOfMemoryError: PermGen space。JDK 8 之后 HotSpot 把永久代换成了 Metaspace,相关参数也变成了-XX:MetaspaceSize/-XX:MaxMetaspaceSize,但“类元数据太多也会撑爆”的问题并没有凭空消失。

很多框架比如Spring增强类的时候,会使用CGLib等字节码技术改变类,动态生成class。运行在JVM上的动态语言比如Groovy也会持续创建类来实现语言的动态性。所以使用这些语言,更应该关注方法区OOM的问题。

  • CGLib字节码增强;
  • 动态语言;
  • JSP(编译为Java类);
  • OSGi(同一个类文件被不同类加载器加载也会被视为不同的类);

直接内存

Java 1.4里的 NIO 引入了直接内存。它可以直接使用 native 函数分配堆外内存,然后在 heap 里创建一个DirectByteBuffer对象保存对 direct memory 的引用,避免在 JVM 堆和 native 内存之间来回复制数据。

报错

OutOfMemoryError直接内存由-XX:MaxDirectMemorySize控制,默认与-Xmx一样大虽然该内存区域不受 heap 约束,但它依然属于 JVM 进程的资源

direct memory 导致的 OOM 的明显特征是:heap dump文件很小,看不到明显的异常。因为 heap 可能用得很小。

总内存占用

Elasticsearch进程执行一段时间之后,进程占用的RAM经常远大于-Xmx的限定,因为-Xmx只能限制heap的大小,别忘了还有direct memory区域,Elasticsearch需要用它来存储Lucene索引数据和缓存。更何况,线程栈、方法区也要占用额外的RAM(虽然相对来说没那么多,但也不能完全无视啊……)。

Memory Usage

在linux上使用htop查看进程内存占用情况,发现系统显示的jvm内存占用已经达到了8G。但是使用jconsole查看,实际used只有3G左右,而committed是8G。那么linux观察到的jvm内存占用和实际内存占用有什么关系呢?

JDK的MemoryUsage展示了内存监控的几种指标:

  • init:比较好理解,就是-Xms指定的内存大小。jvm启动的时候就会向linux申请这么多内存;
  • used:实际使用的大小。就是jvm里所有存活object占用的内存大小;
  • committed:实际占用的系统内存大小,也就是在linux上观察到的jvm内存占用大小。比如jvm需要使用5g,就会向jvm申请这么多。之后jvm垃圾回收,只需要使用3g了,但jvm可能并没有从系统撤回内存占用,所以实际还是占用系统5g内存。
  • max:-Xmx,jvm再怎么向os申请内存,也不会申请超过-Xmx设定的最大内存值。

所以used肯定一直小于等于committed,而他们的上限就是max。但是used和committed未必大于init。当系统闲置占用的内存一段时间后,会释放一部分committed内存量

java memory很像virtualbox的动态分配的虚拟磁盘,二者都是逐渐申请更多资源,且不超过最大值。不同的是,committed是动态调整大小的,会缩小,而virtualbox申请的磁盘不会减少:逐渐占用物理硬盘的空间,直至达到分配的大小,不过当其内部空间不用时不会自动缩减占用的物理硬盘空间

ArrayList会随着元素的添加而增长,但不会随着元素的删除而缩短,除非手动调用trimToSize()

Ref:

默认内存参数

如果jvm启动的时候不设置内存参数(-Xms/-Xmx),jvm只能自己决定内存占用了。具体设置多少是根据系统的实际内存来决定的。所以归根结底还是设置了的,不如我们亲自设置好。

如果有gc log,可以看到一开始设置的initialHeapSizemaxHeapSize

1
2
3
Memory: 4k page, physical 131494708k(22189688k free), swap 8388604k(393376k free)
CommandLine flags: -XX:-BytecodeVerificationLocal -XX:-BytecodeVerificationRemote -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./java_pid<pid>.hprof -XX:InitialHeapSize=2103915328 -XX:+ManagementServer -XX:MaxGCPauseMillis=10 -XX:MaxHeapSize=32178700288 -XX:+PrintAdaptiveSizePolicy -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintTenuringDistribution -XX:TieredStopAtLevel=1 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC 
 0.225: [G1Ergonomics (Heap Sizing) expand the heap, requested expansion amount: 2105540608 bytes, attempted expansion amount: 2105540608 bytes]

OOM

如果已经申请到最大了,jvm内存还是不够放下object,那就是out of memory了。常见的oom类型:

  • heap oom:最常见的oom类型。创建的object实在是太多了,jvm申请的内存不够用;
    • java.lang.OutofMemoryError:Java heap space
  • stack oom:栈用来存储线程的局部变量表、操作数栈、动态链接、方法出口等信息,一般栈爆了就是递归写得出问题了,或者递归层级实在是太深了,比如动态规划的问题。另外因为每个线程的栈都是线程私有的,如果线程创建的太多,对栈空间的占用就会很多;
    • java.lang.StackOverflowError
    • java.lang.OutofMemoryError: unable to create new native thread
  • 永久代oom:常量、加载的类信息,如类名、访问修饰符、常量池、字段描述、方法描述等。如果这些信息太多了,也会OOM。一般发生在会动态生成代理类的应用中。
    • java.lang.OutofMemoryError: PermGen space

jvm使用-Xss设置一个线程能占用栈的最大值,用于线程内部使用,比如保存局部变量、运行状态。linux默认是1M大。如果线程内有递归等操作,超出1M,栈就会oom

OS OOM

如果linux系统内存不够用了,相当于linux系统OOM了,为了活下去,它会挑一个占用内存最大的应用kill掉。

如果jvm不幸被系统选中,kill掉,和jvm OOM是完全不同的:OOM指的是jvm内存达到申请的最大值了,还是放不下已有的object,被kill是jvm跑着跑着突然被系统干掉了

jvm由于是突然去世,来不及记录日志,所以从日志看不出任何端倪,此时只能看linux系统的日志,看看是不是把jvm给kill了

一般用dmesg查看系统log:

1
2
$ dmesg -T | grep -i "out of memory"
$ dmesg -T | grep -i "killed process"

详见:linux-dmesg

Memory Model

最后有必要提一下java内存模型。

现代cpu都有多核,每个核都有自己的cache,作为RAM的copy,用于提升访存的速度。但是这样就会出现一致性问题:RAM里的数据变了,cache还不知道。

为了屏蔽掉各种硬件和OS的内存访问差异,让Java在各个平台下都有一致的访存效果,Java定义了一套自己的内存模型。它和硬件缓存模型很类似:

  • 主内存(Main Memory):所有的变量都存储在main memory。相当于RAM;
  • 工作内存(Working Memory):每个线程都有自己的工作内存。相当于cache;
flowchart LR
    Main["Main Memory\n共享变量的主副本"]
    subgraph ThreadA["Thread A"]
        WA["Working Memory A\n变量副本"]
    end
    subgraph ThreadB["Thread B"]
        WB["Working Memory B\n变量副本"]
    end

    Main -->|"read / load"| WA
    WA -->|"write / store"| Main
    Main -->|"read / load"| WB
    WB -->|"write / store"| Main
    WA -. "普通变量修改\n不保证立刻可见" .-> WB
    WA ==>|volatile / synchronized<br/>建立 happens-before| Main

    style Main fill:#fff3bf,stroke:#f59f00
    style WA fill:#e3f2fd,stroke:#4dabf7
    style WB fill:#e3f2fd,stroke:#4dabf7

线程的工作内存保存了main memory的copy,所有对变量的操作都要在工作内存进行,不能直接读写main memory的变量。所以Java内存模型也要面对类似硬件的main memory和working memory的同步问题,普通变量不能做到一个线程修改完变量值(在自己的working memory),其他线程立即可见(在他们的working memory)。所以这样才会有volatile:看起来如同直接在main memory读写变量一样。所以volatile是jvm里最轻量的同步机制(指可见性,但是不能保证原子性)。以下两种场景,可以只用volatile,不必用锁:

  1. 运算结果不依赖变量当前值;或者能够确保只有单一线程修改变量值;
  2. 该变量不需要其他状态变量共同参与不变约束;

volatile还能禁止指令重排序。

jvm memory model还规定了一些原子操作,以保证一些内存访问操作在并发下是安全的。

本文由作者按照 CC BY 4.0 进行授权