Java7 hashMap底层原理

散列表

java中已知的基于散列表的数据结构有:hashmap,hashset, hashtable,LinkedHashMap,LinkedHashSet。

散列表整合了数组和链表的特点

备注:以下集合的原理均为jdk1.7下的

一.hashMap底层原理

1.1 hashMap数据结构

hashMap的结构如图所示:

这里写图片描述

对应源码

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

hashMap采用的就是散列表。

HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。HashMap数组每一个元素的初始值都是Null.

1.2 HashMap底层操作原理

对于HashMap,我们最常使用的是两个方法:Get 和 Put。

1.2.1 Put方法的原理

先对对象进行hash计算,找到对应的数组坐标,如果发生hash冲突,则使用头插法插入链表。

详情如下:

比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):

index = Hash(“apple”)

假定最后计算出的index是2,那么结果如下:

在这里插入图片描述
但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:

在这里插入图片描述
这时候该怎么办呢?我们可以利用链表来解决。

HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:

在这里插入图片描述

参考:漫画:什么是HashMap?https://www.cnblogs.com/qingyunzong/p/9143233.html

注意:
当index发生冲突时会使用头插法的方式。有点类似于栈的思想,最后进去的最先被找到.

源码如下:

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        //获取key的hash值
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //遍历table中的元素
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //关键代码
            // 如果hash相同并且 (两个key的对象相同 或者 两个key的equals()返回true)。
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                //此刻返回已经存储的值,所以不会对新的Entry进行存储
                return oldValue;
            }
        }
        //修改次数+1
        modCount++;
        //对新的Entry进行存储
        addEntry(hash, key, value, i);
        return null;
}

思考 hashmap如何保证元素的不重复

先判断是否出现hash冲突;

如果有的话,通过链表的遍历方式去逐个遍历,先比较key的对象是否相同,如果不相同则通过equals()看是否相同(通过equals()再比较主要考虑到有些对象可能重写了equals()方法),相同的话用新的value替 换老的value;

如果没有,则在table[index]插入该Entity,把原来在table[index]位置上的Entity赋值给新的 Entity的next,这样插入结束。(即头插法)

总结:数组位置冲突时,先遍历后插入。

参考:Hashpmap的原理,HashMap怎样保证key的唯一性 http://blog.csdn.net/o9109003234/article/details/44107811

1.2.2 Get方法的原理

通过计算hash值,找到对应的数组坐标,然后遍历链表找到对应的值。

详情如下:

首先会把输入的Key做一次Hash映射,得到对应的index:

index = Hash(“apple”)
由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:

在这里插入图片描述
第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。

第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。

之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。

1.2.3 remove方法的原理

分析一下remove元素的时候做了几步:

1、根据key的hash找到待删除的键值对位于table的哪个位置上

2、记录一个prev表示待删除的Entry的前一个位置Entry,e可以认为是当前位置

3、从table[i]开始遍历链表,假如找到了匹配的Entry,要做一个判断,这个Entry是不是table[i]:

(1)是的话,table[i]就直接是table[i]的下一个节点,后面的都不需要动,直接移除。

(2)不是的话,e的前一个Entry也就是prev,prev的next指向e的后一个节点,也就是next,这样,e所代表的Entry就被踢出了,e的前后Entry就连起来了。(同链表的删除原理)

参考自:http://www.cnblogs.com/xrq730/p/5052323.html

1.3 hashMap的算法

index = HashCode(Key) & (Length - 1)

HashMap扩容

默认大小为16,负载因子0.75,扩容时将容量变为原来的2倍以满足容量为2的幂。当超过16*0.75的大小时将进行扩容。

HashMap默认大小为什么是16?

主要目的就是让分布更加均匀。Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后四位。

详细原因如下:
下面我们以值为“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值的最后几位。

假设HashMap的长度是10,重复刚才的运算步骤:

在这里插入图片描述

单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011 :

在这里插入图片描述
让我们再换一个HashCode 101110001110101110 1111 试试 :

在这里插入图片描述
是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!

这样,显然不符合Hash算法均匀分布的原则。

反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

思考

1.hashMap底层数据结构为何使用内部类这样写?

2. hash的遍历原理

二、WeakHashMap

WeakHashMap,从名字可以看出它是某种 Map,即对象为弱引用。它的特殊之处在于 WeakHashMap 里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。

其源码如下:

  /**
     * The entries in this hash table extend WeakReference, using its main ref
     * field as the key.
     */
    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;
    }

@see 浅谈WeakHashMap http://www.importnew.com/23182.html

三、HashSet

HashSet的数据结构和hashmap基本一致,可以说HashSet就是由hashMap演变而来

类成员PRESENT

 // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

HashMap是键值对K-V模型的,HashSet则是普通的集合类型。这里的PRESENT就是用来填充HashMap中的value的。

3.1 构造函数

HashSet提供了4种构造函数。

