文章

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

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”。

报错

方法区大小由-XX:PermSize/-XX:MaxPermSize控制,如果放的类信息过多,会OutOfMemoryError: PermGen space

很多框架比如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一样大虽然该内存区域不受jvm制约,但它依然属于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:

  • https://stackoverflow.com/a/69495373/7676237
  • https://stackoverflow.com/a/23831296/7676237

默认内存参数

如果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

  • https://www.ibm.com/docs/en/ztpf/1.1.0.15?topic=options-xss-option
  • https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/jrdocs/refman/optionX.html
  • https://stackoverflow.com/a/4967914/7676237
  • https://stackoverflow.com/a/27324590/7676237

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;

线程的工作内存保存了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 进行授权