计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析


文章目录

  • ArrayList和LinkedList
    • ArrayList底层实现
    • LinkList底层实现
    • 使用场景:
  • HashMap
    • hashMap扩容过程
    • 转换红黑树条件
    • 红黑树
    • 解决哈希冲突有如下的方法:
  • HashSet
    • 说一下 HashSet 的实现原理?
    • HashSet如何检查重复?HashSet是如何保证数据不可重复的?
    • HashSet与HashMap的区别
  • 底层实现总结
    • Collection
    • Map
  • 线程安全
    • ArrayList为什么不线程安全?

ArrayList和LinkedList
  • ArrayList是基于数组实现的,LinkedList是基于双链表实现的,因此LinkedList可以作为双向队列 ,栈。
  • 因为Array是基于索引(index)的数据结构,它使用索引在数组中搜索和读取数据是很快的,可以直接返回数组中index位置的元素,因此在随机访问集合元素上有较好的性能。Array获取数据的时间复杂度是O(1),但是要插入、删除数据却是开销很大的,因为这需要移动数组中插入位置之后的的所有元素。
  • LinkedList需要更多的内存,因为ArrayList的每个索引的位置是实际的数据,而LinkedList中的每个节点中存储的是实际的数据和前后节点的位置。
ArrayList底层实现
  • ArrayList的默认初始化空间为10;
  • 数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。
    计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析
    文章图片
LinkList底层实现 使用场景: (1)如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;
( 2 ) 如果应用程序有更多的插入或者删除操作,较少的随机访问,LinkedList对象要优于ArrayList对象;
【计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析】(3)不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。
HashMap 在JDK1.8之后,HashMap采用位桶(数组)+链表+红黑树实现,当链表长度超过一定值(默认为8)时,则会将链表结构进行调整转变为红黑树结构;当红黑树中的元素数量小于8个时,则又会将结构体进行转变为链表结构;
hashMap扩容过程 HashMap的默认初始容量为16,加载因子0.75,也就意味着当HashMap中存储的Entry数量达到16*0.75时会进行一次扩容操作;当我们通过HashMap的有参构造自定义一个初始容量时,给定的值必须是2的幂次方值;
根据加载因子规则,当 HashMap.Size > Capacity * LoadFactor 时,hashMap会进行一次扩容操作;一次扩容流程可划分为两个步骤:
  1. resize : HashMap会在原有的数组后再创建一个新的Entry空数组,调整后现有数组的长度为原来的2倍;
  2. rehash:遍历原Entry数组,把所有的Entry重新Hash到新数组。消耗性能.
这也是Redis中字典扩容的方法。
转换红黑树条件 如果是链表Node,则将key和value封装为一个链表Node并插入到链表的尾部。这个插入尾部的过程中,需要遍历链表,如果发现存在相同的key,则更新value,否则执行插入操作,当链表节点个数超过了8个,且数组大于等于64,则会将该链表转化为红黑树
红黑树 它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
如何理解红黑树呢?
  • 首先,最初的有序二叉树如果插入的节点越来越大,二叉树就会变成一个只有右节点的链表,所以要改进。
  • 之后,出现了自平衡的二叉树,是的最短节点和最长节点的高度差<=1,但是这样做经常会导致:我就插入了一个元素,树为了自平衡,就重新变换结构,浪费计算资源。
  • 红黑树就是通过一些列规则,让红黑树的“平衡”,变得不再那么严格,从而达成一种有序二叉树和自平衡二叉树的中间的一种中庸排序树。
    计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析
    文章图片

    由于以上特性,导致红黑树的最短路径是黑+黑+黑+…,最长路径为宏观黑相间,也就是纯黑的一倍而已,如果超出这个差,我们再进行树的结构调整,以此来节约调整树结构的计算资源。
计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析
文章图片

解决哈希冲突有如下的方法:
  1. 开放定址法(线性探测,二次探测,伪随机探测)
  2. 链地址法
  3. 再散列法(双重散列,多重散列)
  4. 当发生冲突时,使用第二个、第三个、哈希函数计算地址,直到无冲突。缺点:计算时间增加。
  5. 建立一个公共溢出区
HashSet 说一下 HashSet 的实现原理? HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。
HashSet如何检查重复?HashSet是如何保证数据不可重复的? 向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
HashSet 中的add ()方法会使用HashMap 的put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。
HashSet与HashMap的区别 计算机基础|【数据结构】Java容器——ArrayList、LinkedList、HashMap(红黑树)等结构的分析
文章图片

底层实现总结 Collection
  1. List
    Arraylist: Object数组
    Vector: Object数组
    LinkedList: 双向循环链表
  2. Set
    HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素
    LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。
    TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树。)
Map
  1. HashMap:
    JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
  2. LinkedHashMap:
    LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  3. HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  4. TreeMap: 红黑树(自平衡的排序二叉树)
线程安全 hashtable比hashmap多了个线程安全。
ArrayList为什么不线程安全? ArrayList的实现主要就是用了一个Object的数组,用来保存所有的元素,以及一个size变量用来保存当前数组中已经添加了多少元素。
我们看add函数:
ublic boolean add(E e) {/** * 添加一个元素时,做了如下两步操作 * 1.判断列表的capacity容量是否足够,是否需要扩容 * 2.真正将元素放在列表的元素数组里面 */ ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }

add元素时,实际做了两个大的步骤:
  • 判断elementData数组容量是否满足需求
  • 在elementData对应位置上设置值
这样也就出现了第一个导致线程不安全的隐患,在多个线程进行add操作时可能会导致elementData数组越界。具体逻辑如下:
  1. 列表大小为9,即size=9
  2. 线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
  3. 线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
  4. 线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
  5. 线程B也发现需求大小为10,也可以容纳,返回。
  6. 线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10。
  7. 线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException。
另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作,它由如下两步操作构成:
  • elementData[size] = e;
  • size = size + 1;
在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:
  1. 列表大小为0,即size=0
  2. 线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
  3. 接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
  4. 线程A开始将size的值增加为1
  5. 线程B开始将size的值增加为2
  6. 这样线程AB执行完毕后,理想中情况为size为2,elementData下标0的位置为A,下标1的位置为B。而实际情况变成了size为2,elementData下标为0的位置变成了B,下标1的位置上什么都没有。并且后续除非使用set方法修改此位置的值,否则将一直为null,因为size为2,添加元素时会从下标为2的位置上开始。

    推荐阅读