深入分析JAVA容器类

一.JAVA容器类总体概述与分类

       

       在JAVA中,如果一个类专门用于存储其它类型类的对象,同时对使用者屏蔽了底层实现细节,我们将这个类称为容器类。下面从存储数据的格式和线程安全两个角度来对JAVA容器类进行分类概述。      

      

       从存储数据的格式来分,有collection接口与Map接口。collection接口存储单元素数据,包括:List、Queue、Set。List接口在逻辑上顺序存储,可以存储相同元素,主要的实现类包括:ArrayList、LinkedList。Set接口在逻辑上没有顺序,不能存储相同的元素,主要的实现类包括:HashSet、TreeSet。Queue接口是队列,存储的元素先进先出。Map接口存储key-value形式的数据,主要的实现类:HashMap、TreeMap、LinkedHashMap。

      

       从线程安全与非线程安全的角度来分,这里只讨论线程安全的容器。线程安全的容器包括:同步容器与并发容器。同步容器包括:通过Collections工具类封装的容器都是同步容器、通过Synchronized来实现线程安全的容器。Collection接口下的同步容器包括:vector、Stack。Map接口下的同步容器包括:HashTable。并发容器主要有两种形式,一种是通过无锁的形式(AQS架构)实现线程安全的容器,主要包括:实现了BlockingQueue接口的各个阻塞队列,如ArrayBlockingQueue、LinkedBlockingQueue等,Map接口下的ConcurrentHashMap。另一种是通过CopyOnWrite机制(读数据时不加锁,写的时候复制一份数据,修改拷贝的数据,修改完成后将修改后的数据对象赋值给原先的数据对象,通过violate保证可见性)实现线程安全的容器,主要包括:Collection接口下的CopyOnWriteArrayList、CopyOnWriteArraySet


二.JAVA中各容器类详细分析

      

       由于线程安全的容器会涉及到大量其他方面的知识,包括Synchronized、CAS、AQS架构等。讨论这些知识需要花费大量的篇幅,因此作为开篇,本文不详细分析线程安全的容器类。

      

       参考了JDK1.8的源码,本文将从初始容量大小、扩容机制、是否线程安全、存储结构等几个方面来对各个容器类进行详细分析,同时对容器类中的一些重要方法进行源码级分析。


      1.ArrayList

      

        内部使用数组来存储数据。用户创建容器时若未指定大小,存储数据的数组为NULL,只有在第一次add元素时才执行初始化操作,此时数组的默认大小为10。当add元素时,如果数组已经没有空间了则实现扩容,扩容后数组的大小为原来数组的1.5倍。ArrayList是非线程安全的。

     

       分析ArrayList的add(E e)的源码:首先判断是否需要扩容,如果需要,则先扩容,在判断是否需要扩容的时候将modCount(记录容器结构修改的次数,用于迭代遍历时的快速失败机制)的值加1。然后将元素插入到数组的末尾。


     2.LinkedList


        内部使用双向链表来存储数据,存在头指针(first)、尾指针(last)、存储数据的个数(size),LinkedList是无界的,不需要扩容。LinkedList是非线程安全的。因为LinkedList中使用的双向链表没有使用标志节点,因此插入元素与删除元素是需要分情况讨论的。插入元素:分为链表为空、链表不为空。删除元素:删除中间元素、删除头指针、删除尾指针。


   3.Vector


        Vector是线程安全的ArrayList,但与ArrayList也有部分区别。Vector内部使用数组来存储元素,用户首次创建Vector时,如果没有指定容器大小,系统会将存储数据的数组初始化为大小为10的数组,这与ArrayList是不同的(用户若未指定容器大小,ArrayList会等到第一次add操作时才初始化)。Vector与ArrayList的扩容机制也是不同的。用户在初始化Vector时候,可以设置属性capacityIncrement的值。若capacityIncrement>0,则扩容后数组大小为:原数组大小+capacityIncrement,若capacityIncrement=0,则扩容后数组大小为:原数组大小的两倍。Vector是线程安全的,是同步容器。


  4.Stack


        继承于Vector,实现了先进后出的数据结构。


 5.HashMap


        HashMap是最重要的JAVA容器Hashmap的存储结构包括:数组+链表+红黑树。当链表长度大于8时,转为红黑树,提高查询、增加、删除的效率。Hashmpa默认初始大小为16,扩容为原来的2倍。它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null 。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

       

       需要注意的是,HashMap要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。同时通过equals方法前后比较也必须是一致的。两个对象通过equals方法比较相等,那么这两个对象的哈希值也必须相等的,两个对象通过equals方法比较不相等,那么这两个对象的哈希值可能相等,这时就发生的hash冲突,hashmap解决hash冲突的办法是:链地址法。因此,当自定义对象作为key时,需要实现hashCode方法和equals方法。

    

      JDK1.8中,hashmap中桶的数量必须为2的幂次方(主要是为了计算桶位置和hash扩容时的优化,后面会有详细的介绍)。源码中的tableSizeFor(int cap)方法将返回一个大于或等于cap的一个最小的2的幂次方的数,比如cap=9,则返回16;cap=8,则返回8。实现该算法有两种思路:

     1.移位比较。比如cap=9,二进制为:1001。bit=0,cap=cap>>>1,如果cap>0,则bit++,最后返回2的bit次方。

     2.方法1中比较次数和移位次数跟cap有很大的关系。如果cap特别大,那么比较次数和移位次数就较多。JDK1.8提供了一个非常巧妙的算法。算法的基本思路是:通过不断的将(cap-1)二进制中的第一个开始的1后面的各位都变为1。该算法没有比较次数,只有固定的移位次数(5次)。源码如下:

