【多线程】多线程面试问题总结

目录

 

synchronized相关

1.知道synchronized吗?

2.synchronized的修饰方式?

3.知道单例模式吗?手写一下double-check 实现单例模式?能讲解一下吗?

4.知道synchronized的原理吗?

5.知道jdk1.6之后对synchronzied做了哪些优化吗 ?

6.synchronzied和ReentrantLock(可重入锁)的区别?

7.synchronized和volatile的区别?

什么是CAS?

加锁和不加锁的对比分析

CAS的缺点:

什么是ABA问题?怎么解决?

怎么办呢?

线程池相关

1.线程池的原理?

2.Java提供的4种线程池

3.项目里用到的线程池

问题集锦

1.了解并发相关的包吗?

2.常用的并发工具类

3.为什么要使用重入锁?然后它的实现是怎样的?

4.synchronized是可重入锁吗?

5.具体说下AQS做了些什么?

AQS做了什么:

6.原子变量具体解决的是什么问题

7.线程安全性体现在3个方面

8.volatile关键字的作用。说下指令重排序和可见性。


synchronized相关

1.知道synchronized吗?

synchronized同步锁,主要是解决多线程同步问题,在synchronized的作用范围内同一时刻只有一个线程可以执行。
是不可中断锁,也就是说必须执行完不可中断。

2.synchronized的修饰方式?

  • synchronized修饰方法,作用范围是整个方法,作用对象是调用方法的对象。
  • synchronized修饰代码块,作用范围是整个代码块,作用对象是调用方法的对象。
  • synchronized修饰类,作用范围是整个类,作用对象是所有对象。
  • synchronized修饰静态方法,作用范围是整个方法,作用对象是所有对象

如果父类的方法使用了synchronized关键字修饰,子类继承该方法时,默认是不包含synchronized的,因为synchronized不是方法声明的一部分,如果子类也需要同步,则需要显式的添加synchronized。

3.知道单例模式吗?手写一下double-check 实现单例模式?能讲解一下吗?

关于单例模式,在这里总结过:【并发编程】单例模式与线程安全

4.知道synchronized的原理吗?

  • synchronized修饰同步语句块的情况,实际上是在语句块前后加上了两个指令,monitorenter插在代码块开始位置,monitorexit插在代码块结束的位置,任何对象都有一个Monitor与之相连,当一个线程持有monitor时,它将处于锁定状态,计数器+1,释放Monitor时,计数器-1,当计数器为0时才可以持有monitor。
  • synchronized修饰方法时并没有加指令,而是添加了 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

5.知道jdk1.6之后对synchronzied做了哪些优化吗 ?

  • 自旋锁:为了避免因等待资源而阻塞线程的情况,自旋锁的思想是让获取共享资源的线程在获取不到的情况下不停的循环重试一段时间,在这段时间内获得锁,就可以避免进入阻塞状态;虽然避免进入阻塞状态,但会一直占用cpu时间,适合那些锁占用时间短的情况。
  • 锁消除将不可能存在竞争关系的共享对象的锁进行消除。主要是使用逃逸分析,逃逸分析就是说,当一个在方法中声明的对象,如果他被外部方法所引用,那么就发生了逃逸。
  • 锁粗化:如果一系列连续的操作都对同一个对象反复加锁解锁,那么可以将加锁操作粗化到这一系列连续操作的外部,可以减少加锁解锁。

可以看这里:https://blog.csdn.net/qq_25353433/article/details/100798206

 

