多线程笔记---锁(Synchronized)的优化和种类

通过上一篇文章大致了解了锁 (Synchronized),他最大的特征是在同一时间只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块中执行,其他线程需要在外面等待,表现出一种互斥性。但是这样有一个很明显的的问题,效率低下,那么多线程都在外面等你执行,这时候就需要对锁进行优化,既然一次只能通过一个线程的形式不能改变,那么我们可以对锁进行优化,缩短获取锁的时间。

1.乐观锁和悲观锁

这个问题是面试常客了,”请你简要谈谈乐观锁和悲观锁“相信面试过的人都基本被问过。那么这里就看看这两种锁分别是什么,首先这两种不是具体的锁,而是一种策略。

  • 悲观锁

    顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。具有强烈的独占性和排他性。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java的synchronizedReentrantLock就是悲观锁 Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。 ReenTrantLock可以指定是公平锁还是非公平锁。而Synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
  • 乐观锁

    顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。Java在java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
使用场景

乐观锁适用于写比较少的情况下(多读场景),减少冲突,降低锁的开销,加大系统吞吐量。一旦产生冲突,会导致上层应用不断retry,这样对性能损耗更大,因而乐观锁适用于写比较多的情况下(多写场景)

1.1乐观锁的实现方式

乐观锁一般由版本号机制或CAS算法实现

  • 版本号机制
    一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
  • CAS(compare and swap:变换与比较)算法

CAS是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)

CAS 操作中包含三个操作数

  • 需要读写的内存值 V

  • 进行比较的值 A

  • 拟写入的新值 B CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。(自增不是原子操作)

    使用场景

    在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。例如atomic包中的实现类也几乎都是用CAS实现,下面以AtomicInteger 为例子看看,看一下在不使用锁的情况下是如何保证线程安全的

public class AtomicInteger extends Number implements java.io.Serializable {  
    private volatile int value; 

    public final int get() {  
        return value;  
    }  

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

    public final boolean compareAndSet(int expect, int update) {  
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);  
    }  
}

复制代码
  1. 在没有锁的机制下,字段value要借助volatile原语,保证线程间的数据是可见性。这样在获取变量的值的时候才能直接读取。然后来看看 ++i 是怎么做到的。

2.getAndIncrement 采用了CAS操作,每次从内存中读取数据然后将此数据和 +1 后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

3.而 compareAndSet 利用JNI(Java Native Interface)来完成CPU指令的操作,下面应该是汇编代码(这是我自己找的,不确定是不是)。因为看不懂就不讲解了,知道什么意思的可以留言讲解一下。

template<>
template<typename T>
inline T Atomic::PlatformXchg<4>::operator()(T exchange_value,
                                             T volatile* dest,
                                             atomic_memory_order order) const {
  STATIC_ASSERT(4 == sizeof(T));
  // alternative for InterlockedExchange
  __asm {
    mov eax, exchange_value;
    mov ecx, dest;
    xchg eax, dword ptr [ecx];
  }
}
复制代码
CAS的缺点:

1.ABA问题: 如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗? 如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为CAS操作的“ABA”问题。不过从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(
               V      expectedReference,//预期引用

               V      newReference,//更新后的引用

              int    expectedStamp, //预期标志

              int    newStamp //更新后的标志
)
复制代码

2.循环时间长开销大: 前面可以看到getAndIncrement方法中有一个for(;;),如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。 3.只能保证一个共享变量的原子操作: 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性.不过,万能的JDK1.5之后提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

2.锁的优化

众所周知,一旦在并发中用到锁,就是为了进行阻塞,性能自然而然就会降低,因为锁的优化就是在阻塞的情况下去提高性能,让锁造成的障碍降到最低,但并不是就能解决锁阻塞造成的性能问题,这是不可能的。一般性能优化问题都是从减少耗时和降低自身复杂度做起,因而锁优化的方法由前人总结目前有以下几点:

  • 减少锁持有时间
  • 减小锁粒度
  • 锁分离
  • 锁粗化
  • 锁消除

2.1 减少锁的持有时间

使用锁会造成其他线程进行等待,因为降低持有锁的时间和减少锁的范围,其他线程获取锁的速度也会加快,尽可能减少冲突时间,举个最熟悉的单例例子

 public synchronized static SingleDoggetInstance() {
    if(singleDog== null){
        singleDog= new SingleDog();
    }
    return singleDog;
 }

