多线程中的同步与锁
对于顺序一致性模型,指令重排序和三级缓存的概念请参考我的上篇文章 Java之多线程 ,这篇文章主要讲述的就是多线程中同步和锁的问题。
volatile
volatile主要有2个功能,一个是保证变量的内存可见性,另一个就是禁止变量与普通变量的指令重排序。而JVM是怎么禁止进行重排序的呢?这里就不得不提到一个概念:内存屏障 。
什么是内存屏障?从硬件方面来说分为两种,一种是读屏障,一种是写屏障。作用是,第一阻止屏障两侧指令重排序,第二是强制将高速缓冲区中的脏数据刷到主内存中,或者让缓存中相应数据失效,这里的缓存指的是CPU的L1和L2区,JMM插入内存屏障的策略如下:
1、在每个volatile写操作之前加入StoreStore屏障,保证这个屏障之前的写操作对处理器是可见的。
2、在每个volatile写操作之后加入StoreLoad屏障,保证在这之后的读操作之前,这个写操作对所有处理器都是可见的,同时开销是最大的,需要刷高速缓冲区,清空无效队列,大多数处理器中这个屏障是万能屏障,兼具其他三种内存屏障的功能。
3、在每个volatile读操作之后加入LoadLoad屏障,保证上面的读在屏障下面的读之前完成。
4、在每个volatile读操作之后再加入LoadStore屏障,保证屏障之后的读写操作一定在屏障之前读操作完成之后进行。
从重排序的角度来说就是:
1、如果一个操作是volatile读,那么无论第二个操作是什么都不能重排序。
2、如果第二个操作是volatile写,那么无论第一个操作是什么都不能重排序。
3、如果第一个操作是volatile写,第二个操作是volatile读,那么不能重排序。
Sychronized
谈到这个关键字。首先我们要明确的是,Java中的锁都是对象锁,而常听到的类锁其实就是Class对象锁,每个Class对象会有多个实例对象,每个实例对象都共享这个Class对象。
Sychronized通常会以一下三种形式使用:
1、加在实例方法上,锁为当前实例
public synchronized void test_1(){
//code
}
2、加在静态方法上,锁为当前Class对象
public static synchronized void test_2(){
//code
}
3、加在代码块中,锁为括号中对象
public void test_3(){
Object o = new Object();
synchronized (o){
//code
}
}
由上面例子我们可知两种等价的写法。
1、锁为当前实例,等价写法
public synchronized void test_1(){
//code
}
public void test_4(){
synchronized (this){
//code
}
}
2、锁为Class对象,等价写法
public static synchronized void test_2(){
//code
}
public void test_5(){
synchronized (this.getClass()){
//code
}
}
锁
Java6之前只有重量级锁,Java6之后为了减少获得和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在Java中共有四种锁状态。1、无锁状态。2、偏向锁。3、轻量级锁。4、重量级锁。依次就是锁升级,同时也有锁降级,在HotSpot虚拟机中锁降级的条件比较苛刻,需要程序在STW状态下且JVM进入安全点的时候会考虑锁降级。
在讲锁升级之前,需要先讲一下Java对象的结构
在Java中,每个对象都有对象头,非数组对象,用两个字宽存储对象头,数组对象用三个字宽存储对象头,因为需要额外一个字宽存储数组长度。两个字宽中,一个存储对象的Mark Word,用于存储hashcode,锁信息等,如上图,另一个字宽用于存储类信息在元空间的地址指针。在64位虚拟机中一个字宽为64位。
由上图我们可以看到,当对象状态为偏向锁的时候,Mark Word存储的是偏向的线程id,状态为轻量级锁的时候存储的是只想县城占中Lock Record的指针,状态为重量级锁的时候存储的是指向互斥量(重量级锁)的指针。下面我将依次详细的解释每个锁。
偏向锁
HotSpot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争情况,而且总是一个线程多次获得,所以引入偏向锁,这个偏向锁就是偏向第一个访问的线程,记录下来,如果没有其他线程竞争的话,就一直是偏向锁,那么就不需要CAS操作(CAS在后面会着重讲述),会提高程序的运行性能。
实现过程就是,一个线程进入到同步块的时候会对对象头和栈帧的Lock Record中存储锁的偏向线程id,当下次该线程进入同步代码块的时候会去检查锁的Mark Word里面是不是放的同样的线程id,如果是那么就不需要进行锁升级,如果不是就需要判断是否需要进行锁升级。
当不同线程竞争锁的时候会尝试用CAS去替换掉Mark Word中的线程id,如果成功表示之前的线程不在了,那么锁就不需要升级了,还是偏向锁,不成功,就表示存在竞争,就需要进入抢占模式,先暂停之前的线程,然后设置锁的标志位为00,标识轻量级锁,然后按照轻量级锁的方式竞争锁。偏向锁升级为轻量级锁的过程大致如下:
1、在一个安全点(在这个时间没有字节码正在执行)暂停拥有锁的线程。
2、遍历线程栈,如果存在锁记录的话需要修复锁记录和Mark Word,使其变成无锁状态。
3、唤醒停止的线程,将锁升级为轻量级锁。
轻量级锁
JVM会为每个线程在自己的线程栈中创建一个锁记录,我们称之为Dispatch Mark Word,如果一个线程获得锁的时候发现是轻量级锁,那么就将Mark Word复制给Dispatch Mark Word,然后尝试将锁的Mark Word通过CAS修改成自己的锁记录的指针,如果成功,说明竞争到了锁,如果失败,说明有别的线程竞争到了锁,那么就进入自旋状态。
自旋是比较消耗CPU资源的,如果一直获取不到锁,一直自旋会白白浪费CPU资源,所以JDK采用了自适应自旋的方式,就是如果这个线程自旋成功了,那么就认为下次轮到你的机会很高,就增多自旋的次数,如果很长时间自旋都失败,就减少次数,早点进入阻塞,以免白白的浪费CPU资源。线程阻塞那么锁就升级为重量级锁,同时释放锁并唤醒阻塞的线程。
重量级锁
重量级锁是依赖于操作系统的互斥量(在同一时刻,只能有一个线程获得互斥锁)来实现的,这样就需要操作系统在用户态和内核态不断的切换,转换需要比较长的时间,所以重量级锁效率非常低,而且非常消耗CPU。
当一个线程尝试获得锁的时候,如果锁被占用,那么就把当前线程封装成一个等待着,放入Contention List 的队首,也就是竞争队列,然后挂起当前线程,当锁释放的时候会从Contention List 或 Entry List(有资格成为候选线程的List)中唤醒一个线程,通过自旋尝试获得锁。如果线程调用Wait方法,那么就进入等待队列,等待notify之后进入Contention List 或 Entry List中。如果调用一个锁对象的wait方法或notify方法的话,锁会先膨胀成重量级锁。
下面再简单的概述一下整个锁升级的过程:
1、每一个线程获取公共资源的时候,都先看看Mark Word里面存放的是不是自己的线程id。如果是,那就是偏向锁。
2、如果不是自己的线程id,用CAS进行切换,根据Mark Word中的线程id通知该线程暂停,然后清空Mark Word内容。
3、两个线程都将锁对象的hashcode复制到自己的锁记录空间,接着通过CAS操作,通过将Mark Word中的内容修改成自己的锁记录空间的地址的方式竞争锁。
4、第三步成功执行CAS的获得资源,其他线程进入自旋。
5、自旋的过程中成功竞争到资源,那么一直处于轻量级锁的状态,如果自旋失败。进入第六步
6、进入重量级锁的状态,这个时候自旋的线程阻塞,等待之前的线程执行完成并唤醒自己。
乐观锁与悲观锁
在叙述CAS之前,我们先说两个锁概念。
乐观锁: 假设对共享资源的访问总是没有冲突,线程可以不断的执行,无需加锁,无需等待,一旦多线程发生冲突,大多采用CAS技术来保证线程执行的安全性。多用于读多写少的场景,避免频繁加锁影响性能。
悲观锁: 假设对共享资源的访问每次都会发生冲突,所以对每次数据操作都需要上锁,以保证临界区在同一时刻只能有一个线程执行。多用于写多读少的情况,避免频繁失败和重试影响性能。
CAS
CAS的全称是比较并交换(Compare and Swap),其中有三个值。
V:要更新的变量;E:旧值;N:新值;
每次进行CAS的时候都需要比较V和E是否相等,如果相等说明没有其他线程对其进行修改,那就把N赋值给E,如果不相等,说明有其他线程对其进行修改了,那么就CAS失败。并且因为CAS是一种原子操作,是CPU的一条原子指令,所以不存在我判断两个值相等的时候其他线程对其修改的情况。
那么Java是怎么使用CAS的呢?
在包sun.misc中有Unsafe类,里面有一些相关CAS的方法。
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
native方法都是底层用C++实现的,具体实现和操作系统和CPU都有关系。在LinuxX86下主要通过cmpxchgl这个指令在CPU级完成CAS操作的,在多数情况下都需要加Lock实现。
接下来对Java中一个原子操作类进行源码简析,来分析一下原子操作的作用。
AtomicInteger
通过该类的getAndAdd方法来看Java是如何实现原子操作的。
public final int getAndSet(int newValue) {
//这里调用了unsafe对象中的getAndSetInt方法
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
//下面是AtomicInteger类中的静态常量和静态代码块
private static final long valueOffset;
static {
try {
//这里就能看出是计算出value在这个类中的偏移量,而不是具体的对象中的偏移量,属于相对偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
public final int getAndSetInt(Object o, long offset, int delta) {
//定义一个int v就是要更新的变量
int v;
do {
//一个do -while循环通过对象和对象偏移量得到这个变量的值
v = this.getIntVolatile(o, offset);
} while(!this.compareAndSwapInt(o, offset, v, delta));//一直做比较交换,直到成功
//返回结果
return v;
}
至于为什么把赋值v的语句放在循环体内,这个问题其实很好理解,因为如果其他线程修改了值,此时肯定要拿到新值做后续比对才可以,要不然一直是旧址可能永远都不会成功了。但是上面的代码还是存在一个经典的问题就是ABA问题。
CAS实现原子操作的三大问题
ABA问题
ABA问题简单来说就是把A改成了B,又将B改成了A,这样CAS是没有发现变化的,然后就更新了。ABA问题的解决思路就是增加版本号或者时间戳控制,如atomic包中提供了AtomicStampedReference类,我们来看一下它的源码实现。
//这是一个私有的静态类,就是对数据进行封装,内包含引用和标识,内部操作都是基于Pair类
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
//生成一个current pair
Pair<V> current = pair;
return
//旧引用等于当前引用
expectedReference == current.reference &&
//旧标识等于当前标识
expectedStamp == current.stamp &&
//值相等或者CAS成功就返回
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
循环时间长开销大
我们知道CAS多与自旋结合,自旋不成功就一直占用CPU资源,解决思路是让JVM支持处理器提供的pause指令 ,他能让自旋失败时CPU睡眠一小段时间再继续自旋,并且能降低读操作的频率。
只能保证一个共享变量的原子操作
第一种解决方案:用AtomicStampedReference包装;
第二章解决方案:加锁,使用临界区,保证临界区只能当前线程操作;
参考文献《深入浅出Java多线程》