Java集合详解

List

ArrayList

ArrayList实现了List接口,底层使用动态数组(Dynamic Array)来存储元素,因此,它可以在运行时根据需要增长和缩小。ArrayList集合可以存储不同类型的对象,包括用户自定义的对象。

ArrayList的特点包括:

  1. 动态大小 - 可以在运行时调整大小,无需声明固定长度,插入或删除对象的索引位置越小效率越低,当向指定的索引位置插入对象时,会同时将指定索引位置及之后的所有对象相应的向后移动一位。
  2. 顺序存储 - 按照插入顺序存储元素,可以容纳重复的元素。
  3. 随机访问 - 通过索引快速访问元素,提供了get(int index)set(int index, E element)方法。

ArrayList默认容量和扩容机制

ArrayList默认容量为10

private static final int DEFAULT_CAPACITY = 10

扩容机制

//minCapacity 代表着最小扩容量
    private void grow(int minCapacity) {
        // overflow-conscious code
        //elementData 是 ArrayList存储数据的数组 这里是获取当前数组的长度
        int oldCapacity = elementData.length;
        //计算扩容后的数组长度 = 当前数组长度  + (当前数组长度 * 0.5) ;也就是扩容到当前的 1.5 倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //判断新的数组是否满足最小扩容量,如果不满足就将新数组的扩容长度赋值为最小扩容量
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

当集合中的元素数量超过当前容量时,ArrayList会创建一个新的数组,其大小是旧数组大小的1.5倍,然后将所有的元素从旧数组中拷贝到新数组中。这一过程涉及到数组的创建和数据的拷贝,会消耗一定的时间和空间资源,因此在初始化ArrayList时,如果可以预知数据的大小,最好指定一个合适的初始化容量,以减少不必要的扩容操作。

ConcurrentModificationException

List<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");

        for (String s : list) {
            if (s.equals("B")) {
                list.remove(s);
            }
        }

ConcurrentModificationException 出现在使用 ForEach遍历,迭代器遍历的同时,进行删除,增加出现的异常。

ForEach 遍历就是 For(Object o : List) 这种遍历方式,ForEach循环只是JAVA的一个语法糖,在字节码层面上,等同于迭代器循环遍历。

迭代ArrayList的Iterator中有一个变量expectedModCount,该变量会初始化和modCount相等,但如果接下来如果集合进行修改modCount改变,就会造成expectedModCount!=modCount,此时就会抛出java.util.ConcurrentModificationException异常,其实这个机制是为了免于面临不一致的结果。因为在遍历的过程中,如果集合发生了变化,那么已经遍历过的元素和还未遍历的元素可能会存在一些不一致的状态,这会带来很多问题。例如,一个元素可能会被遍历多次,或者一些元素可能会被跳过。为了避免这种可能性

public E next() {
    checkForComodification();
    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

解决方案有两种:

  1. 使用Iterator提供的remove方法,用于删除当前元素
  2. 使用并发容器CopyOnWriteArrayList代替

为什么ArrayList线程不安全

public boolean add(E e) {

    /**
     * 添加一个元素时,做了如下两步操作
     * 1.判断列表的capacity容量是否足够,是否需要扩容
     * 2.真正将元素放在列表的元素数组里面
     */
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

多线程新增元素的时候会出现两个地方线程不安全

  1. 假设列表大小为9,线程A新增一个元素,判断容量是不是足够,同时线程B也新增一个元素,判断容量是不是足够,线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10,线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException.
  2. 另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作

CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

CopyOnWriteArrayList的实现原理

在使用CopyOnWriteArrayList之前,我们先阅读其源码了解下它是如何实现的。以下代码是向ArrayList里添加元素,可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy出N个副本出来。

public boolean add(T 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;
        // 把原数组引用指向新数组

        setArray(newElements);

        return true;

    } finally {

        lock.unlock();

    }

final void setArray(Object[] a) {
    array = a;
}

}

读的时候不需要加锁,如果读的时候有多个线程正在向ArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的ArrayList

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

CopyOnWrite的缺点

CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

  1. 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
  2. 数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

LinkedList

LinkedList 是基于双向链表实现的。这意味着 LinkedList 在添加和删除元素时不需要进行数组的复制和移动操作,这使得在列表中间插入和删除操作比 ArrayList 更高效。LinkedList没有任何同步手段,所以多线程环境须慎重考虑,可以使用Collections.synchronizedList(new LinkedList(...));保证线程安全。

主要特点:

  1. 动态增长:由于是链表结构,它可以在运行时动态地增加和减少其大小,且不需要像数组那样在内存中连续存放。
  2. 随机访问性能较低:由于数据是链表结构,随机访问(比如访问中间的一个元素)的时间复杂度是 O(n),相比之下 ArrayList 执行相同操作的时间复杂度是 O(1)。
  3. 高效的插入和删除:在链表的任何位置插入和删除元素的时间复杂度都是 O(1),只需要修改前后元素的链接即可,但前提是你已经有了要操作元素的直接引用。如果是通过索引来访问元素,则需要从头结点或尾节点遍历到目标位置,时间复杂度提升为 O(n)。

Vector

Vector和ArrayList有一些相似,其内部都是通过一个容量能够动态增长的数组来实现的。不同点是Vector是线程安全的。因为其内部有很多同步代码快来保证线程安全。

public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);
    }

