Java内存模型

一、Java内存模型简介

1. Java内存模型的“底层原理”

从Java代码到CPU指令的变化过程是怎样的?

  • 最开始,我们编写的Java代码,即 *.Java文件
  • 在执行编译Javac命令后,从刚才的*.Java文件会变出一个新的Java字节码文件,即*.class文件
  • JVM会执行刚才生成的*.class字节码文件,并把字节码文件转化为机器指令
  • 机器指令可以直接在CPU上执运行,也就是最终的程序执行

而不同的JVM实现会带来不同的“翻译”,不同的CPU平台的机器指令干差万别,所以我们在Java代码层写的各种Lock,其实最后依赖的是JVM的具体实现(不同版本会有不同实现)和CPU的指令,才能帮我们达到线程安全的效果。但是为了能在不同的 JVM 中,不同的CPU 上,同一段代码能达到同样的效果,这就需要一种规范,来屏蔽掉各种硬件和操作系统的内存访问差异,这时候就衍生出一种Java内存模型(Java Memory Model,JMM),它可以帮助我们实现让Java程序在各种平台下都能达到一致的内存访问效果。

2. JVM内存结构 VS Java内存模型 VS Java对象模型

  • JVM内存结构,是指Java虚拟机的运行时数据区域
  • Java内存模型,和Java并发编程有关。
  • Java对象模型,是指Java对象在虚拟机中的表现形式
(1)Java内存结构如下:

Java虚拟机运行时数据区图

  • 堆Heap
    整个内存占用最大的,内存占用最多的
    存放对象的实例对象
    运行时动态分配
  • 虚拟机栈(VM stack)Java栈
    保存基本数据类型
    保存了对象的引用
    编译时就确定了大小,在运行时这个大小不会改变
  • 方法区(Method Area)
    存储已加载的static静态变量
    类信息
    常量信息
    包含永久引用—>如新建一个由static修饰的Student类
  • 本地方法栈
    包括了native方法
  • 程序计数器
    占内存区域最小
    保存当前线程执行到的字节码的行号数,上下文切换的时候,也会被保存
    包括下次执行 指令、分支、循环等异常处理
(2)Java对象模型

对象自身在虚拟机中的存储模型,因为Java是面向对象的,所以每一个对象的存储都有一定的存储结构。

  • 首先针对一个Model类,会在方法区创建出类的信息,instanceKlass
  • 该类new出来的实例对象都会放到堆中,堆中的对象又分为对象头和实例数据两部分
  • 若对象被调用了,那就会在栈中保存这个对象的引用
(3)Java内存模型,JMM(Java Memory Model)

JMM是什么?
JMM: Java Memory Model,JMM是是一组规范,各种JVM的实现都需要遵守JMM规范,再加上CPU、编译器需要对该规范进行配合,使得开发者更方便地开发多线程程序

为什么需要JMM?
如果不存在JMM,比如 C 语言就不存在,这就只能依赖处理器本身的内存一致性模型,这样很多并发操作在不同处理器上运行结果不一样,无法保证并发安全,因此需要一个标准,让多线程运行在不同处理器上的结果都能达到预期。这个标准就是 JMM。

很多工具类的底层原理都是基于JMM实现的:

volatile、synchronized、Lock等的原理都是JMM,如果没有JMM,那就需要我们自己指定什么时候用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序。

**JMM最重要的三点内容:**重排序、可见性、原子性。

二、重排序

1. 重排序举例:

/**
 *  演示重排序的现象
 *      重排序不是100%发生,所以需要多次重复,直到达到某个条件才停止
 */
public class OutOfOrderExecution {
    private static int x,y=0;
    private static int a,b=0;

    public static void main(String[] args) throws InterruptedException {

        CountDownLatch latch = new CountDownLatch(1);

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //加上栅栏
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b = 1;
                y = a;
            }
        });


        thread2.start();
        thread1.start();
        //放开闸门
        latch.countDown();
        thread1.join();
        thread2.join();

        System.out.println("x:" + x + "," + "y:"+y);
    }
}

上面使用了 CountDownLatch 工具类,countDown()放开闸门,await()设置闸门,之所以使用,是因为线程thread1和线程thread2,他们的运行顺序会影响到最后的x、y的值,为了让两个线程里面的指令重排序,需要让两个线程的指令同时进行。

对于两个子线程,其实共有四行有效的代码,如下:

a = 1;
x = b;
b = 1;
y = a;

由于两个子线程的执行是并发的,有的执行快有的执行慢,所以初步推测有以下三种结果:

a=1;x=b;b=1;y=a;  最终结果是x=0,y=1   // 线程thread1先执行,thread2后执行
b=1;y=a;a=1;x=b;  最终结果是x=1,y=0   // 线程thread2先执行,thread1后执行
b=1;a=1;x=b;y=a;  最终结果是x=1,y=1   // 线程thread1和线程thread2交叉执行指令

实际执行结果为:

上面的分析都是默认同一个线程内的两行代码是按照顺序执行的,那有没有可能同一个线程中,下面的代码先执行,上面的后执行呢,也就是下列情况(以下都是代码执行顺序颠倒后出现的可能结果):

y=a;a=1;x=b;b=1; 最终结果是x=0,y=0
x=b;b=1;y=a;a=1; 最终结果是x=0,y=0
x=b;y=a;a=1;b=1; 最终结果是x=0,y=0

这里加上循环,来测试一下,只有当满足条件 x=0,y=0,才能跳出循环:

/******
 @author 阿昌
 @create 2021-05-28 22:23
  *******
  *  演示重排序的现象
  *      重排序不是100%发生,所以需要多次重复,直到达到某个条件才停止
 */
public class OutOfOrderExecution {
    private static int x, y = 0;
    private static int a, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int count = 0;//计数
        CountDownLatch latch = new CountDownLatch(1);
        for (; ; ) {
            count++;

            //数据重置
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    //加上栅栏
                    try {
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    a = 1;
                    x = b;
                }
            });

            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        latch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    b = 1;
                    y = a;
                }
            });


            thread2.start();
            thread1.start();
            //放开闸门
            latch.countDown();
            thread1.join();
            thread2.join();

            String result = "第"+count+"次 "+ "(x:"+x+", y:"+y+")";
             //修改代码部分
             //死循环结束条件
            if (x == 0 && y == 0) {
            	System.out.println(result);
            	break;
            } else {
            	System.out.println(result);
            }
        }
        
    }
}

可以看到,出现了这个结果:

这也就表示,发生了上面所说的,代码执行顺序颠倒了,两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,这就是重排序。

2. 可是 JMM 为啥要设置 重排序 呢?

这是因为重排序有一个好处,可以提高处理速度,比如下面这个例子:

(1) 没有发生重排序时,对于左边的三行代码,右边给出了 cpu 的指令顺序

(2)进过重排序后的指令的优化情况

减少了对a的读取和对a的写入指令的次数:

3. 发生重排序的3种场景

  • 编译器优化:包括JVM,JIT编译器等
  • CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排序。
  • 内存的“重排序”:线程A对某个变量修改之后,线程B在读取时依然读取的是修改之前的值,这表明上看是代码执行顺序颠倒的问题(线程B以为线程A还没执行),实际上下节要讲的可见性问题。

三、可见性

1. 案例展示

/**
 *      演示可见性带来的问题
 */
public class FielidVisibility {
    int a = 1;
    int b = 2;

    private void change() {
        a=3;
        b=a;
    }
    
    private void print() {
            System.out.println("b:"+b+",a:"+a);
    }

    public static void main(String[] args) {
        while (true){
        FielidVisibility test = new FielidVisibility();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                test.change();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                test.print();
            }
        }).start();

        }
    }

}

两个子线程共有三行代码,如下:

a=3;
b=a;
System.out.println("b:"+b+",a:"+a);

初步推测可能的执行结果如下:

a=3;b=a;System.out.println("b:"+b+",a:"+a);  最终结果是 b:3,a:3
a=3;System.out.println("b:"+b+",a:"+a);b=a;  最终结果是 b:2,a:3
System.out.println("b:"+b+",a:"+a);a=3;b=a;  最终结果是 b:2,a:1
b=a;System.out.println("b:"+b+",a:"+a);a=3;  最终结果是 b:1,a:1   // 这个是指令重排序的结果,几率比较低,这里不做考虑

即最终结果只有四种,不太可能出现 b=3,a=1 的情况。经过多次尝试,发现实际结果中出现了 b=3,a=1

那这是为什么呢?

这里就要涉及到主内存和本地内存的概念了,因为当第二个线程读取到b=3后,如果第一个线程还没有把a=3这个值从本地内存同步到主内存时,第二个线程获取到的a就是初始值1。这就是线程的可见性问题造成的。

怎么解决这个问题呢?

可以使用 volatile 关键字修饰变量,强制每次线程被修改后都会立即被其他线程可见。

/**
 *      解决可见性问题方案:使用 volatile
 */
public class FielidVisibility {
    volatile int a = 1;
    volatile int b = 2;

    private void print() {
        System.out.println("b:"+b+",a:"+a);
    }

    private void change() {
        a=3;
        b=a;
    }
    
    public static void main(String[] args) {
        while (true){
        	FielidVisibility test = new FielidVisibility();
        	new Thread(new Runnable() {
            	@Override
            	public void run() {
              	  try {
              	      Thread.sleep(1);
              	  } catch (InterruptedException e) {
              	      e.printStackTrace();
             	   }
            	    test.change();
           	 }
      	 	}).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();

        }
    }

}

以上代码无论执行多少次,都不会再出现 b=3,a=1 的结果了。因为 第一个线程在对 a 和 b 的值修改之后,第二个线程读取这俩变量时,volatile 会将修改后的值强制同步到主内存中,保证了这俩变量的可见性。

2. 什么是可见性:

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

为什么会有可见性问题:

CPU有多级缓存,如果缓存cache没有及时同步到主内存,就可能导致其他线程读取的数据是过期的。之所以使用缓存,是因为执行速度快,仅次于寄存器,在CPU和主内存之间加了Cache层,可以更高效的读取数据。

最主要的原因是:

线程间的对于共享变量的可见性问题不是直接由多核CPU引起的,而是由多层缓存引起的。如果所有的cpu都只用一个缓存,那么也就不存在内存可见性问题。每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些cpu核心读取的值是一个过期的值。

(1)什么是主内存&本地内存

Java 作为高级语言,屏蔽了这些底层细节,用JMM 定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM 抽象了主内存和本地内存的概念,这里说的本地内存并不是真的是一块给每个线程分配的内存,而是对于寄存器、一级缓存、二级缓存等的抽象。

线程工作在WorkingMemory中,他不与主内存直接沟通,而是通过Buffer缓冲区与主内存进行同步,线程间的交互最终也就是通过主内存实现的;

(2) 主内存和本地内存的关系:
  • 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;
  • 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中;
  • 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成;

总结来说,所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

(3) 能保证线程可见性的措施
  • 除了volatile可以让变量保证可见性外,synchronized、Lock、并发集合、Thread.join0和Thread.start0等都可以保证的可见性;
  • 支持 happens-before原则的规定

3. happens-before原则

**什么是happens-before原则 **:先行发生原则,动作A发生在动作B之前,B保证能看见A,这就是happens-before。

**什么不是happens-before原则:**两个线程没有相互配合的机制,所以代码X和Y的执行结果并不能保证总被对方看到的,这就不具备happens-before。

happens-before的作用:

影响JVM重排序。如果两个操作不具备happens-before,那么JVM是可以根据需要自由排序的,但是如果具备happens-before(比如新建线程时,run方法里面的语句一定发生在thread.start()之前),那么JVM也不能改变它们之间的顺序。

(1) happens-before原则具体有哪些体现呢?
  • 单线程原则:若是单线程执行,那么后面执行的语句肯定能看到前面执行的语句做了的行为结果,这里的前后是指执行顺序,不是java文件的代码顺序,因为文件中的代码顺序有可能被重排序打乱。

  • 锁操作(synchronized 和 Lock)

    线程B在加锁的时候,能看到线程A解锁之前的所有操作。

    synchronized 介绍>>:synchronized 关键字

  • volatile变量

    该变量只要有写入,后续的读取都能看到。

    volatile 介绍>>:volatile 关键字

volatile有一个特性:近朱者赤。他不仅可以帮助自己可见性,也可以帮助在他进行赋值之前进行的操作也具有可见性

/**
 * 描述:     演示可见性带来的问题
 */
public class FieldVisibility {

    volatile int a = 1;
    volatile int b = 2;

    private void change() {
        a = 3;
        b = a;
    }


    private void print() {
        System.out.println("b=" + b + ";a=" + a);
    }

    public static void main(String[] args) {
        while (true) {
            FieldVisibility test = new FieldVisibility();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }

    }
}

此时 不仅 b 保证了可见性,a 也由于 b 的 volatile,也保证了可见性。

  • 线程启动

  • 线程join

使用了 join ,主线程会等待one、two两个子线程执行完,再执行

  • 传递性

如果第一行代码的运行结果能被第二行看到,第二行的运行结果能被第三行看到;那么第一行运行的结果就能被第三行看到。

  • 中断

一个线程被其他线程interrupt时,那么检测中断(isInterrupted)或者抛出InterruptedException一定能被其他线程看到。

就是说,A被中断了,那么B线程就能因可见性而看到A被中断了。

  • 支持happen-before 原则的工具类

线程安全的容器get一定能看到在此之前的put等存入动作CountDownLatch线程池CyclicBarrier
CountDownLatch
Semaphore
Future
线程池
CyclicBarrier

四、原子性

1. 什么是原子性:

是指对于一系列操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分割的。

2. Java中的原子操作有哪些?

  • 除了long和double之外的基本类型(int、byte、boolean、short、char、float)
  • 所有引用reference的复制操作,不管是32位的机器还是64位的机器
  • java.concurrent.Atomic.*包中所有类的原子操作。

3. long和double的原子性

long和double所占的都是64位,所以在 32 位的 JVM 中,他会被写入两次,第一次32位,第二次32位,因此就不具备原子性,但是在64位的JVM上是原子的。不过在实际开发中无需考虑这个问题,商用Java虚拟机中已经考虑到,默认保证了long和double的原子性。

4. 原子操作+原子操作 != 原子操作

简单的把原子操作组合在一起,并不能保证整体依然具有原子性。比如一个操作组合:先取值,然后再赋值;如果是这两个操作都是原子性的,但是两个操作合在一起,就不是原子性,不能保证线程安全,所以需要进行额外的保护。

文章来源:Java内存模型

个人微信:CaiBaoDeCai

微信公众号名称:Java知者

微信公众号 ID: JavaZhiZhe

谢谢关注!

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值