Java 并发方案全面学习总结

12次阅读

共计 11985 个字符,预计需要花费 30 分钟才能阅读完成。

并发与并行的概念

并发(Concurrency): 问题域中的概念—— 程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件

并行(Parallelism): 方法域中的概念——通过将问题中的多个部分 并行执行,来加速解决问题。

进程、线程与协程

它们都是并行机制的解决方案。

进程: 进程是什么呢?直白地讲,进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。进程拥有代码和打开的文件资源、数据资源、独立的内存空间。启动一个进程非常消耗资源,一般一台机器最多启动数百个进程。

线程: 线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。线程拥有自己的栈空间。在进程内启动线程也要消耗一定的资源,一般一个进程最多启动数千个线程。操作系统能够调度的最小单位就是线程了。

协程: 协程又从属于线程,它不属于操作系统管辖,完全由程序控制,一个线程内可以启动数万甚至数百万协程。但也正是因为它由程序控制,它对编写代码的风格改变也最多。

Java 的并行执行实现
JVM 中的线程

主线程: 独立生命周期的线程
守护线程: 被主线程创建, 随着创建线程结束而结束

线程状态

要注意的是, 线程不是调用 start 之后马上进入运行中的状态, 而是在 ” 可运行 ” 状态, 由操作系统来决定调度哪个线程来运行。
Jetty 中的线程
Web 服务器都有自己管理的线程池, 比如轻量级的 Jetty, 就有以下三种类型的线程:

Acceptor
Selector
Worker

最原始的多线程——Thread 类
继承类 vs 实现接口

继承 Thread 类
实现 Runnable 接口

实际使用中显然实现接口更好, 避免了单继承限制。
Runnable vs Callable

Runnable:实现 run 方法,无法抛出受检查的异常,运行时异常会中断主线程,但主线程无法捕获,所以子线程应该自己处理所有异常

Callable:实现 call 方法,可以抛出受检查的异常,可以被主线程捕获,但主线程无法捕获运行时异常,也不会被打断。

需要返回值的话,就用 Callable 接口一个实现了 Callable 接口的对象, 需要被包装为 RunnableFuture 对象, 然后才能被新线程执行, 而 RunnableFuture 其实还是实现了 Runnable 接口。
Future, Runnable 和 FutureTask 的关系如下:

可以看出 FutureTask 其实是 RunnableFuture 接口的实现类,下面是使用 Future 的示例代码
public class Callee implements Callable {
AtomicInteger counter = new AtomicInteger(0);

private Integer seq=null;

public Callee()
{
super();
}

public Callee(int seq)
{
this.seq = seq;
}

/**
* call 接口可以抛出受检查的异常
* @return
* @throws InterruptedException
*/
@Override
public Person call() throws InterruptedException {
Person p = new Person(“person”+ counter.incrementAndGet(), RandomUtil.random(0,150));
System.out.println(“In thread(“+seq+”), create a Person: “+p.toString());
Thread.sleep(1000);
return p;
}
}
Callee callee1 = new Callee();
FutureTask<Person> ft= new FutureTask<Person>(callee1);
Thread thread = new Thread(ft);
thread.start();

try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
return;
}

System.out.println(“ft.isDone: “+ft.isDone());

Person result1;
try {
result1 = ((Future<Person>) ft).get();
} catch (InterruptedException e) {
e.printStackTrace();
result1 = null;
} catch (ExecutionException e) {
e.printStackTrace();
result1 = null;
}
Person result = result1;
System.out.println(“main thread get result: “+result.toString());
线程调度

Thread.yield() 方法:调用这个方法,会让当前线程退回到可运行状态,而不是阻塞状态,这样就留给其他同级线程一些运行机会
Thread.sleep(long millis):调用这个方法, 真的会让当前线程进入阻塞状态, 直到时间结束
线程对象的 join():这个方法让当前线程进入阻塞状态,直到要等待的线程结束。
线程对象的 interrupt(): 不要以为它是中断某个线程!它只是线线程发送一个中断信号,让线程在无限等待时(如死锁时)能抛出异常,从而结束线程,但是如果你吃掉了这个异常,那么这个线程还是不会中断的!
Object 类中的 wait():线程进入等待状态,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个状态跟加锁有关,所以是 Object 的方法。
Object 类中的 notify():唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;类似的方法还有一个 notifyAll(),唤醒在此对象监视器上等待的所有线程。

