1、Java中的锁
1.1、JVM 做了哪些锁优化?
- 锁从乐观和悲观的角度可分为乐观锁和悲观锁,
- 乐观锁
- 乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但在更新时会判断在此期间别人有没有更新该数据,通常采用在写时先读出当前版本号然后加锁的方法。具体过程为:比较当前版本号与上一次的版本号,如果版本号一致,则更新,如果版本号不一致,则重复进行读、比较、写操作。Java中的乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现的,CAS是一种原子更新操作,在对数据操作之前首先会比较当前值跟传入的值是否一样,如果一样则更新,否则不执行更新操作,直接返回失败状态。
- 悲观锁
- 悲观锁:假定并发环境是悲观的,如果发生并发冲突,就会破坏一致性,所以要通过独占锁彻底禁止冲突发生
- 乐观锁:(锁的粒度小)假定并发环境是乐观的,即,虽然会有并发冲突,但冲突可发现且不会造成损害,所以,可以不加任何保护,等发现并发冲突后再决定放弃操作还是重试。
- 从获取资源的公平性角度可分为公平锁和非公平锁
- 从是否共享资源的角度可分为共享锁和独占锁
- 从锁的状态的角度可分为偏向锁、轻量级锁和重量级锁。同时,在JVM中还巧妙设计了自旋锁以更快地使用CPU资源。下面将详细介绍这些锁
- 偏向锁 --》轻量级锁 --》自旋锁、自适应自旋、锁消除、锁粗化。
- 自旋锁 – JVM
- 自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需等一等(也叫作自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在内核状态的切换上导致的锁时间消耗。线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在线程执行的时间超过自旋等待的最大时间后,线程会退出自旋模式并释放其持有的锁
1.2、为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?
- 重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。
- 这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。
- 而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
1.3、偏向锁有撤销、膨胀,性能损耗这么大为什么要用呢?
- 偏向锁的好处是在只有一个线程获取锁的情况下,只需要通过一次 CAS 操作修改markword,之后每次进行简单的判断即可,避免了轻量级锁每次获取释放锁时的 CAS 操作。
- markword 见3.7节
- CAS详解见第4节
- 如果确定同步代码块会被多个线程访问或者竞争较大,可以通过
-XX:-UseBiasedLocking
参数关闭偏向锁。
1.4、偏向锁、轻量级锁、重量级锁分别对应了什么使用场景?
- 1)偏向锁
- 适用于只有一个线程获取锁。当第二个线程尝试获取锁时,即使此时第一个线程已经释放了锁,此时还是会升级为轻量级锁。
- 但是有一种特例,如果出现偏向锁的重偏向,则此时第二个线程可以尝试获取偏向锁。
- Action:什么是重偏向?
- 2)轻量级锁
- 适用于多个线程交替获取锁。跟偏向锁的区别在于可以有多个线程来获取锁,但是必须没有竞争,如果有则会升级会重量级锁。有同学可能会说,不是有自旋,请继续往下看。
- 3)重量级锁
- 适用于多个线程同时获取锁。
- Action:只有重量级锁才会有自旋操作
1.5、自旋发生在哪个阶段?
自旋发生在重量级锁阶段。
网上99.99%的说法,自旋都是发生在轻量级锁阶段,但是实际看了源码(JDK8)之后,并不是这样的。
- 轻量级锁阶段并没有自旋操作,在轻量级锁阶段,只要发生竞争,就是直接膨胀成重量级锁。
- 而在重量级锁阶段,如果获取锁失败,则会尝试自旋去获取锁。
1.6、为什么要设计自旋操作?
因为重量级锁的挂起开销太大。
- 一般来说,同步代码块内的代码应该很快就执行结束,这时候竞争锁的线程自旋一段时间是很容易拿到锁的,这样就可以节省了重量级锁挂起的开销。
1.7、自适应自旋是如何体现自适应的?
自适应自旋锁有自旋次数限制,范围在:1000~5000。
- 如果当次自旋获取锁成功,则会奖励自旋次数100次,如果当次自旋获取锁失败,则会惩罚扣掉次数200次。
- 所以如果自旋一直成功,则JVM认为自旋的成功率很高,值得多自旋几次,因此增加了自旋的尝试次数。
- 相反的,如果自旋一直失败,则JVM认为自旋只是在浪费时间,则尽量减少自旋。
2、Volatile关键字如何保证内存可见性 阿里面试第21题
2.1、Volatile关键字
Java提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
2.2、Volatile作用
- 1、保证此变量对所有线程的可见性(即当一条线程修改了这个值,新值对于其他所有线程来说是立即得知的) 原理是:每次访问变量时都会进行一次刷新,因此每次访问都是主存中最新的版本
- 2、禁止指令重排序优化(volatile修饰的变量相当于生成内存屏障,重排列时不能把后面的指令排到屏障之前)
2.3、使用场景
当且仅当满足以下所有条件时,才应该使用volatile变量
- 1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值;
- 2、该变量没有包含在具有其他变量的不变式中。
volatile的使用场景
1、状态标记量
- 使用volatile来修饰状态标记量,使得状态标记量对所有线程是实时可见的,从而保证所有线程都能实时获取到最新的状态标记量,进一步决定是否进行操作。例如常见的促销活动“秒杀”,可以用volatile来修饰“是否售罄”字段,从而保证在并发下,能正确的处理商品是否售罄。
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
2、双重检测机制实现单例
- 普通的双重检测机制在极端情况,由于指令重排序会出现问题,通过使用volatile来修饰instance,禁止指令重排序,从而可以正确的实现单例。
public class Singleton { // 私有化构造函数 private Singleton() { } // volatile修饰单例对象 private static volatile Singleton instance = null; // 对外提供的工厂方法 public static Singleton getInstance() { if (instance == null) { // 第一次检测 synchronized (Singleton.class) { // 同步锁 if (instance == null) { // 第二次检测 instance = new Singleton(); // 初始化 } } } return instance; } }
使用建议:
- 1、在两个或者更多线程需要访问的成员变量上使用 volatile。当要访问的变量已在 synchronized 代码块中,或者为常量时,没必要使用 volatile;
- 2、由于使用 volatile 屏蔽掉了 JVM中必要的代码优化,所以效率上比较低,因此一定在必要时才使用此关键字。
缺点:
- 1、无法实现i++原子操作(解决方案:CAS)使用场景:单线程
- 解决方案见 第4节CAS
- 2、不具备“互斥性”:多个线程能同时读写主存,不能保证变量的“原子性”:(i++不能作为一个整体,分为3个步骤读-改-写),可以使用CAS算法保证数据原子性
2.4、Volatile/Synchronized两者区别(锁的目标:关注互斥性和可见性)
- 1、volatile 不会进行加锁操作
- volatile 变量是一种稍弱的同步机制,在访问 volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile变量是一种比synchronized 关键字更轻量级的同步机制。
- 2、volatile 变量作用类似于同步变量读写操作
- 从内存可见性来说,写入 volatile变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块;
- 3、volatile不如 Synchronized安全
- 4、volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性,例如: count++ ;
2.5、什么是内存可见性、什么是指令重排?
- 背景:Java虚拟机规范试图定义一种Java内存模型(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。简单来说,由于CPU执行指令的速度是很快的,但是内存访问的速度就慢了很多,相差的不是一个数量级,所以搞处理器的那群大佬们又在CPU里加了好几层高速缓存。
- 在Java内存模型里,对上述的优化又进行了一波抽象。JMM规定所有变量都是存在主存中的,类似于上面提到的普通内存,每个线程又包含自己的工作内存,方便理解就可以看成CPU上的寄存器或者高速缓存。所以线程的操作都是以工作内存为主,它们只能访问自己的工作内存,且工作前后都要把值在同步回主内存。
- 线程、主内存、工作内存三者的交互关系如下图所示:
- 在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。
- 使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。比如看下面一个例子:
i = i + 1;
- 假设 i 初值为0,当只有一个线程执行它时,结果肯定得到1,当两个线程执行时,会得到结果2吗?这倒不一定了。可能存在这种情况:
线程1: load i from 主存 // i = 0 i + 1 // i = 1 线程2: load i from 主存 // 因为线程1还没将i的值写回主存,所以i还是0 i + 1 //i = 1 线程1: save i to 主存 线程2: save i to 主存
- 如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这就是缓存不一致问题。
- 下面就要提到你刚才问到的问题了,JMM主要就是围绕着如何在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。而volatile跟可见性和有序性都有关。
- 原子性(Atomicity):一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行;
- 我们先来看看哪些是原子操作,哪些不是原子操作,先有一个直观的印象:
int k = 5; //代码1 k++; //代码2 int j = k; //代码3 k = k + 1; //代码4
- 上面这4个代码中只有代码1是原子操作。
- 代码2:包含了三个操作。1.读取变量k的值;2.将变量k的值加1;3.将计算后的值再赋值给变量k。
- 代码3:包含了两个操作。1.读取变量k的值;2.将变量k的值赋值给变量j。
- 代码4:包含了三个操作。1.读取变量k的值;2.将变量k的值加1;3.将计算后的值再赋值给变量k。
- 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
- 我们先看下以下的例子,对可见性有一个直观的印象:
// 线程A执行的代码 int k = 0; //1 k = 5; //2 // 线程B执行的代码 int j = k; //3
- 上面这个例子,如果线程A先执行,然后线程B再执行,j的值是多少了?
- 答案是无法确定。因为即使线程A已经把k的值更新为5,但是这个操作是在线程A的工作内存中完成的,工作内存所更新的变量并不会立即同步回主内存,因此线程B从主内存中得到的变量k的值是不确定的。这就是可见性问题,线程A对变量k修改了之后,线程B没有立即看到线程A修改的值。
- Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新
- 有序性(Ordering):一个线程中的所有操作必须按照程序的顺序来执行。
- 我们先看下以下的例子,对有序性有一个直观的印象:
int k = 0; int j = 1 k = 5; //代码1 j = 6; //代码2
- 按照有序性的规定,该例子中的代码1应该在代码2之前执行,但是实际上真的是这样吗?
- 答案是否定的,JVM并不保证上面这个代码1和代码2的执行顺序,因为这两行代码并没有数据依赖性,先执行哪一行代码,最终的执行结果都不会改变,因此,JVM可能会进行指令重排序。
int k = 1; // 代码1 int j = k; // 代码2
- 在单线程中,代码1的执行顺序会在代码2之前吗?
- 答案是肯定的,因为代码2依赖于代码1的执行结果,因此JVM不会对这两行代码进行指令重排序。
- Java语言提供了volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。
- 我们发现synchronized关键字在需要这3种特性的时候都可以作为其中一种的解决方案,看起来很“万能”。的确,大部分的并发控制操作都能使用synchronized来完成。synchronized的“万能”也间接造就了它被程序员滥用的局面,越“万能”的并发控制,通常会伴随着越大的性能影响。
2.6、Volatile 总结
- 1、每个线程有自己的工作内存,工作内存中的数据并不会实时刷新回主内存,因此在并发情况下,有可能线程A已经修改了成员变量k的值,但是线程B并不能读取到线程A修改后的值,这是因为线程A的工作内存还没有被刷新回主内存,导致线程B无法读取到最新的值。
- 2、在工作内存中,每次使用volatile修饰的变量前都必须先从主内存刷新最新的值,这保证了当前线程能看见其他线程对volatile修饰的变量所做的修改后的值。
- 3、在工作内存中,每次修改volatile修饰的变量后都必须立刻同步回主内存中,这保证了其他线程可以看到自己对volatile修饰的变量所做的修改。
- 4、volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
- 5、volatile保证可见性,不保证原子性,部分保证有序性(仅保证被volatile修饰的变量)。
- 6、指令重排序的目的是为了提高性能,指令重排序仅保证在单线程下不会改变最终的执行结果,但无法保证在多线程下的执行结果。
- 7、为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止重排序。
3、Synchronized(底层是由JVM层面实现)/锁的升级降级? 核心
3.1、Synchronized背景案例
- Synchronized 的使用小例子?
public class SynchronizedTest { // 保证内存可见性 public static volatile int race = 0; private static CountDownLatch countDownLatch = new CountDownLatch(2); public static void main(String[] args) throws InterruptedException { // 循环开启2个线程来计数 for (int i = 0; i < 2; i++) { new Thread(() -> { // 每个线程累加1万次 for (int j = 0; j < 10000; j++) { race++; } countDownLatch.countDown(); }).start(); } // 等待,直到所有线程处理结束才放行 countDownLatch.await(); // 期望输出 2万(2*1万) System.out.println(race); } }
- 熟悉的2个线程计数的例子,每个线程自增1万次,预期的结果是2万,但是实际运行结果总是一个小于等于2万的数字,为什么会这样了?
- race++在我们看来可能只是1个操作,但是在底层其实是由多个操作组成的,所以在并发下会有如下的场景:
- 为了得到正确的结果,此时我们可以将 race++ 使用 synchronized 来修饰,如下:
synchronized (SynchronizedTest.class) { race++; }
- 加了 synchronized 后,只有抢占到锁才能对 race 进行操作,此时的流程会变成如下:
3.2、什么是synchronized
- synchronized是Java的关键字,是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问
- synchronized特点:
- synchronized则可以使用在变量、方法(静态方法,同步方法)、和类级别的修饰代码块时,减少了锁范围,耗时的代码放外面,可以异步调用
- synchronized 各种加锁场景?
- 1、作用于非静态方法,锁住的是对象实例(this),每一个对象实例有一个锁
public synchronized void method() {}
- 2、作用于静态方法,锁住的是类的 Class 对象,Class 对象全局只有一份,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程。
public static synchronized void method() {}
- 3、作用于 Lock.class,锁住的是 Lock 的 Class 对象,也是全局只有一个
synchronized (Lock.class) {}
- 4、作用于 this,锁住的是对象实例,每一个对象实例有一个锁
synchronized (this) {}
- 5、作用于静态成员变量,锁住的是该静态成员变量对象,由于是静态变量,因此全局只有一个
public static Object monitor = new Object(); synchronized (monitor) {}
- 有些同学可能会搞混,但是其实很容易记,记住以下两点:
- 1)必须有“对象”来充当“锁”的角色。
- 2)对于同一个类来说,通常只有两种对象来充当锁:实例对象、Class 对象(一个类全局只有一份)。
- Class 对象:静态相关的都是属于 Class 对象,还有一种直接指定 Lock.class。
- 实例对象:非静态相关的都是属于实例对象。
3.3、为什么调用 Object 的 wait/notify/notifyAll 方法,需要加 synchronized 锁?
- “sleep 和 wait 的区别”,答案中非常重要的一项是:“wait会释放对象锁,sleep不会”,既然要释放锁,那必然要先获取锁。
- 因为这3个方法都会操作锁对象,所以需要先获取锁对象,而加 synchronized 锁可以让我们获取到锁对象
- 来看一个例子:
public class SynchronizedTest { private static final Object lock = new Object(); public static void testWait() throws InterruptedException { lock.wait(); } public static void testNotify() throws InterruptedException { lock.notify(); } }
- 在这个例子中,wait 会释放 lock 锁对象,notify/notifyAll 会唤醒其他正在等待获取 lock 锁对象的线程来抢占 lock 锁对象。
- 既然你想要操作 lock 锁对象,那必然你就得先获取 lock 锁对象。就像你想把苹果让给其他同学,那你必须先拿到苹果。
- 再来看一个反例:
public class SynchronizedTest { private static final Object lock = new Object(); public static synchronized void getLock() throws InterruptedException { lock.wait(); } }
- 该方法运行后会抛出 IllegalMonitorStateException,为什么了,我们明明加了 synchronized 来获取锁对象了?
- 因为在 getLock 静态方法中加 synchronized 方法获取到的是 SynchronizedTest.class 的锁对象,而我们的 wait() 方法是要释放 lock 的锁对象。
- 这就相当于你想让给其他同学一个苹果(lock),但是你只有一个梨子(SynchronizedTest.class)
3.4、synchronize 底层维护了几个列表存放被阻塞的线程?
这题是紧接着上一题的,很明显面试官想看看我是不是真的对 synchronize 底层原理有所了解。
- synchronized 底层对应的 JVM 模型为 objectMonitor,使用了3个双向链表来存放被阻塞的线程:_cxq(Contention queue)、_EntryList(EntryList)、_WaitSet(WaitSet)。
- 当线程获取锁失败进入阻塞后,首先会被加入到 _cxq 链表,_cxq 链表的节点会在某个时刻被进一步转移到 _EntryList 链表。
具体转移的时刻?见题目30。
- 当持有锁的线程释放锁后,_EntryList 链表头结点的线程会被唤醒,该线程称为 successor(假定继承者),然后该线程会尝试抢占锁。
- 当我们调用 wait() 时,线程会被放入 _WaitSet,直到调用了 notify()/notifyAll() 后,线程才被重新放入 _cxq 或 _EntryList,默认放入 _cxq 链表头部。
objectMonitor 的整体流程如下图:
3.5、为什么释放锁时被唤醒的线程会称为“假定继承者”?被唤醒的线程一定能获取到锁吗?
因为被唤醒的线程并不是就一定获取到锁了,该线程仍然需要去竞争锁,而且可能会失败,所以该线程并不是就一定会成为锁的“继承者”,而只是有机会成为,所以我们称它为假定的。
- 这也是 synchronized 为什么是非公平锁的一个原因。
3.6、Synchronized 锁能降级吗?
答案是可以的。
- 具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。
- 当锁降级时,主要进行了以下操作:
- 1)恢复锁对象的 markword 对象头;
- 2)重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。
3.7、Synchronized 为什么是非公平锁?非公平体现在哪些地方?
Synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:
- 1)当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
- 先将锁的持有者 owner 属性赋值为 null;
- 唤醒等待链表中的一个线程(假定继承者);
- 在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。
- 2)当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
3.8、既然加了 synchronized 锁,那当某个线程调用了 wait 的时候明明还在 synchronized 块里,其他线程怎么进入到 synchronized 里去执行 notify 的?
- 如下例子:调用 lock.wait() 时,线程就阻塞在这边了,此时代码执行应该还在 synchronized 块里,其他线程为什么就能进入 synchronized 块去执行 notify() ?
public class SynchronizedTest { private static final Object lock = new Object(); public static void testWait() throws InterruptedException { synchronized (lock) { // 阻塞住,被唤醒之前不会输出aa,也就是还没离开synchronized lock.wait(); System.out.println("aa"); } } public static void testNotify() throws InterruptedException { synchronized (lock) { lock.notify(); System.out.println("bb"); } } }
- 只看代码确实会给人题目中的这种错觉,这也是 Object 的 wait() 和 notify() 方法很多人用不好的原因,包括我也是用的不太好。
- 这个题需要从底层去看,当线程进入 synchronized 时,需要获取 lock 锁,但是在调用 lock.wait() 的时候,此时虽然线程还在 synchronized 块里,但是其实已经释放掉了 lock 锁。
- 所以,其他线程此时可以获取 lock 锁进入到 synchronized 块,从而去执行 lock.notify()。
3.9、如果有多个线程都进入 wait 状态,那某个线程调用 notify 唤醒线程时是否按照进入 wait 的顺序去唤醒?
- 答案是否定的。上面在介绍 synchronized 为什么是非公平锁时也介绍了不会按照顺序去唤醒。
- 调用 wait 时,节点进入_WaitSet 链表的尾部。
- 调用 notify 时,根据不同的策略,节点可能被移动到 cxq 头部、cxq 尾部、EntryList 头部、EntryList 尾部等多种情况。
- 所以,唤醒的顺序并不一定是进入 wait 时的顺序。