1. fail-fast和fail-safe机制
fail-fast(快速失败)和fail-safe(安全失败)是两种在遍历集合时处理并发修改的策略。
1.1. fail-fast机制
遍历集合时,如果发现集合被修改(除了通过迭代器自身的remove方法),会立即抛出ConcurrentModificationException异常。这种机制的目的是快速检测到不一致性并报告错误。如,使用ArrayList时,若一线程中正在遍历,而另一线程对其进行了修改,就可能触发fail-fast。HashMap、HashSet也有这个机制。
1.2. fail-safe机制
遍历集合时,会先复制一份集合,然后在复制的集合上遍历。这样,即使原始集合在遍历过程中被修改,也不会影响正在进行的遍历,不会抛异常。CopyOnWriteArrayList就是采用了fail-safe机制。
1.3. 总的区别
在多线程环境中,多个线程可能同时操作一个集合。
- 使用fail-fast的集合(一般是非并发的集合类),可能会因为并发修改导致遍历出错;
- 使用fail-safe的集合(一般是并发的集合类),能保证遍历的顺利进行,但由于要复制数据,会有一定的性能开销。
总的来说:
- 非并发的集合类被并发使用时,fail-fast快速抛出异常,fast fail。
- 并发的集合类被并发使用时,fail-safe因为遍历前复制了,所以safe。
2. ArrayList和LinkedList
数组和链表的读快写快区别就不说了。
- 都是线程不安全的。
- LinkedList是用的双向链表。
- 要线程安全:
- 【不推荐】用原生的Vector:通过synchronize修饰保证线程安全,性能较差。
- 【不推荐】用Collections.synchronizedList(List list)返回一个线程安全的ArrayList:通过synchronize修饰保证线程安全,性能较差。
- 【推荐】CopyOnWriteArrayList:写时复制,写时加锁;读的时候不加锁。性能相对高一些。
3. hashMap
3.1. 数据结构
数组+链表+红黑树,通过散列映射来存储键值对数据。
HashMap的key、value都可以为null。
3.2. hash操作
放到hashMap里的key,已经有hashCode了,为啥还要被hashMap自己的hash方法再hash一把呢?
答:是为了让高位和低位都参与hash,把hashCode匀一下,哈得更稀一些。
为啥高位和低位都一起hash一下,可以和得更均匀呢?
举个例子:
想象一个拥有3个货架(编号1至3)的菜鸟驿站,它坐落在3个小区(分别叫a、b、c小区)之间,小区的居民平时会寄卡片、买衣服、买书本。
我们做一下类比:
- 用货架类比hashMap里的数组,3个货架就类比成一个大小是3的数组;
- 用小区编号类比key的高位、用居民买的东西类比key的低位。
- 我们不知道哪些个小区买哪些类东西多,但我们希望尽量让每个货架堆放东西的数量比较平均,该怎么做呢?
我们有以下3个方案:
- 一个小区一个货架(只用高位来判断存储)
- 一个品类一个货架(只用低位来判断存储)
- 交叉放,但要有个关系映射,比如:1货架放a小区的卡片、b小区的衣服、c小区的书本;2货架放b小区的卡片、c小区的衣服、d小区的书本;3货架放c小区的卡片、a小区的一方、b小区的书本
对方案1来说,如果某个小区很少买东西,它的货架基本就空的;对方案2来说,如果大家很少买某个品类的东西,这个品类对应的货架也是空的;对方案3来说,以上两个问题都不存在,这就是高低位都混到一起搅一下后的好处。
3.3. 计算某个key的位置
3.3.1. 怎么计算的?
- 1.7采用hash对数组大小取模:int newIndex =(hash&0x7FFFFFFF)% newCapacity;
其中hash位与0x7FFFFFFF只是为了让hash变成正数
- 1.8采用hash跟数组大小-1位与:int newIndex =(newCapacity-1) & hash;
数组大小-1后,一定变成全低位全是1的二进制形式,如00001111、00111111、00000011这样。为啥一定会变成这样?因为hashMap的数组大小始终是2的n次幂形式(可以看resize的代码,确保了这一点,即使初始化hashMap为非2次幂的形式,也会被它强制干成2次幂形式),所以数组大小-1后,就成了低位全是1了。
比如数组大小是16(2的4次方,二进制形式是00010000),它减去1后就变成了15(二进制是00001111)。
3.3.2. 为啥要这样改变?
位与的性能比取模更高,而且一般来说,二进制位上的位与,其随机性比取模的要搞,所以位置冲突会少一些。
3.4. 扩容情况
默认的负载因子是0.75,如果数组中已经存储的元素个数大于数组长度的75%,将会引发扩容操作。
- 创建一个长度为原长度两倍的新数组。
- 重新计算每个节点在数组中的位置:具体做法见上文计算某个key的位置。
3.5. hashMap的put操作
- 如果数组是空的,扩容;
- 通过要put的key的hash值计算这个key在数组中的位置;
- 如果这个位置是空的,直接插入;
- 如果这个位置上的key跟要插入的key相同,用要插入的value直接把当前数组位置的节点的value覆盖;
- 如果不同,看这个数组位置上是不是一个红黑树;
- 如果是,直接红黑树插入要put的key和value;
- 如果不是红黑树,看当前数组位置上的节点是不是大于8了;
- 如果是,把它转成红黑树再插,否则直接链表上插入;
- 插入完了后,看当前哈希表长度是不是超过threshold,是的话就扩容。
简单来说:
找到位置,太长就转红黑树,插入,插完了看是不是太长,太长就扩容。
4. ConcurrentHashMap
4.1. 在1.7和1.8上的区别
结构上跟hashmap比较像,jdk1.7用了分段锁,一个段管几个table的位置,有多个段。
- jdk 1.7使用分段(Segment)锁,锁在一个个的段(Segment继承的是ReentrantLock)上,相当于一次锁住多个table位置,锁的范围较大;
- jdk 1.8放弃了分段锁,采用类似HashMap的数组+链表/红黑树的结构,同时使用synchronized关键字和CAS来保证线程安全,相当于一次锁在一个table位置上,粒度比1.7更细了。
4.2. JDK1.8为什么使用synchronized而不再用ReentrantLock了?
synchronized在Java后续的版本中进行了大量的性能优化,其性能已经不逊于甚至优于ReentrantLock。
jdk对synchronized做的优化:
- 锁粗化:如果一系列连续的操作都对同一个对象反复加锁和解锁,JVM会将这些锁操作合并为一个范围更大的锁操作,以减少锁操作的开销。例如,如果在一个循环内部频繁地对同一个对象加锁解锁,可能会被优化为在循环外部加一次锁,循环结束后再解锁。
- 锁消除:JVM在运行时,如果发现某些共享数据不可能存在竞争,那么会消除这些数据的锁操作。比如,一个方法内部的局部变量,不会被其他线程访问到,对其加锁就没有意义,会被优化掉。
- 偏向锁:当一个线程获取到锁时,如果在接下来的一段时间内没有其他线程来竞争这个锁,那么这个锁就会偏向这个线程,后续这个线程再次获取锁时,不需要再进行同步操作。这适用于大多数情况下锁总是被同一个线程获取的场景。
- 轻量级锁:当多个线程竞争锁时,如果锁的竞争不激烈,不会直接升级为重量级锁,而是先使用轻量级锁进行优化。轻量级锁通过CAS操作来尝试获取锁,避免了重量级锁带来的系统调用和上下文切换的开销。
- 适应性自旋:当线程获取锁失败时,不会立即阻塞,而是先自旋等待一段时间,看持有锁的线程是否很快释放锁。如果等待时间短且成功获取锁,就避免了线程阻塞和唤醒的开销。
4.3. put操作
在锁住要插入位置的桶后,操作与HashMap比较相似:都会进行查找是否存在相同键、更新值或插入新节点等基本操作。
但也存在一些不同:ConcurrentHashMap是在多线程环境下操作的,需要时刻注意线程安全,在操作过程中可能会有其他线程同时访问其他桶。在处理冲突节点(如链表或红黑树)的插入、更新等操作时ConcurrentHashMap会有一些同步机制。
在JDK1.8中,ConcurrentHashMap的put操作大致如下:
- 计算键的哈希值确定元素应该放入的桶位置。
- 通过synchronized关键字对该桶的头节点进行加锁。
- 如果该桶为空,直接创建新节点并插入。
- 如果该桶不为空,遍历链表或红黑树,查找是否已经存在相同的键。
- 如果存在相同的键,更新对应的值。
- 如果不存在相同的键,根据情况将新节点插入到链表尾部或插入红黑树。
- 完成操作后,释放锁。
4.4. get操作
在JDK1.8中,ConcurrentHashMap的get操作无需加锁,操作与HashMap比较相似,大致流程如下:
- 计算键的哈希值确定要查找的桶位置。
- 直接遍历该桶对应的链表或红黑树来查找是否存在指定的键。
- 如果找到对应的键,就返回其关联的值。
由于get操作不涉及对数据结构的修改,只是读取操作,所以不需要加锁,而且Node的元素value和指针next都是用volatile修饰的,被其他线程修改是可见的。
table这个数组也用volatile修饰了,目的是保证在数组扩容的时候多线程之间的可见性。
4.5. key、value为啥都不能为null
key、value不能为null的原因类似:在get和contains之间,其他线程可能会过来捣乱,使得我们不知道到底是有个值是null,还是根本没有这个值。
下面用value不能为null详细说明:
如果value可以为null,那get(key)得到null的时候,就不知道(1)是这个key对应的value是null,(2)还是这个key都不存在。在HashMap里,这种情况下为了区分是(1)还是(2),可以用containsKey(key)来判断一下,如果containsKey(key)是true,说明不是(2),否则就是(2)。但在ConcurrentHashMap中,如果在调用containsKey(key)判断之前被别的线程塞了个(key, null)键值对进去,就会得到结果是(1);如果在调用containsKey(key)判断之前“没有”被别的线程塞了个(key, null)键值对进去,就会得到结果是(2)。所以在ConcurrentHashMap中区别不出来是哪种情况。为了避免这个尴尬,就干脆让value不能为null了。
4.6. 弱一致性的ConcurrentHashMap
在遍历ConcurrentHashMap时,它不会把整个集合锁定以保证看到集合的绝对一致的视图。变化发生在已经遍历过的部分,本次遍历不会体现出来。
5. LinkedHashMap
LinkedHashMap是在HashMap的基础上(所以它的kv都可以为null),通过额外维护一个双向链表来保证元素的顺序。
LinkedHashMap支持两种顺序(插入顺序和访问顺序,要哪种顺序,可用LinkedHashMap的构造函数的参数来指定)。
LinkedHashMap的用途:
- 当需要按插入顺序遍历Map时;
- 当需要按访问顺序遍历Map时,例如实现简单的LRU。
LinkedHashMap实现LRU的方法:定义一个LRU类继承LinkedHashMap,重写removeEldestEntry方法,当元素数量超过指定容量时,删除最久未使用的元素。
6. HashTable
线程安全的hash表,它给整个数组加了一把大锁,性能比ConcurrentHashMap就低很多了(1.7分段锁、1.8CAS+synchronized,锁粒度更细)。Hashtable不允许键和值存在null。
7. TreeMap
元素的“key”是有序的,不允许key为空,value可以为空。
底层数据结构是红黑树。
TreeMap是按key排序的,如果key可以为空,当插入多个元素时,如何确定null键在排序中的位置?是放在最前面、最后面,还是其他位置?所以key不能为空。
8. HashSet
可以理解HashSet就是包了HashMap,只用到了HashMap里的key,其value是一个固定的值。
HashSet里的元素是无序的。
1. hashset的add方法中,为什么要在map.put的val上放上一个object类型的静态常量present?
为了知道add进元素前,是不是已经有一个相同的值了。
2. hashset的remove方法中,为什么要在map.remove key完了之后要和present进行一个等值比较呢?
为了知道remove的时候,是不是真的remove掉了东西。
9. TreeSet
可以理解TreeSet就是包了TreeMap,不允许key为空,只用到TreeMap的key,其value是一个固定的值。
TreeSet是SortedSet接口的一个实现类,基于红黑树实现,插入、删除和查找的时间复杂度是O(log n)。
特点:
- 元素是排序的。
- 元素不重复。
10. 总结一下哪些kv能为null
- TreeMap的key不能空,value可为空;
- TreeSet是包了TreeMap且只用它的key,所以TreeSet的元素不能为空;
- HashMap的key、value都可以为空;
- HashTable包了HashMap,但它为了线程安全,key、value都不能为空;
- HashSet包了HashMap且只用它的key,所以HashSet的元素可以为空;
- ConcurrentHashMap的key、value为了线程安全,都不能为空;
- LinkedHashMap包了HashMap,所以它的key、value都可以为空。
速记:
- Tree为了排序所以k不能空
- 为了线程安全的kv都不能空,怕其他线程捣乱(HashTable、ConcurrentHashMap)