同步与锁
内存一致性错误
public class Counter {
private int c = 0;

public void increment() {
c++;
}

public void decrement() {
c–;
}

public int value() {
return c;
}
}
volatile
public class Foo {
private int x = -1;
private volatile boolean v = false;
public void setX(int x) {
this.x = x;
v = true;
}
public int getX() {
if (v == true) {
return x;
}
return 0;
}
}
volatile 关键字实际上指定了变量不使用寄存器, 并且对变量的访问不会乱序执行。但仅仅对原始类型变量本身生效,如果是 ++ 或者 – 这种“非原子”操作,则不能保证多线程操作的正确性了
原子类型
JDK 提供了一系列对基本类型的封装, 形成原子类型(Atomic Variables),特别适合用来做计数器
import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
private AtomicInteger c = new AtomicInteger(0);

public void increment() {
c.incrementAndGet();
}

public void decrement() {
c.decrementAndGet();
}

public int value() {
return c.get();
}
}
原子操作的实现原理,在 Java8 之前和之后不同
Java7
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
Java8
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
至于 Compare-and-Swap,以及 Fetch-and-Add 两种算法,是依赖机器底层机制实现的。
线程安全的集合类

BlockingQueue: 定义了一个先进先出的数据结构,当你尝试往满队列中添加元素,或者从空队列中获取元素时,将会阻塞或者超时

ConcurrentMap: 是 java.util.Map 的子接口,定义了一些有用的原子操作。移除或者替换键值对的操作只有当 key 存在时才能进行,而新增操作只有当 key 不存在时。使这些操作原子化,可以避免同步。ConcurrentMap 的标准实现是 ConcurrentHashMap,它是 HashMap 的并发模式。

ConcurrentNavigableMap: 是 ConcurrentMap 的子接口,支持近似匹配。ConcurrentNavigableMap 的标准实现是 ConcurrentSkipListMap,它是 TreeMap 的并发模式。

ThreadLocal- 只有本线程才能访问的变量
ThreadLoal 变量,它的基本原理是,同一个 ThreadLocal 所包含的对象(对 ThreadLocal< String > 而言即为 String 类型变量),在不同的 Thread 中有不同的副本(实际是不同的实例,后文会详细阐述)。这里有几点需要注意
因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被 private static 修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。后文会通过实例详细阐述该观点。另外,该场景下,并非必须使用 ThreadLocal,其它方式完全可以实现同样的效果,只是 ThreadLocal 使得实现更简洁。
synchronized 关键字

方法加锁:其实不是加在指定的方法上,而是在指定的对象上,只不过在方法开始前会检查这个锁
静态方法锁:加在类上,它和加在对象上的锁互补干扰
代码区块锁:其实不是加在指定的代码块上,而是加在指定的对象上,只不过在代码块开始前会检查这个锁。一个对象只会有一个锁,所以代码块锁和实例方法锁是会互相影响的

需要注意的是:无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁――而且同步方法很可能还会被其他线程的对象访问, 每个对象只有一个锁(lock)与之相关联
加锁不慎可能会造成死锁
线程池(Java 5)
用途
真正的多线程使用,是从线程池开始的,Callable 接口,基本上也是被线程池调用的。
线程池全景图

线程池的使用
ExecutorService pool = Executors.newFixedThreadPool(3);

Callable<Person> worker1 = new Callee();
Future ft1 = pool.submit(worker1);

Callable<Person> worker2 = new Callee();
Future ft2 = pool.submit(worker2);

Callable<Person> worker3 = new Callee();
Future ft3 = pool.submit(worker3);

