当咱们在谈论内存的时候,咱们在谈论什么?

前言

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

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

内存是什么?

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

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

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

基础:Java内存结构

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

JVM内存分区

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

  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内存区域中的对象,须要先将它们拷贝到堆外内存再进行传递,会形成额外的空间和性能浪费),主要经过ByteBufferUnsafe两种方式来进行分配和使用。

    可是在使用时必定要注意,堆外内存是彻底不受GC控制的,也就是说和C++同样,须要咱们手动去分配和回收内存。

Java对象的内存结构

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

img

能够看到,一个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的内存空间就是0x0030 ~ 0x0033,根据这个CPU就能够很容易地获取到变量了。

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

@Test
public 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))。

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

基础:JVM GC

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

可回收对象的标记

GC的第一步是须要搞明白,当前究竟有哪些对象是能够被回收的。因为引用计数法在存在循环引用时没法正常标记,因此通常是采用 可达性分析算法 来标记究竟有哪些对象能够被回收,以下图所示:

垃圾回收器会从一系列的 GC Root 对象出发,向下搜索全部的对象,那些没法经过 GC Root对象达到的对象就是须要被回收的对象。 GC Root 对象主要包括如下几种:

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

垃圾收集算法

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

  1. 标记 - 清除法

    扫描一遍全部对象,并标记哪些可回收,而后清除,缺点是回收完会产生不少碎片空间,并且总体效率不高。

  2. 复制法

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

  3. 标记 - 整理法

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

对象分代

在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自己的特性所决定的(有关这个问题能够看下知乎上的讨论:传送门)。那么在平常编程中,有什么办法可让对象的内存被回收掉呢?

清除引用

根据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 expend
effort toward recycling unused objects in order to make the memory
they currently occupy available for quick reuse. When control
returns from the method call, the virtual machine has made
its best effort to recycle all discarded objects.
复制代码

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

所以,咱们的建议是,除非真的有必要,不然永远不要使用System.gc()

结论

综合上述内容,能够分析获得下面的结论:

  • Java的内存是被自动管理的
  • 没法经过手动的方式回收内存,由于这违反了Java语言设计的初衷
  • 能够经过减小变量做用域等方式帮助GC更好地工做
相关文章
相关标签/搜索