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

目录

前言

volatile的应用

volatile的定义与实现原理

与volatile实现原理相关的CPU术语及说明

Synchronized 的实现原理与应用

Java对象头

锁的升级与对比

偏向锁

轻量级锁

锁的优缺点对比

原子操作的实现原理

处理器实现原子操作的两种机制

Java如何实现原子操作

各种锁 + CAS

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


前言

Java代码在编译后会变成Java字节码【class文件】,字节码被类加载器加【classloader】载到JVM【Java Virtual Machine Java虚拟机】里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。

volatile的应用

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见性”【当一个线程修改一个共享变量时,其它线程对此修改有感知】。在使用得当的情况下它比synchronized关键字执行成本更低,因为它不会引起线程上下文的切换和调度。

volatile的定义与实现原理

Java语言规范第3版中对volatile的定义如下:

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

        Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

与volatile实现原理相关的CPU术语及说明

术语英文单词术语描述
内存屏障memory barriers一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache line缓存中可以分配的最小存储单元。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
原子操作atomic operations不可中断的一个或一系列操作
缓存行填充cache line fill当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存
写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域

有volatile 变量修饰的共享变量在进行写操作时会多出一条Lock前缀的指令。

这条指令会做以下两件事:

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

        在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。 

volatile的两条实现原则

  • Lock前缀指令会引起处理器缓存回写到内存
  • 一个处理器的缓存回写到内存会导致其它处理器的缓存失效

Synchronized 的实现原理与应用

        日常工作中最常使用到的同步机制就是Synchronized关键字,很多人称它为重量级锁,但其实在JDK1.6之后对其进行了各种优化,例如引入了偏向锁、轻量级锁等。 

synchronized 使用的三种形式

对于普通同步方法,锁的是当前实例对象

对于静态同步方法,锁的是当前类的Class对象

对于同步方法块,锁的是Synchronized括号里配置的对象

反编译【javap -c xxx.class】Class文件如下:

Java对象头

        synchronized 用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于4字节,即32bit。

 图片来源:百度 

锁的升级与对比

        在JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种策略是为了提高获得锁和释放锁的效率。

偏向锁

        当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(偏向锁的标识):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销还需要等待全局安全点【这个时间点上没有正在执行的字节码】。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还存活,若不存活则将对象头设置为无锁状态;如果存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其它线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

图片来源:百度

关闭偏向锁

偏向锁在Java6、7、8里是默认开启的,但它在应用程序启动几秒后才激活,通过JVM参数【-XX:BiasedLockingStartupDelay=0】来关闭延迟。如果不想使用偏向锁也可以通过JVM参数【-XX:UseBiasedLocking=false】,设置后程序默认进入轻量级锁状态。


注:JDK15偏向锁被标记为废弃

轻量级锁

轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁

轻量级锁解锁时,会使用CAS 操作将Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

图片来源:百度

锁的优缺点对比

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

原子操作的实现原理

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

处理器实现原子操作的两种机制

  • 通过总线锁保证原子性:所谓总线锁就是使用处理器提供的一个LOCK# 信号,当一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
  • 通过缓存锁定来保证原子性:所谓 “缓存锁定” 是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言 LOCK# 信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

以下两种情况处理器不会使用缓存锁定:

  • 需要操作的数据不能被缓存在处理器内部或操作的数据跨多个缓存行
  • 处理器不支持缓存锁定

Java如何实现原子操作

各种锁 + CAS

CAS(Compare And Swap || Set)比较并交换

CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有则设置为新值,发生了变化则不设置。JVM中的CAS操作是利用了处理器提供的CMPXCHG指令来实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

下面代码实现了一个基于CAS实现的线程安全计数器safeCount和一个常规非线程安全计数器

unSafeCount

public class CasTest {

    private int unSafeCount = 0;
    private AtomicInteger safeCount = new AtomicInteger(0);

    /**
     * CAS: 线程安全
     */
    private void safeCount() {
        while (true) {
            int i = safeCount.get();
            // 循环CAS直到成功
            boolean flag = safeCount.compareAndSet(i, i + 1);
            if (flag) {
                break;
            }
        }
    }

    /**
     * 常规累加: 非线程安全
     */
    private void unSafeCount() {
        unSafeCount++;
    }

    public static void main(String[] args) throws InterruptedException {
        CasTest cas = new CasTest();
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    cas.safeCount();
                    cas.unSafeCount();
                }
            });
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.start();
        }

        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println("非线程安全计数器: " + cas.unSafeCount);
        System.out.println("CAS线程安全计数器: " + cas.safeCount);
    }

}

程序运行结果如下:

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

ABA问题

如果一个值原来是A,在CAS过程中变成了B,又变成了A,那么CAS进行检查时就会发现它的值并没有改变,实际上却变化了。这个问题的解决思路就是使用版本号,从 A -> B -> A 变成 1A -> 2B -> 3A。从Java1.5开始,Atomic包中提供了AtomicStampedReference来解决ABA问题,它的作用是首先检查当前引用是否等于预期引用,同时会检查当前标志【版本号】是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

循环时间长开销大

自旋CAS如果长时间不成功则会给CPU带来非常大的执行开销。 

只能保证一个共享变量的原子操作

循环CAS无法保证对多个共享变量操作时的原子性,但可以利用AtomicReference将多个变量放在一个对象里进行CAS操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值