本篇Blog我们来学习下Java的底层对并发是如何支持的,也就是Java底层的并发机制到底是什么样的?在JVM系列的Blog我们知道,Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。

共享变量和资源

什么是共享资源和变量,在JVM模型中来说,就是JVM的堆和⽅法区,这部分内容是所有线程共享的区域:

  • 是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
  • ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的内容。在上一篇BLOG中我们阐述了共享变量可变可能引发的问题,可以通过Java底层机制解决:

  • Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
  • Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的
  • 原子操作,如果操作是原子的,那么每次线程执行的就是不可中断的一组指令,在次过程中当然是不可变的。

本篇Blog我们就来看看Java底层如何解决共享资源的同步访问问题。

Java 并发支付锁 java并发机制是什么_共享变量

volatile关键字

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。可见性就是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度

volatile的内存语义

正是因为有了volatile读写的内存语义,才能保证volatile的可见性和禁止指令重排,其读写内存语义如下:

  • volatile写的内存语义如下。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile读的内存语义如下。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

对volatile写和volatile读的内存语义做个总结。

  1. 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  2. 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  3. 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

整体效果如下:

Java 并发支付锁 java并发机制是什么_Java并发_02

volatile可见性保障

了解了volatile的内存语义,我们来看下volatile是如何实现共享变量可见性保证的。

共享变量内存不可见性

在JMM内存模型中,分为主内存线程独立的工作内存,Java内存模型规定,

  • 对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),
  • 线程只能访问自己的工作内存,不可以访问其它线程的工作内存。
  • 工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。

如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,定义了8种原子操作:

  1. lock,将主内存中的变量锁定,为一个线程所独占
  2. read,将主内存中的变量值读到工作内存当中
  3. load,将read读取的值保存到工作内存中的变量副本中。
  4. use,将值传递给线程的代码执行引擎
  5. assign,将执行引擎处理返回的值重新赋值给变量副本
  6. store,将变量副本的值存储到主内存中。
  7. write,将store存储的值写入到主内存的共享变量当中。
  8. unclock,将lock加的锁定解除,此时其它的线程可以有机会访问此变量

通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。为了提高处理速度,处理器不直接和内存进行通信而是和缓存交互

  • 写命中:当一个线程对共享变量执行完操作将值回写到主内存的时候,如果发现存在缓存行,则直接回写到缓存行后再进行回写主内存操作,但该操作执行时间不可预期
  • 缓存命中:当一个线程对共享变量操作的时候,发现缓存中已经存在了该变量,那么不会从主内存中同步,而是使用缓存中的变量值

这两种情况都会导致线程在操作共享变量时使用了过期的数据。这样会引发不可预期的执行错误

内存可见性的重要性

很多时候我们需要一个线程对共享变量的改动,其它线程也需要立即得知这个改动该怎么办呢?下面举两个例子说明内存可见性的重要性:有一个全局的状态变量boolean open=true,这个变量用来描述对一个资源的打开关闭状态,true表示打开,false表示关闭,假设有一个线程A,在执行一些操作后将open修改为false

//线程A
resource.close();
open = false;

线程B随时关注open的状态,当open为true的时候通过访问资源来进行一些操作:

//线程B
while(open) {
doSomethingWithResource(resource);
}

当A把资源关闭的时候,open变量对线程B是不可见的,如果此时open变量的改动尚未同步到线程B的工作内存中,那么线程B就会用一个已经关闭了的资源去做一些操作,因此产生错误。

volatile如何保证可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。那么volatile如何保证可见性?在每次对变量的赋值操作后,都会在赋值操作后多执行一个lock addl $0X0, (%esp)操作,这个操作相当于一个内存屏障volatile能实现依赖两个原则:

  • 写命中破坏:Lock前缀指令会引起处理器缓存立即回写到内存。如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,所以该操作是原子的
  • 缓存命中破坏:一个处理器的缓存回写到内存会导致其他处理器缓存无效,其它线程使用前会从主内存刷新该变量值。该写入动作也会引起别的CPU或者别的内核无效化其Cache,这种操作相当于对Cache中的变量做了一次前边介绍的store和write操作,所以通过这样操作,其它线程使用该变量时发现其Cache已经无效,就会从主内存中更新最新值

因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点,这就是volatile实现可见性的方式。需要注意,volatile虽然能保证可见性,也就是线程间看到的变量都是一致的,但是并不能保证操作的原子性

volatile有序性保障

上一篇Blog提到,程序执行过程中会存在指令重排现象,如果一个操作不是原子的,就会给JVM留下重排的机会,所以对共享变量的读写操作很有可能会指令重排。

指令重排问题

在上一篇Blog中已经讲到,单线程会禁止数据依赖的指令进行重排,但是对于不存在数据依赖的指令允许重排,只要最后执行结果一致,这在单线程中没有问题,但是多线程中就会有问题,多线程举个例子:

