赞
踩
目录
目录
2.5 LinkedList插入和删除元素的时间复杂度(双向链表的复杂度)
3.2 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
4.2 ArrayDeque 与 LinkedList 的区别
4.6 ArrayBlockingQueue 和 LinkedBlockingQueue 的区别
5.10 ConcurrentHashMap 和 Hashtable 的区别
5.11 ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
5.12 ConcurrentHashMap 为什么 key 和 value 不能为 null?
5.13 ConcurrentHashMap 能保证复合操作的原子性吗?
Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
、 Queue
。
List
(对付顺序的好帮手): 存储的元素是有序的、可重复的。Set
(注重独一无二的性质): 存储的元素不可重复的。Queue
(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值ArrayList
:Object[]
数组。Vector
:Object[]
数组。LinkedList
:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)HashSet
(无序,唯一): 基于 HashMap
实现的,底层采用 HashMap
来保存元素。LinkedHashSet
(有序,唯一): LinkedHashSet
是 HashSet
的子类,并且其内部是通过 LinkedHashMap
来实现的。TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)PriorityQueue
: Object[]
数组来实现小顶堆。DelayQueue
: 内部也是PriorityQueue,
根据元素的延迟时间进行排序。ArrayDeque
: 可扩容动态双向数组。HashMap
:JDK1.8 之前 HashMap
由数组+链表组成的,数组是 HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap
:LinkedHashMap
继承自 HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
:数组+链表组成的,数组是 Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的。TreeMap
:红黑树(自平衡的排序二叉树)。Map
接口下的集合,需要排序时选择 TreeMap
,不需要排序时就选择 HashMap
,需要保证线程安全就选用 ConcurrentHashMap
。Collection
接口的集合,需要保证元素唯一时选择实现 Set
接口的集合比如 TreeSet
或 HashSet
,不需要保证唯一就选择实现 List
接口的比如 ArrayList
或 LinkedList
,然后再根据实现这些接口的集合的特点来选用。相较于数组,Java 集合的优势在于它们的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。
ArrayList
:Object[]
数组。Vector
:Object[]
数组。LinkedList
:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)ArrayList
内部基于动态数组(Object[])实现,比 Array
(静态数组) 使用起来更加灵活:
ArrayList
会根据实际存储的元素动态地扩容或缩容,而 Array
被创建之后就不能改变它的长度了。当向 ArrayList
中添加元素并超出当前数组的容量时,ArrayList
会创建一个新的、更大的数组,并将旧数组中的元素复制到新数组中。新的容量通常是旧容量的 1.5 倍(但这同样可能会因 Java 的不同版本或不同的 JVM 实现而有所不同)。这种扩容策略有助于在大多数情况下避免频繁的数组复制操作,从而提高性能。ArrayList
允许你使用泛型来确保类型安全(编译时报错),Array
则不可以(运行时才报错)。ArrayList
中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array
可以直接存储基本类型数据,也可以存储对象。ArrayList
支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()
、remove()
等。Array
只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList
创建时不需要指定大小,而Array
创建时必须指定大小。ArrayList
是 List
的主要实现类,底层使用 Object[]
存储,适用于频繁的查找工作,线程不安全 。Vector
是 List
的古老实现类,底层使用Object[]
存储,线程安全。Vector
和 Stack
两者都是线程安全的,都是使用 synchronized
关键字进行同步处理。Stack
继承自 Vector
,是一个后进先出的栈,而 Vector
是一个列表。 随着 Java 并发编程的发展,Vector
和 Stack
已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap
、CopyOnWriteArrayList
等)或者手动实现线程安全的方法来提供安全的多线程操作支持。
对于插入:
ArrayList
的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。对于删除:
ArrayList
和 LinkedList
都是不同步的,也就是都不保证线程安全;ArrayList
底层使用的是 Object
数组;LinkedList
底层使用的是 双向链表 数据结构,无容量限制(JDK1.6 之前为循环链表,JDK1.7 取消了循环)ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候, ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)(没发生扩容时)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList
采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、 removeLast()
),时间复杂度为 O(1),如果是要在指定位置 i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
,remove(int index)
), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。LinkedList
不支持高效的随机元素访问,而 ArrayList
(实现了 RandomAccess
接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据) 我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
。
当向 ArrayList
中添加元素并超出当前数组的容量时,ArrayList
会创建一个新的、更大的数组,并将旧数组中的元素复制到新数组中。新的容量通常是旧容量的 1.5 倍(但这同样可能会因 Java 的不同版本或不同的 JVM 实现而有所不同)。这种扩容策略有助于在大多数情况下避免频繁的数组复制操作,从而提高性能。
HashSet
(无序,唯一): 基于 HashMap
实现的,底层采用 HashMap
来保存元素。LinkedHashSet
(有序,唯一): LinkedHashSet
是 HashSet
的子类,并且其内部是通过 LinkedHashMap
来实现的。TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)equals()
判断时 ,返回 false,需要同时重写 equals()
方法和 hashCode()
方法。HashSet
、LinkedHashSet
和 TreeSet
都是 Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和 TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于 HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。PriorityQueue
: Object[]
数组来实现小顶堆。DelayQueue
: 阻塞队列,PriorityQueue
。ArrayDeque
: 可扩容动态双向数组。Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则.Deque
是双端队列,在队列的两端均可以插入或删除元素。Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。ArrayDeque
和 LinkedList
都实现了 Deque
接口,两者都具有队列的功能。
ArrayDeque
是基于可变长的数组和双指针来实现,而 LinkedList
则通过链表来实现。
ArrayDeque
不支持存储 NULL
数据,但 LinkedList
支持。
ArrayDeque
是在 JDK1.6 才被引入的,而LinkedList
早在 JDK1.2 时就已经存在。
ArrayDeque
插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList
不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque
来实现队列要比 LinkedList
更好。此外,ArrayDeque
也可以用于实现栈。
PriorityQueue
是在 JDK1.5 中被引入的, 其与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储 NULL
和 non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个 Comparator
作为构造参数,从而来自定义元素优先级的先后。 BlockingQueue
(阻塞队列)是一个接口,继承自 Queue
。BlockingQueue
阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
ArrayBlockingQueue
:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue
:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE
。和ArrayBlockingQueue
不同的是, 它仅支持非公平的锁访问机制。PriorityBlockingQueue
:支持优先级排序的无界阻塞队列。元素必须实现Comparable
接口或者在构造函数中传入Comparator
对象,并且不能插入 null 元素。SynchronousQueue
:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue
通常用于线程之间的直接传递数据。DelayQueue
:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。 ArrayBlockingQueue
和 LinkedBlockingQueue
是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
ArrayBlockingQueue
基于数组实现,而 LinkedBlockingQueue
基于链表实现。ArrayBlockingQueue
是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue
创建时可以不指定容量大小,默认是Integer.MAX_VALUE
,也就是无界的。但也可以指定队列大小,从而也成为有界的。ArrayBlockingQueue
中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue
中的锁是分离的,即生产用的是putLock
,消费是takeLock
,这样可以防止生产者和消费者线程之间的锁争夺。ArrayBlockingQueue
需要提前分配数组内存,而 LinkedBlockingQueue
则是动态分配链表节点内存。这意味着,ArrayBlockingQueue
在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue
则是根据元素的增加而逐渐占用内存空间。HashMap
:JDK1.8 之前 HashMap
由数组+链表组成的,数组是 HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap
:LinkedHashMap
继承自 HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
:数组+链表组成的,数组是 Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的。TreeMap
:红黑树(自平衡的排序二叉树)。HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!);HashMap
要比 Hashtable
效率高一点。另外,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 的幂作为哈希表的大小。HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable
没有这样的机制。 HashSet
底层就是基于 HashMap
实现的。(HashSet
的源码非常非常少,因为除了 clone()
、writeObject()
、readObject()
是 HashSet
自己不得不实现之外,其他方法都是直接调用 HashMap
中的方法。主要区别就是HashSet只是存储对象,而HashMap存储键值对。
TreeMap
和HashMap
都继承自AbstractMap
,但是需要注意的是TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。
NavigableMap
接口让 TreeMap
有了对集合内元素的搜索的能力(如:定位大于、小于、大于等于、小于等于给定键的最接近的键值对;高效地创建原集合的子集视图,而无需复制整个集合;返回一个逆序的 NavigableMap
视图;方便地访问和移除边界元素)。SortedMap
接口让 TreeMap
有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序。 综上,相比于HashMap
来说, TreeMap
主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
在 JDK1.8 中,HashSet
的add()
方法只是简单的调用了HashMap
的put()
方法,并且判断了一下返回值以确保是否有重复元素。实际上无论HashSet
中是否已经存在了某元素,HashSet
都会直接插入,只是会在add()
方法的返回值(boolean)处告诉我们插入前是否存在相同元素。
JDK1.8 之前 HashMap
底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode
经过扰动函数(所谓扰动函数指的就是 HashMap 的 hash
方法,使用扰动函数之后可以减少碰撞)处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
- static final int hash(Object key) {
- int h;
- // key.hashCode():返回散列值也就是hashcode
- // ^:按位异或(不同为1,相同为0)
- // >>>:无符号右移,忽略符号位,空位都以0补齐
- return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
- }
将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树(数组长度大于64),以减少搜索时间。
注:红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash
”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方(看下面注解)。
注:我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
JDK1.7 及之前版本的 HashMap
在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap
,因为多线程下使用 HashMap
还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap
。
JDK1.7 及之前版本,在多线程环境下,HashMap
扩容时会造成死循环和数据丢失的问题。死循环在JDK1.8以后通过尾插法解决了;而数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在。
案例:
- public V put(K key, V value) {
- return putVal(hash(key), key, value, false, true);
- }
-
- final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
- boolean evict) {
- // ...
- // 判断是否出现 hash 碰撞
- // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
- if ((p = tab[i = (n - 1) & hash]) == null)
- tab[i] = newNode(hash, key, value, null); // 这个tab[i] = newNode导致数据被覆盖
- // 桶中已经存在元素(处理hash冲突)
- else {
- // ...
- }
还有一种情况是这两个线程同时 put
操作导致 size
的值不正确,进而导致数据覆盖的问题:
if(++size > threshold)
判断时,假设获得 size
的值为 10,由于时间片耗尽挂起。if(++size > threshold)
判断,获得 size
的值也为 10,并将元素插入到该桶位中,并将 size
的值更新为 11。put
操作,但是 size
的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap
中。其中 Units 为 ns/op 意思是执行完成时间(单位为纳秒),而 Score 列为平均执行时间, ±
符号表示误差。从以上结果可以看出,两个 entrySet
的性能相近,并且执行速度最快,接下来是 stream
,然后是两个 keySet
,性能最差的是 KeySet
。
遍历不存在阻塞:
- Benchmark Mode Cnt Score Error Units
- Test.entrySet avgt 5 288.651 ± 10.536 ns/op
- Test.keySet avgt 5 584.594 ± 21.431 ns/op
- Test.lambda avgt 5 221.791 ± 10.198 ns/op
- Test.parallelStream avgt 5 6919.163 ± 1116.139 ns/op
遍历存在阻塞:
- Benchmark Mode Cnt Score Error Units
- Test.entrySet avgt 5 1554828440.000 ± 23657748.653 ns/op
- Test.keySet avgt 5 1550612500.000 ± 6474562.858 ns/op
- Test.lambda avgt 5 1551065180.000 ± 19164407.426 ns/op
- Test.parallelStream avgt 5 186345456.667 ± 3210435.590 ns/op
在多线程中,存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低。
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的 HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。ConcurrentHashMap
已经摒弃了 Segment
的概念,而是直接用 Node
数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized
和 CAS 来操作。(JDK1.6 以后 synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap
,虽然在 JDK1.8 中还能看到 Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本。(具体看5.11)Hashtable
(同一把锁) :使用 synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。JDK 1.7:
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。Segment
数组中的每个元素包含一个 HashEntry
数组,每个 HashEntry
数组属于链表结构。首先将数据分为一段一段(这个“段”就是 Segment
)的存储,然后给每一段数据配一把锁,每个 Segment
守护着一个 HashEntry
数组里的元素,当对 HashEntry
数组的数据进行修改时,必须首先获得对应的 Segment
的锁。也就是说,对同一 Segment
的并发写入会被阻塞,不同 Segment
的写入是可以并发执行的。当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。Segment
继承了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
一个 ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
JDK 1.8:
ConcurrentHashMap
不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
。当冲突链表达到一定长度时,链表会转换成红黑树。ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。综上所述:
Segment
分段锁来保证安全, Segment
是继承自 ReentrantLock
。JDK1.8 放弃了 Segment
分段锁的设计,采用 Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。 ConcurrentHashMap在JDK 1.7之前key 和 value 不能为 null,JDK1.8后则可以。HashMap
在任何JDK版本中都可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。
二义性问题:
ConcurrentHashMap
的 value 为 null时,拿 get 方法取值来说,返回的结果为 null 存在两种情况:
但其实我们在单线程中可以通过containsKey(key)来判断是否存在这个键值对,但是在多线程环境下,存在一个线程操作该 ConcurrentHashMap
时,其他的线程将该 ConcurrentHashMap
修改的情况,所以无法通过 containsKey(key)
来判断否存在这个键值对,也就没办法解决二义性问题了。
ConcurrentHashMap
是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap
多线程操作导致死循环问题。但是,不保证所有的复合操作(如put,get,containKey等复合组成的命令)都是原子性的。
案例:
- // 线程 A
- if (!map.containsKey(key)) {
- map.put(key, value);
- }
- // 线程 B
- if (!map.containsKey(key)) {
- map.put(key, anotherValue);
- }
如果线程 A 和 B 的执行顺序是这样:
那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。
解决方法:
ConcurrentHashMap
提供了一些原子性的复合操作,如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge
等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
注:不建议使用加锁的同步机制(synchronized),违背了使用 ConcurrentHashMap
的初衷。在使用 ConcurrentHashMap
的时候,尽量使用这些原子性的复合操作方法来保证原子性。
参考:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。