锁分类
按照对共享资源是否上锁,可以分为:
- 乐观锁
- 悲观锁
乐观锁的定义:
乐观锁是一种乐观思想,认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在提交修改的时候去判断一下,在此之前有没有其他的线程也修改了这个数据:
如果其他的线程还没有提交修改,那么当前线程就将自己修改的数据成功写入;如果其他的线程已经提交了修改,则当前线程会根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在java中是采用CAS 算法实现的,具体表现就是juc包下的原子类。使用CAS算法的代码也被称为无锁编程(Lock-free)
悲观锁的定义:
悲观锁是一种悲观思想,认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
悲观锁在java中的实现就是synchronized和Lock接口的实现类。
上锁失败之后,是继续重试还是阻塞线程,可以分为:
- 自旋锁
- 适应性自旋锁
因为java中的线程其实是对应的操作系统中的线程,阻塞和唤醒一个线程是需要切换到内核态进行的,频繁的进行内核态和用户态的切换会带来性能消耗。因为CPU处理器的速度很快,可能发生线程刚进入阻塞,cpu就已经执行完了上一条指令,又需要再次切换内核态来唤醒线程。为了避免频繁的切换上下文,当后面的线程请求锁失败的时候,不会直接就阻塞,而是进行自旋操作,也就是做do-while操作,让cpu执行无意义的操作,等待共享资源的释放。
可以看到这里就有个while操作,但是不会一直就等待下去,超过了一定次数之后就会进入阻塞,默认是 10 次,可以使用参数 -XX:PreBlockSpin来更改。
而适应性自旋锁则是对自旋锁做出的更新,适应性自旋锁并不会自旋固定次数,而是会根据同一个锁同过自旋获取到锁的状态来判断是否进行自旋:
- 如果在同一个锁上,自旋的线程获得了锁,并且正在运行,那么就会认为当前线程也会有很大概率获得锁,会进行自旋。
- 如果在同一个锁上,很少有线程通过自旋获取到锁,那就不会进行自旋,直接阻塞。
根据共享资源的竞争激烈情况,对synchronized上锁分为四个状态:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
无锁就是使用CAS算法来进行对共享资源的获取。
偏向锁就是会偏向被第一个获取锁的线程,如果在之后的执行过程中,第一个获取锁的线程一直持有这个锁,没有其它锁来进行竞争,那么第一个线程就不需要进行CAS操作来竞争锁。
当有其他线程来竞争这个锁,但是线程之间并不会发生冲突,而是交替持有锁,那么锁就会升级为轻量级锁,轻量级锁是不会尝试进行自旋操作的。
在多个线程尝试同时获取锁的时候,获取失败就会进行自旋操作,仍然获取失败就会升级为重量级锁,称为重量级锁是因为,当因为竞争锁失败而进入阻塞和唤醒阻塞的线程都是需要切换到内核态来操作的,比较消耗性能,所以称之为重量级锁。
是否根据排队顺序来选择获取锁:
- 公平锁
- 非公平锁
根据申请锁的顺序来进行排队,公平锁的整体效率会比较低,因为等待队列中除了队头的第一个线程以外,其他所有线程都会被阻塞住,而阻塞线程的唤醒需要操作系统陷入内核态。
非公平锁效率比公平锁高在于线程有可能不需要阻塞直接就能获取到锁,但是低优先级的线程可能就会饿死,一直没有执行机会。
根据锁是否可以重复使用:
- 可重入锁
- 不可重入锁
可重入锁在尝试获取同一个锁的时候,会直接成功,并且锁的重入次数会加一,当线程退出临界区的时候,锁的重入次数需要减一操作,只有锁的重入次数计量变为0,其他线程才可以重新获取这个锁。
根据锁是否可以被多个线程持有:
- 排他锁
- 共享锁
排他锁:也称互斥锁、独享锁,该锁一次只能被一个线程所持有
共享锁:该锁可被多个线程所持有
排他锁是一种悲观锁,每次访问资源都先加上排他锁,但是读操作其实并不会影响数据的一致性,而排他锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取,这限制了并发性。
共享锁是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。ReentrantReadWriteLock中的读锁ReadLock就是一种共享锁。
认识synchronized关键字
synchronized的三个特点:
- 当一个线程访问某对象的synchronized方法或者synchronized代码块时,其他线程对该对象的该synchronized方法或者synchronized代码块的访问将被阻塞。
- 当一个线程访问某对象的synchronized方法或者synchronized代码块时,其他线程仍然可以访问该对象的非同步代码块。
- 当一个线程访问某对象的synchronized方法或者synchronized代码块时,其他线程对该对象的其他的synchronized方法或者synchronized代码块的访问将被阻塞。
/**
* 相当于类锁
*/
public static synchronized void test1() {
}
/**
* 对象锁,锁的是当前实例对象,其他线程尝试获取这个对象的其余synchronized修饰的方法都会被阻塞
*/
public synchronized void test2() {
}
/**
* 锁的是tpf这个对象
*
* @param tpf
*/
public void test3(TestPrintYield tpf) {
synchronized (tpf) {
}
}
synchronized修饰的方法或代码块在编译之后生成的class文件,会多出两个指令:monitorente和monitorexit,这个是jvm提供的指令用来实现同步,monitorenter编译后是在同步代码块的开始的地方插入生成,monitorexit是在方法结束处或者抛出异常处插入生成。
实现同步的原理
在Hotspot虚拟机中,每个对象都是由对象头(Header),实例数据(Instance data)和对齐填充(padding)。
其中对象头中的Mark Word的描述如下:
HotSpot 虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为 “Mark Word”。
对象头中的Mark Word就存储了和每个对象关联的监视器,而synchronized编译生成的指令就是这样和监视器有关的指令,所以在synchronized修饰代码块中,可以将任意对象当作一个锁。
任何一个对象都与一个监视器(monitor)相关联。
当一个监视器有拥有者(owner)的时候,这个监视器就会被锁定(locked),所谓拥有者(owner)就是说执行 monitorenter的线程会尝试获得监视器的所有权,或者说尝试获得对象的锁。
另外,每个监视器都维护着一个自己被持有次数(或者说被锁住 locked)的计数器(count),具体如下:
- 如果与对象关联的监视器的计数器为零,则线程进入监视器成为该监视器的拥有者,并将计数器设置为 1。
- 当同一个线程再次进入该对象的监视器的时候,计数器会再次自增。
- 当其他线程想获得该对象关联的监视器的时候,就会被阻塞住,直到该监视器的计数器为 0 才会再次尝试获得其所有权。
锁的内存语义
java官方文档中定义: 所有 Lock 实现都必须强制执行与内置监视器锁 synchronization 提供的相同的内存同步语义。
锁获取的内存语义
当线程想去获取共享资源的时候,JMM会将本地工作内存中的共享资源的副本置为无效,这样就必须要去主内存中获取变量的值。
锁释放的内存语义:
当释放锁的时候,会将工作内存中的数据刷新到主内存中。
final关键字
写 final 域的重排序规则
- JMM 禁止编译器把对 final 域的写指令重排序到构造函数之外。
- 编译器会在对 final 域的写指令之后,构造函数 return 之前,插入一个 StoreStore 屏障(这个屏障的作用就是禁止处理器把对 final 域的写指令重排序到构造函数之外)
读 final 域的重排序规则
- 处理器:在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。
- 编译器:编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
有了读final域的重排序规则和写final域的重排序规则,就可以保证final域如果是正确构造的(被构造对象的引用在构造函数中没有“逸出”)),那么不需要使用同步(lock、volatile)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。也就是对外部的可见状态永远都不会改变。
逃逸分析
所谓逃逸,包括方法逃逸和线程逃逸,线程逃逸的逃逸程度高于方法逃逸:
- 当一个对象在方法里面被定义后,它如果被外部方法所引用(例如作为调用参数传递到其他方法中),这种称为方法逃逸;
- 可能被外部其他线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
public class FinalReferenceEscapeTest {
final int i;
static FinalReferenceEscapeTest obj;
public FinalReferenceEscapeTest () {
i = 1; // 1. 写 final 域
obj = this; // 2. this 引用在此 "逸出"
}
// 线程 A
public static void writer() {
new FinalReferenceEscapeExample();
}
// 线程 B
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
CAS的三大问题
- ABA问题
- 只能保证一个共享变量的原子操作
- 循环开销大
ABA问题
CAS思想中主要的三个步骤就是读取,比较,更新;如果读取的值和比较的值一样,那就表示没有其他线程在修改,可以直接更新;否则就报错或者自旋重试。
但是可能比较的值,虽然是和读取的值是一样的,但是可能是经过两次更新的,也就是主内存中的共享变量值原来是A,但是被其他线程先改成B,又改成A, 但是这个在比较的过程中是没法知道的。
java中提供了一个类 AtomicStampedReference 来解决 ABA 问题,原子更新带有版本号的引用类型。这该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
只能保证一个共享变量的原子操作
从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,这样我们就可以把多个变量封装在一个对象里来进行 CAS 操作。
循环时间开销大
其解决办法就是使 JVM 支持底层指令 pause,这个指令的功能就是当自旋失败时让 CPU 睡眠一小段时间再继续自旋,其有两个作用:
1)降低读操作的频率;
2)避免在退出循环的时候因 内存顺序冲突(Memory OrderViolation) 而引起 CPU 流水线被清空(CPU PipelineFlush)。
Lock接口
java中的锁,除了synchronized关键字之外,就是指实现了Lock接口的类。Lock接口的实现基本上都和AQS(AbstractQueuedSynchronizer)有关。
队列同步器AQS中有一个volatile修饰的int变量,就是用来表示获取的锁状态,为0表示没有被获取,大于等于1表示重入了多次。还有一个内部类Node节点类,用来实现FIFO的双向队列。
AQS中区分两种模式,就是在Node节点中做区分,独占式和共享式。
AQS的子类中,只会是独占式或者式共享式其中一个,不会两者皆是。
AQS中的public final void acquire(int arg)方法式final修饰的,子类无法重写,这个方式就是获取锁的方法,但是其中调用的tryAcquire方法不是final 修饰的,并且在AQS只是抛出了异常,是用来让子类进行重写的。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
如果调用tryAcquire方法失败了,就会走到acquireQueued方法的参数addWaiter方法中:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
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;
}
}
}
}
在addWaiter方法中,有个if判断(if (pred != null)),如果当前的尾节点不为空,那就表示不用设置首节点,直接挂在当前的尾节点后面就行,如果为空,则进入enq方法,可以看到enq方法中有个for(;;)死循环方法,必须将传入的node节点挂到当前tail尾节点成功才可以退出。
具体来说,如果某个线程请求锁(共享资源)失败,则该线程就会被加入到 CLH 队列的末端。当持有锁的线程释放锁之后,会唤醒其后继节点,这个后继节点就可以开始尝试获取锁。
独占式
独占式获取锁
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
其中tryAcquire方法是开放给子类重写的获取锁的方法,在AQS中并没有具体实现这个方法,只是一个抛出异常的方法;如果获取失败,就会通过addWaiter方法,在CLH队尾追加一个构造的独占式节点,也就是上面说的有一个写死的for循环,除非追加节点成功。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 获取当前节点的前一个节点
final Node p = node.predecessor();
// 判断当前节点的前一个节点是否式头节点,并且是否获取锁成功
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这里有个判断就是: p == head && tryAcquire(arg); 其中的head节点式CLH队列的头节点,并不会存储数据,只是一个开头,所以如果当前节点的前一个节点是头节点,那就说明上一个节点已经释放锁了,然后当前节点再去尝试获取独占锁,获取成功才开始执行。
独占式释放锁
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
其中的tryRelease方法也是开放给子类实现的,AQS并没有具体实现。释放成功,就会调用unparkSuccessor方法去唤醒下一个节点。
- 在获取同步状态(锁)时,AQS 维护一个 CLH 双向队列,获取锁失败的线程都会通过 CAS 操作被加入到队列尾端,并且在队列中无限自旋等待获取锁;
- 停止自旋(或者说被移除 CLH 队列)的条件是其前驱节点为头节点并且成功获取了独占锁;
- 当前节点(线程)成功释放掉独占锁后,AQS 就会紧接着唤醒该节点的后继节点,这样,这个后继节点又会开始去尝试获取锁。循此往复。
共享式
共享式获取锁
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
tryAcquireShared方法式开放给子类实现的,如果返回值式负值,表示获取失败,为零表示后续尝试获取这个共享锁可能失败,为证正数表示后续的线程尝试获取这个共享锁可能成功;如果大于等于0,那就表示当前线程获取到锁了。
如果获取失败,就会调用doAcquireShared方法,先放到CLH队尾中,然后再自旋去尝试获取锁。
共享式释放锁
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
也独占式的释放锁差不多,还是看不懂。
- 在获取同步状态(锁)时,AQS 维护一个 CLH 双向队列,获取锁失败的线程都会通过 CAS 操作被加入到队列尾端,并且在队列中无限自旋等待获取锁;
- 停止自旋(或者说被移除 CLH 队列)的条件是其前驱节点为头节点并且成功获取了共享锁;
- 通过循环 + CAS 操作确保当前节点(线程)成功释放掉共享锁后,AQS 就会紧接着唤醒该节点的后继节点,这样,这个后继节点又会开始去尝试获取锁。循此往复。
ReentrantLock
ReentrantLock 类中有几个内部类,一个是继承了AQS的Sync类,还有两个继承了Sync的NonfairSync类和FairSync类,从名称上就知道这是非公平锁和公平锁。
获取锁
公平锁FairSync类中有重写的tryAcquire方法,也就是在AQS中提到过很多次的开放给子类重写的方法,非公平锁NonfairSync类中的tryAcquire方法则是调用的Sync类中的nonfairTryAcquire;这个方法写在Sync类中,而不是非公平锁NonfairSync类中。并且ReentrantLock 中的tryLock方法中,是没有区分非公平锁和公平锁的,直接调用的是Sync中的nonfairTryAcquire方法。
非公平锁的获取锁方法:
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;
}
公平锁的获取锁方法:
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方法的判断,看方法名应该是判断是否有前置节点。
可以发现,ReentrantLock 无论是无参构造还是 tryLock 方法,使用的都是非公平锁的方式。这是为啥呢?因为非公平锁的性能更高。公平锁为了保证公平,保证按照时间顺序来获取锁,就必定要进行频繁的线程上下文切换,而非公平锁不需要,谁 CAS 成功了谁就能拿到锁,极少的线程切换保证了其更大的吞吐量。
可重入锁
ReentrantLock 是可重入锁,synchronized关键字也是可重入锁,但是ReentrantLock 是显式的。
可重入锁就实现的重点就是:
获取锁:锁需要去识别获取锁的线程和当前占据锁的线程是否是同一个,如果是,则重复成功获取
释放锁:如果某个线程重复 n 次获取了锁,则只有在第 n 次释放该锁后,其他线程才能够获取到该锁。这个实现其实很简单,用一个计数器(也就是代码中的同步状态 state)存储下锁被占用的次数,每释放一次就减 1 就行了
ReentrantLock 中使用的同步状态就是Sync类继承AQS得来的state字段。
ReentrantReadWriteLock
ReentrantLock和synchronized关键字都是排他锁,ReentrantReadWriteLock则是共享锁。
ReentrantReadWriteLock类只实现了ReadWriteLock接口,ReadWriteLock接口也很简单,就两个方法:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
看名称应该是获取读锁和写锁。
其中的读写锁其实就是两个内部类实现的,这两个内部类都实现了Lock接口。
public static class ReadLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -5992448646407690164L;
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquireShared(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean tryLock() {
return sync.tryReadLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.releaseShared(1);
}
}
public static class WriteLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -4992448646407690164L;
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquire(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock( ) {
return sync.tryWriteLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.release(1);
}
}
两个类中的lock方法和unlock方法可以看到调用了AQS中不同模式的获取,释放锁的方法。
Condition接口
在使用synchronized关键字的时候,可以使用wait方法,notify和notifyAll方法来挂起和唤醒线程,对应的在Reentrant接口中,使用Condition对象来实现线程的挂起和唤醒。
在AQS中,有一个ConditionObject类实现了Condition接口,可以看到在ConditionObject中也使用了Node这个对象,并且实现了一个FIFO的单向队列,叫做等待队列。一个Lock对象可以构建出多个Condition对象。
在同步队列中的节点,获取锁和释放锁的方法都会有CAS操作,但是Condition接口的await和signal,signalAll方法不需要,因为只有当前节点是头节点的下一个节点,也就是获取了锁的节点,才可以调用await方法和singal等方法。signal和synchronized关键字中的notify方法一样,也是不会释放锁的。
当前节点调用await方法,说明当前节点进入等待,也就是同步队列的首节点挂在了等待队列的尾节点,而调用signal则是唤醒其他节点去尝试获取锁,也就是将等待队列的头节点挂在了同步队列的尾节点。