[JavaEE系列] 详解面试中多线程部分(内含Callable接口+JUC常见类+线程安全集合类+死锁)

文章目录

  • 一. Callable接口
  • 二. JUC常见的类
    • 1. ReentrantLock
    • 2. 原子类
    • 3. 线程池
    • 4. 信号量
    • 5. CountDownLatch
  • 三. 线程安全的集合类
  • 四. 死锁

一. Callable接口

        在前面的学习中, 我们在创建线程的时候, 都是会在线程中指定一个任务, 其中就可以使用 Runnable 接口来实现(在前面文章中我所写的代码都是使用 lambda 表达式, 因为写起来更加简洁, 但其实本质都是一样的), 但是使用 Runnable 接口来指定任务的时候是没有带返回值的. 那么如果我们想要有带返回值的任务的时候, 应该怎么做呢?
        这时候就出现了 Callable 接口, 和 Runnable 接口一样的, 在创建线程的时候都可以用来指定一个具体的任务, 但是它比 Runnable 接口多了一个在指定任务的时候是带返回值的.
        例如使用 Callable 接口实现1+2+…+1000的操作:

public class Main {
    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> task = new FutureTask<>(callable);
        Thread thread = new Thread(task);
        thread.start();
        System.out.println(task.get());
    }
}

        当然, 通过上面这段代码观察之后发现: 使用 Callable 接口来创建线程的时候, 是不能像 Runnable 接口那样 Thread thread = new Thread(runnable) 直接将其作为一个参数传进去, 而是只能配合 FutureTask 间接地将 task 作为参数传过去, 而 FutureTask 存在的作用就是负责等待并保存 Callable 返回的结果.


        介绍完 Callable 接口, 我们就可以来简单总结一下从前面到现在的几种创建线程的方法(详细可看前面文章):

  1. 继承 Thread 类
  2. 使用 Runnable 接口
  3. 使用 lambda 表达式
  4. 使用线程池
  5. 使用 Callable 接口

二. JUC常见的类

        很多人好奇JUC是什么东西, 其实它的全称叫做 java.util.concurrent , 实际上就是一个包, 在使用一些类的时候需要导入的一个包.

1. ReentrantLock

        先简单带过 ReentrantLock 类的基本内容, ReentrantLock 也是一种锁: 1. 可以使用 lock() 方法来进行加锁操作; 2. 可以使用 tryLock() 方法来指定等待获取锁的最长时间, 如果超过这个时间那么就会自动放弃加锁操作; 3. 可以使用 unlock() 方法来进行解锁操作.

public class Main {
    public static void main(String[] args) {
        ReentrantLock reentrantLock = new ReentrantLock();
        reentrantLock.lock();
        reentrantLock.tryLock();
        reentrantLock.unlock();
    }
}

        一说到锁啊, 相信看过之前文章的同学会马上想到 synchronized, 毕竟这可是多线程内容的重点. 那么为什么在有 synchronized 的情况下还要有其他锁呢? 接下来, 就来总结这两种锁之间的区别:

相同点:

  • synchronized 和 ReentrantLock 都是Java中提供的可重入锁, 都是用来实现互斥效果, 保证线程安全的.

不同点:

  • synchronized 可以用来修饰普通方法, 静态方法和代码块; 而 ReentrantLock 只能用来修饰代码块.
  • synchronized 是一个关键字, 是JVM内部实现的; 而 ReentrantLock 是Java标准库中的一个类.
  • 使用 synchronized 的时候, 进入 synchronized 块会自动加锁, 并且在执行结束后也会自动释放锁; 而 ReentrantLock 需要手动加锁和解锁.
  • synchronized 是非公平锁; 而 ReentrantLock 默认是公平锁, 但也可以通过构造方法手动指定为非公平锁.
  • synchronized 在申请失败的时候, 会出现死等的情况(也就是不能相应中断); 而ReentrantLock 可以通过 trylock() 方法先等待一段时间后, 如果还是没有获取到锁, 则会相应中断, 解决死锁的问题.
  • synchronized 搭配的是Object.wait / notify, 唤醒的时候, 是随机唤醒一个的; 而 ReentrantLock 则拥有更强大的等待 / 唤醒机制, 其搭配了 Condition 类来实现等待唤醒, 可以做到能随机唤醒一个, 也能指定线程唤醒.

        综上所述: 当锁竞争不激烈的时候, 使用 synchronized 来进行修饰效率会更高, 锁也会自动进行释放; 当锁竞争比较激烈的时候则使用 ReentrantLock 来搭配 tryLock() 方法灵活地控制加锁行为, 不会轻易地出现死等的情况.

