Java并发编程艺术之Java内存模型

 2019-12-10 15:57  阅读(754)
文章分类:Java Core

Java并发编程艺术之Java内存模型

本文章包含的内容

  • Java内存模型基础
  • Java内存模型中的顺序一致性,主要介绍重排序和顺序一致性
  • 同步原语,涉及synchronized, volatile , final
  • 内存模型的设计原理,涉及与内存模型和顺序一致性内存模型关系

一 、Java内存模型基础
1) 并发编码模型的两个关键问题 – 线程是并发执行的活动实体

  • 线程之间如何通信

    • 共享内存 – 通过写-读内存中的公共状态进行隐式通信 ,Java 采用的是共享内存模式,线程之间通信总是隐式进行
    • 消息传递 – 线程之间没有公共状态,通过发送消息进行显示通信
  • 线程之间如何同步
    同步是指程序中用于控制不同线程间操作发生相对顺序的机制(简单理解是线程按照某种顺序进行执行)。

    • 共享内存, 同步是显示的,必须手动指定某个方法或者代码块互斥执行
    • 消息传递,同步是隐式的,且消息的发送必须在接收消息之前

2) Java内存模型的抽象结构
    在Java中,所有实例域、静态域都存储在堆内存中,堆内存在线程之间共享;而局部变量、方法定义参数、异常处理参数不会在线程之间共享,不会有内存可见性问题,也不会受内存模型的影响 。
    java线程之间的通信,通过Java内存模型控制(JMM , Java Memory Model), JMM定义了线程和主内存之间抽象关系: 共享变量存储在主内存中, 线程私有的本地内存(Local Memory) 存储了主内存的副本,用作线程读/写 ,本地内存(Local Memory)是JMM(Java内存模型)的一个抽象概念, 并不真实存在. 如下图:

20191210001437\_1.png

图一、Java内存模型

如果线程A和线程B之间进行通信, 需要经历下面的步骤

  1. 线程A将本地内存(Local Memory)中更新过的共享变量信息刷新到主内存中
  2. 线程B从主内存中获取已更新的共享变量

3) 从源代码到指令序列的重排序
    为了提高性能 编译器、处理器会对源码编译后的指令进行重排序. 主要分为下面 3类

  • 编译器优化的重排序。
    编译器在不改变单线程语义的前提下,重新安排语句的执行顺序。
  • 指令集并行的重排序。
    通过指令级并行技术,将多条指令重叠执行。在不存在数据依赖的前提下,处理器可以改变机器指令的执行顺序 。
  • 内存系统的重排序。
    由于处理器使用 缓存读/写缓冲区, 使得加载和存储操作看上去可能是乱序的。
    其中指令集和内存系统属于处理器重排序
    20191210001437\_2.png
    图二、从源码到最终执行的指令序列的示意图

    重排序可能导致多线程出现可见性问题,针对上面两大类重排序(编译器、处理器), 具体的处理方式如下:

  • 编译器重排序, 针对特定类型的JMM编译器会禁止重排序功能
  • 处理器重排序, Java编译器在生成指令序列时,会插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定处理器重排序。

4)并发编程模型分类
    在向内存写入数据前,处理器会在缓冲区临时保存需要写入的数据, 避免处理器停顿下来等待向内存写入数据而造成的延迟;合并写缓冲区对同一内存地址的多次写,减少多内存总线的占用。
    处理器上的写缓存仅对它所在的处理器可见,并且处理器对内存的读/写操作顺序不一定与内存实际的读/写顺序一致。下面的截图可以大致说明这种情况:
20191210001437\_3.png

图三、处理器操作内存执行结果

20191210001437\_4.png

图四、处理器和内存交互

    现在讲解一下图三中x=y=0情况产生的原因。

  1. 处理器A和B,几乎同时将变更的数据写入自己的缓冲区(A1, B1), 这个时候a=1, b=2 ;
  2. 从内存中读取数据(A2, B2) , 注意这时候a=b=0 , 执行赋值操作x=b, y=a , 最后x=y=0;
  3. 将缓冲区中的数据保存到内存中(A3, B3), 将a=1, b=2, x=0 , y=0 的信息缓存到内存中。
    通过上面的操作流程,可以发现内存中保存的x, y 值是0;

    常见处理器允许的重排序类型列表:
