全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)

文章目录

  • 前言
  • 总览
  • Map
    • HashMap(线程不安全)
      • HashMap底层实现原理
      • HashMap的特征
    • ConcurentHashMap(线程安全)
    • HashTable(已弃用,线程安全)
    • LinkedHashMap(线程不安全)
    • TreeMap(线程不安全)
    • ConcurrentSkipListMap(线程安全)
    • Map容器小结
  • Set
    • HashSet(线程不安全)
    • LinkedHashSet(线程不安全)
    • TreeSet(线程不安全)
    • ConcurrentSkipListSet(线程安全)
    • Set容器小结
  • List
    • ArrayList(线程不安全)
    • Vector(线程安全)
    • CopyOnWriteArrayList(线程安全)
    • LinkedList(线程不安全)
    • List类容器小结
  • Queue
  • Stack
  • 其它常用容器
    • BitSet(线程不安全)
    • Pair(线程不安全)
  • 总结

前言

本篇博文主要是通过解析HashMap、ConcurrentHashMap、ConcurrentSkipListMap、TreeMap、Hashtable、LinkedHashMap、HashSet、ConcurrentSkipListSet、TreeSet 、LinkedHashSet、ArrayList、Vector、CopyOnWriteArrayList、LinkedList、BitSet、Pair等常用容器的底层结构来分析各种容器的特点、用途以及它们之间的联系和区别。

对于文章中出现的Java锁相关知识不是很清楚的可以翻阅我的另外两篇博文《Java高效并发之synchronized关键字和锁优化》和《Volatile关键字》

总览

Java容器库可以分为两大类,一类是继承自Collection接口,继承自Collection接口的这些容器类的元素是单个元素对象,容器中元素对象按照特定规则排列而成独立序列;另外一类是继承自Map接口,继承自Map接口的容器类的元素是键值对。

抽象类是对类的抽象,是一种模板的定义;而接口是对行为的抽象,是一个行为规范,这里通过顶层接口Collection和Map为下层接口以及子类容器定义了容器必须对外提供的行为方法,当然Set、List、Queue等接口也会在继承父类接口的基础上添加属于自己这类容器所特有的行为,比如List接口就定义了通过索引位置获得对象的方法get(int index)。

容器与数组最大一个区别是数组在初始化时需要指定大小,而容器都是不需要,有着自己的默认初始的大小和自己的扩容机制。
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第1张图片
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第2张图片

Map

Map类容器是存储键值对的容器,除了迭代获取元素值还可以通过键值获得其元素值。键(Key)的值唯一不可重复;不同的键值可以拥有相同的元素值。

面试中面试官最常问的问题就是HashMap和ConcurrentHashMap的区别。我们都知道的是HashMap不是线程安全的而ConcurrentHashMap是线程安全的,但为什么会是这样嘞?这里需要通过分析他们的底层结构以及操作实现的机制来进行说明。当然Map的子类容器常用的还有TreeMap和LinkedHashMap以及被弃用的HashTable等,这里通过分析他们的底层结构来进行他们之间的联系和区别的比较。

HashMap(线程不安全)

在介绍HashMap的特点之前,先说明下HashMap的底层结构:
JDK1.8之前: 数组(HashMap.Node[] table)+链表(HashMap.Node next )
JDK1.8: 数组(HashMap.Node[] table)+链表(HashMap.Node next)+红黑树(TreeNode )(当链表中结点数大于阈值(默认为8)且数组大小大于或等于64时,链表就变成红黑树)
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第3张图片

通过插入和查找两个过程来说明HashMap的实现原理。

HashMap底层实现原理

HashMap基于哈希表实现,HashMap的数组就是hash桶,而链表和红黑树是为了解决hash冲突而加入的数据结构(拉链法解决hash冲突)。之所以加入红黑树是因为当链表过长时查找键值对遍历链表效率太低,因此JDK1.8中加入红黑树(自平衡的二叉查找树)。当数组的大小大于或等于64且数组中的链表长度大于阈值(默认为8),链表转化成红黑树,增加查找效率;当数组的大小小于64不管链表长度多长,链表不会转化成红黑树,而是进行扩容。

