关于后端:让人恶心的多线程代码性能怎么优化

52次阅读

共计 10716 个字符,预计需要花费 27 分钟才能阅读完成。

Java 中最烦人的,就是多线程,一不小心,代码写的比单线程还慢,这就让人十分难堪。
通常状况下,咱们会应用 ThreadLocal 实现线程关闭,比方防止 SimpleDateFormat 在并发环境下所引起的一些不统一状况。其实还有一种解决形式。通过对 parse 办法进行加锁,也能保障日期解决类的正确运行,代码如图。

  1. 锁很坏
    然而,锁这个货色,很坏。就像你的贞操锁,一开一闭激情早已云消雾散。
    所以,锁对性能的影响,是十分大的。对资源加锁当前,资源就被加锁的线程所独占,其余的线程就只能排队期待这个锁。此时,程序由并行执行,变相的变成了程序执行,执行速度天然就升高了。
    上面是开启了 50 个线程,应用 ThreadLocal 和同步锁形式性能的一个比照。
    Benchmark                                 Mode  Cnt     Score      Error   Units
    SynchronizedNormalBenchmark.sync         thrpt   10  2554.628 ± 5098.059  ops/ms
    SynchronizedNormalBenchmark.threadLocal  thrpt   10  3750.902 ±  103.528  ops/ms
    ======== 去掉业务影响 ========  
    Benchmark                                 Mode  Cnt        Score        Error   Units
    SynchronizedNormalBenchmark.sync         thrpt   10    26905.514 ±   1688.600  ops/ms
    SynchronizedNormalBenchmark.threadLocal  thrpt   10  7041876.244 ± 355598.686  ops/ms

复制代码
能够看到,应用同步锁的形式,性能是比拟低的。如果去掉业务自身逻辑的影响(删掉执行逻辑),这个差别会更大。代码执行的次数越多,锁的累加影响越大,对锁自身的速度优化,是十分重要的。
咱们都晓得,Java 中有两种加锁的形式,一种就是常见的 synchronized 关键字,另外一种,就是应用 concurrent 包外面的 Lock。针对于这两种锁,JDK 本身做了很多的优化,它们的实现形式也是不同的。

  1. synchronied 原理
    synchronized 关键字给代码或者办法上锁时,都有显示的或者暗藏的上锁对象。当一个线程试图拜访同步代码块时,它首先必须失去锁,退出或抛出异样时必须开释锁。

给一般办法加锁时,上锁的对象是 this

给静态方法加锁时,锁的是 class 对象。

给代码块加锁,能够指定一个具体的对象作为锁

monitor,在操作系统里,其实就叫做管程。
那么,synchronized 在字节码中,是怎么体现的呢?参照上面的代码,在命令行执行 javac,而后再执行 javap -v -p,就能够看到它具体的字节码。能够看到,在字节码的体现上,它只给办法加了一个 flag:ACC_SYNCHRONIZED。
synchronized void syncMethod() {
  System.out.println(“syncMethod”);
}
====== 字节码 =====
synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4                 
         3: ldc           #5                         
         5: invokevirtual #6           
         8: return

复制代码
咱们再来看下同步代码块的字节码。能够看到,字节码是通过 monitorenter 和 monitorexit 两个指令进行管制的。
void syncBlock(){
    synchronized (Test.class){
    }
}
====== 字节码 ======
void syncBlock();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2 
         2: dup
         3: astore_1
         4: monitorenter
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: return
      Exception table:
         from    to  target type
             5     7    10   any
            10    13    10   any

复制代码
这两者尽管显示成果不同,但他们都是通过 monitor 来实现同步的。咱们能够通过上面这张图,来看一下 monitor 的原理。
留神了,上面是面试题目高发地。

如图所示,咱们能够把运行时的对象锁形象的分成三局部。其中,EntrySet 和 WaitSet 是两个队列,两头虚线局部是以后持有锁的线程。咱们能够设想一下线程的执行过程。
当第一个线程到来时,发现并没有线程持有对象锁,它会间接成为流动线程,进入 RUNNING 状态。
接着又来了三个线程,要争抢对象锁。此时,这三个线程发现锁曾经被占用了,就先进入 EntrySet 缓存起来,进入 BLOCKED 状态。此时,从 jstack 命令,能够看到他们展现的信息都是 waiting for monitor entry。
“http-nio-8084-exec-120” #143 daemon prio=5 os_prio=31 cpu=122.86ms elapsed=317.88s tid=0x00007fedd8381000 nid=0x1af03 waiting for monitor entry  [0x00007000150e1000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.io.BufferedInputStream.read(java.base@13.0.1/BufferedInputStream.java:263)
    – waiting to lock <0x0000000782e1b590> (a java.io.BufferedInputStream)
    at org.apache.commons.httpclient.HttpParser.readRawLine(HttpParser.java:78)
    at org.apache.commons.httpclient.HttpParser.readLine(HttpParser.java:106)
    at org.apache.commons.httpclient.HttpConnection.readLine(HttpConnection.java:1116)
    at org.apache.commons.httpclient.HttpMethodBase.readStatusLine(HttpMethodBase.java:1973)
    at org.apache.commons.httpclient.HttpMethodBase.readResponse(HttpMethodBase.java:1735)

