[Java 并发基础] 也来聊聊Java多线程中的一些概念问题

Posted dm_vincent

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[Java 并发基础] 也来聊聊Java多线程中的一些概念问题相关的知识,希望对你有一定的参考价值。

文章导航

什么是多线程并发

理清并发和并行的概念。

并发:同一个时间段内多个任务同时都在执行,并且都没有执行结束。
并行:在单位时间内多个任务同时在执行。

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

为什么要进行多线程并发编程

单核CPU时代对于多线程并发其实是不友好的,因为按照时间片分配CPU资源的方式决定了一次只有一个线程能够获取到资源,时间片用完之后需要将CPU让给其它线程使用,上下文的频繁切换也许还会带来更多的额外开销。

在多核CPU时代这些限制因素都被打破了,这意味着每个线程都可以使用自己的CPU运行,减少了线程上下文切换的开销。但是更多的计算资源也就意味着更多的责任,为了利用好它们,对高并发的编程就有了迫切的需求。

线程安全

并发编程面临的挑战很多,其中最大的一点挑战是我们常说的线程安全问题。那么当我们在谈论线程安全的时候,我们到底在谈些什么?

首先如果我们说一段代码线程不安全,其实我们真正在说的是这段代码在多线程环境下,对某个数据进行写操作可能会造成前后不一致的情况。

这里的关键词是:

  • 多线程环境
  • 某数据的写操作
  • 前后不一致

Java内存模型

那么为什么在多线程环境下会有这种现象?要回答这个问题,就不得不先引入Java内存模型这个概念。下面这张图表达了Java内存模型的概念:

结合这个模型我们来看看问题出现的根源在哪里。

首先,程序运行时是在主存中给变量分配内存区域的。然后当一个线程去对某个变量进行写操作的时候,会首先将该变量拷贝一份副本到自己的工作内存中。后续对变量的操作实际上是在本地完成。而且在操作结束后,并不一定会马上将本地副本同步到主存中。

所以当多个线程同时操作一个变量的时候,很容易就出现了数据不一致的问题。第一个线程获取了变量进行了一些计算,计算完毕之后并没有马上将最新的值同步到主存;此时第二个线程开始对该变量进行操作,在一开始获取变量的环节就出现问题了:从主存中得到的变量不是最新的。那么它在脏数据上进行计算,最后算出来的数据肯定就是错的。

对于这类问题,可以统称为内存可见性问题。这个命名其实就体现了本质,由于每个线程都有自己的工作内存,对主存中的数据操作如果不加以时序上的控制,在操作的一开始可能就已经错了:因为拿到了过时的脏数据。

上面也许讲的比较抽象,可以结合我们熟知的在多线程环境下对变量的自增问题来辅助理解。

映射到现代硬件架构

上面展示了Java内存模型的概念图。它是一个抽象的概念,和具体的实现无关。但是这个模型的实现还是需要落地到现代的硬件架构上,我们以一个带有L1/L2 Cache的现代CPU结构来看看落地之后的内存可见性问题:

首先需要明确的一点是每个Java线程实例都直接映射到一个OS级别的线程实例。而CPU资源是按照OS线程的维度来分配的,所以我们可以看到上图中线程A和线程B分别获得了CPU1和CPU2的资源。

Java内存模型中定义的线程工作内存在这个映射实现下,实际上包含了三部分:

  1. 单个CPU控制器中的寄存器
  2. 单个CPU中的 L1 Cache
  3. 多个CPU间共享的CPU L2 Cache

我们来看看加入线程A和线程B同时操作一个共享变量S的场景,时序描述如下:

  1. S的初始值为0,线程A自增S的值
  2. 线程B自增S的值
  3. 线程A再次自增S的值
  4. 经过三次自增操作,预期S的值为3

但是在这个硬件架构下,最终得到的值可能是2。为什么会这样,看看下面的时序图就清楚了:

当线程A第二次发起自增操作的时候,红色标注的操作是存在问题的。线程A首先还是会优先从本地的工作内存,也就是CPU1的寄存器/L1 Cache中获取共享变量S的值。这个时候它成功从工作内存中获取到了S=1。但是很明显这里是的值和主存中的最新值S=2已经是不一致的了。线程A基于一个过时了的脏数据操作,最终还把错误的结果给刷入主存。

这就是所谓的内存可见性问题。

线程B写入了最新值,但是线程A因为本地工作内存的关系,看不见这个最新值。

内存可见性问题的解决方案

首先我们回顾一下内存可见性问题是什么:

共享变量内存可见性的问题主要是由于线程的工作内存导致的。*

所以解决了线程工作内存和主存之间的同步性问题,实际上也就解决了这个问题。

在Java中,有两种方式能够保证内存可见性问题:

  • synchronized:重量级的解决方案
  • volatile:轻量级的解决方案

synchronized

顾名思义,它反映了线程间的同步关系,这个同步关系当然也包含了线程工作内存和主存之间的同步。因此它能够解决内存可见性问题。

那么问题来了,synchronized是如何解决共享变量的内存可见性问题的?

这个需要先理解synchronized关键字的内存语义。

进入到synchronized块的内存语义就是把在synchronized块内使用到的变量从线程的工作内存中清除(实际上是Invalidate寄存器,L1/L2 Cache),这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主存中获取。退出synchronized块的内存语义则是把在synchronized块内对共享变量的修改刷新到主存。

所以当多个线程来访问和修改同一个共享变量的时候,通过synchronized就能够保证每个线程读取到的都是主存上的值。

那么为什么说它是一种重量级的方案呢?

因为它除了保证内存可见性问题之外,还带有锁的语义。每个synchronized都有一个内置锁隐藏在幕后。它还能够保证一段代码的线程安全,也就是说它还有实现原子性操作的功能。

