要说HashTable和concurrenthashmap的区别,得单独分析各自得实现方式。
首先他们都是线程安全的,但是都是如何保证线程安全的呢?接下来细细说说。
hashtable 底层绝大多数都是和hashmap一样的。都是维护了一个hash表进行数据的存储。
hashtable中的key值和value都不可以为null。而hashmap都可以为null。
区别:
- 线程是否安全
- 数组长度,扩容规则。
- 索引计算
- 插入数据方法
就来说说他们俩明显的不同。最明显的无非就是线程是否安全,hashmap是线程非安全的,hashtable通过synchronized锁来保证线程安全。

可以看出hashtable在操作数据的方法上都加了synchronized锁,来保证线程安全。
其次,数组容量不同。在hashmap中,数组的容量总会是2的n次方,默认大小为16。即使给构造器初始化一个容量大小,hashmap也会将数组长度变为,离指定容量最近的2的n次方容量,比如指定容量为7,hashmap会将数组容量设为8,比如指定容量为9,hashmap会将数组容量设为16。在hashtable中,默认大小为11。扩容规则是新长度 = 旧长度 * 2 +1。
再就是在计算hash时,hashmap需要二次hash,而hashtable不需要二次hash。因为hashmap数组长度为2的n次方,计算桶下标时,会产生很多hash冲突。所以需要进行二次hash,将第一次hash更加扰乱,为了更好地hash均匀分配。而hashtable不需要二次hash,由于他的长度,大多数是质数,所以,本身不会很大程度上出现hash冲突。
最后他们之间的插入数据的方法,也有所不同。hashmap用的是尾插法。hashtable使用的是头插法。
hashtable就是个遗留类,当不考虑线程安全时,建议使用hashmap,考虑线程安全时,由于hashtable的锁,性能太低。也不考虑用。所以推荐用concurrenthashmap。下面就来说说,concurrenthashmap底层如何进行线程安全的保护。
ConcurrentHashMap(1.7)
ConcurrentHashMap在1.7到1.8的跨度中,变化是很大的,所以我们有必要都了解下。
hashtable采用的是锁住整个hash表,所以性能太低,而1.7的concurrenthashmap采用了分段锁的思想。在concurrenthashmap中维护了一个segment数组,segment继承了ReentrantLock。是concurrenthashmap的一个静态内部类。

在segment类中维护了一个hashentry【】数组,真正的数据则将包装成hashentry放在对应segment中的hashentry【】数组中。
存放元素的hashentry也是一个静态内部类。
这个hashentry就和hashmap中的Node,和hashtable中的entry。十分类似。
唯一的不同的就是在concurrenthashmap中核心数据value和next节点,都用了volatile修饰。保证了数据的可见性。
以上对重要的几个类和变量的梳理,可以总结出。concurrenthashmap中维护的是segment数组。而真正的hash表是由segment进行维护的。在segment数组中的,每一个segment都维护了一个子hash表。所以在并发情况下,多个线程操作不同的segment,这时就不需要考虑锁竞争问题,可以同时访问。因此不像hashtable那样,只要是对数据操作都会进行锁竞争。
上面大概讲述了大概的大体结构。
我们接下来首先看concurrenthashmap的构造方法
ConcurrentHashMap 初始化方法有三个参数,initialCapacity(初始化容量)为 16、loadFactor(负载因子)为 0.75、concurrentLevel(并发等级)为 16,如果不指定则会使用默认值。
来说说这三个参数的作用。initialCapacity结合concurrentlevel,可以推断出每一个segment中hashentry【】数组大小。loadfactor负载因子,当segment中的hash表中的数据大小超过阈值,就会扩容当前segment维护的hash表。concurrentlevel可以计算出segment数组的大小。segment是固定的,一旦初始化完成,就不会再改变。
现在来说说具体是如何计算的
segment大小。不是通过简单的concurrentlevel作为数组大小,而是经过计算,算出距离concurrentlevel最近的2的n次方作为segment长度。
hashentry大小:和计算segment大小一样,不仅仅是initialcapacity / concurrentlevel 的值作为长度。而是算出距离其最近的2的n次方作为hashentry【】数组长度。
这也说明为什么无论是16,还是32。在concurrentLevel为16的情况下,hashentry的大小为2。
接下来细谈put方法