6.synchronzied和ReentrantLock(可重入锁)的区别?

  • synchronized是依靠虚拟机实现的,ReentrantLock是JDK实现的。
  • synchronized不需要我们去加锁解锁,ReentrantLock需要需要显式的指定lock()和unlock()
  • synchronized原始的机制就是悲观锁,意味着其他线程只能被阻塞,不过有优化了。ReentrantLock是乐观锁,乐观锁的实现机制就是CAS操作。
  • 两者都是可重入锁:也就是自己可以再次获取自己的内部锁。
  • ReentrantLock增加了一些更高级的功能
    1.可以指定是公平锁还是非公平锁synchronized只能是非公平的
    2.提供了一种中断等待锁的线程的机制(lockInterruptibly),也就是说正在等待锁的线程可以放弃等待去处理别的事情。
    3.提供Condition类,可以分组唤醒需要唤醒的线程;而使用notify/notifyAll进行通知时,是由JVM决定被通知的线程的。
    4.给定一个等待时间也可以避免死锁, lock.tryLock(5, TimeUnit.SECONDS)
    ReentrantLock.tryLock()方法也可以不带参数直接运行。这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁成功,立即返回 true。否则,申请失败,立即返回 false,当前线程不会进行等待。这种模式不会引起线程等待,因此也不会产生死锁。

7.synchronized和volatile的区别?

  • volatile只能通过禁止指令重排来保证有序性,但它不能保证原子性synchronized可以保证原子性
  • volatile只可以修饰变量synchronized可以修饰类、方法、代码块
  • 仅仅使用volatile不能保证线程安全性,但synchronized可以
  • volatile只适合于对变量的写操作不依赖当前值的场景以及该变量没有包含在具有其他变量的不变式中。(start <= end,start如果是volatile变量,则不符合这条规则)

什么是CAS?

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B.

对于上面的getAndAddInt()方法以及compareAndSwapInt方法来举个例子(这是AtomicInteger中的方法):

如果count 当前内存中为1,此时线程A想要执行+1操作,这时候var1 = count,var2 =count的偏移量假设为1000,

如果此时有一个线程B先执行了这个操作,那么对于线程B来说,var1 = count,var2 = 1000,取出的值=1(工作内存),从底层(主内存)取出来的var4 也等于1,此时var2地址的值 == var4,于是就将count的值修改为2.偏移量假设为1001.

这时候线程A继续执行,发现从底层得到的var4 = 2,var2 != var4,那么就不会去执行+1操作,而是重新获取count的值,此时var2 = 1001,值为2,再次取出var4 = 2,这时候进行比较(Compare):var2的值 = var4,于是可以修改(Swap)count的值为3.这个重新尝试的过程被称为自旋

从思想上来说,synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

在并发量非常高的情况下,使用同步锁更合适一些。

不过synchronized已经经过一系列锁优化,性能有很大提升。

加锁和不加锁的对比分析

使用锁,如果一个线程试图获取其他线程已经具有的锁,那么该线程将被阻塞,直到该锁可用。此方法具有一些明显的缺点,其中包括当线程被阻塞来等待锁时,它无法进行其他任何操作。如果阻塞的线程是高优先级的任务,那么该方案可能造成非常不好的结果(称为优先级倒置的危险)。

使用锁还有一些其他危险,如死锁(当以不一致的顺序获得多个锁时会发生死锁)。甚至没有这种危险,锁也仅是相对的粗粒度协调机制,同样非常适合管理简单操作,如增加计数器或更新互斥拥有者。如果有更细粒度的机制来可靠管理对单独变量的并发更新,则会更好一些;在大多数现代处理器都有这种机制。

不加锁

CAS不断地去重试,不会挂起线程。

CAS的缺点

1.CPU开销较大

  在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

2.不能保证代码块的原子性

  CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

3.ABA问题

  这是CAS机制最大的问题所在。

什么是ABA问题?怎么解决?

ABA问题就是指,一个变量的值从A变为B,又从B变为A

假设内存中有一个值为A的变量,存储在地址V当中

 

此时有三个线程想使用CAS的方式更新这个变量值,每个线程的执行时间有略微的偏差。线程1和线程2已经获得当前值,线程3还未获得当前值。

 

接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获得了当前值B。

 

再之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。

 

最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值”A,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。

 

这个过程中,线程2获取到的变量值A是一个旧值,尽管和当前的实际值相同,但内存地址V中的变量已经经历了A->B->A的改变。

再举一个例子就能明白ABA的问题了。

假设有一个遵循CAS原理的提款机,小明有100元存款,要用这个提款机来提款50元。