20191210001437\_5.png

图五,处理器的重排序规则

从图五可以看出下面的信息:

  • 常见的处理器都允许Store – Load操作
  • 常见的处理器都不允许对数据存在依赖的操作进行重排序
  • 使用了写缓存的机器,拥有相对较强的处理器内存模型

注:表格中的 **‘N’表示处理器不允许两个操作重排序,‘Y’**表示处理器允许两个操作重排序。

内存屏障类型表

屏障类型 指令实例 说明
LoadLoadBarriers Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStoreBarriers Store1;StoreStore;Store2; 确保Store1数据对于其它处理器可见(刷新到内存),即先于Store2及所有后续存储指令的存储
LoadStoreBarries Load1;LoadStore;Store2; 确保Load1数据装载先于Store2及后续所有存储指令刷新到内存
StoreLoadBarries Store1;StoreLoad;Load2 确保Store1数据对其它处理器可见(指刷新到缓存),且先于Load2指令及后续所有装载指令的装载,StoreLoadBarries屏障会在屏障之前的所有内存指令完成后,才执行内存屏障之后的指令。

二、重排序

1) 数据依赖性
     两个操作访问同一个变量,且其中一个操作为写操作,那么这两个操作之间存在数据依赖性,具体分为下面3中

名称 代码示例 说明
写后读 a=1;b=a; 写一个变量之后,再读这个变量
写后写 a=1;a=2; 写一个变量之后,再写这个变量
读后写 a=b;b=1; 读一个变量之后,再写这个变量

     编译器和处理器可能会对操作做重排序,而上面的3钟情况,如果发生重排序,执行结果可能会发生改变。

注: 这里所说的数据依赖性仅针对单个处理器执行的指令序列,和单个线程中执行的操作,不同的处理器之间和不同线程之间数据依赖性不被编译器和处理器考虑 。

2) as-if-serial语义
    **意思:**不管编译器和处理器怎么排序,单线程程序执行的结果不会改变。
    编译器和处理器不会对存在数据依赖性的数据进行重排序, 因为如果存在数据依赖性,重排序之后可能会改变执行结果。
    但是如果操作之间不存在数据依赖性,操作可以被编译器和处理器重排序。
比如:

double  pi      =   3.14 ;
    double  r       =   1.0 ;
    double area     =   pi * r * r ;

area 依赖 pi 和 r, 那么area必须在pi 和 r之后操作, 但是pi 和 r之间没有数据依赖的关系, 所以重排序之后是先获取 pi 的值还是先获取 r 的值都没有影响, 从宏观的角度来看代码是顺序执行的。

20191210001437\_6.png

图六、重排序之后的执行顺序

三、顺序一致性

1) 数据竞争和顺序一致性
     Java内存模型规范对数据竞争的定义:在一个线程中写一个变量, 在另一个线程读同一个变量,而且写和读操作没有通过同步来排序。
     JMM对正确同步的多线程的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行结果与程序在顺序一致性内存模型中执行结果相同,即执行将具有顺序一致性。

2) 顺序一致性内存模型

顺序一致性内存模型的两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序, 且在顺序内存模型中,每个操作都必须是原子性的,且操作结果对所有线程可见。

20191210001437\_7.png

图七、顺序一致性内存模型的视图

  • 顺序一致性模型有一个单一的全局内存,这个内存通过左右摇摆的开关连接到任意一个线程
  • 每一个线程必须按照程序的顺序来执行内存的读/写
  • 任意时刻只有一个线程可以连接到内存,当多线程并发执行的时候, 开关装置将多线程的内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)

注: JMM对未同步程序保证。未同步的程序在JMM中不但整体的执行顺序无序,而且所有线程看到的执行顺序也可能不一致。比如: 某个线程将信息保存到本地缓存中(Local Memory), 没有及时将信息刷新到内存中, 对于其它线程这个信息是没有变化的,只有将信息刷新到内存中时,其它线程才可以看见信息的变更。

3)同步程序的顺序一致性效果
实例代码:

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

        public synchronized void reader() {
            if(flag) {
                int i = a;
                //.....
            }
        }
    }