复制代码
处于活动状态的线程,执行结束退出了;或者因为某种原因执行了 wait 办法,开释了对象锁,就会进入 WaitSet 队列。这就是在调用 wait 之前,须要先取得对象锁的起因。就像上面的代码:
synchronized (lock){
    try {
         lock.wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

复制代码
此时,jstack 显示的线程状态是 WAITING 状态,而起因是 in Object.wait()。
“wait-demo” #12 prio=5 os_prio=31 cpu=0.14ms elapsed=12.58s tid=0x00007fb66609e000 nid=0x6103 in Object.wait()  [0x000070000f2bd000]
   java.lang.Thread.State: WAITING (on object monitor)
    at java.lang.Object.wait(java.base@13.0.1/Native Method)
    – waiting on <0x0000000787b48300> (a java.lang.Object)
    at java.lang.Object.wait(java.base@13.0.1/Object.java:326)
    at WaitDemo.lambda$main$0(WaitDemo.java:7)
    – locked <0x0000000787b48300> (a java.lang.Object)
    at WaitDemo$$Lambda$14/0x0000000800b44840.run(Unknown Source)
    at java.lang.Thread.run(java.base@13.0.1/Thread.java:830)

复制代码
产生了这两种状况,都会造成对象锁的开释。进而导致 EntrySet 里的线程从新争抢对象锁,胜利抢到锁的线程成为流动线程,这是一个循环的过程。
那 WaitSet 中的线程是如何再次被激活的呢?接下来,在某个中央,执行了锁的 notify 或者 notifyAll 命令,会造成 WaitSet 中 的线程,转移到 EntrySet 中,从新进行锁的抢夺。
如此周而复始,线程就可按程序排队执行。

  1. 分级锁
    JDK1.8 中,synchronized 的速度曾经有了显著的晋升。那它都做了哪些优化呢?答案就是分级锁。JVM 会依据应用状况,对 synchronized 的锁,进行降级,它大体能够依照上面的门路:偏差锁 -> 轻量级锁 -> 重量级锁。
    锁只能降级,不能降级,所以一旦降级为重量级锁,就只能依附操作系统进行调度。
    和锁降级关系最大的就是对象头里的 MarkWord,它蕴含 Thread ID、Age、Biased、Tag 四个局部。其中,Biased 有 1bit 大小,Tag 有 2bit,锁降级就是靠判断 Thread Id、Biased、Tag 等三个变量值来进行的。
    偏差锁
    在只有一个线程应用了锁的状况下,偏差锁可能保障更高的效率。
    具体过程是这样的。当第一个线程第一次拜访同步块时,会先检测对象头 Mark Word 中的标记位 Tag 是否为 01,以此判断此时对象锁是否处于无锁状态或者偏差锁状态(匿名偏差锁)。
    01 也是锁默认的状态,线程一旦获取了这把锁,就会把本人的线程 ID 写到 MarkWord 中。在其余线程来获取这把锁之前,锁都处于偏差锁状态。
    轻量级锁
    当下一个线程参加到偏差锁竞争时,会先判断 MarkWord 中保留的线程 ID 是否与这个线程 ID 相等,如果不相等,会立刻撤销偏差锁,降级为轻量级锁。
    轻量级锁的获取是怎么进行的呢?它们应用的是自旋形式。
    参加竞争的每个线程,会在本人的线程栈中生成一个 LockRecord (LR),而后每个线程通过 CAS(自旋)的形式,将锁对象头中的 MarkWord 设置为指向本人的 LR 的指针,哪个线程设置胜利,就意味着哪个线程取得锁。
    当锁处于轻量级锁的状态时,就不可能再通过简略的比照 Tag 的值进行判断,每次对锁的获取,都须要通过自旋。
    当然,自旋也是面向不存在锁竞争的场景,比方一个线程运行完了,另外一个线程去获取这把锁。但如果自旋失败达到肯定的次数,锁就会收缩为重量级锁。
    重量级锁
    重量级锁即为咱们对 synchronized 的直观意识,这种状况下,线程会挂起,进入到操作系统内核态,期待操作系统的调度,而后再映射回用户态。零碎调用是低廉的,重量级锁的名称由此而来。
    如果零碎的共享变量竞争十分强烈,锁会迅速收缩到重量级锁,这些优化就有名无实。如果并发十分重大,能够通过参数 -XX:-UseBiasedLocking 禁用偏差锁,实践上会有一些性能晋升,但实际上并不确定。
  2. Lock
    在 concurrent 包里,咱们可能发现 ReentrantLock 和 ReentrantReadWriteLock 两个类。Reentrant 就是可重入的意思,它们和 synchronized 关键字一样,都是可重入锁。
    这里有必要解释一下可重入这个概念,因为在面试的时候常常被问到。它的意思是,一个线程运行时,能够屡次获取同一个对象锁。这是因为 Java 的锁是基于线程的,而不是基于调用的。比方上面这段代码,因为办法 a、b、c 锁的都是以后的 this,线程在调用 a 办法的时候,就不须要屡次获取对象锁。
    public synchronized void a(){
        b();
    }
    public synchronized void b(){
        c();
    }
    public synchronized void c(){
    }

复制代码
次要办法
LOCK 是基于 AQS(AbstractQueuedSynchronizer)实现的,而 AQS 是基于 volitale 和 CAS 实现的。对于 CAS,咱们将在下一课时解说。
Lock 与 synchronized 的应用办法不同,它须要手动加锁,而后在 finally 中解锁。Lock 接口比 synchronized 灵活性要高,咱们来看一下几个要害办法。

lock:lock 办法和 synchronized 没什么区别,如果获取不到锁,都会被阻塞

tryLock:此办法会尝试获取锁,不论能不能获取到锁,都会立刻返回,不会阻塞。它是有返回值的,获取到锁就会返回 true

tryLock(long time, TimeUnit unit):与 tryLock 相似,但它在拿不到锁的状况下,会期待一段时间,直到超时

lockInterruptibly:与 lock 相似,然而能够锁期待能够被中断,中断后返回 InterruptedException

个别状况下,应用 lock 办法就能够。但如果业务申请要求响应及时,那应用带超时工夫的 tryLock 是更好的抉择:咱们的业务能够间接返回失败,而不必进行阻塞期待。tryLock 这种优化伎俩,采纳升高申请成功率的形式,来保障服务的可用性,高并发场景下常常被应用。
读写锁
但对于有些业务来说,应用 Lock 这种粗粒度的锁还是太慢了。比方,对于一个 HashMap 来说,某个业务是读多写少的场景,这个时候,如果给读操作也加上和写操作一样的锁的话,效率就会很慢。
ReentrantReadWriteLock 是一种读写拆散的锁,它容许多个读线程同时进行,但读和写、写和写是互斥的。应用办法如下所示,别离获取读写锁,对写操作加写锁,对读操作加读锁,并在 finally 里开释锁即可。
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    Lock readLock = lock.readLock();
    Lock writeLock = lock.writeLock();

    public void put(K k, V v) {
        writeLock.lock();
        try {
            map.put(k, v);
        } finally {
            writeLock.unlock();
        }
    }

复制代码
那么,除了 ReadWriteLock,咱们能有更快的读写拆散模式么?JDK1.8 退出了哪个 API?欢送留言区评论。
偏心锁与非偏心锁
咱们平时用到的锁,都是非偏心锁。能够回过头来看一下 monitor 的原理。当持有锁的线程开释锁的时候,EntrySet 里的线程就会争抢这把锁。这个争抢的过程,是随机的,也就是说你并不知道哪个线程会获取对象锁,谁抢到了就算谁的。
这就有肯定的概率,某个线程总是抢不到锁,比方,线程通过 setPriority 设置的比拟低的优先级。这个抢不到锁的线程,就始终处于饥饿状态,这就是线程饥饿的概念。
偏心锁通过把随机变成有序,能够解决这个问题。synchronized 没有这个性能,在 Lock 中能够通过结构参数设置成偏心锁,代码如下。
public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
}

复制代码
因为所有的线程都须要排队,须要在多核的场景下保护一个同步队列,在多个线程争抢锁的时候,吞吐量就很低。上面是 20 个并发之下锁的 JMH 测试后果,能够看到,非偏心锁比偏心锁性能高出两个数量级。
Benchmark                      Mode  Cnt      Score      Error   Units
FairVSNoFairBenchmark.fair    thrpt   10    186.144 ±   27.462  ops/ms
FairVSNoFairBenchmark.nofair  thrpt   10  35195.649 ± 6503.375  ops/ms

复制代码

  1. 锁的优化技巧
    死锁
    咱们能够先看一下锁抵触最重大的一种状况:死锁。上面这段示例代码,两个线程别离持有了对方所须要的锁,进入了互相期待的状态,就进入了死锁。面试中手写这段代码的频率,还是挺高的。
    public class DeadLockDemo {
        public static void main(String[] args) {
            Object object1 = new Object();
            Object object2 = new Object();
            Thread t1 = new Thread(() -> {
                synchronized (object1) {
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (object2) {
                    }
                }
            }, “deadlock-demo-1”);

        t1.start();
        Thread t2 = new Thread(() -> {
            synchronized (object2) {
                synchronized (object1) {
                }
            }
        }, “deadlock-demo-2”);
        t2.start();
    }
}

复制代码
应用咱们下面提到的,带超时工夫的 tryLock 办法,有一方退让,能够肯定水平上防止死锁。
优化技巧
锁的优化实践其实很简略,那就是缩小锁的抵触。无论是锁的读写拆散,还是分段锁,实质上都是为了防止多个线程同时获取同一把锁。咱们能够总结一下优化的个别思路:缩小锁的粒度、缩小锁持有的工夫、锁分级、锁拆散、锁打消、乐观锁、无锁等。

缩小锁粒度
通过减小锁的粒度,能够将抵触扩散,缩小抵触的可能,从而进步并发量。简略来说,就是把资源进行形象,针对每类资源应用独自的锁进行爱护。比方上面的代码,因为 list1 和 list2 属于两类资源,就没必要应用同一个对象锁进行解决。
public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    public synchronized void addList1(String v){
        this.list1.add(v);
    }
    public synchronized void addList2(String v){
        this.list2.add(v);
    }
}

