2.1 线程安全容器类
线程安全容器有同步容器类和并发容器类。
同步容器有早期的Vector和HashTable,以及通过Collections.synchronizedXxx等工厂方法创建的容器。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
同步容器将所有对容器状态的访问都串行化,以实现线程安全性,但这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。
并发容器
并发容器是Java5.0新增的,用来改进同步容器性能的。
ConcurrentHashMap用来替代同步Map;CopyOnWriteArrayList用于在遍历操作为主要操作的情况下代替同步的List。另外还增加了两种新的容器类型:Queue和BlockingQueue。
Queue是非阻塞的队列,如果队列元素为空,则获取元素的操作会返回空值。ConcurrentLinkedQueue是queue的一个实现。BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。
关于并发容器,之前的blog中有过简单介绍——线程安全对象简介。
需要注意的是,并发容器的线程安全性指的容器类的方法具有线程安全性,但在使用并发容器类并涉及到复合操作时仍然是线程不安全的。
举个例子,HashMap的put操作是线程不安全的,因为put操作包含了很多底层逻辑,如计算散列值,判断散列位置上是否已经存在元素,是否需要扩容,是否插入新元素等等。多线程环境下put时这些底层逻辑可能会穿插进行,造成线程不安全。而并发HashMap的put操作则是一个原子操作,从而是线程安全的。但在复合操作时,如读取某个key对应的value,然后对value进行更改并写入。这个操作就不具有原子性了,在多线程情况下就是线程不安全的。
2.2 同步工具类
2.2.1 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态,用来确保某些活动直到其他活动都完成后才继续执行。
CountDownLatch
CountDownLatch是一种灵活的闭锁实现,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,被初始化为一个正数,表示需要等待的事件数量。每发生一个事件就调用一次countDown方法来递减计数器。主线程调用await方法进行阻塞等待,直到计数器为0或等待中的线程中断或等待超时。
对于倒计时器,一种典型的场景就是火箭发射。在发射之前需要做各项设备、仪器的检查,只有当所有检查完毕后,引擎才能点火。使用CountDownLatch模拟这个场景如下所示:
public static void main(String[] args) throws Exception {
CountDownLatch countDownLatch = new CountDownLatch(10);
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(new Random().nextInt(10) * 1000L); // 模拟检查任务
System.out.println(Thread.currentThread().getName() + " check complete");
countDownLatch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
countDownLatch.await();
System.out.println("Fire");
}
2.2.2 FutureTask
FutureTask也可以用做闭锁。
FutureTask是Future的实现类,实现了Future的语义,表示一种抽象的可生成结果的计算。
在线程任务中,Runnable接口实例可以实现无返回的线程任务,而Callable接口实例可以实现有返回值的线程任务,其返回值就放在Future对象中。直接看demo:
public static void main(String[] args) throws Exception {
// 通过5个线程执行任务,并输出最终结果
ExecutorService executorService = Executors.newFixedThreadPool(5);
List<Future<String>> futureList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Future<String> future = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(2000);
return Thread.currentThread().getName();
}
});
futureList.add(future);
}
// for(int i = 0; i < futureList.size();) {
// if(futureList.get(i).isDone()) {
// // 如果执行完成,就删除当前元素,后续元素会往前移;如果未执行完成,当前元素保持不变继续等待循环处理
// System.out.println(futureList.get(i).get());
// futureList.remove(futureList.get(i));
// }
// }
boolean flag = true;
while(flag) {
Iterator<Future<String>> iterator = futureList.iterator();
while(iterator.hasNext()) {
Future<String> future = iterator.next();
if(future.isDone()) {
System.out.println(future.get());
iterator.remove();
}
}
if (futureList.isEmpty()) {
flag = false;
}
}
executorService.shutdown();
}
这里使用遍历的方式解决多任务结果,但是不是最优的效果,FutureTask正是为此而存在,它有一个回调函数protected void done(),当任务结束时,该回调函数会被触发,在该函数中直接调用get方法即可获取到执行结果。因此,只需重载该函数,即可实现在线程刚结束时就做一些事情,demo如下:
static class MyFutureTask extends FutureTask<String> {
public MyFutureTask(Callable<String> callable) {
super(callable);
}
@Override
protected void done() {
System.out.println(get() + "执行完毕!");
}
}
public static void main(String[] args) throws Exception {
// 通过5个线程执行任务,并输出最终结果
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
MyFutureTask myFutureTask = new MyFutureTask(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(2000);
return Thread.currentThread().getName();
}
});
executorService.submit(myFutureTask);
}
executorService.shutdown();
}
2.2.3 信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。Semaphore本质是一个有计数功能的共享锁。
Semaphore维护了一个信号量许可集。线程可以通过acquire()来获取信号量的许可并执行操作,当没有许可时,acquire方法将阻塞等待直到有许可(或被中断或操作超时)为止。release操作将创建一个许可。
许可与线程没有关联关系,因此在一个线程中获得的许可可以在另一个线程中释放。可以将acquire操作视为消费一个许可,而release操作可理解为释放一个许可,但实际上是创建一个许可(也就是说许可数是可以增加的,并不受限于Semaphore创建时的初始许可数量)。
Semaphore可用于实现资源池,如构造一个固定长度的资源池,当池为空时请求资源将会阻塞,直到资源池不为空。同样,Semaphore也可以将任何一种容器变成有界阻塞容器,通过信号量许可数指定容器界限,添加元素时获取许可,移除元素时释放许可。demo如下:
public class BoundHashSet<T> {
private final Set<T> set;
private final Semaphore semaphore;
public BoundHashSet(int capacity) {
this.set = Collections.synchronizedSet(new HashSet<>());
this.semaphore = new Semaphore(capacity);
}
public boolean add(T o) throws InterruptedException {
semaphore.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded) {
semaphore.release();
}
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved) {
semaphore.release();
}
return wasRemoved;
}
}
2.2.4 CyclicBarrier
循环栅栏类似于倒计时器CountDownLatch,用于阻塞一组线程直到某个事件发生。但相比于倒计时器,循环栅栏的功能更为强大,也更复杂。
众所周知,在玩游戏时需要先加载资源,然后才能进入游戏。假设现在有10个人玩一个多人对战游戏,其流程如demo所示:
public class CyclicBarrierDemo {
static class Task implements Runnable {
@Override
public void run() {
try {
Thread.sleep(new Random().nextInt(1000));
System.out.println(Thread.currentThread().getName() + "加载完资源");
Thread.sleep(new Random().nextInt(1000) * 2);
System.out.println(Thread.currentThread().getName() + "进入了游戏");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10; i++) {
Runnable task = new Task();
Thread t = new Thread(task);
t.start();
}
}
}
结果如下:
可以看到,部分人很快就加载完并进入了游戏,另一些人则加载得比较慢,进入游戏时间晚。
玩过游戏的人都知道,实际是不可能这样的。无论某个人加载资源多快,他都得等所有人全部加载完才能进入游戏。
通过循环栅栏可以实现这种需求:
public class CyclicBarrierDemo {
static class Task implements Runnable {
CyclicBarrier cyclicBarrier = null;
public Task(CyclicBarrier cyclicBarrier) {
this.cyclicBarrier = cyclicBarrier;
}
@Override
public void run() {
try {
Thread.sleep(new Random().nextInt(1000) * 2);
System.out.println(Thread.currentThread().getName() + "加载完资源");
cyclicBarrier.await();
Thread.sleep(new Random().nextInt(1000));
System.out.println(Thread.currentThread().getName() + "进入了游戏");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws Exception {
CyclicBarrier cyclicBarrier = new CyclicBarrier(10);
for (int i = 0; i < 10; i++) {
Runnable task = new Task(cyclicBarrier);
Thread t = new Thread(task);
t.start();
}
}
}
运行结果如下:
需要注意的是,CountDownLatch倒计时器是独立于线程任务的,作为一个计数器工具使用。而CyclicBarrier循环栅栏是线程任务的一部分,作为任务对象的属性使用。
闭锁CountDownLatch是一次性对象,一旦进入终止状态就不能被重置,而循环栅栏是可以循环使用的。闭锁用于等待一组相关的操作结束,而循环栅栏用于等待线程。