Java 编程之美:并发编程基础晋级篇

时间:2022-12-17 22:27:46

Java 编程之美:并发编程基础晋级篇

本文来自作者 加多  GitChat 上分享 「Java 并发编程之美:并发编程基础晋级篇」

编辑 | Mc Jin

借用 Java 并发编程实践中的话,编写正确的程序并不容易,而编写正常的并发程序就更难了!

相比于顺序执行的情况,多线程的线程安全问题是微妙而且出乎意料的,因为在没有进行适当同步的情况下多线程中各个操作的顺序是不可预期的。

并发编程相比 Java 中其他知识点学习起来门槛相对较高,学习起来比较费劲,从而导致很多人望而却步;

而无论是职场面试和高并发高流量的系统的实现却都还离不开并发编程,从而导致能够真正掌握并发编程的人才成为市场比较迫切需求的。

本 Chat 作为 Java 并发编程之美系列的并发编程必备基础晋级篇,通过通俗易懂的方式来和大家聊聊多线程并发编程中涉及到的高级基础知识(建议先阅读《Java 编程之美 - 线程相关的基础知识》),具体内容如下:

  • 什么是多线程并发和并行。

  • 什么是线程安全问题。

  • 什么是共享变量的内存可见性问题。

  • 什么是 Java 中原子性操作。

  • 什么是 Java 中的 CAS 操作,AtomicLong 实现原理

  • 什么是 Java 指令重排序。

  • Java 中 Synchronized 关键字的内存语义是什么。

  • Java 中 Volatile 关键字的内存语义是什么。

  • 什么是伪共享,为何会出现,以及如何避免。

  • 什么是可重入锁、乐观锁、悲观锁、公平锁、非公平锁、独占锁、共享锁。

多线程并发与并行

首先要澄清并发和并行的概念,并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束;而并行是说在单位时间内多个任务同时在执行;

并发任务强调在一个时间段内同时执行,而一个时间段有多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。

在单个 CPU 的时代多个任务同时运行都是并发,这是因为 CPU 同时只能执行一个任务,单个 CPU 时代多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时候,其它任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其它任务来使用。

所以在单 CPU 时代多线程编程的意义不大,并且线程间频繁的上下文切换还会带来开销。

如下图单个 CPU 上运行两个线程,可知线程 A 和 B 是轮流使用 CPU 进行任务处理的,也就是同时 CPU 只在执行一个线程上面的任务,当前线程 A 的时间片用完后会进行线程上下文切换,也就是保存当前线程的执行线程,然后切换线程 B 占用 CPU 运行任务。

Java 编程之美:并发编程基础晋级篇


如下图双 CPU 时候,线程 A 和线程 B 在自己的 CPU 上执行任务,实现了真正的并行运行。

Java 编程之美:并发编程基础晋级篇

而在多线程编程实践中线程的个数往往多于 CPU 的个数,所以平时都是称多线程并发编程而不是多线程并行编程。

线程安全问题

谈到线程安全问题不得不先说说什么是共享资源,所谓共享资源是说多个线程都可以去访问的资源。

线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施的时候,导致脏数据或者其它不可预见的结果的问题。

Java 编程之美:并发编程基础晋级篇

如上图,线程 A 和线程 B 可以同时去操作主内存中的共享变量,是不是说多个线程共享了资源,都会产生线程安全问题呢?

答案是否定的,如果多个线程都是只读取共享资源,而不去修改,那么就不会存在线程安全问题。

只有当至少一个线程修改共享资源时候才会存在线程安全问题。最典型的就是计数器类的实现,计数 count 本身是一个共享变量,多个线程可以对其进行增加一,如果不使用同步的话,由于递增操作是获取 -> 加1 -> 保存三步操作,所以可能导致导致计数不准确,如下表:

Java 编程之美:并发编程基础晋级篇

假如当前 count=0,t1 时刻线程 A 读取了 count 值到本地变量 countA。

然后 t2 时刻递增 countA 值为1,同时线程 B 读取 count 的值0放到本地变量 countB 值为0(因为 countA 还没有写入主内存)。

t3 时刻线程 A 才把 countA 为1的值写入主内存,至此线程 A 一次计数完毕,同时线程 B 递增 CountB 值为1。

t4 时刻线程 B 把 countB 值1写入内存,至此线程 B 一次计数完毕。

先不考虑内存可见性问题,明明是两次计数哇,为啥最后结果还是1而不是2呢?其实这就是共享变量的线程安全问题。

那么如何解决?这就需要在线程访问共享变量时候进行适当的同步,Java 中首屈一指的是使用关键字 Synchronized 进行同步,这个下面会有具体介绍。

共享变量的内存可见性问题

要谈内存可见性首先需要介绍下 Java 中多线程下处理共享变量时候的内存模型。

Java 编程之美:并发编程基础晋级篇


如上图,Java 内存模型规定了所有的变量都存放在主内存中,当线程使用变量时候都是把主内存里面的变量拷贝到了自己的工作空间或者叫做工作内存。

Java 内存模型是个抽象的概念,那么在实际实现中什么是线程的工作内存呢?

Java 编程之美:并发编程基础晋级篇

如上图是双核 CPU 系统架构,每核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算,并且有自己的一级缓存,并且有些架构里面双核还有个共享的二级缓存。

那么 对应 Java 内存模型里面的工作内存,在实现上这里是指 L1 或者 L2 缓存或者 CPU 的寄存器。

