本文的一些认识皆以Jdk 8为基础。对一些锁的知识点,以及不同的适用场景。结合自己和他人的文章对它进行重新的认知与疏导。非原创 ,引用自:https://tech.meituan.com/Java_Lock.html?utm_source=tuicool&utm_medium=referral
下图:通过锁的特性来进行分类
可总结出,6大特性。a.根据对资源的锁住情况分 乐观锁与悲观锁,b.根据不阻塞情况分 自旋锁和适应性自旋锁 ,c. 根据锁的程度由轻到重分 无锁,偏向锁,轻量级锁,重量级锁。d. 根据竞争时排队情况分,公平锁,非公平锁。e.根据同一线程可重复获取锁的情况分,可重入锁,非可重入锁。f.不同线程共享一把锁的情况分,共享锁,排他锁。
1.乐观锁与悲观锁
含义就不多说了,这里主要说 两者的代表区别,
悲观锁,我们接触最多的synchronized关键字和 Lock的实现类都是典型代表。悲观锁认为,我在使用数据的时候一定有别的线程对它进行修改,因此对它修改的操作进行加锁的。
// synchronized
public synchronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
try{
lock.lock();
// 操作同步资源
}finally{
lock.unlock();
}
}
乐观锁,在Java中通过无锁编程来实现,比如 CAS算法,Concurrent包下的原子类,就是通过CAS自旋实现的,它在更新内存的同步资源之前先判断资源是否被其他线程修改,Unsafe类的compareAndSwap*()方法。native方法。
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
//unsafe.getAndAddInt
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
/**获取原始值*/
var5 = this.getIntVolatile(var1, var2);
/**确认原始值没有被其它线程修改时,再执行更新var5+var4操作*/
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
CAS虽然很高效,但也存在三大问题。1.ABA 问题,解决思路:在变量前面添加版本号,jdk提供了AtomicStampedReference类来解决。
2.CAS如果长时间不成功,会导致其一直自旋,循环时间长开销大
这里就不深入的阐述 CAS的利弊性了。
3.只能保证一个共享变量的原子操作。AtomicReference类可以保证引用对象之间的原子性,可以把多个变量放在一个对象来进行CAS操作。
适用场景:
悲观锁适合 写操作多的场景,先加锁可以保证写操作时取的数据正确。
乐观锁适合 读操作多的场景,不加锁的特点使其读操作的性能提升。
2.自旋锁和适应性自旋锁
阻塞或唤醒一个Java线程需要切换CPU状态来完成,这种切换带来的消耗相对于同步代码块中的简单的内容,用户代码执行的时间来说的话 还要长,则我们可采用自旋的方式来避免阻塞。如果自旋超过了限度次数(默认10次,可以使用-XX:PreBlockSpin来更改)
自旋锁在JDK1.4.2中就引入,使用-XX:+UseSpinning来开启,JDK 6中默认开启,并且也引入了适应性自旋锁。自适应意味着自旋的次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。交给虚拟机来判断了。
3.无锁,偏向锁,轻量级锁,重量级锁
这四种锁是指锁的状态,专门针对synchronized的。
先了解什么是 Monitor:
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的 互斥锁 来实现线程同步的。
在JDK 6 之前,synchronized依赖的这种互斥的实现方法称之为 “重量级锁”,之后为了减少获得锁和锁释放带来额性能消耗,引入了”偏向锁“和”轻量级锁“。
所有目前锁一共有4种状态,锁状态只能升级不能降级。
无锁:
无锁没有对竞争资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。上面我们介绍的CAS原理及应用就是无锁的实现。
偏向锁:
偏向锁是 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。引用偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级 锁执行路径。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(safePoint),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁:
当锁是偏向锁时,被另一个线程访问,就会升级成轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。若当前只有一个等待线程,则该线程通过自旋进行等待,但是当自旋超过一定的次数,或者一个线程在持有锁,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁:
将除了拥有锁的线程以外的线程都阻塞,线程的状态改变了。
4.公平锁和非公平锁
这里看到不错的图很好表述了含义。
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
这里我们通过ReentrantLock的源码来
ReentrantLock 默认为公平锁,可通过构造器来显示指定使用哪种锁。
下面我们来看看这两种获取锁的不同的地方:
区别在于 hasQueuedPredecessors() 判断。
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());
}
判断当前线程是否在同步队列中的第一个。
5.可重入锁和非可重入锁
可重入锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象的是同一个对象或者class)。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
非可重入锁,当前线程是持有锁的线程的时候,再进入该线程的内层方法去获取锁则无法获取锁,阻塞该线程。
看代码可知:
6.独享锁和共享锁
独享锁也叫排他锁。是指该线程一次只能被一个线程所拥有。synchronized 和JUC的Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有,如果线程A对数据a加上共享锁后,则其他线程只能对a再加共享锁,不能加排他锁,获得共享锁的线程只能读数据,不能修改数据。
我们看个 运用 volatile 和 synchronized 实现的 “开销较低的 读-写锁”
@ThreadSafe
public class CheesyCounter {
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
写操作 只能有一个线程获取到锁,对value进行修改,读操作可以有多个线程获取数据value。而这里的volatile 确保了当前值的可见性。对比下 ReentrantReadWriteLock 的读-写操作。
我们发现 读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样,读锁是共享锁,写锁是独享锁。读锁的共享锁可以保证并发读非常高效,而读写,写读,写写的过程互斥,因为读锁和写锁是分离的,所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大的提升。
重点来看 读操作如何获取锁
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1; // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}
可以看到tryAcquireShared()方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态,如果当前线程获取了写锁或者写锁未被获取,则当前线程增加读状态,成功获取读锁,读锁的每次释放均减少读的状态,以达到读读的过程共享,而其他操作互斥。
结语:
后续会增加一些项目实例来结合来说说好点。