您的位置:首页 > 编程语言 > Java开发

深入理解Java并发机制(1)--理论基础

2017-10-25 14:16 716 查看

进程与线程概念

在现代操作系统中,进程支持多线程。

进程是资源管理的最小单元,

线程是程序执行的最小单元。

线程作为调度和分配的基本单位,进程作为资源分配的基本单位。

一个进程的组成实体可以分为两大部分:线程集和资源集。进程中的线程是动态的对象;代表了进程指令的执行。资源,包括地址空间、打开的文件、用户信息等等,由进程内的线程共享。

多道程序设计模型

计算机采用多道程序设计模型可以显著提高CPU的利用率。

多进程与多线程

多进程:并行实体之间不共享同一个地址空间和所有可用数据。

多线程:并行实体之间共享同一个地址空间和所有可用数据。

1、线程比进程更加轻量级,线程的创建、切换等过程比进程开销小,线程的通信比进程间的通信简单。

2、进程的安全性高于线程,因为不共享。

多线程的实现方式

内核线程实现

用户线程实现

用户线程加轻量级进程混合实现

Java线程实现在linux和windows平台上均采用的是内核线程实现,Java线程与内核线程为一比一的对应关系,也就是说Java线程由内核直接调度。

多线程面临的挑战

线程通信

上下文切换

死锁

资源限制

线程安全

线程通信

多线程相当于是多个线程为了完成一件“大事”而协同工作,那这多个线程之间如何协同就是线程通信的问题了。

线程间的通信是多线程编程的基础,线程间通信方式有两种:共享内存和消息传递。Java线程间的通信机制为共享内存方式。

线程间通信就要保证通信的可靠性,确保信息的可靠传递。这就涉及到线程安全的问题。

线程安全

线程安全最核心的概念是正确性

在操作系统中,正确性是指多个线/进程读写共享数据,最后的结果与线/进程运行的精准时序无关,亦即不存在竞争条件

在Java中,正确性是指:某个类的行为与其规范完全一致。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

可以将Java线程安全按由强到弱分为5类,同时也可以看到线程安全的责任由对象本身向调用者的转移:

不可变

不可变对象一定是线程安全的,无论是对象的方法还是方法的调用者,都不需要再采取任何的线程安全保障措施。(final关键字的使用)

绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。这个要求非常严格,Java API中大多数都不是绝对线程安全的类。

相对线程安全

保证对某个对象的单独操作是线程安全的,在调用的时候不需要做额外的同步措施。但是对于同一个对象的特定顺序的连续调用,可能需要在调用端做额外的同步手段(多为扩大同步范围)来保证调用的正确性。Java中大部分线程安全类属于相对线程安全。

线程兼容

对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下的线程安全性。Java中的普通类。

线程对立

无论调用端是否采取了同步措施,都无法在并发环境中使用。

多线程编程的出发点

并发编程的基本出发点:先保证正确性,再提高效率

保证正确性有三种方式:

互斥同步

非阻塞同步

无同步方案

互斥同步由互斥量(操作系统级或者Java语言级)支持,非阻塞同步由CAS指令支持,无同步方案为可重入代码和Java中的ThreadLocal变量。

提高效率中很重要的方式有重排序和减少上下文切换等。

互斥同步

先来看一个最基础的支撑技术。

原子操作

原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为”不可被中断的一个或一系列操作” 。如有些场景下需要将读取变量值,再修改其值,再写入内存合并为一个原子操作完成。

intel处理器使用基于对缓存加锁和总线加锁的方式来实现多处理器之间的原子操作。

总线加锁:使用处理器提供的lock#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞,该处理器可以独占共享内存。

缓存加锁:内存区域如果被缓存在处理器的缓存行中,并且在lock操作期间被锁定,那么当处理器执行锁操作回写到内存时,处理器修改内部的内存地址,通过缓存一致性协议来保证操作的原子性和可见性。缓存一致性协议会阻止同时修改由两个一上处理器缓存的内存数据,当处理器回写被锁定的缓存行的数据时,会使其他处理器的缓存行失效,其他处理器在下次读取时将重新从共享内存中读取。

可以看出原子操作的同时也提供了可见性。

再看操作系统如何保证正确性。

临界区

对共享内存进行访问的程序片段成为临界区。

如果可以协调多线/进程不可能同时处于临界区,就能避免竞争条件(即互斥),从而保证正确性。同时在协调机制中也尽可能要求高效,一个好的协调方案,要满足以下4个条件:

任何两个进/线程不能同时处于临界区

不应对CPU的速度和数量做任何假设(单核、多核、超线程)

