Java常见的集合类汇总

关系图

某些实现类的底层实现方式此图不准确,在新版本的JVM中有所变化,比如HashMap,但是继承和实现关系没有变。实线白色箭头是继承,蓝色虚线箭头是实现。
在这里插入图片描述

Map

HashMap

java8之前底层实现是数组+链表,从java8开始改成了数组+链表+红黑树。
在这里插入图片描述

数组的特点是:寻址容易,插入和删除困难;
链表的特点是:寻址困难,插入和删除容易。
哈希表((Hash table)则把二者融合既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。HashMap底层就采用了哈希表,把根据key计算得到的hashcode放在数组里,把真正的key和value放到链表里,查找时先查找到hashcode,再根据key找到相同的节点返回value。
Java8 对 HashMap 进行了一些修改, 最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的
具体下标,但是之后的话, 需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中, 当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
HashMap的默认容量是16,当数组达到0.75倍空间使用率时就会进行扩容,扩容为原来的两倍。key允许1个为null,value可以多个key对应的都是null,无序。

ConcurrentHashMap

Collections.synchronizedMap()、HashTable、ConcurrentHashMap都是线程安全的,但是前两个的性能太低,大多数时候都是用ConcurrentHashMap。ConcurrentHashMap也是无序的但是它不允许key值为null。
JDK1.8之前,ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。整个 ConcurrentHashMap 由一个个 Segment 组成, Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个segment.简单理解就是, ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
在这里插入图片描述
它默认有 16 个 Segments,所以理论上, 这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置concurrencyLevel为其他值,但是一旦初始化以后,它是不可以扩容的。如果线程数超过最大并发度拿不到锁时就会自旋。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。每次put数据时会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 在往该 segment 写入前,需要先获取该 segment 的独占锁,获取失败尝试获取自旋锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        // segment 内部的数组
        HashEntry<K,V>[] tab = table;
        // 利用 hash 值,求应该放置的数组下标
        int index = (tab.length - 1) & hash;
        // first 是数组该位置处的链表的表头
        HashEntry<K,V> first = entryAt(tab, index);
 
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key)  key ||
                    (e.hash  hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        // 覆盖旧值
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                // 继续顺着链表走
                e = e.next;
            }
            else {
                // node 是不是 null,这个要看获取锁的过程。没获得锁的线程帮我们创建好了节点,直接头插法
                // 如果不为 null,那就直接将它设置为链表表头;如果是 null,初始化并设置为链表表头。
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
 
                int c = count + 1;
                // 如果超过了该 segment 的阈值,这个 segment 需要扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node); // 扩容
                else
                    // 没有达到阈值,将 node 放到数组 tab 的 index 位置,
                    // 将新的结点设置成原链表的表头
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        // 解锁
        unlock();
    }
    return oldValue;
}

从JDK1.8开始ConcurrentHashMap底层实现进行了调整,采用了CAS+Synchronized取代了之前的Segment+ReentrantLock。没有了segment的概念,和普通的hashmap结构一致,只是在put时会锁住数组中每个节点下链表或红黑树的第一个节点,因为put时需要按顺序比较key,把第一个元素锁住就保证了同步。相比起锁segment能进一步减少线程冲突。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成后的table。
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 如果在链表中找到值为key的节点e,直接设置e.val = value即可。
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            // 如果没有找到值为key的节点,直接新建Node并加入链表尾部即可(尾插法)。
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    // 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 如果节点数>=8,那么转换链表结构为红黑树结构。
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 计数增加1,有可能触发transfer操作(扩容)。
    addCount(1L, binCount);
    return null;
}

