多线程并发艺术(三)volatitle

volatile

  1. 在构建这个章节之前,我们做了太多铺垫。先讲述了JMM,又讲述了CPU的缓存一致性协议。这些都只是为了你更好的理解volatitle。同时,这也是我自己在网上学习了之后,自己整理出来的一套流程。相信大部分其他博主也都是如此。
  2. 在前面我说过,volatitle只能保证JMM中的可见性和有序性。对于原子性是无法保证的,不过synchronized 是可以保证原子性的。对于synchronized后续也会去详细介绍它,这也是个非常有趣的东西。
  3. 那么首先我们需要知道,什么是volatitle。
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该通过排他锁单独获取这个变量
  1. 说的可能有点笼统,首先介绍它的保证的可见性。
volatitle保证可见性

当你将一个共享变量定义为volatitle时,对它进行写操作时,汇编代码会多一条lock前缀的指令,该指令有如下作用(集合Java内存模型思考)

1)修改了当前线程缓存行的该变量,会强制将修改后的值立即写入主内存
2)这个写入内存的操作会使在其他线程里缓存的该变量无效

不知道大家还记不记得JMM的可见性。

当一个线程对从主内存中拷贝的某一共享变量进行修改时,对于其他缓存了该共享变量的线程来说是可见的

毫无疑问,volatitle完美的实现了可见性。当该线程修改之后,其他线程中的该共享变量就是无效的,读取操作时就只能再次从主内存中读取,保证了共享变量准确和一致的更新。
具体的如何使用lock前缀的指令去实现这些作用的,有兴趣的读者可以自行搜索查阅,方便的话还可以留言传授给我它的底层原理。


说到这里,大家是不是感觉这个volatitle的实现功能有些熟悉的味道。
是不是想起了缓存一致性协议?这就相当于缓存一致性协议将某一共享状态的缓存块,设置为了Modified,然后其他CPU中的该缓存块状态修改为Invalid(失效状态),在其他CPU需要再次读取该缓存块时,Modified状态的缓存块会提前将修改后的内容写入主内存中,以保证了缓存块的数据一致性。
参考我的上一篇博文
多线程并发艺术(二)volatitle修饰符之缓存一致性协议


当我在学习到二者之后,很长一段时间都在思考,

  1. 缓存一致性协议和volatile有什么关系
  2. 既然CPU已经实现了缓存一致性协议,那么为什么JVM还需要使用volatitle去再次实现呢

后来我在长时间的网上翻阅后,总结出了几下几点观念

  1. 大家可能没有注意到我在并发的第一个章节里介绍JMM的时候,多写了一句话
JMM主要用于屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
如果扩展开来说,它也可以称为内存一致性模型。

JMM屏蔽了计算机的硬件问题,屏蔽了底层的差异,对上层提供了一个统一的模型。所以通过volatitle再次实现了这个一致性。同时也是为了防止优化到寄存器,虽然有一致性协议但是编译器可能会把全局变量优化到寄存器,而各个CPU的寄存器则是完全独立的。
这是一种回答。

  1. 缓存一致性问题,是硬件层面的问题,指的是因为多核计算机中有多套缓存,各个缓存之间的数据不一致性问题。
Java多线程中,每个线程都有自己的工作内存,需要和主内存进行交互。
这里的工作内存和计算机硬件的缓存根本并不是一回事,只是可以相互类比。
所以,并发编程中的可见性问题,是由于哥哥线程之间的本地内容数据不一致导致的,和计算机缓存并无关系

这也涉及到了后续对于进程,线程,CPU核数之间的讨论。暂且不提。
这是另外一种回答。
如果有读者对上述问题有解答的,欢迎留言回复。


volatitle保证有序性
  1. volatile是如何保证有序性的:使用内存屏障
内存屏障 Memory Barrier
内存屏障:又称内存栅栏(Memory Fence),是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题
  1. 先介绍几种内存屏障的类型
1)LoadLoad 屏障:语句 Load1,LoadLoad, Load2
	在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2)StoreStore 屏障:语句 Store1, StoreStore, Store2
	在Store2及后续写入操作执行前,保证Store1的写入操作对其他处理器可见
3)LoadStore 屏障:语句 Load1,LoadStore,Store2 
	在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
4)StoreLoad 屏障:语句 Store1,StoreLoad,Load2
	在Load2及后续读取操作执行前,保证Store1的写入对其他处理器可见。
	它的开销是四种屏障中最大的,在大多数处理器的实现中,这个屏障是万能屏障,兼具其他三种内存屏障的功能

再介绍一份非常经典的volatitle重排序规则表

