这篇主要是关于主要是之前刷油管的时候看到过的一个视频,粗略看过一遍,感觉内容不错,不过之前学习的时候做的笔记已经不小心被我删了不少,所以这次有空又看了一遍,稍微总结一下。
Pre
在linux中查看进程的内存使用,并按内存使用降序排列:top -o %MEM
需要关注的列包含:
- virt
- res
实际上,还需要关注一下整个系统可用的空闲空间,后面会提到,可能会有意料之外的direct内存泄露。
如果需要更细致的看单个进程内的内存使用情况,linux中有一个pmap:pmap -x pid
以我的一个简单的Java应用为例,pmap的结果为
可以看到我限定了堆大小为1g,而第一个区域的大小就是1g,并且它的mapping为anonymous,可以猜测这一块就是堆,因为一般它是可以识别是什么进程/命令/文件映射占用了这一块内存。而最终这一个进程占用的总大小为
3598872kb,大概也就是3.4g这样。不过这里的这个是top里的virt内存:
可以看到是一致的,细心的能看出来这里的pid不一致,因为我重新跑了一下,不影响。 实际占用的内存,可以看到是res,也就是1.2g左右。
Java本身提供了针对JVM内部的内存追踪工具,也就是native memory tracking,主要收集的是在JVM内部的内存的信息,需要在应用启动时显式的指定开启:-XX:NativeMemoryTracking={summary|detail}
在应用运行期间,可以通过jcmd来获取当前时刻的Java应用的内存信息:jcmd pid VM.native_memory
这里需要提一嘴的就是,可用的jcmd工具可以通过jcmd 0 help来获取。
可以得到大概这样的结果:
这里会把内存的获取形式也分开来,像在Class这一类里就有malloc和mmap两种。
后面还有一大堆的类别,可以分为四大类:
- Java heap
- Class loading
- JIT compilation
- Threads
Java heap
这里主要指的是heap本身以及gc使用的额外的内存。
需要明确的是,xmx和xms指定的只有heap这一块区域的大小。其他诸如gc本身使用的额外的内存,是不算在里面的;比如g1,为了实现g1的算法,需要额外的数据结构比如remembered set、mark bitmap、mark stacks等等,这些也需要占用额外的内存。
一般来说,越高效的gc算法,一般会占用更多的额外内存,这也是体现出空间换时间的概念。
除了gc算法这一部分以外,还有另一点值得一提,那就是heap的xmx,xms指定了大小之后,运行jar包启动程序,但是实际上,此时JVM不会按照给定的参数来占用内存,比如:
我指定的xmx、xms都是1g,但是从res来看实际上只有220m左右的占用。 出现这种现象是由于操作系统的lazy physical memory allocation,或者说first-touch policy。也就是在使用系统调用申请物理内存的时候,操作系统通常不会立即分配物理内存,而是虚假的保留一块地址空间,直到真正对这一块区域进行访问的时候,操作系统才真正的分配物理内存。
这种现象可能会带来及其偶然的page fault,造成意外的错误。此时,可以通过指定一个jvm参数:-XX:+AlwaysPreTouch来让JVM在启动时遍历所有的page,来真正的让操作系统分配物理内存。
那么在加上这个参数之后,重新运行就可以看到:
此时的res就是接近1g的大小了,多出来的100m正好可以验证所说的额外的内存开销。
而xmx、xms的奥秘还在于,xms实际上并不是minimal size的意思,而是initial size。那么也即是说,其实JVM是可以在运行期间调整heap的大小的,即使heap的大小会小于xms,那么也是允许的。
这个实际上就是heap的adaptive size policy,可以通过-XX:-AdaptiveSizePolicy来强制关闭这种策略,那么可以确保heap在运行期间至少占用xms,且最多占用xmx。
Adaptive Size Policy
对于这个adaptive size policy,它就是在运行期间动态的调整heap大小,这也是有限制的,通过两个参数限制:
- -XX:MinHeapFreeRatio(=40)
- -XX:MaxHeapFreeRatio(=70) 一般的调优是不会动到这个东西的,了解一下即可。一般来说,只有针对于一些桌面应用比较有用,因为开启这个policy,可以让JVM将空闲的内存还给操作系统,那么这些内存就可以给其他的应用使用。
注意到上面的说法,开启这个只是允许JVM归还内存,实际上是否归还还得看gc算法的具体实现,比如说:
- Parallel即使开启了这个选项,也不会归还内存
- CMS在经过几次FullGC之后,可以逐渐归还内存
- g1可以在FullGC之后立刻归还几乎所有的空闲内存,这个关联一个jep 346
- shenandoad也允许归还几乎所有的空间
Class loading
主要就是类信息,大概如下图:
可以看到,分为metadata以及class space两块,这里的reserved内存也挺大的,但是实际占用并不是很多。
Metadata
result of parsing file。 可以再细分为
- classes
- methods
- constant pools
- symbols
- annotations 实际上,可以再归类为classes和class metadata(除了classes以外的所有)。
可以通过参数-XX:MaxMetaspaceSize控制大小,默认是没有限制的。早期的jdk中,这一块区域是和heap共享的,所以会有大小限制,很容易导致应用oom。
还有一点就是,Java提供了compressed class pointer,也就是压缩对象指针,来让对象的指针保持在32位,这个默认是开启的,而当它开启的时候,实际上
- classes
会被挪到另一个叫做Compressed Class Space的区域进行存放,并且这个区域是有大小限制的,关联到参数
-XX:CompressedClassSpaceSize,默认为1g,最大为3g。
所以,如果没有关闭压缩指针,那么如果加载了过多的类,会导致出现oom:compressed class space这样的异常出现。
Classloader statistics
可以通过jcmd来获取到类加载器的一些信息,比如:jcmd pid VM.classloader_stats,可以得到这样的结果:
可以看到每种类加载器加载了多少类,占用了多少空间。底下也可以看到,这里给出的是占用的metaspace的空间。
在jdk15之前,有一个jcmd工具叫GC.class_stats可以看到每个类的具体信息,比如:
但是在jdk14的时候这一个工具被deprecated,jdk15正式被删除。因为jdk开发者认为这个工具比较少使用,并且依赖一个全局safepoint,overhead比较高,所以被移除掉了。我也没有找到一个替代的东西。
如果只是单纯的好奇类的实例数量以及占用空间等,可以使用一个GC.class_histogram,大概是这么个效果,比较简单一点:
言归正传,metaspace实际上也可以看作是就一块,只是在开启压缩指针的情况下,classes这部分会单独计算一块区域来存放,并且有大小限制。
有一个比较疑惑的参数:-XX:MetaspaceSize,这并不是minimal/maximum的参数,这是一个水位线参数,也就是当metaspace的占用到达这一个值的时候会触发一次FullGC。
JIT compilation
这一块也可以分为两部分,分别是code和compiler。如下图
Compiler
在code cache存放的就是被jit编译过后的方法代码,除此之外,code cache还可以存放一些解释器编译的代码以及一些runtime stubs。所以,code cache:
- compiled methods
- interpreter, stubs (method) code cache大小关联到两个参数:
- -XX:InitialCodeCacheSize 初始大小
- -XX:ReservedCodeCacheSize 最大大小
还有一部分叫做compiler的,这一部分实际上和gc一样,这一部分内存是用来实现jit的一些必要的额外内存,比如说IR graph等等。
这里要提到的就是Graal,Graal是没有compiler这部分的,它的这些额外信息是存储在heap当中的,因为Graal本身也是一个Java应用。
由于tiered compilation的存在,在HotSpot中,实际的code cache大小会是在ReservedCodeCacheSize的5倍这样。如果关掉tiered compilation,那么就是原本ReservedCodeCacheSize的大小。
过小的code cache会影响到应用的性能,因为jit编译后的方法以及解释器编译的方法,这两者之间的转换是有开销的,并且频繁的替换code cache里的内容也是有开销的,这会非常的影响CPU。
Threads
主要就看这部分:
Thread stacks
涉及到参数-Xss,这里配置每个线程可用的栈的大小,最小是200k,默认是1m。 不过由于前面提到的,操作系统的lazily allocation的存在,所以大部分时间来说,committed != resident。
Symbol
主要包含symboltable以及stringtable这两个内置的map:
这也是通过jcmd看到的结果,jcmd pid VM.{stringtable|symboltable}。
symboltable存放的主要是variable names,method signature等,stringtable存放的就是interned strings。
可以看到上面的图,stringtable一共占用了2m多的空间。
这是一个关于如何打印出这些信息的一个问题: stackoverflow.com/q/35238902/…
Other
还有另一块比较有意思的点:
这里实际上代表的就是off-heap的内存空间,比如说:
- Direct ByteBuffers
- Unsafe.allocateMemory 诸如此类,也就是所谓的直接内存。
在jdk11之后,这一类内存被划分到了other区域:
但是其实在nmt的报告中,仍然能看到Internal这一块,此时,这两块区域分别代表:
- Internal:JVM内部本身使用的native memory
- Other:上面指出的两种方法使用的直接内存
所以,需要关注的就是Other这一块区域。
Direct ByteBuffers
分为:
- ByteBuffer.allocateDirect
通过
-XX:MaxDirectMemorySize限制,默认和-Xmx一致。但是实际上,这一个内存和heap没有任何关系。 - FileChannel.map 没有大小限制,并且在nmt报告中并不会追踪这一块区域的使用。但是在pmap中是可以看到各个mapping的rss的。
Java本身也提供了BufferPoolMBean来监测这一块的使用。
Reclamation of Direct ByteBuffers
一般来说,JVM会在gc之后自动释放没有被使用的direct bytebuffer,但是也会存在一种情况:在gc之前,direct bytebuffer就达到了limit,此时,JVM会显式的调用System.gc来等待gc的发生。
所以,如果通过参数禁用了显式的gc,这种算法就不会生效。关联两个参数:
- -XX:+DisableExplicitGC 禁用显式gc
- -XX:+ExplicitGCInvokesConcurrent 启用显式gc
那么在禁用显式gc的时候,allocation失败,就会报oom。
Is Heap ByteBuffer better
当然不会。因为诸如这样的:
byte[] array = new byte[1024];
...
socketChannel.write(ByteBuffer.wrap(array));
Java不能直接通过heap buffer来实现io。所以每次通过heap buffer来进行read/write的时候,JVM会先分配一个temporary direct buffer,然后将数据复制到direct buffer里面,再从direct buffer复制到heap buffer中,再把direct buffer释放掉。 所以,使用heap buffer大致的流程是:
- allocate temp direct buffer
- write to direct buffer
- release temp direct buffer
所以,在heap dump文件中,可以看到会有一个internal cache of direct buffer,所以这个buffer本身也是会占用heap空间的。
其实在jdk本身的类里就可以看到这个cache,定位到sun.nio.ch.Util这个类就可以看到有个明晃晃的bufferCache:
由于这是一个存在于heap里的实例对象,所以这个bufferCache是永远都不会变小的。
并且由于这里可以看到是一个ThreadLocal,所以在很多线程同时使用byteBuffer来间接使用这里的cache的时候,会有一些问题。因为:
- 这是一个per-thread的缓存,缓存的是DirectByteBuffer,用来减少重复的allocation
- 由于这是一个thread-local,并且每个BufferCache内部实际上还是一个Buffer数组,所以每个线程都会持有这个pool of buffers的引用,直到线程被terminated
那么就会导致:
- 这些direct memory变成了thread-private的,即使是在空闲期间也无法被使用
- JVM占用的native memory也就是other类会随着线程数量线性增长
也就是会造成内存空间的浪费,并且这些cached direct buffer是算入到-XX:MaxDirectMemorySize里面的,所以可能会因为这个导致意料之外的FullGC。更多的可以自行了解,这里就是提一嘴。
不过,jdk9之后有一个应用参数:-Djdk.nio.maxCachedBufferSize,这个可以控制进入缓存的直接内存的大小,详见:
www.oracle.com/java/techno…
Memory Allocator
如果不做任何的修改,默认的内存获取都是通过malloc来实现的。malloc每次会从操作系统获取一个大小为64m(PROMT_NONE)的large chunk作为reserved。后续的mprotect都会占用一部分,这样很容易就会造成内存碎片的现象,导致空间的浪费。
这就是标准malloc的问题,容易造成空间浪费。所以对于频繁使用直接内存的应用,建议就是替换malloc实现,一般推荐的都是jemalloc,像这样:
即可,并且jemalloc内置了一个profiler,可以用于观测memory allocation行为。
Conclusion
所以总结一下,大概可以这么划分
Java process memory footprint = sumUp(
1. heap (-xmx)
2. code cache (-XX:ReservedCodeCacheSize)
3. gc and compiler structures (unlimited)
4. metaspace (-XX:MaxMetaspaceSize)
5. symbol tables/string tables (unlmited)
6. thread stacks (-xss)
7. direct buffers (-XX:MaxDirectBufferSize)
8. mapped files (unlimited)
9. native libraries (unlimited)
10. malloc overhead (unlimited)
11. ...
)
那么这里就是把一些参数和一些区域给关联起来了,大伙也可以自查一下,毕竟这只是一个简短的总结,了解一下即可。
所以,实际上是很难预测一个Java应用到底会使用多少内存,只能说通过一些工具来进行检测,来得知部分区域的使用情况。不过更重要的是先要知道,一个Java进程到底会有哪些占用内存空间的部分,这样在后续排查问题或者学习的时候,心里才更有底。
当然,本文更多的是作为一个引子,内容准确与否也需要各位自行判断,毕竟自己花时间去查过才是真正学到手的知识。