【学习笔记】Java并发

第一章:可见性、原子性与有序性

可见性、原子性与有序性的问题是并发Bug的源头。

一、问题根源

CPU、内存、I/O设备之间存在很大的速度差异>>并发编程。

  • CPU、内存的速度:CPU(天上一天),内存(地上一年)
  • 内存、I/O设备:内存(天上一天),I/O设备(地上十年)

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

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

  1. CPU与内存之间:增加了缓存;
  2. CPU与IO设备:操作系统增加了进程、线程,以分时复用CPU;
  3. 充分利用缓存:编译程序优化指令执行次序。

二、出错原因

但是由于计算机设计的原因,经常会出现很多并发引起的问题。

A.可见性

  1. 定义:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

  2. 单核系统:所有的线程都是在一颗CPU上执行,任何一个线程对缓存的写,对另外一个线程来说一定是可见的。

    如下图所示,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。

  1. 多核系统:每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。

    如下图所示,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存。很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了。

    Ps: CPU <<==>>寄存器<<==>>缓存<<==>>内存

B.原子性

  1. 定义:把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。

    CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

  2. 切换:Java并发程序都是基于多线程的,会涉及到多任务之间的切换问题。

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

    • 指令1:首先,需要把变量count从内存加载到CPU的寄存器;
    • 指令2:之后,在寄存器中执行+1操作;
    • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
    操作系统做任务切换,可以发生在任何一条CPU指令执行完,而不是高级语言里的一条语句。

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

C.有序性

  1. 定义:有序性指的是程序按照代码的先后顺序执行。

    编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。

    不过有时候编译器及解释器的优化可能导致意想不到的Bug,下面举个例子说明。

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

1
2
3
4
5
6
7
8
9
10
11
12
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的成员变量就可能触发空指针异常。

三、总结

只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发BUG都是可以理解和诊断的。

四、思考题

问:常听人说,在32位的机器上对long型变量进行加减操作存在并发隐患,到底是不是这样呢?

答:long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原
子性,所以并发的时候会出问题


第二章:Java内存模型

用于解决可见性与有序性

一、什么是Java内存模型

  • 导致可见性问题的原因是缓存。
  • 导致有序性的原因是编译优化。

为了解决可见性、有序性,最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,程序的性能可就堪忧了。合理的方案应该是按需禁用缓存以及编译优化

Java内存模型是个很复杂的规范,可以从不同的视角来解读。站在程序员的视角,本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法,其中包括:
  • volatile、synchronized 和 final 三个关键字
  • 六项 Happens-Before 规则。

二、volatile

volatile关键字并不是Java特有的,它最原始的意义就是禁用CPU缓存。
例如,声明一个volatile变量volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。

三、final操作

  • volatile:禁用缓存以及编译优化
  • final修饰的变量:这个变量生而不变,可以优化。

四、Happens-Before规则

Happens-Before规则用于限制编译器优化规则,表达的意思是前面一个操作的结果对后续操作是可见的。

A.理解规则

它表达的是前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。

正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before 规则。

B.六大规则

