深入理解Java虚拟机之——高并发原理

声明:原创作品,转载请注明出处https://www.jianshu.com/p/a7c86cd45eac

今天来说下Java虚拟机的高并发问题,在讲之前首先需要明白什么是并发,说到并发有人还会联想到并行,那么他们到底是什么,有什么区别呢?这里用一个例子来解释下:假如你现在正在吃饭,这时来了一个电话,你吃完饭再去接电话,说明你不支持并发也不支持并行。如果你放下筷子去接电话,接完再接着吃,说明你支持并发,如果你边吃饭边接电话,说明你支持并行。可以看到并发和并行都是指在处理多任务,两者的区别是是否可以同时进行,如果是同时进行的,那么就是并行,否则就是并发。当然这是一种狭义上的定义,广义上来讲,并发指的就是多任务处理,并行是其中的一个特例。本文所讲的并发就是这个概念。

并发产生的问题

接下来我们看下计算机在并发情况下会有什么问题。我们知道计算机的处理器运算速度是很快的,但是它在与内存交互比如读取计算数据,存储计算结果时是很慢,两者差了几个数量级。为了解决这个问题,现在的计算机都会在处理器和内存之间加入一层临时缓冲,这个临时缓冲数据读取速度和处理器计算速度差不多,每次运算时从主内存中加载数据到临时缓冲,计算完后再把缓冲中的数据写回主内存。这就很好解决了两边速度不匹配的问题,但是这又引入了一个新的问题。

并发产生的问题

如上图所示,假如该计算机中有两个处理器A、B,主内存有个变量i,初始值为0,每个处理器和主内存之间有个高速缓存,处理器A运算i++的指令,处理器B运算i=i+2的指令,处理器运算时会先从主内存把变量i加载到对应的缓冲中,计算完成后再把计算结果写回主内存。处理器B也是同理。理论上经过处理器A、B的运算后这个主内存中的i会变成3。但是会出现这么一种情况,就是当处理器A计算完i++后,i变成1,此时i还在缓冲中没写入主内存,也就是说现在主内存的值依然是0,但是就在这时处理器B开始运算,把主内存的i=0读取到缓冲然后计算i = i+2,此时处理器B计算后i的值为2,然后把i = 2写回主内存,此时主内存中i的值变为2,处理器B写回主内存后,处理器A这时也把之前的计算结果,也就是处理器A中的缓冲中的i= 1又写回主内存,结果主内存中i的值就从之前的2变成1了,可以看到与我们预期的值不符。这正是并发所产生的问题。我们称这个问题为缓冲一致性(Cache Coherence)问题。在计算机中,为了解决刚才的缓冲一致性问题,处理器在访问内存时都需要遵循一些协议,比如MSI、MESI、MOSI等,我们称之为缓存一致性协议,如下图所示,当然这些协议不是本文的重点,有兴趣的同学可以查看相关文档来了解。
计算机并发模型

Java虚拟机并发模型

上面我们分析了计算机在并发情况下,处理器,临时缓存,主内存之间的关系,同理在虚拟机中也对应着这么一套模型,如下所示:


Java虚拟机并发

这里可以看到Java虚拟机并发的问题其实就是解决好线程,工作内存,和主内存之间的关系,同理每个线程在访问数据的时候需要遵循一些协议,比如这里的Save和Load等操作(下文会提到)。这里的主内存和工作内存和Java虚拟机中内存区域堆、栈、方法区等不是同一个层次,为了便于理解你可以勉强理解为主内存对应Java堆中的对象实例数据部分,工作内存对应虚拟机栈中的部分区域。
接下来我们来详细看下上述中的交互协议,也就是Save和Load操作。这个交互协议是规定了一个变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存之类的细节,虚拟机定义了8种操作来完成这一目的,这个操作如下:lock、unlock、read、load、use、assign、store、write,我们依次看下他们的作用:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
    知道了这几个操作,我们再来看下上面提到的,要想把一个变量从主内存复制到工作内存中来,就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是说read和load之间、store和write之间是可以插入其他指令的。如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此以外Java虚拟机还规定了在执行上述八种基本操作时必须满足如下规则:
  • 不允许read和load、store和write的操作之一单独出现,即不允许一个变量从主内存读取了但是工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不予许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,对此执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)

volatile在并发中的作用

