Spark 性能优化的第一步,就是要在开发 Spark 作业的过程中注意和应用一些性能优化的基本原则。开发调优,就是要让大家了解以下一些 Spark 基本开发原则,包括:RDD 血统 (lineage) 设计、算子的合理使用、特殊操作的优化等。在开发过程中,时时刻刻都应该注意以上原则,并将这些原则根据具体的业务以及实际的应用场景,灵活地运用到自己的 Spark 作业中。

7.4.1 原则一:避免创建重复的 RDD

通常来说,我们在开发一个 Spark 作业时,首先是基于某个数据源(比如 Hive 表或 HDFS 文件)创建一个初始的 RDD;接着对这个 RDD 执行某个算子操作,然后得到下一个 RDD;以此类推,循环往复,直到计算出最终我们需要的结果。在这个过程中,多个 RDD 会通过不同的算子操作(比如 map、reduce 等)串起来,这个 “RDD 串”,就是 RDD 血统 (lineage),也就是 “RDD 的血缘关系链”。

我们在开发过程中要注意:对于同一份数据,只应该创建一个 RDD,不能创建多个 RDD 来 代表同一份数据

一些 Spark 初学者在刚开始开发 Spark 作业时,或者是有经验的工程师在开发 RDD 血统 (lineage) 极其冗长的 Spark 作业时,可能会忘了自己之前对于某一份数据已经创建过一个 RDD 了,从而导致对于同一份数据,创建了多个 RDD。这就意味着,我们的 Spark 作业会进行多次重复计算来创建多个代表相同数据的 RDD,进而增加了作业的性能开销

举一个简单的例子

  1. // 需要对名为 “hello.txt” 的 HDFS 文件进行一次 map 操作,再进行一次 reduce 操作。
  2. // 也就是说,需要对一份数据执行两次算子操作。
  3. // 错误的做法:对于同一份数据执行多次算子操作时,创建多个 RDD。
  4. // 这里执行了两次 textFile 方法,针对同一个 HDFS 文件,创建了两个 RDD 出来,
  5. // 然后分别对每个 RDD 都执行了一个算子操作。
  6. // 这种情况下,Spark 需要从 HDFS 上两次加载 hello.txt 文件的内容,并创建两个单独的 RDD;
  7. // 第二次加载 HDFS 文件以及创建 RDD 的性能开销,很明显是白白浪费掉的。
  8. val rdd1 = sc.textFile("hdfs://192.168.25.102:9000/hello.txt")
  9. rdd1.map(...)
  10. val rdd2 = sc.textFile("hdfs://192.168.25.102:9000/hello.txt")
  11. rdd2.reduce(...)
  12. // 正确的做法:对于一份数据执行多次算子操作时,只使用一个 RDD。
  13. // 这种写法很明显比上一种写法要好多了,因为我们对于同一份数据只创建了一个 RDD,然后对这一个 RDD 执行了多次算子操作。
  14. // 但是要注意到这里为止优化还没有结束,由于 rdd1 被执行了两次算子操作,第二次执行 reduce 操作的时候,
  15. // 还会再次从源头处重新计算一次 rdd1 的数据,因此还是会有重复计算的性能开销。
  16. // 要彻底解决这个问题,必须结合 “原则三:对多次使用的 RDD 进行持久化”,才能保证一个 RDD 被多次使用时只被计算一次。
  17. val rdd1 = sc.textFile("hdfs://192.168.25.102:9000/hello.txt")
  18. rdd1.map(...)
  19. rdd1.reduce(...)

7.4.2 原则二:尽可能复用同一个 RDD

除了要避免在开发过程中对一份完全相同的数据创建多个 RDD 之外,在对不同的数据执行算子操作时还要尽可能地复用一个 RDD。比如说,有一个 RDD 的数据格式是 key-value 类型的另一个是单 value 类型的这两个 RDD 的 value 数据是完全一样的那么此时我们可以只使用 key-value 类型的那个 RDD因为其中已经包含了另一个的数据。对于类似这种多个 RDD 的数据有重叠或者包含的情况,我们应该尽量复用一个 RDD,这样可以尽可能地减少 RDD 的数量,从而尽可能减少算子执行的次数。

