1. 首页
  2. 后端开发
  3. JAVA语言

【漫画】JAVA并发编程 如何解决可见性和有序性问题

在上一篇文章中,我们初识了并发编程的三个bug源头:可见性、原子性、有序性。明白了它们究竟为什么会发生,那么今天我们就来聊聊如何解决这三个问题吧。

序幕

_1

Happens-Before是什么?

_2

A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

_3

Happens-Before的作用

happens-before原则非常重要,它是判断线程是否安全的主要依据,依靠这个原则,我们就能解决在并发环境下可见性和有序性问题。
比如某天老板问你“胖滚猪,我这段并发代码会有线程安全问题吗”,那么你可以对照着happens-before原则一个个看,要是符合其中之一并且是原子性的,你就可以大声告诉老板“没得问题!”
比如这段代码:

i = 1;       //线程A执行
j = i ;      //线程B执行

j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。
这就是happens-before原则的威力!让我们走进它的世界吧!

Happens-Before八大原则 解决原子性和有序性问题

image.png

###规则一:程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这规则挺好理解的,毕竟是在一个线程中呐。
你会觉得这是个废物规则。其实这个规则是一个基础规则,happens-before 是多线程的规则,所以要和其他规则约束在一起才能体现出它的顺序性,别着急,继续向下看。

###规则二: Volatile变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。我们在上篇文章说过,因为缓存的原因,每个线程有自己的工作内存,如果共享变量没有及时刷到主内存中,那就会导致可见性问题,线程B没有及时读到线程A的写。但是只要加上Volatile,就可以避免这个问题,相当于volatile的作用是对变量的修改会绕过高速缓存立刻刷新到主存。不过要注意一下,volatile除了保证可用性,它还可以禁止指定重排序哦!

public class TestVolatile1 {
    private volatile static int count = 0;
    public static void main(String[] args) throws Exception {
        final TestVolatile1 test = new TestVolatile1();
        Thread th1 = new Thread(() -> {
            count = 10;
        });
        Thread th2 = new Thread(() -> {
            //没有volatile修饰count的话极小概率会出现等于0的情况
            System.out.println("count=" + count);
        });
        // 启动两个线程
        th1.start();
        th2.start();
    }
}

规则三: 传递性规则

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。这也很好理解。我们举个例子,writer和reader是两个不同的线程,它们有如下操作:

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

这个例子和上面那个Volatile的例子有个区别就是,有两个变量。那么我们来分析一下:
(1)和(2)在同一个线程中,根据规则1,(1)Happens-Before于(2)
(3)和(4)在同一个线程中,同理,(3)Happens-Before于(4)
根据规则2,由于v用了volatile修饰,那么(2)必然 Happens-Before于(3)。
那么根据传递性规则可得:(1)Happens-Before于(4),因此x必然为42。
所以即使x没有用volatile,它也是可以保证可见性的!所以为啥刚刚说规则1要和其他规则联合起来看才有意思,现在你知道了吧!

规则四: 管程中的锁规则

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

synchronized (this) { // 此处自动加锁
  if (this.x < 10) {//临界区
  }  
} // 此处自动解锁

这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
_4

规则五: 线程启动规则

主线程 A 启动子线程 B 后(线程 A 调用线程 B 的 start() 方法),子线程 B 能够看到主线程在启动子线程 B 前的操作。

private static long count = 0;
public static void main(String[] args) throws InterruptedException {
    Thread B = new Thread(() -> {
        // 主线程调用 B.start() 之前 所有对共享变量的修改,此处皆可见
        // 因此count肯定为10
        System.out.println(count);
    });
    // 此处对共享变量count修改
    count = 10;
    // 主线程启动子线程
    B.start();
}

规则六: 线程终止规则

主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么主线程能够看到子线程的操作(指共享变量的操作),换句话说就是线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

private static long count = 0;
public static void main(String[] args) throws InterruptedException {
    Thread B = new Thread(() -> {
        // 主线程调用 B.start() 之前 所有对共享变量的修改,此处皆可见
        // 因此count肯定为10
        count = 10;
    });

    // 主线程启动子线程
    B.start();
    // 主线程等待子线程完成
    B.join();
    // 子线程所有对共享变量的修改 在主线程调用 B.join() 之后皆可见
    System.out.println(count);//count必然为10
}

规则七:线程中断规则

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。即线程A调用线程B的interrupt()方法,happens-before于线程A发现B被A中断(通过Thread.interrupted()方法检测到是否有中断发生)。

private static long acount = 0;
private static long bcount = 0;
public static void main(String[] args) throws InterruptedException {
    Thread B = new Thread(() -> {
        bcount = 7;
        System.out.println("Thread A被中断前bcount="+bcount+" acount="+acount);
        while (true){
            if (Thread.currentThread().isInterrupted()){
                bcount = 77;
                System.out.println("Thread A被中断后bcount="+bcount+" acount="+acount);
                return;
            }
        }
    });
    B.start();
    Thread A = new Thread(() -> {
        acount = 10;
        System.out.println("Thread B 中断A前bcount="+bcount+" acount="+acount);
        B.interrupt();
        acount = 100;
        System.out.println("Thread B 中断A后bcount="+bcount+" acount="+acount);
    });
    A.start();
}

规则八:对象规则

一个对象的初始化完成(构造函数执行结束,一般都是用new初始化)happen—before它的finalize()方法的开始。finalize()是在java.lang.Object里定义的,即每一个对象都有这么个方法。这个方法在该对象被回收的时候被调用。该条原则强调的是多线程情况下对象初始化的结果必须对发生于其后的对象销毁方法可见。

    public HappensBefore8(){
        System.out.println("构造方法");
    }
    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象销毁");
    }

    public static void main(String[] args){
        new HappensBefore8();
        System.gc();
    }

关于有序性的那些疑问

_5

扩展有序性的概念:Java内存模型中的程序天然有序性可以总结为一句话,如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。 这其实还涉及到一个高频面试考点:as-if-serial语义

as-if-serial语义:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。所以编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

划重点:单线程中保证按照顺序执行。
synchronized同一时刻只有一个线程在运行,也就相当于保证了有序性。至于这个双重检查案例,出问题,并不是因为synchronized没有保证有序性。而是指令重排导致了在多个线程中无序。

总结

_6

BDStar原创文章。发布者:Liuyanling,转载请注明出处:http://bigdata-star.com/archives/2393

发表评论

登录后才能评论

联系我们

562373081

在线咨询:点击这里给我发消息

邮件:562373081@qq.com

工作时间:周一至周五,9:30-18:30,节假日休息

QR code