除了上一篇中提到的各类锁之外,JUC包也提供了其他可用于并发场景下的同步工具,包括AtomicInteger等原子操作类、CountDownLatch等并发工具类、ConcurrentHashMap等并发集合类。本篇将会重点讲述这类并发工具的概念与使用方法,并简要介绍线程池的使用方法。
java.util.concurrent.atomic包(简称Atomic包)提供了4种类型、12个类的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
Atomic包里的类基本都是使用Unsafe实现的包装类。
以AtomicInteger为例,常用方法如下:
利用AtomicInteger可以保证并发场景下的安全性,例如编写一个多线程安全的全局唯一ID生成器:
class IdGenerator {
AtomicInteger var = new AtomicInteger(0);
public int getNextId() {
return var.getAndIncrement();
}
}
AtomicInteger如何保证其各个方法属于原子操作呢?主要是通过CAS实现,以getAndIncrement()为例:
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
CAS(Compare and Set)是指,在这个操作中,先检查当前数值是否等于current。如果AtomicInteger的当前值不是cunrrent,就意味着AtomicInteger的值没有被其他线程修改过,则将AtomicInteger的当前数值更新成next的值,如果不等compareAndSet方法会返回false,程序会进入for循环重新进行compareAndSet操作。
JUC包提供了多种有用的并发工具类,其中CountDownLatch、CyclicBarrier和
Semaphore工具类提供了一种并发流程控制的手段,Exchanger工具类则提供了在线程间交换数据的一种手段。
CountDownLatch能够使一个或多个线程在等待另外一些线程完成操作之后,再继续执行。
CountDownLatch的构造函数接收一个int类型的参数作为计数器,传入N就代表等待N个点完成。当调用CountDownLatch的countDown方法时,N就会减1,CountDownLatch的await方法会阻塞当前线程,直到N变成零。
CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。
下面这个Demo展示了创建一个计数器值为3的CountDownLatch对象,创造3个工作线程,每个工作线程在完成操作后各自使CountDownLatch的计数器值减1,而主线程会在等待计数器值归零后继续进行其他操作。
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
// 指定全局唯一的CountDownLatch对象,计数为3
CountDownLatch latch = new CountDownLatch(3);
long start = System.currentTimeMillis();
// 创造3个不同的工作线程,每个线程都持有该latch对象
WorkerThread first = new WorkerThread(1000, latch, "worker-1");
WorkerThread second = new WorkerThread(2000, latch, "worker-2");
WorkerThread third = new WorkerThread(3000, latch, "worker-3");
first.start();
second.start();
third.start();
// await方法会阻塞当前线程,直到计数器latch变成零
latch.await();
// 计数器归零后,主线程继续其他操作
System.out.println(Thread.currentThread().getName() + " has finished. Spend Time = " + (System.currentTimeMillis() - start));
}
// 定义工作线程类,传入CountDownLatch对象
static class WorkerThread extends Thread {
private int delay;
private CountDownLatch latch;
public WorkerThread(int delay, CountDownLatch latch, String name) {
super(name);
this.delay = delay;
this.latch = latch;
}
@Override
public void run() {
try {
Thread.sleep(delay);
// 调用countDown方法使计数器值减1
latch.countDown();
System.out.println(Thread.currentThread().getName() + " finished");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
和synchronized、Condition中的wait方法、await方法一样,可以使用await(long time,TimeUnit unit)指定等待特定时间后,就会不再阻塞当前线程。
最后,上面的例子通过Thread.sleep()方法避免各个线程同时修改CountDownLatch,在许多情况下可能需要对调用countDown方法做同步处理,例如:
public class Parallellimit {
public static void main(String[] args) {
ExecutorService pool = Executors.newCachedThreadPool();
CountDownLatch cdl = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
CountRunnable runnable = new CountRunnable(cdl);
pool.execute(runnable);
}
}
}
class CountRunnable implements Runnable {
private CountDownLatch countDownLatch;
public CountRunnable(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
synchronized (countDownLatch) {
/*** 每次减少一个容量*/
countDownLatch.countDown();
System.out.println("thread counts = " + (countDownLatch.getCount()));
}
countDownLatch.await();
System.out.println("concurrency counts = " + (100 - countDownLatch.getCount()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier),可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
CyclicBarrie的构造方法包括CyclicBarrier(int parties)和CyclicBarrier(int parties,Runnable barrierAction),parties参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier我已经到达了屏障,然后当前线程被阻塞,可以类比为CountDownLatch的计数器初始值;而第二个构造方法中的barrierAction参数可以用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。
下面的这个例子中,我们创建了一个CyclicBarrier拦截4个不同的线程,并且在所有线程都已经到达屏障时,打印最后一个到达屏障的线程的名称以及其他信息:
public class CyclicBarrierDemo {
static class TaskThread extends Thread {
CyclicBarrier barrier;
public TaskThread(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println(getName() + " 到达栅栏 A");
barrier.await();
System.out.println(getName() + " 冲破栅栏 A");
Thread.sleep(2000);
System.out.println(getName() + " 到达栅栏 B");
barrier.await();
System.out.println(getName() + " 冲破栅栏 B");
Thread.sleep(3000);
System.out.println(getName() + " 到达栅栏 C");
barrier.await();
System.out.println(getName() + " 冲破栅栏 C");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
int threadNum = 4;
CyclicBarrier barrier = new CyclicBarrier(threadNum, new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 完成最后任务");
}
});
for(int i = 0; i < threadNum; i++) {
new TaskThread(barrier).start();
}
}
}
最终输出结果如下:
Thread-1 到达栅栏 A
Thread-2 到达栅栏 A
Thread-3 到达栅栏 A
Thread-0 到达栅栏 A
Thread-0 完成最后任务
Thread-0 冲破栅栏 A
Thread-1 冲破栅栏 A
Thread-3 冲破栅栏 A
Thread-2 冲破栅栏 A
Thread-0 到达栅栏 B
Thread-2 到达栅栏 B
Thread-1 到达栅栏 B
Thread-3 到达栅栏 B
Thread-3 完成最后任务
Thread-3 冲破栅栏 B
Thread-2 冲破栅栏 B
Thread-0 冲破栅栏 B
Thread-1 冲破栅栏 B
Thread-2 到达栅栏 C
Thread-0 到达栅栏 C
Thread-1 到达栅栏 C
Thread-3 到达栅栏 C
Thread-3 完成最后任务
Thread-0 冲破栅栏 C
Thread-3 冲破栅栏 C
Thread-2 冲破栅栏 C
Thread-1 冲破栅栏 C
可以看到我们让CyclicBarrier调用了3次await方法,事实上形成了3个不同的屏障。这里也能看出CyclicBarrier和CountDownLatch的一大区别,即CyclicBarrier 是可循环利用的。二者之间的差别可以总结如下:
CountDownLatch | CyclicBarrier |
---|---|
一次性的 | 可循环利用的 |
是线程组之间的等待,即一个(或多个)线程等待N个线程完成某件事情之后再执行;各个线程的职责是不一样的,有的在倒计时,有的在等待倒计时结束 | 是线程组内的等待,即每个线程相互等待,即N个线程都被拦截之后,然后依次执行;各个线程职责是一样的 |
计数器由使用者控制 | 计数器由自己控制 |
线程调用await方法只是将自己阻塞而不会减少计数器的值 | 线程调用await方法不仅会将自己阻塞还会将计数器减1 |
构造方法中的barrierAction参数可以用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景 | |
提供其他有用的方法,比如getNumberWaiting方法可以获得Cyclic-Barrier阻塞的线程数量。isBroken()方法用来了解阻塞的线程是否被中断 |
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
例如,我们希望只有10个线程能够进入临界区,可以创造一个许可证数量为10的Semaphore对象,线程在进入临界区时调用Semaphore对象的acquire方法,在离开临界区时调用其release方法;其中:
acquire方法:线程进入临界区时,需要获取许可证,如果许可证数量大于0,则使许可证数量减1,并且使线程进入临界区;否则线程将进入等待状态;
release方法:线程离开临界区时,需要归还许可证,使许可证数量加1。
public class SemaphoreTest {
private static final int THREAD_COUNT = 30;
private static ExecutorServicethreadPool = Executors.newFixedThreadPool(THREAD_COUNT);
private static Semaphore s = new Semaphore(10);
public static void main(String[] args) {
for (inti = 0; i< THREAD_COUNT; i++) {
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
s.acquire();
System.out.println("save data");
s.release();
} catch (InterruptedException e) {
}
}
});
}
threadPool.shutdown();
}
}
在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法
Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。
Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
在下面的例子中,我们让两个线程分别输入不同的数据,并且通过exchange方法进行互相比较。
public class ExchangerTest {
private static final Exchanger<String> exgr = new Exchanger<String>();
private static ExecutorServicethreadPool = Executors.newFixedThreadPool(2);
public static void main(String[] args) {
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
String A = "银行流水A"; // A录入银行流水数据
exgr.exchange(A);
} catch (InterruptedException e) {
}
}
});
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
String B = "银行流水B"; // B录入银行流水数据
String A = exgr.exchange(B);
System.out.println("A和B数据是否一致:" + A.equals(B) + ",A录入的是:" + A + ",B录入是:" + B);
} catch (InterruptedException e) {
}
}
});
threadPool.shutdown();
}
}
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe)。比如一些不变类String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类是线程安全的;再例如Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
不过在这里,我们重点关注JUC包针对List、Map、Set、Deque等集合提供的并发集合类,可以归纳如下:
集合 | 非线程安全的类 | JUC提供的线程安全的类 | 其他线程安全的类 |
---|---|---|---|
List | ArrayList | CopyOnWriteArrayList | Vector/Collections.synchronizedList |
Map | HashMap | ConcurrentHashMap | HashTable |
Set | HashSet | CopyOnWriteArraySet | |
Queue | ArrayDeque / LinkedList | ConcurrentLinkedQueue | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
使用这些并发集合与使用非线程安全的集合类完全相同。以ConcurrentHashMap为例:
Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");
虽然java.util.Collections工具类还提供了一个旧的线程安全集合转换器,例如Collections.synchronizedMap,但事实上是用一个包装类包装了非线程安全的Map,然后对所有读写方法都用synchronized加锁,性能很低;HashTable、Vector也是采用了相同的设计,性能低下。因此原则上更推荐使用JUC包提供的并发集合类。
HashTable容器针对全表加锁,性能较低。而ConcurrentHashMap使用锁分段技术使得在线程安全的前提下实现更高的性能。首先将数据分成一段一段地存储(即不同的Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
一个ConcurrentHashMap里包含1个Segment数组(默认大小为16),Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色,守护着一个HashEntry数组里的元素。
每个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,用于存储键值对数据。当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。
由于ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到某个Segment,然后再定位到其下的HashEntry。ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列,这样可以减少散列冲突,使元素能够均匀地分布在不同的Segment上,从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。
先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素,代码如下:
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
整个get过程不需要加锁,除非读到的值是空才会加锁重读。原因是它的get方法里需要使用的共享变量都被定义为volatile类型,例如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值。
在定位元素的代码里可以发现,定位Segment使用的是元素的hashcode通过再散列后得到的值的高位,而定位HashEntry直接使用的是再散列后的值。其目的是避免两次散列后的值一样。
put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必
须加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。
在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进
行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。
尽管Segment里的全局变量count是一个volatile变量,相加时
可以获取每个Segment的count的最新值,但是如果直接将各个Segment中的count简单相加,可能累加前使用的count发生了变化,那么统计结果就不准了。
ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。非阻塞的实现方式则可以使用循环CAS的方式来实现。
ConcurrentLinkedQueue是一个基于链接节点的无界的、线程安全的、先进先出队列,采用了CAS来实现。
ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成。默认情况下head节点存储的元素为空,tail节点等于head节点。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法:
1)支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)支持阻塞的移除方法:在队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
JDK 7提供了7个阻塞队列,如下。