Flink系列分享第一篇 初识Flink内存优化

896 阅读7分钟

问题引入

Flink的特性: 高吞吐、低延时、高性能

Flink 是如果做到这些特性???

Flink 是Java语言实现的一个大数据计算框架,既然是Java语言,那一定绕不开 JVMGC

Java语言在内存管理方面存在的问题
  • Java 对象存储密度低。一个只包含 boolean 属性的对象占用了16个字节内存:对象头占了8个,boolean 属性占了1个,对齐填充占了7个。而实际上只需要一个bit(1/8字节)就够了。
  • Full GC 会极大地影响性能,尤其是为了处理更大数据而开了很大内存空间的JVM来说,GC 会达到秒级甚至分钟级。
  • OOM 问题影响稳定性。OutOfMemoryError是分布式计算框架经常会遇到的问题,当JVM中所有对象大小超过分配给JVM的内存大小时,就会发生OutOfMemoryError错误,导致JVM崩溃,分布式框架的健壮性和性能都会受到影响。
  • 缓存未命中问题 : CPU 进行计算的时候,是从 CPU 缓存中获取数据。 现代体系的CPU 会有多级缓存,而加载的时候是以 Cache Line 为单位加载。如果能够将对象连续存储,这样就会大大降低 Cache Miss。使得 CPU 集中处理业务,而不是空转。(Java 对象在堆上存储的时候并不是连续的,所以从内存中读取 Java 对象时,缓存的邻近的内存区域的数据往往不是 CPU 下一步计算所需要的,这就是缓存未命中。 此时 CPU 需要空转等待从内存中重新读取数据。)

Flink如何克服以上问题呢???

Flink内存管理

Flink 并不是将大量对象存在堆上,而是将对象都序列化到一个预分配的内存块上,这个内存块叫做 MemorySegment,它代表了一段固定长度的内存(默认大小为 32KB),也是 Flink 中最小的内存分配单元,并且提供了非常高效的读写方法。它的底层可以是一个普通的 Java 字节数组(byte[]),也可以是一个申请在堆外的 ByteBuffer。每条记录都会以序列化的形式存储在一个或多个MemorySegment中。

自定义序列化框架和比较器

Java 生态圈提供了众多的序列化框架:Java serialization, Kryo, Apache Avro 等等

对于大多数流式数据计算的场景中,由于处理的数据其实格式是一致的,所有对于同一个数据集可以只保存一份数据格式SCHEME,通过数据和元数据SCHEME分离,可以极大的节省存储空间,数据在MemorySegment中可以通过固定偏移位置查找,所有序列化数据反系列化或者查找某些数据的时候,不需要反系列化整个java对象;这样可以带来速度上的大大优化。

Flink能够自动识别数据类型。Flink 通过 Java Reflection 框架分析基于 Java 的 Flink 程序 UDF (User Define Function)的返回类型的类型信息,通过 Scala Compiler 分析基于 Scala 的 Flink 程序 UDF 的返回类型的类型信息。

  • BasicTypeInfo: 任意Java 基本类型(装箱的)或 String 类型。
  • BasicArrayTypeInfo: 任意Java基本类型数组(装箱的)或 String 数组。
  • WritableTypeInfo: 任意 Hadoop Writable 接口的实现类。
  • TupleTypeInfo: 任意的 Flink Tuple 类型(支持Tuple1 to Tuple25)。Flink tuples 是固定长度固定类型的Java Tuple实现。
  • CaseClassTypeInfo: 任意的 Scala CaseClass(包括 Scala tuples)。
  • PojoTypeInfo: 任意的 POJO (Java or Scala),例如,Java对象的所有成员变量,要么是 public 修饰符定义,要么有 getter/setter 方法。
  • GenericTypeInfo: 任意无法匹配之前几种类型的类。

前六种Flink自动生成对应的TypeSerializer,能非常高效地对数据集进行序列化和反序列化,GenericTypeInfo 比较特殊 Flink会使用Kryo进行序列化和反序列化

image-20210803114901402.png

缓存加速

随着磁盘IO和网络IO越来越快,CPU逐渐成为了大数据领域的瓶颈。从 L1/L2/L3 缓存读取数据的速度比从主内存读取数据的速度快好几个量级。通过性能分析可以发现,CPU时间中的很大一部分都是浪费在等待数据从主内存过来上。如果这些数据可以从 L1/L2/L3 缓存过来,那么这些等待时间可以极大地降低,并且所有的算法会因此而受益。

堆外内存

