Java的HashMap的原理

概要:HashMap是以数组和链表的结构形式存储数据。
数组:查找快(通过位置index查找,准确定位),增删慢(增删需要改变“变动元素”后面所有元素的位置),内存区域是连续的。
链表:增删快(只需要断开连接或者添加一个新的指针元素),查找慢(链表需要遍历所有元素),内存存储不连续,通过指针指向下一个元素

查看HashMap的构造函数

   static final int DEFAULT_INITIAL_CAPACITY = 16;

    
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    
    int threshold;

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }   

initialCapacity:默认初始集合的容量,默认值16,在JDK1.8上默认容量是4

loadFactor:加载因子。默认值是0.75

默认情况下hashMap的实际容量是threshold 16*0.75 = 12;如果table数组的容量超过实际容量threshold就会扩容,size = 2 * table.length,新数组的长度是老数组的的2倍。

put()方法

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
        int i = indexFor(hash, table.length);
        for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

在JDK1.8中首先判定table是否为null,如果为null,则初始化table数组调用inflateTable(threshold),初始化table数组,在1.7中在构造函数中初始化table数组。

  private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        // Android-changed: Replace usage of Math.min() here because this method is
        // called from the <clinit> of runtime, at which point the native libraries
        // needed by Float.* might not be loaded.
        float thresholdFloat = capacity * loadFactor;
        if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
            thresholdFloat = MAXIMUM_CAPACITY + 1;
        }

        threshold = (int) thresholdFloat;
        table = new HashMapEntry[capacity];
    }

capacity代表table数组的长度,并且是2^n。JDK1.8中创建一个默认长度值是4的table数组。然后回到put函数,首先判定key是否为null,如果为null,则取读取table[0]的元素为链表的头节点,遍历该条链表,如果有key==null的元素则替换value值。

看一下key不为null的情况下的存储,JDK1.8是h& (length-1)的位运算,等价于对length取模,也就是h%length,但是位运算比取余运算具有更高的效率,所以在1.8的时候改为位运算。

int i = indexFor(hash, table.length);
	//JDK1.8
	static int indexFor(int h, int length) {
        return h & (length-1);
    }
    
	 //JDK1.7
	 static int indexFor(int h, int length) {
		 return HashCode(Key) % Length 
	}
	
 for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

以table[index]为链表的头节点开始遍历所有的元素,根据key的hash值和key相等,则新的value值替换老的value值,并返回老的value,如果当前对象第一次保存,看下面源码

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }
    
      void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMapEntry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
        size++;
    }

