一、同步容器(线程安全还不够)
同步容器保证了装入容器数据访问的线程安全,也就是说无论多少个线程同时调用,都不会破坏容器、容器内数据的安全性。
虽然做到了线程安全,容器的复合操作:迭代、跳转、运算仍会带来新问题。因为同步容器保证了容器内数据的安全,可不能保证反馈数据的正确性。因为多线程中的调用者不合时宜的调用,经常会导致调用异常(然而仍然是线程安全,并没有破坏vector)。
这就像什么呢,容器虽然是安全的,人为操作容器仍需要安全规范。
例如查找index来遍历线程安全容器Vector的操作:
public class VectorExample {
private static Vector list = new Vector();
public static void initVector() {
for(int i=0; i<10; i++) {
list.addElement(new Integer(i));
}
}
public static Object getLast() {
return list.get(list.size()-1);
}
public static void deleteLast() {
list.remove(list.size()-1);
}
}
此时,如果线程A调用deleteLast( ),线程B同时getLast( )。线程B调用list.size的索引值将不再有效(过时),这就势必抛出ArrayIndexOutOfBoundsException异常。
若用迭代器Iterator遍历Vector则实现了改良,引入了“fail-fast”机制。fail-fast即在遍历某个集合时,不得同时修改该集合,否则马上抛出异常:
public class VectorExample {
private static Vector list = new Vector();
public static void main(String[] args) {
for(int i=0; i<10; i++) {
list.addElement(new Integer(i));
}
Iterator listIte = list.iterator();
int count = 0;
while(listIte.hasNext()) {
if(count == 2)
list.remove(2);
System.out.println(listIte.next());
count++;
}
}
}
遍历第三个元素,抛出ConcurrentModificationException异常:
如何保证不抛出异常的安全遍历呢?加锁是一个看似不错的机制:
public class VectorExample {
private static Vector list = new Vector();
public static void initVector() {
synchronized(list) {
for(int i=0; i<10; i++) {
list.addElement(new Integer(i));
}
}
}
public static Object getLast() {
synchronized(list) {
return list.get(list.size()-1);
}
}
public static void deleteLast() {
synchronized(list) {
list.remove(list.size()-1);
}
}
}
这样,不管有几个线程,getLast( )和deleteLast( )函数执行前都必须先申请得到容器list上的锁。也就保证了,同一时刻遍历、修改操作只能有一个能发生。
然而实际情况是,如果对于list容器的操作很多,每个操作都需要等待list锁会造成两个问题:
(1)饿死,某个线程持有锁的时间很长,许多线程竞争list锁就更激烈。导致多线程其他操作不能执行,程序“假死”。
(2)吞吐量降低,许多线程都只等待一个锁被释放,将极大降低吞吐量、CPU利用率,其执行效率甚至远低于单线程。
那么既然加锁不是适合方案,另一个替代方案是“克隆容器”。也就是说,我们复制list容器。每个线程保有一份list容器的副本,在副本上进行迭代、修改、提交。这样虽然大大减少了线程间竞争,却显著提高了内存的开销,也不是一个非常可取的方案。
二、并发容器(加速同步容器的遍历访问)
因为修改、遍历元素的原子操作,也就是同一时刻只能有一个线程操作容器,同步容器对容器状态的操作“串行”。串行提高了线程安全性,却严重降低了并发性。
例如HashMap,通过hashcode取余来分布散列值。因为hashcode的随机性,糟糕的散列函数会导致元素并不是在Map容器里均匀分布。最严重的,所有元素都发生碰撞冲突,一个散列表变成了活生生的线性链表。那么这时候原始的同步容器访问会花费很长时间。
ConcurrentHashMap
HashMap是java.util.包下的集合类,ConcurrentHashMap是java.util.concurrent包下的。它们都同样是扩展了Map接口,并用Hash来进行散列分布。
不同的是,ConcurrentHashMap利用了粒度更细的“分段锁”。Java 1.8及以前,ConcurrentHashMap使用了包含了默认16个私有锁对象的数组,每个锁保护所有散列桶的1/16, 第N个散列桶由第N mod 16的锁来保护。因而,ConcurrentHashMap能够支持16个并发的写入器。
根据分段锁的概念,我们很容易写出StripedMap(ConcurrentHashMap实现简化版):
public class StripedMap {
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node{ }
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for(int i=0; i<N_LOCKS; i++) {
locks[i] = new Object();
}
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized(locks[hash % N_LOCKS]) {
for(Node m = buckets[hash]; m != null; m = m.next) {
if(m.key.equals(key))
return m.value;
}
}
}
}
分段锁也有其天然的缺点:当分段锁数量变多,访问获取锁的线程也大量增加时,获取多个锁来实现独占访问会更困难,且开销更高。
CopyOnWriteArrayList
CopyOnWriteArrayList即“写入时复制”,印证了之前所提的“克隆容器”的概念。此种方案避免了对容器的加锁机制,也取消了迭代遍历时的复制,而只对容器发生修改操作才创建并重新发布一个新的容器副本,并发性能大大提高。
但是这样的应用范围也相当有限,因为只有迭代访问操作远远多于修改操作时,才适用“写入时复制”容器。
BlockingQueue
BlockingQueue即阻塞队列,它是一个接口。其中主要提供了6种抽象函数,分别满足阻塞、非阻塞的读写操作。
offer(E e):非阻塞入队,若Queue没满,立即返回true; 如果Queue已满,立即返回false;
put(E e):阻塞入队,若Queue已满,则一直阻塞至队列不满/线程被中断(interrupted)
offer(E e, long timeout, TimeUnit unit):阻塞入队,若Queue已满则进入等待,直到出现以下三种情况之一中断:
a. 其他线程被唤醒;
b. 等待时间超时,Queue仍满;注意timeout为时间单位个数,unit为单位时间度量。例如timeout = 100, unit = 0.02s,那么等待时间为2s;
c. 当前线程被中断;
poll():非阻塞出队,若Queue为空直接返回null;如果有元素则出队;
take():阻塞出队,若Queue为空一直阻塞至Queue不为空/线程被中断;
poll(long timeout, TimeUnit unit):阻塞出队,若Queue为空则进入等待,直到出现以下三种情况:
a. 其他线程被唤醒;
b. 等待时间超时;
c. 当前线程被中断;
BlockingQueue体现的是设计模式中的“生产者消费者模式” ,其场景主要是一个线程生产对象,而另外一个线程消费这些对象。负责生产的线程将会持续生产新对象并将其插入到BlockingQueue中,直到阻塞队列达到队长上限。如果该BlockingQueu e达到了队长上限(临界点),生产线程插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象;同理,负责消费的线程一直从该BlockingQueue中拿出对象。如果消费线程尝试去从一个空队列中提取对象的话,这个消费线程将会处于阻塞之中,直到生产线程把一个对象丢进队列。
例如,我们可以手写一个BlockingQueue的应用案例:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueExample {
public static void main(String[] args) throws InterruptedException {
BlockingQueue queue = new ArrayBlockingQueue(1000);
Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);
new Thread(producer).start();
new Thread(consumer).start();
Thread.sleep(3000);
}
}
class Producer implements Runnable{
protected BlockingQueue mQueue = null;
Producer(BlockingQueue queue){
mQueue = queue;
}
@Override
public void run() {
// TODO Auto-generated method stub
try {
mQueue.put("1");
Thread.sleep(1000);
mQueue.put("2");
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
class Consumer implements Runnable{
protected BlockingQueue mQueue = null;
Consumer(BlockingQueue queue){
mQueue = queue;
}
@Override
public void run() {
// TODO Auto-generated method stub
try {
System.out.println(mQueue.take().toString());
System.out.println(mQueue.take().toString());
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
ArrayBlockingQueue
在案例中,我们用到了ArrayBlockingQueue。顾名思义,这是用数组形式实现的BlockingQueue。ArrayBlockingQueu e是一种有界缓存区。因为数组初始化需要指定其容量,一旦创建就不能扩大容量。
也许ArrayBlockingQueue因为不能扩容,所以设计初衷便是特定的高并发需求。ArrayBlockingQueue所有阻塞入队、出队操作都共用一把ReentrantLock。申请lock前需要满足两个条件:
(1)集合不为空
(2)集合不为满
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ArrayBlockingQueueDemo<E> {
private final E[] items;
private int takeIndex;
private int putIndex;
private int count;
private final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
public ArrayBlockingQueueDemo(int capacity) {
this(capacity,false);
}
public ArrayBlockingQueueDemo(int capacity, boolean fair) {
if(capacity <= 0)
throw new IllegalArgumentException();
this.items = (E[])new Object[capacity];
this.lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
private void insert(E x) {
items[putIndex] = x;
putIndex = putIndex++;
++count;
notEmpty.signal();
}
private E extract() {
final E[] items = this.items;
E x = items[takeIndex];
items[takeIndex] = null;
takeIndex = takeIndex++;
--count;
notFull.signal();
return x;
}
public void put(E e)throws InterruptedException{
if(e == null)
throw new NullPointerException();
final E[] items = this.items;
final ReentrantLock lock = this.lock;
lock.lockInterruptibly(); //获取可中断锁
try {
try {
while(count == items.length)
notFull.await();
}catch(InterruptedException exception) {
notFull.signal();
throw exception;
}
insert(e);
}finally {
lock.unlock();
}
}
public E take() throws InterruptedException{
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
try {
while(count == 0)
notEmpty.await();
}catch(InterruptedException exception) {
notEmpty.signal();
throw exception;
}
E x = extract();
return x;
}finally {
lock.unlock();
}
}
... //offer操作和poll操作在此不作赘述
}
自然,ArrayBlockingQueue的特性不适合大数据量、入队出队都需要高并发的场景。共用一把ReentrantLock锁会导致生产线程和消费线程的竞争,数组实现会让容量比较有限。于是,另一种BlockingQueue的形式应运而生。
LinkedBlockingQueue
LinkedBlockingQueue,顾名思义,即链表形式实现的BlockingQueue。对比链表和数组的插入、删除速度,加上链表不用指定初始化容量(理论上属于无界缓存区,但是容量终究是一个有限值),LinkedBlockingQueue往往更受项目使用者青睐。
此外,LinkedBlockingQueue用两把ReentrantLock(文中的takeLock和putLock)分别管理入队、出队,减少了生产者、消费者之间的竞争,高并发的性能要大大优于ArrayBlockingQueue。
以下便是LinkedBlockingQueue的源码实现:
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class LinkedBlockingQueueDemo<E> {
private final int capacity;
private final AtomicInteger count = new AtomicInteger(0);
private transient Node<E> head;
private transient Node<E> last;
private final ReentrantLock takeLock = new ReentrantLock();
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
private final Condition notFull = putLock.newCondition();
static class Node<E>{
volatile E item;
Node<E> next;
Node(E x){
item = x;
}
}
public LinkedBlockingQueueDemo() {
this(Integer.MAX_VALUE);
}
public LinkedBlockingQueueDemo(int capacity) {
if(capacity <= 0)
throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
private void insert(E x) {
last = last.next = new Node<E>(x);
}
private E extract() {
Node<E> first = head.next;
head = first;
E x = first.item;
first.item = null;
return x;
}
private void signalNotFull() {
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
notFull.signal();
}finally {
putLock.unlock();
}
}
private void signalNotEmpty() {
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
notEmpty.signal();
}finally {
takeLock.unlock();
}
}
public void put(E e)throws InterruptedException{
if(e == null)
throw new NullPointerException();
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
try {
while(count.get() == capacity)
notFull.await();
}catch(InterruptedException exception) {
notFull.signal();
throw exception;
}
insert(e);
c = count.getAndIncrement();
if(c+1 < capacity)
notFull.signal();
}finally {
putLock.unlock();
}
if(c == 0)
signalNotEmpty();
}
public E take() throws InterruptedException{
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
try {
while(count.get() == 0)
notEmpty.await();
}catch(InterruptedException exception) {
notEmpty.signal();
throw exception;
}
x = extract();
c = count.getAndIncrement();
if(c>1)
notEmpty.signal();
}finally {
takeLock.unlock();
}
if(c == capacity)
signalNotFull();
return x;
}
...
}
BlockingDeque
自Java1.6开始,Deque和BlockingDeque也被加入容器类中。Deque/BlockingDeque实际是对Queue/BlockingQueue的一个扩展。Deque是一个双端队列,以外普通Queue是队尾插入、队头取出满足FIFO原则(First In First Out);Deque则均可在队头、队尾进行高效插入、移除。
阻塞双端队列在阻塞队列的生产者/消费者模式上,新增加了一条特性叫”工作密取“。传统的生产者/消费者模式,消费者、生产者共用一条工作队列,相比来说生产线就很繁忙。”工作密取“模式为,每个消费者维系自己的一个双端工作队列,减少了竞争机制。而且,如果当前消费者完成了自己的任务,可以访问其他消费者双端队列的队尾元素(访问队头则会发生碰撞冲突),提高CPU利用率。
此外,”工作密取“非常适合生产者/消费者为同一线程的问题,因为强制分开生产线程、消费线程不一定能把问题简单化。例如:当执行某个工作,会导致更多工作出现;网页爬虫处理一个网页过程时,需要先处理其他更多页面。工作密取”模式背后体现出的“享元模式”也值得继续深入探究。