假如线程 A 和 B 同时去处理一个共享变量,会出现什么情况呢?

使用上图 CPU 架构,假设线程  A和 B 使用不同 CPU 进行去修改共享变量 X,假设 X 的初始化为0,并且当前两级 Cache 都为空的情况,具体看下面分析:

  • 假设线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中,所以到主内存加载了 X=0,然后会把 X=0 的值缓存到两级缓存,假设线程 A 修改 X 的值为1,然后写入到两级 Cache,并且刷新到主内存(注:如果没刷新会主内存也会存在内存不可见问题)。

    这时候线程 A 所在的 CPU 的两级 Cache 内和主内存里面 X 的值都是1;

  • 然后假设线程 B 这时候获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回 X=1;然后线程 B 修改 X 的值为2;然后存放到线程2所在的一级 Cache 和共享二级 Cache,最后更新主内存值为2;

  • 然后假设线程 A 这次又需要修改 X 的值,获取时候一级缓存命中获取 X=1,到这里问题就出现了,明明线程 B 已经把 X 的值修改为了2,为啥线程 A 获取的还是1呢?

    这就是共享变量的内存不可见问题,也就是线程 B 写入的值对线程 A 不可见。

那么对于共享变量内存不可见问题如何解决呢?Java 中首屈一指的 Synchronized 和 Volatile 关键字就可以解决这个问题,下面会有讲解。

Java 中 Synchronized 关键字

Synchronized 块是 Java 提供的一种原子性内置锁,Java 中每个对象都可以当做一个同步锁的功能来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。

线程在进入 Synchronized 代码块前会自动尝试获取内部锁,如果这时候内部锁没有被其他线程占有,则当前线程就获取到了内部锁,这时候其它企图访问该代码块的线程会被阻塞挂起。

拿到内部锁的线程会在正常退出同步代码块或者异常抛出后或者同步块内调用了该内置锁资源的 wait 系列方法时候释放该内置锁;

内置锁是排它锁,也就是当一个线程获取这个锁后,其它线程必须等待该线程释放锁才能获取该锁。

上一节讲了多线程并发修改共享变量时候会存在内存不可见的问题,究其原因是因为 Java 内存模型中线程操作共享变量时候会从自己的工作内存中获取而不是从主内存获取或者线程写入到本地内存的变量没有被刷新会主内存。

下面讲解下 Synchronized 的一个内存语义,这个内存语义就可以解决共享变量内存不可见性问题。

线程进入 Synchronized 块的语义是会把在 Synchronized 块内使用到的变量从线程的工作内存中清除,在 Synchronized 块内使用该变量时候就不会从线程的工作内存中获取了,而是直接从主内存中获取;

退出 Synchronized 块的内存语义是会把 Synchronized 块内对共享变量的修改刷新到主内存。

对应上面一节讲解的假如线程在 Synchronized 块内获取变量 X 的值,那么线程首先会清空所在的 CPU 的缓存,然后从主内存获取变量 X 的值;

当线程修改了变量的值后会把修改的值刷新回主内存。

其实这也是加锁和释放锁的语义,当获取锁后会清空本地内存中后面将会用到的共享变量,在使用这些共享变量的时候会从主内存进行加载;

在释放锁时候会刷新本地内存中修改的共享变量到主内存。

除了可以解决共享变量内存可见性问题外,Synchronized 经常被用来实现原子性操作,另外注意,Synchronized 关键字会引起线程上下文切换和线程调度的开销。

Java 中 Volatile 关键字

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太重,因为它会引起线程上下文的切换开销,对于解决内存可见性问题,Java 还提供了一种弱形式的同步,也就是使用了 volatile 关键字。

一旦一个变量被 volatile 修饰了,当线程获取这个变量值的时候会首先清空线程工作内存中该变量的值,然后从主内存获取该变量的值;

当线程写入被 volatile 修饰的变量的值的时候,首先会把修改后的值写入工作内存,然后会刷新到主内存。这就保证了对一个变量的更新对其它线程马上可见。

下面看一个使用 volatile 关键字解决内存不可见性的一个例子,如下代码的共享变量 value 是线程不安全的,因为它没有进行适当同步措施。

    public class ThreadNotSafeInteger {        private int value;        public int get() {            return value;
        }        public void set(int value) {            this.value = value;
        }
    }

首先看下使用 synchronized 关键字进行同步方式如下:

    public class ThreadSafeInteger {        private int value;        public synchronized int get() {            return value;
        }        public synchronized  void set(int value) {            this.value = value;
        }
    }

然后看下使用 volatile 进行同步如下:

    public class ThreadSafeInteger {        private volatile int value;        public int get() {            return value;
        }        public void set(int value) {            this.value = value;
        }
    }

这里使用 synchronized 和使用 volatile 是等价的,都解决了共享变量 value 的内存不可见性问题;但是前者是独占锁,同时只能有一个线程调用 get() 方法,其它调用线程会被阻塞;

并且会存在线程上下文切换和线程重新调度的开销;而后者是非阻塞算法,不会造成线程上下文切换的开销。

这里使用 synchronized 和使用 volatile 是等价的,但是并不是所有情况下都是等价的,这是因为 volatile 虽然提供了可见性保证,但是并没有保证操作的原子性。

那么一般什么时候才使用 volatile 关键字修饰变量呢?

扫描下方二维码

阅读完整原文

并在读者圈与作者交流

Java 编程之美:并发编程基础晋级篇