【Java基础03】常用的集合

容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。

Collection

List

主要分为以下三种

ArrayList

基于动态数组实现,支持随机访问。ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入多个null元素(多个null的位置如何确定?),底层通过数组实现。

方法
1、add(Object element) 向列表的尾部添加指定的元素。add(int index, E e)向指定位置添加元素
2、size() 返回列表中的元素个数。
3、get(int index) 返回列表中指定位置的元素,index从0开始。
4、isEmpty()判断是否为空
扩容

每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍。
最核心的是grow()方法,调用Arrays.copyOf方法将elementData数组指向新的内存空间,并将elementData的数据复制到新的内存空间。

  1. 简单来说就是在增长数组的时候,与所需的最小的容量进行比较
  2. 保证要扩容的大小大于最小满足的容量
  3. 如果已经大于了最大的数组大小,再做一次最大的容量处理

在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。

数组(Array)和列表(ArrayList)有什么区别

  • 数据类型:Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型。
  • 容量上:Array 大小固定,ArrayList 的大小是动态变化的。
  • 操作上:ArrayList提供更多的方法和特性,如:addAll(),removeAll(),iterator()等等。

使用基本数据类型或者知道数据元素数量的时候可以考虑 Array;ArrayList 处理固定数量的基本类型数据类型时会自动装箱来减少编码工作量,但是相对较慢。

ArrayList 与 LinkedList 区别

  • 数据结构: Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构;

  • 插入和删除:Arraylist 查询快,LinkedList 增加、删除更快

  • 内存空间:LinkedList 比 ArrayList 更占内存,因为 LinkedList
    为每一个节点存储了两个引用,一个指向前一个元素,一个指向下一个元素。

ArrayList 与 Vector 区别
● Vector是线程安全的,ArrayList不是线程安全的。其中,Vector在关键性的方法前面都加了synchronized关键字,开销就比ArrayList 要大。
● ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间。

LinkedList

LinkedList同时实现了List接口和Deque对口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(stack)。

底层数据结构
LinkedList底层通过双向链表实现,通过first和last引用分别指向链表的第一个和最后一个元素。当链表为空的时候first和last都指向null。
可以了解一下内部结构:

  transient int size = 0;

    /**
     * Pointer to first node.
     * Invariant: (first == null && last == null) ||
     *            (first.prev == null && first.item != null)
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) ||
     *            (last.next == null && last.item != null)
     */
    transient Node<E> last;

Node是私有的内部类:

 private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

构造函数

  public LinkedList() {
    }

    public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }

getFirst(), getLast()

 /**
     * Returns the first element in this list.
     *
     * @return the first element in this list
     * @throws NoSuchElementException if this list is empty
     */
    public E getFirst() {
        final Node<E> f = first;
        if (f == null)
            throw new NoSuchElementException();
        return f.item;
    }

    /**
     * Returns the last element in this list.
     *
     * @return the last element in this list
     * @throws NoSuchElementException if this list is empty
     */
    public E getLast() {
        final Node<E> l = last;
        if (l == null)
            throw new NoSuchElementException();
        return l.item;
    }
add()
    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    
    /**
     * Links e as last element.
     */
    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

