Java并发编程相关理论知识

249 阅读20分钟

1 前言

并发编程可以总结为三个核心问题:分工、同步、互斥。 所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK 并发包很大部分内容都是按照这三个维度组织的,例如 Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种典型的同步方式,而可重入锁则是一种互斥手段。

image.png

2 可见性,原子性和有序性问题

可见性,原子性和有序性问题,这三大问题是并发编程Bug的源头。

计算机CPU、内存和IO设备都在不断迭代,朝着更快更好的方向发展。但是我们知道这些组件之间的速度存在很大差异。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

2.1 缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程马上就能看到,我们称为可见性。

从下图JMM模型我们可以知道,一个线程对一个变量的修改,需要先从主内存中难道值,然后再到工作内存(CPU缓存中去计算,这个工作内存可能是多个处理器的多个缓存),计算完了将内存中的值更新。 我们容易得之,因为这个过程中存在多个处理核心(多CPU),多缓存,多线程,很容易会出现一个线程在内存中计算完一个值,另一个线程不能立刻看到最新的变更的值。这个就导致了内存的可见性问题。

image.png

image.png

2.2 线程切换带来的原子性问题

多进程分时复用 计算机CPU、内存和IO设备的速度差距巨大,因此操作系统为了更好利用(榨干)CPU的性能,就发明了多进程。 一个CPU同一时刻只能处理一个任务,如果一个进程在一个时间片内是进行IO操作,这个时候该任务可以让出CPU使用权给其他需要CPU计算的任务。

image.png 由于进程之间不共享内存空间,要做进程间的任务切换需要切换内存映射地址,所以通常任务切换都是值的是线程切换,同进程下的线程之间共享内存空间,切换成本相对较低。

从下图可知,代码层面的 count+1=1,需要三个CPU指令

  1. 需要把变量 count 从内存加载到 CPU 的寄存器;
  2. 在寄存器中执行 +1 操作;
  3. 将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。 image.png

我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。 我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。 CPU能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

2.4 编译优化带来的有序性问题

语言的编译器有时候处于优化性能的目的会进行 指令重排序 大部分时候,因为重排序不影响最终结果而且确实能够起到优化性能的目的。 但是很多时候也会导致意想不到的结果。

// 双重检查锁定单例实现
public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现instance ==null,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查instance == null时会发现,已经创建过Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢? instance = new Singleton,会被编译器编译成如下JVM指令:

  • memory =allocate();    //1:分配对象的内存空间 
  • ctorInstance(memory);  //2:初始化对象 
  • instance =memory;     //3:设置instance指向刚分配的内存地址

这些指令可能经过CPU和JVM "擅自优化,改变顺序", 也就是指令重排序

  • memory =allocate();    //1:分配对象的内存空间 
  • instance =memory;     //3:设置instance指向刚分配的内存地址 
  • ctorInstance(memory);  //2:初始化对象

如果线程A完成了3,这个时候B进入第二个判空(或者第一个判空)为假,然后返回线程A未初始化完的对象。 image.png

怎么解决这个问题呢,在单例对象属性上加个volatile修饰,可以防止指令重排序。

public class Singleton {
    private Singleton() {}  //私有构造函数
    private volatile static Singleton instance = null;  //单例对象
    //静态工厂方法
    public static Singleton getInstance() {
          if (instance == null) {      //双重检测机制
         synchronized (Singleton.class){  //同步锁
           if (instance == null) {     //双重检测机制
             instance = new Singleton();
                }
             }
          }
          return instance;
      }
}

参考 # 漫画:什么是单例模式?(整合版) 题外话,虽然单例模式比较简单,但是可以深挖到多线程安全,指令重排序,JVM内存加载(为什么静态内部类实现的单例是线程安全的?),是面试比较高频的问题,大家一定要好好掌握呀。

3 可见性 volatile关键字

前面提到,导致可见性的原因是缓存,导致原子性问题的原因是编译优化。那么最简单能想到解决这些问题的方法是禁用缓存和编译优化,但是这样性能就成问题了。合理的方案应该是按需禁用缓存以及编译优化。

3.1 Java中 Happens-Before规则

3.1.1 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。 程序前面对某个变量的修改一定是对后续操作可见的。比较符合单线程的运行逻辑。

3.1.2 volatile变量规则

这条规则是指对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作。

3.1.3 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。

3.1.4 锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

synchronized (this) { 
  //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
     this.x = 12;
     }
    } //此处自动解锁

3.1.5 线程start()规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程前的操作。换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码。