当我们使用put(K,V)来进行元素插入时,原理步骤如下:
(1)获得元素的位置:先通过使用hash函数对键值对象的hash码进行运算得到hash值,通过hash值%数组大小(n)来获得该键值对映射在数组中的位置table[i]。(数组的大小一定因此可能多个键值对映射到同一个位置产生冲突)
(2)判断该位置table[i]上是否已有Node结点,若无则直接创建新Node结点(新结点内值为新键值对),将新结点放入该数组位置;若有则判断该数组结点是否是红黑树的结点,若是则说明链表已转换为红黑树,红黑树是据hash值的大小形成的,因此红黑树查找插入位置是自根结点从上往下遍历红黑树,先比较hash,若hash值大于该结点的hash值则走其右子结点,若小于则走其左子结点,否则进行hash值和Key值比较,若有相同Key值的结点则返回该结点,跳到步骤(4);若无相同Key值,则找到合适的位置,插入新new的TreeNode结点。
(3)若不是红黑树则说明用于解决哈希冲突的还是链表,遍历链表,通过equals(Key)比较要插入的键值对的键值已存在,若存在返回已存在结点,跳到步骤(4);若不存在则new一个新的Node结点插入到链表尾部,并判断该链表是否需要变成红黑树。
(4)链表或者红黑树中已存在需要插入的键值对的键值,则用新值替代旧值。
(5)判断容器中的元素是否超过阈值,是否需要扩容。

插入元素源码分析如下:

   //利用hash函数对hashcode进行再次运算,使得hash值更加均匀分布
   static final int hash(Object key) {
        int h;
        return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;
    }
    //插入元素函数,调用putVal()进行元素插入
   public V put(K key, V value) {
        return this.putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        HashMap.Node[] tab;
        int n;
        //使用懒加载机制,第一次进行put时才真正赋予默认大小的内存。
        if ((tab = this.table) == null || (n = tab.length) == 0) {
            n = (tab = this.resize()).length;
        }

        Object p;
        int i;
        //映射的数组位置是否已有结点,若无则创建新Node插入该位置
        if ((p = tab[i = n - 1 & hash]) == null) {
            tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);
        } else {    //若有结点
            Object e;
            Object k;
      //该数组的结点是否key值与待插入的相同,相同则赋值用以后面的新值替代旧值
            if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
                e = p;
            } 
            //若key值不相同,则判断结点是否是红黑树的结点,若是则将结点插入红黑树中
            else if (p instanceof HashMap.TreeNode) {
                e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
            }
            //若是链表,则遍历链表是否有相同Key的结点,若有则返回结点,用以后面新值替代旧值,若无则将值插入到链表尾部
             else {
                int binCount = 0;
                while(true) {
                   //达到尾部,将新结点插入尾部
                    if ((e = ((HashMap.Node)p).next) == null) {
                        ((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
                        //当链表长度等于8时,链表变为红黑树,且数组的长度得大于等于64
                        if (binCount >= 7) {
                            this.treeifyBin(tab, hash);
                        }
                        break;
                    }
                     //当发现链表中具有相同Key值的结点,停止遍历,返回结点  
                    if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
                        break;
                    }
                   
                    p = e;
                    ++binCount;
                }
            }
            //(链表或者红黑树中已存在该key值时)将返回的结点,进行新值替代旧值 
            if (e != null) {
                V oldValue = ((HashMap.Node)e).value;
                if (!onlyIfAbsent || oldValue == null) {
                    ((HashMap.Node)e).value = value;
                }

                this.afterNodeAccess((HashMap.Node)e);
                return oldValue;
            }
        }
        ++this.modCount;
         //若插入新结点,则将HashMap容器中的元素加1,并判断是否查过容量阈值,若超过则扩容
        if (++this.size > this.threshold) {
            this.resize();
        }

        this.afterNodeInsertion(evict);
        return null;
    }

当我们使用get(K)来进行元素查找时,原理步骤如下:
(1)通过用hash函数对键值key的hashcode进行运算获得hash值,再通过hash值%数组大小(n)得到该键值对所在的数组位置;
(2)判断数组上的第一个结点的键值是否就是待查找的key值,若是,返回第一个结点;
(3)若不是则判断数组上的第一个结点是否是红黑树的结点,若是则遍历红黑树进行查找,返回结点的元素值(value);
(4)若不是红黑树结点,则遍历链表进行查找。

