Spark本质上是基于内存的,当内存本身比较紧张,其性能也会受到影响。因此需要对内存使用进行优化,减少其消耗。常用的性能优化点包括:数据结构、序列器、缓存持久化与Checkpoint、JVM参数调优(因为本质上依赖GC)、并行度、共享数据与本地化存储、算子合理选择等方面。接下来我们结合各个优化点,依次看看如何有效利用内存。
一、数据结构与内存
减少内存开销的重要手段之一就是优化数据结构。由于JAVA对象的特性,会隐含额外的内存开销,例如对象头与集合等。先看对象头,由于每个对象都包含对象头(ObjectHeader),对象头分为两部分:MarkWord与ClassPointer(类型指针)。MarkWord存储了对象的hashCode、GC与锁信息;ClassPointer存储了指向类对象信息的指针。在32位JVM上对象头占用8字节,64位JVM则为16字节,两种类型的MarkWord和ClassPointer各占一半空间。因此某些数据存储时,可能内容本身会小于自己的对象头(如整型数据)。
图1:对象头示例
集合类型内部会通过自定义对象来构造特定的数据结构,例如HashMap,内部会通过自定义类型Entry进行封装。同样这类对象除对象头还包含指针,也会额外占用内存。而对于存储基本数据类型的集合(例如int类型),内部会通过其对应的包装器进行封装(例如Integer),无形中也会扩大内存占用。优化Spark应用程序的内存,本质上是要优化自定义的算子函数中使用的局部变量,减少其对内存的占用。
优化的着手点可以考虑以下几个方面:
1、数组优于集合
例如:List<Integer> list = new ArrayList<Integer>(),可以替换为:int[] array = new int[]。转换后的数组相比集合类型,一方面减少了元信息的存储开销;其次由于直接使用基本数据类型,降低了对象头的内存开销。
2、扁平优于嵌套
由于多层次嵌套的对象中可能包含大量的小对象,因此可以对多层嵌套的对象结构进行拆解。例如:
public class Orders {
private List<Items> items = newArrayList<Items>()
}
可以替换为JSON:
{"orderId":1001,items:[{"itemId":1,"itemName":"cafe"},{"itemId":2, "itemName":"applewatch"}]}
如何判断Spark的内存消耗?
1、设置RDD并行度(即partition数)。 2、调用RDD.cache()方法将RDD缓存到内存中。 3、观察Driver的日志可以找到类似于: “INFO ... Added rdd__01 in memory on mbk.local:50311 (size: 717.5KB, free: 332.3 MB)”的信息,显示每个partition占用的内存。 4、内存数量*partition数量,即RDD内存总占用量。
二、序列化与内存
Spark算子通常会使用到外部的数据,当处理的数据比较大时,同样会对内存造成巨大的开销。因此引入了序列化技术,其实本质上就是对数据进行压缩,减少对内存的占用。Spark默认使用基于ObjectInputStream/ObjectOutputStream的原生序列化机制,其优点在于便捷性,但问题是其性能不高,因此某些场景下并非最佳选择。
Kryo序列化机制——Spark支持使用Kryo类库来进行序列化,相对而言Kryo更快而且序列化后的数据占用更小。但缺点在于需要预先在Spark应用中对所有需要序列化的类型注册。如果不注册,Kryo必须保存类型的全限定名,反而会增大内存开销。
配置参与以启用Kryo序列化:
newSparkConf()
.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")
注册自定义类型以使用Kryo序列化:
SparkConf conf = newSparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(BigData.class)
JavaSparkContext sc = newJavaSparkContext(conf)
此外值得注意的是,如果注册的序列化类型比较大,此时需要对Kryo缓存进行调整,因为可能内部缓存无法存放过大的对象。可以调用SparkConf.set("spark.kryoserializer.buffer.mb", x)方法进行调整(缓存默认大小为2M)。