hashmap知识点汇总

什么是HashMap:

集合框架中已经有list与set系列的数据结构,比如ArrayList满足了我们对数组这种数据结构需求,可以顺序存储数据并可以高效的随机访问数据;LinkedList满足了我们对链表的这种数据结构的需要,可以进行对此添加删除操作。Hashset满足我们能够在一个顺序表中不进行重复值的需求。
而HashMap就是为了满足我们对哈希表需求而封装的一个类。这个类中分装了一个hash表,并向外界提供了对这个hash表的操作。

HashMap的实现:

1.HashMap底层封装了一个哈希表,hash表是一种可以存储键值对的数据结构。就是知道一个关键字key,可以通过哈希函数计算出这个key在hash表中的索引下标从而得到key对应的value值。这相比较与数组这种数组随机访问来说,我只需要知道key就可以得到对应的value,而数组我们必须得知道访问元素的确切下标。
2.哈希函数
(1)伪随机数法:通过产生一个伪随机数来定位表中的下标
(2)除留余数法:一般用key % 哈希表的长度。
3.通过哈希函数可以定位到哈希表中的一个下标,存在问题:多个key可能定位到同一个哈希表下标。
注意:同一个key必须对应同一个下标;但对应同一个下标的不一定是同一个key
4.哈希冲突解决方案:
(1)开放地址法:从冲突的位置开始,向后依次找,直到不再冲突
(2)拉链法:哈希表中放的是一个链表,冲突时挂链
(3)再哈希法:冲突时,换一种哈希函数,直至不再冲突
(4)建立公共溢出区:开辟一个新的区域存放有冲突的元素

5.在HashMap中,它底层采用链表数组的形式来存储键值对,也就是说在产生hash冲突时,它采用的是拉链法来进行挂链解决的。然后他存放的结点类型有两个,一个为Node<K,V> implements Map.Entry<K,V>,他表示桶中存放的是链表时的结点类型。另一个是TreeNode<K,V> extends LinkedHashMap.Entry<K,V>(1799行),他表示桶中存放的是红黑树时的结点类型。

HashMap中重要的成员变量:

在这里插入图片描述
entrySet:HashMap中k-v节点的set集合

Capicity:哈希表的容量(1.8中已经没有这个变量,可通过table.length获得,构造HashMap时的初始化容量用threshold暂时保存,首次put元素后更新threshold的实际值为threshold=threshold*0.75【即threshold变量实际表示的含义:容量临界值】)

为什么HashMap的容量是2的次幂?

当我们创建一个hashmap对象时,如果使用无参构造器,那么默认长度是16。
为什么是16?
1.默认长度大小需要适中
2.最重要的是HashMap的哈希表的长度,必须是2的次幂,为什么?
(1)HashMap中通过key得到对应哈希表中对应下标时,首先通过hash方法,把对应的key对象转化为一个数值。然后通过 key数值&(容量-1) 得到。
当容量为2的次幂时(2n),可以用位运算&来代替取余操作。进行&运算时,(容量-1)低n位全为1,那么数值低n位是什么对应的下标就是多少,减小哈希冲突。

n为4时:
Xxx xxx 1101   	Xxx xxx 1011  
&				&
Xxx xxx 1111	Xxx xxx 1111
	  	1101            1011
	  	
当容量不为2的次幂时,如10:
Xxx xxx 1101   	Xxx xxx 1011  
&				&
Xxx xxx 1001	Xxx xxx 1001
	    1001            1001

(2)实质上当容量为2的次幂时,key数值&(容量-1)这就是除留余数法, key数值&(容量-1)=key数值%容量
(3)理解x/(2n),就是把x右移n位,剩下的部分。那么移走部分的含义是什么呢?就是x%(2n)。

3.为2的次幂时,当容量>=8时,计算最大临界值时,容量*0.75时得到的是一个整数。

为什么要显示设置HashMap的初始容量?

1.当我们明确自己的HashMap中要放多少个元素时,应该在构造HashMap时明确指出他的大小。(阿里巴巴java开发手册)
因为如果存储数据为100000个,而未指定初始大小时,在添加元素过程中需要多次执行resize方法去重建哈希表,这个过程影响性能。
2.那么如果我们知道需要的容量为7,那么构造时就填7吗?
HashMap中当我们指定为7时,通过tableSizeFor将其变为大于7的最小次幂8.然后算出threshold=容量*装载因子,即8 * 0.75=6。这显然不是我们希望看到的结果。

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;
    }

所以在明确我们需要的容量为n时,我们传入的容量应该为:n/负载因子+1,
如上面的7, 7/0.75+1=10,tableSizeFor后为16,最大临界值为12,这样就不会存在扩容带来的影响。当然,会浪费一定的空间,所以这是均衡后的一种解决方案。
3.一般不建议修改装载因子,默认为0.75.该值是通过一系列实验统计比对出来的。

