文章目录
- 前言
- 大厂面试题复盘 —— 并发编程高级面试解析
- 从轻松的乐观锁和悲观锁开讲
- 通过8种情况演示锁运行案例,看看我们到底锁的是什么
- 公平锁和非公平锁
- 可重入锁(又名递归锁)
- 死锁及排查
- 写锁(独占锁)/读锁(共享锁)
- 自旋锁SpinLock
- 无锁 -> 独占锁 -> 读写锁 -> 邮戳锁
- 无锁 -> 偏向锁 -> 轻量锁 -> 重量锁
前言
又是一年春招季,距离自己第一次在网上找Java开发的工作差不多过去一年了,这一年有大半年是在外面实习,从最开始疯狂背面经到后来在工作中慢慢理解这些概念,然后最后在实际开发中使用到具体的知识,这相当于是一个质的跃迁。
最近又在开始复习八股文和整理自己的实习经验这些,今天刚好是我们学校这个区的招聘会,看那待遇真的让人心寒,在这互联网的环境也不太好,就越发得觉得这些努力没有白费,如果很早之前就松懈了,那我也可能就只能写出这样的简历,找到这样的工作,现在在外面有实际的实习经验,相对于来说面试的时候有更多的话和面试官说了,希望不仅仅是你问我答的那种面试,更希望是一种可以切磋技术并且在工作规划人生看法能让我有不同看法或者听到不同见解的。
能坚持到现在我觉得大家首先都是优秀的,其次就是都喜欢编程的,我是在编程的时候真的会有一种很舒服的感觉,那时一心只想完成需求,那段时间总过得很快,很喜欢完成需求后获得的那种成就感,是觉得自己是真的在干一些有意义的事情:或者帮助了业务,减少重复性工作,提升了效率。
这次春招是在两三段实习后,毕设和健身间隙准备的,在大学的最后一期,在这自习室送走了22届考研,看着23届考研,现在又是一堆陌生的24届考研的同学,随着在校园熟悉的面孔越来越少,说明我们真的要毕业了。
有好长一段时间没有写博客了,这段时间有在好好工作,完成需求;有在好好生活,陪着家人;也在这间隙锻炼了身体,认识了一群相同行业的朋友。
接下来还是不能停下求职的脚步,一边学习新知识,复习总结学过的,争取都能找到一个互相心仪的公司
大厂面试题复盘 —— 并发编程高级面试解析
一、Synchronized相关问题
- Synchronized用过吗,其原理是什么?
- 你刚才提到获取对象的锁,这个"锁"到底是什么?如何确定对象的锁?
- 什么是可重入性,为什么说Synchronized是可重入锁?
- JVM对Java的原生锁做了哪些优化?
- 为什么说Synchronized是非公平锁?
- 什么是锁消除和锁粗化?
- 为什么说Synchronized是一个悲观锁?乐观锁的实现原理又是什么?什么是CAS,他有
- 乐观锁一定就是好的吗?
二、可重入锁ReentrantLock及其他显式锁相关问题
跟Synchronized相比,可重入锁ReentrantLock其实现原理有什么不同?
那么请谈谈AQS框架是怎么回事儿?
请尽可能详尽地对比下Synchronized和ReentrantLock的异同
ReentrantLock是如何实现可重入性的?
你怎么理解Java多线程的?怎么处理并发?线程池有哪几个核心参数?
Java加锁有哪几种锁?
简单说说lock
hashmap的实现原理?hash冲突怎么解决?为什么使用红黑树?
spring里面都使用了哪些设计模式?循环依赖怎么解决?
项目中哪个地方用了countdownlanch,怎么使用的?
从轻松的乐观锁和悲观锁开讲
悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Synchronized关键字和Lock的实现类的都是悲观锁。
适合写操作多的场景,先加速可以保证写操作时数据正确。显示的锁定之后再操作同步资源。一句话:狼性锁
乐观锁:认为自己在使用数据时不会有别的线程修改数据或资源,所以不会添加锁。在Java中是通过使用无锁编程来实现,只是在更新数据的时候去判断,之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果这个数据已经被其他线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改、重试抢锁等等
判断规则
- 版本号机制Version
- 最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我命,再努力就是
一句话:佛系锁两种实现方式:Version版本号机制;CAS
//伪代码说明 //====悲观锁的调用方式 public synchronized void m1(){ //加锁后的业务逻辑 } //====保证多个线程使用的是同一个Lock对象的前提下 ReentrantLock lock = new ReentrantLock(); public void m2(){ lock.lock(); try{ //操作同步资源 }finally{ lock.unlock(); } } //====乐观锁的调用方式 //保证多个线程使用的是同一个AtomicInteger private AtomicInteger atomicInteger = new AtomicInteger(); atomicInteger.incrementAndGet();
通过8种情况演示锁运行案例,看看我们到底锁的是什么
锁相关的8种案例演示code
class Phone{//资源类
public static synchronized void sendEmail(){
try{ TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println("----sendEmail");
}
public synchronized void sendSMS(){
System.out.println("----sendSMS");
}
public void hello(){
System.out.println("----hello");
}
}
/**
* @author William
* @create 2022-07-03 18:34
* 题目:谈谈你对多线程锁的理解,8锁案例说明
* 口诀:线程 操作 资源类
* 8锁案例说明:
* 1. 标准访问有ab两个线程,请问先打印邮件还是短信 邮件
* 2. sendEmail方法中加入暂停3秒钟,请问先打印邮件还是短信 邮件
* 3. 添加一个普通的hello方法,请问先打印邮件还是hello hello
* 4. 有两部手机,请问先打印邮件还是短信 短信
* 5. 有两个静态同步方法,有1部手机,请问先打印邮件还是短信 邮件
* 6. 有两个静态同步方法,有2部手机,请问先打印邮件还是短信 邮件
* 7. 有1个静态同步方法,有1个普通同步方法,有1部手机 短信
* 8. 有1个静态同步方法,有1个普通同步方法,有2部手机 短信
*
* 笔记总结:
* 1-2
* 一个对象里面如果有多个synchronized方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法了,
* 其他的线程都只能等待,换句话说,某一个时刻,只能有唯一的一个线程去访问这些synchronized方法
* 锁的是当前对象this,被锁定后,其他的线程都不能进入到当前对象的其它的synchronized方法
* 3-4
* 加个普通方法后发现和同步锁无关
* 换成两个对象后,不是同一把锁了,情况立刻变化
*
* 5-6 都换成静态同步方法后,情况又变化
* 三种synchronized锁的内容有一些差别:
* 对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部部手机,所有的普通同步方法用的都是同一把锁 -> 实例对象本身
* 对于静态同步方法,锁的是当前类的Class对象,如Phone.class唯一的一个模板
* 对于同步方法块,锁的是 synchronized 括号内的对象
* 7-8
* 当一个线程试图访问同步代码时它首先必须得到锁,正常退出或抛出异常时必须释放锁
*
* 所有的普通同步方法用的都是同一把锁——实例对象本身,就是new出来的具体实例对象本身,本类this
* 也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁
*
* 所有的静态同步方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板Class
* 具体实例对象this和唯一模板Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的
* 但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁
*/
public class Lock8Demo {
public static void main(String[] args) {
Phone phone = new Phone();
Phone phone2 = new Phone();
new Thread(() -> {
phone.sendEmail();
}, "a").start();
//暂停毫秒,保证a线程先启动
try{ TimeUnit.MILLISECONDS.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(() -> {
//phone.sendSMS();
phone.hello();
//phone2.sendSMS();
},"b").start();
}
}
解释说明-小总结:
阿里巴巴规约:【强制】高并发时,同步调用应该去考量锁的性能耗损。能用无锁数据结构,就不要用类锁。
说明:尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用RPC方法
synchronized有三种应用方式
JDK源码(notify方法)说明举例
8种锁的案例实际体现在3个地方:
- 作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁
- 作用于代码块,对括号里配置的对象加锁
- 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁
从字节码角度分析synchronized实现
-
javap -c ***.class 文件反编译
- -c 对代码进行反汇编
- javap -v ***.class文件反编译
- -v -verbose 输出附加信息(包括行号、本地变量表,反汇编等详细信息)
-
synchronized同步代码块:实现使用的是monitorenter和monitorexit指令
Compiled from "LockSyncDemo.java" public class com.juc.locks.LockSyncDemo { java.lang.Object object; public com.juc.locks.LockSyncDemo(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init> ":()V 4: aload_0 5: new #2 // class java/lang/Object 8: dup 9: invokespecial #1 // Method java/lang/Object."<init> ":()V 12: putfield #3 // Field object:Ljava/lang/Object; 15: return public void m1(); Code: 0: aload_0 1: getfield #3 // Field object:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #4 // Field java/lang/System.out:Ljav a/io/PrintStream; 10: ldc #5 // String ----hello synchronized c ode block 12: invokevirtual #6 // Method java/io/PrintStream.prin tln:(Ljava/lang/String;)V 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return Exception table: from to target type 7 17 20 any 20 23 20 any public static void main(java.lang.String[]); Code: 0: return } 一定是一个enter和两个exit吗? 一般情况下就是1个enter对应2个exit;极端:m1方法里面自己添加一个异常试试
-
synchronized普通同步方法:调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置。如果设置了,执行线程会将先持有monitor锁,然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor
-
synchronized静态同步方法:ACC_STATIC,ACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法
反编译synchronized锁的是什么
面试题:为什么任何一个对象都可以成为一个锁
什么是管程monitor
大厂面试题讲解
synchronized实现原理,monitor对象什么时候生成的?知道monitor的monitorenter和monitorexit这两个是怎么保证同步的吗,或者说,这两个操作计算机底层是如何执行的
管程
管程(Monitors,也称为监视器)是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
通过C底层原语了解
在HotSpot虚拟机中,monitor采用ObjectMonitor实现
上述C++源码解读
ObjectMonitor.java -> ObjectMonitor.cpp -> objectMonitor.hpp
objectMonitor.hpp
每个对象天生都带着一个对象监视器
每一个被锁住的对象都会和Monitor关联起来
ObjectMonitor中有几个关键属性
_owner | 指向持有ObjectMonitor对象的线程 |
---|---|
_WaitSet | 存放处于wait状态的线程队列 |
_EntryList | 存放处于等待锁block状态的线程队列 |
_recursions | 锁的重入次数 |
_count | 用来记录该线程获取锁的次数 |
对于synchronized关键字,我们在《Synchronized与锁升级》章节还会再深入讲解
synchronized必须作用于某个对象中,所以Java对象的头文件存储了锁的相关信息。锁升级功能主要依赖于MarkWord中的锁标志位和释放偏向锁标志位,后续讲解锁升级时候我们再加深,目前为了承前启后的学习,先对下图混个眼熟即可
Hotspot的实现
公平锁和非公平锁
从ReentrantLock卖票demo演示公平和非公平现象
class Ticket{//资源类,模拟3个售票员卖完50张票
private int number = 50;
ReentrantLock lock = new ReentrantLock(true);
public void sale(){
lock.lock();
try{
if(number > 0){
System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
}
}finally{
lock.unlock();
}
}
}
/**
* @author William
* @create 2022-07-11 20:26
*/
public class SaleTicketDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> { for(int i = 0; i < 55; i++) ticket.sale();},"a").start();
new Thread(() -> { for(int i = 0; i < 55; i++) ticket.sale();},"b").start();
new Thread(() -> { for(int i = 0; i < 55; i++) ticket.sale();},"c").start();
}
}
何为公平锁 / 非公平锁?
- 公平锁:是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买后来的人在队尾排着,这是公平的 Lock lock = new ReentrantLock(true); //true表示公平锁,先来先得
- 非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁) Lock lock = new ReentrantLock(false);//false表示非公平锁,后来的也可能先获得锁,默认非公平
**面试题:为什么会有公平锁/非公平锁的设计?**为什么默认非公平?
- 挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间
- 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销
什么时候用公平,什么时候用非公平? 如果为了更高的吞吐量·,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了;否则那就用公平锁,大家公平使用
预埋伏AQS
可重入锁(又名递归锁)
是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
如果是1个有synchronized修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁
进入什么:进入同步域(即同步代码块/方法或显式锁锁定的代码)
一句话:一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁
可重入锁种类:
-
隐式锁(即synchronized关键字使用的锁)默认是可重入锁
指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的
/** * @author William * @create 2022-07-11 21:08 */ public class ReEntryLockDemo { public synchronized void m1(){ //指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁 System.out.println(Thread.currentThread().getName()+"\t ----come in"); m2(); System.out.println(Thread.currentThread().getName()+"\t ----end m1"); } public synchronized void m2(){ System.out.println(Thread.currentThread().getName()+"\t ----come in"); m3(); } public synchronized void m3(){ System.out.println(Thread.currentThread().getName()+"\t ----come in"); } public static void main(String[] args) { ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo(); new Thread(() -> { reEntryLockDemo.m1(); },"t1").start(); } private static void reEntryM1() { final Object object = new Object(); new Thread(() -> { synchronized(object){ System.out.println(Thread.currentThread().getName()+"\t ----外层调用"); synchronized(object) { System.out.println(Thread.currentThread().getName()+"\t ----中层调用"); synchronized (object){ System.out.println(Thread.currentThread().getName()+"\t ----内层调用"); } } } },"t1").start(); } }
-
Synchronized的重入的实现机理
每个锁对象拥有一个计数器和一个指向持有该锁的线程的指针。当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁
当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1,计数器为零代表锁已被释放
-
显示锁(即Lock)也有ReentrantLock这样的可重入锁
死锁及排查
是什么 产生死锁的主要原因 系统资源不足,进程运行推进的顺序不合适,资源分配不当
死锁是指两个或两个以上的线程在执行过程中,因抢夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁
请写一个死锁代码case
public class DeadLockDemo {
public static void main(String[] args) {
final Object objectA = new Object();
final Object objectB = new Object();
new Thread(() -> {
synchronized (objectA){
System.out.println(Thread.currentThread().getName()+"\t 自己持有A锁,希望获得B锁");
try{ TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized(objectB){
System.out.println(Thread.currentThread().getName()+"\t 成功获得B锁");
}
}
},"A").start();
new Thread(() -> {
synchronized (objectB){
System.out.println(Thread.currentThread().getName()+"\t 自己持有B锁,希望获得A锁");
try{ TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized(objectA){
System.out.println(Thread.currentThread().getName()+"\t 成功获得A锁");
}
}
},"B").start();
}
}
如何排查死锁 jps -l jstack+进程号 图形化 jconsole(通用)
写锁(独占锁)/读锁(共享锁)
自旋锁SpinLock
无锁 -> 独占锁 -> 读写锁 -> 邮戳锁
无锁 -> 偏向锁 -> 轻量锁 -> 重量锁
本章锁内容,上半场小总结
8锁案例运行,锁的到底是什么 对象锁、类锁
公平锁和非公平锁2
可重入锁(又名递归锁)
死锁及排查
为什么任何一个对象都可以成为一个锁 objectMonitor.hpp
小总结(重要):指针指向monitor对象(也称为管程或监视器锁)的起始地。每个对象都存在着一个monitor与之关联,当一个monitor被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
《书到用时方恨少,钱到月底不够花》