volatile围绕并发的三大的特性
- 原子性:voliate只能保证单一数据的读/写操作的原子性,像voliate++这种复合操作是不具备原子性(所以通常认为voliate不具备原子性)。
- 可见性:当一个线程写数据时,其他线程能立即感知到。
- 有序性:通过在voliate修饰的变量前后加上各种特定的内存屏障来禁止指令重排来保证有序的。
volatile内存语义
主要依赖于两个硬件级别的机制: lock前缀指令 和 MESI协议
-
lock前缀指令:
- 当访问volatile修饰的变量时,他会在每次read和write操作前使用lock前缀指令,这些确保了在读或写这个变量期间,处理器会锁定缓存行(cache line),这样其他线程就无法同时修改这个变量了。
- lock前缀指令 会导致缓存行的写入操作在副本内存 和 主内存中的进行数据同步,这样确保了可见性。
-
MESI协议:
- MESI(Modified, Exclusive, Shared, Invalid)协议是一种缓存一致性协议,用于维护多核处理器系统中的缓存一致性。
- 四种状态:M:已修改,E:独占,S:共享,I:无效
- 当一个处理器需要写入一个
volatile变量时,它会将该缓存行的状态从Shared变为Modified,然后将新值写入主存中。当其他线程感应到这个变量的写操作时,它们会将自己的缓存行状态设置为Invalid,这样可以确保它们在后续的读取操作中能够从主存中获取最新的值。 - 而当其他线程需要读取一个
volatile变量时,它们会检查缓存行的状态。如果状态是Invalid,它们会从主内存中重新加载变量的最新值到缓存中,并设置为Shared状态。当其他线程使用这个共享变量时,会继续保持Shared状态,直到有处理器将这个缓存行的状态修改为Modified,并写回主存。 - 这里的关键是
volatile变量的写操作会强制其他线程的缓存行状态变为Invalid,而读操作会确保从主内存中获取最新的值。这样可以保证volatile变量的读写操作在多线程环境中是可见的,保证了内存的可见性和有序性。
通过
lock前缀指令和 MESI 协议的协同工作,volatile变量的读写操作能够确保跨线程的可见性和有序性。然而,需要注意的是,volatile并不能保证操作的原子性,例如递增操作(i++)就不是原子的,需要额外的同步机制来保证原子性。
volatile伪共享问题
- 伪共享:CPU每次读取数据都是固定长度的,一般是64bit,导致就算只改了 Long类型的A (8bit) ,也会将没改的 变量B-K 一起读取(凑到64bit),降低了效率。
编辑
-
解决方案:【数据填充】
-
JDK8前
-
定义p1-6,加上value的8bit,总共56 字节,另外这里的PaddingLong是一个类对象, 而类对象的字节码的对象头占用8字节,所以一个FilledLong对象实际会占用64字节的内存,这正好可以放入一个缓存行。
public final static class PaddingLong{ public volatile long value = 0L; public long p1, p2, p3, p4, p5, p6; } -
也可以写个类单独继承方法,PaddingExample正好可以放入一个缓存行。
public class PaddingExample extends AbstractPaddingObject { public volatile long value = 0L; } public class AbstractPaddingObject { public long p1, p2, p3, p4, p5, p6; }
-
-
-
jdk8后,使用注解@sun.misc.Contended,启动的时候需要加上该参数
-XX:-RestrictContended
ConcurrentHashMap中就使用了此注解:
编辑
import sun.misc.Contended;
public class ContendedExample {
@Contended
private volatile long value1;
@Contended
private volatile long value2;
}
volatile重排序问题
重排序:编译器和处理器为了提高并行的效率会对代码执行重排序,单线程程序执行结果不会发生改变的,也就是as-if-serial语义,但在多线程情况下就会存在问题。
-
解决方案:
-
内存屏障解决重排序:
- 写内存屏障: 在指令后插入Store Barrier,能够让写入副本中的数据立即更新到主内存中。
- 读内存屏障: 在指令前插入load Barrier,可以让告诉缓存中的数据失效,强制读主内存数据,让副本与主内存保持一致,避免缓存导致的一致性问题。
-
双重检验锁的单例也应该加上volatile
Singletion.getInstance()完成的动作:
1.分配对象的内存空间(memory = allocate();)
2.调用构造函数初始化
3.将对象复制给变量
第二步和第三步可以进行指令重排,如果instance没有被volatile修饰,那么线程A可能先将对象复制给变量,然后在调用构造函数初始化,在初始化的过程中,线程B也来调用getInstance(),会发生instance不为空,会直接返回instance对象,但Singleton类未初始化,会报错
//懒汉模式的单例
public class Singleton {
// 使用volatile关键字保证可见性和有序性
private static volatile Singleton instance;
// 私有构造方法,防止外部直接创建实例
private Singleton() {}
// 获取单例实例的方法
public static Singleton getInstance() {
// 第一次检查
if (instance == null) {
// 多个线程会在这行代码处同步,只有一个线程能进入此同步块
synchronized (Singleton.class) {
// 第二次检查
if (instance == null) {
// 创建实例
instance = new Singleton();
}
}
}
return instance;
}
}
Volatile和Synchronized的区别
volatile和synchronized都是Java中用于实现线程安全的机制,但它们的工作方式和适用场景有所不同:
-
volatile:- 保证变量的可见性:当一个线程修改了一个
volatile变量时,新值会立即对其他线程立即可见。 - 防止指令重排序:在
volatile变量的前后插入内存屏障,确保变量的读写顺序不会被重排序。 - 适用于轻量级的同步,如读写操作不频繁的场景。
- 不能用于方法同步或代码块同步。
- 保证变量的可见性:当一个线程修改了一个
-
synchronized:- 保证对一段代码或方法的独占执行:同一时刻只有一个线程可以执行被
synchronized保护的代码或方法。 - 提供了更细粒度的锁定,可以锁定整个方法或代码块。
- 适用于对共享资源进行修改的场景,如对共享数据进行读写操作。
- 可能会导致线程饥饿和死锁。
- 保证对一段代码或方法的独占执行:同一时刻只有一个线程可以执行被
总结:volatile用于保证变量的可见性和防止指令重排序,而synchronized用于实现方法或代码块的独占执行。volatile适用于轻量级的同步,synchronized适用于对共享资源的修改。