举一个简单的例子:

  1. // 错误的做法:
  2. // 有一个 <long, String> 格式的 RDD,即 rdd1。
  3. // 接着由于业务需要,对 rdd1 执行了一个 map 操作,创建了一个 rdd2,
  4. // 而 rdd2 中的数据仅仅是 rdd1 中的 value 值而已,也就是说,rdd2 是 rdd1 的子集。
  5. JavaPairRDD<long, String> rdd1 = ...
  6. JavaRDD<String> rdd2 = rdd1.map(...)
  7. // 分别对 rdd1 和 rdd2 执行了不同的算子操作。
  8. rdd1.reduceByKey(...)
  9. rdd2.map(...)
  10. // 正确的做法:
  11. // 上面这个 case 中,其实 rdd1 和 rdd2 的区别无非就是数据格式不同而已,
  12. // rdd2 的数据完全就是 rdd1 的子集而已,却创建了两个 rdd,并对两个 rdd 都执行了一次算子操作。
  13. // 此时会因为对 rdd1 执行 map 算子来创建 rdd2,而多执行一次算子操作,进而增加性能开销。
  14. // 其实在这种情况下完全可以复用同一个 RDD。
  15. // 我们可以使用 rdd1,既做 reduceByKey 操作,也做 map 操作。
  16. // 在进行第二个 map 操作时,只使用每个数据的 tuple._2,也就是 rdd1 中的 value 值即可。
  17. JavaPairRDD<long, String> rdd1 = ...
  18. rdd1.reduceByKey(...)
  19. rdd1.map(tuple._2...)
  20. // 第二种方式相较于第一种方式而言,很明显减少了一次 rdd2 的计算开销。
  21. // 但是到这里为止,优化还没有结束,对 rdd1 我们还是执行了两次算子操作,rdd1 实际上还是会被计算两次。
  22. // 因此还需要配合 “原则三:对多次使用的 RDD 进行持久化” 进行使用,
  23. // 才能保证一个 RDD 被多次使用时只被计算一次。

7.4.3 原则三:对多次使用的 RDD 进行持久化

当你在 Spark 代码中多次对一个 RDD 做了算子操作后,你已经实现 Spark 作业第一步的优化了,也就是尽可能复用 RDD 时就该在这个基础之上,进行第二步优化了,也就是要保证对一个 RDD 执行多次算子操作时,这个 RDD 本身仅仅被计算一次。

Spark 中对于一个 RDD 执行多次算子的默认原理是这样的:每次你对一个 RDD 执行一个算子操作时,都会重新从源头处计算一遍,计算出那个 RDD 来,然后再对这个 RDD 执行你的算子操作。这种方式的性能是很差的。

因此对于这种情况,我们的建议是:对多次使用的 RDD 进行持久化 (即缓存)。此时 Spark 就会根据你的持久化策略,将 RDD 中的数据保存到内存或者磁盘中。以后每次对这个 RDD 进行算子操作时,都会直接从内存或磁盘中提取持久化的 RDD 数据,然后执行算子,而不从源头处重新计算一遍这个 RDD,再执行算子操作。

对多次使用的 RDD 进行持久化的代码示例:

  1. // 如果要对一个 RDD 进行持久化,只要对这个 RDD 调用 cache() 和 persist() 即可。
  2. // 正确的做法:
  3. // cache() 方法表示:使用非序列化的方式将 RDD 中的数据全部尝试持久化到内存中。
  4. // 此时再对 rdd1 执行两次算子操作时,只有在第一次执行 map 算子时,才会将这个 rdd1 从源头处计算一次。
  5. // 第二次执行 reduce 算子时,就会直接从内存中提取数据进行计算,不会重复计算一个 rdd。
  6. val rdd1 = sc.textFile("hdfs://192.168.25.102:9000/hello.txt").cache()
  7. rdd1.map(...)
  8. rdd1.reduce(...)
  9. // persist() 方法表示:手动选择持久化级别,并使用指定的方式进行持久化。
  10. // 比如说,StorageLevel.MEMORY_AND_DISK_SER 表示,内存充足时优先持久化到内存中,内存不充足时持久化到磁盘文件中。
  11. // 而且其中的 _SER 后缀表示,使用序列化的方式来保存 RDD 数据,此时 RDD 中的每个 partition 都会序列化成一个大的字节数组,然后再持久化到内存或磁盘中。
  12. // 序列化的方式可以减少持久化的数据对内存/磁盘的占用量,进而避免内存被持久化数据占用过多,从而发生频繁 GC。
  13. val rdd1 = sc.textFile("hdfs://192.168.25.102:9000/hello.txt").persist(StorageLevel.MEMORY_AND_DISK_SER)
  14. rdd1.map(...)
  15. rdd1.reduce(...)

