Java集合

277 阅读13分钟

接口框架

Collection和Map<K,V>是java.util框架中的两个根接口,代表了两种不同的数据结构:集合和映射表。而List、Set则是继承自Collection下最核心的两个接口,List有序可重复并可以通过整数索引来访问,Set不包含重复元素。

  • collection

    Collection接口是集合的根接口,他代表了一组元素。但是Collection并不关心这组元素是否重复,是否有序。他只提供操作对这组元素的基本操作方法,怎么添加,怎么删除,怎么循环。所有的实现类都必须提供这些方法,下面列出了Collection接口的部分方法:

    // 集合的大小
    int size();
    // 是否包含一个object
    boolean contains(Object o);
    //返回此集合中元素的迭代器。
    Iterator<E> iterator();
    //返回包含此集合中所有元素的数组。
    Object[] toArray();
    /返回包含此集合中所有元素的数组;返回数组的运行时类型是指定数组的运行时类型。
    <T> T[] toArray(T[] a);
    // 添加一个元素
    boolean add(E e);
    // 删除集合中的一个元素
    boolean remove(Object o);
    // 清空集合
    void clear();
    
    • foreach :从JDK5开始使用“for each”这种更加方便的方式来遍历集合,只要实现了Iterable接口,都可以使用“for each”来遍历,效果和使用iterator一样。

    • toarray和toArray(T[] a)

      toArray和toArray(T[ ] a)返回的都是当前所有元素的数组。 toArray返回的是一个Object[]数组,类型不能改变。 toArray(T[ ] a)返回的是当前传入的类型T的数组,更方便用户操作,比如需要获取一个String类型的数组:toArray(new String[0])。

  • List

    List接口最重要的特点在有序(ordered collection)这个关键字上面,实现这个接口的类可以通过整数索引来访问元素。他可以包含重复的元素。 除了包含Collection接口的所有方法外,还包括跟索引有关的部分方法:

    E get(int index);
    // 修改操作
    E set(int index, E element);
    // 用于向列表的末尾插入新元素
    boolean add(E e);
    // 指定位置插入(其中index不可以大于list的size)
    void add(int index, E element);
    // 插入一个集合(尾插) 不能传入null
    boolean addAll(Collection<? extends E> c);
    // 插入一个集合通过index指定从哪开始插入 不能传入null(其中index不可以大于list的size)
    boolean addAll(int index, Collection<? extends E> c);
    E remove(int index);
    int indexOf(Object o);
    ListIterator<E> listIterator();
    ListIterator<E> listIterator(int index);
    List<E> subList(int fromIndex, int toIndex);
    

    ListIterator接口继承自Iterator接口,所以他们的差异在ListIterator接口新增的功能上:

    • ListIterator可以向后迭代previous()
    • ListIterator可以获取前后索引nextIndex()
    • ListIterator可以添加新值add(E e)
    • ListIterator可以设置新值set(E e)
  • ArrayList 与 LinkedList
    • 首先ArrayList通过数组实现,LinkedList则使用了双向链表。

      为什么都是继承了list却造成了两者的不同呢?

      其实LinkedList继承了list和deque,且允许所有元素插入(包括null),所以linkedlist是一个双向的链表(deque继承了Queue类是一个双向队列的接口)

      当要查找LinkedList的时候,实际上是先找到其索引然后根据索引规划是从头还是从尾开始进行遍历,而ArrayList是数组实现的所以通过下标就可一很快的找到要查询的数据,所以ArrayList的查找速度会更快

    • 构造函数

      ArrayList提供了3种构造方式,默认的构造函数会初始化一个空的数组,在之后添加元素的过程中会对数组进行扩容(即在初始化ArrayList的时候是0,只有使用add的时候ArrayList才会真正的扩充长度),扩容操作在一定程度上会影响数组的性能。如果能提前预估最终的数组使用空间大小,可以通过ArrayList(int initialCapacity) 这种构造方式来初始化数组大小,这样会减少扩容造成的性能损失。(为什么么会影响性能呢?主要因为每次ArrayList进行扩容的时候是重新创建一个1.5倍的新的ArrayList,然后将原先的数据拷贝过去)

      // 构造一个初始容量为10的空列表。
      public ArrayList() {
          this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
      }
      
      //构造一个具有指定初始容量的空列表。
      public ArrayList(int initialCapacity) {
          if (initialCapacity > 0) {
              this.elementData = new Object[initialCapacity];
          } else if (initialCapacity == 0) {
              this.elementData = EMPTY_ELEMENTDATA;
          } else {
              throw new IllegalArgumentException("Illegal Capacity: "+
                                                 initialCapacity);
          }
      }
      // 构造一个包含指定集合的元素的列表,其顺序由集合的迭代器返回。
      public ArrayList(Collection<? extends E> c) {
          elementData = c.toArray();
          if ((size = elementData.length) != 0) {
              // c.toArray might (incorrectly) not return Object[] (see 6260652)
              if (elementData.getClass() != Object[].class)
                  elementData = Arrays.copyOf(elementData, size, Object[].class);
          } else {
              // replace with empty array.
              this.elementData = EMPTY_ELEMENTDATA;
          }
      }
      

    LinkedList只提供了2种构造方式,默认的构造函数是一个空函数,因为链表这种数据结构在使用上不需要初始化空间,也不需要扩容,每次需要添加元素时直接追加就可以,在空间的最大化利用上链表比数组更加合理。这并不代表链表使用的空间小,相反,链表每个节点因为要存储下一个节点引用(双向链表会存储上下两个节点的引用),在相同元素空间使用上会比数组大的多。

    public LinkedList() {
    }
    // 构造一个包含指定*集合的元素的列表,其顺序由集合的迭代器返回
    public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }
    
    • 扩容

      ArrayList在添加元素的过程中,需要考虑数组空间是否足够,不够的情况下需要扩容。

      //ArrayList<E>添加元素到末尾
      public boolean add(E e) {
          //检查数组容量,不够就扩容,扩容调用grow(int minCapacity) 方法
          ensureCapacityInternal(size + 1);  
          elementData[size++] = e;
          return true;
      }
      
      private void ensureExplicitCapacity(int minCapacity) {
          modCount++;
          // overflow-conscious code
          if (minCapacity - elementData.length > 0)
              grow(minCapacity);
      }
      
      //扩容
      private void grow(int minCapacity) {
          // overflow-conscious code
          int oldCapacity = elementData.length;
          //向右位移一位,相当于除以2,比除法运算要快,每次扩容在原容量的基础上增加一半,新的容量为原容量的1.5倍。
          int newCapacity = oldCapacity + (oldCapacity >> 1);
          if (newCapacity - minCapacity < 0)
              newCapacity = minCapacity;
          if (newCapacity - MAX_ARRAY_SIZE > 0)
              newCapacity = hugeCapacity(minCapacity);
          //拷贝所有数据元素到新的数组中,内部调用System.arraycopy来拷贝所有数组元素
          elementData = Arrays.copyOf(elementData, newCapacity);
      }
      

      从中可以看出,不扩容的情况下添加元素到末尾非常方便,时间复杂度为O(1),扩容的情况下每次都需要拷贝所有元素到新数组,时间复杂度上为O(n),存在一定性能损耗。 LinkedList在添加元素时由于链表的特性,不需要考虑扩容的问题,但LinkedList每次都需要new一个Node来存储元素。

      //LinkedList<E>添加元素到末尾
      public boolean add(E e) {
          linkLast(e);
          return true;
      }
      
      void linkLast(E e) {
          final Node<E> l = last;
          //new一个新的链表元素并链接到末尾
          final Node<E> newNode = new Node<>(l, e, null);
          last = newNode;
          if (l == null)
              first = newNode;
          else
              l.next = newNode;
          size++;
          modCount++;
      }
      

      在添加到末尾时,ArrayList和LinkedList在性能上差距不明显,尽管ArrayList需要扩容,但LinkedList也需要new一个Node对象。

      在插入到头部时,LinkedList性能明显好于ArrayList,因为ArrayList每次都需要将所有元素向后移动一个位置,而LinkedList由于是双向链表每次只需要改变first元素就可以了。

      在插入到中间位置的时候,ArrayList性能优明显好于LinkedList,这是因为ArrayList此时只需要移动一半的元素,而LinkedList因为其双向链表查找元素的特殊性,只能从头或者尾部开始遍历,每次都需要遍历一半的元素,这个操作耗费了大量时间,而ArrayList在扩容以及移动元素上的性能消耗比想象的要小。

    • 删除

      ArrayList删除元素通过遍历元素查找到相等的元素然后使用索引删除,删除之后还要将被删除元素后的元素前移。

      public boolean remove(Object o) {
          if (o == null) {
              for (int index = 0; index < size; index++)
                  if (elementData[index] == null) {
                      fastRemove(index);
                      return true;
                  }
          } else {
              for (int index = 0; index < size; index++)
                  //查找到equals的元素的索引然后删除
                  if (o.equals(elementData[index])) {
                      fastRemove(index);
                      return true;
                  }
          }
          return false;
      }
      
      private void fastRemove(int index) {
          modCount++;
          int numMoved = size - index - 1;
          if (numMoved > 0)
              //所有删除元素后的元素前移
              System.arraycopy(elementData, index+1, elementData, index,
                                  numMoved);
          elementData[--size] = null; // clear to let GC do its work
      }
      

      LinkedList通过向后遍历链表的方式查找到equals的元素直接删除即可。

      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;
      }
      
      • 遍历

        在遍历元素上ArrayList存在更有效的方式,他实现了RandomAccess接口,代表ArrayList支持快速访问。 RandomAccess本身是一个空接口,这种接口一般用来代表一类特征,RandomAccess代表实现类具有快速访问的特征。ArrayList实现快速访问的方式是通过索引。这代表ArrayList在遍历时通过for循环方式要比通过Iterator或ListIterator迭代器方式要快。LinkedList没有实现这个接口,所以一般还是通过Iterator迭代器来访问。

      • 线程安全

        ArrayList是线程不安全的 如果要线程安全那么请使用Vector,或者使用Collections.synchronizedList将一个普通的ArrayList包装成一个线程安全的容器

  • Set

    Set接口在方法签名上与Collection接口是一样的,只不过在方法的说明上有更严格的定义,最重要的特点是他拒绝添加重复元素,不能通过整数索引来访问。Set的equals方法定义如果两个集相等是他们包含相同的元素但顺序不必相同。

  • Map

    从键映射到值的一个对象,键不能重复,每个键至多映射到一个值。 从键不能重复这个特点很容易想到通过Set来实现键,他的接口方法Set keySet()也证明了这点,下面选取了Map<K,V>接口中的一些典型方法:

    int size();
    // 是否包含某个key
    boolean containsKey(Object key);
    // 通过key获取value
    V get(Object key);
    V put(K key, V value);
    V remove(Object key);
    void clear();
    //返回此映射中包含的键的集合视图。
    Set<K> keySet();
    //返回此映射中包含的值的集合视图。
    Collection<V> values();
    //返回此映射中包含的映射的集合视图。
    Set<Map.Entry<K, V>> entrySet();
    boolean equals(Object o);
    int hashCode();
    
    

    java Set<K> keySet() 返回映射中包含的键集视图,是一个Set,说明了映射中键是不可重复的。 java Collection<V> values() 返回映射中包含的值得集合视图,Collection,说明了映射中值是可以重复的。 java Set<Map.Entry<K,V>> entrySet() 返回映射中包含的映射集合视图,这个视图是一个Set,这是由他的键集不能重复的特点决定的。 entrySet()返回的是一个Map.Entry<K,V>类型的集,Map.Entry<K,V>接口定义了获取键值、设置值的方法,定义如下:

    interface Entry<K,V> {
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
    }
    
    
  • HashMap

    hashMap是由数组和链表组合构成的数据结构。数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。

    • 构造函数

      在HashMap中有三个构造函数,第一个无参构造源码如下

      public HashMap() {
              this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
      	}
      
      

      在源码中的注释是这么说的:使用默认的初始容量(16)和默认的加载因子(0.75)构造一个空的 HashMap

      由此我们可以得知,如果在初始化的时候没有设置初始容量那么HashMap的长度位16,那么加载因子又是什么呢?

      加载因子,或者负载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,同时如果一个哈希表中的数据存储长度如果超过了哈希表的长度负载因子那么也就意味着哈希表要进行扩容了。

      那么HashMap是如何进行扩容的呢?

      分为两步

      • 扩容:创建一个新的Entry空数组,长度是原数组的2倍。

      • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组

        因为hash的公式位index = HashCode(Key) & (Length - 1),所以当数组扩容的时候需要重新hash因为数组的长度改变了hash值也改变了

      第二个构造函数为有参构造函数

      // 只能指定hashMap的长度,负载因子使用的是默认的0.75
      public HashMap(int initialCapacity) {
              this(initialCapacity, DEFAULT_LOAD_FACTOR);
          }
       // 同时指定长度和负载因子 
      public HashMap(int initialCapacity, float loadFactor) {
              if (initialCapacity < 0)
                  throw new IllegalArgumentException("Illegal initial capacity: " +
                                                     initialCapacity);
              if (initialCapacity > MAXIMUM_CAPACITY)
                  initialCapacity = MAXIMUM_CAPACITY;
              if (loadFactor <= 0 || Float.isNaN(loadFactor))
                  throw new IllegalArgumentException("Illegal load factor: " +
                                                     loadFactor);
              this.loadFactor = loadFactor;
              this.threshold = tableSizeFor(initialCapacity);
          }
      
      

      第三个构造函数

      // 构造一个新的HashMap,其映射与指定的Map相同。HashMap 是使用默认负载因子(0.75)和足以将映射保存在指定的Map中的初始容量创建的。 
      public HashMap(Map<? extends K, ? extends V> m) {
              this.loadFactor = DEFAULT_LOAD_FACTOR;
              putMapEntries(m, false);
          }
      
      
      • 数组和链表

        hashmap是由数组和链表组成的那么我们hash之后将数组存到对应的数组就可以为什么还有链表呢?因为hash一个key的时候是可能产生相同的hashcode的,当这个时候就需要将数据放入到链表中了,在jdk1.8之前如果链表中插入一个新的数据那么会将新的数据插在链表的头部,即头插法,而jdk1.8之后改用了尾插法

      • 线程安全

        hashMap是线程不安全的,即使在jdk1.8中使用尾插法而不会造成链表成环的情况,但是其put/get方法并没有添加同步锁,所以并不能保证数组的同步。所以在线程安全的需求的时候还是用ConcurrentHashMap

      • hashcode和equals

        为什么重写equals要重写hashcode呢?因为在同一个链表中的entry是key经过hash算法算出来的index,但是如果需要去取对应的value就需要通过equals去判断key的地址,所以我们必须要保证相同的对象必须是同一个hash值