4. Scala不可变Set详解

一、不可变集合概述

1.1 不可变性

Scala为了防止用户在不经意间修改集合而带来逻辑问题,引入了不可变集合类,在不可变集合上任何的更改操作,都不会直接在原集合上进行,而是返回一个新的集合。

如下代码,每次调用buf += i,都会将buf指向一个新的对象。相反,对于可变集合的Set或者Java的HashSet,在添加元素时,都是在原对象上进行操作。

val len = 1000
var buf: Set[Int] = Set()
for (i <- Range(0, len)) {
  buf += i
}

1.2 Structural Shares

如上所说,我们每次在更改集合时,都要创建一个新的对象,那么,这个对象的创建就需要考虑性能问题,最朴素的方法是,直接将原本集合中的所有元素全部复制到新的集合上,然而,这种操作的问题也是显而易见的:复制本身消耗的计算成本以及这些元素的成本都增加,所以这种全部复制的方案是不可以接受的。

Scala的解决方案:Scala为了支持部分复制,它在存储的时候就采用分段分层的一种树形结构存储,我们称之为Structural Shares技术,在复制的时候只复制对应的段即可,其余的元素与旧的集合共用。

如有Vector v1(scala中的一种不可变数组):

val v1=Vector(1,2,0,9, 7,2,9,6, ..., 3,2,5,5, 4,8,4,6)

采用分段存储后,它的底层存储我们假设是这样的

image-20230909110131807

如果我们要将Vector中的第四个元素7换成8,那么只会复制和7所在元素块有关联的元素

val v2 = v1.updated(4, 8)
v2
res50: Vector[Int] = Vector(1, 2, 0, 9, 8, 2, 9, 6, ..., 3, 2, 5, 5, 4, 8, 4, 6)

image-20230909110412451

我们仔细观察上图的树,会发现只有叶子结点(数组)中存储实际数据,而中间节点是存了指向块的引用。

二、不可变Set的代码详解

2.1 整体实现结构

不可变Set采取的思路是和Structural Shares完全一样的,我们这里从源码的角度分析下不可变Set的具体实现。

image-20230909111432123

如上图所示,在Scala中,immutable.Set是一个接口,它的具体实现类有EmptySet -> Set1 -> Set2 -> Set3 -> Set4 -> immutable.HashSet,这里采用箭头的原因是因为这些实现类之间存在转换关系,当初始化一个Set()时,我们创建了一个EmptySet,当添加一个元素的时候,得到一个Set1,添加第二个元素时,用Set1中的元素和当前要添加的元素创建一个Set2,以此类推,一直到immutable.HashSet。

2.2 EmptySet到Set4的简单转换

这一整个流程都体现了不可变集合的特点,从EmptySet到Set4的变化都比较简单,我们简单看其中一个代码即可:

class Set3[A] private[collection] (elem1: A, elem2: A, elem3: A) extends AbstractSet[A] with Set[A] with Serializable {
  override def size: Int = 3
  def contains(elem: A): Boolean =
    elem == elem1 || elem == elem2 || elem == elem3
  def + (elem: A): Set[A] =
    if (contains(elem)) this
    else new Set4(elem1, elem2, elem3, elem)
}

如上代码,在Set3中添加元素时,首先判断要添加的元素在当前已经存储的3个元素中是否存在,如果存在,就返回当前集合即可,如果不存在,就创建一个Set4。

2.3 Set4到immutable.HashSet的转换

当Set中元素已经达到4个了,此时我们如果在添加元素,会转成immutable.HashSet(后续简称HashSet),具体转换的方法是先创建一个空的HashSet,然后调用HashSet的添加方式,逐步往集合中添加元素

class Set4[A] private[collection] (elem1: A, elem2: A, elem3: A, elem4: A) extends AbstractSet[A] with Set[A] with Serializable {
  override def size: Int = 4
  def contains(elem: A): Boolean =
    elem == elem1 || elem == elem2 || elem == elem3 || elem == elem4
  def + (elem: A): Set[A] =
    if (contains(elem)) this
    else new HashSet[A] + elem1 + elem2 + elem3 + elem4 + elem
}

