首页 > 技术文章 > Java集合类结合源码知识点总结

Water2Wine 2020-03-12 16:02 原文

一、Java集合必备元素

集合框架:根本上是Map,Set和List的实现类集合

泛型:类型的参数化

比较器:Comparable接口和Comparator接口。Comparable接口提供了CompareTo(),Comparator接口提供了Compare()和equals()

迭代器:Iterator接口。提供了很多对集合元素进行迭代的方法,包括remove(),hasNext(),next()

二、比较器和迭代器

迭代器Iterator

iterator是为了实现对Java容器(collection)进行遍历功能的一个接口。

在iterator实现了Iterator接口后,相当于把一个Collection容器的所有对象,做成一个线性表(List),而iterator本身是一个指针,开始时位于第一个元素之前。

Iterator的三个主要方法:

1.Boolean hasNext();

判断 iterator 内是否存在下1个元素,如果存在,返回true,否则返回false。(注意,这时上面的那个指针位置不变)

2.Object next();

返回 iterator 内下1个元素,同时上面的指针向后移动一位。
故,如果不断地循环执行next()方法,就可以遍历容器内所有的元素了。

3.void remove();

删除 iterator 内指针的前1个元素,前提是至少执行过1次next();
(这个方法不建议使用,建议使用容器本身的romove 方法)

比较器Comparable和comparator

1.Comparable是一个内比较器,也就是说,可以自己跟自己进行比较。提供了一个compareTo方法用于比较,也被成为自然比较方法。**如果想使用Collections.sort方法排序某个对象容器的话,该容器必须实现Comparable接口。

2.Comparator相当于一个外比较器,也就是说,不支持自己跟自己比较。提供了compare和equals两个方法。如果一个对象没有实现Comparable接口,可以使用Comparator在主类中比较两个对象之间的值。

3.Comparator相对于Comparable来说,更个性化一些:如果实现类没有实现COmparable接口,又想对两个类进行比较,就可以使用Comparator进行自定义设置;同时,实现Comparator接口比实现Comparable接口耦合性要更弱一些,所以,从耦合性上,Comparator也比Comparable更好一些。

三、集合框架

父接口:Map接口和Collection接口。

Collection子接口:Set接口和List接口。

Set:不能包含重复的元素,无序。
List:可以包含重复的元素,有序(这里的有序指的是按照索引的升序,和值无关)。

Map接口的实现类:HashMap、TreeMap、HashTable、ConcurrentHashMap等。

Set接口的实现类:HashSet、TreeSet、LinkedHashSet等。
List接口的实现类:ArrayList、LinkedList、Stack、Vector等。

四、集合类相关特性分析

有关于集合类的讨论,主要是分析一下特性:

底层数据结构

CRUD

初始容量,扩容方式,扩容时机

是否线程安全

是否允许空,是否允许重复,是否有序

1. ArrayList&Vector&LinkedList

ArrayList

  • 底层数据结构:Object数组

  • CRUD:增删时都会首先判断索引是否合法,容量是否需要改变,然后就是正常的通过检查下标对数组的增删改查。

  • 初始容量,扩容方式,扩容时机:默认初始容量为10,随着元素的增加,容量也会不断自动增长。

private static final int DEFAULT_CAPACITY = 10; // 初始化的默认容量为10

public ArrayList(int initialCapacity) { // 如果初始化带参,容量为参数;否则默认初始化容量为10
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

public boolean add(E e) {
        ensureCapacityInternal(size + 1);  //首先确定容量是否够,不够扩容
        elementData[size++] = e; // 扩容后添加元素
        return true;
    }


private void grow(int minCapacity) { // 扩容机制,默认取1.5倍扩容和要求的容量的较大者
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity); // 这里的MAX_ARRAY_SIZE为Integer.MAX_VALUE-8,所以巨大化的话会增加到MAX_VALUE
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity); // 这里返回一个new的一个新长度的数组,不会改变原数组值
    }

  • 是否线程安全:非线程安全

  • 是否允许空,是否允许重复,是否有序:允许为空,允许重复,有序(有下标)。

  • CRUD时的线程安全:

Fail-Fast 机制
我们知道 java.util.ArrayList 不是线程安全的,ArrayList,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对ArrayList 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。
在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 ArrayList。
所以在这里和大家建议,当大家遍历那些非线程安全的数据结构时,尽量使用迭代器

Vector

Vector大部分机制和ArrayList相同。下面介绍不一样的地方:

  • 初始容量,扩容方式,扩容时机:初始容量与ArrayList相同,但在扩容方式上有更多的灵活性:Vector除了定义了基础容量外,还额外定义了一个扩容因子,如果扩容因子大于0,则每次扩容增加扩容因子的容量;如果未定义大小,也就是等于0,则每次扩容2倍。
public Vector(int initialCapacity, int capacityIncrement) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
        this.capacityIncrement = capacityIncrement;
    }

