资讯专栏INFORMATION COLUMN

Java并发编程——线程安全性深层原因

Faremax / 2524人阅读

摘要:线程安全性深层原因这里我们将会从计算机硬件和编辑器等方面来详细了解线程安全产生的深层原因。类似这种不影响单线程语义的乱序执行我们称为指令重排。通过线程安全性深层原因我们能更好的理解这三大性质的根本性原因。上一篇并发编程线程基础查漏补缺

线程安全性深层原因

这里我们将会从计算机硬件和编辑器等方面来详细了解线程安全产生的深层原因。

缓存一致性问题 CPU内存架构

随着CPU的发展,而因为CPU的速度和内存速度不匹配的问题(CPU寄存器的访问速度非常快,而内存访问速度相对偏慢),所有在CPU和内存之间出现了多级高速缓存。下图是现代CPU和内存的一般架构图:

我们可以看到高速缓存也分为三级缓存,越靠近寄存器的级别缓存访问速度越快。其中L3 Cache为多核共享的,L1和L2 Cache为单核独享,而L1又有数据缓存(L1 d)和指令缓存(L1 i)。

正因为高速缓存的出现,各CPU内核从主内存获取相同的数据将会存在于缓存中,当多核都对此数据进行操作并修改值,此时另外的核心并不知道此值已被其他核心修改,从而出现缓存不一致的问题。

如何解决缓存一致性问题

解决缓存一致性问题一般有两个方法:

第一个是采用总线锁,在总线级别加锁,这样从内存种访问到的数据将被当个CPU核心独占,在多核的情况下对单个资源将是串行化的。这种方式性能上将大打折扣。

第二个是采用缓存锁,在缓存的级别上进行加锁。此种方式需要某种协议对缓存行数据进行同步,后面所说的缓存一致行协议便是一种实现。

缓存一致性协议(MESI)

为了解决缓存一致性的问题,一些CPU系列(比如Intel奔腾系列)采用了MESI协议来解决缓存一致性问题。此协议将每个缓存行(Cache Line)使用4种状态进行标记。

M: 被修改(Modified)

该缓存行只被缓存在该CPU核心的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)

该缓存行只被缓存在该CPU核心缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU核心读取该内存时变成共享状态(shared)。同样地,当CPU核心修改该缓存行中内容时,该状态可以变成Modified状态。

S: 共享的(Shared)

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)

该缓存是无效的(可能有其它CPU核心修改了该缓存行)

在MESI协议中,每个CPU核心的缓存控制器不仅知道自己的操作(local read和local write),每个核心的缓存控制器通过监听也知道其他CPU中cache的操作(remote read和remote write),再确定自己cache中共享数据的状态是否需要调整。

local read(LR):读本地cache中的数据;

local write(LW):将数据写到本地cache;

remote read(RR):其他核心发生read;

remote write(RW):其他核心发生write;

针对操作,缓存行的状态迁移图如下:

指令重排序问题

在我们编程过程中,习惯性程序思维认为程序是按我们写的代码顺序执行的,举个例子来说,某个程序中有三行代码:

int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3

从程序员角度执行顺序应该是1 -> 2 -> 3,实际经过编译器和CPU的优化很有可能执行顺序会变成 2 -> 1 -> 3(注意这样的优化重排并没有改变最终的结果)。类似这种不影响单线程语义的乱序执行我们称为指令重排。(后面讲Java内存模型也会讲到这部分。)

编译器指令重排

举个例子,我们先看可以看一段代码:

class ReorderExample {  
    int a = 0;  
    boolean flag = false;  
    public void write() {  
        a = 1;                     // 1  
        flag = true;               // 2  
    }
  
    public void read() {  
        if (flag) {                // 3  
            int i =  a * a;        // 4  
        }
    }
}

在单线程的情况下如果先write再read的话,i的结果应该是1。但是在多线程的情况下,编译器很可能对指令进行重排,有可能出现的执行顺序是2 -> 3 -> 4 -> 1。这个时候的i的结果就是0了。(1和2之间以及3和4之间不存在数据依赖,有关数据依赖在后面的Java内存模型中会讲到。)

CPU指令重排

在CPU层面,一条指令被分为多个步骤来执行,每个步骤会使用不同的硬件(比如寄存器、存储器、算术逻辑单元等)。执行多个指令时采用流水线技术进行执行,如下示意图:

注意这里出现的”停顿“,出现这个原因是因为步骤22需要步骤13得到结果后才能进行。CPU为了进一般优化:消除一些停顿,这时会将指令3(指令3对指令2和1都没有数据依赖)移到指令2之前进行运行。这样就出现了指令重排,根本原因是为了优化指令的执行。

