Java虚拟机(二):Java内存模型

1 基本概念

在上一篇文章Java内存区域 中,我们讲了JVM为了更好的管理内存,将Java进程的内存划分成了几个功能、用途不同的区域,所以很多人会认为划分后的内存布局就是Java内存模型。严格来说,这个说法是不准确的,不过大家在交流的时候直接说成内存模型好像也无伤大雅。那究极什么才是严格意义上的Java内存模型呢?

Java内存模型(Java Memory Model,简称JMM)本身是一个抽象的概念,不是真实存在的,它描述的是一组规则,Java内存访问内存都需要遵循这组规则。在深入了解之前,我们先来看看JMM里有几个基本概念:

  • 工作内存。由于Java程序是单进程程序,故Java并发大多值的都是线程级别的并发,即线程是程序执行的最小单位。JMM规定了每个线程都有一个属于自己的工作内存,线程对本地局部变量的操作都直接在工作线程上执行,而对共享变量的操作需先从主内存(马上就介绍主内存)中拷贝一份到工作内存,然后在工作内存中对该变量进行操作,完成之后再写回主内存。
  • 主内存。主内存主要存储的是实例对象,所有线程创建的实例对象都存储在主内存中(这句话在新版本的Java中会不太准确,因为在新的JVM中,有些特殊情况会使得对象实例被分配在栈上,成为线程私有的实例对象)。主内存还包括了一些常量,静态变量,类的元信息等,总之,主内存就是被多个线程共享的内存。

其大致结构可以看看下图:

借用了CSDN博主@zejian_ 的图片

根据虚拟机规范,对于一个实例的方法,如果该方法包含的本地变量(包括参数)是基本数据类型,那么对应的值将被存储在工作内存中(也可以理解为在虚拟机栈帧中),如果是引用类型,那么引用本身也会被存储在工作内存中,而其指向的实例对象会被存储在主内存中,被各个线程共享。而对于实例的字段,无论是基本类型还是引用类型,都会直接存储到主内存中。如下图所示:

了解了上述内容,我们知道实例对象在JMM的控制下是存储在主内存的,也就是被多个线程共享的,每个线程要操作对象就必须拷贝一份到工作内存中,然后进行操作,最后再写回内存。我想说到这了,大家不难看出这就是导致线程安全问题的原因,关于线程安全问题,我的博客里有一些文章,大家可以去看看。

2 为什么要有Java内存模型

JMM只是一组规则,并不是真实存在的。即使上面的图画得再漂亮,在底层都只是一块内存(即使现在的个人计算机都能插多个内存条,但是操作系统还是把他们抽象成一整块连续的存储)。那这组规则是什么呢?上面我提到过JVM把内存划分成了可共享区域和线程私有区域,这回导致线程安全问题,JMM的存在就是为了解决这个问题。

这里我不得不再次说一下:JMM只是一组规则,JVM将内存划分为几个区域,分为线程私有的和线程共享的区域,而作为工作内存和主内存其实还是这些区域,也就是说它们主内存、工作内存和方法区、虚拟机栈、程序计数器、本地方法栈、堆等式有交叉关系的。JMM之所以再做抽象,分为主内存和工作内存,主要目的就是为了更好的描述这组规则。

JMM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见。JMM是围绕程序执行的原子性,可见性和顺序性来展开的,下面我们来一一分析。

2.1 原子性

原子性指的是一个或者一组操作即使在多线程环境下也不可被中断,一旦开始就不能被其他线程所影响,即一旦操作开始,那么直到该操作结束,CPU都不可以被其他线程占用。这是一个很重要的特性,至于如何做到,我这里简单大致的说一下:我们知道线程的执行是需要CPU做调度的,引发线程切换的因素有多个,例如当前线程时间片用完了、被阻塞了等等,但总归来说,他们被切换的根本原因就是发生了中断,所以,我们可以在操作准备开始的时候,屏蔽中断,结束的时候再打开中断,这样就实现了原子性。

我想,通过上面的描述应该不难理解原子性对于线程安全的作用了吧。通过保证操作的原子性,就可以避免其他线程的干扰,从而保证线程安全。例如JDK里有java.util.concurrent.atomic包,该包下有很多Atomic打头的类,通常我们称作“原子类”。使用这些类可以很轻松的解决一部分线程安全问题,例如在并发环境下做计数:

public class Counter {
    
    //使用原生类型,在并发环境下会发生线程安全问题
    //private static int counter = 0;
    
    //使用原子类可以保证线程安全
    private static final AtomicInteger counter = new AtomicInteger(0);
    
