HashMap

1. 

Map接口有两个实现类:HashMap  TreeMap  HashTable(线程安全)

LinkedHashMap是HashMap的子类  

Properties类是HashTable的子类

2. 源码解析

1. HashMap是有序的还是无序的 ? LinkedHashMap? TreeMap?

2. HashMap 容量 还有扩容机制

3. 为什么是线程不安全的?


public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable

(1)类的属性

加载因子:是哈希表在其容量自动增加之前可以达到多满的一种尺度。它衡量的是一个散列表的空间的使用程度

负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

关于这个,再说得细一点,之所以采用Hash散列进行存储,主要就是为了提高检索速度。 
众所周知,有序数组存储数据,对数据的检索效率会很高,但是,插入和删除会有瓶颈产生。而链表存储数据,通常只能采用逐个比较的方法来检索数据(查找数据),但是,插入和删除的效率很高

//默认初始化化容量,即16  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//最大容量,即2的30次方  
static final int MAXIMUM_CAPACITY = 1 << 30;  

//默认装载因子  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  

//HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态  
static final Entry<?,?>[] EMPTY_TABLE = {};  

//空的存储实体  
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  

//实际存储的key-value键值对的个数
transient int size;

//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold
int threshold;

//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;

//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

 

(2)构造方法

HashMap()
构造一个空的HashMap,默认初始容量为16,默认加载因子为0.75。
HashMap(int initialCapacity) 
构造一个空的HashMap,指定初始容量,默认加载因子为0.75。
HashMap(int initialCapacity, float loadFactor)
构造一个空的HashMap,指定初始容量和加载因子。
HashMap(Map<? extends K,? extends V> m)
构造一个映射关系与指定 Map 相同的 HashMap。

 

在这四个构造方法中,其他三个构造方法都共同调用了第三个构造方法:

//其他三种构造方法最后都指向了该构造方法
    public HashMap(int initialCapacity, float loadFactor) {
        //检查初始容量是否小于0,是则抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //检查初始容量是否大于默认最大容量值,是则重置为MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //检查加载因子是否合法
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        //指定加载因子
        this.loadFactor = loadFactor;
        //初始化阈值
        threshold = initialCapacity;
        //初始化函数,里面是空的,供子类调用
        init();
    }


    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }


    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

3) put 操作

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;

这里的Entry是什么?它也是维护着一个key-value映射关系,除了key和value,还有next引用(该引用指向当前table位置的链表),hash值(用来确定每一个Entry链表在table中位置)

public V put(K key, V value) {
    //检查是否为空表,是则膨胀容量
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //检查key是否为null,这个很熟悉吧
        if (key == null)
            return putForNullKey(value);
        //计算key的hash值
        int hash = hash(key);
        //获取bucketIndex,即在table中存放的位置
        int i = indexFor(hash, table.length);
        //取出该索引下的Entry,遍历单链
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //检查hash码是否相同,key是否相等
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                //该key已存在,取出对应的value并转移
                V oldValue = e.value;
                //存入新的value
                e.value = value;
                //该方法内容为空,供子类重写所用
                e.recordAccess(this);
                //返回对应的旧value
                return oldValue;
            }
        }
        //记录表结构修改次数;到了这里证明,该table中并不存在该key,向表中增加Entry
        modCount++;
        //增加Entry
        addEntry(hash, key, value, i);
        //返回空值
        return null;
    }

从源码中我们可以看到,put方法进行了如下操作: 
1. HashMap是在put操作的时候才开始膨胀的; 
2. 然后判断输入的key是否为空值,如果为空则调用putForNullKey(V)设入空key(原理差不多,但需要注意,空Key都是放在table[0]里面的); 
3. hash(key)获取哈希码; 
4. indexFor(hash, table.length)获取存放位置的索引; 
5. 遍历table[i],检查是否存在,存在则覆盖并返回旧值; 
6. 不存在,准备修改表结构,先记录次数; 
7. 调用addEntry(hash, key, value, i)增加元素。

遍历Entry单链了,这个应该很好理解,Entry是以单链的形式存在的,用于解决hash碰撞时的存放问

看源码可以知道HashMap是可以把null当做key的,看下putForNullKey方法:

HashMap默认把null键的Entry放在数组的0位置,因为null无法获得hash值

private V putForNullKey(V value) {
        //查找链表中是否有null键
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //如果链中查找不到,则把该null键插入
        addEntry(0, null, value, 0);
        return null;
    }

