《Java并发编程的艺术》读书笔记:二、Java并发机制的底层实现原理

二、Java并发机制底层实现原理

这里是我的《Java并发编程的艺术》读书笔记的第二篇,对前文有兴趣的朋友可以去这里看第一篇:一、并发编程的目的与挑战
有兴趣讨论的朋友可以给我留言!


1、Volatile关键字

volatile的意义与定义

volatilesynchronized这两个关键字在并发编程中都扮演着极为重要的角色,这里我会先讨论volatile

volatile是轻量级的synchronized,它在开发中保证了共享变量的可见性和与其相关指令的有序性

  • 可见性就是指当某个共享变量被一个线程修改时,其他线程能够立即获知这种修改。
  • 有序性指的是对于针对被volatile修饰的变量的单个操作,其之前与之后的指令在经过编译器的指令重排序后不会越过我们做出的该操作

volatile定义:当某个字段被声明为volatile时,Java线程模型确保所有线程看到的这个变量的值皆统一。

这里需要先了解一下相关CPU术语:

  • 内存屏障:本质是一组处理器指令,用于实现对内存操作的顺序限制
  • 缓冲行:CPU高速缓存中可以分配的最小存储单位
  • 原子操作:不可拆分的一个或一系列操作
  • 缓冲行填充:当系统识别到从内存中读取的信息是可以缓存的,处理器即读取整个高速缓冲行至适当的缓存层级中(L1,L2,L3等等)
  • 缓存命中:若缓冲行填充操作的内存位置仍然是下次处理器访问的地址,则处理器会从高速缓存中取操作数,而不去读取内存
  • 写命中:当处理器将操作数写回一个缓存区域时,首先检查这个缓存的内存地址是否还存在于缓存行中,若存在一个有效的缓存行,则将这个操作数写回缓存行而非内存
  • 写缺失:一个有效的缓存行被写入不存在的缓存内存区域

底层原理

一句话概括其原理的本质就是利用了CPU的缓存一致性

详细来说,针对有volatile关键字的共享变量进行写操作时,其指令会多出一行汇编代码,该代码为lock前缀的一行指令。这样的指令在多核处理器下会引发两件事:

  1. 将当前处理器缓存行的数据写回到系统内存
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

什么概念呢?我们知道CPU的速度非常快,而读写内存相对慢许多,所以CPU会将内存中数据缓存到Cache中再操作。而volatile的lock指令会造成这样的影响:当触发lock指令时,直接将该变量所对应的缓存行数据写回系统内存。
那么根据CPU的缓存一致性特性,当一个处理器的缓存回写时,所有从内存中缓存了对应该区域的缓存内容的缓存行都会被标记为过期数据,当这些处理器想要对该行数据操作时,就需要先重新从内存中读取一次数据,再进行操作。

总结下来,volatile通过两件事保证了可见性:

  • lock前缀指令会引起处理器缓存回写到内存
    • 该指令有两种实现:1、总线锁定;2、缓存锁定
    • 锁总线即是声言LOCK#信号,该信号确保在声言期间,一切其他处理器对于总线的占用都会被阻塞,从而确保该处理器独享了内存,但是这么做开销很大,毕竟这就相当于将一个多处理器CPU退化到了单处理器
    • 另一种做法就是我们的lock,这种做法不声言LOCK#,而是锁定这块缓存并且将其写回内存,利用缓存一致性机制,其他处理器此时会获知这块内存对应的缓存已经过期无效,进而组织同时修改由两个以上处理器缓存的内存区域,从而确保了修改的原子性。
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

那么有序性又是怎么实现的呢?主要是依靠在针对volatile的指令前后添加内存屏障来实现的,当编译器进行指令重排序时,该屏障会阻止指令越过该屏障,这样,位于该指令之前的指令重排序后仍然只能处于该指令之前,其后的指令同理。


volatile功能总结

前面已经说了,volatile关键字主要就是两个功能:

  • 可见性,确保任何针对该变量的操作皆可见
    • 当对非volatile变量进行读写的时候,每个线程先从主内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache中。
      而标注了volatile的变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证了每次读写变量都从主内存中读,跳过CPU cache这一步。当一个线程修改了这个变量的值,新值对于其他线程是立即得知的。这就是内存可见性。
  • 有序性,确保针对该变量的写操作不会因为重排序而语义错误
    • 指令重排序的目的是优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。
      比如a=1;b=2;complete=true,由于这三个指令互不相关,所以进行指令重排后,很可能会变成complete=true;a=1;b=2那么此时如果另外一个一直挂起的线程打算通过complete看看a,b是否赋值完成,就会出现问题。
      而针对volatile修饰的变量,在读写操作指令前后会插入内存屏障,指令重排序时越过屏障重排序,从而避免了某些这种错误

