java并发编程的艺术

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,程序并不会终止。想要终止需要程序员主动检查标志位,主动终止程序。

相关推荐
zwjapple1 分钟前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five3 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序3 分钟前
vue3 封装request请求
java·前端·typescript·vue
前端每日三省4 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript
凡人的AI工具箱17 分钟前
15分钟学 Go 第 60 天 :综合项目展示 - 构建微服务电商平台(完整示例25000字)
开发语言·后端·微服务·架构·golang
陈王卜20 分钟前
django+boostrap实现发布博客权限控制
java·前端·django
小码的头发丝、21 分钟前
Spring Boot 注解
java·spring boot
java亮小白199725 分钟前
Spring循环依赖如何解决的?
java·后端·spring
飞滕人生TYF32 分钟前
java Queue 详解
java·队列
chnming198733 分钟前
STL关联式容器之map
开发语言·c++