Java 并发编程(1): Java 内存模型(JMM)

1. 并发编程

1.1 并发编程的挑战

并发编程的目的是为了加快程序的运行速度, 但受限于上下文切换和死锁等问题, 启动更多的线程并非能让程序最大限度地并行执行.

1.1.1 上下文切换

  • CPU 通过分配时间片的方式来支持多线程.
    • 通过时间片分配算法来循环执行任务, 任务切换前会保存上一个任务的状态, 以便以后再次运行时的再加载.
    • 时间片一般为几十毫秒(ms), 所以感觉多个线程是同时运行的.
    • 上下文切换(Context Switch) : 任务从保存到再加载的过程.
  • 并发执行会产生线程创建和上下文切换的开销, 所以并非一定比串行执行更快.
  • 减少上下文切换
    • 无锁并发编程. 多线程竞争锁时, 会引起上下文切换.
    • CAS 算法. 使用CAS 可以在无需加锁的情况下, 进行Atomic 原子操作.
    • 使用最少线程, 避免创建不需要的线程.
    • 协程. 在单线程中实现多任务的调度和切换.

1.1.2 死锁

  • 一旦产生死锁, 就会造成系统功能不可用.
    • 此时, 业务是可感知的, 因为不能继续提供服务了.
    • 通过dump 线程来查看到底是那个线程出现了问题.
  • 避免死锁的常用手段:
    • 避免一个线程同时获取多个锁.
    • 避免一个线程在持有锁期间同时占用多个资源, 尽量保证每个锁只占用一个资源.
    • 尝试使用定时锁. 例如 lock.tryLock(timeout).

1.2 资源限制的挑战

只有在串行执行会浪费资源时, 将其修改为并行执行才能加快运行速度.

  • 在并发编程时, 程序的执行速度受限于PC 硬件资源或软件资源, 如网速, 硬盘读写速度, CPU 处理速度, 数据库的链接数和socket 连接数等.
  • 资源限制引发的问题:
    • 在资源受限时, 将串行执行的代码并发执行, 其结果仍然是串行执行. 由于增加了上下文切换和资源调度的消耗, 可能会使得程序的运行速度更慢.
    • 例如, 在较慢的网络条件下, 下载一个大文件, 单线程比并发编程速度更快.

2. Java 内存模型(JMM)

现代软硬件的共同目标: 在不改变程序执行结果的前提下, 尽可能提高并行度.

2.1 内存模型的基础

  • 并发编程模型的两个关键问题: 线程间如何通信, 以及线程之间如何同步.
    • 通信: 交换信息的机制. 有两种常见的方式:
      • 共享内存: 读写内存中的公共状态来进行隐式通信.
      • 消息传递: 无公共状态, 通过发送消息来显示进行通信.
    • 同步: 用以控制不同线程间操作发生的相对顺序的机制.
      • 共享内存的通信机制下, 必须进行显式的同步.
      • 消息传递的通信机制下, 消息的发送顺序隐式进行了同步.
    • JAVA 采用共享内存模型, 线程间的通信过程对外完全透明.

2.2 JMM 的抽象结构

  • JAVA 中的内存存储.
    • 堆内存: 存放实例域, 静态域, 数组元素. 在线程间共享.
    • 栈内存: 存放局部变量, 方法定义参数和异常处理器参数.
      • 不会共享, 也不会有内存可见性问题. 不受JMM 的影响.


  • JMM 决定一个线程对共享变量的写入何时对另一个线程可见.
    • 定义了线程的本地内存和主内存之间的抽象关系.
    • 主内存负责存储共享变量.
    • 本地内存涵盖了缓存,写缓存,寄存器及其他优化. 它会存储该线程读写共享变量的副本.
    • 两个线程间的通信过程, 必须经过主内存.