接下来来说下volatile在并发中有什么作用,说到 volatile我们首先了解下Java虚拟机并发机制下的几个特性:原子性、可见性和有序性。可以这么说,Java虚拟机的并发问题就是围绕这几个特性展开来的。我们依次来看下这几个特性的定义:

  • 原子性:指的是一个不可被拆分的操作,在Java多线程中,一个原子操作指的是,如果一个操作正在被某个线程执行,它不会被其他线程打断。比如上面介绍的read、load、assign、use、store和write这六个操作都是原子性的。
  • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
  • 有序性:在计算机中,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。在单线程中这样的指令重排序并不会带来问题,但是在多线程中就会出现问题。
    指令重排序在多线程中的问题

    如上图所示(截取自深入理解Java虚拟机一书),有两个线程A和B,线程A做些初始化配置的工作,初始化成功后将initialized设置成true,线程B一直在循环判断这个initialized变量,如果为true说明初始化成功,可以接着执行下面的工作。但是线程A由于指令重排序的问题导致
    initialized = true这句语句被提前执行,这样导致了线程B中还没等初始化完成就直接执行doSomethingWithConfig();方法,自然程序就会出异常。
    在多线程中,volatile的作用主要是保证可见性和有序性。来具体看下volatile是如何保证这两点的。
    volatile的可见性保证:
    在文章开头中我们提到,在多线程中,一个线程A对一个主内存的普通变量做了修改,另一个线程B访问这个变量时,可能获取的值还是之前的值,因为线程A修改的值还没及时写回主内存中,那么这时这个变量对线程B是不可见的。但是如果用volatile修饰这个变量时,就不会出现这个问题。那么volatile是如何保证这点的呢?其实很简单,就是Java规定某个线程要访问用volatile修饰的变量,每次执行use操作前都需要执行load和read,这样就可以保证这个线程访问到的是这个变量的最新值,每次执行assign后,都需要立刻执行store和write操作来把修改后的值同步到主内存中,这样就保证了其他线程访问这个变量是最新的值。
    volatile的有序性的保证:
    如果有两个线程A、B,线程A先于线程B对一个volatile修饰的变量执行use或assign操作,那么对应线程A的read或write也要先于线程B。这条规则保证volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。

先行发生原则

上面我们知道volatile可以保证有序性,那么除此之外这个有序性还有其他方式可以保障吗,或者说这个重排序有什么规则可循呢?答案是肯定的,这个规则就是先行发生原则,如果两个操作没有遵循这个原则那么虚拟机就可以对它们进行随意的重排序。接下来我们来具体看下这个先行发生原则:
首先我们来解释下什么是先行发生,先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被B观察到,这里的影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。举个例子:

// 线程A
i = 1;
// 线程B
j = i;
// 线程C
i = 2;

假设线程A中的操作i = 1先行发生于线程B的操作就j = i,那我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,得出这个结论的依据有两个,一是根据先行发生原则,i = 1的结果可以被观察到;二是线程C登场之前,线程A操作结束之后没有其他线程会修改变量i的值。现在再来考虑线程C,我们依然保持线程A和B之间的先行发生关系,而线程C出现在线程A和线程B的操作之间,但是线程C与线程B没有先行发生关系,那j的值会是多少?答案是不确定,1和2都有可能,因为线程C对变量i的影响可能会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。
接下来看下具体的先行发生原则:

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流的顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而后面是指时间上的先后顺序。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样是指时间上的先后顺序。
  • 线程启动规则:Thread对象的start方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则:对线程interrupt()方法调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生与操作C的结论。
    接下来举一个例子来说明这个规则在多线程中扮演的作用。
private int value  = 0;
public void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}

上面的代码很简单,定义了一个value字段,然后定义这个字段的set和get方法。假设存在线层A和线程B,线程A先调用setValue(1)方法,即给value赋值为1,然后线程B调用这个getValue方法,那么此时线程B得到的返回值是什么?答案是0和1都有可能,为什么会出现这种情况,我们用先行发生原则来看下。
由于两个方法分别有线程A和线程B调用,不在一个线程中,所以这里没有程序次序规则;由于没有同步块即没有加锁,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里没有关系。因此没有一个适用的先行发生规则,虚拟机在处理set和get时,即使set方法在时间顺序上先于get执行,但由于没有先行发生原则导致指令重排序,就会出现get到的value还是原先的值,不符合我们预期的结果。
我们看到上面的例子在多线程中出现了问题,我们也称之为线程不安全,那么什么是线程安全呢?《Java Concurrency In Practice》的作者Brian Goetz对线程安全做了一个比较恰当的定义:

