并发工具类(三)

1.写在前面

之前的博客已经介绍了一部分的JUC包下的一些并发的工具类,还有一部分的并发的工具类没有介绍,笔者今天继续介绍剩下的并发的工具类。

2.原子类

2.1累加

前面笔者写了一个累加器的案例,具体的代码如下。这个例子中add10k这个方法不是线程安全的,问题就出在变量count的可见性和count+1的原子性上。可见性问题可以用volatile来解决,而原子性问题我们前面一直都是采用互斥锁方案

public class Test {
	long count = 0;
	void add10K() {
		int idx = 0;
		while(idx++ < 10000) {
			count += 1;
		}
	}
}

其实对于简单的原子性问题,还有一种无锁方案。就是原子类,具体的代码如下:

public class Test {
	AtomicLong count = new AtomicLong(0);
	void add10K() {
		int idx = 0;
		while(idx++ < 10000) {
			count.getAndIncrement();
		}
	}
}

无锁的方法最大的好处就是性能。

2.2无锁方案的实现原理

其实原子类性能高的秘密很简单,硬件支持而已。CPU 为了解决并发问题,提供了 CAS 指令(CAS,全称是 Compare And Swap,即“比较并交换”)。CAS 指令包含 3 个参数:共享变量的内存地址 A、用于比较的值 B 和共享变量的新值 C;并且只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。作为一条 CPU 指令,CAS 指令本身是能够保证原子性的。通过以下的代码来模拟CAS的工作原理。

class SimulatedCAS{
	int count;
	synchronized int cas(int expect, int newValue){
		// 读目前 count 的值
		int curValue = count;
		// 比较目前 count 值是否 == 期望值
		if(curValue == expect){
			// 如果是,则更新 count 的值
			count = newValue;
		}
		// 返回写入前的值
		return curValue;
	}
}
  • 核心:只有当目前count的值和期望值expect相等时,才会将count更新为newValue。

使用CAS来解决并发问题,一般都会伴随着自旋,而所谓自旋就是循环尝试。例如,实现一个线程安全的count += 1操作,“CAS+ 自旋”的实现方案如下所示,首先计算 newValue = count+1,如果 cas(count,newValue) 返回的值不等于 count,则意味着线程在执行完代码①处之后,执行代码②处之前,count 的值被其他线程更新过。那此时该怎么处理呢?可以采用自旋方案,就像下面代码中展示的,可以重新读 count 最新的值来计算 newValue 并尝试再次更新,直到成功。

