再进入正文之前,先看看集合相关操作的时间复杂度:
本故事源自于~
开唠:
PART0:
- 为什么突然蹦出集合这个玩意,就是因为咱们基础那里学的“数组”不够用~:
- 数组一般用来保存一组类型相同的数据【声明数组时的数据类型也决定了该数组存储的数据的类型】,但是咱们实际开发过程中存储的数据的类型是多种多样的,这个只有靠单双列集合来实现咯
- 数组存储的数据是有序的、可重复的,特点单一【集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据】
- 无序性和不可重复性指的是:
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和 hashCode() 方法
- 无序性和不可重复性指的是:
- 数组存储的数据是有序的、可重复的,特点单一【集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据】
- 数组的缺点是一旦声明之后,长度就不可变了
- 数组一般用来保存一组类型相同的数据【声明数组时的数据类型也决定了该数组存储的数据的类型】,但是咱们实际开发过程中存储的数据的类型是多种多样的,这个只有靠单双列集合来实现咯
PART1:
- 先热个神,Collection框架中实现比较要怎么做:或者说Comparable 和 Comparator 的区别【
一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法
】第一种,实体类实现Comparable接口,并实现 compareTo(T t) 方法,称为内部比较器
;- Comparable 接口实际上是出自java.lang包,Comparable 接口有一个 compareTo(Object obj)方法用来排序
- 换句话说就是,对于arrayList为[7, -1, 2, -3, 9]
- Collections.sort(arrayList);表示按自然排序的
升序
排序,结果为:[-3, -1, 2, 7, 9] - 咱们可以按照咱们的意思定制一款排序(规则):[9, 7, 2, -1, -3 ]
... // 定制排序的用法 Collections.sort(arrayList, new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o2.compareTo(o1); } }); ...
实现了Comparable接口并重写 compareTo 方法后
就可以实现一些骚操作,比如按年龄、按体重、按身高…来排序,其实我感觉就是利用compareTo(T o)里面的这个o作为一个标杆一个基准,设置一个规则,当你大于o.xxx就返回…,小于o.xxx时就返回…,…... /** * T重写compareTo方法实现按年龄来排序 */ @Override public int compareTo(Person o) { if (this.age > o.getAge()) { return 1; } if (this.age < o.getAge()) { return -1; } return 0; } ...
- 借助Comparator的reversed方法倒序:
- 在Comparator.comparing中定义排序反转:comparing方法还有一个重载方法,java.util.Comparator#comparing(java.util.function.Function<? super T,? extends U>, java.util.Comparator<? super U>),第二个参数就可以传入Comparator.reverseOrder(),可以实现倒序:
- 借助Comparator的reversed方法倒序:
- Collections.sort(arrayList);表示按自然排序的
第二种,创建一个外部比较器,这个外部比较器要实现Comparator接口的 compare(T t1, T t2)方法
- 使用 Lambda 表达式替换Comparator匿名内部类。Java8 的匿名内部类可以简化为 Lambda 表达式:
Collections.sort(students, (Student h1, Student h2) -> h1.getName().compareTo(h2.getName()))
;- 在 Java8 中,List类中增加了sort方法,所以Collections.sort可以直接替换为:
students.sort((Student h1, Student h2) -> h1.getName().compareTo(h2.getName()))
; - 根据 Java8 中 Lambda 的类型推断,我们可以将指定的Student类型简写:
students.sort((h1, h2) -> h1.getName().compareTo(h2.getName()))
;
- 在 Java8 中,List类中增加了sort方法,所以Collections.sort可以直接替换为:
- 如果是通用的对比逻辑,可以直接定义一个实现类
- Comparator接口实际上是出自 java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序
- 在 Java8 中,
Comparator类新增了comparing方法,可以将传递的Function参数作为比较元素
。
- 使用 Lambda 表达式替换Comparator匿名内部类。Java8 的匿名内部类可以简化为 Lambda 表达式:
- 我们在系统开发过程中,对数据排序一般来说,我们可以采用两种方式:
- 借助存储系统(SQL、NoSQL、NewSQL 都支持)的排序功能,查询的结果即是排好序的结果
- 查询结果为无序数据,在内存中实现数据排序
- Java集合类主要由两个根接口Collection和Map派生出来的(或者说主要包括两种类型的容器):
- 一种是单列集合(Collection),存储一个元素集合。Collection派生出了三个子接口:List、Set和Queue三个小类【
Collection是一个接口,Collections是一个工具类
Collections!=Collection】:
- List:有序可重复,可直接根据元素的索引来访问
- ArrayList
- ArrayList 的扩容机制:ArrayList扩容的**
本质就是计算出新的扩容数组的size后实例化
,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍
**。 - Arraylist 和 Vector 的区别?
- ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全
Vector 是 List 的古老实现类,底层使用 Object[ ]存储,线程安全的
。
- ArrayList 的扩容机制:ArrayList扩容的**
- LinkedList
- Arraylist与 LinkedList 异同点:
- 相同点:
- ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全
- 不同点
- 1.ArrayList 是实现了基于数组的【
ArrayList 底层使用的是 Object 数组
】,存储空间是连续的。LinkedList 基于双向链表的,存储空间是不连续的。(LinkedList 是基于双向链表)
- Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构;
- 2.对于
随机访问【根据下标访问】
get 和 set ,ArrayList 优于 LinkedList,因为 LinkedList 要移动指针。
- LinkedList 不支持高效的随机元素访问;ArrayList 实现了RandmoAccess 接口,所以有随机访问功能(快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法))。而没有实现RandomAccess接口【andomAccess 接口中什么都没有定义,说明RandomAccess 接口不过是一个标识罢了,
标识实现这个接口的类具有随机访问功能
】的集合只能通过迭代器的next一个一个获取。
- LinkedList 不支持高效的随机元素访问;ArrayList 实现了RandmoAccess 接口,所以有随机访问功能(快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法))。而没有实现RandomAccess接口【andomAccess 接口中什么都没有定义,说明RandomAccess 接口不过是一个标识罢了,
- 3.对于新增和删除操作 add 和 remove ,要具体情况具体分析。总结说:
ArrayList或者说数组是尾部插入删除快,中间和头部慢;LinkedList是头尾插入删除快,中间慢
- 查询和随机访问
- 随机访问是根据索引随便挑一个索引去访问
- 查询是到集合中找某个元素的内容:查询时ArrayList和LinkedList的性能或者说数组和链表查询的性能是差不多的,都要去查某个元素的内容时,你又能比别人快多少呢?
- 在集合
尾部
插入删除:ArrayList和LinkedList两个差不多
【或者说数组和链表在尾部插入和删除的性能差不多】- 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。说明ArrayList在尾部插入删除性能都很高。
在头部和尾部,LinkedList插入和删除性能都很高。
- 在集合头部插入删除:
在头部和尾部,LinkedList插入和删除性能都很高。
,ArrayList在除了尾部以外的其他部分插入删除性能都很低,肯定包括头部,你得移动复制多少元素到后面呀
- 在除了头部尾部之外的部分也就是
中间部分
,- 由于 ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响,
所以ArrayList中间插入删除性能很低
。可能会移动或者说复制元素,也就是说如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作 - LinkedList 采用链表存储,
所以LinkedList 在中间位置插入,删除元素时间复杂度不受元素位置的影响,性能挺高
,都是近似 O(1)而数组为近似 O(n)。但是你要知道,你要在中间插入删除你至少得到中间呀,你链表访问到中间性能就很慢了,只是你到了中间之后的仅仅插入和删除操作很快而已。
- 由于 ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响,
- 查询和随机访问
- 4.
同样的数据量 LinkedList 所占用空间可能会更小,因为 ArrayList 需要预留空间便于后续数据增加【ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间】,而 LinkedList 增加数据只需要增加一个节点
- ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间;
- 另外,说ArrayList可以利用CPU缓存,局部性原理提升性能,
- LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继指针和直接前驱指针以及数据,所以LinkedList占用内存也很多)。
- LinkedList利用不了局部性原理。
- 5.
LinkedList 不支持高效的随机元素访问,而 ArrayList 支持随机元素访问
。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)
。
- 1.ArrayList 是实现了基于数组的【
- 相同点:
- Arraylist与 LinkedList 异同点:
- Vector
- ArrayList 与 Vector 区别:
- Vector是线程安全的(
Vector在关键性的方法前面都加了synchronized关键字
,来保证线程的安全性。如果有多个线程会访问到集合,那最好是使用 Vector
,因为不需要我们自己再去考虑和编写线程安全的代码。);ArrayList不是线程安全的 - ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间
- Vector是线程安全的(
- Array 和 ArrayList 有什么区别?什么时候该应 Array 而不是 ArrayList 呢:
- Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型
- Array 大小是固定的,ArrayList 的大小是动态变化的
- ArrayList 提供了更多的方法和特性,比如:addAll(),removeAll(),iterator() 等等
- ArrayList 与 LinkedList 区别,在上面
- ArrayList 与 Vector 区别:
- List补充几个小的知识点:
- Arrays.asList()方法,我们把数组转化成集合时,常使用 Arrays.asList(array)。Arrays.asList()会有两个问题【
Arrays.asList 方法返回的 List 并不是 java.util.ArrayList,而是自己内部的一个静态类,该静态类直接持有数组的引用,并且没有实现 add、remove 等方法,这些就是问题 1 和 2 的原因
。】:- 不能对新 List 进行 add、remove 等操作,否则运行时会报 UnsupportedOperationException 错误。
- 修改数组的值,会直接影响原list。
- list.toArray方法
- toArray 的无参方法,无法强转成具体类型,这个编译的时候,就会有提醒,我们一般都会去使用带有参数的 toArray 方法,这时就有一个坑,如果参数数组的大小不够,这时候返回的数组值是空。
- Collections.emptyList()方法。除了emptyList,还有emptySet、emptyMap等也一样。
- 在返回的 Collections.emptyList(); 上调用了add()方法,抛出异常 UnsupportedOperationException。Collections.emptyList() 返回的是不可变的空列表,这个空列表对应的类型是EmptyList,这个类是Collections中的静态内部类,继承了AbstractList。AbstractList中默认的add方法是没有实现的,直接抛出UnsupportedOperationException异常。而EmptyList只是继承了AbstractList,却并没有重写add方法,因此直接调用add方法会抛异常。
- List.subList()方法:list.subList() 产生的集合也会与原始List互相影响。
建议使用时,通过List list = Lists.newArrayList(arrays); 来生成一个新的list,不要再操作原列表
。 - UnmodifiableList是Collections中的内部类,通过调用 Collections.unmodifiableList(List list) 可返回指定集合的不可变集合**。集合只能被读取,不能做任何增删改操作,从而保护不可变集合的安全。但这个不可变仅仅是正向的不可变。反过来如果修改了原来的集合,则这个不可变集合仍会被同步修改。因为不可变集合底层使用的还是原来的List。**。
- Arrays.asList()方法,我们把数组转化成集合时,常使用 Arrays.asList(array)。Arrays.asList()会有两个问题【
- ArrayList
- Set:无序不可重复,只能根据元素本身来访问
如果是需要对我们自定义的对象去重,就需要我们重写 hashCode 和 equals 方法
。不然HashSet调用默认的hashCode方法判断对象的地址,不等就达不到想根据对象的值去重的目的。- HashSet、LinkedHashSet 和 TreeSet
- HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素【除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。】
- 在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。
- HashSet 如何检查重复?
- 当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
- 在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。
- LinkedHashSet: LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
- TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)
- 上面三个的异同点:
- HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,所以肯定都能保证元素唯一或者说无重复性,并且
都不是线程安全的
。 - HashSet、LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- HashSet 用于不需要保证元素插入和取出顺序的场景,
LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景
,TreeSet 用于支持对元素自定义排序规则的场景
。
- HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,所以肯定都能保证元素唯一或者说无重复性,并且
- HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素【除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。】
- Queue(Java5新增的队列):队列集合
- (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的
- PriorityQueue: Object[] 数组来实现二叉堆
- PriorityQueue 是在 JDK1.5 中被引入的, PriorityQueue与 Queue 的区别在于元素出队顺序是与优先级相关的,即
PriorityQueue总是优先级最高的元素先出队
- PriorityQueue 是在 JDK1.5 中被引入的, PriorityQueue与 Queue 的区别在于元素出队顺序是与优先级相关的,即
- ArrayQueue: Object[] 数组 + 双指针
- ArrayDeque 与 LinkedList 的区别:
从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈
- ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能。
ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现
。- ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在
- ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
- ArrayDeque 与 LinkedList 的区别:
- PriorityQueue: Object[] 数组来实现二叉堆
- Queue 与 Deque 的区别
- Queue 是单端队列,只能从一端插入元素,另一端删除元素,一般遵循 先进先出(FIFO) 规则。
- Queue 扩展了 Collection 的接口,根据因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值
Deque 是双端队列
,在队列的两端均可以插入或删除元素,Deque 扩展了 Queue 的接口
- Queue 是单端队列,只能从一端插入元素,另一端删除元素,一般遵循 先进先出(FIFO) 规则。
- (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的
- List:有序可重复,可直接根据元素的索引来访问
- 另一种是双列集合图(Map),存储键/值对映射,可根据元素的key来访问value。【Map不是Collection的子接口】
- ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找
- 跳表(链表—>平衡树---->跳表)
- 对于一个单链表,即使链表是有序的,
如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低
,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可
。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可
。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
- 对于一个单链表,即使链表是有序的,
- 跳表(链表—>平衡树---->跳表)
- HashMap:
JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)
。JDK1.8 以后
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间- 需要根据键值获取到元素值时并且不需要排序时就选择 HashMap
- LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑
- Hashtable【
Hashtable 基本被淘汰,不要在代码中使用它
】: 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的- HashMap 和 Hashtable 的区别:
- HashMap 是非线程安全的,Hashtable 是线程安全的【
因为 Hashtable 内部的方法基本都经过synchronized 修饰
】,但是要保证线程安全的话就使用 ConcurrentHashMap。 - 保证线程安全就得做额外的操作,比如锁,所以HashMap 要比 Hashtable 效率高一点
- HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍
。- 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小
JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)
。Hashtable 没有这样的机制。
- HashMap 是非线程安全的,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,竞争会越来越激烈效率越低。
- HashMap 和 Hashtable 的区别:
- TreeMap: 红黑树(自平衡的排序二叉树)
- 需要排序时并且需要根据键值获取到元素值时选择 TreeMap
- HashMap 和 TreeMap 区别:
- TreeMap 和HashMap 都继承自AbstractMap,TreeMap它还实现了NavigableMap接口【实现 NavigableMap 接口让 TreeMap 有了
对集合内元素的搜索的能力
。】和SortedMap 接口【实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序
,不过我们也可以指定排序的比较器。】
- 相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力
- TreeMap 和HashMap 都继承自AbstractMap,AbstractMap如下:
- TreeMap 和HashMap 都继承自AbstractMap,TreeMap它还实现了NavigableMap接口【实现 NavigableMap 接口让 TreeMap 有了
- 需要根据键值获取到元素值时并且
需要保证线程安全
就选用 ConcurrentHashMap - 多线程下安全的操作 map还有其他方法吗?
- 还可以使用Collections.synchronizedMap方法,对方法进行加同步锁(如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!)
- 还可以使用Collections.synchronizedMap方法,对方法进行加同步锁(如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!)
- ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找
- 一种是单列集合(Collection),存储一个元素集合。Collection派生出了三个子接口:List、Set和Queue三个小类【
PART2:线程安全的集合有哪些?线程不安全的呢?
线程安全的
:这里说它们是线程安全的是指,多个线程调用咱们对集合进行实例化出来的同一个实例中的集合的某个方法时,是线程安全的。(它们的每个方法是原子的,但它们多个方法的组合不是原子的
,比如get方法和put方法都是安全的,源码中加了synchronized锁,但是这两个方法一起用不一定是原子性的,得在整个方法调用组合外边加上安全措施,比如锁)- 线程安全集合类:可以分为三大类:
前两类都是使用synchronized实现的线程安全,性能较低,所以推荐使用第三大类
- 第一大类:【并发性能很低,所以不经常使用】:HashTable、Vector
- Hashtable:
比HashMap多了个线程安全
。因为Hashtable的诸如get()、put()方法都是用synchronized修饰的,所以是线程安全的。
- Vector:比Arraylist多了个同步化机制。
- Stack:栈,也是线程安全的,继承于Vector。
- 字符串相关:
- String:是不可变类,创建了新的类,原来的类内部状态没有改变
- StringBuffer
- Integer:是不可变类
- Random
- Hashtable:
- 第二大类:java.util.Collections 工具类底下
- 使用Collections装饰的线程安全集合类:运用了设计模式中的装饰器模式【JDK 统一推荐我们使用 Collections.synchronized 类。*】【
需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合,使用synchronized* 类效率很低
】
- 使用Collections装饰的线程安全集合类:运用了设计模式中的装饰器模式【JDK 统一推荐我们使用 Collections.synchronized 类。*】【
第三大类【推荐使用
】:java.util.concurrent包下的类,包含三类关键词:BlockingXxx、CopyOnWriteXxx、ConcurrentXxx;比如:- BlockingXxx大部分实现基于锁,并提供
用来阻塞的方法
- LinkedBlockingQueue原理
- 加锁分析:
- LinkedBlockingQueue原理
- CopyOnWriteXxx之类容器
修改开销相对较重
ConcurrentXxx类型的容器
,推荐使用
- ConcurrentXxx类型的容器特点:
- 内部很多操作
使用cas优化
,一般可以提供较高吞吐量 - 弱一致性
- 遍历时弱一致性【弱一致性:可能其他人已经对值进行了修改,你读到的就是旧值】,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
- 遍历时如果发生了修改,对于非安全容器来讲,使用fail-fast 机制也就是让遍历立刻失败,抛出
- 求大小弱一致性,size操作未必是100%准确
- 读取弱一致性,可能其他人已经对值进行了修改,你读到的就是旧值
ConcurrentModificationException,不再继续遍历
- 遍历时弱一致性【弱一致性:可能其他人已经对值进行了修改,你读到的就是旧值】,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
- 内部很多操作
- ConcurrentHashMap:是一种高效但是线程安全的集合。用的是细粒度的锁。看本篇PART3-2
- concurrentHashMap 1.7 和 1.8 有什么区别
- ConcurrentHashMap不允许为null,因为null会引起歧义,如果value为null,我们无法得知是值为null,还是key未映射具体值?null 就是个隐藏的炸弹
- concurrentHashMap 1.7 和 1.8 有什么区别
CopyOnWriteArrayList是一个线程安全 ArrayList
:(对其进行的修改操作都是在底层的一个复制的数组(快照,也就是咱们经常说的这个底层数组的副本)上进行的
,也就是使用了写时复制策略
。)~JUC之CopyOnWriteArrayList篇
public class CopyOnWriteArrayList<E> extends Object implements List<E>, RandomAccess, Cloneable, Serializable
- ConcurrentXxx类型的容器特点:
- BlockingXxx大部分实现基于锁,并提供
- 第一大类:【并发性能很低,所以不经常使用】:HashTable、Vector
- 线程安全集合类:可以分为三大类:
- 线性不安全的:
- Arraylist、LinkedList
- HashSet、TreeSet
- HashSet
- HashSet
- HashMap、TreeMap
- HashMap为什么线程不安全?
- 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,
在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环
。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题【jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap】
- 在 hashMap1.7 中扩容的时候,因为采用的是头插法,
所以会可能会有循环链表产生,导致数据有问题
,在 1.8 版本已修复,改为了尾插法
- 在 hashMap1.7 中扩容的时候,因为采用的是头插法,
- 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失【桶下标冲突】。此问题在JDK 1.7和 JDK 1.8 中都存在
当桶下标冲突时,采用拉链法,形成一个链表:
在任意版本的 hashMap 中,如果在插入数据时多个线程命中了同一个槽,可能会有数据覆盖的情况发生,导致线程不安全
- put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。
- HashMap 是无序的。HashMap 使用的是哈希方式进行存储的,因此存入和读取的顺序可能是不一致的,这也说 HashMap 是无序的集合,所以会导致插入的顺序,与最终展示的顺序不一致。
解决方案就是将无序的 HashMap 改为有序的 LinkedHashMap【LinkedHashMap 属于 HashMap 的子类,所以 LinkedHashMap 除了拥有 HashMap 的所有特性之后,还具备自身的一些扩展属性,其中就包括 LinkedHashMap 中额外维护了一个双向链表,这个双向链表就是用来保存元素的(插入)顺序的。】
。
- 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,
- hashMap 线程不安全怎么解决:
- HashMap为什么线程不安全?
PART3-1:ArrayList 的扩容机制:ArrayList扩容的 本质就是计算出新的扩容数组的size后再实例化新的殊荣
,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍
。
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
private static final long serialVersionUID = 8683452581122892189L;
/**
* 默认初始容量大小
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 空数组(用于空实例)。
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
//用于默认大小空实例的共享空数组实例。
//我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 保存ArrayList数据的数组
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* ArrayList 所包含的元素个数
*/
private int size;
/**
* 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小)
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
//如果传入的参数大于0,创建initialCapacity大小的数组
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
//如果传入的参数等于0,创建空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
//其他情况,抛出异常
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
/**
*默认无参构造函数
*DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
...
//以JDK1.8为例说明
public boolean add(E e) {
//add 方法首先调用了ensureCapacityInternal(size + 1)判断是否可以容纳e,若能,则直接添加在末尾;若不能,则进行扩容,然后再把e添加在末尾
ensureCapacityInternal(size + 1); // Increments modCount!!。每次在add()一个元素时,arraylist都需要对这个list的容量进行一个判断。通过ensureCapacityInternal()方法确保当前ArrayList维护的数组具有存储新元素的能力,经过处理之后将元素存储在数组elementData的尾部
//将e添加到数组末尾
elementData[size++] = e;
return true;
}
/**
*每次在add()一个元素时,arraylist都需要对这个list的容量进行一个判断。通过ensureCapacityInternal()方法确保当前ArrayList维护的数组具有存储新元素的能力,经过处理之后将元素存储在数组elementData的尾部*/
private void ensureCapacityInternal(int minCapacity) {
//当要add进第1个元素时,minCapacity 为1,在Math.max()方法比较后,minCapacity为10。
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 获取默认的容量和传入参数的较大值
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
//在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 若ArrayList已有的存储能力满足最低存储要求,则返回add直接添加元素;如果最低要求的存储能力>ArrayList已有的存储能力,这就表示ArrayList的存储能力不足,因此需要调用 grow();方法进行扩容
//通俗一点来说,当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length 为 0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0成立,所以会进入 grow(minCapacity) 方法。当 add 第 2 个元素时,minCapacity 为 2,此时 e lementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。也刚好印证咱们刚开始为0扩容到10,然后1.5倍扩容
if (minCapacity - elementData.length > 0){
grow(minCapacity);
}
}
private void grow(int minCapacity) {
// 获取elementData数组的内存空间长度。oldCapacity为旧容量,
int oldCapacity = elementData.length;
// 扩容至原来的1.5倍。newCapacity为新容量,将oldCapacity 右移一位,其效果相当于oldCapacity /2,位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.
int newCapacity = oldCapacity + (oldCapacity >> 1);
//校验容量是否够,然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//若预设值大于默认的最大值,检查是否溢出
if (newCapacity - MAX_ARRAY_SIZE > 0)
//如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE。如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
newCapacity = hugeCapacity(minCapacity);
// 调用Arrays.copyOf方法将elementData数组指向新的内存空间
//并将elementData的数据复制到新的内存空间
elementData = Arrays.copyOf(elementData, newCapacity);
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//如果传入的是个空数组则最小容量取默认容量与minCapacity之间的最大值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
...
}
- ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{...}
:- RandomAccess 是一个标志接口,
表明实现RandomAccess这个接口的 List 集合是支持快速随机访问的
。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问 - ArrayList 实现了 Cloneable 接口 ,即覆盖了函数clone(),能被克隆。
- ArrayList 实现了 java.io.Serializable接口,这意味着ArrayList支持序列化,能通过序列化去传输。
- RandomAccess 是一个标志接口,
- ArrayList 源码中的 ensureCapacity 方法:这个方法 ArrayList 内部没有被调用过,是提供给用户调用的。作用就是
在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数。向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能
- 不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList 里面添加这么多元素。
- ArrayList 源码中大量调用了
System.arraycopy() 和 Arrays.copyOf()
这两个方法- System.arraycopy() 方法
arraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 ;copyOf() 是系统自动在内部新建一个数组,并返回该数组
- Arrays.copyOf()方法:以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素)。
copyOf()内部实际调用了 System.arraycopy() 方法
- Arrays.copyOf(elementData, size);//elementData:要复制的数组;size:要复制的长度
- System.arraycopy() 方法
- 结合上面程序可以看出来,ArrayList的扩容机制主要有下面几点需要注意:
- 实例化的不同影响:
当咱们List<...> list = new ArrayList<>()时
,此时ArrayList会使用长度为0的数组,也就是此时ArrayList列表的初始容量是0【以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组】
- 当真正对数组进行添加元素操作时,才真正分配容量。即
向数组中添加第一个元素时,数组容量扩为 10
。 扩容就是通过add这些方法触发的嘛,所以咱得看看add这些方法
- 当真正对数组进行添加元素操作时,才真正分配容量。即
当咱们List<...> list = new ArrayList<>(int initialCapacity)时使用有参构造进行实例化时
,此时ArrayList会使用制定容量为initialCapacity的数组,也就是此时ArrayList列表的初始容量是initialCapacity
- 当通过这个构造器public ArrayList(Collection<? extends E> c)进行实例化时,会使用C这个集合的大小作为数组容量,
也就是此时ArrayList列表的初始容量是这个集合C的大小
- add()【将指定的元素追加到此列表的末尾。】和addAll()方法:
add(Objecto)首次扩容为10,再次扩容为上次容量的1.5倍
- 此时没有元素时【也就是刚开始容量为0时】,add()方法才会扩容为10,有元素时哪怕只有一个,第一次扩容还是按照初始容量的1.5倍来
- grow():
当 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。以此类推······
- hugeCapacity() 方法
- 从grow() 方法源码我们知道: 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。
- 从grow() 方法源码我们知道: 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。
- addAll(Collection c)没有元素时,扩容为
Math.max(10,实际元素个数)
,有元素时为Math.max(原容量1.5倍,实际元素个数)
- 实例化的不同影响:
PART3-2:HashMap
- HashMap的底层原理:【
Hash函数是指把一个大范围映射到一个小范围,目的往往是为了节省空间,使得数据容易保存。 比较出名的有MurmurHash、MD4、MD5等等
。并且key可以为null,key 为 Null 的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置】- HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
- HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个
- HashMap的四个构造函数:
- 前三个构造函数:
- HashMap 中
带有初始容量
的构造函数
- Load factor为负载因子(默认值是0.75)。默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下 :(作者在源码中的注释(JDK1.7):翻译过来大概的意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。)
- 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值
- 如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
- threshold是HashMap所能容纳键值对的最大值。threshold = length * Load factor(也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多)
- HashMap 默认的
初始化大小为 16
。之后每次扩充,容量变为原来的 2 倍
。并且, HashMap 总是使用 2 的幂作为哈希表的大小
。
- 前三个构造函数:
- HashMap中包含了几个字段:
- Entry<K, V>[] table:虽然说table是一个数组,初始化长度length(默认值是16),但是这个数组很牛逼,他每一个格子都放着一个很长的链表,他也是用拉链法来解决哈希冲突(当由哈希函数计算出来的内存地址产生冲突了后,意思就是计算之后,哎,这俩货竟然被算到可同一个格子中了,这不就冲突了,那怎么办,就开始用拉链法,也就是说把哈希值和散列桶取模运算结果相同的Entry放到同数组同一个格子里面的长长的链表中就算是解决哈希冲突了呗)
- Key:
- key能否为null,作为key的对象有什么要求?
- ①HashMap 的key可以为null,但Map的其他实现则不然
- ②作为key的对象,必须实现hashCode和equals,
并且key的内容不能修改(不可变)
【如果hashCode相同不一定equals,如果两个对象equals则两个对象的hashcode肯定相同】
- 重写hashCode是为了key在HashMap中有更好的分布性,进而提高性能
- 重写equals是为了防止key一样,在做进一步区分
- HashMap 中 key 的存储哈希索引是怎么计算的:(Hash 算法本质上就是三步)
- HashMap 中 计算key 的存储索引第一步:首先根据key的值调用字符串的hashcode方法计算出hashcode这个整数值
- 一般用什么作为HashMap的key:一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用
- 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就是 HashMap 中的键往往都使用字符串的原因。
- 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了 hashCode() 以及 equals() 方法。
- 用可变类当 HashMap 的 key 有什么问题:hashcode 可能发生改变,导致 put 进去的值,无法 get 出
- 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就是 HashMap 中的键往往都使用字符串的原因。
- 一般用什么作为HashMap的key:一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用
- HashMap 中 计算key 的存储哈希索引第二步:然后根据hashcode值调用hash方法计算出hash值(
JDK1.7和1.8的不同之处就在于第二步,以JDK1.8为例,n为table的长度
。)
- 相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次
- 相比于之前的版本,JDK1.8 以后在解决哈希冲突时有了较大的变化。往下看
- JDK1.8 为什么要 hashcode 异或其右移十六位的值:
- 因为在JDK 1.7 中扰动了 4 次,计算 hash 值的性能会稍差一点点。 从速度、功效、质量来考虑,JDK1.8 优化了高位运算的算法,通过hashCode()的高16位异或低16位实现:(h = k.hashCode()) ^ (h >>> 16)。
这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中
,同时不会有太大的开销。
- 因为在JDK 1.7 中扰动了 4 次,计算 hash 值的性能会稍差一点点。 从速度、功效、质量来考虑,JDK1.8 优化了高位运算的算法,通过hashCode()的高16位异或低16位实现:(h = k.hashCode()) ^ (h >>> 16)。
- HashMap 中 计算key 的存储哈希索引第三步:最后通过hash&(length-1)计算得到存储元素的位置。(通过二次哈希值与数组容量取模计算下标)【如果计算出来存储当前元素的位置已经存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突】
- 为什么 hash 值要与length-1相与:
- 把 hash 值对数组长度取模运算,模运算的消耗很大,
没有位运算快
,所以可以把求模优化为位运算快,但是位运算有个前提,数组长度必须是2的n次方 - 当 length 总是 2 的n次方时,h& (length-1) 运算等价于对length取模,也就是 h%length,但是 & 比 % 具有更高的效率。
HashMap数组的长度为什么是 2 的幂次方
:- 这样做效果上等同于取模,在速度、效率上比直接取模要快得多。除此之外,2 的 N 次幂有助于减少碰撞的几率。如果 length 为2的幂次方,则 length-1 转化为二进制必定是11111……的形式,在与hash的二进制与操作效率会非常的快,而且空间不浪费【当 length =15时,6 和 7 的结果一样,这样表示他们在 table 存储的位置是相同的,也就是产生了碰撞,6、7就会在一个位置形成链表,4和5的结果也是一样,这样就会导致查询速度降低
- Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才是用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”,长度是2的幂次方时与h的二进制与操作效率会非常的快,而且空间不浪费【 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方,并且
采用二进制位操作 &,相对于%能够提高运算效率
】 ----->
进一步分析,还会发现空间浪费非常大,以 length=15 为例,在 1、3、5、7、9、11、13、15 这八处没有存放数据。因为hash值在与14(即 1110)进行&运算时,得到的结果最后一位永远都是0,即 0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的。----->
HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。会取大于或等于这个数的 且最近的2次幂作为 table 数组的初始容量,使用tableSizeFor(int)方法,如 tableSizeFor(10) = 16(2 的 4 次幂),tableSizeFor(20) = 32(2 的 5 次幂),也就是说 table 数组的长度总是 2 的次幂。】
- 把 hash 值对数组长度取模运算,模运算的消耗很大,
- 为什么 hash 值要与length-1相与:
- HashMap 中 计算key 的存储索引第一步:首先根据key的值调用字符串的hashcode方法计算出hashcode这个整数值
- key能否为null,作为key的对象有什么要求?
- Entry<K, V>[] table:虽然说table是一个数组,初始化长度length(默认值是16),但是这个数组很牛逼,他每一个格子都放着一个很长的链表,他也是用拉链法来解决哈希冲突(当由哈希函数计算出来的内存地址产生冲突了后,意思就是计算之后,哎,这俩货竟然被算到可同一个格子中了,这不就冲突了,那怎么办,就开始用拉链法,也就是说把哈希值和散列桶取模运算结果相同的Entry放到同数组同一个格子里面的长长的链表中就算是解决哈希冲突了呗)
- HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
//就是下面这个tableSizeFor方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。
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;
}
/*
* 解释:位或( | ) int n = cap - 1; 让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
*/
-
JDK1.7的HashMap与JDK1.8的HashMap:
- 在JDK1.7 中,由“数组+链表”组成链表散列,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
- 如果仅仅使用数组【数组格子也叫做桶,数组下标也叫桶下标】,则查询很慢,所以搞些哈希值出来,方便快速查找
- HashMap 通过 key 的 hashcode 经过扰动函数【扰动函数指的就是 HashMap 的 hash 方法】处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断
该元素与要存入的元素的 hash 值以及 key 是否相同
,如果相同的话直接覆盖
,不相同就通过拉链法
解决冲突。- 使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
//JDK 1.8 的 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 方法。相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。 static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
- 使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
- 如果仅仅使用数组【数组格子也叫做桶,数组下标也叫桶下标】,则查询很慢,所以搞些哈希值出来,方便快速查找
- 有了哈希值实现了快速查找之后,但是
新问题又来了
,哈希容易出现哈希冲突,当桶下标冲突时,采用拉链法,形成一个链表:
- 当发生hash冲突时有哪些解决办法?
HashMap用的链地址法(拉链法)
。解决Hash冲突方法有:开放定址法也称为再散列法
:基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,p1=H§,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。再哈希法(双重散列,多重散列)
,提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。链地址法(拉链法)
,将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。建立公共溢出区
,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
- 搞哈希值出来除了产生哈希碰撞这个问题之外,还有一个问题就是,如果咱们使用拉链法解决哈希冲突之后,
形成的链表长度很长时,很影响性能
,所以,此时又有了两种思路
:- 缩减链表长度【
当数组容量小于64时
,或者原始哈希值也就是第一次哈希值不一样时采用】:此时这个拉链法拉出来的链表长度只会在数组长度扩容时扩容
【而数组长度会在元素个数到达数组长度的3/4时会扩容】,因为数组扩容后桶下标会重新计算【因为三步中最后一步要与数组长度求模,那你数组长度扩容了,肯定求模结果不一样了呀】- 此时,如果你原始哈希值,也就是第一次求出的哈希值一样时,那么求模出来的桶下表也不会变,也就没法缩减拉链法拉出来的链表长度
- 红黑树应运而生,进行红黑树的树化【树化是万不得已的办法】:
树化有两个条件
,同时满足两个条件
:当拉链法拉出来的链表长度大于8时
当数组容量大于等于64时
- 红黑树的比较规则就是:先按照哈希码比较,哈希码相同时才按照字符串值比较
- 为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
红黑树用来避免DoS攻击【超长串】,防止链表超长时性能下降
,树化应当是偶然情况,树化不是一种正常情况哦,谨记。- hash 表的查找,更新的时间复染度是0(1),而红黑树的查找,更新的时间复杂度是0(log2 n), TreeNode 占用空间也比普通Node的大,如非必要,尽量还是使用链表。
- 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,
链表结构已经能保证查询性能,并且用红黑树占用的内存还大
。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
- 不用红黑树,用二叉查找树可以么?
- 可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。
- 缩减链表长度【
- 当发生hash冲突时有哪些解决办法?
- 在JDK1.8 中,由“数组+链表+红黑树【链表和红黑树可以互相转化】”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换来
解决哈希冲突等问题
:
- 将链表转换成红黑树前会判断,如果
当前数组的长度小于 64
,那么会选择先进行数组扩容,而不是转换为红黑树
,以减少搜索时间 当链表超过 8 且数据总量超过 64 才会将链表转红黑树(putVal 方法中执行链表转红黑树的判断逻辑),以减少搜索时间
- 当链表长度大于阈值(默认为 8)时,会首先调用
treeifyBin()方法
。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容而不会执行转换操作
- 为什么链表改为红黑树的阈值是 8?
- hash值如果足够随机,则在hash表内按泊松分布,
在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006,选择8就是为了让树化几率足够小
- 是因为
泊松分布
,我们来看作者在源码中的注释的翻译过来的意思:理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了
,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的
**。
- hash值如果足够随机,则在hash表内按泊松分布,
- 当链表长度大于阈值(默认为 8)时,会首先调用
- 红黑树何时会退化为链表:
- 退化情况1:在扩容时如果拆分树时,
树元素个数<= 6则会退化链表
, - 退化情况2: remove树节点时,若root、 root.left、 root.right、 root.left.left有一 个为null,也会退化为链表
- 意思就是,比如你刚才增加或者删除一个节点了,然后操作完了,红黑树为了保证自己左小于根,根小于右等特征会进行左旋右旋操作,会移动树内各节点,那是不是存在移除前某个节点的儿子在,过一会又不在了
- 正是因为二叉查找树在某些情况下会退化成一个线性结构这种情况存在,TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷。
- 退化情况1:在扩容时如果拆分树时,
- 将链表转换成红黑树前会判断,如果
- 在JDK1.7 中,由“数组+链表”组成链表散列,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
-
HashMap 的put方法流程:
- 首先根据 key 的值通过哈希算法计算 hash 值,找到该元素在数组中或者说存储的桶下标
- 如果数组是空的,则调用 resize 进行初始化
- 如果计算出来的要放元素的数组下标元素为空,则将key和value封装为Entry对象(JDK1.7是Entry对象, JDK1.8是Node对象)并放入该位置。【其实就是如果定位到的数组位置没有元素 就直接插入】
- 如果没有哈希冲突直接放在对应的数组下标里
- 如果冲突了,且 key 已经存在,就覆盖掉 value
- 如果数组下标位置元素不为空,相当于冲突了,则要分情况:
- 如果是在JDK1.7,则首先会判断是否需要扩容,如果要扩容就进行扩容,如果不需要扩容就生成Entry对象,并使用头插法添加到当前链表中。
- 如果是在JDK1.8中,则会先判断当前位置上的TreeNode类型,看是红黑树还是链表Node
- 如果是红黑树TreeNode【如果冲突后,发现该节点是红黑树,就将这个节点挂在树上】,则将key和value封装为一个红黑树节点并添加到红黑树中去,在这个过程中会判断红黑树中是否存在当前key,如果存在则更新value。
- 如果此位置上的Node对象是链表节点【如果冲突后是链表,判断该链表是否大于 8 ,如果大于8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value】,则将key和value封装为一个Node并通过尾插法插入到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历过程中会判断是否存在当前key,如果存在则更新其value,当遍历完链表后,将新的Node插入到链表中,插入到链表后,会看当前链表的节点个数,如果大于8,则会将链表转为红黑树
- HashMap常见遍历方式:
- 迭代器(Iterator)方式遍历
- 迭代器 EntrySet
- 迭代器 KeySet
entrySet 的性能比 keySet 的性能高出了一倍之多,我们应该尽量使用 entrySet 来实现 Map 集合的遍历,EntrySet在循环中创建了一个遍历对象 Entry【EntrySet 只遍历了一遍 Map 集合,之后通过代码“Entry<Integer, String> entry = iterator.next()”把对象的 key 和 value 值都放入到了 Entry 对象中,因此再获取 key 和 value 值时就无需再遍历 Map 集合,只需要从 Entry 对象中取值就可以了。】,KeySet 在循环时使用了 map.get(key),map.get(key) 相当于又遍历了一遍 Map 集合去查询 key 所对应的值【在使用迭代器或者 for 循环时,其实已经遍历了一遍 Map 集合了,因此再使用 map.get(key) 查询时,相当于遍历了两遍。】
- 迭代器 EntrySet
- For Each 方式遍历
- ForEach EntrySet
- ForEach KeySet
- ForEach EntrySet
- Lambda
- Streams API 单线程
- Streams API 多线程
- 迭代器(Iterator)方式遍历
- HashMap常见遍历方式:
- 将key和value封装为Node插入到链表或红黑树后,在判断是否需要扩容,如果需要扩容,就结束put方法。
- HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。或者说put方法是调用了putVal方法来实现元素的添加的。putVal 方法添加元素的逻辑如下:
- 如果定位到的数组位置没有元素 就直接插入
- 如果定位到的数组位置
有元素就和要插入的 key 比较
,如果key 相同就直接覆盖
,如果 key 不相同,就判断p 是否是一个树节点
,如果是一个树节点就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是一个树节点就遍历链表插入(插入的是链表尾部)。
public V put(K key, V value) { // 对key的hashCode()做hash return putVal(hash(key), key, value, false, true); } //putVal 方法中执行链表转红黑树的判断逻辑。 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 步骤1:tab为空则创建 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步骤2:计算index,并对null做处理 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 步骤3:节点key存在,直接覆盖value if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 步骤4:判断该链为红黑树 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 步骤5:该链为链表 else { //遍历链表 for (int binCount = 0; ; ++binCount) { // 遍历到链表最后一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //链表长度大于8转换为红黑树进行处理 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 红黑树转换(并不会直接转换成红黑树) treeifyBin(tab, hash); break; } // key已经存在直接覆盖value if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 步骤6:超过最大容量 就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } //treeifyBin 方法中判断是否真的转换为红黑树。 final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // static final int MIN_TREEIFY_CAPACITY = 64; // // 判断当前数组的长度是否小于 64,如果大于8但是数组容量小于64,就进行扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 resize(); }else if ((e = tab[index = (n - 1) & hash]) != null) { // 否则才将列表转换为红黑树 } ... }
- 如果数组下标位置元素不为空,相当于冲突了,则要分情况:
- JDK1.7 和1.8 的put方法区别是什么?
- 解决哈希冲突时,JDK1.7 只使用链表,JDK1.8 使用链表+红黑树,当满足一定条件,链表会转换为红黑树。
- 链表插入元素时,
- JDK1.7 使用头插法插入元素,在多线程的环境下有可能导致
环形链表
的出现,扩容的时候会导致死循环。【jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap
】- 单线程下:
- 多线程下:出问题,不仅是1.7,1.8并发下也会出问题
- 单线程下:
JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,
但JDK1.8 的 HashMap 仍然是线程不安全的
- JDK1.7 使用头插法插入元素,在多线程的环境下有可能导致
-
HashMap的get 方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 数组元素相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 桶中不止一个节点
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
- HashMap 的扩容方式(扩容机制)【HashMap的resize 方法】:HashMap 在容量超过负载因子(初始容量的四分之三)所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。
- 进行扩容会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。
在编写程序中,要尽量避免 resize
- 并发死链:究其原因,
是因为在多线程环境下使用了非线程安全的map集合
JDK 8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序), 但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
- 进行扩容会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。
//JDK1.7 的代码:这里就是使用一个容量更大的数组来代替已有的容量小的数组
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
//这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1],newTable[i] 的引用赋给了 e.next ,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置。这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
- JDK1.8在扩容方面做了两处优化
- resize 之后,元素的位置在原来的位置,或者原来的位置 +oldCap (原来哈希表的长度)。不需要像 JDK1.7 的实现那样重新计算hash ,只需要看看原来的 hash 值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap ”。这个设计非常的巧妙,省去了重新计算 hash 值的时间
- 如下图所示,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。
- 元素在重新计算 hash 之后,因为 n 变为 2倍,那么 n-1 的 mask 范围在高位多 1 bit(红色),因此新的index就会发生这样的变化
- 如下图所示,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。
- JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法,比如16扩充为 32 的 resize 示意图:
- resize 之后,元素的位置在原来的位置,或者原来的位置 +oldCap (原来哈希表的长度)。不需要像 JDK1.7 的实现那样重新计算hash ,只需要看看原来的 hash 值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap ”。这个设计非常的巧妙,省去了重新计算 hash 值的时间
// JDK1.8 的 resize 源码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表优化重hash的代码块
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
PART3-3:ConcurrentHashMap 的实现原理是什么:ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的
- 【
ConcurrentHashMap 可以看作线程安全的 HashMap
】- 除了ConcurrentHashMap,在并发场景下如果要保证线程安全的另一种可行的方式是使用 Collections.synchronizedMap() 方法来包装我们的 HashMap,
但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题
在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问
- 除了ConcurrentHashMap,在并发场景下如果要保证线程安全的另一种可行的方式是使用 Collections.synchronizedMap() 方法来包装我们的 HashMap,
- JDK1.7:JDK1.7中的ConcurrentHashMap 是由 Segment 数组结构【
Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构
。】和 HashEntry 数组结构组成,即ConcurrentHashMap 把哈希桶切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成.首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问
。- 首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。【每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的】
- 每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容
Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁
,扮演锁的角色;HashEntry 用于存储键值对数据。
- 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。
Segment 数组的大小默认是 16,也就是说ConcurrentHashMap 默认可以同时支持 16 个线程并发写
。
- 首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。【每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的】
- JDK1.8:在数据结构上【JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树】,
JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构
;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。(将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。)
- Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树【Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))】。Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升
- 总结一下: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 数组的大小,并发度更大
- 线程安全实现方式 :
- Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树【Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))】。Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升
ConcurrentHashMap 的构造器
- JDK7中: Java 7 中 ConcurrnetHashMap 的初始化逻辑大概就是:
//ConcurrentHashMap 的无参构造。无参构造中调用了有参构造,传入了三个参数的默认值,他们的值是。 public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); } /** * 默认初始化容量 */ static final int DEFAULT_INITIAL_CAPACITY = 16; /** * 默认负载因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 默认并发级别 */ static final int DEFAULT_CONCURRENCY_LEVEL = 16; @SuppressWarnings("unchecked") public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { // 参数校验 if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); // 校验并发级别concurrencyLevel 大小,如果大于 1<<16这个最大值,重置为 65536这个最大值。无参构造默认值是 16。 if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments。寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 // 2的多少次方 int sshift = 0; int ssize = 1; // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值 while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 记录段偏移量segmentShift ,这个值为【容量 = 2 的N次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. this.segmentShift = 32 - sshift; // 记录段掩码segmentMask,默认是 ssize - 1 = 16 -1 = 15. this.segmentMask = ssize - 1; // 设置容量 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容 // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量 int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数 while (cap < c) cap <<= 1; // create segments and segments[0] // 创建 Segment 数组,设置 segments[0] Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }
- JDK8中:
- Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树
- ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。
- JDK7中: Java 7 中 ConcurrnetHashMap 的初始化逻辑大概就是:
ConcurrentHashMap 的 put 方法
执行逻辑是什么?-
JDK1.7:
- 首先,计算要 put 的 key 的位置,获取指定位置的 Segment。如果指定位置的 Segment 为空,则初始化这个 Segment
- 然后会尝试获取锁【Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁】,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁【当自旋次数大于指定次数时,
使用 lock() 阻塞获取锁
。在自旋时顺表获取下 hash 位置的 HashEntry。】。- tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法【
这个方法做的操作就是不断的自旋 tryLock() 获取锁
】继续获取
- tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法【
- 获取到锁后:
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
- 计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value【如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.】
- 遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。
- 遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中【Segment.put 插入 key,value 值】,同时会先判断是否需要扩容
- 释放 Segment 的锁
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
- 首先,计算要 put 的 key 的位置,获取指定位置的 Segment。如果指定位置的 Segment 为空,则初始化这个 Segment
-
JDK1.8
- 根据 key 计算出 hash值
- 判断是否需要进行初始化
- 定位到 Node,拿到首节点 f,判断首节点 f
- 如果为 null ,则通过cas的方式尝试添加
- 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容
- 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入
- 当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树
-
ConcurrentHashMap 的 get 方法
是否要加锁,为什么?- get 方法只需要两步:
- 根据 hash 值计算得到 key 的存放位置
- 遍历指定位置查找相同 key 的 value 值
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找就行
- 如果是链表,遍历查找就行
- JDK8中:
- JDK7:
get 方法不需要加锁
。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。.这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 安全效率高的原因之一。- get方法不需要加锁与volatile修饰的哈希桶有关吗?
- 没有关系。哈希桶table用volatile修饰主要是保证在数组扩容的时候保证可见性
- get方法不需要加锁与volatile修饰的哈希桶有关吗?
- get 方法只需要两步:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 存放数据的桶
transient volatile HashEntry<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//可以看到这些都用了volatile修饰
volatile V val;
volatile Node<K,V> next;
}
- 扩容 rehash:
ConcurrentHashMap 的扩容只会扩容到原来的两倍
。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
// 老容量
int oldCapacity = oldTable.length;
// 新容量,扩大两倍
int newCapacity = oldCapacity << 1;
// 新的扩容阀值
threshold = (int)(newCapacity * loadFactor);
// 创建新的数组
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,默认2扩容后是4,-1是3,二进制就是11。
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
// 遍历老数组
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
// 如果当前位置还不是链表,只是一个元素,直接赋值
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// 如果是链表了
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
// 新的位置只可能是不便或者是老的位置+老的容量。
// 遍历结束后,lastRun 后面的元素位置都是相同的
//这里第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置
for (HashEntry<K,V> last = next; last != null; last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。
//第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。这样实现的原因可能是基于概率统计
newTable[lastIdx] = lastRun;
// Clone remaining nodes
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
// 遍历剩余元素,头插法到指定 k 位置。
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 头插法插入新的节点
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
- size():
- JDK8:
- JDK7:
- JDK8:
- 扩容:transfer():
- JDK8中:
- JDK7:
- JDK8中:
- ConcurrentHashMap 不支持 key 或者 value 为 null 的原因:
value 为什么不能为 null ,因为ConcurrentHashMap 是用于多线程的 ,如果map.get(key)得到了 null ,无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,这就有了二义性。
而用于单线程状态的HashMap却可以用containsKey(key) 去判断到底是否包含了这个 null 。
用反证法来推理:
假设ConcurrentHashMap 允许存放值为 null 的value,这时有A、B两个线程,线程A调用ConcurrentHashMap .get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的key。那么,我们可以用ConcurrentHashMap .containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回false。
但是在我们调用ConcurrentHashMap .get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap .put(key, null )的操作。那么我们调用containsKey方法返回的就是true了,这就与我们的假设的真实情况不符合了,这就有了二义性。
至于ConcurrentHashMap 中的key为什么也不能为 null 的问题,源码就是这样写的,哈哈。如果面试官不满意,就回答因为作者Doug不喜欢 null ,所以在设计之初就不允许了 null 的key存在
- ConcurrentHashMap 的并发度是多少:在JDK1.7中,并发度默认是16,这个值可以在构造函数中设置。如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
- ConcurrentHashMap 迭代器是强一致性还是弱一致性:
与HashMap迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键
- JDK1.7与JDK1.8 中ConcurrentHashMap 的区别?
- 数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。
- 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
- ConcurrentHashMap 的效率要高于Hashtable,因为Hashtable给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在JDK1.7中采用分段锁实现线程安全,在JDK1.8 中采用CAS+Synchronized实现线程安全
- Hashtable的锁机制 :Hashtable是使用Synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
PART3-4:Iterator 和 ListIterator 有什么区别?
- 遍历。使用Iterator,可以遍历所有集合,如Map,List,Set;但只能在向前方向上遍历集合中的元素;使用ListIterator,只能遍历List实现的对象,但可以向前和向后遍历集合中的元素
- 比如说,这个foreach遍历就会创建一个迭代器:
- 比如说,这个foreach遍历就会创建一个迭代器:
- 添加元素。Iterator无法向集合中添加元素;而,ListIteror可以向集合添加元素
- 修改元素。Iterator无法修改集合中的元素;而,ListIterator可以使用set()修改集合中的元素
- 索引。Iterator无法获取集合中元素的索引;而,使用ListIterator,可以获取集合中元素的索引
PART3-5:讲一讲快速失败(fail-fast)和安全失败(fail-safe)
- 快速失败(fail-fast):原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,
都会检测modCount变量是否为expectedmodCount值,也就是看看两个相等不相等
,是的话就返回遍历;否则抛出异常,终止遍历
java.util包下面的所有的集合类都是 fail-fast 的
- 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出
ConcurrentModificationException
- 注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug
- 场景:
java.util包下的集合类都是快速失败fail-fast的
,不能在多线程下发生并发修改(迭代过程中被修改),发生就报ConcurrentModificationException错,比如HashMap、ArrayList 这些集合类
- 安全失败(fail—safe):原理:由于迭代时是对
原集合的拷贝
进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModification Exception
添加是一个数组【旧数组】,遍历用的是另一个复制出来的新数组
java.util.concurrent包下面的所有的类都是 fail-safe 的
- 采用安全失败机制的集合容器,
在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历
- 缺点:基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,
也就是会读到旧数据,也就是弱一致性
,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的 - 场景:
java.util.concurrent包下的容器都是安全失败
,可以在多线程下并发使用,并发修改,比如:ConcurrentHashMap。
PART4:集合最佳实践:
- 集合判空:
判断所有集合内部的元素是否为空使用 isEmpty() 方法【isEmpty() 方法的可读性更好,并且时间复杂度为 O(1)】
,而不是size()==0【java.util.concurrent 包下的某些集合(ConcurrentLinkedQueue 、ConcurrentHashMap...)这些集合的size()方法时间复杂度不是O(1),就因为这几个害群之马,人家大部分集合的size()方法时间复杂度还是O(1)的】
的方式- ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。
ConcurrentLinkedQueue 可以看做一个线程安全的 LinkedList
,这是一个非阻塞队列
- ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。
- 集合转 Map
在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时
,一定要注意当 value 为 null 时会抛 NPE 异常public static <T, K, U, M extends Map<K, U>> Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier) { //toMap() 方法内部调用了 Map 接口的 merge() 方法,merge() 方法会先调用 Objects.requireNonNull() 方法判断 value 是否为空。所以有可能会抛出NPE异常 BiConsumer<M, T> accumulator = (map, element) -> map.merge(keyMapper.apply(element), valueMapper.apply(element), mergeFunction); return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID); }
- 集合遍历
不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁
。- foreach 语法底层其实还是依赖 Iterator,不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iterator 的 remove/add方法,这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException 来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制。
- 集合去重
可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作
- Set 去重代码
HashSet 的 contains() 方法底部依赖的 HashMap 的 containsKey() 方法
,时间复杂度接近于 O(1)(没有出现哈希冲突的时候为 O(1))。我们有 N 个元素插入进 Set 中,那时间复杂度就接近是 O (n)。
- List 去重代码
- ArrayList 的 contains() 方法是通过遍历所有元素的方法来做的,时间复杂度接近是 O(n)。List 有 N 个元素,那时间复杂度就接近是 O (n^2)。
- Set 去重代码
- Java基基老师的大数据量级下常用去重算法文章(大量数据的快速排序、查找、去重):假设有这样一个需求:在20亿个随机整数中找出某个数m是否存在其中,并假设32位操作系统,4G内存。按位存储:每一位表示一个数,0表示不存在,1表示存在,这正符合二进制;把一个数放进去、清除一个数、
- 集合转数组
使用集合转数组的方法,必须使用集合的 toArray(T[] array)【toArray(T[] array) 方法的参数是一个泛型数组,如果 toArray 方法中没有传递任何参数的话返回的是 Object类 型数组。】
,传入的是类型完全一致、长度为 0 的空数组。
- 数组转集合
- 使用
工具类 Arrays.asList()【用Arrays.asList()将一个数组转换为一个 List 集合】
把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常
- Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。当传入一个原生数据类型数组时,Arrays.asList() 的真正得到的参数就不是数组中的元素,而是数组对象本身
- Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,换句话说
就是一个假集合
,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法,所以使用集合的修改方法: add()、remove()、clear()会抛出异常我们可以有几种方法把这个转换出来的假集合转成为真集合
:- List list = new ArrayList<>(Arrays.asList(“a”, “b”, “c”))
使用 Java8 的 Stream
:
- 使用 Apache Commons Collections或者Java9 的 List.of()方法
- 使用
巨人的肩膀:
https://www.runoob.com/java/java-collections.html
https://www.javalearn.cn
javaguide