《Java高并发程序设计》总结--5. 并行模式与算法

来源:互联网 发布:决策树源码 编辑:程序博客网 时间:2024/06/05 05:52
1. 探讨单例模式
它是一种对象创建模式,用于产生一个对象的具体实例,确保系统中一个类只有一个实例。这样带来的好处主要有两点: 
1. 对于频繁使用的对象,可以省略new操作花费的时间,这样对于那些重量级对象而言,可以节省非常可观的一笔系统开销。
2. 由于new操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻GC压力,缩短GC停顿时间。
下面给出了一个单例的实现,这个实习生非常简单的,但无疑是一个正确并且良好的实现。
public class Singleton {
private Singleton() {
System.out.println("Singleton is create");
}
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
若想精确控制instance的创建时间,具体实现如下:
public class LazySingleton {
private LazySingleton() {
System.out.println("LazySingleton is create");
}
private static LazySingleton instance = null;
public static synchronized LazySingleton getInstance() {
if(instance == null)
instance = new LazySingleton();
return instance;
}
}
将上述两种方法优势结合的方法:
public class StaticSingleton {
private StaticSingleton () {
System.out.println("StaticSingleton is create");
}
private static class SingletonHolder {
private static StaticSingleton instance = new StaticSingleton();
}
public static StaticSingleton getInstance() {
return SingletonHolder.instance;
}
}
上述代码实现了一个单例,并且同时拥有前两种方式的优点。首先getInstance()方法中没有锁,这使得在高并发环境下性能优越。其次,只有在getInstance()方法被第一次调用时,StaticSingleton的实例才会被创建。因为这种方法巧妙地使用了内部类和类的初始化方式。内部类SingletonHolder被申明为private,这使得我们不可能在外部访问并且初始化它。而我们只可能在getInstance()内部对SingletonHolder类进行初始化,利用虚拟机的类初始化机制创建单例。

2. 不变模式
在并行软件的开发过程中,同步操作似乎是不可避免的,当多线程对同一个对象进行读写操作时,为了保证数据一致性和正确性,有必要对对象进行同步。而同步操作对系统的性能是有相当的损耗的,为了尽可能的取出这些同步操作,提高程序并行能力,可以使用一种不可变对象,依靠对象的不变性 
可以确保其在没有同步操作的多线程环境中依然时钟保持内部状态一致性和正确性,这就是不变模式。
不变模式天生就是多线程友好的,它的核心思想是,一个对象一旦被创建,则它的状态将永远不会发生改变。所以,没有一个线程可以修改其内部状态和数据,同时其内部状态也绝不会自行发生改变。基于这些特性,对不变对象的多线程操作不需要进行同步控制。
不变模式和只读模式是有一定区别的。不变模式是比只读属性具有更强一致性和不变性。对只读属性的对象而言,对象本身不能被其他线程修改,但是对象的自身状态却可能自行修改。
不变模式的主要使用场景需要满足2个条件:
1. 当前对象创建后,其内部状态和数据不再发生任何变化。
2. 对象需要被共享,被多线程频繁访问。
在Java语言中,不变模式的实现很简单。为确保对象被创建后,不发生任何改变,并保证不变模式正常工作,只需注意以下4点:
1. 取出所有setter方法及所有修改自身属性的方法.。
2. 将所有属性设置为私有,并用final修饰,确保其不可修改。
3. 确保没有子类可以重载修改它的行为,即final class。 
4. 有一个可以创建完整对象的构造函数。
以下代码实现了一个不变的产品对象,它拥有序列号、名称和价格三个属性。
public final class Product {
private final String no;
private final String name;
private final double price;

public Product(String no, String name, double price) {
super();
this.no = no;
this.name = name;
this.price = price;
}
public String getNo() {
return no;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
在不变模式的实现中,final关键字起到了重要的作用。对属性的final定义确保所有数据只能在对象被构造时赋值1次。之后,就永远不再发生变化。而对class的final确保了类不会有子类。
在JDK中,不变模式的应用非常广泛。其中,最为典型的就是java.lang.String类。此外,所有的元数据类包装类,都是使用不变模式实现的。主要的不变模式类型如下:
java.lang.String
java.lang.Boolean
java.lang.Byte
java.lang.Character
java.lang.Double
java.lang.Float
java.lang.Integer
java.lang.Long
java.lang.Short
由于基本数据类型和String类型在实际的软件开发中应用及其广泛,使用不变模式后,所有实例的方法均不需要进行同步操作,保证了它们在多线程环境下的性能。

3. 生产者-消费者模式
生产者-消费者是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。在生产者-消费者模式中,通常有两类线程,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费者线程负责具体处理生产者提交的任务。生产者和消费者之间则通过共享内存缓冲进行通信。 
生产者-消费者模式的核心组件是共享内存缓冲区,它作为生产者消费者间的通信桥梁,避免了两者直接通信,从而将生产者和消费者进行解耦。生产者不需要知道消费者存在,消费者也不需要知道生产者的存在。
同时,由于内存缓冲区的存在,允许生产者和消费者在执行速度上存在时间差,无论谁快谁慢,都可以通过共享缓冲区得到缓解,确保系统稳定允许。
生产者-消费者模式主要角色如下表所示。
角色作用生产者用于提交用户请求,提取用户任务,并装入内存缓冲区消费者在内存缓冲区提取并处理任务内存缓冲区缓存生产者提交的任务或数据,供消费者使用任务生产者向内存缓冲区提交的数据结构Main使用生产者和消费者的客户端
实现一个基于生产者-消费者模式的求整数平方的并行程序。
首先,生产者线程的实现如下,它构建PCData对象,并放入BlockingQueue队列中。
public class Producer implements Runnable{
private volatile boolean isRunning = true;
private BlockingDeque<PCData> queue; //内存缓冲区,通过构造时外部引入,保证和消费者用的是同样的内存缓冲区.
private static AtomicInteger count = new AtomicInteger(); //总数,原子操作.
private static final int SLEEPTIME = 1000;

public Producer(BlockingDeque<PCData> queue) {
this.queue = queue;
}

@Override
public void run() {
PCData data = null;
Random random = new Random();
System.out.println("start producter .."+Thread.currentThread().getId());
try {
while (isRunning){
Thread.sleep(random.nextInt(SLEEPTIME)); //模拟执行过程
data = new PCData(count.incrementAndGet()); //现获取当前值再+1
System.out.println(data + " is put into Queue");
//提交数据到缓冲队列中.设定等待的时间,如果在指定的时间内,还不能往队列中加入BlockingQueue,则返回失败
if (!queue.offer(data,2, TimeUnit.SECONDS)){
System.out.println("failed to put data "+data);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
//因为BlockingQueue的offer操作上的锁是重入锁中的可以中断的锁,所以如果有异常,就中断,防止死锁.
Thread.currentThread().interrupt();
}
}
public void stop(){
isRunning = false;
}
}
对应的消费者线程的实现如下。它从BlockingQueue队列中取出PCData对象,并进行相应的计算。
public class Consumer implements Runnable {
private BlockingDeque<PCData> queue;
private static final int SLEEPTIME = 1000;
//同理,和Producter共用同一个BlockingQueue,保证存/取都在一个缓冲区
public Consumer(BlockingDeque<PCData> queue) {
this.queue = queue;
}
@Override
public void run() {
System.out.println("start Consumer id : "+Thread.currentThread().getId());
Random r = new Random();
try {
while (true){
PCData data = queue.take();
if (null != data){
int re = data.getIntData() * data.getIntData();
System.out.println(MessageFormat.format("{0} * {0} = {1}",data.getIntData(),re));
Thread.sleep(r.nextInt(SLEEPTIME));
}
}
}catch (InterruptedException e){
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
PCData作为生产者和消费者之间的共享数据模型,定义入下:
public class PCData {
private final int intData;
public PCData(int d) {
intData = d;
}
public PCData(String d){
intData = Integer.parseInt(d);
}
public int getIntData() {
return intData;
}
@Override
public String toString() {
return "PCData{" +
"intData=" + intData +
'}';
}
}
在主函数中,创建三个生产者和消费者,并让它们协作运行。在主函数的实现中,定义LinkedBlockingQueue作为BlockingQueue的实现类。
public class Main {
public static void main(String[] a) throws InterruptedException {
//建立共享缓冲区
BlockingDeque<PCData> queue = new LinkedBlockingDeque<>(10);
//建立生产者
Producer producter1 = new Producer(queue);
Producer producter2 = new Producer(queue);
Producer producter3 = new Producer(queue);
Producer producter4 = new Producer(queue);
Producer producter5 = new Producer(queue);
//建立消费者
Consumer consumer1 = new Consumer(queue);
Consumer consumer2 = new Consumer(queue);
Consumer consumer3 = new Consumer(queue);
//建立线程池
ExecutorService es = Executors.newCachedThreadPool();
//运行生产者
es.execute(producter1);
es.execute(producter2);
es.execute(producter3);
es.execute(producter4);
es.execute(producter5);
//运行消费者
es.execute(consumer1);
es.execute(consumer2);
es.execute(consumer3);
//运行时间
Thread.sleep(1000 * 10);
//停止生产者
producter1.stop();
producter2.stop();
producter3.stop();
producter4.stop();
producter5.stop();
//停止生产者后,预留时间给消费者执行
Thread.sleep(1000 * 5);
System.out.println("关闭线程池...");
//关闭线程池
es.shutdown();
}
}

4. 高性能的生产者-消费者:无锁的实现
BlockingQueue实现生产者-消费者是一个不错的选择,它很自然地实现了作为生产者和消费者的内存缓冲区。但是,BlockingQueue并不是一个高性能的实现,它完全使用锁和阻塞等待来实现线程间的同步。在高并发场合,它的性能并不是特别优越。 就像我们之前提过的ConcurrentLinkedQueue是一个高性能的队列,但是BlockingQueue只是为了方便数据共享。而ConcurrentLinkedQueue的秘诀就是大量使用了无锁的CAS操作。同理,如果我们使用了CAS来实现生产者-消费者模式,也同样可以获得可观的性能提升。
1)无锁的缓存框架:Disruptor
Disruptor框架是由于LMAX公司开发的一款高效的无锁内存队列,它使用无锁的方式实现了一个环形队列,非常适合生产者-消费者模式。在Disruptor中,使用了环形队列来代替普通的线性队列,这个环形队列内部实现为一个普通的数组。对于一般的队列,势必要提供队列头部head和尾部tail两个指针,用于出队和入队,这样无疑就增加了线程协作的复杂度。但如果队列的环形的,则只需要提供一个当前队列的位置cursor,利用这个cursor既可以出队也可以入队。由于是环形队列的缘故,队列的总大小必须事先指定,不能动态扩展。为了能够快速从一个序列sequence对应数组的实际位置(每次有元素入队,序列就加1),Disruptor要求我们必须将数组的大小设置为2的整数次方。这样通过sequence&(queueSize-1)就能立即定位到实际的元素位置index。这个要比取余(%)操作快得多。
如图所示,显示了RingBuffer的结构,生产者向缓冲区中写入数据,而消费者从中读取数据,生产者写入数据使用CAS操作,消费者读取数据时,为了防止多个消费者处理同一个数据,也使用CAS操作进行保护。
这种固定大小的环形队列的另一个好处就是可以做到完全内存复用。在系统运行过程中,不会有新的空间需要分配或者老的空间需要回收。因此,可以大大减少系统分配空间以及回收空间的额外开销。

2)用Disruptor实现生产者-消费者案例
首先,我们需要一个代表数据的PCData:
public class PCData {
private long value;
public void set(long value) {
this.value = value;
}
public long get() {
return value;
}
}
消费者实现为WorkHandler接口,它来着Disruptor框架:
public class Consumer implements WorkHandler<PCData> {
@Override
public void onEvent(PCData event) throws Exception {
System.out.println(Thread.currentThread().getId() + ":Event: --" +
event.get() * event.get() + "--");
}
}
消费者的作用是读取数据进行处理。这里,数据的读取已经由Disruptor进行封装,onEvent()方法为框架的回调方法。因此,这个只需要简单地进行数据处理即可。
还需要一个产生PCData的工厂类。它会在Disruptor系统初始化时,构造所有的缓冲区中的对象实例:
public class PCDataFactory implements EventFactory<PCData>{
@Override
public PCData newInstance() {
return new PCData();
}
}
接下来,看一下生产者:
public class Producer {
private final RingBuffer<PCData> ringBuffer;
public Producer(RingBuffer<PCData> ringBuffer) {
this.ringBuffer = ringBuffer;
}
public void pushData(ByteBuffer byteBuffer){
long sequence = ringBuffer.next();
try {
PCData event = ringBuffer.get(sequence);
event.set(byteBuffer.getLong(0));
} finally {
ringBuffer.publish(sequence);
}
}
}
生产者需要一个RingBuffer的引用,也就是环形缓冲区。它有一个重要的方法pushData()将产生的数据推入缓冲区。方法pushData()接收一个ByteBuffer对象。在ByteBuffer中可以用来包装任何数据类型。pushData()的功能就是将传入的ByteBuffer中的数据提取出来,并装载到环形缓冲区中。
上述第12行代码,通过next()方法得到下一个可用的序列号。通过序列号,取得下一个空闲可用的PCData,并且将PCData的数据设为期望值,这个值最终会传递给消费者。最后,在第21行,进行数据发布。只有发布后的数据才会真正被消费者看见。
至此,我们的生产者、消费者和数据都已经准备就绪。只差一个统筹规划的主函数将所有内容整合起来:
public static void main(String[] args) throws InterruptedException {
Executor executor = Executors.newCachedThreadPool();
//PCDataFactory factory = new PCDataFactory();
EventFactory<PCData> factory = new EventFactory<PCData>() {
@Override
public PCData newInstance() {
return new PCData();
}
};
//设置缓冲区大小,一定要是2的整数次幂
int bufferSize = 1024;
WaitStrategy startegy = new BlockingWaitStrategy();
//创建disruptor,它封装了整个Disruptor的使用,提供了一些便捷的API.

Disruptor<PCData> disruptor = new Disruptor<PCData>(factory, bufferSize, executor, ProducerType.MULTI, startegy);
//设置消费者,系统会将每一个消费者实例映射到一个系统中,也就是提供4个消费者线程.
disruptor.handleEventsWithWorkerPool(new Consumer(),
new Consumer(),
new Consumer(),
new Consumer());
//启动并初始化disruptor系统.
disruptor.start();
RingBuffer<PCData> ringBuffer = disruptor.getRingBuffer();
//创建生产者
Producer productor = new Producer(ringBuffer);
ByteBuffer byteBuffer = ByteBuffer.allocate(8);
//生产者不断向缓冲区中存入数据.
for (long l=0;true;l++){
byteBuffer.putLong(0,l);
productor.pushData(byteBuffer);
Thread.sleep(new Random().nextInt(500));
System.out.println("add data "+l);
}
}

3)提高消费者的响应时间:选择合适的策略
Disruptor为我们提供了几个策略,这些策略由WaitStrategy接口进行封装。
1. BlockingWaitStrategy:默认策略。和BlockingQueue是非常类似的,他们都使用了Lock(锁)和Condition(条件)进行数据监控和线程唤醒。因为涉及到线程的切换,BlockingWaitStrategy策略是最省CPU的,但在高并发下性能表现是最差的一种等待策略。
2. SleepingWaitStrategy:这个策略也是对CPU非常保守的。它会在循环中不断等待数据。它会先进行自旋等待,如果不成功,则使用Thread.yield()让出CPU,并最终使用LockSupport.parkNanos(1)进行线程休眠,以确保不占用太多的CPU数据。因此,这个策略对于数据处理可能产生比较高的平均延时。适用于对延时要求不是特别高的场合,好处是他对生产者线程的影响最小。典型的场景是异步日志。
3. YieldWaitStrategy:用于低延时场合。消费者线程会不断循环监控缓冲区变化,在循环内部,它会使用Thread.yield()让出CPU给别的线程执行时间。如果需要高性能系统,并且对延迟有较高要求,则可以考虑这种策略。这种策略相当于消费者线程变成了一个内部执行Thread.yield()的死循环,
因此最好有多于消费者线程的逻辑CPU(“双核四线程”中的四线程),否则整个应用会受到影响。
4. BusySpinWaitStrategy:疯狂等待策略。它就是一个死循环,消费者线程会尽最大努力监控缓冲区的变化。它会吃掉CPU所有资源。所以只在非常苛刻的场合使用它。因为这个策略等同于开一个死循环监控。因此,物理CPU数量必须大于消费者线程数。因为如果是逻辑核,那么另外一个逻辑核必然会受到这种超密集计算的影响而不能正常工作。

4)CPU Cache的优化:解决伪共存问题
我们知道,为了提高CPU的速度,CPU有一个高速缓存Cache。在高速缓存中,读写数据的最小单位是缓存行(Cache Line),它是主内存(memory)复制到 缓存(Cache)的最小单位,一般为32~128byte(字节)。
假如两个变量存放在同一个缓存行中,在多线程访问中,可能互相影响彼此的性能。如图,运行在CPU1上的线程更新了X,那么CPU2伤的缓存行就会失效,同一行的Y即使没有修改也会变成无效,导致Cache无法命中。接着,如果在CPU2上的线程更新了Y,则导致CPU1上的缓存行又失效(此时,同一行的X)。这无疑是一个潜在的性能杀手,如果CPU经常不能命中缓存,那么系统的吞吐量会急剧下降。
为了使这种情况不发生,一种可行的做法就是在X变量前后空间都占据一定的位置(暂叫padding,用来填充Cache Line)。这样,当内存被读入缓存中时,这个缓存行中,只有X一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体失效的情况。


public class FalseSharing implements Runnable {
public final static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
static {
for(int i=0; i<longs.length; i++) {
longs[i] = new VolatileLong();
}
}
public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(String[] args) throws Exception {
final long start = System.currentTimeMillis();
runTest();
System.out.println("duration = " + (System.currentTimeMillis() - start));
}
private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for(int i=0; i<threads.length; i++) {
threads[i] = new Thread(new FalseSharing(i));
}
for(Thread t : threads) {
t.start();
}
for(Thread t : threads) {
t.join();
}
}
@Override
public void run() {
long i = ITERATIONS + 1;
while(0 != --i) {
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6, p7;
}
}
在VolatileLong中,准备了7个long型变量用来填充缓存。实际上,只有VolatileLong.value是会被使用的。而那些p1、p2等仅仅用于将数组中第一个VolatileLong.value是会被使用的。而那些p1、p2等仅仅用于将数组第一个VolatileLong.value和第二个VolatileLong.value分开,防止它们进入同一个缓存行。
Disruptor框架充分考虑了这个问题,它的核心组件Sequence会被非常频繁的访问(每次入队,它都会被加1),其基本结构如下:
class LhsPadding
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding
{
protected volatile long value;
}
class RhsPadding extends Value
{
protected long p9, p10, p11, p12, p13, p14, p15;
}
public class Sequence extends RhsPadding {
//省略具体实现
}
虽然在Sequence中,主要使用的只有value。但是,通过LhsPadding和RhsPadding,在这个value的前后安置了一些占位空间,使得value可以无冲突的存在于缓存中。
此外,对于Disruptor的环形缓冲区RingBuffer,它内部的数组是通过以下语句构造的:
this.entries = new Object[sequencer.getBufferSize() + 2 * BUFFER_PAD];
实际产生的数组大小是缓冲区实际大小再加上两倍的BUFFER_PAD。这就相当于在这个数组的头部和尾部两段各增加了BUFFER_PAD个填充,使得整个数组被载入Cache时不会受到其他变量的影响而失效。

5. Future模式
它的核心是异步调用,如果我们不着急要结果,可以让被调用者立刻返回,随后让它在后台慢慢处理这个请求,对于调用者来说则可以处理其他任务,在真正需要数据的场合再去尝试获得需要的结果。


1)Future模式的主要角色
参与者作用Main系统启动,调用Client发出请求Client返回Data对象,立即返回FutureData,并开始ClientThread线程装配RealDataData返回数据的接口FutureDataFuture数据,构造很快,但是一个虚拟的数据,需要装配RealDataFutureRealData真实的数据,其构造比较慢
它的核心结构如图所示。

2)Future模式的简单实现
在这个实现中,有一个核心接口Data,这就是客户端希望获取的数据。在Future模式中,这个Data接口有两个重要的实现,分别是RealData,也就是真是数据,这就是我们最终需要获得的,有价值的信息。另外一个就是FutureData,它就是用来提取RealData的一个“订单”。因此FutureData是可以立即返回得到。
下面是Data接口:
public interface Data {
public String getResult();
}
FutureData实现了一个快速返回的RealData包装。它只是一个包装,或者说是一个RealData的虚拟实现。因此,它可以很快被构造并返回。当使用RealData准备好并注入到FutureData中,才最终返回数据。
//FutureData是Future模式的关键,它实际上是真实数据RealData的代理,封装了获取RealData的等待过程
publicclassFutureDataimplementsData {
    RealData realData =null;//FutureData是RealData的封装
    booleanisReady = false//是否已经准备好
     
    publicsynchronizedvoidsetRealData(RealData realData) {
        if(isReady)
            return;
        this.realData = realData;
        isReady =true;
        notifyAll();//RealData已经被注入到FutureData中了,通知getResult()方法
    }
 
    @Override
    publicsynchronizedString getResult() throwsInterruptedException {
        if(!isReady) {
            wait();//一直等到RealData注入到FutureData中
        }
        returnrealData.getResult();
    }
}
RealData是最终需要使用的数据模型。它的构造很慢。在这里,使用sleep()函数模拟这个过程,简单地模拟一个字符串的构造。
publicclassRealDataimplementsData {
    protectedString data;
 
    publicRealData(String data) {
        //利用sleep方法来表示RealData构造过程是非常缓慢的
        try{
            Thread.sleep(1000);
        }catch(InterruptedException e) {
            e.printStackTrace();
        }
        this.data = data;
    }
 
    @Override
    publicString getResult() {
        returndata;
    }
}
接下来就是我们的客户端程序,Client主要实现了获取FutureData,并开启构造RealData的线程。并在接受请求后,很快的返回FutureData。注意,它不会等待将数据真的构造完毕再返回,而是立即返回FutureData,即使这个时候FutureData并没有真实数据。
publicclassClient {
    publicData request(finalString string) {
        finalFutureData futureData = newFutureData();
         
        newThread(newRunnable() {
            @Override
            publicvoidrun() {
                //RealData的构建很慢,所以放在单独的线程中运行
                RealData realData =newRealData(string);
                futureData.setRealData(realData);
            }
        }).start();
        returnfutureData;//先直接返回FutureData
    }
}
最后,就是主函数Main,它主要负责调用Client发起请求,并消费返回的数据。
public static void main(String[] args) {
Client client = new Client();
Data data = client.request("name");
System.out.println("请求完毕");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
System.out.println("数据 = " + data.getResult());
}

3)JDK中的Future模式
Future模式的基本结构,其中Future接口就类似于订单或者说是契约。通过它,可以得到真实的数据。RunnableFuture继承了Future和Runnable两个接口,其中run()方法用于构造真实的数据。它有一个具体的实现FutureTask类。FutureTask有一个内部的Sync,一些实质性工作,会委托Sync类实现。而Sync类最终会调用Callable接口,完成实际数据的组装工作。
Callable()接口只有一个方法call(),它会返回需要构造的实际数据。这个Callable接口也是这个Future框架和应用程序之间的重要接口。如果我们要实现自己的业务系统,通常需要实现自己的Callable对象。此外,FutureTask类也与应用程序密切相关,通常,我们会使用Callable实例构造一个FutureTask实例,并将它提交给线程池。
下面将展示内置的Future模式的使用:
public class JRealData implements Callable<String> {
private String para;
public JRealData(String para) {
this.para = para;
}
@Override
public String call() throws Exception {
StringBuffer sb = new StringBuffer();
for(int i=0; i<10; i++) {
sb.append(para);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
return sb.toString();
}
}
上述代码实现了Callable接口,它的call()方法会构造我们需要的真实数据并返回。当然这个过程可以是缓慢的,这里使用Thread.sleep()模拟它:
public class FutureMain {
public static void main(String[] args) throws InterruptedException, ExecutionException {
FutureTask<String> future = new FutureTask<>(new JRealData("a"));
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.submit(future);
System.out.println("请求完毕");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
System.out.println("数据 = " + future.get());
}
}
上述代码就是使用Future模式的典型。第4行,构造了FutureTask对象实例,表示这个任务是有返回值的。构造FutureTask时,使用Callable接口,告诉FutureTask我们需要的数据应该如何产生。接着,第8行,将FutureTask提交给线程池。显然,作为一个简单任务的提交,这里必然是立即返回的,因此程序不会阻塞。接下来,不用关心数据是如何产生的。可以去做一些额外的事情,然后在需要的时候就可以通过Future.get()得到实际的数据。
除了基本的功能外,JDK还为Future接口提供了一些简单的控制功能:
boolean cancel(boolean mayInterruptIfRunning); //取消任务
boolean isCancelled(); //是否已经取消
boolean isDone(); //是否已经完成
V get() throws InterruptedException,ExecutionException //取得返回对象
V get(long timeout, TimeUnit unit); //取得返回对象,可以设置超时时间

6. 并行流水线
并发算法虽然可以充分发挥多核CPU的性能,但并非所有的计算都可以改造成并发形式。执行过程中有数据相关性的运算都是无法完美并行化的。
假如现在有两个数,B和C。如果要计算(B+C)*B/2,那么这个运算过程就是无法并行的。原因是,如果B+C没有执行完成,则永远算不出(B+C)*B,这就是数据相关性。
遇到这种情况,可以借鉴日常生产中的流水线思想。
类似的思想可以借鉴到程序开发中。即使(B+C)*B/2无法并行,但是如果需要计算一大堆B和C,可以将它流水话。首先将计算过程拆分为三个步骤:
P1:A=B+C
P2:D=AxB
P3:D=D/2
上述步骤中P1、P2和P3均在单独的线程中计算,并且每个线程只负责自己的工作。此时,P3的计算结果就是最终需要的答案。
P1接收B和C的值,并求和,将结果输入P2。P2求乘积后输入给P3。P3将D除以2得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到(B+C)*B/2的结果。
为了实现这个功能,需要定义一个在线程间携带结果进行信息交换的载体:
public class Msg {
public double i;
public double j;
public String orgStr = null;
}
P1计算的是加法:
public class Plus implements Runnable {
public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while(true) {
try {
Msg msg = bq.take();
msg.j = msg.i + msg.j;
Multiply.bq.add(msg);
} catch (InterruptedException e) {
}
}
}
}
上述代码中,P1取得封装了两个操作数的Msg,并进行求和,将结果传递给乘法线程P2。当没有数据需要处理时,P1进行等待。
P2计算乘法:
public class Multiply implements Runnable {
public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while(true) {
try {
Msg msg = bq.take();
msg.i = msg.i * msg.j;
Div.bq.add(msg);
} catch (InterruptedException e) {
}
}
}
}
和P1非常类似,P2计算相乘结果后,将中间结果传递给除法线程P3。
P3计算除法:
public class Div implements Runnable {
public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>();
@Override
public void run() {
while(true) {
try {
Msg msg = bq.take();
msg.i = msg.i / 2;
System.out.println(msg.orgStr + "=" + msg.i);
} catch (InterruptedException e) {
}
}
}
}
P3将结果除以2后输出最终结果。
最后是提交任务的主线程,这里,提交100万个请求,让线程组进行计算:
public class PStreamMain {
public static void main(String[] args) {
new Thread(new Plus()).start();
new Thread(new Multiply()).start();
new Thread(new Div()).start();
long s1 = System.currentTimeMillis();
for(int i=1; i<=1000; i++) {
for(int j=1; j<=1000; j++) {
Msg msg = new Msg();
msg.i = i;
msg.j = j;
msg.orgStr = "((" + i + "+" + j + ")*" + i + ")/2";
Plus.bq.add(msg);
}
}
}
}
上述代码中,将数据提交给P1加法线程,开启流水线的计算。在多核或者分布式场景中,这种设计思路可以有效地将有依赖关系的操作分配在不同的线程中进行计算,尽可能利用多核优势。

