笔记ctype - Java 并发机制的底层实现原理

一、前言:

总结自《Java 并发编程的艺术》 如有雷同,纯属摘抄-.-

首先,多线程并行执行不总是比串行执行快,因为存在线程创建和上下文切换的开销。所以在计算量较小的情况下,并发执行跟串行执行效率差不多,甚至有比串行慢的情况。
减少上下文切换的方法主要有:无锁并发编程、CAS算法、使用最少线程和使用协程。

  • 无锁并发编程:
    多线程竞争锁时,会引起上下文切换,可以使用数据ID按Hash算法取模分段,不同线程处理不同段的数据来避免使用锁。
  • CAS算法
    Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 使用最少线程
  • 协程
    在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

二、volatile

1. volatile 实现原理

volatile 是轻量级的 synchronized, 它在多处理器开发中保证了共享变量的“可见性”,可见性是指当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值,它一般比 synchronized 的成本低,因为它不会引起线程上下文切换和调度。
volatile 修饰的共享变量进行 写操作 时会多出一行 lock 指令的汇编代码, lock 前缀的指令在多处理器下会做两件事:
(1)将当前处理器缓冲行的数据写回到系统内存;
(2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据失效。
整个过程可以描述为:如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在的缓冲行的数据写回到系统内存,基于多处理器的缓存一致性协议,其他处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否已经过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成过失效状态。当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读到处理器缓存中。

2. synchronized 的实现原理

主要介绍Java 对象头、锁的级别和存储、锁的升级过程。
利用 synchronized 实现同步的基础: Java 中每个对象都可以作为锁。
Java 中每个对象都可以作为锁。

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

Synchronized 在 JVM 的实现原理: JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者细节不一样;代码块同步是使用monitorenter 和 monitorexit 指令实现的,方法同步采用的方式在 JVM 规范中没有详细说明,但也可用前者的两个指令实现。
monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处, JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获取对象的锁。

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

对象头分为三个部分:

  1. Mark Word :存储对象 hashCode、对象分代年龄、是否为偏向锁和 2 bit 锁标志位
  2. Class Metadata Address :存储到对象类型数据的指针
  3. Array length (数组类型) :数组的长度

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化,具体如下图(盗图)
这里写图片描述

在 Java SE 1.6 中,锁一共有四种状态: 无锁状态偏向锁状态轻量级锁状态重量级锁状态,这几种状态的会随竞争情况逐渐升级,锁可以升级不能降级,目的是为了提高获得锁和释放锁的效率。

  1. 偏向锁
    在很多情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获取锁代价更低而引入了偏向锁。
    偏向锁的获取
    当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。 如果测试成果,表示线程已经获得了锁。如果测试失败、则需要再测试一下 Mark Word 中的偏向锁的标识是否设置为 1 (表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
    偏向锁的撤销
    偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。 偏向锁的撤销,需要等到全局安全点(在这个时间点上没有正在执行的字节码)。它首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不合适作为偏向锁,最后唤醒暂停的线程。
    关闭偏向锁
    偏向锁在 Java 6 和 Java 7 是默认开启的,但是一般在程序启动几秒钟之后才激活,可以使用 JVM 参数来关闭延迟:
    -XX:BiasedLockingStartupDelay=0
    如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:
    -XX:-UseBasiedLocking=false,程序默认会进入轻量级锁状态。
  2. 轻量级锁
    轻量级锁的加锁
    线程在同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的 Mark Word 复制到锁记录中(Displaced Mark Word)然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针,如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
    (所谓的自旋,就是让没有获得锁的线程自己运行一段时间的自循环,这就是自旋锁。自旋锁在JDK6以后已经默认开启,可以通过-XX:+UseSpinning参数来开启。不挂起线程的代价就是该线程会一直占用处理器。如果锁被占用的时间很短,自旋等待的效果就会很好,反之,自旋会消耗大量处理器资源。)
    轻量级锁的解锁
    轻量级锁解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
  3. 重量级锁
    当锁状态处于重量级锁状态下,其他线程尝试获取锁时,都会被阻塞住,当持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之战。
3. 原子操作的实现原理

原子操作:不可被中断的一个或一系列操作。
3.1 处理器实现原子操作
处理器会自动保证基本的内存操作的原子性,保证从系统内存中读取或写入一个字节是原子的,但对于复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度、跨多个缓存行和跨页表的访问。
但是处理器提供了对 总线加锁缓存加锁 两个机制来保证复杂内存操作的原子性。

  • 使用总线锁保证原子性(处理器独占整个内存)
    所谓总线锁,即使用处理器提供的一个 LOCK # 信号,当处理器在总线上输出信号时,其他处理器的请求将被阻塞住,那么该处理器可以独享内存。
  • 使用缓存锁保证原子性
    在同一时刻,我们只需保证对某个内存地址的操作是原子性的即可,但总线锁定把 CPU 和内存之间的通信锁住了,这使得在锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这时候就可以用缓存锁定来代替总线锁定进行优化。
    所谓缓存锁定,即内存区域如果被缓存在处理器的缓存行中,并且在 Lock 操作期间被锁定,那么执行锁操作回写到内存时,处理器不在总线上声言 LOCK # 信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存无效。
  • 不能使用缓存锁定的两种情况
    1. 操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,则会调用总线锁定。
    2. 有些处理器不支持缓存锁定。

3.2 Java 实现原子操作

在 Java 中可以使用 循环CAS 的方式来实现原子操作。

  • 使用锁机制实现原子操作
    锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM 实现了很多中锁,包括偏向锁、轻量级锁和互斥锁(重量级锁),除了偏向锁,JVM实现锁的方式都使用了 循环 CAS,即当一个线程想进入同步块的时候使用循环CAS 的方式来获取锁。当退出同步块时使用循环 CAS 释放锁。
  • 使用 循环CAS 实现原子操作
    JVM 中 CAS 操作正是利用处理器提供的 CMPXCHG 指令实现的。自旋 CAS 实现的基本思路就是循环进行 CAS 操作知道成功为止。
    书上例子,手打一遍
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    private Integer integer = 0;
    private void safeCount(){
        for (;;){
            int i = atomicInteger.get();
            boolean suc = atomicInteger.compareAndSet(i,++i);
            if (suc){
                break;
            }
        }
    }
    private void unsafeCount(){
        integer++;
    }

    public static void main(String[] args){
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<Thread>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++){
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++){
                        cas.unsafeCount();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts){
            t.start();
        }
        //等待所有线程执行完成
        for (Thread t : ts){
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.integer);
        System.out.println(cas.atomicInteger.get());
        System.out.println(System.currentTimeMillis() - start);
    }
}

这里写图片描述

  • CAS 实现操作的三大问题
    1. ABA问题
      因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用 CAS 进行检查时发现它的值没有发生变化,但实际上已经变化了,解决思路就可以像乐观锁一样加个版本号解决。
    2. 循环时间开销大
    3. 只能保证一个共享变量的原子性
      针对这个问题,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性。

猜你喜欢

转载自blog.csdn.net/MachineRandy/article/details/81172506