Java内存模型(JMM)

7 篇文章 0 订阅

Java内存模型概念

Java内存模型(Java Memory Mode,JMM)用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下能够达到一致的内存访问效果。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

JMM定义了一套在多线程读写共享数据时,对数据的可见性、有序性和原子性的规则和保障。

Java内存模型和CPU缓存模型类似,是基于CPU缓存模型来建立的,CPU缓存模型如下图,通过高速缓存平衡了CPU和内存速度不匹配的问题,操作系统增加了进程、线程、CPU分时复用,平衡了CPU与I/O设备的速度差异:
在这里插入图片描述
Java内存模型:
在这里插入图片描述

JMM规定了变量(这里的变量指的是共享变量,不包括局部变量和方法参数,因为这是线程私有的,分配在JVM虚拟机栈中)都存储在主存中,每条线程都有自己的工作内存,线程的工作内存中保存了该线程中使用到的主存中变量的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方线程工作内存中的变量,线程间变量的传递需要自己的工作内存和主存之间进行数据同步。
多个线程访问主内存中的共享变量,不是直接访问,而是先将共享变量从主内存拷贝到自己的工作内存中形成一个共享变量副本,这样,线程实际操作的是自己工作内存中的共享变量副本。

举例,这段代码我们期望的是当第二个线程改变initFlag为true的时候,第一个线程能够跳出while循环,但是while循环会一直进行下去,这就是因为第一个线程先将initFlag = false拷贝到了自己的工作内存,第二个线程对initFlag变量的修改并不会影响到第一个线程工作内存中的initFlag变量:

public class Test {
    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("waiting data...");
                // 期望的是当第二个线程改变initFlag为true的时候,跳出循环
                while (! initFlag) {}
                System.out.println("success...");
            }
        }).start();

        // 让程序暂停2s,保证第一个线程已执行到while,
        // 否则第一个取到的initFlag值可能是被第二个线程改为true之后的值
        Thread.sleep(2000);

        new Thread(new Runnable() {
            @Override
            public void run() {
                prepareData();
            }
        }).start();
    }

    public static void prepareData() {
        System.out.println("prepareing data...");
        initFlag = true;
        System.out.println("prepareing data end...");
    }
}

private static boolean initFlag = false;改为private static volatile boolean initFlag = false;即可达到期望的效果。
在讲解volatile关键词之前,先了解下JMM中定义的一系列原子操作。

JMM数据原子操作

  • read(读取):从主内存读取数据
  • load(载入):将从主内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来进行计算(读到线程中进行计算)
  • assign(赋值):将计算好的值重写赋值到工作内存中
  • store(存储):将工作内存数据写入主内存(这时候只是写入主内存,但是还没有对主内存中相应的变量进行赋值)
  • write(写入):将store过去的变量赋值给主内存中相应的变量
  • lock(锁定):将主内存变量加锁,表示为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

还是以上个例子为例,线程1读取到initFlag变量到自己的线程中并使用会经过read、load、use过程,线程2还对initFlag变量进行了修改,因此还需要经过assign、store、write过程,但是线程1中的initFlag变量并没有更新(线程之间是不会直接传递数据的)。

解决线程之间共享数据不一致的传统方式是总线加锁,即一个线程读取主内存中的数据到高速缓存后,会在总线上对这个数据加锁,这样其他线程就无法读取或写入这个数据,直到这个线程使用完数据后才会释放锁,但是这种做法效率太低,造成大量的IO阻塞。更好的方案是使用MESI协议。

总线:用于CPU和其他硬件如内存进行数据交换的线路。store操作就需要经过总线

缓存一致性协议(MESI协议)

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中一个CPU修改了缓存中的数据,该数据会马上同步回主存,其它CPU通过CPU总线嗅探机制可以感知到数据的变化而将自己缓存里的数据失效。
比如上面例子中,如果线程2开启了缓存一致性协议,那么在prepareData()方法中的第二条语句对initFlag变量进行修改后会马上同步回主存,而不是等prepareData()方法中后续代码全部执行完毕才进行这个操作。
CPU总线嗅探机制就是在硬件级别上实现CPU对总线进行监听,一旦其他线程修改了共享数据并写回到主内存,其他线程通过CPU会立即感知到数据的变化,然后将自己工作内存中共享变量副本的值清空,当线程再去从工作内存中读取这个变量的时候,就会发现这个变量已经失效了,那么它会到主内存中重新读取这个数据,这时候就能读取到最新的数据。

并发编程三大特性

  • 可见性
  • 原子性
  • 有序性

volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制。
JMM就是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

Volatile保证可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。通过volatile关键字修饰变量可以保证可见性,volatile底层主要是通过汇编语言中的lock前缀指令实现,volatile修饰的共享变量的修改操作对应的汇编指令会加上lock前缀,当cpu发现这个lock前缀指令时,会这样处理(根据IA-32架构软件开发者手册对lock指令的解释是):

  • 会将当前内核高速缓存行的数据立即写回到主内存中
  • 这个写回主内存的操作会引起在其他内核缓存中,缓存了该数据的缓存行状态变为无效(MESI协议)