HashMap中hash方法的具体实现:

1.hash方法,他把对应的key对象,转化为一个数值。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

在jdk1.7中,还通过一个叫indexFor()的方法,根据hash方法返回的数值做上面的&运算。
在jdk1.8中,应该是觉得indexFor()只做了(容量-1)&hash返回数值,所以没有单独提供,但在使用时,还是通过(容量-1)&hash返回数值来获取哈希表的下标。
4.上面的(容量-1)&hash有什么问题吗?(还是以n为4 为例)

Xxx xx0 1101			Xxx x01  1101        。。。。。
&						&
		1111             		 1111
        1101			   		 1101

也就是说如果hash方法返回的数值后n位相同,那么得到哈希表下标就相同,这是很不合理的。所以在hash方法中做了以下处理:
根据key 对象返回其hashcode
通过一系列移位操作来扰乱其排列。
jdk1.7和jdk1.8中采用的移位操作不太相同,具体查看源码。

put方法原理:

当我们往HashMap中放一个元素时,首先根据key通过hash得到一个返回数值,然后通过该数字做&运算得到哈希表的下标。当key为null时,放在下标为0的位置
在这里插入图片描述
补充:
当遍历完table[i]上的链表后,发现不存在与本次放置的key相同的结点。就会创建新的结点并挂在链尾。之后会进行是否进行链表向数的转化判断,即当前链表长度>=8。当条件满足后,还会判断当前table的长度是否>=64,成立的话才会进行链表向数组的转换,不成立的话执行的扩容。 【这里增加了对数组长度的判断,应该是因为当哈希表的长度小于64时,进行扩容带来的代价与性能改善会比数化带来的性能更好】。

get方法的原理

Get方法也是根据key通过hash得到一个返回数值,然后通过该数字做&运算得到哈希表的下标。然后在对应下标的链表或红黑树中比对与该key相同的结点,并返回其value值。

扩容机制:

1.扩容因子:2。同样,为什么?(以下是个人想法,未考证)
(1)将当前容量乘以2,可用移位运算代替
(2)保证其容量还是2的次幂(服务于hash算法)
(3)扩容的大小要适中
2.扩容时机:
在这里插入图片描述
在这里插入图片描述
put时发现哈希表还未建立,调用resize进行建表;
产生哈希冲突时,将新建的结点挂在链表后,若链表长度>=8 && 哈希表长度<64时进行扩容;
put完成后,若发现size>=临界值,进行扩容。

为什么要在链表长度大于8时进行链表向红黑树的转化,有为什么在数的结点小于6时进行数向链表的转化,为什么不是8?

数化为什么是8: 根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

红黑树向链表转化时为6,而不为8的原因是:如果每一个位置的Entry个数始终在8的上下浮动,就会导致频繁的链表与数之间的转换。

JDK1.7与JDK1.8实现不同之处以及区别?

1.引入了红黑树,当结点存放链表结构时,如何hash碰撞严重的话,将会导致put和get操作的性能退化到O(N),jdk1.8中当链表长度超过8,且hash表长度大64时,会进行链表到树的转化,目的就是为了解决hash碰撞严重时导致的添加、获取、删除性能下降问题。因为红黑树是一种二叉搜索树,所以在查询时它的复杂度为O(logN)
2. 1.7冲突或者扩容将原哈希表中元素移至新哈希表时,采用的是头插法;1.8采用尾插法。

为什么说HashMap是非线程安全的,高并发下,为什么HashMap会出现死锁?体现在哪?

Put:线程A和线程B的key同时定位到一个下标,假设该位置为空,线程A正准备放置新建结点时时间片到;线程B执行放置了结点,之后线程A再放时,覆盖了B放置的结点。
Resize:jdk1.7中将原哈希表中元素移至新哈希表时,由于采用头插法,在并发环境下会造成环状链表。

fast-fail机制和fail-safe机制

设计的原因。有人可能会问,既然fast-fail有这么多弊端,为什么还要设计呢,以HashMap为例,因为HashMap本身就是设计成线程不安全的,不支持多个线程同时安全修改,但这也意味着HashMap有较快的速度。fail-safe机制设计的初衷就是在集合在迭代被修改时,提供一种提醒机制。
fast-fail在获取迭代器时,把集合中countMod成员变量赋值给迭代器中的一个叫做expectCountMod成员变量,在迭代过程中,当对集合进行增加、移除时,会对countMod的值进行改变,导致在迭代器中遍历过程中进行判断(countMod==expectCountMod)时,发现不再相等,就会抛出异常

HashMap与其他Map实现的区别

HashMap与HashTable的区别?

