一、volatile
1、作用:防止重排序、保障可见性、不保障原子性
(1)防止重排序
一个单例模式的例子:
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在singleton = new Singleton();这一行,实际上发生了三个步骤:
分配内存空间。
初始化对象。
将内存空间的地址赋值给对应的引用。
由于指令重排序,步骤可能会变成如下:
分配内存空间。
将内存空间的地址赋值给对应的引用。
初始化对象
所以,当线程A执行到内存空间地址赋值时,singleton==null这个判断就是false了,此时线程B执行到这里就会不走if,直接返回一个还未初始化的singleton,这是不安全的。所以给变量singleton加了volatile修饰,禁止了这一重排序过程
(2)可见性
线程A对volatile变量的写,可以立马通知到线程B,通过的是缓存一致性原理
(3)不保障原子性
其实可以保障单次读、写的原子性
2、实现原理
(1)可见性实现原理:内存屏障
内存屏障是一个CPU指令。
JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
(2)有序性实现原理:happens-before
线程A的写 happens-before 线程B的读
3、应用场景
(1)状态标识
(2)一次性安全发布,类似go的sync.Once
(3)java bean都是volatile的
(4)单例
(5)开销较低的读写锁策略
二、synchronized
1、使用
(1)对象锁
非静态方法加synchronized、代码段加synchronized(this){}
(2)类锁
静态方法加synchronized,代码段加synchronized(XXX.class){}
(3)不用对构造方法加synchronized,因为构造方法本身就是线程安全的,但由于构造方法可能涉及一些共享资源的操作,所以可以在构造方法内加synchronized代码段
2、原理
(1)加锁和释放锁的原理:monitor监视器
相关代码反编译后可以看到monitorenter和monitorexit两个指令,它们会让对象在执行时,使其锁计数器加1或者减1,一个对象只对应一个monitor。
线程获取对象monitor时,会发生三种情况:
a. monitor计数器为0,意味着目前对象没被锁,那这个线程就会立刻获得然后把锁计数器+1
b. 计数器不为0,但锁的所有权是本线程,则可以重入这把锁,锁计数器就会累加
c. 计数器不为0,且锁的所有权不是本线程,那这把锁已经被别的线程获取了,阻塞等待锁释放
释放也就是monitorexit,计数器-1
(2)可重入性原理
递归场景 或 外层方法需要获取一个锁,内层方法也要获取同一个对象锁,则需要锁可重入,所以monitor计数器可累加
(3)保障可见性原理
对同一个监视器的解锁,happens-before于对该监视器的加锁
3、锁优化
在java1.6之前,synchronized是一把重型锁,当一个线程获取锁后,其他线程只能阻塞等待,及其影响性能。但1.6之后,java对synchronized进行了优化,体现在:
(1)锁消除
消除一些不必要的加锁
(2)锁粗化
也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
(3)锁升级
无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
64位的对象头的Markword结构:
无锁:对于共享资源,不涉及多线程的竞争访问。不可能,至少有一个线程访问共享资源
偏向锁:当一个共享资源首次被某个线程访问时,锁就会从无锁状态升级到偏向锁状态,偏向锁会在Markword的偏向线程ID里存储当前线程的操作系统线程ID
发生竞争时,偏向锁的升级和降级都需要等待全局安全点safepoint,它会首先暂停拥有偏向锁的线程A,如果线程A已经退出同步块或不存活了,即可回退至无锁,否则升级至轻量级锁
轻量级锁:有锁竞争失败就会升级为轻量级锁
事实上,只要存在多线程,必然会有锁竞争成功与失败,那就必然升级到轻量级锁,所以偏向锁意义不大,可以通过JVM参数:-XX:-UseBiasedLocking选择是否开启,甚至在java18后直接被废弃了
重量级锁:轻量级锁竞争失败后自旋,10次自旋后还是获取不到锁,升级为重量级锁