// 默认无参构造函数
    public HashSet() {
        map = new HashMap<>();
    }
    
    // 以现有集合构造
    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }
    
    // 带参构造 initialCapacity: 初始大小  loadFactor:加载因子
    public HashSet(int initialCapacity, float loadFactor) {
        map = new HashMap<>(initialCapacity, loadFactor);
    }
    // 带参构造 initialCapacity: 初始大小
    public HashSet(int initialCapacity) {
        map = new HashMap<>(initialCapacity);
    }
    
    // 仅用来构造LinkedHashSet使用
    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }

由此可以证明HashSet就是由hashMap演变而来

add 方法

 public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

add方法很简单,直接调用HashMap的put方法实现。value则是用PRESENT进行填充。

remove 方法

public boolean remove(Object o) {
        return map.remove(o)==PRESENT;
    }

remove方法也是同样,调用HashMap的remove方法。

HashSet大多数的方法都是通过HashMap的方法来实现的。

获取元素

hashSet没有提供get()方法来获取指定的元素,要想获取元素只有通过遍历的形式来获取:

/** 
     * 返回对此set中元素进行迭代的迭代器。返回元素的顺序并不是特定的。 
     *  
     * 底层实际调用底层HashMap的keySet来返回所有的key。 
     * 可见HashSet中的元素,只是存放在了底层HashMap的key上, 
     * value使用一个static final的Object对象标识。 
     * @return 对此set中元素进行迭代的Iterator。 
     */  
    public Iterator<E> iterator() {  
    return map.keySet().iterator();  
    }  

hashCode方法 & equals方法

如果我们在使用HashSet来存储自定义的对象时候,要记得重写hashCode方法和equals方法。
我们将通过一个示例来说明重写的必要性。

public class Test {

    String a;

    public Test(String a) {
        this.a = a;
    }
    
    public static void main(String[] args) {

        Set set = new HashSet();

        Test o1 = new Test("abc");
        Test o2 = new Test("abc");

        set.add(o1);
        set.add(o2);

        System.out.println(o1.equals(o2));
        System.out.println(set.size());

    }

上面程序输出应该是:

false
2

为什么要重写?

默认equals方法是通过先比较引用是否一致的,然后比较对应的值是否一致。o1和o2显然是2个对象,他们是不相等的,他们各自的hashcode显然是不同的,所以HashSet会把他们当做2个不同对象来处理。

重写的思路就让两个对象的hashcode值相同如:

@Override
    public int hashCode() {

        return 123 * 31 + a.hashCode();
    }

    @Override
    public boolean equals(Object o) {

        Test test = (Test) o;
        return test.a.equals(this.a);
    }

参考:源码分析之HashSet https://www.jianshu.com/p/43f92a4b0b6f

四、hashTable

hashtable的底层结构和hashmap基本一致:
不同点:

1、结构不同-继承的父类不同

public class Hashtable extends Dictionary implements Map 
public class HashMap extends AbstractMap implements Map 

2.线程安全
Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize。
如:

public synchronized V put(K key, V value) {}
 public synchronized V get(Object key) {}

3、key和value是否允许null值
Hashtable中,key和value都不允许出现null值。
在HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。

4.内部实现使用的数组初始化和扩容方式不同

  HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。
  Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
  Hashtable和HashMap它们两个内部实现方式的数组的初始大小和扩容的方式。HashTable中hash数组默认大小是11,增加的方式是 old*2+1。

5.哈希值的使用不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值并取最后那几个。

五、LinkedHashMap

关于LinkedHashMap,先提两点:

1、LinkedHashMap可以认为是HashMap+LinkedList,即它既使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序

2、LinkedHashMap的基本实现思想就是----多态。LinkedHashMap是HashMap的子类,自然LinkedHashMap也就继承了HashMap中所有非private的方法。

其对象结构如下:
在这里插入图片描述

其数据结构如下:
这里写图片描述

其中前面四个是从HashMap.Entry中继承过来的;后面两个,是LinkedHashMap独有的。不要搞错了next和before、After。
next是用于维护HashMap指定table位置上连接的Entry的顺序的,before、After是用于维护Entry插入的先后顺序的。

LinkedHashMap的插入就是HashMap+LinkedList的实现方式,以HashMap维护数据结构,以LinkList的方式维护数据插入顺序。即LinkedHashMap在插入时保证顺序主要是通过before和after节点的改变。

LinkedHashMap的删除同时维护HashMap+LinkedList,HashMap维护数据结构,LinkList维护数据的原先顺序,即LinkedHashMap在删除时会打断before和after节点,然后重组before和after所指引的位置来保护数据的顺序。

LinkedHashMap进行遍历时,迭代的是LinkedList这个双向链表来进行迭代输出,这样就保证了元素的有序性

在LinkedHashMap的get方法中,通过HashMap中的getEntry方法获取Entry对象。即用到的是HashMap,不会访问到before和after节点。

5.1 利用LinkedHashMap实现LRU算法缓存

参考我写的另一篇文章: LinkedHashMap https://blog.csdn.net/sinat_34814635/article/details/79206821

六、LinkedHashSet

LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法,因此LinkedHashSet的实现和LinkedHashMap基本一致。其源码如下:

public class LinkedHashSet<E>
    extends HashSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {
    ......
    // LinkedHashSet里面有一个LinkedHashMap
    public LinkedHashSet(int initialCapacity, float loadFactor) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }
    ......
    public boolean add(E e) {//简单的方法转换
        return map.put(e, PRESENT)==null;
    }
    ......
}

