HashMap底层分析

1、基本结构

HashMap是基于哈希表的Map接口非同步实现,允许null值与null键,不保证映射的顺序(不保证顺序的持久不变),是无序的。
在这里插入图片描述在这里插入图片描述
注意:Map不是继承Collertion接口。

各个实现类说明:
1)HashMap: 用于存储key-value键值对的集合。它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
2)Hashtable: Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它继承自Dictionary类,键值都不可为null,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。(Hashtable通过方法synchronized保证同步,ConcurrentHashMap在1.7通过分段锁,1.8通过cas+synchronized保证同步)。
3)LinkedHashMap: LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问的最近时间次序排序。可以同步实现简单的LRU算法。
4)TreeMap: TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,红黑树,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

2. 存储结构

从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下图所示:
在这里插入图片描述

     * The default initial capacity - MUST be a power of two.(hashmap的初始容量,必须是2幂次方)
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认负载因子
    static final int TREEIFY_THRESHOLD = 8; //大于8链表结构转为红黑树结构
    static final int UNTREEIFY_THRESHOLD = 6; //小于6红黑树结构转为链表结构
    static final int MIN_TREEIFY_CAPACITY = 64;
    Node<K,V>[] table; //内部结构  数组+Node节点(内部类的链表结构)
	int threshold; // 扩容阈值 
	final float loadFactor; // 负载因子 
	transient int modCount; // 出现线程问题时,负责及时抛异常 
	transient int size; // HashMap中实际存在的Node数量
	 /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

1)Node[] table: 即哈希桶数组,明显它是一个Node的数组,数组的每一个元素Node可以理解为一个包含key-value组合的桶。Node是HashMap的内部类,实现了Map.Entry<K, V>。Node[] table的初始化长度length(默认值是16),在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数。为什么一定要是2的n次方?–》这样length-1能够保证低位全部都是1,这样h(即hash值)的每一位变化都会导致索引位置的变化,会更加的散列。
散列值的特点:

  • 同一个对象调用多次hashcode()方法,必须返回相同的值
  • 如果两个对象根据equals()方法比较是相同的,那么两个对象调用hashcode()方法的返回值一定相同
  • 如果两个对象根据equals()方法比较是不相同的,那么两个对象调用hashcode()方法的返回值有可能相同–导致哈希冲突

冲突解决:
常见的hash冲突的解决方法:开放地址法(线性探查法、线性补偿探测法、伪随机探测)、拉链法、再散列(双重散列,多重散列)等。JDK中的HashMap采用了拉链法解决冲突。
2)常量说明

  • DEFAULT_INITIAL_CAPACITY : 初始容量,也就是默认会创建 16 个桶,
    桶的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。
  • MAXIMUM_CAPACITY : 哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题。
  • DEFAULT_LOAD_FACTOR : 默认的填充因子。
    TREEIFY_THRESHOLD : 这个值表示当某个桶中,链表长度大于 8 时,会转化成红黑树。
  • UNTREEIFY_THRESHOLD : 在哈希表扩容时,如果发现链表长度小于 6,则会由红黑树重新退化为链表。
  • MIN_TREEIFY_CAPACITY : 在转变成红黑树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

3. 功能实现

1). 确定哈希桶数组索引位置–Hash算法
如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组(Node[] table)的大小,并在此基础上设计好的hash算法减少Hash碰撞。所以好的Hash算法和扩容机制至关重要。
Hash算法本质上就是三步:
(1) 取key的hashCode值,h = key.hashCode();
(2) 高位参与运算,h ^ (h >>> 16);
(3) 取模运算,h & (length-1)。
在这里插入图片描述
优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

2). 分析HashMap的put方法
在这里插入图片描述
HashMap中put方法的逻辑:
1)首先判断Node[] table数组是否为空,如果为空则进行扩容。
2)根据Hash算法找到key对应的索引位置i,如果table[i] == null,则直接新键节点并插入转入6,否则转入3
3)判断table[i]的首个元素是否与key相同,如果相同,则直接对value值覆盖,否则转入4
4)判断table[i]是否为红黑树,如果是,则直接在树中插入键值对。否则转入5
5)遍历table[i]进行链表的插入。如果判断该链表的长度大于8,将链表转换为红黑树并执行插入操作。如果在遍历链表的过程中,发现元素的key已经存在,则直接将value覆盖
6)节点插入成功后,判断当前键值对的个数是否大于最大容量threshold,如果大于则进行扩容。

