集合类深入

集合类深入

先上一张继承关系图

1594931-d1061afa6f0cbc96.png

ArrayList

对象数组结构。
public ArrayList() {
    //调用ArrayList(int initialCapacity)
    this(10);
}
public ArrayList(int initialCapacity) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    //创建存储数据对象
    this.elementData = new Object[initialCapacity];
}
元素添加:
public boolean add(E e) {
    //校验数组容量  
    ensureCapacity(size + 1);  // Increments modCount!!  
    //将数据对象插入到数组的size末端  
    elementData[size++] = e;
    return true;
}

//插入指定位置的元素  
public void add(int index, E element) {
    //索引>size或索引小于0直接抛出异常  
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(
                "Index: " + index + ", Size: " + size);
    ensureCapacity(size + 1);  // Increments modCount!!  
    //将index后面的数据后移一位  
    System.arraycopy(elementData, index, elementData, index + 1,
            size - index);
    //将数据插入到指定index位置  
    elementData[index] = element;
    //size加一  
    size++;
}

public void ensureCapacity(int minCapacity) {
    modCount++;
    //原有数组容量大小  
    int oldCapacity = elementData.length;
    //插入的索引位置大于数组容量,增大数组容量  
    if (minCapacity > oldCapacity) {
        Object oldData[] = elementData;
        //数组容量增大,按照原数组容量大小0.5增加  
        int newCapacity = (oldCapacity * 3) / 2 + 1;
        //增大的容量如果还是小于插入的索引位置,将索引直接赋值给新的数组容量  
        if (newCapacity < minCapacity)
            newCapacity = minCapacity;
        //将数组按照新的数组容量进行扩容  
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
}

先校验底层的对象数组是否能再添加元素,如果没法添加,先将对象数组进行扩容,进行一次数组拷贝,扩容因子为0.5,再进行插入;如果能添加元素,直接将数据插入到size对应的索引位置。如果插入到指定位置将会涉及到数组元素的后移,进行一次数组拷贝。

元素获取:
public E get(int index) {
    RangeCheck(index);//校验index大小
    return (E) elementData[index];//获取index位置的数据
}

private void RangeCheck(int index) {
    if (index >= size)
        throw new IndexOutOfBoundsException(
                "Index: " + index + ", Size: " + size);
}
元素删除:
public E remove(int index) {
    RangeCheck(index);//检查index大小
    modCount++;
    E oldValue = (E) elementData[index];//标记删除位置的值
    int numMoved = size - index - 1;//计算删除索引到size剩入数据
    if (numMoved > 0)//如果剩入数据大于0,则将index后的数据进行前移。
        System.arraycopy(elementData, index + 1, elementData, index,
                numMoved);
    elementData[--size] = null; //将size大小-1,并将elementData的原来索引位置为size-1的数据清空。
    return oldValue;
}

删除涉及到数组的前移,前移的数据范围为删除索引的值+1到size-1的元素,删除时并未缩小数组大小。

(1):ArrayList的实际存储对象为一个数组对象,没有容量大小,可以无限增加【扩展因子为0.5】。

(2):ArrayList中可以包含重复的元素,并可以存储NULL值。

(3):ArrayList的所有方法都是非线程安全的。

(4):ArrayList的索引访问和元素更新方面有着非常优秀的性能,因为不需要花费精力做范围检查,所以从ArrayList的末端添加元素,删除元素也有着非常优秀的性能,除非ArrayList的存储容量不足而需要扩展内部数组的长度。插入和删除数据需要一个数组的拷贝,需要拷贝的元素数量是ArrayList的元素长度(size-1)减去索引号。对插入来讲,插入元素到ArrayList的第一个元素位置的性能最差,插入到最后一个位子的性能最好,数组拷贝所需的时间会根据元素的数量增加而显著增加。

(5):ArrayList查找指定位置的数据时间为O(1),插入到指定位置的数据时间为O(size-index-1)。

(6):ArrayList插入大量的元素,提前指定ArrayList的大小,防止在插入过程ArrayList进行扩容。

Vector

实现与ArrayList基本一致,但所有的关键方法均加入了synchronized关键字,是线程安全的,适用于多线程共享数据存储的情况。

LinkedList

双向链表结构。

双向链表的每个节点用内部类Node表示,LinkedList通过first和last引用分别指向链表的第一个和最后一个元素。

元素添加:
public boolean add(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++;
    return true;
}

public void add(int index, E element) {
    checkPositionIndex(index);//index >= 0 && index <= size;
    if (index == size)//插入位置是末尾,包括列表为空的情况
        add(element);
    else{
        Node<E> succ = node(index);//1.先根据index找到要插入的位置
        //2.修改引用,完成插入操作。
        final Node<E> pred = succ.prev;
        final Node<E> newNode = new Node<>(pred, e, succ);
        succ.prev = newNode;
        if (pred == null)//插入位置为0
            first = newNode;
        else
            pred.next = newNode;
        size++;
    }
}

直接在队尾的链表节点上增加一个元素节点,时间复杂度为O(1);先根据index找到要插入的位置,再修改链表节点关系,将新数据加入。注意这里查找index的时候,判断了index的位置与size的关系,如果靠后则从后向前遍历查找。

元素获取:
public E get(int index) {
    //检查index是否合法
    checkElementIndex(index);
    //如果合法就返回该节点位置的值
    return node(index).item;
}
//获取index位置上的节点
Node<E> node(int index) {
    //断言index在链表中
    // assert isElementIndex(index);
    //从第一个节点开始寻找直到index位置,然后返回index//位置的节点
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {//从最后一个节点开始往前寻找节点
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}
//检查index值的合法性
private void checkElementIndex(int index) {
    if (!isElementIndex(index))
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
//判断index是否存在于链表中
private boolean isElementIndex(int index) {
    return index >= 0 && index < size;
}

获取index节点时需要从头到尾遍历链表,当链表数据量比较大时,耗时比较严重。

元素删除:
public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}
E unlink(Node<E> x) {
    // assert x != null;
    //保存x节点的值
    final E element = x.item;
    //保存x节点的后继
    final Node<E> next = x.next;
    //保存x节点的前驱
    final Node<E> prev = x.prev;
    //如果前驱为null,说明要移除的是第一个节点,把First指向下一个节点就行
    if (prev == null) {
        first = next;
    } else {//否则,把x节点前驱的后继指向x的后继,并把x的前驱设置为null
        prev.next = next;
        x.prev = null;
    }
    //如果后继为null则要移除的是最后一个节点,则把last的引用指向x节点的前驱就ok
    if (next == null) {
        last = prev;
    } else {//否则,把x节点的后继的前驱设置为x节点的前驱,并x节点的后继设为null
        next.prev = prev;
        x.next = null;
    }
    //把x节点的值设为null,这样x就没有任何引用了,gc处理
    x.item = null;
    //把链表的size减少1
    size--;
    //结构性修改的次数增加1
    modCount++;
    //返回x节点的值,在移除之前已经保存在element中了
    return element;
}

HashMap

链表数组结构。
74baaccb-cf91-38d0-9ad5-486d6742efbd.jpg

在HashMap内部,有一个transient Entry[] table这样的结构数组,它保存所有Entry的一个列表,而Entry的定义是一个典型的链表结构,它是单独为HashMap服务的一个内部单链表结构的类。

 static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        Entry<K,V> next;  
        final int hash;  
        Entry(int h, K k, V v, Entry<K,V> n) {  
            value = v;  
            next = n;  
            key = k;  
            hash = h;  
        }  
//...  
} 
元素添加
public V put(K key, V value) {  
    // HashMap允许存放null键和null值。  
    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
    if (key == null)  
        return putForNullKey(value);  
    // 根据key的keyCode重新计算hash值。  
    int hash = hash(key.hashCode());  
    // 搜索指定hash值在对应table中的索引。  
    int i = indexFor(hash, table.length);  
    // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。  
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
        Object k;  
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
            V oldValue = e.value;  
            e.value = value;  
            e.recordAccess(this);  
            return oldValue;  
        }  
    }  
    // 如果i索引处的Entry为null,表明此处还没有Entry。  
    modCount++;  
    // 将key、value添加到i索引处。  
    addEntry(hash, key, value, i);  
    return null;  
}  

