synchronized和ReentrantLock性能瓶颈及实现

个人理解

synchronized和ReentrantLock相信懂Java的人并不陌生,这两种锁虽然提供了不同的线程同步方式,但是对于我们使用者来说,他们完成的功能都差不多,或许后者在使用上更加灵活,功能更加强大。但是他们到底有什么区别呢,我们可能很关心这个问题,因为这将取决于我们面试到底该怎么吹牛逼(哈哈,瞎说啥大实话),言归正传,搞懂这两者的区别我们能够对自己的代码更可控,写出性能更高更优秀的代码。

demo测试

下面代码分别使用testLock、testSynchronized测试synchronized和ReentrantLock对应的性能。

package org.apache.hadoop.mapreduce;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Test {
    long start = System.nanoTime();
    long sum = 0;
    long end = start;
    
    public void testLock() {
        final Lock lock = new ReentrantLock();
        for (int i = 0; i < 6; i++)
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Thread.currentThread().setName("lock-test");
                    while (true) {
                        lock.lock();
                        sum = sum + 1;
                        lock.unlock();
                        if (sum % 10000000 == 0 && sum > 10000000) {
                            end = System.nanoTime();
                            System.out.println("count:" + sum + "\ttime:" + (end - start));
                            start = System.nanoTime();
                        }
                    }
                }
            }).start();
    }

    public void testSynchronized() {
        for (int i = 0; i < 6; i++)
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Thread.currentThread().setName("sync-test");
                    while (true) {
                        synchronized (Test.this) {
                            sum = sum + 1;
                        }
                        if (sum % 10000000 == 0 && sum > 10000000) {
                            end = System.nanoTime();
                            System.out.println("count:" + sum + "\ttime:" + (end - start));
                            start = System.nanoTime();
                        }
                    }
                }
            }).start();
    }
    
    public static void main(String[] args) throws InterruptedException {
//        new Test().testLock();
        new Test().testSynchronized();
    }
}

测试结果

testLock输出:synchronized和ReentrantLock性能瓶颈及实现_第1张图片testSync输出:
synchronized和ReentrantLock性能瓶颈及实现_第2张图片
对比上述两个输出结果,好像性能都差不多,大概sync性能要比lock差两成,但也不至于有数量级的差别。那是不是对于可以不care这种性能丢失?

我们再来看看这两个函数调用分别对应的cpu load
testLock cpu load:
synchronized和ReentrantLock性能瓶颈及实现_第3张图片testSync cpu load:
synchronized和ReentrantLock性能瓶颈及实现_第4张图片
从cpu load来看后者达到了前者的两倍以上,由于博主机器是4核,这里基本打满,理论上测试场景下后者的cpu load约等于线程个数×100%,前者的cpu load徘徊在200%左右。为什么会这样呢?

性能分析

前两篇博客介绍了火焰图,这里博主是通过火焰图分析性能瓶颈和执行流程。
testLock flame graph
synchronized和ReentrantLock性能瓶颈及实现_第5张图片
这里可以看到无论是调用park还是unpark的native方法,都会涉及到两次system call,尤其实在lock阶段大概占所有消耗的50%。两次系统调用分别执行的futex_wake,和futex_wait。这里涉及到了操作系统提供的futex功能,什么是futex呢?

hadoop@hadoop-ThinkPad-T460s:~$ cat /usr/include/asm/unistd_64.h |grep futex
#define __NR_futex 202
hadoop@hadoop-ThinkPad-T460s:~$ cat /proc/26301/task/26389/syscall 
202 0x7f520c413414 0x80 0x0 0x0 0x1 0x0 0x7f51d277f270 0x7f5213e379f3