对于 persist() 方法而言,我们可以根据不同的业务场景选择不同的持久化级别。

7.4 程序开发调优 - 图1

Spark 的持久化级别详解:

  • MEMORY_ONLY 使用未序列化的 Java 对象格式,将数据保存在内存中。如果内存不够存放所有的数据,则数据可能就不会进行持久化。那么下次对这个 RDD 执行算子操作时,那些没有被持久化的数据,需要从源头处重新计算一遍。这是默认的持久化策略,使用 cache() 方法时,实际就是使用的这种持久化策略。
  • MEMORY_AND_DISK 使用未序列化的 Java 对象格式,优先尝试将数据保存在内存中。如果内存不够存放所有的数据,会将数据写入磁盘文件中,下次对这个 RDD 执行算子时,持久化在磁盘文件中的数据会被读取出来使用。
  • MEMORY_ONLY_SER 基本含义同 MEMORY_ONLY。唯一的区别是,会将 RDD 中的数据进行序列化,RDD 的每个 partition 会被序列化成一个字节数组。这种方式更加节省内存,从而可以避免持久化的数据占用过多内存导致频繁 GC
  • MEMORY_AND_DISK_SER 基本含义同 MEMORY_AND_DISK。唯一的区别是,会将 RDD 中的数据进行序列化,RDD 的每个 partition 会被序列化成一个字节数组。这种方式更加节省内存,从而可以避免持久化的数据占用过多内存导致频繁 GC
  • DISK_ONLY 使用未序列化的 Java 对象格式,将数据全部写入磁盘文件中。
  • MEMORY_ONLY_2, MEMORY_AND_DISK_2, 等等 对于上述任意一种持久化策略,如果加上后缀 _2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上。这种基于副本的持久化机制主要用于进行容错。假如某个节点挂掉,节点的内存或磁盘中的持久化数据丢失了,那么后续对 RDD 计算时还可以使用该数据在其他节点上的副本。如果没有副本的话,就只能将这些数据从源头处重新计算一遍了。

如何选择一种最合适的持久化策略:

  • 默认情况下,性能最高的当然是 MEMORY_ONLY,但前提是你的内存必须足够足够大,可以绰绰有余地存放下整个 RDD 的所有数据。因为不进行序列化与反序列化操作,就避免了这部分的性能开销;对这个 RDD 的后续算子操作,都是基于纯内存中的数据的操作,不需要从磁盘文件中读取数据,性能也很高;而且不需要复制一份数据副本,并远程传送到其他节点上。但是这里必须要注意的是,在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果 RDD 中数据比较多时(比如几十亿),直接用这种持久化级别,会导致 JVM 的 OOM 内存溢出异常
  • 如果使用 MEMORY_ONLY 级别时发生了内存溢出,那么建议尝试使用 MEMORY_ONLY_SER 级别。该级别会将 RDD 数据序列化后再保存在内存中,此时每个 partition 仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。这种级别比 MEMORY_ONLY 多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果 RDD 中的数据量过多的话,还是可能会导致 OOM 内存溢出的异常
  • 如果纯内存的级别都无法使用,那么建议使用 MEMORY_AND_DISK_SER 策略,而不是 MEMORY_AND_DISK 策略。因为既然到了这一步,就说明 RDD 的数据量很大,内存无法完全放下。序列化后的数据比较少,可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中,内存缓存不下才会写入磁盘。
  • 通常不建议使用 DISK_ONLY 和后缀为 _2 的级别。因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有 RDD。后缀为 _2 的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性否则不建议使用