System.out.println(“ 准备通知线程池 shutdown…”);
pool.shutdown();
System.out.println(“ 已通知线程池 shutdown”);
try {
pool.awaitTermination(2L, TimeUnit.SECONDS);
System.out.println(“ 线程池完全结束 ”);
} catch (InterruptedException e) {
e.printStackTrace();
}
线程池要解决的问题

任务排队:当前能并发执行的线程数总是有限的,但任务数可以很大

线程调度:线程的创建是比较消耗资源的,需要一个池来维持活跃线程

结果收集:每个任务完成以后,其结果需要统一采集

线程池类型

newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。

newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。

newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。

newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

newSingleThreadScheduledExecutor:创建一个单线程的线程池。此线程池支持定时以及周期性执行任务的需求。

线程池状态

线程池在构造前(new 操作)是初始状态,一旦构造完成线程池就进入了执行状态 RUNNING。严格意义上讲线程池构造完成后并没有线程被立即启动,只有进行“预启动”或者接收到任务的时候才会启动线程。这个会后面线程池的原理会详细分析。但是线程池是出于运行状态,随时准备接受任务来执行。
线程池运行中可以通过 shutdown()和 shutdownNow()来改变运行状态。shutdown()是一个平缓的关闭过程,线程池停止接受新的任务,同时等待已经提交的任务执行完毕,包括那些进入队列还没有开始的任务,这时候线程池处于 SHUTDOWN 状态;shutdownNow()是一个立即关闭过程,线程池停止接受新的任务,同时线程池取消所有执行的任务和已经进入队列但是还没有执行的任务,这时候线程池处于 STOP 状态。
一旦 shutdown()或者 shutdownNow()执行完毕,线程池就进入 TERMINATED 状态,此时线程池就结束了。
isTerminating()描述的是 SHUTDOWN 和 STOP 两种状态。
isShutdown()描述的是非 RUNNING 状态,也就是 SHUTDOWN/STOP/TERMINATED 三种状态。

任务拒绝策略

Fork/Join 模型(Java7)
用途
计算密集型的任务,最好很少有 IO 等待,也没有 Sleep 之类的,最好是本身就适合递归处理的算法
分析
在给定的线程数内,尽可能地最大化利用 CPU 资源,但又不会导致其他资源过载(比如内存),或者大量空线程等待。
ForkJoinPool 主要用来使用分治法 (Divide-and-Conquer Algorithm) 来解决问题。典型的应用比如快速排序算法。
这里的要点在于,ForkJoinPool 需要使用相对少的线程来处理大量的任务。
比如要对 1000 万个数据进行排序,那么会将这个任务分割成两个 500 万的排序任务和一个针对这两组 500 万数据的合并任务。以此类推,对于 500 万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于 10 时,会停止分割,转而使用插入排序对它们进行排序。
那么到最后,所有的任务加起来会有大概 2000000+ 个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。
所以当使用 ThreadPoolExecutor 时,使用分治法会存在问题,因为 ThreadPoolExecutor 中的线程无法像任务队列中再添加一个任务并且在等待该任务完成之后再继续执行。而使用 ForkJoinPool 时,就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。
以上程序的关键是 fork()和 join()方法。在 ForkJoinPool 使用的线程中,会使用一个内部队列来对需要执行的任务以及子任务进行操作来保证它们的执行顺序。
那么使用 ThreadPoolExecutor 或者 ForkJoinPool,会有什么性能的差异呢?
首先,使用 ForkJoinPool 能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用 4 个线程来完成超过 200 万个任务。但是,使用 ThreadPoolExecutor 时,是不可能完成的,因为 ThreadPoolExecutor 中的 Thread 无法选择优先执行子任务,需要完成 200 万个具有父子关系的任务时,也需要 200 万个线程,显然这是不可行的。
ps:ForkJoinPool 在执行过程中,会创建大量的子任务,导致 GC 进行垃圾回收,这些是需要注意的。
原理与使用
ForkJoinPool 首先是 ExecutorService 的实现类,因此是特殊的线程池。
创建了 ForkJoinPool 实例之后,就可以调用 ForkJoinPool 的 submit(ForkJoinTask<T> task) 或 invoke(ForkJoinTask<T> task)方法来执行指定任务了。
其中 ForkJoinTask 代表一个可以并行、合并的任务。ForkJoinTask 是一个抽象类,它还有两个抽象子类:RecusiveAction 和 RecusiveTask。其中 RecusiveTask 代表有返回值的任务,而 RecusiveAction 代表没有返回值的任务。

