写点什么

Java 并发(二),redis 深度笔记

  • 2021 年 11 月 10 日
  • 本文字数:2174 字

    阅读完需:约 7 分钟

所以,解决问题的关键旧在于,当一个线程要开始读改写共享变量的时候,其他线程是不能执行的


处理器可以使用总线锁定来解决这个问题,CPU 需要通过总线才能从内存中读取数据,总线锁的本质其实就是一个 lock 信号,当一个线程在总线上输出了这个信号,那么其他线程的请求将会被阻塞,此时这个线程就可以独享共享内存了,所以三步都是要带上 lock 信号的,volatile 就是使用 lock 信号来实现的

缓存锁定

使用总线锁定固然可以解决共享变量的问题,但总线锁定会阻塞其他线程的所有请求,那么不操作共享变量的线程也会被阻塞,效率会降低,而且开销比较大


所以就使用处理器的缓存一致性协议来实现锁定,缓存一致性协议会阻止同时修改由两个以上处理器缓存的内存区域数据,所以处理器不需要使用 Lock 信号来锁定总线,而是通知其他线程放弃自己内部的数据,去主内存重新去获取数据


Java 的原子操作实现




Java 的原子操作的实现是通过锁和循环 CAS 的方式来实现原子操作的


而 CAS 的底层实现,是一条指令 cmpxchg,这样就让比较和修改成为了一个原子性操作


那问题来了,这条指令也可能会发生指令重排序,特别是多处理器的时候(多处理器的作用就是减少线程上下文的切换,因为运算器只有一个,所以只能被一个处理器调用,所以执行命令上,单处理器跟多处理器是差不多的)


单处理器自身会维护顺序一致,不需要内存屏障,但多处理器无法进行维护顺序一致,所以在多处理器的时候,会加上一个 lock 前缀


所以完整的命令为 lock cmpxchg


lock 前缀的作用就是提供内存屏障从而防止指令重排序,同时保证指令的原子性,因为 lock 的功能是锁住总线,那么其他处理器暂时无法通过总线去访问内存的


所以,lock 指令不单是 volatile 的底层实现,同时也是多处理器的 cas 实现

循环时间开销大

CAS 可以不让线程挂起,从而提高线程的执行效率,但这不一定是好事,前面已经提到过,CAS 自旋也是会消耗 CPU 性能的,假如一个线程迟迟不释放锁,那么其他线程就会造成很大的消耗,循环时间开销大

只能保证一个共享变量的原子操作

CAS 只可以保证一个共享变量的原子操作,对于多个变量是无法保证的,这个时候就必须要使用锁。


注意,cas 是一种机制,volatile 是另一种机制


Java 内存模型




Java 内存模式,java memory model 简称 JMM,也就是 Java 虚拟机的内存是怎样的


他控制着 Java 线程之间是如何进行通信的,因为线程之间有着各自的状态,所以为了实现同步,必须要进行通信

Java 内存模型的抽象结构

在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间是共享的,所以称为共享变量,但一些线程本身的局部变量(local variables)、方法定义参数和异常处理器参数是不会在线程之间共享的,这三个是没有内存可见性的,当然也不受 Java 内存模型的影响



从上面的抽象结构可以看出,如果线程 A 与线程 B 要进行通信,必须要经历下面两个步骤


  1. 线程 A 把自己本地内存 A 的共享变量副本更新到主内存中

  2. 线程 B 到主内存中重新把共享变量读取进自己的本地内存


总的来说,也就是两个线程之间的通信必须要经过主内存才能进行,JMM 通过控制主内存与每个线程的本地内存之间的交互,从而实现线程之间内存可见性

指令重排序

前面已经讲过,CPU 会发生指令重排序的,这是编译器和处理器为了提高性能,常常会对指令做重排序,而重排序也分为 3 种类型


  1. 编译器优化的重排序,一般这个是针对单线程的,在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序,也就是改变你写的代码的执行顺序,比如说初始化变量

  2. 指令级并行的重排序,现在的处理器可以将多条指令重叠执行(即一组指令一起执行,宏观和微观上都是一起执行的,区别于并发,并发宏观上看起来是一起发生,但微观上仍然是逐个进行的),如果指令之间不存在数据依赖性,那么处理器是可以改变语句对应机器指令的执行顺序,当然可以使用内存屏障来防止指令重排序

  3. 内存系统的重排序,处理器使用缓存和读写缓冲区,使得加载和写入操作可能是乱序执行的,缓存就是指处理器的本地内存而不是主存,写缓冲区是用来临时保存内存写入的数据


前面两个重排序都比较好理解,下面就讲一下什么是内存系统的重排序


处理器写的时候,为了保证写指令可以持续运行,也就是降低连续写入数据时产生的停顿,一般对于新的数据都是以批处理的方式刷新写缓冲区的,来合并写缓冲区中对于同一内存地址的多次写,减少对总线的占用,但这也会产生缺点,由于写缓冲区是每个线程独有的,仅仅自己可见,其他线程不会等待当前线程刷新写缓冲区,然后再去读


假如此时两个处理器分别进行写和读另一个处理器的数据,在处理器的指令上是先写后读,但实际的执行顺序是还没写完就读,因为新数据是批量刷新的,要等待一会才刷新,因此最后读出来的数据都是默认值而不是线程初始化的值


举个栗子,一个线程修改 a 变量,同时去获取 b 变量,另一个线程修改 b 变量,同时去获取 a 变量,一开始在主内存中 a 变量和 b 变量都为 0



整个过程如上图所示


此时线程 A 和线程 B 都执行了修改的指令,也就是执行了 A1 和 B1,然后线程 A 和线程 B 就要去获取对方修改的变量了,虽然此时 A2 和 B2 还没有完成,但线程 A 和线程 B 并不会停止去等待完成,而是去执行 A3 和 B3 去获取,所以最终获得结果依然为 0,从内存操作上看,这就发生了一个重排序的过程,相当于先执行了读(A3)然后再执行完整的写(A1 和 A2),也就是 Store-Load 重排序,Store 指的是写并且要确保是刷新进了主内存,并对其他线程是可见的,而 Load 指的是加载数据

评论

发布
暂无评论
Java并发(二),redis深度笔记