具体原理请参考
5)图解集合6:LinkedHashMap http://www.cnblogs.com/xrq730/p/5052323.html
6)Java集合详解5:深入理解LinkedHashMap和LRU缓存https://blog.csdn.net/a724888/article/details/80290276

七、ConcurrentHashMap

为什么引用ConcurrentHashMap?

7.1.高并发下的HashMap不安全

在这里插入图片描述

7.1.1 Resize

HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。
这时候,HashMap需要扩展它的长度,也就是进行Resize。

影响发生Resize的因素有两个:

1.Capacity

HashMap的当前长度。上一期曾经说过,HashMap的长度是2的幂。

2.LoadFactor

HashMap负载因子,默认值为0.75f。

衡量HashMap是否进行Resize的条件如下:

HashMap.Size >= Capacity * LoadFactor

2.Hashmap的Resize包含扩容和ReHash两个步骤,

Resize流程

1.扩容
创建一个新的Entry空数组,长度是原数组的2倍。

注意:hashMap的扩容方法void resize(int newCapacity) 是在put(K key, V value)方法里

2.ReHash
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

7.1.2 ReHash

假设一个HashMap已经到了Resize的临界点。此时有两个线程A和B,在同一时刻对HashMap的同一个table进行Put操作,就有可能造成形成链表环:

整体情况如图所示:

在这里插入图片描述

当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环!

具体原理请参考:
1.漫画:高并发下的HashMap https://blog.csdn.net/minkeyto/article/details/78667944
2.漫画算法:如何判断链表有环?
https://mp.weixin.qq.com/s?__biz=MzIxMjE5MTE1Nw==&mid=2653189798&idx=1&sn=c35c259d0a4a26a2ee6205ad90d0b2e1&chksm=8c99047cbbee8d6a452fbb171133551553a825c83fb8b0cc66210dcda842c61157a07baaeb6b&scene=21#wechat_redirect

解决方案
1)HashTable或者Collections.synchronizedMap()
2)ConcurrentHashMap

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,会产生性能问题。
如下:
在这里插入图片描述
在这里插入图片描述

7.2 ConcurrentHashMap的数据结构

7.2.1 Segment

Segment是什么呢?

Segment本身就相当于一个HashMap对象。

同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。

单一的Segment结构如下:

这里写图片描述
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。Segment的大小size默认为16。

因此整个ConcurrentHashMap的结构如下:

这里写图片描述

可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。

在这里插入图片描述

7.3 Segment的并发控制

  1. 不同Segment的并发写入:不同Segment的写入是可以并发执行的。

在这里插入图片描述

  1. 同一Segment的一写一读:同一Segment的写和读是可以并发执行的。

思考

1 写的时候读,难到不怕读到的不是最新的数据吗?

答:可以使用volatile修饰对应变量,使其保证在其他线程的可见性。

在这里插入图片描述

  1. 同一Segment的并发写入:Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。
    在这里插入图片描述

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。

实现原理

Get方法:

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.再次通过hash值,定位到Segment当中数组的具体位置。

Put方法:

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.获取可重入锁

4.再次通过hash值,定位到Segment当中数组的具体位置。

5.插入或覆盖HashEntry对象。

6.释放锁。

Size方法:
Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来。但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?

请设想这样的场景:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:

1.遍历所有的Segment。

2.把Segment的元素数量累加起来。

3.把Segment的修改次数累加起来。

4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。

5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。加了锁之后,在统计过程中其他线程就无法进行put等操作了

6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。

7.释放锁,统计结束。

这种思想应用到了cas和乐观锁转为悲观锁的思想。

官方源代码如下:

public int size() {
    // Try a few times to get accurate count. On failure due to
   // continuous async changes in table, resort to locking.
   final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {
        for (;;) {
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

具体实现原理请参考:漫画:什么是ConcurrentHashMap?http://www.sohu.com/a/205451532_684445

参考资料

1)http://blog.csdn.net/lizhongkaide/article/details/50595719
2)码农翻身:http://chuansong.me/n/2045375251011
3)HashTable和HashMap的区别详解 http://blog.csdn.net/fujiakai/article/details/51585767
4)源码分析之HashSet https://www.jianshu.com/p/43f92a4b0b6f
5)图解集合6:LinkedHashMap http://www.cnblogs.com/xrq730/p/5052323.html
6)Java集合详解5:深入理解LinkedHashMap和LRU缓存https://blog.csdn.net/a724888/article/details/80290276
7)Java集合框架源码剖析:LinkedHashSet 和 LinkedHashMap http://www.cnblogs.com/CarpenterLee/p/5541111.html
8)漫画:高并发下的HashMap https://blog.csdn.net/minkeyto/article/details/78667944
9)漫画:什么是ConcurrentHashMap?http://www.sohu.com/a/205451532_684445

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值