由于提款机硬件出了点小问题,小明的提款操作被同时提交两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

理想情况下,应该一个线程更新成功,另一个线程更新失败,小明的存款只被扣一次。

线程1首先执行成功,把余额从100改成50。线程2因为某种原因阻塞了。这时候,小明的妈妈刚好给小明汇款50元。(线程3)

线程2仍然是阻塞状态,线程3执行成功,把余额从50改成100。

线程2恢复运行,由于阻塞之前已经获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以成功把变量值100更新成了50。

原本线程2应该提交失败,却由于ABA问题提交成功了。

怎么办呢?

加一个版本号就行,compare的时候不仅比较值,还比较版本号。

AtomicStampedReference类就实现了用版本号做比较的CAS机制

以上CAS和ABA相关来自:程序员小灰

线程池相关

https://www.cnblogs.com/sxkgeek/p/9343519.html

1.线程池的原理?

一个任务从提交到执行,有以下过程:

1.当前运行的线程数<corePoolSize,则新建一个线程来执行任务。

2.如果当前运行线程数>=corePoolSize,且workQueue没满,则提交到workQueue中等待空闲线程来执行任务。

3.如果当前运行线程数>=corePoolSize,且< maximumPoolSize,且workQueue满了,才创建新线程来执行任务。

4.如果当前运行线程 >=maximumPoolSize,workQueue也满了,就采取拒绝策略。

拒绝策略有哪些呢?

1.Abort Policy:直接抛弃任务,抛出异常。

2.DiscardPolicy:丢弃任务,不抛异常。

3.DiscardOldestPolicy:丢弃队列最前面的任务,重新尝试执行任务,重复此过程。

4.CallerRunsPolicy:由调用线程去执行任务。

2.Java提供的4种线程池

1.newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
说明:对线程池的大小是没有要求的,可以取到Integer.MAX_VALUE;但是当长时间没有向线程池提交任务,默认1分钟后工作线程会自动收回。如果提交了新任务,会重新创建线程。

2.newFixedThreadPool:创建一个固定大小的线程池,可以控制并发数,超过长度的线程会被放进队列,或者执行拒绝策略。

3.newScheduledThreadPool:创建固定大小的线程池,可以执行定时或延时任务。

4.newSingleThreadPool:创建大小为1的线程池,他只会用唯一的线程来执行任务。

3.项目里用到的线程池

Spring boot简化了配置,使用提供的ThreadPoolTastExecutor进行配置,标注@EnableAsync,然后在需要使用多线程的方法上声明@Async

@Configuration
@EnableAsync
public class BeanConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 设置核心线程数
        executor.setCorePoolSize(5);
        // 设置最大线程数
        executor.setMaxPoolSize(10);
        // 设置队列容量
        executor.setQueueCapacity(20);
        // 设置线程活跃时间(秒)
        executor.setKeepAliveSeconds(60);
        // 设置默认线程名称
        executor.setThreadNamePrefix("hello-");
        // 设置拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());//由调用线程处理任务
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        return executor;
    }
}

问题集锦

1.了解并发相关的包吗?

java.util.concurrent 包

常用的:

java.util.concurrent.Atomic:通过CAS,unsafe.compareAndSwap来保证原子性。

java.util.concurrent.CountDownLatch : CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待它可以让某一个线程等待直到倒计时结束,再开始执行。  以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作

java.util.concurrent.CyclicBarrier:多个线程互相等待,当每个线程都就绪后才能继续执行后续操作。

与countDownLatch有相似,都是用计数器实现,可循环重用。

区别:countDownLatch只能用一次,cyclicBarrier可以用reset重置。

2.常用的并发工具类

1.CountDownLatch: 用来协调多个线程之间的同步。这个工具通常用来控制线程等待它可以让某一个线程等待直到倒计时结束,再开始执行。  以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作

2.CyclicBarrier 同步屏障:让一组线程到达屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。与countDownLatch有相似,都是用计数器实现,可循环重用。
区别:countDownLatch只能用一次,cyclicBarrier可以用reset重置。