volatitle重排序规则表
是否能重排序第二个操作第二个操作第二个操作
第一个操作普通读/写volatile读volatile写
普通读/写NO
volatile读NONONO
volatile写NONO

我们解析以下这张表

  1. 当第一个操作是 volatitle读的时候,不管第二个操作是什么,都不能重排序。这个规则确保了volatile读之后的操作不会被编译器重排序到volatile读之前
  2. 当第二个操作是volatitle写的时候,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

如何实现上述的volatile重排序表呢。
volatile的基于保守策略的内存屏障插入策略非常严格保存,非常悲观而且毫无安全感的心态
(悲观又让我想起了乐观锁和悲观锁的概念,后续也会发布相应的博客)

在每个volatile写操作前插入 StoreStore屏障,在写操作后插入 StoreLoad屏障
在每个volatile读操作后插入 LoadLoad屏障, 在读操作后插入 LoadStore屏障
  1. 保守策略下,volatile写插入内存屏障后生成的指令序列示意图
    如图:
    在这里插入图片描述
    上图的StoreStore屏障可以保证在volatile写之前,其前面的所有写操作已经对任意处理器可见了。
    因为StoreStore屏障会保证上面所有写操作在volatile写之前刷新到主内存

  2. 保守策略下,volatile读插入内存屏障后生成的指令序列示意图

在这里插入图片描述

其实我本人对于详细volatile的内存屏障也是一知半解。如果有详细研究的读者还请不吝赐教。


有兴趣的读者还可以去研究以下Java的 happen-before原则。

happen-before原则
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happen-before关系

其中它的第三条原则便是

volatile变量原则:对一个变量的写操作先行发生于后面对这个变量的读操作

volatile不能保证操作的原子性

已经介绍完了 volatile保证了可见性和有序性。
那么它为什么不能保证原子性呢,下面有一个经典的案例来说明:

package com.keyring.weixin.controller;

/**
 * @Project: proxy
 * @Author: Mr_yao
 * @Date: 2019/4/20 8:27 PM
 * @Desc:
 */
public class Test {

    private volatile int count = 0;