另外,获取size的方法也进行了优化,JDK1.8之前,是采用的分段锁给每个segment加个锁来保证线程并发,但是当获取size时需要锁住所有segment才能得到准确的大小,计算结束之后,再依次解锁。不过这样做,将会导致写操作被阻塞,一定程度降低ConcurrentHashMap性能。JDK1.8之后采用了counterCells 和baseCount来统计数量,baseCount 记录元素数量的,每次元素元素变更之后,将会使用 CAS方式更新该值。如果多个线程并发增加新元素,baseCount 更新冲突,将会启用 CounterCell,通过使用 CAS 方式将总数更新到 counterCells 数组对应的位置,减少竞争。如果 CAS 更新 counterCells 数组某个位置出现多次失败,这表明多个线程在使用这个位置。此时将会通过扩容 counterCells方式,再次减少冲突。通过上面的努力,统计元素总数就变得非常简单,只要计算 baseCount 与 counterCells总和,整个过程都不需要加锁。

final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

LinkedHashMap

HashMap是无序的,但是有时需要根据按插入时的顺序再取出来,这时就用到了LinkedHashMap。虽然LinkedHashMap增加了时间和空间上的开销,但是它通过维护一个额外的双向链表保证了迭代顺序。特别地,该迭代顺序可以是插入顺序,也可以是访问顺序。因此,根据链表中元素的顺序可以将LinkedHashMap分为:保持插入顺序的LinkedHashMap 和 保持访问顺序的LinkedHashMap,其中LinkedHashMap的默认实现是按插入顺序排序的。本质上,HashMap和双向链表合二为一即是LinkedHashMap。所谓LinkedHashMap,其落脚点在HashMap,因此更准确地说,它是一个将所有Entry节点链入一个双向链表双向链表的HashMap。在LinkedHashMapMap中,所有put进来的Entry都保存在hashMap中,但由于它又额外定义了一个以head为头结点的双向链表 ,因此对于每次put进来Entry,除了将其保存到哈希表中对应的位置上之外,还会将其插入到双向链表的尾部。

   /**
         * 实例化一个LinkedHashMap;
         *
         * LinkedHashMap的插入顺序和访问顺序;
         * LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder);
         * 说明:
         * 	当accessOrder为true时表示当前数据的插入读取顺序为访问顺序;
         * 	当accessOrder为false时表示当前数据的插入读取顺序为插入顺序;
         */
        Map<String,String> map = new LinkedHashMap<>(5,0.75f,true);
//      Map<String,String> map = new LinkedHashMap<>(5,0.75f,false);
        map.put("1","1");
        map.put("2","2");
        map.put("3","3");
        map.put("4","4");
        map.put("5","5");
        System.out.println(map.get("3"));
        System.out.println(map);

TreeMap

TreeMap的底层实现是红黑树,key不允许为null,线程不安全,但是却是有序的。排序规则为默认按照字典顺序排序,也可以自定义实现排序规则,在定义treemap时把排序类通过构造方法传进去。

HashTable

hashTable已经被淘汰了,现在基本没有用hashTable的了,单线程用hashMap,多线程用ConcurrentHashMap,因为他里面所有的方法都是用的悲观锁来锁住,多线程情况下效率较低。且内部实现还是数组+链表的形式,官方已经不再对这个更新了。

Collection

List

ArrayList

ArrayList是有序的,顺序为插入的顺序。底层实现是数组,线程不安全的,内容是允许重复的,和数组一样也是查询快增删慢(增删慢的原因是当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高),实现了随机访问接口,可以快速随机访问,所以用for循环来遍历ArrayList比用iterator要快.扩容时当list非空时扩容为原来的1.5倍大小,list为空时扩容为默认容量10。

LinkedList

