JVM和并发编程Java技术升华Java Foundation

并发理论

2020-08-16  本文已影响0人  让我们荡起双桨呀

可见性、原子性和有序性问题

这些年,CPU、内存、I/O设备的不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心核心矛盾一直存在,就是这三者的速度差异

程序里大部分语句都要访问内存,有些还要访问I/O设备,根据木桶理论,程序整体的性能取决于最慢的操作---读写I/O设备,也就是说单方面提高CPU性能是无效的。

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

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

但是并发程序的根源也在这里:

源头之一:缓存导致可见性问题

在单核时代,所有的线程都在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有的线程都是操作同一个CPU缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。例如下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V,那么线程B之后再访问变量V,得到的一定是V的最新值。

CPU缓存与内存的关系图

一个线程对共享变量的修改,另外一个线程能够立即看到,我们称之为可见性

多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程再不同的CPU上执行,这些线程操作的是不同的CPU缓存。比如,下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了。这个就属于硬件给软件挖的”坑“。

多核CPU的缓存与内存的关系图

下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码,每执行一次add10K()方法,都会循环10000次count += 1操作。在calc()方法中我们创建了两个线程,每个线程调用一次add10K()方法,我们来想一想执行calc()方法得到的结果应该是多少?

public class Test{
    private long count = 0;
    private void add10K(){
        int idx = 0;
        while(idx++ < 10000){
            count += 1;
        }
    }
    public static long calc(){
        final Test test = new Test();
        // 创建两个线程,执行add操作
        Thread th1 = new Thread(() -> {
            test.add10K();
        });
         Thread th2 = new Thread(() -> {
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        return count;
    }
}

直觉告诉我们应该是20000,因为在单线程里调用两次add10K()方法,count的值就是20000,但实际上calc()的执行结果是个10000到20000之间的随机数。为什么呢?

我们假设线程A和线程B同时开始执行,那么第一次都会将count = 0读到各自的CPU缓存里,执行完count += 1之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发现内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的count值来计算,所以导致最终count的值都是小于20000的,这就是缓存可见性问题。

循环10000次count += 1操作如果改为循环1亿次,最终count的值接近1亿,而不是2亿。如果循环10000次,count的值接近20000,原因是两个线程不是同时启动的,有一个时差。

变量count在CPU缓存和内存的分布图

源头二:线程切换带来的原子性问题:

由于IO太慢,早起的操作系统就发明了多进程,即便在单核CPU上我们也可以以便听着歌,一边写Bug,这就是多进程的功劳。

操作系统允许某个进程进行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(任务切换),这50毫秒称为”时间片“。

线程切换示意图

在一个时间片内,如果一个进程进行了一个IO操作,例如读个文件,这个时候进程可以把自己标记为”休眠状态“。并让出CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了。

这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间可以做别的事情,这样一来CPU的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。

早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的”任务切换“都是指”线程切换“。

Java并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如上面代码的count += 1,至少需要三条CPU指令。

操作系统做任务切换,可以发送在任何一条CPU指令执行完,是的,是CPU指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设count = 0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count += 1的操作,但是得到的结果不是我们期望的2,而是1。

非原子操作的执行路径示意图

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

源头三:编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a = 6;b = 7;编译器优化后可能变成:b = 7;a = 6;在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。

在Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。

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()方法并不完美。问题出在哪里呢?出在new操作上,我们意味new的操作应该是:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 然后M的地址赋值给instance变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存M;
  2. 将M的地址赋值给instance变量;
  3. 最后在内存M上初始化Singleton对象。

优化后会导致什么问题?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上,如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance != null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

双重检查创建单例的异常执行路径

Java内存模型

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,但是性能可就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化

Java内存模型是个很复杂的规范,可以从不同的视角来解读。具体来说,这些方法包括volatilesynchronizedfinal三个关键字,以及六项Happens-Before规则

使用volatile的困惑:

volatile关键字并不是java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。

例如,我们声明一个volatile int x = 0;它表大的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或写入。这个语义看上去相当明确,但是在实际上使用的时候却会带来困惑。

例如下面实例代码,假设线程A执行writer()方法,按照volatile语义,会把变量v = true写入内存;假设线程B执行reader()方法,同样按照volatile语义,线程B会从内存读取变量v,如果线程B看到v = true时,那么线程B看到的变量x是多少呢?

直觉上看,应该是42,那实际应该是多少呢?这个要看java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;如果在1.5以上的版本上运行,x就是等于42。

class VolatileExample{
    int x = 0;
    volatile boolean v = false;
    public void writer(){
        x = 42;
        v = true;
    }
    public void reader(){
        if (v == true){
            // 这里 x 会是多少呢?
        }
    }
}

分析一下,为什么1.5以前的版本会出现x = 0的情况呢?变量x可能被CPU缓存而导致可见性问题。这个问题在1.5版本已经被圆满解决了。java内存模型在1.5版本对volatile语义进行了增强。怎么增强的呢?答案是一项Happens-Before规则。

Happens-Before规则:

它真正要表达的是:前面一个操作的结果对后续操作是可见的。

1.程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。比如刚才那段示例代码,按照程序的顺序,第6行代码x = 42;Happens-Before于第7行代码v = true;这就是规则1的内容,程序前面对某个变量的修改一定是对后续操作可见的。

2.volatile变量规则

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

这个就有点费解了,对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存的意思,貌似和1.5版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是再看规则3就不一样了。

3.传递性

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

我们将规则3的传递性应用到我们的例子中,会发生什么?

示例代码中的传递性规则

从图中,我们可以看到:

  1. x = 42 Happens-Before 写变量v = true,这是规则1的内容;
  2. 写变量v = true Happens-Before 读变量v true,这是规则2的内容。

再根据这个传递性规则,我们得到结果:x = 42 Happens-Before读变量v = true。这意味着什么?

如果线程B读到了v = true,那么线程A设置x = 42对线程B是可见的。也就是说,线程B能看到x = 42。这就是1.5版本对volatile语义的增强,这个增强意义重大,1.5版本的并发包(java.util.concurrent)就是靠volatile语义来搞定可见性的。

4.管程中锁的规则

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

要理解这个规则,就首先要了解”管程指的是什么“。管程是一种通用的同步原语,在java中指的就是synchronized,synchronized是java里对管程的实现。

管程中的锁在java里是隐式实现的,例如下面的代码,在进入同步快之前,会自动加锁、而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

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

所以结合规则4---管程中锁的规则,可以这样理解:假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,能够看到线程A对x的写操作,也就时线程B能够看到x = 12。

5.线程start()规则

这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。

换句话说,如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作Happens-Before于线程B中的操作。具体可参考下面示例代码。

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

6.线程join()规则

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

换句话说,如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回,具体可参考下面示例代码。

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

被我们忽略的final

前面将volatile为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?就是final关键字

final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化

在1.5以后java内存模型对final类型变量的重排进行了约束,现在只要我们提供正确构造函数没有”逸出“,就不会出问题了。

”逸出“有点抽象,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,这就是”逸出“,线程通过global.obj读取x是可能读到0的。因此我们一定要避免”逸出“。

// 以下代码来源于【参考 1】
final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲 this 逸出,
  global.obj = this;
}

互斥锁

原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换时依赖CPU中断的,所以禁止CPU发生中断就能够进制线程切换。

在早期单核CPU时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。这里我们以32位CPU上执行long型变量的写操作为例来说明这个问题,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下如所示)。