总结

volatile能提供可见性,并且避免变量写操作被错误的排序。在实现上较为轻量级,但是无法提供原子性,在实际使用中,仅当保护的变量本来就只涉及原子性操作时才应采用volatile。

同时要注意,由于volatile同时还提供了内存屏障,使得语句免于重排序优化,所以在效率上是会被大大拉低的,如若必要请不要使用。


2、Synchronized关键字

synchronized是并发编程中的元老级角色,而它实现同步机制的基础就是锁,在Java中,任何一个对象都可以作为synchronized关键字持有的锁存在,具体表现为以下三种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是synchronized括号里配置的对象

而一个对象的锁状态如何判断就需要用到对象头中的一段数据,被称为 Mark Word。在64位系统中,其可能状态如下:

锁状态 25bit 31bit 1bit 4bit 分代年龄 1bit 偏向锁 2bit 锁标志位
无锁态 unused hashcode unused 分代年龄 0 01
锁状态 54bit 2bit 1bit 4bit 分代年龄 1bit 偏向锁 2bit 锁标志位
偏向锁 当前线程指针 Epoch unused 分代年龄 1 01
锁状态 62bit 2bit 锁标志位
轻量级锁 指向线程栈中Lock Record的指针 00
重量级锁 指向互斥量的指针 10
GC标记 CMS过程用到的标记 11

锁一共有四种状态,从低到高是:

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

这几个状态会随着竞争情况逐渐升级,要注意,锁只能升级,不能降级,这种单向提升的策略是为了提高获取锁和释放锁的效率。


1.1、锁的升级与对比

一、偏向锁

偏向锁的来历是这么一回事:Hotspot,也就是目前应用最广泛的Java虚拟机的作者发现:绝大多数情况下,锁不仅不存在竞争,而且总是由同一线程多次重复获得。在这种情况下,反复获取锁再解锁就非常影响效率,所以引入了偏向锁这种超轻量级的锁。

偏向锁的锁机制

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,此后该线程进入和退出同步块时就可以免于加锁去锁了,只需要检查一下对象头中的Mark Word里是否存储着指向当前线程的偏向锁即可。若成功即表明已经获取锁,若失败则要检查偏向锁标识是否为1,如果失败则需要竞争锁,如果成功则将对象头中的偏向锁指向当前线程。

偏向锁的撤销

同时,偏向锁使用了一种仅当竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。这个操作需要等到全局安全点(该时间点上没有正在执行的字节码文件)才会执行,它首先会暂停拥有偏向锁的线程,并检查其是否存活,若不处于活动状态则直接将对象头设置为无锁状态。若存活则拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的偏向锁要么重新偏向其他线程,要么恢复无锁或标记为不适合作为偏向锁。最后唤醒暂停的线程。

偏向锁设置

偏向锁是默认启用的,但是它在应用程序启动后延迟数秒才会激活。若必要则可以使用JVM参数关闭延迟:

-XX:BiasedLockingStartupDelay=0

若你能确定,程序中所有的锁通常情况下皆处于竞争状态,则可以取消偏向锁,使得所有锁都默认直接进入轻量级锁状态。

-XX:-UseBiasedLocking=false


二、轻量级锁

轻量级锁加锁机制

轻量级锁的加锁机制简单来讲就是:

  • 首先线程中开辟一个存储锁记录的空间,然后将对象头中的Mark Word复制到锁记录中
  • 然后,尝试通过CAS操作,将对象头中的Mark Word换成指向锁记录的指针
  • 若成功,则获得锁;而如果此CAS操作失败,即表示竞争发生,尝试自旋获取锁

轻量级锁解锁机制

解锁时,我们要有借有还,线程会尝试通过CAS操作将存在栈帧锁记录中的Mark Word文本写回对象头中,如果此操作成功,即说明没有发生竞争,成功解锁。若CAS操作失败,证明存在竞争,该锁就会膨胀为重量级锁。

