个人珍藏的80道多线程并发面试题(11-20答案解析)

前言

个人珍藏的80道Java多线程/并发经典面试题,现在给出11-20的答案解析哈,并且上传github哈~

https://github.com/whx123/JavaHome

个人珍藏的80道多线程并发面试题(1-10答案解析)

11、为什么要用线程池?Java的线程池内部机制,参数作用,几种工作阻塞队列,线程池类型以及使用场景

回答这些点:

  • 为什么要用线程池?
  • Java的线程池原理
  • 线程池核心参数
  • 几种工作阻塞队列
  • 线程池使用不当的问题
  • 线程池类型以及使用场景

为什么要用线程池?

线程池:一个管理线程的池子。

  • 管理线程,避免增加创建线程和销毁线程的资源损耗。
  • 提高响应速度。
  • 重复利用。

Java的线程池执行原理

image

为了形象描述线程池执行,打个比喻:

  • 核心线程比作公司正式员工
  • 非核心线程比作外包员工
  • 阻塞队列比作需求池
  • 提交任务比作提需求


    image

线程池核心参数

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
   long keepAliveTime,
   TimeUnit unit,
   BlockingQueue workQueue,
   ThreadFactory threadFactory,
   RejectedExecutionHandler handler) 
  • corePoolSize: 线程池核心线程数最大值
  • maximumPoolSize: 线程池最大线程数大小
  • keepAliveTime: 线程池中非核心线程空闲的存活时间大小
  • unit: 线程空闲存活时间单位
  • workQueue: 存放任务的阻塞队列
  • threadFactory: 用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。
  • handler:线城池的饱和策略事件,主要有四种类型拒绝策略。

四种拒绝策略

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

几种工作阻塞队列

  • ArrayBlockingQueue(用数组实现的有界阻塞队列,按FIFO排序量)
  • LinkedBlockingQueue(基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列)
  • DelayQueue(一个任务定时周期的延迟执行的队列)
  • PriorityBlockingQueue(具有优先级的无界阻塞队列)
  • SynchronousQueue(一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态)

线程池使用不当的问题

线程池适用不当可能导致内存飙升问题哦

有兴趣可以看我这篇文章哈:源码角度分析-newFixedThreadPool线程池导致的内存飙升问题

线程池类型以及使用场景

  • newFixedThreadPool

适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。

  • newCachedThreadPool

用于并发执行大量短期的小任务。

  • newSingleThreadExecutor

适用于串行执行任务的场景,一个任务一个任务地执行。

  • newScheduledThreadPool

周期性执行任务的场景,需要限制线程数量的场景

  • newWorkStealingPool

建一个含有足够多线程的线程池,来维持相应的并行级别,它会通过工作窃取的方式,使得多核的 CPU 不会闲置,总会有活着的线程让 CPU 去运行,本质上就是一个 ForkJoinPool。)

有兴趣可以看我这篇文章哈:面试必备:Java线程池解析

12、谈谈volatile关键字的理解

volatile是面试官非常喜欢问的一个问题,可以回答以下这几点:

  • vlatile变量的作用
  • 现代计算机的内存模型(嗅探技术,MESI协议,总线)
  • Java内存模型(JMM)
  • 什么是可见性?
  • 指令重排序
  • volatile的内存语义
  • as-if-serial
  • Happens-before
  • volatile可以解决原子性嘛?为什么?
  • volatile底层原理,如何保证可见性和禁止指令重排(内存屏障)

vlatile变量的作用?

  • 保证变量对所有线程可见性
  • 禁止指令重排

现代计算机的内存模型

image
  • 其中高速缓存包括L1,L2,L3缓存~
  • 缓存一致性协议,可以了解MESI协议
  • 总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,CPU和其他功能部件是通过总线通信的。
  • 处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存数据在总线上保持一致。

Java内存模型(JMM)

image

什么是可见性?

可见性就是当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。

指令重排序

指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序。


image

volatile的内存语义

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

as-if-serial

如果在本线程内观察,所有的操作都是有序的;即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会被改变。

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

步骤C依赖于步骤A和B,因为指令重排的存在,程序执行顺讯可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面执行,这将违反as-if-serial语义。


image

Happens-before

Java语言中,有一个先行发生原则(happens-before):

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

volatile可以解决原子性嘛?为什么?

不可以,可以直接举i++那个例子,原子性需要synchronzied或者lock保证

public class Test {
    public volatile int race = 0;
     
    public void increase() {
        race++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<100;j++)
                        test.increase();
                };
            }.start();
        }
        
        //等待所有累加线程结束
        while(Thread.activeCount()>1)  
            Thread.yield();
        System.out.println(test.race);
    }
}

volatile底层原理,如何保证可见性和禁止指令重排(内存屏障)