static final int tableSizeFor(int cap) { 
int n = cap - 1; 
n |= n >>> 1; 
n |= n >>> 2; 
n |= n >>> 4; 
n |= n >>> 8; 
n |= n >>> 16; 
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 
} 

        cap-1操作是防止cap本身就是2的幂次方。若n=001xx。x表示0或1。n>>>1=0001x。n|n>>>1=0011x。由上可知,后两位已经都为1了。算法不断的通过把第一个1开始后面的位变成1。最后001xx变为00111,最后返回8。上面的算法保证了hashmap中的桶的数目为2的幂次方。但为什么JDK1.8中设置hashmap中桶的数目必须为2的幂次方(或者说为什么hashmap中的初始大小为16)?主要是为了减少hash定位的时间和优化扩容机制,使得扩容后数据能够均匀的分散到各个桶中,从而减少hash冲突(后面会详细的介绍)。

      

       hash冲突次数是衡量哈希表性能好坏的重要指标。那么减小hash冲突,需要注意什么?试想一下,如果哈希桶数组很大,即使较差的hash算法也会比较分散,如果哈希桶数组很小,即使好的hash算法也会出现较多冲突,所以就需要在空间成本和时间成本之间权衡,根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少hash碰撞。好的hash算法和扩容机制可以降低hash碰撞的概率,同时哈希桶数组(Node[] table)占用空间也不会很大。


       在源码分析HashMap中的重要方法之前,我们首先谈谈HashMap中的重要属性。

int threshold;             // 所能容纳的key-value对极限 
final float loadFactor;    // 负载因子
int modCount;  
int size;

    

        首先,Node[] table的初始化长度length(默认值是16),Loadfactor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。


       结合负载因子的定义公式可知,threshold就是在此Loadfactor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

      

     size是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。需要注意的是,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。


       结合JDK1.8中HashMap源码,选择根据key获取哈希桶数组索引位置、put方法的详细执行流程、扩容过程三个具有代表性的点展开深入分析。