7. 并行搜索
对于有序数据,通常可以采用二分查找法。对于无序数据,则只能挨个查找。在本节中,我们将讨论有关并行的无序数组的搜索实现。
给定一个数组,我们要查找满足条件的元素。对于串行程序来说,只要遍历一下数组就可以得到结果。但如果要使用并行方式,则需要额外增加一些线程间的通信机制,使各个线程可以有效地运行。
一种简单的策略就是将原始数据集合按照期望的线程数进行分割。每个线程各自独立搜索,当其中一个线程找到数据后,立即返回结果即可。
现在假设有一个整数数组,我们需要查找数组内的元素:
static int[] arr;
定义线程池、线程数量以及存放结果的变量result。在result中,我们会保存符合条件的元素在arr数组中的下标。默认为-1,表示没有找到给定元素。
public static final int THREADNUM = 2;
static ExecutorService pool = Executors.newCachedThreadPool();
static AtomicInteger result = new AtomicInteger(-1);
并发搜索会要求每个线程查找arr中的一段,因此,搜索函数必须指定线程需要搜索的起始和结束位置:
public static int search(int searchValue,int beginPos,int endPos) {
int i = 0;
for(i=beginPos; i<endPos; i++) {
if(result.get() >= 0) {
return result.get();
}
if(arr[i] == searchValue) {
if(!result.compareAndSet(-1, i)) {
return result.get();
}
return i;
}
}
return -1;
}
上述代码中,首先通过result判断是否已经有其他线程找到了需要的结果。如果已经找到,则立即返回不再进行查找。如果没有找到,则进行下一步搜索。第7行代码成立则表示当前线程找到了需要的数据,那么就会将结果保存到result变量中。这里使用CAS操作,如果设置失败,则表示其他线程已经先我一步找到了结果。因此,可以无视失败的情况,找到结果后,进行返回。
定义一个线程进行查找,它会调用前面的search()方法:
public static class SearchTask implements Callable<Integer> {
int begin,end,searchValue;
public SearchTask(int searchValue,int begin,int end) {
this.begin = begin;
this.end = end;
this.searchValue = searchValue;
}
@Override
public Integer call() throws Exception {
int re = search(searchValue,begin,end);
return re;
}
}
最后是pSearch()并行查找函数,它会根据线程数量对arr数组进行划分,并建立对应的任务提交给线程池处理:
public static int pSearch(int searchValue) throws InterruptedException,ExecutionException {
int subArrSize = arr.length/THREADNUM+1;
List<Future<Integer>> re = new ArrayList<Future<Integer>>();
for(int i=0; i<arr.length; i+=subArrSize) {
int end = i + subArrSize;
if(end>=arr.length) end = arr.length;
re.add(pool.submit(new SearchTask(searchValue, i, end)));
}
for(Future<Integer> fu : re) {
if(fu.get() >= 0) return fu.get();
}
return -1;
}
上述代码中使用了JDK内置的Future模式,其中4~8行将原始数组arr划分为若干段,并根据划分结果建立子任务。每一个子任务都会返回一个Future对象,通过Future对象可以获得线程组得到的最终结果。在这里,由于线程之间通过result共享彼此的信息,因此只要当一个线程成功返回后,其他线程都会立即返回。因此,不会出现由于排在前面的任务长时间无法结束而导致整个搜索结果无法立即获取的情况。

