Java 进阶——并发编程必知必会之需要掌握的进程、线程、Java内存模型、synchronized、volatile等基本理论(一)

引言

一个计算机操作系统主要由I/O设备、总线、主存与中央处理器、外设等组成,由于CPU 的运算速度远远远远超过总线上的其他设备,如果想要充分利用计算机处理器的能力,原则上就不能让他控线下来,否则就太浪费资源了,于是就设计让计算机处理器“同时”处理多项任务…

“同时”——宏观上可以执行多个应用程序;但微观只有一个CPU,一个时间片内只能运行一条进程,只不过切换的速度非常非常地快,看起来同时。

设备之间轮流占有CPU的资源(以时间片为最小单位)执行各自的任务

时间片——由内核分配的,每个线程被分配到一个时间段(若干个时间片),即该进程允许运行的时间,从而使得各个程序从表面上看是同时进行的。若在自己的时间段结束时进程还在运行,则CPU将被剥夺并分配给另一个进;如果进程在时间片结束前阻塞或结束,则CPU当即进行上下文切换,这样就不会导致CPU资源浪费。

进入并发前你需要掌握一些理论知识,系列文章如下:

一、进程和线程

1、进程和线程设计思想概述

为了管理好CPU资源分配,于是乎进程Process这一概念应运而生,以进程为CPU资源分配的最小单位,可以把进程理解对CPU工作时间段的描述,而执行执行一段程序代码,在相关前置条件(即所有的程序上下文)都满足后,就需要申请CPU资源,当得到CPU时开始执行,早程序执行完了或者分配给他的CPU执行时间到了,那么为了公平它就要被切换出去,等待下一次获取CPU资源。通常在被切换出去的最后一步工作就是保存程序上下文,因此进程=上下文切换的程序执行时间总和 = CPU加载上下文+CPU执行+CPU保存上下文,一个进程最基本的内容包含:PCB、程序段、数据段等。

通俗来说,先加载A程序片段的上下文接着真正执行A,然后保存程序A的上下文;再调入下一个要执行的程序B的程序上下文,然后开始执行B,保存程序B的上下文…

虽然已经有了进程这个概念,但是粒度还是太大且进程切换性能略大,于是线程应运而生,线程(最基本的内容包含:线程Id、当前指令指针PC、寄存器集合、堆栈)是轻量级的进程,线程是进程中的一个实体,所以也可以看成是CPU工作时间段的另一种更小描述,是被系统独立调度和分派的基本单位,线程自己不拥有自己的系统资源,只拥有一点在运行中必不可少的资源,但它可以与同属一个进程的其他线程共享进程所拥有的全部资源一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。在同一线程内,代码是严格遵照逻辑顺序执行的,但是在多线程则不然,而且进程必定多个线程,当进程得到CPU资源之后理论上会依次执行各部分的线程,这些线程共享进程的上下文环境,CPU在跳转执行时无需进行上下文切换的。简而言之,CPU 把时间片分配到进程中,再获取线程作为最小单位执行。(仅供参考)。

2、Windows 下的进程和线程

Windows中进程只是作为资源的拥有者,并不是实际任务的执行者,实际的执行靠线程实现。一个进程可以拥有多个线程,多个线程共享进程拥有的资源,在具体执行任务时由线程来使用处理机。Windows中,进程实现靠createProcess实现。而createProcess有一大堆的参数,不过很多时候都默认为null。其作用相当于创建一个进程的同时创建一个线程(一般一个),其作用相当于fork+execv。

3、Linux 下的进程

Linux起源于Unix,而早期Unix在大型机器上使用,没有进程的概念,直到后来也不明确区分进程、线程,根据长期总结经验,得到Linux快速复制父进程的方法——fork进程本身clone一个新的出来,快速复制父进程,子进程直接使用父进程的地址,开销小,效率高。基本上Linux的进程创建靠fork实现。Linux中进程本身是可以执行的(区别Windows),通过fork创建新进程后,即fork之后子进程和父进程都执行同样的一段代码,想要区分,必须通过fork的返回值来区分。

int pid = fork();
if (pid < 0) { printf("error");} //小于0,发生错误
else if( pid == 0 ) { printf("子进程!");} //==0,为子进程
else{  printf("父进程! ");} // >0,父进程

