文章目录
Java的锁是多线程编程中必须理解的概念,其synchronized
关键字本质上就是一个互斥锁的实现。
本文比较在Java中使用锁和使用atomic包中类型在多线程环境下的性能差异,并探讨JVM获取锁的一般步骤。
假设要对一个整数做++操作1000000次。在使用锁(本文使用的是synchronized
关键字,也可以用Java中其他的锁Reentrantlock,Readwritelock等)的情况下,可以这样实现。先新建一个MyInt
类,表示并发情况下的有数据竞争的对象,该对象需要加锁,代码如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| package problem1; public class MyInt { private int intValue; public MyInt(int _intValue) { intValue = _intValue; } public void inc(){ synchronized(this) { intValue ++; } } public boolean incWithLimit(int limit){ synchronized(this) { if(intValue < limit){ intValue ++; return true; } return false; } } public int getIntValue() { return intValue; } public void setIntValue(int intValue) { this.intValue = intValue; } }
|
注意,inc
方法,虽然只有简单的一步++操作,也需要加锁,因为这个在JVM中是分成2步来做的,先读再做+1运算,即与x=x+1
这样的代码等价。因为要限制inc
的上限1000000次,实际使用的是incWithLimit
方法。还要有一个类,用于包装MyInt
,并设置上限,代码如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| package problem1; public class IncWithLock implements Runnable { private MyInt intValue; private final int maxM; private int count; public IncWithLock(MyInt _intValue) { intValue = _intValue; maxM = 1000000; count = 0; } public IncWithLock(MyInt _intValue, int _maxM) { intValue = _intValue; maxM = _maxM; count = 0; } @Override public void run() { while(intValue.incWithLimit(maxM)) {count++;} } public int getIntValue() { return intValue.getIntValue(); } public void setIntValue(MyInt intValue) { this.intValue = intValue; } public int getMaxM() { return maxM; } }
|
以上代码中,注释掉的while(true)
那一段,是使用inc
方法来实现的,测试中也是可用的。最后是main
方法的代码,有了之前的准备,main
的就很简单了,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| package problem1; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Problem1 { public static void main(String[] args) { int numN = 3; MyInt intVal = new MyInt(0); ExecutorService exec = Executors.newFixedThreadPool(numN); final long start = System.currentTimeMillis(); for(int i = 0; i < numN; i++) { exec.execute(new IncWithLock(intVal)); } while(!exec.isTerminated()) { exec.shutdown(); } long t = System.currentTimeMillis()-start; System.out.println("Use ["+t+"] ms to get "+intVal.getIntValue()); } }
|
以上代码中,使用ExecutorService
实现线程池,这样比较便于控制线程个数;intVal
是有数据竞争的数据,将被N(N分别取值3,30,300,1000)个线程竞争;最后在全部分支线程执行完后,打印执行所需的时间。
上述的是有锁情况下的代码,使用atomic包中的AtomicInteger
可以完全避免使用锁,AtomicInteger
使用了compareAndSet
方法,在一个指令中完成比对原值并设置新值,因此无需使用锁。MyInt
的代码如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| package problem2; import java.util.concurrent.atomic.AtomicInteger; public class MyInt { private AtomicInteger intValue; public MyInt(AtomicInteger _intValue) { intValue = _intValue; } public void inc(){ int curr = intValue.get(); intValue.compareAndSet(curr, curr+1); } public boolean incWithLimit(int limit){ int curr = intValue.get(); while (curr < limit) { if(intValue.compareAndSet(curr, curr+1)){ return true; }else{ curr = intValue.get(); } } return false; } public AtomicInteger getIntValue() { return intValue; } public void setIntValue(AtomicInteger intValue) { this.intValue = intValue; } }
|
无锁版本的MyInt
与有锁版本的有一些差异。首先,对象不再是Integer
类型,而是AtomicInteger
类型;其次,没有再使用锁,synchronized
关键字没有出现。由于很多实现已经包装在MyInt
中,MyInt
的包装类IncWithoutLock
与有锁版本的IncWithLock
差异很小,如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| package problem2; public class IncWithoutLock implements Runnable{ private MyInt intValue; private final int maxM; private int count; public IncWithoutLock(MyInt _intValue) { intValue = _intValue; maxM = 1000000; count = 0; } public IncWithoutLock(MyInt _intValue, int _maxM) { intValue = _intValue; maxM = _maxM; count = 0; } @Override public void run() { while(intValue.incWithLimit(maxM)) {count++;} } public Integer getIntValue() { return intValue.getIntValue().get(); } public void setIntValue(MyInt intValue) { this.intValue = intValue; } public int getMaxM() { return maxM; } }
|
注意,注释掉的while(true)
那段代码,在无锁版本下是有可能会出错的,因为get()
和inc()
方法虽然本身都是线程安全的,但是它们被分别调用,其间可能插入其他的操作,因此,这段代码可能会有数据竞争的风险。所以,对于无锁版本,使用incWithLimit
方法是唯一选择。main
方法就几乎完全相同了,如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package problem2; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class Problem2 { public static void main(String[] args) { int numN = 3; MyInt intVal = new MyInt(new AtomicInteger(0)); ExecutorService exec = Executors.newFixedThreadPool(numN); final long start = System.currentTimeMillis(); for(int i = 0; i < numN; i++) { exec.execute(new IncWithoutLock(intVal)); } while(!exec.isTerminated()) { exec.shutdown(); } long t = System.currentTimeMillis()-start; System.out.println("Use ["+t+"] ms to get "+intVal.getIntValue()); } }
|
运行上述两个版本的程序,分别取线程数为3,30,300,1000,在我的机器上执行耗时(每一种情况执行5次取平均值)如下,
3 |
78.8 |
59.8 |
30 |
77.6 |
62.4 |
300 |
184.2 |
179.4 |
1000 |
2186.0 |
2077.6 |
可见,无锁版本的程序始终要比有锁的略快一些,在线程较少(<=CPU个数)的时候,尤其明显。同时,也可以看到,线程并不是越多越好,过多的线程本身就会有很多线程消耗,另外引起的锁竞争现象也会愈发明显。
以上是有锁和无锁情况下的程序性能对比。下面探讨JVM获取锁的一般步骤,主要的步骤是:偏向锁->轻量级锁->自旋锁->重量级锁。在执行过程中,还有执行期间的锁粗化和锁消除等优化。
首先说一下重量级锁,重量级锁类似于操作系统中的锁操作,不过是JVM的对象监视器Monitor实现的。当线程需要竞争某个资源的时候,先进入竞争队列,如果竞争不到锁,本线程就在竞争队列中等待下一次竞争锁;如果竞争到资源,就成为这个资源的Owner,并与其他获取资源的线程互斥;如果调用了wait,说明线程被阻塞,等待阻塞被notify,然后重新进入竞争队列。以上的竞争队列都是利用CAS实现的的无锁队列。由于需要维护多个队列,重量锁的性能消耗实际上是比较大的,但是也更加保险。
既然重量级锁资源消耗比较多,那么稍微轻量级一些的自旋锁就避免了维护多个队列的情况。自旋锁的实现原理是,在竞争不到对象的时候,线程空转几周,即忙等,然后重新尝试获取锁。如果另一个线程对锁的占用时间很短,那么第二次的尝试,很有可能就能拿到锁。过程中,没有复杂的数据结构,因此,在第二次可以拿到锁的情况下,性能要好于重量级锁。但是,对于自旋后仍然不能获取到锁的情况,就把任务交给重量级锁,这样,比普通的重量级锁要多出几次自旋的消耗。为了避免这样的问题,JDK 1.6中引入了自适应自旋锁,对第二次尝试的间隔时间做了自适应处理,比如,默认情况下自旋10次,但是这个对象被占用的时间较长,那么JDK会逐渐延长这个自适应的时间,比如100,发现获取到对象的成功率提高了,那么对这个对象就会间隔100次自旋再去获取。如果无论怎么调整自旋的次数获取到锁的概率都很低,那么就跳过获取自旋锁。
自旋锁的问题是每次仍然要去获取锁然后再进行对象的读写,轻量级锁用CAS操作进一步优化了锁获取。CAS(compareAndSet)在一个指令内完成了对象的读和写,轻量级锁在需要对象资源的时候,先去用CAS读写对象头的轻量级锁标志位,如果成功,说明对象没有被其他线程占用,那么本线程占有了这个资源,并设置本线程为对象的Owner和对象头指向本线程的栈帧,即线程栈和对象互相有指针指向对方,然后执行同步块;如果不成功,检查是否对象头指向本线程,如果是,那么说明本线程已经占有了这个对象,是可重入的锁,继续执行同步块;否则,说明该对象已经被其他线程占有,轻量级锁获取不成功,升级到上一级自旋锁来处理。
轻量级锁无论在有没有竞争的时候,都会需要CAS操作,并且在CAS失败之后,还需要检查是否可重入,偏向锁减少了这两部分的消耗。在获取竞争对象的时候,偏向锁会偏向第一个获取到这个锁的线程。第一个线程在首次获取到偏向锁的时候,将对象头设置为偏向锁模式,并写入本线程的线程号,在下次获取锁的时候,如果仍然是本线程的请求,那么本线程直接获取到竞争对象,无需做同步,也无需CAS操作;如果是另一个线程的请求,因为线程号和之前不同,偏向锁获取不成功,升级到轻量级锁模式来处理。偏向锁的使用也是自适应的,当JVM发现每次偏向锁的请求多数都会发生锁升级,那么就会禁止偏向锁。
由上述分析可以发现,越轻量级的锁,违反锁成立的条件也越容易达到。重量级锁,无法违反;自旋锁,第二次请求获取(自旋)失败即违反;轻量级锁,第一次请求获取对象失败即违反;偏向锁,有第二个线程请求资源即违反。每一次违反,都需要将锁升级到更重量级一级的锁来处理,这个步骤也叫做锁膨胀。
除去以上的各种锁,JVM还引入了锁消除和锁粗化技术,这两个技术都用到了JVM的即时编译器。锁消除是,在程序运行过程中,发现一些在同步块中的对象,不可能被外部的其他线程使用,那么就将这部分对象的代码移动到同步块外部执行,减少同步块的代码量。判定无法被其他线程使用的依据主要是来自逃逸分析,检查堆上的数据是否会逃逸出去被其他线程访问,该技术仍然在发展中。锁粗化是,在一系列连续操作中,发现对某个对象反复的加锁,因为加锁本身也有性能消耗,所以只加一次锁,把中间的代码都放入到同步块内,会减少加锁的次数,从而提高性能。锁粗化的思路和锁消除某种程度上是相反的。
本文主要参考了以下文章,,,。