之前没有集中学习过线程、锁、并发容器和线程池等,学习时间一直很散,希望能通过这两三天的学习使自己对 java 并发编程的理解进一步加深。

我打算分三篇来记录,这是第一篇。

进程与线程

首先区分并行与并发:

  • 并行是真的同时进行,在多核 CPU 上多个任务在同一时间真正地同时执行。
  • 并发是假的同时进行,多个任务在同一时间段内交替执行,通过时间片轮转来调度线程,因为1秒内就可能交替执行上万次,所以从人的视角来看似乎是“同时执行”。

然后区分进程与线程:

  • 进程是操作系统资源分配的最小单位,拥有独立的地址空间。进程之间相互隔离。一个 jvm 就是一个进程。
  • 线程是 CPU 调度的最小单位,多个线程可以共享一个进程的资源,比如堆和方法区,也可以拥有独立的程序计数器、虚拟机栈、本地方法栈等资源。

一个进程内部多个线程可以并发执行,这正是多线程提升性能的根本原因,但同时也是所有并发问题的来源,我总结为两大类,性能问题和安全问题。

性能问题:主要是线程创建和上下文切换的开销,但需要说明的是,并发不一定一定比串行快,只有在任务涉及 I/O 等阻塞操作时,并发才能通过利用等待时间去执行其他任务,从而提高 CPU 利用率和吞吐量。

安全问题:主要就三类:原子性、可见性、有序性。下文还会细说。

java 有一套完整的规范来协调这些行为,那就是 java memory model,又叫 java 内存模型。

Java 内存模型(JMM)

主内存与工作内存

JMM 是虚拟机规范中定义的一个抽象模型,针对的是多线程环境下如何在主内存与工作内存(逻辑概念,不等于内存,一般指 CPU 缓存)之间安全地执行操作。注意区分 JMM 和 JVM 的运行时区域。

  • 主内存:所有的共享变量都存储在主内存中。
  • 工作内存:每个线程都保存了一份共享变量的副本。

线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。如果线程 A 要和线程 B 通信,要经过两个步骤:线程 A 将本地内存中更新过的共享变量刷新到主内存,线程 B 再到主内存中读取。

JMM 解决的三大问题

  • 原子性:一个或多个操作要么全部执行成功,要么全部不执行(JMM 只解决基础读写的原子性)。
  • 可见性:一个线程修改了共享变量的值,另一个线程应该立即知道。
  • 有序性:在单个线程内,程序的执行结果看起来像按代码顺序执行(即 as-if-serial 语义);多线程间的有序性则由 happens-before 规则来约束。

更底层看,JMM 通过内存屏障来解决可见性以及禁止重排序的问题(具体不细说太难懂),而 Happens-Before 规则是 JMM 在内存屏障之上对程序员暴露的语义层:只要两个操作满足 happens-before 关系,就无须关心底层实现,JMM 也能保证结果的一致性。

volatile

volatile 关键字可以保证多线程环境下共享变量的可见性,同时也禁止指令重排序,但不保证原子性。

重排序的意义

编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。比如 a = 1; b = 2; c = a + b ,前两步不存在数据依赖关系,所以可能发生重排序(比如先执行 b = 2 ,再执行 a = 1)。重排序是为了优化性能,但不管怎么排序,单线程下程序的执行结果不能被改变。比如单线程下,第三步不可能早于前两步执行。

happens-before

JMM 需要在强大的内存模型(易于编程)和性能(提高编译器和处理器的执行效率)之间找到平衡,平衡点就是 happens-before 规则。

一个操作 happens-before 于另一个操作,那么第一个操作的执行结果将对第二个操作执行结果可见,且第一个操作的执行顺序在第二个顺序之前。这是 JMM 对程序员的保证,至于实际处理时到底是按什么顺序来的,你不用管。

天然的 happens-before 关系:

  • 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程的任意后续操作。
  • 监视器锁规则(synchronized):对于一个锁的解锁,happens-before 于对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:a happens-before b,且b happens-before c,那么a happens-before c。
  • start规则:线程 A 执行 ThreadB.start() 启动线程 B,那么该操作 happens-before 于子线程 B 中的任意操作,也就是说主线程在子线程 start() 之前的所有写入,都对子线程可见。
  • join 规则:线程 A 执行 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 join 中成功返回,也就是说子线程的所有写入,在 join() 返回后,对主线程可见。
  • 线程中断规则:一个线程对另一个线程调用 interrupt() 方法,happens-before 于被中断线程检测到中断事件的发生。也就是发送中断信号的线程,在调用interrupt() 之前的所有写入,对于被中断线程在检测到中断后都是可见的。

volatile 的经典使用

最经典的就是双重检查锁了,懒汉单例的经典场景。

class Singleton {
    private static volatile Singleton INSTANCE; 
    private Singleton() {}
    public Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

两层判空的作用,外层是为了性能考虑,不用每次都上锁进入同步块;内层是为了保证单例,假如没有,线程 T1 和 T2 同时经过第一层判空,T1 拿到了锁创建了实例,此时 T2 拿到锁又创建一个全新对象,单例失效。

volatile 在此处的双重作用:

  1. 禁止重排序INSTANCE = new Singleton(); 底层分为三步——分配内存、初始化对象、将引用指向内存。加了 volatile 后,第 2、3 步不会被重排。
  2. 保证可见性:T1 写入 INSTANCE 后,T2 在自己的工作内存中能立刻读到最新值。

如果缺少 volatile,T1 可能执行完第 1 步和第 3 步(此时引用已非空),T2 在外层判空时直接跳过同步块,返回一个尚未初始化的"半成品"对象。

懒汉单例也有其他方案比如方法上加 synchronized、静态内部类(加载类时 JVM 隐式加锁),他们都不需要 volatile,前者是因为提到的监视器锁规则,后者则利用了 JVM 类加载机制时的安全保证。

注意:volatile 并不能保证原子性,想计数不要用 volatile,可以选择给计数操作加锁或使用并发容器。

Synchronized

synchronized 有三种用法:

  • 修饰实例方法:锁当前对象 this。
  • 修饰静态方法:锁类的 Class 对象。
  • 修饰代码块:锁指定对象。

synchronized 常被拿来和 ReentrantLock 对比,简单说明一下。synchronized 是 JVM 层面关键字,底部基于监视器实现,只能是非公平锁。

随着线程竞争得激烈,synchronized 会从偏向锁到轻量级锁再到重量级锁,这也是 JVM 对 synchronized 的优化,性能不一定比 ReentrantLock 差。

final 在并发编程中也有重要价值,final 字段的写操作不会被重排序到构造函数之外,因为对象被其他线程可见时一定已完成初始化,这正是 String 等不可变类的字段被设计为 final 的原因。

第一篇就先到这里,经过这些概念的学习,我感觉我已经升华了。