在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时进制CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现Bug。

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

简易锁模型

当谈到互斥,解决方案:锁。

简易锁模型

我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们成这个线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。

这个过程非常想办公室高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。

改进后的锁模型

我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面模型中是没有体现的,所以我们需要完善一下我们的模型。

改进后的锁模型

首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,我们要保护资源R就得它创建一把锁LR;最后,针对这把锁LR,我们还需在进出临界区时添加加锁操作和解锁操作。

java语言提供的锁技术:synchronized

锁时一种通用的技术方案,java语言提供的synchronized关键字,就是锁的一种实现。synchronized关键字可以用来修饰方法,也可以用来修饰代码块,他的使用示例基本上都是下面这个样子:

class X{
    // 修饰非静态方法
    synchronized void foo(){
        // 临界区
    }
    // 修饰静态方法
    synchronized static void bar(){
        // 临界区
    }
    // 修饰代码块
    Object obj = new Object();
    void baz(){
        synchronized(obj){
            // 临界区
        }
    }
}

看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?其实这两个操作都是有的,只是这两个操作是被java默默加上的,java编译器会在synchronized修饰的方法或代码块前后自动加上锁lock()和解锁unlock(),这样做的好初就是加锁和解锁一定是成对出现的,毕竟忘记解锁是个致命的bug(意味着其它线程只能死等下去)。

