V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
zazalu
V2EX  ›  Java

一个比较悲观锁和 CAS 乐观锁性能的简单实例引发的问题

  •  
  •   zazalu · 2019-06-23 13:13:08 +08:00 · 4549 次点击
    这是一个创建于 1762 天前的主题,其中的信息可能已经有所发展或是发生改变。

    大家周末好!

    线程安全和锁这一块一直是我想理解清楚的问题,今天看了 https://www.v2ex.com/t/574995#reply13 的问题,再次引发了我对 java 锁这块知识的疑问。

    我从网络上得知,使用 synchronized 包裹方法来实现悲观锁,这是重量级的,是性能不高的。使用 CAS 实现乐观锁,这是轻量级的,一定场合下性能高于前者。

    所以我就想自己测下验证下这个结论,但是验证过程中遇到了和结论不匹配的结果,所以希望请教下大家

    我的示例代码如下

    public class Demo1 {
        private static final Sheep sheep = new Sheep(0);
    
        static class Sheep{
            private AtomicInteger size;
    
            public Sheep(int size) {
                this.size = new AtomicInteger(0);
            }
    
            public AtomicInteger getSize() {
                return size;
            }
    
            public void setSize(int size) {
                this.size.set(size);
            }
    
        }
    
        //悲观
        public synchronized static void increase1() {
            int value = sheep.getSize().intValue();
            sheep.setSize(value + 1);
        }
    
        //乐观(CAS)
        public static void increase2() {
            while (true){
                int expect = sheep.getSize().intValue();
                if(sheep.getSize().compareAndSet(expect,expect+1)){
                    break;
                }
            }
        }
    }
    

    我使用如下代码来做耗时测试:

    //10 个线程模拟 10 个用户,它们同时调用递增方法修改 size 属性,记录每次的耗时结果
    public static void main(String[] args)  throws InterruptedException{
            long start = System.currentTimeMillis();
            long end = 0;
    
            ExecutorService executor = Executors.newFixedThreadPool(10);
            int callTime = 10000000;
            CountDownLatch countDownLatch = new CountDownLatch(callTime);
            
            for(int i=0; i<callTime; i++) {
                executor.execute(new Runnable() {
                    @Override
                    public void run() {
                        increase1();
                        //increase2();
                        countDownLatch.countDown();
                    }
                });
            }
            
            countDownLatch.await();
            end = System.currentTimeMillis();
            executor.shutdown();
            
            System.out.println("调用次数:" + sheep.getSize().intValue());
            System.out.println("调用耗时: " + (end - start) );
        }
    

    我的记录结果如下:

     *      总共操作 1000w 次,两者的耗时差不多,记录了 5 次运行的耗时:
     *      悲观递增耗时记录(毫秒):6923,7030,6987,7353,7125
     *      乐观递增耗时记录(毫秒):7149,7044,6937,6777,6717
     *
     *      总共操作 5000w 次,两者的耗时差不多,记录了 5 次运行的耗时:
     *      悲观递增耗时记录(毫秒):49843,49807,51404,49143,49543
     *      乐观递增耗时记录(毫秒):49762,48950,49376,48918,50324
     *
     *      测试环境是 IDEA,跑 main 方法
    

    可以看到,这个案例下,悲观锁和 CAS 乐观锁在效率上没有很大的区别!为什么在这个案例下这两种实现在效率上没有区别呢?

    第 1 条附言  ·  2019-06-23 14:44:34 +08:00
    是我的计时的写法不科学导致的错误的结果。改变计时的思路,严格记录自增方法的耗时,结果是 CAS 胜利了!耗时更短

    ```
    //修改了计时思路
    long start = System.currentTimeMillis();
    increase2();
    long end = System.currentTimeMillis();
    ```
    第 2 条附言  ·  2019-06-24 00:11:32 +08:00
    上面的测试结论作废。

    原因:
    1.我没法确定 JVM 遇到 synchronized 就是直接用了重量级悲观锁
    2.理论上根据不同的竞态条件,每种锁的实现思想都有其自己独特的优势,不能一概而全
    3.JVM ,计算机配置以及其他复杂情况都会影响结果

    结论:
    在实际场景,对不同的同步实现方式做充足必要的压力测试才是比较好的方式。
    15 条回复    2019-06-24 18:37:22 +08:00
    ywcjxf1515
        1
    ywcjxf1515  
       2019-06-23 14:16:47 +08:00   ❤️ 1
    这写法好奇怪,这样不是起 10 线程吧,是在有 10 个线程中的线程池中要取出并放回(以及等待) calltime 次数的线程,大量的时间不是耗在自增上吧。
    zazalu
        2
    zazalu  
    OP
       2019-06-23 14:42:22 +08:00
    @ywcjxf1515 谢谢提醒!我修改了下计时机制!我只计时了自增的耗时!这次结果确实是 CAS 胜利了! 谢谢解惑! 测试程序写的不周到导致的结果。请原谅我的无知
    mind3x
        3
    mind3x  
       2019-06-23 18:37:46 +08:00   ❤️ 3
    前 JVM 开发者,建议谨慎应用你的结论 :)

    简单说 synchronized 并不一定就是悲观锁,JVM 会先尝试基于 CAS 的瘦锁,发现有 contention 再升级为重量级的悲观锁。

    实际用哪种锁,取决于你并发的规模和读 /写比例。多数情况下,因为 synchronized 的自适应性,其实综合表现更好。不然为什么 Java 8 的 concurrenthashmap 仍然用 synchronized 来锁 slot。

    你可以看下 https://blog.overops.com/java-8-stampedlocks-vs-readwritelocks-and-synchronized/,你的 CAS 实现大致对应里面的 StampedLock 版本。然后他里面的 optimistic 版本是 fail fast 的,其实没有比较意义。

    另外要做 Java 的 microbenchmark,必须要考虑 JIT 的介入,还是得用 JMH,自己写个大循环加 current time millis 远远不够。
    arrow2015
        4
    arrow2015  
       2019-06-23 19:24:14 +08:00 via Android
    @mind3x 是开发 HotSpot 吗?
    palmers
        5
    palmers  
       2019-06-23 21:58:11 +08:00
    不记得是 1.6 还是 1.7 后 sync 锁不会一开始就是重量级锁, 首先是偏向锁 -> 轻量级锁 -> 重量级锁 逐步升级的 所以现在不能再模糊的说 sync 是重量级锁 如果模糊场景进行比较,那也可以说 cas 是在浪费 cpu 性能 做了很多无用功 在这个层面上和被锁住一样的性质
    mind3x
        6
    mind3x  
       2019-06-23 22:50:34 +08:00
    @arrow2015 不不,十多年前做 J2ME VM 的
    shikimoon
        7
    shikimoon  
       2019-06-23 22:50:35 +08:00
    synchronized 现在性能也没那么差了
    shikimoon
        8
    shikimoon  
       2019-06-23 22:51:56 +08:00
    @mind3x 请问下一般哪些常见的 contention ?
    manecocomph
        9
    manecocomph  
       2019-06-23 23:42:22 +08:00   ❤️ 1
    一点点建议:
    1. JVM 慢热, 所以最好之前有个热身, 比如你要对比的代码在真正测试之前, 先运行 3000 遍, 热身之后会, 很多运行时优化都做完了, 才能真正反映性能;
    2. increase1() 里面也是使用的 AtomicInteger, 这个里面使用的是 volatile int value, volatile 变量前后都要写回主存, 之前的 Synchronize 也有刷回主存的含义, 所以可能导致 synchronize 前后 和 volatile 变量前后 2 次刷回主存, 影响部分性能.
    3. 微观测试, 计时选用 System.nanoTime() 会更精确, 它经度更高.

    如上面所说, Synchronize 现在是自适应, 从偏向锁开始, 轻量级, 到重量级, 不过你这里 10 个线程竞争很激烈, 估计很快升级到重量级锁.

    另外 如果你机器有 10 个以上 CPU, 这里的 CAS 竞争也异常激烈, 为了缓存一致性, 迫使 CPU 指令不断从主存拿数据, 更新主存, 导致 CPU 流水线不是那么顺畅. 另外是不是 CPU NUME aware 也会 有部分影响.
    zazalu
        10
    zazalu  
    OP
       2019-06-23 23:48:08 +08:00
    @mind3x
    首先,非常感谢您的回答,让我见识匪浅!我也对自己草率的测试和草率的理解表示歉意!同时也对同步,锁这方面的技术研究感觉涉及范围之广,考虑要素之多而有点望而却步!(只负责写过小项目 crud 的 coder 再次感受到了 coder 与 coder 之间的差距!)

    下面是我的一些小总结,也算是对自己的提问的初衷做一个交代。

    假设有一个计数器,由于在计算机中,线程是可以同时“执行“的缘故,所以会容易发生少记一次的问题发生。于是人们想出了[线程同步],它就像是一个门的锁,可以让线程执行到一个位置的时候停下来等候前一个线程执行完毕。通过这种思想,就可以解决前面发生的少记一次的问题。

    但是这种思想带来了一个副作用,那就是使得程序的效率下降了。所以我们在尽可能达到目的的同时。推出了各种锁的实现来弱化这个副作用。目前来看,锁的实现思想有很多,下面是我个人可以 get 到的一些,
    1. 最原始的思想,让线程串行,所谓的悲观(?),缺点就是效率不好
    2. 基于 1 的优化思想,让读写操作分离处理,不是让全部线程单纯的等待,而是符合一定安全条件的线程可以继续执行代码,比如 RWLock,对于读读读操作可以让他们并行执行,使得当读写比例中读占绝大多数比重的时候,RWLock 的效率是相当高的。这种优化思想的缺点是:优点就是他的缺点,是一种双刃剑,符合一种条件,那么肯定会违背另一个对立面,导致其性能大大降低
    3. 基于 CAS ( Compare And Swap )和原子性的思想,是非堵塞的,故为乐观锁的一种实现方式。它先检查我们读取的 count 的值是否是最新的,如果是最新的,那么进行更新(这整个过程必须符合原子性)。比如 Atomic 类的 compareAndSet 方法,其本身通过 CAS 指令(硬件指令集)达到了原子性,直接从内存中取出数据比较并赋值。如果不是最新的(存在 contention ),则进行第二步操作,这个第二步操作目前我 get 到的有如下三种:

    3.1 升级为重量级的悲观锁,缺点可想而知
    3.2 再查一次,直到最新为止(本文的 increase2 就是这个思想),缺点是当竞争激烈的时候,在循环上的开销会很大
    3.3 不继续查询,在当前线程内存中记录冲突次数(英文貌似叫 delta ),当读取 count 的时候,返回 count+sum(delta)的值,比如 LongAddr 类。这似乎是三种思路里面最好的方法,不过也有缺点就是读取的时候要做相加的额外操作以及多余的空间开销。

    那么其实说了这么多,都是在为下面的结论做铺垫:
    "使用哪种锁策略是不能随便纸上谈兵的,必须要根据实际情况,根据实际的竞态条件来决定哪种思想更适合,最好是能模拟出实际环境,随后使用 JMH 来做下 microbenchmark !并且不要小看 synchronized 的实力,其背后到底使用哪种思想去实现锁不是我们非底层实现人员可以随便猜想的,synchronized 作为 java 实现同步的主力军,有自己的努力,综合上来说,synchronized 的性能是非常稳定的,这肯定得益于其背后的优化算法,正如 mind3x 所说的那样!"
    zazalu
        11
    zazalu  
    OP
       2019-06-23 23:51:54 +08:00
    @manecocomph 谢谢建议!你说的一些我还有点没搞懂。今天太晚了,下次有空我再来消化你的建议。感谢回复小白的问题
    mind3x
        12
    mind3x  
       2019-06-24 04:10:48 +08:00
    @zazalu 说歉意言重了,大家都是参加讨论互相学习,我不是搞高并发的专家,也很业余的。而且我觉得你总结得比我好多了。

    另外我举例子的那个网页,之前我没有细看他的代码和结论,需要道歉的是我 XD
    其实他本身的方法和结论有很大的问题。像 RWLock,本来就是为多读少写的情况下提高读吞吐量而存在,他的 benchmark 反而显示多读少写的情况下 RWLock 最慢,是因为他统计的是读写线程全部完成以后的最大耗时,而不是读 /写各自的吞吐量。在多读少写的情况下,写线程会因为大量读锁而等待,所以按他的测量方式反而会大幅拖慢总运行时间。
    mind3x
        13
    mind3x  
       2019-06-24 04:20:55 +08:00
    @shikimoon 广义的 contention 就是两个或者更多线程竞争同一个资源。对 synchronized 这样的临界区来说,就是有一个以上的线程都试图进入同一个 synchronized 修饰的 block。
    neoblackcap
        14
    neoblackcap  
       2019-06-24 10:46:45 +08:00   ❤️ 1
    加一句,首先不要相信网上所谓的什么重量级的锁,一般我们指的锁都是 mutex,哪怕是这样的锁,你试试用一个线程加锁解锁,跟不加锁的版本比较一下,你就知道到底这些锁有多重。
    加锁的成本并不高,高是因为加锁之后带来的锁竞争,那个才是高的原因。
    因此不分并发度,单调地评论同步方式,我觉得意义并不大。
    troywinter
        15
    troywinter  
       2019-06-24 18:37:22 +08:00
    CAS 的表现极度依赖硬件平台,如果处理器很多并且竟态条件严重的情况下,CAS 的开销也会很大,Oracle 官方曾经有文章研究过这个问题,另外,synchronized 的锁升级情况也要考虑,在不同竟态条件下锁的开销不一样,这个楼上已经提到过了。
    再说一点,你的测试方法有问题,没有预热,这种测试正规应该用 jmh 来做,在不同的系统 load 情况下,performance 会有很大差别。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3099 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 10:59 · PVG 18:59 · LAX 03:59 · JFK 06:59
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.