四、共享模型之内存
Java内存模型(JMM)
JMM即为 Java Memory Model ,他定义了主存(多个线程所共享的空间、例:成员变量)、工作内存(线程的私有空间,例:局部变量)的抽象概念,对应着底层的CPU寄存器、缓存、硬件内存、CPU指令优化等;
概要:我们通过操作java这些抽象概念,间接的操作复杂底层(化繁为简)
JMM体现在以下的几个方面 :
- 原子性:保证指令不会受到线程的上下文切换的影响
- 可见性:保证指令不会受到CPU缓存的影响
- 有序性:保证指令不会受到CPU指令优化的影响
可见性
退不出的循环问题
看一个现象:
public class VisibleTest {
static boolean isrun = true ;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(isrun){
}
}, "T1");
t1.start();
Thread.sleep(1000);
System.out.println("T1线程停止");
isrun = false ;
//线程t1并不会如预想的一样停下来!
}
}
测试结果:
为什么会这样?分析一下:
1、初始状态,T1线程从主存当中读取了run的值到工作内存;
2、因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率
1秒之后,main线程修改了run的值,并同步至主存,而+是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决:
方法一:为变量添加修饰:volatile(易变化关键字)
volatile static boolean isrun = true ;
这样做的目的是:加上volatile 的变量,每次循环都是只能在主存当中获取,不会从高速缓存区中获取!
测试结果:T1线程停止
方法二:使用synchronized
在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。(线程获得对象锁后,会清空工作区内存,重新在主存中获取!)
可见性VS原子性
重点区分:volatile和synchronized ;
- 我们的volatile只能保证线程看到的变量是实时的,但是并不能保证是安全的!
- 多个线程同时访问,即使被volatile修饰,仍然可能会出现指令交错问题!
有序性
JVM会在不影响正确的条件下,调整语句的执行顺序!这种特性称作【指令重排】
//如下i和j的++操作调换顺序不影响结果!
public class ReSortTest {
static int i = 0 ;
static int j = 0 ;
public static void main(String[] args) {
i++; //修改为j++
j++; //修改为i++
}
}
思考:正常执行是正确的,而且多线程条件下指令重排可能是会出现问题的,为什么要进行指令重排的优化呢?
指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的CPU指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令—指令译码—执行指令—内存访问—数据写回这5个阶段
重排之前:指令串行执行!
现代CPU支持多级指令流水线,例如支持同时执行取指令~指令译码–执行指令–内存访问–数据写回的处理器,就可以称之为五级指令流水线。这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC =1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
重拍之后:指令并行执行 !
总结:指令级别的优化,我们线程的不同指令的不同阶段可同时进行!【指令级别的并发】
重排序的目的:为的是一个指令执行某一个阶段的时候,通过重排序,让其他执行执行其他的阶段!达到最大的指令并发!
当然前提是:重排互不影响结果 !
public class ReSortTest {
static int i = 0 ;
static int j = 0 ;
public static void main(String[] args) {
i++; //2条指令可重排序!
j++;
i= j - 10 ; //不可重排序,会影响结果
j++ ;
}
}
禁止指令重排序
可以使用volatile实现,因为volatile可以使得被修饰的变量之前的操作是不会被重排序的
Volatile原理 *
以上可以了解到Volatile可以保证共享变量的有序性、可见性 , 我们接下来了解一下原理 ;
volatile的底层实现原理是内存屏障,Memory Barrier (Memory Fence)
- 对volatile变量的写指令后会加入写屏障 ;
- 对volatile变量的读指令前会加入读屏障 ;
1、如何保证的可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public class ReSortTest {
static int i = 0 ;
volatile static int j = 0 ;
public static void main(String[] args) {
i++;
j++; //由于j是volatile修饰的,此处对j进行写操作,所以插入写屏障 !
//所以j++ 以及之前的代码全部会被同步到主存当中
}
}
- 读屏障(lfence)保证的是在该屏障之前的,对共享变量的改动,都同步到主存当中!
public class ReSortTest {
volatile static int j = 0 ;
public static void main(String[] args) {
if(j > 1){ //读取j ,在此之前的代码插入读屏障!(保证读取到的是主存中最新的数据!)
}
}
}
2、如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public class ReSortTest {
static int i = 0 ;
volatile static int j = 0 ;
public static void main(String[] args) {
i++;
j++; //由于j是volatile修饰的,此处对j进行写操作,所以插入写屏障 !
//所以j++ 以及之前的代码全部会被同步到主存当中
//写屏障 , 之前的代码不会发生指令重排序!
}
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public class ReSortTest {
volatile static int j = 0 ;
public static void main(String[] args) {
//读屏障:之后的代码不会被指令重排序
if(j > 1){ //读取j ,在此之前的代码插入读屏障!(保证读取到的是主存中最新的数据!)
}
}
}
总结:
- 读屏障之后的代码不会发产生指令重排序、而且读到的都是主存中的数据
- 写屏障之前的代码不会发生指令重排序、而且之前的代码会全部更新在主存当中!
虽然能解决可见性和有序性,但是仍然不能解决指令交错问题(原子性) ;
3、DCL(Double Check Locking)问题的分析、纠正、解决
happens before规则
七大规则(保证共享变量可见性的七种方法)!
五、共享模型之无锁
CAS + Volatile 无锁实现并发,保证线程安全(乐观锁)
CAS的工作方式
CAS (Compare And Set) : 比较并设置
//测试代码!
static AtomicInteger baclace ;
public void withdraw(Integer amount){
while(true){
int pre = balance.get() ;
int next = pre - amount ;
if (balance.ComapreAndSet(pre,next)) break ; //比较并设置设置值
}
}
//多个线程访问如下方法
其中ComapreAndSet,简称就是CAS(也有Compare And Swap的说法) ,它必须是原子操作!
当CAS方法执行时,prev 会与主存的实时balance比较一次,如果发现不一致(其他线程修改了),那么就返回false ;
//源码
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update); //执行cas时expect会与自身value比较
}
CAS 与 volatile
获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。即一个线程对volatile变量的修改,对另一个线程可见。
注意
volatile仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
//在我们原子整数当中,value都是被volatile修饰过的!
private volatile int value;
CAS必须借助volatile才能读取到共享变量的最新值来实现【比较并交换】的效果
为什么无锁效率高?
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
而且最好用于线程数少于核心数的情况,线程数多的话CAS所在线程分不到时间片依然会进行上下文切换!
总结:因为CAS无锁保证线程安全的话,线程不会说会受到其他线程的影响陷入BLOCK阻塞状态,而是多个线程都会操作共享对象,但是cas会一直比较保证线程安全,线程是不会停止的,sync有锁方式则会出现一个线程获得锁,其他线程只能陷入BLOCK状态等待!
CAS的特点
- 结合CAS和volatile可以实现无锁并发,适用于线程数少、多核CPU的场景下。
- CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我点再重试呗。
- synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们想改,我改完了解开锁,你们才有机会。
- CAS体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
- 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一·
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
原子整数
JUC的子包 java.util.concurrent.atomic 提供了
- AtomicInteger
- AtomicBoolean
- AtomicLong
AtomicInteger为例:
AtomicInteger i = new AtomicInteger(0);
//下边方法属于原子方法,线程安全的!
System.out.println(i.getAndIncrement()); // 结果为 0 等价 i ++ (线程不安全的!)
System.out.println(i.incrementAndGet()); // 结果为 2 等价 ++ i
System.out.println(i.getAndAdd(5));// 结果2
System.out.println(i.addAndGet(5));// 结果12
读取到的 要更改为
System.out.println(i.updateAndGet(x -> x * 10)); //输出 50
//本质都是compare and set ;
原子引用
除了保护我们的基本类型,还可以保护BigDecimal这种引用类型 ;
//测试代码!
private AtomicReference<BigDecimal> baclace ; //外加一层AtomicReference
public void withdraw(Integer amount){
while(true){
BigDecimal pre = balance.get() ;
BigDecimal next = pre.subtract(amount) ; //引用数据类型减法
if (balance.ComapreAndSet(pre,next)) break ; //比较并设置设置值
}
}
BigDecimal decimal = new BigDecimal("1000"); //初始化时最好传递的时字符串!
ABA问题
我们都知道我们cas保证的时最新的值和pre是否相等来判断是否被修改,但是存在这么一种情况:值被修改但是,修改后还是跟pre一致,这种情况,cas则无法判断是否被修改过 ;(虽然对业务无影响,但是仍是个隐患!)
AtomicStampedReference
因此,为了解决ABA这种问题引入
AtomicStampedReference<String> str = new AtomicStampedReference<>("a",0); // 0相当于版本号,只要修改过就会 + 1
//除了比较值是否相等还会比较版本号,版本号会记录改过的次数
AtomicMarkableReference
想对上面AtomicMarkableReference只关心是否被修改过,并不关心修改的次数
AtomicMarkableReference<String> s = new AtomicMarkableReference<>("123",false);
原子数组
保护数组里面的元素、有点复杂没看懂涉及JDK8新特性
原子更新器
保护某个对象里的属性、保证多个线程访问对象中属性的安全性!
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
public class AtomicField {
public static void main(String[] args) {
Student student = new Student(); //多个线程修改其中的name属性
//为Student的name属性设置更新器
AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(Student.class,String.class,"name");
updater.compareAndSet(student,null,"张三");
System.out.println(student); //Student{name='张三'}
}
}
class Student{
volatile String name ; //必须volatile修饰、不然抛出异常!
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
'}';
}
}
通过打断点Debug模拟其他线程提前操作,导致cas匹配失败!
原子累加器
在进行累加的时候,JDK提供如下的2个类的性能是优越于AtomicInteger、AtomicLong这些的,提高4、5倍!
- LongAdder
- LongAccumulator
性能提升的原因很简单,就是在有竞争时,我们的AtomicLong向一个累加单元多次尝试,会降低效率,然而LongAdder设置多个累加单元,Therad-0累加Cell[0],而Thread-1 累加Cell[1]…最后将结果汇总。这样它们在累加时操作的不同的Cell变量,因此减少了CAS重试失败从而提高性能。
LongAdder源码 *
缓存伪共享行为
其中的Cell为累加单元
//防止缓存行伪共享
@sun.misc.contended
public static final class cell {
volatile long value;
cell( long x) { value = x; }
//最重要的方法,用来 cas.方式进行累加,prev表示旧值,next表示新值
final boolean cas( long prev,long next) {
return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next);
}
//省略不重要代码
解释这个需要从CPU的缓存说起
- 因为CPU与内存的速度差异很大,需要靠预读数据至缓存来提升效率。
- 而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是64 byte (8 个long)
- 缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中
- CPU要保证数据的一致性,如果某个CPU核心更改了数据,其它CPU核心对应的整个缓存行必须失效
因为Cell 是数组形式,在内存中是连续存储的,一个Cell为24字节(16字节的对象头和8字节的value),因此缓存行可以存下2个的Cell对象。这样问题来了:
无论谁修改成功,都会导致对方Core的缓存行失效,比如Core-0中ce11[0]=6000,Cell[1]=800。要累加cell[e]=6001,cell[1]=800e,这时会让Core-1的缓存行失效
@sun.misc.Contended用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加128字节大小的padding,从而让CPU将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效
总结:就是我们CPU的多个核心分别获取这个共享对象的累加单元,多个累加单元是以数组的形式保存在CPU的缓存行当中!当我们的CPU获取其缓存中的一个累加单元,并且操作后,另外的一个核心的缓存中的累加单元失效,以上就是伪共享!我们可以用Contended注解使得我们的累加单元分别保存在不同的缓存行!
add方法解析
//源码
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
总结一个add的流程图
LongAccumulate
总结LongAccumulate流程图
sum方法分析
我们获取最终的累加结果
//源码
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}