class SimulatedCAS{
	volatile int count;
    // 实现 count+=1
	addOne(){
		do {
			newValue = count+1; //①
		}while(count !=cas(count,newValue) //②
	}
	// 模拟实现 CAS,仅用来帮助理解
	synchronized int cas(int expect, int newValue){
		// 读目前 count 的值
		int curValue = count;
		// 比较目前 count 值是否 == 期望值
		if(curValue == expect){
			// 如果是,则更新 count 的值
			count= newValue;
		}
		// 返回写入前的值
		return curValue;
	}
}

2.3ABA问题

还是上面的代码,如果 cas(count,newValue) 返回的值不等于count,意味着线程在执行完代码①处之后,执行代码②处之前,count 的值被其他线程更新过,那如果cas(count,newValue) 返回的值等于count,是否就能够认为 count 的值没有被其他线程更新过呢?显然不是的,假设 count 原本是 A,线程 T1 在执行完代码①处之后,执行代码②处之前,有可能 count 被线程 T2 更新成了 B,之后又被 T3 更新回了 A,这样线程T1 虽然看到的一直是 A,但是其实已经被其他线程更新过了,这就是 ABA 问题。所以在使用 CAS 方案的时候,一定要先 check 一下。

2.4看 Java 如何实现原子化的 count += 1

在 Java 1.8 版本中,getAndIncrement() 方法会转调 unsafe.getAndAddLong() 方法。这里 this 和 valueOffset 两个参数可以唯一确定共享变量的内存地址。

final long getAndIncrement() {
	return unsafe.getAndAddLong(
		this, valueOffset, 1L);
}

该方法首先会在内存中读取共享变量的值,之后循环调用 compareAndSwapLong() 方法来尝试设置共享变量的值,直到成功为止。compareAndSwapLong() 是一个 native 方法,只有当内存中共享变量的值等于expected 时,才会将共享变量的值更新为 x,并且返回 true;否则返回 fasle。compareAndSwapLong 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已。

public final long getAndAddLong(
	Object o, long offset, long delta){
		long v;
		do {
			// 读取内存中的值
			v = getLongVolatile(o, offset);
		} while (!compareAndSwapLong(
			o, offset, v, v + delta));
		return v;
	}
// 原子性地将变量更新为 x
// 条件是内存中的值等于 expected
// 更新成功则返回 true
native boolean compareAndSwapLong(
	Object o, long offset,
	long expected,
	long x);

compareAndSet() 的语义和CAS 指令的语义的差别仅仅是返回值不同而已,compareAndSet() 里面如果更新成功,则会返回 true,否则返回 false。

2.5原子类概述

分成五个类别:原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。

在这里插入图片描述

2.51原子化的基本数据类型

主要是AtomicBoolean、AtomicInteger 和 AtomicLong ,具体的方法如下:

getAndIncrement() // 原子化 i++
getAndDecrement() // 原子化的 i--
incrementAndGet() // 原子化的 ++i
decrementAndGet() // 原子化的 --i
// 当前值 +=delta,返回 += 前的值
getAndAdd(delta)
// 当前值 +=delta,返回 += 后的值
addAndGet(delta)
//CAS 操作,返回是否成功
compareAndSet(expect, update)
// 以下四个方法
// 新值可以通过传入 func 函数来计算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)

2.52原子化的对象引用类型

相关实现有 AtomicReference、AtomicStampedReference 和AtomicMarkableReference,利用它们可以实现对象引用的原子化更新。AtomicReference 提供的方法和原子化的基本数据类型差不多,这里不再赘述。 AtomicStampedReference 和AtomicMarkableReference 这两个原子类可以解决 ABA 问题

解决 ABA 问题的思路其实很简单,增加一个版本号维度就可以了,每次执行 CAS操作,附加再更新一个版本号,只要保证版本号是递增的,那么即便 A 变成 B 之后再变回A,版本号也不会变回来(版本号递增的)。AtomicStampedReference 实现的 CAS 方法就增加了版本号参数 ,方法签名如下:

boolean compareAndSet(
	V expectedReference,
	V newReference,
	int expectedStamp,
	int newStamp)

AtomicMarkableReference 的实现机制则更简单,将版本号简化成了一个 Boolean 值,方法签名如下:

boolean compareAndSet(
	V expectedReference,
	V newReference,
	boolean expectedMark,
	boolean newMark)

2.53原子化数组

相关实现有 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,利用这些原子类,我们可以原子化地更新数组里面的每一个元素。这些类提供的方法和原子化的基本数据类型的区别仅仅是:每个方法多了一个数组的索引参数,所以这里也不再赘述了。

2.54原子化对象属性更新器

相关实现有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和AtomicReferenceFieldUpdater,利用它们可以原子化地更新对象的属性,这三个方法都
是利用反射机制实现的,创建更新器的方法如下:

public static <U>
AtomicXXXFieldUpdater<U>
newUpdater(Class<U> tclass,
	String fieldName)

注意:对象属性必须是 volatile 类型的,只有这样才能保证可见性;如果对象属性不是 volatile 类型的,newUpdater() 方法会抛出 IllegalArgumentException 这个运行时异常。

你会发现 newUpdater() 的方法参数只有类的信息,没有对象的引用,而更新对象的属性,一定需要对象的引用,那这个参数是在哪里传入的呢?是在原子操作的方法参数中传入的。例如 compareAndSet() 这个原子操作,相比原子化的基本数据类型多了一个对象引用obj。原子化对象属性更新器相关的方法,相比原子化的基本数据类型仅仅是多了对象引用参数,所以这里也不再赘述了。

2.55原子化的累加器

DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快,但是不支持
compareAndSet() 方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。

3.Executor与线程池

3.1线程池是一种生产者-消费者模式

线程池普遍采用的都是生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。具体可以参考下面的代码

// 简化的线程池,仅用来说明工作原理
class MyThreadPool{
	// 利用阻塞队列实现生产者 - 消费者模式
	BlockingQueue<Runnable> workQueue;
	// 保存内部工作线程
	List<WorkerThread> threads = new ArrayList<>();
	// 构造方法
	MyThreadPool(int poolSize,BlockingQueue<Runnable> workQueue){
		this.workQueue = workQueue;
		// 创建工作线程
		for(int idx=0; idx<poolSize; idx++){
			WorkerThread work = new WorkerThread();
			work.start();
			threads.add(work);
		}
	}
	// 提交任务
	void execute(Runnable command){
		workQueue.put(command);
	}
	// 工作线程负责消费任务,并执行任务
    class WorkerThread extends Thread{
        public void run() {
            // 循环取任务并执行
            while(true){ //①
                Runnable task = workQueue.take();
                task.run();
            }
        }
    }
}
/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(10, workQueue);
// 提交任务
pool.execute(()->{
    System.out.println("hello");
});

在 MyThreadPool 的内部,我们维护了一个阻塞队列 workQueue 和一组工作线程,工作线程的个数由构造函数中的 poolSize 来指定。用户通过调用 execute() 方法来提交Runnable 任务,execute() 方法的内部实现仅仅是将任务加入到 workQueue 中。MyThreadPool 内部维护的工作线程会消费 workQueue 中的任务并执行任务,相关的代码就是代码①处的 while 循环。

3.2如何使用 Java 中的线程池

java并发包提供的线程池,远比我们自己的写的代码强大多了。最核心就是ThreadPoolExecutor,我们可以看下它的构造函数:

ThreadPoolExecutor(
	int corePoolSize,
	int maximumPoolSize,
	long keepAliveTime,
	TimeUnit unit,
	BlockingQueue<Runnable> workQueue,
	ThreadFactory threadFactory,
	RejectedExecutionHandler handler)

把线程池类比为一个项目组,而线程就是项目组的成员。

