Java底层原理——HashTable 和 ConcurrentHashMap详解

HashTable底层实现原理

与HashMap十分类似,再put、get、remove等方法上加了同步块,方法的synchronized使用了this锁,把整个对象都锁了,锁粒度大

计算哈希值,0x7FFFFFFF转换为二进制是1个0,31个1,返回一个符号位为0的数,即丢弃最高位,一面函数外产生影响。

int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;

使用volatile关键字

Hashtable的key和value都不允许为null,Hashtable遇到null,直接返回NullPointerException

JDK1.6中ConcurrentHashMap底层实现原理

ConcurrentHashMap是Java5中引用的一个线程安全的支持高并发的HashMap集合类。

CourrentHashMap主要有三大结构:整个hash表,segment,HashEntry(节点)

线程不安全的HashMap

在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU的利用率接近100%,所以在并发情况下不能用HashMap

效率低下的HashTable容器

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率十分低下。因为当一个线程访问HashTable的同步方法时,其他线程访问HashTable的同步方法时,会陷入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素(因为锁住的是this,同一个对象),所以竞争越激烈效率越低。

 

ConcurrentHashMap的锁分段技术

HashTable容器在竞争激烈的并发环境下出现效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁。

ConcurrentHashMap使用锁分段技术,把数据分厂一段一段处处,然后给每一段配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

ConcurrentHashMap的结构

 ConcurrentHashMap内部结构

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segement数组,Segement里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的一个元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁

HashEntry类

static final class HashEntry<K,V> { 
        final K key;                 // 声明 key 为 final 型
        final int hash;              // 声明 hash 值为 final 型 
        volatile V value;           // 声明 value 为 volatile 型
        final HashEntry<K,V> next;  // 声明 next 为 final 型 

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

在定义里可以看到,除了value值没有定义final,其余的都定义为final类型。这就意味着我们删除或者增加一个节点的时候,就必须从头开始建立Hash链,因为next引用值需要改变。

segment类

Segment类继承于ReentrantLock类,从而使得Segment对象能充当锁的角色。每个Segment对象用来守护其(成员对象table中)包含的若干个桶

table是一个由HashEntry对象组成的数组。table数组数组的每一个数组成员就是散列映射表的一个桶。

count变量是一个计数器,它表示每个Segment对象管理的table数组(若干个HashEntry组成的链表)包含的HashEntry对象的个数。每一个Segment对象都有一个count对象来表示本Segment中包含的HashEntry对象的总数。

 ConcurrentHashMap的初始化

ConcurrentHashMap初始化方法是通过initialCapacity,loadFactor,concurrentcyLevel几个参数来初始化segments数组,段偏移量segmentShift,段掩码segmentMask和每个segment里的HashEntry数组

初始化segments数组让我们来看一下初始化segmentShift,segmentMask和segments数组的源代码。

int sshift = 0;
int ssize = 1;
while (ssize < DEFAULT_CONCURRENCY_LEVEL) {//DEFAULT_CONCURRENCY_LEVEL=16
    ++sshift;
    ssize <<= 1;
}
int segmentShift = 32 - sshift;
int segmentMask = ssize - 1;

为了能通过按位与的哈希算法来定位segments数组的索引,必须保证segments数组的长度是2的n次方,所以必须计算出一个大于或等于DEFAULT_CONCURRENCY_LEVEL的2的n次方值作为segments数组的长度。

初始化segmentShift和segmentMask

初始化segmentShift和segmentMask。这两个全局变量在定位segment时的哈希算法里需要使用,sshift等于ssize向左移位的次数,在默认情况下DEFAULT_CONCURRENCY_LEVEL等于16,1需要想做移位4次,所以sshift等于4,segmentShift用于定位参与hash运算的位数,segmentShift等于32-sshift,所以等于28,这里之所以使用32是因为ConcurrentHashMap里的hash()方法输出的最大数是32位的。segmentMask是哈希运算的掩码,等于ssize-1,即15,掩码的二进制各个位的值都是1,因为ssize的最大长度是66536,所以segmentShift最大值是16,segmentMask最大值是65535,对应的二进制是16位,每一位都是1.

初始化每个Segment

输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadFactor是每个segment的负载因子,在构造方法里需要通过这两个参数来初始化数组中的每个segment。

定位Segment

hash >>> segmentShift & segmentMask      //定位Segment所使用的hash算法
int index = hash & (tab.length - 1)      //定位HashEntry所使用的hash算法

java7的时候进行put操作需要rehash(java8优化掉了)

既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过哈希算法定位到Segment。可以看到ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再哈希

get操作(java7中)

Segment的get操作先经过一次再哈希,然后使用这个哈希值通过哈希运算定位到segment,再通过哈希算法定位到元素

//java7中
public V get(Object key) {
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}
final Segment<K,V> segmentFor(int hash) {
    return segments[(hash >>> segmentShift) & segmentMask];
}

get操作的高效之处就在于整个get过程不需要加锁,除非读到的值是空值才会加锁重读,HashTable的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要使用的共享变量都定义为volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成valatile的变量,能够再线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写。

put操作

由于put方法需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里

是否需要扩容

在插入元素前会先判断Segment里的HashEntry数组是否超过容量,如果超过阈值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是插入元素后判断是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容后就没有新元素插入,这时HashMap就进行了一次无效的扩容

如何扩容

扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

size操作

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小然后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,并不是把所有Segment的count相加就可以得到整个ConcurrentHashMap的大小,因为拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,实在统计size的时候把所有Segment的put、remove和clean方法全部锁住,但是这种做法非常低效。

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁柱Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。

那么CountcurrentHashMap是如何判断在统计的时候容器是否发生了什么变化呢?

使用modCount变量,在put,remove和clean方法里操作元素前都会将变量modCount进行+1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

总结

在使用锁来协调多线程并发访问的模式下,减少对所得竞争可以有效提升高并发性,有两种方式可以减小对锁的竞争:

  • 减小请求同一个锁的频率
  • 减少持有锁的时间

ConcurrentHashMap的高并发行主要来自于三个方面

  • 用分离锁实现多个线程间的更深层次的共享访问
  • 用HashEntry对象的不变性来降低执行读操作的线程在遍历链表期间对加锁的需求
  • 通过对同一个volatile变量的写/读访问,协调不同线程间读/写的内存可见性

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值