通过源码可以看出这部分的大致操作。分为两部分。
1.定位具体的segment。
2.调用该segment的put方法。
我们这部分解决 是如何进行定位segment的。
首先得到key的hash,这是第一步,毋庸置疑。接下来还要进行二次hash,根据二次hash得到的值就可以进行索引的获取,此时还需要进行看segment的数组长度是2的几次方。假设此时segment长度为16,就是2的四次方。那么索引就取二次hash值得高四位作为segment得下标。
真正put方法如下。
segment中的put操作大致分为两个大部分。
1.当trylock返回true时,也就是成功拿到锁。
2.当trylock返回false时,也就是没有拿到锁。
首先说说当拿到锁的时候。
其实和hashmap的操作大概相同,首先根据key计算下标。这里首先要知道hashentry的长度为2的多少次方。比如此时如果hashentry的长度为2。此时是2的1次方。此时我们取二次hash的低一位作为下标。计算出下标后,和1.7的hashmap一样,通过equals进行判断该值是否有重复,有则覆盖,没有则使用头插法进行插入。最后释放锁。
当没有获得锁的时候,将会走scanAndLockForPut()方法

这个方法,一步一步进行梳理后,也比较好理解。
首先,第一步是获取该key所该对应hashentry【】数组的具体下标中的第一个节点。再赋值给first。再进行反复的进行尝试获取锁,如果失败则根据条件进入不同的if语句中。
首先分析第一个if块。首先第一个if块儿的作用就是,看此时要加入的对象是否已经存在,不存在就创建并赋值给node,并当获取到锁时,直接返回该节点。如果存在,就返回null。
第二个if块。作用就是不让该线程一直尝试重复获取锁,达到阈值时,会调用lock方法进行阻塞。
第三个if块。当其他线程对该下标的hashentry更改时,这里需要进行重新进行判断,并把根节点进行重新赋值。
ConcurrentHashMap(1.8)
concurrenthashmap1.8中底层丢弃了segment这种实现方式,在1.8中,底层使用cas+synchronized实现线程安全。
首先我们对1.8的concurrenthashmap整体结构做一个整理。在1.7中,不难看出,在进行对象创建的时候,segment,hashentry这些已经创建完成了。属于饿汉式。但在1.8中,只有当第一次put时才回去创建hash表。属于懒汉式。
1.8的扩容机制也有些许不同,当元素长度等于阈值就会进行扩容。这里需要重新了解两个参数,initialcapacity和factor。
initialcapacity并不是指定的数组长度,而是指定以后将会存多少数据,concurrenthashmap根据你所指定的数据来判断需要多少的数组长度。比如当initialcapacity为16时,factor为0.75时,此时初始化数组长度为32。当数组长度为16时,阈值为12。16超过了12,所以不行。所以数组长度为32,32的阈值为24,比16大。
factor。负载因子。这个参数也和之前不同。这个因子,只有在初始化数组的时候才生效,当后续进行扩容是,此时设置的factor将不再生效,将会一直使用0.75这个负载因子。
接下来我们就来说说put操作
通过查看源码,大概可以分为几个操作。
1.首先会判断key和value是否为null,如果有则会抛出异常。
2.接着会判断数组为是否为空,不为空就会初始化数组。
3.接着判断,根据hash算出桶位置,查询该桶位置是否有数据,如果为null,就说明这时第一次向该桶位置插入数据,随后调用Unsafe的compareAndSwapObject方法,把该节点进行插入。
4.接着判断f.hash == -1 这个判断是为了知道,此时,此时的头节点为forwardingNode。数组是否处于扩容的情况。如果是,就一起进行扩容操作。
5.最后的情况就是把最新的node,按照链表或者红黑树的方式进行插入,和前面的hashmap差不多。有所不同就是,在此时进行插入操作时,加了一个synchronized锁,锁的对象是头节点f。
6.当插入元素完成后,进行链表长度的判断,如果长度>=8则进入树化方法。
7.最后进行数组扩容判断。
接下来我们看看扩容操作。
当concurrenthashmap进行扩容时,是从后向前进行数据的迁移,这里新数据与旧数据并不同一个对象。如果是同一个对象,在特定的查询情况会出现问题。
首先来说forwardingNode,这个Node节点是当某一个桶数据全部迁移完之后,将在头节点放一个forwardingNode节点,这个节点的hash值为-1。当头节点为forwardingNode时,说名此时正在扩容。
扩容时的put:当在扩容时,有其他线程对其进行put操作时,如果当put操作的是靠前的还没有被迁移,就会进行对应的put,不会受影响。如果当发现该桶的头节点为forwardingNode时,就会帮助进行扩容操作。
扩容时的get:当发现是forwardingNode时,就会跑去新数组进行get,当发现还没被迁移时,就会在旧的数组进行查询,由于get是没有上锁,所以可以并行执行。
区别
经过以上的讲解。这个自行总结了。
开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情





