为了账号安全,请及时绑定邮箱和手机立即绑定

Java容器/集合之实现原理

标签:
Java


集合框架中包含了一系列不同数据结构(线性表,查找表...),是用来保存一组数据的结构。

整个集合框架关系展现

image.png

原图出处:http://pierrchen.blogspot.com/2014/03/java-collections-framework-cheat-sheet.html

处于图片左上角的那一块灰色里面的四个类(Dictionary、HashTable、Vector、Stack)都是线程安全的,可是它们都是JDK的老的遗留类。如今都有了相应的取代类。

当中Map接口是用来取代图片中左上角的那个Dictionary抽象类。

HashTable,官方推荐ConcurrentHashMap来取代。接着以下的Vector是List以下的一个实现类。

最上面的粉红色部分是集合类全部接口关系图。其中Collection有三个继承接口:List、Queue和Set。

绿色部分则是集合类的主要实现类,也是我们常用的集合类。

在这里,集合类分为了Map和Collection两个大的类别。

1) Collection

一组"对立"的元素,通常这些元素都服从某种规则

   1.1) List必须保持元素特定的顺序

   1.2) Set不能有重复元素

   1.3) Queue保持一个队列(先进先出)的顺序

2) Map

一组成对的"键值对"对象

集合分类:

依照实现接口分类:

实现Map接口的有:EnumMap、IdentityHashMap、HashMap、LinkedHashMap、WeakHashMap、TreeMap

实现List接口的有:ArrayList、LinkedList

实现Set接口的有:HashSet、LinkedHashSet、TreeSet

实现Queue接口的有:PriorityQueue、LinkedList、ArrayQueue

依据底层实现的数据结构分类:

底层以数组的形式实现:EnumMap、ArrayList、ArrayQueue

底层以链表的形式实现:LinkedHashSet、LinkedList、LinkedHashMap

底层以hash table的形式实现:HashMap、HashSet、LinkedHashMap、LinkedHashSet、WeakHashMap、IdentityHashMap

底层以红黑树的形式实现:TreeMap、TreeSet

底层以二叉堆的形式实现:PriorityQueue

Collection常用方法

   int size():返回集合里边包含的对象个数

   boolean isEmpty():是否为空(不是null而是里边没有元素)

   boolean contains(Object o):是否包含指定对象

   boolean clear():清空集合

   boolean add(E e):向集合中添加对象

   boolean remove(Object o):移出某个对象

   boolean addAll(Collection <?extends E> c):将另一个集合中的所有元素添加到集合中。

   boolean removeAll(Collection<?> c):移出集合中与另一个集合中相同的全部元素。

   Iterator<E> iterator():返回该集合的对应的迭代器。

list常用方法

List除了继承Collection定义的方法外,还根据线性表的数据结构定义了一系列方法。

   1)get(int index)方法,获取集合中索引的元素。

       注:这个方法是List中独有的,返回的是Object

   2)Object set(int index,Object obj):将给定的元素替换集合中索引为index的元素,返回的是被替换的元素。        

   3)add和remove有方法重载        

       add(int index, Object obj):将给定的元素插入索引处,原位置上及后面的元素顺序向后移(插队)。

       Object remove(int index):删除指定索引处的元素,该方法的返回只是被删除的元素。    

  List还提供类似String的indexOf和lastIndexOf方法,用于在集合中检索某个对象,其判断逻辑为:(o==null?get(i)==null:o.equals(get(i)))

   1)int indexOf(Object obj):返回首次在集合中出现该元素的索引值。

   2)lastIndexOf(Object obj):返回最后一次在集合中出现该元素的索引值。

 还有可以将集合转换为数组的方法:    

   3)toArray():将集合转化为数组。这里参数仅仅是告知集合要转换的数组类型,并不会使用我们提供的数组,所以不需要给长度。

    集合中的元素应为同一个类型。

   String[] array = (String[])list.toArray(new String[0]);  

ArrayList底层实现方式

   ArrayList底层是用数组实现的存储。 特点:查询效率高,增删效率低,线程不安全。

ArrayList底层使用Object数组来存储元素数据。所有的方法,都围绕这个核心的Object数组来操作。

但是,数组长度是有限的,而ArrayList是可以存放任意数量的对象,长度不受限制。

其本质上就是通过定义新的更大的数组,将旧数组中的内容拷贝到新数组,来实现扩容。 ArrayList的Object数组初始化长度为10,如果我们存储满了这个数组,需要存储第11个对象,就会定义新的长度更大的数组,并将原数组内容和新的元素一起加入到新数组中。

image.png

LinkedList底层实现

LinkedList底层用双向链表实现的存储。特点:查询效率低,增删效率高,线程不安全。

双向链表也叫双链表,是链表的一种,它的每个数据节点中都有两个指针,分别指向前一个节点和后一个节点。 所以,从双向链表中的任意一个节点开始,都可以很方便地找到所有节点。每个节点都应该有3部分内容:

  class  Node {

        Node  previous;     //前一个节点

        Object  element;    //本节点保存的数据

        Node  next;         //后一个节点

}

