天天看点

漫道多线程(二):临界区、锁与JMM

欢迎大家查看我的上一篇博客:多线程与并行计算简述

临界区

在上一章,我们就讨论过,在多线程程序中数据是脆弱的,而这些脆弱的数据在多线程的概念中就是所谓的临界区

临界区用来一种公共资源资源或者是共享享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程想要使用这个资源,就必须等待。

锁的作用就是保障临界区

阻塞与非阻塞

阻塞与非阻塞是用来形容线程间的影响的。

通过临界区的定义我们可知,当一个线程占用了临界区资源,那么其他线程必须在这个临界区等待。等待会导致线程挂起,这种情况就是阻塞。

简而言之,一个线程导致其他线程进行等待的过程就是阻塞。反之,一个线程没有妨碍其他线程的运行就是非阻塞。

死锁、饥饿以及活锁

  • 死锁:两个线程或进程在请求对方占有的资源。导致双方都无法正常运行。

死锁是最糟糕的一种情况,就如同我们程序员界的一个段子:

当一个没有工作经验且求知若渴的人士去应聘该企业时,该企业却只找工作经验的人,并且也只一个一个面试者进行面试时。如果没有做其他优化处理的话(各自好好放过对方),求职线程和招聘线程就会反复卡在这里。这就是一个关于死锁的典型场景。如果出现大量的死锁的情况,有几率引起资源的耗尽,会鱼死网破.jpg

图片如下:

漫道多线程(二):临界区、锁与JMM
  • 饥饿:某些线程因为种种原因无法获得所需的临界资源,导致一直无法执行。

一般出现在非公平锁的情况,比如a线程的优先级低,一直被优先级高的线程抢占临界区。

假设,现实生活中,某个小银行vip和普通用户使用同一个通道,并且银行优先给vip办理业务,当一个普通用户和n个vip用去去银行办理业务时,普通用户则一直无法完成业务办理。这种情况就称之为饥饿。

  • 活锁:两个线程或进程在谦让自己已有的资源,都让对方进行进行运行。导致双方都无法正常运行。本质是重试+回滚
如同两个非常有礼貌,懂谦让,但是略微古板的绅士同时准备跨入一家酒馆时,都发现了对方,先后收回跨入酒馆的脚步,并且让对方先进入,自己再才进入,当两个人都如此想的时候,导致双方都无法进入酒馆。这种反复绅士谦让的行为就是活锁。所以这个道理告诉我们,太过绅士也会什么东西都办不成.jpg。

注:活锁与死锁相反,死锁是加不上就通过阻塞死等,活锁是加不上就放开已获得的资源重试。

所以说如果死锁是遵从抢占的原则的话,那么活锁就是遵从谦让的原则。

死锁、饥饿和活锁都属于多线程的活跃性问题。如果发现上述几种情况,那么相关线程可能就不再活跃,也就说它可能很难再继续往下执行了。

5个并发级别

  • 阻塞: 是一种悲观的调度策略,当多个线程冲突时,只允许一个线程进行使用资源,其他线程会被挂起,直到该线程释放资源。 (一个线程是阻塞的,在其他线程释放资源前,当前线程无法执行。)
  • 无饥饿:以先来后到为原则而非以优先级进行线程的调度执行。
  • 无障碍:非阻塞是一种乐观的调度策略,默认多个线程大概率不会冲突,如果发生冲突的话就会选择回滚数据。
  • 无锁:无锁的并行都是无障碍的,是无障碍概念的子集,相对于无障碍,无锁保证一个线程会在有限步操作中胜出。
  • 无等待:是无锁的子集,是最高级别的并发,要求所有的线程都必须在有限步内完成,可见,其性能最强。

注:对于非公平的锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平锁,满足先来后到,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源,就必须乖乖排队。那么所有的线程都有机会执行。

并发级别与活跃性问题

阻塞锁会出现死锁+饥饿的问题。

无障碍锁可能会出现活锁+饥饿的问题,故无障碍锁开源算是最弱的非阻塞调度机制。

当使用无障碍锁的时候,多线程不一定开源顺畅的运行。因为当临界区中存在严重的冲突时,所有的线程都可能会不断的回滚自己的操作,而没有一个线程可以走出临界区。

