Java大数据平台开发 学习笔记(59)—— 高并发 JUC (BlockingQueue、ConcurrentMap、ExecutorService、Lock、Atomic)

一、概述

  1. JUC是JDK1.5中提供的一套并发包及其子包:
    java.util.concurrent,java.util.concurrent.atomic,java.util.concurrent.lock
  2. JUC中包含了5套接口:
    BlockingQueue,ConcurrentMap,ExecutorService,Lock,Atomic

二、BlockingQueue - 阻塞式队列

2.1、概述

  1. 特征:阻塞、FIFO(First In First Out))
  2. BlockingQueue不同于之前学习的Queue,BlockingQueue不能扩容。即BlockingQueue在使用的时候指定的容量是多少就是多少不能改变
  3. 当队列已满的时候,试图放入元素的线程会被阻塞;当队列为空的时候,试图获取元素的线程会被阻塞
  4. 阻塞式队列中不允许元素为null

2.2、常见的实现类

1、ArrayBlockingQueue - 阻塞式顺序队列

  1. 底层依靠数组来存储数据
  2. 在使用的时候需要指定容量

2、LinkedBlockingQueue - 阻塞式链式队列

  1. 底层依靠单向节点来存储数据
  2. 在使用的时候可以指定容量也可以不指定。如果制定了容量,则容量不可变。如果没有指定容量,则容量是Integer.MAX_VALUE,即231-1。此时因为这个容量相对较大,一般认为队列是无限的

3、PriorityBlockingQueue - 具有优先级的阻塞式队列

  1. 在使用的时候可以不指定容量。如果不指定,则默认初始容量为11 - 在容量不够的时候,会进行扩容
  2. 底层依靠数组来存储数据 PriorityBlockingQueue 会对放入其中的元素进行自然排序,要求>3. 元素对应的类必须实现Comparable接口,覆盖compareTo方法
  3. 如果需要给队列来单独指定比较规则,那么可以传入Comparator对象
  4. 迭代遍历不保证排序

4、SynchronousQueue - 同步队列

  • 这个队列在使用的时候不需要指定容量,容量默认为1且只能为1

2.3、扩展:BlockingDeque - 阻塞式双向队列

  • 允许两端放两端拿

2.4、代码实现

public class BlockingQueueDemo {
    
    

    public static void main(String[] args) throws InterruptedException {
    
    

        // 构建队列
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(5);

        // 添加元素
        queue.add("a");
        queue.add("a");
        queue.add("a");
        queue.add("a");
        queue.add("a");

        // 队列已满
        // 抛出IllegalStateException
        // queue.add("b");
        // 返回false
        // boolean r = queue.offer("c");
        // System.out.println(r);
        // 产生阻塞
        // queue.put("d");
        // 定时阻塞
        boolean r = queue.offer("e", 5, TimeUnit.SECONDS);
        System.out.println(r);

        System.out.println(queue);
    }
}


三、ConcurrentMap - 并发映射

3.1、概述

  1. ConcurrentMap是JDK1.5提供的一套用于应对高并发以及保证数据安全的映射机制
  2. ConcurrentMap包含了ConcurrentHashMap和ConcurrentNavigableMap

3.2、常见的实现类

1、ConcurrentHashMap - 并发哈希映射

  1. 底层是基于数组+链表来进行存储。数组的每一个位置称之为是一个桶(bucket),每一个桶中维系一个链表
  2. 默认初始容量是16,默认加载因子是0.75。如果超过加载因子,进行扩容
  3. ConcurrentHashMap的容量一定是2n的形式,但是最大容量为2^30, 即数组的长度不能超过2次方30
  4. 从JDK1.8开始,ConcurrentHashMap引入了红黑树机制:当某一个桶中的元素个数达到8个的时候,这个桶中的链表就会扭转成一棵红黑树;如果这个桶中红黑树的节点个数不足7个的时候,这棵红黑树会再扭转回链表。在ConcurrentHashMap中,启用红黑树机制的前提是容量至少达到64
  5. ConcurrentHashMap是一个异步线程安全的映射。通过分段/桶锁机制来保证线程安全。即一个线程访问这个映射中的键值对的时候,会将这个键值对所在桶给锁住,此时其他线程依然可以访问其他的桶中的键值对
  6. ConcurrentHashMap依然采用锁机制来保证线程安全,但是锁在使用的时候会带来非常大的开销(CPU的独占,线程上下文的调度、线程状态切换等),所以在JDK1.8中,ConcurrentHashMap引入了无锁算法CAS(Compare And Swap - 比较和交换)。因为CAS涉及到了线程的重新调度问题,所以CAS需要结合具体的CPU内核架构来设计完成,因此Java无法完成这个过程。JDK中的CAS过程底层是依靠C语言完成的
  7. ConcurrentHashMap的使用方法和HashMap一致,之前使用HashMap的地方都可以替换为ConcurrentHashMap。实际生产过程中,如果不考虑安全那么使用HashMap;如果需要保证数据安全,那么使用ConcurrentHashMap