上面的代码在JMM和顺序一致性模型中的执行对比流程如下:
20191210001437\_8.png

图八、JMM和顺序一致性内存执行对比图

从上面流程对比,有下面的总结:

  • JMM在具体实现上的基本方针不变:在不改变程序执行结果的前提下,尽可能的为处理器和编译器提供方便。
  • 顺序一致性模型中,所有操作完全按照程序顺序串行执行
  • JMM中,临界区内的代码可以重排序,并且其它线程无法看到这个重排序,这样既可以保证总体与顺序一致性模型具有相同的视图, 也可以为处理器和编译器提供方便,以提高效率。

4) 未同步程序的执行特性
    JMM最小安全性:对于未同步或者未正确同步的多线程程序,程序读取的值要么是之前某个线程写入的值,要么是默认值(0, null, false), 不会无中生有(Out Of Thin Air)。
    为了实现最小安全性:JMM在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象
    未同步程序在JMM模型和顺序一致性模型中的差异:

  • 顺序一致性保证单线程内的操作会按照程序的顺序执行,JMM不能保证单线程内的操作会按照程序的顺序执行
  • 顺序一致性保证所有线程只能看到一致的操作执行顺序,JMM不能保证所有线程能看到一致的执行顺序。
  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性保证所有的内存读/写操作都具有原子性

对上述第三点JMM模型long型和double型变量的写操作不能保证原子性的一点说明(针对32位处理器):
20191210001437\_9.png

图九、总线事务执行的时序图

在32位的处理器上,64位的操作会被分为两个操作, 而这两个操作可能会被两个总线事务执行,参见上图如果在执行两个写事务期间, 可能另一个处理器读取了long型或double型变量的高32位的无效值,从而没有保证原子性(不可拆分的一个或一些列操作)的特性。

四、volatile的内存语义
1) volatile的特性
    可以理解对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写做了同步。

示例代码 1:

class VolatileFeatureExample{
        volatile long v1 = 0L;         //使用volatile声明64位的long型变量
        public void set(long l){       
            this.v1 = l ;              //单个volatile变量的写
        }
        public void getAndIncrement() {
            v1++;                      //复合(多个volatile变量的读写)
        }
        public long get(){
            return this.v1 ;           //单个volatile变量的读
        }
    }

示例代码 2:

class VolatileFeatureExample{
        long v1 = 0L;                  //64位的long型普通变量
        public synchronized void set(long l){       
            this.v1 = l ;              //对单个的普通变量的写使用同一个锁同步
        }
        public void getAndIncrement() {//普通方法的调用
            lont temp = get();         //调用已同步的读方法
            temp += 1L ;               //普通写操作
            set(temp);                 //调用已同步的写方法
        }
        public synchronized long get(){
            return this.v1 ;           //对单个的普通变量的读使用同一个锁
        }
    }

比较示例代码 1 和 示例代码 2 , 使用volatile修饰的变量进行单个读/写操作,与一个普通变量的读/写操作都使用同一个锁同步的执行结果相同。volatile具有下列特性:

  • 可见性. 对一个变量的读取,总能看见任意线程对这个volatile变量的写入。
  • 原子性. 对任意volatile变量的单个读/写具有原子性,类似 i++ 这种复合操作不满足原子性。

2) volatile写-读建立的happens-before关系
    相对于上一节提到的volatile自身特性,volatle对线程内存可见性显得更为重要。从JDK5开始,volatile变量的读/写已经实现了线程之间的通信。
    从内存语义角度来看,<1> volatile写和锁的释放具有相同的语义, <2>volatile读和锁的获取具有相同的语义

示例代码 :

class VolatileExample {
        int a = 0;
        volatile boolean flag = false;
        public void writer() {
            a = 1;      // 1
            flag = true;    // 2
        }
        public void reader() {
            if (flag) {    // 3
                int i = a;   // 4
                ……
            }
        }
    }

示例代码的执行可以参照下面的图示:
20191210001437\_10.png

图10、代码执行的顺序

从执行图中,有下面的执行顺序

  • 1 happen before 2 , 3 happen before 4
  • volatile规则, 2 happen before 3
  • happen before规则具有传递性,1 happen before 4

    如果线程A写一个volatile变量后,B线程读同一个volatile变量,那么线程A在写volatile变量前对所有可见的共享变量的修改,在线程B读volatile变量后,修改的所有共享变量将对B可见。