复制代码

上面这样如果每个进来的线程都加锁后再判断实例是否已经存在,然而这样加了不必要的锁,所以为了减少不必要的加锁次数,进行下面优化。

 public static Singleton getInstance() {
    if (singleDog== null) {
        // 在判断实例是否存在的时候,再加锁
        synchronized (SingleDog.class) {
            if (singleDog== null) {
                singleDog= new SingleDog();
            }
        }
    }
    return singleDog;
 }
复制代码

上面当实例已经存在的时候就直接返回实例,就不需要增加不必要的锁

2.1 减少锁粒度

将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最典型的例子就是ConcurrentHashMap(虽然写android的我从来没用过),反例就是HashTable,每一个方法都上锁,不过最近FaceBook好像推出了一个改良的HashTable F14,还没看,想了解的朋友可以看一下,要翻墙

这里主要用ConcurrentHashMap来介绍下减少锁粒度优化,因为jdk1.7和1.8的实现方式不一样,这里只介绍1.8 jdk1.7主要使用分段锁的方式,最大的并发性与分段的段数相等。jdk 1.8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))并且将每个segment的分段锁ReentrantLock改为CAS+Synchronized ConcurrentHashMap从原理上看和hashmap很类似,差距值是ConcurrentHashMap做了一个链表转红黑树功能和线程安全机制

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        //1. 计算key的hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //2.当前tab中索引为i位置的元素为null,直接使用CAS将值插入
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
             //3.扩容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //4.存入对应tab的链表中
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
复制代码

上面就是ConcurrentHashMap的put写入代码,从上面可以看到它将锁的粒度减少,最后上锁的地方是在存入对应数组的链表(或者红黑树)的时候。 举个简单例子,假如使用hashtable,里面存了100个数据,因为put方法就被synchronized,那么多线程去写入数据时都要等待前一个线程往100个数据的列表中写入数据,使用ConcurrentHashMap后假如数据平均分为10个tab,每个tab10个数据,那么判断数据应该存在哪个tab这部分时不加锁的,加锁的只是在往一个10个数据的链表中写入数据,这一下,锁的粒度就降到1/10,性能自然也大幅度上升。

2.3 锁分离

根据同步操作的性质,把锁划分为的读锁和写锁,读锁之间不互斥,提高了并发性。这个比较简单,如下表

 - 读锁 写锁
读锁 可访问 不可访问
写锁 不可访问 不可访问

读写分离思想可以延伸,只要操作互不影响,锁就可以分离。典型的示例LinkedBlockingQueue队列,在它内部, take和put操作本身是隔离的, 从尾部写入数据,从头部取出数据,分别持有一把独立的锁.

LinkedBlockingQueue队列

2.4 锁粗化

为了保证多线程之间的并发,每个线程持有锁的持续时间应该尽量缩短,在执行完代码块之后,应该立刻释放锁,这样后续的线程才能尽快获得资源去继续执行下去,但是一个锁的请求、同步和释放,其本身也是消耗系统的资源的,反而对性能优化不利,有点类似于Java不要频繁创建对象,要对锁的内容进行封装。

for (int i = 0; i < 100; i++) {
            synchronized (this){ 
            }
        }
替换为
 synchronized (this) {
            for (int i = 0; i < 100; i++) {
            }
        }


   synchronized (this){ 
        A()
    }
   synchronized (this){ 
      B()
    }
替换为
 synchronized (this){ 
      A()
      B()
    }
复制代码

2.5 锁消除

锁消除是编译器做的事,即时编译时如果发现不可能被共享的对象,则可以消除这些对象的锁操作。 有时候对完全不可能加锁的代码执行了锁操作,因为些锁并不是我们加的,是JDK的类引用进来的,当我们使用的时候,会自动引进来,所以我们会在不可能出现在多线程需要同步的情况就执行了锁操作。在某些条件成熟下,系统会消除这些锁。

例如StringBuffer就是线程安全的操作字符串类

 public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
复制代码

假如你写了如上一个方法,因为返回的是一个字符串,在外面调用这个方法时并不会用到里面的同步操作,所以这其中的锁是无意义的,除非这样

 public static StringBuffer createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb
    }