查找元素源码分析如下:

  //通过调用getNode()获得待找的结点,若结点为null返回null否则返回结点的元素值(value)
 public V get(Object key) {
        HashMap.Node e;
        return (e = this.getNode(hash(key), key)) == null ? null : e.value;
    }

    final HashMap.Node<K, V> getNode(int hash, Object key) {
        HashMap.Node[] tab;
        HashMap.Node first;
        int n;
        //当数组该位置不为空,进行查找,否则返回null
        if ((tab = this.table) != null && (n = tab.length) > 0 && (first = tab[n - 1 & hash]) != null) {
            Object k;
            //判断第一个结点的key值和hash值是否与待找的相同,相同则返回第一个结点,否则继续往下进行
            if (first.hash == hash && ((k = first.key) == key || key != null && key.equals(k))) {
                return first;
            }

            HashMap.Node e;
            //判断第一个结点是否是红黑树结点,若是则取红黑树中进行查找
            if ((e = first.next) != null) {
                if (first instanceof HashMap.TreeNode) {
                    return ((HashMap.TreeNode)first).getTreeNode(hash, key);
                }
            //若不是则遍历链表进行查找,找到则返回该结点
                do {
                    if (e.hash == hash && ((k = e.key) == key || key != null && key.equals(k))) {
                        return e;
                    }
                } while((e = e.next) != null);
            }
        }

        return null;
    }

HashMap的特征

1、HashMap是线程不安全的,通过以上在容器中插入元素的代码,你可以发现在插入元素的整个过程都没有使用到锁机制,其实不止是插入操作,其它任何改变或者读取元素值的操作都没加锁,因此在多线程中会出现扩容死循环以及读写值错误。
2、HashSet和LinkedHashMap都是基于HashMap的基础上实现的;HashMap与ConcurrentHashMap底层结构相同,主要差别是ConcurrentHashMap在操作过程中使用了锁机制,因此它是线程安全的,而HashMap不是。
3、键值允许为NULL,但因为唯一性,所以键值为NULL的键值对只有一个。
4、HashMap的扩容机制是当容器中的元素数量大于等initialCapacity(数组长度)*loadFactor(加载因子,默认为0.75)的大小时发生扩容,扩充为原来的两倍,对容器中的元素重新进行映射,映射到新的位置。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。之所以必须是两倍的原因是HashMap的大小必须是2的倍数,因为HashMap源码在进行元素位置定位时为了提高效率,使用的是 (n - 1 )& hash而不是hash%n,虽然它们的含义都是通过用hash值对数组的大小n进行取余来进行映射获得键值对的位置,但hash&(n-1) 的计算更快,但它要求数组的大小必须是2的倍数。

ConcurentHashMap(线程安全)

底层结构:
JDK1.8之前:分段的数组(Segment[],一个 Segment对象包含一个 HashEntry 数组)+链表(每个 HashEntry 是一个链表结构的元素)
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第4张图片
JDK1.8:数组+链表+红黑树(数组的每一个位置table[i]上都有一把锁)
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第5张图片

ConcurrentHashMap与HashMap的区别就在于ConcurrentHashMap是线程安全的,而它的线程安全的实现是因为在操作过程中使用锁机制。在JDK1.8之前,底层利用分段锁的思想,使用了CAS自旋锁和+ReentrantLock(可重入锁)来保证ConcurrentHashMap的插入元素、修改元素、获得元素值、扩容等操作在多线程中不会出错。JDK1.8之后,ConcurrentHashMap的底层结构变成了数组+链表+红黑树,使用的锁机制也变成了CAS自旋锁Synchronized关键字。JDK1.7中锁是加在Segment对象上(1个segment对象可能包含多个HashEntry 对象),而JDK1.8中锁是加在数组的第一个Node结点(相当于JDK1.7的HashEntry 对象)上,锁的粒度较之前更小,并发度提高了。而且通过加入红黑树,减少了遍历查找的消耗。

JDK1.8之前的插入过程:当调⽤ConcurrentHashMap的put⽅法时,(1)先根据key计算出键值对在对应的Segment[]的数组中的位置segments[j],确定好当前键值对应该插⼊到数组中哪个Segment对象中,(2)如果segments[j]为空,则使用自旋锁CAS的⽅式在j位置⽣成⼀个 Segment对象。(3)然后调用Segment对象的put⽅法。 Segment对象的put方法会先对Segment对象加锁,然后也根据key计算出对应的HashEntry[]的数组下标i,然后将 key,value封装为HashEntry对象插入或者替换进该位置的链表中,此过程和JDK7的HashMap的put⽅法⼀样,然后解锁。

