CH02-理论基础

多线程的优势

CPU、内存、IO 设备的速度存在巨大差异,为了合理利用 CPU 的高性能,平衡三者之间的速度差异,计算机体系结构、操作系统、编译程序实现了相关优化:

  • CPU 增加了缓存,以平衡与内存的速度差异——导致了可见性问题
  • 操作系统提供了进程、线程,以分时复用 CPU,进而均衡 CPU 与 IO 设备之间的速度差异——导致原子性问题
  • 编译程序优化了指令执行顺序,使缓存能够得到更合理的利用——导致了有序性问题

线程不安全

如果多个线程对同一份数据执行读写而不采取同步措施的话,可能导致混乱(非预期)的操作结果。

class ThreadUnsafeCounter {
  private int count =0;
  
  public void add() {
    count++;
  }
  
  public int get() {
    return count;
  }
}

class Bootstrap {
  public static void main(String[] args) {
    int threadSize=1000;
    ThreadUnsafeCounter counter = new ThreadUnsafeCounter();
    CountDownLatch latch = new CountDownLatch(threadSize);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0;i<threadSize;i++){
      executor.execute(() -> {
        counter.add();
        latch.countDown();
      })
    }
    
    latch.await();
    executor.shutdown();
    System.out.println(counter.get()); // will always < 1000
  }
}

并发三要素

可见性:CPU 缓存

可见性:一个线程对共享变量的修改,其他线程能够立即看到。

// thread 1
int i=0;
i=10;

// thread 2
j = i;

如果 CPU1 执行 Thread1、CPU2 执行 Thread2。当 Thread1 执行 i=10 时,会首先将 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为 10,那么在 CPU1 的高速缓存中 i 的值变为了 10,却被没有被立即写回主存。

此时 Thread2 执行 j=i,首先去主存读取 i 的值加载到 CPU2 的高速缓存,(这时主存中 i 的值仍未 0),这就导致 j 的值为 0,而非 10。

原子性:分时复用

原子性:一个操作或多个操作那么全都执行,要么全不执行,不会被任何因素打断。

有序性:指令重排序

有序性:程序执行的顺序完全按照代码的先后顺序执行。

程序执行时为了提高性能,编译器和处理器通常会对执行进行重排序,分为三种类型:

  1. 编译器优化:编译器再不改变单线程程序语义的前提下,重新安排语句的执行顺序。

  2. 指令级并行:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统重排序:由于处理器通过高速缓存读写缓冲区,是的加载和存储操作看上去实在乱序执行。

从 Java 代码到最终要执行的指令序列,会经历以上三种重排序。

  • 第一种属于编译器重排序,2、3 属于处理器重排序。
  • 这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 中的编译器重排序规则会禁止特定类型的重排序操作。
  • 对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过这些内存屏障指令来禁止特定类型的处理器重排序操作。

Java 如何解决并发问题:JMM

核心知识点

Java 内存模型规范了 JVM 如何提供按需禁用编译和缓存优化的方法。

  • volatile、synchronized、final 关键字
  • Happens Before 规则

可见性、有序性、原子性

  • 原子性:Java 中通过 synchronized 和 Lock 实现原子性保证。
  • 可见性:Java 中通过 volatie 提供可见性保证。
    • synchronized 和 Lock 保证同一时刻只有一个线程获取锁然后执行代码,释放锁前或将数据刷新到主存。
  • 有序性:Java 中通过 volatile 保证一定的有序性。
    • synchronized 和 Lock 保证同一时刻只有一个线程执行,相当于多个线程顺序执行代码,即有序执行。

volatile、synchronized、final

Happens Before

除了 volatile、synchronized、Lock 能够保证有序性,JVM 还规定了先行发生规则,使一个操作无需显式控制即可保证先于另一个操作发生。

  1. 单一线程:Single Thread Rule

    • 在一个线程内,程序中前面的操作先于后面的操作。

      NAME
  2. 管程锁定:Monitor Lock Rule

    • 一个 unlock 操作先于后面对一个锁的 lock 操作。
    NAME
  3. Volatile 变量:Volatile Variable Rule

    • 对一个 volatile 变量的写操作先于对该变量的读操作。

      NAME
  4. 线程启动:Thread Start Rule

    • Thread 对象的 start 方法先于该线程的每一个动作。

      NAME
  5. 线程加入:Thread Join Rule

    • Thread 对象的结束先于 join 方法返回。

      NAME
  6. 线程中断:Thread Interruption Rule

    • 对线程 interrupt 方法的调用先于检测到中断的代码执行。
  7. 对象终结:Finalizer Rule

    • 对象构造函数执行完成先于 finalize 方法开始。
  8. 传递性:Transitivity

    • 如果操作 A 先于 B,B 先于 C,那么 A 先于 C。

线程安全:安全程度

一个类可以被多个线程安全调用时,该类就是线程安全的。

将共享数据按照安全程度的强弱来划分安全强度的等级:

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

不可变

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。

  • final 关键字修饰的基本数据类型

  • String

  • 枚举类型

  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

  • Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。

绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。

相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

线程安全:实现

互斥同步—阻塞同步

  • synchronized
  • ReentrantLock

非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

  • CAS

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个参数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

  • AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。

  • ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

  • 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

  • ThreadLocal

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

  • 可重入代码

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。