那么子进程需要执行其他的资源呢?用exevc族,此时才为子进程开辟自己的地址空间,再传入需要执行的文件,就可以执行具体的内容了。从Linux内核角度而言,基本没有什么线程和进程的区别(本质上都是进程)一个进程的多个线程只是多个特殊的进程他们虽然有各自的进程描述结构,却共享了同一个代码上下文,这样的进程称为轻量级进程Light weight process。在Linux 2.4内核的多线程无法使用多个cpu,一个进程的线程都被限制在同一个cpu上运行。因此多线程库pthread的实现是建立在特有的线程模型之上,2.4内核使用了一个内核线程来处理用户态进程中的多个线程的上下文切换(线程切换)。内核中也并没有线程组(一个进程的多个线程)的概念,因此必须依在pthread库中实现一个额外的线程来管理其他用户线程(即用户程序生成的线程)的建立、退出、资源分配和回首以及线程的切换。加上早期硬件并没有线程寄存器之类的东西支持多线程,因此多线程切换效率低下,并且需要引入复杂的机制在进程的栈中为各个线程划分出各自的栈数据所在位置,并且在切换时进行栈数据拷贝,而最大的问题是内核中缺乏对线程间的同步机制的支持,因pthread库不得不在底层依靠信号量的方式来实现同步,因此线程互斥中的互斥操作和条件量操作都转换为进程的信号操作(信号量是比较低俗的通信方式,势必降低线程的实际性能。最后的问题是信号处理,内核对线程并不清楚,必须由管理线程接收信号投递给相应线程,造成了效率低下以及不必要的问题)。在后来的2.5内核之后,硬件结构中已经大为发展,出现了对线程寄存器的支持,因此pthread的切换速度已经大大提高,不过受硬件限制,线程数量小于8192个。直到2.6内核中已经使用了NPTL(Native POSIX Thread Library),linux线程的一种新实现,在性能和稳定性方面都提供了重大的改进.

  • NPTL不在使用管理线程.现在内核本身就可以实现这些功能,内核还可以处理线程堆栈所使用内存的回首工作.

  • NPTL没有了管理线程,使得其在NUMA和SMP系统上有了更好的伸缩性和同步机制.

  • NPTL线程库可以避免使用信号量来实现线程同步,它引入了一种名为futex的机制,在共享内存区域上尽心工工作,可以实现进程之间共享,就可以提供进程间posix同步机制.它使得线程间、进程间共享成为可能。Posix兼容,对信号处理等机制相应处理更为健全向后兼容,至此,linux已经可以支持多处理机下的多线程操作。

4、Java多线程

Java编写的程序都运行在Java虚拟机(JVM)中,而在JVM的内部,程序的多任务是通过线程来实现的。每用java命令启动一个java应用程序,就会相应启动一个JVM进程。在同一个JVM进程中有且只有一个进程(就是它自身),在这个JVM进程里所有程序代码的运行都是以线程来运行的。JVM找到程序程序的入口点main(),然后运行main()方法,随即产生了一个称之为主线程的线程,所以当main方法结束后,主线程运行完成,JVM进程也随即退出。所以其实并不是完全Java语言自带的多线程机制,只不过是通过在编译器级别使用多线程内存模型,利用其一般的顺序一致性原则来编写高效率的多线程程序,底层往往调用系统的api,中间过程做了自动优化。C++新标准使用了名为sequential consistency for data race free programs的多线程模型机制;而Java的缓存一致性模型(包含顺序一致性模型、释放一致性模型)来实现其多线程。

5、多任务vs 多进程、多线程

计算机的早期,多任务被称作多道程序.多任务处理是指计算机同时运行多个程序的能力.多任务的一般方法是运行第一个程序的一段代码,保存工作环境;再运行第二个程序的一段代码,保存环境;……恢复第一个程序的工作环境,执行第一个程序的下一段代码……现代的多任务,每个程序的时间分配相对平均.对于实时操作系统,任务调度直接影响性能. 任务切换、调度方式等也都类似于进程、线程调度方式,其实这里的任务在windows下可能就是一堆等待执行的线程,也可能像linux下的多个进程等方式.实际上,任务调度就是在对进程或线程调度.

二、Runnable

Runnable 意为可被执行的意思,Runnable 通过公开的run接口交由实现类(可以理解为任务)去实现,相当于是提供了外部应用程序可以通过对应的实例对象调用到run方法,因此无论是何种方式都需要通过Thread的run方法来真正启动线程。当Runnable 被传入到Thread 之后时候,会把它设置到Thread#target属性之上,而Thread#run、方法执行时就是调用这个target的run方法,可以说Runnable 是Thread与其要执行的任务的隔离机制——Thread 可以执行各种不同的任务,只要传入不同的Runnable实现即可。

三、线程创建的原理概述

在java中,创建线程有三种形式:

  • 直接继承Thread并重写run方法

  • 实现Runnable接口并传入Thread,通过Thread 来执行Runnale 任务。

  • 实现Callable 配合FutrueTask 传入Thread。

如果你大量创建线程之后,可能会报”unable to create new native thread“的异常,因为线程使用的是堆外的内存空间,而通过构造函数得到Thread对象本身只是JVM内的一个普通对象,可以看成是一个线程操作的外壳,只有调用了Thread#start方法(JVM内部会通过JNI创建)由OS 真正分配线程资源之后的Thread对象才是真正的线程对象,所以真正创建线程有两大步骤:

  1. 在JVM的堆中创建了一个Thread普通对象(外壳)
  2. 调用start方法,通过JNI 给这个外壳赋予真正的线程能力,才能被OS 调度
