Java 多线程 相关概念

3,560 阅读12分钟

前言

本篇文章介绍一些多线程的相关的深入概念。理解后对于线程的安全性会有更深的理解。

先说一个格言,摘自Java核心技术:
如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取;或者一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须同步。

下面就是概念了。

1. Monitor机制:

  • Monitor其实是一种同步工具、同步机制,通常被描述成一个对象,主要特点是:

    1. 同步。
      对象的所有方法都被互斥的执行。好比一个Monitor只有一个运行“许可”,任一个线程进入任何一个方法都需要获得这个“许可”,离开时把许可归还。
    2. 协作。
      通常提供signal机制。允许正持有许可的线程暂时放弃许可,等待某个监视条件成真,条件成立后,当前线程可以通知正在等待这个条件的线程,让它可以重新获得运行许可。
  • 在 Monitor Object 模式中,主要有四种类型参与者:

    1. 监视者对象 Monitor Object
      负责公共的接口方法,这些公共的接口方法会在多线程的环境下被调用执行。
    2. 同步方法
      这些方法是监视者对象所定义。为了防止竞争条件,无论是否有多个线程并发调用同步方法,还是监视者对象还用多个同步方法,在任一事件内只有一个同步方法能够执行。
    3. 监控锁 Monitor Lock
      每一个监视者对象都会拥有一把监视锁。
    4. 监控条件 Monitor Condition
      同步方法使用监视锁和监视条件来决定方法是否需要阻塞或重新执行。
  • Java中,Object 类本身就是监视者对象,Java 对于 Monitor Object 模式做了内建的支持。

    • Object 类本身就是监视者对象
    • 每个 Object 都带了一把看不见的锁,通常叫 内部锁/Monitor 锁/Instrinsic Lock, 这把锁就是 监控锁
    • synchronized 关键字修饰方法和代码块就是同步方法
    • wait()/notify()/notifyAll() 方法构成监控条件(Monitor Condition)

2. 内存模型

Java的并发采用的是共享内存模型,线程间通信是隐式的,同步是显示的;而我们在Android中所常说的Handler通信即采用的是消息传递模型,通信是显示的,同步是隐式的。

  • 并发编程模型的分类
    并发编程中,需要处理两个问题:线程之间如何通信、线程之间如何同步。

    • 通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
      在共享内存的并发模型里,线程之间通过写-读内存中的公共状态来隐式进行通信;而在消息传递模型里,线程之间没有公共状态,必须通过明确的发送信息来显示进行通信。
    • 同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
      在共享内存并发模型里,同步是显示进行的,程序员必须显示指定某段代码或方法需要在线程间互斥执行;而在消息传递模型中,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
  • Java内存模型的抽象
    Java堆内存在线程间共享,下文所说的共享变量即被存储在堆内存中变量:实例域、静态域和数组。局部变量、方法定义参数和异常处理参数不会在线程之间共享,不会有内存可见性问题,也不受内存模型影响。

  • Java线程之间的通信由Java内存模型(JMM,Java Memory Module)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。
    JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,也叫工作内存,本地内存中存储了该线程以读/写共享变量的副本。(本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。)
    所以线程A和线程B要通信步骤如下:

    1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去
    2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量
  • 线程模型图

    线程模型
    线程模型

3. 原子性

原子性指:一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。
java.util.concurrent.atomic包中很多类使用了CAS指令来保证原子性,而不再使用锁。如AtomicIntergerAtomicBooleanAtomicLongAtomicReference等。
原子性不保证顺序一致性,只保证操作是原子的。

4. 内存可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。

  • 从上面可知道线程模型,线程a对共享变量修改时先把值放到自己的工作内存中,然后再把工作内存中的共享变量更新到主内存中;线程b同样如此;当线程a更新了主内存后线程b刷新工作内存后就能看到a更新后的最新值。这就是内存可见性问题。
  • 内存可见性要保证两点:
    1. 线程修改后的共享变量更新到主内存;
    2. 从主内存中更新最新值到工作内存中;

5. happens-before

happens-before规则对应于一个或多个编译器和处理器重排序规则,对于程序员来说,该规则易懂,避免为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

使用happens-before的概念来阐述操作之间的内存可见性
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
这两个操作可以在一个线程内,也可以是不同线程。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作前执行;仅仅要求前一个操作的执行结果对后一个可见,且前一个操作按顺序排在第二个操作之前。

  • 传递规则:如果操作1在操作2前面,而操作2在操作3前面,则操作1肯定会在操作3前发生。该规则说明了happens-before原则具有传递性
  • 锁定规则:一个unlock操作肯定会在后面对同一个锁的lock操作前发生。这个很好理解,锁只有被释放了才会被再次获取
  • volatile变量规则:对一个被volatile修饰的写操作先发生于后面对该变量的读操作
  • 程序次序规则:一个线程内,按照代码顺序执行
  • 线程启动规则:Thread对象的start()方法先发生于此线程的其它动作
  • 线程终结原则:线程的终止检测后发生于线程中其它的所有操作
  • 线程中断规则: 对线程interrupt()方法的调用先发生于对该中断异常的获取
  • 对象终结规则:一个对象构造先于它的finalize发生

