【JavaSE 并发总述】面试官:谈一谈你对Java并发的理解?(满分回答,背下来)

文章目录

金手指1:
1、面试回答问题的都是语言描述,所以博客内容要使用语言描述(代码段都去掉,语言多用对比阐述)。
2、开放性试题,回答的越多越好,这里给出答案。

对于并发,一定要从计算机组成原理和操作系统的层面上去讲,这样吹逼才能吹上去

11篇博客,记下来,够吹了,博客列表:

Java并发001 使用层面:线程相关知识

Java并发002 使用层面:线程同步与线程通信

Java并发003 原理层面:并发底层知识

Java并发004 原理层面:synchronized关键字源码解析

Java并发005 原理层面:volatile关键字原理

Java并发006 原理层面:lock相对于synchronized的优势

Java并发007 原理层面:lock同步AQS队列,加锁解锁源码解析lock.lock() lock.unlock()

Java并发008 原理层面:lock等待队列,阻塞唤醒源码解析condition.await() condition.signal()/signalAll()

Java并发009 原理层面:ThreadLocal源码解析

Java并发010 原理层面:countDownLatch源码解析

Java并发011 原理层面:CAS源码解析

一、线程生命周期+线程优先级+线程礼让+后台线程+联合线程

1.1 线程生命周期和六种状态

在这里插入图片描述

补充:因为可运行状态Runnable包括Ready就绪状态和Running运行状态,但是就绪状态进入运行状态是CPU调度,程序员无法控制,所本文中对这两种状态不做区分,统一为可运行状态,两者中转换如图:
在这里插入图片描述

(1)有的说Java线程5种状态,这是因为将“等待状态Waiting+限时等待状态Timed_Waiting”作为一种状态,5种状态为:
新建状态New、可运行状态Runnable(Running+Ready)、等待状态Waiting+Timed_Waiting、阻塞状态Blocked、结束状态Terminated
(2)有的说Java线程6种状态,这是因为将将Running和Ready两种状态拆分开了,6种状态为:
新建状态New、准备状态Ready、运行状态Running、等待状态Waiting+Timed_Waiting、阻塞状态Blocked、结束状态Terminated
或者将等待状态Waiting和限时等待状态Timed_Waiting两种状态拆开,6种状态为:
新建状态New、可运行状态Runnable(Running+Ready)、等待状态Waiting、计时等待状态Timed_Waiting、阻塞状态Blocked、结束状态Terminated
(3)有的说Java线程7种状态,这是因为将将Running和Ready两种状态拆分开、等待状态Waiting和限时等待状态Timed_Waiting两种状态拆开,7种状态为:
新建状态New、准备状态Ready、运行状态Running、 等待状态Waiting、计时等待状态Timed_Waiting、阻塞状态Blocked、结束状态Terminated
不管采用哪种说法,Java线程状态以下几种,新建状态New、可运行状态Runnable(Running+Ready)、等待状态(等待状态Waiting+限时等待状态Timed_Waiting)、阻塞状态Blocked、结束状态Terminated

问题(2):六种状态isAlive()返回值?

回答(2):新建状态和终止状态isAlive()为false,表示线程此时不存活;其他四种状态isAlive()为true,表示线程此时为存活。

问题(3):进入终止状态terminate还可以再回到可运行状态吗?

回答(3):不可以,进入终止状态线程只能死亡,不可以再回到可运行状态。

问题(4):等待状态(包括计时等待)和阻塞状态的区别?wait()、wait(毫秒数)和sleep(毫秒数)区别?

回答(4):等待状态和阻塞状态区别:Java中,每个对象都有两个池,锁池和等待池,
阻塞状态两种:
I/O阻塞(本程序不涉及,略)和获取同步锁失败而阻塞(本程序当前情况),线程对象处于锁池,锁池的中线程对象获取到同步锁之后就可以进入可运行状态运行,即阻塞状态的线程获取同步锁成功后就可以运行;
等待和计时等待状态:
进入等待状态(包括计时等待)方式有三种(sleep(毫秒数)、wait()、wait(毫秒数))
sleep(毫秒数)只能等待自动唤醒,wait(毫秒数)可以等待自动唤醒或notify()/notifyAll()唤醒,wait()只能notify()/notifyAll()唤醒,三种等待都是处于等待池中。
另外,wait()和wait(毫秒数)释放同步锁,其间不占用同步锁,sleep(毫秒数)占用同步锁,其间不释放同步锁