临界区外运行的进/线程不得阻塞其他进/线程

不得使进/线程无限期等待进入临界区

其中前两个条件是对正确性的保证,后两个条件是对效率的保证。

互斥

在原子操作的支撑下,有多种方案可以实现互斥。大体分为两类:

<
4000
/tr>
类别原理优点缺点使用场景
忙等待定义共享变量,设定某个值表示是否有线程进入临界区。如果已有线程进入,则各线程对该变量进行轮询,自旋等待,直到进入临界区CPU自旋等待,如果短期内能进入临界区,避免了线程阻塞和上下文切换带来的开销CPU空转,同时存在优先级反转问题在有理由认为等待时间是非常短的情况下使用
等待/通知机制一样定义共享变量,但是如果已有线程进入,则后面的线程将阻塞,当之前的线程出临界区时通知等待的线程进入临界区执行。不会出现CPU空转,CPU占用极高的情况线程阻塞将带来线程切换上下文的开销临界区竞争激烈,且执行时间较长的情况下使用
表格中的共享变量既可以使用信号量也可以使用互斥量,一般使用互斥量。

条件变量

当仅有互斥量时且有线程在临界区时,其他线程将在互斥量上等待,然而很多情况下线程的执行不仅需要进入临界区,而且还需要满足一些其他的条件,当条件不满足时进入临界区线程不能继续执行也没有什么意义。这时候就需要条件变量了。

这时,一个线程运行需要条件变量满足,并且能锁住互斥量。如果锁住了互斥量,然而需要在某个条件变量上等待,线程将释放互斥量(给别的线程机会),进入阻塞态,等待其他线程在条件变量上唤醒自己,转入就绪态继续竞争互斥量。

管程(Monitor)

通过互斥量、条件变量以及相关的原子操作即可保证多线程通信的正确性,不过在多线程中,存在发送给变量的信号可能会丢失的情况,还有可能会出现死锁的情况,如果直接面向这些量编写多线程程序,会不可避免的出现各种奇奇怪怪的问题。为了简化多线程程序的编写,管程出现了。

管程实际上可以理解为是对互斥量、条件变量、以及组织协调多线程进入临界区算法的一个封装。高层代码只需要临界区交给管程管理就好了,管程会保证正确性。

JVM中管程与经典管程有本质区别:Java没有内嵌的条件变量。Java将等待/通知机制提出来作为wait()/notify()供程序员自定义使用,自定义条件变量(如某个对象)。

非阻塞方案

互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题。是一种悲观的同步方案:不管有没有竞争,都要加解锁。

非阻塞方案是一种基于冲突检测的乐观方案:先进行操作,如果没有竞争,则操作就成功了;如果有竞争,产生了冲突,则采取补偿措施(一般即为不断尝试直到成功),这种方案并不需要阻塞线程。

CMPXCHG指令,该指令是原子操作。简单理解,cmpxchg指令接受两个参数,一个是将要修改的变量的预期值,一个是修改值,指令比较预期值与变量的实际值是否一致,如果一致则将修改值赋值给变量。否则不做修改。

由于这个指令是主动比较,一般会放到自旋中,适用于冲突比较少的场景。

无同步方案

如果方法中不涉及共享数据,那么该方法不需要做任何同步。

可重入代码

可重入代码的特征如不依赖公共数据,不调用非可重入代码等,可重入代码中多为局部变量,不与其他代码共享,也就不存在数据竞争了。

线程本地存储–ThreadLocal

重排序

再看提高执行效率的方式:指令重排序。

重排序分三种:1.编译器优化的重排序。2.指令级并行的重排序。3.内存系统的重排序

单线程重排序

数据依赖

如果两个操作访问同一个变量,且两个操作中有一个为写操作(读写/写读/写写),此时这两个操作之间存在数据依赖性。如果对存在数据依赖性的操作重排序,程序执行结果将会改变。因此,编译器和处理器不会改变存在数据依赖性的操作的执行顺序。

as-if-serial模型

as-if-serial:不管怎么重排序,单线程执行的结果不能被改变。

1、对存在数据依赖性的操作重排序非法。

2、对不存在数据依赖性的操作是否重排序不做要求。

as-if-serial为单线程提供了顺序执行的保证。

多线程重排序

冲突访问

多线程中,对同一个共享字段或者数组元素存在两个访问(读或写),且至少有一个访问为写操作,称之为有冲突。

数据竞争

冲突访问

读写操作没有通过同步来排序

当上述情况发生时,就存在数据竞争。代码中出现数据竞争时,常有可能会出现有违直觉的结果。

那么如何判断程序有没有正确的同步呢?

