JVM_06 内存模型(JMM)篇

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并不会如预想的一样停下来!
    }
}

测试结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qO5zGNpw-1638256002608)(JUC并发编程.assets/image-20211010191147105.png)]

为什么会这样?分析一下:

1、初始状态,T1线程从主存当中读取了run的值到工作内存;

2、因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率(JIT : Just In Time Compiler,一般翻译为即时编译器,)

1秒之后,main线程修改了run的值,并同步至主存,而+是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

解决:

方法一:为变量添加修饰:volatile(易变化关键字)

volatile static boolean isrun = true ;

这样做的目的是:加上volatile 的变量,每次循环都是只能在主存当中获取,不会从高速缓存区中获取!

测试结果:T1线程停止

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cvoG4bIR-1638256002609)(JUC并发编程.assets/image-20211010192206659.png)]

方法二:使用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问题的分析、纠正、解决

DCL : Double Check Locking 双检锁

看如下代码:

	// 最开始的单例模式是这样的
    public final class Singleton {
    
    
        private Singleton() {
    
     }
        private static Singleton INSTANCE = null;
        public static Singleton getInstance() {
    
    
        // 首次访问会同步,而之后的使用不用进入synchronized
        synchronized(Singleton.class) {
    
    
        	if (INSTANCE == null) {
    
     // t1
        		INSTANCE = new Singleton();
            }
        }
            return INSTANCE;
        }
    }
// 但是上面的代码块的效率是有问题的,因为即使已经产生了单实例之后,之后调用了getInstance()方法之后还是会加锁,这会严重影响性能!因此就有了模式如下double-checked lockin:
    public final class Singleton {
    
    
        private Singleton() {
    
     }
        private static Singleton INSTANCE = null;
        public static Singleton getInstance() {
    
    
            if(INSTANCE == null) {
    
     // t2									#1
                // 首次访问会同步,而之后的使用没有 synchronized		
                synchronized(Singleton.class) {
    
    								#2	
                    if (INSTANCE == null) {
    
     // t1							#3
                        INSTANCE = new Singleton();							#4
                    }
                }
            }
            return INSTANCE;												#5
        }
    }
//但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,不能享有synchronized保证的原子性,可见性。所以

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

多线程情况下,上述代码仍然存在指令重排的问题

当我们的线程t1,执行到 if(INSTANCE == null) #3,发现此时的实例为null,就去获取锁创建对象,我们看一下new对象的的字节码指令

// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;

此时我们的发生指令重排,先执行赋值操作,先将空的实例对象返回(此时Instance实例已经有值了),然后执行构造初始化对象!

就在我们的的初始化执行一半,线程t2过来了,发现instance不为null,执行 return INSTANCE;我们此时返回还是未被初始化的对象,所

以问题就此发生!!

解决DCL

加volatile就行了。

public final class Singleton {
    
    
        private Singleton() {
    
     }
        private static volatile Singleton INSTANCE = null;
        public static Singleton getInstance() {
    
    
            // 实例没创建,才会进入内部的 synchronized代码块
            if (INSTANCE == null) {
    
    
                synchronized (Singleton.class) {
    
     // t2
                    // 也许有其它线程已经创建实例,所以再判断一次
                    if (INSTANCE == null) {
    
     // t1
                        INSTANCE = new Singleton();
                    }
                }
            }
            return INSTANCE;
        }
    }

对volatile修饰的变量进行些操作的时候,在写操作后加上内存屏障,使得写屏障之前的代码不会发生指令重排!

happens before规则

七大规则(保证共享变量可见性的七种方法)!

二、共享模型之无锁

CAS + Volatile 无锁实现并发,保证线程安全(乐观锁)

CAS的工作方式

CAS (Compare And Set) : 比较并设置

//测试代码! 
public class CasTest02 {
    
    

    AtomicInteger balance2 = new AtomicInteger(100);

    public void withdraw(Integer amount){
    
    
        while(true){
    
    
            int pre = balance2.get() ;
            int next = pre - amount ;
            if(balance2.compareAndSet(pre,next)){
    
    
                System.out.println(balance2.get());   //90
                break ;  //比较并设置设置值
            }
        }
    }
}
class TestCas{
    
    
    public static void main(String[] args) {
    
    
        CasTest02 test02 = new CasTest02();
        test02.withdraw(10);
    }

//多个线程访问如下方法

其中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匹配失败!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9giXoooG-1638256002610)(JUC并发编程.assets/image-20211012232944170.png)]

原子累加器

在进行累加的时候,JDK提供如下的2个类的性能是优越于AtomicInteger、AtomicLong这些的,提高4、5倍!

  • LongAdder
  • LongAccumulator

性能提升的原因很简单,就是在有竞争时,我们的AtomicLong向一个累加单元多次尝试,会降低效率,然而LongAdder设置多个累加单元,Therad-0累加Cell[0],而Thread-1 累加Cell[1]…最后将结果汇总。这样它们在累加时操作的不同的Cell变量,因此减少了CAS重试失败从而提高性能。

缓存伪共享行为

其中的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的第一个核需要操作a变量,第二个核需要操作b变量,表面看a和b是没有任何关系的,但是a和b在同一个cache line中,这样假设核心一修改了变量a的值,那么它将会刷新所有和a相关的缓存的数据,b变量也就会受到牵连,最后导致核心二再去缓存读取b变量的时候出现cache miss,需要重新到主存加载新数据,这就是所谓的false share(伪共享) !

我们可以用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流程图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LmA6WR0n-1638256002611)(JUC并发编程.assets/image-20211013225809268.png)]

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;
    }

猜你喜欢

转载自blog.csdn.net/m0_46571920/article/details/121633341