    public void addCount() {
        counter.getAndIncrement();
    }
}

除了使用“原子类”,一般还使锁来保证原子性,线程执行操作之前需要先获取锁,操作完成之后需要释放锁。在Java里,锁有内置锁和显式锁,内置锁就是synchronized,这是一个可重入锁,同一线程不需要重复获取锁。显式锁就是 java.util.concurrent.locks 包下的相关类,例如 ReentrantLock,ReadWriteLock ,ReentrantReadWriteLock等。

2.2 可见性

可见性即当一个线程修改了某个共享变量的值,其他线程能够马上得知这个修改的值。对于串行程序,可见性是没什么意义的,因为单线程环境下程序是顺序执行的(在并发环境下,每个线程执行也是顺序执行的,但是因为时序的问题,所以整体看起来就不是顺序执行的了),不存在修改无法得知的情况。Java提供了volatile关键字来保证可见性,在这里先不说,文章后面会详细讲到volatile。

2.3 顺序性

在可见性那里提到过一些,顺序性指的程序的执行是按照顺序有序执行的。在单线程环境下,确实如此,没有毛病。但是到多线程的环境下,对于每个线程自己来说,自己本身确实是顺序执行的,这也没毛病,但是如果一个线程观察另一个线程,那么所有的操作都是无序的。

2.4 happens-before原则

除了使用锁来保证原子性和使用volatile之外,在JMM中,还提供了happens-before原则来辅助我们。我们可以在happens-before前后加一些词语来修饰,这样会便于理解。即“在同一个线程中,书写在前面的操作happens-before书写在后面的操作”。happens-before原则共有8个,如下:

  • 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
  • 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
  • volatile的happen-before原则:对一个volatile变量的写操作happen-before对此变量的任意操作(当然也包括写操作了)。
  • happen-before的传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
  • 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
  • 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
  • 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
  • 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。

happens-before原则主要是辅助我们判断代码是否是线程安全的,如果以上原则任何一个都不满足就意味着我们应该重新审视一下代码,并作出想应修改来保证代码在并发环境下的线程安全。关于happens-before更多的解释,网上有不少好文章,在此不再赘述。

3 volatile

现在来看看volatile关键字,在Java并发程序中,经常能看到volatile的身影,但也容易被滥用。volatile是JVM提供的轻量级同步机制,主要有两个作用:

  • 保证可见性,当一个线程修改了有volatile修饰的变量,这个修改会立刻反应到主内存中,换句话说,当其他线程访问该变量时,总是会得到新的值。
  • 禁止指令重排。

3.1 保证可见性

可见性上面已经说过了,在此说说访问volatile的流程,理解了流程,就能理解为什么volatile能保证可见性了。我们知道,每个线程都有自己的工作内存,操作变量的时候需要先到主内存复制一份拷贝到工作内存中,完成操作后再写回主内存,在线程写回主内存之前,其他线程是无法得知修改的,这就造成了其他线程有可能读取到的值是一个过期无效的值,从而导致线程安全问题。而有volatile修饰的变量稍有不同,线程在对volatile变量进行修改的时候,完事之后会立即刷新到主内存中,其他线程读取的时候也会被迫去主内存中取值。从宏观上看,就好像其他线程能看到当前线程修改之后的值一样,这就保证了可见性。

3.2 禁止指令重排

指令重排是编译器的优化操作,编译器可能会对一些没有依赖关系代码做重新排序,导致编译后的代码和我们编写的代码顺序上有一些差异(说到这,我想起了JS的变量提升,将一些没有依赖关系的变量声明提升到代码顶端),如下所示:

int x = 1; //1
System.out.Println(x); //2
int y = 2; //3

如果允许编译器做指令重排,那么编译后的代码顺序可能是下面这样的:

int x = 1;  //1
int y = 2; //3
System.out.Println(x); //2

这就是Java的指令重排。

为什么需要做指令重排呢?编译器既然做了,就肯定是有原因的,要不然费这劲干哈!因为重排序之后有利于指令的执行,从而提供程序的性能。CPU执行指令是采用流水线的方式,这种方式可以提高CPU的利用率,在CPU执行指令的时候有可能会因为依赖关系而出现“停顿”,这将使得CPU在这个时钟周期内无事可干,导致CPU的利用率降低,程序总体性能会受到影响。指令重排后,会处理这些依赖关系,最终会减少CPU停顿次数,最好的情况下完全消除停顿,使得CPU利用率最大化。关于指令重排的更加详细的解释,可以看看全面理解Java内存模型(JMM)及volatile关键字 这篇文章的指令重排部分,该博主解释的非常好,清晰易懂,推荐多多关注。