Set

一个不包含重复元素的 collection。更确切地讲,set 不包含满足 e1.equals(e2) 的元素对 e1 和 e2,并且最多包含一个 null 元素。

HashSet

private transient HashMap<E,Object> map;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

HashSet是Java中的一种集合,它实现了Set接口,使用哈希表(实际上是HashMap实例)作为内部数据结构。HashSet支持创建一个包含不重复元素的集合,即任何两个元素都不相同。

特点:

  1. 唯一性HashSet内部的元素不会有重复,它通过元素的hashCode()equals()方法来检查元素的唯一性。
  2. 无序集合:元素在HashSet中的存储是无序的,也就是说,迭代HashSet时,元素的返回顺序并不是其被添加到集合时的顺序。
  3. null元素HashSet允许插入一个null元素。
  4. 性能高:由于使用哈希表,HashSet在添加、删除和查询元素时可以提供常数时间的性能,这个性能依赖于哈希函数的好坏以及HashSet初始容量和负载因子的设置。

LinkedHashSet

结合了 HashSet 的唯一性特点和 LinkedList 的顺序性特点。LinkedHashSet 继承自 HashSet,并且内部是通过 LinkedHashMap 来实现的。

特点:

  1. 唯一性:和 HashSet 一样,LinkedHashSet 也保证集合内的元素唯一。
  2. 有序性:不同于 HashSetLinkedHashSet 可以按照元素被添加的顺序进行迭代。
  3. null 值LinkedHashSet 允许插入一个 null 元素。
  4. 性能:由于维护了元素的插入顺序,LinkedHashSet 在插入和删除操作上略微慢于 HashSet,但是差别通常很小。对于查找操作,LinkedHashSet 依然提供了很好的性能。

TreeSet

public interface NavigableMap<K,V> extends SortedMap<K,V> 
    
    private transient NavigableMap<E,Object> m;
    
    public boolean add(E e) {
        return m.put(e, PRESENT)==null;
    }

TreeSet基于红黑树(Red-Black tree)实现,这是一种自平衡的二叉搜索树。因此,TreeSet中的元素都是自动排好序的,无论你何时添加元素,集合都会保持元素的排序顺序。TreeSet的这种性质使其非常适合于需要大量排序操作的场景。

特点:

  1. 元素唯一和排序TreeSet保证集合中的元素唯一,并且根据元素的自然顺序或者构造TreeSet时指定的Comparator进行排序。
  2. 性能:添加、删除、查找等操作的时间复杂度是O(log n),其中n是集合中元素的数量。这是因为基于红黑树的实现。
  3. null元素TreeSet不允许插入null元素,若尝试插入null,则会抛出NullPointerException

Map

将键映射到值的对象。一个映射不能包含重复的键;每个键最多只能映射到一个值。

HashMap

HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的

Hash冲突

Java的HashMap通过“链表法”来解决哈希冲突问题。在Java 8及更高版本中,当链表长度大于某个阈值(默认为8)时,链表会转换成红黑树,以减少搜索时间。

  • 链表法:在同一个索引位置,HashMap维护了一个链表来存储所有哈希值相同的条目(键值对)。当产生哈希冲突时,新的条目会被添加到链表的末尾。当需要获取一个条目时,HashMap会先定位到这个条目所在的链表,然后遍历链表来查找具体的条目。

  • 红黑树:当一个索引位置上的链表长度超过阈值时(比如,在 Java 8 中,默认是8),链表就会被转换成红黑树。红黑树是一种自平衡的二叉搜索树,这意味着数据的插入、删除和查找操作都能在对数时间内完成,对比于链表的线性时间,这大大提升了性能。