Thread B = new Thread(()->{
   //主线程调用B.start()之前
   //所有对共享变量的修改,此处皆可见
   //此例中,var==77
 });
 //此处对共享变量var修改
 var = 77;
 //主线程启动子线程
 B.start();

3.1.6 线程join()规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

Thread B = new Thread(()->{
 //此处对共享变量var修改
  var = 66;
 });
 //例如此处对共享变量修改,
 //则这个修改结果对线程B可见
 //主线程启动子线程
B.start();
B.join()
//子线程所有对共享变量的修改
//在主线程调用B.join()之后皆可见
// 此例中,var==66

image.png

4 原子性 互斥锁

前面提到原子性问题主要由线程切换带来。那能不能不让线程切换来保证程序的原子性呢。在单核CPU时代,这个方案是可行的(保证一段程序的原子性就是在这段程序运行期间禁止线程切换依赖的CPU中断)。

image.png 多核场景下,在32位机器上修改long型变量,需要两次写。同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

4.1 简易锁模型

image.png

  • 临界区: 一段需要互斥执行的代码
  • 互斥实现: 抢占锁,进入临界区,临界区保证原子性,出临界区释放锁。

4.2 改进锁模型

简单模型提供了一个思路实现互斥,但是锁住的是什么并没有明确。实际上锁和被保护的资源是有对应关系。否则锁的范围太大了,会造成性能问题。所以对简易锁模型改进如下。 image.png

4.3 synchronized加锁

修饰位置和锁的作用范围

  1. 修饰静态方法,作用当前类的对象(所有当前类的实例)
  2. 修饰非静态方法,作用是当前实例对象this
  3. 修饰代码块,锁的范围由后面跟的括号显示指定
    • synchronized(obj)
    • synchronized(Object.class)
class SafeCalc {
	long value = 0L;
	long get() {   // get()方法不可见,可以由Happens-Before规则推论出来
		return value;
	}
        synchronized void addOne() {
        // 修饰非静态方法 等同于 synchronized(this) void addOne(){ 
		value += 1;
	}
}

被 synchronized 修饰后,无论是单核CPU 还是多核 CPU,同一时刻只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否可以可见性呢,由于Happens-Before中对于锁的规则

管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

由于线程A的解锁 Happens-Before 在下一个线程B的加锁操作,线程A在临界区对变量的修改Happens-Before线程A的解锁。因此线程A对临界区变量的修改对下一个线程B可见。

image.png

锁和受保护资源的关系是 1:N的关系,也就是说一把锁可以保护N个资源。反过来多把锁不能保护一个资源。

// 两把锁的例子
class SafeCalc {
	static long value = 0L;
	synchronized long get() {    // synchronized(this)
		return value;
	}
	synchronized static void addOne() {   // synchronized(SafeCalc.class)
		value += 1;
	}
}

4.4 互斥锁

一把锁可以管理多个资源,比如用this这把锁管理账户的余额和密码,但是这个时候修改余额和密码就是串行的了,实际业务场景没有必要,也会造成性能底下。这个时候就可以对应多个资源使用多把锁对受保护资进行精细化管理,这种锁叫 细粒度锁

class Account {
	private int balance;    // this锁可以保护balance,保护不了target
        //转账
	synchronized void transfer(   // 将修饰范围改下才能行 synchronized(Account.class) 
		Account target, int amt){
		if (this.balance > amt) {
			this.balance -= amt;
			target.balance += amt;
		}
	}
}

4.5 死锁

使用细粒度锁可以提高并行度,是性能优化的重要手段。但是有个代价是可能导致死锁。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。 死锁发生的条件:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

反过来说,只要破坏这四个条件中其中任意一个,就可以成功避免死锁的发生。

image.png

4.6 等待-通知机制

image.png

image.png

方法作用
wait线程自动释放占有的对象锁,并等待notify。
notify随机唤醒一个正在wait当前对象的线程,并让被唤醒的线程拿到对象锁
notifyAll唤醒所有正在wait当前对象的线程,但是被唤醒的线程会再次去竞争对象锁。因为一次只有一个线程能拿到锁,所有其他没有拿到锁的线程会被阻塞。推荐使用。

假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。

所以除非经过深思熟虑,否则尽量使用 notifyAll()。

5 安全性、活跃性和性能问题

5.1 安全性问题

