java并发编程的艺术
第一章--并发的挑战
1。上下文切换
上下文切换是由于多任务操作系统需要管理多个线程或进程的并发
第二章---java并发机制的底层实现原理
java代码编译成字节码,然后被类加载器加载到jvm中,jvm执行,最终转换为汇编指令在cpu上执行,java的并发机制依赖于jvm和cpu的指令。
1.volatile的应用
volatile加在共享变量上,保证所有线程看到这个变量的值是一致的,即valoatile变量相当于加了一个读写锁,是通过原子操作实现的。
实现原理:汇编代码给volatile变量加了lock前缀指令。
lock指令:将当前cpu缓存的volatile变量写回系统内存,使其他cpu缓存中的volatile变量数据无效。
MESI 协议是一种常见的缓存一致性协议,用于处理多处理器系统中的缓存一致性问题。
1.Modified(M)
:这个状态表示缓存行已被修改,并且与主存中的数据不一致。这意味着这个缓存行中的数据是处理器专有的,并且尚未写回主存。其他处理器无权访问此缓存行。
2.Exclusive(E)私有的
:这个状态表示缓存行是干净的,与主存中的数据一致。这个缓存行只存在于一个处理器的缓存中,其他处理器没有副本。当一个处理器读取数据时,数据会从主存加载到这个处理器的缓存中,并切换到 Exclusive 状态。
3.Shared(S)
:这个状态表示缓存行是干净的,与主存中的数据一致。多个处理器可以拥有这个缓存行的副本,因此在多处理器系统中,它可以存在于多个处理器的缓存中。如果一个处理器写入了这个缓存行,它必须先将其转换为 Modified 状态,然后进行修改。
4.Invalid(I)
:这个状态表示缓存行是无效的,无法使用。这可能是因为其他处理器修改了与之相关的数据,或者处理器主动将其标记为无效。在 Invalid 状态下,缓存行不能用于读或写操作。
MESI 协议的一般实现方式:
1.状态标记
:每个缓存行都有一个状态标记,用于表示它的状态,可以是 Modified(M)、Exclusive(E)、Shared(S)、或 Invalid(I)之一。处理器在缓存中维护这些标记,以跟踪每个缓存行的状态。
2.缓存操作
:当处理器进行缓存读取或写入操作时,MESI 协议规定了缓存状态的转换条件。例如,如果一个处理器要读取一个缓存行,但当前状态为 Invalid(I),则它必须从主存中加载数据,将状态转换为 Exclusive(E)或 Shared(S),并执行读取操作。
3.写回和写分配
:当处理器对一个缓存行执行写入操作时,MESI 协议规定了数据如何写回主存以保持一致性。如果一个缓存行处于 Modified(M)状态,写回主存是必需的,然后状态转换为 Exclusive(E)。如果处于 Exclusive(E)状态,写入操作可以直接写入缓存,不需要写回主存。
4.缓存间通信
:当一个处理器修改一个缓存行并使其状态从 Exclusive(E)转换为 Modified(M)时,它必须通知其他可能拥有该缓存行的处理器。这通常涉及到发送消息或信号给其他处理器,以通知它们相应缓存行的状态变化。
5.原子操作
:在某些情况下, MESI 协议需要保证多个缓存操作是原子的,以确保数据一致性。这可能涉及到使用原子操作来实现状态的转换和数据的读写,以防止竞态条件。
MESI 协议的实现要求硬件支持,并且通常由处理器内部的缓存控制逻辑来管理。硬件支持 MESI 协议可以确保缓存状态的正确管理和转换,从而确保数据的一致性。不同的处理器架构和硬件实现可能会略有不同,但遵循了 MESI 协议的一般原则。
1.缓存锁定(Cache Locking):缓存行锁后,其他缓存中的share状态数据转换为invalid,需要重新加载。
:
- 缓存锁定是一种方法,通过它可以锁定缓存中的某些数据,防止其被其他处理器修改。
- 当一个处理器锁定缓存中的数据时,其他处理器将不能修改或访问这些数据,从而确保数据一致性。
- 缓存锁定通常以粒度更细的方式进行,例如锁定某个特定的缓存行,而不是整个缓存。
- 这种方法通常会引入更低的争用,因为只有某些数据被锁定,而其他数据可以继续并发访问。
2.总线锁定(Bus Locking):锁总线后,其他cpu无法访问内存,从而保证一致性,但是很影响性能。
同时处理器也实现了嗅探技术来保证缓存一致性。
嗅探技术:
当一个处理器中的缓存进行修改后,会在总线上通知其他处理器,将其他处理器的share状态改变为invalid。
硬件的缓存一致性是通过,MESI状态转换和嗅探技术实现的。
2.synchronized的实现
synchronized保证一个方法或者代码块同时只有一个线程执行(不会发生中断和抢占)。
(1)Java中每个对象都可以作为锁(对象头)
具体实现:
《1》普通同步方法:锁是当前实例对象。
《2》静态同步方法:锁是当前类的class文件。
《3》对于配置了synchonized对象的,锁是配置对象。
当进程访问代码块时,必须先得到锁,退出或抛出异常时释放锁。
synchronized是通过jvm实现的,jvm是通过monitorenter和monitorexit指令实现的。
monitorenter获得对象锁,monitorexit释放对象锁。对象锁存在于对象头中。
对象头的内容:对象锁信息,
markword:存储对象的hashcode和锁信息。
ClassMetadataAddress:对象的元数据(包括方法表,构造方法,成员变量。。)
arrayLength:数组的长度(如果是数组的话,如果不是数组没有这个)
锁存在于MarkWord中:
锁状态25bit4bit1bit是否是偏向锁2bit锁标志位对象的hashcode对象分代年龄001
各种锁的MarkWord:
轻量级锁指向栈中锁记录的指针00重量级锁指向互斥量(重量级锁)的指针10GC标记11偏向锁线程IDepoch,对象分代年龄101
(2)锁升级
在synchronized中有四种级别的锁:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
锁只能升级,不能降级。
(1)偏向锁:一个锁由一个对象多次获得,为了代价最低,引入了偏向锁
当一个线程获得了无锁状态的锁,那么就会将自己线程ID通过CAS记录进对象头MarkWord中,从无锁升级为偏向锁。
当线程再次获得了锁对象,就检测一下锁对象的对象头的MardWCASDord中的偏向锁是否是自己的线程ID,如果是直接用,如果不是则用CAS修改为自己的线程ID,然后使用。
如果CAS失败次数超过自旋阈值(也就是存在大量竞争),那么就将升级为轻量级锁。
(2)轻量级锁:
jvm现在当前线程的栈中创建用于存储锁记录的空间,并且将对象头中的MarkWord复制到锁记录中,然后线程会尝试用CAS将对象头中的markword替换为指向锁的指针(指向栈的指针),如果成功,当前线程获得锁,如果失败,表示其他进程在竞争锁,当前线程便使用自旋来获得锁。
如果自旋很多次都没有获得锁,那么就会膨胀成重量级锁。
(3)重量级锁
这是一个互斥锁,一旦线程完成了synchronized就会释放锁,其他进程竞争锁。
没有获得锁的进程会进入阻塞,当有锁时候会被唤醒,这会造成上下文切换,造成较大的性能损失。
2。处理器实现原子操作
当存在跨总线宽度,跨多个缓存行和跨页表时候,处理器不能保证其原子性,但是可以提供总线锁定和缓存锁定来保证原子性。
MESI协议
(1)总线锁定:当一个cpu处理时,其他cpu无法访问这个内存。
(2)缓存锁定:使用缓存一致性MESI,使用缓存行时候,其他cpu缓存中的共享缓存行设置为invalied。
3。java实现原子操作
(1)使用循环CAS
缺点:ABA问题,占用cpu使用,只能保证一个原子元素的原子操作。
(2)使用锁
第三章----java内存模型:线程间通信,顺序一致性
java是通过共享内存来实现进程间通信的。
同步是指程序中应用于控制不同进程间操作发生的相对顺序的操作。
对于实例域和静态域和数组是存储在主内存中的,是共享变量,需要从主内存中取出然后放在属于内存的本地内存中。
本地内存并非真实存在,它是cpu缓存,寄存器的抽象概念。
局部变量存在本地内存上。
进程间通信就是通过修改共享变量来实现的。线程修改本地内存,然后刷入主内存。
(1)从源代码到指令代码的重排序
编译器和处理器为了提高效率,会对指令作重排序。
源代码-》编译器优化重排序-》指令级并行重排序-》内存系统重排序-》最终指向的指令序列
这些重排序会导致并行执行发生冲突。
对于编译器级别的重排序,JMM会禁止部分编译器的重排序。
对于处理器级别的重排序,JMM会Java编译器在生成指令序列时候,插入内存屏障,通过内存屏障来实现对处理器重排序的禁止。
JMM(java内存模型)是语言级别的内存模型,它确保在不同编译器和处理器平台上,通过禁止重排序,来实现一致的内存可见性保证。
(2)内存屏障:通过缓存刷入保证,顺序一致性
有四种屏障,只拿StoreLoad Barriers举例。
StoreLoad Barriers : Store 1 ;StoreLoad; Load 2;
确保先将数据保存到主内存,然后加载出到本地内存中(加载出的是新数据)。
执行该屏障花费很大,会将cpu缓存中所有的数据都刷入到内存中(flush)。
(3)happens-before关系:一个偏序关系
happens-before关系代表:前一个执行结果对于u后一个操作可见,且第一个操作在第二个操作之前。
是一种为了对程序员方便的抽象概念。
(4)数据依赖性:后一个操作依赖前一个操作
单线程的数据依赖性,编译器和处理器会自然保证顺序。
对于数据依赖关系的操作,编译器和处理器不会进行重排序。
不管怎样,单线程的执行结果不会被改变,这就是as-if-serial语义。
(5)当单线程happens-before不会影响结果的,会重排序提高并行性。
(6)顺序一致性内存模型:所有进程串行执行
在顺序一致性模型中,操作是全序关系,每个线程都全知整体。
但是JMM没有这个保证,未同步程序整体是无须的,并且线程也不知道彼此之间的执行顺序。
(7)JMM实现同步程序的顺序一致性的相似效果
通过给临界区加锁,临界区内的代码可以重排序(但是不能溢出到临界区外),临界区外的代码也可以重排序。
这种JMM方法,在不改变执行结果的前提下,尽可能地位编译器和处理器的优化开方便之门。
(8)未同步(没有加锁。。)的执行特征
不会保证和顺序一致性内存模型相同的执行结果。
为了实现最小安全性,JVM在分配对象时,首先会对内存空间清零。
1.volatile的内存语义
使用volatile相当于给变量操作都加锁,保证不会中断,而且刷入主内存。
volatile能建立happens-before关系,volatile读写不能重排序,是编译器在生成字节码文件时,在指令序列中插入内存屏障来阻止处理器重排序实现的。
屏障通过刷入主内存来阻止,屏障上下进行重排序。
JMM对于volatile十分保守:在每一个volatile写的后面或读的前面都会插入一个内存屏障。
所有类型的屏障都是通过组织屏障两边重排序实现的。
2.锁的内存语义
临界区:共享资源,可能被多个线程访问。需要同步控制,保证一个时间,只有一个线程访问。
锁的释放--获取建立的happens-before关系。
锁除了让临界区互斥执行外,还可以让释放的锁的线程向获取锁的进程发送消息(保证happens-before)。
实例方法,synchronized在调用的对象上加锁,每个对象实例都能唯一使用,多个对象可以同时执行。
静态方法,在类的class上加锁,只能有一个对象调用。
(1)锁的释放和获取的内存语义
线程获取锁时,线程的本地内存无效,需要去主内存中获取。
线程释放锁时,线程会将本地内存写入主内存。
(2)公平锁和非公平锁都是通过一个volatile变量实现的。
3.final域的内存语义
final:不可变
final类:不可继承
final方法:不可重写
final值:常量
(1)final的重排序规则
禁止将final域的写操作,重排序到构造函数之外。
实现:JMM禁止把final域的写重排序到构造函数之外。编译器会将构造函数返回之前,插入内存屏障,将缓存写入主内存。
(2)作用:
一个对象初始化给其他线程执行,成员变量的初始化可能重排序到返回对象之后,从而导致其他线程检测到了这个引用对象,但是对象的成员变量为0。
而final确保了在引用对象被所有线程可见之前,final域已经在构造函数返回对象之前初始化完成了,其他线程看到的都是初始化后的。
4.happens-before
happens-before时指两个操作之间的执行顺序,可以在一个线程,也可以不在。
程序员不想要重排序,为了容易理解。
而电脑想要重排序,为了效率。
所以JMM:改变结果的重排序被禁止,而不改变结果的被允许。
例如:如果一个锁只会被一个线程访问,那么锁就被消除了。volatile变量也是。
happens-before的规则:
(1)一个线程中,结果串行。
(2)锁解锁后,happens-before随后对锁加锁。
(3)volatile,写happens-before读。
(4)传递性
(5)Thread.start()在这个线程的所有操作之前。
(6)Thread.join()happens-before 这个线程的所有操作。
5.线程安全的初始化
延迟初始化:懒汉模式,用的时候才初始化
但是面对多线程的访问,延迟初始化可能会看到引用对象没有初始化的值。(因为普通变量重排序到了构造函数return之后)。
我们的解决办法是:给初始化加synchronized。(synchronized在进入时会加内存屏障,出去时也会加内存屏障,确保synchronized代码中的操作输入主内存中)相当于成为了一个原子操作。同时保证只有一个线程能创建对象,不会引起多个线程创建的问题。
synchronized static instance getInstance(){
if(instance==null)
instance=new instance;
return instance;
}
但是开销很大。
双重检查锁定方法:
public static Instance getInstance(){
if(instance==nul){
synchronized(this.class){
if(instance==null)
instance=new instance;
}
}
return instance;
}
如果第一次检查不为空就不会执行synchroized的代码,从而减少开支。
但是这是有问题的,第一次检查对象不为空,但是对象还没有初始化完成,这样返回这个对象,会出问题的。(synchronied有内存屏障保证了初始化的完整性,而这个没办法保证)
《》双重检查锁定延迟初始化问题的根源
创建一个对象,其过程可以分解为:
(1)分配空间
(2)初始化
(3)指向这个空间
在单线程中,可以交换2和3,即先指向再初始化,不会影响结果。
所以双重检查延迟初始化的多线程执行过程如下:
(1-a线程)分配instance空间
(2-a线程)设置instance指向分配的空间
(3-b线程)判断instance不为空,访问instance
(4-a线程)初始化instance。
(5-a线程)访问instance。
在知晓了问题根源后,我们的解决方案如下:
(1)不允许指向insatnce空间和初始化instance重排序。
(2)允许重排序,但是保证两个操作不可中断(原子性)。
《1》基于volatile的解决方案
volatile也会在进入和出去之前加内存屏障,所以能保证所有线程看到的都是最新的。
而给instance加上volatile,那么就会在初始化后才能读。
《2》基于类初始化的解决方案
第四章--java并发基础
1.并发与并行
并发是多cpu,并行是时间块轮询。
2.进程状态
new,runnable,blocked(阻塞于锁),waiting(等被通知唤醒),time_waiting(超时等待),terminated
3.Daemon线程
只有当所有非守护线程结束后,才自动结束。
Daemon的finally代码不会执行,不能依靠finally来执行关闭或清理。
4.构造线程
一个新构造的线程对象是由其parent线程来进行空间分配的,以及继承了parent的是否为Daemon,优先级,和加载资源的contextClassLoader和可继承的ThreadLocal,同时还会分配一个唯一的Id来标识这个线程,至此,一个可以运行的线程对象就初始化好了,在堆中等待着运行了。
使用start()来启动进程。
5.中断:interrupt是进程间通信的一种方式
中断就是一个标识位属性,其他线程的interrupt(),会将其设置为true。
线程通过检查自身是否被中断来进行响应,通过isInterrupute来判断是否被中断。
中断标识为true,程序并不会终止。想要终止需要程序员主动检查标志位,主动终止程序。