JDK1.8的插入过程:(1)首先根据key计算对应的数组下标i,如果该位置没有元素,则通过⾃旋锁CAS的⽅法去向该位置赋值。( 2).如果该位置有元素,则synchronized会加锁 (3)加锁成功之后,在判断该元素的类型 a. 如果是链表节点则添加节点到链表中并进行是否需要转换成红黑树若是则转换 b. 如果是红⿊树则添加节点到红⿊树 (4). 插入新结点后,ConcurrentHashMap的元素个数加1,但是这个操作也是需要并发安全 的,并且元素个数加1成功后,会继续判断是否要进⾏扩容,如果需要,则会进⾏扩容,扩容过程也需要加锁。

HashTable(已弃用,线程安全)

底层结构:数组+链表
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第6张图片

它与HashMap的重要区别是它是线程安全的,它使用synchronized关键字对整个数组进行加锁(相当于全表锁,对整个容器上了锁),因此它的并发程度就很低,效率极其低下。这可能就是它被弃用的重要原因之一,非线程安全有HashMap,线程安全有ConcurrentHashMap,都比它好使。它不允许键值为NULL,插入键值NULL的键值对会抛出异常,而HashMap允许。Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。

LinkedHashMap(线程不安全)

底层结构: 数组+链表+红黑树+双向链表
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第7张图片

LinkedHashMap的底层结构与HashMap几乎一致,区别在于LinkedHashMap在HashMap的结构基础之上,在各个结点之间维护了一条双向链表。HashMap无序,而LinkedHashMap有序,而这一点是通过它维护的双向链表实现的。LinkedHashMap继承自HashMap,很多方法都直接调用的父类HashMap,仅为维护双向链表重写了部分方法。它在 HashMap 基础上,通过维护一条双向链表,添加了可以通过按插入顺序或者访问顺序进行排序的功能。因此我们在遍历容器的元素时是按照插入顺序或者访问顺序来进行遍历的,按访问顺序来进行遍历的特点可以使得LinkedHashMap用来做支持LRU算法的缓存。

默认情况下,LinkedHashMap 是按插入顺序维护链表,双向链表的插入顺序是在元素插入容器的过程中进行维护的;不过我们可以在初始LinkedHashMap时,指定 accessOrder 参数为 true,即可让它按访问顺序维护双向链表。当我们调用get/getOrDefault/replace等方法进行访问元素时,只需要将这些方法访问的节点移动到链表的尾部即可。

具体过程原理我就不详述的,如果感兴趣的可以自行搜索或者查看我看过的一篇博客(上面的图就是偷他的,写的还不错)《LinkedHashMap 源码详细分析(JDK1.8)》

TreeMap(线程不安全)

底层结构: 红黑树

TreeMap的底层结构只是红黑树,红黑树是自平衡的二叉查找树,因此TreeMap可使键值对按照键值大小进行排序(升序),当然也可以通过自定义排序规则(实现Comparator接口,重写compare()方法)。TreeMap 实现了NavigableMap接口,它支持一系列的导航方法。比如返回有序的key集合。TreeMap 实现了Cloneable接口,它能被克隆。TreeMap 实现了java.io.Serializable接口,意味着它支持序列化。不允许键值为空。

TreeMap的性能虽然比HashMap低一些,但它支持有序查找。若无排序要求,推荐使用HashMap,若有排序要求则使用TreeMap。注意TreeMap和LinkedHashMap的区别,LinkedHashMap是按照插入顺序或者访问顺序进行排序,而TreeMap是按照key值的大小或者自定义进行排序。

ConcurrentSkipListMap(线程安全)

若是对跳跃表不了解的,可以翻阅《跳跃表原理以及实现(含ConcurrentSkipListMap和zset底层实现原理分析)》这篇博文。

底层结构: 跳跃表(skip list)
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第8张图片
ConcurrentSkipListMap的底层结构是跳跃表,跳跃表是元素值有序存储的多层次链表结构,因此ConcurrentSkipListMap也是有序存储的,那它自然具有与TreeMap一样的有序查找功能。再者其内部通过volatile关键字和CAS操作来保证了其操作过程中修改值的线程安全性,因此ConcurrentSkipListMap是线程安全的。与TreeMap相同,因为需要对键排序,因此键不允许为空。