方法名 线程状态 唤醒 状态期间
wait() 进入等待状态 只能notify()/notifyAll()唤醒 释放同步锁,其间不占用同步锁
wait(毫秒数) 进入计时等待状态 可以等待自动唤醒或notify()/notifyAll()唤醒 释放同步锁,其间不占用同步锁
sleep(毫秒数) 进入计时等待状态 只能等待自动唤醒 占用同步锁,其间不释放同步锁

1.2 线程优先级两个方法和三个常量

Java中提供了操作线程优先级的方法setter-getter,

int getPriority() :返回线程的优先级;
void setPriority(int newPriority) : 更改线程的优先级。

另外,提供了三个表现线程优先级的常量,

MAX_PRIORITY=10,最高优先级

MIN_PRIORITY=1,最低优先级

NORM_PRIORITY=5,默认优先级,

线程优先级有1~ 10,10种,程序员也可以直接使用数字1~10设置线程优先级。

注意1:线程优先级并不是指线程执行的先后顺序,而是线程被执行的概率权重。事实上,除非程序员使用标志位做线程通信,否则Java并没有提供任何线程执行先后顺序的机制,哪个线程先执行只取决于CPU调度。
注意2:不同的操作系统支持的线程优先级不同的,建议使用上述三个优先级MAX_PRIORITY、MIN_PRIORITY、NORM_PRIORITY,不要自定义。

1.3 线程礼让

附:线程礼让yield()和线程休眠sleep()的相同点和不同点
sleep方法和yield方法的区别:
1):相同点:都能使当前处于运行状态的线程放弃CPU,把运行的机会给其他线程.
2):不同点——转让运行机会的条件不同:sleep方法会给其他线程运行机会,但是不考虑其他线程的优先级,yield方法只会给相同优先级或者更高优先级的线程运行的机会.
3):不同点——转让后进入的状态不同:调用sleep方法后,线程进入计时等待状态,调用yield方法后,线程进入就绪状态.
4):不同点——两方法开发中用途不同:sleep()方法开发中更多的用于模拟延迟,让多线程并发访问同一个资源的错误效果更明显.;yield()方法开发中很少会使用到该方法,该方法主要用于调试或测试,它可能有助于因多线程竞争条件下的错误重现现象。

1.4 后台进程

注意1:main线程是前台线程默认新建的的线程是前台线程,要想新建后台线程,需要设置后台线程:thread.setDaemon(true),该方法必须在start方法调用前,否则出现IllegalThreadStateException异常。

注意2:后台线程的最重要的特点:前台线程停止,后台线程也停止,即使是finally块的内容也不执行了。
如何处理后台线程的中finally子句必须执行?
原因:这是因为main()退出时,JVM就会立即关闭所有的后台进场,
解决方式:只能让程序员自己来解决这个问题,写程序的时候,要慎用后台线程,如代码2,若将main()中setDaemon()注释掉,就永远可以看到finally子句的执行,所有要慎用后台线程,否则finally子句将不再安全。

1.5 联合线程

join()方法就用阻塞当前正在运行的线程(代码1中为main线程),运行新建加入的线程(代码1中的JoinThread),等到JoinThread运行完之后再运行mian线程。可以更加简单的理解,就是在main线程中嵌入一个JoinThread线程。

main :0
main :1
main :2
main :3
JoinThread: 0
JoinThread: 1
JoinThread: 2
JoinThread: 3
JoinThread: 4
main :4

小结:线程联合两点:
第一个,暂停当前线程,上面是main线程,插入新线程,上面是joinThread线程;
第二个,插入的线程执行完毕后,再执行被插入的线程(这里是joinThread线程执行完毕后,再执行main线程)。

二、线程同步+线程通信

2.1 Thread和Runnable两种方式

实现多线程有两种常见的方式,
1、继承线程类(extends Thread):某类继承线程类之后,该类成为线程类,拥有独立的线程空间并可以执行;
2、实现Runnable接口(implements Runnable):某类实现Runnable接口后,可以实现多线程,注意它不是多线程类,只是具有多线程方法。但是可以满足我们目前的需求就够了。

注意:Java多线程的最常见的两种实现方式:继承Thread类和实现Runnable接口,其异同我们在这里看出来了

继承Thread类 实现Runnable接口
相同点 都可用于实现多线程
继承与实现 Java类是单继承,所以继承了Thread类就不能在继承别的类了 Java接口是多实现,所以实现了Runnable接口还可以实现其他接口,继承其他类
代码编写 继承方式简单,获取线程名称也简单 实现方式稍复杂,获取线程名称稍复杂,用Thread.currentThread()来获取当前线程的引用
多线程是否共享同一个资源 否,多个线程,每一个线程用自己的资源,无法访问共享资源 是,访问共享资源