如何减少哈希冲突

  1. 使用高质量的哈希函数:一个好的哈希函数能够尽量减少冲突,将键均匀地分布在哈希表中。

  2. 动态调整容量HashMap 会根据存储的键值对数量自动调整内部数组的大小,这个过程叫作rehashing。当键值对的数量超过负载因子(默认为0.75)和当前容量的乘积时,哈希表会进行扩容并重新哈希所有的条目,这有助于减少冲突并保持较快的访问速度。

put方法详解

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
             Node<K,V>[] tab; Node<K,V> p; int n, i;
             // 如果map还是空的,则先开始初始化,table是map中用于存放索引的表
             if ((tab = table) == null || (n = tab.length) == 0) {
                    n = (tab = resize()).length;
                 }
             // 使用hash与数组长度减一的值进行异或得到分散的数组下标,预示着按照计算现在的
             // key会存放到这个位置上,如果这个位置上没有值,那么直接新建k-v节点存放
             // 其中长度n是一个2的幂次数
             if ((p = tab[i = (n - 1) & hash]) == null) {
                     tab[i] = newNode(hash, key, value, null);
                 }
             // 如果走到else这一步,说明key索引到的数组位置上已经存在内容,即出现了碰撞
             // 这个时候需要更为复杂处理碰撞的方式来处理,如链表和树
             else {
                     Node<K,V> e; K k;
                     // 其中p已经在上面通过计算索引找到了,即发生碰撞那一个节点
                     // 比较,如果该节点的hash和当前的hash相等,而且key也相等或者
                     // 在key不等于null的情况下key的内容也相等,则说明两个key是
                     // 一样的,则将当前节点p用临时节点e保存
                     if (p.hash == hash &&
                                 ((k = p.key) == key || (key != null && key.equals(k)))) {
                             e = p;
                         }
                     // 如果当前节点p是(红黑)树类型的节点,则需要特殊处理
                     // 如果是树,则说明碰撞已经开始用树来处理,后续的数据结构都是树而非
                     // 列表了
                     else if (p instanceof TreeNode) {
                             // 其中this表示当前HashMap, tab为map中的数组
                             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                         }
                     else {
                             for (int binCount = 0; ; ++binCount) {
                                     // 如果当前碰撞到的节点没有后续节点,则直接新建节点并追加
                                     if ((e = p.next) == null) {
                                             p.next = newNode(hash, key, value, null);
                                             // TREEIFY_THRESHOLD = 8
                                             // 从0开始的,如果到了7则说明满8了,这个时候就需要转
                                             // 重新确定是否是扩容还是转用红黑树了
                                             if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                                     treeifyBin(tab, hash);
                                             break;
                                         }
                                     // 找到了碰撞节点中,key完全相等的节点,则用新节点替换老节点
                                     if (e.hash == hash &&
                                                 ((k = e.key) == key || (key != null && key.equals(k))))
                                         break;
                                     p = e;
                                 }
                         }
                     // 此时的e是保存的被碰撞的那个节点,即老节点
                     if (e != null) { // existing mapping for key
                             V oldValue = e.value;
                             // onlyIfAbsent是方法的调用参数,表示是否替换已存在的值,
                             // 在默认的put方法中这个值是false,所以这里会用新值替换旧值
                             if (!onlyIfAbsent || oldValue == null)
                                     e.value = value;
                             // Callbacks to allow LinkedHashMap post-actions
                             afterNodeAccess(e);
                             return oldValue;
                         }
                 }
             // map变更性操作计数器
             // 比如map结构化的变更像内容增减或者rehash,这将直接导致外部map的并发
             // 迭代引起fail-fast问题,该值就是比较的基础
             ++modCount;
             // size即map中包括k-v数量的多少
             // 当map中的内容大小已经触及到扩容阈值时,则需要扩容了
             if (++size > threshold)
                     resize();
             // Callbacks to allow LinkedHashMap post-actions
             afterNodeInsertion(evict);
             return null;
         }

 HashMap的put方法过程:

  1. 计算哈希值:首先,put方法会调用键对象的hashCode方法来计算其哈希值。

  2. 转换为索引值:然后,HashMap会使用哈希值的某部分(通过对哈希值进行一些转换操作)来计算得出索引值。这个索引值表示键值对在内部数组(存储着所有键值对的一个线性表)中的位置。

  3. 处理哈希冲突:如果在计算得出的索引位置上,已经有其他的键值对存在(即发生哈希冲突),那么HashMap会将新的键值对添加到该索引位置的末尾,形成一个链表。在Java 8及更高版本中,当链表长度超过阈值(默认为8)时,链表就会被转换为红黑树。

  4. 替换旧值:如果插入的键已经存在于HashMap中,那么新的值将替换旧的值,同时put方法返回旧的值。如果插入的键在HashMap中不存在,那么put方法返回null。

  5. 扩容:如果键值对数量超过了容量与加载因子的乘积(默认容量为16,加载因子为0.75),HashMap就会进行扩容,新的容量是旧的容量的两倍,然后将旧的键值对移到新的空间中(rehash过程)。