一个程序是正确同步的:当且仅当所有顺序一致的执行过程中都不存在数据竞争。不论调度系统如何调度,所有可能的执行顺序序列下,都不存在数据竞争。

那么应该如何来组织多线程程序的同步排序呢?

内存模型

给定一个程序和该程序的一串执行轨迹,内存模型描述了该执行轨迹是否是该程序的一次合法执行。内存模型检查执行轨迹中的每次读操作,然后根据特定规则,检验该读操作观察到的写是否合法。

内存模型的一个高级、非正式的概述显示其是一组规则,规定了一个线程的写操作何时会对另一个线程可见。

顺序一致性模型

顺序一致性是程序执行过程中 可见性和顺序的强有力保证。

有序性

原子性

可见性

有序性:在顺序一致的执行过程中,所有动作(如读和写)间存在一个全序关系,与程序的顺序一致。

原子性和可见性:每个动作都是原子的并且立即对所有线程可见。

顺序一致性模型是一个理论参考模型,它在提供了极强的操作有序性、原子性和可见性的同时,使编译器和处理器优化(比如重排序)不再合法。

happens-before规则

JSR-133中对happens-before的定义:

1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。(有序性和可见性)

2. 两个操作之间存在happens-before关系,并不意味着java平台的具体实现必须要按照happens-before关系指定的顺序来执行。只要结果与按照happens-before关系来执行的一致,编译器和处理器的重排序是合法的。(最大限度的减少对编译器和处理器优化的约束)

程序顺序规则

在一个线程内,按照程序代码顺序,书写在前面的操作happens-before书写在后面的操作。

管程锁规则

一个unlock操作happens-before后面对同一个锁的lock操作。

volatile变量规则

对一个volatile变量的写操作happens-before后面对这个变量的读操作。

线程启动规则

Thread对象的start()方法happens-before此线程的每一个动作。

线程终止规则

线程中所有操作都happens-before对此线程的终止检测。

线程中断规则

对线程interrupt()方法的调用happens-before被中断线程的代码检测到中断事件的发生。

对象终结规则

一个对象的初始化完成(构造函数执行完毕)happens-before它的finalize()方法。

传递性

A happens-before B,B happens-before C,s.t. A happens-before C.

注意

happens-before首先强调了前一个操作对后一个操作的顺序和可见性,但是同时又没有限定具体实现,只要求执行结果要与happens-before一致。那么在实际的代码执行中,happens-before和代码时间上的先行发生并没有直接关系。

happens-before允许违反因果关系的事情发生。(比如:out of thin air)

happens-before和as-if-serial

as-if-serial保证单线程内程序的执行结果不被改变。向上向程序员保证程序执行顺序,向下约束编译器和处理器重排序的规则。

happens-before关系保证正确同步的多线程程序的执行结果不被改变。向上向程序员保证正确同步的多线程程序的执行结果正确性,向下约束编译器和处理器重排序的规则。

顺序一致性模型不允许重排序-过于严格,happens-before允许违反因果关系的事情发生-过于宽松,都不适合作为Java内存模型。

Java内存模型

Java线程间的通信机制,JMM决定一个线程对共享变量的写入何时对另外一个线程可见。

屏蔽各种硬件和操作系统的内存访问差异,实现java跨平台的一致的内存访问效果

向上向程序员保证正确同步的程序具有顺序一致性

向下向编译器和处理器提供尽可能宽松的重排序约束规则

在happens-before的基础上提供了对因果关系的充分保证

抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读写共享变量的副本。本地内存是JMM的一个抽象概念,实际并不存在,它涵盖了缓存、寄存器以及其他的硬件和编译器优化。

同时定义了工作内存与主内存的交互操作协议:lock、unlock、read、load、use、assign、store、write共8种原子性操作以及它们之间的操作规则。

Java内存模型的特征

Java内存模型建立在解决三个并发问题的基础上。

原子性

可见性

有序性

对于原子性的保证:有read、load、use、assign、store、write这类数据访问的基本原子性操作,更大范围的可以用lock和unlock操作来保证。

对于可见性的保证:Java内存模型通过主内存作为可见性实现的媒介。写操作刷新回主内存,读操作重新读主内存实现前一个操作对后一个操作的可见性。

对于有序性的保证:happens-before规则保证基本操作的有序性,通过同步来保证其他操作的有序性。

下一篇讲解Java内存模型的具体实现,看Java如何在允许指令重排序的情况下保证正确同步。

现代操作系统

Java并发编程的艺术

深入理解Java虚拟机

Java并发编程实战

JSR-133

并发编程网
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息