那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?这个也是java的一条隐式规则:

当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子就是Class X;

当修饰非静态方法的时候,锁定的是当前实例对象this。

对应上面的例子,synchronized修饰静态方法相当于:

class X{
    // 修饰静态方法
    synchronized(X.class) static void bar(){
        // 临界区
    }
}

修饰非静态方法,相当于:

class X{
    // 修饰非静态方法
    synchronized(this) void foo(){
        // 临界区
    }
}

用synchronized解决count += 1问题

相信你一定记得前面提到的count += 1存在的并发问题,现在我们可以尝试用synchronized来小试牛刀一把。代码如下所示。SafeCalc这个类有两个方法:一个是get()方法,用来获取value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。那么我们使用的这两个方法有没有并发问题呢?

class SafeCalc{
    long value = 0;
    long get(){
        return value;
    }
    synchronized void addOne(){
        value += 1;
    }
}

我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性呢?要回答这个问题,就要回顾之前的管程中锁的规则

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

管程,就是我们这里的synchronized,我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓”对一个锁解锁Happens-Before后续对这个锁的加锁“,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。

但也许,一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没发保证。那如何解决呢?很简单,就是get()方法synchronized一下, 完整的代码如下所示。

class SafeCalc{
    long value = 0;
    synchronized long get(){
        return value;
    }
    synchronized void addOne(){
        value += 1;
    }
}

上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。线程要进入临界区get()和addOne(),必须先获得this这个锁,这样get()和addOne()也是互斥的。

保护临界区get()和addOne()的示意图

这个模型更像现实世界里面球赛门牌的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是java类里的方法,而门牌就是用来保护资源的”锁“,java里的检票工作是由synchronized解决的。

锁和受保护资源的关系

我们前面提到,受保护资源和锁之间的关联关系非常重要,它们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但是并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的”包场“了。

上面那个例子稍作改动,把value改成静态变量,把addOne()方法改成静态方法,此时get()方法和addOne()方法是否存在并发问题呢?

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

如果仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。我们可以用下面这副图来形象描述这个关系。由于临界区get()和addOne()使用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对get()也没有可见性保证,这就导致了并发问题了。

两把锁保护一个资源的示意图

保护没有关联关系的多个资源

在现实世界里,球场的作为和电影院的作为就是没有关联关系的,这种场景非常容易解决,拿就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。

同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额的取款操作,也有针对账户密码的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。

相关的示例代码如下,账户类Acount有两个成员变量,分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁;而更改密码undatePassword()和查看密码getPassword()操作会修改账户密码password。我们创建一个final对象pwLock作为锁。不同的资源用不同的锁保护,各自管各自的,很简单。

class Account {
  // 锁:保护账户余额
  private final Object balLock = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock = new Object();
  // 账户密码
  private String password;
 
  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看余额
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }
   // 更改密码
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密码
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }

 

当然,我们可以用一把互斥锁来保护多个资源,例如我们可以用this这一把锁来管理账户类里所有的资源;账户余额和用户密码。具体实现简单,示例程序中所有的方法都增加同步关键字synchronized就可以了。

但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁叫细粒度锁

保护有关联关系的多个资源

如果多个资源有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是有关联关系的。而对于像转账这种由关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?

