Loading

JUC笔记(一)多线程的认识

JUC笔记

JUC是java.uitl.concurrent包下的一系列的的并发编程的包。

JUC思维导图(一)

JUC思维导图(二)

image-20220315091756691

线程和进程

image-20220315091112781

线程

在操作系统中,线程是比进程更小的能够独立运行的基本单位。同时,它也是CPU调度的基本单位线程本身基本上不拥有系统资源,只是拥有一些在运行时需要用到的系统资源,例如程序计数器,寄存器和Java虚拟机栈等一个进程中的所有线程可以共享进程中的所有资源。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

线程,在网络或多用户环境下,一个服务器通常需要接收大量且不确定数量用户的并发请求,为每一个请求都创建一个进程显然是行不通的,无论是从系统资源开销方面或是响应用户请求的效率方面来看。因此,操作系统中线程的概念便被引进了线程,是进程的一部分,一个没有线程的进程可以被看作是单线程的。线程有时又被称为轻权进程或轻量级进程,也是 CPU 调度的一个基本单位。

image-20220315092956918

进程

进程是表示资源分配的基本单位,又是调度运行的基本单位。例如,用户运行自己的程序,系统就创建一个进程,并为它分配资源,包括各种表格、内存空间、磁盘空间、I/O设备等。然后,把该进程放人进程的就绪队列。进程调度程序选中它,为它分配CPU以及其它有关资源,该进程才真正运行。所以,进程是系统中的并发执行的单位。

在Mac、Windows NT等采用微内核结构的操作系统中,进程的功能发生了变化:它只是资源分配的单位,而不再是调度运行的单位。在微内核系统中,真正调度运行的基本单位是线程。因此,实现并发功能的单位是线程。

进程,是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竟争计算机系统资源的基本单位。每一个进程都有一个自己的地址空间,即进程空间或(虚空间)。

进程是一个具有独立功能的应用程序,能够申请和分配系统资源。程序本身是没有生命的,只有处理器赋予其执行权力时,程序才能够成为一个活动的实体,我们称之为进程。一个进程可以包含多个线程,线程是进程的最小调度单位。

在Java中,当我们启动了main 函数时其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程的一个线程,也成为主线程。

相关面试题

  1. 进程和线程之间的区别

线程和进程之间的关系:线程是属于进程的,线程运行在进程空间内,同一进程所产生的的线程共享同一用户内存空间,当进程退出时该进程所产生的线程都会被强制退出并清除。所以,线程不能独立地执行,它必须依附在一个运行的应用程序上(即进程上),而一个进程至少需要一个线程作为它的指令执行,进程管理着资源(比如CPU、内存、文件等等)。而将线程分配到某个CPU上执行。

区别:当操作系统分配给进程资源后,同属一个进程的多个线程之间可以相互共享进程中的内存资源,原因是线程没有自己独立的内存资源,它只有自己执行的堆栈和局部变量。而多个进程之间每个进程都拥有自己的一整套变量,即每个进程都有自己独立的内存单元。这就使得多线程之间的通信比多进程之间的通信更加的容易和高效。

  1. 说一下线程的生命周期?线程有哪几种状态?

Java线程在运行的生命周期中的指定时刻只可能处于下面几种不同状态的其中一个状态。

线程通常有5种状态,创建、就绪、运行、阻塞和死亡。

创建(new)就是,线程被创建,但是还没有调用start()方法。

就绪(runnable),线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。

运行状态(Running),就绪状态的线程获取了CPU,执行程序代码。

阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。

阻塞又可以分为:

  1. 等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒,wait是object类的方法

  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。

  3. 其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。sleep是Thread类的方法。

死亡状态(Dead):线程执行完了或者因异常退出了run方法,该线程结束生命周期。

  1. 为什么要使用多线程?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代: 在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
  • 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
  1. 说一下你对线程安全理解?

线程安全指的是,我们写的某段代码,在多个线程同时执⾏这段代码时,不会产⽣混乱,依然能够得到正常的结果,⽐如i++,i初始化值为0,那么两个线程来同时执⾏这⾏代码,如果代码是线程安全的,那么最终的结果应该就是⼀个线程的结果为1,⼀个线程的结果为2,如果出现了两个线程的结果都为1,则表示这段代码是线程不安全的。

所以线程安全,主要指的是⼀段代码在多个线程同时执⾏的情况下,能否得到正确的结果。

  1. 线程之间如何通信?

线程之间是如何通信 - BeaBrick0 - 博客园 (cnblogs.com)

  1. 你知道守护线程吗 ?

