从Java并发编程到升职加薪之并发工具类

上一章节讲到了线程的入门,从线程的创建到其生命周期,以及Thread类的相关方法的使用,以及讲到了ThreadLocal类的底层实现原理以及造成内存泄露的原因等等。

没看过的通过可以看下 https://blog.csdn.net/weixin_46621763/article/details/106246350

本章节主要讲一些常用的并发工具类,比如fork-join框架,CountDownLatch等等

1. Fork-Join框架

1.1 基本概念

Java下多线程的开发一般是新启线程或者用线程池的方式,还可以用forkjoin。 从字面上看,fork-join就是先分叉,再合并,就是所谓的分而治之的思想。forkjoin屏蔽掉关于Thread,Runnable等线程相关的使用,只要遵循forkjoin的开发模式,也能写出很好的多线程并发程序。

Fork/Join主要用到分治策略,所谓的分治就是将一个大问题,拆分成一个个小任务,这些小任务互相独立且与原问题形式相同。然后各个击破,最终将各小任务的解合并得到原问题的解。

image.png

1.2 Fork/Join 标准范式

使用ForkJoin框架时,首先要创建一个ForkJoin任务,它提供在任务中执行fork和join的操作机制。通常情况下不直接继承ForkJoinTask类,只需要继承其子类:

  • RecursiveAction:用于没有返回结果的任务
  • RecursiveTask:用于有返回值的任务,注意这是一个泛型类。

使用步骤如下:

image.png

  1. 因为task要通过ForkJoinPool来执行,所以需要先创建一个ForkJoinPool() 对象
  2. 创建一个继承ForkJoinTask那两个子类的对象实例
  3. 在computer() 方法里面判断下当前任务是否需要继续拆分,如果不需要继续拆就完成自己的工作,执行完后提交给上一级的task
  4. 如果需要继续拆分,则在子任务总调用invokeAll()方法,又会进入compute方法,层层递归,直到拆分到任务不可分割为止。
  5. 使用join()方法返回计算结果

这边需要注意的是,task需要通过ForkjoinPool的submit或invoke进行提交,两者的区别是:

  • submit:异步执行
  • invoke:同步执行,调用之后等待任务完成(相当于线程挂起),才能执行后面的代码
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class TestForkJoin {

    private static final int ARRAY_LENGTH = 400000000;

    private static class SumTask extends RecursiveTask<Long> {

        /*阈值*/
        private final static int THRESHOLD = ARRAY_LENGTH / 10;
        private final int[] src;
        private final int fromIndex;
        private final int toIndex;

        public SumTask(int[] src, int fromIndex, int toIndex) {
            this.src = src;
            this.fromIndex = fromIndex;
            this.toIndex = toIndex;
        }

        @Override
        protected Long compute() {
            /*任务的大小是否合适*/
            if (toIndex - fromIndex < THRESHOLD){
                //System.out.println(fromIndex + ", " + toIndex  + "," + THRESHOLD);
                long count = 0;
                for(int i= fromIndex;i<=toIndex;i++){
                    count += src[i];
                }
                //System.out.println("src length: " + src.length + ", count: " + count);
                return count;
            }else{
                //fromIndex....mid.....toIndex
                int mid = (fromIndex+toIndex)/2;
                SumTask left = new SumTask(src,fromIndex,mid);
                SumTask right = new SumTask(src,mid+1,toIndex);
                invokeAll(left,right);
                return left.join()+right.join();
            }
        }
    }

    public static int[] makeArray(int length) {
        //new一个随机数发生器
        Random r = new Random();
        int[] result = new int[length];
        for(int i=0;i<length;i++){
            //用随机数填充数组
            result[i] = r.nextInt(length*3);
        }
        return result;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int[] src = makeArray(ARRAY_LENGTH);
        long sum = 0;
        long start = System.currentTimeMillis();
        for (int i = 0; i < src.length; i++) {
            sum += src[i];
        }
        System.out.println("The count is "+ sum
                +" spend time:"+(System.currentTimeMillis()-start)+"ms");

        /*new出池的实例*/
        ForkJoinPool pool = new ForkJoinPool();
        /*new出Task的实例*/

        SumTask innerFind = new SumTask(src,0,src.length-1);
        long start1 = System.currentTimeMillis();
        pool.invoke(innerFind);
        System.out.println("The count is "+innerFind.join()
                +" spend time:"+(System.currentTimeMillis()-start1)+"ms");
    }
}

