并发编程的一些基础

文章来源B站   作者:临窗旋墨   发布时间:2023-08-10   阅读:2137   标签:多线程 分类:并发编程 专题:学习记录

并发编程的一些基础

01 CPU多核并发缓存架构

  • CPU - CPU寄存器 - CPU高速缓存 - 主内存

02 JMM内存模型

  • java多线程内存模型和CPU缓存模型类似,是基于CPU缓存模型来建立的,JAVA线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别。

  • 线程 - 工作内存(共享变量副本) == JVM控制 == 主内存(共享变量)

03 JMM数据原子操作

  • read(读取): 从主内存读取数据
  • load(载入):将主内存读取到数据写入到工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好打包值重新赋值到工作内存
  • store(存储):将工作内存数据写入到主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以缩编该变量

比如:

线程1 read 主线程变量flag,load到工作内存,然后use,use后assign给工作内存,然后store到主内存,然后write给主内存变量。此时线程2中的共享变量flag副本是感知不到变量已经变化了。

那为什么加volatile后线程2中就能感知到了呢?

  • volatile修饰的变量,在赋值语句的汇编语言处会加上lock汇编指令,从而会立即写到内存,且令其他副本失效,且禁止重排序

04 JMM缓存不一致问题

缓存一致性协议MESI

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以告知到数据的变化,从而将自己缓存里的数据失效。

缓存加锁

缓存锁的核心是基于缓存一致性协议来实现的,一个处理器的缓存写回到内存会导致其他处理器的缓存无效。IA-32和Intel 64处理器使用MESI实现缓存一致性协议。

05 volatile可见性底层实现原理

底层通过汇编lock前缀指令,锁定这块内存区域的缓存(缓存行锁定),并写回主内存

IA-32和Intel64架构软件开发者手册对lock指令的解释:

  1. 会将当前处理器缓存行的数据立即写回到系统内存
  2. 这个协会内存的操作会引起其他CPU里缓存了该内存地址的数据无效(MESI协议)
  3. 提供内存屏障功能,使lock前后指令不能重排序

java程序汇编代码查看