7.4.4 原则四:尽量避免使用 shuffle 类算子

如果有可能的话,要尽量避免使用 shuffle 类算子。因为 Spark 作业运行过程中,最消耗性能的地方就是 shuffle 过程。shuffle 过程,简单来说,就是将分布在集群中多个节点上的同一个 key,拉取到同一个节点上,进行聚合或 join 等操作。比如 reduceByKey、join 等算子,都会触发 shuffle 操作。

shuffle 过程中,各个节点上的相同 key 都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同 key。而且相同 key 都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的 key 过多,导致内存不够存放,进而溢写到磁盘文件中。因此在 shuffle 过程中,可能会发生大量的磁盘文件读写的 IO 操作,以及数据的网络传输操作。磁盘 IO 和网络数据传输也是 shuffle 性能较差的主要原因。

因此在我们的开发过程中,能避免则尽可能避免使用 reduceByKey、join、distinct、repartition 等会进行 shuffle 的算子,尽量使用 map 类的非 shuffle 算子。这样的话,没有 shuffle 操 作或者仅有较少 shuffle 操作的 Spark 作业,可以大大减少性能开销。

Broadcast 与 map 进行 join 代码示例

  1. // 传统的 join 操作会导致 shuffle 操作。
  2. // 因为两个 RDD 中,相同的 key 都需要通过网络拉取到一个节点上,由一个 task 进行 join 操作。
  3. val rdd3 = rdd1.join(rdd2)
  4. // Broadcast + map 的 join 操作,不会导致 shuffle 操作。
  5. // 使用 Broadcast 将一个数据量较小的 RDD 作为广播变量。
  6. val rdd2Data = rdd2.collect()
  7. val rdd2DataBroadcast = sc.broadcast(rdd2Data)
  8. // 在 rdd1.map 算子中,可以从 rdd2DataBroadcast 中,获取 rdd2 的所有数据。
  9. // 然后进行遍历,如果发现 rdd2 中某条数据的 key 与 rdd1 的当前数据的 key 是相同的,那么就判定可以进行 join。
  10. // 此时就可以根据自己需要的方式,将 rdd1 当前数据与 rdd2 中可以连接的数据,拼接在一起(String 或 Tuple)。
  11. val rdd3 = rdd1.map(rdd2DataBroadcast...)
  12. // 注意,以上操作,建议仅仅在 rdd2 的数据量比较少(比如几百 M,或者一两 G)的情况下使用。
  13. // 因为每个 Executor 的内存中,都会驻留一份 rdd2 的全量数据。

7.4.5 原则五:使用 map-side 预聚合 shuffle 操作

如果因为业务需要,一定要使用 shuffle 操作,无法用 map 类的算子来替代,那么尽量使用可以 map-side 预聚合的算子。

所谓的 map-side 预聚合,说的是在每个节点本地对相同的 key 进行一次聚合操作,类似于 MapReduce 中的本地 combiner。map-side 预聚合之后,每个节点本地就只会有一条相同的 key,因为多条相同的 key 都被聚合起来了。其他节点在拉取所有节点上的相同 key 时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘 IO 以及网络传输开销。通常来说,在可能的情况下,建议使用 reduceByKey 或者 aggregateByKey 算子来替代掉 groupByKey 算子。因为 reduceByKey 和 aggregateByKey 算子都会使用用户自定义的函数对每个节点本地的相同 key 进行预聚合。而 groupByKey 算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。

比如下图,就是典型的例子,分别基于 reduceByKey 和 groupByKey 进行单词计数。其中第一张图是 groupByKey 的原理图,可以看到,没有进行任何本地聚合时,所有数据都会在集群节点之间传输;第二张图是 reduceByKey 的原理图,可以看到,每个节点本地的相同 key 数据,都进行了预聚合,然后才传输到其他节点上进行全局聚合。

7.4 程序开发调优 - 图2

7.4.6 原则六:使用高性能的算子

除了 shuffle 相关的算子有优化原则之外,其他的算子也都有着相应的优化原则。