3.Semaphore信号量emaphore 就是操作系统中的信号量,可以控制对互斥资源的访问线程数

场景:适用于仅能提供有限的访问资源,数据库连接数,并发很大的情况会导致无法获取数据库连接而导致异常,可以用Semaphore做同步访问控制。每个线程使用资源前调用ss.acquire()方法获取权限,使用完后调用ss.release()方法释放权限。

4.Exchanger用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

3.为什么要使用重入锁?然后它的实现是怎样的?

可重入锁最大作用(优点)是避免死锁。缺点:必须手动开启和释放锁。

已经获得该锁的线程可以再次进入被该锁锁定的代码块。内部通过计数器 state 实现。

重入锁实现可重入性原理或机制是:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。

4.synchronized是可重入锁吗?

是的。

在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以线程为粒度的,per-invocation 互斥体获得对象锁的操作是以每调用作为粒度的)。

5.具体说下AQS做了些什么?

AQS概念 : AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。

AQS做了什么

1.提供解决同步问题的基础框架。AQS类内维护了一个volatile int型的变量state,用于表示同步状态(锁的释放与获取),同时提供了一些列诸如getstate、setstate、compareAndSetState的方法来管理该同步状态,这些方法是子类中需要重写的部分,并且,AQS提供了模板方法去调用这些重写的方法;另外,AQS用一个虚拟的CLH FIFO的双向队列来管理被阻塞的线程。

2.AQS定义两种资源共享方法:

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。 CyclicBarrier、ReadWriteLock 。

-------------详细---------------------

AQS核心思想是如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

    1

private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过procted类型的getState,setState,compareAndSetState进行操作

    1

    2

    3

    4

    5

    6

    7

    8

    9

  10

  11

  12

//返回同步状态的当前值

protected final int getState() { 

        return state;

}

 // 设置同步状态的值

protected final void setState(int newState) {

        state = newState;

}

//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)

protected final boolean compareAndSetState(int expect, int update) {

        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);

}

6.原子变量具体解决的是什么问题

解决线程间共享数据访问的问题。

最常用的AtomicInteger,AtomicStampedReference,LongAdder。都是基于CAS操作的,unsafe类提供了硬件级别的原子操作

CAS是不加锁的。

使用锁,如果一个线程试图获取其他线程已经具有的锁,那么该线程将被阻塞,直到该锁可用。此方法具有一些明显的缺点,其中包括当线程被阻塞来等待锁时,它无法进行其他任何操作。如果阻塞的线程是高优先级的任务,那么该方案可能造成非常不好的结果(称为优先级倒置的危险)。

使用锁还有一些其他危险,如死锁(当以不一致的顺序获得多个锁时会发生死锁)。甚至没有这种危险,锁也仅是相对的粗粒度协调机制,同样非常适合管理简单操作,如增加计数器或更新互斥拥有者。如果有更细粒度的机制来可靠管理对单独变量的并发更新,则会更好一些;在大多数现代处理器都有这种机制。

AtomicStampedReference的坑:https://blog.csdn.net/xybz1993/article/details/79992120

7.线程安全性体现在3个方面

  • 原子性:提供了互斥访问,同一时刻只能有一个线程对它进行操作
  • 可见性:一个线程对主内存的修改可以及时地被其他线程观察到。
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序

8.volatile关键字的作用。说下指令重排序和可见性。

volatile可以保证可见性,有序性。

通过加入内存屏障禁止重排序优化来实现的。

  • 对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存。
  • 对volatile读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

这样,volatile变量在线程访问时,都强迫从主内存中读取变量的值,而当变量发生变化时,又会强迫线程将变量刷新到主内存,这样在任何时候不同线程总能看到变量的最新值

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

a.重排序操作不会对存在数据依赖关系的操作进行重排序。

b.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

happens-before原则(只摘取了volatile原则)

volatile原则:对一个变量的写操作先行发生于后面对这个变量的读操作。

理解:通过添加屏障来阻止重排序,保证写在读之前。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值