当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

hash(int h)方法根据key的hashCode重新计算一次散列。

static int hash(int h) {  
    h ^= (h >>> 20) ^ (h >>> 12);  
    return h ^ (h >>> 7) ^ (h >>> 4);  
}  

接下来看一下indexFor方法。

static int indexFor(int h, int length) {  
    return h & (length-1);  
} 

首先,HashMap的容量总是2的n次方,即底层数组的长度总是为2的n次方,h& (length-1)运算等价于对length取模,当数组长度为2的n次幂的时候,不同的key算得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

HashMap的resize:

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16 * 0.75=12的时候,就把数组的大小扩展为 2 * 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

元素获取
public V get(Object key) {  
    if (key == null)  
        return getForNullKey();  
    int hash = hash(key.hashCode());  
    for (Entry<K,V> e = table[indexFor(hash, table.length)];   
    // table[indexFor(hash, table.length)] 就是将indexFor运算得到的值直接映射到数组的索引  
         e != null;  
         e = e.next) {  
         Object k;  
         if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
        //找到hash值相同的情况下可能出现hash碰撞,所以需要调用equals方法来比较是否相等  
            return e.value;  
    }  
    return null;  
}  

先通过indexFor找到index位置,拿到首节点Entry,再循环遍历链表,通过比较key值是否相同,找到对应的Entry值。

