专栏名称: 朱小厮的博客
著有畅销书:《深入理解Kafka》和《RabbitMQ实战指南》。公众号主要用来分享Java技术栈、Golang技术栈、消息中间件(如Kafka、RabbitMQ)、存储、大数据以及通用型技术架构等相关的技术。
目录
相关文章推荐
广东疾控  ·  每天运动 VS ... ·  2 天前  
51好读  ›  专栏  ›  朱小厮的博客

当我们在谈论内存时,我们在谈论什么

朱小厮的博客  · 公众号  ·  · 2020-02-18 08:51

正文

点击上方“朱小厮的博客”,选择“ 设为星标”

后台回复” 加群 “获取公众号专属群聊入口


来源:阿里巴巴中间件


内存,是程序员绕不过的一道坎。写过 C 和 C++ 的人想必都会对内存的手动分配和释放难以忘怀,在 Java 中,得益于 JVM 的自动垃圾回收( GC )机制,大部分情况下编程并不需要关心内存的分配与回收。当然,有了 GC 并不意味着就完事大吉了,如果不了解其中的原理,以错误的姿势滥用 GC ,很有可能翻车酿成不可估量的损失。


在经历过一次严重的线上故障之后,本文试图深入分析 JVM 的内存管理机制,探索如何监控和避免内存使用量过高的场景出现。难免有错误之处,还请各位指正。



内存是什么?



这个问题看似很好回答:内存不就是一块存放运行时数据的空间么。但,真的只是这么简单吗?

当你在编写代码时,你是否真正感受到过它的存在?当你不知不觉创建出一个巨大的缓存对象时,是否思考过它会占用多少内存,又将在何时被回收?我相信大多数的 Java 程序员在编写代码时不会思考这些问题,这一方面证明了 JVM 自动内存管理机制设计的成功,但另一方面也说明 GC 正在不知不觉中被滥用。

对于程序员而言,内存究竟是什么呢?在编写代码时(开发态),内存是指针,是引用,也是偏移地址,这是我们在代码中能够直接与内存打交道的三种最常见的方式;在代码运行时(运行态),内存是 GC 频率,是 GC 时长,也是机器的水位,这是实际运维过程中最需要关注的三个指标。这些便是内存在实际开发中的存在形式,不管你是否注意的到,都必须承认,内存无处不在。


基础: Java内存结构



回到 Java 本身,要想真正了解内存,必须先从 JVM 本身的内存机制入手,首先简单地回顾下 JVM 内存结构。

JVM内存分区


JVM中将运行时数据(内存)划分为五个区域:

1、程序计数器
程序计数器是一块线程私有的内存区域,它是当前线程所执行的字节码的行号指示器。简单来说,它记录的是当前线程正在执行的虚拟机字节码指令(如果是 Native 方法,该值为空)。

一般我们很少关心这个区域

2、 Java 虚拟机栈
Java 虚拟机栈是一块线程私有的内存区域,它总是和某个线程关联在一起,每当创建一个线程时, JVM 就会为其创建一个对应的 Java 虚拟机栈,用于存储 Java 方法执行时用到的局部变量表、操作数栈、动态链接、方法出口等信息。

一般我们也不怎么需要关心这个区域

3、本地方法栈
本地方法栈是为 JVM 运行 Native 方法使用的空间,它也是线程私有的内存区域,它的作用与上一小节的Java虚拟机栈的作用是类似的。除了代码中包含的常规的 Native 方法会使用这个存储空间,在 JVM 利用 JIT 技术时会将一些 Java 方法重新编译为 NativeCode 代码,这些编译后的本地方法代码也是利用这个栈来跟踪方法的执行状态。

这也是一个不怎么需要关注的区域

4、 Java 堆
JVM 管理内存中最大的一块,也是 JVM 中最最最核心的储存区域,被所有线程所共享。我们在 Java 中创建的对象实例就储存在这里,堆区也是 GC 主要发生的地区。