需要工具包(hsdis

-server -Xcopm -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=comlileonly,*XxxxClassName.methodName

06指令重排序

  • 并发编程的三大特性:可见性、有序性、原子性
  • volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制
  • 指令重排序:在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化。
    -
  • 重排序会遵循as-if-serialhappens-before原则
    • as-if-serial:
      • 不管怎么重排序(编译器和处理器为了提供并行度),,(单线程)程序的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。
      • 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序
    • happens-before原则
      • 辅助保证程序执行的原子性、可见性和有序性,它是判断数据是否存在竞争、线程是否安全的依据,原则如下:
        1. 程序顺序原则:即再一个线程内必须保证语义串行性,也就是按照代码顺序执行。
        2. 锁规则:解锁(unlock)操作必然发生再后续的同一个锁的加锁(lock)之前。也即,如果对于一个锁解锁后,再加锁,那么加锁的动作必须再解锁动作之后(同一个锁)。
        3. volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性。也即,volatile变量再每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
        4. 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么线程B执行sttart方法时,线程A对共享变量的修改对线程B可见。
        5. 传递性:A先于B,B先于C,那么A必然先于C。
        6. 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行线程终止,假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功发挥后,线程B对贡献变量的修改将对线程A可见。
        7. 线程中断规则:对线程interrupt方法的调用先行发生于被终端线程的代码检测到中断事件的发生,可以通过Thread.interrupted方法检测线程是否中断。
        8. 对象终结规则:对象的构造函数执行,结束先于finalize方法
  • 阿里面试题:双重检测锁DCL对象版初始化问题

阿里面试题:双重检测锁DCL对象版初始化问题

//这行代码在底层可能被重排序,所以把instance 用volatile修饰
instance = new DoubleCheckLockSingleton();

  1. /**
  2. * 说明: volatile 测试有序性
  3. * 双重锁检测DCL 问题
  4. *
  5. * @author Vic.xu
  6. * @since 2023/6/29/0029 16:27
  7. */
  8. public class DoubleCheckLockSingleton {
  9. private static DoubleCheckLockSingleton instance = null;
  10. private DoubleCheckLockSingleton() {
  11. }
  12. //DCL
  13. public static DoubleCheckLockSingleton getInstance() {
  14. if (instance == null) {
  15. synchronized (DoubleCheckLockSingleton.class) {
  16. if (instance == null) {
  17. //这行代码在底层可能被重排序,所以把instance 用volatile修饰
  18. instance = new DoubleCheckLockSingleton();
  19. }
  20. }
  21. }
  22. return instance;
  23. }
  24. public static void main(String[] args) {
  25. DoubleCheckLockSingleton instance = DoubleCheckLockSingleton.getInstance();
  26. }
  27. }

对象创建流程

类加载检测 →是否已经加载 →(否 ,加载类)→分配内存 → 初始化零值 → 设置对象头 → 执行init方法

1 类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一类的符号引用,并且检测这个符号引用代表的类是否已经被加载、解析和初始化。如果没有,那必须先执行相应的类加载过程。

new指令对应到语言层面上讲是,new关键词,对象克隆、对象序列化等

2 内存分配

在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。

这一步有两个问题:

  1. 如何划分内存
  2. 在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同事使用了原来的指针来分配内存的情况。
5 执行< init >方法

执行init方法,即对象按照程序员的意思进行初始化。对应到语言层面讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法

07 内存屏障

java规范定义的内存屏障

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证load1的读取操作在load2及后续读取操作之前执行
StoreStoreStore1;StoreStore;Store2在store2机器后的写操作执行之前,保证store1的写操作已经刷到主内存
LoadStoreLoad1;LoadStoreStore2在store2及其后的写操作之前,保证load1的读操作已读取结束
StoreLoadStore1;StoreLoad;Load2保证store1的写操作已经刷新到主内存之后,load2机器后续操作才能执行

Java规定volatile需要实现的内存屏障

  1. StoreStore屏障
  2. a=1;//volatile写,a为volatile变量
  3. StoreLoad屏障
  4. b=a;// volatile变量
  5. LoadLoad屏障
  6. LoadStore屏障
  • 不同CPU硬件对于JVM的内存屏障规范实现不一样

  • Intek CPU硬件级内存屏障实现指令

    • ifence:
    • sfence:

    • mfence:

  • JVM底层简化了内存屏障硬件指令的实现

    • lock前缀:lock指令不是一种内存屏障,但是它谁能完成类似内存屏障的功能

多线程高并发底层锁机制与优化实践

  • jdk1.6以前synchronized 是重量级锁(线程阻塞、上下文切换、操作系统线程调度)
  • AtomicInteger#increaseAndget() 借助CAS 优化了性能
    • CAS:也即无锁、自旋锁、乐观锁、轻量级锁
CAS的原子性问题,本质是硬件级别加了锁

比如native compareAndSwapInt

  • c++底层调用了汇编语言的时候,对多核进行了lock,然后调用compare and swap
  • 也即使用硬件指令级,保证了原子性(一般是缓存锁,数据量大是总线锁)
CAS的ABA问题,加version :AtomicStampedReference
  • 既其他线程把原来的值A先改为B,但是后面又改成了A

synchronized 锁优化

  1. 对锁引入了状态
    • 无状态(001):
      • 偏向锁启用,一个线程加锁,将线程id写入对象头markword
    • 偏向锁(101):
      • 多个线程加锁,CAS轻度竞争
    • 轻量级锁(00):
      • CAS自旋不成功(锁膨胀),重度竞争,自适应自旋
    • 重量级锁(10):
轻量级锁一定比重量级锁性能高吗?
  • 不一定

  • 多个线程自旋,CPU空转,耗CPU比较多,还不如放入等待队列

对象内部结构

  • 对象头
    • mark word (64位虚拟机则长度为64)
      • 对象的hash code
      • epoch threadId age 偏向状态 0或1 锁状态标志01
    • matadata 元素据指针: 指向元空间的内class的指针
    • 数组长度(数组对象才有)
  • 实例数据
    • data 1
    • danta n
  • 对齐填充位(选填)对象大小需8整除 (方便寻址)

synchronized 锁升级过程

  • jol-core-0.10.jar 帮助打印对象内部组成结构
  • 多少个线程为把轻量级锁升级为重量级锁?
    • 1.8下, 两个线程也会
    • 主要是自旋次数,暂时底层有默认次数,比如10(自适应自旋)

LongAdder分段CAS优化

  • 和AtomicInteger类似
  • 思想:内部加Cell数组,避免大量的线程空转,然后最终sum累加

线程池

  • ThreadPoolExecutor
    • 核心线程数
    • 最大线程数
    • 过期时间
    • 时间单位
    • 任务队列
    • 线程工厂
    • 拒绝策略:比如在shutdown的时候就应该拒绝
线程池部分属性
  • 线程池状态

    • RUNNING:
    • SHUTDOWN: RUNNING 调用shutdown,不能接受新线程
    • STOP:RUNNING调用shutdownNow, 不能接受新线程
    • TIDYING: 线程池中的线程都关掉
    • TERMINATED:TIDYING 调用terminated()
  • AtomicInteger ctl:表示线程状态和线程数

    • 高3bit表示线程状态,低29位表示线程数
#execute 方法
  • 判断线程状态

  • 判断线程数,是否新开线程

  • 超过核心线程数
    • 加入队列
    • 核心最大和队列全满了就reject
#addWork ★
  • 判断状态
  • 超过容量(核心或非核心线程数)
  • 线程个数加1
  • 创建可复用线程:new Worker(firstTask)加到workers
    • 内部run结束后,会从队列中拿到线程继续runwhile保活)(根据线程数和核心线程数比较判断是take还是poll
      • 拿线程#getTask():
        • 拿不到,则返回null,则线程消失
        • 当前线程数超过核心线程数:
          • poll()方法会直接返回null
          • take()一直阻塞
        • allowCoreThreadTimeout:允许核心线程消除
      • 执行我们自己线程run方法抛出异常的时候也会退出while
        • 但是不能因此减少核心线程数,进入processWorkerExit(worker, true)
          • 重新加一个任务
  • 成功添加了worker则start

ReentrantLock上锁流程的原理分析

线程start,表示线程就绪

什么是锁的重入?
  • 锁住的同步代码块内,此时锁没有释放,进入另一个同步代码块,被相同的这个锁锁住,此时应该支持重入。
模拟一个Lock:
  1. SimulateLock{
  2. // 0-默认没有加锁;
  3. // 非0,标识加锁了,1-加锁成功1次,2-加锁成功2次....
  4. volatile int state;
  5. static Unsafe unsafe;
  6. private static long stateOffset;
  7. public void lock(){
  8. // t1加锁成功,标识state +1
  9. //判断当前线程位t1
  10. while(!compareAndSet(0,1)){}
  11. }
  12. }

https://www.bilibili.com/video/BV1c44y1U7em/?p=169

ReentrantLock默认非公平 ★

  • Thread n
  • lock
  • state
    • state == 0 没人持有
      • 不能立刻加锁,因为可能其他线程可能在等待竞争(非公平锁回立刻cas)
      • 判断队列中是否有排队
        • 没有其他线程,则cas
    • state !=0 被人持有
      • 判断下当前线程是否等于持有锁的线程,
        • 等于,则state+1 ,然后重入
公平锁和非公平锁

tryAcquire 和nonfairTryAcquire

  • 非公平锁:state==0 的时候立刻cas
    • 如果暴力点:直接cas,不判断state状态
    • (先cas,失败,再判断state==0,再cas)

发表评论

目录