2、ConcurrentNavigableMap - 并发导航映射

  1. ConcurrentNavigableMap提供了用于截取子映射的方法
  2. 实现类:ConcurrentSkipListMap - 并发跳跃表映射,底层是基于跳跃表来实现
  3. 跳跃表
    a 针对有序元素来使用
    b 适合于查询多增删少的场景
    c 跳跃表可以经过多层提取,但是规定最上层的跳跃表的元素个数不能少于2个
    d 典型的"以空间换时间"的产物
    e 当新添元素的时候,新添的元素是否要提取到上层的跳跃表中,要遵循"抛硬币"原则
    f 时间复杂度是O(logn),空间复杂度是O(n)

3.3、代码实现

public class ConcurrentNavigableMapDemo {
    
    

    public static void main(String[] args) {
    
    
//        ConcurrentHashMap

        // 实现类ConcurrentSkipListMap - 并发跳跃表映射
        ConcurrentNavigableMap<String, Integer> map = new ConcurrentSkipListMap<>();
        map.put("Mike", 90);
        map.put("Grace", 75);
        map.put("Cathy", 48);
        map.put("John", 70);
        map.put("Sam", 81);
        map.put("Tony", 60);
        map.put("Rose", 49);

        System.out.println(map);
        // 从头开始截取到指定位置
        System.out.println(map.headMap("John"));
        // 从指定位置开始截取到尾部
        System.out.println(map.tailMap("Mike"));
        // 截取指定范围的数据
        System.out.println(map.subMap("Grace", "Sam"));
    }
}


四、ExecutorService - 执行器服务

4.1、概述

  1. 本质上就是一个线程池。意义:减少线程的创建和销毁,减少服务器资源的浪费,做到线程的复用
  2. 线程池在刚定义的时候,里面是空的,没有任何线程,如果接收到一个请求,线程池中就会创建一个线程(core thread - 核心线程)用于处理这个请求
  3. 核心线程用完之后不会销毁而是会去等待下一个请求
  4. 在定义线程池的时候,需要同时给定核心线程的数量
  5. 在核心线程达到指定数量之前,每次来的请求都会触发创建一个新的核心线程
  6. 如果核心线程被全部占用,那么后来的线程就会放到工作队列(work queue)中来临时存储。工作队列本质上是一个阻塞式队列
  7. 如果工作队列被全部占用,那么后来的请求会被交给临时线程(temporary thread)来处理
  8. 在定义线程池的时候,需要同时给定临时线程的数量
  9. 临时线程在处理完请求之后,会存活指定的一段时间。如果在这段时间内接收到新的请求,那么临时线程会继续处理新的请求而暂时不会被销毁;如果超过这段时间临时线程没有接收到新的请求,那么这个临时线程就会被销毁
  10. 如果临时线程被全部占用,那么后来的请求会交给拒绝执行处理器(RejectedExecutionHandler)来进行拒绝处理

4.2、常见的实现类

1、ThreadPoolExecutor - 线程池

  • 线程池。意义:减少线程的创建和销毁,减少服务器资源的浪费,做到线程的复用。

代码实现

public class ExecutorServiceDemo {
    
    

    public static void main(String[] args) {
    
    

        // 构建线程池
        // int corePoolSize - 核心线程数量
        // int maximumPoolSize - 最大线程数量 = 核心线程数 + 临时线程数
        // long keepAliveTime - 临时线程用完之后的存活时间
        // TimeUnit unit - 时间单位
        // BlockingQueue<Runnable> workQueue - 工作队列
        // handler - 拒绝执行处理器 - 如果有具体的拒绝流程,那么需要覆盖这个接口
        ExecutorService es = new ThreadPoolExecutor(
                5, // 5个核心线程
                10, // 5个临时线程
                5, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(5),
                // 实际过程中,会有一套明确的拒绝流程,例如记录日志,跳转页面等
                (r, e) -> System.out.println("拒绝执行线程:" + r)
        );
//        RejectedExecutionHandler handler = (r, e) -> System.out.println("拒绝执行线程:" + r);
        // new Thread(new ExecutorThread()).start();
        // 可以通过线程池来执行这个线程
        // es.execute(new ExecutorThread ());
        // es.submit(new ExecutorThread());

        // 5个核心线程,工作队列为5,5个临时线程
        for (int i = 0; i < 18; i++) {
    
    
            es.submit(new ExecutorThread());
        }
        // 关闭线程池
        es.shutdown();
    }
}