2.2 synchronized关键字的使用

synchronized关键字的使用注意1:
为什么synchronized后面有一个小括号,里面还有一个this呢?
解答:这就是同步锁对象,是同步代码块得以成功实现线程同步的必要条件,因为在任何时候,最多允许一个线程拥有同步锁,谁拿到锁就进入代码块,其他的线程只能在外等着.。
(1): 对于非static方法,同步锁就是this;对于static方法,我们使用当前方法所在类的字节码对象(Apple.class).
(2):Java程序运行使用任何对象作为同步监听对象,但是一般的,我们把当前并发访问的共同资源作为同步监听对象.

synchronized关键字的使用注意2:
synchronized范围越大,效率越低
synchronized关键字的使用注意3:synchronized 范围内的操作的原子性只对其他synchronized方法或块有用,对非synchronized方法或块没有用;即synchronized只是保证其他synchronized方法不打断当前synchronized操作,不保证其他非synchronized方法或块不打断当前synchronized操作。其实,读者担心synchronized被非synchronized打断是不是一种风险,其实不用担心,因为既然它是非synchronized,就是说明它访问变量和当前的synchronized访问的变量在业务逻辑上基本没有关系,打断就打断呗,如果业务上有逻辑关系,影响到业务了,就给非synchronized方法或块加上synchronized,就防止打断了,就是这么简单!

2.3 标志位的意义,将线程停止下来

附: 其实我们只要用标志位(如上boolean isEmpty)控制,就可以控制线程的通信了,为什么还要加上wait()-notify()/notifyAll()或者lock+await()+signal()/signalAll()这样的东西呢?
代码a:
while (!isEmpty) { wait(); }
代码b:
while (!isEmpty);
从逻辑上讲,代码a和代码b是一样的,都是在等待isEmpty=true,才能跳出循环,区别在于代码a执行了wait()/condition.await()函数,这个函数使当前线程处于waiting()等待状态,JVM把当前线程存在对象等待池中,不占用cpu,不消耗系统资源;如果向代码b,当前线程处于running运行状态,占用Cpu,消耗系统资源。因为代码b使线程无论运行还是等待都处于running运行状态,过多的消耗系统资源,不利于程序执行;故我们的程序都写成的代码a的形式,线程通信中线程等待时,处于waiting状态,当然后果是要加上配套的notify()/notifyAll()或者condition.signal()/condition.signalAll()方法。

2.4 线程同步与线程通信

线程同步 线程通信
作用/用途 保证原子性操作 保证线程间执行顺序
组合:synchronized+标志位+wait()+notify()/notifyAll() synchronized 标志位+wait()+notify()/notifyAll()
组合:lock+标志位+await()+signal()/signalAll() lock机制 标志位+await()+signal()/signalAll()

2.5 死锁定义、四个条件、哲学家进餐问题及其解决方法(死锁理论+死锁应用)

第一,死锁定义:死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。

第二,死锁问题的四个条件(括号是对应哲学家问题)