个人认为 ForkJoinPool 设计不太好的地方在于,ForkJoinTask 不是个接口,而是抽象类,实际使用时基本上不是继承 RecursiveAction 就是继承 RecursiveTask,对业务类有限制。
示例
典型的一个例子,就是一串数组求和
public interface Calculator {
long sumUp(long[] numbers);
}
public class ForkJoinCalculator implements Calculator {
private ForkJoinPool pool;

private static class SumTask extends RecursiveTask<Long> {
private long[] numbers;
private int from;
private int to;

public SumTask(long[] numbers, int from, int to) {
this.numbers = numbers;
this.from = from;
this.to = to;
}

@Override
protected Long compute() {
// 当需要计算的数字小于 6 时,直接计算结果
if (to – from < 6) {
long total = 0;
for (int i = from; i <= to; i++) {
total += numbers[i];
}
return total;
// 否则,把任务一分为二,递归计算
} else {
int middle = (from + to) / 2;
SumTask taskLeft = new SumTask(numbers, from, middle);
SumTask taskRight = new SumTask(numbers, middle+1, to);
taskLeft.fork();
taskRight.fork();
return taskLeft.join() + taskRight.join();
}
}
}

public ForkJoinCalculator() {
// 也可以使用公用的 ForkJoinPool:
// pool = ForkJoinPool.commonPool()
pool = new ForkJoinPool();
}

@Override
public long sumUp(long[] numbers) {
return pool.invoke(new SumTask(numbers, 0, numbers.length-1));
}
}
这个例子展示了当数组被拆分得足够小(<6)之后,就不需要并行处理了,而更大的数组就拆为两半,分别处理。
Stream(Java 8)
概念
别搞混了,跟 IO 的 Stream 完全不是一回事,可以把它看做是集合处理的声明式语法,类似数据库操作语言 SQL。当然也有跟 IO 类似的地方,就是 Stream 只能消费一次,不能重复使用。
看个例子:
int sum = widgets.stream()
.filter(w -> w.getColor() == RED)
.mapToInt(w -> w.getWeight())
.sum();
流提供了一个能力,任何一个流,只要获取一次并行流,后面的操作就都可以并行了。例如:
Stream<String> stream = Stream.of(“a”, “b”, “c”,”d”,”e”,”f”,”g”);
String str = stream.parallel().reduce((a, b) -> a + “,” + b).get();
System.out.println(str);

流操作

生成流

Collection.stream()
Collection.parallelStream()
Arrays.stream(T array) or Stream.of()
java.io.BufferedReader.lines()
java.util.stream.IntStream.range()
java.nio.file.Files.walk()
java.util.Spliterator
Random.ints()
BitSet.stream()
Pattern.splitAsStream(java.lang.CharSequence)
JarFile.stream()

示例
// 1. Individual values
Stream stream = Stream.of(“a”, “b”, “c”);
// 2. Arrays
String [] strArray = new String[] {“a”, “b”, “c”};
stream = Stream.of(strArray);
stream = Arrays.stream(strArray);
// 3. Collections
List<String> list = Arrays.asList(strArray);
stream = list.stream();
需要注意的是,对于基本数值型,目前有三种对应的包装类型 Stream:
IntStream、LongStream、DoubleStream。当然我们也可以用 Stream<Integer>、Stream<Long> >、Stream<Double>,但是 boxing 和 unboxing 会很耗时,所以特别为这三种基本数值型提供了对应的 Stream。
Intermediate
一个流可以后面跟随零个或多个 intermediate 操作。其目的主要是打开流,做出某种程度的数据映射 / 过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。
已知的 Intermediate 操作包括:map (mapToInt, flatMap 等)、filter、distinct、sorted、peek、limit、skip、parallel、sequential、unordered。
Terminal
一个流只能有一个 terminal 操作,当这个操作执行后,流就被使用“光”了,无法再被操作。所以这必定是流的最后一个操作。Terminal 操作的执行,才会真正开始流的遍历,并且会生成一个结果,或者一个 side effect。
已知的 Terminal 操作包括:forEach、forEachOrdered、toArray、reduce、collect、min、max、count、anyMatch、allMatch、noneMatch、findFirst、findAny、iterator