image.png

private static class Node<E> {

    //业务数据

        E item;

    //指向下个node

        Node<E> next;

    //指向上个node

        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {

            this.item = element;

            this.next = next;

            this.prev = prev;

        }

    }

如果原来firstNode为空的话,说明这个list为空,那么这时FirstNode也就是lastNode,这个链表只有一个node。

首节点的prev和lastNode的next为null

HashMap实现原理

Map就是用来存储“键(key)-值(value) 对”的。 Map类中存储的“键值对”通过键来标识,所以“键对象”不能重复。

哈希表

        

  哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。

      HashMap底层实现采用了哈希表,这是一种非常重要的数据结构。

      数据结构中由数组和链表来实现对数据的存储,他们各有特点。

      (1) 数组:占用空间连续。 寻址容易,查询速度快。但是,增加和删除效率非常低。

      (2) 链表:占用空间不连续。 寻址困难,查询速度慢。但是,增加和删除效率非常高。

      而“哈希表”具备了数组和链表的优点。 哈希表的本质就是“数组+链表”。

        在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

  数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。

  比如我们要新增或查找某个元素,我们通过把当前元素的关键码通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

        存储位置 = f(关键码)

  其中,这个函数f一般称为哈希函数,通过关键码就可以直接定位到元素的存储位置。

哈希冲突

  如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀。但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。

image.png

或者

image.png

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

一个Entry对象存储了:

      1. key:键对象 value:值对象

      2. next:下一个节点

      3. hash: 键对象的hash值

存储数据

  我们的目的是将”key-value两个对象”成对存放到HashMap的Entry[]数组中。

      (1) 获得key对象的hashcode

           首先调用key对象的hashcode()方法,获得hashcode。

      (2) 根据hashcode计算出hash值(要求在[0, 数组长度-1]区间)

           hashcode是一个整数,我们需要将它转化成[0, 数组长度-1]的范围。我们要求转化后的hash值尽量均匀地分布在[0,数组长度-1]这个区间,减少“hash冲突”

           i. 一种极端简单和低下的算法是:

           hash值 = hashcode/hashcode;

           也就是说,hash值总是1。意味着,键值对对象都会存储到数组索引1位置,这样就形成一个非常长的链表。相当于每存储一个对象都会发生“hash冲突”,HashMap也退化成了一个“链表”。

           ii. 一种简单和常用的算法是(相除取余算法):

           hash值 = hashcode%数组长度

           这种算法可以让hash值均匀的分布在[0,数组长度-1]的区间。 早期的HashTable就是采用这种算法。但是,这种算法由于使用了“除法”,效率低下。JDK后来改进了算法。首先约定数组长度必须为2的整数幂,这样采用位运算即可实现取余的效果:hash值 = hashcode&(数组长度-1)。

      (3) 生成Entry对象

          如上所述,一个Entry对象包含4部分:key对象、value对象、hash值、指向下一个Entry对象的引用。我们现在算出了hash值。下一个Entry对象的引用为null。

      (4) 将Entry对象放到table数组中

          如果本Entry对象对应的数组索引位置还没有放Entry对象,则直接将Entry对象存储进数组。如果对应索引位置已经有Entry对象,则将已有Entry对象的next指向本Entry对象,形成链表。

总结:

      当添加一个元素(key-value)时,首先计算key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,就形成了链表,同一个链表上的Hash值是相同的,所以说数组存放的是链表。

取数据过程get(key)

      我们需要通过key对象获得“键值对”对象,进而返回value对象。明白了存储数据过程,取数据就比较简单了,参见以下步骤:

      (1) 获得key的hashcode,通过hash()散列算法得到hash值,进而定位到数组的位置。

      (2) 在链表上挨个比较key对象。 调用equals()方法,将key对象和链表上所有节点的key对象进行比较,直到碰到返回true的节点对象为止。

      (3) 返回equals()为true的节点对象的value对象。

      明白了存取数据的过程,我们再来看一下hashcode()和equals方法的关系:

      Java中规定,两个内容相同(equals()为true)的对象必须具有相等的hashCode。因为如果equals()为true而两个对象的hashcode不同;那在整个存储过程中就发生了悖论。

扩容问题

      HashMap的位桶数组,初始大小为16。实际使用时,显然大小是可变的。如果位桶数组中的元素达到(0.75*数组 length), 就重新调整数组大小变为原来2倍大小。

      扩容很耗时。扩容的本质是定义新的更大的数组,并将旧数组内容挨个拷贝到新数组中。

JDK8将链表在大于8情况下变为红黑二叉树

      JDK8中,HashMap在存储一个元素时,当对应链表长度大于8时,链表就转换为红黑树,这样又大大提高了查找的效率。

HashMap原理借鉴https://www.cnblogs.com/chengxiao/p/6059914.html

TreeMap原理实现

首先介绍一下二叉树和红黑二叉树