8. 并行排序
1)分离数据相关性:奇偶交换排序
对于奇偶交换排序来说,它将排序过程分为两个阶段,奇交换和偶交换。对于奇交换来说,它总是比较奇数索引以及其相邻的后续元素。而偶交换总是比较偶数索引和其相邻的后续元素。并且,奇交换和偶交换会成对出现,这样才能保证比较和交换涉及到数组中的每一个元素。
下面是奇偶交换排序的串行实现:
public static void oddEvenSort(int[] arr) {
int exchFlag = 1, start = 0;
while(exchFlag == 1 || start == 1) {
exchFlag = 0;
for(int i=start; i<arr.length-1; i+=2) {
if(arr[i] > arr[i+1]) {
int temp = arr[i];
arr[i] = arr[i+1];
arr[i+1] = temp;
exchFlag = 1;
}
}
if(start == 0)
start = 1;
else
start = 0;
}
}
其中,exchFlag用来记录当前迭代是否发生了数据交换,而start变量用来表示是奇交换还是偶交换。初始时,start为0,表示进行偶交换,每次迭代结束后,切换start的状态。如果上一次比较交换发生了数据交换,或者当前正在进行的是奇交换,循环就不会停止,直到程序不再发生交换,并且当前进行的是偶交换为止(表示奇偶交换已经成对出现)。
改造后的并行模式代码如下:
static int arr[];
static int exchFlag = 1;
static final int NUM_ARR = 10000;
static {
arr = new int[NUM_ARR];
for(int i=0; i<NUM_ARR; i++) {
arr[i] = new Random().nextInt(10000);
}
}
static synchronized void setExchFlag(int v) {
exchFlag = v;
}
static synchronized int getExchFlag() {
return exchFlag;
}
public static class OddEvenSortTask implements Runnable {
int i;
CountDownLatch latch;
public OddEvenSortTask(int i, CountDownLatch latch) {
this.i = i;
this.latch = latch;
}
@Override
public void run() {
if(arr[i] > arr[i+1]) {
int temp = arr[i];
arr[i] = arr[i+1];
arr[i+1] = temp;
setExchFlag(1);
}
latch.countDown();
}
}
public static void pOddEventSort() throws InterruptedException {
int start = 0;
ExecutorService pool = Executors.newCachedThreadPool();
while(getExchFlag() == 1 || start == 1) {
setExchFlag(0);
CountDownLatch latch = new CountDownLatch(arr.length/2 - (arr.length%2==0?start:0));
for(int i=start; i<arr.length-1; i+=2) {
pool.submit(new OddEvenSortTask(i, latch));
}
latch.await();
if(start == 0)
start = 1;
else
start = 0;
}
}
public static void main(String[] args) throws InterruptedException {
pOddEventSort();
for(int i=0; i<NUM_ARR; i++) {
System.out.println(arr[i]);
}
}
上述代码定义了奇偶排序的任务类。该任务的主要工作是进行数据比较和必要交换。并行排序的 主体是pOddEventSort()方法,它使用CountDownLatch记录线程数量,对于每一次迭代,使用单独的线程对每一次元素比较和交换进行操作。在下一次迭代前,必须等待上一次迭代所有线程的完成。
2)改进的插入排序:希尔排序
插入排序的基本思想是:一个未排序的数组(或链表)可以分为两个部分,前半部分是已经排序的,后半部分是未排序的。在进行排序时,只需要在未排序的部分选择一个元素,将其插入到前面有序的数组中即可。最终,未排序的部分会越来越少,直到为0,那么排序就完成了。
插入排序的实现如下:
public static void insertSort(int[] arr) {
int length = arr.length;
int j, i, key;
for(int i=1; i<length; i++) {
//key为要准备插入的元素
key = arr[i];
j = i - 1;
while(j>=0 && arr[j]>key) {
arr[j+1] = arr[j];
j--;
}
//找到合适的位置插入key
arr[j+1] = key;
}
}
上述代码第6行,提取要准备插入的元素(也就是未排序序列中的第一个元素)。接着,在已排序队列中找到这个元素的插入位置(第8~10行),并进行插入(第13行)即可。
简单的插入排序是很难并行化的。因为这一次的 数据插入依赖于上一次得到的有序序列,因此多个步骤之间无法并行。为此,可以对插入排序进行扩展,这就是希尔排序。
希尔排序将整个数组根据间隔h分隔为若干个子数组。子数组互相穿插在一起,每一次排序时,分别对每一个子数组进行排序。
在每一次排序完成后,可以递减h的值,进行下轮更加精细的排序。直到h为1,此时等价于一次插入排序。
希尔排序的一个主要优点是,即使一个较小的元素在数组的末尾,由于每次元素移动都以h为间隔进行,因此数组末尾的小元素可以在很少的交换次数下,就被换到最接近元素最终位置的地方。
希尔排序的串行实现:
public static void shellSort(int[] arr) {
//计算出最大的h
int h = 1;
while(h<=arr.length/3) {
h = h*3+1;
}
while(h>0) {
for(int i=h; i<arr.length; i++) {
if(arr[i]<arr[i-h]) {
int tmp = arr[i];
int j = i - h;
while(j>=0 && arr[j]>tmp) {
arr[j+h] = arr[j];
j-=h;
}
arr[j+h] = tmp;
}
}
h = (h-1)+3;
}
}
上述代码4~6行,计算一个合适的h值,接着正式进行希尔排序。第8行的for循环进行间隔为h的插入排序,每次排序结束后,递减h的值。直到h为1,退化为插入排序。
希尔排序每次都针对不同的子数组进行排序,各个子数组之间是完全独立的。因此,改写成并行程序:
public class ParallelShellSort {
static int arr[];
static final int ARRNUM = 1000;
static {
arr = new int[ARRNUM];
for (int i = 0; i < ARRNUM; i++) {
arr[i] = new Random().nextInt(1000);
}
}
public static class ShellSortTask implements Runnable {
int i = 0;
int h = 0;
CountDownLatch l;
public ShellSortTask(int i,int h,CountDownLatch latch) {
this.i = i;
this.h = h;
this.l = latch;
}
@Override
public void run() {
if(arr[i] < arr[i-h]) {
int tmp = arr[i];
int j = i - h;
while(j>=0 && arr[j] > tmp) {
arr[j+h] = arr[j];
j -= h;
}
arr[j+h] = tmp;
}
l.countDown();
}
}
public static void pShellSort() throws InterruptedException {
int h = 1;
CountDownLatch latch = null;
ExecutorService pool = Executors.newCachedThreadPool();
while(h<=arr.length/3) {
h = h*3 + 1;
}
while(h>0) {
System.out.println("h=" + h);
if(h>=4)
latch = new CountDownLatch(arr.length-h);
for(int i=h; i<arr.length; i++) {
if(h>=4) {
pool.execute(new ShellSortTask(i, h, latch));
} else {
if(arr[i] < arr[i-h]) {
int tmp = arr[i];
int j = i -h;
while(j>=0 && arr[j]>tmp) {
arr[j+h] = arr[j];
j -= h;
}
arr[j+h] = tmp;
}
}
}
latch.await();
h = (h-1)/3;
}
}
public static void main(String[] args) throws InterruptedException {
pShellSort();
for(int i=0; i<ARRNUM; i++) {
System.out.println(arr[i]);
}
}
}
上述代码中定义ShellSortTask作为并行任务。一个ShellSortTask的作用是根据给定的起始位置和h,对子数组进行排序,因此可以完全并行化。
为控制线程数量,这里定义并行主函数pShellSort()在h大于或等于4时使用并行线程,否则则退化为传统的插入排序。
每次计算后,递减h的值。

