Java并发编程实战基础概要

Java并发编程实战基础概要

开篇

最近在极客时间中学习《Java并发编程实战》,现在只学习到了前面基础的几章,总结了一下一些学习要点,并且在公司内部进行了一次小型的分享了。如果想要查看分享PPT的话,我已经上传资源了:Java并发编程实战-基础概要,PPT的内容会更精简一些,关注重点也会更清晰一些。

多线程问题有啥难点呢?

难发现,难重现,难调试,难定位

为啥要学习并发编程?

现在的程序都是多线程运行的,学习了并发编程,让你脑子里有一些思路去解决定位多线程中遇到的问题,比如多线程模式下的单例问题,Spring单例模式慎用成员变量,甚至一些死锁问题等等。 也会让你在平时开发中少写一些BUG。

并发问题的根源是什么?

在我们解决并发问题前首先要理解产生并发问题的根源是什么?很多人一说到并发问题就说加锁解决,但是加锁也是并发问题的其中一种解决方案。而且如果只是了解解决方案,但是不理解问题的根源,我们还是很难定位问题,对症下药。所以要学好并发编程,还是要理解并发问题的根源是什么?

  • 起因:如何最大化的利用CPU
  • 核心问题:CPU > 内存 > I/O 设备

为了解决这个问题,计算机分别从缓存、任务切换、指令排序优化这几个方向进行了优化

  1. 进程和线程的产生,分时复用 CPU,提高CPU的利用率。让我们在听歌的同时也能打代码。 但同时也带了并发问题的根源之一:CPU切换线程执导致的原子性问题
  2. CPU高速缓存的产生。CPU高速缓存是用于减少CPU访问内存所需的时间,其容量远小于内存,但其访问速度却是内存IO的几十上百倍。有缓存就涉及到了缓存的更新和刷新到内存的时机问题,所以这也就带来了并发问题的根源之二:缓存导致的可见性问题
  3. 指令优化(重排序)。指令顺序优化的初衷的初衷就是想通过调整CPU指令的执行顺序和异步化的操作来提升CPU执行指令任务的效率。其重排序的大体逻辑就是优先把CPU比较耗时的指令放到最先执行,然后在这些指令执行的空余时间来执行其他指令,就像我们做菜的时候会把熟的最慢的菜最先开始煮,然后在这个菜熟的时间段去做其它的菜,通过这种方式减少CPU的等待,更好的利用CPU的资源。所以并发问题根源之三:指令优化导致的重排序问题

参考: 并发理论基础:并发问题产生的三大根源

CPU切换线程执导致的原子性问题是如何发生的?

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。

在这里插入图片描述

Java 并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。

这里举了一个例子:

例如代码中的count += 1,高级语言里一条语句至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,注意是CPU指令级别,而不是高级语言的语句指令级别。

在这里插入图片描述

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

缓存导致的可见性问题是如何发生的?

多核时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

在这里插入图片描述

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。很明显在上面的情况,线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。

一样举刚才count += 1的例子,第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。

指令优化(重排序)带来的有序性问题是如何发生的?

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,从而产生意想不到的BUG。

在Java领域一个经典的案例就是利用双重检查创建单例对象,我们来看下面的代码:

public class Singleton { 

    static Singleton instance; 
    
    static Singleton getInstance(){ 
        if (instance == null) { 
            synchronized(Singleton.class) { 
                if (instance == null) {
                    instance = new Singleton(); 
                }
            } 
        }
        return instance; 
    }
}

二次判空原因是为了避免不必要的同步

代码看上去很完美,其实暗藏玄机

我们以为的 new 操作应该是:

    1. memory = allocate(); // 1.分配对象的内存空间;分配一块内存 M;
    2. ctorInstance(memory); // 2.初始化对象;在内存 M 上初始化 Singleton 对象;
    3. instance = memory;  // 3.设置instance指向刚分配的内存地址;然后 M 的地址赋值给 instance 变量。

但是上述伪代码中的2和3之间可能会发生重排序,重排序后的执行顺序如下:

    1. memory = allocate();  // 1.分配对象的内存空间;分配一块内存 M
    2. instance = memory;  // 2.设置instance指向刚分配的内存地址,此时对象还没有被初始化;然后 M 的地址赋值给 instance 变量。
    3. ctorInstance(memory);  // 3.初始化对象;最后在内存 M 上初始化 Singleton 对象。

在这里插入图片描述

解决方法就是将对象声明为volatitle后,前面的重排序在多线程环境中将会被禁止

public class Singleton { 

    private static volatile Singleton instance; 
    
    static Singleton getInstance(){ 
        if (instance == null) { 
            synchronized(Singleton.class) { 
                if (instance == null) {
                    instance = new Singleton(); 
                }
            } 
        }
        return instance; 
    }
}

Java内存模型

前面说到了并发问题的三个幕后黑手:

  1. 原子性问题
  2. 可见性问题
  3. 有序性问题

那在Java中是如何解决其中的线程的可见性和有序性问题的,说到这,就不得不提一个Java的核心技术,那就是——Java的内存模型。

参考:掌握Java的内存模型,你就是解决并发问题最靓的仔

什么是Java内存模型

Java线程之间的通信由Java内存模型(JMM)控制。JMM定义了线程和主内存之间的抽象关系:在内存里,Java内存模型规定了所有的变量都存储在主内存(物理内存)中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行。不同的线程无法访问其他线程的工作内存里的内容。

我们可以使用下图来表示在逻辑上 线程主内存工作内存的三者交互关系。

在这里插入图片描述