跳跃表的查找性能与平衡树差不多,时间复杂度为O(logn),因此ConcurrentSkipListMap在理论上能够在O(logn)时间内完成查找、插入、删除操作。而ConcurentHashMap的底层结构因为是哈希表,理论上能在O(1)时间内完成查找/插入/删除,查找/删除/插入效率更高,而且它也是线程安全的。但ConcurrentSkipListMap有着ConcurrentHashMap没有的特点,它支持排序。并且ConcurrentHashMap是对数组的每个位置上锁,因此它的性能与并发量有关,例如并发量多时,大量线程访问数组的同一位置做修改元素操作,然后造成大量的线程阻塞等待。而ConcurrentSkipListMap是对结点不停尝试CAS操作,它的存取性能跟线程数无关,而跟数据量有关,存取时间复杂度为O(logn),增删改结点需要先查找到结点再进行增删改操作,而相比查找结点的过程,真正进行数据修改等操作花费的时间微不足道,而CAS又是不断尝试修改,修改一个完毕一个,不会因为修改过程造成长时间阻塞,因此即使是大量线程一起尝试修改一个结点的值也能很快完成,线程量不影响其性能。因此当并发数量(线程数)较少时,ConcurrentHashMap的性能更好,经过测试发现在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右;而并发量高(线程数多)而容器内数据量一定时,ConcurrentSkipListMap的性能会更好,因为它的存取时间与并发量无关,而与数据量有关。

Map容器小结

Map的应用场景是键值对,通过索引找到对象。单线程中,需要使用Map容器时,优先使用HashMap;当有按照键值大小进行排序的需求时,选用TreeMap;当有按照插入顺序或者访问顺序进行排序的需求时,选用LinkedHashMap;多线程中,若并发量低选用ConcurrentHashMap;若并发量低且有按照键值大小排序要求时,可以使用Collections.synchronizedSortedMap方法对TreeMap进行包装来使用,若并发量高且有按照键值大小排序要求时,使用ConcurrentSkipListMap。

使用示例(若使用迭代器对Collections.synchronizedSortedMap包装后的Map容器进行遍历,需要对该容器加锁,再进行遍历)

        TreeMap<Integer,Integer> example=new TreeMap<>();
        example.put(1,1);
        example.put(4,4);
        example.put(2,2);
        example.put(16,16);
        example.put(8,8);
        SortedMap<Integer,Integer> test=Collections.synchronizedSortedMap(example);
        System.out.println("Synchronized sorted map is :"+test);
        Set s = test.keySet();  // Needn't be in synchronized block
        synchronized(test) {  // Synchronizing on m, not s!
            Iterator i = s.iterator(); // Must be in synchronized block
            while (i.hasNext())
                System.out.println(i.next());
        }

Set

Set容器接口继承自Collection接口,容器内存储的是对象而不是键值对,容器内的元素是无序的且是不可重复的。

HashSet(线程不安全)

底层结构: 数组(HashMap.Node[] table)+链表+红黑树

HashSet是在HashMap的基础上实现的,底层结构与HashMap相同,HashSet除了几个不得不自己实现的方法,例如clone()方法,其它方法都是直接调用了HashMap的方法,因此HashSet的插入、删除、扩容等机制基本跟HashMap一样。HashSet使用HashMap键值对中的key存储其元素对象,HashSet的重要特征容器内的元素都是不可重复的利用的就是HashMap的键值不可重复的特点。

HashSet的添加元素代码如以下所示:

private transient HashMap<E, Object> map;
public HashSet(Collection<? extends E> c) {
   this.map = new HashMap(Math.max((int)((float)c.size() / 0.75F) + 1, 16));
   this.addAll(c);
}
//HashSet添加元素的方法,直接调用了HashMap的插入元素的方法
public boolean add(E e) {
        //将待插入的值插入到HashMap的key中,而HashMap的value值为一个类常量Object对象
        return this.map.put(e, PRESENT) == null;
    }

LinkedHashSet(线程不安全)

底层结构: 数组+链表+红黑树+双向链表

同其它Set集合类相同,LinkedHashSet是基于LinkedHashMap实现的,底层结构同LinkedHashMap。它是直接new了一个LinkedHashMap类的对象,除了若干个不得不自己实现的方法,其它方法都是直接通过这个对象调用LinkedHashMap的方法。LinkedHashSet使用LinkedHashMap键值对中的key存储其元素对象,LinkedHashSet容器内的元素都是不可重复的特性就是因为LinkedHashMap的键值不可重复的特点。因此LinkedHashSet的插入、删除、扩容等机制基本跟LinkedHashMap一样,且拥有有序性,可按照插入顺序和访问顺序进行排序。

TreeSet(线程不安全)

底层结构: 红黑树