public class Singleton {
  private static Singleton instance = null;
  private Singleton() { }
  public static Singleton getInstance() {
     if(instance == null) {
        synchronzied(Singleton.class) {
           if(instance == null) {
               instance = new Singleton();  //非原子操作
           }
        }
     }
     return instance;
   }
}

看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();    //1:分配对象的内存空间 
instance =memory;     //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);  //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错,注意这里volatile阻止的并不是 instance = new Singleton(); //非原子操作的重排序,而是保证了在一个写操作完成之前,不会调用读操作 if(instance == null)

volatile禁止指令重排

除了前面内存可见性中讲到的volatile关键字可以保证变量修改的可见性之外,还有另一个重要的作用:在JDK1.5之后,可以使用volatile变量禁止指令重排序,例如的instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行。同时存在两个对变量的操作的时候,instance =memory; 就是对volatile变量的写,并且在顺序执行里为第二个动作,第一个动作是ctorInstance(memory)

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

那么volatile依靠什么实现的禁止指令重排呢?那就是内存屏障

内存屏障

volatile关键字通过提供内存屏障的方式来防止指令被重排序,为了实现内存屏障的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存
  • 在每个volatile写操作后插入StoreLoad屏障;对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
  • 在每个volatile读操作前插入LoadLoad屏障;对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作后插入LoadStore屏障;对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

如果编译器无法确定后面是否还会有volatile读或者写的时候,为了安全,编译器通常会在这里插入一个StoreLoad屏障,通过以上的保守策略,volatile禁止指令重排,防止执行出现错误。下面是volatile写操作的内存屏障

Java 并发支付锁 java并发机制是什么_共享变量_03


下面是volatile读操作的内存屏障,

Java 并发支付锁 java并发机制是什么_Java并发_04

volatile总结

相对于synchronized块的代码锁,volatile提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

  • volatile变量作用类似于同步变量读写操作,从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
  • volatile不如synchronized安全:在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
  • volatile无法同时保证内存可见性和原子性:加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

不保证原子性

如果运算操作不是原子操作,导致volatile变量的运算在并发下一样是不安全的。依然没法保证volatile同步的正确性。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍需要加锁synchronized或java.util.concurrent中的原子类来保证原子性(如果把一个事务可看作是一个程序,它要么完整的被执行,要么完全不执行。这种特性就叫原子性):

  • 对变量的写入操作不依赖于该变量的当前值(比如a=0;a=a+1的操作,整个流程为a初始化为0,将a的值在0的基础之上加1,然后赋值给a本身,很明显依赖了当前值,即使如果对a的加操作立即对其它线程可见,但是多个线程同时可见,同时更新,会导致在大量循环中的a++达不到预期的值,例如循环100次,值最终更新为75),或者确保只有单一线程修改变量。
  • 该变量不会与其他状态变量纳入不变性条件中。(当变量本身是不可变时,volatile能保证安全访问,比如双重判断的单例模式。但一旦其他状态变量参杂进来的时候,并发情况就无法预知,正确性也无法保障)不变式就是a>5,如果a>b,b是个变量,就不能保证了。

也就是在原子操作时volatile并不能百分百保证。

使用建议

使用volatile时需要注意:

  1. 在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
  2. 由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字

对于volatile不能解决的方案,可以使用锁来实现。

性能评价

某些情况下volatile的同步机制比锁要好,但很难量化这种优势。volatile自己和自己比较,它的读操作的性能消耗和普通变量几乎没啥区别,但写操作要慢一些,因为需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。即便如此,在大多数场景下,volatile总开销仍然比锁要低,根据volatile语义是否满足场景来选择。如果情况不合适,就使用传统的synchronized关键字同步共享变量的访问,用来保证程序正确性(这个关键字的性能会随着jvm不断完善而不断提升,将来性能会慢慢逼近volatile)。

synchronized关键字

synchronized,我们谓之锁,主要用来给方法、代码块加锁。当某个方法或者代码块使用synchronized时,那么在同一时刻至多仅有有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。具体表现形式有三种。

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

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。在指令层面,同步方法块和同步方法是使用monitorentermonitorexit指令实现的, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

synchronized的实现

通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。如:

public synchronized void getResult();

synchronized关键字表明该方法已加锁,在任一线程在访问改方法时都必须要判断该方法是否有其他线程在“独占”。每个类实例对应一个把锁,每个synchronized方法都必须调用该方法的类实例的锁方能执行,否则所属线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,被阻塞的线程方能获得该锁。

synchronized代码块所起到的作用和synchronized方法一样,只不过它使临界区变的尽可能短了,换句话说:它只把需要的共享数据保护起来,其余的长代码块留出此操作,语法如下:

synchronized(object) {  
    //允许访问控制的代码  
}

如果我们需要以这种方式来使用synchronized关键字,那么必须要通过一个对象引用来作为参数,通常这个参数我们常使用为this.

synchronized (this) {
    //允许访问控制的代码 
}

对于synchronized(this)有如下理解:

  1. 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
  2. 当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其他synchronized(this)同步代码块得访问将被阻塞。
  3. 当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问object中的非synchronized(this)同步代码块。

接下来分别举例说明同步方法和同步代码块的使用:

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

package com.company;
public class ThreadTest {
    public static void main(String[] args) throws InterruptedException {
        for(int i = 0 ; i < 5 ; i++){
            new Thread(new Runner(),"Thread_" + i).start();
        }
    }
}

class Runner implements Runnable {
    public void run(){
        runInfo();
    }
    public synchronized void runInfo(){
        for (int i = 0; i < 3; i++) {
            System.out.println("Runner Thread----- :" + Thread.currentThread().getName());
        }
    }
}

运行结果

Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_2
Runner Thread----- :Thread_0
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4

这个结果与我们预期的结果有点不同(这些线程在这里乱跑),照理来说,run方法加上synchronized关键字后,会产生同步效果,这些线程应该是一个接着一个执行run方法的。在上面提到,一个成员方法加上synchronized关键字后,实际上就是给这个成员方法加上锁,具体点就是以这个成员方法所在的对象本身作为对象锁。但是在这个实例当中我们一共new了5个ThreadTest对象,那个每个线程都会持有自己线程对象的对象锁,这必定不能产生同步的效果。所以:如果要对这些线程进行同步,那么这些线程所持有的对象锁应当是共享且唯一的!

package com.company;

public class ThreadTest {
    public static void main(String[] args)  {
        Runner runner=new Runner();
        for(int i = 0 ; i < 5 ; i++){
            new Thread(runner,"Thread_" + i).start();
        }
    }
}

class Runner implements Runnable {
    public void run(){
        runInfo();
    }
    public synchronized void  runInfo(){
        for (int i = 0; i < 3; i++) {
            System.out.println("Runner Thread----- :" + Thread.currentThread().getName());
        }
    }
}

运行结果

Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4

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

package com.company;
public class ThreadTest {
    private static final Object MONITOR = new Object();
    public static void main(String[] args)  {
        for(int i = 0 ; i < 5 ; i++){
           new Thread(() -> {
                synchronized (MONITOR) {
                    for (int j = 0; j < 3; j++) {
                        System.out.println("Runner Thread----- :" + Thread.currentThread().getName());
                    }
                }
            },"Thread_" + i).start();

        }

    }

}

运行结果如下:

Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1

我们创建了一个MONITOR对象 ,对象锁是唯一且共享的。线程同步!在这里synchronized锁住的就是MONITOR对象

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

package com.company;
public class ThreadTest {
    public static void main(String[] args) throws InterruptedException {
        for(int i = 0 ; i < 5 ; i++){
            new Thread(new Runner(),"Thread_" + i).start();
        }
    }
}

class Runner implements Runnable {
    public void run(){
        runInfo();
    }
    public static synchronized void runInfo(){
        for (int i = 0; i < 3; i++) {
            System.out.println("Runner Thread----- :" + Thread.currentThread().getName());
        }
    }
}

运行结果如下

Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_0
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_4
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_3
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_2
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1
Runner Thread----- :Thread_1

在这个实例中,run方法使用的是一个同步方法,而且是static的同步方法,那么这里synchronized锁的又是什么呢?我们知道static超脱于对象之外,它属于类级别的。所以,对象锁就是该静态放发所在的类的Class实例。由于在JVM中,所有被加载的类都有唯一的类对象,在该实例当中就是唯一的 Runner类实例。不管我们创建了该类的多少实例,但是它的类实例仍然是一个!所以对象锁是唯一且共享的。线程同步!

锁的内存语义

了解了synchronized的实现之后,我们想知道它底层到底采取了什么机制保障了这样的实现,我们从锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

下面对锁释放和锁获取的内存语义做个总结。

  1. 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  2. 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  3. 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

了解了锁的内存语义后我们再来看看锁到底存在哪里,怎么优化和转换。

1. 获得同步锁;
2. 清空工作内存;
3. 从主内存拷贝对象副本到工作内存;
4. 执行代码(计算或者输出等);
5. 刷新主内存数据;
6. 释放同步锁

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键

  • 锁的释放,公平锁和非公平锁释放时,最后都要写一个volatile变量state。
  • 公平锁获取时,首先会去读volatile变量。非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

锁释放-获取的内存语义的实现至少有下面两种方式。

  • 利用volatile变量的写-读所具有的内存语义。
  • 利用CAS所附带的volatile读和volatile写的内存语义