1.根据key获取哈希桶数组索引位置


        JDK1.8中的源码如下:

         

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

        主要分为3个步骤:获取key的hashcode值、高位异或运算、取模运算(与运算)。

       

         获取key的hashcode值就是调用key的hashCode方法。高位异或算法是hashcode值的低16位与高16位的异或运算。主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证高低Bit位都参与到Hash的计算中,同时不会有太大的开销(解释了为什么需要高位运算)。取模运算通过h & (table.length -1)来得到该对象存储在哈希桶数组索引位置,而HashMap底层数组的长度总是2的n次方,当length总是2的n次方时,h&(length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率,这是JDK1.8中的HashMap在速度上的优化


   2.分析HashMap的put方法


       JDK1.8中的源码如下: 


final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

         put方法的具体执行流程如下:

 

      ⑴.判断存放键值对数组table是否为空或大小是否为0,如果是,则说明是第一次向该HashMap中插入数据,因此首先调用resize()方法执行扩容操作。

      ⑵.根据键key计算插入数组的索引位置i,若table[i]=null,直接新建节点添加,转向步骤(7),如果table[i]!=null,则转向步骤(3)。

      ⑶.判断table[i]的首个元素中的键值是否与key一样(这里一样的依据是:hash值相同并且key值相同(调用equals方法)),如果一样,记录节点到e,转向步骤(6),如果不一样,则转向步骤(4)。

      ⑷.判断table[i]是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接调用红黑树的相关操作,并将方法返回的节点记录到e,转向步骤(6),如果不是红黑树,则转向步骤(5)。

      ⑸.遍历table[i],如果发现key已经存在(hash值相同,key调用equals相等),记录节点到e,调转到步骤(6)如果不存在,则在尾部插入新的节点,将e设置为null,插入节点后,若链表长度>=8,则将链表转换为红黑树结构,跳转到(6)。

     ⑹.判断e是否为null,如果e不为null,说明存在键值为key的节点,那么直接覆盖value,调用钩子方法afterNodeAccess(e)方法(服务于LinkedHashMap),返回旧值。如果e为null,转向步骤(7)。

     ⑺.modCount++(插入操作导致hashmap结构发生变化),size++,判断size是否大于threshold,如果大于,则调用resize()方法实现扩容。调用钩子方法afterNodeInsert()方法(服务于LinkedHashMap),返回null。


  3.Hashmap中扩容机制

 

         源码比较长,这里就不给出了,可以自行查看JDK1.8中的源码。


         阅读源码可以发现,JDK1.8中使用的是2次幂的扩展(指长度扩为原来2倍),同时桶的大小是2次幂。结合上述的特征,扩容后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。具体的证明如下:之前插入桶的位置为:hash&(pow(2,n)-1),扩容后桶的大小为:pow(2,n+1)=2*pow(2,n)=pow(2,n)+pow(2,n)。因此扩容后插入桶的位置为:hash&(pow(2,n+1)-1)=hash&(pow(2,n)-1+pow(2,n))=hash&(pow(2,n)-1)+hash&(pow(2,n))=原索引+hash&(pow(2,n))。当原来hash值新增的那个bit为1时,hash&(pow(2,n))=pow(2,n);为0时,hash&(pow(2,n))=0。因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。同时,需要注意的是,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,插入元素时采用的是头插入的方式,链表元素会倒置,在多线程访问的情况下可能会导致死循环,cpu达到100%。JDK1.8采用的是尾插入的方式,修复了这个bug。


        JDK1.8中的HashMap的扩容机制设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的桶中了,降低了hash碰撞的机率。


6.LinkedHashMap

       

         LinkedHashMap是HashMap的一个子类。LinkedHashMap 与 HashMap 的不同之处在于,LinkedHashMap 维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序。默认是按插入顺序排序,如果指定按访问顺序排序,那么调用get方法后,会将这次访问的元素移至链表尾部,不断访问可以形成按访问顺序排序的链表。


        LinkedHashMap继承于HashMap,大部分功能都交给了HashMap来实现。LinkedHashMap底层的存储结构是:hash表与双向链表。其中hash表用于存储数据,双向链表用于实现顺序迭代。


       LinkedHashMap重新定义了数组中保存的元素 Entry,该Entry 除了保存当前对象的引用外,还保存了其上一个元素 before 和下一个元素 after 的引用,从而在哈希表的基础上又构成了双向链接列表。还增加了3个成员变量:head、tail、accessOrder。head表示双向链表的头,tail表示双向链表的尾,accessOrder表示迭代的顺序是否是访问顺序,accessOrder=true表示访问顺序,否则为插入顺序。accessOrder的值默认为false。


      LinkedHashMap是如何实现顺序迭代的了?其实主要是通过实现了HashMap中的相关模板方法来实现的。总共有6个模板方法,具体分析如下:


1.newNode(int hash, K key, V value,Node<K,V> e)

       当调用put操作往map中插入数据时,如果key之前不存在,则会调用上述方法向hash表中插入一个节点。LinkedHashMap重新实现了该方法,维护了双向链表的链接关系(插入顺序),每次将新插入的节点插入到双向链表的末尾。


2.newTreeNode(int hash, K key, V value,Node<K,V> next)

      和newNode方法类似,但这里插入的树节点(红黑树)。


3.afterNodeAccess(Node<K,V> e)

      当节点e被再次访问之后会调用上述模板方法。节点e被访问有两种情况:put操作时,key存在,覆盖value;get操作。LinkedHahsMap重新实现了该方法,当accessOrder=true时,即迭代顺序为访问顺序时,将新访问的节点e插入到双向链表的尾部。


4.afterNodeRemoval(Node<K,V> e)

       当删除节点e时,同时需要在双向链表中也删除对e的引用。


5.afterNodeInsertion(boolean evict)

      当向hash表中插入新的节点时会调用该模板方法。LinkedHashMap重新实现了该方法。当evict=true(hash表不是出于创建模式)&&removeEldestEntry(first)方法返回true时,会淘汰头结点,以实现LRU算法。


6.removeEldestEntry(Map.Entry<K,V>eldest)

     是否删除最老的元素,该方法是LinkedHashMap中特有的方法。默认返回false,用户可以继承LinkedHashMap,实现该方法,以提供LRU缓存调度算法。

 

LinkedHashMap最典型的应用是实现LRU缓存算法。


下面的代码利用LinkedHashMap实现LRU缓存算法:

public class LRULinkedHashMap<K, V> extends LinkedHashMap<K, V> {

	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;
	/**
	 * 缓存的大小,当数据量大于capacity时,需要淘汰老的数据
	 */
	private int capacity;

	public LRULinkedHashMap() {
		super(16, 0.75f, true);
		capacity = 4;
	}

	public LRULinkedHashMap(int capacity) {
		super(16, 0.75f, true);
		if (capacity <= 0) {
			throw new IllegalArgumentException("Illegal initial capacity: "
					+ capacity);
		}
		this.capacity=capacity;
	}

	@Override
	protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
		if(size()>capacity)
			return true;
		else{
			return false;
		}
	}
}

      基本的思路是:继承LinkedHashMap,设置缓存的大小;选择访问顺序(accessOrder=true);重新实现removeEldestEntry(Map.Entry<K,V> eldest)方法,当插入数据后,size>缓存大小时,返回true,否则返回false。


