java并发机制的底层实现原理

349 阅读10分钟

Java代码 编译之后 得到 Java字节码,被 类加载器加载到JVM中,最终 转化为汇编指令。

预备知识

缓存

img

缓存系统中是以缓存行(cache line)为单位存储的,最常见的缓存行大小是 64个字节。

因此当CPU在执行一条读内存指令时,它是会将内存地址所在的缓存行大小的内容都加载进缓存中的。也就是说,一次加载一整个缓存行。

缓存一致性机制

每个处理器核心都有自己的缓存,多个处理器核心在对共享的数据进行写操作时,就需要保证该共享数据在所有处理器核心中的可见性一致性。

『 窥探技术 + MESI协议 』的出现,就是为了解决多核处理器时代,缓存不一致的问题的。

窥探技术

“窥探”背后的基本思想是,所有内存传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(arbitrate):同一个指令周期中,只有一个缓存可以读写内存。窥探技术的思想是,缓存不仅仅在做内存传输的时候才和总线打交道,而是不停地在窥探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的缓存行已经失效。

MESI协议

缓存系统操作的最小单位就是缓存行,而MESI是缓存行四种状态的首字母缩写,任何多核系统中的缓存行都处于这四种状态之一,用来描述该缓存行是否被多处理器共享、是否修改。

状态 描述 监听任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

状态切换:

缓存行除了在Invalid状态都可以满足CPU读的状态,一个Invalid的缓存行必须从主存中读取来满足CPU的读请求。 一个写请求只有在该缓存行是M或者E状态时才能被执行,如果缓存行处于S状态,必须先将其它缓存中该缓存行变成Invalid状态

例如:双核修改数据那么执行流程是:

  • CPU A 计算完成后发指令需要修改x.
  • CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
  • CPU A 对x进行赋值。

volatile实现原理

volatile是轻量级的synchronized,被volatile修饰的变量,保证指令的有序性和共享变量的可见性。

如果声明的volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行数据写回到系统内存,并通过缓存一致性机制保证内存的值与各处理器缓存的值的一致性。

volatile的实现原则:

1、lock前缀指令会引起处理器缓存回写到内存

当访问的内存区域已经缓存在处理器内部,则缓存锁定(不会声言LOCK#信号),锁定这块内存区域的缓存并回写到内存

然后通过缓存一致性机制来保证修改的原子性

lock前缀指令实际上相当于一个内存屏障

内存屏障是一组处理器指令,它并不由JVM直接暴露,因此JVM会根据不同的操作系统插入不同的指令以达成我们所要内存屏障效果。

2、一个处理器的缓存回写到内存会导致其他处理器的缓存无效

缓存一致性机制:如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个内存地址处于共享状态,那么挣扎嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充

(缓存行填充:当处理器识别到从内存操作数是可缓存的,处理器读取整个缓存行到适合的缓存)

原子操作的实现原理

原子操作:不可被中断的一个或者一系列操作

处理器如何实现原子操作

处理器提供总线锁定和缓存锁定两个机制来保证内存操作的原子性

通过总线锁保证原子性

如果多个处理器同时对共享变量进行读改写操作,那么共享变量就会被多个处理器同时进行操作,那么操作完后的共享变量的值可能会不一致(例如i++)

CPU1和CPU2同时从各自的缓存中读写变量i,分别进行加1操作,然后回写到系统内存中,可能结果是2。

想要保住读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享内存地址的缓存。

处理器使用总线锁来解决这个问题。

总线:主要由CPU使用,用来与高速缓存、主存之间传送信息。

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

通过缓存锁定保证原子性

在同一时刻,只需保证对某个内存地址的操作是原子性的

总线锁缺点显而易见,开销太大类,所以处理器在某些场合下使用缓存锁代替总线锁来进行优化 缓存锁定:如果缓存在处理器的缓存行中,内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。(当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能使用缓存i的缓存行)

这两个机制,Intel处理器提供很多Lock前缀的指令来实现。 例如修改指令:BTS、BTR、BTC;交换指令XADD。COMPXCHG,以及其他一些操作数和逻辑指令(如ADD,OR)。被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它

java如何实现原子操作

使用CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。(底层原理)

什么是CAS?

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。

public abstract boolean compareAndSet(T obj, int expect, int update);

以原子的方式更新这个更新器所管理的对象(obj)的成员变量,若当前值=expect,则把值更新为update,返回true,否则返回false

CAS 是一个乐观锁,会让线程不断去尝试更新,知道返回true

Java:CAS(乐观锁)

CAS实现原子操作的三大问题

(1)ABA问题(java1.5已解决,使用compareAndSet) (2)循环时间长开销大 (3)只能保证一个共享变量的原子操作(取巧办法:多个共享变量合并成一个共享变量来操作)

ABA问题是什么?

使用锁实现原子操作

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。

保证对共享变量的修改是互斥的,也就能保证原子性

用锁保证原子性

synchronized实现原理

synchronized 使用

synchronized 可以用来修饰以下 3 个层面:

  • 修饰实例方法;(实例锁,锁是当前实例对象)

  • 修饰静态类方法;(对象锁,锁水当前类的class对象)

  • 修饰代码块。(锁是synchronized括号里配置的对象)

当线程试图访问同步代码块时,首先必须得到锁,退出或者异常退出时必须释放锁

实现原理

被synchronized修饰的方法在被编译为字节码后,在方法的flags属性中会被标记为ACC_SYNCHRONIZED标志。当虚拟机访问一个被标记为ACC_SYNCHRONIZED 的方法时,会自动在方法的开始和结束(或异常)位置添加 monitorenter 和 monitorexit 指令。( monitorexit 有两个,一个是代码正常执行结束后释放锁,一个是在代码执行异常时释放锁。)

JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

monitorenter和monitorexit,可以理解为一把具体的锁。

在这个锁中保存着两个比较重要的属性:计数器和指针。

  • 计数器代表当前线程一共访问了几次这把锁;
  • 指针指向持有这把锁的线程

img

锁计数器默认为0,当执行monitorenter指令时,如锁计数器值为0说明这把锁并没有被其它线程持有。那么这个线程会将计数器加1,并将锁中的指针指向自己。当执行monitorexit指令时,会将计数器减1。

扩展:sychronized用的锁是存在java对象头里的。偏向锁和轻量级锁。

推荐阅读:深入解析 volatile 、CAS 的实现原理,作者:tomas家的小拨浪鼓