Loading

多线程的一些基本概念

多线程笔记(一)

1. sleep()方法和yield()方法

  • 共同点:让当前线程释放cpu资源,让其他线程来运行

  • 不同点:调用sleep()方法后,线程进入到TIMED_WAITING状态,等待超时后进入RUNNABLE状态,开始抢占CPU资源。调用yield()方法后,线程进入RUNNABLE状态,直接开始抢占CPU资源。

2. 偏向锁和可重入锁的区别

  • 偏向锁是指一个线程访问同步块的时候,第一次会获取锁,在没有其他线程竞争锁的情况下再访问同步块不需要再获取锁,直接访问同步块。节省了获取锁和释放锁的开销。

  • 可重入锁是指一个线程访问同步块1需要锁A并获得锁A,接下来访问另一个同步块2也需要锁A,在线程持有锁A的情况下访问同步块2时不需要再获取锁。

3. wait, notify/notifyAll注意事项

  • wait, notify/notifyAll 必须放到同步块或者同步方法里面去执行
  • 注意使用锁的对象来调用wait, notify/notifyAll
  • 其底层使用的是Monitor机制,wait过后线程会进入monitor对象的对应的WaitSet
  • 调用wait方法后就会释放锁
  • 调用notify/notifyAll方法后并不会立即获得锁。要等前面的线程释放锁之后再去争抢锁

4. Java内存模型(JMM)

  • 所有变量(共享的)都存储再主内存中,每个线程都有自己的工作内存,工作内存中保存该线程使用到的变量的主内存副本拷贝
  • 线程对变量的所有操作(读,写)都应该再工作内存中完成
  • 不同线程不能相互访问工作内存,交互数据要通过主内存

内存之间的交互操作

  • lock: 锁定,把变量表示为线程独占,作用于主内存变量

  • read: 读取,把变量值从主内存读取到工作内存

  • load: 载入,把read读取到的值放入工作内存的变量副本中

  • use: 使用,把工作内存中的一个变量的值传递给执行引擎

  • assign: 赋值,把从执行引擎接收到的值赋给工作内存里面的变量

  • store: 存储,把工作内存中的一个变量的值传递到主内存中

  • write: 写入,把store进来的数据存放如主内存的变量中

  • unlock: 解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量

内存间交互操作的规则

  • 不运行read和load;store和write操作之一单独出现,以上两个操作必须按顺序执行,但不保证连续执行,也就是说,read和load;store和write之间是可以插入其他指令的
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个新的共享变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施use和store操作之前,必须先执行过了load和assign操作
  • 一个共享变量在同一时刻只允许一个线程对其执行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 如果对一个共享变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load
  • 如果一个共享变量没有被lock操作锁定,则不允许对它执行unlock操作,也不能unlock一个被其他线程锁定的共享变量
  • 对一个共享变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)

5. 并发编程三大特性

原子性:一个操作要么全部执行成功,要么全部执行失败

可见性:一个线程修改了共享变量之后,其他线程能够立刻看到这个修改

有序性:程序执行的顺序是按照代码的逻辑先后循序来执行的

6. 重排序

编译器或处理器为了优化程序的执行性能,对指令执行的顺序重新排列

目的:尽可能减少寄存器的读取和存储次数,复用寄存器存储的数据

分类

  • 编译器重排序:编译器再不改变程序在单线程环境下运行的语义的前提下,重新安排语句的执行顺序
  • 指令重排序:处理器将多条指令并行执行,如果不存在数据依赖,处理器可以改变语句对应的指令的执行顺序
  • 内存系统重排序:处理器使用缓存和读写缓冲区,使得数据的加载存储操作看上去是乱序执行的

数据依赖

如果两个操作访问同一个共享变量,而且两个操作里面有一个为写操作,那么这两个操作直接就存在数据依赖性。

具有数据依赖性的指令是不会被重排的

数据依赖的分类:

  • 读后写:读一个变量过后,再写这个变量
  • 写后写:写一个变量过后,再写这个变量
  • 写后读:写一个变量过后,再读这个变量

as-if-serial语义

不管有没有重排序,也不关心如何进行重排序,单线程环境下,程序的执行结果不会被改变。

7. happens-before

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见(保障可见性)

    且第一个操作的执行顺序排在第二个操作之前(JMM对程序员做出的一个逻辑保障,并不是代码指令真正的执行保障)

  2. 即使两个操作之间存在happens-before关系,并不意味着Java平台的实现必须要按照happens-before关系指定的顺序来执行

第一条是JMM对程序员做出的逻辑保障

第二条是JMM对编译器,处理器进行重排序的约束原则:只要不改变程序的执行结果(不管是单线程还是多线程),爱怎么排怎么排