这里顺便谈谈synchronized的锁语义:

  • synchronized块是一种原子性内置锁,任何Java对象可以作为锁(Monitor,监视器锁)
  • 进入块实际上就是获取了Monitor锁,其它线程访问会被阻塞挂起
  • 进入块的线程会在退出/抛出异常/调用Monitor锁的wait系列方法时释放锁
  • Monitor锁是排它锁,引起阻塞会引发上下文切换(用户态-内核态)

其实加锁和释放锁的也包含了内存语义。当获取锁后会清空锁区域内用到的共享变量在工作内存中的拷贝(Cache),在使用这些共享变量时直接从主存中加载,在释放锁时将本地内存中修改的共享变量刷新回主存。

因此synchronized的锁语义包含了内存语义。加锁的功能之一就是禁止线程使用工作内存(通常都是CPU的L1/L2 Cache),直接使用主存。

synchronized方案重是指它通过内置的监视器锁来保证内存可见性,与此同时还能保证对共享资源的排他性访问(线程安全),多个线程访问的时候会造成线程上下文切换。如果你需要的只是内存可见性的语义,而非线程安全性,那么考虑使用轻量级的volatile方案。

volatile

上面说synchronized可以解决共享变量内存可见性的问题,但是因为它涉及到锁的操作,会带来线程上下文的切换开销,因此比较重。

对于解决内存可见性,Java还提供了一种弱形式的同步,就是volatile关键字。该关键字可以确保对一个变量的更新对其他线程立马可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值直接回刷主存。当其他线程读取该共享变量时,会从主存重新获取最新值,而不是使用当前线程的工作内存中的值。

所以,volatile实际上就是禁止CPU的工作内存(寄存器,L1/L2 Cache),直接使用主存中的数据。所以在内存可见性这一层面,volatile和synchronized有相似之处:

  • 读取volatile变量值时就等价于进入synchronized块 - 清空本地内存变量值,再从主存获取最新值
  • 写入volatile变量值时就等价于线程退出synchronized块 - 把写入工作内存的变量值同步到主存

那为什么说这是一种轻量级地解决内存可见性问题的方法?

主要还是在于阻塞和线程上下文切换

因为synchronized是独占锁,多个线程同时调用会导致线程阻塞,引发线程上下文切换和线程重新调度的开销。但volatile不会引发线程阻塞,在没有这些开销的情况下,保证了内存可见性。

但并非在所有的情况下都可以等价地使用它们,volatile虽然具有内存可见性的保证,但是并不能保证操作的原子性,即它是无法保证线程安全的。

使用volatile的场景主要是当写入变量值不依赖变量的当前值时,也就是说操作不是 【读取-计算-写入】 三连。因为这个操作包含三个步骤,并非是原子性的,所以volatile无法保证其在多线程环境下的正确性。

另外,synchronized和volatile一般不需要同时使用,因为synchronized已经保证了内存可见性,声明volatile是多此一举。

原子性和线程安全,锁和内存可见性

上面提到了synchronized能够保证操作的原子性。关于原子性的问题,可以看看这篇文章,介绍了原子性的概念,以及保证原子性的几个方案:

  • synchronized
  • CAS

这里尝试进一步来梳理一下原子性和线程安全,锁和内存可见性这些概念之间千丝万缕的联系。

首先尝试给它们做一点定义:

原子性和线程安全

直观地说,就是一组操作要么全部完成,要么全部不完成。没有中间状态。比如对一个变量的自增操作,i++,它在汇编指令层级是三个指令,如果只执行了前面两个,但是最后的自增后的i没有保存下来,那么这个操作也不是原子性的。只不过这种情况除非突然系统断点,宕机等极端场景,在单线程环境下一般不会出现。

另外,在学习DB事务时候也会提到原子性,意思是一样的。事务中包含的操作要么全部成功,要么全部失败。这个也很好理解。

只有在多线程环境下,原子性才会有点让人迷惑。

在多个线程同时执行i++后,最终的结果可能不是我们所预期的。我们预期的是前一个线程原子性地执行完这个操作后,下一个线程在正确的结果基础上,再一次原子性地执行这个操作。最终能够符合我们的预期。

所以,讨论多线程并发场景下的原子性,其实是在讨论一种时序性。每个线程的执行序列不能发生交错,如果发生了交错那就存在竞态条件(Race Condition)。此时最终的计算结果很可能就是不正确的。

锁和内存可见性

这两个概念的区别和联系经过上面的讨论,已经很清晰了,这里再回顾一次加深印象。

内存可见性是偏底层的一个概念,主要指的是线程的工作内存中的变量可能会覆盖主存中对应变量的值,此时线程使用到的数据源不是最新的,造成最后计算结果的错误。而保证内存可见性,就是让线程总是能够有办法获取到主存中的最新值。

保证内存可见性的办法主要有两种,一个是volatile关键字这种轻量级的方案,另一个就是锁方案,加锁和释放锁操作也有内存可见性的语义。加锁会清空锁区域内用到的共享变量在工作内存中的拷贝(Cache),直接从主存中加载;释放锁时将本地内存中修改的共享变量刷新回主存。

所以,锁语义包含了内存语义。只不过,它还有保证线程安全的能力。

以上是关于[Java 并发基础] 也来聊聊Java多线程中的一些概念问题的主要内容,如果未能解决你的问题,请参考以下文章

聊聊Java线程是个啥东西-Java多线程

聊聊并发——Java中的阻塞队列

Java并发编程系列之二线程基础

Java多线程:并发死锁问题分析资源限制的挑战

Java多线程和并发基础

Java并发编程基础(入门篇)