线程分为⽤户线程和守护线程,⽤户线程就是普通线程,守护线程就是JVM的后台线程,⽐如垃圾回收线程就是⼀个守护线程,守护线程会在其他普通线程都停⽌运⾏之后⾃动关闭。我们可以通过设置 thread.setDaemon(true)来把⼀个线程设置为守护线程。

  1. 什么是上下文切换?什么会造成上下文切换?

这个题在阿里的2023届开发岗笔试题不定项选择题中出现过。做的时候很懵,只知道要避免上下文切换,它会造成效率降低。

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器、栈(本地方法栈和虚拟机栈)信息等。

当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

  1. wait()和sleep()的区别?
  1. 这两个方法来自不同的类分别是,sleep来自Thread类,和wait来自Object类。sleepThread的静态类方法,谁调用的谁去睡觉,即使在a线程里调用了b的sleep方法,实际上还是a去睡觉,要让b线程睡觉要在b的代码中调用sleep。

  2. 最主要是sleep方法没有释放锁,而wait方法释放了锁使得其他线程可以使用同步控制块或者方法sleep不出让系统资源;wait是进入线程等待池等待,出让系统资源,其他线程可以占用CPU。一般wait不会加时间限制,因为如果wait线程的运行资源不够,再出来也没用,要等待其他线程调用notify/notifyAll唤醒等待池中的所有线程,才会进入就绪队列等待OS分配系统资源。sleep(milliseconds)可以用时间指定使它自动唤醒过来,如果时间不到只能调用interrupt()强行打断。Thread.Sleep(0)的作用是“触发操作系统立刻重新进行一次CPU竞争”。

  3. 使用范围:waitnotifynotifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用

 synchronized(x){ 
      x.notify() 
     //或者wait() 
   }
  1. sleep必须捕获异常,而waitnotifynotifyAll不需要捕获异常

总结:

  • 两者都可以暂停线程的执行。对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。

  • Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。

  • sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。

  • 在调用sleep()方法的过程中,线程不会释放对象锁。而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。线程不会自动苏醒。

[译]Java中Wait、Sleep和Yield方法的区别 - 简书 (jianshu.com)

并行、并发和串行

image-20220319185905165

串行在时间上不可能发生重叠,前一个任务没搞定,下一个任务就只能等着。也叫顺序执行

image-20220316104908664

并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行。

image-20220316104839559

并发允许两个任务彼此干扰。统一时间点、只有一个任务运行,交替执行。

image-20220316104811778

并发的三大特性

  1. 原子性

原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行

就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。

private long count = 0; 
public void calc() { 
  count++; 
}
  • 将 count 从主存读到工作内存中的副本中

  • +1的运算

  • 将结果写入工作内存

  • 将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的)

平时面试的过程中,又会问i++是原子性操作吗?显然不是。

那程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。

关键字:synchronized

  1. 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

若两个线程在不同的cpu,那么线程1改变了i 的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

//线程1 
boolean stop = false; 
while(!stop){ 
	doSomething(); 
}
//线程2 
stop = true; 

如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

关键字:volatilesynchronizedfinal

  1. 有序性

虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。

int a = 0; 
bool flag = false; 
public void write() { 
		a = 2; //1 
		flag = true; //2 
}
public void multiply() { 
	if (flag) { //3 
			int ret = a * a;//4 
	} 
} 

write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步

关键字:volatilesynchronized

volatile本身就包含了禁止指令重排序的语义,而synchronized关键字是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则明确的。

synchronized关键字同时满足以上三种特性,但是volatile关键字不满足原子性。在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),因为volatile的总开销要比锁低。

我们判断使用volatile还是加锁的唯一依据就是volatile的语义能否满足使用的场景(原子性)

下面是我以前写过的博客:关于VolatileSynchronizedLock的分析,在此就不做赘述了。

synchronized和Lock的区别 - BeaBrick0 - 博客园 (cnblogs.com)

谈谈你对Volatile关键字的理解 - BeaBrick0 - 博客园 (cnblogs.com)

相关面试题

  1. 并发和并行的区别

并发:同一时间段,多个任务都在执行(单位时间内不一定同时执行)

并行:单位时间内,多个任务同时执行

创建线程(单线程和多线程)

怎么去创建多线程 - BeaBrick0 - 博客园 (cnblogs.com)

锁机制

image-20220319185759210

锁

image-20220316111112656

谈到锁机制,相信各位应该并不陌生了,我们在JavaSE阶段,通过使用synchronized关键字来实现锁,这样就能够很好地解决线程之间争抢资源的情况。那么,synchronized底层到底是如何实现的呢?

我们知道,使用synchronized,一定是和某个对象相关联的,比如我们要对某一段代码加锁,那么我们就需要提供一个对象来作为锁本身:

public static void main(String[] args) {
    synchronized (Main.class) {
        //这里使用的是Main类的Class对象作为锁
    }
}

synchronized的字节码分析

其中最关键的就是monitorenter指令了,可以看到之后也有monitorexit与之进行匹配(注意这里有2个),monitorentermonitorexit分别对应加锁和释放锁,在执行monitorenter之前需要尝试获取锁,每个对象都有一个monitor监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。

在代码执行完成之后,我们可以看到,一共有两个monitorexit在等着我们,那么为什么这里会有两个呢,按理说monitorentermonitorexit不应该一一对应吗,这里为什么要释放锁两次呢?

首先我们来看第一个,这里在释放锁之后,会马上进入到一个goto指令,跳转到15行,而我们的15行对应的指令就是方法的返回指令,其实正常情况下只会执行第一个monitorexit释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个,其实是用来处理异常的,可以看到,它的位置是在12行,如果程序运行发生异常,那么就会执行第二个monitorexit,并且会继续向下通过athrow指令抛出异常,而不是直接跳转到15行正常运行下去。

img

实际上synchronized使用的锁就是存储在Java对象头中的,我们知道,对象是存放在堆内存中的,而每个对象内部,都有一部分空间用于存储对象头信息,而对象头信息中,则包含了Mark Word用于存放hashCode和对象的锁信息,在不同状态下,它存储的数据结构有一些不同。

img

重量级锁

在JDK6之前,synchronized一直被称为重量级锁,monitor依赖于底层操作系统的Lock实现,Java的线程是映射到操作系统的原生线程上,切换成本较高。而在JDK6之后,锁的实现得到了改进。我们先从最原始的重量级锁开始:

我们说了,每个对象都有一个monitor与之关联,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor(互斥量指针)实现的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制:

img

ObjectWaiter首先会进入 Entry Set等着,当线程获取到对象的monitor后进入 The Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加 1 ,若线程调用wait()方法,将释放当前持有的monitorowner变量恢复为nullcount自减 1 ,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取对象的monitor

虽然这样的设计思路非常合理,但是在大多数应用上,每一个线程占用同步代码块的时间并不是很长,我们完全没有必要将竞争中的线程挂起然后又唤醒,并且现代CPU基本都是多核心运行的,我们可以采用一种新的思路来实现锁。

在JDK1.4.2时,引入了自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。

轻量级锁

​ 从JDK 1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁。

image-20220319190110551

轻量级锁的目标是,在无竞争情况下,减少重量级锁产生的性能消耗(并不是为了代替重量级锁,实际上就是赌一手同一时间只有一个线程在占用资源),包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。它不像是重量级锁那样,需要向操作系统申请互斥量。它的运作机制如下:

在即将开始执行同步代码块中的内容时,会首先检查对象的Mark Word,查看锁对象是否被其他线程占用,如果没有任何线程占用,那么会在当前线程中所处的栈帧中建立一个名为锁记录(Lock Record)(指针)的空间,用于复制并存储对象目前的Mark Word信息(官方称为Displaced Mark Word)。

接着,虚拟机将使用CAS操作将对象的Mark Word更新为轻量级锁状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧)

CAS(Compare And Swap)是一种无锁算法,它并不会为对象加锁,而是在执行的时候,看看当前数据的值是不是我们预期的那样,如果是,那就正常进行替换,如果不是,那么就替换失败。比如有两个线程都需要修改变量i的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问i,而是直接尝试修改i的值,但是在修改时,需要确认i是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败。
在CPU中,CAS操作使用的是cmpxchg指令,能够从最底层硬件层面得到效率的提升。

如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块了,这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。

这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的)

img

所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁

解锁过程同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败,说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。

偏向锁

偏向锁相比轻量级锁更纯粹,干脆就把整个同步都消除掉,不需要再进行CAS操作了。它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取,这种情况下,我们可以对轻量级锁进一步优化

偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。

可以从之前的MarkWord结构中看到,偏向锁也会通过CAS操作记录线程的ID,如果一直都是同一个线程获取此锁,那么完全没有必要在进行额外的CAS操作。当然,如果有其他线程来抢了,那么偏向锁会根据当前状态,决定是否要恢复到未锁定或是膨胀为轻量级锁。

如果我们需要使用偏向锁,可以添加-XX:+UseBiased参数来开启。

所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁

值得注意的是,如果对象通过调用hashCode()方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor(有预留位置保存)中。
image-20220302214647735

锁消除和锁粗化

锁消除和锁粗化都是在运行时的一些优化方案,比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。

image-20220316164403026

JMM内存模型

image-20220319185934650

