【2】多线程并发面试总结

171 阅读14分钟

重排序是什么?如何避免?

程序执行的顺序按照代码的先后顺序执行。但是处理器为了提高程序运行效率,可能会对输入代码进行优化,进行重新排序(重排序),它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

排序对单线程运行是不会有任何问题,但是多线程就不一定了。

重排序有哪些分类?如何避免?

  • 编译器重排序:对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  • CPU指令重排序:在指令级别,让没有依赖关系的多条指令并行。
  • CPU内存重排序:CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

避免:volatile

volatile关键字的作用

保证可见性和禁止指令重排,当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主内存中,当有其他线程需要读取时,它会去内存中读取新值。

举个例子:

X=0, 线程A x=1,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。

volatile 变量和 atomic 变量有什么不同?

  • volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。

  • 而 AtomicInteger 类提供的 atomic方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。

说说自己是怎么使用 synchronized 关键字?

synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。

  • 修饰实例方法:当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

  • 修饰静态方法:那么该方法锁的对象是类对象。每个类都有唯一的一个类对象。获取类对象的方式:类名.class。

  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

Java 中新的Lock接口相对于同步代码块(synchronized block)有什么优势?

Lock是接口有很多实现,我只用过ReentrantLock

ReentrantLock就提供了比synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量

主要区别:

  • ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
  • ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
  • ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
  • 二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号里面的对象

如果让你实现一个高性能缓存,支持并发读取和单一写入,你如何保证数据完整性。

Lock接口的最大优势是它为读和写提供两个单独的锁(ReentrantReadWriteLock),ReentrantReadWriteLock的特点是:“读读共享”,“读写互斥”,“写写互斥”。

高性能缓存简易示例

public class ReadWriteMap {

    private final Map<Object, Object> map;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();

    public ReadWriteMap(Map<Object, Object> map) {
        this.map = map;
    }

    public Object put(Object key, Object value) {
        try {
            writeLock.lock();
            return map.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    public Object get(Object key) {
        try {
            readLock.lock();
            return map.get(key);
        } finally {
            writeLock.unlock();
        }
    }
}

如何在Java中实现一个阻塞队列

生产者−消费者模型

  • 内存队列本身要加锁,才能实现线程安全。
  • 阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。
  • 双向通知。消费者被阻塞之后,生产者放入新数据,要notify()消费者;反之,生产者被阻塞之后,消费者消费了数据,要notify()生产者。

1.如何阻塞?

  • 办法1:线程自己阻塞自己,也就是生产者、消费者线程各自调用wait()和notify()。
  • 办法2:用一个阻塞队列,当取不到或者放不进去数据的时候,入队/出队函数本身就是阻塞的。

2.如何双向通知?

  • 办法1:wait()与notify()机制。
  • 办法2:Condition机制

生产者在通知消费者的同时,也通知了其他的生产者;消费者在通知生产者的同时,也通知了其他 消费者。原因在于wait()和notify()所作用的对象和synchronized所作用的对象是同一个,只能有一个对 象,无法区分队列空和列队满两个条件。这正是Condition要解决的问题。

直接使用BlockingQueue也可以实现一个阻塞队列

写一段死锁代码。说说你在Java中如何解决死锁。

public class Deadlock {
    public static String str1 = "str1";
    public static String str2 = "str2";

    public static void main(String[] args){
        Thread a = new Thread(() -> {
            try{
                while(true){
                    synchronized(Deadlock.str1){
                        System.out.println(Thread.currentThread().getName()+"锁住 str1");
                        Thread.sleep(1000);
                        synchronized(Deadlock.str2){
                            System.out.println(Thread.currentThread().getName()+"锁住 str2");
                        }
                    }
                }
            }catch(Exception e){
                e.printStackTrace();
            }
        });

        Thread b = new Thread(() -> {
            try{
                while(true){
                    synchronized(Deadlock.str2){
                        System.out.println(Thread.currentThread().getName()+"锁住 str2");
                        Thread.sleep(1000);
                        synchronized(Deadlock.str1){
                            System.out.println(Thread.currentThread().getName()+"锁住 str1");
                        }
                    }
                }
            }catch(Exception e){
                e.printStackTrace();
            }
        });

        a.start();
        b.start();
    }
}

上面的代码就是一个完整的死锁程序,程序中有两个线程,线程1锁住了str1,获得锁之后休眠1秒钟,这个时候线程2锁住了str2,也进行休眠操作。

线程1休眠完了之后去锁str2,但是str2已经被线程2给锁住了,这边只能等待,同样的道理,线程2休眠完之后也要去锁str1,同样也会等待,这样死锁就产生了。

避免线程死锁

  • 避免一个线程同时获得多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制

创建线程的几种方式?

  • 继承 Thread 类
  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 使用匿名内部类方式
  • 线程池

什么是 FutureTask?

utureTask 表示一个异步运算的任务,FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。

为什么要用线程池?

线程池是为突然大量爆发的线程设计的,通过有限的几个固定线程为大量的操作服务,减少了创建和销毁线程所需的时间,从而提高效率。

  • 降低资源消耗:重用存在的线程,减少对象创建销毁的开销。
  • 提高响应速度。可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
  • 附加功能:提供定时执行、定期执行、单线程、并发数控制等功能。

四种构建线程池的方式?

通过Executors提供四种线程池,分别为:

  • newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
  • newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  • newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

线程池执行流程,即对应execute()方法:

  • 提交一个任务,线程池里存活的核心线程数小于线程数corePoolSize时,线程池会创建一个核心线程去处理提交的任务。
  • 如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
  • 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建一个非核心线程执行提交的任务。
  • 如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

四种拒绝策略:

  • AbortPolicy(抛出一个异常,默认的)
  • DiscardPolicy(直接丢弃任务)
  • DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)
  • CallerRunsPolicy(交给线程池调用所在的线程进行处理)

阿里规范:手动创建线程池,效果会更好哦。

通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

1)FixedThreadPool和SingleThreadPool:
允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
2)CachedThreadPool:
允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