1
2
3
4
5
6
7
8
9
10
11
12
13
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这⾥x会是多少呢?
}
}
}
  1. 程序的顺序性规则

    它是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。

    举例来说,第6行 x = 42; Happens-Before 于第7行v = true;

    这就是规则1的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

  2. volatile变量规则

    它是指的是,对一个volatile变量的写操作Happens-Before于后续对这个volatile变量的读操作。这条规则需要与第三条规则一起理解。

  3. 传递性

    它是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。

    还是以上面的代码为例,

    • x=42Happens-Before 写v=true (顺序性);
    • v=trueHappens-Before 读v=true(volatile规则) 。
    • 根据这个传递性规则,x=42Happens-Before读v=true。所以如果线程B读到了v=true,那么线程A设置的x=42对线程B是可见的。也就是说,线程B能看到 x=42
  4. 管程中锁的规则

    它是指对一个锁的解锁Happens-Before于后续对这个锁的加锁。
    管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。
    管程中的锁在Java里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    synchronized (this) { //此处⾃动加锁
    // x是共享变量,初始值=10
    if (this.x < 12) {
    this.x = 12;
    }
    } //此处⾃动解锁
    /*
    1.假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁)。
    2.线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。
    */
  5. 线程 start() 规则

    它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
    换句话说就是,如果线程A调用线程B的 start() 方法(即在线程A中启动线程B),那么该start()操作Happens-Before于线程B中的任意操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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() 操作的返回。具体可参考下面示例代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Thread B = new Thread(()->{
    // 此处对共享变量var修改
    var = 66;
    });
    // 例如此处对共享变量修改,
    // 则这个修改结果对线程B可⻅
    // 主线程启动⼦线程
    B.start();
    B.join()
    // ⼦线程所有对共享变量的修改
    // 在主线程调⽤B.join()之后皆可⻅
    // 此例中, var==66

五、总结

Happens-Before的语义是一种因果关系。在现实世界里,如果A事件是导致B事件的起因,那么A事件一定是先于(Happens-Before)B事件发生的,这个就是Happens-Before语义的现实理解。 在Java语言里面,Happens-Before的语义本质上是一种可见性,A Happens-Before B 意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上,B事件发生在线程2上,Happens-Before规则保证线程2上也能看到A事件的发生。

Java内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向JVM的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是Happens-Before规则。

六、思考题

有一个共享变量 abc,在一个线程A设置了abc=3,有哪些办法可以让其他线程能够看到abc==3?

  • 使用volatile可行 。
  • 在B中调用A线程的start()方法
  • 在B中调用A线程的join()方法

第三章:互斥锁

用于解决原子性。

  • 原子性:一个或者多个操作在CPU执行过程中不被中断的特性,称为原子性。
  • 问题源头:线程切换
  • 解决方法:操作系统做线程切换依赖CPU中断,所以禁止CPU发生中断就能禁止线程切换。保证对共享变量的修改是互斥的。
  • 互斥:同一时刻只有一个线程执行。

举例说明:在32位机器上执行long(64位)写操作。

  • 单核:同一时刻只有一个线程执行,禁止CPU中断(禁止线程切换),一旦获取到CPU的线程就可以不间断的执行,所以可以保证原子性。
  • 多核:同一时刻,可能有多个线程在不同的CPU上执行,此时禁止中断,智能保证线程连续执行,不能保证同一时刻只有一个线程执行,如果这两个线程同时都写long的高32位的话,可能会出现原子性问题。

一、简易的锁模型

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

这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事
就是临界区。很长时间里,我也是这么理解的。这样理解本身没有问题,但却很容易让我们忽视两个非常非
常重要的点:我们锁的是什么?我们保护的又是什么?

二、改进后的锁模型

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

  1. 我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;
  2. 为保护资源R创建一把锁LR;
  3. 针对锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。
  4. 另外,在锁LR和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。

三、 锁技术: synchronized

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class X {
// 1. 修饰⾮静态⽅法
synchronized void foo() {
// 临界区
}
// 2. 修饰静态⽅法
synchronized static void bar() {
// 临界区
}
// 3. 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}

Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的。

锁定的对象:

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

实例

用synchronized解决count+=1问题

阶段1 : 使用synchronized修饰addOne方法
  • 原子性:addOne方法,因为使用了synchronized修饰,所以保证了原子性。
  • 可见性:因为happen-before规则,所以可以保证。
  • get()方法:执行addOne()方法后,value的值对get()方法是可见的吗?这个可见性是没法保证的。
1
2
3
4
5
6
7
8
9
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
阶段2:使用synchronized修饰addOne和get方法
1
2
3
4
5
6
7
8
9
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}

阶段3: 受保护资源和锁之间的关联关系是N:1的关系。
1
2
3
4
5
6
7
8
9
10
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
s
ynchronized static void addOne() {
value += 1;
}
}