2. 原子类

        Java中的原子类有很多(AtomicBoolean, AtomicInteger, AtomicIntegerArray, AtomicLong, AtomicReference, AtomicStampedReference …), 但是这些都不需要刻意去记, 当需要使用到的时候再查文档即可.
       注意: 原子类内部使用的是上一篇文章中总结的CAS来实现的, 所以在性能方面是要比加锁实现++操作更高一些的.

public class Demo17 {
    private static AtomicInteger count = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for(int i = 0; i < 5000; i++){
                count.getAndIncrement();
            }
        });
        Thread thread2 = new Thread(() -> {
            for(int i = 0; i < 5000; i++){
                count.getAndIncrement();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("count = " + count);
    }
}

3. 线程池

       前面文章中已经有总结过一部分的线程池相关内容, 这里再对线程池一些其他的知识点做一个补充.


创建线程池的几种方式:

  • 处理大量短时间工作任务的线程池, 如果线程池中没有可用的线程将创建新的线程: ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
  • 创建一个操作无界队列且固定大小线程池: ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
  • 创建一个单线程执行器, 可以在给定时间后执行或定期执行: ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
  • 创建一个指定大小的线程池, 可以在给定时间后执行或定期执行: ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
  • 创建一个指定大小(不传入参数,为当前机器CPU核心数)的线程池, 并行地处理任务, 不保证处理顺序: Executors.newWorkStealingPool();
  • 自定义线程池(使用的最多): ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3, 10, 10000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());

       ThreadPoolExecutor 类是线程池中最核心的线程池类, 为了更好地掌控代码, 在实际开发中一般都会倾向于选择ThreadPoolExecutor 类来创建线程池, 将这种定义方式也称为是自定义线程池, 比较灵活.


总结ThreadPoolExecutor 类中的参数:
先在IDEA中查看对应的源码:
[JavaEE系列] 详解面试中多线程部分(内含Callable接口+JUC常见类+线程安全集合类+死锁)_第1张图片

  • corePoolSize 表示核心线程数 ; maximumPoolSize 表示最大线程数
  • keepAliveTime & unit 表示如果线程空闲时间超过 unit 时, 线程就会被自动销毁
  • workQueue 表示任务队列. 虽然线程池内部可以内置任务队列, 但是我们在使用的时候也可以自己定义队列交给线程池来使用, 这体现了线程池的可扩展性
  • threadFactory 表示参与具体的线程创建工作
  • handler 表示的是一个拒绝策略. 当任务队列为满的时候, 如果此时尝试添加一个新的任务, 那么线程池可能就会有以下几个拒绝策略: 1. 直接抛出异常; 2. 交给添加任务的调用者进行处理; 3. 丢弃任务队列中最老的任务; 4. 丢弃任务队列中最新的任务

       那么上面使用ThreadPoolExecutor 类在填写参数的时候 corePoolSize 和 maximumPoolSize 都应该填多少合适呢?
       其实这是没有一个准确的答案的, 因为在不同的机器下, 不同的环境下, CPU占用, 内存占用都是会不一样的. 对此我们通常情况下都会采用测压的方式来进行确定, 也就是对当前程序进行性能测试, 分别设置不同的线程数量进行测试操作. 当然, 这对CPU密集型的程序和对IO密集型的程序又会有所区别, 但是总而言之, 在确定线程数的时候最好都是进行一个测压操作.

4. 信号量

       信号量(Semaphore)是用来表示"可用资源的个数"的, 其本质上就是一个计数器.

       举一个形象的例子: 有n个座位(表示可用资源空间为n), 当有人坐上去(表示n-1, 申请一个可用资源, 信号量-=1, 称为是P操作), 当有人离开这个座位(表示n+1, 释放一个可用资源, 信号量+=1, 称为是V操作), 其中这些 +=, -= 的操作都是原子的, 也即是说 Semaphore 是可以直接使用于多线程安全的控制中, 当 n 变为0的时候, 说明可用资源空间已经满了, 这时候如果再有要申请资源的, 就需要进行阻塞等待了.
       实际上, 我们也可以把信号量视为是一个广义的锁, 因为当信号量的取值是0和1的时候, 它就会退化成是一个普通的锁.

