深入理解Java虚拟机之Java并发机制底层实现

前言

前面两篇学习了Java内存模型,始终围绕了三大特性(原子性、可见性、有序性)三个方面来讨论内存模型的顺序一致性.当然还有探讨了其中的编译器、处理器的指令优化相关知识,JMM并没有直接讲这些底层处理细节指令开放给我们,但是通过开放了相应的关键字,譬如 volatilesynchronized 这些关键字来供我们在并发情况下自行调用.

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码.

最终需要转换为汇编指令在CPU上执行. Java中使用的并发机制同样就需要依赖JVM的实现和CPU指令的调度了.

但是始终感觉还要写一篇文章来谈一谈这些关键字底层到底干了一些什么事,能够保证并发过程中,数据操作起来是可以安全的,好了废话不多说,直奔主题吧.

volatile的应用

在多线程并发编程中 synchronizedvolatile 都扮演重要的角色, volatile 是轻量级的 synchronized .在 多处理器 开发中保证了 共享变量可见性(当一个线程修改一个共享变量时,另外一个线程能够读到这个修改的值) .

如果volatile变量修饰变量使用的恰当的话,比synchronized使用和执行成本更低.

?why?

不会引起线程上下文的切换和调度.

那么就深入的去分析一下如何在硬件处理器层面如何实现volatile的.

1)volatile的定义与实现原理

**volatile定义: **

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

如果一个字段被声明为volatile.Java线程内存模型确保所有线程看到这个变量的值是一致的.

首先插播一下CPU术语的定义,方便后面有分析的知识依据吧.

问题:volatile是如何保证可见性的?

那就要问问CPU要干些啥了.

Java代码如下:

instance = new Singleton(); 			// instance 是被volatile变量

汇编代码如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

被volatile变量修饰的共享变量在进行写操作的时候会多出第二行汇编代码上述 lock 指令,Lock前缀的指令在多处理器下会触发两件事

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

好了有了这二件事,我们就放心了,数据无效什么概念.数据重回系统内存,又是什么概念.这不就是我们希望的吗.你不总是怕这个数据被线程A修改,导致线程B去读取的时候出错,底层都帮你干好了这件事了,好了,专心写bug吧,天塌不下来.

好了,言归正传,为了提高处理速度,处理器不直接和内存进行通信( 划重点,要考 ).而是将系统内存的数据读到内存缓存后在进行其他操作.

在多处理器下,为了保证各个处理的缓存是一致的,就会实现缓存一致性协议

如何实现:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是否过期.当处理发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态.当处理器对这个数据进行修改操作,会重新从系统内存中把数据读到处理器缓存里.

volatile两条实现原则剖析

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

Lock前缀指令导致在执行指令期间,声明处理器的 Lock# 信号.在多处理器环境 中, Lock# 信号取保在声明该信号期间,处理器可以 独占 任何共享内存.但是在最近的处理器里, Lock# 信号一般不锁总线,而是锁缓存( 原因锁总线开销比较大 ).

在锁操作时,总是在总线上声明 Lock# 信号.在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声明 Lock# 信号,相反的,它会锁定这块内存区域的缓存并写回到内存,并使用 缓存一致性机制 来确保修改的原子性.此操作被称为 “缓存锁定” .

缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据.( 两个以及以上圈起来,要考,这不就是只能一次修改一个处理器缓存的内存区域的数据.书上说的,我没瞎说. )

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

处理器使用 嗅探技术 保证其内部缓存、系统内存和其他处理器的缓存数据在总线上保持一致.

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

2)volatile的使用优化

并发编程大师Doug lea在JDK7的并发包里新增了一个队列集合类 LinkedTransferQueue , 在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能.

/**队列中的头部节点*/
private transient final PaddedAtomicReference<QNode> head;

/**队列中的尾部节点*/
private transient final PaddedAtomicReference<QNode> tail;

static final class PaddedAtomicReference<T> extends AtomicReference<T>{
    // 使用很多4个字节的引用追加64个字节
    Object p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;
    PaddedAtomicReference(T t){
        super(t);
    }
}

public class AtomicReference<V> implements java.io.Serializable{
    private volatile V value;
    //...
}

1、追加字节优化性能?

这一点需要了解 处理器架构 的知识.可参见: 深入浅出计算机组成原理

回到这个 LinkedTransferQueue 这个类吧,使用了一个内部类 PaddedAtomicReference 来定义队列的头节点和尾节点.而这个内部类相对于父类 AtomicReference 只做了一件事,就是将共享变量追加到64个字节.

一个对象的引用占用4个字节( void * 类型占用4个字节 sizeof(void*) C++), 追加了15个变量(总共占用60个字节),再加上父类的value变量.一共64个字节.

解答上述问题:为什么追加64字节能够提高并发编程的效率. 因为现代处理器的L1、L2或者L3缓存的 高速缓存行是64个字节宽, 不支持部分填充缓存行. 这意味着,如果队列的头节点和尾节点都 不足64字节 的话.处理器将它们 读取到同一个高速缓存行中 ,在多处理器下 每个处理器都会缓存同样的头、尾节点 .

