现在,大多数计算机有多个处理器或多核,程序员要让这些处理器都工作。

并发任务

设计并发程序时,你需要考虑那些可以一起运行的任务。

运行任务

在java中,Runnable接口描述一个你想运行的任务。

Runnable hellos = () -> {  
    for(int i=1;i<=1000;i++) {  
        System.out.println("Hello " + i);  
    }  
};  
Runnable goodbyes = () ->{  
    for (int i=0;i<=1000;i++){  
        System.out.println("Goodbye " + i);  
    }  
};
//会产生一个针对很多短暂任务或者任务大多数时间处于等待状态的程序优化过的executor,当线程池的线程空闲一段时间时,executor会终止这些线程,然后程序才终止
ExecutorService executorService = Executors.newCachedThreadPool();

//或者
//会产生一个数目固定的线程池。对于计算密集型任务,或者对服务资源消耗进行限制的任务,这是一个不错的选择
executorService = Executors.newFixedThreadPool(nThreads);

//可以根据可用的处理器数目,推断出线程数,就像这样获取可用的处理器数目  
int processors = Runtime.getRuntime().availableProcessors();

executorService.execute(hellos);  
executorService.execute(goodbyes);

future

虽然Runnable执行任务,但是它没有返回值。如果任务需要计算结果,则使用Callable<V>接口代替Runnable。

要执行Callable,将其提交到ExecutorService:

ExecutorService executor = Executors.newCachedThreadPool(); 
Callable<V> task =...;  
Future<V> result = executor.submit(task);
var data = result.get();//会被阻塞,直到有了可用的结果(或到达超时)。

当你提交任务时,会得到一个future,它代表计算的对象。

任务可能需要等待多个子任务的完成结果。可以将Callable实例的一个集合传递给一个invokeAll方法,实现一次提交多个任务。
例子:计算某个单词在一组文件中出现的频率。

String word = "hello";  
Set<Path> paths = ...;  
List<Callable<Long>> tasks = new ArrayList<>();  
for (Path p: paths) {  
    tasks.add(() ->{  
        return number of occurrences of word in p;  
  });  
}  
  
//该调用会被阻塞,直到所有的任务都完成了  
List<Future<Long>> results = executorService.invokeAll(tasks);  
long total = 0;  
for (Future<Long> result : results) total += result.get();

主调任务会被阻塞,直到所有子任务都完成了,如果这种执行情况不符合你的需求,就可以使用ExecutorCompletionService。它会以完成顺序返回future。
例子:

ExecutorCompletionService service = new ExecutorCompletionService(executorService);  
for (Callable<Long> task: tasks){  
    service.submit(task);  
}  
for(int i=0;i<tasks.size();i++){  
    service.take().get();  
    //还能干一点其他工作  
}

invokeAny方法与invokeAll方法相象,但是只要提交的所有任务中任何一个完成了并且没有抛出异常,它就返回。

对于只要发现一个匹配就结束的搜索,这样就非常有用。
例子:

List<Callable<Path>> tasks1 = new ArrayList<>();  
for (Path p: paths) {  
    tasks1.add(()->{  
        if(word occurs in p) return p;  
        else throw ...  
    });  
}  
ExecutorService executor = Executors.newCachedThreadPool(); 
Path found = executor.invokeAny(tasks1);

异步计算

之前实现并行计算的方法都是进行任务分解,然后等待,直到所有分片都完成。下面将介绍如何实现无等待或异步计算。

异步运行任务并获取CompletableFuture

可以用CompletableFuture.supplyAsync:

CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {return "";}, executor);

列出几个CompletableFuture的常见的方法

thenAccept()

注册一个回调函数,一旦结果可用,会以该结果作为参数触发(在某个线程中)回调函数。用这种方法,不会阻塞,你就可以处理结果,一有可有的结果,会立即处理。

CompletableFuture<String> f = ...
f.thenAccept((String s)-> Process the result s);

whenComplete()

要么计算出结果,要么计算出为捕获的异常。

CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {return "";}, executor);  
f.whenComplete((s, t)->{  
    if(t == null){  
        //Process the result s;  
  }  
    else{  
        //Process the Throwable t;  
  }  
});

complete()

CompletableFuture被称为可完成的,这是因为你可以手动设置一个完成值。

public boolean complete(@Nullable T value);
//在多线程中调用同一个future的complete方法是安全的,如果future已经完成了,这些调用没影响。

组合可完成的Future

给CompletableFuture<T>对象添加一个action,结果是void类型的方法通常用在流水线处理的末尾部分,这里T -> U代表Function<? super T, U>

方法 参数 描述
thenApply T -> U 在结果上应用一个函数
thenAccept T ->void 与thenApply类似,但是结果为void类型
thenCompose T -> CompletableFuture<U> 对结果调用一个函数并执行返回的future
handle (T, Throwable)-> U 处理结果或错误
whenComplete (T,Throwable) -> void 与handle类似,但是结果为void类型
exceptionally Throwable -> T 将错误转换到默认结果中
thenRun Runnable 执行返回void的Runnable对象

线程安全

可见性