最终输出结果如下,从结果中可以看到,当数据量较大时,通过单线程去计算需要消耗的时间远大于forkjoin的。

image.png

2. CountDownLactch

image.png

CountDownLatch这个类能够使一个线程等待其他线程完成各自的工作后再执行。

注意由于CountDownLatch是通过一个计数器来实现的,在 new CountDownLatch(10) 时,会声明计数器的初始任务数,并且执行 CountDownLatch.countDown() 方法时,计数器才会加一,只有当计数器的个数等于初始任务数时,主线程才会被唤醒。否则主线程将一直处于等待状态。

import java.util.concurrent.CountDownLatch;

public class TestCountDownLatch {

    /**
     * 声明了一个初始任务数
     */
    private static CountDownLatch latch = new CountDownLatch(4);

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println(Thread.currentThread().getName() + ": " + i);
                // latch每执行一次countDown()方法,初始任务数-1
                System.out.println(latch.getCount());
                // 计数+1
                latch.countDown();
            }
        }).start();
        System.out.println("主线程等待中...");
        // 主线程处于等待状态,只有当 latch.countDown();被执行了11次时,主线程才会被唤醒
        latch.await();
        System.out.println("main down ...");
    }
}

结果分析:如果初始任务数设置过大,则会导致主线程阻塞。如上述程序中,new CountDownLatch(4),而线程只执行三次 latch.countDown() 方法,所以主线程就处于等待中。只有初始任务数 <= 3 时,才会输出 main down ...

image.png

3. CyclicBarrier

image.png

CyclicBarrier 的字面意思就是可循环使用(Cyclic)的屏障(Barrier)

它的工作原理就是设置一道屏障(也叫同步点),当到达屏障的线程数等于初始任务数时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

import java.util.Map;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CyclicBarrier;

public class TestCyclicBarrier {

    private static Map<String, Long> map = new ConcurrentHashMap<>(4);
    
    /**
     * 参数一:初始化任务数
     * 参数二:汇总时执行的线程
     */
    private static CyclicBarrier barrier = new CyclicBarrier(2, new Thread(() -> {
        StringBuilder sb = new StringBuilder();
        map.forEach((k, v) -> sb.append("[").append(v).append(']'));
        System.out.println("汇总线程:" + sb);
    }));

    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                long id = Thread.currentThread().getId();
                map.put(Thread.currentThread().getName(), id);
                try {
                    Thread.sleep(1000);
                    System.out.println("Thread_"+id+" ....do something ");
                    // 执行该方法会判断当前屏障前已经汇总的线程数,如果达到初始任务数,则会执行汇总线程
                    // 该方法允许被执行多次,也就是说允许多次汇总
                    barrier.await();
                    System.out.println("Thread_"+id+" ....do its business ");
                    barrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
        // 主线程不会被阻塞,继续执行
        System.out.println("main down...");
    }
}

结果分析:由于在创建 CyclicBarrier 时设置的初始化任务数为2,执行 barrier.await(); 会判断当前屏障处等待的线程数,如果未达到初始任务数2,则进行等待,如果满足条件则屏障屏障,并且当有汇总线程时会先执行汇总线程。并且 await() 是允许被多次调用的。

image.png

4. CountDownLatch与CyclicBarrier区别

咋一看这两工具类貌似没多大区别,都是通过计数的方式去协调线程。但是需要注意的是:

  • CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以反复使用(await允许多次调用)
  • CountDownLatch.await() 一般阻塞工作线程,而CyclicBarrier 是通过各自线程调用await从而自行阻塞。
  • CyclicBarrier 提供barrierAction(汇总线程),合并多线程计算结果

5. Semaphore(信号量)

image.png

Semaphore 是什么东东?从图上看,Semaphore在创建的时候指定一个可用许可证数量,当有线程来访问当时候,需要使用 acquire() 方法去获取一个许可证,使用完后调用release() 方法归还许可证。