public synchronized boolean add(E e){ // 线程安全
      ensureCapacityHelper(elementCount + 1); // 首先判断大小,再添加元素
}

int newCapacity = oldCapacity + ((capacityIncrement > 0) ? // 如果扩容因子大于0,加上扩容因子的长度,否则按照2倍扩容
                                         capacityIncrement : oldCapacity);
  • 是否线程安全:vector大部分方法都是用了synchronized修饰符,所以它是线程安全的。

LinkedList

  • 底层数据结构:基于链表实现

  • CRUD:常规的链表的CRUD

  • 是否线程安全:非线程安全

  • 是否允许空,是否允许重复,是否有序:允许为空,允许重复,有序

2. HashMap&HashTable&ConcurrentHashMap

1、相同点

初始数据结构相同:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//哈希值
        final K key;//key
        V value;//value
        Node<K,V> next;//链表后置节点

相关参数相同:

//最大容量 2的30次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //默认的加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //哈希桶,存放链表。 长度是2的N次方,或者初始化时为0.
    transient Node<K,V>[] table;

    //加载因子,用于计算哈希表元素数量的阈值。  threshold = 哈希桶.length * loadFactor;
    final float loadFactor;
    //哈希表内元素数量的阈值,当哈希表内元素数量超过阈值时,会发生扩容resize()。
    int threshold;

都无序:

final class KeyIterator extends HashIterator
        implements Iterator<K> {
        public final K next() { return nextNode().key; } // 根据key顺次返回,但是插入的元素的key并不是顺序的,因此遍历无序
    }

hash方式本质相同:

hashcode+扰动函数
因为hashCode()是int类型,取值范围是40多亿,只要哈希函数映射的比较均匀松散,碰撞几率是很小的。
但就算原本的hashCode()取得很好,每个key的hashCode()不同,但是由于HashMap的哈希桶的长度远比hash取值范围小,默认是16,所以当对hash值以桶的长度取余,以找到存放该key的桶的下标时,由于取余是通过与操作完成的,会忽略hash值的高位。因此只有hashCode()的低位参加运算,发生不同的hash值,但是得到的index相同的情况的几率会大大增加,这种情况称之为hash碰撞。 即,碰撞率会增大。
扰动函数就是为了解决hash碰撞的。它会综合hash值高位和低位的特征,并存放在低位,因此在与运算时,相当于高低位一起参与了运算,以减少hash碰撞的概率。

//注意这里取下标 是用 哈希值 与 桶的长度-1 。 由于桶的长度是2的n次方,这么做其实是等于 一个模运算。但是效率更高
newTab[e.hash & (newCap - 1)] = e; // 通过取哈希和扰动函数使hash值更加均衡
int index = (hash & 0x7FFFFFFF) % tab.length; // hashtable的方式
int hash = spread(key.hashCode());
static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }
static final int HASH_BITS = 0x7fffffff; // concurrenthashmap的方式

CRUD相同

2、不同点

初始容量和扩容方式不同:

hashmap:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始容量16

newCap = oldCap << 1 // 新容量为旧容量2倍

// 保持容量为2的次方方便用与操作代替取余操作,优化速度

hashtable:

    // 如果初始化是赋值,初始容量为所传入的值
    public Hashtable(int initialCapacity) {
        this(initialCapacity, 0.75f);
    }
    // 默认初始化容量为11
    public Hashtable() {
        this(11, 0.75f);
    }
    int newCapacity = (oldCapacity << 1) + 1; // 扩容为2倍+1

    // Hashtable之所以初始容量为11(质数)和扩容方式保证为奇数,是为了散列得更均匀,也就是减少碰撞发生的几率。

concurrentHashmap:

private static final int DEFAULT_CAPACITY = 16; // 初始容量为16
static final int MIN_TREEIFY_CAPACITY = 64; // 扩容上限为64
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)    // 如果当前容量小于扩容上限
                tryPresize(n << 1);        // 按2倍扩容
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { // 如果已经大于扩容上限
                synchronized (b) {    //使用synchronized同步器,将该节点处的链表转为树

进阶数据结构不同:

hashmap&&concurrenthashmap:

static final int TREEIFY_THRESHOLD = 8; // 当链表长度到达8时,转换为红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;

hashtable:没有TREEIFY_THRESHOLD参数,始终为链表

转换为进阶数据结构的方式不同:

hashmap:

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
if ((e = oldTab[j]) != null) {
      for (int j = 0; j < oldCap; ++j) {
                oldTab[j] = null; // 释放旧数组中的对象引用
                newTab[e.hash & (newCap - 1)] = e;// 创建新数组,将旧数组中的数一个一个移过去 通过扰动函数判断是保持在低位还是高位

concurrenthashmap:

private transient volatile int sizeCtl; // 该参数用来控制多线程共同参与对节点扩容
/*-1 代表table正在初始化  
-N 表示有N-1个线程正在进行扩容操作  
其余情况:  
1、如果table未初始化,表示table需要初始化的大小。  
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍*/

static final class ForwardingNode<K,V> extends Node<K,V> // 该节点会在头插的过程中最后插入,因此可以作为是否结束转移的标志

private transient volatile int transferIndex; // 表示下一个需要转移的节点下标

// 创建一个 fwd 节点,用于占位。当别的线程发现这个槽位中是 fwd 类型的节点,则跳过这个节点。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// CAS 修改 transferIndex,即 length - 区间值,留下剩余的区间值供后面的线程使用
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0)))

private transient volatile long baseCount; // 当前节点没有值时使用

private transient volatile int cellsBusy;
/*对于get读操作,如果当前节点有数据,还没迁移完成,此时不影响读,能够正常进行。 

如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时get线程会帮助扩容。 


  对于put/remove写操作,如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时写线程会帮助扩容,如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。 
*/

空&重复不同:

hashmap:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true); 
    }
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // hash(key)允许key为null,hashcode不允许key为null,因此hashmap允许键值为null
    }