class ExecutorThread implements Runnable {
    
    
    @Override
    public void run() {
    
    
        try {
    
    
            System.out.println("hello");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

2、ScheduledExecutorSevice - 定时调度执行器服务。

  • 能够起到定时调度的效果

代码实现

public class ScheduledExecutorServiceDemo {
    
    

    public static void main(String[] args) {
    
    

        // ScheduledExecutorService ses = Executors.newScheduledThreadPool(5);
        ScheduledExecutorService ses = new ScheduledThreadPoolExecutor(5);

        // 延时执行
        // ses.schedule(new ScheduleThread(), 5, TimeUnit.SECONDS);

        // 每隔5s执行一次
        // 从上次的开始来计算下一次的启动时间
        // 实际间隔时间 = max(指定时间, 线程执行时间);
        ses.scheduleAtFixedRate(new ScheduleThread(), 0,
                5, TimeUnit.SECONDS);
        // 从上次的结束来计算下一次的启动时间
        // 实际间隔时间 = 指定时间 + 线程执行时间
        // ses.scheduleWithFixedDelay(new ScheduleThread(), 0,
        //         5, TimeUnit.SECONDS);
    }
}

class ScheduleThread implements Runnable {
    
    
    @Override
    public void run() {
    
    
        try {
    
    
            System.out.println("hello");
            Thread.sleep(8000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

3、ForkJoinPool - 分叉合并池

  1. 分叉:将一个大的任务拆分成多个小的任务分配给多个线程来执行
  2. 合并:将拆分出去的小的任务的执行结果进行汇总
  3. 分叉合并会产生大量的线程去抢占CPU,所以能非常有效的提高CPU的利用率(100%~103%)。同时导致其他的线程会被挤占,因此分叉合并适合于放在空闲的时间进行
  4. 分叉合并底层是依靠Callable线程来实现的
  5. 在数据量比较大的情况下,分叉合并的效率是高于循环的;如果数据量比较小,循环的效率要高于分叉合并
  6. 分叉合并在底层实现过程中,为了保证效率,底层还使用了"work-stealing"(工作窃取)策略:当一个核上的任务执行完成之后,这个核并不会闲下来,而是会随机扫描一个核,然后会从这个核的任务队列尾端来"偷取"一个任务回来执行

代码实现

public class ForkJoinPoolDemo {
    
    

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    
    
    
        // 求1-100000000000L的和
        long begin = System.currentTimeMillis();

        // 主函数所在的类默认是一个线程类 - 主线程
        // 现在的CPU是多核的 - 一个线程只能落地到CPU核上
        // 39031
        // long sum = 0;
        // for (long i = 1; i <= 100000000000L; i++) {
    
    
        //     sum += i;
        // }
        // System.out.println(sum);

        // 25506
        ForkJoinPool pool = new ForkJoinPool();
        Future<Long> f = pool.submit(new Sum(1, 100000000000L));
        System.out.println(f.get());
        pool.shutdown();

        long end = System.currentTimeMillis();
        System.out.println(end - begin);
    }
}

class Sum extends RecursiveTask<Long> {
    
    

    private long start;
    private long end;

    public Sum(long start, long end) {
    
    
        this.start = start;
        this.end = end;
    }

    // 分叉合并的逻辑就是覆盖在这个方法中
    @Override
    protected Long compute() {
    
    
        // 拆分,如果拆分出去的范围比较大,那么可以继续拆分
        // 如果拆分出去的范围比较小,那么就将这个小范围内的数字进行求和
        if (end - start <= 10000) {
    
    
            long sum = 0;
            for (long i = start; i <= end; i++) {
    
    
                sum += i;
            }
            return sum;
        } else {
    
    
            long mid = (start + end) / 2;
            Sum left = new Sum(start, mid);
            Sum right = new Sum(mid + 1, end);
            // 分叉
            left.fork();
            right.fork();
            // 合并
            return left.join() + right.join();
        }
    }
}


五、Lock - 锁

5.1、概述

  1. 在JDK1.5中,提供了一套Lock机制来取代synchronized。相对synchronized而言,Lock更加的灵活和精细
  2. 在使用synchronized的过程中,需要确定一个锁对象。这个锁对象在某些情况下不好确定,如果锁对象不统一,甚至会导致死锁的产生
  3. 重入锁和非重入锁
    a 当锁资源被释放之后,线程依然可以抢占资源重新加锁重新使用
    b 当锁资源被释放之后,这个锁就不能被二次抢占
  4. 共享锁和排他锁
    a 实际生产过程中,绝大部分的锁都是排他锁
    b 如果产生并发操作,且并发的这些操作之间不会产生数据安全问题,那么可以考虑使用共享锁
  5. 读写锁
    a 读锁:允许多个线程同时读,但是不允许线程写 - 共享锁
    b 写锁:只允许一个线程写,不允许线程读 - 排他锁
  6. 自旋锁和其他排他锁
    a 自旋锁本质上也是一种排他锁
    b 对于排他锁而言,当线程发现资源被锁住的时候,这些线程就会陷入阻塞状态,直到锁资源被释放,这些线程才会被唤醒来试图抢占锁资源
    c 对于自旋锁而言,当线程发现资源被锁住的时候,这个线程不会陷入阻塞状态,而是会持续判断锁资源是否被释放
    d 自旋锁的效率要高于其他的排他锁,因为自旋锁没有线程状态的切换;同时自旋锁会持续占用CPU资源
  7. 锁的公平和非公平原则
    a 在资源有限的情况下,线程之间的实际抢占次数并不均等,这种现象称之为非公平策略
    b 在公平策略的前提下,各个线程之间并不是直接抢占资源而是抢占入队顺序。当队列中有线程的时候,会自动的将队头的线程取出来使用资源;其他的线程依然可以抢占入队顺序。在这种策略下,各个线程之间的实际执行次数是大致相等的
    c 相对而言,非公平策略的效率要稍微高一些
    d 如果不指定,锁默认使用的是非公平策略

5.2、代码实现

public class LockDemo {
    
    

    static int i = 0;

    public static void main(String[] args) throws InterruptedException {
    
    

        // Lock lock = new ReentrantLock();
        // 获取写锁
        ReadWriteLock rwl = new ReentrantReadWriteLock();
        Lock lock = rwl.writeLock();
        new Thread(new Add(lock)).start();
        new Thread(new Add(lock)).start();

        // main所在的类默认是是一个线程类 - 主线程
        // 主线程在执行过程中需要启动Add线程
        // 线程启动需要花费时间
        // 主线程会在Add线程启动期间先抢占执行权
        // 需要的结果:等Add线程执行完,主线程再打印
        // 也就意味着主线程即使抢到执行权,也需要阻塞
        Thread.sleep(3000);
        System.out.println(i);

    }

}

class Add implements Runnable {
    
    

    private final Lock lock;

    public Add(Lock lock) {
    
    
        this.lock = lock;
    }

    @Override
    public void run() {
    
    
        // 加锁
        lock.lock();
        for (int i = 0; i < 100000; i++) {
    
    
            LockDemo.i++;
        }
        // 解锁
        lock.unlock();
    }
}

5.3、其他

  1. CountDownLatch:闭锁/线程递减锁。对线程来进行计数的,在计数归零之前,线程会陷入阻塞。当计数归零的时候,会放开阻塞 - 一组线程结束之后,另一组线程开始执行
  2. CyclicBarrier:栅栏。对线程进行计数的。在计数归零之前,线程会陷入阻塞。当计数归零的时候,会放开阻塞 - 所有线程到达同一个点之后再分别继续执行
  3. Exchanger:交换机。只能用于交换两个线程之间的数据
  4. Semaphore:信号量。线程需要先获取信号之后才能执行。当信号归零之后,后来的线程会被阻塞,直到有信号被释放,那么后来的线程才能获取信号执行逻辑 - 实际生产过程中,会利用信号量来进行限流

六、Atomic - 原子性

6.1、概述

  1. 原子性操作实际上针对属性来提供了大量的线程安全的方法。在JDK1.8中,采用了CAS+volatile机制来保证是属性的线程安全
  2. volatile是Java中的关键字之一,是Java提供的一种轻量级的线程间的通信机制
    a 保证线程的可见性。当共享资源发生变化的时候,其他线程能够立即感知到这种变化并且做出对应的操作,这个过程称之为可见性
    b 不保证线程的原子性。原子性指的是线程的执行过程不可分割。换言之,就是线程的执行过程不会被打断不会被抢占。加锁实际上保证线程的原子性
    c 禁止指令重排

6.2、代码实现

public class VolatileDemo {
    
    

    public static void main(String[] args) throws InterruptedException {
    
    

        Data d = new Data();
        d.i = 10;

        // 线程A
        new Thread(() -> {
    
    
            System.out.println("A线程启动~~~");
            while (d.i == 10) ;
            System.out.println("A线程结束~~~");
        }).start();

        // 延迟B的启动,给A线程启动和执行留下充足的时间
        Thread.sleep(3000);

        // 线程B
        new Thread(() -> {
    
    
            System.out.println("B线程启动~~~");
            d.i = 12;
            System.out.println("B线程结束~~~");
        }).start();
    }
}

class Data {
    
    
    volatile int i;
}


• 由 ChiKong_Tam 写于 2020 年 12 月 28 日

猜你喜欢

转载自blog.csdn.net/qq_42209354/article/details/111870005