首先注意区分JVM内存结构Java内存模型

前者是jvm为了管理内存,对内存区域的一个划分,分为

线程共享的:堆+元空间

不共享的:本地方法栈+虚拟机栈+程序计数器

参考:

后者是为多线程通信而设计的模型 Java Memory Model(JMM)

内存模型

概念

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范

内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障

缓存一致性

学过计算机体系结构的都了解,在cpu和内存(RAM)之间,还有cache,最近刚装了台主机玩游戏,选用的是Intel 酷睿i5 9400F,它的参数是这样的:

java 内存模型图 java内存模型设计_java 内存模型图

可以看出非常小但是速度却很快,其依据也是28原则。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。

单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存

java 内存模型图 java内存模型设计_Java_02

单核CPU,多线程。进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。

多核CPU,多线程。每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

指令重排

为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理

Java内存模型

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

首先解释下原子性、可见性(缓存一致性)以及有序性

原子性

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

可见性

在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

有序性

除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。

下面介绍java处理以上三个问题的做法:

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

这个设计很像cpu,cache和memroy之间的关系,对比下列两个图:

java 内存模型图 java内存模型设计_java 内存模型图_03

java 内存模型图 java内存模型设计_缓存_04

主内存与工作内存的交互协议通过Java内存模型中定义的8中操作来完成:lock,unlock,read,load,user,assign,store这些交互操作使用有一定规范,比如不允许read和load。store和write操作之一单独出现……。这里我没有细究,需要研究请参考书中内容。总之通过对内存的划分,操作的定义和使用规则限定保证了原子性

volatile

这个关键字有两个作用:

一是保证此变量对所有线程的可见性(修改了变量的值对其它线程立即得知)

二是禁止指令重排序优化

看一个bad case

public class VolatileTest {

    private static volatile int a = 0;

    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        for (int i = 0; i < 20; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        a++;
                    }
                }
            });
            threads[i].start();
        }

        System.out.println(a);
    }
}

结果总是小于20*100的,因为volatile这个关键字不能保证原子性,而a++这个操作并不是原子的,具体可以看书上的解释,因此volatile的使用场景是能够确保只有单一的线程修改变量的值

使用volatile指令重排的特性,最常见的就是单例模式

public class Singleton {  
    private static volatile Singleton instance = null;  
    private Singleton(){}  
    public static Singleton getInstance() {  
        if (instance == null) {  
            synchronized (Singleton.class) {  
                if (instance == null) {  
                    instance = new Singleton();  
                }  
            }  
        }  
        return instance;  
    }  
}

synchronized

synchronized保证了原子性,可见性和有序性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码,在Java中对应的关键字就是synchronized。

除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作

使用

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结

 

原子性

可见性

有序性

volatile

0

1

1

synchronized

1

1

1

参考

《深入理解Java虚拟机》

https://www.hollischuang.com/archives/2550