3) volatile写-读的内存语义

  • volatile写内存语义
    当写一个volatile变量时,JMM会把该线程对应的本地内存(Local Memory)中的共享变量更新到主内存中
  • volatile读内存语义
    当读volatile变量时,JMM会把当前线程的本地内存(Local Memory)置为无效,将会从主内存重新读取共享变量。

volatile写和volatile读的内存语义的总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来要读这个volatile变量的其它线程发出共享变量有做修改的消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的对共享变量有做修改的消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上线程A通过主内存向线程B发送消息。

4) volatile内存语义的实现
    为了实现volatile的内存语义, JMM会限制编译器重排序和处理器重排序的类型,编译器指定的规则表如下:
20191210001437\_11.png

图11、编译器volatile重排序规则表

    关于规则表表格信息说明举例:

  1. 对第一行、第三列,当第一个操作为普通读/写,第二个操作为volatile写,则编译器不能重排序这两个操作。
  2. 对第二行、第二列,当第一个操作为volatile读,第二个操作为volatile写,则编译器不能重排序这两个操作。
  3. 当第一个操作为volatile读时,不管第二个什么操作,都不允许编译器重排序这两个操作。
  4. 当第二个操作为volatile写,不管第一个什么操作,都不允许编译器对这两个操作进行重排序。

    基于JMM保守策略的内存屏障插入策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

上述内存屏障的插入策略比较保守,但是它可以保证在任意处理器平台和任意程序中都可以得到正确的volatile内存语义。
    1. 下面是volatile写 插入内存屏障后生成的指令序列示意图
20191210001437\_12.png

图11、volatile写插入内存屏障示意图

    因为编译器常常无法准确判断在一个volatile写的后面是否需要插入StoreLoad屏障(比如: volatile写的后面直接return返回),为了能正确的实现volatile的内存语义,**JMM采取了保守的策略:在每个volatile写的后面或者volatile读的前面,插入一个StoreLoad屏障。**JMM实现上首先确保正确性,让后再去追求执行效率。

    2. 下面是保守策略下, volatile读插入内存屏障后生成的指令序列示意图
20191210001437\_13.png

图12、volatile读插入内存屏障示意图

    上面的volatile读和volatile写的内存屏障插入策略非常保守, 在实际执行时, 只要不该变volatile 读 – 写的内存语义,编译器可以根据具体的情况生路不必要的屏障。比如下面的代码:

class VolatileBarrierExample {
        int a;
        volatile int v1 = 1;
        volatile int v2 = 2;
        void readAndWrite() {
            int i = v1;   // 第一个volatile读
            int j = v2;   // 第二个volatile读
            a = i + j; // 普通写
            v1 = i + 1;   // 第一个volatile写
            v2 = j * 2;   // 第二个 volatile写
        }
        …       // 其他方法
    }

上面的代码优化内存屏障信息后,有下面的内存指令执行序列图
20191210001437\_14.png

图13、优化后的内存指令执行示意图

注: 1. 上图中最后一步的StoteLoad内存屏障, 是为了安全起见,因为编译器可能无法准确确定判断后面是否有volatile读/写操作,编译器会在这里添加StoreLoad屏障。2.x86处理器需要特别注意(这里暂时没有整理,后期有时间会补充这部分信息)

  1. 对volatile进一步研究可以参考Brian Goetz的文章 Java理论与实践:正确使用Volatile变量 做进一步了解。

五、锁的内存语义
1) 锁的释放和获取建立的happens-before更新
    锁除了让临界区互斥执行,还可以让释放锁的线程A向获取同一个锁的线程B发送消息。
示例代码 :

class MonitorExample {
        int a = 0;
        public synchronized void writer() {     // 1
            a++;                           // 2
        }                                // 3
        public synchronized void reader() {     // 4
            int i = a;                      // 5
            ……
        }                                // 6
    }

    假设线程A执行writer()方法, 随后线程B执行reader()方法,根据happens-before规则,有下面规则

  • 1 happens-before 2, 2 happens-before 3 ; 4 happens-before 5, 5 happens-before 6
  • 3 happens-before 4
  • 根据happens-before的传递性, 2 happens-before 5