7.HashTable

   

       HashTable是遗留类,很多常用的功能与HashMap类似,不同的是HashTable 继承自Dictionary类,并且利用Synchronized实现了线程安全,并发性不如ConcurrentHashMap。Hashtable已经不建议使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。  


8.TreeMap

          

         TreeMap实现了SortedMap接口,可以根据Key的值排序,默认是升序排序,用户也可以传入指定排序的比较器。当用户使用Iterator遍历TreeMap时,输出的记录是排好序的。需要注意的是,使用TreeMap时,Key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

       

        TreeMap是基于红黑树实现的,红黑树中部分源码实现我没有看懂,等看懂了,我会写一篇博客对TreeMap的实现进行详细分析。


9.HashSet

       

       HashSet是基于HashMap实现的,其实是对HashMap做了一次简单的“封装”,只使用了HashMap中的key来实现集合的各种特征。因此,如果将自定义对象存储在HashSet中,需要根据自己的业务逻辑重写hashCode方法和equals方法。


10.TreeSet


  TreeSet是基于TreeMap实现的,因此,使用TreeSet时,存储元素对象必须实现Comparable接口或者在构造TreeSet传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。


三.参考文章


   1. JDK1.8源码
   2. 分布式Java应用基础与实践. 林昊
   3. JAVA8系列之重新认识HashMap.美团技术点评博客.https://tech.meituan.com/java-hashmap.html






      

          





     

    



 










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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值