注意这里提到的内存模型和我们在JVM中介绍的内存模型不在同一个层次,JVM中的内存模型是虚拟机规范对整个内存区域的规划,而Java内存模型,是在JVM内存模型之上的抽象模型,具体实现依然是基于JVM内存模型实现的,我们会在后面介绍。

Java内存模型

我们在计算机组成原理中学习过,在我们的CPU中,一般都会有高速缓存,而它的出现,是为了解决内存的速度跟不上处理器的处理速度的问题,所以CPU内部会添加一级或多级高速缓存来提高处理器的数据获取效率,但是这样也会导致一个很明显的问题,因为现在基本都是多核心处理器,每个处理器都有一个自己的高速缓存,那么又该怎么去保证每个处理器的高速缓存内容一致呢

img

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSIMESI(Illinois Protocol)MOSISynapseFireflyDragon Protocol等。

而Java也采用了类似的模型来实现支持多线程的内存模型:

image-20220303114228749

JMM(Java Memory Model)内存模型规定如下:

所有的变量全部存储在主内存(注意这里包括下面提到的变量,指的都是会出现竞争的变量,包括成员变量、静态变量等,而局部变量这种属于线程私有,不包括在内)

每条线程有着自己的工作内存(可以类比CPU的高速缓存)线程对变量的所有操作,必须在工作内存中进行,不能直接操作主内存中的数据。

不同线程之间的工作内存相互隔离,如果需要在线程之间传递内容,只能通过主内存完成,无法直接访问对方的工作内存。

也就是说,每一条线程如果要操作主内存中的数据,那么得先拷贝到自己的工作内存中,并对工作内存中数据的副本进行操作,操作完成之后,也需要从工作副本中将结果拷贝回主内存中,具体的操作就是Save(保存)和Load(加载)操作。

那么各位肯定会好奇,这个内存模型,结合之前JVM所讲的内容,具体是怎么实现的呢?

  • 主内存对应堆中存放对象的实例的部分。

  • 工作内存对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。

    比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中。

前面我们提到,在CPU中可能会遇到缓存不一致的问题,而Java中,也会遇到,比如下面这种情况:

private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程1结束");
        }).start();
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程2结束");
        }).start();
        //等上面两个线程结束
        Thread.sleep(1000);
        System.out.println(i);
        
        //输出结果:
      	//因为我是m1芯片 所以我的cpu处理的快 结果比较大
         //线程1结束
         //线程2结束
         //173382
    }

可以看到这里是两个线程同时对变量i各自进行100000次自增操作,但是实际得到的结果并不是我们所期望的那样。

那么为什么会这样呢?在之前学习了JVM之后,相信各位应该已经知道,自增操作实际上并不是由一条指令完成的(注意一定不要理解为一行代码就是一个指令完成的):

image-20220316172237827

包括变量i的获取、修改、保存,都是被拆分为一个一个的操作完成的,那么这个时候就有可能出现在修改完保存之前,另一条线程也保存了,但是当前线程是毫不知情的。

img

所以说,我们当时在JavaSE阶段讲解这个问题的时候,是通过synchronized关键字添加同步代码块解决的,当然,我们后面还会讲解另外的解决方案(原子类)

重排序

在编译或执行时,为了优化程序的执行效率,编译器或处理器常常会对指令进行重排序,有以下情况:

  • 编译器重排序:Java编译器通过对Java代码语义的理解,根据优化规则对代码指令进行重排序。
  • 机器指令级别的重排序:现代处理器很高级,能够自主判断和变更机器指令的执行顺序。
  • 指令重排序能够在不改变结果(单线程)的情况下,优化程序的运行效率,比如:
public static void main(String[] args) {
    int a = 10;
    int b = 20;
    System.out.println(a + b);
}

我们其实可以交换第一行和第二行:

public static void main(String[] args) {
    int b = 10;
    int a = 20;
    System.out.println(a + b);
}

即使发生交换,但是我们程序最后的运行结果是不会变的,当然这里只通过代码的形式演示,实际上JVM在执行字节码指令时也会进行优化,可能两个指令并不会按照原有的顺序进行。

虽然单线程下指令重排确实可以起到一定程度的优化作用,但是在多线程下,似乎会导致一些问题:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            if(b == 1) {
                if(a == 0) {
                    System.out.println("A");
                }else {
                    System.out.println("B");
                }   
            }
        }).start();
        new Thread(() -> {
            a = 1;
            b = 1;
        }).start();
    }
}

