一、基础锁
java中的锁机制的实现基础有2种。第一种来自于JVM的原生支持,这一类通过java虚拟机来直接实现,在语言层面的体现就是synchronized关键字。第二种的是在JUC(即java.util.concurrent)中通过LockSupport类来实现的。
1.1、JVM中的锁
1、synchronized关键字
synchronized是java中一个重要的关键字,用于限制多线程互斥访问临界资源,synchronized的实现依赖于java中的实例对象结构,如图为一个典型的32位java虚拟机中java实例对象结构。
mark word为synchronized起作用的关键。mark word的主要分类为4种:
- 无锁状态
无锁状态很好理解,就是不适用任何会阻塞线程执行的代码来协调执行顺序。
public static void main(String[] args) {
Object head = new Object();
System.out.println(JavaHeadParser.parse(head).toJson());
}
由于JVM默认开启偏向锁,在使用JVM参数-XX:-UseBiasedLocking关闭偏向锁后,可以看到此时打印的结果为
{"markWord": {"hashCode": 0,"age": 0,"lockType": "none"},"instanceSize": 0,"spaceLoss": 0}
- 偏向锁
偏向锁是JDK1.6之后引入来优化synchronized性能的一个方法,它默认当前属于单线程场景或多线程之间是交替使用资源,不存在锁竞争,因此同一线程二次进入同步代码块无需获取锁。使用偏向锁需要加JVM参数-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws JsonProcessingException {
Object head = new Object();
System.out.println(JavaHeadParser.parse(head).toJson());
synchronized (head) {
System.out.println("加锁");
System.out.println(JavaHeadParser.parse(head).toJson());
}
System.out.println(JavaHeadParser.parse(head).toJson());
}
此时的打印结果为
{"markWord":{"threadId":0,"epoch":0,"age":0,"lockType":"biasedLock"},"instanceSize":0,"spaceLoss":0}
加锁
{"markWord":{"threadId":55386,"epoch":1,"age":0,"lockType":"biasedLock"},"instanceSize":0,"spaceLoss":0}
{"markWord":{"threadId":55386,"epoch":1,"age":0,"lockType":"biasedLock"},"instanceSize":0,"spaceLoss":0}
可以看到,在进入同步代码块后,对象头的threadId变为了当前线程的id(这个id是操作系统中的线程id)
- 轻量级锁
轻量级锁是JDK1.6之后引入来优化synchronized性能的一个方法,它默认当前锁竞争很少,就算出现竞争自旋一下也就拿到锁了。
想要看到轻量级锁出现很简单,只要JVM禁止使用偏向锁就可以,因此JVM参数添加-XX:-UseBiasedLocking,代码和偏向锁一样。
此时打印的结果为
{"markWord":{"hashCode":0,"age":0,"lockType":"none"},"instanceSize":0,"spaceLoss":0}
加锁
{"markWord":{"lockPoint":11402638,"lockType":"light"},"instanceSize":0,"spaceLoss":0}
{"markWord":{"hashCode":0,"age":1,"lockType":"none"},"instanceSize":0,"spaceLoss":0}
偏向锁和轻量级锁很相似,偏向锁通过指向线程id来标识线程,轻量级锁通过指向当前线程堆栈锁记录来表示线程。
- 重量级锁
根据上面的对象头结构可以看出,重量级锁是通过objectmonitor来实现的,在JDK1.5之前,是直接通过字节码指令来实现的。
public class Test11 {
public synchronized void testFunction() {
int a = 1;
}
public void testArg() {
Object a = new Object();
synchronized (a) {
int b = 1;
}
}
}
javap -v Test11.class反编译后结果可以看到
对于方法同步和代码块同步分别通过monitorenter/monitorexit以及ACC_SYNCHRONIZED来在JVM层创建重量级锁,但是一般会通过偏向锁和轻量级锁来尝试替代掉重量锁。
测试代码为2个线程并发调用:
public class TestSyncAndLockPerformance {
private static Map<String, IFixedTimeConsumeTask> tasks = new HashMap<>();
private static IFixedTimeConsumeTask syncTask = new SyncConsumeTask();
static {
tasks.put("sync", syncTask);
}
private static void test(int thread, int sleepTime, int sample, ConsumeTaskType consumeTaskType) throws InterruptedException, IllegalAccessException, InstantiationException {
ConsumeTimeArg arg = new ConsumeTimeArg(sleepTime, consumeTaskType);
nullTask.init(arg);
syncTask.init(arg);
lockTask.init(arg);
atomicTask.init(arg);
String sceneName = thread + "-" + sleepTime + "-" + consumeTaskType.toString();
FixedTimeScene fixedTimeScene = new FixedTimeScene();
IResult result = fixedTimeScene.startSync(sceneName, thread, sample, tasks, MultiResult.class);
result.show(sceneName);
}
public static void main(String[] args) throws InterruptedException, InstantiationException, IllegalAccessException, JsonProcessingException {
test(2, 16, 10, ConsumeTaskType.io);
}
}
此时打印的结果为:
{"markWord":{"monitorPoint":102419874,"lockType":"heavy"},"instanceSize":0,"spaceLoss":0}
2、锁的优化
JVM中针对锁的优化操作有很多,包括锁粗化(加大锁的范围避免频繁释放获取锁)、锁消除(通过逃逸分析去除调不存在竞争的临界区的锁)、锁膨胀(消耗资源低的锁会随着场景的不同逐渐膨胀为消耗资源高的锁)。
这里着重介绍一下锁膨胀。锁的膨胀顺序为:无锁->偏向锁->轻量级锁->重量级锁。具体膨胀的顺序见下图。
1.2、JUC中的锁
1、unsafe&LockSupport
JVM原生支持的锁是通过java的实例对象头结构来实现的,对于java类库中的锁实现方式截然不同,主要是通过JNI来调用操作系统底层的CAS和线程操作的API来实现的,其中实现这个操作最基础的一个类就是unsafe类。LockSupport只是做了unsafe调用的简单封装,这里着重接招一下unsafe类。
public final class Unsafe {
......
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
public native void unpark(Object var1);
public native void park(boolean var1, long var2);
......
}
compareAndSwap***是CAS的操作,park会消费许可,unpark会生产许可,调用park如果没有许可会当前线程会阻塞状态。unpark需要指定线程来发送许可,多次生产也只会有1个许可,因此可以把许可看作一个0-1信号量。
其中还实现了一些自旋锁的操作:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
2、AQS
AQS是JDK1.5引入的线程同步器框架,实现一个线程同步器最基本的三要要素:线程阻塞队列、共享资源的互斥访问、线程的阻塞和环境。
本文的主题是介绍锁的实现,因此这里着重介绍一下AQS中共享资源的互斥访问,AQS中需要互斥访问的资源为线程的阻塞队列和共享资源。
- 共享资源
AQS的共享资源使用一个整型的state变量来表示,不过对于共享资源的争用基本都是在实现类实现的,简单的拿CountDownLatch来看:
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
getState()获取共享资源的数量,对于共享资源的修改是通过乐观锁+自旋来实现的,如上图代码,首先取得初始state的值放到变量c,然后修改之后通过CAS来设置回state,修改不成功就重复尝试。
- 线程的阻塞队列
AQS的阻塞队列用的是一个先进先出的链式队列,队列的每个Node都是一个等待资源的线程。修改队列的过程都是通过CAS操作来完成的,如下为AQS入队API的源码(head、tail为队列的头尾):
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
图3
二、衍生锁
Java基础锁中的synchronized在JVM实现,因此衍生锁都是基于JUC来做的,主要是通过JUC的AQS来实现,AQS的实现主要包括两大部分:同步器和锁。同步器主要包括Semaphore、CountDownLatch和LimitLatch,锁的实现主要包括ReentrantLock和ReentrantReadWriteLock,ReentrantReadWriteLock平时使用较少,这里着重介绍可重入锁ReentrantLock的实现。
2.1、ReentrantLock
可重入锁是通过类似0-1同步信号量来实现的,想要lock的线程会去消费信号量,unlock的线程会去生产信号量。
这里面采取的同步信号量包括公平争用和不公平争用两种方式。
- 非公平争用
资源获取
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
核心逻辑在于acquire函数,它表达的意思很简单,申请获取资源(tryAcquire),要是获取不到资源就把自己插入等待队列(acquireQueued),获取完之后等待线程会唤醒(selfInterrupt)。
资源释放
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
资源的释放的同时会唤醒一个阻塞的线程,因此前面调用lock没获取到资源被阻塞的线程会被唤醒。
- 公平争用
和非公平争用类似,只是这里会进入acquire后直接调用hasQueuedPredecessors去排队,源码差异如下:
final void lock() {
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
综合以上:
非公平锁在获取锁的时候是先直接尝试获取锁,如果成功就不进行排队操作
公平锁在获取锁的时候通过hasQueuedPredecessors() 方法判断是否有其它线程等待获取锁的时间超过当前线程,如果有则表明同步队列中已有线程先行进行等待获取锁,就会进入队列排队。
2.3、原子锁
Java中的原子锁对应着不同的数据类型,主要分为三类:
1、基本数据类型
AtomicBoolean、AtomicInteger、AtomicLong
2、数据类型
AtomicIntegerArray、AtomicLongArray
3、引用类型
AtomicMarkableReference、AtomicReference、AtomicReferenceArray、AtomicStampedReference
原子类型的实现很简单,都是基于unsafe类的CAS操作来实现的,这里以AtomicBoolean的getAndSet为例,采用的是CAS+自旋
public final boolean getAndSet(boolean newValue) {
boolean prev;
do {
prev = get();
} while (!compareAndSet(prev, newValue));
return prev;
}
2.4、自旋锁
自旋锁实际和前面列举的不属于一个分类,它只是一种应对临界资源等待时间很短的时的一种处理手段,主要为了避免频繁线程阻塞导致系统调用过多影响性能,操作手法就是通过循环判断来代替直接调用线程阻塞的API。
三、性能测试
3.1、测试方案
1、task每次完成业务操作(通过休眠来模拟IO密集型的操作,自旋来模拟CPU密集型的操作)都会计数
2、任务分为sync、lock、atomic三种
3、最终根据任务计数的总量来确定那种类型的任务计数最高,以此来确定性能差异
例如test(1, 16, 10, ConsumeTaskType.io);表示1个线程业务操作时间16ms,IO密集型操作,运行10s
public class TestSyncAndLockPerformance {
private static Map<String, IFixedTimeConsumeTask> tasks = new HashMap<>();
private static IFixedTimeConsumeTask syncTask = new SyncConsumeTask();
private static IFixedTimeConsumeTask lockTask = new LockConsumeTask();
private static IFixedTimeConsumeTask atomicTask = new AtomicConsumeTask();
static {
tasks.put("sync", syncTask);
tasks.put("lock", lockTask);
tasks.put("atomic", atomicTask);
}
private static void test(int thread, int sleepTime, int sample, ConsumeTaskType consumeTaskType) throws InterruptedException, IllegalAccessException, InstantiationException {
ConsumeTimeArg arg = new ConsumeTimeArg(sleepTime, consumeTaskType);
syncTask.init(arg);
lockTask.init(arg);
atomicTask.init(arg);
String sceneName = thread + "-" + sleepTime + "-" + consumeTaskType.toString();
FixedTimeScene fixedTimeScene = new FixedTimeScene();
IResult result = fixedTimeScene.startSync(sceneName, thread, sample, tasks, MultiResult.class);
result.show(sceneName);
}
public static void main(String[] args) throws InterruptedException, InstantiationException, IllegalAccessException, JsonProcessingException {
//1,2,5,10,20个线程休眠16ms
test(1, 16, 10, ConsumeTaskType.io);
test(2, 16, 10, ConsumeTaskType.io);
test(5, 16, 10, ConsumeTaskType.io);
test(10, 16, 10, ConsumeTaskType.io);
test(20, 16, 10, ConsumeTaskType.io);
//1,2,5,10,20个线程休眠128ms
test(1, 128, 10, ConsumeTaskType.io);
test(2, 128, 10, ConsumeTaskType.io);
test(5, 128, 10, ConsumeTaskType.io);
test(10, 128, 10, ConsumeTaskType.io);
test(20, 128, 10, ConsumeTaskType.io);
//1,2,5,10,20个线程休眠16ms
test(1, 16, 10, ConsumeTaskType.cpu);
test(2, 16, 10, ConsumeTaskType.cpu);
test(5, 16, 10, ConsumeTaskType.cpu);
test(10, 16, 10, ConsumeTaskType.cpu);
test(20, 16, 10, ConsumeTaskType.cpu);
//1,2,5,10,20个线程休眠128ms
test(1, 128, 10, ConsumeTaskType.cpu);
test(2, 128, 10, ConsumeTaskType.cpu);
test(5, 128, 10, ConsumeTaskType.cpu);
test(10, 128, 10, ConsumeTaskType.cpu);
test(20, 128, 10, ConsumeTaskType.cpu);
}
}
3.2、测试结果
线程数 | 业务耗时 | 业务类型 | 任务完成计数(sync/lock/aotmic) |
---|---|---|---|
1 | 16ms | IO | 598/598/597 |
2 | 16ms | IO | 595/1188/1202 |
5 | 16ms | IO | 594/2745/2980 |
10 | 16ms | IO | 597/4745/5990 |
20 | 16ms | IO | 596/10239/11980 |
1 | 128ms | IO | 77/77/77 |
2 | 128ms | IO | 77/154/154 |
5 | 128ms | IO | 77/347/385 |
10 | 128ms | IO | 77/552/770 |
20 | 128ms | IO | 77/1094/1540 |
1 | 16ms | CPU | 625/603/625 |
2 | 16ms | CPU | 605/1147/1166 |
5 | 16ms | CPU | 625/1730/3035 |
10 | 16ms | CPU | 513/3683/4567 |
20 | 16ms | CPU | 536/2248/4172 |
1 | 128ms | CPU | 76/76/76 |
2 | 128ms | CPU | 75/142/152 |
5 | 128ms | CPU | 68/278/375 |
10 | 128ms | CPU | 74/655/719 |
20 | 128ms | CPU | 76/902/800 |
1、synchronized在多线程性能效果很差,具体原因可以打印观察锁文件头得知,在一开始发生资源竞争时,锁就已经膨胀成重量级锁了,而JDK1.5之后锁的优化手段适用的场景很小。
2、提升争用的线程无法提升synchronized的性能。
3、对于低耗时(16ms以下)的业务操作,lock和atomic性能差异越发明显。
四、总结
4.1、锁机制总览图
图4
4.2、不同锁的适用范围
1、synchronized代码简单,但是对多线程并发提升很小,建议尽量少用。
2、JUC的可重入锁适用于资源竞争激烈的条件下使用,而且性能比synchronized优异很多,随着并发线程的提高,性能差异变得越发明显。
3、对于单变量资源共享的场景尽量使用原子操作,原子操作性能会明显优于lock,特别是在并发高的场景。