1.Hashtable是相对线程安全的,它内部的公用方法涉及到线程安全的敏感操作,都加了synchronized进行了修饰。HashMap在多线程下不能保证线程安全。虽然有fast-fail机制来中断数据被修改的情况,但不该依赖这种机制来在多线程下使用。
2.Hashtable在的hash算法采用的是取余操作,HashMap则采用的是位运算&。这里的话,不一定HashMap比Hashtable性能好,因为HashMap还进行移位扰乱操作。
3.HashMap的默认大小为16,扩容后大小为2原始大小;Hashtable默认大小为11,扩容后大小为2原始容量+1。HashTable默认值是11,扩容为2倍加1是为了尽量保持容量为质数(仅仅是尽量),来减少哈希冲突。
4.HashMap键值都允许为null,Hashtable都不允许
在这里插入图片描述
为什么HashMap允许K-V为null,而线程安全的Map却不允许:
答案是在并发环境下,无法知道究竟是是因为key不存在返回的null,还是value本身就是null。
对于HashMap来说,他的应用场景是单线程,所以可以看判断是否存在,存在再去获取,这样就解决了。
但放在多线程下,虽然contains与get操作可以保证线程安全,但是这两个操作组合在一起就无法保证原子性了。所以这就是Hashtable与ConcurrentHashMap的K-V不允许为null的原因。

Collections.synchronizedMap():

Collections.synchronizedMap(Map map,Object mutex)会返回一个线程安全的Map【位于Collections中的内部类】。其原理是他会对关键操作加上synchronized锁,锁的对象如果在构造函数传入就用传入的mutex,未传入就用this。
千万别说是在方法上加了synchronized。

WeakHashMap:

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;
 
        Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
}

Entry继承自WeakReference,那么WeakHashMap中的entry数组中,都是弱引用,随时会被回收。所以这里提供了ReferenceQueue,当前某个Entry结点要被回收时(注意,这里是已经确定要回收了,但还没有被回收),会放入这个引用队列。
WeakHashMap的增删改查操作都会直接或者间接的调用expungeStaleEntries()方法,该方法会对queue加锁,遍历该queue,去找到哈希表(Entry数组)中存储的对应的Entry将其从哈希表中删除,并修改size变量。

介绍出现原因、如何让变成弱引用、如何回收

LinkedHashMap:

简述:
LinkedHashMap在HashMap的基础上,将哈希表中的各个结点按照添加的顺序,用链表的形式串起来,这样使得遍历时的顺序与加入的顺序一致其它的操作本身还是操作HashMap。所以可以看做LinkedHashMap=LinkedList+HashMap。

实现原理:
LinkedHashMap用head与tail分别执行链表的头与尾。他的结点Entry相比HashMap多了Entry<K,V> before, after;分别指向前一个结点与后一个结点。

另外,LinkedHashMap在构造时,通过制定accessOrder这个Boolean变量来指定是否在访问一个结点后,将该结点由原位置放置到链表尾部(LRU算法)。

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>{

	/**双向链表的头节点*/
	transient LinkedHashMap.Entry<K,V> head;

	/**双向链表的尾节点*/
	transient LinkedHashMap.Entry<K,V> tail;

	/**
	  * 1、如果accessOrder为true的话,则会把访问过的元素放在链表后面,放置顺序是访问的顺序
	  * 2、如果accessOrder为false的话,则按插入顺序来遍历
	  */
	  final boolean accessOrder;
}
static class Entry<K,V> extends HashMap.Node<K,V> {
	//before指的是链表前驱节点,after指的是链表后驱节点
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
}

具体文章介绍实现原理

ConcurrentHashMap及JDK1.8的优化

一、Jdk1.7:

 	 /**
     * 默认并发级别
     */
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

static final class Segment<K,V> extends ReentrantLock implements Serializable {   
         transient volatileint count;
         transient int modCount;
         transient int threshold;
         transient volatile HashEntry<K,V>[] table;
         final float loadFactor;
 }

构造:

@SuppressWarnings("unchecked")
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
    // 参数校验
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    // 校验并发级别大小,大于 1<<16,重置为 65536
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    // Find power-of-two sizes best matching arguments
    // 2的多少次方
    int sshift = 0;
    int ssize = 1;
    // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    // 记录段偏移量
    this.segmentShift = 32 - sshift;
    // 记录段掩码
    this.segmentMask = ssize - 1;
    // 设置容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数
    while (cap < c)
        cap <<= 1;
    // create segments and segments[0]
    // 创建 Segment 数组,设置 segments[0]
    Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}

concurrencyLevel:用来指明要分段的数量,真实分段数为大于该数的最小2次幂