如上代码,当我们要往Set4中添加元素时,如果改元素不在Set4中,就创建一个空的HashSet,然后逐步把elem1、elem2、elem3、elem4添加到HashSet中。

2.4 immutable.HashSet添加元素

在HashSet中添加元素是本文的第二个重点

2.4.1 immutable.HashSet及其子类介绍

HashSet是一个类,它还有两个子类:immutable.HashSet1(后续简称HashSet1)和immutable.HashTrieSet(后续简称HashTrieSet),在添加元素时,涉及到这三个类的转换。

  • HashSet:这几个类的父类,HashSet相当于是空集合,在添加元素时会创建HashSet1对象

  • HashSet1:真正存放数据的对象,类似于Java HashMap的Node对象,一个HashSet1中只可以存储1个数据

  • HashTrieSet:用于存放引用的对象,相当于在Structural Shares中介绍的中间节点;

    HashTrieSet中的元素介绍:

    • HashSet数组:HashTrieSet中存储着一个HashSet数组,这个数组既可能是HashSet1[] 也可能是 HashTrieSet[],该HashSet数组长度和元素个数完全一样

    • bitmap:一个int类型的整数,将其转成二进制就是32位的二进制编码,从低位到高位分别对应着HashSet数组的下标,第一位对应第一个元素,第二位对应第二个元素,以此类推。

      举例:

      元素:[a,b]

      位图(二进制):00010000000000000000100000000000

      代表的含义子元素: —b----------------a-----------

      bitmap的生成方式

      ​ 初始化:第一个bitmap生成的时候,是在HashSet1中添加元素,转HashTrieSet时,val bitmap = (1 << index0) | (1 << index1)

      ​ 后续添加时的迭代方式:在后续添加的时候,将老bitmap与新的index做或运算,val bitmapNew = bitmap | (1 << index)

      ​ 后续删除时的迭代方式,在后续删除的时候,将老bitmap与要删除元素的index做异或运算,val bitmapNew = bitmap ^ (1 << index)

      bitmap的巧妙之处:采用了bitmap后,我们在创建HashSet数组时,就不需要将每个数组长度全部置成32,只需要按照元素个数存储即可,这样可以极大的减少空间浪费。

2.4.2 添加元素流程

HashSet添加元素的流程:

  1. 添加第一个元素时,创建一个HashSet1

  2. 添加第二个元素时,调用HashSet1的添加方法, 用当前的HashSet1和要添加的新元素创建一个HashTrieSet,HashTrieSet中存储着一个HashSet1的数组,此时这个数组长度为2

  3. 添加第三个元素时,调用HashTrieSet的添加方法,首先计算出新元素要添加的下标,然后分下面几种情况:

    a. 如果该位置没有值,则直接创建一个新的数组,新数组中包含老数组的所有元素和新值;

    b. 如果该位置有值且是HashSet1,就走步骤2;

    c. 如果该位置有值且是HashTrieSet,就走步骤3。

图示说明步骤3:

假设我们有Set(1, 2, 3, 4, 5, …),存储结构如下

image-20230909201742394

a. 如果该位置没有值

当我们想要添加元素6时,假设在第一层的时候计算出来位置是4,此时第一次的4没有元素,那么直接添加即可,添加后返回的新数组如下所示

image-20230909204359680

b.如果该位置有值且是HashSet1

当我们想添加元素7时,假设在第一层的时候计算出来的位置是0,此时发现0的位置有元素1了,那么就需要走步骤2,将HashSet1转成HashTrieset,示意图如下:

image-20230909204759652

c.如果该位置有值且是HashTrieSet

但我们想添加元素8时,假设在第一层计算出来的位置是1,此时发现1的位置有一个HashTrieSet,那么就继续走步骤3,再调用HashTrieSet的添加方法,然后计算出在第二层的位置是0,此时又发现第二层的位置0是元素3,那么就走步骤2,示意图如下:

image-20230909205023049

2.5 immutable.HashSet查找元素

上述其实已经将查找的流程说明白了,就是

  1. 先在第一层找,如果这个元素的位置在第一层是HashSet1,则直接返回即可;
  2. 如果是HashTrieSet,就去第二层,再重复1

2.6 immutable.HashSet删除元素