2. 扩容resize方法

    void resize(int newCapacity) {
         Entry[] oldTable = table;
         int oldCapacity = oldTable.length;
         if (oldCapacity == MAXIMUM_CAPACITY) {
             threshold = Integer.MAX_VALUE;
             return;
         }

         //创建一个新的 Hash 表
         Entry[] newTable = new Entry[newCapacity];

         transfer(newTable, initHashSeedAsNeeded(newCapacity));
         table = newTable;
         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
     }

     void transfer(Entry[] newTable, boolean rehash) {
         int newCapacity = newTable.length;
         for (Entry<K,V> e : table) {
             while(null != e) {
                 Entry<K,V> next = e.next;
                 if (rehash) {
                     e.hash = null == e.key ? 0 : hash(e.key);
                 }
                 int i = indexFor(e.hash, newCapacity);
                 e.next = newTable[i];
                 newTable[i] = e;
                 e = next;
             }
         }
     }

还记得HashMap中的一个变量吗,threshold是2^30,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量 
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); 
什么时候发生扩容? 

还记得HashMap中的一个变量吗,threshold,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量 
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); 
什么时候发生扩容? 
当不断添加key-value,size大于了容量极限threshold时,会发生扩resize。 
resize这里是个大坑,因为会导致死锁,而根本原因来自transfer方法,这个方法干的事情是把原来数组里的1-2-3链表transfer成了3-2-1,不太看得懂的同学可以看我下面的演示代码,本质是一样的:

package com.amuro.studyhashmap;

public class HashMapStudy
{
    public static void main(String[] args)
    {
        Node n1 = new Node();
        n1.data = 1;

        Node n2 = new Node();
        n2.data = 2;

        Node n3 = new Node();
        n3.data = 3;

        n1.next = n2;
        n2.next = n3;

        printLinkedNode(n1);
        System.out.println("------");

        Node newHead = mockHashMapTransfer(n1);
        printLinkedNode(newHead);
    }

    static class Node
    {
        int data;
        Node next;
    }

    static void printLinkedNode(Node head)
    {

        while(head != null)
        {
            System.out.println(head.data);
            head = head.next;
        }
    }

    static Node mockHashMapTransfer(Node e)
    {
        Node newHead = null;

        while(e != null)
        {

            Node next = e.next;
            e.next = newHead;
            newHead = e;
            e = next;
            System.out.print("");
        }

        return newHead;
    }

}

结果输出: 



_ _ 



transfer方法的本质就是这个,那为什么会导致死锁呢?简单分析一下: 但是容量不可能为2
我们假设有两个线程T1、T2,HashMap容量为2,T1线程放入key A、B、C、D、E。在T1线程中A、B、C Hash值相同,于是形成一个链接,假设为A->C->B,而D、E Hash值不同,于是容量不足,需要新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中(refresh)。这时T2进程闯进来了,T1暂时挂起,T2进程也准备放入新的key,这时也发现容量不足,也refresh一把。refresh之后原来的链表结构假设为C->A,之后T1进程继续执行,链接结构为A->C,这时就形成A.next=B,B.next=A的环形链表。一旦取值进入这个环形链表就会陷入死循环。 
所以多线程场景下,建议使用ConcurrentHashMap,用到了分段锁的技术,后面有机会再讲。

最后整理一下put的步骤: 
1. 传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中; 
2. 然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束; 
3. 否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部。

四、get

    public V get(Object key) {
        //如果key为null,求null键
        if (key == null)
            return getForNullKey();
        // 用该key求得entry
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

put能看懂的同学看get应该毫无压力,调用hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置,然后遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。

从HashMap的结构和put原理我们也能理解为什么HashMap在遍历数据时,不能保证插入时的顺序。这时需要使用LinkedHashMap。

最后把java里map的四个实现类做个总结。 
1.Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null;允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。

2.Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。

3.LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的.也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。

4.TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。

最后的最后再加个tip: 
ConcurrentHashMap提供的线程安全是指他的put和get等操作是原子操作,是线程安全的。但没有提供多个操作(判断-更新)的事务保护,也就是说:

//T1:                                        
concurrent_map.insert(key1,val1);
//T2:
concurrent_map.contains(key1);

是线程安全的。

//T1,T2同时
if (! concurrent_map.contains(key1) {
    concurrent_map.insert(key1,val1)
}

线程不安全。

转自 https://blog.csdn.net/amurocrash/article/details/78882498

https://blog.csdn.net/jevonscsdn/article/details/54619114

阅读更多
换一批

没有更多推荐了,返回首页