因为多个处理器对应的是同样的头尾节点,如果当一个处理器试图 修改头节点 时,会将整个 缓存行锁定 ,那么在缓存一致性机制的作用下.会导致其他处理器 不能访问自己高速缓存中的尾节点 . 然后队列的入队和出队则需要不停地修改头节点和尾节点.

因此在多处理器的情况下严重影响到队列的入队和出队效率.使用追加到64字节的方式来填充高速缓存区的缓存行. 避免头节点和尾节点加载到同一个缓存行 ,使得头节点、尾节点修改时候不互相锁定.

不过这种填充方式在JDK7之后会自动填充了.

使用volatile变量都应该追加64字节码?

  • 缓存行非64字节宽的处理器

  • 共享变量不会被频繁的写

    • 因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区, 这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率非常小.

synchronized的实现原理与应用

在多线程并发编程中synchronized扮演着重要的角色,被人称为重量级锁. 随着JavaSE 1.6对synchronized进行了各种优化之后,也并非那么重了.

为了减少获得锁和释放锁带来的性能消耗,这里引入了偏向锁和轻量级锁以及锁的存储结构和升级过程的概念.

synchronized实现同步的基础

Java中每一个对象都可以作为锁,有如下三种表现:

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

当一个线程试图访问同步代码块,首先必须获得锁,退出或则抛出异常时必须释放锁.

问题1: 锁到底存在哪里? — Java对象头

问题2:锁里面会存什么信息? – 锁的标志位.锁状态

JVM基于进入和退出 Monitor 对象来实现 方法同步代码块同步 ,但是两者实现细节不同,代码块同步是使用monitorenter和monitorexit指令实现的.而方法同步是使用另外一种方式实现的.

monitorenter指令是在编译后插入到同步块的开始位置, monitorexit 是插入到方法结束处和异常处.JVM要保证每个 monitorenter 必须有对应的 monitorexit 配对(这里来了,如果使用加锁两次,就意味着要解锁两次,你Get到了吗)

任何对象都有一个monitor与之关联,并且一个monitor被持有后,将处于锁定状态. 线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,尝试获取对象的锁 .

1)、Java对象头

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

Java对象头里面的Mark Word里 默认 存储对象的HashCode、分代年龄和锁标记位.

运行期间 ,Mark Word里存储的数据会随着 锁标志位 的变化而变化.Mark Word可能会存储 相应指向栈中锁记录的指针,指向互斥量的指针 等.

在64位虚拟机下,Mark Word是64bit大小的. 其存储结构如下:

2)锁的升级与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁” 和 “轻量级锁”.

在Java SE 1.6中,锁一共有4种状态, 级别从 低到高: 无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态 .

注意:锁可以升级但不能降级、意味着偏向锁升级为轻量级锁后不能降级回去.目的是为了提高获得锁和释放锁的效率.

1、偏向锁

大多数情况下,锁不仅不会存在多线程竞争、而且总是由同一线程多次获的,为了让线程获得锁的代价更低而引入了偏向锁.

当一个线程访问同步块并获取锁时,会在对象头和栈帧种的锁记录里存储锁偏向的线程ID,那么之后该线程在进入和退出同步块时候不需要进行 CAS 操作来加锁和解锁.

  • 偏向锁的撤销

    偏向锁使用了一种 等到竞争出现才释放锁的机制 .所以 当其他线程尝试竞争偏向锁时候 ,持有偏向锁的线程才会释放锁.

    偏向锁的撤销

    • 首先会暂停拥有偏向锁的线程.
    • 然后检查持有偏向锁的线程是否还存活.
      • 如果线程不处于活动状态,则将对象头设置为无锁状态.
      • 如果线程仍然存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或则标记对象不适合作为偏向锁.
    • 最后唤醒暂停的线程.
  • 关闭偏向锁

    偏向锁在Java6和Java7里面默认时启用的, 但是在应用程序启动几秒后才激活,如有有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0.如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking= false.那么程序默认会进入到轻量级锁状态.

2、轻量级锁

  • 轻量级锁加锁

    线程在执行同步块之前,JVM会先在 当前线程 的栈帧中创建用于存储锁记录的 空间 ,

    并将对象头的Mark Word复制到 锁记录 中.然后线程尝试使用 CAS 将对象头中的Mark Word替换为指向锁记录的指针(有了这个指针,就相当于找到了这个锁的家庭住址了), 如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁. 当前线程便尝试使用自旋获取锁 .

  • 轻量级锁解锁

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

注意

因为自旋会消耗CPU,为了避免无用的自旋(比如获取锁的线程被阻塞了),一旦锁升级成了重量级锁.就不会恢复到轻量级锁状态. 当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程会进行新一轮的夺锁之争.

3)锁的优缺点对比

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