HashSet

基于HashMap实现的,HashSet底层使用HashMap来保存所有元素。

private transient HashMap<E,Object> map;  
  
// Dummy value to associate with an Object in the backing Map  
private static final Object PRESENT = new Object(); 

它里面放置的元素都对应到map里面的key部分,而在map中与key对应的value用一个Object()对象保存。

TreeMap

基于红黑树实现,首先需要了解二叉树、平衡二叉树和红黑树。

  1. 没有子节点的节点称为树叶叶子节点
  2. 有相同父亲的节点称为兄弟节点
  3. 对任意节点的深度是从根到该节点的唯一路径的长度。
  4. 树的指的是从根节点到所有节点的路径中最长的一个。

二叉树是一棵树,其中每个节点都不能有多余两个儿子。对于树中的每个节点X,它的左子树中所有项的值小于X中的项,而它的右子树中所有的项的值大于X中的项。

平衡二叉树必须具备如下特性:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。也就是说该二叉树的任何一个等等子节点,其左右子树的高度都相近

对二叉树进行插入操作可能会使其失去平衡的条件,但这可以通过对树进行简单的修正来保持其平衡的属性,这种操作称之为旋转,实现平衡二叉树,保持树的深度必须是O(log N)。

  1. 对a的左儿子的左子树进行一次插入 (右旋)
  2. 对a的左儿子的右子树进行一次插入 (左右旋)
  3. 对a的右儿子的左子树进行一次插入 (右左旋)
  4. 对a的右儿子的右子树进行一次插入 (左旋)

历史上平衡二叉树最流行的另一变种是红黑树。红黑树具有下列着色性质的二叉查找树:

  1. 每一个节点或者着成红色,或者着成黑色。
  2. 根是黑色的。
  3. 如果一个节点是红色的,那么它的子节点必须是黑色的。
  4. 一个节点到一个null引用的每一条路径必须包含相同数量的黑色节点。
