HashMap、HashTable、ConcurrentHashMap的底层原理

1 HashMap的原理

在这里插入图片描述
HashMap是基于链地址法实现的一个散列表,jdk7使用数组、链表,jdk8使用数组、链表和红黑树。以下将HashMap的原理分为四部分讲解。

  • 初始容量。数组的初始容量为16。扩充容量每次扩充为2的次方,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模运算。即若当前容量为16,下次扩充时,应变为16*2=32。
    初始容量默认为16:
    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  
    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

每次扩充时扩充为2的次方:如下为扩容函数,newCap = oldCap << 1、newThr = oldThr << 1; // double threshold即新一次的扩容,使用

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
   ...
   }
  • 插入元素。通过key值计算出数组的索引。
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

使用hash()方法计算哈希值,首先使用hashcode计算出一个值,为了进一步提高该值的随机性(减少插入hashmap时的冲突,即提高离散性能),将该值的低16位与高16位进行异或运算,得到新的低16位。(注意,key是null时,哈希值就是0)

上面的代码只是用hashCode的低16位与高16进行了异或运算,可高16位没有变化呀,为什么就能提高离散性能呢?
这里还是要看hashCode转换成数组索引时的取模运算。

在putVal方法中(不仅仅只在putVal中),有这么一行代码

 if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

i = (n - 1) & hash,n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash值。
大多数情况下,内部数组的容量不会很大,基本分布在16(2^ 4)~ 256(2^8)之间。
所以使用(n-1)&hash将hash值转换为数组索引时,只用到了hash值的低n-1(4<=n-1<=7)位(这一部分我们增强了其随机性),而高位其实没有用到。

  • 解决冲突。为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。
    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;//转换为树的契机(阈值),>=8。

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;//转换为非树,即由红黑树转换为链表的契机(阈值),<=6
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
                  if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                       treeifyBin(tab, hash);
                  break;
	}
	
	final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)//<=6时,由红黑树转换为链表
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null) // (else is already treeified)
                        loHead.treeify(tab);//>=7时,由链表转换为红黑树
                }
            }
	}

对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。

  • 扩容。数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
  
    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

2 HashTable

2.1 HashTable实现线程安全使用synchronized

Hashtable 为了实现线程安全,给每个方法都添加了synchronized

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

2.2 HashMap与HashTable的区别

下面来介绍下HashMap与Hashtable的区别:

  • 父类:HashMap继承的类是AbstractMap类,而Hashtable继承的是Dictionary类,而Dictionary是一个过时的类,因此通常情况下建议使用HashMap而不是使用Hashtable
  • 内部结构:其实HashMap与Hashtable内部基本都是使用数组-链表的结构,但是HashMap引入了红黑树的实现,内部相对来说更加复杂而性能相对来说应该更好
  • null:通过前面的介绍我们知道Hashtable是不允许key-value为null值的,Hashtable对于key-value为空的情况下将抛出NullPointerException,而HashMap则是允许key-value为null的,HashMap会将key=null方法index=0的位置。
     public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }
      ...
      }
  • 线程安全:通过阅读源码可以发现Hashtable的方法中基本上都是有synchronized关键字修饰的,但是HashMap是线程不安全的,故对于单线程的情况下来说HashMap的性能更优于Hashtable,单线程场景下建议使用HashMap。

3 ConcurrentHashMap

ConcurrentHashMap为了实现并发,Java7之前采用分段锁机制,Java8开始采用了CAS算法无锁算法。

3.1 Java7中的分段锁

参考文献:Java并发编程笔记之ConcurrentHashMap原理探究
ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment (锁段)的结构。一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,我们用下面这一幅图来看下 ConcurrentHashMap 的内部结构。
.
ConcurrentHashMap内部有两层数组结构+一个链表
从下面的结构可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在链表的头部
.
优点:写操作时可以只对元素所在的 Segment 加锁,不会影响到其他的 Segment。最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上)。通过这种结构,ConcurrentHashMap 的并发能力可以大大的提高。
缺点:这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长。
.
我们用下面这一幅图来看下ConcurrentHashMap的内部结构详情图,如下:
在这里插入图片描述
源码:
Segment 继承于 ReentrantLock。

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count;    //Segment中元素的数量
    transient int modCount;          //对table的大小造成影响的操作的数量(比如put或者remove操作)
    transient int threshold;        //阈值,Segment里面元素的数量超过这个值那么就会对Segment进行扩容
    final float loadFactor;         //负载因子,用于确定threshold
    transient volatile HashEntry<K,V>[] table;    //链表数组,数组中的每一个元素代表了一个链表的头部
}

后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。

3.2 Java8中的CAS算法

参考文献:从concurrentHashMap看CAS的基础原理

CAS的全称是Compare And Swap 即比较交换,其算法核心思想如下

执行函数:CAS(V,E,N)
包含3个参数

V表示要更新的变量
E表示预期值
N表示新值

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。
通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。
原理图如下:
在这里插入图片描述

由于CAS操作属于乐观派,它总认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作,这点从图中也可以看出来。基于这样的原理,CAS操作即使没有锁,同样知道其他线程对共享资源操作影响,并执行相应的处理措施。同时从这点也可以看出,由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说无锁操作天生免疫死锁。

HashTable与ConcurrentHashMap比较
HashTable安全性更高,ConCurrentHashMap并发性能更高。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张之海

若有帮助,客官打赏一分吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值