9. 并行算法:矩阵乘法
在矩阵乘法中,第一个矩阵的列数和第二个矩阵的行数必须是相同的。如果需要进行并行计算,一种简单的策略是可以将A矩阵进行水平分割,得到子矩阵A1和A2,B矩阵进行垂直分割,得到子矩阵B1和B2。此时,我们只要分别计算这些子矩阵的乘积,将结果进行拼接,就能得到原始矩阵A和B的乘积。
我们使用ForkJoin框架来实现这个并行矩阵相乘的想法。为了方便矩阵计算,我们使用jMatrces开源软件,作为矩阵计算的工具。其中,使用的主要API如下:
Matrix:代表一个矩阵
MatrixOperator.multiply(Matrix, Matrix):矩阵相乘
Matrix.row():获得矩阵的行数
Matrix.getSubMatrix():获得矩阵的子矩阵
MatrixOperator.horizontalConcatenation(Matrix, Matrix):将两个矩阵进行水平连接
MatrixOperator.verticalConcatenation(Matrix, Matrix):将两个矩阵进行垂直连接
并行算法代码如下:
public class MatrixMulTask extends RecursiveTask<Matrix> {
public static final int granularity = 3;
Matrix m1;
Matrix m2;
String pos;
public MatrixMulTask(Matrix m1,Matrix m2,String pos) {
this.m1 = m1;
this.m2 = m2;
this.pos = pos;
}
@Override
protected Matrix compute() {
if(m1.rows() <= MatrixMulTask.granularity || m2.cols()<=MatrixMulTask.granularity) {
Matrix mRe = MatrixOperator.multiply(m1, m2);
return mRe;
} else {
int rows;
rows = m1.rows();
Matrix m11 = m1.getSubMatrix(1, 1, rows/2, m1.cols());
Matrix m12 = m1.getSubMatrix(rows/2+1, 1, m1.rows(), m1.cols());
Matrix m21 = m2.getSubMatrix(1, 1, m2.rows(), m12.cols()/2);
Matrix m22 = m2.getSubMatrix(1, m2.cols()/2+1, m2.rows(), m2.cols());
ArrayList<MatrixMulTask> subTasks = new ArrayList<MatrixMulTask>();
MatrixMulTask tmp = null;
tmp = new MatrixMulTask(m11, m21, "m1");
subTasks.add(tmp);
tmp = new MatrixMulTask(m11, m22, "m2");
subTasks.add(tmp);
tmp = new MatrixMulTask(m12, m21, "m3");
subTasks.add(tmp);
tmp = new MatrixMulTask(m12, m22, "m4");
subTasks.add(tmp);
for(MatrixMulTask t : subTasks) {
t.fork();
}
Map<String, Matrix> matrixMap = new HashMap<String,Matrix>();
for(MatrixMulTask t :subTasks) {
matrixMap.put(t.pos, t.join());
}
Matrix tmp1 = MatrixOperator.horizontalConcatenation(matrixMap.get("m1"), matrixMap.get("m2"));
Matrix tmp2 = MatrixOperator.horizontalConcatenation(matrixMap.get("m3"), matrixMap.get("m4"));
Matrix reM = MatrixOperator.verticalConcatenation(tmp1, tmp2);
return reM;
}
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
Matrix m1 = MatrixFactory.getRandomMatrix(10, 10, null);
Matrix m2 = MatrixFactory.getRandomMatrix(10, 10, null);
MatrixMulTask task = new MatrixMulTask(m1, m2, null);
ForkJoinTask<Matrix> result = forkJoinPool.submit(task);
Matrix pr = result.get();
System.out.println(pr);
}
}
MatrixMULTask中的成员变量m1和m2表示要相乘的两个矩阵,pos表示这个乘积结果在父矩阵相乘结果中所处的位置,有m1,m2,m3,和m4等四种。先对矩阵进行分割,分割后得到m11、m12、m21和m22等四个任务,并将它们进行子任务的创建。然后计算这些子任务,最后将m1,m2,m3,和m4拼接成新的矩阵作为最终结果。