与HashSet类似,TreeSet基于TreeMap实现,底层结构与TreeMap相同,TreeSet除了几个不得不自己实现的方法,例如clone()方法,其它方法都是直接调用了TreeMap的方法,因此TreeSet的插入、删除、扩容等机制基本跟TreeMap一样。TreeSet使用TreeMap键值对中的key存储其元素对象,TreeSet的重要特征容器内的元素都是不可重复的利用的就是TreeMap的键值不可重复的特点。TreeSet拥有与TreeMap一样的特点可通过键值进行排序(默认按键值升序排列,但可自定义排序规则),因此TreeSet是一个元素有序的Set容器。

ConcurrentSkipListSet(线程安全)

底层结构: 跳跃表(skip list)
若是对跳跃表不了解的,可以翻阅《跳跃表原理以及实现(含ConcurrentSkipListMap和zset底层实现原理分析)》这篇博文。

与其它set容器老铁一样,都是基于相应的Map容器类的基础上实现的, ConcurrentSkipListSet是在ConcurrentSkipListMap的基础上完成的,底层结构与ConcurrentSkipListMap相同,ConcurrentSkipListSet除了几个不得不自己实现的方法,其它方法都是直接调用了ConcurrentSkipListMap的方法,因此ConcurrentSkipListSet的插入、删除、扩容等机制基本跟ConcurrentSkipListMap一样,因此ConcurrentSkipListSet也是线程安全的,ConcurrentSkipListSet使用ConcurrentSkipListMap键值对中的key存储其元素对象。

ConcurrentSkipListMap容器在初始化时new了一个ConcurrentSkipListMap对象,然后在实现方法时直接让ConcurrentSkipListMap对象调用对应的方法,以此实现其功能。

 private final ConcurrentNavigableMap<E, Object> m;
 public ConcurrentSkipListSet() {
        this.m = new ConcurrentSkipListMap();
    }
    //给出一个ConcurrentSkipListSet添加元素方法的源码示例
 public boolean add(E e) {
        return this.m.putIfAbsent(e, Boolean.TRUE) == null;
    }


Set容器小结

Set的特征就是元素的不可重复性,应用场景也是要求元素值独一无二。若无有序要求,在要求元素不可重复的场景中,推荐使用HashSet,它底层是哈希表,查找时间复杂度为O(1);TreeSet的底层是红黑树,查找时间复杂度为O(logN),整体HashSet性能更好。若有元素有序要求,则使用TreeSet;若需要按照访问顺序或者插入顺序进行排序,则使用LinkedHashSet。若在多线程环境中,若有排序要求且并发量高则使用ConcurrentSkipListSet,若有排序要求且并发量低则使用Collections.synchronizedSortedSet()对TreeSet进行包装使用,若无排序要求可以使用Collections.synchronizedSet()对HashSet进行包装使用。

List

List类容器的最大特点是容器内元素可重复;用户可以精确控制列表中每个元素的插入位置,也可通过索引访问元素,是一个有序的容器。

ArrayList(线程不安全)

底层结构: 动态数组( Object[] elementData)

ArrayList应该是我们使用最多的容器了,经常在初始化不知道该分配多大空间时用它来替代数组。它是基于动态数组实现的,也就是说它的数据结构是顺序结构,顺序结构的特点是存储密度大随机查找效率高(O(1)),随机插入/删除效率低(O(n)),因此它适合应用的场景是经常做随机查找、很少做插入和删除操作。ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。

ArrayList的扩容机制-ArrayList在初始化时只分配了一个空数组,没有分配容量,当第一次插入元素时,ArrayList的底层数组扩容为10,后来每次数组内的元素达到当前分配的最大容量时,进行扩容,扩容为当前分配容量的1.5倍。

这里就不进行源码分析了,如果感兴趣可以翻阅下《ArrayList源码分析》这篇文章,里面有清楚的源码分析。

Vector(线程安全)

底层结构: 动态数组

与ArrayList相同的是,它的底层结构也是动态数组,适合用于经常做随机查找的场景。不同的是Vector容器是线程安全的,它在内部进行add、remove、set、get等操作时使用了Synchronized关键字对容器对象进行了加锁操作,保证了并发安全性。但它的一个缺点就是并发程度低,因为它对读写等操作都进行了加锁,导致了多个不同线程之间进行读读、读写、写读、写写的操作时除拥有锁的线程,其它线程都阻塞。也就是说只要有一个线程在修改或者插入或者删除或者读该容器,其它线程就不能对这个线程做修改或者插入或者删除或者读操作,因为它用synchronized关键字修饰的方法在执行时,会用锁锁住这个容器对象;其它线程要访问该容器对象时,需要使用锁,发现锁被占用,就只能阻塞等待。

