说一说你对synchronized的理解?
synchronized关键字用于解决多个线程访问临界资源的同步问题,它可以保证同一时刻只有一个线程在操作一个临界资源。
在Java的早期版本synchronized属于重量级锁,效率低下。因为监视器锁(monitor)是以来于操作系统底层的Mutex Lock
来实现的,Java的线程映射到操作系统的原生线程上,要挂起或者唤醒一个线程实现线程的切换,都需要涉及到操作系统的用户态和内核态的转换,开销比较大。
但是在Java6之后,Java官方在JVM层面对synchronized进行来优化,JDK1.6实现来自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等减少来锁的开销。
说说synchronized怎么使用的?
- 加在实例方法上,获取的是当前对象实例的锁
- 加在静态方法上,获取的是class的锁,不会与实例对象上的锁冲突
- 加载某一代码块上,可以this来表示要获得当前对象的锁,也可以写类.class表示获取类的锁
使用synchronized实现双重检验锁方法写的单例模式
保证了线程安全
// 单例:双重校验锁
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
为什么要加双重锁呢,因为instance = new Singleton();
这段代码其实是分三步执行的:
- 为instance分配空间
- 初始化instance
- 将instance指向分配的内存地址
但是由于JVM有指令重排的特性,执行循序又可能变成1→3→2,多线程环境下有可能导致一个线程获得一个还没有初始化的实例。比如线程A执行了1和3,此时线程B调用getUniqueInstance()后发现instance不为空,但是得到的instance此时还未被初始化。
使用 volatile
可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
构造方法可以使用synchronized关键字修饰吗?
构造方法不能用synchronized关键字修饰,因为构造方法本身就是线程安全的,不存在同步的构造方法一说。
讲一下synchronized关键字的底层原理?
同步代码块
查看使用了synchronized关键字的代码的字节码,会发现同步语块使用的是monitorenter
和montorexit
指令,monitorenter
指令指向同步代码块的开始位置,monitorexit
指令表示同步代码块的结束位置。
当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有权。
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
在执行 monitorexit
指令后,将锁计数器设为 0,表明锁被释放。
同步方法
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
不过两者的本质都是对对象监视器monitor的获取
说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?
JDK1.6 对锁的实现引入了大量的优化,如偏向锁
、轻量级锁
、自旋锁
、适应性自旋锁
、锁消除
、锁粗化
等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态
、偏向锁状态
、轻量级锁状态
、重量级锁状态
,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
关于具体的优化和Java中各种锁的介绍,在Synchronized优化篇——Java中的各种锁中介绍。
谈谈 synchronized 和 ReentrantLock 的区别?
相同点:
- 两者都是可重入锁
“可重入锁”指的是可以再次获取自己已经获得的锁。如果锁不可重入的话就会导致死锁。同一个线程获取锁,锁的计数器会自增1,所以要等到锁的计数器下降为0时才能释放锁。
synchronized的可重入是利用monitor的计数器来实现的,而ReentrantLock则是利用底层AQS的state来实现的。
不同点:
- synchronized依赖于JVM而ReentrantLock依赖于API
synchronized是依赖于JVM实现的,包括之前说的Java官方在JDK1.6做的很多优化也是在虚拟机层面去实现的,没有直接暴露给我们。
ReentrantLock是JDK层面实现的,可以直接查看到源码看他是怎么实现的,调用lock()和unlock()方法时也要配合try,catch去完成。
- ReentrantLock比synchronized增加来一些高级功能
-
等待可中断
提供了
lockInterruptibly()
方法实现了等待可中断机制,就是当前线程在等待锁的过程中,可以中断来放弃等待,不去获取锁了。不过只有该线程执行了interrupt()
方法之后,lockInterruptibly()
才起作用。 -
可以指定是公平锁还是非公平锁
具体在RreentrantLock篇中介绍
-
可实现选择性通知
原本我们的
notify()
是由JVM进行随机唤醒一条线程的,但是用了Condition
我们可以进行指定唤醒,线程对象可以注册在指定的Condition
上,实现“选择性通知”。而
notifyAll()
方法的话就会通知所有处于等待状态的线程,造成很大的效率问题,而Condition
实例的signalAll()
方法只会唤醒注册在该Condition
实例中的所有等待线程。
什么是虚假唤醒?如何避免?
虚假唤醒就是比如一个消费者和生产者的场景,商品只有一个,生产者也只有一个,但是消费者有很多个。如果生产者生产了一个商品后notifyAll()
,那很多个消费者线程都会被唤醒,但是只能有一个获得锁并进行消费,其他人都还获取不到,就是虚假唤醒
;但是其他人都在这第一个消费者之后获取了锁还继续执行了消费的操作,导致商品数量变为负数,这个时候就出现问题了。
public synchronized void consume() {
// if换成while来解决
if (num <= 0) {
System.out.println("库存已空,无法消费");
try {
// 等待生产者生产
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num--; // 进行消费
this.notifyAll(); // 唤醒其他所有线程
}
因为多个消费者线程被同时唤醒后进行资源争抢,但是他们获取锁之后就不再判断当前资源是否还有剩余,或者说满足消费操作的条件就直接继续执行后面的消费操作了,这样就会导致资源超卖变成负数。
解决办法当然是让他们在从wait()
返回获取锁之后,仍然还要再次判断资源是否满足条件,避免前面已经有别的消费者已经消费了。而让他们再次判断资源是否满足条件那就把if
换成while
就可以了,这也是JDK官方建议的做法。