采取这种策略需要结合其加锁机制,轻量级锁如果尝试获取锁没成功就会一直自旋尝试获取,而自旋是占用CPU的,所以为了避免影响CPU效率,一旦持有锁的线程释放锁时发现存在竞争,就会使得该锁膨胀为重量级锁。
此时所有自旋尝试获取锁的线程会被阻塞,仅能由持有锁的线程释放锁后再将等待线程全部唤醒,再进行锁的竞争。

三、锁的对比

优点 缺点 适用场景
偏向锁 加锁与解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会被阻塞,提高了程序响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU资源 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不消耗CPU资源 线程阻塞,响应缓慢 追求吞吐量,同步块执行时间较长

3、原子操作

原子的本意即 “不能被进一步分割的最小粒子” ,自然,原子操作即是一个或一组不可被中断的操作。对于并发程序来说,原子操作,或者说原子性是一种极为重要的特性。

处理器实现原子操作

处理器本身就可以实现一些最基本操作的原子性:即当处理器从内存中读或写一个字节时,该操作是必然原子的。

而涉及到缓存的一些复杂内存操作就无法保证其原子性,这种情况下,处理器提供两种机制来保证内存操作的原子性:

  • 总线锁定
  • 缓存锁定

其实之前Volatile的部分我们就聊过这两种机制了,总线锁定就是LOCK #信号。该信号会暂时阻塞一切CPU针对总线输出信号的请求,简单粗暴且开销巨大。

而缓存锁定就是lock前缀的一行汇编指令,会强行要求处理器对该数据存在的高速缓存行进行一次内存写回,通过CPU的缓存一致性机制来阻止其他进程针对其进行操作,从而保证我们的操作的原子性。

但是要注意,缓存锁定在以下两种情况下是不适用的:

  • 当操作的数据不能被换存在处理器内部,或者是操作的数据跨多个缓存行时,处理器会调用总线锁定来实现原子操作
  • 有些处理器并不支持缓存锁定

Java实现原子操作

讲完了处理器,来看看Java如何实现原子操作。Java正如前面所说,有两大实现原子操作的方法

  • CAS操作
  • 锁机制

首先是利用CAS操作,思路很简单,自旋的针对一个变量进行CAS操作,直到成功为止,成功以后我们知道,由于硬件限制,其他处理器无法操作这一缓存对应的内存区域,从而实现了原子性。

// 下面是一个线程安全的计数方法
private AtomicInteger atomicI = new AtomicInteger(0);
void safeCount(){
    for(;;){
        int i = atomic.get();
        boolean suc = atomic.compareAndSet(i,++i);	
        // 通过compareAndSet()调用CMPXCHG指令,该指令具有原子性
        // 仅当操作成功才退出循环
        if(suc){
            break;
        }
    }
}

// 非线程安全的计数,当多个线程同时计数则可能错误
private int i = 0;
void unsafeCount(){
    i++;
}

CAS操作虽然相对锁更轻便,但还是有如下缺点:

  • ABA问题
    • 即CAS核心在于将栈帧中的E(预期值)与内存中的V(实际值)比对,若相等则进行计算值的替换,但是这里问题在于,判断值是否相等,不等于判断该值是否变动过。即,也有可能该值经历了从A变成B,继而又变成了A,但最终CAS却判定相等了,那么这就是不对的。
    • JDK1.5时,AtomicStampedReference类的compareAndSet()方法就解决了这个问题,它会先判断当前引用是否等于预期引用,也就是值有没有变,再判断当前标志是否等于预期标志,也就是有没有被改动过,这样就规避了ABA问题
  • 自旋时间长开销大
    • 由于CAS操作如果对比不成功,就会自旋地继续读取内存中的新值再度尝试使用CAS操作来进行更新。若CAS一直不成功,自旋等待将会大大影响CPU性能。
  • 只能保证一个共享变量的原子操作
    • 当我们一次操作涉及多个共享变量时,由于不同变量可能分别被不同线程实现CAS操作,所以是无法保证原子性的
    • 解决方法是可以将多个共享变量通过某种方法组合为一个变量在进行操作,比如假设a,b各为一个共享变量,那么我们可以设置c=a*b为CAS对象,或者将多个变量封装进一个对象里,通过AtomicReference类来实现对象间原子性的保证,间接实现各个共享变量的原子性保证
posted on 2022-10-20 23:13  寒光潋滟晴方好  阅读(79)  评论(0编辑  收藏  举报