一、Map集合
1、HashMap
1、描述
- key 无序,唯一
- HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现。设计目标是尽量实现哈希表O(1)级别的增删改查效果,与HashTable主要区别为**不支持同步和允许null作为key和value**。
- HashMap线程不安全,主要表现在:
- 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
- 多线程扩容时会出现环状结构,造成死循环。
- 多线程使用迭代器时会触发fast-fail机制。
- 1.7和1.8
- 1.7采用头插法,1.8采用尾插法
- 扩容后数据存储位置计算方式不一样
- 1.7异或方法
- 1.8扩容前的位置+扩容大小
- 1.7数组+单链表,1.8数组+链表+红黑树
- 遍历方式
- 遍历HashMap的entrySet键值对集合
- 遍历HashMap键的Set集合获取值;
- 遍历HashMap“值”的集合;
2、底层实现
-
HashMap 底层是 hash 数组和单向链表实现,数组中的每个元素都是链表,由 **Node 内部类(实现 Map.Entry<K,V>接口)**实现,HashMap 通过 put & get 方法存储和获取。
存储对象时,将 K/V 键值传给 put() 方法:①、调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标;②、调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);
③、i.如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;
ii.如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;
iii. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。-
链表转红黑树的阈值为什么是8?
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log(6)=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
-
为什么是红黑树,二叉平衡树不行吗?
-
因为平衡二叉是高度平衡的树, 当平衡被破坏时,需要要 rebalance(再平衡), 开销会比红黑树大.
-
原因:
-
插入引起的不平衡:
插入一个node引起了树的不平衡,平衡二叉树和红黑树都是最多只需要2次旋转操作,即两者都是O(1);
-
删除引起的不平衡:
最坏情况下,平衡二叉树需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级是O(logN)此,而红黑树最多只需3次旋转,只需要O(1)的复杂度
-
-
-
数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
-
负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
-
以6和8来作为平衡点是因为,中间有个差值7可以防止链表和树之间频繁的转换。假设,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。链表:如果元素小于8个,查询成本高,新增成本低,红黑树:如果元素大于8个,查询成本低,新增成本高。
-
怎么减少碰撞
-
使用final修饰的对象、或不可变的对象作为键
-
使用(Integer、String)(是不可变、final的,而且已经重写了equals和hashCode方法)这样的wrapper类作为键是非常好的,(我们可以使用自定义的对象作为键吗?答:当然可以,只要它遵守了equals和hashCode方法定义规则,并且当对象插入到Map中之后将不会再改变。)
-
扰动函数可以减少碰撞。
-
-
HashMap是非线程安全的,那么原因是什么呢?(HashMap的死锁)
- put的时候导致的多线程数据不一致
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。 - resize而引起死循环
这种情况发生在HashMap自动扩容时,当2个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7中,会出现resize前后元素顺序倒置的情况)。接下来再想通过get()获取某一个元素,就会出现死循环。
- put的时候导致的多线程数据不一致
-
JDK1.8中HashMap的插入源码分析**
/** * 将指定参数key和指定参数value插入map中,如果key已经存在,那就替换key对应的value * put(K key, V value)可以分为三个步骤: * 1.通过hash(Object key)方法计算key的哈希值。 * 2.通过putVal(hash(key), key, value, false, true)方法实现功能。 * 3.返回putVal方法返回的结果。 * * @param key 指定key * @param value 指定value * @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null */ public V put(K key, V value) { // 倒数第二个参数false:表示允许旧值替换 // 最后一个参数true:表示HashMap不处于创建模式 return putVal(hash(key), key, value, false, true); } /** * Map.put和其他相关方法的实现需要的方法 * putVal方法可以分为下面的几个步骤: * 1.如果哈希表为空,调用resize()创建一个哈希表。 * 2.如果指定参数hash在表中没有对应的桶,即为没有碰撞,直接将键值对插入到哈希表中即可。 * 3.如果有碰撞,遍历桶,找到key映射的节点 * 3.1桶中的第一个节点就匹配了,将桶中的第一个节点记录起来。 * 3.2如果桶中的第一个节点没有匹配,且桶中结构为红黑树,则调用红黑树对应的方法插入键值对。 * 3.3如果不是红黑树,那么就肯定是链表。遍历链表,如果找到了key映射的节点,就记录这个节点,退出循环。如果没有找到,在链表尾部插入节点。插入后,如果链的长度大于TREEIFY_THRESHOLD这个临界值,则使用treeifyBin方法把链表转为红黑树。 * 4.如果找到了key映射的节点,且节点不为null * 4.1记录节点的vlaue。 * 4.2如果参数onlyIfAbsent为false,或者oldValue为null,替换value,否则不替换。 * 4.3返回记录下来的节点的value。 * 5.如果没有找到key映射的节点(2、3步中讲了,这种情况会插入到hashMap中),插入节点后size会加1,这时要检查size是否大于临界值threshold,如果大于会使用resize方法进行扩容。 * * @param hash 指定参数key的哈希值 * @param key 指定参数key * @param value 指定参数value * @param onlyIfAbsent 如果为true,即使指定参数key在map中已经存在,也不会替换value * @param evict 如果为false,数组table在创建模式中 * @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null。 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K, V>[] tab; Node<K, V> p; int n, i; //如果哈希表为空,调用resize()创建一个哈希表,并用变量n记录哈希表长度 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /** * 如果指定参数hash在表中没有对应的桶,即为没有碰撞 * Hash函数,(n - 1) & hash 计算key将被放置的槽位 * (n - 1) & hash 本质上是hash % n,位运算更快 */ if ((p = tab[i = (n - 1) & hash]) == null) //直接将键值对插入到map中即可 tab[i] = newNode(hash, key, value, null); else {// 桶中已经存在元素 Node<K, V> e; K k; // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 将第一个元素赋值给e,用e来记录 e = p; // 当前桶中无该键值对,且桶是红黑树结构,按照红黑树结构插入 else if (p instanceof TreeNode) e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value); // 当前桶中无该键值对,且桶是链表结构,按照链表结构插入到尾部 else { for (int binCount = 0; ; ++binCount) { // 遍历到链表尾部 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 检查链表长度是否达到阈值,达到将该槽位节点组织形式转为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 链表节点的<key, value>与put操作<key, value>相同时,不做重复操作,跳出循环 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 找到或新建一个key和hashCode与插入元素相等的键值对,进行put操作 if (e != null) { // existing mapping for key // 记录e的value V oldValue = e.value; /** * onlyIfAbsent为false或旧值为null时,允许替换旧值 * 否则无需替换 */ if (!onlyIfAbsent || oldValue == null) e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 更新结构化修改信息 ++modCount; // 键值对数目超过阈值时,进行rehash if (++size > threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; }
-
3、各项默认值
-
初始容量(capacity):16(1<<4),即**2^4**。
-
最大容量:(1<<30),即**2^30**。
-
默认扩容因子(loadFactor):0.75。
-
自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。
-
扩容机制:扩容至**当前HashMap容量*2.0,且每次扩容后的容量必须为2的n次方**。
-
容量必须为2的n次方的原因:
HashMap对元素进行读、写操作时,需要将Map元素的Key的哈希值对数组长度(HashMap 的容量)进行取模运算,结果作为该元素在数组中的索引(index),在计算机中,取模运算的代价远高于位运算的代价,当数组长度为 2^n 时,可以将Map元素的Key的hashcode对 (2^n)-1 进行 与运算(&),效果与对数组长度进行取模运算相等,所以为了提高 HashMap 的操作效率,规定 HashMap 的容量必须为2的n次方,即2^n。h & (length - 1) == h % length。
扩容机制:
-
1.7先新建一个数组,数组长度为原数组的2倍
循环遍历原数组的每一个键值对的,得到键的Hashcode然后与新数组的长度(此处为8)进行&运算得到新数组的位置。然后把键值对放到对应的位置。故扩容之后可能会变成这样
-
1.8只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+原数组长度”。
-
-
如何解决哈希冲突
- 使用散列表
- 使用两次扰动函数
- 引入红黑树
4、线程安全
-
是否线程安全?不安全的场景。
HashMap线程不安全,主要表现在:
- 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
- 多线程扩容时会出现环状结构,造成死循环。
- 多线程使用迭代器时会触发fast-fail机制。
-
如何解决?
- 使用 Collections 的 synchronizedMap() 对其进行包装,使其线程安全。(锁整个表,效率差)
- 直接使用 线程安全的ConcurrentHashMap。(分段锁/CAS,效率相对较高)
2、HashTable
1、描述
- Hashtable 继承于 Dictionary 类(Dictionary类声明了操作键值对的接口方法),实现Map接口(定义键值对接口);
- 与HashMap一样也是哈希表(散列表),存储元素也是键值对。
- Hashtable 大部分情况下是线程安全的。
2、底层实现
继承于 Dictionary 类,实现Map接口,采用 Entry数组+链表 实现。
3、各项默认值
- 初始容量(capacity):11。
- 最大容量:Integer.MAX_VALUE – 8,即**(2^31-1)-8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):0.75。
- 自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。
- 扩容机制:扩容至**当前HashMap容量*2.0+1**。
4、线程安全
Hashtable大部分方法采用 synchronized 修饰,说明Hashtable大部分情况下是线程安全的,但它采用的是 独占锁 机制,并发时会**锁住整个hash表,导致效率十分低下,且在执行一些复合操作时,也会有线程安全隐患。**
复合操作:
- 若不存在则添加。
- 若存在则删除。
- 等。
5、HashTable与 HashMap 的区别
HashMap | HashTable | |
---|---|---|
线程安全 | 不安全 | 大部分情况线程安全 |
允许为null | key和value都允许为null | key和value都不允许为null |
初始容量 | 16 | 11 |
扩容机制 | 扩容至当前容量的2倍 | 扩容至当前容量的2倍+1 |
3、ConcurrentHashMap
1、描述
ConcurrentHashMap 在 JDK1.5 时被加入,是 HashMap 线程安全的版本,其使用方式与 HashMap 一样。
2、底层实现
其他实现与 HashMap 一样,只是添加了线程安全的保障,这里主要讲线程安全的实现:
-
JDK1.7:
- 线程安全采用**分段锁机制来实现,底层数据结构仍然是数组+链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组**,每个Segment下管理一个与HashMap数据结构差不多的table数组,在并发操作加锁时,锁住的是整个Segment,也就是说Segment的个数就是ConcurrentHashMap的最大并发数。
- 默认有16个Segment,即最大并发数为16。,这个值可以通过构造函数改变,但一经创建就不可更改。
- 尽管采用分段锁使锁的粒度小了很多,不再像HashTable那样锁住整个Hash表,但JDK1.7的ConcurrentHashMap的并发量还是受到了Segment个数的影响,为了能提供更大的并发量,JDK1.8的ConcurrentHashMap直接抛弃了分段锁机制,转而采用**CAS算法+synchronized**实现。
-
JDK1.8及以后:
-
为进一步提高并发性,JDK1.8放弃了分段锁机制,将锁的级别控制在了更细粒度的**table元素级别上**,也就是说,只需要锁住一个table元素链表的头节点(head),并不会影响其他的table元素的读写,好处在于锁的粒度更细,影响更小,从而并发效率更好。
-
使用**CAS算法【Compare And Swap】 + synchronized关键字** 来保证**put操作**的线程安全,步骤如下:
- 先判断当前被put进Map的元素的Key在table中所对应的数组元素是否为null,若为null,则**通过CAS操作**将其设置为当前put的元素的value。
- 如果Key对应的数组元素(也即链表表头或者红黑树的根元素)不为null,则对该数组元素使用synchronized关键字申请锁,然后再进行操作。
-
CAS算法原理:
CAS即CompareAndSwap,中文意思是:比较并替换,是由底层硬件提供的一种同步算法,大致原理如下:
-
缺点
-
循环时间长,开销很大
-
如果CAS失败,会一直进行尝试,长时间不成功,可能会给CPU带来很大的开销
-
只能保证一个共享变量的原子操作
-
ABA问题
-
CAS会导致ABA问题,CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。
尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。
- 解决:AtomicStampedReference
-
-
-
CAS需要有3个操作数:
- 内存地址V:即将要修改的主存变量所在的地址。
- 旧的预期值A:即将要修改的主存变量所在的地址的值。
- 计算后得到的新值B,即将要更新的目标值。
-
CAS改值操作步骤:
- 读取到主存变量的内存地址(V);
- 得到内存地址V的值(A)【旧的预期值】;
- 通过计算得到新的值(B);
- 在将要更新V的值之前,先将A与V的值进行比较(Compare);
- 当且仅当A==V的值时,才会将V的值改为B,否则什么都不做。
- CAS核心:当且仅当内存地址V的值与预期值A相等时,才将内存地址V的值修改为B,否则就什么都不做。整个CAS(比较并替换)操作是一个原子操作。
-
-
JDK1.8是基于CAS算法的轮询访问改值方式实现同步(即一直循环CAS算法去尝试改值,直到修改成功为止),虽然对CUP的消耗会大些,但如此实现的**线程同步是非阻塞式**的,并发量将得到很大提高。
-
3、各项默认值
- 默认最大并发数:16,即为 Segment 的个数。(jdk1.7)
- 其他默认值与HashMap一致。
4、Treemap
底层红黑树
AVL 树具有以下特点:
- 每个结点的平衡因子只可能是 -1、0、1(如果绝对值超过 1,则认为是失衡)
- 每个结点的左右子树高度差不超过 1
- 搜索、插入、删除的时间复杂度是 O(logn)
红黑树是一种含有红黑结点并能自平衡的二叉搜索树。(旋转变色 保持平衡)
以下特点:
- 每个结点是要么是红色或黑色
- 根结点必须是黑色
- 叶结点(外部结点、空结点)是黑色
- 红色结点不能连续(也就是,红色结点的孩子和父亲都是黑色)
- 对于每个结点,从该点至 nil(树尾端,Java 中为 null 的结点)的任何路径都包含所相同个数的黑色结点
二、List集合
1、ArrayList
1、描述
- ArrayList是List接口可调整大小的数组实现。实现所有可选的List操作,并允许所有元素,包括null,元素可重复。
- ArrayList是线程不安全的,体现在并发修改时会触发**快速失败(fail-fast)**机制,。
2、底层实现
可调整大小的数组实现。
3、各项默认值
- 初始容量(capacity):10。
- 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):1。
- 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
- ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。
- 把新元素添加到扩容以后的数组中
- 扩容机制:扩容至**当前ArrayList容量*1.5**。
4、线程安全
- 故障现象:java.util.ConcurrentModificationException
- 导致原因:add方法为了保证并发性,没有加锁,一个线程正在写,另一线程过来抢夺,导致数据不一致,即并发修改导致的异常
- 解决方案:
- new Vector()
- Vector的add方法有synchronized锁,可以保证线程安全,但是性能降低
- Collections.synchronizedList()
- new CopyOnWriteArrayList<>()
- 写时复制,读写分离
- 往一个容器添加元素时候,不直接往当前容器Object[]添加,而是先将容器进行Copy,复制出一个新的容器,然后往新的容器中添加元素,添加完成以后,将原容器的引用指向新的容器,这样可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
- new Vector()
2、Vector
1、描述
- 与ArrayList一样,底层是数组结构。但因它的所有方法都用**synchronized**修饰,所以Vector是线程安全的。
- 因为线程安全,所以它的效率比ArraList低。
2、底层实现
数组。
3、各项默认值
- 初始容量(capacity):10。
- 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
- 默认扩容因子(loadFactor):1。
- 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
- 扩容机制:扩容至**当前ArrayList容量*2**。
4、线程安全
Vector是线程安全的。
3、LinkedList
1、描述
- LinkedList是List和Deque接口的**双向链表**的实现。实现了所有可选List操作,并允许包括null值。
- LinkedList是线程不安全的。
2、底层实现
双向链表。
3、各项默认值
无,因为是双向链表。
4、线程安全
LinkedList是线程不安全的,需要用 Collections.synchronizedList() 对其进行包装。
4、ArrayList、LinkedList、Vector的区别
ArrayList | LinkedList | Vector | |
---|---|---|---|
底层实现 | 数组 | 双向链表 | 数组 |
性能 | 索引查询快,添加、删除慢(主要是扩容) | 添加、删除快,索引查询慢 | 比ArrayList慢 |
扩容 | 动态扩容 | 双向链表,不用扩容 | 动态扩容 |
线程安全 | 不安全 | 不安全 | 安全 |
三、Set集合
1、HashSet
1、描述
- HashSet是Set接口的实现,元素无序、不可重复,底层是一个HashMap,用以保存数据,value是一个叫PRESENT Object类型的常量,即HashSet只关心key。
- 不能保证元素的排列顺序,顺序有可能发生变化。
- 线程不安全。
- 集合元素可以是null,但只存在一个null。
2、底层实现
HashMap。
3、各项默认值
与 HashMap 一致。
4、线程安全
HashSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
2、LinkedSet
1、描述
- LinkedSet是Set接口的实现,继承于HashSet,元素不可重复,底层是一个LinkedMap,用以保存数据。
- LinkedSet**可保证元素的插入顺序。**
2、底层实现
LinkedMap
3、各项默认值
与 HashMap 一致。
4、线程安全
LinkedSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
5、与HashSet的区别
LinkedHashSet在迭代访问Set中的全部元素时,性能比HashSet好,但是插入时性能稍微逊色于HashSet。即**顺序访问性能好,插入、删除性能差。**
3、TreeSet
1、描述
- TreeSet是SortedSet接口的唯一实现类,元素不可重复。底层是一个TreeMap,用以保存数据。
- TreeSet**默认可保证元素的自然顺序(元素需要实现Comparable接口),可指定排序规则(需要重写元素的hashCode()方法和equals()方法)**。
2、底层实现
TreeMap
3、各项默认值
与 HashMap 一致。
4、线程安全
TreeSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。
4、HashSet、LinkedSet、TreeSet的区别
HashSet | LinkedSet | TreeSet | |
---|---|---|---|
顺序 | 无序 | 保证插入顺序 | 默认保证自然顺序 |
底层实现 | HashMap | LinkedMap | TreeMap |
线程安全 | 不安全 | 不安全 | 不安全 |
四、Java8新特性
1、速度更快
更改了底层数据结构,如HashMap、HashSet。
2、代码更少
新增语法:lambda表达式。
3、有强大的Stream API
Stream API
4、便于并行
优化 Fork/Join 框架。
5、最大化减少空指针异常
新增 Optional 容器类。
6、总结
最主要的核心还是 Lambda 表达式和 Stream API