现代处理器即使处理共享变量读/写这种简单操作的过程非常复杂。

private static boolean done = false;
Runnable hellos = ()->{  
    for(int i =1;i<=1000;i++){  
        System.out.println("Hello " + i);  
    }  
    done = true;  
};  
Runnable goodbye = () ->{  
    int i =1;  
    while (!done) i++;  
    System.out.println("Goodbye " + i);  
};  
  
Executor executor = Executors.newCachedThreadPool();  
executor.execute(hellos);  
executor.execute(goodbye);

运行这个程序,程序已经输出了"Hello 1000",但却不终止。因为对正在运行的第二个任务的线程可能是不可见的。

下面总结了几种方法,它们可以确保对变量的更新是可见的:

  • final变量的值在初始化后是可见的。
  • static变量的初始值在静态化初始化后是可见的。
private static volatile boolean done;  
static {  
    done = false;  
}
  • 对volatile变量的改变是可见的。
  • 发生在锁被释放之前的改变对任何试图获取同一个锁的所有人是可见的。
private static boolean done;  
public static synchronized boolean isDone() {  
    return done;  
}  
public static synchronized void setDone() {  
    done = false;  
}

就我们的这个例子而言,声明共享变量done时带上volatile修饰符,问题就解决了。volatile修饰符足以解决这种特定问题。

private static volatile boolean done = false;

竞争条件

假设多个并发任务更新一个共享整数计数器(counter).

private static volatile Integer count = 1;

Runnable hellos1 = ()->{  
    for(int i =1;i<=10000;i++){  
        System.out.println(count++);  
    }  
};  
Runnable goodbye1 = () ->{  
    for(int i =1;i<=10000;i++){  
        System.out.println(count++);  
    }  
};
Executor executor = Executors.newCachedThreadPool();  
executor.execute(hellos);  
executor.execute(goodbye);

它并不会将counter累加到20000,这样的增加操作不是原子的(原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行)。可以使用锁来解决,解决方案在锁和条件的章节中。

为了避免损毁共享变量,我们需要确保每次只有一个线程可以计算并设置新值。必须完整地、没有中断地执行的代码块,被称为临界区。可以使用锁来实现临界区:

例子: 使用显示锁

Runnable hellos1 = ()->{  
    for(int i =1;i<=10000;i++){  
        countLock.lock();  
        try {  
            System.out.println(count++);  //临界区
        }finally {  
           countLock.unlock();  //确保锁被解锁了
        }  
    }  
};  
Runnable goodbye1 = () ->{  
    for(int i =1;i<=10000;i++){  
        countLock.lock();  
        try {  
            System.out.println(count++);  
        }finally {  
            countLock.unlock();  
        }  
    }  
};

虽然使用锁来保护临界区简单明了,但是,它们不是解决所有并发问题的通用解决方案。锁很难恰当使用,从而导致性能严重下降,甚至导致"死锁"——因为所有的线程都在等待锁,没有任何线程可以前进。因此应该将使用锁作为最后一个选择。

synchronized关键字

由于synchronized关键字使用"隐式"锁,因此不需要使用显示锁。

Object object = new Object();
synchronized (object){  
    //临界区   
}

本质含义如下:这段代码只是用来说明当你使用synchronized关键字时发生了什么。

object.intrinsicLock.lock();  
try {  
    //临界区
}finally {  
   object.intrinsicLock.unlock();  
}

将方法声明为synchronized:

public synchronized void method(){
    方法体
}

等同于:

public void method(){
    this.intrinsicLock.lock();  
    try {  
        方法体
    }finally {  
       this.intrinsicLock.unlock();  
    }
}

例如,计数器可以被简单地声明为:

class Counter{  
    private int value;  
    private synchronized int increment(){  
        value++;  
        return value;  
    }  
}

原子计数器和累加器

如果多个线程更新一个共享计数器,则你需要确保更新操作是以线程安全方式进行的。java.util.concurrent.atomic包中有很多类,它们使用安全并且高效的机器级指令以确保对整数、long和boolean值、对象引用和数组操作的原子性。比如应用原子计数器和累加器。

public static AtomicLong nextNumber = new AtomicLong();

//在某线程中......incrementAndGet方法自动将AtomicLong的值加1,并返回增加后的值
long id = nextNumber.incrementAndGet();

当很多线程访问同一个原子值时,由于乐观地执行更新,因此性能会严重下降。

如果你预测程序中存在高度竞争,那么应该使用LongAdder来代替AtomicLong。

final LongAdder count = new LongAdder();
for(...)
    executor.execute(() ->{
        while(...){
            ...
            if(...) count.increment();
        }
    });
...
//这些变量的累加值就是当前值
long total = count.sum();

LongAccumulator将这种思想泛化到任何累加操作中。使用accumulate方法与新值相加,调用get方法获取当前值。

LongAccumulator accumulator = new LongAccumulator(Long :: sum, 0);
//在某些任务中...
accumulator.accumulate(value);
//当所有工作都完成时
long sum = accumulator.get();

WinRT
21 声望4 粉丝

临渊羡鱼,不如退而结网


« 上一篇
注解
下一篇 »
日期和时间API