为什么使用堆外内存,能带来那些好处?

  • 启动超大内存(上百GB)的JVM需要很长时间,GC停留时间也会很长(分钟级)。使用堆外内存的话,可以极大地减小堆内存(只需要分配Remaining Heap那一块),使得 TaskManager 扩展到上百GB内存不是问题。
  • 高效的 IO 操作,堆外内存在写磁盘或网络传输时是 zero-copy,而堆内存的话至少需要 copy 一次(JVM机制)。
  • 堆外内存是进程间共享的。也就是说,即使JVM进程崩溃也不会丢失数据。

带来的问题也有

  • 堆内存的使用、监控、调试都要简单很多。堆外内存意味着更复杂更麻烦。自己管理内存,当发生内存溢出时排查起来非常困难。
  • 短生命周期的 MemorySegment,在堆上会更廉价。
  • 堆内存速度更快

如何使用Java 堆外内存: 未公开的Unsafe和NIO包下ByteBuffer。

Flink 将原来的 MemorySegment 变成了抽象类,并生成了两个子类。HeapMemorySegmentHybridMemorySegment。从字面意思上也很容易理解,前者是用来分配堆内存的,后者是用来分配堆外内存和堆内存的。

image-20210803122630375.png

 public abstract class MemorySegment {  
  // 堆内存引用  
  protected final byte[] heapMemory;  
  // 堆外内存地址  
  protected long address;    
  //堆内存的初始化  
  MemorySegment(byte[] buffer, Object owner) {    
    //一些先验检查    
    ...    
    this.heapMemory = buffer;    
    this.address = BYTE_ARRAY_BASE_OFFSET;    
    ...  
  }  
  //堆外内存的初始化  
  MemorySegment(long offHeapAddress, int size, Object owner) { 
    //一些先验检查    
    ...    
    this.heapMemory = null;   
    this.address = offHeapAddress;   
    ...  
  }    
  
  public final long getLong(int index) {    
    final long pos = address + index;    
    if (index >= 0 && pos <= addressLimit - 8) {      
      // 这是我们关注的地方,使用 Unsafe 来操作 on-heap & off-heap      
      return UNSAFE.getLong(heapMemory, pos);    
    }else if (address > addressLimit) {      
      throw new IllegalStateException("segment has been freed");    
    }else {
      // index is in fact invalid      
      throw new IndexOutOfBoundsException();   
    }  
  }  
  
  ...
}


public final class HeapMemorySegment extends MemorySegment {  
  // 指向heapMemory的额外引用,用来如数组越界的检查  
  private byte[] memory;  
  
  // 只能初始化堆内存  
  HeapMemorySegment(byte[] memory, Object owner) {    
    super(Objects.requireNonNull(memory), owner);    
    this.memory = memory;  }  
    ...
  }

  public final class HybridMemorySegment extends MemorySegment {  
    
    private final ByteBuffer offHeapBuffer;    
    
    // 堆外内存初始化  
    HybridMemorySegment(ByteBuffer buffer, Object owner){
       super(checkBufferAndGetAddress(buffer), buffer.capacity(), owner);
       this.offHeapBuffer = buffer;  }   
    
    // 堆内存初始化  
    HybridMemorySegment(byte[] buffer, Object owner) {    
      super(buffer, owner);    this.offHeapBuffer = null;  
    }  
    
    ...
  }

为了提升性能,flink 实现了两种方案

方案1:只能有一种 MemorySegment 实现被加载

代码中所有的短生命周期和长生命周期的MemorySegment都实例化其中一个子类,另一个子类根本没有实例化过(使用工厂模式来控制)。那么运行一段时间后,JIT 会意识到所有调用的方法都是确定的,然后会做优化。final 修饰 所有的方法调用都可以被去虚化(de-virtualized)和内联(inlined),这可以极大地提高性能

方案2:提供一种实现能同时处理堆内存和堆外内存

这就是 HybridMemorySegment 了,能同时处理堆与堆外内存,这样就不需要子类了。这里 Flink 优雅地实现了一份代码能同时操作堆和堆外内存。这主要归功于 sun.misc.Unsafe提供的一系列方法,如getLong方法:sun.misc.Unsafe.getLong(Object reference, long offset)

  • 如果reference不为空,则会取该对象的地址,加上后面的offset,从相对地址处取出8字节并得到 long。这对应了堆内存的场景。
  • 如果reference为空,则offset就是要操作的绝对地址,从该地址处取出数据。这对应了堆外内存的场景。

下一节 重点介绍Flink的内存模型和内存管理相关知识