2.3 处理器和内存的交互, 内存屏障(Memory Barriers / Memory Fence)

  • CPU 会使用写缓存区来临时保存需要向内存写入的数据.
    • 避免由于处理器停顿下来等待向内存写入数据而产生的延迟, 从而保证了指令流水线持续运行.
    • 同时, 通过以批处理的方式刷新写缓存, 以及合并对同一内存地址的多次写, 减少了对内存总线的占用.
  • 但是, 每个CPU 上的写缓存区, 仅对该CPU 可见.
    • 该特性会对内存操作的执行顺序产生影响: CPU 对内存的读写操作的执行顺序, 不一定与内存实际发生的读写顺序一致.
    • 由此, CPU 允许对写-读操作进行重排序(因为本来也无法保证其顺序性, 且重排序能够提升性能).
  • 在适当的位置插入内存屏障来禁止特定类型的CPU 重排序.
屏障类型 指令示例
LoadLoad Barries Load1; LoadLoad; Load2
StoreStore Barries Store1; StoreStore; Store2
Load�Store Barries Load1; LoadStore; Store2
StoreLoad Barries Store1; StoreLoad; Load2
  • 其中, StoreLoad 同时具有其它3个屏障的效果, 执行它的开销很大, 因为它需要把写缓存区的数据全部刷新到内存中(Buffer Fully Flush).

2.4 happens-before

2.4.1 happens-before 的定义

  • 阐述操作之间的内存可见性:
    • 如果一个操作的结果需要对另一个操作可见, 那么两个操作之间必须要存在happends-before 关系.
      • 两个操作可以是一个线程内, 或者在不同的线程间.
    • 存在happens-before 关系, 并不意味着Java 平台的具体实现必须要按照关系指定的顺序来执行.
      • 重排序后的执行结果与按happens-before 关系执行的结果一致时, 该重排序是合法的.
  • 目的: 在不改变程序结果的前提下, 尽可能提高程序执行的并行度.
    • as-if-serial: 保证单线程内程序执行结果不被改变, happens-before 保证正确同步的多线程程序的执行结果不被改变.

2.4.2 happens-before 规则:

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

2.4.3 程序顺序规则

  • 两个具有happens-before 关系的操作, 仅仅要求前一个操作(的执行结果)对后一个操作可见.
    • 而不要求前一个操作要在后一个操作之前执行.
    • 如果A happens-before B, 但A 和B 之间不存在数据依赖性, 则可能会进行重排序, 使得B 在A 之前执行.

2.5 重排序

2.5.1 数据依赖性

  • 如果两个操作访问同一变量, 且有一个是写操作, 则这两个操作存在数据依赖性.
  • 它仅针对单个CPU 中执行的指令序列和单个线程中执行的操作.

2.5.2 as-if-serial 语义

  • 无论如何重排序, 单线程程序的执行结果不能被改变.
  • 为了遵守该语义, 编译器和CPU 不会对存在数据依赖关系的操作做重排序.
  • 造成了一个幻觉: 单线程程序是按程序的顺序来执行的.

2.5.3 从源码到指令序列的重排序

  • 从JAVA 源代码到最终实际执行的指令序列, 会经历3种重排序:
    • 编译器优化重排序: 在不改变单线程程序语义的前提下, 重新安排语句的执行顺序.
    • 指令级并行的重排序: 如果不存在数据依赖性, CPU 可以改变语句对应机器指令的执行顺序.
      • 采用ILP(指令级并行技术) 来将多条指令重叠执行.
    • 内存系统的重排序: 由于CPU 私用缓存和读/写缓冲区, 加载和存储操作看起来是在乱序执行.
    • 1 属于编译器重排序, 2和3 属于处理器重排序.

2.5.4 JMM 的设计初衷

  • 程序员希望内存模型易于理解和编程, 所以需要一个强内存模型.
  • 编译器和CPU 则希望内存模型对其有最小的束缚, 方便做优化来提高性能. 所以需要一个弱内存模型.
  • 重排序会导致多线程程序出现内存可见性问题.
    • JMM 的编译器重排序规则会禁止特定类型的重排序.
    • JMM 的处理器重排序规则会在生成指令序列时, 插入特定类型的内存屏障来禁止特定类型的重排序.
  • JMM 属于语言级的内存模型. 它确保在不同的编译器和处理器平台上, 通过禁止特定类型的编译器和处理器重排序, 对外提供一致的内存可见性保证.

