并发编程基础——JMM简介

版权声明:版权没有,盗用不究 https://blog.csdn.net/liman65727/article/details/82928561

前言

这篇博客尝试针对JMM模型进行总结,并分析volatile和synchronized的一些原理(理解的并不深入)

JMM内存模型

在谈JMM内存模型的之前,得先了解JVM内存模型。

JVM内存模型

JVM在运行程序的时候将自动管理的内存划分为几个区域。从总体上看主要分为两大类,线程共享的数据区域和线程私有的内存区域。

图中右侧代表的是所有线程共享的数据区域,左侧代表的是每个线程私有的数据区域

方法区:

方法区属于线程共享的内存区域,称为非堆(Non-Heap) ,主要存储JVM加载的类信息,常量,静态变量,即时编译器编译后的代码等数据,根据JVM规范,当方法无法满足内存分配需求的时候,将抛出OutOfMemeoryError异常。在方法区中同时还存在一个运行时常量池的区域,用于存放编译器生成的各种字面量和符号引用。常量池的存在只是为了方便程序运行时使用一些常见的符号和变量。

JVM堆:

线程共享内存区域,JVM启动时创建,用于存放对象实例,几乎所有的对象实例都在这里分配内存,Java堆是垃圾收集器管理的主要区域,因此还有另外一个名字——GC堆,堆中没有内存分配实例,而且也无法扩展空间,将会抛出OutOfMemoryError的异常。

程序计数器:

线程私有的数据区域,记录下一条需要执行的指令的位置,这个和计算机组成原理中的概念一样。

虚拟机栈:

线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。栈帧中会存储对象在堆上的地址。

本地方法栈:

线程私有的数据区域,这个和native方法有关。

总览

先上一张图,JMM其实本身是一种抽象的概念,并不真实存在,它描述的是一种规范。JVM运行程序的实体是线程,每个线程创建的时候都会为其创建一个工作内存,用于存储线程的私有数据,而Java内存模型中规定所有的变量都存储在主内存,主内存是所有线程共享的,所有线程共享。线程对变量的操作必须在工作内存中完成。首先要将内存读取到自己的工作内存空间,然后再对变量进行操作,操作完成后再将变量写会主内存。同时不同线程间的工作内存是隔离的。线程间的数据通信必须通过主内存来完成。 图中还标记了8个原子操作。(图片来自其他博客)

主内存:

主要存储的是Java实例对象,所有线程创建的实例对象都存在于主内存中,不管该实例对象是成员变量还是方法中的本地变量,同时也有类的信息、常量、静态变量。

多个线程同时访问主内存会引发线程安全问题

工作内存:

主要存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,即线 程中的本地变量堆其他线程是不可见的。每个线程都有自己的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

主内存与工作内存的数据存储类型以及操作方式:

根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含的本地变量是基本数据类型,将直接存储在工作内存的栈帧中。如果本地变量是引用类型,那么该变量的引用会存储在功能内存的栈帧中,而对象示例存储在主内存中。

对于一个实例对象的成员变量,不管它是基本数据类型或者包装类型还是引用类型,都会存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中(方法区)。

在JVM内存模型中,主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作之后才刷新到主内存,示意图如下(依旧盗的大牛的图)

JMM内存模型的必要性

由于线程在操作数据的时候,会先将数据读取到自己的线程私有内存中,然后再对数据进行操作之后再将数据写到主内存中,这个过程会造成线程安全的问题。为了解决线程安全问题所以才提出了JMM内存模型。

线程安全问题

线程安全问题其实是一个大的问题,总体分为三个方面——原子性、有序性、可见性。

原子性

原子性这个知道怎么回事就行,和数据库原子性的概念一致

有序性

有序性是指对于单线程的执行代码,并不完全按照顺序依次执行,对于多线程环境会出现乱序现象,因为程序编译成机器码指令后会出现指令重排现象,重排后的指令与原指令的顺序未必一致。在多线程环境下,也可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令执行顺序并不一致。