volatile 修饰的变量,转成汇编代码,会发现多出一个lock前缀指令。lock指令相当于一个内存屏障,它保证以下这几点:

  • 1.重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 2.将本处理器的缓存写入内存
  • 3.如果是写入动作,会导致其他处理器中对应的缓存无效。

2、3点保证可见性,第1点禁止指令重排~

有兴趣的朋友可以看我这篇文章哈:Java程序员面试必备:Volatile全方位解析

13、AQS组件,实现原理

AQS,即AbstractQueuedSynchronizer,是构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。可以回答以下这几个关键点哈:

  • state 状态的维护。
  • CLH队列
  • ConditionObject通知
  • 模板方法设计模式
  • 独占与共享模式。
  • 自定义同步器。
  • AQS全家桶的一些延伸,如:ReentrantLock等。

state 状态的维护

  • state,int变量,锁的状态,用volatile修饰,保证多线程中的可见性。
  • getState()和setState()方法采用final修饰,限制AQS的子类重写它们两。
  • compareAndSetState()方法采用乐观锁思想的CAS算法操作确保线程安全,保证状态
    设置的原子性。

对CAS有兴趣的朋友,可以看下我这篇文章哈~
CAS乐观锁解决并发问题的一次实践

CLH队列

image

CLH(Craig, Landin, and Hagersten locks) 同步队列 是一个FIFO双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。AQS依赖它来完成同步状态state的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。

ConditionObject通知

我们都知道,synchronized控制同步的时候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以实现等待/通知模式。而Lock呢?它提供了条件Condition接口,配合await(),signal(),signalAll() 等方法也可以实现等待/通知机制。ConditionObject实现了Condition接口,给AQS提供条件变量的支持 。

image

ConditionObject队列与CLH队列的爱恨情仇:

  • 调用了await()方法的线程,会被加入到conditionObject等待队列中,并且唤醒CLH队列中head节点的下一个节点。
  • 线程在某个ConditionObject对象上调用了singnal()方法后,等待队列中的firstWaiter会被加入到AQS的CLH队列中,等待被唤醒。
  • 当线程调用unLock()方法释放锁时,CLH队列中的head节点的下一个节点(在本例中是firtWaiter),会被唤醒。

模板方法设计模式

什么是模板设计模式?

在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。

AQS的典型设计模式就是模板方法设计模式啦。AQS全家桶(ReentrantLock,Semaphore)的衍生实现,就体现出这个设计模式。如AQS提供tryAcquire,tryAcquireShared等模板方法,给子类实现自定义的同步器。

独占与共享模式

  • 独占式: 同一时刻仅有一个线程持有同步状态,如ReentrantLock。又可分为公平锁和非公平锁。
  • 共享模式:多个线程可同时执行,如Semaphore/CountDownLatch等都是共享式的产物。

自定义同步器

你要实现自定义锁的话,首先需要确定你要实现的是独占锁还是共享锁,定义原子变量state的含义,再定义一个内部类去继承AQS,重写对应的模板方法即可啦

AQS全家桶的一些延伸。

Semaphore,CountDownLatch,ReentrantLock

可以看下之前我这篇文章哈,AQS解析与实战

14、什么是多线程环境下的伪共享

  • 什么是伪共享
  • 如何解决伪共享问题

什么是伪共享

伪共享定义?

CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改相互独立的变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。这就是伪共享

现代计算机计算模型,大家都有印象吧?我之前这篇文章也有讲过,有兴趣可以看一下哈,Java程序员面试必备:Volatile全方位解析

image
  • CPU执行速度比内存速度快好几个数量级,为了提高执行效率,现代计算机模型演变出CPU、缓存(L1,L2,L3),内存的模型。
  • CPU执行运算时,如先从L1缓存查询数据,找不到再去L2缓存找,依次类推,直到在内存获取到数据。
  • 为了避免频繁从内存获取数据,聪明的科学家设计出缓存行,缓存行大小为64字节。

也正是因为缓存行,就导致伪共享问题的存在,如图所示:


image

假设数据a、b被加载到同一个缓存行。

  • 当线程1修改了a的值,这时候CPU1就会通知其他CPU核,当前缓存行(Cache line)已经失效。
  • 这时候,如果线程2发起修改b,因为缓存行已经失效了,所以core2 这时会重新从主内存中读取该 Cache line 数据。读完后,因为它要修改b的值,那么CPU2就通知其他CPU核,当前缓存行(Cache line)又已经失效。
  • 酱紫,如果同一个Cache line的内容被多个线程读写,就很容易产生相互竞争,频繁回写主内存,会大大降低性能。

如何解决伪共享问题

既然伪共享是因为相互独立的变量存储到相同的Cache line导致的,一个缓存行大小是64字节。那么,我们就可以使用空间换时间,即数据填充的方式,把独立的变量分散到不同的Cache line~

共享内存demo例子:

public class FalseShareTest  {

    public static void main(String[] args) throws InterruptedException {
        Rectangle rectangle = new Rectangle();
        long beginTime = System.currentTimeMillis();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                rectangle.a = rectangle.a + 1;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                rectangle.b = rectangle.b + 1;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println("执行时间" + (System.currentTimeMillis() - beginTime));
    }

}

class Rectangle {
    volatile long a;
    volatile long b;
}

运行结果:

执行时间2815

一个long类型是8字节,我们在变量a和b之间不上7个long类型变量呢,输出结果是啥呢?如下:

class Rectangle {
    volatile long a;
    long a1,a2,a3,a4,a5,a6,a7;
    volatile long b;
}

运行结果:

执行时间1113

可以发现利用填充数据的方式,让读写的变量分割到不同缓存行,可以很好挺高性能~

15、 说一下 Runnable和 Callable有什么区别?

  • Callable接口方法是call(),Runnable的方法是run();
  • Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
  • Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常;
@FunctionalInterface
public interface Callable {
    /**
     * 支持泛型V,有返回值,允许抛出异常
     */
    V call() throws Exception;
}

@FunctionalInterface
public interface Runnable {
    /**
     *  没有返回值,不能继续上抛异常
     */
    public abstract void run();
}

看下demo代码吧,这样应该好理解一点哈~

/*
 *  @Author 捡田螺的小男孩
 *  @date 2020-08-18
 */
public class CallableRunnableTest {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        Callable  callable =new Callable() {
            @Override
            public String call() throws Exception {
                return "你好,callable";
            }
        };

        //支持泛型
        Future futureCallable = executorService.submit(callable);

        try {
            System.out.println(futureCallable.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("你好呀,runnable");
            }
        };

        Future futureRunnable = executorService.submit(runnable);
        try {
            System.out.println(futureRunnable.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        executorService.shutdown();

    }
}

运行结果:

你好,callable
你好呀,runnable
null

16、wait(),notify()和suspend(),resume()之间的区别

  • wait() 使得线程进入阻塞等待状态,并且释放锁
  • notify()唤醒一个处于等待状态的线程,它一般跟wait()方法配套使用。
  • suspend()使得线程进入阻塞状态,并且不会自动恢复,必须对应的resume() 被调用,才能使得线程重新进入可执行状态。suspend()方法很容易引起死锁问题。
  • resume()方法跟suspend()方法配套使用。

suspend()不建议使用,suspend()方法在调用后,线程不会释放已经占有的资 源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。

17.Condition接口及其实现原理

  • Condition接口与Object监视器方法对比
  • Condition接口使用demo
  • Condition实现原理

Condition接口与Object监视器方法对比

Java对象(Object),提供wait()、notify(),notifyAll() 系列方法,配合synchronized,可以实现等待/通知模式。而Condition接口配合Lock,通过await(),signal(),signalAll() 等方法,也可以实现类似的等待/通知机制。

对比项 对象监视方法 Condition
前置条件 获得对象的锁 调用Lock.lock()获取锁,调用Lock.newCondition()获得Condition对象
调用方式 直接调用,object.wait() 直接调用,condition.await()
等待队列数 1个 多个
当前线程释放锁并进入等待状态 支持 支持
在等待状态中不响应中断 不支持 支持
当前线程释放锁并进入超时等待状态 支持 支持
当前线程释放锁并进入等待状态到将来的某个时间 不支持 支持
唤醒等待队列中的一个线程 支持 支持
唤醒等待队列中的全部线程 支持 支持

Condition接口使用demo

public class ConditionTest {
    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();

    public void conditionWait() throws InterruptedException {
        lock.lock();
        try {
            condition.await();
        } finally {
            lock.unlock();
        }
    }