而在临界区执行前,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的。

Java 并发支付锁 java并发机制是什么_volatile_05


实际上在指令级我们可以看到:synchronized 同步语句块的实现使⽤的是 monitorentermonitorexit 指令,其中 monitorenter指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌

Java对象头

那么锁到底存在哪里呢?锁里面会存储什么信息呢?synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,在JVM系列的Blog中我们介绍过Java对象头的组成形式:HotSpot虚拟机的对象头包括两部分信息,分别是Mark Word和类型指针(klass pointer),锁就存储在Mark Word中:

Java 并发支付锁 java并发机制是什么_volatile_06


各部分的含义如下:

  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

当然还有32位虚拟机的布局,该布局组成元素同64位相同,只是占用大小略有不同

Java 并发支付锁 java并发机制是什么_volatile_07

锁的升级与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁轻量级锁,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

设置锁的操作

我们给对象设置锁时,使用的方式是CAS(Compare and Swap)比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。传入两个参数,旧值(期望操作前的值)和新值,执行时会比较旧值是否和给定的数值一致,如果一致则修改为新值,不一致则不修改新值

偏向锁

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

偏向锁加锁

偏向锁的加锁流程如下,主要就是检测对象头中的偏向锁信息。

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

以上就是偏向锁的加锁流程。

偏向锁撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)才会执行

  1. 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
  2. 如果线程不处于活动状态,则将对象头设置成无锁状态,然后重新偏向其它线程
  3. 如果线程仍然活动着,检查该对象的使用情况
    1. 如果仍然需要持有偏向锁,也就是产生了竞争,则偏向锁升级为轻量级锁
    2. 如果不需要持有偏向锁,则重新变为无锁状态,然后重新偏向新的线程,本线程偏向锁撤销。
  4. 最后唤醒暂停的线程。

偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态

Java 并发支付锁 java并发机制是什么_共享变量_08

关闭偏向锁

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

轻量级锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word

  1. 轻量级加锁时,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
  2. 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

解锁失败会导致膨胀

Java 并发支付锁 java并发机制是什么_synchronized_09


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

三种锁对比

以下是偏向锁、轻量级锁以及重量级锁三者之间的优缺点和使用场景。

Java 并发支付锁 java并发机制是什么_Java并发_10


我们也可以按照时间线的顺序来看待这三个锁的状态变化

  • 成为偏向锁
  • 升级为轻量级锁,一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态
  • 膨胀为重量级锁,轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

以上就是锁状态的切换过程

原子操作

原子操作(atomic operation)意为不可被中断的一个或一系列操作,在多线程中实现原子操作较为复杂。处理器使用基于对缓存加锁总线加锁的方式来实现多处理器之间的原子操作。

  • 首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。
  • Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问

但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

处理器如何保证操作原子性

处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2

Java 并发支付锁 java并发机制是什么_volatile_11


原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

使用缓存锁保证原子性

第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,

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

当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行,但是有两种情况下处理器不会使用缓存锁定。

  • 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
  • 第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现

Java如何保证操作原子性

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

使用循环CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count

package com.company;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadTest {
    private AtomicInteger atomicI = new AtomicInteger(0);  //安全计数器共享变量atomicI初始化值0
    private int a = 0;     //安全计数器共享变量a初始化值0
    public static void main(String[] args) {
        final ThreadTest cas = new ThreadTest();
        List<Thread> ts = new ArrayList<>(600);
        //开启100个线程,每个线程执行10000次,总计执行一百万次
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    cas.count();
                    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("count "+cas.a);
        System.out.println("safecount "+cas.atomicI.get());
    }
    /** * 使用CAS实现线程安全计数器 */
    private void safeCount() {
        for (;;) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }
    /**
     * 非线程安全计数器
     */
    private void count() {
        a++;
    }

}

执行结果如下,可以看到安全执行的原子操作刚好符合预期。

count 987753
safecount 1000000

可以看看该方法操作的源码:

/**
     * Atomically sets the value to the given updated value
     * if the current value {@code ==} the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

继续向下钻取查看:

//预期引用
 private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

这里还有个比较有意思的是继续钻取查看,发现该类定义的变量为volatile ,就是为了满足共享变量的可见性。

private volatile int value;
CAS的问题

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

  • ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
  • 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销
  • 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用

虽然有折中解决的办法,例如循环开销大可以使用处理器的指令pause,只能保证一个共享变量原子操作可以考虑把多个共享变量合并成一个共享变量来操作。但最好的解决方式还是使用锁

使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁

总结

本篇Blog从并发编程三大特性出发,探索了Java底层对于该三个原则的满足方式,分别讨论了volatile关键字和synchronized关键字从底层对Java并发的支持。这里只是做了一个大概的理解,很多概念在深入理解了JMM底层的内存机制后再进行详细阐述。