并发编程攻略-并发编程的三大特性讲解

118 阅读5分钟

本期重点内容

本章将重点讲述并发编程的三个特性 原子性、可见性、有序性以及与其密切相关的JAVA领域的JMM(JAVA内存模型)

JAVA内存模型

不同的硬件和不同操作系统在内存操作上面有一定的差异,JAVA为了解决相同代码在不同硬件不同操作系统上面出现的各种问题,用JMM(JAVA内存模型)屏蔽掉了各种硬件和操作系统带来的差异,为了能后让并发编程可以跨平台。

JMM规定所有的变量会存储在主内存中,线程在操作的时候需要从主内存中拉取到线程的工作内存中,操作完成再写回主内存

并发编程的原子性

什么是并发编程的原子性

系统执行的操作是一个整体。在执行的过程中,不会被其他线程干扰,程序执行的结果是可以预期的。 也即多线程操作临界区资源,预期结果与最终的结果一致。

a=1 这个操作就是原子操作,符合上面对原子性的定义
i++ 这个操作在被编译为字节码指令时是分三步完成的,不符合上面的定义

如何保障并发编程的原子性

锁住临界区资源

非原子性的操作需要借助锁来保障操作的原子性,这样在多线程修改临界区资源时能够保障实际的结果与预期的结果是相符的。

CAS操作

CAS也就是比较并交换,是CPU级别的并发原语,是一个原子性的操作。需要注意的是CAS操作只能保障对一个变量的原子性。 在使用CAS时,需要关注ABA问题、自旋时间过长,导致CPU空转。

ThreadLocal

ThreadLocal在一定程度上也能解决原子性问题,但要看具体的使用场景,ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据。使用时需要避免内存泄露问题。

并发编程的可见性

什么是并发编程的可见性

可见性问题的本质是CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,CPU就提供了多级缓存,每次去主内存拿完数据后,就会存储到CPU的多级缓存中,每次去缓存拿数据,效率肯定会提升。但是现在CPU都是多核,每个线程的工作内存都是独立的,每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,就会导致数据不一致问题,进而出现了可见性。

如何保障并发编程的可见性

volatile

使用volatile修饰的变量,不允许使用CPU缓存,必须从主内存中读取数据, volatile会让CPU每次操作这个数据时,必须立即同步到主内存以及从主内存读数据。

synchronized

根据synchronized的内存语义,也是可以解决可见性的问题。 如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

lock

Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作,如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中,还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。

final

final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。final和volatile是不允许同时修饰一个属性的。

happens-before原则保障内存可见性

多线程环境下由于指令重排序会造成内存可见性问题,满足happens-before条件,保障了内存可见性

并发编程的有序性

什么是并发编程的有序性

在Java中,.java文件会被编译为字节码.class文件,后续在执行前需要再次编译为CPU可以识别的指令,CPU在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排,即会进行编译器优化重排序和指令器并行的重排序,都是为了尽可能的发挥CPU的性能。

as-if-serial

不论指定如何重排序,需要保证单线程的程序执行结果是不变的,如果存在依赖的关系,那么也不可以做指令重排。

happens-before原则

即使满足了Happens-Before原则,编译器和处理器仍然可能进行重排序,但这种重排序必须保证程序的执行结果与顺序执行的结果一致。

如何保障并发编程的有序性

让程序对某一个属性的操作不会出现指令重排可以基于volatile修饰属性,就不会出现指令重排的问题。volatile是基于内存屏障的概念来禁止指令重排序的,内存屏障也是一条指令,会在两个操作之间加上这个指令,这个指令就可以避免上下其他的指令进行重排序。