10. 网络NIO
JavaNIO中涉及的基础内容有通道(Channel)和缓冲区(Buffer)、文件IO和网络IO。
1)基于Socket的服务端的多线程模式
这里,以Echo服务器为例。对于Echo服务器,它会读取客户端的一个输入,并将这个输入原封不动地返回给客户端。服务器会为每一个客户端连接启用一个线程,这个新的线程将全心全意为这个客户端服务。同时,为了接受客户端连接,服务器还会额外使用一个派发线程。
下面的代码实现了服务器:
public class MultiThreadEchoServer {
private static ExecutorService tp = Executors.newCachedThreadPool();
static class HandleMsg implements Runnable {
Socket clientSocket;
public HandleMsg(Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public void run() {
BufferedReader is = null;
PrintWriter os = null;
try {
is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
os = new PrintWriter(clientSocket.getOutputStream(),true);
String inputLine = null;
long b = System.currentTimeMillis();
while((inputLine = is.readLine()) != null) {
os.println(inputLine);
}
long e = System.currentTimeMillis();
System.out.println("spend:" + (e-b)+"ms");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if(is!=null) is.close();
if(os!=null) os.close();
clientSocket.close();
} catch (Exception e2) {
e2.printStackTrace();
}
}
}
}
public static void main(String[] args) {
ServerSocket echoServer = null;
Socket clientSocket = null;
try {
echoServer = new ServerSocket(8000);
} catch (Exception e) {
System.out.println(e);
}
while(true) {
try {
clientSocket = echoServer.accept();
System.out.println(clientSocket.getRemoteSocketAddress() + " connect!");
tp.execute(new HandleMsg(clientSocket));
} catch (Exception e) {
System.out.println(e);
}
}
}
}
使用了一个线程池来处理一个客户端连接。并定义了HandleMsg线程,它由一个客户端Socket构成,它的任务是读取这个Socket的内容并将其进行返回,返回成功后,任务完成,客户端Socket就被正常关闭。程序中统计并输出了服务端线程处理一次客户端请求所花费的时间(包括读取数据和回写数据的时间)。主线程main的主要作用是在8000端口上进行等待。一旦有新的客户端连接,它就根据这个连接创建HandleMsg线程进行处理。
这就是一个支持多线程的服务端的核心内容。它的特点是,在相同可支持的线程范围内,可以尽量多地支持客户端的数量,同时和单线程服务器相比,它也可以更好地使用多核CPU,可以尽量多地支持客户端的数量,同时和单线程服务器相比,它可以更好地使用多核CPU。
下面为一个客户端的参考实现:
public static void main(String[] args) throws IOException {
Socket client = null;
PrintWriter writer = null;
BufferedReader reader = null;
try {
client = new Socket();
client.connect(new InetSocketAddress("localhost", 8000));
writer = new PrintWriter(client.getOutputStream(),true);
writer.println("Hello!");
writer.flush();
reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
System.out.println("from server:" + reader.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
if(writer!=null)writer.close();
if(reader!=null)reader.close();
if(client!=null)client.close();
}
}
下面为一个让CPU等待IO的极端例子:
public class HeavySocketClient {
private static ExecutorService tp = Executors.newCachedThreadPool();
private static final int sleep_time = 1000*1000*1000;
public static class EchoClient implements Runnable {
@Override
public void run() {
Socket client = null;
PrintWriter writer = null;
BufferedReader reader = null;
try {
client = new Socket();
client.connect(new InetSocketAddress("localhost",8000));
writer = new PrintWriter(client.getOutputStream(),true);
writer.write("H");
LockSupport.parkNanos(sleep_time);
writer.write("e");
LockSupport.parkNanos(sleep_time);
writer.write("l");
LockSupport.parkNanos(sleep_time);
writer.write("l");
LockSupport.parkNanos(sleep_time);
writer.write("o");
LockSupport.parkNanos(sleep_time);
writer.write("!");
LockSupport.parkNanos(sleep_time);
writer.println();
writer.flush();
reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
System.out.println("from server:" + reader.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if(writer!=null)writer.close();
if(reader!=null)reader.close();
if(client!=null)client.close();
} catch (Exception e2) {
// TODO: handle exception
}
}
}
}
public static void main(String[] args) {
EchoClient ec = new EchoClient();
for(int i=0; i<10; i++) {
tp.execute(ec);
}
}
}
上述代码定义了一个新的客户端,它会进行10次请求。每一次请求都会访问8000端口。连接成功后,会向服务器输出“Hello!”字符串,但是在这一次交互中,客户端会慢慢地进行输出,每次只输出一个字符,之后进行1秒的等待。因此,整个过程会持续6秒。
开启多线程池的服务和上述客户端。服务端的部分输出如下:
spend:5987ms
spend:5985ms
spend:5992ms
spend:5992ms
spend:5995ms
spend:5989ms
spend:6000ms
spend:5997ms
spend:6000ms
spend:6004ms
对于服务端来说,每一个请求的处理时间都在6秒左右。这很容易理解,因为服务器要先读入客户端的输入,而客户端缓慢的处理速度(也可能是一个拥塞的网络环境)使得服务器花费了不少等待时间。
这这个案例中,服务器处理请求之所以慢,并不是因为在服务器端有繁重的任务,而仅仅是因为服务线程在等待IO。

2)使用NIO进行网络编程
在NIO中的一个关键组件Channel(通道)。Channel有点类似于流,一个Channel可以和文件或者网络Socket对应。如果Channel对应着一个Socket,那么往这个Channel中写数据,就等同于向Socket中写入数据。
和Channel一起使用的另一个重要组件就是Buffer。可以简单地把Buffer理解成一个内存区域或者byte数组。数据需要包装成Buffer的形式才能和Channel交互(写入或者读取)。
另一个与Channel密切相关的是Selector(选择器)。在Channel的众多实现中,有一个SelectableChannel实现,表示可被选择的通道。任何一个SelectableChannel都可以将自己注册到一个Selector中。这样,这个Channel就能被Selector所管理。而一个Selector可以管理多个SelectableChannel。当SelectableChannel的数据准备好时,Selector就会接到通知,得到那些已经准备好的数据。而SocketChannel就是SelectableChannel的一种。
一个Selector可以由一个线程进行管理,而一个SelectableChannel则可以表示一个客户端连接,因此这就构成由一个或者极少数线程,来处理大量客户端连接的结构。当与客户端连接的数据没有准备好时,Selector会处于等待状态,而一旦有任何一个SelectableChannel准备好了数据,Selector就能立即得到通知,获取数据进行处理。
用NIO重构这个多线程的Echo服务器。
首先,需要定义一个Selector和线程池:
private Selector selector;
private ExecutorService tp = Executors.newCachedThreadPool();
其中,selector用于处理所有的网络连接。线程池tp用于对每一个客户端进行相应的处理。每一个请求都会委托给线程池中的线程进行实际的处理。
为了弄够统计服务器线程在一个客户端上花费了多少时间,还需要定义一个与时间统计有关的类:
public static Map<Socket, Long> time_stat = new HashMap<Socket, Long>(10240);
它用于统计在某一个Socket上花费的时间,time_start的key为Socket,value为时间戳(可以记录处理开始时间)。
下面为NIO服务器的核心线程,下面的startServer()方法用于启动NIO Server:
private void startServer() throws Exception {
selector = SelectorProvider.provider().openSelector();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
InetSocketAddress isa = new InetSocketAddress("localhost", 8000);
//InetSocketAddress isa = new InetSocketAddress(8000);
ssc.socket().bind(isa);

SelectionKey acceptKey = ssc.register(selector, SelectionKey.OP_ACCEPT);

for(;;) {
selector.select();
Set readyKeys = selector.selectedKeys();
Iterator i = readyKeys.iterator();
long e = 0;
while(i.hasNext()) {
SelectionKey sk = (SelectionKey)i.next();
i.remove();

if(sk.isAcceptable()) {
doAccept(sk);
}
else if(sk.isValid() && sk.isReadable()) {
if(!time_stat.containsKey(((SocketChannel)sk.channel()).socket()))
time_stat.put(((SocketChannel)sk.channel()).socket(), System.currentTimeMillis());
doRead(sk);
}
else if(sk.isValid() && sk.isWritable()) {
doWrite(sk);
e = System.currentTimeMillis();
long b = time_stat.remove(((SocketChannel)sk.channel()).socket());
System.out.println("spend:" + (e-b) +"ms");
}
}
}
}
上述代码中第2行,通过工厂方法获得一个Selector对象的实例。第3行,获得表示服务端的SocketChannel实例。第4行,将这个SocketChannel设置为非阻塞模式。实际上,Channel也可以像传统的Socket那样按照阻塞的方式工作。但在这里,更倾向于让其工作在非阻塞模式,在这种模式下,我们才可以向Channel注册感兴趣的事件,并且在数据准备好时,得到必要的通知。接着,在第6~8行进行端口绑定,将这个CHannel绑定到8000端口。
在第10行,将这个ServerSocketChannel绑定到Selector上,并注册它感兴趣的时间为Accept。这样,Selector就能为这个Channel服务了。当Selector发现ServerSocketChannel有新的客户端连接时,就会通知ServerSocketChannel进行处理。方法register()返回值是一个SelectionKey,SelectionKey表示一对Selector和Channel的关系。当Channel注册到Selector上时,就相当于确定了两者的服务关系,那么SelectionKey就是这个契约。当Selector或者Channel被关闭时,它们对应的SelectionKey就会失败。
第12~37行是一个无穷循环,它的主要任务就是等待-分发网络消息。
第13行的select()方法是一个阻塞方法。如果当前没有任何数据准备好,它就会等待。一旦有数据可读,它就会返回。它的返回值是已经准备就绪的SelectionKey的数量。这里简单地将其忽略。
第14行获取那些准备好的SelectionKey。因为Selector同时为多个Channel服务,因此已经准备就绪的Channel就有可能是多个。所以,这里得到的自然是一个集合。得到这个就绪集合后,剩下的就是遍历这个集合,挨个处理所有的Channel数据。
第15行得到这个集合的迭代器。第17行使用迭代器遍历整个集合。第18行根据迭代器获得一个集合内的SelectionKey实例。
第19行将这个元素移除!注意,这个非常重要,否则就会重复处理相同的SelectionKey。当处理完一个SelectionKey后,务必将其从集合内删除。
第21行将这个元素当前SelectionKey所代表的Channel是否在Acceptable状态,如果是,就进行客户端的接收(执行doAccept()方法)。
第24行判断Channel是否已经可以读了,如果是就进行读取(doRead()方法)。这里为了统计系统处理每一个连接的时间,在第25~27行记录了在读取数据之前的一个时间戳。
第30行判断通道是否准备好进行写。如果是就进行写入(doWrite()),同时在写入完成后,根据读取前的时间戳,输出处理这个Socket连接的耗时。
doAccept()方法,它与客户端建立连接:
private void doAccept(SelectionKey sk) {
ServerSocketChannel server = (ServerSocketChannel)sk.channel();
SocketChannel clientChannel;
try {
clientChannel = server.accept();
clientChannel.configureBlocking(false);
//Register this channel for reading
SelectionKey clientKey = clientChannel.register(selector, SelectionKey.OP_READ);
//Allocate an EchoClient instance and attach it to this selection key.
EchoClient echoClient = new EchoClient();
clientKey.attach(echoClient);
InetAddress clientAddress = clientChannel.socket().getInetAddress();
System.out.println("Accepted connection from " + clientAddress.getHostAddress() + ".");
} catch (Exception e) {
System.out.println("False to accept new client.");
e.printStackTrace();
}
}
和Socket编程很类似,当有一个新的客户端连接接入时,就会有一个新的Channel产生代表这个连接。上述代码第5行,生成的clientChannel就表示和客户端通信的通道。第6行,将这个Channel配置为非阻塞模式,也就是要求系统在准备好IO后,再通知我们的线程来读取或者写入。
第9行很关键,它将新生成的Channel注册到selector选择器上,并告诉Selector,我现在对读(OP_READ)操作感兴趣。这样,当Selector发现这个Channel已经准备好读时,就能给线程一个通知。
第11行新建一个对象实例,一个EchoClient实例代表一个客户端。在第12行,我们将这个客户端实例作为附件,附加到表示这个连接的SelectionKey上。这样在整个连接的处理过程中,我们都可以共享这个EchoClient实例。
EchoClient的定义很简单,它封装了一个队列,保存在需要回复给这个客户端的所有信息,这样,再进行回复时,只要outq对象中弹出元素即可。
public class EchoClient {
private LinkedList<ByteBuffer> outq;
EchoClient() {
outq = new LinkedList<ByteBuffer>();
}
public LinkedList<ByteBuffer> getOutQueue() {
return outq;
}
public void enqueue(ByteBuffer bb) {
outq.addFirst(bb);
}
}
当Channel可以读取时,doRead()方法就会被调用。
private void doRead(SelectionKey sk) {
SocketChannel channel = (SocketChannel)sk.channel();
ByteBuffer bb = ByteBuffer.allocate(8192);
int len;
try {
len = channel.read(bb);
if(len < 0) {
disconnect(sk);
return;
}
} catch (Exception e) {
System.out.println("Failed to read from client.");
e.printStackTrace();
disconnect(sk);
return;
}

bb.flip();
tp.execute(new HandleMsg(sk, bb, selector));
}
方法doRead()接收一个SelectionKey参数,通过这个SelectionKey可以得到当前的客户端Channel(第2行)。在这里,我们准备8K的缓冲区读取数据,所有读取的数据存放在变量bb中(第7行)。读取完毕后,重置缓冲区,为数据处理做准备(第19行)。
在这个示例中,我们对数据的处理很简单。但是为了模拟复杂的场景,还是使用了线程池进行数据处理(第20行)。这样,如果数据处理很复杂,就能在单独的线程中进行,而不用阻塞任务派发线程。
HandleMsg的实现也很简单:
public class HandleMsg implements Runnable {
SelectionKey sk;
ByteBuffer bb;
Selector selector;
public HandleMsg(SelectionKey sk, ByteBuffer bb, Selector selector) {
this.sk = sk;
this.bb = bb;
this.selector = selector;
}
@Override
public void run() {
EchoClient echoClient = (EchoClient)sk.attachment();
echoClient.enqueue(bb);
sk.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
//强迫selector立即返回
selector.wakeup();
}
}
上述代码中,简单地将接收到的数据压入EchoClient的队列。如果需要处理业务逻辑,就可以在这里进行处理。
在数据处理完成后,就可以准备将结果写回到客户端,因此,重新注册感兴趣的消息事件,将写操作(OP_WRITE)也作为感兴趣的事件进行提交。这样在通道准备好写入时,就能通知线程。
写入操作使用doWrite()函数实现:
private void doWrite(SelectionKey sk) {
SocketChannel channel = (SocketChannel)sk.channel();
EchoClient echoClient = (EchoClient)sk.attachment();
LinkedList<ByteBuffer> outq = echoClient.getOutQueue();

ByteBuffer bb = outq.getLast();
try {
int len = channel.write(bb);
if(len == -1) {
disconnect(sk);
return;
}

if(bb.remaining() == 0) {
outq.removeLast();
}
} catch (Exception e) {
System.out.println("Failed to write to client");
e.printStackTrace();
disconnect(sk);
}

if(outq.size()==0) {
sk.interestOps(SelectionKey.OP_READ);
}
}
函数doWrite()也接收一个SelectionKey,当然针对一个客户端来说,这个SelectionKey实例和doRead()拿到的SelectionKey是同一个。因此,通过SelectionKey我们就可以在这两个操作中共享EchoClient实例。上述代码第3~4行,我们取得EchoClient实例以及它的发送内容列表。第6行,获得列表顶部元素,准备写回客户端。第8行进行写回操作。如果全部发送完成,则移除这个缓存对象(第16行)。
在doWrite()中最重要的,也是最容易被忽略的是在全部数据发送完成后(也就是outq的长度为0),需要将写事件(OP_WRITE)从感兴趣的操作中移除(第25行)。如果不这么做,每次Channel准备好写时,都会来执行doWrite()方法。而实际上,你又无数据可写,这显然是不合理的。因此,这个操作很重要。
现在使用NIO服务器来处理上一节中客户端的访问。同样的,客户端也是要花费将近6秒钟,才能完成一次消息的发送,使用NIO技术后,服务端线程需要花费的时间如下:
spend:1ms
spend:4ms
spend:1ms
spend:1ms
spend:1ms
spend:0ms
spend:0ms
spend:6ms
spend:1ms
spend:7ms
可以看到,在使用NIO技术后,即使客户端迟钝或者出现了网络延迟等现象,并不会给服务器带来太大的问题。