    public void conditionSignal() throws InterruptedException {
        lock.lock();
        try {
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
}

Condition实现原理

其实,同步队列和等待队列中节点类型都是同步器的静态内部类 AbstractQueuedSynchronizer.Node,接下来我们图解一下Condition的实现原理~

等待队列的基本结构图

image

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点 (lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队

AQS 结构图

ConditionI是跟Lock一起结合使用的,底层跟同步器(AQS)相关。同步器拥有一个同步队列和多个等待队列~


image

等待

image

当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。

通知

image

调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在
唤醒节点之前,会将节点移到同步队列中。

18、线程池如何调优,最大数目如何确认?

在《Java Concurrency in Practice》一书中,有一个评估线程池线程大小的公式

Nthreads=NcpuUcpu(1+w/c)**

  • Ncpu = CPU总核数
  • Ucpu =cpu使用率,0~1
  • W/C=等待时间与计算时间的比率

假设cpu 100%运转,则公式为

Nthreads=Ncpu*(1+w/c)

估算的话,酱紫:

  • 如果是IO密集型应用(如数据库数据交互、文件上传下载、网络数据传输等等),IO操作一般比较耗时,等待时间与计算时间的比率(w/c)会大于1,所以最佳线程数估计就是 Nthreads=Ncpu*(1+1)= 2Ncpu 。
  • 如果是CPU密集型应用(如算法比较复杂的程序),最理想的情况,没有等待,w=0,Nthreads=Ncpu。又对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。所以 Nthreads = Ncpu+1

有具体指参考呢?举个例子

比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:线程池大小=(1+1.5/05)*8 =32。

参考了网上这篇文章,写得很棒,有兴趣的朋友可以去看一下哈:

  • 根据CPU核心数确定线程池并发线程数

19、 假设有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?

可以使用join方法解决这个问题。比如在线程A中,调用线程B的join方法表示的意思就是:A等待B线程执行完毕后(释放CPU执行权),在继续执行。

代码如下:

public class ThreadTest {

    public static void main(String[] args) {

        Thread spring = new Thread(new SeasonThreadTask("春天"));
        Thread summer = new Thread(new SeasonThreadTask("夏天"));
        Thread autumn = new Thread(new SeasonThreadTask("秋天"));

        try
        {
            //春天线程先启动
            spring.start();
            //主线程等待线程spring执行完,再往下执行
            spring.join();
            //夏天线程再启动
            summer.start();
            //主线程等待线程summer执行完,再往下执行
            summer.join();
            //秋天线程最后启动
            autumn.start();
            //主线程等待线程autumn执行完,再往下执行
            autumn.join();
        } catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
}

class SeasonThreadTask implements Runnable{

    private String name;

    public SeasonThreadTask(String name){
        this.name = name;
    }

    @Override
    public void run() {
        for (int i = 1; i <4; i++) {
            System.out.println(this.name + "来了: " + i + "次");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

运行结果:

春天来了: 1次
春天来了: 2次
春天来了: 3次
夏天来了: 1次
夏天来了: 2次
夏天来了: 3次
秋天来了: 1次
秋天来了: 2次
秋天来了: 3次

20. LockSupport作用是?

  • LockSupport作用
  • park和unpark,与wait,notify的区别
  • Object blocker作用?

LockSupport是个工具类,它的主要作用是挂起和唤醒线程, 该工具类是创建锁和其他同步类的基础。

public static void park(); //挂起当前线程,调用unpark(Thread thread)或者当前线程被中断,才能从park方法返回
public static void parkNanos(Object blocker, long nanos);  // 挂起当前线程,有超时时间的限制
public static void parkUntil(Object blocker, long deadline); // 挂起当前线程,直到某个时间
public static void park(Object blocker); //挂起当前线程
public static void unpark(Thread thread); // 唤醒当前thread线程

看个例子吧:

public class LockSupportTest {

    public static void main(String[] args) {

        CarThread carThread = new CarThread();
        carThread.setName("劳斯劳斯");
        carThread.start();

        try {
            Thread.currentThread().sleep(2000);
            carThread.park();
            Thread.currentThread().sleep(2000);
            carThread.unPark();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    static class CarThread extends Thread{

        private boolean isStop = false;

        @Override
        public void run() {

            System.out.println(this.getName() + "正在行驶中");

            while (true) {

                if (isStop) {
                    System.out.println(this.getName() + "车停下来了");
                    LockSupport.park(); //挂起当前线程
                }
                System.out.println(this.getName() + "车还在正常跑");

                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }

        public void park() {
            isStop = true;
            System.out.println("停车啦,检查酒驾");

        }

        public void unPark(){
            isStop = false;
            LockSupport.unpark(this); //唤醒当前线程
            System.out.println("老哥你没酒驾,继续开吧");
        }

    }
}

运行结果:

劳斯劳斯正在行驶中
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑
停车啦,检查酒驾
劳斯劳斯车停下来了
老哥你没酒驾,继续开吧
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑
劳斯劳斯车还在正常跑

LockSupport的park和unpark的实现,有点类似wait和notify的功能。但是

  • park不需要获取对象锁
  • 中断的时候park不会抛出InterruptedException异常,需要在park之后自行判断中断状态
  • 使用park和unpark的时候,可以不用担心park的时序问题造成死锁
  • LockSupport不需要在同步代码块里
  • unpark却可以唤醒一个指定的线程,notify只能随机选择一个线程唤醒

Object blocker作用?

方便在线程dump的时候看到具体的阻塞对象的信息。

公众号

image

参考与感谢

  • 《java并发编程的艺术》
  • 杂谈 什么是伪共享(false sharing)?
  • 根据CPU核心数确定线程池并发线程数
  • LockSupport的用法及原理
  • 探讨缓存行与伪共享

你可能感兴趣的:(个人珍藏的80道多线程并发面试题(11-20答案解析))