Home Java并发编程的艺术(笔记)

JMM知识梳理图

一、基础概念

1.并发编程模型的两个问题

  • a.线程通信
  • b.线程同步

2.Java内存模型的抽象结构

  • a.什么是JMM
    • 解释:JMM决定一个线程对共享变量的写入何时对另一个线程可见;JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本,并不真实存在
  • b.JMM做什么
    • 回答:JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

二、上层规则

1.happens-before

  • 作用:简单易懂,避免Java程序员为了理解JMM提高的内存可见性保证而去学习复杂的重排序规则和这些规则的具体实现方法

2.volatile

  • 三大特性
    • a.可见性
    • b.原子性(单个变量具有)
    • c.有序性
  • volatile写-读建立的happens-before关系
  • volatile写-读的内存语义(与锁的释放-获取相同)
    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
    • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
  • volatile内存语义的实现
    • 由JMM分别限制编译器重排序和处理器重排序
      • 实现方式:插入内存屏障

三、下层实现

JMM内存模型层面所做的事情

  • 1.禁止特定类型的编译器重排序和处理器重排序以提供一致的内存可见性保证
    • JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致内存可见性保证

编译器和处理器层面所作的事情

  • 1.编译器和处理器:为提高程序执行性能,对指令做重排序
    • 重排序规则
      • a.数据依赖性
      • b.as-if-serial
      • c.程序顺序规则
    • 重排序分类
      • a.编译器优化的重排序
      • b.指令级并行的重排序
      • c.内存系统的重排序
    • 重排序的影响
      • 单线程下无影响
      • 多线程下可能会改变程序的执行结果
  • 2.编译器:在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序
    • a.LoadLoad
    • b.StoreStore
    • c.LoadStore
    • b.StoreLoad

 

切入点:说说volatile关键字吧?

好的,首先volatile关键字具有三大特性,即 可见性,原子性,有序性;

可见性,指的是该关键字修饰的对象在多线程情况下任何一个线程对其的更改都及时的对其他线程可见,它实现的原理,就得从Java的内存模型—JMM说起,JMM的作用是在不同的平台上为Java程序提供一致的内存可见性,之所以能做到这点,是因为其 通过控制 主内存和每个线程的本地内存之间的交互,具体来说 就是其通过禁止特定类型的编译器重排序和处理器重排序来提供内存可见性的,这里就谈到了编译器和处理器;为了提高程序执行性能,编译器和处理器会对指令做重排序,这也就是为了多线程情况下程序执行会出问题的根源,而单线程下由于重排序遵守as-if-serial规则,保证了不会出错;

那么回到前面。禁止特定类型的编译器重排序和处理器重排序是怎么做到的呢?那就是通过编译器在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序而做到的。

那么原子性是怎么做到的呢?这是因为volatile写-读的内存语义和锁的释放—获取相同

当写一个volatile变量的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

有序性,通过由JMM分别限制编译器重排序和处理器重排序实现,前面已经说了是通过插入内存屏障的方式


3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题(线程通信、线程同步)

线程之间如何通信线程之间如何同步(这里的线程是指并发执行的活动实体)。

线程通信

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的信机制有两种:共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态通过写-读内存中的公共状态进行隐式通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

线程同步

同步是指程序中用于控制不同线程间操作发生相对顺序的机制

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,而Java线程之间的同步总是显式进行

 

3.1.2 Java内存模型的抽象结构

共享变量:实例域,静态域,数组元素

局部变量、方法定义参数、异常处理器参数不会在线程间共享,那么它们不会有内存可见性问题,也不受内存模型的影响。

什么是JMM?

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(LocalMemory)本地内存中存储了该线程以读/写共享变量的副本本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

线程通信步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

 

3.1.3 从源代码到指令序列的重排序

目的:执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
编译器优化的重排序 编译器在不改变单线程程序语义的前提下(代码中不包含synchronized关键字),可以重新安排语句的执行顺序。
指令级并行的重排序 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

重排序又可以分为两大类,一是编译器重排序:编译器优化重排序,二是处理器重排序:指令级并行重排序、内存系统重排序。

1)编译器优化的重排序

2)指令级并行的重排序(处理器)

3)内存系统的重排序(处理器)

 

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

 

3.1.4 并发编程模型的分类

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁 止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表3-3所示。

 

 

3.1.5 happens-before简介(作用是阐述操作之间的内存可见性)

