volatile 能否保证线程安全?在DCL上的作用是什么?
一.工作内存,主内存,CPU缓存是什么?线程在读写时是怎么运作的?
工作内存
- 工作内存包括二个部分,一个是线程私有栈,一个是对主内存部分变量进行拷贝的寄存器【程序计数器PC和CPU高速缓存区】
- 程序计数器PC【Program Counter】是用来存储 程序执行到 【当前行代码指令】的地址,程序在执行的过程中,PC会自动更新下一条指令。
- 工作内存是是每个线程的私有内存区域(栈内存),用于存储线程的是私有数据,包括局部变量,方法,参数等,工作内存中的数据是主内存中的数据副本。
主内存(堆内存+java8叫元空间【java6叫方法区】)
- 主内存是所有线程共享的内存区域,用于存储对象实例,静态变量等共享数据。保证正了数据的一致性和可见性。
CPU缓存
- CPU缓存用于CPU和主内存之间的高速缓存(变量被缓存到CPU缓存中的时机取决于CPU的缓存策略、程序的数据访问模式以及变量的访问频率)
二.线程的读写机制
读取数据
- 如果该变量在CPU缓存中存在,直接从CPU缓存中读取数据,并将数据复制到工作内存中,无需访问主内存。如果该变量在CPU缓存中不存在,从主内存中读取数据,并将数据复制到工作内存中.
写入数据
- 线程首先在自己的工作内存中更新变量的值,会在合适的时候(线程结束,线程释放锁,使用volatile关键字修饰的变量,使用synchronized同步的代码块或者方法)将修改的值同步更新到主内存。
三.禁止指令重排(保证有序性)
- 指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度
- volatile禁止指令重排,在读写操作指令前后会插入内存屏障
示例说明:
double r = 2.1; //(1)
double pi = 3.14;//(2)
double area = pi*r*r;//(3)
虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。
- 指令重排序带来的问题 如果一个操作不是原子的,就会给JVM留下重排的机会。
线程A中
{
context=loadContext();
init=true;
}
线程B
{
if(inited){
fun(context)
}
}
如果线程A中发生了指令重排序,那么B中很可能拿到一个尚未初始化完成的context,从而引发程序错误。
- 指令重排在双重锁定单例模式中的影响基于双重检验的单例模式(懒汉型)
public class Singleton3 {
private static Singleton3 instance = null;
private Singleton3() {}
public static Singleton3 getInstance() {
if (instance == null) {
synchronized(Singleton3.class) {
if (instance == null)
instance = new Singleton3();// 非原子操作,可能会被指令重排
}
}
return instance;
}
}
instance= new Singleton()并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:
memory = allocate();//1.分配对象的内存空间
init instance(memory);//2.初始化Singleton对象,调用Singleton对象的构造方法,给内部的成员变量赋值.
instance = memory;//3.将内存空间的地址赋值给对象的引用
上面的操作2依赖于操作1,但是操作3并不依赖于操作2。所以JVM是可以针对他们进行指令的优化,经过重排序后:
memory = allocate();//1.分配对象的内存空间
instance = memory;//2.将内存空间的地址赋值给对象的引用
init instance(memory);//3.初始化Singleton对象,调用Singleton对象的构造方法,给内部的成员变量赋值.
指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。
volatile在DCL(double-check-lock)的作用是什么?解决指令重排序
用volatile关键字修饰instance变量,使得instance在读、写操作前后都会插入内存屏障,避免重排序。
public class Singleton3 {
private static volatile Singleton3 instance = null;
private Singleton3() {}
public static Singleton3 getInstance() {
if (instance == null) {
synchronized(Singleton3.class) {
if (instance == null)
instance = new Singleton3();
}
}
return instance;
}
}
四.volatile和synchronize的区别?
- volatile只能作用于变量(基本数据类型和对象引用 ),但是synchronized可以作用于变量(仅对象引用),方法,类(代码块).
- volatile之保证了可见性(禁止Cpu缓存),有序性(禁止编译器优化),无法保证原子性(其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断比如:count++),synchronized可以保证线程间的有序性,原子性和可见性。
可见性:synchronized 关键字可以保证可见性。当一个线程释放了一个 synchronized 监听器(锁)后,它对共享变量的修改会立即对其他线程可见。其他线程在获取同一个锁时,会看到最新的变量值。
有序性:synchronized 关键字也保证了有序性。它禁止了指令重排序,确保了代码的执行顺序与程序中的顺序一致。这有助于防止由于编译器或处理器的优化而导致的多线程问题。
原子性:synchronized 关键字可以保证原子性。在一个 synchronized 块或方法中,所有操作都是原子的,即要么全部执行,要么全部不执行。这确保了在多线程环境下对共享资源的访问是安全的。
- volatile线程不阻塞,synchronized线程是阻塞的.
- volatile本质是告诉jvm当前变量在CPU cache中的值是不安全的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问当当前变量,其他线程被阻塞。
- volatile标记的变量不会被编译器优化(禁止指令重排),synchronized标记的变量可以被编译器优化(可以被指令重排)
五.volatile能否保证线程的安全?
- volatile无法保证线程的安全和变量的原子性,只能保证线程的可见性和有序性。
- 原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性。
//其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断
public class Main {
public volatile static int count = 0;
public static void main(String[] args) {
count=1;
//开启5个线程
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//让count的值自增100次
for (int j = 0; j < 100; j++) {
count++;
System.out.println("count= " + count);
}
}
}).start();
}
}
}
输出数据:
...
count= 495
count= 496
count= 497
上面的代码,我们开启了5个线程,每隔线程让静态变量count自增100次,执行之后会发现,最终的count 的结果值未必是500,有可能小于500,出现上面的情况的原因是volatile没法保证操作的原子性 (读 - 改 - 写 ) 过程中的原子性,例如:A线程获取到count的值为2,此时主内存与工作内存一致,然后我们执行自增操作,count值为3,但是主内存的值很有可能被其他线程更新为了8或者其他数值,如果A线程执行更新主内存,那么数目就相当于往下降低了
六.作用范围对比
1.volatile作用的对象 volatile仅能修饰变量(基本数据类+对象引用),其核心功能是保证变量的可见性和禁止指令重排序。
private volatile int count=0; //基本数据类
private static volatile SingleTon instance=null; //对象引用
2.synchronized 作用的对象
- 变量 (锁定对象引用)
public class Count{
private int count=0;
private final Object lock=new Object(); //锁定当前对象实例
public void increment(){
synchronized(lock){ /* 代码块 */
count++;
}
}
public void getCount(){
synchronized(lock){ /* 代码块 */
return count;
}
}
}
- 普通方法 【锁定当前对象实例】
public synchronized void method{ /*方法体*/ }
synchronized (this) {} //也是锁住的当前实例对象
- 静态方法 【锁定的是类对象 MyClass.class】
public static synchronized void method{ /* 方法体*/ }
- 类对象
synchronized(MyClass.class){ /* 代码块 */ }
七 .并发控制的影响
1.实例方法锁的局限性
-
同一个实例的线程竞争:若多个线程操作同一个实例的synchronized 普通方法,会因为争夺this 锁而互斥。
-
不同实例的线程竞争:不同实例的线程调用各自的synchronized 普通方法,不会互相阻塞,因为锁的对象不同。
2.静态方法锁的全局性
- 跨实例的线程竞争:所有线程调用给类的静态方法时,均需要竞争同一个类锁(Class对象),无论是哪个实例。
- 静态与非静态的独立性:静态方法锁和实例方法锁互不干扰,因为锁对象不同(Class 对象 vs this)
场景:
SynchronizedExample obj1 = new SynchronizedExample();
SynchronizedExample obj2 = new SynchronizedExample();
// 线程1:调用 obj1 的实例方法(锁 obj1)
new Thread(() -> obj1.method()).start();
// 线程2:调用 obj2 的实例方法(锁 obj2)
new Thread(() -> obj2.method()).start(); // 不会阻塞,因锁对象不同
// 线程3:调用静态方法(锁 SynchronizedExample.class)
new Thread(() -> SynchronizedExample.staticMethod()).start(); // 所有静态方法调用均互斥
八.适用范围
- 实例方法锁:适用于保护实例级别的共享资源,锁粒度小,适合多实例场景.
- 静态方法锁:适用于保护类级别的全局资源,锁粒度较大,注意性能影响.