内存系统重排

CPU经过长时间的优化,在寄存器和L1缓存之间添加了LoadBuffer、StoreBuffer来降低阻塞时间。LoadBuffer、StoreBuffer,合称排序缓冲(Memoryordering Buffers (MOB)),Load缓冲64长度,store缓冲36长度,Buffer与L1进行数据传输时,CPU无须等待。

CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。

CPU执行store写数据时,把数据写到StoreBuffer中,待到某个适合的时间点,把StoreBuffer的数据刷到主存中。

因为StoreBuffer的存在,CPU在写数据时,真实数据并不会立即表现到内存中,所以对于其它CPU是不可见的;同样的道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据;由于StoreBuffer和LoadBuffer是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序。

由于引入StoreBuffer和LoadBuffer导致异步模式,从而导致内存数据的读写可能是乱序的(也就是内存系统的重排序)。

内存屏障

为了解决CPU优化带来的不可见、重排序的问题,可以使用内存屏障(memory barrier)来阻止一定的优化(在后面介绍Java内存模型也会详细结合讲内存屏障)。不同的CPU架构对内存屏障的实现方式与实现程度非常不一样,下面我们看下X86架构中内存屏障的实现。

Store Barrier

使所有Store Barrier之前发生的内存更新都是可见的。

Load Barrier

使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的。

Full Barrier

所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。

延伸

在程序我们常说的三大性质:可见性、原子性、有序性。通过线程安全性深层原因我们能更好的理解这三大性质的根本性原因。(可见性、原子性、有序性会在后面文章中进行详细讲解。)

上一篇:Java并发编程——线程基础查漏补缺

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/72746.html

相关文章

  • 当我们在说“并发、多线程”,说的是什么?

    摘要:兜底任务,处理数据不一致状态的任务。什么是多线程多线程是并发的一种重要形式。通过具体的多线程问题引出多线程编程中的关键点和对应的工具与知识点,轻松学会多线程编程。 这篇文章的目的并不是想教你如何造火箭(面试造火箭,工作拧螺丝),而是想通过对原理和应用案例的有限度剖析来协助你构建起并发的思维,并将操作系统的理论知识与工程实践结合起来,贯穿从学到会的全过程。当然,虽然我们是从实用角度出发,...

    sf_wangchong 评论0 收藏0
  • 第10章:并发和分布式编程 10.1并发性和线程全性

    摘要:并发模块本身有两种不同的类型进程和线程,两个基本的执行单元。调用以启动新线程。在大多数系统中,时间片发生不可预知的和非确定性的,这意味着线程可能随时暂停或恢复。 大纲 什么是并发编程?进程,线程和时间片交织和竞争条件线程安全 策略1:监禁 策略2:不可变性 策略3:使用线程安全数据类型 策略4:锁定和同步 如何做安全论证总结 什么是并发编程? 并发并发性:多个计算同时发生。 在现代...

    instein 评论0 收藏0
  • 线程安全

    摘要:不可变在中,不可变的对象一定是线程安全的。在里标注自己是线程安全的类,大多都不是绝对线程安全,比如某些情况下类在调用端也需要额外的同步措施。无同步方案要保证线程安全,不一定就得需要数据的同步,两者没有因果关系。 在之前学习编程的时候,有一个概念根深蒂固,即程序=算法+数据结构。数据代表问题空间中的客体,代码就用来处理这些数据,这种思维是站在计算机的角度去抽象问题和解决问题,称之为面向过...

    fuyi501 评论0 收藏0
  • 并发 - 收藏集 - 掘金

    摘要:在中一般来说通过来创建所需要的线程池,如高并发原理初探后端掘金阅前热身为了更加形象的说明同步异步阻塞非阻塞,我们以小明去买奶茶为例。 AbstractQueuedSynchronizer 超详细原理解析 - 后端 - 掘金今天我们来研究学习一下AbstractQueuedSynchronizer类的相关原理,java.util.concurrent包中很多类都依赖于这个类所提供的队列式...

    levius 评论0 收藏0
  • 并发 - 收藏集 - 掘金

    摘要:在中一般来说通过来创建所需要的线程池,如高并发原理初探后端掘金阅前热身为了更加形象的说明同步异步阻塞非阻塞,我们以小明去买奶茶为例。 AbstractQueuedSynchronizer 超详细原理解析 - 后端 - 掘金今天我们来研究学习一下AbstractQueuedSynchronizer类的相关原理,java.util.concurrent包中很多类都依赖于这个类所提供的队列式...

    fantix 评论0 收藏0

发表评论

0条评论

Faremax

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<