扩容

Map初始默认大小是16,负载因子是0.75当HashMap的size>16*0.75=12时就会发生扩容,容量扩大为2倍

为什么HashMap扩容是2的倍数

第一个截图是向HashMap中添加元素部分源码,第二个截图是HashMap扩容时调用resize()方法中的部分源码,可以看到都会使用(n-1)&hash,计算元素在集合中的位置,符号&是位运算,计算机能直接运行,非常高效,只有当对应的位置的数据都是1时,运算结果也是1,当HashMap的容量是2的n次幂时,(n-1)的二进制也就是111111***11111这样的形式,这样与添加元素的hash值位运算的时候,能够充分散列,使得元素能均匀分布在HashMap的每个位置,减少hash冲突。

HashMap为什么线程不安全

  • JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap#transfer(),具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。
  • JDK1.8 中,使用的是尾插法,由于多线程对HashMap进行put操作,调用了HashMap#putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

ConcurrentHashMap

JDK7

ConcurrentHashMap JDK7基于分段锁,底层数据结构仍然是数组和链表,与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组,每个Segment包含一个与HashMap数据结构参不多的链表数组。

Segment继承ReentranLock,对于写操作,并不是要求同时获取所有Segment的锁,因为那样就相当于锁住整个Map,ConcurrentHashMap会先获取该k-v所在Segment的锁,获取成功后就可以像操作HashMap一样操作。

JDK8

JDK8中的实现通过synchronized和CAS降低了锁的粒度,减少了Segment结构,降低了数据结构的复杂度,维护成本更低,提高了存储效率

put总结:

  1. 懒惰初始化。如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 找到对应的桶位置 ,如果没有hash冲突,即该位置为空,就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入(1.7是头部),一种是红黑树就按照红黑树结构插入,只对该链表的头结点或红黑树的头结点加synchronized,不是整个map,提高并发度。
  5. 如果Hash冲突时会形成Node链表,在链表长度超过8,Node数组超过64时会将链表结构转换为红黑树的结构,便于查找,O(logn),break再一次进入循环
  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

LinkedHashMap

所谓LinkedHashMap,其落脚点在HashMap,因此更准确地说,它是一个将所有Entry节点链入一个双向链表双向链表的HashMap。在LinkedHashMapMap中,所有put进来的Entry都保存在如下面第一个图所示的哈希表中,但由于它又额外定义了一个以head为头结点的双向链表(如下面第二个图所示),因此对于每次put进来Entry,除了将其保存到哈希表中对应的位置上之外,还会将其插入到双向链表的尾部。

LinkedHashMap有几种顺序

  • 两种顺序:插入顺序和访问顺序,通过创建实例对象时定义accessOrder参数的值来进行转换。默认为false,为插入顺序。
  • 如果设置为访问顺序,那么put和get已存在的节点时,会将该节点移动到双向链表的尾部(实际上是先删后插)

HashTable

跟HashMap的区别就是HashTable时线程安全的,通过synchronized进行安全同步

public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}

TreeMap

  1. 存入TreeMap的键值对的key是要能自然排序的(实现了Comparable接口),否则就要自定义一个比较器Comparator作为参数传入构造函数。
  2. TreeMap是以红黑树将数据组织在一起,在添加或者删除节点的时候有可能将红黑树的结构破坏了,所以需要判断是否对红黑树进行修复。
  3. 由于底层是红黑树结构,所以TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是 log(n) 。 
  4. 由于TreeMap实现了NavigableMap,所以TreeMap有一系列的导航方法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值