**在没有扩容的情况下:**取出存放在table[bucketIndex]的元素即链表头结点oldEntry,将新对象的指针指向oldEntry。也就是说每次添加新的元素都是添加到table数组上,也就是放到链表的头节点,这种插入方式叫做“头插入”。为什么不插入链表尾部而是头部呢?后面get()查询中会解释。
扩容的情况下:
resize(2 * table.length)以原数组2倍的形式进行扩容。

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

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

    void transfer(HashMapEntry[] newTable) {
        int newCapacity = newTable.length;
        for (HashMapEntry<K,V> e : table) {
            while(null != e) {
                HashMapEntry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

首先要创建一个新的数组,新的数组创建完毕,需要将老数组的数组全部重新存放到新的数组上面,由于newTable的length发生了变化,所以所有元素需要重排序存放。

  1. 重新创建一个 newTable = new HashMapEntry[2 * table.length];
  2. 遍历老数组table
  3. 遍历每个桶的链表
  4. 将table数组上的元素存放到newTable。

扩容的过程中需要将原因数组的所有元素重新排序存放到newTable上,遍历数组遍历链表是个很耗时的过程,所以在知道需要存放元素的个数时,将初始容量设置好。初始容量必须是2的N次幂,至于为什么要这样设置后面的问题环节解答。

put方法流程总结:

  • 1:key为null时,则存放在table[0]的链表上,如果该链表存在之前key == null的元素,则替换,如果不存在key == null的元素,则直接添加到table[0]作为链表的头节点。
  • 2:如果key不为null时,首先通过hash%table.length取余,即为table数组的index,查找table数组的头节点,遍历该链表的元素,判定存在则替换,不存在,则添加,添加时先判定是否需要扩容。扩容是以oldTable.length的2倍的形式。将新添加的元素存放到数组上,老的元素的指针指向上次的元素。

get()方法
明白了put()方法,那get方法就简单了,贴出get()代码

  public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

   final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
        for (HashMapEntry<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;
    }

首先如果key == null,则从table[0]上的链表中区遍历查找,
如果key !=null,通过key的散列运算获取到index,从table[index]上的链表中遍历查找。如果没有则返回null。

上面的问题:为什么不插入链表尾部?

之所以把newEntry放在头节点,因为我们每次查询时首先确定table数组的index,便是链表的头节点,
每次查询都是从链表的头节点开始遍历,最后插入的Entry被放到链表的头节点,这样被查找的可能性更大。
如果放到链表的尾部,那查询时可能需要遍历链表到尾部才能查询的到

2.HashMap的数据结构特点
HashMap融合了数组和链表二者的优点,相对而言比单纯的链表和数组,查找相对快,增删相对快。
HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,如果需要满足线程安全,可以用 Collections的synchronizedMap方法返回一个新的SynchronizedMap具有线程安全的能力,或者使用ConcurrentHashMap。

3.map使用时注意事项

  • 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。初始化容量的值必须的2的幂,例如:需要在map中设置75个元素,需要添加initialCapacity = 75/0.75+1 =101,但是101时不能满足需求的,要求必须是2的幂次方,离101最近的是128,所以需要设置初始化容量是128。

  • 为什么初始化容量必须是2的幂?

    在JDK1.8,计算key的位置运用的是“与运算”有如下的公式(Length是HashMap的长度):index =  HashCode(Key) &  (Length - 1)下面我们以值为“book”的Key来演示整个过程:
 
 1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
 
 2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
 
 3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
 
 可以说,Hash算法最终得到的index结果,==完全取决于Key的Hashcode值的最后几位==。
 
 如果不按照lenghth的值是2的幂,有些index结果的出现几率会更大,而有些index结果永远不会出现,如果按照长度是2的幂,index的结果等同于hashCode()后几位的值,
 Length-1的值是==所有二进制位全为1==,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

  • HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。那么为什么说HashMap是线程不安全的?在并发的多线程使用场景中使用HashMap可能造成死循环。Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在(多线程)并发的情况下可能会**形成链表环http://mp.weixin.qq.com/s/dzNq50zBQ4iDrOAhM4a70A

  • hash碰撞带来的问题?数组table的长度大于集合中元素的数量,每个桶包含的元素就比较少(最好就一个数据),但是如果多个hash值落在同一个桶里面(最差全部落在同一个桶里),这些值存储在一个链表上,这样会造成查询的时间复杂度O(1)到 O(n)。

问题

HashCode()算法

Hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5,SHA1,SSL中的公私钥验证都属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。

下面列举几种常用的hash算法:
Object:返回的是object对象内存地址经过处理后的结构,每个对象的内存地址不同所以算法返回的hash值也不同。改方法是native方法,这要取决出JVM的设计,
一般情况下是Java中的hashCode方法就是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值

String:的hash算法s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
关于为什么取31为权?主要是因为31是一个奇质数,所以31*i=32*i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。

Integer:  public int hashCode() {
              return value;
            }
        返回的是value的值,如果俩个大小相同的Integer对象,即使不是同一个对象,hashCode值也是一样的。

JDK1.8和1.7比较的优化点在哪里?

JDK1.8:优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,
这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。 

加入了红黑树,Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构,如果同一个格子里的key不超过8个,使用链表结构存储。
如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销
也就是说put/get的操作的时间复杂度最差只有O(log n)


JDK1.7在hashcode特别差的情况下,比方说所有key的hashcode都相同,这个链表可能会很长,那么put/get操作都可能需要遍历这个链表
也就是说时间复杂度在最差情况下会退化到O(n)

HashMap和HashTable的比较

(map继承关系图)
这里写图片描述
(HashTable继承关系图)
这里写图片描述
HashTable是遗留类,继承于Dictionary并且线程安全,但是其并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,
不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

什么是hash攻击?

通过请求大量的key,虽然key不同,但是key的hash值却相同,让hashMap不断的发生碰撞,变成一个SingleLinkedList,
即单链表。这样put/get()的时候时间复杂度从O(1)变成了O(n),cpu负载直线上升,这种方式叫做hash攻击。

提高HashMap的性能?

1.减少扩容次数。扩容是个相当耗时的过程,将初始容量设置好,避免再次扩容能提高效率。
2.解决碰撞损失:使用高效的HashCode与loadFactor,这个…由于JDK8的高性能出现,这儿问题也不大了。
3.解决数据结构选择的错误:在大型的数据与搜索中考虑使用别的结构比如TreeMap,这个就是积累了,一般需要key排序时,建议使用TreeMap;

让HashMap在多线程的情况下安全的原理
使用Collections.synchronizedMap(map),返回的是一个全新的Map对象,查看源码

private static class SynchronizedMap<K,V>
        implements Map<K,V>, Serializable {
        private static final long serialVersionUID = 1978198479659022715L;

        private final Map<K,V> m;     // Backing Map
        final Object      mutex;        // Object on which to synchronize

        SynchronizedMap(Map<K,V> m) {
            this.m = Objects.requireNonNull(m);
            mutex = this;
        }

        SynchronizedMap(Map<K,V> m, Object mutex) {
            this.m = m;
            this.mutex = mutex;
        }
      
        public V get(Object key) {
            synchronized (mutex) {return m.get(key);}
        }

        public V put(K key, V value) {
            synchronized (mutex) {return m.put(key, value);}
        }
        public V remove(Object key) {
            synchronized (mutex) {return m.remove(key);}
        }
        ........................
       

SynchronizedMap是一个加锁处理的Map的子类,这种加锁简单粗暴,在所有的方法体内都进行同步代码块限制,在高并发的情况下,更多的时候线程需要等待,这种方法效率比较低。HashTable继承于Dictionary,实现原理和SynchronizedMap类似,所有线程都在竞争一把锁,该锁锁住了table数组,它是安全的,但是效率是比较低的。建议使用ConcurrentHashMap,下一次研究一些ConcurrentHashMap的原理。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值