10.Java内存模型与线程

Java内存模型与线程

1. 硬件的效率与一致性

在多处理器系统中,每个处理器都有自己的高速缓存,它们又共享同一主内存。

为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据协议进行操作,这些协议有MSI、MESI、MOSI等。

内存模型:在特定的操作协议下,对特定的内存、高速缓存进行读写访问的过程抽象。

一般内存模型

2. Java内存模型

2.1 主内存、工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存、从内存取出变量这样的底层细节。此处的变量与Java编程中的变量有所区别,此处的变量包含了实例字段、静态字段、构成数组对象的元素,但不包括局部变量、方法参数。因为局部变量、方法参数是线程私有的,不会被共享,不会存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存中(虚拟机内存的一部分)。

线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量。

不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

这里讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等不是同一个层次的内存划分,这两者基本上是没有关系的。

内存模型中的主内存就对应于物理硬件的内存,为了效率,工作内存可能优先存储在寄存器、高速缓存中。

下图是Java内存模型:

Java内存模型

2.2 内存间交互操作

注意,在2.1中已经指出,下面提到的变量只包括实例字段、静态字段和构成数组对象的元素,不包括局部变量、方法参数

内存间交互操作指的是一个变量从主内存拷贝到工作内存,从工作内存同步回主内存。

Java内存模型定义了以下8种原子性操作来完成内存间交互操作。虚拟机实现时必须保证下面8种操作是原子性的,不可再分的:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。(锁定主内存的变量
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。(解锁主内存的变量
  3. read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。(主内存——>工作内存
  4. load(载入):作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中。(read——>工作内存
  5. use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行这个操作。(工作内存——>执行引擎
  6. assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。(执行引擎——>工作内存
  7. store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。(工作内存——>主内存
  8. write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。(store——>主内存
变量在执行引擎工作内存主内存流转图

Java内存模型规定了执行上述8种原子操作必须满足的规则:

  1. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写但主内存不接受的情况出现。并且要求这两组操作的每一个都必须顺序执行,但是不保证连续执行,如read后面肯定会有load,但是不要求read后紧跟load,对主内存变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a,这种顺序满足Java内存模型的要求。store与write操作同理。
  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,也就是说,对一个变量实施use、store操作之前,必须先执行了assign、load操作。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  6. 如果对一个变量进行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load、assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,那就不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步回主内中(执行store、write操作)。

对于double、long型变量来说,虚拟机允许将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作执行,即允许虚拟机实现可以不保证64位数据类型的load、store、read、write的原子性。但是目前各种平台下的商用虚拟机几乎都把64位的读写操作作为原子操作,因此在编写代码时,不需要把用到的long、double变量专门声明为volatile。

另外,Java内存模型对volatile变量又定义了一些特殊规则:T表示一个线程,V、W表示两个volatile变量:

  1. 只有当线程T对变量V执行的前一个动作是load时,线程T才能对变量V执行use;并且,只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load。线程T对变量V的use可以认为是和线程T对变量的load、read相关联,必须连在一起出现。这条规则要求在工作内存中,每次使用变量V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值。

  2. 只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量V执行store;并且,只有当线程T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign。线程T对变量V的assign可以认为是和线程T对变量V的store、write相关联,必须连在一起出现。这条规则要求在工作内存中,每次修改V后,都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。

  3. 假定动作A是线程T对变量V实施的use或assign,假定动作F是和动作A相关联的load或store,假定动作P是和动作F相应的对变量V的read或write动作。同样,假定动作B是线程T对变量W实施的use或assign,假定动作G是和动作B相关联的load或store,假定动作Q是和动作G相应的对变量W的read或write。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。

    如下图所示:

    volatile禁止重排序

上面这些规则严谨但繁琐,可以用先行发生原则等效。

先行发生原则:

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说,在发生B之前,A产生的影响能被B观察到。这里说的影响包括:修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型中一些天然的先行发生关系,不需要任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作间的关系不在下面的范围内,或者无法由下面的规则推导出来,就没有顺序性保障,虚拟机对它们可以随意进行重排序:

  1. 程序次序规则

    在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确说,应该是控制流而不是程序代码顺序,因为要考虑分支、循环等。

  2. 管程锁定规则

    一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调是同一个锁,“后面”是指时间上的先后顺序。

  3. volatile变量规则

    对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”指时间上的先后顺序。

  4. 线程启动规则

    Thread对象的start()先行发生于此线程的每一个动作。

  5. 线程终止规则

    线程中所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

  6. 线程中断规则

    对线程interrupt()的调用先行发生于被中断线程的代码检测到中断事件的发生,通过Thread.interrupted()检测到是否有中断发生。

  7. 对象终结规则

    一个对象的初始化完成(构造函数执行完成)先行发生于它的finalize()的开始。

  8. 传递性

    如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

一个操作“时间上的先发生”不代表这个操作会是“先行发生”;一个操作“先行发生”不代表这个操作必定是“时间上的先发生”。

2.3 关于Java中的原子操作

对于局部变量、方法参数来说,因为是在线程独享的区域中,不会存在线程安全问题,所以对它们的操作(赋值、运算等),是不是原子操作都没有关系。

对于共享变量,则要涉及到Java内存模型(主内存、工作内存),只有8种操作是原子操作。

即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化为若干条本地机器码指令。

2.4 原子性、可见性、有序性

Java内存模型是围绕着并发过程中如何处理原子性、可见性、有序性建立的。

  1. 原子性(Atomicity)

    1. 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,可以认为基本数据类型的访问读写都具备原子性。

    2. 如果应用场景还需要一个更大范围的原子性保证,Java内存模型还提供了lock、unlock满足这种需求。虚拟机并没有直接把lock、unlock操作开放给用户使用,但是提供了字节码指令monitorenter、monitorexit来隐式使用lock、unlock操作。这两个字节码指令反映到Java代码中就是同步块(synchronized)。因此synchronized块之间的操作具备原子性。

      synchronized保证同步的措施:

      • 同步方法不需要字节码指令控制,由方法表集合中的访问控制标志ACC_SYNCHRONIZED声明。
      • 同步代码块由monitorenter、monitorexit指令保证。monitorenter、monitorexit来隐式使用lock、unlock操作。
    3. 对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

  2. 可见性(Visibility)

    可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

    1. Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的。参见上面对volatile变量的规则(第1、2条):只有当线程T对变量V执行的前一个动作是load时,线程T才能对变量V执行use;并且,只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load;只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量V执行store;并且,只有当线程T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign;其中V是volatile变量。

      普通变量和volatile变量的区别是:volatile的特殊规则保证了新值能立即同步回主内存,以及每次使用前立即从主内存刷新。因此volatile变量保证了多线程操作变量时的可见性,但是普通变量做不到可见性。(上面定义中已经给出立即可见才是可见性。普通变量也要在改变后回写主内存,使用时从主内存中读,参见上面的规则“不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。” 但普通变量每次读取、修改后不保证立即与主内存交互。)

    2. synchronized也能实现可见性,是通过进入synchronized对变量操作时必须先lock,退出synchronized必须对变量进行unlock。上面的规则:一个变量在同一时刻只允许一条线程对其进行lock操作。如果对一个变量进行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load、assign操作初始化变量的值。对一个变量执行unlock操作之前,必须先把此变量同步回主内中。保证了synchronized的可见性。

      即,synchronized无论是同步方法还是同步代码块,都会先把主内存的数据拷贝到工作内存中,synchronized结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。

    3. final也能具有可见性。被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,那在其他线程中就能看见final字段的值。

  3. 有序性

    1. Java程序中天然的有序性:如果在本线程内观察,所有操作都是有序的(线程内表现为串行的语义,Within-Thread As-If-Serial Semantics);如果在一个线程中观察另一个线程,所有操作都是无序的(指令重排序现象和工作内存与主内存同步延迟现象)。

    2. volatile:包含了禁止指令重排序的语义,保证线程之间操作的有序性。

    3. synchronized:一个变量在同一个时刻只允许一条线程对其进行lock操作,决定了持有同一个锁的两个同步块只能串行地进入。

2.5 volatile

volatile变量有两个语义:

  1. 保证此变量对所有线程的可见性,当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  2. 禁止指令重排序优化。普通的变量仅仅保证在该方法执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

volatile 指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。

但是,基于volatile变量的运算在并发下并非总是安全的。

public class VolatileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }, "计算线程" + i).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(race);
    }
}

上面的实例表明volatile变量的运算在并发下并不一定是线程安全的。上面多次运行的结果不同,并且是小于20*10000的数字。

3. Java与线程

在Java API中一个native方法往往意味着这个方法没有使用或无法使用平台无关的手段来实现,当然也可能是为了执行效率而使用native方法。native方法是使用平台相关的手段实现的。

3.1 线程的实现方式

实现线程主要有3种方式:使用内核线程实现(1:1,Sun JDK使用该线程模型)、使用用户线程实现(1:N)、使用用户线程加轻量级进程混合实现(N:M)。

  1. 使用内核线程实现

    内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

    程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的进程。

    这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。

    使用内核线程实现
  2. 使用用户线程实现

    用户线程(User Thread,UT)

    狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

    这种进程与用户线程之间1:N的关系称为一对多的线程模型。

    使用用户线程实现
  3. 使用用户线程加轻量级进程混合实现

    在这种混合实现下,既存在用户线程,又存在轻量级进程。

    用户线程和轻量级进程的数量比是不定的,为N:M的关系。

    使用用户线程加轻量级进程混合实现

Java中线程的实现

使用基于操作系统原生线程模型实现。对于Sun JDK而言,Windows与Linux都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程中。(即上面的内核线程实现)

3.2 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:

  1. 协同式线程调度(Cooperative Threads Scheduling)
  2. 抢占式线程调度(Preemptive Threads Scheduling)(Java使用的线程调度方式是这种方式)。

如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。

Java使用的线程调度方式是抢占式调度。

线程调度时,不能太依赖优先级,优先级可能会被系统自行改变。所以不能在程序中通过优先级来完全判断一组为ready的线程将会先执行哪一个。

4. 附件

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

推荐阅读更多精彩内容