//synchronized修饰实例方法,锁住的是容器对象
 public synchronized E set(int index, E element) {
        if (index >= this.elementCount) {
            throw new ArrayIndexOutOfBoundsException(index);
        } else {
            E oldValue = this.elementData(index);
            this.elementData[index] = element;
            return oldValue;
        }
    }

CopyOnWriteArrayList(线程安全)

底层结构: 动态数组

CopyOnWriteArrayList容器是线程安全版的ArrayList,它通过使用volatile关键字和synchronized关键字来保证并发安全。它的并发程度比Vector更高,因为它只在写操作(包括修改、插入、删除等操作)上加了锁,而且锁住的是容器内的一个常量对象,而不是容器对象,而读操作不加锁,因此它导致写操作和读操作之间不产生冲突。在进行写操作时不是对容器内的元素进行修改,而是将容器内的元素复制一份,对副本进行修改,因此不影响其它线程读取正确的数据。因此它导致的只是写写操作之间的阻塞(写操作需要抢该常量对象的锁),而读读、读写、写读等操作之间不会相互阻塞,以此提高了并发程度。它的思想与数据库的MVCC差不太多,类似于多版本并发控制。

 final transient Object lock = new Object();
public boolean add(E e) {
        synchronized(this.lock) {  //注意它锁的是常量对象,而不是容器对象
            Object[] es = this.getArray();
            int len = es.length;
             //添加元素是将原数组进行复制,产生一份副本,将数组添加到副本数组,再将副本数组替换原数组,使得其它线程正确读数据    
            es = Arrays.copyOf(es, len + 1);
            es[len] = e;
            this.setArray(es);
            return true;
        }
    }

LinkedList(线程不安全)

底层结构: 双向链表
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第9张图片
LinkedList因为底层结构是链表,因此它的特点是存储密度小(因为部分空间用来存储指针),删除或者插入效率高,查找效率低,不支持高效的随机元素访问。

LinkedList容器实现了Deque接口,Deque是Queue的子接口,因此LinkedList可以用来作为队列使用,如下:

Deque<String> que=new LinkedList<>();

List类容器小结

初始时,不知给数组分配多大内存时,可以选择使用List类容器,当主要用来进行随机查找而插入删除操作较少时,可以选择使用顺序结构的List容器,如ArrayList和Vector、CopyOnWriteArrayList。在单线程环境使用ArrayList,多线程环境推荐使用CopyOnWriteArrayList,并发性能更好。若是插入和删除操作比较多,推荐使用链表结构,例如LinkedList。若是想要在并发环境中使用List类的非线程安全容器,可以使用Collections.synchronizedList()方法对其进行包装使用。

容器是否实现RandomAccess 接口表明容器是否支持高效的随机访问能力,实现了 RandomAccess 接口的list,优先选择普通 for 循环 ,其次 foreach,未实现 RandomAccess接口的list,优先选择iterator遍历(foreach遍历底层也是通过iterator实现的),大size的数据,千万不要使用普通for循环。

Queue

队列是数据结构中的一大类,一般用于缓冲、并发访问,主要特征是元素先进先出( FIFO),好比排队取钱,我先来我就能排在前面,就能先取到钱先走人。队列在数据结构中可以被分为两种,一种是单队列,一种是循环队列,循环队列主要是防止队列的假溢出问题(队列中有位置,却无法加入新元素)。队列的实现有挺多种的,这里不进行详细叙述,只做简要说明。队列的实现可以分为两类,一类线程不安全的,例如LinkedList、PriorityQueue ;另一类是线程安全的,线程安全的又可分为阻塞队列和非阻塞队列,阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。非阻塞队列的典型例子是 ConcurrentLinkedQueue;而阻塞队列主要的就是阻塞队列接口BlockingQueue接口的实现类,比如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue(PriorityQueue的线程安全版)。

单队列
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第10张图片

循环队列
全面解析Java常用容器(从底层结构解析HashMap、ConcurrentHashMap、ArrayList、Vector、LinkedList等常用容器之间的区别和特点)_第11张图片

Stack

栈也是数据结构中的一大类,主要特征是后进先出(LIFO),在Java中主要提供了Stack类用于对栈的实现,它是Vector的子类,基于Vector实现,因此它的底层结构也是动态数组,是线程安全的。