使用 reduceByKey/aggregateByKey 替代 groupByKey — map-side 预聚合的 shuffle 操作

详情见 “原则五:使用 map-side 预聚合的 shuffle 操作”。

使用 mapPartitions 替代普通 map — 函数执行频率

mapPartitions 类的算子,一次函数调用会处理一个 partition 所有的数据,而不是一次函数调用处理一条,性能相对来说会高一些。但是有的时候,使用 mapPartitions 会出现 OOM(内存溢出)的问题。因为单次函数调用就要处理掉一个 partition 所有的数据,如果内存不够,垃圾回收时是无法回收掉太多对象的,很可能出现 OOM 异常。所以使用这类操作时要慎重!

使用 foreachPartitions 替代 foreach — 函数执行频率

原理类似于 “使用 mapPartitions 替代 map”,也是一次函数调用处理一个 partition 的所有数据,而不是一次函数调用处理一条数据。在实践中发现,foreachPartitions 类的算子,对性能的提升还是很有帮助的。比如在 foreach 函数中,将 RDD 中所有数据写 MySQL,那么如果是普通的 foreach 算子,就会一条数据一条数据地写,每次函数调用可能就会创建一个数据库连接,此时就势必会频繁地创建和销毁数据库连接,性能是非常低下;但是如果用 foreachPartitions 算子一次性处理一个 partition 的数据,那么对于每个 partition,只要创建一个数据库连接即可,然后执行批量插入操作,此时性能是比较高的。实践中发现,对于 1 万条左右的数据量写 MySQL,性能可以提升 30% 以上。

使用 filter 之后进行 coalesce 操作 — filter 后对分区进行压缩

通常对一个 RDD 执行 filter 算子过滤掉 RDD 中较多数据后(比如 30% 以上的数据),建议使用 coalesce 算子,手动减少 RDD 的 partition 数量,将 RDD 中的数据压缩到更少的 partition 中去。因为 filter 之后,RDD 的每个 partition 中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个 task 处理的 partition 中的数据量并不是很多,有一点资源浪费,而且此时处理的 task 越多,可能速度反而越慢。因此用 coalesce 减少 partition 数量,将 RDD 中的数据压缩到更少的 partition 之后,只要使用更少的 task 即可处理完所有的 partition。在某些场景下,对于性能的提升会有一定的帮助。

使用 repartitionAndSortWithinPartitions 替代 repartition 与 sort 类 操作 — 如果需要在 repartition 重分区之后,还要进行排序,建议直接使用 repartitionAndSortWithinPartitions 算子

repartitionAndSortWithinPartitions 是 Spark 官网推荐的一个算子,官方建议,如果需要在 repartition 重分区之后,还要进行排序,建议直接使用 repartitionAndSortWithinPartitions 算子。因为该算子可以一边进行重分区的 shuffle 操作,一边进行排序。shuffle 与 sort 两个操作同时进行,比先 shuffle 再 sort 来说,性能可能是要高的。

7.4.7 原则七:广播大变量

有时在开发过程中,会遇到需要在算子函数中使用外部变量的场景(尤其是大变量,比如 100M 以上的大集合),那么此时就应该使用 Spark 的广播(Broadcast)功能来提升性能。

在算子函数中使用到外部变量时,默认情况下,Spark 会将该变量复制多个副本,通过网络传输到 task 中,此时每个 task 都有一个变量副本。如果变量本身比较大的话(比如 100M,甚至 1G),那么大量的变量副本在网络中传输的性能开销,以及在各个节点的 Executor 中占用过多内存导致的频繁 GC,都会极大地影响性能。

因此对于上述情况,如果使用的外部变量比较大,建议使用 Spark 的广播功能,对该变量进行广播。广播后的变量,会保证每个 Executor 的内存中,只驻留一份变量副本,而 Executor 中的 task 执行时共享该 Executor 中的那份变量副本。这样的话,可以大大减少变量副本的数量,从而减少网络传输的性能开销,并减少对 Executor 内存的占用开销,降低 GC 的频率。