第一,互斥使用(也称互斥资源,不共享资源),即当资源被一个线程使用(占有)时,别的线程不能使用(同一个筷子不能被两个哲学家共享使用,这是规则,无法改变

第二,不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放(对于别的手里的筷子,哲学家不能取抢占,只能等待,对于桌面上的筷子,哲学家可以抢占,这是规则,无法改变

第三,请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有(哲学家对于桌面上两个筷子的请求不是原子操作,这里是突破口,可以封装为原子操作,破坏死锁

第四,循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路(可能会形成等待队列,这是可以的,无法改变

注意:这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立。

Java语言中,因为Java在设计时,JVM不检测也不试图避免这种情况,所以死锁无法在语言层上得到根本性解决,只能由程序员每次写程序时注意避免。

第三,约束条件(括号后面是对于四个条件):

(1)如果筷子已被别人拿走,则必须等别人吃完之后才能拿到筷子(不可抢占,不能抢别人手里的筷子)。

(2)只有拿到两只筷子时,哲学家才能吃饭,但是,任一哲学家在自己未拿到两只筷子吃完饭前,不会放下手中已经拿到的筷子(请求和保持,获取两个筷子不是原子操作)

第四,解决方法(破坏请求和保持条件,原子性获取资源)

哲学家进餐问题特点:每个哲学既是消费者(拿起左右两只筷子) 又是生产者(放下左右两只筷子) (这就是哲学家就餐问题与之前的生产者-消费者问题不同之处)
只要使用synchronized 或者lock 将 "拿起左右两只筷子"和“放下左右两只筷子” 封装为原子操作,再使用 wait()–notifyAll() 或者 await()–signalAll() 实现哲学家之间(生产者消费者之间)的通信,整个问题就解决。
两种方式: synchronized+wait()+notifyAll() 和 lock+await()+signalAll()

三、源码解析:synchronized+标志位+wait()/notify()/notifyAll()

3.1 synchronizd底层实现:synchronized五种用法和同步锁对象头的五种状态

宏观上的五种用法(多线程+锁对象)
修饰目标
方法 实例方法 当前实例对象(即方法调用者)
静态方法 类对象
代码块 this 当前实例对象(即方法调用者)
class对象 类对象
任意Object对象 任意示例对象

五种情况主要是方法级别锁和代码级别锁,还有就是锁对象的不同,
方法级别锁和代码级别锁很好理解,看代码就懂,
锁对象不同是什么意思?
第一种情况和第三种情况,形成锁竞争:普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象
第二种情况和第四种情况:静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定
小结:同步两因素:第一多线程,第二锁对象,就是因为存在不止一个线程才能形成竞争,只有一个线程你和谁竞争,所以一旦设置同步,多线程必不可少;第二就是竞争的资源,多个线程之间竞争什么,代码中通过对竞争的资源加锁解锁形成原子操作,所以一旦涉及同步,锁对象必不可少。
锁对象表示的是竞争的对象,竞争的对象相同才能形成竞争,竞争的对象不同是无法形成竞争的,举一反三,比如非同步方式(没有synchronized修饰),就啥都不竞争,和同步原子没半毛钱关系

宏观上的对象头

小结:
金手指1:对象头三个部分
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;(无锁状态:29bit hashcode+ 3bit lock)
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。
金手指2:五种状态
无锁状态
hashcode 哈希码 29bit
无锁是29位哈希码,GC标志为30位为空,good 好记
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
偏向锁(线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3,和无锁一起记忆):(线程id 23 + 偏向时间戳 2 + 分代年龄 4 都是偏向锁特有,后两个都是涉及时间,好记
JavaThread*: 保存持有偏向锁的线程ID,23bit
epoch: 保存偏向时间戳,2bit(到40,升级为轻量级锁)
age: 保存对象的分代年龄,4bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
轻量锁和重量锁(30+2)
ptr: monitor的指针(就是锁指针,同步代码块的锁由monitorenter和monitorexit完成,同步方法的锁由ACC_SYNCHRONIZED修饰,底层是一致的),30bit
lock: 锁状态标识位,2bit 00 10
GC标志(30+2)
空,30bit
lock: 锁状态标识位,2bit 11
记忆方法:偏向锁和无锁一起记忆,
其他三个,轻量级锁和重量级锁一起记忆

3.2 synchronizd底层实现:synchronized代码级别和方法级别区别

金手指:
synchronized代码级别锁底层:monitorenter+monitorexit
synchronized方法级别锁底层:ACC_SYNCHRONIZED
如果synchronized在方法上,底层使用ACC_SYNCHRONIZED修饰该方法,然后在常量池中获取到锁对象,实际实现原理和同步块一致

synchronized宏观小结
1、可重入性

2、当代码段执行结束或出现异常后会自动释放对监视器的锁定

3、是非公平锁,不可中断锁,在等待获取锁的过程中不可被中断

4、synchronized的内存语义(详见面试打怪升升级-被问烂的volatile关键字,这次我要搞懂它(深入到操作系统层面理解,超多图片示意图))

5、互斥性,被synchronized修饰的方法同时只能由一个线程执行

3.3 synchronizd底层实现:锁升级

锁升级的由来

1、只能升级不能降级,目的是为了提高 获得锁和释放锁的效率
2、升级顺序:无锁状态 0 01、偏向锁状态101、轻量级锁状 态000和重量级锁状态010

第一步,偏向锁的获取

面试官:偏向锁的获取,看图说话
在这里插入图片描述当一个线程第一次访问同步代码块并获取锁时(使用cas操作获取到锁),会在同步锁对象的对象头的栈帧中的锁记录lock record中记录存储偏向锁的线程ID。以后该线程再次进入同步块时不再需要CAS来加锁和解锁,只需简单测试一下对象头的mark word中偏向锁线程ID是否是当前线程ID(所以说,对于一个线程来说,偏向锁的获取只需要第一次使用CAS操作,该线程后面的直接判断即可,懂了,最乐观,最高效,加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。)
如果成功,表示线程已获取到锁直接进入代码块运行。
如果测试失败(要么无锁,要么就是有其他的线程,偏向锁不适合多线程,多次cas操作自旋(如上图),大概率会变成轻量级锁),检查当前偏向锁字段是否为0,如果为0,表示是001,无锁,将偏向锁字段设置为1,并且更新自己的线程ID到同步锁对象的对象头的mark word字段当中(下面我们可以在程序中打印这个对象的对象头 good,用来查看锁升级的过程
如果为1,表示此时偏向锁已经被别的线程获取。则此线程不断尝试使用CAS获取偏向锁或者将偏向锁撤销,升级为轻量级锁(升级概率较大,偏向时间戳epoche默认达到40升级为轻量级锁)。
后面的测试类2中,main线程两次偏向锁,第一次需要cas,第二次直接判断就好了,很方便快捷
上图中,测试失败,不断自旋,然后两条路,升级为轻量级锁和撤销偏向锁是不同的两条路?原因:这个图告诉我们,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁

第二步,偏向锁的撤销

在这里插入图片描述
访问同步块:就是synchronized代码块
检查对象头中是否存储线程1:锁对象的对象头中,默认是偏向锁,是否有threadid1,第一次一定是没有,将threadid1设置到同步锁对象的对象头中的mark word(存放哈希码和锁对象)中
threadid1设置到锁对象成功,进入偏向锁状态
此时,同步锁对象的对象头中的mark word存放者threadid1,末尾三位为101,表示进入偏向锁状态
执行同步代码块
与此同时,如果线程2访问同步代码块,同步锁对象的threaid一定不是threadid2,且锁偏向字段为1,线程2自旋,使用cas想要将同步锁对象的mark word中的threadid设置为自己的,但是没有成功(因为线程1还没执行完成,其实,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁),线程2发起撤销偏向锁
(1)暂停拥有偏向锁的线程,这里是暂停线程1,如上图,线程1被暂停
(2)检查持有偏向锁的线程是否活着,
a.如果线程活动状态,则将同步锁对象头设置成无锁状态;
b.如果线程非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种)
最后唤醒暂停的线程。
这里,线程1还活着,恢复到无锁 001,唤醒被暂停的线程1,就是上图的恢复线程。

第三步,偏向锁的关闭

小结:关闭偏向锁注意两个:
JVM参数设置: XX:BiasedLockingStartupDelay=0 关闭偏向锁延迟
JVM参数设置:XX:UseBiasedLocking=false 关闭偏向锁,直接进入轻量级锁

第四步,偏向锁和轻量级锁中同步锁对象头中的mark word

Displaced Mark Word到底是什么?是当前线程的栈帧(方法调用栈)中一个名为锁记录lock record的空间,将mark word存放在这里
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。

细节:偏向锁和轻量级锁中的同步锁对象的对象头中的mark word
第一次获得偏向锁的时候,会将threadid放到栈帧中的锁记录Lock Record和同步锁对象的对象头中,其余次只要对比就好
撤销偏向锁的时候,如果持有偏向锁的线程不存活,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种)
升级为轻量级锁之前,线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到刚刚新建的锁记录空间中,
轻量级锁加锁的时候,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,表示轻量级锁加锁成功,
轻量级锁解锁的时候,使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,解锁成功,失败,升级为重量级锁。

第五步,轻量级锁的加锁和解锁

小结:
轻量级锁:多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争。针对此种情况,JVM采用轻量级锁来避免线程的阻塞以及唤醒。
只要在同一时间内有线程去竞争锁,那么线程执行一次CAS操作,然后发现已经被别的线程抢占,直接升级为重量级锁,不在进行CAS操作;
轻量级锁及膨胀流程图
在这里插入图片描述
加锁
线程在执行同步代码块之前,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word字段直接复制到此空间中。然后线程尝试使用CAS将对象头的mark word替换为指向锁记录的指针(指当前线程),如果成功表示获取到轻量级锁。如果失败,表示其他线程竞争轻量级锁,当前线程便使用自旋来不断尝试。
释放
解锁时,会使用CAS将复制的mark word替换回对象头,如果成功,表示没有竞争发生,正常解锁。如果失败,表示当前锁存在竞争,进一步膨胀为重量级锁(下图中markword第四个010)。

面试官:偏向锁、轻量级、重量级锁
三种锁的对比:

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 最乐观的锁,适用于一个线程访问同步代码块,偏向锁不适用与多个线程,因为多个线程竞争同步资源,一定会有失败,失败了就是偏向锁撤销,然后不但自旋,到达阈值epoche=40,就升级轻量级锁
轻量级锁 竞争的线程不会阻塞,不断自旋,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 响应时间很短,同步块执行速度非常快,适用于多个线程在不同时间段申请同一把锁
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

重量级锁会阻塞,唤醒请求加锁的线程。针对的是多个线程同一个时刻竞争同一把锁的情况,JVM采用自适应自旋,来避免线程在面对非常小的同步块时,仍会被阻塞以及唤醒。

轻量级锁采用CAS操作,将锁对象的标记字段替换为指向线程的指针,存储着锁对象原本的标记字段。针对的是多个线程在不同时间段申请同一把锁的情况。

偏向锁只会在第一次请求时采用CAS操作,在锁对象的mark
word字段中记录下当前线程ID,此后运行中持有偏向锁的线程不再有加锁过程。针对的是锁仅会被同一线程持有
偏向锁是一个线程,多个线程大概率升级为轻量级锁,轻量级锁和重量级锁都是多个线程。
既然是一个线程,偏向锁有什么用?因为对象一开始就是偏向锁,遇到synchronized大概率变为轻量级锁,你也可以一开始就是轻量级锁

3.4 synchronizd底层实现:重量锁的获取和释放

小结:重量级锁的获取

  1. _owner字段 :通过CAS尝试把monitor的_owner字段设置为当前线程;
  2. synchronized和lock都是可重入锁 _owner、_recursions :如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
  3. 查看当前线程的锁记录空间中的Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
  4. 如果获取锁失败,则等待锁的释放;

重量锁的释放:
1、初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
2、根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
调用wait()的线程加入_WaitSet中,然后等待notify唤醒他们,重新加入到锁的竞争之中,值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。

调用wait()和hashcode()直接变为重量锁
面试语言组织:调用wait()直接将偏向锁升级为重量级锁,构造demo很简单,新建两个线程,休眠5秒,启动第一个线程,打印对象头为偏向锁,调用wait()进入阻塞状态,释放锁,将锁直接设置为重量级锁,启动第二个线程打印对象头就可以知道
面试语言组织:调用hashcode()直接将偏向锁升级为重量级锁,构造demo很简单,新建一个线程,休眠5秒,启动线程,打印对象头为偏向锁,调用hashcode(),打印对象头为重量级锁。

锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级

四、源码解析:lock+标志位+condition.await()/signal()/signalAll()

4.1 lock机制的类结构

第一,ReentrantLock类:ReentrantLock类实现了Lock接口和Serializable接口,Lock接口提供六个方法,Serializable接口不提供方法;
第二,ReentrantReadWriteLock类:ReentrantReadWriteLock类实现了ReadWriteLock接口和Serializable接口,ReadWriteLock接口接口提供两个方法,Serializable接口不提供方法;
第三,两者关系:ReentrantReadWriteLock类并没有实现Lock接口,它只是实现了ReadWriteLock接口,这个接口与Lock接口没有关系,唯一的关系是两个接口都在package java.util.concurrent.locks;中。
小结:ReentrantLock类是Lock接口的唯一实现类,ReentrantReadWriteLock类是ReadWriteLock接口的唯一实现类

4.2 synchronized缺陷(lock和synchronized不同)

总结来说,Lock和synchronized有以下几点不同:

(1)都是可重入锁,底层实现不同:Lock是一个接口,而synchronized是java中的关键字,synchronized是内置的语言实现;ReentrantLock和synchronized都是可重入锁,但是ReentrantLock是API层面的互斥锁,synchronized是原生语法层面的互斥锁;

(2)死锁:synchronized不会产生死锁,但是ReentrantLock如果使用不当,会产生死锁(synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unlock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁)。

第二点附加:Lock只适用于代码块锁,而synchronized可用于修饰方法、代码块

(3)Lock接口:有限等待可中断,且返回是否获取锁成功的标志,并且一个锁可以绑定多个条件newCondition(),由Lock接口的6个方法实现,ReentrantLock类具体实现:

有限等待:可以不让等待的线程一直无期限地等待下去,只等待一定的时间,通过Lock就可以办到。

中断:Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;

获取锁成功标志:通过Lock可以知道有没有成功获取锁,而synchronized却无法办到;

(4)ReentrantLock类:公平锁和非阻塞获取锁:ReentrantLock类中的Condition的等待队列决定:

synchronized中的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过构造方法ReentrantLock(true)来要求使用公平锁。公平锁就是多个线程都在等待同一个锁时,必须按照申请锁的时间顺序排队等待,而非公平锁则不保证这点,在锁释放时,任何一个等待锁的线程都有机会获得锁。

ReentrantLock具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁;

第三点和第四点是一样,因为ReentrantLock是Lock接口的唯一实现类

(5)ReentrantReadWriteLock类:提高多个进程读操作的效率,由ReadWriteLock接口提供方法,ReentrantReadWriteLock提供实现:Lock可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

4.3 两种线程通信方法对比

对比项 Object Monitor Methods Codition
阻塞前置条件(就是阻塞之前获取锁,理解了,没问题) 获取对象的锁 调用Lock.lock()获取锁,调用Lock.newCondition()获取Condition对象
阻塞调用方法(理解了,没问题) object.wait() (wait()是Object类中的方法) condition.await()(await()是Codition类中的方法)
等待队列个数 一个,只能一个等待队列 多个,一个condition对象维护一个等待队列,但是一个lock可以绑定多个condition对象,所有就有多个等待队列,等待队列是先进先出FIFO
当前线程释放锁并进入等待状态(没问题) 支持object.wait() 支持condition.await()
当前线程释放锁并进入等待状态,在等待状态下不响应中断 (没问题) 不支持 支持condition.await()
当前线程释放锁并进入超时等待状态(没问题) 支持object.wait() 支持 condition.await()
当前线程释放锁并进入等待状态到将来的某一个时间(没问题) 不支持 支持condition.await()
唤醒等待状态中的一个线程 支持,notify() 支持,condition.signal()
唤醒等待状态中的全部线程 支持,notifyAll() 支持,condition.signalAll()

4.4 底层实现:condition队列+condition.await()+condition.signal()

4.4.1 底层队列

Condition是AQS的内部类。每个Condition对象都包含一个队列(等待队列)。等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。等待队列的基本结构如下所示。

在这里插入图片描述

一个condition对象维护一个等待队列,等待队列中每一个节点维护一个线程引用(而不是线程本身),线程引用指向调用了这个condition对象的condition.await()的线程本身。

等待分为首节点和尾节点。当一个线程调用condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。新增节点就是将尾部节点指向新增的节点。节点引用更新本来就是在获取锁以后的操作,所以不需要CAS保证。同时也是线程安全的操作。

4.4.2 condition.await()等待

当线程调用了await方法以后。线程就作为队列中的一个节点被加入到等待队列中去了。同时会释放锁的拥有。

当前线程加入到等待队列中如图所示:

在这里插入图片描述

当从await方法返回的时候。一定会获取condition相关联的锁。当等待队列中的节点被唤醒的时候,则唤醒节点的线程开始尝试获取同步状态。如果不是通过 其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException异常信息。

4.4.3 condition.signal()通知

调用Condition的signal()方法,将会唤醒在等待队列中等待最长时间的节点(条件队列里的首节点),在唤醒节点前,会将节点移到同步队列中。

在调用signal()方法之前必须先判断是否获取到了锁。接着获取等待队列的首节点,将其移动到同步队列并且利用LockSupport唤醒节点中的线程。节点从等待队列移动到同步队列如下图所示:

在这里插入图片描述

金手指:
(1)获取等待队列的首节点;
(2)将其移动到同步队列;
(3)利用LockSupport唤醒节点中的线程。

被唤醒的线程将从await方法中的while循环中退出。随后加入到同步状态的竞争当中去。成功获取到竞争的线程则会返回到await方法之前的状态。

五、volidate关键字

5.1 synchronized/lock如何实现原子性、有序性、可见性

synchronized和Lock如何保证操作的原子性?
synchronized和Lock通过保证任一时刻只有一个线程执行含有共享变量的代码块(对于没有 synchronized和Lock修饰的非同步方法、非同步代码块,不会阻塞的,它们与 synchronized和Lock无关),那么自然就不存在原子性问题了,从而保证了原子性。

synchronized和Lock如何保证操作的可见性?
synchronized和Lock通过保证同一时刻只有一个线程获取锁然后执行同步代码(保证原子性),并且在释放锁之前会将对变量的修改刷新到主存当中(保证可见性),因此可以保证可见性。

synchronized和Lock如何保证操作的有序性?
synchronized和Lock保证每个时刻是有一个线程执行同步代码(保证原子性),其原子内部顺序执行,保证有序性,原子外部没有互斥资源,不需要保证有序性,所有保证了有序性。

5.2 介绍一下 JMM中自带的有序性中的 happen-before 先行发生原则?

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则。
如果两个操作执行次序可以使用happens-before推导出来,则先后顺序确定,虚拟机无法对其重排序
如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序
下面就来具体介绍下happens-before原则(先行发生原则):

(1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

(2)锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;

(3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

(4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

(5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;

(6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

(7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

(8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
     这8条原则摘自《深入理解Java虚拟机》。

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

下面我们来解释一下前4条规则:

第一条规则:对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

5.3 volatile保证可见性、有序性、不保证原子性(volatile部分重点)

问题:volatile如何保证可见性?
回答:volatile通过强制要求volatile修饰的变量进行写操作,立即可见,从而保证可见性
volatile修饰的变量进行写操作,立即可见,从而保证可见性;(强制将对缓存的修改操作(即写操作)立即写入主存;如果是写操作,导致其他CPU中对应的缓存行无效,让其他CPU只能从主存中拿刚刚更新的,两个操作(立即写入主存和使其他CPU中对应的缓存行失效)保证可见性)
JVM层面,线程中工作内存缓存行失效,只能到主存中拿;
硬件层面,CPU中缓存行失效,只能到主存中拿。
volatile保证可见性的不足:volatile修饰的变量进行读操作,是不可见的,是不更新的

问题:volatile如何保证部分有序性?
回答:volatile通过强制要求volatile修饰的变量禁止指令重排序来保证有序性
volatile修饰的变量禁止指令重排序,它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
  1)在程序执行时,当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2)在指令优化时不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
上面两条不同的,第一条指明程序执行的情况,第二条指明指令优化不能违反第一条所保证的,只能指令优化被volatile变量分隔的。
volatile保证有序性不足:语句3中包含volatile修饰的变量,但是语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

小结:可见性只能保证多线程下每一个线程每次读取的是最新的值,原子性保证多线程下任意线程的操作不被其他线程打断,有序性保证多线程下禁止关键指令重排
本节stop = true; 中常量复制,是原子操作,只要保证可见性,所以正确。
下一节中自增操作是三步操作,所有要同时保证原子性和可见性,所以出错。
哪些是原子操作,哪些是非原子操作?
只有读取和常量复制才是原子操作
变量赋值是两步操作,常量计算是两步操作。
变量计算是三步操作

5.4 volatile底层原理:volatile底层是如何保证可见性和有序性的?

lock前缀指令

5.5 如何弥补volatile关键字的不足?

volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性,要想弥补这个不足,使用volatile必须具备以下2个条件:

1)对volatile变量的写操作不依赖于当前值

2)该volatile变量没有包含在具有其他变量的不变式中

标记位和单例模式双层检测

标记位

volatile boolean inited = false;   // 从三个性质上来说,inited 是volatile 变量,是状态标记量,常量赋值保证原子性
// 从两个条件上来说,无论是inited =false这一句,还是下面的inited =true那句
// 第一,这里对volatile变量的写操作不依赖于当前值
// 第二,volatile变量没有包含在具有其他变量的不变式中
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
    
    
sleep()
}
doSomethingwithconfig(context);

单例模式双层检测

class Singleton{
    
    
    private volatile static Singleton instance = null; 
     //从三个性质上来说,  instance 是 volatile 变量,唯二赋值第一个是这里赋值常量null,原子操作一定没问题
 // 第二个操作是双层if中的new Singleton(),虽然不是原子操作,但是双层if保证不会让其他线程进来,保证不会有其他打断这个new Singleton()操作  

// 从两个条件上来说,无论是instance = null和instance = new Singleton()这一句
// 第一,这里对volatile变量的写操作不依赖于当前值
// 第二,volatile变量没有包含在具有其他变量的不变式中  
    private Singleton() {
    
    
         
    }
     
    public static Singleton getInstance() {
    
    
        if(instance==null) {
    
    
            synchronized (Singleton.class) {
    
    
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

六、LocalThread类详解(不是很重要)

6.1 LocalThread类结构

①、一个线程一个ThreadLocalMap:每个Thread维护着一个ThreadLocalMap的引用,变量副本存储在线程自己的ThreadLocalMap中;

②、ThreadLocalMap结构:ThreadLocalMap是ThreadLocal的内部类,用Entry来存储key-value,键值为ThreadLocal对象,value为线程变量;

③、ThreadLocal仅仅作为key:ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

6.2 源码解析(ThreadLocal专门博客里面)

ThreadLocal专门博客里面,粘贴过来太长了。

其实,还可以扯一下集合框架和JVM层面的五种线程安全
集合框架:HashMap、ConcurrentHashMap
JVM层面的五种线程安全:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立
相对线程安全:Vector hashtable
线程兼容:ArrayList HashMap

猜你喜欢

转载自blog.csdn.net/qq_36963950/article/details/107895211