引 言
容器是Java基础类库中使用频率最高的一部分,Java集合包中提供了大量的容器类来帮组我们简化开发,最简单的就是通过synchronized关键字将所有使用到非线程安全的容器代码全部同步执行。这种方式虽然可以达到线程安全的目的,但存在几个明显的问题:首先是在编码上存在一定的复杂性;再者就是在竞争激烈的环境下,性能可能达不到我们的预期。在JDK1.5中为我们提供了一系列的并发容器,集中在java.util.concurrent包下,用来解决这两个问题。
首先,我们先来看一下Java中的同步容器。
同步容器
同步容器可以理解为通过synchronized来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。同步容器包括Vector、Hashtable、Collections.synchronizedXXX(List/Set/Map)等工厂方法创建的类。
Vector和HashTable这两个容器的实现和早期的ArrayList和HashMap代码实现基本一样,不同在于Vector和HashTable在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问,部分源码如下:
Vector
public synchronized boolean add(E e) { }
public synchronized E remove(int index) { }
public synchronized E get(int index) { }
HashTable
public synchronized int size() { }
public synchronized boolean contains(Object value) { }
public synchronized V put(K key, V value) { }
接下来我们通过几个简单的例子来对比Vector与ArrayList,HashTable与HashMap在线程安全性上的差别。
- Vector与ArrayList
先看下ArrayList:(ps:下面代码演示的例子是5000个线程去累计计数的)
@Slf4j
public class ArrayListTest {
// 请求总数
public static int clientTotal = 5000;
// 同时并发执行的线程数
public static int threadTotal = 200;
// ArraryList
private static List<Integer> list = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService exe = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(threadTotal);
final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
for (int i = 0; i < clientTotal; i++) {
final int count = i;
exe.execute(() -> {
try {
semaphore.acquire();
update(count);
semaphore.release();
} catch (Exception e) {
log.error("execption", e);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
exe.shutdown();
log.info("size:{}", list.size());
}
private static void update(int i) {
list.add(i);
}
}
测试结果:size:4936
再运行几次,size还是达不到我们的期望值——5000 (ps:我们如果想到达我们的期望值,可以在update()方法加上synchronized关键字)
我们再演示一下Vector,还是同样的例子,稍稍修改一下上面的代码:
// 把ArrayList改成Vector
private static Vector<Integer> vector = new Vector<>();
再运行几次,我们看下测试结果:size:5000
从上面的测试来看,我们可以得出这样一个结论:在没有手动做同步处理的情况下,ArrayList是线程不安全的,Vector是线程安全的
- HashTable与HashMap
public class HashMapTest {
// 请求总数
public static int clientTotal = 5000;
// 同时并发执行的线程数
public static int threadTotal = 200;
// HashMap
private static Map<Integer,Integer> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService exe = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(threadTotal);
final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
for (int i = 0; i < clientTotal; i++) {
final int count = i;
exe.execute(() -> {
try {
semaphore.acquire();//是否允许被执行
update(count);
semaphore.release();
} catch (Exception e) {
log.error("execption", e);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
exe.shutdown();
log.info("size:{}", map.size());
}
private static void update(int i) {
map.put(i,i);
}
}
测试结果:size:4857
再运行几次,size还是达不到我们的期望值——5000
我们把HashMap换成HashTable测试一下
// 把HashMap换成HashTable
private static Map<Integer,Integer> table = new Hashtable<>();
再运行几次,我们看下测试结果:size:5000
由此可见:HashMap是线程不安全的,HashTable是线程安全的
原因我们已经说过了:Vector和HashTable在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问
3. Collections.SynchronizedXXX(List/Set/Map)
我们再来看一下Collections.SynchronizedXXX等通过工厂方法创建的类,首先给出结论:这种方式创建的类是线程安全的
还是上面累计计数的例子,我们稍作一些修改:
分别把 ArrayList换成Collections.SynchronizedList/Collections.SynchronizedSet/Collections.SynchronizedMap
// 由工厂方法创建的List
private static List<Integer> list = Collections.synchronizedList(Lists.newArrayList());
//由工厂方法创建的Set
private static Set<Integer> set = Collections.synchronizedSet(Sets.newHashSet());
// 由工厂方法创建的Map
private static Map<Integer,Integer> map = Collections.synchronizedMap(Maps.newHashMap());
分别对上面的例子多运行几次。测试结果总能达到我们的预期:size:5000
所以说:这种方式创建的类是线程安全的
我们以Collections.synchronizedMap为例,看下部分源码实现
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public int size() {
synchronized (mutex) {
return m.size();
}
}
public V put(K key, V value) {
synchronized (mutex) {
return m.put(key, value);
}
}
看到内部方法实现都带有了synchronized (mutex),此时就明白了这种工厂方法创建的类是线程安全的吧。
同步容器虽然能够保证线程的安全性,但是如果在操作频繁、数据量极大、资源竞争激烈的环境下,就很难保证性能。所以,并发容器就出场了,接下来,我们来看下并发容器。
并发容器
常见的并发容器主要有:CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentSkipListSet、ConcurrentHashMap、ConcurrentSkipListMap.
CopyOnWrite:写时复制容器是一种常用的并发容器,它通过多线程下读写分离来达到提高并发性能的目的,这种方式的好处显而易见:通过copy一个新的容器来进行修改,这样读操作就不需要加锁,可以并发读,因为在读的过程中是采用的旧的容器,即使新容器做了修改对旧容器也没有影响,同时也很好的解决了迭代过程中其他线程修改导致的并发问题。
以CopyOnWriteArrayList为例,看其内部源码实现:读操作是不需要加锁的,写操作是利用了ReentrantLock。
// get()方法
public E get(int index) {
return get(getArray(), index);
}
// 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();
}
}
// set()方法
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
还是上面累计计数的例子,我们稍作一下修改:
// new CopyOnWriteArrayList()
private static List<Integer> list = new CopyOnWriteArrayList<>();
多运行几次,测试结果:size:5000
再修改成CopyOnWriteArraySet来测试一下:
// new CopyOnWriteArraySet()
private static Set<Integer> list = new CopyOnWriteArraySet<>();
测试结果也是一致的:size:5000
我们接下来看一下ConcurrentSkipListMap
ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景。
ConcurrentSkipListMap和TreeMap,它们虽然都是有序的哈希表,但是也有很大的区别,一是它们的线程安全机制不同,TreeMap是非线程安全的,而ConcurrentSkipListMap是线程安全的;二是ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。
什么是跳表呢?请看下面这张图:
在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。 但ConcurrentSkipListMap有几个ConcurrentHashMap 不能比拟的优点:
- ConcurrentSkipListMap 的key是有序的。
- ConcurrentSkipListMap 支持更高的并发。ConcurrentSkipListMap的存取时间是log(N),和线程数几乎无关。也就是说在数据量一定的情况下,并发的线程越多,ConcurrentSkipListMap越能体现出他的优势。
在非多线程的情况下,应当尽量使用TreeMap。此外对于并发性相对较低的并行程序可以使用Collections.synchronizedSortedMap将TreeMap进行包装,也可以提供较好的效率。对于高并发程序,应当使用ConcurrentSkipListMap,能够提供更高的并发度。
所以在多线程程序中,如果需要对Map的键值进行排序时,请尽量使用ConcurrentSkipListMap,可能得到更好的并发度。
ConcurrentSkipListMap主要用到了Node和Index两种节点的存储方式,通过volatile关键字实现了并发的操作,源码如下:
// Node是最底层的链表的节点,包括键值对和指向下一个节点的指针
static final class Node<K,V> {
final K key;
volatile Object value;
volatile Node<K,V> next;
Node(K key, Object value, Node<K,V> next) {
this.key = key;
this.value = value;
this.next = next;
}
Node(Node<K,V> next) {
this.key = null;
this.value = this;
this.next = next;
}
// 索引节点结构,存储了两个指针,分别指向右边和下边的节点
// 索引节点的value为链表节点
static class Index<K,V> {
final Node<K,V> node;
final Index<K,V> down;
volatile Index<K,V> right;
Index(Node<K,V> node, Index<K,V> down, Index<K,V> right) {
this.node = node;
this.down = down;
this.right = right;
}
// 索引层的头节点结构
// 在索引节点的基础上添加了表示层数的level变量
static final class HeadIndex<K,V> extends Index<K,V> {
final int level;
HeadIndex(Node<K,V> node, Index<K,V> down, Index<K,V> right, int level) {
super(node, down, right);
this.level = level;
}
}
ConcurrentSkipListMap的put()方法是通过调用doPut()方法实现的,源码如下:
// put()方法
public V put(K key, V value) {
if (value == null)
throw new NullPointerException();
return doPut(key, value, false);
}
//doPut()方法
private V doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z; // added node
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) { //查找前继节点
if (n != null) { //查找到前继节点
Object v; int c;
Node<K,V> f = n.next; //获取后继节点的后继节点
if (n != b.next) //发生竞争,两次节点获取不一致,并发导致
break;
if ((v = n.value) == null) { // 节点已经被删除
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)
break;
if ((c = cpr(cmp, key, n.key)) > 0) { //进行下一轮查找,比当前key大
b = n;
n = f;
continue;
}
if (c == 0) { //相等时直接cas修改值
if (onlyIfAbsent || n.casValue(v, value)) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
break; // restart if lost race to replace value
}
// else c < 0; fall through
}
z = new Node<K,V>(key, value, n); //9. n.key > key > b.key
if (!b.casNext(n, z)) //cas修改值
break; // restart if lost race to append to b
break outer;
}
}
int rnd = ThreadLocalRandom.nextSecondarySeed(); //获取随机数
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
while (((rnd >>>= 1) & 1) != 0) // 获取跳表层级
++level;
Index<K,V> idx = null;
HeadIndex<K,V> h = head;
if (level <= (max = h.level)) { //如果获取的调表层级小于等于当前最大层级,则直接添加,并将它们组成一个上下的链表
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
}
else { // try to grow by one level //否则增加一层level,在这里体现为Index<K,V>数组
level = max + 1; // hold in array and later pick the one to use
@SuppressWarnings("unchecked")Index<K,V>[] idxs =
(Index<K,V>[])new Index<?,?>[level+1];
for (int i = 1; i <= level; ++i)
idxs[i] = idx = new Index<K,V>(z, idx, null);
for (;;) {
h = head;
int oldLevel = h.level;
if (level <= oldLevel) // lost race to add level
break;
HeadIndex<K,V> newh = h;
Node<K,V> oldbase = h.node;
for (int j = oldLevel+1; j <= level; ++j) //新添加的level层的具体数据
newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
if (casHead(h, newh)) {
h = newh;
idx = idxs[level = oldLevel];
break;
}
}
}
// 逐层插入数据过程
splice: for (int insertionLevel = level;;) {
int j = h.level;
for (Index<K,V> q = h, r = q.right, t = idx;;) {
if (q == null || t == null)
break splice;
if (r != null) {
Node<K,V> n = r.node;
// compare before deletion check avoids needing recheck
int c = cpr(cmp, key, n.key);
if (n.value == null) {
if (!q.unlink(r))
break;
r = q.right;
continue;
}
if (c > 0) {
q = r;
r = r.right;
continue;
}
}
if (j == insertionLevel) {
if (!q.link(r, t))
break; // restart
if (t.node.value == null) {
findNode(key);
break splice;
}
if (--insertionLevel == 0)
break splice;
}
if (--j >= insertionLevel && j < level)
t = t.down;
q = q.down;
r = q.right;
}
}
}
return null;
}
跳表生产索引的源码可能比较晦涩难懂,我们可以这样简单理解(笔者的水平有限,大家也可以去google一下相关资料再去理解一下)
- 获取前继节点后通过CAS来插入节点
- 对level层数进行判断,如果大于最大层数,则插入一层
- 第三步插入对应层的数据
回归我们本文最开始的那个小例子,修改成ConcurrentSkipListMap
// new ConcurrentSkipListMap<>();
private static Map<Integer,Integer> map = new ConcurrentSkipListMap<>();
测试结果: size:5000
这是在插入过程全部通过CAS自旋的方式保证并发情况下的数据正确性
ConcurrentSkipListSet是通过ConcurrentSkipListMap实现的,它的接口基本上都是通过调用ConcurrentSkipListMap接口来实现的,所以我们这里就不再对它的源码进行分析了!
最后我们再来看一下ConcurrentHashMap
在底层数据结构上,ConcurrentHashMap和HashMap都使用了数组+链表+红黑树的方式,只是在HashMap的基础上添加了并发相关的一些控制,所以这里只对ConcurrentHashMap中并发相关代码做一些分析,直接上源码:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); //计算桶的hash值
int binCount = 0;
//循环插入元素,避免并发插入失败
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果当前桶无元素,则通过cas操作插入新节点
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
//如果当前桶正在扩容,则协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//hash冲突时锁住当前需要添加节点的头元素,可能是链表头节点或者红黑树的根节点
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
在put元素的过程中,有几个并发处理的关键点:
- 如果当前桶对应的节点还没有元素插入,通过典型的无锁cas操作尝试插入新节点,减少加锁的概率,并发情况下如果插入不成功,很容易想到自旋,也就是for (Node<K,V>[] tab = table;;)
- 如果当前桶正在扩容,则协助扩容((fh = f.hash)==MOVED)。这里是一个重点,ConcurrentHashMap的扩容和HashMap不一样,它在多线程情况下或使用多个线程同时扩容,每个线程扩容指定的一部分hash桶,当前线程扩容完指定桶之后会继续获取下一个扩容任务,直到扩容全部完成。扩容的大小和HashMap一样,都是翻倍,这样可以有效减少移动的元素数量,也就是使用2的幂次方的原因,在HashMap中也一样。
- 在发生hash冲突时仅仅只锁住当前需要添加节点的头元素即可,可能是链表头节点或者红黑树的根节点,其他桶节点都不需要加锁,大大减小了锁粒度。
后续我们还会对ConcurrentHashMap()进行单独的一篇文章进行分析,本文就介绍到这了。
回归本文最开始的计数小例子,把ArrayList换成ConcurrentHashMap
// new ConcurrentHashMap<>();
private static Map<Integer,Integer> map = new ConcurrentHashMap<>();
测试结果: size:5000
通过ConcurrentHashMap添加元素的过程,W我们知道了ConcurrentHashMap容器是通过CAS + synchronized一起来实现并发控制的