线程安全的含义是程序按照我们期望执行,本质是正确性。

  • 竞态条件(Race Condition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。存在竞态条件才需要考虑线程安全的问题。
  • 数据竞争 (DataRace)。存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。

5.2 活跃性问题

所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。

  • 活锁 线程没有发生阻塞,但是依然执行不下去。比方两个人过桥,大家互相谦让,结果一直耗着,谁也过不去。
  • 饥饿 线程无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

image.png

如何避免饥饿问题,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。 那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。

5.3 性能问题

阿姆达尔(Amdahl)定律

S=1(1p)+pnS=\frac{1}{ (1-p) + \frac{p}{n} }

公式里的 n 可以理解为 CPU 的核数,p 可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的 5%。我们再假设 CPU 的核数(也就是 n)无穷大,那加速比 S的极限就是 20。也就是说,如果我们的串行率是 5%,那么我们无论采用什么技术,最高也就只能提高 20 倍的性能。

从公式中我们可以知道串行度和性能负相关。锁加的多了,增加了串行度,就需要特别注意对程序性能的影响。

  • 既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好。
  • 减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能有三个指标非常重要: 吞吐量 ,延迟,并发量

  • 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。 常见QPS
  • 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。常见 三九线,九九线
  • 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

6 Java线程的生命周期

Java 语言中线程共有六种状态,分别是:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

image.png

6.1 RUNNABLE <-> BLOCKED

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。

6.2 RUNNABLE <-> WAITING

有三种场景会触发这种转换。 1.获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。 2.调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。 3.调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从WAITING 状态转换到 RUNNABLE。

6.3 RUNNABLE <-> TIMED_WAITING

有五种场景会触发这种转换:

  1. 调用带超时参数的 Thread.sleep(long millis) 方法;
  2. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  3. 调用带超时参数的 Thread.join(long millis) 方法;
  4. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  5. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。

6.4 NEW <-> RUNNABLE

Java除开线程池创建线程原生三种方法

1.继承Thread,重写run方法

//自定义线程对象
class MyThread extends Thread {
	public void run() {
             //线程需要执行的代码
		......
	}
}
//创建线程对象
MyThread myThread = new MyThread();

2.实现Runnable 接口,重写run方法

//实现Runnable接口
class Runner implements Runnable {
	@Override
	public void run() {
    //线程需要执行的代码
		......
	}
}
//创建线程对象
Thread thread = new Thread(new Runner());

3。实现Callable 接口,重写run方法.与Runnable不同的地方在于Callable的run方法有返回参数。

创建如上线程对象线程的状态都会是 NEW,但是此时线程并未执行。调用start方法才会转为RUNNABLE状态。

MyThread myThread = new MyThread();
//从NEW状态转换到RUNNABLE状态
myThread.start();

7 应该创建多少线程?

这个问题是个高频面试问题,十次面试有被问到八次。相关的问题如线程池的参数怎么设置。

image.png 这个问题怎么考虑,我们要回到为什么引入多线程这个问题上来。我们知道引入多线程是为了提高CPU利用率,当一个线程在做IO的时候,可以让出CPU给其他线程做计算(时分复用),提高程序的性能。

以下两个线程交替做计算和IO,把CPU和I/O操作的利用率都提到了100%。如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。 image.png

在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。

image.png

回到设置多少线程合适? 需要看多线程具体的应用场景。一般程序都是CPU计算和IO操作交叉执行,因为IO硬件上比CPU速度上差太远,有个比方叫做“天上一天,地下一年”,因此大多数程序都是IO密集型的。

  • IO密集型 IO操作占了程序执行时间的大多数
  • CPU密集型 大部分时间都是纯CPU计算

7.1 CPU密集型

对于CPU密集型的场景我们知道多整几个线程不会带来性能的提升,反而带来了线程上下文切换的开销。这种场景下应用多线程就是为了提升多核下CPU的利用率。如果服务器有Ncpu,理论上设置N个线程CPU利用率就提高到了100%。

但是这种场景下通常我们通常将线程数设置成coreNum +1,这是为了防止有的线程偶尔内存页失效或者

最佳线程数=coreNum+1//注:CPU密集型任务最佳线程数= coreNum +1 //注:CPU密集型任务

7.2 IO密集型

对于IO密集型的多线程任务,单核处理器,我们需要看IO操作比上CPU计算操作的比例,如果这个比例是2:1,则三个线程是最合适的。如下图,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。

image.png

最佳线程数=1+I/O耗时/CPU耗时)//注:IO密集型任务最佳线程数 =1 +(I/O 耗时 / CPU 耗时) //注:IO密集型任务

多核CPU场景下计算等比例扩大倍数就行

最佳线程数=CPU核数[1+I/O耗时/CPU耗时)]最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

现在的cpu有物理核心数和逻辑线程数的概念,应该采逻辑线程。

参考资料