3)分析HashMap的get方法

 public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }


/**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //1、找到该哈希桶第一个节点的key相等,直接返回
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
                //否则去遍历哈希桶
            if ((e = first.next) != null) {
            //判断节点是红黑树节点还是链表节点
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                //循环遍历链表
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

4). 分析HashMap的扩容机制
HashMap对象内部的数组无法装载更多的元素的时候,就需要扩大数组的长度来装入更多的元素。Java中的数组是无法自动扩容的,方法是使用一个新的数组来替代已有的容量小的数组。简单来说就是换一个更大的数组进行重新映射。
扩容的时机:当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值-即当前数组的长度乘以加载因子的值的时候,就要自动扩容。扩容后新的数组的容量是之前的2倍。
就是这个map里面包含的元素,也就是size的值,大于等于这个阈值的时候,才会resize();
JDK1.7的扩容机制:采用的是头插法
在这里插入图片描述
JDK1.8的扩容机制:采用的是尾插法
小结
(1) 扩容是一个特别耗性能的操作,所以使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) HashMap是线程不安全的,在并发的环境中建议使用ConcurrentHashMap。
(3) JDK1.8引入红黑树大程度优化了HashMap的性能,这主要体现在hash算法不均匀时,即产生的链表非常长,这时把链表转为红黑树可以将复杂度从O(n)降到O(logn)。
HashMap在jdk1.7中采用头插入法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。而在jdk1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但是仍然会有线程安全的问题,会造成数据丢失

4. ConcurrentHashMap工作原理

Hashtable中线程安全性的保证是对每一个方法都加上synchronize锁–十分的暴力。synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,就可以并发进行。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。在JDK1.7中size()方法的思想:用一个变量来记录循环的次数,如果循环的次数小于2,那么比较当前的sum与上一次计算的是否相等,如果相等则直接break,否则继续循环。如果循环的次数大于2(从-1开始),则强制进行for循环,顺序锁定所有的segment。
1)ConcurrentHashMap的结构
ConcurrentHashMap和Hashtable主要区别就是围绕着锁的粒度以及如何锁,可以简单理解成把一个大的HashTable分解成多个,形成了锁分离。如图:
在这里插入图片描述
ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点)。在整张Hash表中有多个segment,每一个segment是一个特殊的Hashtable。HashEntry代表每个hash链中的一个节点,其结构如下所示:
在这里插入图片描述
可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。定位到segment的方法:
在这里插入图片描述
2)ConcurrentHashMap的数据结构
Hash表的一个很重要方面就是如何解决hash冲突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。
在这里插入图片描述
对于每一个segment的数据成员:
在这里插入图片描述
count用来统计该段数据的个数,它是volatile(volatile 变量使用指南),它用来协调修改和读取操作,以保证读取操作能够读取到几乎最新的修改。协调方式是这样的,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操作开始都要读取count的值。这利用了 Java 5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变。threashold用来表示需要进行rehash的界限值。table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得能够读取到最新的 table值而不需要同步。loadFactor表示负载因子。
2)ConcurrentHashMap的功能方法-1.7

删除remove()方法:
在这里插入图片描述
在这里插入图片描述
首先定位到待删除的节点,如果该节点为null则直接返回。否则,对于该节点之前的节点进行克隆(并且顺序是倒序的),该节点后面的节点不用处理。同时修改键值对的数量。

插入put()方法 头插法,需要加分段锁,有可能需要扩容(在添加数据之前扩容)
在这里插入图片描述
方法也是在持有段锁(锁定整个segment)的情况下执行的,这当然是为了并发的安全,修改数据是不能并发进行的,必须得有个判断是否超限的语句以确保容量不足时能够rehash。接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2^n。而比较难懂的是这句int index = hash & (tab.length - 1),原来segment里面才是真正的hashtable,即每个segment是一个传
统意义上的hashtable,如上图,从两者的结构就可以看出区别,这里就是找出需要的entry在table的哪一个位置,之后得到的entry就是这个链的第一个节点,如果e!=null,说明找到了,这是就要替换节点的值(onlyIfAbsent == false),否则,我们需要new一个entry,它的后继是first,而让tab[index]指向它,什么意思呢?实际上就是将这个新entry插入到链头(头插),剩下的就非常容易理解了

获取get()方法
在这里插入图片描述
get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count 变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。接下来就是根据hash和key对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在 table数组中的值使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为 put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为 HashEntry中的value不是final的,非同步读取有可能读取到空值。仔细看下put操作的语句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在这条语句中,HashEntry
构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。这里当v为空时,可能是一个线程正在改变节点,而之前的get操作都未进行锁定,根据bernstein条件,读后写或写后读都会引起数据的不一致,所以这里要对这个e重新上锁再读一遍,以保证得到的是正确值。

Size() 方法:
先采用不加锁的方式,连续计算元素的个数,最多计算3次:
1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;

  • JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与Segment 数量相等
  • JDK 1.8 使用了 CAS(Compare And Swap比较和交换)操作来支持更高的并发度,在 CAS操作失败时使用内置锁 synchronized。
    • 对链表的头结点用synchronize加锁,而不采用分段锁的方式,进一步减少线程的冲突
    • CAS主要包含三个操作数,内存位置V,进行比较的原值A,和新值B。当位置V的值与A相等时,CAS才会通过原子方式用新值B来更新V,否则不会进行任何操作。无论位置V的值是否等于A,都将返回V原有的值。非阻塞机制
  • 并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。

5. LinkedHashMap工作原理

LinkedHashMap是Map接口的哈希表和链表实现,具有可预知的迭代顺序。LinkedHashMap实现与HashMap的不同之处在于,后者维护着一个运行于所有条目的
双向链表,该链表定义了迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序LinkedHashMap并不是线程安全的。

1)Entry元素
LinkedHashMap采用的hash算法和HashMap相同,但是它重新定义了数组中保存的元素Entry,该Entry除了保存当前对象的引用外,还保存了其上一个元素before和下一个元素after的引用,从而在哈希表的基础上又构成了双向链表。
在这里插入图片描述
2)构造函数
在这里插入图片描述

3)put方法
LinkedHashMap并未重写父类HashMap的put方法,而是重写了父类HashMap的put方法调用的子方法void addEntry(int hash, K key, V value, int bucketIndex) 和void createEntry(int hash, K key, V value, int bucketIndex),提供了自己特有的双向链接列表的实现。
在这里插入图片描述
在这里插入图片描述
该方法默认返回false,如果要返回true需要继承LinkedHashMap并重写该方法–实现LRU缓存。 为true时具有淘汰机制.

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @ClassNmae LRUCache
 * @Author lsq
 * @Description TODO
 * @CreateTime 2019-11-27 16:35
 * @Version 1.0
 **/
public class LRUCache<K, V> extends LinkedHashMap<K, V> {

    private final int CACHE_SIZE;

    public LRUCache(int cacheSize) {
        super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
        CACHE_SIZE = cacheSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > CACHE_SIZE;
    }

    public static void main(String[] args) {
        LRUCache lruCache = new LRUCache(3);
        lruCache.put("1", "a");
        lruCache.put("2", "a");
        lruCache.put("3", "a");
    }
}

4)get方法
LinkedHashMap重写了父类HashMap的get方法,实际在调用父类getEntry()方法取得查找的元素后,再判断当排序模式accessOrder为true时,记录访问顺序,将最新访问的元素添加到双向链表的表头,并从原来的位置删除。由于的链表的增加、删除操作是常量级的,故并不会带来性能的损失。
在这里插入图片描述
SynchronizedMap:
在Collections类中提供了一个方法返回一个 同步版本的HashMap用于多线程的环境
SynchronizedMap类是定义在Collections中的一个静态内部类。它实现了Map接口,并对其中的每一个方法实现,通过synchronized关键字进行了同步控制。
在这里插入图片描述
都是使用同步代码块实现的线程安全,使用的加锁对象是一个Object对象,可以手动赋值锁对象,也可以默认使用当前对象
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值