1.ConcurrentHashMap
HashMap
HashMap是Java中常用的一种数据结构,实现了Map接口,用于存储键值对。它通过哈希表来存储数据,具有较高的查找效率,查找的时间复杂度在理想情况下无限接近 O(1)。不过,HashMap是非线程安全的,在多线程环境下使用可能会出现数据不一致等问题。
版本演进:
JDK 1.7:
- 数据结构:采用“数组 + 链表”的结构。HashMap的主结构类似于一个数组,添加值时通过key
确定储存位置. 每个位置是一个Entry的数据结构,该结构可组成链表. 当发生冲突时,相同hash值的键值对会组成链表.数组中的每个位置称为一个桶(bucket),当发生哈希冲突时,新的元素会以链表的形式存储在相应桶的位置。
- 插入方式:采用头插法,即新插入的元素会放在链表的头部。
JDK 1.8:
- 数据结构:进化为“数组 + 链表/红黑树”的结构。当链表长度达到一定阈值且数组长度满足条件时,链表会转换为红黑树;当红黑树节点数量减少到一定程度时,红黑树会退化为链表。
- 插入方式:采用尾插法,即新插入的元素会放在链表的尾部。
关键参数:
1. 数组初始长度:HashMap数组的初始长度为 16,这个值定义在源码中:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
2. 扩容因子:扩容因子(Load Factor)默认值为 0.75,其作用是衡量HashMap数组填满程度的指标。当HashMap中元素的数量超过数组长度乘以扩容因子时,就会触发扩容操作:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3. 链表转红黑树阈值:在 JDK 1.8 中,当链表长度达到 8 且数组长度大于等于 64 时,链表会转换为红黑树:
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
4. 红黑树退化为链表阈值:当红黑树节点数量减少到 6 时,红黑树会退化为链表:
static final int UNTREEIFY_THRESHOLD = 6;
HashTable
HashTable 是 Java 早期提供的哈希表实现,它继承自 Dictionary 类并实现了 Map 接口,用于存储键值对。HashTable 采用数组 + 链表的存储结构,数组中的每个位置称为桶(bucket),当发生哈希冲突时,新元素会以链表形式存储在对应桶位置。
HashTable 是线程安全的,这是它的一个重要特性。它通过对整个结构加锁来保证线程安全,具体表现为其大部分关键方法(如 put、get、remove 等)都被 synchronized 关键字修饰。
虽然 HashTable 的加锁机制保证了线程安全,但这种对整个结构加锁的方式带来了明显的性能开销,主要体现在以下方面:
1.并发性能差:同一时间只能有一个线程访问 HashTable 的同步方法,即使不同线程访问的是不同的桶,也需要排队等待锁的释放。这在高并发场景下,会导致大量线程阻塞,系统的并发性能大幅下降。
2.锁竞争激烈:多个线程频繁竞争同一把锁,会增加上下文切换的开销,进一步降低系统性能。例如,在多线程环境下,多个线程同时进行 put 或 get 操作时,由于锁的存在,这些操作无法并行执行,只能依次进行。
我们知道 HashMap 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 Collections.synchronizedMap() 方法来包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。所以就有了 HashMap 的线程安全版本—— ConcurrentHashMap 的诞生。
在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。
ConcurrentHashMap 线程安全的 Juc包下的
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构:
JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment) ,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表/红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
2.CopyOnWriteArrayList
public class CopyOnWriteArrayList<E>
extends Object
implements List<E>, RandomAccess, Cloneable, Serializable
JDK 中提供了 CopyOnWriteArrayList 类比相比于在读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升
ReentrantReadWriteLock 读写锁的思想:读读共享、写写互斥、读写互斥、写读互斥
CopyOnWriteArrayList的原理
CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
从 CopyOnWriteArrayList 的名字就能看出CopyOnWriteArrayList 是满足CopyOnWrite 的 ArrayList,所谓CopyOnWrite 也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了
读取操作的实现
读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
public E get(int index) {
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
final Object[] getArray() {
return array;
}
写入操作的实现
CopyOnWriteArrayList 写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();//加锁
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);//拷贝新数组
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();//释放锁
}
}
3.ConcurrentLinkedQueue
Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。
ConcurrentLinkedQueue这个队列使用链表作为其数据结构
应用场景
ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。
4.BlockingQueue
阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:
示例:简单的优解阻塞队列
package com.thread;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 自定义有界阻塞队列实现
*/
public class BlockingQueue {
private Lock lock = new ReentrantLock();
private Condition full = lock.newCondition();
private Condition empty = lock.newCondition();
private int size;
private volatile int count = 0;
private Node head;
private Node tail;
public BlockingQueue(int size) {
this.size = size;
}
/**
* 向队列中添加元素,如果队列已满则阻塞
* @param value 要添加的元素
* @throws InterruptedException 线程中断异常
*/
public void put(int value) throws InterruptedException {
lock.lockInterruptibly();
try {
// 当队列已满时,当前线程等待
while (count == size) {
full.await();
}
// 创建新节点
Node newNode = new Node(value);
if (tail == null) {
// 如果队列为空,新节点既是头节点也是尾节点
head = tail = newNode;
} else {
// 否则将新节点添加到队尾
tail.next = newNode;
newNode.pre = tail;
tail = newNode;
}
count++;
// 唤醒所有等待出队的线程
empty.signalAll();
} finally {
lock.unlock();
}
}
/**
* 从队列中取出元素,如果队列为空则阻塞
* @return 出队的元素
* @throws InterruptedException 线程中断异常
*/
public int take() throws InterruptedException {
lock.lockInterruptibly();
try {
// 当队列为空时,当前线程等待
while (count <= 0) {
empty.await();
}
// 取出头节点的值
int value = head.value;
if (head.next == null) {
// 如果队列只有一个元素,清空队列
head = tail = null;
} else {
// 否则将头节点指向下一个节点
head = head.next;
head.pre = null;
}
count--;
// 唤醒所有等待入队的线程
full.signal();
return value;
} finally {
lock.unlock();
}
}
/**
* 队列节点类
*/
class Node {
Node next;
Node pre;
int value;
public Node(int value) {
this.value = value;
}
}
public static void main(String[] args) {
BlockingQueue queue = new BlockingQueue(5);
// 示例:可以在这里编写多线程测试代码
}
}
ArrayBlockingQueue
ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:
private static ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(10,true);
所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。
LinkedBlockingQueue
LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE。
/**
*某种意义上的无界队列
* Creates a {@code LinkedBlockingQueue} with a capacity of
* {@link Integer#MAX_VALUE}.
*/
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
/**
*有界队列
* Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
*
* @param capacity the capacity of this queue
* @throws IllegalArgumentException if {@code capacity} is not greater
* than zero
*/
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
PriorityBlockingQueue
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。
默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。
PriorityBlockingQueue 并发控制采用的是 ReentrantLock(可重入锁),队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。
简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
ConcurrentSkipListMap
跳表
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。
跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。
这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
跳表的实现原理
跳表的本质是同时维护了多个链表,并且链表是分层的,
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
跳表的查询
跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如下图所示,在跳表中查找元素 18。
查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。
从上面很容易看出,跳表是一种利用空间换时间的算法。
使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:
哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。