- Day 1,第二篇
- 本文的主题是
【Java实习生面试题系列】-- 多线程篇二
1. 说一说自己对于 synchronized 关键字的了解?
1.1、synchronized的用法有哪些?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得
当前对象实例
的锁 - 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给Class
类上锁 - 修饰代码块:指定加锁对象,对
给定对象
加锁,进入同步代码库前要获得给定对象的锁
特别注意:
①如果一个线程A调用一个实例对象的非静态 synchronized
方法,而线程B需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁
②尽量不要使用 synchronized(String s)
,因为JVM中,字符串常量池具有缓冲功能
1.2、synchronized的作用有哪些?
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
- 有序性:有效解决重排序问题,即 “一个
unlock
操作先行发生(happen-before)
于后面对同一个锁的lock
操作”。
1.3、说一下 synchronized 底层实现原理?
synchronized
同步代码块的实现是通过 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。当执行 monitorenter
指令时,线程试图获取锁也就是获取 monitor
(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。
其内部包含一个计数器,当计数器为 0
则可以成功获取,获取后将锁计数器设为 1
也就是加1。相应的在执行 monitorexit
指令后,将锁计数器设为 0
,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized
修饰的 方法
并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
1.4、多线程中 synchronized 锁升级的原理是什么?
synchronized
锁升级原理:在锁对象的对象头里面有一个 threadid
字段,在第一次访问的时候 threadid
为空,jvm 让其持有偏向锁,并将 threadid
设置为其线程 id
,再次进入的时候会先判断 threadid
是否与其线程 id
一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁
,此过程就构成了 synchronized
锁的升级。
锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized
的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
1.5、synchronized 为什么是非公平锁?非公平体现在哪些地方?
synchronized
的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:
1)当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
- 先将锁的持有者 owner 属性赋值为 null
- 唤醒等待链表中的一个线程(假定继承者)。
在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。
2)当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
2. 说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?
从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过 锁消除、锁粗化、自旋锁
等方法使用各种场景,给 synchronized
性能带来了很大的提升。
2.1、锁膨胀
上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。
- 偏向锁
一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
核心思想:
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word
的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word
的锁标记位为偏向锁以及当前线程ID等于Mark Word
的ThreadID即可,这样就省去了大量有关锁申请的操作。
- 轻量级锁
轻量级锁是由偏向锁升级而来,当存在 第二个线程申请同一个锁对象
时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
- 重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
2.2、锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1
和method2
的执行效率是一样的,因为object
锁是私有变量,不存在所得竞争关系。
2.3、 锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁
。比如下面 method3
经过锁粗化优化之后就和 method4
执行效率一样了。
2.4、 自旋锁与自适应自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起
,还会进行一项称为自旋锁的优化手段。
- 自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
- 自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,
它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
,这就解决了自旋锁带来的缺点。 - 为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?
重量级锁底层依赖于 系统的同步函数
来实现,在 linux 中使用 pthread_mutex_t
(互斥锁)来实现。
这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。
而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁
,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
3. 谈谈 synchronized 和 ReenTrantLock 的区别?
1.两者都是可重入锁
- 可重入锁:重入锁,也叫做递归锁,
可重入锁指的是在一个线程中可以多次获取同一把锁
,比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
2.synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized
是依赖于JVM
实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的ReentrantLock
是JDK
层面实现的(也就是 API 层面,需要lock()
和unlock()
方法配合try/finally
语句块来完成)
3.ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
- 等待可中断:通过
lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。 所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过
ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。 - 用ReentrantLock类结合Condition实例可以实现“选择性通知”
4.使用选择
- 除非需要使用
ReentrantLock
的高级功能,否则优先使用synchronized
。 ReentrantLock
不是所有的JDK
版本都支持,且它需要显式的释放锁。并且使用synchronized
不用担心没有释放锁而导致死锁问题,因为JVM
会确保锁的释放
4. synchronized 和 volatile 的区别是什么?
volatile
解决的是内存可见性问题,会使得所有对 volatile
变量的读写都直接写入主存,即 保证了变量的可见性。
synchronized
解决的事执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被 synchronized
关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized
还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得这个锁的线程的所有操作都 happens-before
于随后获得这个锁的线程的操作。
两者的区别主要有如下:
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile 仅能使用在变量级别;synchronized 则可以使用在 变量. 方法. 和类级别的
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
5. 谈一下你对 volatile 关键字的理解?
volatile的两层语义:
volatile
保证变量对所有线程的可见性:当volatile
变量被修改,新值对所有线程会立即更新。或者理解为多线程环境下使用volatile
修饰的变量的值一定是最新的 。- jdk1.5以后
volatile
完全避免了指令重排优化,实现了有序性。
volatile的原理:
- 获取JIT(即时Java编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现
volatile
多加了lock addl
指令,这个操作相当于一个内存屏障,使得lock
指令后的指令不能重排序到内存屏障前的位置。这也是为什么JDK1.5以后可以使用双锁检测实现单例模式。(内存屏障实现避免指令重排
) lock
前缀的另一层意义是使得本线程工作内存中的volatile
变量值立即写入到主内存中,并且使得其他线程共享的该volatile
变量无效化,这样其他线程必须重新从主内存中读取变量值 。
这一篇的面试题是面试重难点,所以我就不放太多题上来了,好好吃透。总结面试题也花费了我不少时间,所以说总结不易,如果你感觉对你有帮助的话,请你三连支持,后面的文章会一点点更新。