上面这段代码,在正常情况下,按照我们的正常思维,是不可能输出A的,因为只要b等于1,那么a肯定也是1才对,因为a是在b之前完成的赋值。但是,如果进行了重排序,那么就有可能,a和b的赋值发生交换,b先被赋值为1,而恰巧这个时候,线程1开始判定b是不是1了,这时a还没来得及被赋值为1,可能线程1就已经走到打印那里去了,所以,是有可能输出A的。

volatile关键字

谈谈你对Volatile关键字的理解 - BeaBrick0 - 博客园 (cnblogs.com)

前面说道并发的三大特性:之所以按照这个顺序写,是因为并发操作和原子操作,都是为了保证对主内存数据的一致性。

原子性、并发性、一致性,具体细节还请往上看。

我们之前说了,如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作,下面这个操作看起来是一个有限循环,但是是无限的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;   //很明显,按照我们的逻辑来说,a的值被修改那么另一个线程将不再循环
    }
}

实际上这就是我们之前说的,虽然我们主线程中修改了a的值,但是另一个线程并不知道a的值发生了改变,所以循环中依然是使用旧值在进行判断,因此,普通变量是不具有可见性的。

要解决这种问题,我们第一个想到的肯定是加锁,同一时间只能有一个线程使用,这样总行了吧,确实,这样的话肯定是可以解决问题的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0) {
                synchronized (Main.class){}
            }
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        synchronized (Main.class){
            a = 1;
        }
    }
}

但是,除了硬加一把锁的方案,我们也可以使用volatile关键字来解决,此关键字的第一个作用,就是保证变量的可见性。当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的volatile变量缓存无效,这样,另一个线程修改了这个变时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。

public class Main {
    //添加volatile关键字
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

结果还真的如我们所说的那样,当a发生改变时,循环立即结束。

当然,虽然说volatile能够保证可见性,但是不能保证原子性,要解决我们上面的i++的问题,以我们目前所学的知识,还是只能使用加锁来完成:

public class Main {
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) a++;
            System.out.println("任务完成!");
        };
        new Thread(r).start();
        new Thread(r).start();

        //等待线程执行完成
        Thread.sleep(1000);
        System.out.println(a);
    }
}

不对啊,volatile不是能在改变变量的时候其他线程可见吗,那为什么还是不能保证原子性呢?还是那句话,自增操作是被瓜分为了多个步骤完成的,虽然保证了可见性,但是只要手速够快,依然会出现两个线程同时写同一个值的问题(比如线程1刚刚将a的值更新为100,这时线程2可能也已经执行到更新a的值这条指令了,已经刹不住车了,所以依然会将a的值再更新为一次100)

那要是真的遇到这种情况,那么我们不可能都去写个锁吧?后面,我们会介绍原子类来专门解决这种问题。

最后一个功能就是volatile会禁止指令重排,也就是说,如果我们操作的是一个volatile变量,它将不会出现重排序的情况,也就解决了我们最上面的问题。那么它是怎么解决的重排序问题呢?若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 保证特定操作的顺序
  2. 保证某些变量的内存可见性(volatile的内存可见性,其实就是依靠这个实现的)由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重序。

img

image-20220316192345107

所以volatile能够保证,之前的指令一定全部执行,之后的指令一定都没有执行,并且前面语句的结果对后面的语句可见。

最后我们来总结一下volatile关键字的三个特性:

  • 保证可见性
  • 不保证原子性
  • 防止指令重排

happens-before原则

经过前面的讲解,相信各位已经了解了JMM内存模型以及重排序等机制带来的优点和缺点,综上,JMM提出了happens-before(先行发生)原则,定义一些禁止编译优化的场景,来向各位程序员做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下:

  • 程序次序规则:同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
    同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求。
  • 监视器锁规则:对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
    就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x是前一个线程修改后的结果12(所以synchronized是有happens-before规则的)
  • volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作。
    就是如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。
  • 线程启动规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。
    在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程加入规则:如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。
  • 传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C。
public class Main {
    private static int a = 0;
  	private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我们定义以上出现的操作:

  • A:将变量a的值修改为10
  • B:将变量b的值修改为a + 1
  • C:主线程启动了一个新的线程,并在新的线程中获取b,进行判断,如果为true那么就打印a

首先我们来分析,由于是同一个线程,并且B是一个赋值操作且读取了A,那么按照程序次序规则,A happens-before B,接着在B之后,马上执行了C,按照线程启动规则,在新的线程启动之前,当前线程之前的所有操作对新的线程是可见的,所以 B happens-before C,最后根据传递性规则,由于A happens-before B,B happens-before C,所以A happens-before C,因此在新的线程中会输出a修改后的结果10。

posted @ 2022-03-18 13:26  BearBrick0  阅读(92)  评论(0编辑  收藏  举报