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

[TOC]

2.1 volatile的应用

2.1.1 volatile实现原理

如果对声明了volatile的变量进行了写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改的时候,会重新从系统内存中把数据读到处理器缓存里。

volatile的两条实现原则

  • Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器里,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存
  • 一个处理器的缓存回写到内存回导致其他处理器的缓存无效。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。

volatile变量具有synchronized的可见性特性,但是不具备原子特性。

正确使用volatile变量的条件

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式(例如start <= end)中。

两个线程同时对volatile变量进行修改时,如果没有对语句进行加锁,那么就有可能会出现修改前满足不变式,而修改后破坏不变式的情况。如下面的代码所示:

/**
 * 如果两个线程同时分别调用setLower(4),setUpper(3)调用后,
 * low, upper的值分别为4, 3,破坏了不变式low <= upper.
 * 这时需要对setLower(), setUpper()使用synchronized关键字,
 * 来保证对lower, upper进行原子性修改。
 */
@NotThreadSafe 
public class NumberRange {
    private volatile int lower, upper;

    public int getLower() { return lower; }
    public int getUpper() { return upper; }

    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }

    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

性能考虑 在某些情形下,使用volatile变量要比使用相应的锁简单得多。其次在某些情况下,volatile变量的同步机制的性能要优于锁。 volatile操作不会像锁一样造成阻塞,因此在能够安全使用volatile的情况下,volatile可以提供一些优于锁的可伸缩特性。如果读操作的次数远远超过写操作,与锁相比,volatile变量通常能够减少同步的性能开销。

2.1.2 volatile的使用优化

Doug Lea在JDK 7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

正确使用volatile的模式

只有在状态真正独立于程序内其他内容时才能使用volatile。

  1. 状态标识
  1. 一次性安全发布(one-time safe publication)

    缺乏同步会导致无法实现可见性 。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由一个线程写入)和该对象状态的旧值同时存在。这是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。 实现安全发布对象的一种技术就是将对象引用定义为volatile类型。

public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;

    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}

public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}

如果theFlooble没有用volatile关键字定义,那么调用者可能会引用到不完整的Flooble对象。

  1. 独立观察(independent observation)

    定期“发布”观察结果供程序内部使用”,比如收集程序的统计信息。

  2. "volatile bean"模式

    volatile bean模式模式适用于将JavaBeans作为“荣誉结构”使用的框架。 volatile bean模式的基本原理是:很多框架为易变数据的持有者(例如HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }

    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }

    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }

    public void setAge(int age) { 
        this.age = age;
    }
}
  1. 开销较低的读-写锁策略

    volatile只保证可见性,没有锁的互斥访问特性,所以volatile不足以实现计数器,++x可能会丢失更新的值(在添加的过程中)。我们可以增加对计数变量的互斥访问,便可以用volatile实现计数器功能。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    private volatile int value;

    public int getValue() { return value; }

    public synchronized int increment() {
        return value++;
    }
}

参考: IBM-developerworks

2.2 synchronized的实现原理及应用

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

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

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

2.2.1 Java对象头

synchronized用的锁是存在于Java对象头里的 。如果对象是数组类型,则虚拟机用三个字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头。

![Java对象头的长度](media/14792922158581.jpg) Java对象头的长度

Java对象头里的Mark World里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark World的默认存储结构如下表所示。

64位存储结构如下:

在运行期间,Mark World里存储的数据会随着锁标志位的变化而变化,Mark World可能变化为存储以下4种数据。

2.2.2 锁的升级与对比

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

偏向锁

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

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。当一个线程试图锁住一个处于biasable并且ThreadID不等于自己ID的对象时,这时由于存在锁竞争,需要撤销偏向锁。

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先 暂停拥有偏向锁的线程 ,然后 检查持有偏向锁的线程是否活着 ,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向锁的Lock Record,栈中的锁记录和对象头的Mark Word要么重新偏向与其他线程,要么恢复到无锁或者标识对象不适合作为偏向锁, 最后唤醒暂停的线程

![偏向锁的初始化流程](media/14793543778614.jpg) 偏向锁的初始化流程

关闭偏向锁

偏向锁在Java里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。可以通过参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

互斥是一种导致线程挂起,并在较短时间内又需要重新调度回原线程的,较为消耗资源的操作。为了优化Java的Lock机制,从Java6开始,引入了轻量级锁的概念。 轻量级锁本意是为了减少多线程进入互斥的几率,并不是要替代互斥。它利用了CPU原语CAS,尝试避免互斥,导致线程阻塞。

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

![](media/14823242618585.jpg)

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

![](media/14793545932951.jpg) 争夺锁导致的锁膨胀流程图

锁的优缺点对比

![](media/14793546855251.jpg) 锁的优缺点的对比

2.3 原子操作的实现原理

术语定义

![](media/14793554516329.jpg) CPU术语定义

处理器如何实现原子操作

32位IA-32处理器使用基于 对缓存加锁总线加锁 的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。 处理器保证系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址

  • 使用总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(比如i++),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一样。处理器使用总线加锁来解决这个问题。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么处理器可以 独占共享内存
  • 使用缓存锁保证原子性。总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以 总线锁定的开销比较大 ,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内缓存中进行,并不需要声明总线锁。

缓存锁定 所谓缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是 修改内部的内存地址 ,并允许它的缓存一致性机制来保证操作的原子性,因为 缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据 ,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

但是有两种情况下处理器不会使用缓存锁定: 第一种情况是,当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器调用总线锁定。 第二种情况是,有些处理器不支持缓存锁定。

Java如何实现原子操作

Java中可以通过循环CAS的方式来实现原子操作。

使用循环CAS实现原子操作 JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是 循环进行CAS操作直到成功为止

在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。CAS虽然很高效地解决了原子操作,但是存在三大问题,ABA问题循环时间长开销大,以及只能保证一个共享变量的原子操作

  • ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值并没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本。从JDK 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题,这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

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

  • 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。从JDK 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

使用锁机制实现原子操作 锁机制保证了 只有获得锁的线程才能够操作锁定的内存区域 。除了偏向锁, JVM实现锁的方式都使用了循环CAS ,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

复习题

基础

死锁的必要条件,用Java代码模拟死锁场景。

synchronizer的基础原理

synchronized关键字的作用 哪些java元素可以使用synchronized关键字,分别表示的意义是什么? 什么是Monitor对象,它的作用是什么?持有和释放它的指令分别是什么? 与Lock接口定义的同步功能有什么区别? synchronized用的锁是存在于什么位置的? java-se里面实现的锁有哪些状态,分别是什么功能?各有什么优缺点? 锁升级的过程是怎样的? 什么是原子操作?在JVM中是怎么实现原子操作的? CPU是如何实现原子操作的? Java是如何实现原子操作的? 什么是ABA问题?Java是怎么解决的?

results matching ""

    No results matching ""