关于无障碍、无锁、无等待所必须知道的一些事情!

共同点:都属于乐观锁,采用非阻塞的策略完成对于临界区的资源保护,而且需要避免活锁的情况。

区别:

  • 无障碍锁是最弱的非阻塞锁。有几率出现活锁的问题
  • 无锁的不会出现活锁问题的非无障碍锁。因为它保证,在多个线程的并发中,必然会存在一个线程在有限步的操作中离开临界区。但是会出现饥饿问题
  • 无等待是最高级别的并发锁,要求所有的线程都必须在有限步内完成,不会出现活锁+饥饿的问题。无等待可以说是并行最高级别了,它基本上能使整个系统发挥到最好佳效率。

如何实现非阻塞锁

一种可行的无障碍实现是以来一个“一致性标记”来实现。线程在操作前先读取并保存这个标记,在操作完成后再读取检查这个标记是否被更改如果是一致的则表示资源没有访问冲天,否则表示有其他线程对此资源有过修改需要重试操作。而任何对资源有修改的线程在更改数据前需要更新这个一致性标记,表示数据不安全。

关于线程间的通信和同步模型

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

共享内存的通信是显式通信,需要线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,其中 erlang 以及java的 aKKa框架 就是采用的消息模型处理并发问题。

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

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java内存模型——JMM

在上一篇博客中,我们知道java线程是基于共享状态锁实现的,所以java是基于共享内存模型进行的并发通信。

而这内存模型也称之为JMM

JMM即为JAVA 内存模型(java memory model)

为什么需要jmm?

因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。

关于jmm

  1. JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
  2. 从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存/工作内存(Local Memory/Working Memory),本地内存中存储了该线程以读/写共享变量的副本。
  3. 本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

注:线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

主内存以及本地内存对应jvm的那一部分?

对于JMM和JVM本身的内存模型,这两者本身没有关系。JVM内存模型是指的jvm内存分区,JMM的目的是为了解决Java多线程对共享数据的读写一致性问题。

在多线程的环境下,就必须提JMM(java内存模型)

在JVM内部使用的java内存模型(JMM)将线程栈和堆之间的内存分开:

  • JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
  • 对于原始类型的局部变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。并且直接保存在线程栈当中
  • 堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的局部变量,它都会被存储在堆区。
  • 一个局部变量如果是原始类型,那么它会被完全存储到栈区。 一个局部变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。

    对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使它们所属的对象在堆区。 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

注:许多程序员通常把jmm和jvm内存划分模型搞混,其实这是两种完全不同的概念,我们通常所说的堆栈都是指的更加抽象的jmm内存模型,而非更加底层的jvm内存划分模型。如果是jvm的内存模型就不单纯只是比较模棱两可的堆栈,需要说出具体的方法区以及常量池等。

JVM内存分区模型中包括:程序计数器(PC)、java虚拟机栈、本地方法栈、java堆、方法区

程序计数器(PC)

程序计数器是一块很小的内存空间,用于记录下一条要运行的指令。每个线程都需要一个程序计数器,各个线程之中的计数器相互独立,是线程中私有的内存空间

java虚拟机栈

java虚拟机栈也是线程私有的内存空间,它和java线程同一时间创建,保存了局部变量、部分结果,并参与方法的调用和返回

本地方法栈

本地方法栈和java虚拟机栈的功能相似,java虚拟机栈用于管理Java函数的调用,而本地方法栈用于管理本地方法的调用,但不是由Java实现的,而是由C实现的

java堆

为所有创建的对象和数组分配内存空间,被JVM中所有的线程共享

方法区

也被称为永久区,与堆空间相似,被JVM中所有的线程共享。方法区主要保存的信息是类的元数据,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息,其中运行时常量池就在方法区,对永久区的GC回收,一是GC对永久区常量池的回收;二是永久区对元数据的回收

如果一定要勉强对应,则从变量,主内存,工作内存的定义来看,主内存主要是对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就是物理内存,而为了获取更好的执行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为运行时主要访问–读写的是工作内存。

jmm带来的问题

Java内存模型是围绕着并发过程中如何处理原子性、可见性、有序性这三个特征来建立的,下面是这三个特性的简述:原子性(Atomicity)

原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在java中原子性指一个操作不可中断。即使是多个线程的一起执行的时候,一个操作一旦开始,就不会被其他操作打扰。

