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

简介: JAVA并发编程 如何解决可见性与有序性问题呢?HappensBefore八大原则来搞定!胖滚猪用漫画的形式带你迅速入门。

原创声明:本文来自公众号【胖滚猪学编程】,以漫画形式让编程so easy and interesting,转载请注明出处!

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

序幕

_1

Happens-Before是什么?

_2

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

_3

Happens-Before的作用

原创声明:本文来自公众号【胖滚猪学编程】,以漫画形式让编程so easy and interesting,转载请注明出处!

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();
    }

关于有序性的那些疑问

原创声明:本文来自公众号【胖滚猪学编程】,以漫画形式让编程so easy and interesting,转载请注明出处!

_5

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

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

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

总结

_6

原创声明:本文来自公众号【胖滚猪学编程】,以漫画形式让编程so easy and interesting,转载请注明出处!

相关文章
|
2月前
|
Java 编译器 开发者
深入理解Java内存模型(JMM)及其对并发编程的影响
【9月更文挑战第37天】在Java的世界里,内存模型是隐藏在代码背后的守护者,它默默地协调着多线程环境下的数据一致性和可见性问题。本文将揭开Java内存模型的神秘面纱,带领读者探索其对并发编程实践的深远影响。通过深入浅出的方式,我们将了解内存模型的基本概念、工作原理以及如何在实际开发中正确应用这些知识,确保程序的正确性和高效性。
|
23天前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
25 0
|
2月前
|
存储 Java
深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。
【10月更文挑战第16天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。HashSet基于哈希表实现,添加元素时根据哈希值分布,遍历时顺序不可预测;而TreeSet利用红黑树结构,按自然顺序或自定义顺序存储元素,确保遍历时有序输出。文章还提供了示例代码,帮助读者更好地理解这两种集合类型的使用场景和内部机制。
48 3
|
5天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
28天前
|
缓存 Java 开发者
Java多线程并发编程:同步机制与实践应用
本文深入探讨Java多线程中的同步机制,分析了多线程并发带来的数据不一致等问题,详细介绍了`synchronized`关键字、`ReentrantLock`显式锁及`ReentrantReadWriteLock`读写锁的应用,结合代码示例展示了如何有效解决竞态条件,提升程序性能与稳定性。
121 6
|
1月前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
47 2
|
29天前
|
设计模式 安全 Java
Java 多线程并发编程
Java多线程并发编程是指在Java程序中使用多个线程同时执行,以提高程序的运行效率和响应速度。通过合理管理和调度线程,可以充分利用多核处理器资源,实现高效的任务处理。本内容将介绍Java多线程的基础概念、实现方式及常见问题解决方法。
68 0
|
2月前
|
存储 Java 开发者
HashSet和TreeSet教你重新认识Java集合的无序与有序
【10月更文挑战第14天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了它们分别实现无序和有序存储的机制。通过理解HashSet基于哈希表的无序特性和TreeSet利用红黑树实现的有序性,帮助开发者更好地选择合适的集合类型以满足不同的应用场景。
33 2
|
3月前
|
Java 开发者
深入探索Java中的并发编程
本文将带你领略Java并发编程的奥秘,揭示其背后的原理与实践。通过深入浅出的解释和实例,我们将探讨Java内存模型、线程间通信以及常见并发工具的使用方法。无论是初学者还是有一定经验的开发者,都能从中获得启发和实用的技巧。让我们一起开启这场并发编程的奇妙之旅吧!
34 5
|
3月前
|
算法 安全 Java
Java中的并发编程是如何实现的?
Java中的并发编程是通过多线程机制实现的。Java提供了多种工具和框架来支持并发编程。
21 1