3)使用NIO实现客户端
首先,需要初始化Selector和Channel:
private Selector selector;
public void init(String ip, int port) throws IOException {
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
this.selector = SelectorProvider.provider().openSelector();
channel.connect(new InetSocketAddress(ip, port));
channel.register(selector, SelectionKey.OP_CONNECT);
}
上述代码第3行,创建一个SocketChannel实例,并设置为非阻塞模式。第5行创建了一个Selector。第6行,将SocketChannel绑定到Socket上。但由于当前Channel是非阻塞的,因此,connect()方法返回时,连接并不一定建立成功,在后续使用这个连接时,还需要使用finishConnect()再次确认。第7行,将这个Channel和Selector进行绑定,并注册了感兴趣的事件作为连接(OP_CONNECT)。
初始化完成后,就是程序的主要执行逻辑:
public void working() throws Exception {
while(true) {
if(!selector.isOpen()) {
break;
}
selector.select();
Iterator<SelectionKey> ite = this.selector.selectedKeys().iterator();
while(ite.hasNext()) {
SelectionKey key = ite.next();
ite.remove();
if(key.isConnectable()) {
connect(key);
} else if(key.isReadable()) {
read(key);
}
}
}
}
在上述代码中,第5行得到已经准备好的事件。如果当前没有任何事件准备就绪,这里就会阻塞。这里的整个处理机制和服务端非常类似,主要处理两个事件,首先是表示连接就绪的Connect事件(由connect()函数处理)以及表示通道可读的Read事件(由read()函数处理)。
函数connect()的实现如下:
public void connect(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel)key.channel();
//如果正在连接,则完成连接
if(channel.isConnectionPending()) {
channel.finishConnect();
}
channel.configureBlocking(false);
channel.write(ByteBuffer.wrap(new String("hello server!\r\n").getBytes()));
channel.register(this.selector, SelectionKey.OP_READ);
}
上述connect()函数接收SelectionKey作为其参数。在第4~6行,它首先判断是否连接已经建立,如果没有,则调用finishConnect()完成连接。建立连接后,向Channel写入数据,并同时注册读事件为感兴趣事件(第10行)。
当Channel可读时,会执行read()方法,进行数据读取:
public void read(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel)key.channel();
//创建读取的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(100);
channel.read(buffer);
byte[] data = buffer.array();
String msg = new String(data).trim();
System.out.println("客户端收到信息:" + msg);
channel.close();
key.selector().close();
}
上述read()函数首先创建了100字节的缓冲区(第4行),接着从Channel中读取数据,并将其打印到控制台上。最后,关闭Channel和Selector。