单次操作的原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。

多次操作的原子性:如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块—synchronized关键字,因此在synchronized块之间的操作也具备原子性。

Java中的原子操作至少包括:

  • 除long和double之外的基本类型的赋值操作
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.* 包中所有类的一切操作。

注:java对long和double的赋值操作是非原子操作!long和double占用的字节数都是8,也就是64bits。在32位操作系统上对64位的数据的读写要分两步完成,每一步取32位数据。所以算是多次操作,需要使用volatile等锁方式才能保证原子性。

可见性

**可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。**显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。

但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。

注意,该情况会大多发生在多核cpu的情况,单核cpu的多线程出现问题的几率会小。

至于为什么?答案:缓存一致性。

在现代计算机中,cpu的指令速度远超内存的存取速度,所以计算机系统都不得不加入高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

因此,就会出现一个线程修改了一个共享变量,其他线程不能马上知道这个改动。

我们上面说过,可见性一般发生在多核cpu中,但是单核cpu的多线程出现该问题的几率较小,但是依然会发生。那么为什么单核cpu没有一致性缓存的情况,也会发生可见性问题?

这是因为可见性问题是一个综合问题。除了缓存优化外,还会有硬件优化,指令重排以及编辑器的优化都会导致一个线程的修改不会立即被其他线程发觉。

面对这么多棘手的可见性问题,可以通过java提供的volatile等手段保护临界区。

有序性

有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码而言,我们总是习惯地认为代码的执行是从先往后,依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,是吗?有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。

不过这里还需要强调一点,对于一个线程来说,它看到的指令执行顺序一定是一致的(否则的话我们的应用根本无法正常工作,不是吗?)。也就是说指令重排是有一个基本前提的,就是保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此,在串行代码中,大可不必担心。

注意: 指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。

为什么多线程在执行指令时会乱序呢?

这其实是因为cpu底层采用了一套流水线设计的操作,

因为一套指令需要经过许多步骤依次执行的,而且每次执行都会涉及不同的硬件,取指、译码、执行、访存、写回都会用到不同的硬件等。

所以,由于每一个步骤都会用到不同的硬件完成,聪明的工程师就发明了流水线技术来执行指令。

流水线示例:

我们首先每行java代码会被jvm编译为多条指令。

比如a=b+c就会被优化为4条指令

  • 加载b的值到R1(寄存器1)
  • 加载c的值到R2(寄存器2)
  • 将R1和R2的值相加存放到R3(寄存器3)
  • 把寄存器3的值回写到a

而且每个指令都会有取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)这5个过程。那么流水线处理如图所示:

指令以及流水线处理阶段 stage1 stage2 stage3 stage4 stage5 stage7 stage8 stage9 stage10
load b->R1 IF ID EX MEM WB
load c->R2 IF ID EX MEM WB
R1 ADD R2 -> R3 IF ID X EX MEM WB
load R3->a IF X ID EX MEM WB

分析:

x就代表流水线中断的意思,为什么这里会流水线中断,是因为R2的数据还没有准备好!所以低ADD操作必须中断一次,由于ADD的中断,导致后面的操作也进行了一次中断。

这就是流水线的示例,指令的重排的示例更加复杂,这里就不详细描述了,如果大家感兴趣可以去看一下《实战java高并发程序》这本书,对于指令重排讲的非常通俗易懂,本篇博客也多有参考该书。

简而言之,指令重排也是为了避免流水线的中断,提高硬件的利用率。但是效果是会因为重排导致多线程的情况读在写前。容易导致可见性问题。

那些指令不能重排

哪些指令不能重排:Happen-Before 规则

在前文已经介绍了指令重排,虽然 Java 虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。

程序顺序原则:

  • 一个线程内保证语义的串行性
  • volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性
  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
  • 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C
  • 线程的 start() 方法先于它的每一个动作
  • 线程的所有操作先于线程的终结(Thread.join())
  • 线程的中断(interrupt())先于被中断线程的代码
  • 对象的构造函数执行、结束先于 finalize() 方法

以程序顺序原则为例,重排后的指令绝对不能改变原有的串行语义。

最后,看在博主这么辛苦的写博客的份上,希望大家一键三连!!!