原子操作的实现原理

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

1)术语定义

在了解原子操作的实现原理前,了解一下CPU相关的术语.

2)处理器如何处理原子操作

主体上来说通过总线锁定和缓存锁定来处理原子操作.

首先处理器会自动保证基本的内存操作的原子性,处理器保证从系统内存中读取或则写入一个字节是原子的???

意思就是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址.

1、使用总线锁保证原子性

第一个机制是通过总线锁来保证原子性.

如果多个处理器同时对共享变量进行读写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作.那么这样的读改写操作就不是原子的. 操作完之后共享变量会和期望的不一致.

原因: 可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中.那么这个时候进行加1操作的处理器并没有把操作之后的数据同步到主内存中,导致在下一次进行加1操作的时候使用的还是旧的值,因此当完成两次i++操作,结果可能为2.

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

处理器使用总线锁就是用来解决这个问题的. 所谓的总线锁就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号(其实就是该处理器在总线上发出一个要锁定的信号,通知其他处理器不要抢),其他处理器的请求将被阻塞,那么该处理器就可以独占共享内存.

2、使用缓存锁保证原子性

第二个机制就是通过缓存锁来保证原子性.

在同一时刻,只需要保证对某个内存地址的操作是原子性即可.但是总线锁定把CPU和内存之间的通信锁定了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,因此目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化.

频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁.

缓存锁定的定义指的内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声明LOCK#信号.而是修改内部的内存地址,并通过缓存一致性机制来保证操作的原子性.

缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据.当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效.

但是有两种情况下处理器不会使用缓存锁定
  • 当操作的数据不能被缓存在处理器内部或者操作的数据跨多个缓存行,则处理器会调用总线锁定
  • 有些处理器不支持缓存锁定,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定.

3)如何实现原子操作

在Java中可以通过锁和循环CAS方式来实现原子操作

(1)使用循环CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的 CMPXCHG 指令实现的.

自旋CAS实现的基本思路:循环进行CAS操作知道成功为止.

public class Counter {
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    private int i = 0;

    public static void main(String[] args) {
        final Counter counter = new Counter();
        List<Thread> ts = new ArrayList<>(600);
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        counter.count();
                        counter.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts) {
            t.start();
        }
        //等待所有线程退出
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.err.println(“count:”+counter.i);
        System.err.println(“atomic:”+counter.atomicInteger.get());
        System.err.println(“time:”+System.currentTimeMillis() - start);
    }
	// 使用CAS实现线程安全
    private void safeCount() {
        for (; ; ) {
            int i = atomicInteger.get();
            boolean suc = atomicInteger.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }
	// 非现场安全
    private void count() {
        i++;
    }
}

测试结果:
count: 984486
atomic: 1000000
time: 130

从Java1.5开始,JDK的并发包里提供一些类来支持原子操作,如AtomicBoolean(原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新long值).

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

  • ABA问题

    因为CAS需要在操作值的时候,检查内存值与旧预期值是否一致,如果内存值没有发生了变化,则更新. 但是如果是内存值原来是A,变成了B,又变回了A.那么通过CAS机性能检查的时候会发现它的值没有发生变化.但是实际上却变化了.

    ABA问题的解决思路: 使用版本号,在变量前面追加版本号,每次更新的时候就把版本号加1.那么A->B->A就变成了1A->2B->3A.

    从Java1.5开始,JDK的Atomic包提供了一个类 AtomicStampedReference 来解决ABA问题. 这个类的compareAndSet方法的作用就 是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值 1

    public boolean compareAndSet(v expectedReference, // 预期引用
                                 V newReference,// 更新后的引用
                                int expectedStamp,//预期标志
                                int newStamp //更新后的标志
                                ){
          
    }
  • 循环时间长开销大

    自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销. 如果 JVM能支持处理器提供的 pause 指令, 那么 效率会有一定的提升(这句话意味着啥,如果那么,表否定,或者理想吧.是否JVM不支持呢)

    pause指令有两个作用:

    • 可以延迟流水线执行指令,使得CPU不会消耗过多的执行资源,延迟的时间取决于具体的版本
    • 避免在退出循环的时候因顺序冲突而引起CPU流水线被清空,从而提供CPU的执行效率
  • 只能保证一个共享变量的原子操作

    当对一个共享变量执行操作时,可以使用循环CAS的方式来保证原子操作.但是对多个共享变量操作时,循环CAS就无法保证操作的原子性了, 第一种办法可以用锁.

    另外一种办法就是通过将多个共享变量合成一个对象,通过JDK5提供的 AtomicReference 类来保证引用对象之间的原子性.就可以把多个变量放在一个对象中进行CAS操作.

(3)使用锁机制来实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域. JVM内部实现了很多种锁机制: 偏向锁、轻量级锁和互斥锁.

除了偏向锁, JVM实现锁的方式都是使用了循环CAS, 即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁.

致谢

Java并发编程艺术

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章