删除元素就是把添加元素的流程反过来,即删除元素只可能发生在HashTrieSet上,删除元素意味着HashTrieSet中数组的长度要减1,当这个数组的长度的最后减为1时,HashTrieSet就退回成HashSet1。

三、分析不可变Set的性能

3.1 查找性能

因为每个HashTrieSet最多可以存储32个对象,所以查找的时间复杂度在最理想的情况下是 log ⁡ 32 ( n ) \log_{32} (n) log32(n),而因为数组长度只有32,所以发生hash冲突的概率很大,可能一个HashTrieSet中只存储四五个对象,那就会导致层数就变多,时间复杂度也会上涨,所以这里的时间复杂度只能是定义成 l o g ( n ) log (n) log(n),log的底数需要具体分析。例如我们把1000w个整数加载到Set中,发现有230w个HashSet[],如下图。这意味着平均一个HashTrieSet中置存储4.3个元素,近似成5,那此时的查找时间复杂度就时 l o g 5 ( n ) log_5 (n) log5(n)

image-20230910164425666

对比Scala的可变Set或者Java的HashSet,因为hash冲突的概率不太大,它们的查找时间复杂度一般认为是O(1)

3.2 添加性能

添加时首先要查找,然后再把要改的子节点的链路上的数组都进行复制,时间复杂度是也差不多是 log ⁡ ( n ) \log (n) log(n)

3.3 存储占用

3.3.1 immutable.HashSet中个结构内存分析

对象内存占用分析具体参考:

经过2.4的分析,我们知道immutable.HashSet的内存占用就是由3种对象引起的:HashSet1,HashTrieSet和HashSet[]

HashSet1的结构
class HashSet1[A](private[HashSet] val key: A, private[HashSet] val hash: Int) extends LeafHashSet[A] {}

可以看到,HashSet1由泛型key的对象指针和int类型的hash构成,一个指针占64位即8byte,一个int占32位即4byte,对象头占12byte,所以一个HashSet1占24byte

按照3.1示例中的分析,1000w个Integer加载到immutable.Set中后,创建了1000w个HashSet1,那么HashSet1的内存占用就是

24 ∗ 10 , 000 , 000 = 240 , 000 , 000 b y t e 24 * 10,000,000 = 240,000,000byte 2410,000,000=240,000,000byte,图中是280,000,000byte的原因是一个HashSet1占了28个byte,目前不理解为什么不是8字节对齐

HashTrieSet的结构
class HashTrieSet[A](private val bitmap: Int, private[collection] val elems: Array[HashSet[A]], private val size0: Int)
      extends HashSet[A] {}

可以看到,HashTrieSet由int类型的bitmap,一个数组指针和一个int类型的size组成,bitmap占4byte、指针占8byte、size占4byte,对象头占12byte,加起来是28byte,因为JVM 8字节对齐的特性,所以一个HashTrieSet占32byte

按照3.1示例中的分析,1000w个Integer加载到immutable.Set中后,创建了2329497个HashTrieSet,那么HashTrieSet的内存占用就是

32 ∗ 2329497 = 74543904 b y t e 32 * 2329497 = 74543904byte 322329497=74543904byte,和图中的是可以对的上的

HashSet[]内存分析

HashSet[]是一个数组,里面存储的是HashSet1或HashTrieSet的引用,所以HashSet[]占用的空间计算方式如下:

size_b(HashSet[]) = 12byte(对象头) + 4byte(数组长度) + 8byte * length + 对齐填充(不一定有)

按照示例3.1中的分析,1000w个Integer加载到immutable.Set中后,创建了2329497个HashSet[],那么平均每个HashSet[]的长度就是4.3,我们近似成5,所以一个HashSet[]占的大小就是 12 + 4 + 8 ∗ 5 = 56 b y t e 12 + 4 + 8 * 5 = 56byte 12+4+85=56byte,总大小就是

56 ∗ 2329497 = 130 , 451 , 832 b y t e 56 * 2329497 = 130,451,832byte 562329497=130,451,832byte,和3.1图中的分析基本接近,3.1的内存占用一个长度为5的HashSet[]会占用64byte,目前不太清楚原因

总结

