多线程面试题归纳
说一说关于synchronized关键字的理解
synchronized关键字解决的是多个线程之间访问资源的同步性。synchronized关键字可以保证被它修饰过的方法或者代码块在任意时刻只能被一个线程执行
在Java早期版本中,synchronized属于重量级锁,效率低下。而JDK1.6版本后对锁的实现做了大量优化,比如自旋锁、适应性锁、锁消除、偏向锁、轻量级锁等技术来减少锁的开销。
说说自己是怎么使用synchronized关键字,在项目使用到了吗
一般有三种使用场景
- 修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁
讲一下synchronized关键字的底层原理
synchronized关键字底层原理属于JVM层面
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同 步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图 获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么 Java中任意对象可以作为锁的原因) 的持有权.当计数器为0则可以成功获取,获取后将锁计数器设 为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
说说JDK1.6之后的synchronized关键字底层做了哪些优化,可以详细介绍一下这些优化吗
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
谈谈 synchronized和ReenTrantLock 的区别
① 两者都是可重入锁 “可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
**② synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API **
synchronized 是依赖于 JVM 实现的,ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成)
③ ReenTrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReenTrantLock增加了一些高级功能。
主要有三点:①等待可中断;②可实现公平锁; ③可实现选择性通知(锁可以绑定多个条件)
-
ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
-
ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是非公平的,但可以通过 ReenTrantLock类的》
ReentrantLock(boolean fair)
构造方法来制定是否是公平的。 -
synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也 可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很 好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵 活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合 Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而 synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果 执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的 signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
如果你想使用上述功能,那么选择ReenTrantLock是一个不错的选择。
讲一下Java内存模型
在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,不需要特别注意的。
而在当前的Java 内存模型下,线程可以把变量保存**本地内存(比如机器的寄存器)**中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
要解决这个问题,就需要把变量声明成volatile,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取
说白了,volatile关键字的主要作用就是保证变量的可见性然后还有一个作用是防止指令重排序。
说说 synchronized 关键字和 volatile 关键字的区别
-
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
-
多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
-
volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
-
volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
为什么要用线程池?
使用线程池的好处:
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性, 使用线程池可以进行统一的分配,调优和监控。
实现Runnable接口和Callable接口的区别
两者的区别在于 Runnable 接口不会返回结果,但是 Callable 接口可以返回结果。
备注: 工具类 Executors
可以实现 Runnable
对象和 Callable
对象之间的相互转换。
Executors.callable(Runnable task)
或
Executors.callable(Runnable task,Object resule)
。
执行execute()方法和submit()方法的区别是什么呢
1)execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功;
2)submit()
方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断 任务是否执行成功,并且可以通过future的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
如何创建线程池
方法一:通过构造方法实现
方法二:通过Executor框架的工具类Executors来实现 我们可以创建三种类型的ThreadPoolExecutor
-
FixedThreadPool:该方法返回一个固定线程数量的线程池。
-
SingleThreadExecutor: 方法返回一个只有一个线程的线程池。
-
CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但 若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新 的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
介绍一下Atomic 原子类
原子类说简单点就是具有原子特征的类。
指一个操作不可中断。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
AQS介绍
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面
AQS:也就是队列同步器,是实现 ReentrantLock 的基础。
AQS 有一个 state 标记位,值为1 时表示有线程占用,其他线程需要进入到同步队列等待,同步队列是一个双向链表。
当获得锁的线程需要等待某个条件时,会进入condition 的等待队列,等待队列可以有多个。
当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。
ReentrantLock 就是基于 AQS 实现的,如下图所示,ReentrantLock 内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。
和 ReentrantLock 实现方式类似,Semaphore 也是基于 AQS 的,差别在于 ReentrantLock 是独占锁,Semaphore 是共享锁。
从图中可以看到,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。
它有公平锁FairSync和非公平锁NonfairSync两个子类。
ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
跟我聊一下CAS
CAS(Compare And Swap 比较并且替换)是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
CAS 是怎么实现线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
举个栗子:现在一个线程要修改数据库的name,修改前我会先去数据库查name的值,发现name=“张三”,拿到值了,我们准备修改成name=“李四”,在修改之前我们判断一下,原来的name是不是等于“张三”,如果被其他线程修改就会发现name不等于“张三”,我们就不进行操作,如果原来的值还是张三,我们就把name修改为“李四”,至此,一个流程就结束了。
Tip:比较+更新 整体是一个原子操作
他是乐观锁的一种实现,就是说认为数据总是不会被更改,我是乐观的仔,每次我都觉得你不会渣我,差不多是这个意思。
CAS存在什么问题呢?
要是结果一直循环了,CUP开销是个问题,还有ABA问题和只能保证一个共享变量原子操作的问题。
你能分别介绍一下么?
好的,我先介绍一下ABA这个问题,直接口述可能有点抽象,我画图解释一下:
看到问题所在没,我说一下顺序:
- 线程1读取了数据A
- 线程2读取了数据A
- 线程2通过CAS比较,发现值是A没错,可以把数据A改成数据B
- 线程3读取了数据B
- 线程3通过CAS比较,发现数据是B没错,可以把数据B改成了数据A
- 线程1通过CAS比较,发现数据还是A没变,就写成了自己要改的值
在这个过程中任何线程都没做错什么,但是值被改变了,线程1却没有办法发现,其实这样的情况出现对结果本身是没有什么影响的,但是我们还是要防范,怎么防范我下面会提到。
循环时间长开销大的问题:
是因为CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。
只能保证一个共享变量的原子操作:
CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。
我还记得你之前说在JUC包下的原子类也是通过这个实现的,能举个栗子么?
那我就拿AtomicInteger举例,他的自增函数incrementAndGet()就是这样实现的,其中就有大量循环判断的过程,直到符合条件才成功。
大概意思就是循环判断给定偏移量是否等于内存中的偏移量,直到成功才退出,看到do while的循环没。
乐观锁在项目开发中的实践,有么?
有的就比如我们在很多订单表,流水表,为了防止并发问题,就会加入CAS的校验过程,保证了线程的安全,但是看场景使用,并不是适用所有场景,他的优点缺点都很明显。
那开发过程中ABA你们是怎么保证的?
加标志位,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值。
举个栗子:现在我们去要求操作数据库,根据CAS的原则我们本来只需要查询原本的值就好了,现在我们一同查出他的标志位版本字段vision。
聊一下悲观锁?
悲观锁从宏观的角度讲就是,他是个渣男,你认为他每次都会渣你,所以你每次都提防着他。
我们先聊下JVM层面的synchronized:
synchronized加锁,synchronized 是最常用的线程同步手段之一,上面提到的CAS是乐观锁的实现,synchronized就是悲观锁了。
它是如何保证同一时刻只有一个线程可以进入临界区呢?
synchronized,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?
如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。
由于每个对象都有锁,可以如使用虚拟对象
synchronized 应用在方法上时,在字节码中是通过方法的 ACC_SYNCHRONIZED 标志来实现的。
反正其他线程进这个方法就看看是否有这个标志位,有就代表有别的仔拥有了他,你就别碰了。
synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。
每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。
小结: 同步方法和同步代码块底层都是通过monitor来实现同步的。
两者的区别:同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现,同步代码块是通过monitorenter和monitorexit来实现。
我们知道了每个对象都与一个monitor相关联,而monitor可以被线程拥有或释放。
以前我们一直锁synchronized是重量级的锁,为啥现在都不提了?
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。
但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。锁只能升级,不能降级。
Tip:锁升级的过程