理解Java内存模型

概述

一直以来总是把Java内存模型当成JVM运行时数据区域,但是现在发现并不是这样的。JVM运行时数据区域是在Java程序运行时的内存区域的划分,例如堆、虚拟机栈、方法区之类,而Java内存模型是用来屏蔽Java程序在不同操作系统上对内存进行访问的差异性,也就是使得Java在不同的平台上访问内存具有一致性,这也是Java具备跨平台能力的基础。Java内存模型其实就是一种规范,它会对程序中变量的访问规则进行定义,目的是解决由于多线程通过共享内存进行通信时,存在的缓存一致性问题、处理器优化问题和指令重排问题。这些问题和原子性、可见性、有序性相对应,因此Java内存模型是围绕着并发过程中如何处理原子性、可见性和有序性这三个特征来设计的。

原子性

原子性从字面意思理解,就是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作。当前执行的代码块要么全做,要么全部不做,以防止执行过程中的某个变量出现一致性的问题。i++这种操作并不是原子性的,但是即使i = 1这种赋值操作也未必是原子性的。我们知道int是32位的,而double和long都是64位的,在JVM中是允许将没有被volatile修饰的64位数据划分为两次32位数据来进行读写操作的,这也就是long和double的非原子协定。

可见性

可见性和有序性都可以由volatile关键字保证。每个线程会在本地内存保存引用变量在堆内存中的副本,线程对变量的所有操作都是在本地区域中进行的,执行结束后再同步到堆内存中。在这个过程中会产生一个时间差,在这个时间差内,该线程对副本的操作对于其他线程都是不可见的。这使得线程之间无法及时感知到该变量的更改,从而导致一致性的问题。当使用volatile关键字对该变量进行修饰后,意味着任何对此变量的操作都会直接在内存中进行,不会产生副本,以保证共享变量的可见性。

有序性

从代码层面的角度来看,整个程序的逻辑顺序一定不会改变,而此处的有序性指的是禁止JVM或OS对其底层指令进行优化,使得一段非原子性代码的执行在指令层面是有序的,不会进行指令重排。指令重排不会影响单个线程的执行,但是会影响到多个线程并发执行的正确性。例如对于代码instance = new Object(),这条语句实际上包含了三步操作:

1、分配对象的内存空间;
2、初始化对象;
3、设置instance指向刚分配的内存地址。

出于对流水线指令优化的考虑,OS可能会将以上三条指令的运行顺序变为:

1、分配对象的内存空间;
2、设置instance指向刚分配的内存地址;
3、初始化对象。

如果线程A在重排序后,执行完第2步——设置instance指向刚分配的内存地址,而线程B在instance不为null时就会返回,此时由于已经分配内存地址,所以线程B判断instance确实不为null,因此会返回一个只初始化到一半的“半个”实例。该过程在懒汉式单例模式在多线程下的双重检查锁中有很深的体现,此处不再展开详谈,可参考单例模式探讨。然而,单单使用volatile并不一定能够保证线程安全,要使volatile变量提供理想的线程安全,必须同时满足下面两个条件:1、对变量的写操作不依赖于当前值;2、该变量没有包含在具有其他变量的不变式中。比如对于i–而言,该代码片段的实现实际上需要三步:

1、取得原有的i值;
2、计算i-1;
3、对i进行赋值。

volatile关键字仅能保证某个线程对变量i的更改是立即可见的,但是在该三个步骤中,如果有多个线程同时访问,就会出现线程安全问题。在线程A和线程B取得原有的i值后,同时做减1操作,再对i进行赋值时,i会减少2。因为此时多个线程的执行是无序的,没有任何机制来保证多个线程执行的有序性和原子性。所以我们需要创建一扇门,使用synchronized关键字对该过程进行同步,使得每次只有一个线程能够进行这扇门并获得共享变量i,在使用完后释放掉。可以看出volatile仅能在赋值等不可分割的操作中保证线程安全。关键字synchronized可以保证线程间的可见性和有序性,从可见性的角度上讲,synchronized可以完全替代关键字volatile;从有序性的角度上看,由于synchronized限制每次只能有一个线程可以访问同步块,而串行语义又是一致的,因此一定保证了有序性。但是我们需要避免错误地加锁,将i–放入synchronized同步代码块时,锁不能像以下代码片段一样加在i上:synchronized(i)。因为Integer属于不变对象,一旦被创建,就不会被修改,i–在真正执行时会变成:i = Integer.valueOf(i.intValue()-1)i--的本质是创建一个新的Integer对象,并将它的引用赋给i。由于i一直在变化,多个线程间看到的不一定是同一个i对象,因此两个线程每次加锁都可能加在了不同的对象实例上,synchronized本质上只是锁住了一段内存地址,例如线程A锁住2,线程B等待2,2变为1后,线程C锁住1,而线程B依然会对2进行i–操作,从而导致最终结果出现问题。对于基本数据类型而言,synchronized(i)都是错误的加锁方式,正确的加锁方式应该是synchronized(instance),对实例进行加锁。
synchronized关键字能够一次性满足原子性、可见性、有序性三个要求,但是它的实质是悲观锁,频繁地使用会严重地影响性能,所以建议谨慎使用。

happen-before原则

如果内存模型的有序性都要靠volatile和synchronized关键字进行实现,那是非常繁琐的,所以Java内存模型中定义了线程中两个操作之间的顺序关系,用来判断数据之间是否线程安全:

  • 单线程happen-before原则:在同一个线程中,代码流程在前面的操作happen-before代码流程在后面的操作,代码流程是代码的逻辑顺序而不完全是代码顺序,因为可能存在分支等情况。
  • 锁的happen-before原则:对一个锁的unlock操作happen-before该锁的lock操作。
  • volatile的happen-before原则:对一个被volatile修饰变量的写操作happen-before对此变量的任意操作。
  • 线程启动的happen-before原则:对一个线程的start方法happen-before该线程的其它方法。
  • 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的代码检测到中断事件的发生。
  • 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
  • 对象创建的happen-before原则:一个对象的初始化方法调用happen-before该对象的finalize方法调用。
  • happen-before的传递性原则:如果A操作 happen-before B操作,B操作 happen-before C操作,那么A操作 happen-before C操作。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章