linkedList实现方式与ArralList不同,它是采用了链表来实现的,有序且允许重复,线程不安全的,与数组不同的是他无序扩容,只要增加时指定前后节点就可以无限扩容,因为是链表实现的所以他的增删比较快不需要想数组那样复制移动等等,但是它的查询比较慢,因为他需要挨个遍历。(1.数组就像身上编了号站成一排的人,要找第10个人很容易,根据人身上的编号很快就能找到。但插入、删除慢,要望某个位置插入或删除一个人时,后面的人身上的编号都要变。当然,加入或删除的人始终末尾的也快。2. 链表就像手牵着手站成一圈的人,要找第10个人不容易,必须从第一个人一个个数过去。但插入、删除快。插入时只要解开两个人的手,并重新牵上新加进来的人的手就可以。删除一样的道理。

Vector

Vector就像HashTable一样现在用的人比较少了,原因就是它同Collections.synchronizedList()一样性能都比较低,单线程情况下直接使用ArrayList,多线程情况下现在都采用CopyOnWriteList来代替了。他底层也是数组实现的,有序,允许重复,扩容的话是可以通过构造方法指定扩容数量或者不指定直接扩容为现有容量的两倍。

CopyOnWriteArrayList

Collections.synchronizedList(List list),但是无论是读取还是写入,它都会把整个list加锁,当我们并发级别特别高,线程之间在任何操作上都会进行等待,因此在某些场景中它不是最好的选择。在很多的场景中,我们的读取操作可能远远大于写入操作,这时使用这种方式,显然不能让我们满意,为了将读取的性能发挥到极致,JUC包中提供了CopyOnWriteArrayList类,该类在使用过程中,读读之间不互斥并且更厉害的是读写也不互斥。

 public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();//写入时加锁,然后执行下面的代码
        try {
        	//把原来的数组拷贝了一份然后把用一个新的数组放进去原来的数组内容和增加的内容
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            //再把新数组赋给老数组,老数组是用volatile声明的,所以其他线程立马能拿到最新数据
            setArray(newElements);
            return true;
        } finally {
         	/**
         	* 最后释放锁。实现了只在写入时加锁,因为这时即使加了锁和用sync关键字
         	* 锁整个list是不一样的,实现了读写分离,且读不加锁保证了 读读之间不互斥并且更厉害的是读写也不互斥。
         	* /
            lock.unlock();
        }
    }

从源码中,我们可以看出add操作中使用了重入锁,但是此锁只针对写-写操作。 读写之间不互斥的关键就在于添加值的操作并不是直接在原有数组中完成,而是使用原有数组复制一个新的数组,然后将值插入到新的数组中,最后使用新数组替换旧数组,这样插入就完成了。使用这种方式,在add的过程中旧数组没有得到修改,因此写入操作不影响读取操,另外,数组定义时采用volatile修饰,保证内存可见性,修改了之后读取线程可以马上拿到最新数据。(注意调用subList()方法时,返回的是CopyOnWriteList的一个内部类叫COWSubList,使用时注意类型不同可能导致的报错)

//读取
 public E get(int index) {
        return get(getArray(), index);
    }

因此在读取多写入少的情况下尽量采用CopyOnWriteList。但是当读取少写入多的情况,我们反而可以采用Collections.synchronizedList,因为它直接就是加锁写入,少了复制数组这个环节。

Set

HashSet

hashSet底层其实是用HashMap来存储的,把每个元素作为key,value放一个空的对象放入hashMap中去。

LinkedHashSet

linkedHashSet底层其实是用LinkedHashMap来实现的,也不再赘述。

TreeSet

TreeSet底层是TreeMap 也就是红黑树 不再赘述。

CopyOnWriteArraySet

CopyOnWriteArraySet底层其实是CopyOnWriteArrayList,是线程安全的。当增加元素时会挨个遍历list中是否有和这个值相同的元素,如果有则复制一份原来的list,放进去最新的然后替换掉(其实就是CopyOnWriteArrayList的add步骤)返回true,如果已经有了则不再添加并返回false。

Dueue双向队列

LinkedList

LinkedList既实现了List接口也实现了Deque(继承Queue,Deque是双端队列就是可以从两头写数据,Queue只能从头往里面写)接口。所以声明时不仅可以用List list = new LinkedList(); 也可以用Deque deque = new LinkedList();来声明,用作栈和队列。

队列的实现和操作。(先进先出)

import java.util.LinkedList;
import java.util.Queue;
//用linkedList模拟队列,因为链表擅长插入和删除
public class Hi {
    public static void main(String [] args) { //做剑指offer遇见过这个数结
        Queue<String> queue = new LinkedList<String>();
        //追加元素
        queue.add("zero");
        queue.offer("one");
        queue.offer("two");
        queue.offer("three");
        queue.offer("four");
        System.out.println(queue);//[zero, one, two, three, four]
        //从队首取出元素并删除
        System.out.println(queue.poll());// zero
        System.out.println(queue.remove());//one
        System.out.println(queue);//[two, three, four]
        //从队首取出元素但是不删除
        String peek = queue.peek();
        System.out.println(peek); //two
        //遍历队列,这里要注意,每次取完元素后都会删除,整个
        //队列会变短,所以只需要判断队列的大小即可
        while(queue.size() > 0) {
            System.out.println(queue.poll());
        }//two three four
    }
}

栈的实现和操作(先进后出)

import java.util.Deque;
import java.util.LinkedList;
 
public class Hi {
    public static void main(String[] args) {
        /*模拟栈,这是从头开始进来的*/
        Deque<String> deque = new LinkedList<String>();
        // push从栈顶开始往里面放
        deque.push("a");
        deque.push("b");
        deque.push("c");
        System.out.println(deque); //[c, b, a]
        //获取栈首元素后,元素不会出栈
        System.out.println(deque.peek());//c
        while(deque.size() > 0) {
            //获取栈首元素后,元素将会出栈
            System.out.println(deque.pop());//c b a
        }
        System.out.println(deque);//[]
         
        /*栈也可以倒过来放倒过来取  倒过来的话和队列是一样的顺序*/
        deque.offerLast("a");//这儿也可以从栈尾放
        deque.offerLast("b");
        deque.offerLast("c");// [a, b, c]
        while(!deque.isEmpty())
            System.out.println(deque.pollLast());
    }   // 先输出c再b最后a
}
ArrayDeque