其它常用容器

BitSet(线程不安全)

底层结构: long类型的数组(long[] words)

BitSet是一个只存储0/1值的位数组,它是基于long类型数组实现的,因此它的长度是64的倍数(初始时分配64位),自动扩容后依然是64的倍数。BitSet常见的应用场景是对海量数据的处理,用于大数据量的查找、去重、统计、排序、判别以及求数据的并集、交集、补集等,还可以用于日志分析、用户数统计等统计工作。

常用方法如下:

//创建一个默认的对象(64为),所有位初始化为 false
BitSet();
//允许用户指定初始大小,所有位初始化为 false
BitSet(int nbits);
//a = a & b
void and(BitSet set);        
//a = a & !b
void andNot(BitSet set);
//a = a^b
void xor(BitSet set);
//a = a | b
void or(BitSet set);
//将指定索引处的位设置为 true
void set(int bitIndex)        
//将指定索引处的位设置为指定的值
void set(int bitIndex, boolean value);          
//将指定的 fromIndex(包括)到指定的 toIndex(不包括)范围内的位设置为 true
void set(int fromIndex, int toIndex);        
//将指定的 fromIndex(包括)到指定的 toIndex(不包括)范围内的位设置为指定的值
void set(int fromIndex, int toIndex, boolean value);
//返回指定索引处的位值
boolean get(int bitIndex);       
//返回一个新的 BitSet,它由 fromIndex(包括)到 toIndex(不包括)范围内的位组成
BitSet get(int fromIndex, int toIndex);
//返回此 BitSet 的“逻辑大小”,即实际使用的位数
int length();         
//返回此 BitSet 表示位值时实际使用空间的位数,即 words.length * 64
int size();  
//将此 BitSet 中的所有位设置为 false
void clear();        
//将索引指定处的位设置为 false
void clear(int bitIndex);       
//将指定的 fromIndex(包括)到指定的 toIndex(不包括)范围内的位设置为 false
void clear(int fromIndex, int toIndex);         
//将指定索引处的值设置为其当前值的补码
void flip(int bitIndex);          
//将 fromIndex(包括)到指定的 toIndex(不包括)范围内的每个位设置为其当前值的补码
void flip(int fromIndex, int toIndex);         
//返回此 BitSet 中设置为 true 的位数
int cardinality();       
//如果指定 BitSet 中设置为 true 的位,在此 BitSet 中也为 true,则返回 ture
boolean intersects(BitSet set);          
//如果此 BitSet 中没有包含任何设置为 true 的位,则返回 ture
boolean isEmpty();        
//返回 fromIndex(包括)之后第一个设置为 false 的位的索引
int nextClearBit(int fromIndex);
//返回 fromIndex(包括)之后的第一个设置为 true 的位的索引
int nextSetBit(int fromIndex);        
//返回该 BitSet 中为 true 的索引的字符串拼接形式
String toString();
//返回 hashcode 值
int hashcode();
//复制此 BitSet,生成一个与之相等的新 BitSet。
Object clone();
//将此对象与指定的对象进行比较。
boolean equals(Object obj);

感兴趣者可以翻阅以下两篇博客或者自行搜索,这里不做过多详述。
BitSet实现原理
BitSet的应用

Pair(线程不安全)

Pair与Map有点类似,都是一次可以存储两个元素对象,但它比Map底层结构简单的不要太多,Pair可以存储两个值(Key,Value),但它其实没有键与值之分,也不能通过键获得值,只是个单纯的用于存储两个值的容器类。读者可以自行去阅读源码,源码极其简单。应用场景是只是单纯的需要容器存储两个元素对象。

总结

各类容器没有好坏之分,只有是否适用之分,当需要根据键值获取到元素值时就选用Map接口下的集合,当要求容器内不能出现重复元素时,使用Set接口下的集合,一般情况下就选择List接口下的集合。选择容器类别之后再根据需求选择具体的容器。

若是想要在多线程环境中使用非线程安全容器可以使用Collections 的 synchronized XXX方法对该容器进行包装使用以保证线程安全。有如下Collections.synchronizedCollection()、Collections.synchronizedList()、Collections.synchronizedMap()、Collections.synchronizedSet()、Collections.synchronizedSortedMap()、Collections.synchronizedSortedSet()等方法。

你可能感兴趣的:(JavaSE,数据结构)