//使用 Semphore 来控制线程安全, 实现两个线程增加同一个变量
public class Main {
    private static int count = 0;

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

        for(int i = 0; i < 50; i++){
            Thread thread1 = new Thread(() -> {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });

            Thread thread2 = new Thread(() -> {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();
        }
        System.out.println(count);
    }
}

5. CountDownLatch

       CountDownLatch类: 相当于在一个大任务被拆分成若干个子任务的时候, 用来衡量这些子任务执行结束的时间.
       构造CountDownLatch实例, 其参数是表示需要执行任务的个数.

//基于 CountDownLatch 模拟跑步比赛的过程
public class Main {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        for(int i = 0; i < 10; i++){
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println("到达终点");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
            Thread.sleep(1000);
        }
        latch.await();
        System.out.println("比赛结束");
    }
}

三. 线程安全的集合类

       在Java标准库中大部分的集合类都是线程不安全的, 例如ArrayList, LinkedList, HashMap, TreeMap, HashSet, TreeSet, StringBuilder等等这些类都时可能会涉及到多线程修改共享数据的, 它们都是没有任何加锁操作的. 对于线程安全的集合类来说, ConcurrentHashMap 和 StringBuffer 是使用的比较多的. 除此之外, 还有一个是不进行加锁但是是线程安全的集合类 String, 因为其不涉及到修改操作.

总结 HashTable, HashMap, ConcurrentHashMap 之间的区别:
       在总结这三者的区别之前, 先来详细总结一下 HashTable 和 ConcurrentHashMap 分别是什么.
       (1) HashTable: 是线程安全的. 使用 synchronized 直接针对 HashTable 对象本身进行加锁的, 如果多个线程访问同一个 HashTable 就会直接造成锁冲突. 如下图:
[JavaEE系列] 详解面试中多线程部分(内含Callable接口+JUC常见类+线程安全集合类+死锁)_第2张图片
       从图中不难看出, 它是将锁直接作用在整个对象上, 这样固然是线程安全的, 但是当我们需要对不同哈希桶上的元素进行操作的时候, 会发现这样是不会牵扯到修改同一个变量, 也就是说这样的情况本身就不会出现线程安全的问题, 但是 HashTable 依旧给他加上锁, 这样就会使得锁冲突的概率比较高, 整体效率会下降.
       (2) ConcurrentHashMap: 相对于 HashTable, ConcurrentHashMap在其基础上又做了优化 — 锁粒度细化.
[JavaEE系列] 详解面试中多线程部分(内含Callable接口+JUC常见类+线程安全集合类+死锁)_第3张图片
       将原来的一个大锁细化成了若干个小锁, 当再次访问不同哈希桶上的元素的时候, 也就不会出现锁冲突的情况了(但是其内部还是会出现阻塞等待的情况的), 大大地降低了锁冲突的概率.


       此外, ConcurrentHashMap 还做了一些其他的优化, 下面来总结一下 ConcurrentHashMap 的主要优化特点:

  1. 把锁粒度进行了细分, 每(几)个哈希桶都会有一把对应的锁, 降低了锁冲突概率.
  2. 读不会加锁, 写才会去进行加锁操作.
  3. 更充分使用CAS特性.
  4. 针对扩容场景进行了优化, 化整为零, 会逐渐地进行扩容操作(每次操作都扩容一点点), 直至完成全部扩容, 不会一口气完成扩容操作.

       最后就是总结三者之间的区别了, 下面会从重要区别到次要区别来进行列举:

  • HashMap 是线程不安全的, 而另外两者都是线程安全的
  • HashTable 是使用一把大锁, 锁冲突的概率是比较高的; 而 HashcurrentHashMap 这是每个哈希桶对应一个锁, 锁冲突概率是比较低的
  • HashcurrentHashMap 相对于其他两者还对其他方面进行了一系列的优化策略…
  • HashMap 的 key 是允许 null 的; 而另外两个是不允许的

