Java集合
两个抽象接口派生:一个是Collection接口,存放单一元素;一个是Map接口存放键值对。
Vector为什么是线程安全
简单,因为官方在可能涉及到线程不安全的操作都进行了synchronized操作,就自身源码就给你加了把锁。
Vector的扩容机制
Vector每次扩容两倍大小(capacityIncrement为0时)
相较于ArrayList,Vecotr多了一个构造方法 :
public Vector(int initialCapacity, //向量的初始容量
int capacityIncrement) //向量溢出时容量增加的量
ArrayList 的空间浪费
ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间。
通过ArrayList扩容源码得知,当列表长度超过数组长度,开始调用 grow() 方法扩容,如果成功扩容1.5倍,就会在列表结尾处留出一定空间。
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//再检查新容量是否超出了ArrayList所定义的最大容量,
//若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
//如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
RandomAccess接口
RandomAccess接口代码:
public interface RandomAccess {
}
RandomAccess 接口不过是一个标识罢了,ArrayList实现RandomAccess接口表明它有快速访问功能,只是个标识,并不是说它实现RandomAccess接口才具有随机访问功能的。
ArrayList要添加大量元素应该怎么做 / 怎么扩容
在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。
ArrayList核心代码解读(部分)
以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
/**
*默认无参构造函数
*DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
补:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是 10 的 Object[] 数组 elementData。
ArrayList扩容机制
- 当 add 第 1 个元素时,oldCapacity 为 0,经比较后第一个 if 判断成立,newCapacity = minCapacity(为 10)。但是第二个 if 判断不会成立,即 newCapacity 不比 MAX_ARRAY_SIZE 大,则不会进入 hugeCapacity 方法。数组容量为 10,add 方法中 return true,size 增为 1。
- 当 add 第 11 个元素进入 grow 方法时,newCapacity 为 15,比 minCapacity(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 hugeCapacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。
- 以此类推······
System.arraycopy() 和 Arrays.copyOf()方法
arraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf() 是系统自动在内部新建一个数组,并返回该数组。
元素排序Comparable和Comparator有什么区别?
先从二者的字面含义来理解它,Comparable 翻译为中文是“比较”的意思,而 Comparator 是“比较器”的意思。Comparable 是以 -able 结尾的,表示它自身具备着某种能力,而 Comparator 是以 -or 结尾,表示自身是比较的参与者,这是从字面含义先来理解二者的不同。
Comparable
Comparable 接口只有一个方法 compareTo,实现 Comparable 接口并重写 compareTo 方法就可以实现某个类的排序了,它支持 Collections.sort 和 Arrays.sort 的排序。
Comparator
Comparator 和 Comparable 的排序方法是不同的,Comparable 排序的方法是 compareTo,而 Comparator 排序的方法是 compare。
Comparator 除了可以通过创建自定义比较器外,还可以通过匿名类的方式,更快速、便捷的完成自定义比较器的功能(平常俺习惯在leetcode上刷题其实就是用了comparator)。
Comparable和Comparator使用场景的区别
使用 Comparable 必须要修改原有的类,也就是你要排序那个类,就要在那个中实现 Comparable 接口并重写 compareTo 方法,所以 Comparable 更像是“对内”进行排序的接口。
而 Comparator 的使用则不相同,Comparator 无需修改原有类。通过 Comparator 接口可以实现和原有类的解耦,在不修改原有类的情况下实现排序功能,所以 Comparator 可以看作是“对外”提供排序的接口。
区别总结
Comparable 和 Comparator 都是用来实现元素排序的,它们二者的区别如下:
- Comparable 是“比较”的意思,而 Comparator 是“比较器”的意思。
- Comparable 是通过重写 compareTo 方法实现排序的,而 Comparator 是通过重写 compare 方法实现排序的。
- Comparable 必须由自定义类内部实现排序方法,而 Comparator 是外部定义并实现排序的。
无序性和不可重复性
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和 hashCode() 方法。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
- HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。
Queue的add方法和offer方法的区别
根据因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
抛出异常 | 返回特殊值 |
---|---|
add(E e) | offer(E e) |
remove() | poll() |
element() | peek() |
PriorityQueue
PriorityQueue并没有直接实现 Queue接口,而是通过继承 AbstractQueue 类来实现 Queue 接口的一些方法,在 Java 定义中,PriorityQueue 是一个基于优先级的无界优先队列。
与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。这里列举其相关的一些要点:
- PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据。
- PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
- PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
- PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。
扩容机制
关于HashMap和HashTable初始容量大小和扩充容量大小的区别
- 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
- 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小。
下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap 和 TreeMap 区别
TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
HashSet 如何检查重复?
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
注:在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。
HashMap底层实现
JDK1.8之前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
JDK1.8之后
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
注:链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。注意这里是执行逻辑,并不是直接转换为红黑树!!(其实就算调用treeifyBin函数进行判断)。treeifyBin 方法中判断是否真的转换为红黑树,判断当前数组的长度是否小于 64,如果当前数组的长度小于 64,那么会选择先进行数组扩容,则才将列表转换为红黑树。
HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。
下标计算方法
为了增强散列性,使元素尽可能的分散开来,从而减少碰撞去使用链表/红黑树,HashMap的发明者采用了位运算的方式来实现一个均匀分布且高效率的函数来求下标。
index = HashCode(Key) & (Length - 1)
而只有当HashMap的长度为2的幂时,length-1 正好相当于一个低位掩码,所有二进制位都为1,才能将哈希值的高位全部归零,只保留低位值用来做数组下标访问,且保证范围在length内。
比如下面:
哈希算法以及扰动函数
HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,换句话说使用扰动函数之后可以减少碰撞。
前面我们知道了下标的运算方式,是通过低位掩码与哈希值相与得到哈希值的低位来做下标,这就意味着这个哈希值的计算方式几乎决定了HashMap的效率(散列值、碰撞率、分散情况),所以哈希算法的实现很关键。扰动函数就是为此而引入。
(jdk1.7中首次引入扰动函数,但是共做了四次扰动,1.8做了优化只用了一次)
JDK1.7 HashMap的hash方法源码
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
// 这里是扰动函数
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK1.8 HashMap的hash方法源码
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看出扰动函数就是将key的哈希值h与右移16位后的h进行异或运算。
用一句话来概括扰动函数的作用就是:将h的hashCode右移16位并与自身相异或 相当于 使自己的高16位和低16位 相异或,得到的值既包含了自己高位的特性又包含了自己低位的特性,从而增加了之后得到的下标的不确定性,降低了碰撞的概率。
具体来说,如下:
put函数
- 通过扰动函数计算key的哈希值
- 如果哈希表为空,初始化
- 如果哈希表不为空,计算下标
-
如果table[i]为空,创建新节点存入
-
如果table[i]不为空,根据HashCode和Key值在链表/红黑树中寻找目标位置并更新其value值
1. 如果没有发生碰撞
- JDK1.7将新节点插在当前链表头部的前面然后再下移(头插法)
- JDK1.8- 如果是红黑树,调整红黑树的插入
- 如果是链表,遍历到链表尾并插入新节点(尾插法),遍历的同时计数,当插入新节点后的计数达到阈值,就把链表转化为红黑树
2.如果发生碰撞,通过比较key的地址或者key的值(equals)遍历链表/红黑树获取旧值,覆盖后返回旧值
3.如果HashMap容量达到阈值initialCapacity*loadFactor,则进行扩容
-
我个人对于JDK1.8尾插法的理解:
JDK1.8 新增链表转红黑树的阈值,因此在插入的时候必须知道链表的长度,如果长度超出这个阈值就将其转化为红黑树,因此在插入式必须遍历链表得到链表长度,于是在jdk1.8里插入结点时选择直接插在链表尾部,反正都要遍历一次,这样还保证了在扩容的时候对元素进行transfer时链表的顺序不会像1.7一样倒转,也就不会出现死循环链表。
resize函数
-
JDK1.7:
(size >= threshold) && (null != table[bucketIndex])
存放的键值对数超出阈值,并且新增结点要插入的地方不为空 -
JDK1.8:
++size > threshold
只要存放键值对数超出阈值就扩容
扩容方式
默认扩容16,原数组长度,构建一个原数组长度两倍的新数组,并调用transfer方法将原数组的数组通过重新计算哈希值得到下标再转移到新增数组。
JDK1.7调用indexFor方法重新计算下标,并采用跟插入结点时一致的方式(头插法)挨个移动结点。transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。比如:线程1准备处理节点,线程2把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。
JDK1.8则是根据规律将原链表拆分为两组,分别记录两个头结点,移动时直接移动头结点。这规律是: HashMap扩容后,原来的元素要么在原位置,要么在原位置+原数组长度 那个位置上。
举个例子:
原来的HashMap长度为4,table[2]上存放了A。现在要进行扩容,先创建了一个长度为8的新数组,现在要进行transfer,那么这个A要放到哪里呢?
我们先来根据他原本所在的位置2来倒推,我们知道index = HashCode(Key) & (Length - 1)
,那么就有:
Hash(key) 可能为010,可能为110。
我们用新的长度(8 = (111)2)和这两个数分别再去通过Hash算法来计算新的下标会发现
- 010 & 111 = 010 在原位置
- 110 & 111 = 110 在原位置+4,当前下标+旧数组长度
在转移链表时,结点的转移和插入是一致的,jdk1.7将采用头插法(转移完后链表反转),jdk1.8在分解完链表后直接移动头结点
为什么HashMap是线程不安全的?
主要体现在:
- JDK1.7中,当多线程操作同一map时,在扩容的时候会因链表反转发生循环链表或丢失数据的情况。
- JDK1.8中,当多线程操作同一map时,会发生数据覆盖的情况:
在put的时候,由于put的动作不是原子性的(没有使用Synchronized),线程A在计算好链表位置后,挂起,线程B正常执行put操作,之后线程A恢复,会直接替换掉线程B所put的值,所以依然不是线程安全的。
为什么会遇到ConcurrentModificationException异常?
首先要知道,HashMap中有个属性modCount,用于记录当前map的修改次数,在对map进行put、remove、clear等操作时都会增加modCount。
他的作用体现在对map进行遍历的时候,我们知道HashMap不是线程安全的,当对其进行遍历的时候,会先把modCount赋给迭代器内部的expectedModCount属性。当我们对map进行迭代时,他会时时刻刻比较expectedModCount和modCount是否相等,如果不相等,则说明有其他的线程对同一map进行了修改操作,于是迭代器抛出ConcurrentModificationException异常,告诉程序员有人修改过了。。
这也就是Fail-Fast机制。
什么是Fail-Fast机制?
在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一个最高级别处理错误。
例子见上一条。
场景:java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。
什么是Fail-Safe机制
使用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,再拷贝的集合上进行遍历。由于迭代时候是对原集合的拷贝进行遍历,所以在遍历的过程中对原集合所作的修改不能被迭代器所检测到,不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
如何避免快速失败?
- 在单线程的遍历过程中,如果要进行 remove 操作,可以调用迭代器 ListIterator 的 remove 方法而不是集合类的 remove方法。看看 ArrayList 中迭代器的 remove 方法的源码,该方法不能指定元素删除,只能 remove 当前遍历元素。
- 使用并发包(java.util.concurrent)中的类来代替 ArrayList 和 hashMap
- CopyOnWriterArrayList代替ArrayList
- ConcurrentHashMap代替HashMap
HashMap为什么要引入红黑树?
将查找的时间复杂度从o(n)提升到o(logn)。在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),加快检索速率。
HashMap为什么树化标准是8个?
概率统计。源码注释里也写了,如果 hashCode的分布离散良好的话,那么红黑树是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,注释中给我们展示了1-8长度的具体命中概率,当长度为8的时候,概率概率仅为0.00000006,趋近于零,因此这是根据概率统计决定的。
HashMap为什么退化为链表的阈值是6?
下面纯属个人理解。。。
当链表长度达到阈值8的时候会转为红黑树,但是红黑树退化为链表的阈值却是6,为什么不是小于8就退化呢?比如说7的时候就退化,偏偏要小于或等于6?
主要是一个过渡,避免链表和红黑树之间频繁的转换。如果阈值是7的话,删除一个元素红黑树就必须退化为链表,增加一个元素就必须树化,来回不断的转换结构无疑会降低性能,所以阈值才不设置的那么临界。
(其实感觉设计成5也可以,感觉更多就算这样设计的。。。刁难人 )
HashMap 有哪几种常见的遍历方式?
HashMap 遍历从大的方向来说,可分为以下 4 类:
- 迭代器方式遍历
- For Each方式遍历
- Lambda表达式遍历(JDK1.8+)
- Streams API遍历(JDK1.8+)
每种类型又有不同实现方式,具体的遍历方式可分为7种:
- 使用迭代器(Iterator)EntrySet的方式进行遍历
- 使用迭代器(Iterator)KeySet的方式进行遍历
- 使用For Each EntrySet方式遍历
- 使用For Each KeySet方式遍历
- 使用Lambda方式遍历
- 使用Streams API单线程方式遍历
- 使用Streams API多线程的方式遍历
性能分析:EntrySet 之所以比 KeySet 的性能高是因为,KeySet 在循环时使用了 map.get(key),而 map.get(key) 相当于又遍历了一遍 Map 集合去查询 key 所对应的值。注意在使用迭代器或者 for 循环时,其实已经遍历了一遍 Map 集合了,因此再使用 map.get(key) 查询时,相当于遍历了两遍。
而 EntrySet 只遍历了一遍 Map 集合,之后通过代码“Entry<Integer, String> entry = iterator.next()”把对象的 key 和 value 值都放入到了 Entry 对象中,因此再获取 key 和 value 值时就无需再遍历 Map 集合,只需要从 Entry 对象中取值就可以了。
所以,EntrySet 的性能比 KeySet 的性能高出了一倍,因为 KeySet 相当于循环了两遍 Map 集合,而 EntrySet 只循环了一遍。
安全性:我们不能在遍历中使用集合 map.remove() 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 iterator.remove() 的方法来删除数据,这是安全的删除集合的方式。同样的我们也可以使用 Lambda 中的 removeIf 来提前删除数据,或者是使用 Stream 中的 filter 过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 for 循环前删除数据在遍历也是线程安全的。
总结: hashMap作为一个key-value存储的数据结构,我们知道可以使用key去获取到value,所以map提供了两种方式:1. 获取key,map.keySet();可以获取到对应的key的集合,我们遍历key就获取value;2. 获取entrySet,entry是包含key、value,这样也就获取到了对应的key与value。对于这两种我们可以用迭代器进行遍历或者用For Each进行遍历。在JDK1.8+,我们还可以用Lambda语句遍历和Streams API进行遍历。
综合性能和安全性来看,我们应该尽量使用迭代器(Iterator)来遍历 EntrySet 的遍历方式来操作 Map 集合,这样就会既安全又高效了。
都是线程安全的,ConcurrentHashMap和Hashtable有什么区别?
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构:JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
- 实现线程安全的方式:
- JDK1.7时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
- JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
- Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
为什么ConcurrentHashMap是线程安全的?
JDK1.7底层实现
ConcurrentHashMap 在不同的 JDK 版本中实现是不同的,在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。大数组 Segment 可以理解为 MySQL 中的数据库,而每个数据库(Segment)中又有很多张表 HashEntry,每个 HashEntry 中又有多条数据,这些数据是用链表连接的,如下图所示:
Segment 本身是基于 ReentrantLock 实现的加锁和释放锁的操作,这样就能保证多个线程同时访问 ConcurrentHashMap 时,同一时间只有一个线程能操作相应的节点,这样就保证了 ConcurrentHashMap 的线程安全了。
也就是说 ConcurrentHashMap 的线程安全是建立在 Segment 加锁的基础上的,所以我们把它称之为分段锁或片段锁,如下图所示:
JDK1.8底层实现
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:
在 JDK 1.8 中 ConcurrentHashMap 使用的是 CAS + volatile 或 synchronized 的方式来保证线程安全的。
在 JDK 1.8 中,添加元素时首先会判断容器是否为空,如果为空则使用 volatile 加 CAS 来初始化。如果容器不为空则根据存储的元素计算该位置是否为空,如果为空则利用 CAS 设置该节点;如果不为空则使用 synchronize 加锁,遍历桶中的数据,替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
我们把上述流程简化一下,我们可以简单的认为在 JDK 1.8 中,ConcurrentHashMap 是在头节点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度,具体加锁示意图如下:
关于TreeBin
TreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
//信号量
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
...
}
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式 :JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。
- Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度 :JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。