指令重排一般分为三种:编译器优化的重排,指令并行的重排,内存系统的重排。其中编译器优化的重排属于编译器重排,后两者属于处理器重排。多线程环境中指令的重排会出现很严重的问题。

指令重排实例

package com.learn.ThreadLuan;

/**
 * autor:liman
 * mobilNo:15528212893
 * mail:[email protected]
 * comment:
 *      指令重排序的实例
 */
public class ReOrderDemo {

    private static int x = 0,y = 0;
    private static int a = 0,b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while(true){
                a = 1;
                x = b;
            }

        });

        Thread t2 = new Thread(()->{
            while(true){
                b = 1;
                y = a;
            }
        });

        t1.start();
        t2.start();
        while(true){
            System.out.println("x = "+x+"->y="+y);

        }
    }
}

执行结果并不唯一

由于编译器优化重排的存在,两个线程中使用的变量能否保证一致是无法确定的。处理器重排是对CPU的性能优化。

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

JMM的解决方法

除了JVM自身提供的对基本数据类型的读写操作的原子性外,对于方法级别的原子性可以用synchronized关键字和可重入锁来解决。

对于工作内存与主内存同步延迟现象导致的可见性问题,也可以使用synchronized和volatile关键字来解决。两者都会使一个线程修改后的变量立即对其他线程可见。

对于有序性问题可以使用volatile关键字解决,因为volatile关键字的另一个作用就是禁止重排序优化

除了上述解决方案之外,还有happens-before原则用于保证多线程环境下两个操作间的原子性、可见性和有序性。

happen-before原则简单理解

1、程序顺序原则:一个线程内必须保证语义串行性

2、锁规则:解锁操作必须在加锁操作之后

3、volatile规则:volatile变量的写必须先发生于读

4、线程启动规则:start方法先于它的第一个动作,即如果线程A在执行线程B的start的方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。

5、线程终止规则:在线程终止操作之前线程的操作都已经处理完成。

6、线程中断规则:interrupt方法调用先发生于被中断线程的代码检测到中断事件的发生。

7、对象终结规则:对象的构造函数执行,结束先于finalize方法

volatile关键字

保证可见性

需要明确的是volatile并不确保原子性,可以保证有序性和可见性。当写一个volatile变量的时候,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从内存中重新读取共享变量。

禁止指令重排

volatile的另一个作用就是禁止指令重排,说到指令重排需要了解内存屏障这个概念。

内存屏障(Memory barrier)也叫内存栅栏,其实质是一个CPU指令,作用主要有两个:1、保证特定操作的执行顺序,2、保证某些变量的内存可见性(这就是volatile能保证可见性的原因)。在编译器或CPU指令重排序优化的时候,如果在指令间加入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,既在内存屏障前和内存屏障后的指令不能进行重排序。

Memory Barrier的另一个作用就是强制刷出CPU的缓存数据,任何CPU上的线程都能读到缓存中的最新版本。

单例模式的经典实现方式——DCL

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

其实instance = new DoubleCheckLock();这一步实质可以分为三步,

1、分配对象内存空间

2、初始化对象

3、设置instance指向刚分配的内存地址,此时instance!=null

第2步和第3步不存在数据依赖关系,因此这两条指令其实是可以重排的(单线程中是不影响语义的,所以不会有问题),如果第3步在第2步之前执行,当一个线程进入代码的时候,发现instance不为null,但是这个时候instance并为初始化完成,这也就出现了线程安全问题。

这个问题可以通过volatile解决

private volatile static DoubleCheckLock instance;

总结

文章简单介绍了JMM模型,JMM模型其实就是一组规则,这组规则在解决并发的时候可能出现的线程安全问题,提供了一系列的内置解决方案。

参考资料:

文中大部分内容参考了这篇博客:https://blog.csdn.net/javazejian/article/details/72772461

猜你喜欢

转载自blog.csdn.net/liman65727/article/details/82928561