在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
·监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。(解锁先于加锁)
·volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(写操作先于读操作)
·传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。(先于传递性)

先于条件注意点:

1.两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

2.happens-before规则简单易懂,相当于是JAVA为了方便程序员理解而设计的规则,其实现通过JMM层面的重排序规则和实现方法。也就是说 happens-before是上层规则!!!

 

3.2 重排序

重排序是指编译器和处理器为了优化程序性能对指令序列进行重新排序的一种手段(编译器重排序,指令重排序,内存重排序)

3.2.1 数据依赖性

如果两个操作访问同一变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

上述三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。(也就是说编译器和处理器所遵守的数据依赖性在并发情况下,无法保证程序正确执行)

 

3.2.2 as-if-serial语义

其语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守该语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但,如果操作之间不存在数据依赖关系,则这些操作就可能被编译器和处理器重排序。

as-if-serial语义使单线程程序员无需担心会被重排序干扰,也无需担心内存可见性问题

 

3.2.3 程序顺序规则

JMM仅仅要求前一个 操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A 的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B 按happens-before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

即如果重排序不影响结果,则可以进行重排序!!!

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下, 尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出, JMM同样遵从这一目标。

 

3.2.4 重排序对多线程的影响

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

 

3.3 顺序一致性

顺序一致性内存模型是一个理论参考模型,设计时,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照

3.3.1 数据竞争与顺序一致性(正确同步则无数据竞争)

程序未正确同步,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下:

在一个线程中写一个变量,
在另一个线程读同一个变量,
而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:

如果程序正确同步程序的执行将具有顺序一致性—即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。此正确同步指广义上的同步,包括对常用同步原语的正确使用。

 

3.3.2 顺序一致性内存模型

顺序一致性模型为程序员提供了极强的内存可见性保证,顺序一致性内存模型有两大特性:

1)一个线程中的所有操作必须按照程序的顺序来执行

2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关 可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发 执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

JMM中无此保证!!!

 

正确同步的程序达到顺序一致性效果

JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

 

3.3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取 到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM在堆上分配对象 时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两操作)。因 此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。

 

3.4 volatile的内存语义

3.4.1 volatile的特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意现场)对这个volatile变量最后的写入

锁的语义决定了临界区代码的执行具有原子性,即64位的long和double变量,只要是volatile变量,对其读/写也有原子性。

volatile的特性:

1.可见性

2.原子性(单个变量具有)

3.有序性

 

3.4.2 volatile写-读建立的happens-before关系

JDK5开始,volatile变量的写-读可以实现线程之间的通信

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和 锁的释放有相同的内存语义volatile读与锁的获取有相同的内存语义。

 

3.4.3 volatile写-读的内存语义

volatile写:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

volatile读:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

总结

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

 

3.4.4 volatile内存语义的实现

JMM为实现volatile写/读的内存语义,JMM会分别限制编译器重排序和处理器重排序

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

·在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的前面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义

 

3.4.5 JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内 存模型允许volatile变量与普通变量重排序。在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类 似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供 一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格 限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获 取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile 变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

volatile和锁对比:

volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大在可伸缩性和执行性能上,volatile更有优势如果我们希望在程序中使用volatile代替锁,请一定谨慎

一、volatile的应用

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。(还包括64位原子操作、禁止指令重排序)

可见性:当一个线程修改一个共享变量时,另外一个线程能够读到这个共享变量值。

如果volatile变量修饰符使用恰当 的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

1.1 volatile定义与原理

volatile定义

如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的

注:有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码(添加 lock),通过查IA-32架 构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情。

1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

1.2 volatile的两条实现原则

1)Lock前缀指令会引起处理器缓存回写到内存(从总线锁定变为缓存锁定,使用缓存一致性机制保着修改的原子性)

缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效

2.1 volatile优化使用

著名的Java并发编程大师Doug lea在JDK 7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性 能。

为什么追加64字节能够提高并发编程的效率呢?

因为对于英特尔酷睿i7、酷睿、Atom和 NetBurst,以及Core Solo和Pentium M处理器的L1、L2或L3缓存的高速缓存行是64个字节宽,不 支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将
它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一
个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致
其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头
节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使 用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存
行,使头、尾节点在修改时不会互相锁定。

那么是不是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该
使用这种方式。
·缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个
字节宽。
·共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速
缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常
小,就没必要通过追加字节的方式来避免相互锁定。
不过这种追加字节的方式在Java 7下可能不生效,因为Java 7变得更加智慧,它会淘汰或 重新排列无用字段,需要使用其他追加字节的方式。