Thread t=new Thread(){...};
t.start();

简而言之,new Thread只是创建外壳,还需要调用start方法才真正完成线程的创建,才能被OS调度。

四、线程的状态

对线程的每个操作,都可能会使线程处于不同的工作机制下,严格地来说线程的状态有两方面,OS底层真正的状态和JDK 上层封装的状态,通过Thread#getState()方法可以得到当前线程的状态值(对应java.lang.Thread.State 枚举)
在这里插入图片描述

1、新建(NEW)

刚创建未执行start方法时的状态,需要注意调用了start方法,并不意味着状态立即就改变,因为期间还有一些步骤,只有这些步骤完成了之后状态值才会改变。

2、运行(RUNNABLE)

操作系统中的RUNNING和READY态时,此状态的线程有可能正在执行,也有可能在等待着CPU为它分配执行时间,
在这里插入图片描述
主要场景有:

  • 当处于NEW的线程执行start方法结束后

  • 当前正在正常运行的线程或当某个运行的线程发生了yield操作

RUNNABLE 态可以由其他状态转成,同样RUNNABLE态的也可以执行很多操作变成其他态,这也是为什么不能连续对同一线程多次start的原因之一,因为RUNNABLE不能转为RUNNABLE。但是Java 的RUNNABLE态并不一定代表它一定处于运行中的状态,比如在BIO中即使线程正阻塞在网络等待时,对应的线程状态依然为RUNNABLE,又比如yield操作时,对应的状态也是RUNNABLE,另外对于处于RUNNABLE 态的线程interrupt无效。

yield——主动放弃当前CPU资源,让其他线程去执行,放弃的时间不确定,可能刚刚放弃马上又重新获得

3、阻塞(BLOCKED)

在等待锁被阻塞导致线程被挂起时的状态,得到锁之后马上变为RUNNABLE。一旦处于BLOCKED,线程就像什么都没做一样,Java层面无法唤醒它,用interrupt 也没用,因为interrupt 只是在里面做一个标记,并不是真正唤醒处于阻塞态的线程。BLOCKED状态是JVM认为程序还不能进入某临界区,因为同时访问会有问题。

4、无限期等待(WAITING)

WAITING表示线程已经进入了临界区切拿到了锁,在即将开始真正的工作时候,发现有些前置条件不满足,自己主动等等去做其他事。处于WAITING态的可以用interrupt 重新唤醒,因为当执行这个方法时内部会抛出一个InterruptedException的异常,进而被run方法捕获,使得run方法正常地执行完成。若在某个线程内执行了锁对象的notify/notifyAll方法候,该线程状态变为RUNNABLE。主要场景有:

  • Thread.sleep()
  • 不设置Timeout 参数的Object.wait()和Thread.join() 方法
  • LockSuport.park方法

5、限期等待(TIMED_WAITING)

相当于把某个时间资源作为锁,进而到达等待的目的,时间到达时或者被重新唤醒自动触发线程回到RUNNABLE,主要场景有:

  • Thread.sleep(ms)
  • 传递了Timeout 参数的Object.wait(ms)和Thread.join(ms) 方法
  • LockSuport.parkUtil 或者parkNanos方法

6、结束(TERMINATED)

run方法执行结束后,这种状态是Java 层面的,OS底层可能已经注销了相应的线程或者已经复用给其他线程的请求

五、调度的优先级和线程中断

1、调度的优先级

线程的调度优先级可以通过setPriority方法实现,JVM为了兼容各种OS 平台设定了110个优先级(有些OS只有35个优先级,JVM 会自动去建立对应的映射关系),数字越大优先级越大,另外线程的优先级具有继承性,A线程启动B线程则B线程的优先级和A的是一样的,一般说来高优先级的会比低优先级的先执行完毕,单并不是所有的高优先级线程都比低优先级线程先执行完毕。此外除了用户线程,JVM中有一种优先级极低的后台(守护)线程(调用setDaemon(true))的线程,不会去抢占别人的CPU,当JVM进程中活着的线程只剩下后台进程时,意味着整个进程要结束了。比如GC 线程,如果main线程一直活着,那么GC 线程就不会销毁,main线程一旦死亡GC 也会自动销毁。

2、线程的中断

