如今,多核处理器在服务器,台式机及笔记本电脑上已经很普遍了,同时也被应用在更小的设备上,比如智能手机和平板电脑。这就开启了并发编程新的潜力,因为多个线程可以在多个内核上并发执行。在应用中要实现最大性能的一个重要技术手段是将密集的任务分隔成多个可以并行执行的块,以便可以最大化利用计算能力。
处理并发(并行)程序,一向都是比较困难的,因为你必须处理线程同步和共享数据的问题。对于java平台在语言级别上对并发编程的支持就很强大,这已经在Groovy(GPars), Scala和Clojure的社区的努力下得以证明。这些社区都尽量提供全面的编程模型和有效的实现来掩饰多线程和分布式应用带来的痛苦。Java语言本身在这方面不应该被认为是不行的。Java平台标准版(Java SE) 5 ,和Java SE 6引入了一组包提供强大的并发模块。Java SE 7中通过加入了对并行支持又进一步增强它们。
接下来的文章将以Java中一个简短的并发程序作为开始,以一个在早期版本中存在的底层机制开始。在展示由Java SE7中的fork/join框架提供的fork/join任务之前,将看到java.util.concurrent包提供的丰富的原语操作。然后就是使用新API的例子。最后,将对上面总结的方法进行讨论。
在下文中,我们假定读者具有Java SE5或Java SE6的背景,我们会一路呈现一些Java SE7带来的一些实用的语言演变。
Java中普通线程的并发编程
首先从历史上来看,java并发编程中通过java.lang.Thread类和java.lang.Runnable接口来编写多线程程序,然后确保代码对于共享的可变对象表现出的正确性和一致性,并且避免不正确的读/写操作,同时不会由于竞争条件上的锁争用而产生死锁。这里是一个基本的线程操作的例子:
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("I am running in a separate thread!");
}
};
thread.start();
thread.join();
例子中的代码创建了一个线程,并且打印一个字符串到标准输出。通过调用join()方法,主线程将等待创建的(子)线程执行完成。
对于简单的例子,直接操作线程这种方式是可以的,但对于并发编程,这样的代码很快变得容易出错,特别是好几个线程需要协作来完成一个更大的任务的时候。这种情况下,它们的控制流需要被协调。
例如,一个线程的执行完成可能依赖于其他将要执行完成的线程。通常熟悉的例子就是生产者/消费者的例子,因为如果消费者队列是空的,那么生产者应该等待消费者,并且如果生产者队列是空的,那么消费者也应该等待生产者。该需求可能通过共享状态和条件队列来实现,但是你仍然必须通过使用共享对象上的java.lang.Object.nofity()和java.lang.Object.wait()来实现同步,这很容易出错。
最终,一个常见的错误就是在大段代码甚至整个方法上使用synchronize进行互斥。虽然这种方法能实现线程安全的代码,但是通常由于排斥时间太长而限制了并行性,从而造成性能低下。
在通常的计算过程中,操作低级原语来实现复杂的操作,这是对错误敞开大门。因此,开发者应该寻求有效地封装复杂性为更高级的库。Java SE5提供了那样的能力。
java.util.concurrent包中丰富的资源
Java SE5引入了一个叫java.util.concurrent的包家族,在Java SE6中得到进一步增强。该包家族提供了下面这些并发编程的原语,集合以及特性:
Executors,增强了普通的线程,因为它们(线程)从线程池管理中被抽象出来。它们执行任务类似于传递线程(实际上,是实现了java.util.Runnable的实例被封装了)。好几种实现都提供了线程池和调度策略。而且,执行结果既可以同步也可以异步的方式来获取。
线程安全的队列允许在并发任务中传递数据。一组丰富的实现通过基本的数据结构(如数组链表,链接链表,或双端队列)和并发行为(如阻塞,支持优先级,或延迟)得以提供。
细粒度的超时延迟规范,因为大部分java.util.concurrent包中的类都支持超时延迟。比如一个任务如果没有在有限之间内完成,就会被executor中断。
丰富的同步模式超越了java提供的互斥同步块。这些同步模式包含了常见的俗语,如信号量或同步栅栏。
高效的并发数据集合(maps, lists和sets)通过写时复制和细粒度锁的使用,使得在多线程上下文中表现出卓越的性能。
原子变量屏蔽开发者访问它们时执行同步操作。这些变量包装了通用的基本类型,比如Integers或Booleans,和对象引用。
大量锁超越了内部锁提供的加锁/通知功能,比如,支持重入,读写锁,超时,或者基于轮询的加锁尝试。
作为一个例子,让我们想想下面的程序:
注意:由于Java SE7引入了新的整数字面值,下划线可以在任何地方插入以提高可读性(比如,1_000_000)。
import java.util.*;
import java.util.concurrent.*;
import static java.util.Arrays.asList;
public class Sums {
static class Sum implements Callable<Long> {
private final long from;
private final long to;
Sum(long from, long to) {
this.from = from;
this.to = to;
}
@Override
public Long call() {
long acc = 0;
for (long i = from; i <= to; i++) {
acc = acc + i;
}
return acc;
}
}
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
List <Future<Long>> results = executor.invokeAll(asList(
new Sum(0, 10), new Sum(100, 1_000), new Sum(10_000, 1_000_000)
));
executor.shutdown();
for (Future<Long> result : results) {
System.out.println(result.get());
}
}
}
这个例子程序利用executor
来计算长整形数值的和。内部的Sum类实现了Callable
接口,并被excutors
用来执行结果计算,而并发工作则放在call方法中执行。java.util.concurrent.Executors
类提供了好几个工具方法,比如提供预先配置的Executors
和包装普通的java.util.Runnable
对象为Callable
实例。使用Callable比Runnable
更优势的地方在于Callable可以有确切的返回值。
该例子使用executor分发工作给2个线程。ExecutorService.invokeAll()
方法放入Callable
实例的集合,并且等待直到它们都返回。其返回Future
对象列表,代表了计算的“未来”结果。如果我们想以异步的方式执行,我们可以检测每个Future
对象对应的Callable
是否完成了它的工作和是否抛出了异常,甚至我们可以取消它。相比当使用普通的线程时,你必须通过一个共享可变的布尔值来编码取消逻辑,并且通过定期检查该布尔值来破坏该代码。因为invokeAll()
是阻塞的,我们可以直接迭代Future
实例来获取它们的计算和。
另外要注意executor
服务必须被关闭。如果它没有被关闭,主方法执行完后JVM就不会退出,因为仍然有激活线程存在。
全部文章在这里地址