复制代码

返回给外界一个StringBuffer,这样外界可能有多线程多这个返回值append操作,这时候就需要锁的存在。 解决办法:(我是写android,并不存在这种需求,所以这里我就直接复制了一篇文章的解决方案) 开启锁消除是在JVM参数上设置的,当然需要在server模式下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
复制代码

3.虚拟机内的锁优化策略

锁机制升级流程:无锁-->偏向锁-->轻量级锁-->重量级锁

3.1无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的实现原理就是前面文章提到的CAS,线程会不断的去尝试修改共享资源,如果没有冲突就修改成功推出,如果有冲突就for(;;)循环去继续修改,多个线程去修改资源,一定有一个最快的线程先修改成功,后面的线程修改失败后就会继续循环重试直到全部修改成功

3.2偏向锁

偏向锁类似于一个同步代码块一直被一个线程持有访问时,该线程会自动获得锁,这样降低了锁的性能消耗,就像常常去光顾饭店的老顾客,你不开口,老板就知道给你上“老样子”

从前面可以知道对象头的Mark Word中有一个25Bit的存储位置,其中当目前是偏向锁时会分配23Bit空间来存储作为当前的常客线程Id,在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。

偏向锁的目的是为了在没有多线程竞争的情况下减少不需要的轻量级锁的执行,轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在替换ThreadID的时候进行一次CAS原子指令即可。

如果ThreadID一旦不一致则意味着发生了多线程竞争,那么锁就不能偏向于一个线程了,这时候锁就会膨胀成轻量级锁,才能保证线程间公平竞争。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

3.3轻量级锁

当偏向锁被多线程竞争访问时就会升级成轻量级锁,其他线程会通过自旋的形式去尝试获取锁,不会造成阻塞,从而提高性能。

当线程进入同步代码块的时候,如果当前同步对象为无锁状态(标志位为“01”,是否为偏向锁为“0”),虚拟机会在当前线程的栈帧中建立一个Lock Record(锁记录)空间来存储锁对象目前的Mark Worder的拷贝,将对象头中Mark Worder的数据复制到记录中

复制成功后,虚拟机使用CAS原子指令操作将对象的Mark Worder更新为指向Lock Record的指针,同时将Lock Record里面的_owner指向对象的Mark Worder

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

3.4自旋锁

线程在竞争时没有获取到锁后,不立刻挂起,而是做几个自旋后再尝试去获取。

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

所以假如在线程竞争时,竞争失败的锁不去立即挂起,而是做几个自旋(空操作),然后再去尝试获取锁,假如此时上个线程已经执行完毕了,你就可以拿到锁,这样就节省了线程挂起切换时间,提升了系统性能。

但是假如锁竞争程度很高,多次自旋仍然拿不到锁,那么自旋锁会逐渐膨胀成重量所,提高系统的整体性能。

  • JDK1.6中-XX:+UseSpinning开启 1.6可关闭和开启操作,
  • JDK1.7中,去掉此参数,改为内置实现 1.7则把他改为内置开启
  • 如果同步块很长,自旋失败,会降低系统性能
  • 如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能

3.5重量级锁

重量级锁是锁升级的终点,标志位为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态,这也就是Synchronize.

所以锁升级的整体流程为 1.如果偏向锁可用可以尝试使用偏向锁 2.偏向锁陷入竞争就升级为轻量级锁 3.轻量级锁竞争失败会尝试自旋 3.自旋尝试失败后升级为重量级锁,在操作系统层挂起

下面用美团文章的一张图片总结一下

锁的种类

总结

本文是在学习锁的时候参考了多篇文章后总结出来的一份笔记,毕竟各人写的文章都有各人的风格,自己总结一下以后复习也更为熟悉和方便,限于篇幅以及个人水平,如有错误,还望指出。

java cas算法实现乐观锁 (Compare and Swap 比较并交换) 面试必问的CAS,你懂了吗? Java并发问题--乐观锁与悲观锁以及乐观锁的一种实现方式-CAS java高并发实战(九)——锁的优化和注意事项 Java高效并发(四) 不可不说的Java“锁”事

猜你喜欢

转载自juejin.im/post/5cd55248f265da037a3d0754
今日推荐