写在前面: Java
容器内容很多,本文只是简单列举了并发容器以及使用方式,并没有针对源码进行细致地解析。可以把本文作为一个索引,对比了解不同容器之间的异同,再选择使用哪些容器,或详细了解底层原理。
容器简介
最开始的jdk中的容器只有两个Vector
和Hashtable
,他们俩的实现都默认加了synchronized
,且当时的同步锁是重量级锁,显然有很多的问题。然后慢慢发展到现在的容器主要分为两大接口Map
和Collection
。Collection
又分三大类List
、Set
和Queue
。接下来会对涉及到并发的容器一一做简单描述。
从HashTable
到ConcurrentHashMap
下面会基于这样一个场景来描述4种Map
:让100个线程往Map
中扔100万个值,记录Map
count值和写入时间;再让100个线程去读Map
中的值,并记录读取时间。
Hashtable
public class TestHashtable {
static Hashtable<UUID, UUID> m = new Hashtable<>();
static int count = 1000000;
static UUID[] keys = new UUID[count];
static UUID[] values = new UUID[count];
static final int THREAD_COUNT = 100;
static {
for (int i = 0; i < count; i++) {
keys[i] = UUID.randomUUID();
values[i] = UUID.randomUUID();
}
}
static class MyThread extends Thread {
int start;
int gap = count / THREAD_COUNT;
public MyThread(int start) {
this.start = start;
}
@Override
public void run() {
for (int i = start; i < start + gap; i++) {
m.put(keys[i], values[i]);
}
}
}
public static void main(String[] args) {
// write
long start = System.currentTimeMillis();
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < threads.length; i++) {
threads[i] = new MyThread(i * (count / THREAD_COUNT));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println(end - start);
System.out.println(m.size());
// read
start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000000; j++) {
m.get(keys[10]);
}
});
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
end = System.currentTimeMillis();
System.out.println(end - start);
}
}
复制代码
最终输出内容为:
237
1000000
32578
复制代码
HashMap
这个就没什么测试的意义了,很显然count值到达不了1000000,毕竟不是线程安全的。
SynchronizedHashMap
使用Collections.synchronizedMap(new HashMap<>())
试一下;
274
1000000
35984
复制代码
发现效率差不多,再简单看下源码。SynchronizedMap
作为HashMap
的包装类,相比之下多加了个锁final Object mutex;
,然后涉及线程安全的方法会锁定该锁。相比Hashtable
锁定class
对象而言效率上没什么区别。
ConcurrentHashMap
使用ConcurrentHashMap
试一下:
425
1000000
592
复制代码
ConcurrentHashMap
对于读操作的效率有了很大的提升。jdk1.7是对每个分片进行锁定,jdk1.8是用sychronized + CAS
代替了分片,进一步减少了锁的粒度,相比原先锁定整个Map
效率肯定高很多。详细的源码可以自己阅读或查询其他资料。
从Vector
到Queue
假设有这样的场景,10个线程消费装满10000条数据的容器,并且当数据消费完毕后自动退出线程。
ArrayList
或LinkedList
public class TestArrayList {
static List<String> list = new ArrayList<>();
static {
for (int i = 0; i < 10000; i++) {
list.add(i + "");
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (list.size() > 0) {
System.out.println("消费了" + list.remove(0));
}
}).start();
}
}
}
复制代码
最后会输出几个null
值,这是因为最后几个线程在判断list.size() > 0
后其他线程已经把最后几个消费了,那么该线程只能消费null
了。
ArrayList
或LinkedList
加锁
本质和Vector
没区别,都是锁住整个数组或链表,效率上会比较低。
public class TestLock {
static List<String> list = new ArrayList<>();
static {
for (int i = 0; i < 10000; i++) {
list.add(i + "");
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
synchronized (list) {
if (list.size() <= 0) {
break;
}
System.out.println("消费了" + list.remove(0));
}
}
}).start();
}
}
}
复制代码
Vector
现在使用Vector
,由于内部方法很多都加上了synchronized
,所以它是线程安全的。最终输出结果也没什么问题。
public class TestVector {
static Vector<String> vector = new Vector<>();
static {
for (int i = 0; i < 10000; i++) {
vector.add(i + "");
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (vector.size() > 0) {
System.out.println("消费了" + vector.remove(0));
}
}).start();
}
}
}
复制代码
Queue
以Queue
主要就是为了多线程高并发用的,这里以ConcurrentLinkedQueue
为例替换前面代码的list,相比前面的容器队列的效率是最高的:
public class TestQueue {
static Queue<String> queue = new ConcurrentLinkedQueue<>();
static {
for (int i = 0; i < 10000; i++) {
queue.add(i + "");
}
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
String s = queue.poll();
if (s == null) {
break;
}
System.out.println("消费了" + s);
}
}).start();
}
}
}
复制代码
并发容器
ConcurrentMap
Map
经常使用的并发容器:
-
ConcurrentHashMap
:用哈希表实现的高并发容器。如果需要可以用于排序的高并发Map
自然而然会想到ConcurrentTreeMap
。但是由于用CAS操作树时非常复杂,所以并没有实现ConcurrentTreeMap
。排序的需求就由基于跳表的ConcurrentSkipListMap
来实现了。 -
ConcurrentSkipListMap
:通过跳表来实现的高并发容器并且这个Map
是有序的;
CopyOnWrite
CopyOnWrite
即写时复制,主要有两个容器分别是CopyOnWriteList
和CopyOnWriteSet
。CopyOnWriteSet
是基于CopyOnWriteList
实现的。CopyOnWriteList
在jdk8用的是ReentrantLock
,在jdk11中用的是synchronized
(具体哪个版本改的没有考证)。
jdk8:
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();
}
}
复制代码
jdk11:
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
复制代码
可以看到在写的时候会把原先数组复制出来在尾部添加新元素,然后让容器指向新数组。因为每次写都要复制效率较低,最好在读多写少的情况下使用。另外需要注意的是,CopyOnWrite
只能保证最终一致性,无法保证实时的一致性。因为在add
和remove
还没完成时,拿到的数据还是老数据。
BlockingQueue
BlockingQueue
即阻塞队列,提供了一系列方法可以让线程实现自动地阻塞。首先Queue
提供了一系列接口,例如:
add
:向队列中加入元素,加入失败则抛异常offer
:向队列中加入元素,返回布尔值表示是否加入成功peek
:取值,不remove
poll
:取值,并remove
BlockingQueue
又添加了两个方法:
put
:向队列加入元素,队列满了会阻塞take
:取值,队列空了会阻塞
BlockingQueue
的几个实现:
LinkedBlockingQueue
:用链表实现了阻塞队列,是个无界队列。说是无界,其实上限是Integer.MAX_VALUE
。ArrayBlockingQueue
:有界且需要指定容量大小。该方法的特点就是队列容量存在上限,一旦满了put
就会阻塞。DelayQueue
:实现了时间上的排序。存放在该队列的元素需要实现Delayed
接口,即compareTo
和getDelay
方法,用于指定排序规则。该队列本质使用的是PriorityQueue
。PriorityQueue
:内部维护了一个二叉树,存放数据时会先进行排序,将最小的优先放在头部。SynchronousQueue
:容量为0,调用put
会阻塞地等待另一个线程take
,相当于需要手把手将数据传递给另一个线程。该队列在线程调度中应用比较多。TransferQueue
:相比SynchronousQueue
只能传一个值,该队列可以传多个队列。并且提供了新的方法transfer
,可以阻塞地等待对方拿到数据后再进行后续的工作。