常见Java集合

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是用的双向链表。
  • 要线程安全:
  1. 【不推荐】用原生的Vector:通过synchronize修饰保证线程安全,性能较差。
  2. 【不推荐】用Collections.synchronizedList(List list)返回一个线程安全的ArrayList:通过synchronize修饰保证线程安全,性能较差。
  3. 【推荐】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小区)之间,小区的居民平时会寄卡片、买衣服、买书本。

我们做一下类比:

  1. 用货架类比hashMap里的数组,3个货架就类比成一个大小是3的数组;
  2. 用小区编号类比key的高位、用居民买的东西类比key的低位。
  3. 我们不知道哪些个小区买哪些类东西多,但我们希望尽量让每个货架堆放东西的数量比较平均,该怎么做呢?

我们有以下3个方案:

  1. 一个小区一个货架(只用高位来判断存储)
  2. 一个品类一个货架(只用低位来判断存储)
  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%,将会引发扩容操作。

  1. 创建一个长度为原长度两倍的新数组。 
  2. 重新计算每个节点在数组中的位置:具体做法见上文计算某个key的位置。

3.5. hashMap的put操作

  1. 如果数组是空的,扩容;
  2. 通过要put的key的hash值计算这个key在数组中的位置;
  3. 如果这个位置是空的,直接插入;
  4. 如果这个位置上的key跟要插入的key相同,用要插入的value直接把当前数组位置的节点的value覆盖;
  5. 如果不同,看这个数组位置上是不是一个红黑树;
  6. 如果是,直接红黑树插入要put的key和value;
  7. 如果不是红黑树,看当前数组位置上的节点是不是大于8了;
  8. 如果是,把它转成红黑树再插,否则直接链表上插入;
  9. 插入完了后,看当前哈希表长度是不是超过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的用途:

  1. 当需要按插入顺序遍历Map时;
  2. 当需要按访问顺序遍历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)。

特点:

  1. 元素是排序的。
  2. 元素不重复。

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)

你可能感兴趣的:(java,数据结构,哈希算法)