unistd_64.h中定义了syscall的调用号,通过查看线程的syscall可以看到正在调用202也就是futex,我个人理解futex存在的意义是能够对线程进行精准控制的互斥量,因为用户态的线程无法对线程状态(主要涉及运行和阻塞)进行精准控制,这里的精准控制主要是线程状态切换的时间开销,操作系统能够做到快速切换线程状态,而用户态的线程则做不到这一点,因为操作系统并不可能把这种线程权限的控制暴露给用户,这将破坏操作系统对线程的管理和控制,从而引入不可控因素,或者说是灾难。futex核心功能是提供一种同步机制,获得futex互斥量的线程处于运行状态,而没有获得互斥量的线程处于阻塞状态,注意这里的阻塞状态最大的特点是不会带来cpu的消耗,这也是和jvm spinlock实现的最大不同,获取futex和释放futex直接通过call系统调用实现,这个是线程在用户态触发的,操作系统核心任务是将获取到futex的线程设置为运行状态,没有获取的线程在等待队列里面处于阻塞状态,期间快速切换线程状态。但是futex调用并不是非常完美的线程同步解决方案,因为系统调用会涉及到context switch,和操作系统实现的限制,对性能都有一定的影响,这也是lock阶段system call cpu load占了50%的原因。

testSync flame graph
synchronized和ReentrantLock性能瓶颈及实现_第6张图片从上图中可以看到sync没有任何系统调用,也就是说java的synchronized这种同步机制完全暴露在用户态的线程中执行的,不需要操作系统参与,那么他是怎么实现线程的同步呢(运行和阻塞)?这里就需要提及jvm的实现方案spinlock,简而言之,他是一种抢占式拿锁的方式,如果调用cpu指令comxchg成功,那么就占到对应的坑位处于运行状态,否者线程就处于等待状态,等待的线程并不是在睡觉,而是在占着cpu空转,因为只有占着cpu,才能实现线程状态快速切换,否者cpu控制权被操作系统回收,待操作系统下次激活线程这个过程的消耗实在太大了。如何实现的空转呢,这也是spinlock的核心之一。通过perf+jvmti可以看到:

       │    SpinPause():                                                                                                                                                                     ▒
 91.72 │      pause                                                                                                                                                                          ▒
  4.97 │      mov    $0x1,%rax                                                                                                                                                               ▒
  3.31 │    ← retq                                                                                                                                                                           ▒
       │      nop    

spinlock实现调用了SpinPause,spin pause执行了cpu的pause指令进行等待,那么这里的pause是睡眠的意思吗?
查看hotspot嵌入汇编linux_x86_64.s代码:

hadoop@hadoop-ThinkPad-T460s:~/jdk/jdk8u-dev/hotspot$ cat ./src/os_cpu/linux_x86/vm/linux_x86_64.s|grep SpinPause: -A4
SpinPause:
        rep
        nop
        movq   $1, %rax
        ret

pause的实现是调用rep;nop,也就让cpu执行循环的空操作。
综上,jvm实现的spinlock并不太优雅,最大的问题是以空转cpu的代价来换取线程状态快速切换的目的,但是完全暴露在用户态的线程要实现spinlock也别无他法,那么synchronized存在的意义在哪里啊?我们知道java是跨平台语言,早期的linux内核或者其他平台并没有提供类似futex的控制,逼不得已我们只有使用简单粗暴的方式来解决问题,这就是synchronized存在的价值,他不依赖于平台,是的java在线程同步功能能够运行在任何平台。

总结

ReentrantLock和synchronized都有对应的优缺点。ReentrantLock功能上具备压倒性优势,在降低cpu的使用率情况下能够快速调度线程,但是期间利用了操作系统对线程的调度方案,带来一定程度上的开销(context switch和操作系统调度机制内部实现);synchronized完全运行在用户态线程下,避免系统调用的开销,但是带来的问题是并发过大会打满cpu,从而反作用与获得锁的线程,在该线程使用完时间片后,其cpu使用权可能被操作系统剥夺,从而降低运行效率。个人认为在并发数小于cpu核数的场景下,并且synchronized锁住的code block不会引入太大的开销(系统调用,io操作,huge loop等等),synchronized的实现要优于ReentrantLock,otherwise使用ReentrantLock绝对是你的不二之选。

你可能感兴趣的:(synchronized和ReentrantLock性能瓶颈及实现)