欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
0. 大厂面试题
1. 乐观锁和悲观锁
悲观锁: 认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。synchronized关键字和Lock的实现类都是悲观锁。
悲观锁适合写操作多的场景
,先加锁可以保证写操作时数据正确。显式的锁定之后再操作同步资源
。
悲观锁伪代码说明如下:
//=====悲观锁的调用方式
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();
复制代码
乐观锁: 乐观锁认为自己在使用数据时不会有别的线程修改数据
,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我命,再抢。乐观锁一般有两种实现方式:
- 采用版本号机制
- CAS(Compare-and-Swap,即比较并替换)算法实现
2. synchronized锁
2.1 synchronized有三种应用方式
- 作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;
- 作用于代码块,对括号里配置的对象加锁。
- 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;
2.2 从字节码角度分析synchronized实现
命令:javap -c a.class文件反编译
- -c:对代码进行反汇编
- -v -verbose 输出附加信息(包括行号、本地变量表,反汇编等详细信息)
2.2.1 synchronized同步代码块
通过javap -c a.class
文件反编译结果如下,发现实现同步代码块使用的是 monitorenter
和 monitorexit
指令 :
一定是一个enter两个exit吗? m1方法里面自己添加一个异常试试,结果如下:
2.2.2 synchronized普通同步方法
javap -v a.class文件反编译,结果如下:
synchronized普通同步方法
调用指令将会检查方法的ACC_SYNCHRONIZED
访问标志是否被设置。如果设置了,执行线程会将先持有monitor然后再执行方法
,最后在方法完成(无论是正常完成还是非正常完成)时释放 monitor
。
2.2.3 synchronized静态同步方法
synchronized静态同步
方法是通过ACC_STATIC, ACC_SYNCHRONIZED
访问标志区分该方法是否静态同步方法。
2.3 反编译synchronized锁的是什么?
大厂面试题:
管程 (英语:Monitors
,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程
。
在HotSpot虚拟机中,monitor
采用ObjectMonitor
实现,从ObjectMonitor.java→ObjectMonitor.cpp→objectMonitor.hpp
objectMonitor.hpp
如下:
ObjectMonitor
中几个关键属性:
_owner
: 指向持有ObjectMonitor对象的线程WaitSet
: 存放处于wait状态的线程队列EntryList
: 存放处于等待锁block状态的线程队列recursions
: 锁的重入次数_count
: 用来记录该线程获取锁的次数
每个对象天生都带着一个对象监视器。synchronized
必须作用于某个对象中,所以Java在对象的头文件存储了锁的相关信息。锁升级功能主要依赖于 MarkWord 中的锁标志位和释放偏向锁标志位。
3. 公平锁和非公平锁
从ReentrantLock
卖票编码演示公平和非公平现象,代码演示如下:
class Ticket {
private int number = 30;
ReentrantLock lock = new ReentrantLock();
public void sale()
{
lock.lock();
try
{
if(number > 0)
{
System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
}
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
public class SaleTicketDemo
{
public static void main(String[] args)
{
Ticket ticket = new Ticket();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"a").start();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"b").start();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"c").start();
}
}
复制代码
排队讲求先来后到视为公平。程序中的公平性也是符合请求锁的绝对时间的,其实就是 FIFO,否则视为不公平。
按序排队公平锁
,就是判断同步队列是否还有先驱节点的存在
(我前面还有人吗?),如果没有先驱节点才能获取锁;先占先得非公平锁
,是不管这个事的,只要能抢获到同步状态就可以。
在AQS源码中两者之前的区别如下图所示,具体AQS源码参考AQS那一章:
面试题
-
为什么会有公平锁/非公平锁的设计为什么默认非公平?
- 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少 CPU 空闲状态时间。
- 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。
-
使⽤公平锁会有什么问题?
公平锁保证了排队的公平性,非公平锁霸气的忽视这个规则,所以就有可能导致排队的长时间在排队,也没有机会获取到锁,这就是传说中的 “锁饥饿” -
什么时候用公平?什么时候用非公平?
如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了;否则那就用公平锁,大家公平使用。
4. 可重入锁(又名递归锁)
可重入锁又名递归锁
:是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚
。所以Java中ReentrantLock和synchronized都是可重入锁
,可重入锁的一个优点是可一定程度避免死锁。
一句话:一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁
。
可重入锁种类分两种:分别是:
- 隐式锁(即synchronized关键字使用的锁)默认是可重入锁
- 显式锁(即Lock)也有ReentrantLock这样的可重入锁。
隐式锁: 指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
同步块代码演示如下:
public class ReEntryLockDemo
{
public static void main(String[] args)
{
final Object objectLockA = new Object();
new Thread(() -> {
synchronized (objectLockA)
{
System.out.println("-----外层调用");
synchronized (objectLockA)
{
System.out.println("-----中层调用");
synchronized (objectLockA)
{
System.out.println("-----内层调用");
}
}
}
},"a").start();
}
}
复制代码
同步方法代码演示如下:
/**
* 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
*/
public class ReEntryLockDemo
{
public synchronized void m1()
{
System.out.println("-----m1");
m2();
}
public synchronized void m2()
{
System.out.println("-----m2");
m3();
}
public synchronized void m3()
{
System.out.println("-----m3");
}
public static void main(String[] args)
{
ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo();
reEntryLockDemo.m1();
}
}
复制代码
3.1 Synchronized的重入的实现机理
-
每个锁对象拥有一个
锁计数器
和一个指向持有该锁的线程的指针
。 -
当执行
monitorenter
时,如果目标锁对象的计数器为零
,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1
。 -
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java虚拟机可以将
其计数器加1
,否则需要等待,直至持有线程释放该锁。 -
当执行
monitorexit
时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。
显式锁代码演示如下:
/**
* 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
*/
public class ReEntryLockDemo
{
static Lock lock = new ReentrantLock();
public static void main(String[] args)
{
new Thread(() -> {
lock.lock();
try
{
System.out.println("----外层调用lock");
lock.lock();
try
{
System.out.println("----内层调用lock");
}finally {
// 这里故意注释,实现加锁次数和释放次数不一样
// 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
lock.unlock(); // 正常情况,加锁几次就要解锁几次
}
}finally {
lock.unlock();
}
},"a").start();
new Thread(() -> {
lock.lock();
try
{
System.out.println("b thread----外层调用lock");
}finally {
lock.unlock();
}
},"b").start();
}
}
复制代码
4. 死锁及排查
死锁
是指两个或两个以上的线程在执行过程中
,因争夺资源而造成的一种互相等待的现象
,若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
产生死锁主要原因:
- 系统资源不足
- 进程运行推进的顺序不合适
- 资源分配不当
面试题:请写一个死锁代码case
public class DeadLockDemo {
public static void main(String[] args) {
final Object objectLockA = new Object();
final Object objectLockB = new Object();
new Thread(() -> {
synchronized (objectLockA) {
System.out.println(Thread.currentThread().getName()+"\t"+"自己持有A,希望获得B");
//暂停几秒钟线程
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (objectLockB) {
System.out.println(Thread.currentThread().getName()+"\t"+"A-------已经获得B");
}
}
},"A").start();
new Thread(() -> {
synchronized (objectLockB)
{
System.out.println(Thread.currentThread().getName()+"\t"+"自己持有B,希望获得A");
//暂停几秒钟线程
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (objectLockA) {
System.out.println(Thread.currentThread().getName()+"\t"+"B-------已经获得A");
}
}
},"B").start();
}
}
复制代码
面试题:如何排查死锁?
方式一:命令:首先使用**jps -l
**命令查看线程信息(类似于linux中的ps -ef
)
接着通过**jstack 进程编号
** 查看指定线程的堆栈信息,如下图所示:
方式二:通过图形化工具jconsole
5. 写锁(独占锁)/读锁(共享锁)
ReentrantLock、ReentrantReadWriteLock、StampedLock讲解
6. 自旋锁SpinLock
7. 无锁→偏向锁→轻量锁→重量锁
8. 无锁→独占锁→读写锁→邮戳锁
ReentrantLock、ReentrantReadWriteLock、StampedLock讲解
参考资料
Java并发编程知识体系
Java并发编程的艺术
Java多线程编程核心技术
Java并发实现原理 JDK源码剖析