当元素加载到immutable.Set中后,会额外带来HashSet1、HashTrieSet、HashSet[]的存储,HashSet1的个数与元素个数保持一致,HashTrieSet和HashSet[]的个数取决于hash冲突,需要在加载的时候具体分析,以1000w个Integer为例,带来了2329497个HashTrieSet和2329497个HashSet[]

因此,在存储1000w个Integer的前提下,immutable.HashSet中每存储一个元素,平均多带来了 ( 280 , 000 , 000 + 154 , 543 , 896 + 74 , 543 , 904 ) / 10 , 000 , 000 = 50.9 b y t e (280,000,000 + 154,543,896 + 74,543,904) / 10,000,000 = 50.9byte (280,000,000+154,543,896+74,543,904)/10,000,000=50.9byte的额外存储

3.3.2与mutable.Set的对比

我们这里用的是Scala 2.12版本,muutable.Set的存储是用的Object数组存储的,而2.13的底层实现是和Java的HashSet,是用的Node数组。

我们把1000w个Integer加载到mutable.Set中观察其内存占用情况

image-20230910221444762

我们看到,在将1000w个Integer加载到内存后,内存占用主要是两种对象占用:Object[]和Integer,Integer就是我们要存储的实际元素,Object[]占了268,631,008,这是因为有一个长度为3355w的数组,其占用空间为 12 + 4 + 8 ∗ 33 , 554 , 432 = 268 , 435 , 472 b y t e 12 + 4 + 8 * 33,554,432 = 268,435,472byte 12+4+833,554,432=268,435,472byte。和jvisuam上显示的差了8byte,目前不太清楚是哪里差的

存储1000w个Integer的前提下,mutable.Set中每存储一个元素,平均多带来的存储是 268 , 435 , 472 / 10 , 000 , 000 = 26.8 b y t e 268,435,472 / 10,000,000 = 26.8byte 268,435,472/10,000,000=26.8byte

3.3.3与Spark中的AppendonlySet的对比

我们发现,在Scala mutable.Set中,我们存储10,000,000个元素时,最终创建的是一个长度为3355w的数组,这与它的扩容策略有关,而我们如果模仿Spark的AppendOnlySet来存储10,000,000个元素,实际消耗又会是多少呢,结果如下

image-20230910224028050

我们发现,Object数组只占了134,380,056byte的存储,这是因为它的底层是一个长度为1342w的数组,如下

image-20230910224215869

存储1000w个Integer的前提下,AppendOnlySet中每存储一个元素,平均多带来的存储是 134 , 217 , 752 / 10 , 000 , 000 = 13.4 b y t e 134,217,752 / 10,000,000 = 13.4byte 134,217,752/10,000,000=13.4byte

注意:当然了,因为AppendOnlySet扩容的量小,其发生Hash冲突的概率也就大于mutable.Set,会降低查找效率,所以我们在实际选用时要根据自己的场景来定

3.3.4与Java的HashSet做对比

将1000w个cuid加载到java的HashSet中后,我们观察其内存占用

image-20230910223204533

因为Java的hashSet的底层是HashMap,而HashMap的底层是Node数组加练表/红黑树实现的,而存储实际元素的节点就是Node对象,所以我们看到额外存储就是由Node对象,和Node数组带来的

存储1000w个Integer的前提下,java HashSet中每存储一个元素,平均多带来的存储是 ( 440 , 043 , 164 + 134 , 261 , 888 ) / 10 , 000 , 000 = 57.4 b y t e (440,043,164 + 134,261,888) / 10,000,000 = 57.4byte (440,043,164+134,261,888)/10,000,000=57.4byte

3.3.5总结——几种集合的对比

  • immutable.Set:存储消耗最大,查找效率最低。适用于多线程场景。
  • mutable.Set:存储消耗仅次于AppendOnlySet,但是查找性能高于AppendOnlySet,仅低于Java的HashSet。但是有一个缺点是需要的连续内存是更多的
  • AppendOnlySet:存储消耗最低,但是查找效率仅比immutable.Set的效率高,适用于对内存要求高,对查找要求不高的场景。
  • Java的HashSet:存储消耗最高,但是查找性能最高。适用于对内存要求不高,对查找性能要求高的场景。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值