前面讲LinkedList时提到Queue是队列,而Deque是双端队列,ArrayDeque是继承自Deque接口,Deque继承自Queue接口,也就是说ArrayDeque可以从前或者从后插入或者取出元素,单向队列只能从一头插入,从另一头取出。内部实现为数组
在这里插入图片描述
元素都存储在Object数组中,head记录首节点的序号,tail记录尾节点后一个位置的序号,队列的容量最小为8。
从头部添加。

Deque<String> deque = new ArrayDeque<>();
        deque.addFirst("3");
        deque.push("2");//内部也是调用了addFirst
        deque.addFirst("1");
        System.out.println(deque);//打印[1,2,3]
        deque.add("3");//内部也是调用了addLast
        deque.addLast("2");
        deque.addLast("1");
        System.out.println(deque);//打印[1,2,3,3,2,1]
        deque.pollLast(); //从头部删除
        deque.pollFirst();//从尾部删除
        System.out.println(deque.getFirst()); //从头部获取但是不删除
        System.out.println(deque.getLast()); //从尾部获取但是不删除
        System.out.println(deque);

Queue 单向阻塞队列

ArrayBlokingQueue

现在实际开发中应用都是分布式的,一个应用有多个副本,所以除了线程池时用到阻塞队列之外很少用到这俩,一般采用Redis实现阻塞队列,比如多个用户群发邮件时候,一般每个人都会给很多邮箱群发,所以一般都是异步处理放到Redis,然后按照排队的先后顺序进行异步处理。
ArrayBlokingQueue是一种有界阻塞队列,它底层是由数组实现,先进先出的顺序。

 Queue<String> a = new ArrayBlockingQueue<>(2);
        a.offer("a");
        a.offer("b");
        //读取元素但不取出
        System.out.println(a.peek());
        //读取并取出元素
        System.out.println(a.poll());
        System.out.println(a);
LinkedBlockingQueue

也是阻塞队列,但是是无界的,用法跟ArrayBlockingQueue基本一样。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值