在讲Flink管理内存之前要了解下Flink为什么要自己实现内存管理
在大数据领域,大多数数据相关的开源框架(Hadoop、Spark、Storm)都是基于JVM运行的,但是JVM的内存管理机制往往存在着诸多类似OutOfMemoryError的问题,主要是因为创建过多的对象实例而超过JVM的最大堆内存限制,却没有被有效回收掉,这在很大程度上影响了系统的稳定性,尤其对于大数据应用,面对大量的数据对象产生,仅仅靠JVM所提供的各种垃圾回收机制很难解决内存溢出的问题。在开源框架中有很多框架都实现了自己的内存管理,例如Apache Spark的Tungsten项目,在一定程度上减轻了框架对JVM垃圾回收机制的依赖,从而更好地使用JVM来处理大规模数据集。
综上,基于JVM的内存管理框架存在以下几个问题
(1)Java对象存储空间利用率低。我们知道Java对象是由对象头、实例变量、填充字节三部分组成。对象头通常占2~3个字宽(1word = 2 Byte = 16 bit),大约占4~6 Byte。比如一个只包含一个byte类型变量的Java对象,在64位虚拟机下,对象头要占用16 Byte,实例变量占用1 Byte,对齐填充字节占用7个(虚拟机要求对象字节必须是8字节的整数倍),总计占用24字节。但是实际占用1 Byte就够了
(2)GC会影响性能。实时计算场景往往数据量极大、对实时性要求极高,为了提高吞吐量、低延迟对JVM的空间占用也比较大,每一次的GC都会增加系统开销,延迟增加。尤其是Full GC带来的系统延迟更不能接受
(3)OutOfMemoryError频发。OutOfMemoryError现象是基于Java开源框架常见的问题,对于大数据应用,面对短时间大量的数据对象产生,仅仅靠JVM所提供的各种垃圾回收机制很难解决内存溢出的问题
因此,Flink基于JVM也实现了自己的内存管理。
Flink基于JVM将内存区分为Managed Heap、Network Buffers、Container cut-off三个部分。其中,Managed Heap包含两部分,或者存在于Jvm heap,即flink managed on-heap,或者存在于Jvm的堆之外,即flink managed off-heap。如下图所示,左图表示在flink managed on-heap,右图表示flink managed off-heap
flink管理内存(简称-托管内存)用于批处理计算。托管内存可以帮助Flink高效地运行批处理计算,并防止OutOfMemoryErrors,因为Flink预先知道可以使用多少内存来执行操作。如果Flink耗尽了托管内存,它就会利用磁盘空间。使用托管内存,可以直接对原始数据执行某些操作,而不必反序列化数据以将其转换为Java对象。总之,托管内存提高了系统的健壮性和速度。
托管内存可以是JVM堆上或者堆外内存的一部分,默认情况下,托管内存是JVM堆上的一部分空间(taskmanager.memory.off-heap:false),通过设置taskmanager.memory.off-heap:true可以开启堆外内存使用。托管内存有两种设置方法:
1)设置内存大小
参数名 | 默认值 | 说明 |
taskmanager.memory.size | "0" | taskmanager在堆上或者堆外的内存大小,用于排序、哈希表和中间结果的缓存。如果为设置其大小,将采用taskmanager.memory.fraction |
2)设置内存比例
参数名 | 默认值 | 说明 |
taskmanager.memory.fraction | 0.7 | taskmanager在堆上火堆外的内存大小(不包括Network Buffers内存),用于排序、哈希表和中间结果的缓存。如,0.7表示taskmanager将预留70%的内存空间给内部缓冲数据用,30%的可用内存预留给taskmanager堆,供代码方法中创建的对象使用。若设置taskmanager.memory.size大小,该参数将失效 |
其计算公式如下:
managed = (total - network) x fraction,即托管内存 = (总内存 - 网络缓存)x 系数
网络缓存主要用于在shuffing时,缓存算子和执行器数据交换的数据记录。通常情况下,比较大的Network Buffers意味着更高的吞吐量。Network Buffers的设置主要有两种方法
1)设定network内存比例(推荐)
主要参数如下:
参数名 | 默认值 | 说明 |
taskmanager.network.memory.fraction | 0.1 | JVM中用于Network Buffer的内存比例,默认0.1。如果设置了taskmanager.network.memory.min和taskmanager.network.memory.max,此参数会被覆盖,变为无效 |
taskmanager.network.memory.min | "64mb" | 最小的Network Buffers内存大小,默认为64MB |
taskmanager.network.memory.max | "1gb" | 最大的Network Buffers 内存大小,默认1GB |
taskmanager.memory.segment-size | "32kb" | 存管理器和Network栈使用的Buffer大小,默认为 32KB |
taskmanager.numberOfTaskSlots | 1 | 一个taskmanager的task的槽数量 |
其计算公式如下,network = Min(max, Max(min, fraction x total))
2)设定 Network Buffer内存数量(过时保留项,不推荐)
从flink1.3开始,提供了通过指定内存比例的方式设置Network Buffer内存大小,主要参数如下:
参数名 | 默认值 | 说明 |
taskmanager.network.numberOfBuffers | 2048 | 最低保障(已过时,被废弃),只有当其它参数都未设置才会生效 |
taskmanager.memory.segment-size | "32kb" | 存管理器和Network栈使用的Buffer大小,默认为 32KB |
如果方法1参数都没有设置,numberOfBuffers才会被用到,计算公式如下:
network = legacy buffers x page
在容器化部署的情况下,总内存使用是有限制的,引入了cut-off,截止线的概念,截止线是一个比例系数,表示占用容器总内存大小的比例,通过参数containerized.heap-cutoff-ratio设置,该比例不低于其最小值,最小值通过参数containerized.heap-cutoff-min设置。引入cut-off主要是考虑内存模型中未考虑到其它类型内存消耗,例如RocksDB本地内存、JVM开销等。它也是一个安全界限,以防止容器超出其内存限制并被容器管理器杀死。
(1)减少GC压力:在整个生命周期中所有长生命周期数据都是以二进制的形式存在于Flink的MemoryManager中,MemoryManager中的长生命数据对象被划分到老年代中。而其他由用户创建的数据对象生命周期都是短暂的,在MiniGC期间可以被快速的回收掉。只要用户不创建大量常驻数据对象,就不会被分配到老年代中,即老年代的内存就不会发生变化,就不会触发MajorGC。从而有效地降低了垃圾回收的压力。在flink1.10中已经支持堆外内存的管理与配置。
(2)防止OutOfMemoryErrors:所有运行时数据结构和算法都是通过内存池申请空间的,而分配的内存段的数量又是固定的,保证了其使用的内存大小是固定的,不会因为运行时数据结构和算法而发生OOM。即使在内存不足的情况下,操作算子也会将一大批的内存端写入磁盘,之后再将其从磁盘读回内存,从而有效的防止了OOM的发生。
(3)节省内存空间:正如上边所讲,Java在对象存储上会额外消耗很多空间,如果数据以二进制的形式存储,则可以避免这种空间消耗
(4)高效的二进制操作 & 缓存友好的计算:在既定的二进制数据格式下,可以有效地比较和操作二进制数据。此外,二进制的数据形式,可以将相关值、哈希值、键和指针等相邻地存储在内存中。这使得数据结构利用好计算机的多级缓存,具有更高效的缓存访问模式
Java生态圈虽然提供了众多序列化框架,如java serialization,Kryo,Apache Avro,Apache Thrift 或 Google的Protobuf等,但是Flink考虑到自己的特殊情况,还是实现了自己的序列化框架。
因为在 Flink 中处理的数据流通常是同一类型,由于数据集对象的类型固定,对于数据集可以只保存一份对象Schema信息,节省大量的存储空间。同时,对于固定大小的类型,也可通过固定的偏移位置存取。当我们需要访问某个对象成员变量的时候,通过定制的序列化工具,并不需要反序列化整个Java对象,而是可以直接通过偏移量,只是反序列化特定的对象成员变量。如果对象的成员变量较多时,能够大大减少Java对象的创建开销,可以显著提升性能,以及内存数据的拷贝大小。
Flink支持处理任意的Java或Scala类型的对象数据。Flink在数据类型上有很大的进步,不需要实现一个特定的接口(像Hadoop中的org.apache.hadoop.io.Writable),Flink能够自动识别数据类型。对于Java程序,Flink 提供了一个基于Java反射的类型提取组件,用于分析用户定义函数的返回类型。对于Scala程序,通过Scala编译器分析基于Scala的Flink程序UDF的返回类型的类型信息。Flink 使用 TypeInformation 表示每种数据类型
BasicTypeInfo:所有 Java 的基础类型或 java.lang.String
BasicArrayTypeInfo:Java 基本类型构成的数组或 java.lang.String
WritableTypeInfo:Hadoop 的 Writable 接口的任何实现
TupleTypeInfo:任何 Flink tuple(Tuple1 到 Tuple25)。Flink tuples 是具有类型化字段的固定长度元组的 Java 表示
CaseClassTypeInfo:任何 Scala CaseClass(包括 Scala tuples)
PojoTypeInfo:任何 POJO(Java 或 Scala),即所有字段都是 public 的或通过 getter 和 setter 访问的对象,遵循通用命名约定
GenericTypeInfo:任意无法匹配之前几种类型的类,即不能标识为其他类型的任何数据类型
前六种数据类型基本上可以满足绝大部分的Flink程序,针对前六种类型数据集,Flink皆可以自动生成对应的TypeSerializer,能非常高效地对数据集进行序列化和反序列化。对于最后一种数据类型,Flink会使用Kryo进行序列化和反序列化。每个TypeInformation中,都包含了serializer,类型会自动通过serializer进行序列化,然后用Java Unsafe接口写入MemorySegments
对于可以用作key的数据类型,Flink还同时自动生成TypeComparator,用来辅助直接对序列化后的二进制数据进行compare、hash等操作。对于 Tuple、CaseClass、POJO 等组合类型,其TypeSerializer和TypeComparator也是组合的,序列化和比较时会委托给对应的serializers和comparators。如下图展示 一个内嵌型的Tuple3 对象的序列化过程。
可以看出这种序列化方式存储密度是相当紧凑的。其中 int 占4字节,double 占8字节,POJO多个一个字节的header,PojoSerializer只负责将header序列化进去,并委托每个字段对应的serializer对字段进行序列化。
Flink 的类型系统可以很轻松地扩展出自定义的TypeInformation、Serializer以及Comparator,来提升数据类型在序列化和比较时的性能。
Flink 提供了如 group、sort、join 等操作,这些操作都需要访问海量数据。这里,我们以sort为例,这是一个在 Flink 中使用非常频繁的操作。
首先,Flink 会从 MemoryManager 中申请一批 MemorySegment,我们把这批 MemorySegment 称作 sort buffer,用来存放排序的数据。
我们会把 sort buffer 分成两块区域。一个区域是用来存放所有对象完整的二进制数据。另一个区域用来存放指向完整二进制数据的指针以及定长的序列化后的key(key+pointer)。如果需要序列化的key是个变长类型,如String,则会取其前缀序列化。如上图所示,当一个对象要加到 sort buffer 中时,它的二进制数据会被加到第一个区域,指针(可能还有key)会被加到第二个区域。
将实际的数据和指针加定长key分开存放有两个目的。第一,交换定长块(key+pointer)更高效,不用交换真实的数据也不用移动其他key和pointer。第二,这样做是缓存友好的,因为key都是连续存储在内存中的,可以大大减少 cache miss(后面会详细解释)。
排序的关键是比大小和交换。Flink 中,会先用 key 比大小,这样就可以直接用二进制的key比较而不需要反序列化出整个对象。因为key是定长的,所以如果key相同(或者没有提供二进制key),那就必须将真实的二进制数据反序列化出来,然后再做比较。之后,只需要交换key+pointer就可以达到排序的效果,真实的数据不用移动。
最后,访问排序后的数据,可以沿着排好序的key+pointer区域顺序访问,通过pointer找到对应的真实数据,并写到内存或外部(更多细节可以看这篇文章 Joins in Flink)
随着磁盘IO和网络IO越来越快,CPU逐渐成为了大数据领域的瓶颈。从 L1/L2/L3 缓存读取数据的速度比从主内存读取数据的速度快好几个量级。通过性能分析可以发现,CPU时间中的很大一部分都是浪费在等待数据从主内存过来上。如果这些数据可以从 L1/L2/L3 缓存过来,那么这些等待时间可以极大地降低,并且所有的算法会因此而受益。
在上面讨论中我们谈到的,Flink 通过定制的序列化框架将算法中需要操作的数据(如sort中的key)连续存储,而完整数据存储在其他地方。因为对于完整的数据来说,key+pointer更容易装进缓存,这大大提高了缓存命中率,从而提高了基础算法的效率。这对于上层应用是完全透明的,可以充分享受缓存友好带来的性能提升。
Flink 基于堆内存的内存管理机制已经可以解决很多JVM现存问题了,为什么还要引入堆外内存?
但是强大的东西总是会有其负面的一面,不然为何大家不都用堆外内存呢。
MemorySegment
,这个申请在堆上会更廉价。Flink用通过ByteBuffer.allocateDirect(numBytes)
来申请堆外内存,用 sun.misc.Unsafe
来操作堆外内存。
基于 Flink 优秀的设计,实现堆外内存是很方便的。Flink 将原来的 MemorySegment
变成了抽象类,并生成了两个子类。HeapMemorySegment
和 HybridMemorySegment
。从字面意思上也很容易理解,前者是用来分配堆内存的,后者是用来分配堆外内存和堆内存的。是的,你没有看错,后者既可以分配堆外内存又可以分配堆内存。为什么要这样设计呢?
首先假设HybridMemorySegment
只提供分配堆外内存。在上述堆外内存的不足中的第二点谈到,Flink 有时需要分配短生命周期的 buffer,这些buffer用HeapMemorySegment
会更高效。那么当使用堆外内存时,为了也满足堆内存的需求,我们需要同时加载两个子类。这就涉及到了 JIT 编译优化的问题。因为以前 MemorySegment
是一个单独的 final 类,没有子类。JIT 编译时,所有要调用的方法都是确定的,所有的方法调用都可以被去虚化(de-virtualized)和内联(inlined),这可以极大地提高性能(MemroySegment的使用相当频繁)。然而如果同时加载两个子类,那么 JIT 编译器就只能在真正运行到的时候才知道是哪个子类,这样就无法提前做优化。实际测试的性能差距在 2.7 被左右。
Flink 使用了两种方案:
方案1:只能有一种 MemorySegment 实现被加载
代码中所有的短生命周期和长生命周期的MemorySegment都实例化其中一个子类,另一个子类根本没有实例化过(使用工厂模式来控制)。那么运行一段时间后,JIT 会意识到所有调用的方法都是确定的,然后会做优化。
方案2:提供一种实现能同时处理堆内存和堆外内存
这就是 HybridMemorySegment
了,能同时处理堆与堆外内存,这样就不需要子类了。这里 Flink 优雅地实现了一份代码能同时操作堆和堆外内存。这主要归功于 sun.misc.Unsafe
提供的一系列方法,如getLong方法:
sun.misc.Unsafe.getLong(Object reference, long offset)
这里我们看下 MemorySegment
及其子类的实现。
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;
}
...
}
可以发现,HybridMemorySegment 中的很多方法其实都下沉到了父类去实现。包括堆内堆外内存的初始化。MemorySegment
中的 getXXX
/putXXX
方法都是调用了 unsafe 方法,可以说MemorySegment
已经具有了些 Hybrid 的意思了。HeapMemorySegment
只调用了父类的MemorySegment(byte[] buffer, Object owner)
方法,也就只能申请堆内存。另外,阅读代码你会发现,许多方法(大量的 getXXX/putXXX)都被标记成了 final,两个子类也是 final 类型,为的也是优化 JIT 编译器,会提醒 JIT 这个方法是可以被去虚化和内联的。
对于堆外内存,使用 HybridMemorySegment
能同时用来代表堆和堆外内存。这样只需要一个类就能代表长生命周期的堆外内存和短生命周期的堆内存。既然HybridMemorySegment
已经这么全能,为什么还要方案1呢?因为我们需要工厂模式来保证只有一个子类被加载(为了更高的性能),而且HeapMemorySegment比heap模式的HybridMemorySegment要快。
参考:https://ci.apache.org/projects/flink/flink-docs-release-1.9/ops/mem_setup.html
参考:https://ci.apache.org/projects/flink/flink-docs-release-1.9/ops/config.html#taskmanager
参考:https://developer.aliyun.com/article/57815