这是我们最核心关注的内存区域

5、方法区
用于储存类信息、常量、静态变量等可以被多个对象实例共享的数据,这块区域储存的信息相对稳定,因此很少发生 GC 。在 GC 机制中称其为“永生区”( Perm,Java 8 之后改称元空间 Meta Space )。

由于方法区的内存很难被 GC ,因此如果使用不当,很有可能导致内存过载。

这是一块常常被忽略,但却很重要的内存区域。

6、堆外内存
堆外内存不是由 JVM 管理的内存,但它也是 Java 中非常重要的一种内存使用方式, NIO 等包中都频繁地使用了堆外内存来实现“零拷贝”的效果(在网络 IO 处理中,如果需要传输储存在 JVM 内存区域中的对象,需要先将它们拷贝到堆外内存再进行传递,会造成额外的空间和性能浪费),主要通过 ByteBuffer 和 Unsafe 两种方式来进行分配和使用。

但是在使用时一定要注意,堆外内存是完全不受 GC 控制的,也就是说和 C++ 一样,需要我们手动去分配和回收内存。


Java 对象的内存结构



进一步的,我们需要了解一下在 JVM 中,一个对象在内存中是如何存放的,如下图:


可以看到,一个 Java对象在内存中被分为4个部分:

1、Mark Word(标记字段):
对象的 Mark Word 部分占4个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。

2、Class Pointer( Class 对象指针):
指向对象所属的 Class 对象,也是占用 4 个字节( 32 位JVM)。

3、对象实际数据:
包括对象的所有成员变量(注意 static 变量并不包含在内,因为它是属于 class 的),其大小由具体的成员变量大小决定,如 byte 和 boolean 是一个字节,int 和 float 是 4 个字节,对象的引用则是 4 个字节( 32 位 JVM )。

4、对齐填充:
为了对齐 8 个字节而增设的填充区域,这是为了提升 CPU 读取内存的效率,详细请看:什么是字节对齐,为什么需要字节对齐?

下面来举一个简单的例子:
public class Int {public int val;}




    
这个类实际占用的内存是 4 (mark word) + 4 (class ref)+ 4(int)+ 4(padding)= 16 字节。这其实正是Integer自动装箱的对象所占用的内存空间大小,可以看到封装成对象后,其占用的内存体积相比原来增加了 4 倍。

在了解了这些知识之后,让我们来思考一个问题:


议题: 如何计算一个对象占用的内存大小?



在编写代码的过程中我们会创建大量的对象,但你是否考虑过某个对象到底占用了多少内存呢?

在 C/C++ 中,我们可以通过 sizeof() 函数方便地计算一个变量或者类型所占用的内存大小,不过在 Java 中并没有这样的系统调用,但这并不意味着在 Java 中就无法实现类似的效果,结合上一节中分析的 Java 对象内存结构,只要能够按照顺序计算出各个区域所占用的内存并求和就可以了。当然这里面是有非常多的细节问题要考虑的,我们一个一个来分析。

首先需要说明的一点是,在不同位数的 JRE 中,引用的大小是不一样的(这个很好理解,因为引用储存的就是地址偏移量),32Bit 的 JRE 中一个引用占用 4 个字节,而 64Bit 的 JRE 中则是 8 个字节。

先看对象头,在不开启 JVM 对象头压缩的情况下, 32Bit JRE 中一个对象头的大小是8个字节(4+4), 64Bit 的 JRE 中则是 16 个字节(8+8)。

接下来就是实例数据,这里包括所有非静态成员变量所占用的数据,成员变量主要包括两种:基本类型和引用类型。在确定的 JRE 运行环境中,基本类型变量和引用类型占用的内存大小都是确定的,因此只需要简单的通过反射做个加法似乎就可以了。不过实际情况并没有这么简单,让我们做一个简单的实验来看一看:

