文章目录
- 2.2.1 Arraylist 与 LinkedList 异同
- 2.2.2 ArrayList 与 Vector 区别
- 2.2.3 HashMap的底层实现
- 2.2.4 HashMap 和 Hashtable 的异同
- 2.2.5 HashMap 的长度为什么是2的幂次方
- 2.2.6 HashMap 多线程操作导致死循环问题
- 2.2.7 HashSet 和 HashMap 区别
- 2.2.8 ConcurrentHashMap 和 Hashtable 的区别
- 2.2.9 ConcurrentHashMap线程安全的具体实现方式/底层具体实现
- 2.2.10 集合框架底层数据结构总结
- 2.2.11 ArrayList 的扩容机制
本文主要源自 JavaGuide 地址:https://github.com/Snailclimb/JavaGuide 作者:SnailClimb
仅供个人复习使用
2.2.1 Arraylist 与 LinkedList 异同
- 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- 底层数据结构: Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向链表数据结构(JDK1.6之前为双向循环链表,JDK1.7取消了循环。注意双向链表和双向循环链表的区别:); 详细可阅读JDK1.7-LinkedList循环链表优化
- 时间复杂度分析:
- ArrayList 复杂度:
add(E e)
如果需要不扩容则默认添加到表尾,所以为o(1);add(int index, E element)
为o(n);remove(int index)
为o(n);get(int index)
为o(1); - LinkedList 复杂度:
add(E e)
默认添加到表尾,所以为o(1);add(int index, E element)
为o(n);remove(int index)
为o(n);get(int index)
为o(n);
- 是否支持快速随机访问: LinkedList 不支持随机元素访问,而 ArrayList 支持。
- 内存空间占用: ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。当列表很大时, ArrayList更节省空间
补充:RandomAccess接口
ArrayList中实现了RandomAccess接口,而LinkedList却没有实现RandomAccess接口。
查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。
public interface RandomAccess {
}
Collections是集合的一个工具类,我们看一下Collections源码中的二分搜索方法。在binarySearch()方法中,它要判断传入的list 是否是RamdomAccess的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法。
因为实现RandomAccess接口的List,优先选择for循环更高效;未实现RandomAccess接口的List,优先选择iterator更高效。(foreach遍历底层也是通过iterator实现的,大size的数据,千万不要使用普通for循环)
public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
return Collections.indexedBinarySearch(list, key);
else
return Collections.iteratorBinarySearch(list, key);
}
tips:
interface 关键字编译后仍然会产生 .class 文件,因此可以将接口看做是一种只包含了功能声明的特殊类。其他类用implement实现接口后,就相当于变成了该接口的子类。
- 子类 instanceof 父类 == true
- 父类 instanceof 子类 == false
2.2.2 ArrayList 与 Vector 区别
1、Vector类的所有方法都会加同步锁,所以是线程安全的,而ArrayList不是线程安全的。
2、ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍。
单线程时,Vector效率要差很多。而多线程时,虽然ArrayList本身不是线程安全的,但通过Collections.synchronizedList可以将其包装成一个线程安全的List。因此,Vector已经几乎不用了。
public class SynchronizedListTest {
public static void main(String[] args) {
// 创建一个List数组
List<String> lists = new ArrayList<String>();
// 添加元素
lists.add("1");
lists.add("2");
// 创建一个synchronizedList
List<String> synlist = Collections.synchronizedList(lists);
// 迭代集合元素
synchronized (lists) {
//获取迭代器
Iterator<String> iterator = synlist.iterator();
//遍历
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
2.2.3 HashMap的底层实现
JDK1.8 之前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
HashMap的链表散列中存的一个个元素是 Entry<K, V>
键值对。
HashMap进行put元素时,会首先用hashCode()获取 key 的hash值, 然后利用hash()得到扰动后的 hash 值(即int hash = hash(key.hashCode());
),然后通过 hash & (n - 1)
(等价于hash%n)判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
tips:使用 hash 方法也就是扰动函数,是为了防止一些实现比较差的hashCode() 方法。换句话说使用扰动函数之后可以减少碰撞。
JDK 1.8 HashMap 的 hash 方法源码:
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
对比一下 JDK1.7的 HashMap 的 hash 方法源码:
static int hash(int h) {
// 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);
}
JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。而JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。
tips:所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
从JDK1.8开始
相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
补充:红黑树
红黑树的性质:
- 性质1:每个节点要么是黑色,要么是红色。
- 性质2:根节点是黑色。
- 性质3:每个叶子节点(NIL)是黑色。
- 性质4:每个红色结点的两个子结点一定都是黑色。
- 性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。
红黑树相比于平衡二叉树(AVL树)树,牺牲了部分平衡性,以换取删除/插入操作时更少的旋转次数,整体来说,性能优于AVL树。因此,红黑树在很多地方都有应用。比如在 Java 集合框架中,很多部分(HashMap, TreeMap, TreeSet 等)都有红黑树的应用,这些集合均提供了很好的性能。
2.2.4 HashMap 和 Hashtable 的异同
- 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;(如果要保证线程安全的话可以使用 ConcurrentHashMap ,Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。);
- 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 已经基本被淘汰了。
- 对Null key 和Null value的支持: HashMap 中,null 可以作为key,这样的key只能有一个,但可以有一个或多个key所对应的value为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同 : HashTable中的hash数组初始大小是11,增加的方式是 old * 2+1。HashMap中hash数组的默认大小是16,而且大小一定是2的指数倍,增加的方式是old * 2。
- 底层数据结构: HashTable和HashMap底层实现几乎一样,只不过HashTable的方法添加了synchronized关键字确保线程同步检查,效率较低。而且JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
2.2.5 HashMap 的长度为什么是2的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ hash & (n - 1) ”。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是2的幂次方。
这个算法应该如何设计呢?
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
2.2.6 HashMap 多线程操作导致死循环问题
在多线程下,进行 put 操作会导致 HashMap 死循环,原因在于 HashMap 的扩容 resize()方法。由于扩容是新建一
个数组,复制原数据到数组。由于数组下标挂有链表,所以需要复制链表,但是多线程操作有可能导致环形链表。
补充:HashMap的扩容条件
HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。
在HashMap中,threshold = loadFactor * capacity。
loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数(capacity为2也同样)。默认情况下,capacity初始为16,因此当其size大于12(16*0.75)时就会触发扩容。
扩容时会新建一个更大的数组,并通过transfer方法,移动元素。
transfer方法:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//核心,头插法
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
说明: newTable[i] 存的是指针,始终指向链表的头结点,所以采用头插法后,链表的元素变成了原来的逆序。
下面举个例子来说明为什么会发生死循环。假设HashMap初始化大小为4,插入3个节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1。
插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。
假设 线程2 在执行到 Entry<K,V> next = e.next;
时,cpu时间片用完了,这时线程2中的变量e指向节点a,变量next指向节点b。
然后线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,移动节点(变成逆序)。
这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行。
执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系:
变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:
1、执行完 Entry<K,V> next = e.next;
,目前节点a没有next,所以变量next指向null;
2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;
3、newTable[i] = e 把节点a放到了数组i位置;
4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;
所以最终的引用关系是这样的:
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。
另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,还有数据遗失的问题。
注意,HashMap在jdk1.7中采用头插法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。而在jdk1.8中采用的是尾插法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但会因为其他问题产生死循环。
所以要避免在并发环境下使用HashMap,要并发就用ConcurrentHashmap。
2.2.7 HashSet 和 HashMap 区别
如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone() 方法、writeObject()方法、readObject()方法是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。)
HashMap | HashSet |
---|---|
实现了Map接口 | 实现了Set接口 |
存储<K,V>键值对 | 仅使用key存储对象 |
使用put() 方法将元素放入map中 | 使用add() 方法将元素放入set中,当元素值重复时则会立即返回 false,如果成功添加的话会返回 true。 |
较快 | 较慢 |
为什么HashMap比Hashset快?
他们俩都必须使用键(key)来计算hashcode,但要考虑HashMap的键的性质,它通常是一个简单的String甚至是一个数字。
而 String和Integer的计算哈希码的速度 比 整个对象的默认哈希码 计算要快得多
换句话说,如果HashMap的键与存储在HashSet中的键是相同的对象,则性能将没有真正的区别。区别在于HashMap的键是哪种对象。
2.2.8 ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: ConcurrentHashMap在JDK1.7底层采用 分段数组+链表 实现,JDK1.8底层采用 数组+链表/红黑树 。而Hashtable仅采用 数组+链表 的形式。
- 实现线程安全的方式(重要):
- ConcurrentHashMap(分段锁):在JDK1.7的时候, ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 数组+链表/红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
- Hashtable(全表锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
补充: synchronized 和 CAS
- synchronized 是悲观锁,这种线程一旦得到锁,其他需要锁的线程就挂起的情况就是悲观锁。
- CAS 操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
参考博客:Java:CAS(乐观锁)
HashTable:
JDK1.7的ConcurrentHashMap:
JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):
2.2.9 ConcurrentHashMap线程安全的具体实现方式/底层具体实现
JDK1.7(上面有示意图):
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色(记住ReentrantLock和sychronized都是可重入锁)。HashEntry 用于存储键值对数据。
可重入锁:一个线程获得了锁之后仍然可以反复的加锁,不会出现自己阻塞自己的情况。
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
JDK1.8 (上面有示意图):
ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。
synchronized只锁定当前链表或红黑二叉树的头节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
2.2.10 集合框架底层数据结构总结
List
- Arraylist: Object数组
- Vector: Object数组
- LinkedList: 双向链表(JDK1.6之前为循环链表,JDK1.7取消了循环) 详细可阅读JDK1.7-LinkedList循环链表优化
Set
- HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
- LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。
- TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树。)
tips:如果无排序要求可以选用HashSet;如果想取出元素的顺序和放入元素的顺序相同,那么可以选用LinkedHashSet。如果想插入、删除立即排序或者按照一定规则排序可以选用TreeSet。
Map
- HashMap(无序,不唯一): JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
- LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组+链表/红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。通过对链表进行相应的操作,可以实现访问顺序相关逻辑。详细可以查看:LinkedHashMap 源码详细分析(JDK1.8)
- HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
- TreeMap(按key有序,不唯一): 红黑树(自平衡的排序二叉树)
tips:Set类的底层都是通过Map类实现的,如TreeSet 底层是通过 TreeMap 来实现的,HashSet底层是是通过HashMap来实现的
2.2.11 ArrayList 的扩容机制
在JDK1.8中,如果通过无参构造的话,初始数组容量为0,当真正对数组进行添加时(即添加第一个元素时),才真正分配容量,默认分配容量为10;当容量不足时(容量为size,添加第size+1个元素时),先判断按照1.5倍(位运算)的比例扩容能否满足最低容量要求,若能,则以1.5倍扩容,否则以最低容量要求进行扩容。
执行add(E e)方法时,先判断ArrayList是否为空,若为空,则先初始化ArrayList初始容量为10;
若不为空,则直接判断当前容量是否满足最低容量要求;若满足最低容量要求,则直接添加;若不满足,则先扩容,再添加。
ArrayList的最大容量为Integer.MAX_VALUE。