二叉树的定义

      二叉树是树形结构的一个重要类型。 许多实际问题抽象出来的数据结构往往是二叉树的形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。

      二叉树(BinaryTree)由一个节点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。下图中展现了五种不同基本形态的二叉树。

image.png

      (a) 为空树。

      (b) 为仅有一个结点的二叉树。

      (c) 是仅有左子树而右子树为空的二叉树。

      (d) 是仅有右子树而左子树为空的二叉树。

      (e) 是左、右子树均非空的二叉树。

注:二叉树的左子树和右子树是严格区分并且不能随意颠倒的,图 (c) 与图 (d) 就是两棵不同的二叉树。

排序二叉树特性如下:

      (1) 左子树上所有节点的值均小于它的根节点的值。

      (2) 右子树上所有节点的值均大于它的根节点的值。

      比如:我们要将数据【14,12,23,4,16,13, 8,,3】存储到排序二叉树中,如下图所示:

image.png

      排序二叉树本身实现了排序功能,可以快速检索。但如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成普通的链表,其检索效率就会很差。 比如上面的数据【14,12,23,4,16,13, 8,,3】,我们先进行排序变成:【3,4,8,12,13,14,16,23】,然后存储到排序二叉树中,显然就变成了链表,如下图所示:

image.png

平衡二叉树(AVL)

      为了避免出现上述一边倒的存储,科学家提出了“平衡二叉树”。

      在平衡二叉树中任何节点的两个子树的高度最大差别为1,所以它也被称为高度平衡树。 增加和删除节点可能需要通过一次或多次树旋转来重新平衡这个树。

      节点的平衡因子是它的左子树的高度减去它的右子树的高度(有时相反)。带有平衡因子1、0或 -1的节点被认为是平衡的。带有平衡因子 -2或2的节点被认为是不平衡的,并需要重新平衡这个树。

      比如,我们存储排好序的数据【3,4,8,12,13,14,16,23】,增加节点如果出现不平衡,则通过节点的左旋或右旋,重新平衡树结构,最终平衡二叉树如下图所示:

image.png

      平衡二叉树追求绝对平衡,实现起来比较麻烦,每次插入新节点需要做的旋转操作次数不能预知。

红黑二叉树

      红黑二叉树(简称:红黑树),它首先是一棵二叉树,同时也是一棵自平衡的排序二叉树。

      红黑树在原有的排序二叉树增加了如下几个要求:

      1. 每个节点要么是红色,要么是黑色。

      2. 根节点永远是黑色的。

      3. 所有的叶节点都是空节点(即 null),并且是黑色的。

      4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的路径上不会有两个连续的红色节点)

      5. 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。

      这些约束强化了红黑树的关键性质:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。这样就让树大致上是平衡的。

      红黑树是一个更高效的检索二叉树,JDK 提供的集合类 TreeMap、TreeSet 本身就是一个红黑树的实现。

image.png

      红黑树的基本操作:插入、删除、左旋、右旋、着色。 每插入或者删除一个节点,可能会导致树不在符合红黑树的特征,需要进行修复,进行 “左旋、右旋、着色”操作,使树继续保持红黑树的特性。

TreeMap是红黑二叉树的典型实现

private transient Entry<K,V> root = null;

root用来存储整个树的根节点。我们继续跟踪Entry(是TreeMap的内部类)的代码:

image.png

     可以看到里面存储了本身数据、左节点、右节点、父节点、以及节点颜色。

 TreeMap的put()/remove()方法大量使用了红黑树的理论。

 TreeMap和HashMap实现了同样的接口Map,因此,用法对于调用者来说没有区别。HashMap效率高于TreeMap;在需要排序的Map时才选用TreeMap。

HashSet实现原理

     HashSet是采用哈希算法实现,底层实际是用HashMap实现的(HashSet本质就是一个简化版的HashMap),因此,查询效率和增删效率都比较高。

image.png

      发现里面有个map属性,这就是HashSet的核心秘密。我们再看add()方法,发现增加一个元素说白了就是在map中增加一个键值对,键对象就是这个元素,值对象是名为PRESENT的Object对象。

本质就是把这个元素作为key加入到了内部的map中”。

      由于map中key都是不可重复的,因此,Set天然具有“不可重复”的特性。

TreeSet实现原理

      TreeSet底层实际是用TreeMap实现的,内部维持了一个简化版的TreeMap,通过key来存储Set的元素。 TreeSet内部需要对存储的元素进行排序,因此,我们对应的类需要实现Comparable接口。这样,才能根据compareTo()方法比较对象之间的大小,才能进行内部排序。

      (1) 由于是二叉树,需要对元素做内部排序。 如果要放入TreeSet中的类没有实现Comparable接口,则会抛出异常:java.lang.ClassCastException。

      (2) TreeSet中不能放入null元素。

©著作权归作者所有:来自51CTO博客作者huingsn的原创作品,如需转载,请注明出处,否则将追究法律责任


点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消