在单线程环境下,指令重排当然没问题,但是在多线程并发环境下,指令重排可能会导致线程安全问题。就拿面试常考的单例模式来讲吧,单例模式至少有7种写法,我们来看看双重检查锁的写法(Double-Check Lock,简称DCL):

public class DCL {

    private static DCL instance;

    private DCL() {

    }

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

私有字段、私有构造函数,公有的静态方法获取实例,整个类只有一个入口点,好像没什么问题。在静态方法里,先判断instance是否为null,不为null就直接返回,为null再进入if逻辑里,然后用内置锁锁住整个类,开辟了一个临界区,其他线程此时就不能访问了,当前线程再判断instance是否为null,之所以要再次判断是因为线程进入临界区之前,进入第一个if逻辑之后可能会被其他线程抢占CPU,其他线程有可能获取到锁并完成了对instance的初始化,为了防止这种情况,在里面再做一次if判断来保证不会重复初始化instance,这就是双重检查这个名字的由来。

但是,这样真的没问题吗?答案是不!这里有可能会因为指令重排导致获取到的值是null。instance = new DCL()不是一个原子操作,而是分三步操作:

  1. 为对象分配内存空间
  2. 初始化对象
  3. 将instance引用指向刚刚分配的地址

这里第2步和第3步没有依赖关系,所以编译器在做指令重排的时候可能会将2和3的顺序做一个调换,变成这样:

  1. 为对象分配内存空间
  2. 将instance引用指向刚刚分配的地址
  3. 初始化对象

这就可能导致一种情况,当前线程执行到“将instance引用指向刚刚分配的地址”这一步,此时被其他线程抢占CPU,其他线程进入方法,做第一个if判断,此时的结果会是false,然后就直接走到方法最后返回instance了,但此时instance没有初始化完成,也就是说此时的instance是无效的!为了解决这个问题,我们可以给instance添加volatile关键字,此时volatile关键字的作用就是禁止指令重排,这样就解决了这个问题。

4 final

final除了用来约束常量,使方法不能被重写,类不能继承之外。还有一些规则,规则主要有两个:

  • final写:“构造函数内对一个final的写入”与“之后把这个被构造对象赋值给其他引用”之间不能重排序。
  • final读:“初次读一个包含final字段的对象”与“之后初次读该对象的final字段”之间不能重排序。

对于写来说,如果一个final字段在构造函数内才被写入值,那么这个写入操作必须要发生在把构造完成的对象赋值给其他引用之前。例如在多线程环境下,A线程调用构造函数,在构造函数里对final字段进行写入操作,B线程不能提前把该对象实例赋值给其他引用,即保证final字段一定先被初始化。

这里需要注意,我们不能将final应用到上述的DCL类,虽然final的写规则确实能防止instance = new DCL()的三个步骤的重排序,但是并不适用于DCL类,因为final字段要么在声明的时候直接写入,要么在初始化块或者构造函数类写入,显然DCL类不符合这个规则。关于使用final的例子,可以看看单例模式的“懒汉”形式,“懒汉”形式之所以是线程安全的,就是因为final的这个规则。

对于读来说,初次读包含final字段的对象和初次读该对象的fianl字段之间存在间接的依赖关系,这个final读规则就保证了要读某个对象的final域,必须写读这个包含这个fianl字段的对象,这个规则比较自然,我们觉得这应该是理所当然的。大多数编译器也确实不会对他们做重排序,所以,这个规则就是用来处理那些比较“皮”的编译器。

final字段也不应该发生“逸出”,这其实主要是针对final字段是引用类型。换句话说,final字段在构造函数执行期间,不应该被其他线程访问到,否则上述规则就都没有了意义。

5 小结

Java内存模型不同于Java内存区域的划分,Java内存模型描述的是一组规则,是一个抽象的概念,工作内存、主内存什么的都是抽象出来的,并不是真实存在的,只是为了更好的描述规则而已。JMM的规则主要是围绕原子性,可见性和顺序性来展开的,理解这三个性质可以更好的理解JMM。保证原子性可以采取锁等同步手段,保证可见性可以利用volatile。volatile不仅能保证可见性,还能防止重排序,这在多线程环境下非常重要。final也有一些规则来防止重排序,但是范围没有volatie那么宽,仅仅只针对部分场景,final的这个特性经常被忽略。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353

推荐阅读更多精彩内容