Java 中最烦人的,便是多线程,一不小心,代码写的比单线程还慢,这就让人十分为难。

通常情况下,咱们会运用 ThreadLocal 完成线程关闭,比方防止 SimpleDateFormat 在并发环境下所引起的一些不一致情况。其实还有一种处理办法。经过对parse办法进行加锁,也能确保日期处理类的正确运转,代码如图。

多线程代码,性能怎么优化!

1. 锁很坏

可是,锁这个东西,很坏。就像你的贞节锁,一开一闭热情早已烟消云散。

所以,锁对功用的影响,是十分大的。对资源加锁今后,资源就被加锁的线程所独占,其他的线程就只能排队等候这个锁。此刻,程序由并行履行,变相的变成了顺序履行,履行速度天然就下降了。

下面是敞开了50个线程,运用ThreadLocal和同步锁办法功用的一个比照。

Benchmark  Mode  Cnt       Score      Error   UnitsSynchronizedNormalBenchmark.sync
thrpt      10  2554.628  5098.059  ops/msSynchronizedNormalBenchmark.threadLocal  thrpt   10  3750.902   103.528  ops/ms
========去掉事务影响========  
Benchmark  Mode  Cnt          Score        Error   UnitsSynchronizedNormalBenchmark.sync         
thrpt      10    26905.514    1688.600  ops/msSynchronizedNormalBenchmark.threadLocal  thrpt   10  7041876.244  355598.686  ops/ms

可以看到,运用同步锁的办法,功用是比较低的。假如去掉事务本身逻辑的影响(删掉履行逻辑),这个差异会更大。代码履行的次数越多,锁的累加影响越大,对锁本身的速度优化,是十分重要的。

咱们都知道,Java 中有两种加锁的办法,一种便是常见的synchronized 要害字,别的一种,便是运用 concurrent 包里边的 Lock。针对于这两种锁,JDK 本身做了许多的优化,它们的完成办法也是不同的。

2. 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

咱们再来看下同步代码块的字节码。可以看到,字节码是经过monitorentermonitorexit两个指令进行控制的。

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 中,从头进行锁的争夺。

如此循环往复,线程就可按顺序排队履行。

3. 分级锁

JDK1.8中,synchronized 的速度已经有了显著的提高。那它都做了哪些优化呢?答案便是分级锁。JVM会根据运用情况,对synchronized 的锁,进行晋级,它大体可以按照下面的途径:倾向锁->轻量级锁->重量级锁。

锁只能晋级,不能降级,所以一旦晋级为重量级锁,就只能依靠操作系统进行调度。

和锁晋级联系最大的便是目标头里的 MarkWord,它包括Thread IDAgeBiasedTag四个部分。其间,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禁用倾向锁,理论上会有一些功用提高,但实际上并不确定。

4. Lock

在 concurrent 包里,咱们可以发现ReentrantLockReentrantReadWriteLock两个类。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();        
}    
}...

公正锁与非公正锁

咱们平常用到的锁,都是非公正锁。可以回过头来看一下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   UnitsFairVSNoFairBenchmark.fair
thrpt   10    186.144    27.462  ops/msFairVSNoFairBenchmark.nofair  
thrpt   10  35195.649  6503.375  ops/ms

5. 锁的优化技巧

死锁

咱们可以先看一下锁抵触最严峻的一种情况:死锁。下面这段示例代码,两个线程别离持有了对方所需求的锁,进入了彼此等候的状况,就进入了死锁。面试中手写这段代码的频率,仍是挺高的。

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。本课时,咱们具体的了解了它们的一些特性,包括完成原理。下面比照方下:

类别 完成办法 底层细节 分级锁是否功用特性 锁别离

Synchronized monitor JVM 单一 无读写锁锁超时

Lock AQS 优化Java API 丰富 无带超时时刻的tryLock

Lock的功用是比synchronized多的,可以对线程行为进行更细粒度的控制。但假如只是用最简略的锁互斥功用,主张直接运用synchronized。有两个原因:

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

  • synchronized引入了倾向锁,轻量级锁等功用,可以从JVM层进行优化,一起,JIT编译器也会对它履行一些锁消除动作

多线程代码好写,但bug难找,期望你的代码即干净又强壮,兼高功用与高牢靠于一身。