happens-before规则

  • 程序顺序规则:一个线程中的每个操作 happens-before 该线程中的任意后续操作

  • 监视器锁规则:对一个锁的解锁操作 happens-before 随后对这个锁的加锁(就是先释放锁,后加锁)

  • volatile变量规则:对一个volatile修饰的字段进行的写操作 happens-before 任意后续对这个字段进行的读操作

  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

  • start规则:如果线程A里执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作 happens-before 于线程B中的任意操作

  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

8. 内存屏障

内存屏障是一种屏障指令,它使得处理器或编译器对屏障指令的前面和后面所发出的内存操作,执行一个排序的约束。也叫内存栅栏或栅栏指令

作用

  • 阻止屏障两边的指令重排序
  • 写数据的时候加了屏障的话,强制把写缓冲区的数据刷回到主内存中
  • 读数据的时候加了屏障的话,让工作内存或CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

分类

读屏障:Load Barrier: 在读指令之前插入读屏障,让工作内存或CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

写屏障:Store Barrier: 在写指令之后插入写屏障,强制把缓冲区的数据刷回到主内存中

9. 重排序与内存屏障

JVM本身为了保证可见性:

对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序

对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序

JMM的内存屏障

  • LoadLoad Barriers:

示例:Load1; LoadLoad; Load2

禁止重排序,访问Load2的读取操作一定不会重排到Load1之前。由于在读指令之前插入读屏障,所有会保证Load2在读取的时候,自己缓存内相应数据失效,Load2会重新到主内存中获取最新的数据

  • LoadStore Barriers:

示例:Load1; LoadStore; Store2

禁止重排序,一定是Load1读取数据完成后,才能让Store2写操作的数据写入到主内存

  • StoreStore Barries:

示例:Store1; StoreStore; Store2

禁止重排序,一定是Store1的数据写入主内存后,才能让Store2写操作的数据写入主内存。由于在写指令之后插入写屏障,所以会保证Store1写出的数据强制刷回到主内存中

  • StoreLoad Barries:

示例:Store1; StoreLoad; Load2

禁止重排序,一定是Store1的数据写入主内存后,才能让Load2读取数据。由于在写指令之后插入写屏障,所以会保证Store1写出的数据强制刷回到主内存中。由于在读指令之前插入读屏障,所有会保证Load2在读取的时候,自己缓存内相应数据失效,Load2会重新到主内存中获取最新的数据。

为什么说StoreLoad Barries是最重(和内存交互次数多,交互延迟较大)的?

因为其既要保证读屏障也要保证写屏障

扩展

这些屏障指令并不是处理器真实的执行指令,它们知识JMM定义出来的,跨平台的指令。因为不同硬件实现内存屏障的方式并不相同,JMM为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由JVM来为不同的平台生成相应的机器码。这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分的JMM的内存屏障指令

10. Volatile关键字

volatile修饰的变量有如下特点:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排
  1. 对一个volatile修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值

  2. 一个线程修改了volatile修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中

  3. 一个线程去读取volatile修饰的变量的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据

volatile内存语义

volatile写的内存语义:写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量的值刷新到主内存中

volatile读的内存语义:读一个volatile变量时,JMM会把线程对应的工作内存中的共享变量数据设置为无效的,然后从主内存中去读共享变量最新的数据

volatile内存语义的实现

  1. 字节码层面:

    它影响的是Class内的Field的 falgs ,添加了一个ACC_VOLATILE。JVM在把字节码生成为机器码的时候,发现操作的是volatile变量的话,就回根据JMM的要求,在相应的位置去插入内存屏障

  2. JMM层面:

第一个操作 第二个操作(普通读写) 第二个操作(volatile读) 第二个操作(volatile写)
普通读写 不允许重排序
volatile读 不允许重排序 不允许重排序 不允许重排序
volatile写 不允许重排序 不允许重排序

​ volatile写之前的操作都禁止重排序到volatile之后

​ volatile读之后的操作都禁止重排序到volatile之前

​ volatile写之后的volatile读,禁止重排序

为了实现volatile内存语义,按如下方式插入内存屏障

​ (1)在每个volatile写操作的前面插入一个StoreStore屏障

​ (2)在每个volatile写操作的后面插入一个StoreLoad屏障

​ (3)在每个volatile读操作的后面插入一个LoadLoad屏障

​ (4)在每个volatile读操作的后面插入一个LoadStore屏障

  1. 处理器层面

​ CPU执行机器码指令的时候,是使用 lock 前缀指令来实现volatile的功能的

​ lock指令相当于内存屏障,功能也类似于内存屏障的功能

​ (1)首先对总线/缓存加锁,然后去执行后面的指令,最后释放锁,同时把CPU高速缓存的数据刷新回到主内存

​ (2)在lock锁住总线/缓存的时候,其他CPU的读写请求就会被阻塞,直到锁被释放。lock过后的写操作,让会其他CPU的高速缓存中相应的数据失效,这样后续这些CPU在读取数据的时候,就会从主内存去加载最新的数据

posted @ 2022-05-03 21:04  KeepGoing4everZxz  阅读(85)  评论(0编辑  收藏  举报