广播大变量的代码示例:

  1. // 以下代码在算子函数中,使用了外部的变量。
  2. // 此时没有做任何特殊操作,每个 task 都会有一份 list1 的副本。
  3. val list1 = ...
  4. rdd1.map(list1...)
  5. // 以下代码将 list1 封装成了 Broadcast 类型的广播变量。
  6. // 在算子函数中,使用广播变量时,首先会判断当前 task 所在 Executor 内存中,是否有变量副本。
  7. // 如果有则直接使用;如果没有则从 Driver 或者其他 Executor 节点上远程拉取一份放到本地 Executor 内存中。
  8. // 每个 Executor 内存中,就只会驻留一份广播变量副本。
  9. val list1 = ...
  10. val list1Broadcast = sc.broadcast(list1)
  11. rdd1.map(list1Broadcast...)

7.4.8 原则八:使用 Kryo 优化序列化性能

在 Spark 中,主要有三个地方涉及到了序列化

  • 在算子函数中使用到外部变量时,该变量会被序列化后进行网络传输(见 “原则七:广播大变量” 中的讲解)。
  • 将自定义的类型作为 RDD 的泛型类型时(比如 JavaRDD,Student 是自定义类型),所有自定义类型对象,都会进行序列化。因此这种情况下,也要求自定义的类必须实现 Serializable 接口
  • 使用可序列化的持久化策略时(比如 MEMORY_ONLY_SER),Spark 会将 RDD 中的每个 partition 都序列化成一个大的字节数组

对于这三种出现序列化的地方,我们都可以通过使用 Kryo 序列化类库,来优化序列化和反序列化的性能。Spark 默认使用的是 Java 的序列化机制,也就是 ObjectOutputStream/ObjectInputStream API 来进行序列化和反序列化。但是 Spark 同时支持使用 Kryo 序列化库,Kryo 序列化类库的性能比 Java 序列化类库的性能要高很多。官方介绍,Kryo 序列化机制比 Java 序列化机制,性能高 10 倍左右。Spark 之所以默认没有使用 Kryo 作为序列化类库,是因为 Kryo 要求最好要注册所有需要进行序列化的自定义类型,因此对于开发者来说,这种方式比较麻烦。

以下是使用 Kryo 的代码示例,我们只要设置序列化类,再注册要序列化的自定义类型即可(比如算子函数中使用到的外部变量类型、作为 RDD 泛型类型的自定义类型等):

  1. // 创建 SparkConf 对象。
  2. val conf = new SparkConf().setMaster(...).setAppName(...)
  3. // 设置序列化器为 KryoSerializer。
  4. conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
  5. // 注册要序列化的自定义类型。
  6. conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))

7.4.9 原则九:预分区 Shuffle 优化

当遇到 userData 和 events 进行 join 时,userData 比较大,而且 join 操作比较频繁,这个时候,可以先将 userData 调用 partitionBy() 分区,可以极大提高效率。即如果一个 RDD 频繁和其他 RDD 进行 Shuffle 操作,比如:cogroup()groupWith()join()leftOuterJoin()rightOuterJoin()groupByKey()reduceByKey()`**combineByKey()** 以及 **lookup()** 等,那么最好先将该 RDD 通过 **partitionBy()` 操作进行预分区,这些操作在 Shuffle 过程中会减少 Shuffle 的数据量**,可以极大提高效率。

7.4 程序开发调优 - 图3

7.4.10 原则十:优化数据结构

Java 中,有三种类型比较耗费内存

  • 对象,每个 Java 对象都有对象头、引用等额外的信息,因此比较占用内存空间。
  • 字符串,每个字符串内部都有一个字符数组以及长度等额外信息。
  • 集合类型,比如 HashMap、LinkedList 等,因为集合类型内部通常会使用一些内部类来封装集合元素,比如 Map.Entry

因此 Spark 官方建议,在 Spark 编码实现中,特别是对于算子函数中的代码,尽量不要使用上述三种数据结构,尽量使用字符串替代对象,使用原始类型(比如 Int、Long)替代字符串,使用数组替代集合类型,这样尽可能地减少内存占用,从而降低 GC 频率,提升性能。