11. AIO
虽然NIO在网络操作中,提供了非阻塞的方法,但是NIO的IO行为还是同步的。对于NIO来说,我们的业务线程是在IO操作准备好时,得到通知,接着就由这个线程自行进行IO操作,IO操作本身还是同步的。
对于AIO,它不是在IO准备好时再通知线程,而是在IO操作依据完成后,再给线程发出通知。因此,AIO是完全不会阻塞的。此时,我们的业务逻辑将变成一个回调函数,等待IO操作完成后,由系统自动触发。
下面,通过AIO实现一个简单的EchoServer以及对应的客户端。
1)AIO EchoServer的实现
异步IO需要使用异步通道(AsynchronousServerSocketChannel):
public final static int PORT = 8000;
private AsynchronousServerSocketChannel server;
public AIOEchoServer() throws IOException {
server = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(PORT));
}
上述代码绑定了8000端口为服务器端口,并使用AsynchronousServerSocketChannel异步Channel作为服务器,变量名为server。
使用这个server来进行客户端的接收和处理:
public void start() throws InterruptedException, ExecutionException, TimeoutException {
System.out.println("Server listen on " + PORT);
server.accept(null,new CompletionHandler<AsynchronousSocketChannel, Object>() {
final ByteBuffer buffer = ByteBuffer.allocate(1024);
@Override
public void completed(AsynchronousSocketChannel result,
Object attachment) {
System.out.println(Thread.currentThread().getName());
Future<Integer> writeResult = null;
try {
buffer.clear();
result.read(buffer).get(100,TimeUnit.SECONDS);
buffer.flip();
writeResult = result.write(buffer);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
} finally {
try {
server.accept(null,this);
writeResult.get();
result.close();
} catch(Exception e) {
System.out.println(e.toString());
}
}
}
@Override
public void failed(Throwable exc, Object attachment) {
System.out.println("Failed: " + exc);
}
});
}
上述定义的start()方法开启了服务器。值得注意的是,这个方法除了第2行的打印语句外,只调用了一个函数server.accept()。之后,看到的一大堆代码只是这个函数的参数。
AsynchronousServerSocketChannel.accept()方法会立即返回。它并不会真的去等待客户端的到来。在这里使用的accept()方法的签名为:
public final <A> void accept(A attachment,
CompletionHandler<AsynchronousSocketChannel,? super A> handler)
它的第一个参数是一个附件,可以是任意类型,作用是让当前线程和后续的回调方法可以共享信息,它会在后续调用中,传递给handler。它的第二个参数是CompletionHandler接口。这个接口有两个方法:
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
这两个方法分别在异步操作accept()成功或者失败时被回调。
因此AsynchronousServerSocketChannel.accept()实际上做了两件事,第一是发起accept请求,告诉系统可以开始监听端口了。第二,注册CompletionHandler实例,告诉系统,一旦有客户端前来连接,如果成功连接,就去执行CompletionHandler.completed()方法;如果连接失败,就去执行CompletionHandler.failed()方法。
所以,server.accept()方法不会阻塞,它会立即返回。
下面,来分析一下CompletionHandler.completed()的实现。当completed()被执行时,意味着已经有客户端成功连接了。使用read()方法读取客户的数据。AsynchronousServerSocketChannel.read()方法也是异步的,换句话说它不会等待读取完成了再返回,而是立即返回,返回的结果是一个Future,因此这里就是Future模式的典型应用。为了编程方便,这里直接调用Future.get()方法,进行等待,将这个异步方法变成了同步方法。因此,在其执行完成后,数据读取就已经完成了。
之后,将数据回写给客户端。这里调用AsynchronousServerSocketChannel.write()方法。这个方法不会等待数据全部写完,也是立即返回的。同样,它返回的也是Future对象。
再之后,服务器进行下一个客户端连接的准备。同时关闭当前正在处理的客户端连接。但在关闭之前,得先确保之前的write()操作已经完成,因此,使用Future.get()方法进行等待。
接下来,只需要在主函数中调用这个start()方法就可以开启服务器了:
public static void main(String[] args) throws Exception {
new AIOEchoServer().start();
while (true) {
Thread.sleep(1000);
}
}
上述代码第2行,调用start()方法开启服务器。但由于start()方法里使用的都是异步方法,因此它会马上返回,并不像阻塞方法那样会进行等待。因此,如果想让程序驻守执行,第4~6行的等待语句是必需的。否则,在start()方法后,不等客户端到来,程序已经运行完成,主线程序就将退出。

