Java并发机制的底层实现
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 共享变量有哪些,在JDK中的位置,1.7和1.8有什么不同
- volatile关键字底层语义如何保证可见性、有序性,为什么保证不了原子性,需要配合什么实现原子性,CAS实现原子性的问题有哪些
- synchronized关键字底层语义如何保证可见性、有序性以及原子性
- synchronized锁是存储在哪的,对象头的结构是什么样的,锁有哪几种,怎么升级
接下来我们看这部分的内容。
共享变量
什么是共享资源和变量,在JVM模型中来说,就是JVM的堆和⽅法区,这部分内容是所有线程共享的区域:
- 堆是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
- ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的内容。共享变量可变可能引发的问题,可以通过Java底层机制解决:
- Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
- Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的
- 原子操作,如果操作是原子的,那么每次线程执行的就是不可中断的一组指令,在次过程中当然是不可变的。
我们来看看Java底层如何解决共享资源的同步访问问题。
volatile关键字
volatile是一种修饰共享变量的轻量级同步方法,它可以保证内存可见性、通过禁止指令重排保证执行的有序性,但是它并不能保证原子性,需要搭配原子类使用,或者直接使用synchronized。
保证内存可见性
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度其读写内存语义如下:
- volatile写的内存语义如下。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- volatile读的内存语义如下。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
通过读写内存语义,可以保证主内存和工作内存的值相互立即同步
禁止指令重排
volatile可以通过使用内存屏障禁止指令重排,单线程会禁止数据依赖的指令进行重排,但是对于不存在数据依赖的指令允许重排,只要最后执行结果一致,这在单线程中没有问题,但是多线程中就会有问题,多线程举个例子:
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)
,instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行。同时存在两个对变量的操作的时候,instance =memory;
就是对volatile变量的写,并且在顺序执行里为第二个动作,第一个动作是ctorInstance(memory)
。
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序
那么volatile依靠什么实现的禁止指令重排呢?那就是内存屏障
不保证原子性
如果运算操作不是原子操作,导致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并不能百分百保证
synchronized关键字
synchronized,我们谓之锁,主要用来给方法、代码块加锁。当某个方法或者代码块使用synchronized时,那么在同一时刻至多仅有有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。具体表现形式有三种。
- 普通同步方法,锁是当前方法所属实例对象。
- 静态同步方法,锁是当前方法所属类的Class对象。
- 同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。在指令层面,同步方法块和同步方法是使用monitorenter和monitorexit指令实现的, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。
我们从锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
synchronized在底层对应的又是什么原理呢,这得从对象头说起。synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,在JVM系列的Blog中我们介绍过Java对象头的组成形式:HotSpot虚拟机的对象头包括两部分信息,分别是Mark Word和类型指针(klass pointer),锁就存储在Mark Word中:
各部分的含义如下:
- 锁标志位(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 SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
设置锁的操作
我们给对象设置锁时,使用的方式是CAS(Compare and Swap)比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。传入两个参数,旧值(期望操作前的值)和新值,执行时会比较旧值是否和给定的数值一致,如果一致则修改为新值,不一致则不修改新值。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁加锁
偏向锁的加锁流程如下,主要就是检测对象头中的偏向锁信息。
- 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID
- 以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁)
- 如果已设置,则尝试使用CAS将对象头的偏向锁指向当前线程
- 如果未设置,则使用CAS竞争锁;
以上就是偏向锁的加锁流程。
偏向锁撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)才会执行
- 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
- 如果线程不处于活动状态,则将对象头设置成无锁状态,然后重新偏向其它线程
- 如果线程仍然活动着,检查该对象的使用情况
1. 如果仍然需要持有偏向锁,也就是产生了竞争,则偏向锁升级为轻量级锁。
2. 如果不需要持有偏向锁,则重新变为无锁状态,然后重新偏向新的线程,本线程偏向锁撤销。 - 最后唤醒暂停的线程。
偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态
关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态
轻量级锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
- 轻量级加锁时,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
- 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
解锁失败会导致膨胀
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
三种锁对比
以下是偏向锁、轻量级锁以及重量级锁三者之间的优缺点和使用场景。
我们也可以按照时间线的顺序来看待这三个锁的状态变化
- 成为偏向锁 ,一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
- 升级为轻量级锁,一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如果是线程1持有锁,且2也要争夺偏向锁,则直接到轻量级锁状态
- 膨胀为重量级锁,轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
以上就是锁状态的切换过程
原子操作的实现和问题
在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释放锁