java内存模型之:一文说清java线程的Happens-before

447 阅读9分钟

1. 前言

学习happens-before的目的不是只限于知道这些规则的存在,而是要进一步知道如何实现和维护这些happens-before关系,在代码中加以注意。

Happens-before 规则是从java代码设计层面保证有序性和可见性的机制。本文将会以图示、样例代码和解释相结合的方式,力图阐述清楚happens-before的原理,为理解如何保证线程安全性打下扎实的基础。

关于happens-before关系,java语言说明中有如下的描述:

Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second. --Java Language Specification Ch 17.4.5. Happens-before Order

java代码编译成计算机指令后,计算机在执行指令时会进行一定程度的重排序。happens-before规则相当于java设计者和java开发者之间的约定,屏蔽了计算机底层的细节,从java层面和开发者建立约定,约定的内容是如下这样:

java语言的设计者建立了一套happens-before规则,如果开发者在相应的场景下确保两个操作之间遵从了happens-before规则,那java会为开发者保证,遵从了happens-before规则的多线程操作具有实际的happens-before关系,进而可以保证共享变量的可见性

这些happenns-before关系大多需要开发者自己保证,而于此同时,java内置就已经实现了一系列happens-before关系,这些关happens-before 关系天然存在,而不需要开发者自行维护。

警惕误区

网上绝大多数的文章在描述happens-before关系时,关于已经存在还是需要开发者留心维护这个点上上没有说清楚。 官方文档所描述的happens-before并非陈述句,不是在表述一个客观事实,而是一个应该补上一个should,完整的语义应该为: Action A should have a happens-before relationship with action B, in order to ensure result of action A is visible to action B. 如果我们把需要我们花气力维护的happens-before关系当成了天然存在的关系,例如,这样的一条规则:“* A write to a volatile field happens-before every subsequent read of that field.”,这是一条需要开发者维护的规则,如果开发者直接把这条规则当成了固有事实,无论怎样的代码顺序都可以保证“对volatile变量的写一定发生在读之前”,那么线程安全就完全得不到保障了。 因此,后文对于happens-before介绍将主要分为两类:即已经存在开发者自行保证

2. Happens-before规则

Happens-before关系可传递性

在学习下面的happens-before规则之前,我们需要了解到的一个已有规则是,happens-before关系具有可传递性。用hb(a,b)表示a happens-before b,而如果 hb(b,c),则我们可以得知hb(a,c)。可传递性这一固有特性将帮助我们顺利理解后面的诸多规则。

已经存在(需要了解)

这一部分happens-before规则已经由java语言设计者实现,故对于开发者而言它们是固有存在的事实,了解他们有助于我们清楚代码为什么可以保证可见性,同时在复杂的情况有分析可见性问题的能力。

1. 单线程规则

我们,都知道,在单线程内部,对于开发者而言,可以确保写在前面的操作会happens-before之后的操作。尽管计算机底层的指令和java代码的顺序会有比较大的差异,但其中的细节jdk会去处理,java可以向开发者保证,单线程所有前面代码的操作结果对于后面代码是可见的。对于这一条规则无需赘述,绝大多数开发者在编码的第一天都是默认了这个事实。而这一规则也是其他所有happens-before规则成立的基石。

2. Thread start规则

线程启动规则指出,线程A中启动线程B,那么线程A中在调用ThreadB.start()方法happens-before线程B 中的所有操作。由于可传递性的存在,我们自然也可以得知,A线程中调用ThreadB.start()之前的所有操作均happens-before B线程的所有操作,即A线程在调用ThreadB.start()之前的操作结果对B线程可见。

3. Thread join规则

该规则指出,线程B中的所有操作happens-before线程B的join()方法。同理,根据可传递性,我们也可以确定,线程B中的所有操作如statement1 happens-before 线程A的statement2。

开发者自行保证(需要了解并清楚如何实现)

这一部分的规则需要开发者额外关注,因为java语言没有内置机制保证这些happens-before规则,要实现这些场景下的可见性,需要开发者自行保证这一关系的存在。如果缺少这些关系,则java无法保证一个线程的操作结果对另一个线程的可见性。

4. volatile变量规则

如果希望A线程的对volatile变量的修改对B线程可见,那么A线程对volatile的变量的写应该happens-before B线程对这个volatile变量的读。

即如果保证线程A对一个volatile变量的写发生在另一个线程B对于这个变量的读之前,在A对这个变量的操作对B可见。 下面通过一个简单的代码样例验证这一规则。

public class TestVolatileHappensBefore {
    static volatile int shared = 0;

    static class WriteTask implements Runnable {
        @Override
        public void run() {
            shared = 5; //1. write to the volatile shared variable
        }
    }

    static class ReadTaslk implements Runnable {
        @Override
        public void run() {
            System.out.println(shared);//2. read to the volatile shared variable
        }
    }