hashtable:

if (value == null) { // 不允许value为null
            throw new NullPointerException();
        }
        int hash = key.hashCode(); // 不允许key为null

ConcurrentHashmap:

if (key == null || value == null) throw new NullPointerException(); // 不允许key和value为null

基类不同:

HashMap&concurrenthashmap:派生于AbstractMap。AbstractMap中提供的基础方法更多,并且实现了多个通用的方法

HashTable:派生于Dictionary。Dictionary中只有少量的接口,并且都是abstract类型。

线程安全机制上不同:

hashmap:非线程安全,没有任何加锁操作

hashtable:线程安全,很多方法加上了synchronize关键字

public synchronized V get(Object key) 
public synchronized V put(K key, V value)
// 读写都直接加上synchronize关键字

concurrenthashmap:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val; 
        volatile Node<K,V> next;
// 用volatile关键字修饰的值和指针next保证了读操作不需要加锁
transient volatile Node<K,V>[] table; // 用volatile修饰Node数组是为了保证数组在扩容时对其他线程可见

synchronize(f) // 通过对节点加锁,对一些公共的参数使用cas操作来保证写和扩容的线程安全

在ConcurrentHashMap中,同步处理主要是通过Synchronized和unsafe两种方式来完成的。
在取得sizeCtl、某个位置的Node的时候,使用的都是unsafe的方法,来达到并发安全的目的
当需要在某个位置设置节点的时候,则会通过Synchronized的同步机制来锁定该位置的节点。
在数组扩容的时候,则通过处理的步长和fwd节点来达到并发安全的目的,通过设置hash值为MOVED
当把某个位置的节点复制到扩张后的table的时候,也通过Synchronized的同步机制来保证线程安全

3. HashMap&LinkedHashMap&TreeMap

LinkedHashMap的数据结构为散列表+双向链表,因此遍历LinkedHashMap可以实现按照插入顺序或者LRU的顺序遍历

TreeMap的数据结构为红黑树,并继承了Navigable接口,内部实现了Comparable接口,因此可以按照升序或者其他顺序进行打印

HashMap的打印既不按照一定的顺序也不按照插入顺序,因为是顺次打印每一个hash元素的所有链表元素,在一开始插入元素较少时,可能显示出来是升序打印,但是插入元素达到一定数量即显示无序

4.HashSet&TreeSet&LinkedHashSet

这三个Set类都基于HashMap实现,底层使用HashMap保存所有元素。

HashSet如何实现不重复:

public boolean add(E e) {
        return map.put(e, PRESENT)==null; // HashSet的add操作的底层实现是map的put
    }
public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true); // 这里的OnlyIfAbsent默认是为false
    }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) 
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
return null; // 如果成功复写,返回旧值返回false,成功添加的话返回true

5. CopyOnWriteArraylist

内部持有一个ReentrantLock lock = new ReentrantLock();
底层是用volatile transient声明的数组 array
读写分离,写时复制出一个新的数组,完成插入、修改或者移除操作后将新数组赋值给array

基于以上机制,实现了高并发读,安全写,常用于读多写少的场景

6. Hashmap线程不安全可能导致的问题

数据丢失:如果两个线程要put值,key值相同,且探测到的位置为null,则两个线程都会new一个entry,那么后new的就会覆盖掉先new的值;有链表的情况也类似

if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

数据重复:两个key相同的数据,如果首先判断数组中有无,然后进入之后顺序不同的话,就可能出现两个重复key都被插入的情况

死循环(JDK7):因为是倒序扩容

扩容时取值失败&put值被覆盖:因为扩容时hashmap不会对新new的数组进行检测,所以这是插入的值可能会被覆盖;同时可能会出现指针转向新new的数组,导致查询失败的情况

put值和get值操作同时进行导致原来的值被覆盖

推荐阅读