当线程的run()方法执行方法体中的最后一条语句后,并经由执行return语句返回时或者在方法体执行过程中出现没有捕获的异常时线程将终止,即线程的中断。Java为提供了一种调用interrupt()方法(interrupt方法不会中断一个正在运行的线程,仅仅设置一个线程中断标志位,若程序中你不检测线程中断标志位,即使设置了中断标志位为true,线程也一样照常运行。)来尝试终止线程的方法,在每一个线程都有一个boolean类型标志,用来表明当前线程是否请求中断,当一个线程调用interrupt() 方法时,线程的中断标志将被设置为true。可通过isInterrupted()或者Thread.interrupted()方法来判断线程的是否请求中断,而抛出异常是为了线程从阻塞状态醒过来,并在结束线程前让程序员有足够的时间来处理中断请求。但是如果每次迭代之后都调用sleep方法(或者其他可中断的方法),isInterrupted检测就没必要也没用处了,因为假如在中断状态被置位时调用sleep方法,它不会休眠反而会清除这一休眠状态并抛出InterruptedException。所以如果在循环中调用sleep,不要去检测中断状态,只需捕获InterruptedException。

六、JVM定义的Java内存模型(Java Memory Model, JMM)

1、JMM概述

由于内存中的运算速度远远大于I/O操作,为了解决这个弊端,主流的计算机系统中引入了高速缓存的概念:
在这里插入图片描述

Cache存储器_电脑中为高速缓冲存储器,是位于CPU和主存储器DRAM(Dynamic Random Access Memory)之间,规模较小,但速度很高的存储器,通常由SRAM(Static Random Access Memory静态存储器)组成。Cache的功能是提高CPU数据输入输出的速率。Cache容量小但速度快,内存速度较低但容量大。

在变量定义初始化时主动写入主存并拷贝到高速缓存中,下次再次访问的时候首先去高速缓存中执行相关操作,通常结果不会马上写回到主存。为了屏蔽不同平台的内存模型差异,JVM不会去操作计算机系统里真正的内存,于是乎JVM的内存模型(JVMM),简而言之,JVMM 就是一套定义程序中各个共享变量(非Java编程语言的变量,包含了除了局部变量和方法参数之外的实例字段、静态字段和构成数组对象的元素)为了让Java的并发操作时线程安全。

局部变量和方法参数是线程私有的,不存在线程安全问题的隐患。

2、JMM的主要内容

  • JMM 规定了所有的非线程私有变量都存在主存Main Memory(类比物理硬件的部分主存,但实际上是JVM内存的一部分)中,每条线程还定义了工作内存Working Memory(类比于高速缓存,用于保存了该线程所有使用到的变量的主存副本拷贝)。

  • 线程对变量的所有操作都必须在工作内存中,而不能直接读写主存的变量。

  • 不同的线程间无法直接访问彼此的工作内存的变量,线程间变量值的传递均需要通过主存来完成。

  • 定义了主存和工作内存的交互协议。

此处的的主存和工作内存,与Java 运行时内存区域中的Java堆、栈、方法区并不是同一层次的概念,主存可以简单对应到Java 堆中的对象实例数据部分;而工作内存则对应于JVM 栈部分区域,即程序运行时的主要区域。

3、主存和工作内存的交互规范

一个变量如何从主存拷贝到工作内存,反之亦然,JMM定义了8种原子操作(对应字节码指令):

原子操作 说明
lock(锁定) 作用于主内存,它把一个变量标记为一条线程独占状态。
unlock(解锁) 作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定。
store(存储) 作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用。
load(载入) 作用于工作内存,它把read操作的值放入工作内存中的变量副本中。
read(读取) 作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用。
write(写入) 作用于主内存,它把store传送值放到主内存中的变量中。
use(使用) 作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作。
assign(赋值) 作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作。

如果要把变量从主存拷贝到工作内存,就要顺序执行read和load操作;而要把工作内存的变量同步到主存则顺序执行store和write操作(仅仅是逻辑上的先后,并没有规定必须是连续执行的,意味着中间可以插入其他指令)Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。

  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)

七、原子性、可见性和有序性

1、原子性(Atomicity)

由Java内存模型来直接保证的原子性变量操作包括read,load,assign,use,store,write。我们大致可以认为基本数据类型的访问读写是具备原子性的(double和long有非原子性协定)如果应用场景需要提供更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,虚拟机未把这两种操作直接开放给用户使用,但是提供了更高层次的字节码指令:monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码反映到Java代码中就是同步块——synchronized关键字,因此,在synchronized块之间的操作具备原子性。

2 、可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。及每次使用前立即从主内存刷新。因此volatile变量保证了多线程操作时变量的可见性。除了volatile,Java还有两个关键字也实现了可见性:synchronized和final。
synchronized同步块的可见性,是由"对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write)"这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,其他字段就能看见final字段的值,即final域能确保初始化过程的安全性。

3、有序性(Ordering)

如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内表现为串行的语义";后半句是指"指令重排序"现象和"工作内存与主内存同步延迟"现象。

部分理论知识整理自《深入理解Java虚拟机:JVM高级特性与最佳实践》

八、synchronized和volatile

未完待续

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章