2.2 synchronized的实现原理与应用

随着Java SE 1.6对sychronized进行了各种优化之后,有些情况下它就并不是重量级锁了。本节将介绍Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

sychronized实现同步的基础:Java中的每一个对象都可以作为锁。

·对于普通同步方法,锁是当前实例对象。
·对于静态同步方法,锁是当前类的Class对象。
·对于同步方法块,锁是Synchonized括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁

代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有
详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

2.2.1 Java对象头

synchronized用的锁是存在Java对象头里的

Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位,在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变

2.2.2 锁的升级与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

1.偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

当一个线程访问同步块并 获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需 要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则 使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

(1)偏向锁的撤销(看书)

(2)关闭偏向锁(如果确定应用程序里所有的锁通常情况下处于竞争状态,设置JVM参数关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如 有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态

2.轻量级锁

(1)轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并 将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁

(2)轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头如果成 功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了)一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

2.3 原子操作的实现原理

2.处理器如何实现原子操作?(处理器层面)

处理器提供总线锁定缓存锁定两个机制来保证复杂内存操作的原子性。

总线锁定:

处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

缓存锁定:

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声 言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处 理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图2-3所示的例子中,当CPU1修 改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。

但是有两种情况下处理器不会使用缓存锁定。

第一种情况是:(不在处理器内部)当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行 (cache line)时,则处理器会调用总线锁定。
第二种情况是:(处理器不支持)有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定

3.Java如何实现原子操作?(Java层面) 建立在处理器层面之上

在Java中可以通过循环CAS的方式来实现原子操作。

(1)使用循环CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本 思路就是循环进行CAS操作直到成功为止

(2)CAS实现原子操作的三大问题

1)ABA问题

2)循环时间开销大

3)只能保证一个共享变量的原子操作(取巧—将多个共享变量合并为一个共享变量来操作,JDK1.5提高AtomicReference)

(3)使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现的锁机制有偏向锁、轻量级锁、互斥锁,除了偏向锁其他的实现方式都使用了循环CAS,当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时 候使用循环CAS释放锁。

技术并无高低,只有是否适用。并发编程也是如此,在某些场景下,使用并发的策略会提高效率,但另一些情况下使用并发反而会较低效率;同时,并发虽好,使用时可不要对其掉以轻心

因为并发往往会带来许多挑战

1.上下文切换

并发是一种策略:将某任务拆分,让多个线程去实现(狭义理解),正因如此,单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现。由于需要进行进程切换,在切换前,我们会保存该线程的状态,等到下次该线程得到时间片的时候方便加载,那么从保存到加载就称之为一次上下文切换。上下文切换需要消耗资源!

解决办法:减少上下文切换

  • 无锁并发编程(分段锁—ConCurrentHashMap)
  • CAS算法(Atomic类,无需加锁)
  • 使用最少线程(避免创建不必要的线程)
  • 使用协程(单线程实现多任务调度,维持多个任务间的切换)

2.死锁

线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行。

当线程进入对象的synchronized代码块时,便占有了资源,直到它退出该代码块或者调用wait方法,才释放资源,在此期间,其他线程将不能进入该代码块。

当线程互相持有对方所需要的资源时,会互相等待对方释放资源,如果线程都不主动释放所占有的资源,将产生死锁。

死锁产生的原因:

1) 系统资源的竞争

2) 进程推进顺序非法

死锁产生的必要条件:产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

1)  资源互斥条件(如打印机)

2) 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走

3) 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放

4) 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求

解决办法:破坏其必要条件(四个中的一个)

·避免一个线程同时获取多个锁。
·避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
·尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
·对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

3.资源限制

什么是资源限制?

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资 源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限 制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接 数和socket连接数等。

资源限制引发的问题

在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程 序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不
能运行完成任务,后来修改成单线程,一个小时就执行完成了。

如何解决资源限制的问题(硬件集群,软件复用)

对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让 程序在多机上运行。

对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket 连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。

在资源限制情况下进行并发编程(因地适宜)

如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整 程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作 时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接

对于大部分程序员来说,如果你学习的技术,并不能转换为生产工具,那就毫无意义。因此,在学习并发编程之前,我们有必要了解

什么是并发(转载)

