线程安全、乐观锁和悲观锁那点事

线程安全的方案:保证线程安全无非就三种方式

1.悲观锁同步,多线程挨个访问共享数据;

2.乐观锁控制+重试机制确保在更新数据时预期值和实际值一致,不一致则执行重试;

3.无锁方式,有些场景比较适合采用无锁方案,每个线程持有一份数据副本,互不干涉

下面详细介绍每种方案:

同步方案:保证共享变量在某一时刻只能被一个线程访问

sychronzied:在其修饰的代码块被编译后,代码块前后会加上monitorenter和monitorexit指令,这两个指令在执行时都需要一个reference类型的参数来指明要锁定和解锁的对象,如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

在执行monitorenter时,会试图获取指定对象的锁,在我的博文"java对象包含哪些内容"中介绍过对象头中包含了锁信息,锁信息中包含了两个标志,一个是当前持有对象锁的线程,一个是锁计数器,当锁计数器为0时锁被释放。只有当计数器为0时或者持有对象锁的是当前线程时(synchronzied时可重入的),当前线程才可以持有或者再次持有对象的锁,此时将锁计数器+1,在执行monitorexit时,锁计数器-1。

在我的博文"java线程模型"中介绍过Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级的操作

ReetrantLock:ReetrantLock拥有和synchronized类似的java语义,同样可以实现互斥同步

synchronized与reetrantLock比较:

     两者拥有相同的并发以及内存语义,都可以满足原子性以及可见性,reetrantLock可以实现synchronized的所有功能,并在此基础上提供了投票获取锁,等待超时(线程在竞争synchronized块时,是无法被中断的),公平锁(按照竞争的先后顺序来获取锁的方式,默认情况下是非公平的,synchronized也是非公平的)的功能,而且还保证了在竞争激烈的情况下更好的性能,为什么说竞争激烈的情况下呢,synchronized在后续的优化中进行了无锁,轻量级锁,偏心锁,自旋转等优化,可以保证在没有竞争或者竞争很少的情况下性能很好(关于虚拟机对synchronzied作出的优化,文章最后会进行补充)。

    一般情况下,只要synchronized可以满足,就用synchronized。Lock需要手动释放锁,适合在synchronized不能满足的情况下使用,比如需要等待超时功能等

           

通过语义我们可以看出即使是在没有多线程竞争的情况下,同步互斥方式依然会有锁控制消耗,是一种悲观的锁策略,我们称为悲观锁。而实际应用中,同步块中的操作可能只需要很短暂的时间就可以执行完成,相对的就很少存在线程竞争的情况,这种情况下使用悲观锁就难免存在过多的资源浪费。

 

非互斥同步方案、乐观锁:

有悲观自然就有乐观,乐观锁假定程序没有多线程竞争,直接执行,在最终提交时验证预期值和实际值是否一致,如果一致说明假定正确,没有竞争,提交成功; 如果不一致,一般会进行重试,直到预期与实际值一致,提交成功。

相对于悲观策略,这种策略在竞争不激烈的情况下能大大减小不必要的消耗。

操作系统支持一种叫做compare and set(cas)的指令用于完成上述的检验操作,该指令需要三个参数,分别是要修改的变量的地址,预期值,新值。在执行时,必须保证传入地址中对应的值与预期值相同才可以将其修改为新值。操作系统可以保证cas操作是原子性的。

在JDK 1.5之后, Java程序中才可以使用CAS操作, 该操作由sun.misc.Unsafe类提供,由于Unsafe类不是提供给用户程序调用的类 , 我们通常都是通过其他的Java API来间接使用它, 如J.U.C包里面的几个Atomic.....类, 其中的compareAndSet( ) 和getAndIncrement( ) 等方法都使用了Unsafe类的CAS操作,提供了原子性的增长和更新操作。

其实在数据库层面,类似CAS的这种先检验再提交的做法可能大家早已已经使用过了,比如:系统为客户C开启了一个账户,账户中存有一定数额的钱,每次为充值或者购买商品时账户金额都要对应修改,并且需要记录账户流水信息,要求流水中包含流水前账户的余额、流水类型(充值、扣款)、流水金额。

