1. Overview为什么java程序消耗的内存,远超-Xms、-Xmx的限制?因为各种原因,或是为了进行某些优化,JVM会额外分配内存。这些额外的分配,会导致java程序占用的内存,超出-Xmx的限制。
本文档列举了通常情况下,JVM会分配哪几部分内存,以及各部分调整大小的方法。然后,了解如何使用Native Memory Tracking工具进行监控。
2. Native Allocations通常,heap是java程序占用内存中的最大的一部分,但也有特例。除了heap,JVM会分配很大一块内存,用于存储metadata、application code、the code generated by JIT、internal data structures等等。如下章节,对各部分进行详述。
2.1. Metaspace
JVM使用专用的non-heap区域,存储已加载类的元数据。java 8版本以前,此区域称为PermGen or Permanent Generation。此区域存储的是类的元数据,而不是类的实例,实例是存储在heap内存中的。
对heap内存的限制,是无法影响Metaspace的。如果要调整Metaspace,要使用如下标志:
-XX:MetaspaceSize,最小值
-XX:MaxMetaspaceSize,最大值
Java 8版本以前,使用-XX:PermSize、-XX:MaxPermSize,含义是一样的。
2.2. Threads
另一个消耗内存较大的部分是stack。创建线程时,同时创建stack。stack存储局部变量和中间结果,在方法调用中扮演重要角色。
线程stack的默认大小与平台相关,但是,对于现在大多的64位操作系统来说,大约是1MB。此值可以通过-Xss调整。
创建的线程越多,此部分占用的内存越多。
另外需要注意的是,JVM本身也需要一些线程,用于执行内部操作,如:GC、JIT编译。
2.3. Code Cache
为了在不同平台运行JVM字节码,需要将其转换成机器指令。程序运行时,JIT编译器负责这个编译工作。当JVM将字节码编译为汇编指令时,它将这些指令存储在一个特殊non-heap区域:Code Cache。Code Cache可以像JVM的其他数据区域一样被管理。控制此区域的大小,使用如下两个指令:
-XX:InitialCodeCacheSize,初始值
-XX:ReservedCodeCacheSize,最大值
2.4. Garbage Collection
JVM附带了一些GC算法,每个算法都适用于不同的用例。所有这些GC算法都有一个共同的特征:他们需要一些堆外数据结构来执行任务。这些内部数据结构,会消耗一些内存。
2.5. Symbols
让我们从Strings开始,这是最常用的数据类型之一。因为其使用频率很高,Strings通常会占用较大一部分heap内存。如果大量的Strings包含相同的内容,那么会造成heap内存的浪费。
为了节省内存,对于每一个String可以仅存一个副本,然后其他的指向该副本。这个过程称为字符串驻留(String Interning)。JVM仅可以驻留编译时的字符串常量(Compile Time String Constants),我们可以对strings手动调用intern()方法以实现驻留。
JVM将驻留的strings存储在一个专用的固定大小的hashtable中,称为String Table,也称为String Pool。可以通过如下标志调节其大小:-XX:StringTableSize。
除了String Table,还有一个内存区域称为运行时常量池(Runtime Constant Pool),JVM使用这个池来存储一些必须在运行时解析的常量,如编译时数值常量或方法和字段引用。
2.6. Native Byte Buffers
JVM通常是大量内存占用的可疑对象,但有时开发人员也可以直接分配内存。最常见的方式是:
- malloc call by JNI;
- NIO's direct ByteBuffers;
2.7. Additional Tuning Flags
本章节使用了一些JVM调节标志。使用如下命令,可以找到几乎所有的、关于特定概念的调节标志。
java -XX:+PrintFlagsFinal -version | grep <concept>
PrintFlagsFinal会打印出JVM中所有的-XX标志。例如,找出所有关于Metaspace的标志:
java -XX:+PrintFlagsFinal -version | grep Metaspace
// truncated
uintx MaxMetaspaceSize = 18446744073709547520 {product}
uintx MetaspaceSize = 21807104 {pd product}
// truncated
我们知道了JVM中消耗内存的几个源头,现在就来看看如何监视它们。首先,启用NMT,在启动命令中加入如下标志即可:
-XX:NativeMemoryTracking=off|sumary|detail
NMT默认是关闭的。
假设,我们想要跟踪一个典型的SpringBoot应用程序:
java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar
3.1. Instant Snapshots
开启NMT后,可以使用如下命令,随时获取本地内存占用信息。其中表示java进程的id。
jcmd <pid> VM.native_memory
下面详解NMT命令的输出内容。
3.2. Total Allocations
NMT显示总的预留内存、已提交内存:
Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB
预留内存表示我们的应用程序可能使用的内存总量。已提交内存表示应用程序当前使用的内存。
尽管仅为应用程序分配了300MB内存,但总的预留内存近1.7GB。类似的,已提交内存近440M。这两个数据都比300MB多了很多。
除了整体的内存占用信息外,NMT还报告了各个源头占用内存的情况。下面章节详述。
3.3. Heap
heap内存占用情况显示如下:
Java Heap (reserved=307200KB, committed=307200KB)
(mmap: reserved=307200KB, committed=307200KB)
预留内存、已提交内存均为300MB,符合我们对heap内存的设置。
3.4. Metaspace
已加载类的元数据内存占用信息如下:
Class (reserved=1091407KB, committed=45815KB)
(classes #6566)
(malloc=10063KB #8519)
(mmap: reserved=1081344KB, committed=35752KB)
加载了6566个class,预留内存差不多1G,提交内存45M。
3.5. Thread
线程内存分配情况如下:
Thread (reserved=37018KB, committed=37018KB)
(thread #37)
(stack: reserved=36864KB, committed=36864KB)
(malloc=112KB #190)
(arena=42KB #72)
37个线程,stack内存共计36M,差不多每个线程的stack占用1M。JVM在创建线程时,同时分配stack内存,所以预留内存和提交内存是一样的。
3.6. Code Cache
JIT生成并缓存的汇编指令的内存占用情况:
Code (reserved=251549KB, committed=14169KB)
(malloc=1949KB #3424)
(mmap: reserved=249600KB, committed=12220KB)
当前,大概13M的内存占用,可能会增加到大约245M(预留内存)。
3.7. GC
G1 GC内存占用情况如下:
GC (reserved=61771KB, committed=61771KB)
(malloc=17603KB #4501)
(mmap: reserved=44168KB, committed=44168KB)
预留内存大概60M。
Serial GC是一个简单的多的方法,当使用此方法时,配置方法如下:
java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar
内存占用情况如下,仅仅用了1M:
GC (reserved=1034KB, committed=1034KB)
(malloc=26KB #158)
(mmap: reserved=1008KB, committed=1008KB)
当然,我们不能仅根据内存消耗来决定选择什么GC算法,因为Serial GC的“stop-the-world”特性,可能会导致性能下降。
3.8. Symbol
symbol内存占用情况如下,如string table和constant pool:
Symbol (reserved=10148KB, committed=10148KB)
(malloc=7295KB #66194)
(arena=2853KB #1)
大概占用10M。
3.9. NMT over Time
NMT使得我们可以跟踪内存占用情况。首先,要记录应用程序当前的内存占用情况,做为基线。命令如下:
$ jcmd <pid> VM.native_memory baseline
Baseline succeeded
然后,过一段时间,可以将当前内存占用与基线做比较:
$ jcmd <pid> VM.native_memory summary.diff
NMT通过+ -符号,表示这段时间内,内存占用的变化情况:
Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
- Java Heap (reserved=307200KB, committed=307200KB)
(mmap: reserved=307200KB, committed=307200KB)
- Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated
预留内存、提交内存分别增长了3M、6M。内存分配中的其他波动也可以很容易地发现。
3.10. Detailed NMT
NMT可以提供关于整个内存空间占用情况的非常详细的信息。要显示详细信息,要使用如下标志:
-XX:NativeMemoryTracking=detail
我们列举了JVM中内存占用的不同类别。然后,我们学习了如何监控一个正在运行的应用程序的内存占用情况。有了这些,我们可以更有效地调整运行时环境的大小。
关于JIT
对于Java语言:
一、你可以说它是编译型的:因为所有的Java代码都是要编译的,.java不经过编译就什么用都没有。
二、你可以说它是解释型的:因为java代码编译后不能直接运行,它是解释运行在JVM上的,所以它是解释运行的,那也就算是解释的了。
三、但是,现在的JVM为了效率,都有一些JIT优化。它又会把.class的二进制代码编译为本地的代码(汇编)直接运行,所以,又是编译的。
像C、C++ 他们经过一次编译之后直接可以编译成操作系统了解的类型,可以直接执行的,所以他们是编译型的语言。没有经过第二次的处理。
而Java不一样,他首先由编译器编译成.class类型的文件,这个是java自己类型的文件 然后再通过虚拟机(JVM)从.class文件中读一行解释执行一行,所以他是解释型的语言,而由于java对于多种不同的操作系统有不同的JVM,所以,Java实现了真正意义上的跨平台!
JIT:Just In Time Compiler,一般翻译为即时编译器,这是是针对解释型语言而言的,而且并非虚拟机必须,是一种优化手段,Java的商用虚拟机HotSpot就有这种技术手段,Java虚拟机标准对JIT的存在没有作出任何规范,所以这是虚拟机实现的自定义优化技术。
HotSpot虚拟机的执行引擎在执行Java代码是可以采用【解释执行】和【编译执行】两种方式的,如果采用的是编译执行方式,那么就会使用到JIT,而解释执行就不会使用到JIT,所以,早期说Java是解释型语言,是没有任何问题的,而在拥有JIT的Java虚拟机环境下,说Java是解释型语言严格意义上已经不正确了。
HotSpot中的编译器是javac,他的工作是将源代码编译成字节码,这部分工作是完全独立的,完全不需要运行时参与,所以Java程序的编译是半独立的实现。有了字节码,就有解释器来进行解释执行,这是早期虚拟机的工作流程,后来,虚拟机会将执行频率高的方法或语句块通过JIT编译成本地机器码,提高了代码执行的效率。
--结束--