概述
提到内存模型,一般都会直接与并发操作相关联,Java的内存模型就是描述Java程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在系统中对变量的存储和取出操作的具体底层细节,它更倾向于是一种规范。
Java的多线程
提到Java的多线程,首先说明一下Java的线程时的内存结构以及线程间的沟通方式:在Java中,一般关于对象的数据(如:实例域、静态域和数组元素)是存在于堆内存中,这块内存是所有线程共享的,这里称它为公共空间,而每个线程在运行阶段还会有各自的私有区域,这块区域对其他线程不可见,这个是线程的工作空间,如下图:
![](https://img.haomeiwen.com/i12839705/c07abcad89746d95.png)
所以说如果线程A与线程B想要通讯,只能通过以下两步:
1、A线程中将x更新后刷新会主内存中
2、B线程在使用x的时候,先到主内存中读取x的值
这样就能达到变量x的值在线程A和线程B中互通的效果。这个整个过程就由JMM(Java内存模型)来控制。
重排序
在实际的编码过程中,开发人员写出来的程序代码,它的执行顺序和具体编译后执行的顺序可能不太一样,这个主要是编译器以及计算机内部有一个优化执行效果的过程,用于提高程序执行性能,它有一个原则:如果两个步骤之间不存在依赖的关系,同时在不改变单线程程序语义的情况下,允许重新安排指令的执行顺序。
注意:这里说的是单线程情况,那么随之带来的就是在多线程情况下,如果不采取任何措施,很有可能会出现各种无法预期的结果。重排序分为三种类型:
1、编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从源代码到最终执行的指令序列,会依次经过上面三种指令排序,1是编译器重排序,2和3都是属于处理器的重排序。JMM处理器重排序规则会在java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过它来达到禁止特定类型的处理器重排序,当然这个并非是所有的处理器重排序都会被禁止。
因为重排序的存在,可能会出现一些奇怪的情况,例如:
![](https://img.haomeiwen.com/i12839705/7d73852fab4eed47.png)
上面这种情况,最后如果我们需要获取x和y的值,可能会出现都为0的情况,因为可能会重排序,比如A中的A1和A2颠倒,B中B1和B2颠倒,同时A和B也存在交叉的情况,这样就会出现很多不确定的结果。
JMM的内存屏障指令有四中:
![](https://img.haomeiwen.com/i12839705/938cc88effa7d914.png)
其中最后一个屏障指令是一个全能屏障,现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。但是相应的,执行屏障指令的代价比较昂贵,因为它会让处理器把当前缓冲区的数据全部刷新到主内存中。
提到重排序,就不得不提一个概念,as-if-serial语义,它是指不管怎么重排序(编译器和处理器为了提高并行度),都必须是单线程下程序的执行结果不能被改变。这个语义是runtime和处理器都必须遵守的,这里举一个求圆形面积的例子,假设有r表示半径,指定PI的值为3.14,那么面积area = r * r * PI;在代码中就是如下情况:
![](https://img.haomeiwen.com/i12839705/78f8907cbefdab7e.png)
可以看到,PI和r是互不依赖的,但是area分别和PI和r都有依赖,依据as-if-serial语义,此时如果发生重排序,无论如何,重排序只可能发生在L1和L2两行,L3就不会重排序。
happens-before
在JDK5之前,Java实行的是一套老的内存模型,它存在很多严重的问题,例如:在基于老的内存模型中,在某些情况下final变量是可以被修改的。JDK5之后,Java采用了一套新的内存模型:SR -133内存模型,它主要的目的就是为了阐述基于的内存操作,它们之间的可见性原则。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。happens-before顾名思义就是:在...之前的意思:
1、程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
2、监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
3、volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
4、传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
注意:这里的happens-before仅仅只是要求前一个操作(执行的结果)对后一个操作可见,并不要求前一个操作必须要在后一个操作之前执行,这里我的理解是:如果存在step1和step2两个步骤的操作,但是两个操作互不影响,虽然step1 happens before step2,但是实际中如果发生重排序,可能会出现step2 先于step1执行,因为这里的执行结果与happens-before的结果一致,JMM就会认为它是合法的。
并发问题
基于前面的情况,在多线程情况下,会出现数据的竞争以及一致性的问题,举个简单的例子,对于普通的 a++这个操作,虽然只是简单一句代码,但是在指令层面它可以分解为三步:读取a的值,将a的值加1,然后将加1后的值赋值给a。
假如此时:有两个线程分别对a执行了5000次a++的操作,那么最终程序执行结束的时候,预期a的值是10000,但实际上可能会出现a的结果小于10000的情况,这是因为a++这个操作不具有原子性。因为如果代码没有进行正确同步,线程之间会存在交叉执行的情况,前面说过,线程在对变量进行读或者写的时候,线程本身都有一块本地工作空间,这部分空间其他线程是不可见的,这里两条线程同时对a进行写操作,就可能发生第一个线程的写操作刚刚写完,还没有将新的数据写出到共享空间,另一个线程就对a进行了加1操作,而此时第二个线程获取到的a的值是上一次缓存在线程工作空间中的值,这时就会存在数据不一致问题。
在Java中解决并发问题的方法常用的方式有两种:volatile和synchronized,前者能保证可见性,但不能保证原子性,但是后者可以同时保证可见性和原子性。
volatile
volatile关键字主要用于修饰变量,在Java中的作用就相当于强制给代码加了一个内存屏障指令,被它修改的变量,在线程执行时,如果读取该变量,会强制到主内存中去读取最新的值到工作空间中,而不是先到工作空间中区读取线程缓存的值;对应的,如果对变量进行写操作,会强制将更新后的变量刷新回主内存中,保证线程间的可见性。
可以看到,它在一定程度上解决了多线程之间数据读取和写入时,因为数据通讯不及时导致的数据不一致问题。尤其是在JMM控制下,用volatile修饰的变量在一定程度上是不会进行重排序的,这也是从微观上对volatile字段进行了一致性的加强。但是由于它仅仅只是加了一层内存屏障指令,所以在多线程中,它并不具有原子性的效果,举个例子:
这里仍然以上面的a++为例,存在t1和t2线程对a进行加加操作,因为a++在指令上是三步操作,但是volatile不能保证这三个步骤的原子性(也就是要么三步全执行,要么全不执行),可能就存在t1线程刚刚读取到了a的值之后就让出了CPU资源,改由t2对a进行加加操作,操作完成后,虽然将a的最新值刷新到了主内存中,但是对于t1线程而言,已经读取过了a的值,下一步只是对a进行加1操作,此时就会存在数据不一致问题。
由此可见,volatile可以保证可见性,但是不能保证原子性,所以单纯使用volatile达到线程安全是不可靠的,那么volatile一般用在什么情况下呢:一般volatile主要用于相互之间不存在依赖关系的语句中,上面a++的例子之所以有问题,就是因为对a的操作是与上一步操作的结果相依赖的。如果不存在依赖,假设设置boolean变量,它的值只能是true或false,利用它的变化,来实现一些其他操作,就可以使用volatile。
synchronized
在Java中,在并发环境下,常常使用的方式就是synchronized,它是Java中非常重要的锁机制。当线程对锁释放的时候,其内存语义就是把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。对比volatile可以发现:锁的释放与volatile写具有相同的内存语义,而锁的获取与volatile的读具有相同的内存语义。
synchronized可以同时保证可见性和原子性,如果在方法上添加synchronized锁,那么在一个线程进入方法中执行期间,其余线程都无法再次进入,直到上一个线程释放锁,其余线程才能继续竞争,只有获取到锁的线程才能拥有该方法的执行权限。再来分析上面的5000次a++的例子,如果给a++所在的方法或者a++这句话加上synchronized,就意味着a++所对应的三步内存指定操作:读取--修改--更新这三步变成了一个原子操作,如果某个线程执行了a++操作,那么其余线程必须等到该线程释放锁,也就是执行完a++所对应的三步指令并释放线程锁,其余线程才能获取对a的后续读写操作。这样就达到了“要执行就全部执行”的原子效果。
但是同样的也必须明白,使用synchronized的开销很大,而且如果在方法上加锁,它的粒度比较大,因此性能较低,更多情况下都是对某段代码进行加锁,将那些可以在并发环境下正常执行代码放到锁的外面,有利于提高程序的性能。另外Java也提供了一些用于同步的工具(如:重入锁ReentrantLock)。
ReentrantLock
Java中可以利用重入锁(ReentrantLock)来达到锁内存语义的效果。它的实现依赖于Java同步框架AbstractQueuedSynchronizer(AQS)
AQS使用一个整型的volatile变量(命名为state)来维护同步状态。不过ReentrantLock分为两种:公平锁和非公平锁;公平锁获取时,首先会去读这个volatile变量。而非公平锁获取时,涉及到底层CAS实现,它与具体处理器有关,总之CAS同时具有volatile读和volatile写的内存语义。而综合重入锁的原理,可以总结为:锁释放-获取的内存语义的实现至少有两种方式:
1、利用volatile变量的写-读所具有的内存语义
2、利用CAS所附带的volatile读和volatile写的内存语义。
Java中在concurrent包下,提供了很多用于并发操作的工具,例如:保证原子性的Atomic开头的工具类、并发环境下高效的ConcurrentHashMap类,这些都为并发编程提供了方便,其中ConcurrentHashMap并非一般意义的加锁,Java本身还有一个线程安全的HashTable,以及可以使用Collections工具类创建线程安全的HashMap,但是这些Map的性能普遍都比较低,而ConcurrentHashMap最新引入了段(segment)机制,所谓段机制说明如下:
在Map中都会有固定的内存区域,针对Map的读写操作,传统的线程安全类,就是在对应方法上加锁,这样同样也就限制了在多线程情况下,多个线程对同一个Map进行读写操作,它是在整个Map的内存区域加锁。而段机制的引入,实际上就相当于将Map对应的内存区域分为许多不同的区域,当一个线程进入时,它只会锁住一小块区域,如果有其他线程同时进入,只要不是针对同一块内存区域进行操作,多个线程可以同时对同一个Map进行读写操作而互不影响,这就大大提高了程序的执行效率。
网友评论