天天看点

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

文章目录

  • java运行时数据区域
    • 程序计数器
    • Java 虚拟机栈
    • 本地方法栈
    • 方法区
    • 直接内存
  • java内存模型
    • 概述
    • 举个例子
  • java内存模型的特性
    • 原子性
    • 可见性
    • 有序性
    • JMM如何保证3个特性
  • 总结

java内存模型是java多线程编程中一个很重要的专题,啃透这方面的知识无论是对日常开发还是个人成长都有很大的帮助。

java运行时数据区域

在学习内存模型前,我认为有必要先了解java运行时数据区域。所谓java运行时数据区域,就是java虚拟机在运行程序的时候,会把内存划分为几个不同的区域,每个区域存放不同类型的数据。

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

以上是java运行时数据区域的示意图,需要注意的是虚线框内的区域是线程私有的,也就是下面要说的线程工作内存,里面的数据只能由所属线程访问,其他线程不能访问(先有个概念,这里很重要)。

程序计数器

属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

Java 虚拟机栈

属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常

本地方法栈

本地是英文native翻译过来的,对应的就是java中一些用native标记的方法,即JNI(java native interface),这些方法是一般是用C/C++编写的。本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务,虚拟机栈为java方法服务(平常的方法),这部分也属于线程私有的。

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

所有对象都在这里分配内存,是垃圾收集的主要区域,所以也称为“GC堆”。堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。这部分是线程共享的区域,即所有线程都可以访问这里的数据。

方法区

方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。

直接内存

在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。

注意

上面都是一些概念性的知识,了解它有助于理解下面的内容,至于这些区域具体存放的哪些数据,比如方法区介绍的是存放类信息、常量等,那具体是类的哪些信息?常量跟java中的final常量是同一个概念?这些以后再去学习总结,大家也可以自行查阅资料学习,今天的重点是java内存模型。

java内存模型

概述

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为线程栈),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

需要注意的是,JMM与java运行时区域的划分是不同的概念,JMM定义的是一套规则,通过这套规则来控制变量的访问,JMM主要是围绕原子性、可见性、有序性(下面会分析这些特性进行)展开的。而java运行时区域的划分就是把内存划分为几个部分,每个部分存放特定的数据。

JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。关于JMM中的主内存和工作内存说明如下

  • 主内存

    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工作内存

    主要存储的是方法仲的成员变量,而且这些成员变量一定要是java中的基本类型( boolean, byte, short, char, int, long, float, double),而对于像实例对象、静态变量等,在工作空间中都是存储一个指向对象实例的引用(这个实例对象一定是储存在主内存中的,不管这个对象是类的成员变量还是方法中的局部变量)。

举个例子

说了那么多,究竟主内存和工作内存储存的是什么样的数据?可能有些人还不是很清楚,还不如用例子说明。

public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;
        MySharedObject localVariable2 = MySharedObject.sharedInstance;
        //... do more with local variables.
        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);
        //... do more with local variable.
    }
}
           
public class MySharedObject {
    public static final MySharedObject sharedInstance = new MySharedObject();
    
    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member1 = 67890;
}
           

假设两个线程执行的都是这段代码,则对应的内存模型如下图所示。

深入理解java内存模型java运行时数据区域java内存模型java内存模型的特性总结

下面分析一下其过程。

  • 在run()方法中,线程首先调用methodOne(),对应的就是进栈,这里有两个线程执行,对应图中的两个Thread Stack,即工作空间。
  • 在methodOne()中,定义了一个局部变量localVariable1,而且是int类型的,即java的基本类型,所以这个局部变量就直接存储在工作空间中,而且每个线程都有一个备份,属于线程的私有变量。
  • 接着定义了一个局部变量localVariable2,它是一个对象,所以它肯定是存储在主内存中的,localVariable2 = MySharedObject.sharedInstance,而MySharedObject.sharedInstance是MySharedObject类中的静态变量,只有在类加载的时候执行一次,所以主内存中只有一个实例对象,对应图中Object3,每个线程的私有空间中存储的都是指向Object3的引用,当线程要访问它里面的数据时,就是访问主内存的数据,可能会出现线程安全问题。
  • 在MySharedObject 中,定义了两个Integer的成员变量object2和object4,因为他们都是对象,所以也是存储在主内存中,可以看到图中Object3分别指向Object2和Object4,线程通过Object3的引用也可以访问Object2和Object4。
  • 在MySharedObject 中,还定义了两个long类型的成员变量,虽然是java的基本类型,但它们不是方法中的局部变量,所以也是在主内存中(在Object3里)。
  • 之后就调用methodTwo(),线程就跳转去执行methodTwo()的代码,对应的是methodTwo()方法进栈。
  • 在methodTwo() 中new了一个Integer的对象,对象肯定存储在主内存中,因为两个线程各创建了一个对象实例,所以工作内存中保存的是指向不同实例的引用。

总结

到这里大家应该有更深的了解了吧,总的来说,线程的工作内存只把方法中的局部变量而且是基本类型的变量复制一份进行存储,其他的变量都是存储一个引用,这个引用指向主内存中的变量。然而,当访问主内存的特定变量时,线程还是会复制一份在工作空间中,即读取主内存变量的值,只不过这个备份是临时的,当操作完成后就会写回到主内存中。

java内存模型的特性

JMM主要是为了解决线程安全的问题,而线程安全问题是由原子性、可见性、有序性引起的(不同时确保3个特性就会有线程安全的问题),先了解一下这3个特性。

原子性