2.5.5 JMM 对待重排序的策略

  • 对于会改变程序执行结果的重排序, JMM 要求编译器和CPU 必须禁止这种重排序.
  • 对于不会改变程序执行结果的重排序, JMM 不做任何要求, 即允许这种重排序.
    • 例如, 若认定锁只会被单线程访问, 则消除之; 若volatile 只会被单线程访问, 则已普通变量对待之.

2.5.6 控制依赖关系

  • 前序操作是条件语句(if, while...), 则后续操作和前序之间就产生了控制依赖关系.
  • 当代码中存在控制依赖性时, 会影响指令序列执行的并行度.
    • 采用猜测(Speculation) 执行来克服控制相关性对并行度的影响.
    • 例如, CPU 会执行后续操作,并将其计算结果保存到重排序缓存(Reorder Buffer: ROB) 的硬件缓存中,如果条件为真, 直接使用该结果顺序执行.
    • 在多线程中, 对存在控制依赖的操作重排序, 可能会改变程序的执行结果.


2.6 顺序一致性

2.6.1 数据竞争与顺序一致性

  • 数据竞争:
    • 两个线程对同一个变量, 分别进行读和写, 且没有通过同步对读写进行排序.
    • 包含数据竞争的代码, 执行结果不定.
  • 顺序一致性:
    • 正确同步的程序, 其执行结果与该程序在顺序一致性内存模型中的执行结果.
    • 同步指的是对同步原语(synchronized, volatile和final)的使用.

2.6.2 顺序一致性内存模型

未同步程序在JMM 中的执行, 整体是无序的, 其执行结果无法预知. 而在顺序一致性模型中, 所有线程看到的是一个一致的整体执行顺序.

  • 对外提供了极强的内存可见性保证.
    • 一个线程中的所有操作必须按照程序的顺序执行.
    • 不管程序是否同步, 所有线程都只能看到一个单一的操作执行顺序. 每个操作都必须原子执行并立刻对所有线程可见.
  • 一个单一的全局内存, 通过左右摇摆的开关连接到任意一个(仅一个)线程.
  • 例如, 两个线程A 和B, 分别有操作A1, A2, A3, B1, B2, B3.
    • 在使用监视器锁来正确同步(A 先B 后)时, 执行顺序为: A1 -> A2 -> A3 -> B1 -> B2 -> B3.
    • 在未同步时, 可能的执行顺序是: A1 -> B1 -> B2 -> A2 -> A3 ->B3.
  • 未同步的多线程程序, 在顺序一致性模型中虽然整体执行顺序是无序的, 但所有线程都只能看到一个一致的整体执行顺序(因为每个操作立即对任意线程可见).
    • 如果A 看到的是: A1 -> B1 -> B2 -> A2 -> A3 ->B3, 那么B 看到的也一定是.
  • 而未同步程序在JMM 中, 不但整体的执行顺序是无序的, 且所有线程看到的操作执行顺序也可能不一致(本地内存不会及时的刷新到主内存中).

2.6.3 未同步程序的执行特性

  • 对于未同步或未正确同步的多线程程序, JMM 只提供最小安全性:
    • 线程执行时读取到的值, 要么是之前某个线程写入的值, 要么是默认值(0, false, null).
    • JMM 保证线程读操作读取到的值不会无中生有(Out of Thin Air).
  • JVM 在堆上分配对象时, 首先会对内存空间进行清零, 然后在其上分配对象(内部会同步这两个操作).
    • 在已清零的内存空间(Pre-zeroed Memory)分配对象时, 域的默认初始化已经完成了.
  • JMM 不保证对64 位的long/double 型变量的写操作具有原子性.
    • CPU 和内存间的数据传递, 通过总线事务来确保所有CPU 对内存的访问以串行化的方式进行.
    • 任意时刻, 最多只能有一个CPU 可以访问内存, 确保了单个总线事务之中的内存读写操作具有原子性.
    • 在一些32 位CPU 上, 保证64 位数据写操作的原子性, 会产生较大的开销.
    • JMM 可能会将一个64 位写操作分拆为两个32 位的写, 从而被分配到不同的总线事务上, 不再具有原子性.
    • JDK 1.5 以后, 保证了读的原子性, 而写允许被分拆.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容