reduce 解析: reduce 本质上是个聚合方法,它的作用是用流里面的元素生成一个结果,所以用来做累加,字符串拼接之类的都非常合适。它有三个参数

初始值:最终结果的初始化值,可以是一个空的对象
聚合函数:一个二元函数(有两个参数),第一个参数是上一次聚合的结果,第二个参数是某个元素
多个部分结果的合并函数: 如果流并发了, 那么聚合操作会分为多段进行, 这里显示了多段之间如何配合

collect: collect 比 reduce 更强大:reduce 最终只能得到一个跟流里数据类型相同的值, 但 collect 的结果可以是任何对象。简单的 collect 也有三个参数:

最终要返回的数据容器
把元素并入返回值的方法
多个部分结果的合并

两个 collect 示例
// 和 reduce 相同的合并字符操作
String concat = stringStream.collect(StringBuilder::new, StringBuilder::append,StringBuilder::append).toString();
// 等价于上面, 这样看起来应该更加清晰
String concat = stringStream.collect(() -> new StringBuilder(),(l, x) -> l.append(x), (r1, r2) -> r1.append(r2)).toString();
// 把 stream 转成 map
Stream stream = Stream.of(1, 2, 3, 4).filter(p -> p > 2);

List result = stream.collect(() -> new ArrayList<>(), (list, item) -> list.add(item), (one, two) -> one.addAll(two));
/* 或者使用方法引用 */
result = stream.collect(ArrayList::new, List::add, List::addAll);
协程
协程,英文 Coroutines,也叫纤程(Fiber)是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
协程实际上是在语言底层 (或者框架) 对需要等待的程序进行调度, 从而充分利用 CPU 的方法, 其实这完全可以通过回调来实现, 但是深层回调的代码太变态了, 所以发明了协程的写法。理论上多个协程不会真的 ” 同时 ” 执行,也就不会引起共享变量操作的不确定性, 不需要加锁(待确认)。
pythone 协程示例

Pythone,Golang 和 C# 都内置了协程的语法,但 Java 没有,只能通过框架实现,常见的框架包括:Quasar,kilim 和 ea-async。
Java ea-async 协程示例
import static com.ea.async.Async.await;
import static java.util.concurrent.CompletableFuture.completedFuture;

public class Store
{
// 购物操作, 传一个商品 id 和一个价格
public CompletableFuture<Boolean> buyItem(String itemTypeId, int cost)
{
// 银行扣款(长时间操作)
if(!await(bank.decrement(cost))) {
return completedFuture(false);
}
try {
// 商品出库(长时间操作)
await(inventory.giveItem(itemTypeId));
return completedFuture(true);
} catch (Exception ex) {
await(bank.refund(cost));
throw new AppException(ex);
}
}
}

参考资料

《七周七并发模型》电子书
深入浅出 Java Concurrency——线程池
Java 多线程学习(吐血超详细总结)
Jetty 基础之线程模型
Jetty-server 高性能, 多线程特性的源码分析
Java 编程要点之并发(Concurrency)详解
Java Concurrency in Depth (Part 1)
Java 进阶(七)正确理解 Thread Local 的原理与适用场景
Java 并发编程笔记:如何使用 ForkJoinPool 以及原理
ForkJoinPool 简介
多线程 ForkJoinPool
Java 8 中的 Streams API 详解
Java 中的协程实现
漫画: 什么是协程

学习源码

正文完
 0