额外的延伸知识,从JVM的角度来看java运行时的内存区域,来对应我们Java内存模型

在这里插入图片描述

前面我们都理解了缓存导致了可见性问题,编译优化导致了有序性问题。也就是说解决可见性和有序性问题的合理的方案应该是按需禁用缓存以及编译优化(全部禁用缓存和编译优化,程序的性能堪忧)

为了解决可见性和有序性问题,Java只需要提供给Java程序员按照需要禁用缓存和编译优化的方法即可。

在这里插入图片描述

Java内存模型规范了Java虚拟机(JVM)如何提供按需禁用缓存和编译优化的方法。这些方法包括:volatile、synchronized和final关键字,以及Java内存模型中的Happens-Before规则

在这里插入图片描述

volatile

volatile关键字不是Java特有的,在C语言中也存在volatile关键字,这个关键字最原始的意义就是禁用CPU缓存。

例如,我们在程序中使用volatile关键字声明了一个变量,如下所示。

volatile int count = 0;

此时,Java对这个变量的读写,不能使用CPU缓存,必须从内存中读取和写入。(蓝色的虚线箭头代表禁用了CPU缓存,黑色的实线箭头代表直接从主内存中读写数据。)

在这里插入图片描述

例如下面的示例代码,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 v=true 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 v == true 时,那么线程 B 看到的变量 x 是多少呢?

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

直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

因为在1.5之前 使用volatile修饰只能解决该修饰变量的内存可见性问题,只能保证这个变量的修改可以立即被其他线程看到. 可以看到,x并没有被volatile修饰,按之前的理解,线程A对x的修改不一定能被线程B看到

在JDK1.5版本中的Java内存模型中引入了Happens-Before原则。

Happens-Before原则

Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。

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

在这里插入图片描述

程序次序规则

在一个线程中,按照代码的顺序,前面的操作Happens-Before于后面的任意操作。也就是一个线程执行过程中,前面对某个变量的修改一定是对后续操作可见的

例如代码中的x = 42; Happens-Before v = true。这个规则比较符合单线程的思维:在同一个线程中,程序在前面对某个变量的修改一定是对后续操作可见的。(强调同一个线程中)

class VolatileExample { 
  int x = 0; 
  volatile boolean v = false;
  public void writer() { 
    x = 42; 
    v = true; 
  } 
  public void reader() { 
    if (v == true) {
      // 这里x会是多少呢? 
    } 
  }
}
volatile变量规则

对一个volatile变量的写操作,Happens-Before于后续对这个变量的读操作。

也就是说,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见 (可以是不同的线程)

貌似和 1.5 版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。

传递规则

如果A Happens-Before B,并且B Happens-Before C,则A Happens-Before C。

我们将规则 3 的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图:

在这里插入图片描述

从图中,我们可以看到:

  1. x=42 Happens-Before 写变量 v=true ,这是规则 1 的内容;
  2. 写变量v=true Happens-Before 读变量 v=true,这是规则 2 的内容
  3. 再根据这个传递性规则,我们得到结果:x=42 Happens-Before 读变量v=true

这意味着什么呢?

如果线程 B 读到了v=true,那么线程 A 设置的x=42对线程 B 是可见的。也就是说,线程 B 能看到 x == 42 ,有没有一种恍然大悟的感觉?这就是 1.5 版本对volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的

锁定规则

对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作。

例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { //此处自动加锁 
// x是共享变量,初始值=10 
    if (this.x < 12) { 
        this.x = 12; 
    } 
} //此处自动解锁

可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

PS: 可以这里理解:对于线程A,将x修改为12这个操作happens before 线程A释放锁(规则1),对于线程B,线程A的释放锁happens before 线程B获得锁(规则4),再根据传递性,x被修改为12 happens before 线程B获得锁(规则3),由此可见,x被修改为12对于线程B获得锁是可见的,所以线程B获得锁之后可以看到x为12。

线程启动规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码。

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

PS: 结合程序顺序性和传递性,就可以认为是 start happen before于B 中的任意操作了

线程终结规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66
线程中断规则

对线程interrupt()方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生。

例如,下面的程序代码。在线程A中中断线程B之前,将共享变量x的值修改为100,则当线程B检测到中断事件时,访问到的x变量的值为100。

//在线程A中将x变量的值初始化为0
private int x = 0;

public void execute(){
    //在线程A中初始化线程B
    Thread threadB = new Thread(()->{
        //线程B检测自己是否被中断
        if (Thread.currentThread().isInterrupted()){
            //如果线程B被中断,则此时X的值为100
            System.out.println(x);
        }
    });
    //在线程A中启动线程B
    threadB.start();
    //在线程A中将共享变量X的值修改为100
    x = 100;
    //在线程A中中断线程B
    threadB.interrupt();
}
对象终结原则

一个对象的初始化完成Happens-Before于它的finalize()方法的开始。

例如,下面的程序代码。

public class TestThread {

   public TestThread(){
       System.out.println("构造方法");
   }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象销毁");
    }

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

运行结果:

构造方法
对象销毁

再说final关键字

前面我们讲 volatile 为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是 final 关键字。final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。

参考

Java内存模型FAQ

How do final fields work under the new JMM?

【高并发】如何解决可见性和有序性问题?这次彻底懂了!

掌握Java的内存模型,你就是解决并发问题最靓的仔

并发理论基础:并发问题产生的三大根源

多线程基础篇 - JavaEE初阶 - 细节狂魔

线程的工作内存

The “Double-Checked Locking is Broken” Declaration

编译器重排序

Java中的双重检查锁(double checked locking)

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值