class Account {
  private int balance;
  // 转账
  void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

相信你的直觉会告诉你这样的解决方案:用户synchronized关键字修饰一下transfer()方法就可以了,于是你很快就完成了相关代码,如下所示。

class Account {
  private int balance;
  // 转账
  synchronized void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

在这段代码中,临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅看似正确,为什么呢?

问题就处在this这把锁上,this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance,就像你不能用自家的锁保护别人家的资产,也不能用自己的票来保护别人的座位一样。

用锁this保护this.balance和target.balance的示意图

下面我们具体分析一下,假设有A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:账户A转给账户B100元,账户B转给账户C100元,最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元,账户C的余额是300元。

我们假设线程1窒息感账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行,那它们互斥吗?我们期望的是,但实际上并不是。因为线程1锁定的账户A的实例(A.this),而线程2锁定的账户B的实例(B.this),所以这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么呢?线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖),就是不可能是200。

并发转账示意图

使用锁的正确姿势

在上面,我们提到用同一把锁来保护多个资源,也就是现实世界的”包场“,那在编程领域怎么”包场“呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中,this是对象级别的锁,所以A对象和B对象都有自己的锁,如何让A对象和B对象共享一把锁呢?

稍微开动脑筋,你会发现其实方案还是挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建Account时传入。方案有了,完成代码就简单了。实际代码如下,我们把Account默认构造函数变为private,同时增加一个带Object lock参数的构造函数,创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。

class Account {
  private Object lock;
  private int balance;
  private Account();
  // 创建 Account 时传入同一个 lock 对象
  public Account(Object lock) {
    this.lock = lock;
  } 
  // 转账
  void transfer(Account target, int amt){
    // 此处检查所有对象共享的锁
    synchronized(lock) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}

这个办法确实能解决问题,但是有点小瑕疵,它要求在创建Account对象的时候必须传入同一个对象,如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。

所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是用Account.class作为共享锁。Account.class是所有Account对象共享的,而且这个对象是java虚拟机在加载Account类的时候创建的,所以我们无需在创建Account对象时传入了,代码更简单。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

下面这幅图很直观地展示了我们是如何使用共享锁Account.class来保护不同对象的临界区的。

”原子性“的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外部可见。所以解决原子性问题,是要保证中间状态对外不可见

一不小心就死锁了,怎么办?

上面,用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。

现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。

我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件夹上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到一下三种情况:

  1. 文件架上恰好有转出账本和转入账本,那就同时拿走;
  2. 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
  3. 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。

两个转账操作并行示意图

而至于详细的代码实现,如下所示。经过这样的优化后,账户 A 转账户 B 和账户 C 转账户 D 这两个转账操作就可以并行了。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {              
      // 锁定转入账户
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用Account.class做为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,叫细粒度锁使用细粒度锁可以提高并行度,是性能优化的一个重要手段

使用细粒度锁是有代价的,这个代价就是可能会导致死锁

在详细介绍死锁之前,我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。我们姑且称为死等吧。

转账业务中的“死等”

现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

上面转账的代码是怎么发生死锁的呢?我们假设线程 T1 执行账户 A 转账户 B 的操作,账户 A.transfer(账户 B);同时线程 T2 执行账户 B 转账户 A 的操作,账户 B.transfer(账户 A)。当 T1 和 T2 同时执行完①处的代码时,T1 获得了账户 A 的锁(对于 T1,this 是账户 A),而 T2 获得了账户 B 的锁(对于 T2,this 是账户 B)。之后 T1 和 T2 在执行②处的代码时,T1 试图获取账户 B 的锁时,发现账户 B 已经被锁定(被 T2 锁定),所以 T1 开始等待;T2 则试图获取账户 A 的锁时,发现账户 A 已经被锁定(被 T1 锁定),所以 T2 也开始等待。于是 T1 和 T2 会无期限地等待下去,也就是我们所说的死锁了。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this){     ①
      // 锁定转入账户
      synchronized(target){ ②
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。

转账发生死锁时的资源分配图

并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。

那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:

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

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。

1.破坏占用且等待条件

从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?

可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本 A 和 B,账本管理员如果发现文件架上只有账本 A,这个时候账本管理员是不会把账本 A 拿下来给张三的,只有账本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请所有资源”。

通过账本管理员拿账本
class Alloctor{
    private List<Object> als = new ArrayList<>();
    // 一次性申请所有资源
    synchronized boolean apply(Object from, Object to){
        if (als.contains(from) || als.contains(to){
            return false;
        } else {
            als.add(from);
            als.add(to);
        }
        return true;
    }
    // 归还资源
    synchronized void free(Object from, Object to){
        als.remove(from);
        als.remove(to);
    }
}
class Account{
    // actr应该为单例
    private Alloctor actr;
    private int balance;
    // 转账
    void transfer(Account target, int amt){
        // 一次性申请转出账户和转入账户,直到成功
        while(! actr.apply(this, target)){
            try{
                // 锁定转出账户
                synchronized(this){
                    // 锁定转入账户
                    synchronized(target){
                        if (this.balance > amt){
                            this.balance -= amt;
                            target.balance += amt;
                        }
                    }
                }
            }finally{
                actr.free(this, target);
            }
        }
    }
}

2.破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

你可能会质疑,“Java 作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。

3.破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按需申请资源。这个实现非常简单,我们假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

class Account{
    private int id;
    private int balance;
    // 转账
    void transfer(Account target, int amt){
        Account left = this        ①
        Account right = target;    ②
        if (this.id > target.id) { ③
        left = target;             ④
        right = this;              ⑤
    }                              ⑥
    // 锁定序号小的账户
    synchronized(left){
        // 锁定序号大的账户
        synchronized(right){
            if (this.balance > amt){
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

用“等待-通知”机制优化循环等待

破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待,核心代码如下:

// 一次性申请转出账户和转入账户,直到成功
while(! actr.apply(this, target))
    ;

如果apply()操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。但是如果apply()操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下,可能要循环上万次才能获取到锁,太消耗CPU了。

其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。

一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁

用synchronized实现等待-通知机制

在java语言里,等待-通知机制可以有多珍重实现方式,比如java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。

如何用synchronized实现互斥锁。在下图里,左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区,当有一个线程进入临界区后,其它线程就只能进入图中左边的等待队列等待。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列

wait()操作工作原理图

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,java对象的wait()方法就能够满足这种需求。如上图所示,当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的锁,线程释放锁后,其它线程就有机会获得锁,并进入临界区了。

那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是java对象的notify()和notifyAll()方法。当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程。告诉它条件曾经满足过

notify()操作工作原理图

为什么说是曾经满足过呢?因为notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。

除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。

上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException

等待-通知机制的基本原理搞清楚后,我们就来看看如何解决一次性申请转出账户和账入账户的问题吧。在这个等待-通知机制中,我们需要考虑一下四个要素。

  1. 互斥锁:上面提到Allocator需要是单例的,所以可以用this作为互斥锁。
  2. 线程要求的条件:转出账户和转入账户都没有被分配过。
  3. 何时等待:线程要求的条件不满足就等待。
  4. 何时通知:当有线程释放账户时就通知。

就上面几个问题考虑清楚,可以快速完成下面的代码。

while(条件不满足){
    wait();
}

利用这种范式可以解决上面提到的条件曾经满足过这个问题。因为当 wait() 返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。

class Allocator{
    private List<Object> als;
    // 一次性申请所有资源
    synchronized void apply(Object from, Object to){
        // 经典写法
        while(als.contains(from) || als.contains(to)){
            try{
                wait();
            } catch(Exception x){
                
            }
        }
        als.add(from);
        als.add(to);
    }
    // 归还资源
    synchronized void free(Object from, Object to){
        als.remove(from);
        als.remove(to);
        notifyAll();
    }
}

在上面的代码中,我用的是 notifyAll() 来实现通知机制,为什么不使用 notify() 呢?这二者是有区别的,notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。

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

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

安全性、活跃性以及性能问题

安全性问题

相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。

那什么是线程安全的呢?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。

那如何才能写出线程安全的程序呢?并发bug的三个源头:原子性问题、可见性问题和有序性问题。也就是说,理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。

那是不是所有的代码都需要认真分析一遍是否存在这三个问题?当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程安全性了嘛。有不少技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等等。

但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。

当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发bug。对此还有一个专业术语,叫做数据竞争(Data Race)。比如,前面有个add10K()方法,当多个线程调用的时候就会发生数据竞争,如下所示。

public class Test{
    private long count = 0;
    void add1oK(){
        int idx = 0;
        while(idx++ < 10000){
            count += 1;
        }
    }
}

那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。例如,对于上面示例,我们稍作修改,增加两个被synchronized修饰的get()和set()方法, add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的,但很显然修改后的add10K()方法并不是线程安全的。

public class Test{
    private long count = 0;
    synchronized long get(){
        return count;
    }
    synchronized void set(long v){
        count = v;
    }
    synchronized add10K(){
        int idx = 0;
        while(idx++ < 10000){
            set(get() + 1);
        }
    }
}

假设count = 0;当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get() + 1操作,结果都是1,之后两个线程再将结果写入内存,你本来期望的是2,而结果是1。

这种问题,有个官方的称呼,叫竞态条件(Race Condition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果时2。再并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大bug。

下面再结合一个例子来说明下竞态条件,就是前面文章中提到的转账操作。转账操作里面有个判断条件---转出金额不能大于账户余额,但是在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能超额转出问题。假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。

class Account{
    private int balance;
    // 转账
    void transfer(Account target, int amt){
        if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
        }
    }
}

所以你也可以按照下面这样来理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:

if (状态变量 满足 执行条件){
    执行操作
}

当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其它线程同时修改了状态变量,导致状态变量不满足执行条件了。当然很多场景下,这个条件不是显式的,例如前面的addOne()的例子中,set(get() + 1)这个符合操作,其实就是隐式依赖get()的结果。

那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:

活跃性问题

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

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的活锁。可以类比现实世界的例子,路人甲从左手出门,路人乙从右手边出门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙走左手边,结果两个又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界,就有可能一直没完没了的谦让下去,称为没有发生阻塞但依然执行不下去的活锁。

解决活锁的方案很简单,谦让时,尝试等待一个随机数时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。等待一个随机时间的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。

饥饿该怎么去理解呢?所谓饥饿,指的是线程因无法访问所需资源而无法执行下去的情况。”不患寡,而患不均“,如果线程优先级”不均“,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发现线程”饥饿“;持有锁的线程,如果执行的时间过长,也可能导致”饥饿“问题。

解决”饥饿“问题的方案很简单,又三种方案:一是保证资源充足,二是公平地分配资源,三是避免持有的锁的线程长时间执行。这三个方案中,方案一和方案三的使用场景比较有限,因为很多场景下,资源的稀缺是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。

那如何公平分配资源呢?在并发编程里,主要是适用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会有限获得资源。

性能问题

使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。

所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是 5%,我们用多核多线程相比单核单线程能提速多少呢?

有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:

S=1(1−p)+pnS=1(1−p)+pn

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

所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能

不过从方案层面,我们可以这样来解决这个问题。

第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……

第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

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

管程

为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法?在刚接触 Java 的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以 Java 选择了管程。

管程,对应的英文是 Monitor,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且java管程的实现参考的也是MESA模型。

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步

,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。

我们先来看看管程是如何解决互斥问题的。

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。不知你有没有发现,管程模型和面向对象高度契合的。估计这也是 Java 选择管程的原因吧。

管程模型的代码化语义

那管程如何解决线程间的同步问题呢?

这个就比较复杂了,在下面,我展示了一幅 MESA 管程模型示意图,它详细描述了 MESA 模型的主要组成部分。

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

MESA管程模型

那条件变量和等待队列的作用是什么呢?其实就是解决线程同步问题。你也可以结合上面提到的入队出队例子加深一下理解。

假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。这和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。

再假设之后另外一个线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。这个过程类似你验血完,回来找大夫,需要重新分诊。

条件变量及其等待队列我们讲清楚了,下面再说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现“队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件,那么线程 T1 需要调用 A.wait()。同理当“队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。

这里我还是来一段代码再次说明一下吧。下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。

  1. 对于入队操作,如果队列已满,就需要等待直到队列不满,所以这里用了notFull.await();
  2. 对于出队操作,如果队列为空,就需要等待直到队列不空,所以就用了notEmpty.await();
  3. 如果入队成功,那么队列就不空了,就需要通知条件变量:队列不空notEmpty对应的等待队列。
  4. 如果出队成功,那就队列就不满了,就需要通知条件变量:队列不满notFull对应的等待队列。
public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();
 
  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      // 入队后, 通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      // 出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

在这段示例代码中,我们用了 Java 并发包里面的 Lock 和 Condition,如果你看着吃力,也没关系,后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是:await() 和前面我们提到的 wait() 语义是一样的;signal() 和前面我们提到的 notify() 语义是一样的

但是有一点,需要再次提醒,对于 MESA 管程来说,有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的

while(条件不满足) {
  wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?

  1. Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
  2. Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
  3. MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需要满足以下三个条件:

  1. 所有等待线程拥有相同的等待条件;
  2. 所有等待线程被唤醒后,执行相同的操作;
  3. 只需要唤醒一个线程。

比如上面阻塞队列的例子中,对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,反映在代码里就是下面这 3 行代码。对所有等待线程来说,都是执行这 3 行代码,重点是 while 里面的等待条件是完全相同的

while (队列已满){
  // 等待队列不满
  notFull.await();
}

所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:

// 省略入队操作...
// 入队后, 通知可出队
notEmpty.signal();

同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。

java线程:java线程的生命周期

通用的线程生命周期

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态终止状态

通用线程状态转换图---五态模型

这“五态模型”的详细情况如下所示。

  1. 初始状态,指的是线程已经被创建,但是还不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  2. 可运行状态,指的是线程可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功传创建了,所以可以分配CPU执行。
  3. 当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态
  4. 运行状态的线程如果调用了一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  5. 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其它任何状态,进入终止状态也就意味着线程的生命周期结束了。

这五种状态在不同编程语言中会有简化合并。例如,C语言的POSIX Threads规范,就把初始状态和可运行状态合并了;java语言则把可运行状态和运行状态合并了,这两个状态在操作系统调用层面有用,而JVM层面不关心这两个状态,因为JVM把线程调度交给操作系统处理了。

除了简化合并,这五种状态也有可能被细化,比如,java语言里就细化了休眠状态。

java中线程的生命周期

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

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

这看上去挺复杂的,状态类型也比较多。但是在操作系统层面,java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即前面的休眠状态。也就是说只要java线程处于这三种状态之一,那么这个线程就永远没有CPU的使用权

所以java线程的生命周期可以简化为下图:

java中的线程状态转换图

其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNING状态转换到这三种状态呢?而这三种状态又是何时转换回RUNNABLE的呢?以及NEW、TERMINATED和RUNNABLE状态是如何转换的?

1. RUNNABLE与BLOCKED的状态转换

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

如何你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,java线程的状态不会发生变化,也就是说java线程的状态会依然保持RUNNABLE状态。JVM层面并不关心操作系统调度相关的状态,因为在JVM看来,等待CPU使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入RUNNABLE状态。

而我们平时所谓的java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是java线程的状态。

2.RUNNABLE与WAITING的状态转换

总体来说,有三种场景会触发这种转换。

第一种场景,获得synchronized隐式锁的线程,调用无参的Object.wait()方法。

第二张场景,调用无参的Thrad.join()方法。其中join()是一种线程同步方法,例如有一个线程对象Thread A,当调用A.join()的时候,执行这条语句的线程会等待thread A执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程thread A执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。

第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,其实java并发包中的锁,都是基于它实现的。调用LockSupport.pack()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpack(Thread thread)可唤醒目标线程,目标线程又会从WAITING状态转换到RUNNABLE。

3.RUNNABLE与TIMED_WAITING的状态转换

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

  1. 调用带超时参数的Thread.sleep(long millis)方法;
  2. 获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeout)方法;
  3. 调用带超时参数的Thread.join(long millis)方法;
  4. 调用带超时参数的LockSupport.packNanos(Object blocker, long deadline)方法;
  5. 调用带超时参数的LockSupport.packUntil(long deadline)方法。

你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了超时参数

4.从NEW到RUNNING状态

java刚创建出来的Thread对象就是New状态,而创建Thread对象主要有两种方法。一种是继承Thread对象,重写run()方法。示例代码如下:

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

另一种是实现Runnable接口,重写run()方法,并将该实现类作为创建Thread对象的参数,示例代码如下:

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

NEW状态的线程,不会被操作系统调度,因此不会执行。java线程要执行,就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了,示例代码如下:

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

5.从RUNNABLE到TERMINATED状态

线程执行完run()方法后,会自动跳转到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?java的Thread类里面有个stop()方法,不过已经被标记为@Deprecated,所以不建议使用了,正确的姿势其实是调用interrupt()方法。

那stop()和interrupt()方法的主要区别是什么呢?

stop()方法会真的杀死线程,不给线程喘息的机会。如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。

而 interrupt() 方法就温柔多了,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。

当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。

当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。

上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。

java线程:创建多少线程才是合适的?

为什么要使用多线程?

使用多线程,本质上就是提升程序性能。

度量性能的指标有很多,但是有两个核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的事件;延迟越短,意味着程序执行得越快,性能也就越号。吞吐量指得是在单位事件内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属于不同的纬度(一个是时间维度,一个是空间维度),并不能互相转换。

我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量

多线程的应用场景

要想“降低延迟,提高吞吐量”,对于的方法呢?基本上有两个方向,一个方向是优化算法,另一个方向是将硬件的性能发挥到极致。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要有两类:一个是I/O,一个是CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率

估计这个时候你会有个疑问,操作系统不是已经解决了硬件的利用率问题了吗?的确是这样的,例如操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免CPU轮询I/O状态,也提升了CPU的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们并发程序,往往需要CPU和I/O设备相互配合,也就是说,我们需要解决CPU和I/O设备综合利用率的问题。关于这个综合利用率的问题,操作胸痛虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。

下面我们用一个简单的示例来说明:如何利用多线程来提升CPU和I/O设备的利用率?假设程序按照CPU计算和I/O操作交叉执行的方式运行,而且CPU计算和I/O操作的耗时是1:1。

如下图所示,如果只有一个线程,执行CPU计算的时候,I/O设备空闲;执行I/O操作的时候,CPU空闲,所以CPU的利用率和I/O设备利用率都是50%。

单线程执行示意图

如果两个线程,如下如所示,当线程A执行CPU计算的时候,线程B执行I/O操作;当线程A执行I/O操作的时候,线程B执行CPU计算,这样CPU的利用率和I/O设备的利用率就都达到了100%。

两个线程执行示意图

我们将 CPU 的利用率和 I/O 设备的利用率都提升到了 100%,会对性能产生了哪些影响呢?通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了 1 倍。此时可以逆向思维一下,如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量

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

为便于你理解,这里我举个简单的例子说明一下:计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算 [1,25 亿),线程 B 计算 [25 亿,50 亿),线程 C 计算 [50,75 亿),线程 D 计算 [75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算 [1,100 亿] 快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%

多核执行多线程示意图

创建多少线程合适?

创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。

下面我们对这两个场景分别说明。

对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。

三线程执行示意图

通过上面这个例子,我们会发现,对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:

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

我们令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%。

不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:

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

java线程:为什么局部变量是线程安全的?

Java 方法里面的局部变量是否存在并发问题呢?下面我们就先结合一个例子剖析下这个问题。

比如,下面代码里的 fibonacci() 这个方法,会根据传入的参数 n ,返回 1 到 n 的斐波那契数列,斐波那契数列类似这样: 1、1、2、3、5、8、13、21、34……第 1 项和第 2 项是 1,从第 3 项开始,每一项都等于前两项之和。在这个方法里面,有个局部变量:数组 r 用来保存数列的结果,每次计算完一项,都会更新数组 r 对应位置中的值。你可以思考这样一个问题,当多个线程调用 fibonacci() 这个方法的时候,数组 r 是否存在数据竞争(Data Race)呢?

// 返回斐波那契数列
int[] fibonacci(int n) {
  // 创建结果数组
  int[] r = new int[n];
  // 初始化第一、第二个数
  r[0] = r[1] = 1;  // ①
  // 计算 2..n
  for(int i = 2; i < n; i++) {
      r[i] = r[i-2] + r[i-1];
  }
  return r;
}

假设多个线程执行到 ① 处,多个线程都要对数组 r 的第 1 项和第 2 项赋值,这里看上去感觉是存在数据竞争的,不过感觉再次欺骗了你。

方法是如何被执行的

高级语言里的普通语句,例如上面的r[i] = r[i-2] + r[i-1];翻译成 CPU 的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第 1 行,声明一个 int 变量 a;第 2 行,调用方法 fibonacci(a);第 3 行,将 b 赋值给 c。

int a = 7;
int[] b = fibonacci(a);
int[] c = b;

当你调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。

方法的调用过程

到这里,方法调用的过程想必你已经清楚了,但是还有一个很重要的问题,“CPU 去哪里找到调用方法的参数和返回地址?”如果你熟悉 CPU 的工作原理,你应该会立刻想到:通过 CPU 的堆栈寄存器。CPU 支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈

例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的

调用栈结构

利用栈结构来支持方法调用这个方案非常普遍,以至于 CPU 里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是靠栈结构解决的。Java 语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。

局部变量存哪里

我们已经知道了方法间的调用在 CPU 眼里是怎么执行的,但还有一个关键问题:方法内的局部变量存哪里?

局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。

保护局部变量的调用栈结构

这个结论相信很多人都知道,因为学 Java 语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。

调用栈与线程

两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程 A、B、C 每个线程都有自己独立的调用栈。

线程与调用栈的关系图

现在,让我们回过头来再看篇首的问题:Java 方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。

线程封闭

方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。

采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。

上一篇下一篇

猜你喜欢

热点阅读