原子性指的是一个操作是不可中断的。一个操作从开始到结束,这个过程不会受其他线程影响,即其他线程不能中断该线程正在进行的这个操作,那么,这个操作就具有原子性。java内存模型保证了基本类型byte,short,int,float,boolean,char(不包括long和double)的读写是原子操作。但是在32位系统中,Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即不具备原子性。值得注意的是,保证基本类型如int的读写是原子操作,是指对int类型的读是原子操作,对int类型的写也是原子操作,但读和写放在一起就不是原子操作了。很多人认为int类型不会出现线程安全问题,其实就是理解错了。

虽然基本类型的读+写不具备原子性,但java提供了他们对应的原子类Atomicxxx,如AtomicInteger,这种类型能保证线程对变量操作的原子性(读→操作变量→写),用这些类能解决一些线程安全的问题。

可见性

可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

可以认为,可见性是由原子性引起的,正因为线程对变量的操作没有原子性,导致了其他线程对这个变量的不可见问题。举一个对int类型读写的例子:例如x=1,线程A执行x + 5,首先读取x的值,然后加5,此时在线程A里,x的值是6,但由于读写不具备原子性,在线程A写回主内存之前,线程B读取x的值,在主存中x还是1,这时就说线程A中x的值对线程B不可见。

可以用volatile关键字修饰变量,保证变量的可见性。

有序性

理解指令重排序

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

  • 编译器优化的重排

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行的重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统的重排

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

这里只介绍编译器优化重排,只需要知道其他重排都是为了优化性能和CPU利用率,这种重排在单线程环境下没问题,但在多线程环境下,会引起线程安全问题。

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}
           

这里举一个例子,假如线程A调用writer()方法,线程B调用read()方法,正常的逻辑下,线程B执行时,如果判断flag为true,那证明线程A已经执行了a=1这个操作,因为a=1在flag=true前面,所以线程B计算出来的i值应该是2,这是正常的逻辑。

然而,出于某种原因,编译器认为先执行flag = true 再执行a = 1性能会得到优化,而这种重排序对线程A是没有影响的,因为两句代码没有依赖的关系。这样就有可能出现一种情况:当线程A执行了flag = true后,被线程B中断,线程B判断为true,并计算i的结果,这是a的值还是0(因为编译器重排序了执行顺序,a = 1还没有执行),所以i = 1,跟我们正常的逻辑不一样,这就是重排序导致的线程安全问题。

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

JMM如何保证3个特性

  • 原子性

    对于原子性,除了上面所说的JVM保证基本类型的读写原子性、AtomicInteger等原子类之外,对于方法或代码块级别的原子性,可以用synchronized关键字或ReentrantLock类来保证。

  • 可见性

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

  • 有序性

    可以用volatile关键字解决,因为volatile的另一个作用是禁止重排序优化。

volatile作用详解

  • 保证可见性

对于用volatile修饰的变量,线程每次读取都会在主内存中读取,当这个变量的值发生变化时,立刻写回主内存,保证其他线程可见。

  • 禁止指令重排序

    这一点在一道非常经典的面试题中有很好的体现,如下面的单例模式所示,面试官可能会问你这是线程安全的吗?

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

乍一看,双重校验锁,既保证了线程安全,又确保了效率,于是很自信地回答:“通过synchronized关键字保证只有一个线程可以创建实例,又通过双重校验提高多线程下的效率,这是完美的线程安全的单例模式。”这时,在你以为自己说出了其优点的时候,面试官可能就在你的简历上打了个叉。

其实这里并非线程安全的,原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。什么意思?

instance = new DoubleCheckLock();

可以分3步完成。

//1.分配对象内存空间
 //2.初始化对象
 //3.设置instance指向刚分配的内存地址,此时instance!=null
           

步骤2和步骤3有可能重排序:

//1.分配对象内存空间
 //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
 //2.初始化对象
           

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

//禁止指令重排优化
  private volatile static DoubleCheckLock instance;
           
  • volatile关键字不能保证线程安全

    volatile只是保证了变量的可见性和禁止重排序,但没有保证原子性,不同时确保这3个特性,就有可能发生线程安全问题。假设有两个线程对下面的变量进行

    x++

    操作

因为x++不是原子操作,它至少分为3步(读取x的值,x=x+1,把x写回主内存),我们再回想volatile对可见性的保证(线程每次读取都会在主内存中读取,当这个变量的值发生变化时,立刻写回主内存,保证其他线程可见),注意,是当变量发生变化时,线程才会立刻写回主内存,在这个例子中相当于把x=x+1、将x写回主内存两个操作合并变成原子操作,但是读和写还是分开的,也就是读写一样不是原子操作。想象一种情况:线程A读取x的值,还没加1时,线程B就抢夺CPU读取x的值,此时还是0,进行加1操作后写入主内存(现在主内存x=1),线程A已经读取过x的值了(在线程A中为0),加1后写入主内存,还是1,所以两个线程各进行了x++操作,理想情况下x=2,现在却为1。

总的来说,volatile没有确保原子性,有可能出现线程安全问题。

happens-before 原则

除了sychronized和volatile关键字等来保证原子性、可见性以及有序性之外,java内存模型还提供了一套happens-before 原则来辅助保证这些特性。happens-before 原则内容如下:

  • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
  • 传递性 A先于B ,B先于C 那么A必然先于C
  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法。

总结

以上就是java内存模型的具体内容,学习它可以让我们在开发中更好地应对多线程编程,出现bug时也可从内存模型层面去考虑和排查。

继续阅读