实验: 对象的实际内存布局


通过jol工具可以查看到一个对象的实际内存布局,现在我们创建了一个如下所示的类:
class Pojo {  public int a;  public String b;  public int c;  public boolean d;  private long e; // e设置为私有的,后面讲解为什么  public Object f;  Pojo() { e = 1024L;}}

使用 jol 工具查看其内存布局如下:

 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE      0    12                    (object header)                           N/A     12     4                int Pojo.a                                    N/A     16     8               long Pojo.e                                    N/A     24     4                int Pojo.c                                    N/A     28     1            boolean Pojo.d                                    N/A     29     3                    (alignment/padding gap)     32     4   java.lang.String Pojo.b                                    N/A     36     4   java.lang.Object Pojo.f
                                N/A


这里由于我的本地环境开启了对象头压缩,因此对象头所占用的大小为(4+8)=12字节。从这个内存布局表上不难看出,成员变量在实际分配内存时,并不是按照声明的顺序来储存的,此外在变量 d 之后,还出现了一块用于对齐内存的 padding gap ,这说明计算对象实际数据所占用的内存大小时,并不是简单的求和就可以的。


考虑到这些细节问题,我们需要一些更有力的工具来帮助我们精确的计算。


Unsafe & 变量偏移地址


在上面的内存布局表中,可以看到 OFFSET 一列,这便是对应变量的偏移地址,如果你了解 C/C++ 中的指针,那这个概念就很好理解,它其实是告诉了 CPU 要从什么位置取出对应的数据。举个例子,假设 Pojo 类的一个对象p存放在以 0x0010 开始的内存空间中,我们需要获取它的成员变量 b ,由于其偏移地址是 32(转换成十六进制为20),占用大小是 4 ,那么实际储存变量b的内存空间就是 0 x0030 ~ 0x0033 ,根据这个 CPU 就可以很容易地获取到变量了。


实际上在反射中,正是通过这样的方式来获取指定属性值的,具体实现上则需要借助强大的 Unsafe 工具。Unsafe 在 Java 的世界中可谓是一个“神龙不见首”的存在,借助它你可以操作系统底层,实现许多不可意思的操作(比如修改变量的可见性,分配和回收堆外内存等),用起来简直像在写 C++ 。 不过也正因为其功能的强大性,随意使用极有可能引发程序崩溃,因此官方不建议在除系统实现(如反射等)以外的场景使用,网上也很难找到Unsafe的详细使用指南(一些参考资料),当然这并不影响我们揭开它的神秘面纱,接下来就看看如何通过变量偏移地址来获取一个变量。

@Testpublic void testUnsafe() throws Exception {  Class> unsafeClass = null;  Unsafe unsafe = null;  try {    unsafeClass = Class.forName("sun.misc.Unsafe");    final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");    unsafeField.setAccessible(true);    unsafe = (Unsafe) unsafeField.get(null);  } catch (Exception e) {    // Ignore.  }  Pojo p = new Pojo();  Field f = Pojo.class.getDeclaredField("e");  long eOffset = unsafe.objectFieldOffset(f); // eOffset = 16  if (eOffset > 0L) {    long eVal = unsafe.getLong(p, eOffset);    System.out.println(eVal); // 1024  }}

出于安全起见,一般情况下在正常的代码中是无法直接获取 Unsafe 的实例的,这里我们通过反射的方式hack了一把来拿到 unsafe 实例。接着通过调用 objectFieldOffset 方法获取到成员变量 e 的地址偏移为 16(和 jol 中的结果一致),最终我们通过 getLong() 方法,传入 e 的地址偏移量,便获取到了 e 的值。可以看到尽管 Pojo 类中 e 是一个私有属性,通过这种方法依然是可以获取到它的值的。


有了 objectFieldOffset 这个工具,我们就可以通过代码精确的计算一个对象在内存中所占用的空间大小了,代码如下(参考自 apache luence )


计算 shallowSize


public long shallowSizeOf(Object o) {Clazz> c = o.getClass(); // 对应的类// 初始大小:对象头  long shallowInstanceSize = NUM_BYTES_OBJECT_HEADER;




    
for (Class> c = clazz; c != null; c = c.getSuperclass()) {// 需要循环获取对象所继承的所有类以遍历其包含的所有成员变量final Field[] fields = c.getDeclaredFields();for (final Field f : fields) {// 注意,f的遍历顺序是按照声明顺序,而不是实际储存顺序if (!Modifier.isStatic(f.getModifiers())) {// 静态变量不用考虑final Class> type = f.getType();// 成员变量占用的空间,如果是基本类型(int,long等),直接是其所占空间,否则就是当前JRE环境下引用的大小final int fsize = type.isPrimitive() ? primitiveSizes.get(type) : NUM_BYTES_OBJECT_REF;// 通过unsafe方法获取当前变量的偏移地址,并加上成员变量的大小,得到最终成员变量的偏移地址结束值(注意不是开始值)final long offsetPlusSize =          ((Number) objectFieldOffsetMethod.invoke(theUnsafe, f)).longValue() + fsize;// 因为储存顺序和遍历顺序不一致,所以不能直接相加,直接取最大值即可,最终循环结束完得到的一定是最后一个成员变量的偏移地址结束值,也就是所有成员变量的总大小        shallowInstanceSize = Math.max(shallowInstanceSize, offsetPlusSize);      }    }  }// 最后进行内存对齐,NUM_BYTES_OBJECT_ALIGNMENT是需要对齐的位数(一般是8)  shallowInstanceSize += (long) NUM_BYTES_OBJECT_ALIGNMENT - 1L;return shallowInstanceSize - (shallowInstanceSize % NUM_BYTES_OBJECT_ALIGNMENT);}
到这里我们计算出了一个对象在内存布局上所占用的空间大小,但这并不是这个对象所占用的实际大小,因为我们还没有考虑对象内部的引用所指向的那些变量的大小。类比Java 中深浅拷贝的概念,我们可以称这个内存大小为 shallowSize,即“浅内存占用”。

计算 deepSize

计算出一个对象占用的shallowSize之后,想要计算它的deepSize就很容易了,我们需要做的便是递归遍历对象中所有的引用并计算他们指向的实际对象的shallowSize,最终求和即可。考虑到会有大量重复的类出现,可以使用一个数组来缓存已经计算过shallowSize的class,避免重复计算。

特别地,如果引用指向了数组或者集合类型,那么只需要计算其基本元素的大小,然后乘以数组长度/集合大小即可。

具体实现代码在此不过多赘述,可以直接参考源代码( from Apache luence ,入口方法为 sizeOf ( Object ))。

源代码:
https://github.com/MarkLux/Java-Memory-Monitor/blob/master/src/main/java/cn/marklux/memory/RamUsageEstimator.java

需要注意的是,这种计算对象内存的方法并不是毫无代价的,由于使用了递归、反射和缓存,在性能和空间上都会有一定的消耗。


基础: JVM GC



研究完了开发态的内存,我们再来看看运行态的内存,对于 Java 程序员而言,运行态我们核心关注的就是 JVM 的 GC 了,先来回顾一些基本知识:

可回收对象的标记


GC 的第一步是需要搞明白,当前究竟有哪些对象是可以被回收的。由于引用计数法在存在循环引用时无法正常标记,所以一般是采用 可达性分析算法 来标记究竟有哪些对象可以被回收,如下图所示:


垃圾回收器会从一系列的 GC Root 对象出发,向下搜索所有的对象,那些无法通过 GC  Root 对象达到的对象就是需要被回收的对象。GC Root 对象主要包括以下几种:

  • 方法中局部变量区中的对象引用
  • Java 操作栈中对象引用
  • 常量池中的对象引用
  • 本地方法栈中的对象引用
  • 类的 Class 对象

垃圾收集算法


GC 的第二步是将所有标记为可回收的对象所占用的空间清理掉,这里有几种算法:

标记 - 清除法
扫描一遍所有对象,并标记哪些可回收,然后清除,缺点是回收完会产生很多碎片空间,而且整体效率不高。

复制法
将内存划分为相等的两块,每次只使用其中一块。当这一块内存用完时,就将还存活的对象复制到另一块上面,然后将已经使用过的内存空间一次清理掉。缺点是对内存空间消耗较大(实际只用了一半),并且当对象存活概率较高的时候,复制带来的额外开销也很高。


标记 - 整理法
将原有标记-清除算法进行改造,不是直接对可回收对象进行清理,而是让所有存活对象都向另一端移动,然后直接清理掉端边界以外的内存。


对象分代


在 JVM ,绝大多数的对象都是 朝生夕死 的短命对象,这是 GC 的一个重要假设。对于不同生命周期的对象,可以采用不同的垃圾回收算法,比如对寿命较短的对象采用复制法,而对寿命比较长的对象采用标记-整理法。为此,需要根据对象的生命周期将堆区进行一个划分:

1、新生代( Young 区)
储存被创建没多久的对象,具体又分为 Eden 和 Survivor 两个区域。所有对象刚被创建时都存在 Eden 区,当 Eden 区满后会触发一次 GC ,并将剩余存活的对象转移到 Survivor 区。为了采用复制法,会有两个大小相同的 Survivor 区,并且始终有一个是空的。

新生代发生的 GC 被称为 Young GC 或 Minor GC,是发生频率最高的一种 GC 。

2、老年代( Old 区)
存放 Young 区 Survivor 满后触发 minor GC 后仍然存活的对象,当 Eden 区满后会将存活的对象放入 Survivor 区域,如果 Survivor 区存不下这些对象, GC 收集器就会将这些对象直接存放到 Old 区中,如果 Survivor 区中的对象足够老,也直接存放到 Old 区中。

如果 Old 区满了,将会触发 Major GC 回收老年代空间。

3、永生代( Perm 区, Java 8 后改为 MetaSpace 元空间)
主要存放类的 Class 对象和常量,以及静态变量,这块内存不属于堆区,而是属于方法区。Perm 区的 GC 条件非常苛刻,以一个类的回收为例,需要同时满足以下条件才能够将其回收:
  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;

  • 加载该类的 ClassLoader 已经被回收;

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。


GC指标

如果你查阅过 JVM GC 相关的文章,会发现 GC 经常被分为三种:发生在新生代的 Minor GC(Young GC)、发生在老年代的 Major GC、和发生在整个内存区域的 Full GC。事实上JVM官方并没有对 Full GC 和 Major GC 这两种 GC 进行明确的定义,所以也没有必要纠结。

不论是 Minor GC 还是 Full GC ,绝大多数的GC算法都是会暂停所有应用线程的(STW),只不过 Minor GC 暂停的时间很短,而 Full GC 则比较长。

由于 GC 对实际应用线程是存在影响的,所以在实际运维中,我们需要一些外部指标来评估 GC 的情况,以判断当前应用是否“健康”。一般来说, GC 的两个重要指标是:

  • GC 时间:由于 GC 是 STW 的,所以在 GC 时间内整个应用是处于暂停状态的。
  • GC 频率:单位时间内 GC 发生的次数。

那么对于一个应用而言, GC 时间和 GC 频率处于什么水平才是正常的?这由应用本身的需要来决定,我们可以从下面三个维度来评估:

1、延迟(latency):一次完整操作完成的时间。

比如某交易系统,要求所有的请求在 1000ms 内得到响应。假设 GC 的时间占比不超过总运行时间的 10% ,那就要求 GC 时间都不能超过 100ms 。






请到「今天看啥」查看全文