6. CAS指令

是现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-写-改操作,这是在多处理器中实现同步的关键。
AtomicIntergerAtomicBooleanAtomicLong的实现都是基于CAS指令。

7. 重排序

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的提高开发并行度。

  • 编译器和处理器会对指令进行重排序以提高性能,重排序有三种类型:
    1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    2. 指令级别的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。
    3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。
  • 这些重排序都可能会导致多线程程序出现内存可见性问题。
    对于处理器重排序,JMM会要求编译器在生成指令序列时插入特定类型的内存屏障指令来禁止特定类型的处理器重排。
    JMM属于语言级别的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止一些重排序问题来保证内存可见性。
  • as-if-serial语义
    是指不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
    所以,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

  • 数据依赖性

    • 有三种类型:
      1. 写后读:a = 1; b = a;
      2. 写后写:a = 1; a = 2;
      3. 读后写:a = b; b =1;
    • 举个例子:
      int a = 1; int b = 1; int sum = a + b;
      A和B不存在数据依赖,sum却依赖A和B。所以执行顺序可能是ABsum,也可能是BAsum。
  • 重排序对多线程的影响
    重排序破坏了多线程程序的语义。对于存在控制依赖的操作(if语句)进行重排序,因为单线程程序是按顺序来执行的,所以执行结果不会改变;而多线程程序中,重排序可能会改变运行结果。
    对控制依赖if(flag){b = a*a}的重排序如下,编译器和处理器会采用猜测执行来克服相关性来对并行度的影响,对先提取并计算a*a,然后把计算结果保存到名为重排序缓冲的硬件缓存中,接下来再判断flag是否为真。另一个线程设置为true了,并设置a=1,然而取得的值可能为0,与预期不符。这就是影响的一个案例。

  • 重排序的一个示例,摘自EffectiveJava:

    while(!done) {
      i++
    }
    //重排后。这种优化称作提示,是HopSpot Server VM的工作
    if(!done){
      while(true) {
        i++;
      }
    }

8. 顺序一致性

如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

  • 顺序一致性内存模型(为程序员提供了极强的内存可见性保证)的两大特性:
    1. 一个线程中的所有操作必须按照程序的顺序来执行
    2. 所有线程都只能看到一个单一的操作执行顺序。每个操作都必须原子执行且立刻对所有线程可见。
  • 其中对顺序一致性和原子性的区别
    原子性保证操作的原子性,而不是顺序的一致性。

9. volatile域

首先要明确,线程的安全性需要三点保证:原子性、可见性,顺序性。只有满足了这三个条件时线程才是安全的。

  • 一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
    1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
      volatile 变量保证的是一个线程对它的写会立即刷新到主内存中,并置其它线程的副本为无效,它并不保证对 volatile 变量的操作都是具有原子性的。
    2. 禁止进行指令重排序。
  • synchronized、Lock完全保证了这三点;volatile仅保证了可见性和顺序性(禁止指令重排),在某些情况下可以使用volatile代替synchronized以提高性能。在这种情况下,volatile是轻量级的synchronized。

    • 某些情况下是指:
      假设对共享变量除了赋值以外并不完成其他操作,那么可以将这些共享变量声明为volatile。即共享变量本身的操作是原子性的、顺序性的,只缺可见性了,此时可以用volatile关键字。在使用时要仔细分析。
      具体是指:

      • 对变量的写操作不依赖于当前值。
      • 该变量没有包含在具有其他变量的不变式中。
    • 要记住,原子性指的是对共享变量的操作(包括其子操作,即多条语句)是一块的,要么执行,要么不执行。不是说用了AtomicInteger就是原子性的,而是对AtomicInteger这个共享变量的操作是不是多条语句,这些多条语句是不是原子性的。

  • 经典示例1:单例模式

  • 经典示例2:

    boolean volatile isRunning = false;
    public void start () {
    new Thread( () -> {
      while(isRunning) {
        someOperation();
      }
    }).start();
    }
    public void stop () {
    isRunning = false;//只有赋值操作,非多条语句
    }


参考:
Java进阶(二)当我们说线程安全时,到底在说什么
Java并发编程:volatile关键字解析
并发模型——共享内存模型(线程与锁)理论篇
《深入理解Java内存模型》