由i++引发的乐观锁和悲观锁的讨论

笔者最近写了一个APM(应用性能监视)的小项目,实现很简单,去和客户交流,其中需要统计执行调用一个类的方法多少次,

最开始由于写的比较急,比较简单,直接上了 i++,结果发现统计不准,改了改;

为什么?

因为有并发的操作,i++其实编译成汇编指令,是三步操作:


这三步相当于是一个方法中的三行代码;

试想一下,n个线程进行执行,有的线程方法执行到第二步的时候,i被增加为i+1,而有的线程是执行到第三步.....

可以想象,i的值肯定最后不是真实的线程访问次数,是一个乱的数值;


解决这个问题,咱们第一个想到的就是悲观锁(Pessimistic Lock);


啥是悲观锁?

当前线程操作的时候,时间可能会非常的长(也就是对其它线程要想获得这个锁是一个悲观的估计),那么其它线程当发现你这时间太长了,自动放弃cpu时间片,或进入休眠状态,或进入阻塞状态;

当当前线程搞完了,释放掉这个悲观锁,其它线程从休眠或者阻塞中苏醒,然后去根据策略或者排队争抢这个悲观锁;

我们看到这个过程中,其它线程休眠,也就意味其不能进行用户态执行了,在内核态去等待信号的调用;而当线程按照操作系统cpu的执行状态,中间经历一个内核态到用户态的一个过程,这一过程需要排队去抢占时间片去执行,如下图所示:


我们分析上面的图,一次线程休眠是非常耗时的,从内核态等待信号,再到排队,并且排队策略可不一定是FIFO,如果是争抢的话,不一定又能执行了;

因此,到这里,我们就知道了,悲观锁的另一个“悲观”的含义,就是其它线程对于拿到这个锁也悲观,不一定啥时候能拿到;


在java中,大家都比较熟知一点,synchronized关键字是JVM层面的悲观锁,它实际的实现就是调用操作系统的mutex_lock(基于Posix系统调用,windows也累死),如果使用synchronized来解这个i++的问题,那么直接用{}给这个方法给包住;

synchronized(xxx){

    i++;

}

这样至少保证i变量绝对是一个线程+1次;


在上述的代码中,xxx是一块内存区域,也就是锁的区域,其实如果在c中,你可以直接malloc一块内存当锁就得了,但在java和c++中,这块锁的位置必须由对象来充当;

所以你需要在当前作用域中,搞一个各个线程都可以看到的内存空间,或是类的一个属性,如果作用域小,就可以是方法体内定义的一块临时内存也行:

Object xxx = new Object ();

synchronized(xxx){

    i++;

}

而java玩锁也玩出了新花样,其可以在方法上加synchronized字样,这样其默认的锁的区域就是整个对象了:


如上图中的锁的区域2,就是整个Test1类的实例对象,这种情况,所有对该对象的方法,属性调用都不能使用了,因此尽量避免在方法上加synchronized;

相对比的看看前面的这个Object xxx,对应锁区域0,其锁的区域就是一块内存,随着方法体没了,其锁也没了,这种锁区域是最弱的,而且是方法体中的线程并行才有效,出了方法体这锁加的就没有意义;

一般我们都会搞一个属性所谓锁,对应锁区域1,这个就很有意义了;


总之,说了这么多,其悲观锁耗时较长这一缺点,但是也不能说其没有优点,当线程数超级多的时候,cpu时间片争抢很激烈,这个时候往往有的线程不干活让其休息不一定是个坏事,毕竟线程上下文切换也相当占无意义cpu,浪费计算机的资源;


那么,现在有个场景,我们宁可让cpu多消耗,我想快点提升我的解锁的响应时间,这种情况下我们就需要搞一个乐观锁,还是先看图:


乐观锁可以看到,根本不会进入内核态进行休眠,即是通过一个版本信息或者是参照物信息,进行比对,如果发现其没改,那么可以直接修改,这种比对就是CAS指令;

所谓的CAS指令是一个先比对,然后再看看改没有改,最后赋值的一个过程,

那么你就说了,这是好几步的操作,必须要做同步,

但CAS的牛B之处就是编译成汇编,也对应一条指令,即要么成功赋值,要么发现没成功,返回失败结果;

即按照这种思路来看,需要搞一个while循环,当前线程需要不断的消耗用户态cpu的时间片进行空转,而空转会浪费cpu的,这个我们知道,因此,可以得出结论,乐观锁实际用在没几次就能修改成功的时候,即线程量不是特别大,这样稍微浪费一些cpu,我们就可以达到非常好的效果,即快速的解锁,将锁定的过程执行掉;


java中的乐观锁没有开放出来,但是其各种不同的JDK都提供了实现,该实现的调用隐含在并发包中各个类中了:


如上述的并发包中的跳表,主要以查询为主,写少,基于这种场景,很容易线程就可以CAS成功,因此这里采用了CAS指令,

调用的就是sun.misc.Unsafe 类,我们看到上图中的方法compareAndSwapObject方法,从字面意思就可以猜出来,该方法就是调用的CAS指令;

每一次node节点的增删改接,因为不是数组操作,就是指针操作,即使大并发下来,这种修改指针变化的频率也较低,因此此处使用乐观锁是完全没有问题的;


回到本文最上面的话题,i++可以配合这个sun.misc.Unsafe 类搞一段代码,类似于


unsafe. compareAndSwapObject(this,valueOffset,i,i+1); 

。。。


而JAVA为了方便,做成了通用的原子类,其就是AtomicInteger,给各种类型都包装了一下


和上面是一个意思,

我们可以看看源码:



总结一下,其实无论是啥锁,都是互斥的,但乐观锁更适应在cpu可控的前提下,能牺牲一些cpu空转浪费,在用户态快速减少响应时间,而当线程特别多,已经严重出现线程上下文切换这种情况了,往往悲观锁更合适一些;

场景不同,用法也不同,很多程序员一看java并发包出来了,就啥地方都用这些类,其实不然,看场景看需求,这才是正解;

本站公众号
   欢迎关注本站公众号,获取更多程序园信息
开发小院