四. 死锁

       死锁是多线程代码中很常见的一种bug, 主要是因为多个线程同时在进行阻塞等待, 它们每个线程都在等其中一个线程释放锁, 但是没有一个线程是会主动来释放锁的, 导致程序进行了无限期的阻塞等待, 进而不能正常终止的情况.

       关于死锁主要有以下这几种情况:
       (1) 情况一: 一个线程针对同一把锁连续加锁两次, 此时如果这个锁是不可重入锁, 那么就会造成死锁了, 在进入第一个锁后第二个锁之前, 直接在这里进行了阻塞等待. 对于这种情况, 比较好的解决方案是将这个锁修改成可重入锁.
       (2) 情况二: 两个线程两把锁, 假如两个线程内部都需要获取到另外一个锁才会释放自己本身的锁的时候, 这时候, 两个线程就会出现互等, 干等的情况, 造成了死锁.
       (3) 情况三: N个线程M把锁.这样的情况就会比较复杂了, 可以通过下面"哲学家就餐问题"的例子来理解.


哲学家就餐问题

       哲学家就餐问题主要意思就是有N个哲学家(代表的是N个线程, 在当前场景我将其假设为有5个哲学家, 也就是5个线程), 当他们在思考哲学问题的时候, 就代表的是线程休眠; 当他们在进行就餐的时候, 就代表的是线程在CPU上运行. 在他们需要就餐的时候就需要拿起筷子, 而这里的筷子就代表的是一把锁(在当前场景我将其假设为有5根筷子, 也就是5把锁), 然后我们又已知每个哲学家需要就餐的时候, 都必须凑齐两根筷子才能进行就餐(也就是说每一个线程都需要获取到两把锁之后才能够在CPU上运行, 如下图), 如果这5个哲学家在同一时间就餐, 这时候就会出现每个人都要去和相邻的人竞争一根筷子, 谁也不让谁, 从而造成了死等… 那么有没有好的办法来进行解决呢?

[JavaEE系列] 详解面试中多线程部分(内含Callable接口+JUC常见类+线程安全集合类+死锁)_第4张图片


       我们通过观察上面的这例子, 不难看出造成死锁的一些条件, 在这里总结了四个造成死锁的主要必要条件:

  1. 互斥使用: 一个线程在使用一把锁的时候, 另外一个线程就不能获取到这把锁了
  2. 不可抢占式的特点: 当一个线程获取到一把锁之后, 其他线程如果需要获取这把锁就需要进行阻塞等待直到这个锁被释放, 而不是直接将这把锁抢过来
  3. 请求和保持的特点: 当一个线程获取到锁的时候, 会一直持有这个获取到锁的状态, 直到主动去释放
  4. 循环等待: 这一点就和编写的代码联系比较大了, 主要就是要注意看有没有线程之间是互相等待的

       既然找出了出现死锁的原因, 那么通过这些原因就可以找到一写对应的解决方法(重点):
       针对多把锁指定一个简单的规则(约定) — 进行编号: 约定在获取多把锁的时候, 是要明确获取到锁的顺序是从小到大的, 也就是说假如当一个线程获取到锁3的时候, 那么这个线程在获取第二个线程的时候只会再继续往后进行获取直到获取到, 而不会出现说返回去获取锁1或者锁2.
       接下来就可以来模拟一下这个案例的整个工作流程(建议照着上面的图来进行理解): 每个哲学家(线程)都先往自己的左手边拿筷子(取出编号较小的锁), 由此绕第一圈之后会发现每个哲学家(线程)都手握左筷子(编号小的锁), 最后一个哲学家(线程5)是不会去拿筷子的(没获取到任何锁), 这是因为这个哲学家左右手分别是锁5和锁1, 根据自定义的规则, 这个哲学家必须去尝试获取锁1, 但是锁1此时此刻正在被第一个哲学家使用, 所以最后一个哲学家(线程5)就会继续思考(阻塞等待); 在执行第二圈的时候哲学家1, 2, 3都获取不到自己右手边的筷子(因为被占用了), 直到哲学家4(线程4), 由于哲学家5(线程5)并没有去获取筷子5(锁5), 所以此时筷子5(锁5)就可以顺利地被哲学家4(线程4)获取到… 直到哲学家4(线程4)就餐完毕后会将筷子重新放回桌子上(锁被释放), 此时哲学家3(线程3)就可以获取到哲学家4释放的筷子, 如此递推下去, 最后就可以让每个哲学家都顺利完成就餐(不会出现死锁的情况).

你可能感兴趣的:(JavaEE初阶系列,面试,java-ee,java)