作者:大宽宽
链接:https://www.zhihu.com/question/307100151/answer/894486042
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

深度好文!!!值得推荐!!!

【并发】(Concurrency)是由【P进程】引申出来的抽象概念。

上面说到了你可以假设自己一个人按照一定的步骤来铺路,一个人从头干到尾,这是一个“串行”的【P进程】。

但你也可以假设有2个人铺路。比如你可以按照长度分两半,一人铺500m * 50m;也可以按宽度划分,一人铺1000m * 25m;你还可以说让一个人负责铺全部路面的前5个步骤,另外一个人负责铺路面的余下5个步骤。然后你可以进一步想,假如不是雇2个人,而是雇20个人概如何分工呢?你可以混搭按长度,宽度,步骤等各种方式进行拆分。你甚至可以考虑这20个人不是完全一样的,有的能力强,有的能力弱,可以适当的调整工作量的比例等等。

不管怎样拆,都意味着你得到了【并发】的【P进程】。换成说人话就是,你有一套方案,可以让多个人一起把事情做的更高效。注意是“可以“让事情更高效,而不是“必然“让事情更高效。是不是更高效要看到底是怎么执行的,后边会讲。

举个写代码的例子,你有一个很长很长的数组,目标是把每一个数都*2。一个并发的做法就是把数组拆为很多个小段,然后每个小段的元素依次自己*2。这样的程序写出来就是一个【并发】的【程序】。这个程序如果运行起来就是【并发】的【OS进程】。

这时就会出现一个问题,当你想把一个【并发】的【P进程】写成程序时,你怎么用编程语言告诉操作系统你的程序的一些步骤是【并发】的。更确切地说,你需要一个写法(可能是语法,也可能是函数库)表达:

  • 几个任务是【并发】的
  • 【并发】的任务之间是怎么交互协作的

为了解决这两个问题,人们总结了一些方法,并将其称为“并发模型”。

比如:

  • Fork & Join模型(大任务拆解为小任务并发的跑,结果再拼起来)
  • Actor模型(干活的步骤之间直接发消息)
  • CSP模型(干活的步骤之间订阅通话的频道来协作)
  • 线程&锁模型(干活的人共享一个小本本,用来协作。注意小本本不能改乱套了,所以得加锁)
  • ……

以Java中的线程为例,大家想表达【并发】就启动新的Thread(或者某种等价操作,如利用线程池);想让Thread之间交互,就要依靠共享内容。但是【并发】的Thread如果同时修改同一份数据就有可能出错(被称为竞争问题),为了解决这个问题就要引入锁(Lock,或者一些高级的同步工具,如CountdownLatch,Semaphore)。

特别强调下,Java的线程是表达并发的概念的类。这个类在绝大部分操作系统上使用操作系统内核中的【线程】实现。二者之间还是有一些细微的差异。即用开发者用Java Thread写代码表达思路,和操作系统调度线程执行是两个层面的事情。请努力认识到这一点。

再比如Erlang是基于Actor的并发模型(其实这是原教旨主义的OO)。那么就是每个参与【并发】的任务称为Process(又一个进程……,和【P进程】以及【OS进程都不太一样】,叫【E进程】好了,Erlang中的”进程“)。【E进程】之间通过消息来协作。每个【E进程】要不是在处理消息,要不就是在等新的消息。

如果你用go,那么表达并发的工具就是goroutine,goroutine之间协作要用channel。(当然也可以用Sync包加锁,不展开)。

对于并发模型《7周7并发模型》这本书讲的非常好。推荐阅读。书中展示了七种最经典的并发模型和大量的编码实例。

 

为啥要并发

把事情设计为【并发】有什么好处呢?假如能同时干活的人只有1个,其实并没有什么好处。【并发】的方法的总耗时总会>=串行的方法。因为【并发】或多或少总会引入需要协作和沟通成本。最小的代价就是不需要沟通,此时【并发】的方法和串行的方法工作量是一样的。

但是【并发】的巨大优势是在可以干活的人数量变多时,马上得到【并行】的好处。假如我们可以得到一个【并发】的【P进程】,并且真的为其配备足够多的人,那么做事的效率就会高很多。回到软件系统,假如有一个【并发】的【程序】,它在只有1个CPU的核心的机器上可以跑,在2个的CPU的也可以跑,在4核CPU上也可以跑。物理上可用CPU核心越多,程序能够越快执行完。而不管在哪里跑,程序本身不用做变化。编程是一件成本很高的事,能够做到程序不变而适应各种环境,可以极大的降低开发成本。你能想象下为1核心CPU开发的Office软件和4核心的不一样吗?

 

