【多线程进阶】JUC中常见类

文章目录

  • 前言
  • 1. Callable 接口
    • 1.1 回想创建线程方法
  • 2. ReentrantLock 可重入互斥锁
  • 3. Atomic 原子类
  • 4. 线程池
  • 5. Semaphore 信号量
  • 6. CountDownLatch
  • 总结


前言

本文主要讲解 JUC ---- java.util.concurrent 中的一些常见类. concurrent 就是并发的意思, 所以该类中放的都是一些多线程并发编程, 常常使用到的东西.

关注收藏, 开始学习吧


1. Callable 接口

Callable interface 也是一种创建线程的方式, 相当于把线程封装了一个 “返回值”. 方便程序员借助多线程的方式计算结果. 他与之前 Thread 使用 Runable 创建线程有些不同, 并且使用 Callable 不能直接作为 Thread 构造方法的参数.

  • Runnable 能表示一个任务 (run 方法), 该方法返回的是一个空值.
  • Callable 也能表示一个任务(call 方法), 该方法返回一个具体的值, 类型可以通过泛型参数来指定.
  • 如果进行多线程操作, 如果只是关心多线程执行的过程, 那么使用 Runnable 即可. (比如线程池, 定时器, 都是使用 Runnable 去实现)
  • 如果是关心多线程的计算结果, 使用 Callable 就更加合适. (创建一个线程, 让这个线程计算从1加到1000)

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本

  • 创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.
  • main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
  • 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了).
  • 当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
public class ThreadDemo26 {
    static class Result {
        public int sum = 0;
        public Object locker = new Object();
    }

    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();

        Thread thread = new Thread() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                synchronized (result.locker) {
                    result.sum = sum;
                    result.locker.notify();
                }
            }
        };

        thread.start();

        synchronized (result.locker) {
            while (result.sum == 0) {
                result.locker.wait();
            }
            System.out.println(result.sum);
        }
    }
}

可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本

  • 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  • 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  • 把 callable 实例使用 FutureTask 包装一下.
  • 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  • 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
public class ThreadDemo27 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();

        int result = futureTask.get();
        System.out.println(result);
    }
}

可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.

理解 Callable

  • Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
  • Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
  • FutureTask 就可以负责这个等待结果出来的工作.

理解 FutureTask

  • 想象自己去吃快餐. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就相当于是 FutureTask.
  • 后面我们可以随时凭这张小票去查看自己的这份饭做出来了没.

1.1 回想创建线程方法

  1. 直接继承 Thread.
  2. 实现 Runnable.
  3. 使用 Lambda.
  4. 使用线程池.
  5. 使用 Callable.

2. ReentrantLock 可重入互斥锁

“Reentrant” 这个单词的原意就是 “可重入”, 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全. 这个锁没有 synchronized 那么常用, 在使用上更接近 C++ 中的 mutex 锁.

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等.
  • trylock(等待时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  • unlock(): 解锁.
ReentrantLock lock = new ReentrantLock(); 
-----------------------------------------
lock.lock();   
try {    
 // working    
} finally {    
 lock.unlock()    
}

ReentrantLock 和 synchronized 的区别:

  • synchronized 是一个关键字, 是 JVM 内部实现的(基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃. 给加锁提供了更多的操作空间.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

当然, ReentrantLock 也有很大的劣势

  • 他的 unlock 操作容易被遗漏, 是非常致命的, 所以在使用时, 最好使用 finally 来执行 unlock.

所以在实际开发中, 用到锁一般优先考虑 synchronized. 在以下两个情况时, 可以优先考虑使用 ReentrantLock.

  • 锁竞争激烈的时候, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 多线程开发时, 该环节需要使用公平锁.

3. Atomic 原子类

原子类, 我们在讲 CAS 部分时介绍过. 原子类内部用的是 CAS 实现, 所以性能要比加锁实现 i++ 高很多. 原子类主要有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

那么在开发中, 原子类主要应用场景有哪些呢?

  1. 计数需求. 在我们逛视频软件时, 经常可以看到一些视频数据, 比如播放量, 点赞量, 投币量, 转发量, 收藏量等等. 同一个视频经常会有很多人都在同时进行这些操作. 很容易出现线程不安全的问题, 如果我们每个操作都进行上锁来避免不安全, 那么就会消耗很大的资源和时间, 此时如果我们采用原子类来进行计数, 就可以在无锁环境下实现线程安全.
  2. 统计效果. 统计出现错误的请求数目, 收到的请求总数(以此简单衡量服务器的压力) , 统计每个请求的响应时间(衡量服务器的运行效率), 通过这些数据内容, 使用原子类来进行计数, 就可以实现一个监控服务器, 用来获取 / 统计 / 展示 / 报警发生错误的情况. 比如在某次发布程序后, 监控服务器显示错误大幅度上升, 说明这个新版本代码大概率存在 bug.

4. 线程池

  • ExecutorService 和 Executors.
  • ThreadPoolExecutor.

线程池在我之前的一篇文章中重点讲述过, 其用法在这里就不多做介绍了.

5. Semaphore 信号量

Semaphore 信号量, 也是并发编程中的一个重要组件, 在操作系统中也经常出现. 本质上就是一个计数器, 用来表示 “可用资源的个数”. 描述的是, 当前该线程, 是否有 “临界资源” 可使用.

临界资源, 指多个线程 / 进程等并发执行的实体, 可以公共使用到的资源.

理解信号量

  • 可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
  • 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作), acquire 申请.
  • 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作), release 释放.
  • 如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

代码示例

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
public class ThreadDemo28 {

    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println("申请资源");
                    semaphore.acquire();
                    System.out.println("我获取到资源了");
                    Thread.sleep(2000);
                    System.out.println("释放资源");
                    semaphore.release();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

        for (int i = 0; i < 20; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
        }
    }
}

6. CountDownLatch

CountDownLatch 是用来针对特定场景的一个组件, 可以同时等待多个任务执行结束. 好像跑步比赛, 10个选手依次就位, 哨声响才同时出发. 所有选手都通过终点, 才能公布成绩.

代码示例

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public class ThreadDemo29 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(10);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep((long) (Math.random() * 10000));
                    System.out.println("一名选手回来");
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

        for (int i = 0; i < 10; i++) {
            new Thread(runnable).start();
        }

        countDownLatch.await();
        System.out.println("game over");
    }
}

总结

✨ 本文主要讲解了 JUC 中的一些常见类, 需要掌握 Callable 接口, ReentrantLock 锁, 原子类, 线程池, 信号量以及 CountDownLatch.
✨ 想了解更多的多线程知识, 可以收藏一下本人的多线程学习专栏, 里面会持续更新本人的学习记录, 跟随我一起不断学习.
✨ 感谢你们的耐心阅读, 博主本人也是一名学生, 也还有需要很多学习的东西. 写这篇文章是以本人所学内容为基础, 日后也会不断更新自己的学习记录, 我们一起努力进步, 变得优秀, 小小菜鸟, 也能有大大梦想, 关注我, 一起学习.

再次感谢你们的阅读, 你们的鼓励是我创作的最大动力!!!!!

你可能感兴趣的:(多线程学习之路,java,jvm,算法,多线程,JUC,信号量,pv操作)