removeFirest(), removeLast(), remove(e), remove(index)
remove()方法也有两个版本,一个是删除跟指定元素相等的第一个元素remove(Object o),另一个是删除指定下标处的元素remove(int index)。

  public boolean remove(Object o) {
        if (o == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null) {
                    unlink(x);
                    return true;
                }
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item)) {
                    unlink(x);
                    return true;
                }
            }
        }
        return false;
    }
    
    /**
     * Unlinks non-null node x.
     */
    E unlink(Node<E> x) {
        // assert x != null;
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

        if (prev == null) {// 第一个元素
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {// 最后一个元素
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null; // GC
        size--;
        modCount++;
        return element;
    }

clear()
为了让GC更快可以回收放置的元素,需要将node之间的引用关系赋空。

 /**
     * Removes all of the elements from this list.
     * The list will be empty after this call returns.
     */
    public void clear() {
        // Clearing all of the links between nodes is "unnecessary", but:
        // - helps a generational GC if the discarded nodes inhabit
        //   more than one generation
        // - is sure to free memory even if there is a reachable Iterator
        for (Node<E> x = first; x != null; ) {
            Node<E> next = x.next;
            x.item = null;
            x.next = null;
            x.prev = null;
            x = next;
        }
        first = last = null;
        size = 0;
        modCount++;
    }
Vector

Vector与ArrayList类似,不过是线程安全的。

Set

HashSet

HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。

由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性。

基本特点
● HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合。
● HashSet 允许有 null 值。
● HashSet 是无序的,即不会记录插入的顺序。
● HashSet 不是线程安全的。

方法
添加元素:add() 方法
判断元素是否存在: contains()
删除元素: remove()
计算集合容量: size()
遍历:for-each

Queue

Queue接口继承自Collection接口,Queue只是一个接口,当需要使用队列时首选ArrayDeque(次选是LinkedList)。包含以下6种方法

Deque

Deque的含义是“double ended queue”,即双端队列,它既可以当作栈使用,也可以当作队列使用。英文读作"deck".
Deque 继承自 Queue接口,除了支持Queue的方法之外,还支持insert,
remove和examine操作,由于Deque是双向的,所以可以对队列的头和尾都进行操作.

当把Deque当做FIFO的queue来使用时,元素是从deque的尾部添加,从头部进行删除的;
所以deque的部分方法是和queue是等同的。

PriorityQueue
PriorityQueue,即优先队列。优先队列的作用是能保证每次取出的元素都是队列中最小的.

PriorityQueue的peek()和element操作是常数时间,add(), offer(), 无参数的remove()以及poll()方法的时间复杂度都是log(N)。

remove()和poll()
remove()和poll()方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回null。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。

Map

HashMap

HashMap实现了Map接口,跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。

底层结构
JDK1.7与1.8的区别
JDK1.7 :由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
JDK1.8 :由“数组+链表/红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。
● 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
● 当链表超过 8 且数据总量超过 64 才会转红黑树。

扩容机制
hashmap什么时候进行扩容呢?
当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作。

如何扩容?
使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
jdk1.7:
transfer方法的作用是把原table的Node放到新的table中,jdk1.7使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
jdk1.8:

  1. 扩容时有个判断 e.hash & oldCap 是否为零,不需要像 JDK1.7 的实现那样重新计算hash ,只需要看看原来的 hash 值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap ”。
  2. JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法。
    举个例子,假设table原长度是16,扩容后长度32,那么一个hash值在扩容前后的table下标是这么计算:
    hash值的每个二进制位用abcde来表示,那么,hash和新旧table按位与的结果,最后4位显然是相同的,唯一可能出现的区别就在第5位,也就是hash值的b所在的那一位,如果b所在的那一位是0,那么新table按位与的结果和旧table的结果就相同,反之如果b所在的那一位是1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制10000就是旧table的长度16。

换言之,hash值的新散列下标是不是需要加上旧table长度,只需要看看hash值第5位是不是1就行了,位运算的方法就是hash值和10000(也就是旧table长度)来按位与,其结果只可能是10000或者00000。
————————————————

所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

HashMap容量
为什么是2的n次方?

  1. 在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。我们一般根据key得到hashcode值,然后跟数组的长度-1做一次“与”运算(&),这样的效率要比取模运算快得多。
  2. 当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,可以让0与1分布均匀,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小。
  3. 默认初始容量是16,即使指定了不是2的n次方的容量,也会扩容成比这个指定容量大一点的2的n次方,所以容量始终保持为2的n次方
    看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
    也就是说length-1在二进制来说每位都是1,这样可以保证最大的程度的散列hash值,否则,当有一位是0时,不管hash值对应位是1还是0,按位与后的结果都是0,会造成散列结果的重复。

为什么可以插入空值
在HashMap中添加key==null的Entry时会调用putForNullKey方法
1.先在table[0]的链表中寻找null key,如果有null key就直接覆盖原来的value,返回原来的value;
2.如果在table[0]中没有找到,就进行头插,但是要先判断是否要扩容,需要就扩容,然后进行头插,此时table[0]就是新插入的null key Entry了。

线程安全问题

如何保证线程安全?
要想实现线程安全,那么需要调用 collections 类的静态方法synchronizeMap()实现。
● HashMap 本身非线程安全的,但是当使用 Collections.synchronizedMap(new HashMap()) 进行包装后就返回一个线程安全的Map。其中每个方法都用synchronized关键字修饰后再返回,实现线程安全
或者使用粒度更小的ConcurrentHashMap。

Collections.synchronizedMap()与ConcurrentHashMap主要区别
Collections.synchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步,而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。这样,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,所以,即使在遍历map时,其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。

1.7-get()
算法思想是首先通过hash()函数得到对应bucket的下标,然后依次遍历冲突链表,通过key.equals(k)方法来判断是否是要找的那个entry。该方法调用了getEntry(Object key)得到相应的entry,然后返回entry.getValue()。

hash(k)&(table.length-1)等价于hash(k)%table.length,原因是HashMap要求table.length必须是2的指数,因此table.length-1就是二进制低位全是1,跟hash(k)相与会将哈希值的高位全抹掉,剩下的就是余数了。

1.7-put()
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法。

//addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);//自动扩容,并重新哈希
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = hash & (table.length-1);//hash%table.length
    }
//在冲突链表头部插入新的entry
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;

}
1.8-get()
相对于 put 来说,get 真的太简单了。
● 计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)
● 判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步
● 判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步
● 遍历链表,直到找到相等(==或equals)的 key

1.8-put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度
    // 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
else {// 数组该位置有数据
    Node<K,V> e; K k;
    // 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        e = p;
    // 如果该节点是代表红黑树的节点,调用红黑树的插值方法,本文不展开说红黑树
    else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
        // 到这里,说明数组该位置上是一个链表
        for (int binCount = 0; ; ++binCount) {
            // 插入到链表的最后面(Java7 是插入到链表的最前面)
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                // TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
                // 会触发下面的 treeifyBin,也就是将链表转换为红黑树
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                break;
            }
            // 如果在该链表中找到了"相等"的 key(== 或 equals)
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                // 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
                break;
            p = e;
        }
    }
    // e!=null 说明存在旧值的key与要插入的key"相等"
    // 对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
    if (e != null) {
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
}
++modCount;
// 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

}

性能
有两个参数可以影响HashMap的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。

hashCode()和equals()
hashCode()方法决定了对象会被放到哪个bucket里,当多个对象的哈希值冲突时,equals()方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap或HashSet中,需重写hashCode()和equals()方法。不然可能导致相同的两个对象同时存在于HashMap当中。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值