线程安全:当多线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

由于实现上面的线程安全非常困难,我们把线程安全由强至弱分成五类:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

  • 不可变:不可变的对象一定是线程安全的,不需要加任何的线程安全保障措施,比如用final修饰的对象。
  • 绝对线程安全:绝对线程安全是完全满足Brian Goetz给出的线程安全定义,一个定义非常严格,一个类要想达到这个意义上的线程安全可以说是不切实际的。
  • 相对线程安全:相对线程安全就是我们通常意义上所讲的线程安全,比如Vector、HashTable
  • 线程兼容: 线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全使用,我们通常说一个类不是线程安全的,绝大多数指的这种情况。Java API中大部分的类都是线程兼容的,如前面Vector和HashTable相对应的集合类ArrayList和HashMap等。
  • 线程对立:线程对立是指不管调用端采用何种同步手段都无法在多线程环境中并发使用的代码。这种通常是有害的,应当尽量避免。
    这里你可能会有疑问,为什么我们平时说的线程安全的Vector仅仅是相对线程安全,而不是绝对线程安全。我们来看个例子:
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args){
    while(true){
        for(int i = 0;i<10;i++){
            vector.add(i);
        }
        Thread removeThread = new Thread(new Runnable(){
            @Override
            public void run(){
                for(int i = 0;i<vector.size();i++){
                    vector.remove(i);
                }
            }
        });
        Thread printThread = new Thread(new Runnable(){
            @Override
            public void run(){
                for(int i = 0;i<vector.size();i++){
                   System.out.println((vector.get(i)))
                }
            }
        });
        removeThread.start();
        printThread.start();
    }
}

上面的程序很简单,有一个集合vector,然后同时开启两个线程,一个线程移除一个vector集合中的元素,另一个线程打印集合中的元素。最后运行后会发现程序报角标越界的错误了。因为两个线程同时运行,当printThread要打印的元素正好被removeThread线程移除掉了的话自然就找不到这个元素。

线程安全的实现方法

介绍了什么是线程安全,接下来我们来看下该如何实现线程安全。
实现线程安全有两种方式,一种为互斥同步,另一种为非阻塞同步。先来看下互斥同步:

互斥同步

互斥同步首先来理解下互斥,互斥就是当一段代码正在被一个线程执行时,其他线程都无法进入该代码执行,也就是说一段代码只能被一个线程执行。这样解释可能有点抽象,举个例子,这个代码段可以理解成一个房间,这个房间是锁着的,如果一个人要进入房间,那他必须得持有这个房间的钥匙。当这个人进去后,就把门锁了,别人想进去没钥匙自然就进不去,当这个人出去的时候,就会把钥匙放在门外,这时其他人要想进去就可以用钥匙来开门,同理他进去后其他人也是无法进去的。同样虚拟机中也引入了锁的概念。当一个线程执行一段有共享变量的代码时,就对其加把锁,这时其他线程都无法访问,只有这个线程执行完了才会让其他线程进来执行。
那么Java虚拟机是如何实现上述的锁机制的呢?有两种方式,可以用synchronized和ReetrantLock。
synchronized
先来看下synchronized的用法,当你想把一段代码片锁起来的时候只需要这样:

synchronized(锁对象){
    doSomething()  //需要加锁的代码
}

可以看到简单,只需要在要加锁的代码片外套层synchronized,注意还需要传入一个锁对象,这个对象可以是任意的一个对象,甚至可以是这个类的类对象。
上面是对某段代码片加锁,当然你也可以直接对某个方法加锁。如下

public synchronized void methodName(){
      doSomething()  //需要加锁的代码
}

有人可能说这里是不是没有指定锁对象,其实是有的,这个锁对象就是这个方法所在类的实例对象,如果是静态方法那么这个锁对象就是类对象。
那么synchronized到底是如何实现这种锁机制呢?synchronized关键字经过编译后,会在代码块的前后分别形成monitorenter和monitorexit这两个字节码指令,
当虚拟机在执行monitorenter指令时,首先会去尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行monitorexit指令时会将锁计数器减1,当计数器为0时锁就被释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。这里有两点需要注意,第一就是synchronized对同一条线程来说是可重入的。第二,当某个线程还没执行完被synchronized加锁的代码前,其他线程都会被阻塞掉。一个线程的阻塞和唤醒都需要系统进行用户态和核心态的转换,因此synchronized会有点性能损耗。
ReentrantLock
这个和synchronized有点类似,只是代码上写法有点区别:

ReentrantLock lock = new ReentrantLock(); // not a fair lock
lock.lock();
try {
    //  doSomething()
} finally {
    lock.unlock();
}

可以看到这个每次要对代码加锁的时候都需要手动lock下,然后执行完在unlock掉。但是ReentrantLock比synchronized多了一些高级功能,主要有这个三项:等待可中断、可实现公平锁、以及锁可以绑定多个条件,这里就不展开说了。

非阻塞同步

上面我们介绍了阻塞同步方式,也就是说当一个线程正在执行同步代码块时,另一个线程要执行时是进不去的,会阻塞掉,当前一个线程执行完后这个线程才会唤醒,线程频繁的阻塞和唤醒是非常消耗性能的。如果要避免这个问题还可以用非阻塞同步的方式。什么是非阻塞同步呢?就是当一个线程要执行代码时,可以直接执行,如果没有发生数据竞争,那么可以顺利执行,如果发生了数据竞争就不断地重试直到执行成功。那么有个问题就是,判读数据是否发生了竞争,和具体的执行操作一定是原子性的,或者说是同步的,但这里我们不能用上面的阻塞同步,不然就没什么意义了,因为这里要讲的是非阻塞同步。那么有什么方法可以保证这两步是原子性的呢?答案就是硬件自身。硬件保证很多从语义上来讲需要很多步的操作只通过一条处理器指令就可以完成。这些指令常用的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap)
  • 加载链接/条件储存(Load-Linked/Store-Conditional)

当然这里与我们有关系的就是第四条比较并交换(Compare-and-Swap),我们简称CAS指令。CAS指令需要三个参数,第一个是内存地址,第二个是这个内存中的旧值,第三个是要替换这个值的新值。他的作用就是判断这个内存中当前的值是否为我们传入的这个旧值,如果是说明数据没有被改,也就没有发生数据竞争,可以执行第二步也就是把这个值换成我们传入的第三个参数。如果不是,说明这个值被别的线程改了,那么就不断地重试直到可以执行为止。既然这个指令是硬件层的,那我们该怎么用呢?在JDK 1.5 之后,Java程序中才可以使用CAS操作,该操作是由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个包装提供,虚拟机在内部对这些方法做了特殊处理,即使编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进入了。但是Unsafe类是隐藏的类,我们直接访问不到只能通过其他Java API间接使用。如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。接下来举一个具体的例子来说明下:

public class IncreaseTest{
    public static int race = 0;
    public static void increase(){
        race++;
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) throw Exception{
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
            threads[i] = new Thread(new Runnable()){
                @Override
                public void run(){
                    for(int i = 0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount()>1){
            Thread.yield();
        }
        System.out.println(race);
    }
}

上面这段代码很简单,我们定义了一个初始量race = 0,然后定义了20个线程,每个线程对race进行累加1000遍,按理说执行完这段代码,race的值是20000,但是实际执行后会发现这个值都会是小于20000,这个就是典型出现了线程同步问题,简单的分析下,就是某个线程从主内存中读取race这个量,然后执行累加操作,但是还没等这个race量写回主内存,就被打断了,另一个线程来了从主内存中读取原来的值,再执行完累加操后写入了主内存,这是又切换回了之前的线程,这时的线程继续接下来的操作也就是把原来累加完的值写回主内存,但是这样一写回就会覆盖掉刚才中间插进来线程累计后的值,这样程序执行完结果自然就小于期望的值。那么这里就可以用上面的CAS,我们把上面的代码稍微改下:

public class AtomicTest{
    public static AtomicInteger race = new AtomicInteger(0);
    public static void increase(){
        race.incrementAndGet();
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) throw Exception{
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i < THREADS_COUNT; i++){
            threads[i] = new Thread(new Runnable()){
                @Override
                public void run(){
                    for(int i = 0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount()>1){
            Thread.yield();
        }
        System.out.println(race);
    }
}
------
运行结果:
20000

可以看到我们用AtomicInteger代替int后,程序输出了正确的结果,这一切都要归功于incrementAndGet()方法的原子性,它的实现很简单,如下:

public final int getAndIncrement(){
    for(;;){
        int current = get();
        int next = current + 1;
        if (compareAndSet(current,next)){
            return current;
        }
    }
}

可以看到,这是一个for循环,先获取当前的值,然后计算加1后的值,接着就是通过compareAndSet方法进行比较,如果当前的值还是current,说明没有数据竞争就把next替换这个旧值,否则就一直循环直到成功,这里compareAndSet就是对硬件CAS指令的封装,是一个原子操作。
通过这个例子,相信你对CAS有了一个好的理解,当然CAS还会有一个问题,就是如果我判断某个值为A时我就把这个A替换掉,但是存在这么一种情况就是,这个值刚开始为A然后被改成了B,接着又被改成了A,这样用CAS判断时会认为这个值没有发生更改,即没有发生数据竞争。我们称之为ABA问题,J.U.C包为了解决这个问题,提供了一个带标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性,不过目前这个类比较鸡肋,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互质同步可能会比原子类更高效。

无同步方案

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保障共享数据争用的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有些代码天生就是线程安全的,比如可重入代码线程本地存储
可重入代码(Reentrant Code)
这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非说有的线程安全的代码都是可重入的。可通过如下方法来判断代码是否可重入:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
线程本地存储
如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

高效并发——锁优化

上面我们分析了线程并发带来的问题,以及如何解决这些问题,其实虚拟机为了更好的更高效的支持并发,在内部做了很多优化,主要是运用一些锁优化技术,如适应性自旋锁、锁消除、锁粗化、轻量级锁、偏向锁等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
自旋锁与自适应自旋
前面我们提到互斥同步会挂起线程影响性能,如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一会儿,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是自旋锁。
自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:UseSpinning参数来开启,在JDK1.6中就已经改为默认开启了,自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能的浪费。因此自选等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。
在JDK1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另一方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越聪明了。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。那么为什么没有共享数据竞争也存在同步逻辑呢,因为有时候同步操作不是我们程序员自己加的,如下代码:

public String concatString(String s1,String s2,String s3){
    return s1+s2+s3;
}

我们知道由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK 1.5之前,会转化为StringBuffer对象的连续append()操作,在JDK1.5及以后的版本中,会转化为StringBuilder对象的连续append操作。即如下代码:

public String concatString(String s1,String s2,String s3){
    StringBuffer sb  = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

这里的每一个append方法内部都是同步的,由于这些代码都是在一个方法中,所以这里虽然有锁,但是可以被安全地消除掉,在即时编译之后,这段代码会忽略点所有的同步而直接执行了。
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。
大部分情况下,上面的原则是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
轻量级锁
轻量级锁是JDK1.6中加入的新型锁机制,它名字中的轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为重量级锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从HotSpot虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpt虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits,官方称它为Mark Word,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,MarkWord的32个Bits空间中,25Bits用于存储对象哈希码,4Bits用于存储对象分代年龄,2Bit用于存储锁标记位,1Bit固定为0,在其他状态下对象的存储内容如下表:

存储内容 标志位 状态
对象HashCode、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀
空,不需要记录信息 11 GC标记
偏向线程,偏向时间戳、对象分代年龄 01 可偏向

在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的帧栈中建立一个名为锁记录的空间,用于存储锁对象目前的MarkWord的拷贝,这时候线程堆栈与对象头的状态如下图所示:


然后虚拟机将使用CAS操作尝试将对象的MarkWord更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象MarkWord的锁标志位将转变为00,即表示此对象处于轻量级锁定的状态,这时候线程堆栈与对象头的状态如下图所示:

如果这个更新操作失败了,虚拟机首先会检查对象的MarkWord是否指向当前线程的帧栈,如果是就说明当前线程已经拥了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为10,MarkWord中存储的就是指向重量级锁(互斥量的指针),后面等待锁的线程也要进入阻塞状态。
上面描述的轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的MarkWord仍然指向线程的锁记录,那就用CAS操作把对象当前的MarkWord和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提升同步性能的依据是对于绝大部分的锁,在整个同步周期内部都是不存在竞争的这是一个经验数据,如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外产生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
偏向锁
偏向锁也是JDK1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。可通过参数-XX:+UseBiaseLocking来启动偏向锁。
最后用一个锁状态升级的图结下尾:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270