具体的表现形式可以参考下面的图:
20191210001437\_15.png

图14、happens-before关系的图

2) 锁的释放和获取的内存语义
    当线程A释放锁时,会把该线程本地内存(Local Memory)中的共享变量刷新到主内存中,当线程B获取锁时,JMM会把该线程对应的本地内存(Local Memory)信息置为无效, 然后从主内存中重新获取对应的共享变量信息。
    从锁的内存语义的说明,可以看出锁的释放与volatile读具有相同的内存语义; 下面对锁的释放和锁获取的内存语义进行的总结:
– 线程A释放锁,实际是线程A对接下来需要获取锁的线程B,发送共享变量发生了改变的信息。
– 线程B获取锁, 实际是线程B收到了某个线程发送的共享变量已经改变的信息。
– 线程A释放锁,随后线程B获取锁,这个过程实际上是线程A通过主内存向线程B发送的共享变量信息已经变更。

六、happens-before
1) JMM的设计
    JMM设计需要关注的点,

  1. 程序员希望基于一个强内存模型来编写代码, 这样的内存模型易于理解,易于编程.
  2. 编译器和处理器希望基于一个弱内存模型,这样束缚越少,能尽可能的优化来提高性能

    为了解决上面两个相互矛盾的问题, JMM对是否禁止重排序采用了下面的规则

  1. 如果重排序结果不改变代码执行结果,编译器和处理器对这种重排序不做要求。
  2. 如果重排序结果改变了代码执行结果, 编译器和处理器必须禁止这种重排序。

1) happens-before的定义
     happens-before的来源可以阅读这篇文章《Time,Clocks and
the Ordering of Events in a Distributed System》
, 而对happens-before的定义如下:

  1. 如果一个操作happens-before另一个操作 , 那么第一个操作的执行结果对第二个的操作可见,且第一个操作的执行顺序必须在第二个操作之前。
  2. 如果两个操作之间存在happens-before关系,并不意味着具体实现必须按照这种关系来执行。如果重排序之后的执行结果(单线程程序和正确同步的多线程程序)与按照happens-before关系执行结果一致, 那么这种重排序并不非法。
  1. happens-before和as-if-serial之间的关系
  • as-if-serial 保证单线程内程序的执行结果不被改变, happens-before保证正确同步的多线程程序的执行结果不被改变。
  • 从宏观角度来看: as-if-serial保证单线程程序是按照程序的顺序来执行的,happens-before保证多线程程序是按照happens-before指定的顺序来执行的。
    as-if-serial 和 happens-before这么做的目的是,在不改变程序执行结果的前提下,尽可能的提高程序的并行度。

3) happens-before规则
    happens-before规则如下:

  1. 程序顺序规则: 一个程序中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁, happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happen-before于任意后续对这个volatile域的读
  4. 传递性: 如果A happens-before B, B happens-before C , 那么A happens – before C。
  5. start规则:如果程序A执行操作ThreadB.start() ,那么线程A的ThradB.start()操作 happens-before于线程B中的任意操作。
  6. join(): 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before 于线程A从ThreadB.join()操作成功返回。

七、双重检查锁定与延迟初始化
1) 双重检查锁定的由来
    对一些开销比较大的初始化工作,并且只有在需要的时候才初始化,这时可能需要采用延迟初始化。下面是相关代码示例,

代码示例1

public class UnsafeLazyInitialization{
        private static Instance instance ;
        public static Instance getInstace() {
            if(instance == null) {                 // 1. 线程A执行到此处
                instance = new Instance() ;        // 2. 线程B执行到此处
            }
            return instance ;
        }
    }

代码示例2

public class SafeLazyInitialization{
        private static Instance instance ;
        public synchronized static Instance getInstace() {
            if(instance == null) {                 
                instance = new Instance() ;        
            }
            return instance ;
        }
    }