2)AIO Echo客户端实现
public class AIOClient {
public static void main(String[] args) throws Exception {
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
client.connect(new InetSocketAddress("localhost", 8000),null,new CompletionHandler<Void, Object>() {
@Override
public void completed(Void result, Object attachment) {
client.write(ByteBuffer.wrap("Hello!".getBytes()), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
try {
ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer,buffer,new CompletionHandler<Integer, ByteBuffer>() {

@Override
public void completed(Integer result,
ByteBuffer buffer) {
buffer.flip();
System.out.println(new String(buffer.array()));
try {
client.close();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc,
ByteBuffer attachment) {
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, Object attachment) {
}
});
}
@Override
public void failed(Throwable exc, Object attachment) {
}
});
Thread.sleep(1000);
}
}
第一个语句是第3行,打开AsynchronousSocketChannel通道。第二个语句是第4~39行,它让客户端去连接指定的服务器,并注册了一系列事件。第三个语句是第41行,让线程进行等待。虽然第2个语句很长,但是它完全是异步的,因此会很快返回,并不会等待在连接操作的过程中。如果不进行等待,客户端会马上退出,也就无法继续工作了。
第4行,客户端进行网络连接,并注册了连接成功的回调函数CompletionHandler<Void, Object>()。待连接成功后,就会进入代码第7行。第7行进行数据写入,向服务端发送数据。这个过程也是异步的,会很快返回。写入完成后,会通知回调函数CompletionHandler<Void, Object>,进入第10行。第10个开始,准备进行数据读取,从服务端读取回写的数据。当然,第12行的read()函数也是立即返回的,成功读取所有数据后,会回调CompletionHandler<Integer, ByteBuffer>接口,进入第15行。在第15~16行,打印接收到的数据。

注:本篇博客内容摘自《Java高并发程序设计》

0 0
原创粉丝点击