当许可证被用完了,之后当线程就会处于等待阻塞状态。一般使用Semaphore做流量控制。

相关方法:

  • acquire():获取一个许可证
  • release():归还许可证
  • tryAcquire():尝试获取许可证
import java.time.LocalTime;
import java.util.concurrent.Semaphore;

public class TestSemaphore {

    public static void main(String[] args) {
        // 初始化三个许可证
        Semaphore semaphore = new Semaphore(3);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    // 获取许可证
                    semaphore.acquire();
                    System.out.println(LocalTime.now() + ": " + Thread.currentThread().getName());
                    Thread.sleep(3000);
                    // 归还许可证
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
        // 线程不会被阻塞
        new Thread(() -> System.out.println(Thread.currentThread())).start();
    }
}

结果分析:从打印信息中很明显当看到,最多允许3个线程执行,只有使用中当线程将许可证归还时,阻塞的线程获取到许可证时,才允许继续运行。

image.png

6. Exchanger

image.png

从图中可以看出,Exchanger(交换者) 主要是为了线程间数据交换,如果线程A先执行exchange()方法,则线程A挂起直到线程B也执行exchange()方法,然后进行数据交换后两个线程继续运行。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Exchanger;

public class TestExchange {

    public static void main(String[] args) {
        Exchanger<List<String>> exchanger = new Exchanger<>();
        new Thread(() -> {
            try {
                List<String> list = new ArrayList<>();

                for (int i = 0; i < 3; i++) {
                    list.add("aaa" + i);
                }
                list = exchanger.exchange(list);
                System.out.println("--->1: " + list);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            List<String> list = new ArrayList<>();
            try {
                for (int i = 0; i < 3; i++) {
                    list.add("bbb" + i);
                }
                list = exchanger.exchange(list);
                System.out.println("--->2: " + list);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

结果分析:很明显看到了数据进行了交换
image.png

7. Callable Future和FutureTask

7.1 Callable 是什么?

我们知道,Runnable 接口中的 run() 方法没有返回值,所以在执行完任务之后无法返回任何结果。

为了满足线程支持返回值,JDK提供了Callable接口,这是一个泛型接口,对外声明了 call() 方法。返回的类型就是传递进来的V类型

7.2 Future 是什么?

Future也是一个泛型接口,对具体的 Runnable 或者 Callable 任务的执行结果进行取消,查询是否完成,获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

7.3 FutureTask 是什么?

image.png

因为 Future 只是一个接口,所以无法直接使用,因此才有 FutureTask类,从图中可以看出它实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,所以FutureTask类既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

7.4 案例分析

public class TestCallable {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 创建一个有返回值的task
        FutureTask<Long> task = new FutureTask<>(() -> {
            TimeUnit.SECONDS.sleep(1);
            long sum = 0;
            for (int i = 0; i < 50000; i++) {
                sum += i;
            }
            // 如果执行cancel方法,则不打印这条记录
            System.out.println(Thread.currentThread() + ": " + sum);
            return sum;
        });
        // 必须丢到线程去启动
        new Thread(task).start();
        Random random = new Random();
        int res = random.nextInt(100);
        if (res > 50) {
            // 线程阻塞
            System.out.println(task.get());
        } else {
            // 取消任务,即中断线程执行,这边使用的是interrupt()方法去中断线程执行
            task.cancel(true);
        }
        System.out.println("main next...");
    }
}

结果分析:如果res的值大于50则线程阻塞,等待计算结果返回,否则中断线程的执行

8. 原子操作CAS

我们知道原子的概念是一个不可细分的粒子(据说还能分成夸克?)

假定有两个操作A和B,从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么不执行B,那么A和B对彼此来说是原子的。举个常见的例子就是数据库的事务操作,要么都执行,要么都不执行。

8.1 实现原子操作的方式

8.1.1 锁机制

实现原子操作可以使用锁机制,比如使用 synchronize 关键字实现的阻塞锁,阻塞的锁机制会导致以下几个问题:

  • 一个优先级很高很重要的线程被优先级很低的线程所阻塞怎么办?
  • 获取锁的线程一直不释放锁怎么办?
  • 如果存在大量线程竞争资源时,CPU将会花费大量的时间和资源来处理这些竞争。
  • 同时还会存在死锁之类的情况等等…
8.1.2 CAS指令

image.png

既然锁机制在使用上存在一些弊端,那么是否有更好的方式去实现原子操作?现代处理器基本都支持 CAS(Compare And Swap) 的指令,所谓的CAS就是比较和交换。每一个CAS操作过程都包含三个运算符:一个内存地址V,一个期望都值A和一个新值B,操作都时候如果这个地址上存放都值等于这个期望都值A,则将地址上都值赋值为新值B,否则不做任何操作,但是要返回原值是多少。循环CAS就是在一个循环里不断的做CAS操作,直到成功为止。

至于CAS如何保证原子操作,如何实现线程的安全。CAS底层通过调用汇编指令 cmpxchg 去锁CPU的总线,从硬件(CPU和内存)层面来保证原子性。

8.2 CAS实现原子操作的三大问题

8.2.1 ABA问题

CAS 在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么CAS进行检查时会发现它的值没有发生变化,但实际上却变化了。这就是所谓的ABA问题。

ABA问题的解决思路就是使用版本号,在变量前追加上版本号,每次变量更新的时候把版本号+1,那么 A -> B -> A 就会变成 1A -> 2B -> 3A

8.2.2 循环时间长开销大

自旋(通俗点的说法就是死循环)CAS如果长时间不成功,会给CPU带来极大的执行开销。

8.2.3 只能保证一个共享变量的原子操作

CAS操作只能保证一个共享变量的原子性,多个共享变量操作时,只能加锁实现。当然也可以把多个共享变量整合成一个共享变量去操作。

8.3 Jdk中的原子操作类

8.3.1 AtomicInteger
  • int addAndGet(int delta):以原子方式将输入的数值与实例中的值进行相加,并返回结果。
  • boolean compareAndSet(int expect, int update):如果输入的值等于预期值,则以原子方式将该值设置为输入的值
  • int getAndIncrement():以原子方式将当前值+1,返回自增前的值
  • int getAndSet(int newValue):以原子的方式设置新值,返回旧值

从AtomicInteger源码中可以看出,内部方法都是基于 sun.misc.Unsafe 类实现的,Unsafe 类是跟底层硬件CPU指令通讯的工具类

unsafe.compareAndSwapInt(this, valueOffset, expect, update) 是一个本地的CAS操作方法,有几个重要参数

  • this:对象本身,需要通过这个类来获取 value 的内存偏移地址
  • valueOffset:value变量的内存偏移地址
  • expect:期望更新的值
  • update:要更新的最新值

image.png

image.png

8.3.2 AtomicIntegerArray

主要提供原子的方式更新数组里的整型,其常用方法如下

  • int addAndGet(int i, int delta):以原子方式将输入值与数组中索引i的元素相加
  • boolean compareAndSet(int i, int expect, int update):如果当前值等于预期值,则以原子方式将数组位置 i 的元素设置成update值

需要注意的是,数组value通过构造方法传入,然后AtomicIntegerArray将当前数组copy一份,所以AtomicIntegerArray对内部对数组元素进行修改时,不会影响传入的原数组。

image.png

8.3.3 AtomicReference

主要提供原子的方式更新引用类型,其常用的方法如下

  • boolean compareAndSet(V expect, V update):如果当前的值等于预期值,则以原子的方式将引用的值更新为update值
  • boolean weakCompareAndSet(V expect, V update):这个方法从 jdk1.8 的源码上跟compareAndSet没啥区别

image.png

原子更新引用类型还有 AtomicStampedReference 和 AtomicMarkableReference 两个类

AtomicStampedReference 利用版本戳的形式记录了每次改变以后的版本号,解决了ABA问题。实际上AtomicMarkableReference实现上差不多>

AtomicStampedReference 的 paic 使用的是 int stamp 作为计数器

image.png

AtomicMarkedReference 的 pair 使用的是boolean mark

image.png

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值