代码示例3

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

    针对上面的三段代码进行分析双重检查锁定的由来,
    1. 针对代码示例1如果线程A代码1的同时,线程B执行代码2,线程A可能看到instance的引用对象还没有初始化。
    2. 针对代码示例2,synchronized将导致性能开销(现在已经有了很大的性能优化),如果频繁调用会导致程序执行性能下降,如果getInstance()不会被多个线程频繁调用,那么这个延迟初始化可以接受。
    3. 针对代码示例3,在synchronized修饰前提下,如果第一次检查instance不为null, 那么就不需要执行下面加锁的初始化操作, 可以大幅降低synchronized带来的性能开销(synchronized修饰后能够进入加锁内部进行初始化操作的次数会非常少),这就是双重锁的由来。

双重锁的问题

  • 针对代码示例3,代码读到instance 不为 null时, instance引用的对象很可能还没有初始化。
    针对前面的代码示例3,instance = new Instance(); 可以分解为下面的操作步骤,
    memory = allicate(); //1. 分配对象的内存空间
    ctorInstance(memory); //2. 初始化对象
    instance = memory; //3. 设置instance指向刚分配的内存地址

在编译器和处理器操作期间,可能对上面步骤进行重排序,将第2,3步骤顺序重排,即还没有初始化对象,instance已经指向分配对象的内存地址,参考下面多线程执行时序图表
20191210001437\_16.png

图15、多线程执行时序表

针对线程A, A2和A3虽然重排序了,但是只要保证A2 一定排在A4的前面,线程A的结果就不会改变 ; 对于线程B,在B1处判断instance不为空,如果访问instance引用对象,线程B将访问一个未初始化的对象。

解决线程安全延迟初始化问题的方式

  • 不允许2 、3重排序
  • 允许2、3重排序,但是不允许其它线程“看到”这个重排序

2) 基于volatile的解决方案
使用volatile修饰延迟初始化可能重排序的变量,比如针对上面的代码示例3,

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

使用volatile修饰之后,步骤2、3之间的重排序在多线程环境中将会被禁止,可以参见下面的时序图
20191210001437\_17.png

图16、volatile修饰后,多线程执行时序图

3)基于类初始化的解决方案
    JVM在Class被加载后,且被线程使用前, 会执行类的初始化,在执行类的初始化期间JVM会获取初始化锁,这个锁可以同步多个线程对同一个类的初始化。

代码示例1

public class InstanceFactory{
        private static class InstanceHolder{
            public static Instance instance = new Instance() ;
        }
        public static Instance getInstance(){
            return InstanceHolder.instance ;      //这里将导致InstanceHolder类被初始化
        }
    }

对示例初始化的流程可以参照下面截图:
20191210001437\_18.png

图17、两个线程并发执行的示意图

对类或者接口T被立即初始化的情况总结:

  • T是一个类,而且这个类的实例被初始化
  • T是一个类,而且这个类的静态方法被调用
  • T中声明的一个静态字段被赋值
  • T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
  • T是一个顶级类(Top Level Class) ,而且一个断言语句嵌套在T内部被执行

Java初始化一个类或者接口的处理过程如下

  • 第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁
    20191210001437\_19.png
    图18、类初始化–第一阶段

20191210001437\_20.png

图19、类初始化——第1阶段的执行时序表

  • 第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待
    20191210001437\_21.png
    图20、类初始化——第2阶段的执行时序表

20191210001437\_22.png

图21、类初始化——第2阶段

  • 第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程
    20191210001437\_23.png
    图22、类初始化——第3阶段
时间 线程A 线程B
t1 A获取类的初始化锁
t2 A设置初始化状态state=initialized
t3 A唤醒在初始化锁condition中等待的所有线程
t4 A释放初始化锁
t5 A线程终止

图23、类初始化——第3阶段的执行时序表

  • 第4阶段:线程B结束类的初始化处理

20191210001437\_24.png

图24、类初始化——第4阶段

20191210001437\_25.png

图25、类初始化——第4阶段的执行时序表

  • 第5阶段:线程C执行类的初始化的处理
    20191210001437\_26.png
    图26、类初始化——第5阶段

20191210001437\_27.png

图27、类初始化——第5阶段的执行时序表

这里的condition和state标记是本文虚构出来的。Java语言规范并没有硬性规定一
定要使用condition和state标记。JVM的具体实现只要实现类似功能即可

**声明:本文章总结自

  1. Java并发编程艺术
  2. http://ifeve.com**
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java并发编程艺术之Java内存模型

相关推荐