【漫画】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,转载请注明出处!

相关文章
|
1天前
|
Java 数据库
JAVA并发编程-一文看懂全部锁机制
曾几何时,面试官问:java都有哪些锁?小白,一脸无辜:用过的有synchronized,其他不清楚。面试官:回去等通知! 今天我们庖丁解牛说说,各种锁有什么区别、什么场景可以用,通俗直白的分析,让小白再也不怕面试官八股文拷打。
|
7天前
|
缓存 Java 编译器
JAVA并发编程volatile核心原理
volatile是轻量级的并发解决方案,volatile修饰的变量,在多线程并发读写场景下,可以保证变量的可见性和有序性,具体是如何实现可见性和有序性。以及volatile缺点是什么?
|
1天前
|
Java
深入理解Java中的多线程编程
本文将探讨Java多线程编程的核心概念和技术,包括线程的创建与管理、同步机制以及并发工具类的应用。我们将通过实例分析,帮助读者更好地理解和应用Java多线程编程,提高程序的性能和响应能力。
15 4
|
9天前
|
Java 调度 开发者
Java并发编程:深入理解线程池
在Java的世界中,线程池是提升应用性能、实现高效并发处理的关键工具。本文将深入浅出地介绍线程池的核心概念、工作原理以及如何在实际应用中有效利用线程池来优化资源管理和任务调度。通过本文的学习,读者能够掌握线程池的基本使用技巧,并理解其背后的设计哲学。
|
1天前
|
安全 Java 开发者
Java并发编程中的锁机制解析
本文深入探讨了Java中用于管理多线程同步的关键工具——锁机制。通过分析synchronized关键字和ReentrantLock类等核心概念,揭示了它们在构建线程安全应用中的重要性。同时,文章还讨论了锁机制的高级特性,如公平性、类锁和对象锁的区别,以及锁的优化技术如锁粗化和锁消除。此外,指出了在高并发环境下锁竞争可能导致的问题,并提出了减少锁持有时间和使用无锁编程等策略来优化性能的建议。最后,强调了理解和正确使用Java锁机制对于开发高效、可靠并发应用程序的重要性。
9 3
|
1天前
|
安全 Java API
JAVA并发编程JUC包之CAS原理
在JDK 1.5之后,Java API引入了`java.util.concurrent`包(简称JUC包),提供了多种并发工具类,如原子类`AtomicXX`、线程池`Executors`、信号量`Semaphore`、阻塞队列等。这些工具类简化了并发编程的复杂度。原子类`Atomic`尤其重要,它提供了线程安全的变量更新方法,支持整型、长整型、布尔型、数组及对象属性的原子修改。结合`volatile`关键字,可以实现多线程环境下共享变量的安全修改。
|
9天前
|
缓存 监控 Java
Java中的并发编程:理解并应用线程池
在Java的并发编程中,线程池是提高应用程序性能的关键工具。本文将深入探讨如何有效利用线程池来管理资源、提升效率和简化代码结构。我们将从基础概念出发,逐步介绍线程池的配置、使用场景以及最佳实践,帮助开发者更好地掌握并发编程的核心技巧。
|
9天前
|
存储 安全 Java
Java并发编程之深入理解Synchronized关键字
在Java的并发编程领域,synchronized关键字扮演着守护者的角色。它确保了多个线程访问共享资源时的同步性和安全性。本文将通过浅显易懂的语言和实例,带你一步步了解synchronized的神秘面纱,从基本使用到底层原理,再到它的优化技巧,让你在编写高效安全的多线程代码时更加得心应手。
|
7天前
|
存储 Java
Java编程中的对象序列化与反序列化
【9月更文挑战第12天】在Java的世界里,对象序列化与反序列化是数据持久化和网络传输的关键技术。本文将带你了解如何通过实现Serializable接口来标记一个类的对象可以被序列化,并探索ObjectOutputStream和ObjectInputStream类的使用,以实现对象的写入和读取。我们还将讨论序列化过程中可能遇到的问题及其解决方案,确保你能够高效、安全地处理对象序列化。
|
2天前
|
Java 开发者
Java编程之旅:探索面向对象的力量
【9月更文挑战第16天】在编程的世界中,Java以其强大的面向对象编程特性而闻名。本文将带你走进Java的世界,一起探索类与对象的奥秘,学习如何通过封装、继承和多态性构建健壮的软件系统。无论你是初学者还是有经验的开发者,本文都旨在提供实用的代码示例,帮助你提升Java技能。准备好开始这段旅程了吗?让我们启程吧!