一、Java内存模型——JMM
JMM本身是一种抽象的概念,并不真实存在,它描述的是一组规则或者规范,就想JVM一样
通过JMM规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式
JMM的可见性、原子性、有序性使得线程安全得到保证
其中JMM对同步做出了规定:
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 线程解锁前,必须把共享变量的值刷新回主内存
- 加锁解锁必须是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域(类似于JVM在运行时数据数据区中为每一个线程都分配一份线程私有的PC、虚拟机栈、本地方法栈)。而JMM中规定所有变量都储存在主内存,主内存是共享内存区域,所有线程都可以访问(类似于JVM运行时数据区中的堆空间和方法区,所有线程共享数据,都可以访问)。
但线程对变量的读写等操作必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存,然后再对变量进行读写操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问其他线程的工作内存,线程间的通信必须通过主内存来完成
二、volatile
保证可见性
由于JMM规范,各个线程对主内存中的共享变量的操作都是各个线程各自拷贝到自己的工作内存中进行操作后再写回到主内存中,这就导致了各个线程之间的不可见性问题,各个线程之间需要同步机制。
volatile
是JVM提供的轻量级同步机制
- 保证可见性
- 不保证原子性
- 禁止指令重排
接下来验证volatile
的可见性
代码中的number
变量没有被volatile
关键字修饰,new Thread
第一个线程将number
值改为10
,由于各个线程之间无法互相访问,所以main
线程并不知道number
值已经被修改,main
线程拿到的number
变量值还是刚开始时从主内存中拷贝到自己工作内存中值0
,由于不可见性导致一直在循环中出不来
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
//创建第一个线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 开始执行");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addNumber();
System.out.println(Thread.currentThread().getName() + "更新变量number:" + myData.number);
}, "Thread1").start();
//第二个线程 main线程
while (myData.number == 0) {
}
System.out.println(Thread.currentThread().getName() + "main线程执行完成:number:" + myData.number);
}
}
class MyData {
int number = 0;
public void addNumber() {
this.number = 10;
}
}
输出结果
当number
使用volatile
修饰,new Thread
第一个线程改变number
值后,会通知main
线程主内存的值已被修改,体现出volatile
关键字保证了可见性
输出结果
三、volatile
不保证原子性
原子性是指保证数据整体的完整性,某个线程在执行时,中间不可被加塞或分割,需要整体完整,要么同时成功,要么同时失败。
volatile
不保证原子性,同时addPlusPlus()
方法也不是同步方法,多线程的情况下,各个线程修改完各自从主内存拷贝的number
值之后,在写回主内存的时候可能会发生覆盖,导致部分写操作无效
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
//等待上面20个线程全部计算结束
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " finally number is " + myData.number);
}
}
class MyData {
volatile int number = 0;
public void addNumber() {
this.number = 10;
}
public void addPlusPlus() {
this.number++;
}
输出结果
从JVM字节码指令角度分析,number++
操作在多线程下是非线程安全的
那如何解决volatile
不保证原子性的问题?有两种解决方法
- 使用
JUC
包提供的Atomic
原子类 - 使用
synchronized
关键字保证方法同步
使用Atomic
原子类保证原子性
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myData.addPlusPlus();
myData.addMyAtomicInteger();
}
}, String.valueOf(i)).start();
}
//等待上面20个线程全部计算结束
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " int类型 finally number is " + myData.number);
System.out.println(Thread.currentThread().getName() + " AtomicInteger类型 finally number is " + myData.myAtomicInteger);
}
class MyData {
volatile int number = 0;
public void addNumber() {
this.number = 10;
}
public void addPlusPlus() {
this.number++;
}
AtomicInteger myAtomicInteger = new AtomicInteger(0);
public void addMyAtomicInteger() {
myAtomicInteger.getAndIncrement();
}
}
输出结果
使用同步关键字synchronized
保证原子性,使用同步代码块或者使用同步方法
四、volatile
禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,可能会导致多线程程序出现内存可见性问题
处理器在进行重排序时必须考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果也就无法预测
处理器基本上都是使用写缓冲区临时保存向主内存写入的数据,虽然写缓冲区保证了处理器的性能,但是每个处理器上的写缓冲区仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生影响,也就是说处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致!关键原因在于有写缓存区的存在,并且仅对自己的处理器可见。
如下图所示,从内存操作实际发生的顺序来看,知道处理器A
执行A3
来刷新自己的写缓存区里的数据到主内存中,写操作A1
才算真正执行了,虽然处理器A
执行内存操作的顺序是A1->A2
,但是内存实际操作的顺序A2->A1
。此时处理器A
的内存操作顺序被重排序了
volatile
实现了禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象
底层实现是因为内存屏障指令,又称内存栅栏,是一个CPU指令。为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。这个指令可以保证特定操作的执行顺序,也保证某些变量的内存可见性(利用该特性实现volatile
的内存可见性)。
由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier
则会告诉编译器和处理器,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
五、多线程下单例模式的安全问题
单例模式的DCL(双重检锁)模式,虽然加了同步关键字,但是多线程下依然会有线程安全问题
public class SingletonDemo {
private static SingletonDemo singletonDemo=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法");
}
//DCL模式 Double Check Lock 双端检索机制:在加锁前后都进行判断
public static SingletonDemo getInstance(){
if (singletonDemo==null){
synchronized (SingletonDemo.class){
if (singletonDemo==null){
singletonDemo=new SingletonDemo();
}
}
}
return singletonDemo;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i+1)).start();
}
}
}
其中singletondemo=new SingletonDemo()
创建一个单例对象可分为以下3步:
memory = allocate(); //1.分配内存
singletondemo(memory); //2.初始化对象
singletondemo= memory; //3.设置引用地址
初始化对象和设置引用地址没有数据依赖关系,可能发生指令重排。
如果发生指令重排,那么在第一次检测,读取到的singletondemo
不为null
时,singletondemo
的引用对象有可能还没有完成初始化,两次检测都会跳过,返回一个对象还没有初始化完成的引用,导致线程安全问题
解决上述问题的方法,可以给singletondemo
对象添加上volatile
关键字,禁止指令重排