实现Runnable接口和Callable接口的区别

函数式接口

Runnable void run() 既没有参数又没有返回值的方法

Callable V call() 没有参数有返回值的方法

  • Callable 接口类似于 Runnable,从名字就可以看出来了,但是 Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。
  • Future 接口表示异步任务,是一个可能还没有完成的异步任务的结果。所以说 Callable用于产生结果,Future 用于获取结果。

执行execute()方法和submit()方法的区别是什么呢?

  • 相同点就是都可以开启线程执行池中的任务。
  • 接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。
  • 返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有
  • 异常处理:submit()方便Exception处理

守护线程和用户线程有什么区别呢?

守护线程通常用在作为垃圾收集器或缓存管理器的应用程序中,执行辅助任务。

  • 用户 (User) 线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程
  • 守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作

sleep() 和 wait() 有什么区别?

两者都可以暂停线程的执行

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

线程的 sleep()方法和 yield()方法有什么区别?

  • (1) sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;

  • (2) 线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态;

  • (3)sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常;

  • (4)sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。

调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

  • new一个Thread,线程进入了新建状态。调用start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
  • 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。

start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。

线程池都有哪些状态?

  • RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
  • STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
  • TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
  • TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。

作者:小杰要吃蛋 链接:juejin.cn/post/684490… 来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

ThreadLocal 是什么?有哪些使用场景?

ThreadLocal相当于维护了一个map,key就是当前的线程,value就是需要存储的对象

threadlocal是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据

使用场景1:

网关解析了token,把用户信息放入了header

在前端微服务拦截器里面,会取出请求header里面的用户信息,会放入一个单例的UserContextHolder(用户上下文),内部就是一个ThreadLocal,用户信息就放入了这个ThreadLocal里面,需要用户信息的时候就可以直接取。

使用场景2:

数据库的连接工具类,从数据源中获取一个连接,并将实现和线程的绑定

业务逻辑控制在一个事务就需要两次操作都是同一个Connection

怎么保证多线程的运行安全?

  • 方法一:使用安全类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger
  • 方法二:使用自动锁 synchronized。
  • 方法三:使用手动锁 Lock。