元素添加:
public V put(K key, V value) {
        Entry<K,V> t = root;
        if (t == null) {
        //如果根节点为null,将传入的键值对构造成根节点(根节点没有父节点,所以传入的父节点为null)
            root = new Entry<K,V>(key, value, null);
            size = 1;
            modCount++;
            return null;
        }
        // 记录比较结果
        int cmp;
        Entry<K,V> parent;
        // 分割比较器和可比较接口的处理
        Comparator<? super K> cpr = comparator;
        // 有比较器的处理
        if (cpr != null) {
            // do while实现在root为根节点移动寻找传入键值对需要插入的位置
            do {
                // 记录将要被掺入新的键值对将要节点(即新节点的父节点)
                parent = t;
                // 使用比较器比较父节点和插入键值对的key值的大小
                cmp = cpr.compare(key, t.key);
                // 插入的key较大
                if (cmp < 0)
                    t = t.left;
                // 插入的key较小
                else if (cmp > 0)
                    t = t.right;
                // key值相等,替换并返回t节点的value(put方法结束)
                else
                    return t.setValue(value);
            } while (t != null);
        }
        // 没有比较器的处理
        else {
            // key为null抛出NullPointerException异常
            if (key == null)
                throw new NullPointerException();
            Comparable<? super K> k = (Comparable<? super K>) key;
            // 与if中的do while类似,只是比较的方式不同
            do {
                parent = t;
                cmp = k.compareTo(t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        // 没有找到key相同的节点才会有下面的操作
        // 根据传入的键值对和找到的“父节点”创建新节点
        Entry<K,V> e = new Entry<K,V>(key, value, parent);
        // 根据最后一次的判断结果确认新节点是“父节点”的左孩子还是又孩子
        if (cmp < 0)
            parent.left = e;
        else
            parent.right = e;
        // 对加入新节点的树进行调整
        fixAfterInsertion(e);
        // 记录size和modCount
        size++;
        modCount++;
        // 因为是插入新节点,所以返回的是null
        return null;
    }

1、以根节点为初始节点进行检索。

2、与当前节点进行比对,若新增节点值较大,则以当前节点的右子节点作为新的当前节点。否则以当前节点的左子节点作为新的当前节点。

3、循环递归2步骤知道检索出合适的叶子节点为止。

4、将新增节点与3步骤中找到的节点进行比对,如果新增节点较大,则添加为右子节点;否则添加为左子节点。

5、平衡二叉树操作fixAfterInsertion,调整的过程务必会涉及到红黑树的左旋、右旋、着色三个基本操作。

TreeSet

底层基于TreeMap实现

private transient NavigableMap<E,Object> m;

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
    
TreeSet(NavigableMap<E,Object> m) {
    this.m = m;
}

public TreeSet() {
    this(new TreeMap<E,Object>());
}

value部分使用PRESENT替代。

LinkedHashMap

LinkedHashMap是HashMap的子类,核心实现是基于HashMap实现,但HashMap是无序的,而LinkedHashMap是有序的,我们来看看它是如何实现的。

在LinkedHashMap中,并没有实现put方法,重写了addEntry和createEntry方法,如下:

    /**
     * 创建节点,插入到LinkedHashMap中,该方法覆盖HashMap的addEntry方法
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        super.addEntry(hash, key, value, bucketIndex);

        // 注意头结点的下个节点即header.after,存放于链表头部,是最不经常访问或第一个插入的节点,
        //有必要的情况下(如容量不够,具体看removeEldestEntry方法的实现,这里默认为false,不删除),可以先删除
        Entry<K,V> eldest = header.after;
        if (removeEldestEntry(eldest)) {
            removeEntryForKey(eldest.key);
        }
    }
    
    /**
     * 创建节点,并将该节点插入到链表尾部
     */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMap.Entry<K,V> old = table[bucketIndex];
        Entry<K,V> e = new Entry<>(hash, key, value, old);
        table[bucketIndex] = e;
        //将该节点插入到链表尾部
        e.addBefore(header);
        size++;
    }

在HashMap的addEntry方法中会调用createEntry方法

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
         resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
         hash = (null != key) ? hash(key) : 0;
         bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
 }

所以可以知道,LinkedHashMap除了将Entry放到对应的桶之外,还将该节点放到了一个双向链表的尾部,用一张图来表达就是这样的。

879896-20160319110227459-704282597.jpg

每次插入的数据除了按照HashMap固有hashcode命中的存放方式外,还会形成一个以header结点为头结点的双向链表,每次将新加入的节点放在双向链表的尾部,用另一个示意图表示就是这样的。

879896-20160319110431662-94784161.jpg

所以HashMap用来存放和获取对象,而双向链表用来实现有序。

LinkedHashSet

在父类HashSet的构造器中,有专门为LinkedHashSet留的包构造器,实现即LinkedHashMap

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

总结

总结一下:

ArrayList基于数组实现,扩容因子为0.5,当索引位置超过数组容量时,按照原数组的0.5倍进行扩容,扩容涉及到数组拷贝,开销较大,同样在某个位置插入也需要拷贝数组,时间开销较大,但访问速度较快。

Vector就是线程安全的ArrayList。

LinkedList基于双向链表实现,在数据量较大的情况下,遍历成本较高,访问某个元素的时间成本也较高,但插入和删除的时间成本较低。

HashMap基于数组加链表的数据结构,初始化大小为16,负载因子为0.75,即当数组占位超过0.75当前长度时,就要对数组进行扩容,而且扩容倍数为当前的2倍。保持两倍的扩容比例是在桶这个层面实现最小哈希冲突的方法。当出现哈希冲突时,通过添加链表的方式解决,但一个HashMap的链表越少,性能才越好。

TreeMap的核心就是红黑树,红黑树是最典型的平衡二叉树实现,任何一次值的添加,都会导致树的失衡,以及左旋右旋的计算。

LinkedHashMap就是在HashMap的基础上,增加了双向链表,用来维护顺序,访问还是基于HashMap来完成,而遍历时则会基于双线链表,实现有序的访问。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342

推荐阅读更多精彩内容