复制代码
能够创立两个不同的锁,改善状况如下:
public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    final Object lock1 = new Object();
    final Object lock2 = new Object();
    public void addList1(String v) {
        synchronized (lock1) {
            this.list1.add(v);
        }
    }
    public void addList2(String v) {
        synchronized (lock2) {
            this.list2.add(v);
        }
    }
}

复制代码
缩小锁持有工夫通过让锁资源尽快的开释,缩小锁持有的工夫,其余线程可更迅速的获取锁资源,进行其余业务的解决。思考到上面的代码,因为 slowMethod 不在锁的范畴内,占用的工夫又比拟长,能够把它挪动到 synchronized 代码快里面,减速锁的开释。
public class LockTimeDemo {
    List<String> list = new ArrayList<>();
    final Object lock = new Object();
    public void addList(String v) {
        synchronized (lock) {
            slowMethod();
            this.list.add(v);
        }
    }
    public void slowMethod(){
    }
}

复制代码
锁分级锁分级指的是咱们文章开始解说的 synchronied 锁的锁降级,属于 JVM 的外部优化。它从偏差锁开始,逐步会降级为轻量级锁、重量级锁,这个过程是不可逆的。
锁拆散咱们在下面提到的读写锁,就是锁拆散技术。这是因为,读操作个别是不会对资源产生影响的,能够并发执行。写操作和其余操作是互斥的,只能排队执行。所以读写锁适宜读多写少的场景。
锁打消通过 JIT 编译器,JVM 能够打消某些对象的加锁操作。举个例子,大家都晓得 StringBuffer 和 StringBuilder 都是做字符串拼接的,而且前者是线程平安的。
但其实,如果这两个字符串拼接对象用在函数内,JVM 通过逃逸剖析剖析这个对象的作用范畴就是在本函数中,就会把锁的影响给打消掉。比方上面这段代码,它和 StringBuilder 的成果是一样的。
String m1(){
    StringBuffer sb = new StringBuffer();
    sb.append(“”);
    return sb.toString();
}

复制代码
End
Java 中有两种加锁形式,一种是应用 synchronized 关键字,另外一种是 concurrent 包上面的 Lock。本课时,咱们具体的理解了它们的一些个性,包含实现原理。上面比照如下:

类别 SynchronizedLock 实现形式 monitorAQS 底层细节 JVM 优化 Java API 分级锁是否性能个性繁多丰盛锁拆散无读写锁锁超时无带超时工夫的 tryLock 可中断否 lockInterruptibly
Lock 的性能是比 synchronized 多的,可能对线程行为进行更细粒度的管制。但如果只是用最简略的锁互斥性能,倡议间接应用 synchronized。有两个起因:

synchronized 的编程模型更加简略,更易于应用

synchronized 引入了偏差锁,轻量级锁等性能,可能从 JVM 层进行优化,同时,JIT 编译器也会对它执行一些锁打消动作

多线程代码好写,但 bug 难找,心愿你的代码即洁净又强健,兼高性能与高牢靠于一身。

正文完
 0