总结一下在 Java 7 中 ConcurrnetHashMap 的初始化逻辑。

  1. 必要参数校验。
  2. 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无惨构造默认值是 16.
  3. 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。
  4. 记录 segmentShift 偏移量,这个值为【容量 = 2 的N次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.
  5. 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.
  6. 初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。

put:

  1. 首先对key进行第1次hash,通过hash值确定segment的位置
  2. 通过继承ReentrantLock的tryLock方法尝试去获取锁,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock方法去获取锁,超过指定次数就调用阻塞式的lock方法,等待唤醒
  3. 获取当前segment的HashEntry数组后对key进行第2次hash,通过hash值确定在HashEntry数组的索引位置
  4. 然后对当前索引的HashEntry链进行遍历,如果有重复的key,则替换;如果没有重复的,则插入到链头
  5. 释放锁

Get操作无需加锁:

V get(Object key, int hash) {
     if (count != 0) { // read-volatile 当前桶的数据个数是否为0
         HashEntry<K,V> e = getFirst(hash); 得到头节点
         while (e != null) {
             if (e.hash == hash && key.equals(e.key)) {
                 V v = e.value;
                 if (v != null)
                     return v;
                 return readValueUnderLock(e); // recheck
             }
             e = e.next;
         }
     }
     returnnull;
 }

由于count使用volatile修饰,HashEntry的value使用volatile修饰可以保证其可见性,next使用final修饰,所以读取操作是不用加锁的。

remove操作要将需要移除结点前的全复制一次:

static final class HashEntry<K,V> {
     final K key;
     final int hash;
     volatile V value;
     final HashEntry<K,V> next;
 }

可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。
为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁

size:
size操作遍历两次所有的segment里的count,每次记录Segment的modCount值,然后将两次modCount值进行比较,相同则表明未发生写入操作,将结果返回。 若判断两次结果不同,此时会对所有Segment进行加锁,然后进行统计。

resize:
当前初始化完成后,Segment的数量就固定,就并发度就固定了。这里的rehash指的是对某个Segment进行扩容,扩容某个Segment不影响其他的Segment。

jdk1.7讲解文章:
put、get、remove介绍

二、jdk1.8:

//	>=0:相当于hashmap中的threshold; -1:正在进行初始化; N:有N个线程正在进行扩容
private transient volatile int sizeCtl;
transient volatile Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
}

put:
获取到key的hash值,然后在自旋过程中,进行以下操作:

  1. 先判断是否需要进行初始化,需要的话先进行初始化(保证只会被初始化一次): 在条件为 table未被初始化的while循环中,判断 sizeCtl是否<0(被创建时,要么是0,要么是大于传入容量的最小2次幂),<0的话(-1)证明正在初始化,此时调用Thread.yield()让当前线程放弃CPU。 然后进行CAS将sizeCtl设置为-1,CAS成功的线程进行初始化哈希表工作,并在初始完毕后将sizeCtl设置为扩容临界值。(补充,当调用yield的线程再次执行时,因为已经不可能再CAS成功了;同时其他线程在调用初始化方法时,由于判断条件的table用volatile修饰,即判断条件不会再成立)。
  2. hash值&(table.length-1)得到哈希表下标i,如果桶的头结点f=table[i]为null,则进行自旋+CAS将K-V对设置在table[i]位置。
  3. 若f不为null,则判断f是否正在扩容(判断f的哈希值是否为MOVED,即值为-1),如果正在扩容,则去帮助进行扩容
  4. 若f也没有在进行扩容,则使用synchronized锁住f,然后进行与HashMap一样put操作。注意这里增加新的结点在synchronized同步块内,进行数化与扩容不在。

get:

//会发现源码中没有一处加了锁
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode()); //计算hash
    if ((tab = table) != null && (n = tab.length) > 0 &&
      (e = tabAt(tab, (n - 1) & h)) != null) {//读取首节点的Node元素
        if ((eh = e.hash) == h) { //如果该节点就是首节点就返回
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //hash值为负值表示正在扩容,这个时候查的是ForwardingNode的find方法来定位到nextTable来
        //eh=-1,说明该节点是一个ForwardingNode,正在迁移,此时调用ForwardingNode的find方法去nextTable里找。
        //eh=-2,说明该节点是一个TreeBin,此时调用TreeBin的find方法遍历红黑树,由于红黑树有可能正在旋转变色,所以find里会有读写锁。
        //eh>=0,说明该节点下挂的是一个链表,直接遍历该链表即可。
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {//既不是首节点也不是ForwardingNode,那就往下遍历
            if (e.hash == h &&
             ((ek = e.key) == key || (ek != null && key.equals(ek))))
                 return e.val;
        }
    }
    return null;
}

无需加锁,与HashMap过程一样。因为Node节点的val与next属性都用volatile进行了修饰。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值