下图是是prepareData()函数执行到initFlag = true;语句时的其中一条汇编指令,只有当initFlag变量被volitale修饰之后,这条指令前边才会加上lock前缀。rsp指的是工作内存中的寄存器,dword表示一个四字节的操作数,add dword ptr [rsp]就是指将操作数写到寄存器中,对应的就是JVM原子操作中的assign操作。

可以使用hsdis工具查看汇编

在这里插入图片描述
用通俗的话解释就是加上lock前缀的指令,会在变量修改了之后,立即写回主内存;它还会开启MESI协议,让多个CPU之间对这个共享变量进行总线嗅探,当一个线程修改了这个共享变量,经过总线回主内存时,其他线程通过总线监听到这一改变,然后将自己缓存中的这个数据对应的缓存行设为无效状态,即数据无效。之后再使用这个变量的时候,发现数据无效,会到主内存中重新读取。

现在思考这样的一个问题,如果多个线程同时执行store和write操作会怎样呢?

lock前缀指令还有这样的一个操作:它会锁定这块内存区域的缓存(缓存行锁定)。在总线加锁机制上,锁是加载read之前的,也就是一个线程读取了主内存中的一个共享变量,那么其他线程就无法再读取该变量。而lock前缀指令是通过在store前加锁的,也就是多个线程可以同时读取一个共享变量,但是他们对变量进行修改并准备执行store操作时,会去竞争一把锁,谁获取到了这个锁,就能执行store和write操作,write操作执行完后,进行unlock释放锁。这种锁的粒度就非常的小,执行store和write操作就是对内存变量的赋值,对内存操作的并发每秒可以达到上百万次,所以这种锁大大提高了系统的并发性能。
在这里插入图片描述

Volatile不能保证原子性

原子性是指一个或者多个操作在CPU执行的过程中不被中断的特性。下面来看看为什么不能保证原子性。
举例:

public class VolatileAtomicTest {
    public static volatile int num = 0;

    public static void increase() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];

        for(int i = 0; i < 10; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // join()的意思是等待所有的子线程都执行完了,主线程才继续往后执行
        for(Thread t : threads) {
            t.join();
        }

        System.out.println(num);
    }
}

上面代码中,预期输出的应该是10000,但实际输出的是小于等于10000的数,每次都值可能都不一样。
有两个点可以思考这个问题
第一点
++操作对应的汇编指令主要有三步:
(1)指令1:把变量num从内存加载到cpu寄存器
(2)指令2:在寄存器中执行++操作
(3)指令3:把结果写入内存(如果有缓存机制,则可能是先写到缓存)

如果线程1执行完指令1后,发送了线程切换,线程2开始执行+1操作把num变成1写回到主存,此时切换回线程1继续执行指令2,这时候num还是0,因此线程1也会吧num为1的值写到主存。所有一共对num进行了2次++操作,但是值是1.
第二点
假设线程1和线程2进行了++操作,然后同时都需要执行store操作,上面说过,用volatile修饰的变量,会在store操作前加上锁,假如此时线程1拿到了锁,把1写入到主内存中的该变量上,这时就会触发线程2中这个变量的失效,当线程2再次进行++操作时,会重新到主内存中取这个变量的值,此时取到的是1,线程2将其变为2,。可以看到,一共执行了3次++操作,但是num的值可能就是2。
在这里插入图片描述
要保证num的最终值是10000,可以给increase()方法加上synchronized关键字,给整个方法加锁;或者使用synchronized给代码块加锁。

Volatile保证有序性

处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中顺序一致,因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。这种优化是为了提高处理器的执行效率,充分利用资源。

int a = 10;    //语句1
int r = 2;    //语句2
r = a*a;     //语句3

上面这3行代码,能保证语句3一定在语句1和语句2后面执行,但是语句1和语句2的执行顺序就不能保证,这在单线程下不会出问题,但是在多线程下就可能会出问题。
如果给变量r加上volatile关键字,则会保证该变量的顺序执行,也就是语句2不会再语句3之后执行,也不会在语句1的前面执行。
下面再看一个关于编译器优化导致多线程问题。

public class Singleton {
	static Singleton instance;
	static Singleton getInstance() {
		if(instance == null) {
			synchronized(Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

这是利用双重检测创建单例对象,预期的new操作应该是这样的:

  1. 分配内存
  2. 在这块内存上初始化Singleton对象
  3. 将这块内存的首地址赋值给instance变量

初始化的过程有可能需要占用很多的时候,编译器就会做这样的优化:

  1. 分配内存
  2. 将这块内存的首地址赋值给instance变量
  3. 在这块内存上初始化Singleton对象

如果线程1执行到第2步的时候发生了线程切换,其他线程在第一次检测instance是否为空时,这时候已经不为空,因此就会得到一个未初始化的实例。
这段代码的第二层检测也会出问题,即使线程1执行完了上面的三个优化后的步骤,instance的值可能没来得及写回主存,此时线程2会在第二层检测到instance值为null,然后又实例化了一个Singleton对象。因此,需要用volatile修饰instance变量,既能保证可见性,又能避免编译器优化带来的无序性问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值