显然,系统中会修改账户余额的操作存在多线程并发执行的情况,因此需要进行同步控制,如果我们使用CAS的思想,一般会这样做:1.查询账户当前余额#{oldBalance}(如果是扣款,余额不足则立即返回) 2.开启事务 3.插入流水信息 4.修改账户余额(我们的update语句会是这样 update c_account set balance=#{newBalance} where id=#{id} and balance=#{oldBalance} 注意这里的第二个条件就是关键,它会去检测我们刚开始操作时查询到的余额和此时数据库中的余额是否一致,如果一致说明从步骤1开始查询直到现在这中间账户余额没有发生变化,操作可以继续执行,如果发生了变化,可以选择返回失败,但一般我们会选择重试,重试从1步骤开始....直到update可以成功提交) 5.提交事务

 

无锁方案、threadlocal:

如果一个对象不需要被多线程共享,也就自然不存在线程安全问题

ThreadLocal变量:

每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以

ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量,然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找。

有些很适合使用ThreadLocal的场景,比如对于一些大家都需要使用,但是又不适合共享的资源,比如session,数据库连接等资源,如果大家都公用一个显然不科学,如果每次收到请求都新建一个session或者Connection,请求完成后再释放这样显然太耗资源,而如果为线程池中的每个线程维护一份连接,只要控制好连接池策略,就可以既不浪费资源又不用公用一个连接。

------------------------------------------------------------------分割线------------------------------------------------------------------------------

补充一点:

 虚拟机对synchronized的优化:

                锁的状态被分为四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。锁的状态被保存在对象的头文件中,JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过

-XX:-UseBiasedLocking来禁用偏向锁。

 

            轻量级锁:

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。它并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁,简单来说,轻量级锁只适用于无锁竞争的情况

 

            轻量级锁的加锁过程:

            Mark Word:

        在我的博文“java对象包含哪些内容”中介绍过,HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态等,这部分数据被称为“Mark Word”,它是实现轻量级锁和偏向锁的关键

        在未执行到同步块时,锁对象的(synchronized(obj){...} obj就是锁对象),锁标记为无锁状态。

      (1)在代码进入同步块的时候,如果同步对象的锁状态为无锁状态,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

      (2)拷贝对象头中的Mark Word到锁记录中。

      (3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。

      (4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

      (5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

 

            偏向锁:

      引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有同一个线程执行同步块时进一步提高性能。只有遇到其他线程尝试竞争锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁

 

            其他优化 :

        1、自旋锁与自适应性自旋:

在我的博文"悲观锁synchronzied与Lock比较、乐观锁CAS、无锁策略概述"中介绍过悲观锁的其中一个缺陷是:我们的应用中,同步块的执行时间往往很短,相对于这部分时间来说,产生竞争时的线程调度反而消耗掉了更多的资源,如果A线程进入了同步块,B线程也执行到了这里,按原本的策略操作系统就会将线程B阻塞,直到A出同步块之后再调度起B线程,如果这里改动一下:B执行到同步块时发现有线程已经占用了对象锁,此时不是立马阻塞,而是循环判断锁是否被释放掉了,前面说到了同步块的执行时间往往很短,所以这里循环时很可能锁被释放掉了,就可以免去操作系统调度线程的消耗。同步块执行时间越短,竞争越不激烈,自旋锁带来的性能提升就越明显。

        显然自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

        2、锁粗化:锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:  public class StringBufferTest {      StringBuffer stringBuffer = new StringBuffer();      public void append(){          stringBuffer.append("a");          stringBuffer.append("b");          stringBuffer.append("c");     } }

          这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

        3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序: public class SynchronizedTest02 {     public static void main(String[] args) {         SynchronizedTest02 test02 = new SynchronizedTest02();       for (int i = 0; i < 100000000; i++) {            test02.append("abc", "def");         }         System.out.println("Time=" + (System.currentTimeMillis() - start));     }      public void append(String str1, String str2) {         StringBuffer sb = new StringBuffer();         sb.append(str1).append(str2);     } }

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消

 

你可能感兴趣的:(线程)