查看原文
其他

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

脚本之家 2021-06-29

The following article is from 阿里巴巴中间件 Author 远岩

  脚本之家

你与百万开发者在一起

本文经授权转自公众号 阿里巴巴中间件(ID:Aliware_2018)

如若转载请联系原公众号

前言


内存,是程序员绕不过的一道坎。写过 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 倍。

有关 Java 对象的内存结构,更详细地可以参考这篇文章,在此不过多赘述了。

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


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



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

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

2、吞吐量(Throughput):单位时间内需要处理完成的操作数量。

仍然以上面的交易系统为例,要求每分钟至少可以处理 1000 个订单,并且 GC 的时间占比不能超过总运行时间的 10% ,那就意味着每分钟的 GC 时间总和不能超过 6s 。
假设单次 GC 的耗时为 50ms ,进一步转换即可得到对 GC 频率的要求为每分钟不超 120 次。

因为每分钟需要完成 1000 次操作,那就意味着平均每9次操作可以触发一次 GC ,这就进一步转换成了对局部变量产生速率的要求。

3、系统容量(Capacity):是在达成吞吐量和延迟指标的情况下,对硬件环境的额外约束。

一般来说是硬件指标,比如某系统要求必须能够部署在 2 核 4G 的服务器实例上,且能够满足延迟和吞吐量的需要。
结合具体的硬件指标和 JVM 特性可以进一步估算得到对 GC 的要求。


议题:如何回收对象占用的内存?



这是一个很有意思的问题,通过上面的分析不难看出,在 JVM 中内存的回收是自动的,并不受程序员手动控制,这是由 GC 本身的特性所决定的那么在日常编程中,有什么办法可以让对象的内存被回收掉呢?

有关这个问题可以看下知乎上的讨论:
https://www.zhihu.com/question/21663879?spm=ata.13261165.0.0.46c63700YRVfps

清除引用


根据 GC 算法的原则,只要一个对象处于“不可达”的状态,就会被回收,因此想要回收一个对象,最好的办法就是将指向它的引用都置为空。当然,这意味着在编码时你需要清晰地知道自己的对象都被哪些地方所引用了。

从这个角度出发,我们在日常编写代码的时候要尽量避免创建不必要的引用。

那么,为了达到清除引用的效果,是不是应该在不需要对象的后,手动将引用置为null呢?让我们看下面这段代码:
public void refTest() { Pojo p = new Pojo(); // ... do something with p // help the gc (?) p = null;}
实际上,由于 p 是在 refTest() 域内声明的局部变量,方法执行完毕后就会被自动回收了,并没有必要将 p 特意设置为 null ,这样做对 GC 的帮助微乎其微。

尽量使用局部变量


想要让一个对象尽快被回收,那就需要尽可能地缩短它的生命周期,最好让它能够在 Young 区的 Minor GC 中被销毁,而不是存活到 suvivor 区甚至是老年代。从这个角度出发,能够使用局部变量的时候就尽量使用局部变量,缩小变量的作用域,以便其能被快速回收。

尽量少用静态变量


静态变量是一种特殊的存在,因为它并不存放在堆区,而是被存放在方法区。通过上文的分析可以看到方法区的 GC 条件是十分苛刻的,所以静态变量一旦被声明了,就 很难被回收,这要求我们在代码中尽量克制地使用静态变量。

一般来说,静态变量本身不会占用很多的空间,但它可能包含很多指向非静态变量的引用,这就会导致那些被引用的变量也无法被回收,久而久之引发内存不足。如果你定义的静态变量中包含了数组和集合类,那就要格外注意控制它的大小,因为这些内存都是很难被回收掉的。

System.gc() ?


这似乎是目前Java中唯一一个可以由代码主动触发 GC 的调用,不过这个调用并不一定会真的发起gc。来看一下官方对于 System.gc() 的定义:

Calling this method suggests that the Java virtual machine expendeffort toward recycling unused objects in order to make the memorythey currently occupy available for quick reuse. When controlreturns from the method call, the virtual machine has madeits best effort to recycle all discarded objects.


 expend effort 说明这个调用并不会保证一定发生 GC。此外,System.gc() 调用所触发的 GC 是一次 Full GC ,如果在代码中频繁调用 Full GC ,那么后果可想而知。

因此,我们的建议是,除非真的有必要,否则永远不要使用 System.gc() 。


结论



综合上述内容,可以分析得到下面的结论:

  • Java的内存是被自动管理的
  • 无法通过手动的方式回收内存,因为这违反了Java语言设计的初衷
  • 可以通过减少变量作用域等方式帮助GC更好地工作



作者信息:

刘明昊,花名远岩,现任职阿里巴巴B2B事业部服务端研发 ,长期聚焦于Serverless,云原生及系统编程领域,目前主要研究JVM中的服务运行沙箱构建。


本文缩略图:icon by Victoria


- END -



更多精彩


在公众号后台对话框输入以下关键词

查看更多优质内容!


女朋友 | 大数据 | 运维 | 书单 | 算法

大数据 | JavaScript | Python | 黑客

AI | 人工智能 | 5G | 区块链

机器学习 | 数学 | 送书

●  硬盘太慢!内存太慢!网络太慢!全靠我来拯救! 

●  脚本之家粉丝福利,请查看!

●  人人都欠微软一个正版?

● 内存都没了,还能运行程序?

 小白都能看得懂的java虚拟机内存模型

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存