多线程、并发
Table of Contents generated with DocToc (opens new window)
# 思维导图
# synchronized
关键字;
可以锁方法,锁代码片段;
是可重入的;
# synchronized锁升级的过程和原理
# 用户态和内核态
内核态:其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态:用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。
# 用户态到内核态的切换
用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核的事情,例如从硬盘读取数据,或者从硬盘获取输入,而唯一可以做这些事情的就是操作系统即内核态(synchronized中依赖的monitor也需要依赖操作系统完成,因此需要用户态到内核态的切换)所以程序就需要先向操作系统请求以程序的名义来执行这些操作。
这时候就需要:将用户态程序切换到内核态,但是不能控制在内核态中执行的命令 这部分先不做太多解释,需要知道的是synchronized是依赖操作系统实现的,因此在使用synchronized同步锁的时候需要进行用户态到内核态的切换。
# synchronized内核态切换
简单来说在JVM中synchronized重量级锁的底层原理monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。
# 为什么优化synchronized
在JDK1.5之前,synchronized是重量级锁,1.6以后对其进行了优化,有了一个 无锁-->偏向锁-->自旋锁-->重量级锁 的锁升级的过程,而不是一上来就是重量级锁了,为什么呢?因为重量级锁获取锁和释放锁需要经过操作系统,是一个重量级的操作。对于重量锁来说,一旦线程获取失败,就要陷入阻塞状态,并且是操作系统层面的阻塞,这个过程涉及用户态到核心态的切换,是一个开销非常大的操作。而研究表明,线程持有锁的时间是比较短暂的,也就是说,当前线程即使现在获取锁失败,但可能很快地将来就能够获取到锁,这种情况下将线程挂起是很不划算的行为。所以要对"synchronized总是启用重量级锁"这个机制进行优化。
# 升级原理
# Java对象在内存中的布局
在Java虚拟机中,普通对象在内存中分为三块区域:对象头、实例数据、对齐填充数据,而对象头包括markword(8字节)和类型指针(开启压缩指针4字节,不开启8字节,如果是32g以上内存,都是8字节),实例数据就是对象的成员变量,padding就是为了保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。数组对象比普通对象在对象头位置多一个数组长度。
例如,Object o = new Object();(16g,开启指针压缩)在内存中占了 8(markWord)+4(classPointer)+4(padding)=16字节。
# Mark Word
(Hotspot 64位虚拟机的实现):
# synchronized升级
# 无锁态
偏向锁位、锁标志位的值为:0、01,此时对象是没有做任何同步限制的
# 偏向锁
偏向锁位、锁标志位的值为:1、01
有研究表明,其实在大部分场景都不会发生锁资源竞争,并且锁资源往往都是由一个线程获得的。如果这种情况下,同一个线程获取这个锁都需要进行一系列操作,比如说CAS自旋,那这个操作很明显是多余的。偏向锁就解决了这个问题。其核心思想就是:一个线程获取到了锁,那么锁就会进入偏向模式,当同一个线程再次请求该锁的时候,无需做任何同步,直接进行同步区域执行。这样就省去了大量有关锁申请的操作。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。
偏向锁加锁过程
访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
如果为可偏向状态,则判断线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
执行同步代码。
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁。在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因。可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁。偏向锁撤销导致的stw
通过加偏向锁的方式可以看到,对象中记录了获取到对象锁的线程ID,这就意味如果短时间同一个线程再次访问这个加锁的同步代码或方法时,该线程只需要对对象头Mark Word中去判断一下是否有偏向锁指向它的ID,有的话就继续执行逻辑了,没有的话,会CAS尝试获得锁,如果持有锁的线程在全局安全点检查时,不需要再使用该锁了则获取成功,程序继续执行,反之则获取锁失败,撤销偏向状态,升级为轻量级锁,即自旋锁。
# 轻量级锁(自旋锁)
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,销偏向锁状态,将锁对象markWord中62位修改成指向自己线程栈中Lock Record的指针(CAS抢)执行在用户态,消耗CPU的资源(自旋锁不适合锁定时间长的场景、等待线程特别多的场景),此时锁标志位为:00。
自旋策略
JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
自适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 大家现在觉得没这么low了吧。
轻量级锁的加锁过程
# wait和notify(All)的原理
synchronized维护的对象锁有2个队列,一个_EntryList,一个_WaitSet。加锁时,线程获取到锁进入临界区(_owner),若线程获取不到锁,便加入_EntryList,进入blocked阻塞状态。线程获取到锁后,调用wait方法,被加入_WaitSet队列,进入waiting状态,然后等待唤醒。当线程被唤醒的时候,被唤醒的线程需要再次获取对象锁。唤醒线程,我们可以调用notify和notifyAll方法,notify只是随机的唤醒一个_WaitSet中的线程。notifyAll会唤醒所有处于_WaitSet中的线程。不管唤醒一个线程,还是唤醒多个线程,最终获得对象锁的,只有一个线程。如果_EntryList同时存在竞争锁资源的线程,那么被唤醒的线程还需要和_EntryList中的线程一起竞争锁资源。但是JVM保证最终只会让一个线程获取到锁。
调用wait方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后当其他线程调用notify或者notifyall以后,会选择从等待队列中唤醒任意一个线程,而执行完notify方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按monitorexit指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了
# wait和notify为什么需要在synchronized里面
wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关的,所以wait必须要获得一个监视器锁
而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。
# volatile
保证修饰的变量的可见性
对修饰的变量进行操作时,禁止指令重排
# CAS
Compare And Swap 顾名思义,比较并交换一般是有一个期望的原值,要修改的现值,一般在底层实现的时候,都是Unsafe类中的native方法,可以保证线程安全
在Unsafe中方法会有四个参数:(Object o, long offset,int expected,int x)
分别是当前对象this,要修改属性的内存地址偏移量,期望的原值,要修改的值,
期望值与通过内存地址获取的值相等时候,即执行swap
# AQS(AbstractQueuedSynchronizer)
# 框架图
# 原理
AQS 核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是 CLH 队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten 队列,是单向链表,AQS 中的队列是 CLH 变体的虚拟双向队列(FIFO),AQS 是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
主要原理图如下:
提示
AQS 使用一个 Volatile 的 int 类型的成员变量来表示同步状态,通过内置的 FIFO 队列来完成资源获取的排队工作,通过 CAS 完成对 State 值的修改。
# 同步队列
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer{
// 指向同步队列的头部
private transient volatile Node head;
// 指向同步队列的尾部
private transient volatile Node tail;
// 同步状态标识
private volatile int state;
// 省略......
}
2
3
4
5
6
7
8
9
# 继承了AbstractOwnableSynchronizer
AbstractQueuedSynchronizer抽象类继承了此AbstractOwnableSynchronizer,AbstractOwnableSynchronizer中主要是设置获取锁的当前线程信息,当使用tryAcquire获取到锁以后,就会调用
protected final void setExclusiveOwnerThread(Thread thread) { exclusiveOwnerThread = thread; }
1
2
3设置获取到锁的线程为当前线程;
当使用tryRelease释放锁的时候,调上面的方法,参数为null,表示已无线程持有锁
# 一些实现
AQS内部以虚拟队列的形式实现了线程对锁资源的获取(tryAcquire)和释放(tryRelease),不过AQS中并没有对这两个方法提供具体实现,而是交由其子类在使用时自己实现,更加灵活
AQS中有一个Node内部类,即是其等待队列的元素的数据结构
static final class Node { // 共享模式 static final Node SHARED = new Node(); // 独占模式 static final Node EXCLUSIVE = null; // 标识线程已处于结束状态 static final int CANCELLED = 1; // 等待被唤醒状态 static final int SIGNAL = -1; // Condition条件状态 static final int CONDITION = -2; // 在共享模式中使用表示获得的同步状态会被传播 static final int PROPAGATE = -3; // 等待状态,存在CANCELLED、SIGNAL、CONDITION、PROPAGATE四种 volatile int waitStatus; // 同步队列中前驱结点 volatile Node prev; // 同步队列中后继结点 volatile Node next; // 获取锁资源的线程 volatile Thread thread; // 等待队列中的后继结点(与Condition有关,稍后会分析) Node nextWaiter; // 判断是否为共享模式 final boolean isShared() { return nextWaiter == SHARED; } // 获取前驱结点 final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } // 省略代码..... }
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
# ReentrantLock
ReentrantLock中的Sync内部类继承了AbstractQueuedSynchronizer,主要实现父类的以下方法:因为ReentrantLock是独占锁
//独占模式下获取锁的方法 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } //独占模式下释放锁的方法 protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
1
2
3
4
5
6
7
8如果是ReentrantReadWriteLock共享锁(实现了ReadWriteLock接口),会实现下列方法:
//共享模式下获取锁的方法 protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } //共享模式下释放锁的方法 protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); }
1
2
3
4
5
6
7
8两者都会实现:
//判断是否持有独占锁的方法 protected boolean isHeldExclusively() { throw new UnsupportedOperationException(); }
1
2
3
4
# 非公平锁
主要是NonfairSync内部类
static final class NonfairSync extends Sync { private static final long serialVersionUID = 7316153563782823691L; // 加锁 final void lock() { // 执行CAS操作,修改同步状态标识获取锁资源 // 因为存在多条线程同时修改的可能,所以需要用CAS操作保证原子性 if (compareAndSetState(0, 1)) // 成功则将独占锁线程设置为当前线程 setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); // 否则再次请求同步状态 } protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15还有Sync中的nonfairTryAcquire方法
// ReetrantLock类内部类 - Sync类 abstract static class Sync extends AbstractQueuedSynchronizer { // NonfairTryAcquire方法 final boolean nonfairTryAcquire(int acquires) { // 获取当前执行线程及当前同步器的状态标识值 final Thread current = Thread.currentThread(); int c = getState(); // 判断同步状态是否为0,并尝试再次获取同步状态 if (c == 0) { //执行CAS操作尝试修改同步标识 if (compareAndSetState(0, acquires)) { // 如果为true则将独占锁线程设置为当前线程 setExclusiveOwnerThread(current); return true; } } // 如果当前线程已获取锁,属于重入锁,再次获取锁后将state值加1 else if (current == getExclusiveOwnerThread()) { // 对当前state值进行自增 int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); // 设置当前同步状态,当前只有一个线程持有锁,因为不会发生线程安全问 // 题,可以直接执行 setState(nextc); setState(nextc); return true; } return false; } //省略...... }
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
非公平的原因:
注意
如果构造ReentrantLock时选择默认构造函数,其内部的sync对象的实现就是NonfairSync类,在调用lock方法时,每次都会先尝试一次取获取锁:
CAS修改AQS中的state变量,如果其设置成功(0->1),则直接拿到锁,并完成AbstractOwnableSynchronizer中的占用锁线程的设置。
注意:这里其实是假设没有锁然后直接去加上,但实际可能与假设不符
如果修改失败,证明锁已被获取,则执行AQS中的acquire方法:
AQS中的acquire方法:
public final void acquire(int arg) { // 再次尝试获取同步状态 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
1
2
3
4
5
6- 先调用NonfairSync类中实现的tryAcquire方法(实际是调用父类Sync中的nonfairTryAcquire方法),此方法在上面写了,即是再次尝试获取锁,如果改state(0->1)返回true;
- 否则获取锁的持有者,如果
当前的线程即是持有锁的线程,那么将state加上传入的acquire
而因为这里使用的是CAS去修改state,因此只要任意一个线程调用
nonfairTryAcquire(acquires)
方法并设置成功即可获取锁,不管该线程是新到来的还是已在同步队列的线程,毕竟这是非公平锁,并不保证同步队列中的线程一定比新到来线程请求(可能是head结点刚释放同步状态然后新到来的线程恰好获取到同步状态)先获取到锁如果tryAcquire也没有获取到锁,那么就将这个线程封装为Node结点加入到同步队列的队尾并将此线程阻塞,最终返回这个结点
private Node addWaiter(Node mode) { // 将请求同步状态失败的线程封装成Node节点 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; // 如果是第一个节点加入肯定为空,跳过。 // 如果不是第一个节点则直接执行CAS入队操作,尝试在尾部快速添加 if (pred != null) { node.prev = pred; // 使用CAS执行尾部节点替换,尝试在尾部快速添加 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 如果第一次加入或者CAS操作没有成功执行enq入队操作 enq(node); return node; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18添加到同步队列的节点都会进入一个自旋过程,每个节点都在观察时机等待条件满足时,开始获取同步状态,然后从同步队列中退出并结束自旋,回到之前的
acquire()
方法,自旋过程是在acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
方法中执行的,代码如下:final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; // 阻塞挂起标识 // 一个死循环自旋 for (;;) { // 获取前驱节点 final Node p = node.predecessor(); // 如果p为头节点才尝试获取同步状态 if (p == head && tryAcquire(arg)) { // 将node设置为头节点 setHead(node); // 将原有的head节点设置为null方便GC p.next = null; // help GC failed = false; return interrupted; } // 如果前驱节点不是head,判断是否阻塞挂起线程 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) // 如果最终都没能成功获取同步状态,结束该线程的请求 cancelAcquire(node); } }
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当前节点中的线程在死循环(自旋)执行过程中,当
节点的前驱节点为头节点时
开始尝试获取同步状态(符合FIFO原则)。head节点是当前占有同步状态标识的线程节点,只有当head节点释放同步状态唤醒后继节点时,后继节点才可能获取同步状态
,所以这也是为什么说:只有当节点的前驱节点为头节点时才开始尝试获取同步状态的原因,在此之外的其他时候将被挂起
。如果当前节点已经开始尝试获取同步状态,进入if后则会执行setHead()
方法将当前线程设置为head结点:// 将传递的节点设置为同步队列的头节点 private void setHead(Node node) { head = node; // 清空当前节点存储的数据信息 node.thread = null; node.prev = null; }
1
2
3
4
5
6
7
总结
非公平的核心就是,在lock获取锁的时候,会先去尝试0->1修改一次state,这是第一点;其次在tryAcquire的时候,如果当前的state=0,非公平的情况下并不会去判断同步队列中是否存在等待获取资源的线程或当前线程自己是不是同步队列头结点
,而只是执行获取操作,这是第二点
# 公平锁
与非公平锁有两点不同:
即注释中的这句话:Don't grant access unless recursive call or no waiters or is first递归调用是可重入锁的情景;no waiter是当前同步队列中没有等待的线程;或者当前线程就是同步队列头结点;
只有这三种情况当前线程才会进行下一步获取锁的操作,否则获取锁失败
// java.util.concurrent.locks.ReentrantLock
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}
2
3
4
5
6
7
8
9
10
11
双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当 h != t 时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了 tail 指向 head,没有将 head 指向 tail,此时队列中有元素,需要返回 True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时 s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果 s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。
# 获取锁的过程
# 线程池
← 框架 HTTP、HTTPS→