    public static void main (String[] args) {
        Thread tWrite = new Thread(new WriteTask());
        Thread tRead = new Thread(new ReadTaslk());
        tWrite.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            //just wait to ensure order
        }
        tRead.start();

    }
}

这段代码的运行结果显而易见,会打印shared的值为5。因为遵从了volatile变量的happens-before规则,故注释中1的操作结果对2可见。如果仅展示正例则无法证明这一规则存在的必要性。而下面的这个反例则可以证明我们必须确保这一 happens-before 关系。

   public static void main (String[] args) {
       Thread tWrite = new Thread(new WriteTask());
       Thread tRead = new Thread(new ReadTaslk());
       tRead.start();
       tWrite.start();

   }

反面例子中仅更改了对于volatile变量读写线程的开始顺序,即我们没有严格遵守volatile变量的happens-before规则,那这样的代码运行结果会如何呢?这里附上数次运行结果的截图:

从结果可以看出,在短短数次的重复运行中,shared变量的值有时为初始值0,有时为被写进程修改后的值5。也就是更新后的shared变量的值无法保证其对其他线程的可见性。故如果需要保证volatile变量的可见性,我们需要满足volatile变量的happens-before规则。

5. 锁操作规则(synchronized及实现了Lock接口实现)

如果A线程是解锁操作,之后另一个B线程执行加锁操作,如果希望A的操作结果对B可见,那么 A应该happens-before B

同样通过一段样例代码展示该规则的正面案例,依然是线程先读后写的情况,同步机制依赖显示锁ReentrantLock:

public class TestLockHappensBefore {
    static int a = 0;
    static ReentrantLock lock = new ReentrantLock();

    public static void modify1() {
        lock.lock();
        try {
            System.out.println("a= " + a + " with " + Thread.currentThread().getName());
        } finally {
            a = 10;
            lock.unlock();
        }
    }

    public static void modify2() {
        lock.lock();
        try {
            System.out.println("a= " + a + " with " + Thread.currentThread().getName());
        } finally {
            a = 5;
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Thread thread0 = new Thread() {
            @Override
            public void run() {
                modify1();
            }
        };
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                modify2();
            }
        };

        thread0.start();
        thread1.start();
    }
}

thread0打印当前a的值并将a赋值为10,thread1同样打印当前a的值并将其赋值为5。

因为thread0 符合happens-before thread1的规则,所以thread0对变量a的修改对thread1可见,故运行结果为如图:

而如果将启动顺序倒置

thread1.start();
thread0.start();

则关系变成了 thread1 happens-before thread0,则thread1对a的修改对thread0可见,运行结果如图:

在这个样例代码中不存在关系的违反,但满足不同的happens-before关系,根据规则,则会有不同的结果,这需要开发者在开发过程中留心观察,理清逻辑。

6. 各式各样的组合场景下的happens-before规则

这就是一个无穷无尽的话题了,在组合的场景下,只有符合了happens-before的线程两两之间能够保证可见性,而不一定可以保证所有线程的互相可见性,要处理好这种情况则需要开发者对 各种场景的happens-before规则烂熟于胸,利用好规则的可传递性,梳理清楚线程交互逻辑,才有可能处理好所有线程间的可见性问题。

一个小彩蛋 笔者在撰写样例代码的时候就遇到了这种组合的情况,happens-before关系并没有形成传递链,故出现了意想不到的结果,仔细分析才得了原因,与各位简单分享一下。代码如下:

public class VolatileUnexpectedHappensBefore {
    static int a = 0;
    public static synchronized void read() {
        System.out.println(a);
    }
    public static synchronized void write() {
        a = 5;
    }

    public static void main(String[] args) {
        Thread write = new Thread(){
            public void run() {
                write();
            }
        };
        write.start();
        read();
    }
}

看到这个代码,不知道各位认为打印出来的a的值会是多少,我一开始下意识认为一定是5,然后结果是初始值0。 之所以会有这样的结果,是因为尽管write 线程的start方法 happens-before 主线程调用read()方法,但因为创建线程和线程执行需要时间,write线程的run()方法并不被保证 happens-before 主线程的write()方法。 在这种情况下write线程的unlcok不被保证 happens-before 于 主线程的read(),主线程自然没法看到write线程的操作结果。而如果我们想要read的结果打印a = 5,方法也很简单,运用我们之前的几个固有的happens-before规则。 例如,应用 thread join规则,将代码改为:

   write.start();
   write.join();
   read();

则write线程和主线程满足happens-before规则,具有实际的happens-before关系,在结合happens-before关系的传递性,happens-before关系从符合thread jion规则传递到符合锁的规则,故write线程的修改一定对主线程可见。仅仅是这样一个简单的样例尚且容易出错,在更复杂的开发场景下,要想处理好可见性问题,只能靠开发人员自己不断实践和总结,才能得到真知。

引用

Java Language Specification

java并发核心编程78讲

Java - Understanding Happens-before relationship