  1. corePoolSize:表示线程池保有的最小线程数。有些项目很闲,但是也不能把人都撤了,至少要留 corePoolSize 个人坚守阵地。
  2. maximumPoolSize:表示线程池创建的最大线程数。当项目很忙时,就需要加人,但是也不能无限制地加,最多就加到 maximumPoolSize 个人。当项目闲下来时,就要撤人了,最多能撤到 corePoolSize 个人。
  3. keepAliveTime & unit:上面提到项目根据忙闲来增减人员,那在编程世界里,如何定义忙和闲呢?很简单,一个线程如果在一段时间内,都没有执行任务,说明很闲,keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说,如果一个线程空闲了keepAliveTime & unit这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
  4. workQueue:工作队列,和上面示例代码的工作队列同义。
  5. threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。
  6. handler:通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收。至于拒绝的策略,你可以通过 handler 这个参数来指定。
  7. ThreadPoolExecutor 已经提供了以下 4 种策略。CallerRunsPolicy:提交任务的线程自己去执行该任务。AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。DiscardPolicy:直接丢弃任务,没有任何异常抛出。DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
  8. Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走。

3.3使用线程池要注意些什么

由于ThreadPoolExecutor的构造函数实在是有些复杂,所以提供线程池的静态工厂类 Executors,利用 Executors 你可以快速创建线程池。

不建议使用 Executors 的最重要的原因是:Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue,高负载情境下,无界队列很容易导致 OOM,而 OOM 会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列。

使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch 它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。

使用线程池,还要注意异常处理的问题,例如通过 ThreadPoolExecutor 对象的 execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理,你可以参考下面的示例代码。

try {
// 业务逻辑
} catch (RuntimeException x) {
// 按需处理
} catch (Throwable x) {
// 按需处理
}

4.Future

4.1如何获取任务执行结果

Java 通过 ThreadPoolExecutor 提供的 3 个 submit() 方法和 1 个 FutureTask 工具类来支持获得任务执行结果的需求。下面我们先来介绍这 3 个 submit() 方法,这 3 个方法的方法签名如下。

// 提交 Runnable 任务
Future<?> submit(Runnable task);
// 提交 Callable 任务
<T> Future<T> submit(Callable<T> task);
// 提交 Runnable 任务及结果引用
<T> Future<T> submit(Runnable task, T result);

Future接口有5个方法:

  • 取消任务的方法 cancel()、
  • 判断任务是否已取消的方法 isCancelled()、
  • 判断任务是否已结束的方法 isDone()
  • 获得任务执行结果的 get() 和 get(timeout, unit),
  • get(timeout, unit) 支持超时机制
  • 注意:这两个 get() 方法都是阻塞式的,如果被调用的时候,任务还没有执行完,那么调用 get() 方法的线程会阻塞,直到任务执行完才会被唤醒。
// 取消任务
boolean cancel(
boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时
get(long timeout, TimeUnit unit);

上面的三个submit方法之间的区别在于方法参数不同,具体的如下:

  1. 提交 Runnable 任务 submit(Runnable task) :这个方法的参数是一个 Runnable接口,Runnable 接口的 run() 方法是没有返回值的,所以 submit(Runnable task)这个方法返回的 Future 仅可以用来断言任务已经结束了,类似于 Thread.join()。

  2. 提交 Callable 任务 submit(Callable task):这个方法的参数是一个 Callable接口,它只有一个 call() 方法,并且这个方法是有返回值的,所以这个方法返回的Future 对象可以通过调用其 get() 方法来获取任务的执行结果。

  3. 提交 Runnable 任务及结果引用 submit(Runnable task, T result):这个方法很有意思,假设这个方法返回的 Future 对象是 f,f.get() 的返回值就是传给 submit()方法的参数 result。这个方法该怎么用呢?下面这段示例代码展示了它的经典用法。需要你注意的是 Runnable 接口的实现类 Task 声明了一个有参构造函数 Task(Resultr) ,创建 Task 对象的时候传入了 result 对象,这样就能在类 Task 的 run() 方法中对result 进行各种操作了。result 相当于主线程和子线程之间的桥梁,通过它主子线程可以共享数据。

    ExecutorService executor = Executors.newFixedThreadPo
    // 创建 Result 对象 r
    Result r = new Result();
    r.setAAA(a);
    // 提交任务
    Future<Result> future = executor.submit(new Task(r),r);
    Result fr = future.get();
    // 下面等式成立
    fr === r;
    fr.getAAA() === a;
    fr.getXXX() === x
    class Task implements Runnable{
    	Result r;
    	// 通过构造函数传入 result
    	Task(Result r){
    		this.r = r;
    	}
    	void run() {
    		// 可以操作 result
    		a = r.getAAA();
    		r.setXXX(x);
    	}
    }
    

    下面我们再来介绍 FutureTask 工具类。前面我们提到的 Future 是一个接口,而FutureTask 是一个实实在在的工具类,这个工具类有两个构造函数,它们的参数和前面介绍的 submit() 方法类似,所以这里我就不再赘述了。

    FutureTask(Callable<V> callable);
    FutureTask(Runnable runnable, V result);
    

    如何使用?其实很简单,FutureTask 实现了 Runnable 和 Future 接口,由于实现了 Runnable 接口,所以可以将 FutureTask 对象作为任务提交给
    ThreadPoolExecutor 去执行,也可以直接被 Thread 执行;又因为实现了 Future 接口,所以也能用来获得任务的执行结果。下面的示例代码是将 FutureTask 对象提交给ThreadPoolExecutor 去执行。

    // 创建 FutureTask
    FutureTask<Integer> futureTask
    = new FutureTask<>(()-> 1+2);
    // 创建线程池
    ExecutorService es =
    Executors.newCachedThreadPool();
    // 提交 FutureTask
    es.submit(futureTask);
    // 获取计算结果
    Integer result = futureTask.get();
    

    FutureTask 对象直接被 Thread 执行的示例代码如下所示。相信你已经发现了,利用FutureTask 对象可以很容易获取子线程的执行结果。

    // 创建 FutureTask
    FutureTask<Integer> futureTask
    = new FutureTask<>(()-> 1+2);
    // 创建并启动线程
    Thread T1 = new Thread(futureTask);
    T1.start();
    // 获取计算结果
    Integer result = futureTask.get();
    

4.2实现最优的“烧水泡茶”程序

在这里插入图片描述

下面我们用程序来模拟一下这个最优工序。我们专栏前面曾经提到,并发编程可以总结为三个核心问题:分工、同步和互斥。编写并发程序,首先要做的就是分工,所谓分工指的是如何高效地拆解任务并分配给线程。对于烧水泡茶这个程序,一种最优的分工方案可以是下图所示的这样:用两个线程 T1 和 T2 来完成烧水泡茶程序,T1 负责洗水壶、烧开水、泡茶这三道工序,T2 负责洗茶壶、洗茶杯、拿茶叶三道工序,其中 T1 在执行泡茶这道工序时需要等待 T2 完成拿茶叶的工序。对于 T1 的这个等待动作,你应该可以想出很多种办法,例如 Thread.join()、CountDownLatch,甚至阻塞队列都可以解决,不过今天我们用 Future特性来实现。

在这里插入图片描述

下面的示例代码就是用这一章提到的 Future 特性来实现的。首先,我们创建了两个FutureTask——ft1 和 ft2,ft1 完成洗水壶、烧开水、泡茶的任务,ft2 完成洗茶壶、洗茶杯、拿茶叶的任务;这里需要注意的是 ft1 这个任务在执行泡茶任务前,需要等待 ft2 把茶叶拿来,所以 ft1 内部需要引用 ft2,并在执行泡茶之前,调用 ft2 的 get() 方法实现等待。

// 创建任务 T2 的 FutureTask
FutureTask<String> ft2 = new FutureTask<>(new T2Task());
// 创建任务 T1 的 FutureTask
FutureTask<String> ft1 = new FutureTask<>(new T1Task(ft2));
// 线程 T1 执行任务 ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程 T2 执行任务 ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程 T1 执行结果
System.out.println(ft1.get());
// T1Task 需要执行的任务:
// 洗水壶、烧开水、泡茶
class T1Task implements Callable<String>{
	FutureTask<String> ft2;
	// T1 任务需要 T2 任务的 FutureTask
	T1Task(FutureTask<String> ft2){
		this.ft2 = ft2;
	}
	@Override
	String call() throws Exception {
		System.out.println("T1: 洗水壶...");
		TimeUnit.SECONDS.sleep(1);
		System.out.println("T1: 烧开水...");
		TimeUnit.SECONDS.sleep(15);
		// 获取 T2 线程的茶叶
		String tf = ft2.get();
		System.out.println("T1: 拿到茶叶:"+tf);
		System.out.println("T1: 泡茶...");
		return " 上茶:" + tf;
	}
}
// T2Task 需要执行的任务:
// 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable<String> {
	@Override
	String call() throws Exception {
		System.out.println("T2: 洗茶壶...");
		TimeUnit.SECONDS.sleep(1);
		System.out.println("T2: 洗茶杯...");
		TimeUnit.SECONDS.sleep(2);
		System.out.println("T2: 拿茶叶...");
		TimeUnit.SECONDS.sleep(1);
		return " 龙井 ";
	}
}

5.写在最后

本篇博客主要讲了下并发的工具类的一部分,还剩最后一部分会在下篇博客中讲到。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值