    public void increase() {
        count++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for (int i = 0; i < 10; i++) {
            new Thread( () -> {
                for (int j = 0; j < 1000; j++) {
                    test.increase();
                    try {
                        Thread.sleep( 100 );
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println( test.count );
                }
            } ).start();
        

        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
    }
}

网上的其他案例怎么测试结果都是正确的,后来我就添加了sleep(100),最终输出的结果果然永远是小于10000的数。
这是为什么呢。网上的解释我翻译过来都是这样的:

自增分为三步,
先读取count的值到自己的工作内存,再将count值进行自增操作,最后将修改后的值写入工作内存。
假如某个时刻变量count的值为10。
现在线程1对变量进行自增操作。
执行了第一步,将count值在自己的工作内存中缓存了,缓存值为10.然后堵塞了。
这个时候线程2进来了,开始自增操作。
由于线程1只是读取了count值,线程2还是直接从主内存中读取了count值,count=10.
第二步,线程2将count值进行自增,count+1,count修改值为11。
第三步,线程2将count值写入工作内存,由于是volatile修饰,直接写入了主内存中。
这个时候最大的争议点到了,线程1重新开始运行,此时它的工作内存的值还是count=10,它直接开始执行自增操作的第二步,count+1,count的值修改为11.
最后线程1执行第三步,将count=11的值写入主内存中。
所以最终我们获取的count值并不是两次线程自增后的12,而是count=11.

这里最大的争议点就是,当线程2将count值写入主内存后,其他的线程中缓存count的值难道不应该无效么。线程1中的count应该无效,它必须再次从主内存中获取count值,然后count=11重新读取出来,再次+1,最终答案应该是正确的12才对。


如果volatile的底层是和我前面述说的缓存一致性协议底层一样,其他的线程中缓存值是通过监听形式,监听到该变量有修改,则变成无效的话,我认为,确实应该是12才对,但是我并不知道它的底层lock究竟是如何判断的。
它是如何让该变量在其他线程中是呈现无效状态的。
是通过与主内存中该变量的对比还是直接给该变量添个状态标识符。
无效状态会不会影响后续线程对它的调用。

当线程2对volatile变量进行修改后,线程1(已经读取该变量值)再开始运行。可能有如下情况:

  1. 该变量值已经是无效,但是线程1依然可以正常调用它,并完成自增
  2. 只有再重新读取一次,该变量值才呈现无效
    毕竟网上解释中线程2拿到的值还是10并没有什么证据去说服。
    不过根据输出结果,volatile确实不能保证原子性。

如果读者有自己的意见的,欢迎留言讨论。


后续我们将会讨论synchronized。并将它与volatile进行比较。它可是可以实现原子性的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
### 回答1: Python多线程并发是指在一个进程中同时运行多个线程,以提高程序的执行效率和响应速度。Python中的多线程是基于操作系统的线程实现的,一个Python线程会对应一个操作系统线程。Python提供了多个模块来支持多线程编程,其中最常用的是`threading`模块。 使用`threading`模块创建一个线程非常简单,只需要定义一个函数作为线程的执行体,然后创建一个`Thread`对象并调用它的`start()`方法即可。下面是一个简单的示例代码: ```python import threading def worker(): print('Worker thread is running') t = threading.Thread(target=worker) t.start() ``` 这个程序创建了一个名为`worker`的函数作为线程的执行体,然后创建了一个`Thread`对象`t`,并调用了`t.start()`方法启动线程。当线程启动后,它会自动调用`worker`函数执行线程任务。 除了`Thread`类之外,Python还提供了一些同步原语,如`Lock`、`Event`、`Condition`等,可以用来协调多个线程之间的操作。使用这些同步原语可以避免多个线程同时访问共享资源导致的竞态条件和数据不一致等问题。 在使用多线程时,需要注意线程安全问题。多个线程同时对同一个共享变量进行读写操作时,可能会产生数据不一致、竞态条件等问题。为了避免这些问题,可以使用一些同步机制来保护共享变量的访问,如使用`Lock`来保证同一时刻只有一个线程能够访问共享变量。 ### 回答2: Python多线程并发是指在Python程序中使用多个线程同时执行任务的一种方式。在传统的单线程程序中,任务是顺序执行的,即一个任务执行完毕后,才能执行下一个任务。而在多线程并发中,多个任务可以同时执行,不需要等待前一个任务执行完毕才能执行下一个任务。 Python的多线程并发是通过threading模块来实现的。通过创建多个线程对象,每个线程对象执行一个任务,可以实现多线程并发多线程并发可以提高程序的执行效率,尤其在处理一些耗时任务时,多线程可以同时执行多个任务,节省了等待时间。 然而,Python的多线程并发也存在一些限制。由于Python的全局解释锁(GIL)机制,每个线程在执行时只能使用一个核心,无法充分利用多核处理器的优势。这就意味着,对于计算密集型的任务,多线程并发可能并不能提高速度。但是对于I/O密集型的任务,如文件读写、网络通信等,多线程并发可以显著提高程序的性能。 在编写多线程并发的程序时,需要注意线程之间的同步问题。多个线程可能会同时访问共享的资源,如果没有正确地进行同步,会引发线程安全问题,如数据竞争、死锁等。在Python中,可以使用锁、条件变量等同步工具来解决这些问题。 总之,多线程并发是一种提高程序性能的方法,可以实现多任务同时执行。但要注意线程安全性和GIL的限制,选择合适的情况下使用多线程并发。 ### 回答3: Python的多线程并发是指在同一时间内,程序中多个线程能够同时运行并执行不同的任务。Python提供了多线程的库,如`threading`,可以通过创建多个线程来实现并发执行。 使用多线程并发有以下几个优点: 1. 提高程序的效率:多线程允许多个任务同时执行,可以充分利用多核处理器的计算能力,以提高程序的效率和响应速度。 2. 提高资源利用率:多线程并发可以有效地利用CPU资源,使其不再空闲,避免因为等待IO操作而浪费时间。 3. 改善用户体验:多线程能够将一些耗时的任务放到后台执行,以提供更好的用户体验,如在图形界面应用中,通过多线程进行网络请求,可以避免阻塞主界面。 4. 实现复杂性:多线程并发可以更容易地实现一些复杂的逻辑,如同时处理多个网络请求、同时获取多个数据源等。 但是,Python中的多线程并发有一些需要注意的地方: 1. 全局解释器锁(GIL):由于GIL的存在,Python中的多线程并不能实现真正的并行执行,因为在同一时间只有一个线程可以执行Python字节码。因此,对于需要CPU密集型的任务,多线程并发并不能显著提升性能。 2. 线程安全问题:多线程并发时,多个线程同时访问共享资源可能引发数据竞争和线程安全问题,需要通过锁(Lock)或其他同步机制来解决。 总的来说,Python的多线程并发在处理IO密集型任务、提高用户体验和利用多核处理器等方面具有优势,但对于CPU密集型任务需要考虑全局解释器锁的影响。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

范大

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值