并发和并行的关系是什么

【并发】(Concurrency)这个词的本意是指两件事没有谁先谁后的关系,或者说关系不确定。举个通俗的例子,自然数任何两个数字都可以比较大小。我们可以明确地说5 > 3。但是如果换一个领域,并不是任何两个元素都有明确的顺序关系,或者说“谁在前面谁在后面都是可以的“。

对于任务执行这个领域,对于两个任务A和B,如果我们说他们俩是【并发】的,这就要求不能在任务B里使用A的结果,也不能让A执行时使用B的结果。因此在执行层面,A可以在B之前执行,也可以在B执行,或者A和B交替执行,或者A和B【并行】的执行。不管执行层面怎么折腾,结果都是对的。

反过来,如果A的执行需要B的结果,那也就意味着A和B不是【并发】的,必须让B先执行完,A才可以开始。在实现层面,就可以用加锁、channel等方式来表达“先B后A”。

Rob Pike在一个Talk里(blog.golang.org/concurr)提到了很重要的两个观点:

  • Concurrency is not Parallelism
  • Concurrency enables parallelism & makes parallelism (and scaling and everything else) easy

前一个观点【并发】和【并行】不是一件事,我们都可以理解了。【并发】说的是处理(Deal)的方法;【并行】说的是执行(Execution)的方法。

后一个观点指的是,如果想让一个事情变得容易【并行】,先得让制定一个【并发】的方法。倘若一个事情压根就没有【并发】的方法,那么无论有多少个可以干活的人,也不能【并行】。比如你让20个人不铺路,而是一起去拧同一个灯泡,也只能有一个人踩在梯子上去拧,其他19个人只能看着,啥也干不了。

对于一个问题,能不能找到【并发】的办法,取决于问题本身。有些问题很容易【并发】,有些问题可以一部分【并发】其余的串行(比如对数组排序就是,无论怎么拆,最终也要把每个拆开的问题结果合并到一起再排序才行),有些问题则根本上就不能【并发】。找不到【并发】的方法也就意味着不管有多少CPU核心,也没法【并行】执行。

换一个极端,假如为最多20个人设计了【并发】的方法,结果来了40个人,就意味着40人里有20个人是闲着的,是浪费。也就是说【并行】的上限是由【并发】的方法的设计决定的。这就解释了你吃鸡的时候,4核CPU和8核差别不大,因为这个游戏压根就没设计成可以利用这么多个CPU核心。(BTW,但游戏被设计为能充分利用显卡的多核心)

其实上面只是将CPU核心当作是“做事的人“,再广义一点,比如显卡,网卡,磁盘都是独立的可以干活的人。这些组件之间也可以并行的跑。因此,在设计程序的时候,可以比如把计算和IO任务拆开设计一个【并发】的方法,然后利用CPU和网卡是两个零件来【并行】的跑

常见的误解

你可能看到过下面的论断:

并发是多个任务交替使用CPU,同一时刻只有一个任务在跑;并行是多个任务同时跑

这个理解不能说全错,但是合到一起就形成了错误的理解。这个错误的理解就是:并发和并行是两个并列的,非此即彼的概念,一个状态要不就是并行的,要不就是并发的。这是完全错误的,实际上看到上面的解释你就会发现【并发】和【并行】描述的是两个频道的事情。正如Rob Pike所言,一个是“如何处理”,一个是“如何执行”。因此,对于:

并发是多个任务交替使用CPU,同一时刻只有一个任务在跑

其实正确的理解是:针对一个问题,想到了一个可以拆解为多个【并发】的任务,这些任务执行时因为只有一个CPU只能“切换”的跑。

对于:

并行是多个任务同时跑

其实的意思是:如果这些并行执行的任务是解决同一个问题的,那么他们既是【并发】的,同时也是【并行】的。

那么可不可以做到只【并行】,而不【并发】呢?当然可以,但这也就意味着【并行】的程序之间没有什么关联,各干各的,就像大街上来来往往的陌生人一样。这的确是【并行】,并且是这个世界的常态。但是一群不认识的,各干各的人是不能一起解决问题的,要一起就得有同一个目标,制定一套沟通的方法,形成【并发】的方案。这种形式在现实当中就是“公司”。